Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -244,6 +246,9 @@ export async function initCommand(
}
}

// ── APM bridge: offer to initialize APM for cross-team distribution ──
await offerApmInit(repoPath, options);

Comment thread
danielmeppiel marked this conversation as resolved.
if (options.json) {
const { ok, status } = deriveFileStatus(allFiles);
const result: CommandResult<{
Expand All @@ -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<boolean> {
try {
await execFileAsync("apm", ["--version"], { timeout: APM_TIMEOUT_MS });
return true;
Comment thread
danielmeppiel marked this conversation as resolved.
} catch {
return false;
}
}

export async function offerApmInit(repoPath: string, options: InitOptions): Promise<void> {
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) {
Comment on lines +298 to +305
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

offerApmInit() currently treats --yes as implicit acceptance (accepted = options.yes ? true : …), which causes agentrc init --yes to run apm init --yes. This conflicts with the documented behavior for --yes/headless mode (skip APM entirely) and introduces unexpected side effects in non-interactive runs. Consider returning early when options.yes is set so APM is neither prompted nor executed in headless mode.

This issue also appears on line 323 of the same file.

Copilot uses AI. Check for mistakes.
try {
await execFileAsync("apm", ["init", "--yes"], { cwd: repoPath, timeout: APM_TIMEOUT_MS });
if (shouldLog(options)) {
process.stderr.write(
"APM initialized — run `apm install <package>` 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"
);
}
}
102 changes: 102 additions & 0 deletions src/services/__tests__/apm-init.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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<typeof execFile>;
});
}

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<typeof execFile>;
});
}

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
});
Comment on lines +56 to +61
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test encodes behavior that contradicts the PR description: it expects --yes to auto-initialize APM when installed. The documented behavior says --yes/headless mode should skip APM entirely, so the expectation (and likely the implementation) should be updated accordingly.

Copilot uses AI. Check for mistakes.

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();
});
});
Loading