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
11 changes: 5 additions & 6 deletions packages/cli/src/commands/configure.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ vi.mock("@codemcp/ade-harnesses", () => ({
}
return undefined;
}),
detectHarnesses: vi.fn().mockResolvedValue([]),
installSkills: mockInstallSkills,
writeInlineSkills: mockWriteInlineSkills
}));
Expand Down Expand Up @@ -69,7 +70,6 @@ const baseLockFile: LockFile = {
version: 1,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
harnesses: ["universal"],
logical_config: {
mcp_servers: [],
instructions: ["do stuff"],
Expand Down Expand Up @@ -181,11 +181,10 @@ describe("runConfigure", () => {
expect(mockInstall).not.toHaveBeenCalled();
});

it("uses lock file harnesses as initial selection for harness prompt", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce({
...baseLockFile,
harnesses: ["cursor"]
});
it("uses auto-detected harnesses as initial selection for harness prompt", async () => {
const { detectHarnesses } = await import("@codemcp/ade-harnesses");
vi.mocked(detectHarnesses).mockResolvedValueOnce(["cursor"]);
vi.mocked(readLockFile).mockResolvedValueOnce(baseLockFile);
vi.mocked(clack.select).mockResolvedValueOnce("sensible-defaults");
vi.mocked(clack.multiselect).mockResolvedValueOnce(["cursor"]);

Expand Down
13 changes: 6 additions & 7 deletions packages/cli/src/commands/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type HarnessWriter,
allHarnessWriters,
getHarnessWriter,
detectHarnesses,
installSkills,
writeInlineSkills
} from "@codemcp/ade-harnesses";
Expand Down Expand Up @@ -74,21 +75,19 @@ export async function runConfigure(
const harnessOptions = harnessWriters.map((w) => ({
value: w.id,
label: w.label,
hint: w.description
hint: w.verified
? w.description
: `${w.description} · unverified — config generation may be inaccurate`
}));

const existingHarnesses = lockFile.harnesses ?? ["universal"];
const validInitialHarnesses = existingHarnesses.filter((h) =>
harnessWriters.some((w) => w.id === h)
);
const initialHarnesses = await detectHarnesses(projectRoot, harnessWriters);

const selectedHarnesses = await clack.multiselect({
message:
"Which coding agents should receive this configuration?\n" +
"ADE generates config files for each agent you select.\n",
options: harnessOptions,
initialValues:
validInitialHarnesses.length > 0 ? validInitialHarnesses : ["universal"],
initialValues: initialHarnesses,
required: false
});

Expand Down
46 changes: 4 additions & 42 deletions packages/cli/src/commands/install.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,37 +40,21 @@ describe("install integration (real temp dir)", () => {
await rm(dir, { recursive: true, force: true });
});

it("applies lock file to regenerate agent files without re-resolving", async () => {
it("completes without error after setup", async () => {
const catalog = getDefaultCatalog();

// Step 1: Run setup to create config.yaml + config.lock.yaml
vi.mocked(clack.select)
.mockResolvedValueOnce("codemcp-workflows") // process
.mockResolvedValueOnce("other"); // architecture
vi.mocked(clack.multiselect).mockResolvedValueOnce([]); // practices: none
await runSetup(dir, catalog);

// Step 2: Run install — writes agent files from lock file
await runInstall(dir, ["claude-code"]);

// Agent files should be written by install
const agentMd = await readFile(
join(dir, ".claude", "agents", "ade.md"),
"utf-8"
);
expect(agentMd).toContain("Call whats_next()");

const mcpJson = JSON.parse(await readFile(join(dir, ".mcp.json"), "utf-8"));
expect(mcpJson.mcpServers["workflows"]).toMatchObject({
command: "npx",
args: ["@codemcp/workflows-server@latest"]
});
await runInstall(dir);
});

it("does not modify the lock file", async () => {
const catalog = getDefaultCatalog();

// Setup first
vi.mocked(clack.select)
.mockResolvedValueOnce("codemcp-workflows") // process
.mockResolvedValueOnce("other"); // architecture
Expand All @@ -82,37 +66,15 @@ describe("install integration (real temp dir)", () => {
"utf-8"
);

// Re-install
await runInstall(dir, ["claude-code"]);
await runInstall(dir);

const lockRawAfter = await readFile(join(dir, "config.lock.yaml"), "utf-8");
// Lock file should be byte-identical (install doesn't rewrite it)
expect(lockRawAfter).toBe(lockRawBefore);
});

it("fails when no config.lock.yaml exists", async () => {
await expect(runInstall(dir, ["claude-code"])).rejects.toThrow(
await expect(runInstall(dir)).rejects.toThrow(
/config\.lock\.yaml not found/i
);
});

it("works with native-agents-md option", async () => {
const catalog = getDefaultCatalog();

// Setup with native-agents-md
vi.mocked(clack.select)
.mockResolvedValueOnce("native-agents-md") // process
.mockResolvedValueOnce("other"); // architecture
vi.mocked(clack.multiselect).mockResolvedValueOnce([]); // practices: none
await runSetup(dir, catalog);

// Run install
await runInstall(dir, ["claude-code"]);

const agentMd = await readFile(
join(dir, ".claude", "agents", "ade.md"),
"utf-8"
);
expect(agentMd).toContain("AGENTS.md");
});
});
141 changes: 18 additions & 123 deletions packages/cli/src/commands/install.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { LogicalConfig } from "@codemcp/ade-core";
vi.mock("@clack/prompts", () => ({
intro: vi.fn(),
outro: vi.fn(),
confirm: vi.fn().mockResolvedValue(true),
cancel: vi.fn(),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }
}));

Expand All @@ -27,131 +29,42 @@ vi.mock("@codemcp/ade-core", async (importOriginal) => {
};
});

const mockInstall = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));

vi.mock("@codemcp/ade-harnesses", () => ({
allHarnessWriters: [
{
id: "universal",
label: "Universal",
description: "Universal",
install: mockInstall
},
{
id: "claude-code",
label: "Claude Code",
description: "Claude Code",
install: mockInstall
},
{
id: "cursor",
label: "Cursor",
description: "Cursor",
install: mockInstall
}
],
getHarnessWriter: vi.fn().mockImplementation((id: string) => {
if (id === "universal" || id === "claude-code" || id === "cursor") {
return { id, install: mockInstall };
}
return undefined;
}),
getHarnessIds: vi
.fn()
.mockReturnValue([
"universal",
"claude-code",
"cursor",
"copilot",
"windsurf",
"cline",
"roo-code",
"kiro",
"opencode"
]),
installSkills: vi.fn().mockResolvedValue(undefined),
writeInlineSkills: vi.fn().mockResolvedValue([])
}));

vi.mock("../knowledge-installer.js", () => ({
installKnowledge: vi.fn().mockResolvedValue(undefined)
}));

import * as clack from "@clack/prompts";
import { readLockFile } from "@codemcp/ade-core";
import { runInstall } from "./install.js";

// ── Tests ────────────────────────────────────────────────────────────────────

const baseLockFile = {
version: 1 as const,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
logical_config: mockLogical
};

describe("runInstall", () => {
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks();
// Re-set the default implementation after clearAllMocks
const { getHarnessWriter } = await import("@codemcp/ade-harnesses");
vi.mocked(getHarnessWriter).mockImplementation((id: string) => {
if (id === "universal" || id === "claude-code" || id === "cursor") {
return {
id,
label: id,
description: "test",
install: mockInstall
};
}
return undefined;
});
vi.mocked(clack.confirm).mockResolvedValue(true);
});

it("reads config.lock.yaml and applies logical config", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce({
version: 1,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
logical_config: mockLogical
});
it("reads config.lock.yaml", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce(baseLockFile);

await runInstall("/tmp/project");

expect(readLockFile).toHaveBeenCalledWith("/tmp/project");
});

it("defaults to universal harness when none specified", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce({
version: 1,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
logical_config: mockLogical
});

await runInstall("/tmp/project");

expect(mockInstall).toHaveBeenCalledWith(mockLogical, "/tmp/project");
});

it("uses harnesses from lock file when present", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce({
version: 1,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
harnesses: ["claude-code", "cursor"],
logical_config: mockLogical
});

await runInstall("/tmp/project");

expect(mockInstall).toHaveBeenCalledTimes(2);
});

it("uses explicit harness ids when provided", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce({
version: 1,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
harnesses: ["claude-code"],
logical_config: mockLogical
});

await runInstall("/tmp/project", ["cursor"]);

// Explicit takes priority over lock file
expect(mockInstall).toHaveBeenCalledTimes(1);
});

it("throws when config.lock.yaml is missing", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce(null);

Expand All @@ -160,26 +73,8 @@ describe("runInstall", () => {
);
});

it("throws when harness id is unknown", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce({
version: 1,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
logical_config: mockLogical
});

await expect(runInstall("/tmp/project", ["unknown-agent"])).rejects.toThrow(
/unknown harness/i
);
});

it("shows intro and outro messages", async () => {
vi.mocked(readLockFile).mockResolvedValueOnce({
version: 1,
generated_at: "2024-01-01T00:00:00.000Z",
choices: { process: "codemcp-workflows" },
logical_config: mockLogical
});
vi.mocked(readLockFile).mockResolvedValueOnce(baseLockFile);

await runInstall("/tmp/project");

Expand Down
39 changes: 2 additions & 37 deletions packages/cli/src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,18 @@
import * as clack from "@clack/prompts";
import { readLockFile } from "@codemcp/ade-core";
import {
type HarnessWriter,
allHarnessWriters,
getHarnessWriter,
getHarnessIds,
installSkills,
writeInlineSkills
} from "@codemcp/ade-harnesses";
import { installSkills, writeInlineSkills } from "@codemcp/ade-harnesses";
import { installKnowledge } from "../knowledge-installer.js";

export async function runInstall(
projectRoot: string,
harnessIds?: string[],
harnessWriters: HarnessWriter[] = allHarnessWriters
): Promise<void> {
export async function runInstall(projectRoot: string): Promise<void> {
clack.intro("ade install");

const lockFile = await readLockFile(projectRoot);
if (!lockFile) {
throw new Error("config.lock.yaml not found. Run `ade setup` first.");
}

// Determine which harnesses to install for:
// 1. --harness flag (comma-separated)
// 2. harnesses saved in the lock file
// 3. default: universal
const ids = harnessIds ?? lockFile.harnesses ?? ["universal"];

const validIds = [...getHarnessIds(), ...harnessWriters.map((w) => w.id)];
const uniqueValidIds = [...new Set(validIds)];
for (const id of ids) {
if (!uniqueValidIds.includes(id)) {
throw new Error(
`Unknown harness "${id}". Available: ${uniqueValidIds.join(", ")}`
);
}
}

const logicalConfig = lockFile.logical_config;

for (const id of ids) {
const writer =
harnessWriters.find((w) => w.id === id) ?? getHarnessWriter(id);
if (writer) {
await writer.install(logicalConfig, projectRoot);
}
}

const modifiedSkills = await writeInlineSkills(logicalConfig, projectRoot);
if (modifiedSkills.length > 0) {
clack.log.warn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe("knowledge docset regression tests", () => {
await rm(join(dir, ".knowledge"), { recursive: true, force: true });

// Now run install — should also write .knowledge/config.yaml
await runInstall(dir, ["claude-code"]);
await runInstall(dir);

// All 4 tanstack docsets are configured via the docset writer
expect(createDocset).toHaveBeenCalledTimes(4);
Expand Down
Loading
Loading