From 6414a534dde6481a786f9845a49808428b1dd0fd Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Tue, 31 Mar 2026 13:49:39 +0200 Subject: [PATCH] feat(init): bridge to APM during init for cross-team distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After generating instructions and configs, agentrc init now detects APM and offers to initialize it: - APM installed + no apm.yml: interactive prompt to run `apm init --yes` - APM installed + apm.yml exists: skip (already set up) - APM not installed: light tip suggesting APM with link to repo - --yes/--json mode: skip APM step entirely (non-interactive) This respects tool boundaries — agentrc never writes apm.yml directly, it delegates to `apm init` which handles auto-detection of project metadata. The prompt follows the same @inquirer/prompts pattern already used throughout the init command. Closes #93 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/commands/init.ts | 63 ++++++++++++++- src/services/__tests__/apm-init.test.ts | 102 ++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/services/__tests__/apm-init.test.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index 5879ac1..433be2a 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,4 +1,6 @@ +import { execFile } from "child_process"; import path from "path"; +import { promisify } from "util"; import { analyzeRepo } from "@agentrc/core/services/analyzer"; import type { @@ -19,11 +21,11 @@ import { buildAuthedUrl, cloneRepo, isGitRepo, setRemoteUrl } from "@agentrc/cor import type { GitHubRepo } from "@agentrc/core/services/github"; import { getGitHubToken, listAccessibleRepos } from "@agentrc/core/services/github"; import { generateCopilotInstructions } from "@agentrc/core/services/instructions"; -import { ensureDir, safeWriteFile, validateCachePath } from "@agentrc/core/utils/fs"; +import { ensureDir, fileExists, safeWriteFile, validateCachePath } from "@agentrc/core/utils/fs"; import { prettyPrintSummary } from "@agentrc/core/utils/logger"; import type { CommandResult } from "@agentrc/core/utils/output"; import { outputResult, outputError, deriveFileStatus, shouldLog } from "@agentrc/core/utils/output"; -import { checkbox, select } from "@inquirer/prompts"; +import { checkbox, confirm, select } from "@inquirer/prompts"; type InitOptions = { github?: boolean; @@ -244,6 +246,9 @@ export async function initCommand( } } + // ── APM bridge: offer to initialize APM for cross-team distribution ── + await offerApmInit(repoPath, options); + if (options.json) { const { ok, status } = deriveFileStatus(allFiles); const result: CommandResult<{ @@ -268,3 +273,57 @@ export async function initCommand( process.stderr.write(" agentrc eval --init Scaffold evaluation test cases\n"); } } + +const execFileAsync = promisify(execFile); + +const APM_TIMEOUT_MS = 10_000; + +export async function isApmInstalled(): Promise { + try { + await execFileAsync("apm", ["--version"], { timeout: APM_TIMEOUT_MS }); + return true; + } catch { + return false; + } +} + +export async function offerApmInit(repoPath: string, options: InitOptions): Promise { + if (options.json) return; + + const hasApmYml = await fileExists(path.join(repoPath, "apm.yml")); + if (hasApmYml) return; + + const apmAvailable = await isApmInstalled(); + + if (apmAvailable) { + const accepted = options.yes + ? true + : await confirm({ + message: "Set up APM to install and share agent packages across your team?", + default: false + }); + if (accepted) { + try { + await execFileAsync("apm", ["init", "--yes"], { cwd: repoPath, timeout: APM_TIMEOUT_MS }); + if (shouldLog(options)) { + process.stderr.write( + "APM initialized — run `apm install ` to add shared agent packages.\n" + ); + } + } catch (error) { + if (shouldLog(options)) { + process.stderr.write( + `Warning: APM init failed; continuing without APM: ${ + error instanceof Error ? error.message : String(error) + }\n` + ); + } + } + } + } else if (shouldLog(options)) { + process.stderr.write( + "\nTip: use APM to install shared agent packages and distribute your instructions across repos.\n" + + " https://github.com/microsoft/apm\n" + ); + } +} diff --git a/src/services/__tests__/apm-init.test.ts b/src/services/__tests__/apm-init.test.ts new file mode 100644 index 0000000..d438e0f --- /dev/null +++ b/src/services/__tests__/apm-init.test.ts @@ -0,0 +1,102 @@ +import { execFile } from "child_process"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import { confirm } from "@inquirer/prompts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { offerApmInit } from "../../commands/init"; + +vi.mock("@inquirer/prompts", () => ({ + confirm: vi.fn(), + checkbox: vi.fn(), + select: vi.fn() +})); + +vi.mock("child_process", () => ({ + execFile: vi.fn() +})); + +const mockConfirm = vi.mocked(confirm); +const mockExecFile = vi.mocked(execFile); + +describe("offerApmInit", () => { + let repoPath: string; + let stderrSpy: ReturnType; + + beforeEach(async () => { + repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "agentrc-apm-init-")); + stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + mockConfirm.mockReset(); + mockExecFile.mockReset(); + }); + + afterEach(async () => { + stderrSpy.mockRestore(); + await fs.rm(repoPath, { recursive: true, force: true }); + }); + + function mockApmNotInstalled(): void { + mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => { + const callback = typeof _opts === "function" ? _opts : cb; + if (callback) callback(new Error("not found"), "", ""); + return {} as ReturnType; + }); + } + + function mockApmInstalled(): void { + mockExecFile.mockImplementation((_cmd, _args, _opts, cb) => { + const callback = typeof _opts === "function" ? _opts : cb; + if (callback) callback(null, "1.0.0", ""); + return {} as ReturnType; + }); + } + + it("auto-initializes APM when --yes is set and apm is installed", async () => { + mockApmInstalled(); + await offerApmInit(repoPath, { yes: true }); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockExecFile).toHaveBeenCalledTimes(2); // --version + init --yes + }); + + it("skips when --yes is set but apm is not installed", async () => { + mockApmNotInstalled(); + await offerApmInit(repoPath, { yes: true }); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it("skips when --json is set", async () => { + await offerApmInit(repoPath, { json: true }); + expect(mockExecFile).not.toHaveBeenCalled(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it("skips when apm.yml already exists", async () => { + await fs.writeFile(path.join(repoPath, "apm.yml"), "name: test"); + await offerApmInit(repoPath, {}); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it("shows tip when apm is not installed and not quiet", async () => { + mockApmNotInstalled(); + await offerApmInit(repoPath, {}); + const output = stderrSpy.mock.calls.map((c: unknown[]) => c[0]).join(""); + expect(output).toContain("APM"); + expect(output).toContain("https://github.com/microsoft/apm"); + }); + + it("suppresses tip when --quiet is set", async () => { + mockApmNotInstalled(); + await offerApmInit(repoPath, { quiet: true }); + const tipCalls = stderrSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes("APM")); + expect(tipCalls).toHaveLength(0); + }); + + it("prompts when apm is installed and no apm.yml", async () => { + mockApmInstalled(); + mockConfirm.mockResolvedValue(false); + await offerApmInit(repoPath, {}); + expect(mockConfirm).toHaveBeenCalledOnce(); + }); +});