@codemcp/ade-cli 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,442 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
- import type { Catalog, LogicalConfig } from "@codemcp/ade-core";
3
-
4
- // ── Mocks ────────────────────────────────────────────────────────────────────
5
-
6
- vi.mock("@clack/prompts", () => ({
7
- intro: vi.fn(),
8
- outro: vi.fn(),
9
- select: vi.fn(),
10
- multiselect: vi.fn(),
11
- confirm: vi.fn(),
12
- isCancel: vi.fn().mockReturnValue(false),
13
- cancel: vi.fn(),
14
- log: { warn: vi.fn(), info: vi.fn() },
15
- spinner: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn() })
16
- }));
17
-
18
- vi.mock("@codemcp/ade-core", async (importOriginal) => {
19
- const actual = (await importOriginal()) as typeof import("@codemcp/ade-core");
20
- return {
21
- ...actual,
22
- readUserConfig: vi.fn().mockResolvedValue(null),
23
- writeUserConfig: vi.fn().mockResolvedValue(undefined),
24
- writeLockFile: vi.fn().mockResolvedValue(undefined),
25
- resolve: vi.fn().mockResolvedValue({
26
- mcp_servers: [],
27
- instructions: [],
28
- cli_actions: [],
29
- knowledge_sources: [],
30
- skills: [],
31
- git_hooks: [],
32
- setup_notes: []
33
- } satisfies LogicalConfig),
34
- collectDocsets: actual.collectDocsets
35
- };
36
- });
37
-
38
- vi.mock("@codemcp/ade-harnesses", () => ({
39
- allHarnessWriters: [
40
- {
41
- id: "claude-code",
42
- label: "Claude Code",
43
- description: "test",
44
- install: vi.fn().mockResolvedValue(undefined)
45
- }
46
- ],
47
- getHarnessWriter: vi.fn().mockReturnValue({
48
- id: "claude-code",
49
- label: "Claude Code",
50
- description: "test",
51
- install: vi.fn().mockResolvedValue(undefined)
52
- }),
53
- getHarnessIds: vi.fn().mockReturnValue(["claude-code"]),
54
- installSkills: vi.fn().mockResolvedValue(undefined),
55
- writeInlineSkills: vi.fn().mockResolvedValue([])
56
- }));
57
-
58
- import * as clack from "@clack/prompts";
59
- import {
60
- readUserConfig,
61
- writeUserConfig,
62
- writeLockFile,
63
- resolve
64
- } from "@codemcp/ade-core";
65
- import { runSetup } from "./setup.js";
66
-
67
- // ── Test catalog fixture ─────────────────────────────────────────────────────
68
-
69
- const testCatalog: Catalog = {
70
- facets: [
71
- {
72
- id: "process",
73
- label: "Process",
74
- description: "How your agent works",
75
- required: true,
76
- options: [
77
- {
78
- id: "workflow-a",
79
- label: "Workflow A",
80
- description: "First workflow option",
81
- recipe: []
82
- },
83
- {
84
- id: "workflow-b",
85
- label: "Workflow B",
86
- description: "Second workflow option",
87
- recipe: []
88
- }
89
- ]
90
- },
91
- {
92
- id: "testing",
93
- label: "Testing",
94
- description: "Testing strategy",
95
- required: false,
96
- options: [
97
- {
98
- id: "vitest",
99
- label: "Vitest",
100
- description: "Use vitest",
101
- recipe: []
102
- },
103
- {
104
- id: "jest",
105
- label: "Jest",
106
- description: "Use jest",
107
- recipe: []
108
- }
109
- ]
110
- }
111
- ]
112
- };
113
-
114
- const docsetCatalog: Catalog = {
115
- facets: [
116
- {
117
- id: "arch",
118
- label: "Architecture",
119
- description: "Stack",
120
- required: true,
121
- options: [
122
- {
123
- id: "react",
124
- label: "React",
125
- description: "React framework",
126
- recipe: [],
127
- docsets: [
128
- {
129
- id: "react-docs",
130
- label: "React Reference",
131
- origin: "https://github.com/facebook/react.git",
132
- description: "Official React docs"
133
- },
134
- {
135
- id: "react-tutorial",
136
- label: "React Tutorial",
137
- origin: "https://github.com/reactjs/react.dev.git",
138
- description: "React learn guide"
139
- }
140
- ]
141
- }
142
- ]
143
- }
144
- ]
145
- };
146
-
147
- // ── Tests ────────────────────────────────────────────────────────────────────
148
-
149
- describe("runSetup", () => {
150
- beforeEach(() => {
151
- vi.clearAllMocks();
152
- });
153
-
154
- it("prompts for each catalog facet and writes user config", async () => {
155
- // User selects "workflow-a" for process, "vitest" for testing
156
- vi.mocked(clack.select)
157
- .mockResolvedValueOnce("workflow-a")
158
- .mockResolvedValueOnce("vitest");
159
- // Harness multiselect
160
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
161
-
162
- await runSetup("/tmp/test-project", testCatalog);
163
-
164
- // select() called once per facet
165
- expect(clack.select).toHaveBeenCalledTimes(2);
166
-
167
- // writeUserConfig called with collected choices
168
- expect(writeUserConfig).toHaveBeenCalledWith(
169
- "/tmp/test-project",
170
- expect.objectContaining({
171
- choices: { process: "workflow-a", testing: "vitest" }
172
- })
173
- );
174
- });
175
-
176
- it("resolves the config and writes the lock file", async () => {
177
- const mockLogical: LogicalConfig = {
178
- mcp_servers: [],
179
- instructions: ["do stuff"],
180
- cli_actions: [],
181
- knowledge_sources: [],
182
- skills: [],
183
- git_hooks: [],
184
- setup_notes: []
185
- };
186
- vi.mocked(resolve).mockResolvedValueOnce(mockLogical);
187
- vi.mocked(clack.select)
188
- .mockResolvedValueOnce("workflow-a")
189
- .mockResolvedValueOnce("vitest");
190
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
191
-
192
- await runSetup("/tmp/test-project", testCatalog);
193
-
194
- // resolve() called with the user config, catalog, and a registry
195
- expect(resolve).toHaveBeenCalledOnce();
196
- const resolveArgs = vi.mocked(resolve).mock.calls[0];
197
- expect(resolveArgs[0]).toMatchObject({
198
- choices: { process: "workflow-a", testing: "vitest" }
199
- });
200
-
201
- // writeLockFile called with the resolved logical config
202
- expect(writeLockFile).toHaveBeenCalledWith(
203
- "/tmp/test-project",
204
- expect.objectContaining({
205
- version: 1,
206
- logical_config: mockLogical
207
- })
208
- );
209
- });
210
-
211
- it("excludes skipped facets from choices", async () => {
212
- // User selects workflow-a for process, skips testing (returns null sentinel)
213
- vi.mocked(clack.select)
214
- .mockResolvedValueOnce("workflow-a")
215
- .mockResolvedValueOnce("__skip__");
216
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
217
-
218
- await runSetup("/tmp/test-project", testCatalog);
219
-
220
- expect(writeUserConfig).toHaveBeenCalledWith(
221
- "/tmp/test-project",
222
- expect.objectContaining({
223
- choices: { process: "workflow-a" }
224
- })
225
- );
226
- });
227
-
228
- it("aborts without writing files when user cancels", async () => {
229
- // First select returns a cancel symbol
230
- const cancelSymbol = Symbol("cancel");
231
- vi.mocked(clack.select).mockResolvedValueOnce(cancelSymbol);
232
- vi.mocked(clack.isCancel).mockReturnValue(true);
233
-
234
- await runSetup("/tmp/test-project", testCatalog);
235
-
236
- expect(writeUserConfig).not.toHaveBeenCalled();
237
- expect(writeLockFile).not.toHaveBeenCalled();
238
- expect(clack.cancel).toHaveBeenCalled();
239
- });
240
-
241
- describe("docset confirmation step", () => {
242
- it("presents implied docsets as a multiselect after facet selection", async () => {
243
- vi.mocked(clack.select).mockResolvedValueOnce("react");
244
- // User accepts all docsets (returns all ids), then harness selection
245
- vi.mocked(clack.multiselect)
246
- .mockResolvedValueOnce(["react-docs", "react-tutorial"])
247
- .mockResolvedValueOnce(["claude-code"]);
248
-
249
- await runSetup("/tmp/test-project", docsetCatalog);
250
-
251
- // multiselect should have been called for docsets
252
- expect(clack.multiselect).toHaveBeenCalledWith(
253
- expect.objectContaining({
254
- message: expect.stringContaining("Documentation")
255
- })
256
- );
257
- });
258
-
259
- it("stores deselected docsets as excluded_docsets in user config", async () => {
260
- vi.mocked(clack.select).mockResolvedValueOnce("react");
261
- // User deselects react-tutorial, keeps only react-docs; then harness
262
- vi.mocked(clack.multiselect)
263
- .mockResolvedValueOnce(["react-docs"])
264
- .mockResolvedValueOnce(["claude-code"]);
265
-
266
- await runSetup("/tmp/test-project", docsetCatalog);
267
-
268
- expect(writeUserConfig).toHaveBeenCalledWith(
269
- "/tmp/test-project",
270
- expect.objectContaining({
271
- excluded_docsets: ["react-tutorial"]
272
- })
273
- );
274
- });
275
-
276
- it("does not set excluded_docsets when all docsets are accepted", async () => {
277
- vi.mocked(clack.select).mockResolvedValueOnce("react");
278
- vi.mocked(clack.multiselect)
279
- .mockResolvedValueOnce(["react-docs", "react-tutorial"])
280
- .mockResolvedValueOnce(["claude-code"]);
281
-
282
- await runSetup("/tmp/test-project", docsetCatalog);
283
-
284
- const configArg = vi.mocked(writeUserConfig).mock.calls[0][1];
285
- expect(configArg.excluded_docsets).toBeUndefined();
286
- });
287
-
288
- it("skips docset prompt when no options have docsets", async () => {
289
- vi.mocked(clack.select)
290
- .mockResolvedValueOnce("workflow-a")
291
- .mockResolvedValueOnce("vitest");
292
- // Only the harness multiselect should be called (no docsets in testCatalog)
293
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
294
-
295
- await runSetup("/tmp/test-project", testCatalog);
296
-
297
- // multiselect should have been called exactly once (for harnesses only)
298
- expect(clack.multiselect).toHaveBeenCalledTimes(1);
299
- expect(clack.multiselect).toHaveBeenCalledWith(
300
- expect.objectContaining({
301
- message: expect.stringContaining("Harnesses")
302
- })
303
- );
304
- });
305
- });
306
-
307
- it("calls intro and outro from @clack/prompts", async () => {
308
- vi.mocked(clack.select)
309
- .mockResolvedValueOnce("workflow-a")
310
- .mockResolvedValueOnce("vitest");
311
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
312
-
313
- await runSetup("/tmp/test-project", testCatalog);
314
-
315
- expect(clack.intro).toHaveBeenCalled();
316
- expect(clack.outro).toHaveBeenCalled();
317
- });
318
-
319
- it("displays each setup note via clack.log.info", async () => {
320
- const mockLogical: LogicalConfig = {
321
- mcp_servers: [],
322
- instructions: [],
323
- cli_actions: [],
324
- knowledge_sources: [],
325
- skills: [],
326
- git_hooks: [],
327
- setup_notes: ["Add lint script to package.json", "Run npm install"]
328
- };
329
- vi.mocked(resolve).mockResolvedValueOnce(mockLogical);
330
- vi.mocked(clack.select)
331
- .mockResolvedValueOnce("workflow-a")
332
- .mockResolvedValueOnce("vitest");
333
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
334
-
335
- await runSetup("/tmp/test-project", testCatalog);
336
-
337
- expect(clack.log.info).toHaveBeenCalledWith(
338
- "Add lint script to package.json"
339
- );
340
- expect(clack.log.info).toHaveBeenCalledWith("Run npm install");
341
- });
342
-
343
- describe("re-run with existing config", () => {
344
- it("passes existing single-select choice as initialValue", async () => {
345
- vi.mocked(readUserConfig).mockResolvedValueOnce({
346
- choices: { process: "workflow-b", testing: "jest" }
347
- });
348
-
349
- vi.mocked(clack.select)
350
- .mockResolvedValueOnce("workflow-b")
351
- .mockResolvedValueOnce("jest");
352
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
353
-
354
- await runSetup("/tmp/test-project", testCatalog);
355
-
356
- // First select (process) should receive initialValue "workflow-b"
357
- expect(clack.select).toHaveBeenCalledWith(
358
- expect.objectContaining({ initialValue: "workflow-b" })
359
- );
360
- // Second select (testing) should receive initialValue "jest"
361
- expect(clack.select).toHaveBeenCalledWith(
362
- expect.objectContaining({ initialValue: "jest" })
363
- );
364
- });
365
-
366
- it("passes existing multi-select choices as initialValues", async () => {
367
- const multiCatalog: Catalog = {
368
- facets: [
369
- {
370
- id: "practices",
371
- label: "Practices",
372
- description: "Dev practices",
373
- required: false,
374
- multiSelect: true,
375
- options: [
376
- {
377
- id: "tdd",
378
- label: "TDD",
379
- description: "Test-driven dev",
380
- recipe: []
381
- },
382
- {
383
- id: "adr",
384
- label: "ADR",
385
- description: "Architecture decisions",
386
- recipe: []
387
- }
388
- ]
389
- }
390
- ]
391
- };
392
-
393
- vi.mocked(readUserConfig).mockResolvedValueOnce({
394
- choices: { practices: ["tdd", "adr"] }
395
- });
396
-
397
- vi.mocked(clack.multiselect)
398
- .mockResolvedValueOnce(["tdd", "adr"])
399
- .mockResolvedValueOnce(["claude-code"]);
400
-
401
- await runSetup("/tmp/test-project", multiCatalog);
402
-
403
- expect(clack.multiselect).toHaveBeenCalledWith(
404
- expect.objectContaining({ initialValues: ["tdd", "adr"] })
405
- );
406
- });
407
-
408
- it("warns when existing choice references a stale option", async () => {
409
- vi.mocked(readUserConfig).mockResolvedValueOnce({
410
- choices: { process: "workflow-a", testing: "mocha" } // "mocha" doesn't exist
411
- });
412
-
413
- vi.mocked(clack.select)
414
- .mockResolvedValueOnce("workflow-a")
415
- .mockResolvedValueOnce("vitest");
416
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
417
-
418
- await runSetup("/tmp/test-project", testCatalog);
419
-
420
- expect(clack.log.warn).toHaveBeenCalledWith(
421
- expect.stringContaining("mocha")
422
- );
423
- });
424
-
425
- it("does not set initialValue for stale option", async () => {
426
- vi.mocked(readUserConfig).mockResolvedValueOnce({
427
- choices: { process: "deleted-option" }
428
- });
429
-
430
- vi.mocked(clack.select)
431
- .mockResolvedValueOnce("workflow-a")
432
- .mockResolvedValueOnce("vitest");
433
- vi.mocked(clack.multiselect).mockResolvedValueOnce(["claude-code"]);
434
-
435
- await runSetup("/tmp/test-project", testCatalog);
436
-
437
- // First select (process) should NOT have initialValue set
438
- const firstCall = vi.mocked(clack.select).mock.calls[0][0];
439
- expect(firstCall).not.toHaveProperty("initialValue");
440
- });
441
- });
442
- });
@@ -1,252 +0,0 @@
1
- import * as clack from "@clack/prompts";
2
- import {
3
- type Catalog,
4
- type Facet,
5
- type UserConfig,
6
- type LockFile,
7
- readUserConfig,
8
- writeUserConfig,
9
- writeLockFile,
10
- resolve,
11
- collectDocsets,
12
- createDefaultRegistry,
13
- getFacet,
14
- getOption,
15
- sortFacets,
16
- getVisibleOptions
17
- } from "@codemcp/ade-core";
18
- import {
19
- allHarnessWriters,
20
- getHarnessWriter,
21
- installSkills,
22
- writeInlineSkills
23
- } from "@codemcp/ade-harnesses";
24
-
25
- export async function runSetup(
26
- projectRoot: string,
27
- catalog: Catalog
28
- ): Promise<void> {
29
- clack.intro("ade setup");
30
-
31
- const existingConfig = await readUserConfig(projectRoot);
32
- const existingChoices = existingConfig?.choices ?? {};
33
-
34
- // Warn about stale choices that reference options no longer in the catalog
35
- for (const [facetId, value] of Object.entries(existingChoices)) {
36
- const facet = getFacet(catalog, facetId);
37
- if (!facet) continue;
38
-
39
- const ids = Array.isArray(value) ? value : [value];
40
- for (const optionId of ids) {
41
- if (!getOption(facet, optionId)) {
42
- clack.log.warn(
43
- `Previously selected option "${optionId}" is no longer available in facet "${facet.label}".`
44
- );
45
- }
46
- }
47
- }
48
-
49
- const choices: Record<string, string | string[]> = {};
50
-
51
- const sortedFacets = sortFacets(catalog);
52
-
53
- for (const facet of sortedFacets) {
54
- const visibleOptions = getVisibleOptions(facet, choices, catalog);
55
- if (visibleOptions.length === 0) continue;
56
-
57
- const visibleFacet = { ...facet, options: visibleOptions };
58
-
59
- if (facet.multiSelect) {
60
- const selected = await promptMultiSelect(visibleFacet, existingChoices);
61
- if (typeof selected === "symbol") {
62
- clack.cancel("Setup cancelled.");
63
- return;
64
- }
65
- if (selected.length > 0) {
66
- choices[facet.id] = selected;
67
- }
68
- } else {
69
- const selected = await promptSelect(visibleFacet, existingChoices);
70
- if (typeof selected === "symbol") {
71
- clack.cancel("Setup cancelled.");
72
- return;
73
- }
74
- if (typeof selected === "string" && selected !== "__skip__") {
75
- choices[facet.id] = selected;
76
- }
77
- }
78
- }
79
-
80
- // Docset confirmation step: collect implied docsets, let user deselect
81
- const impliedDocsets = collectDocsets(choices, catalog);
82
- let excludedDocsets: string[] | undefined;
83
-
84
- if (impliedDocsets.length > 0) {
85
- const selected = await clack.multiselect({
86
- message: "Documentation — deselect any you don't need",
87
- options: impliedDocsets.map((d) => ({
88
- value: d.id,
89
- label: d.label,
90
- hint: d.description
91
- })),
92
- initialValues: impliedDocsets.map((d) => d.id),
93
- required: false
94
- });
95
-
96
- if (typeof selected === "symbol") {
97
- clack.cancel("Setup cancelled.");
98
- return;
99
- }
100
-
101
- const selectedSet = new Set(selected as string[]);
102
- const excluded = impliedDocsets
103
- .filter((d) => !selectedSet.has(d.id))
104
- .map((d) => d.id);
105
- if (excluded.length > 0) {
106
- excludedDocsets = excluded;
107
- }
108
- }
109
-
110
- // Harness selection — multi-select from all available harnesses
111
- const existingHarnesses = existingConfig?.harnesses;
112
- const harnessOptions = allHarnessWriters.map((w) => ({
113
- value: w.id,
114
- label: w.label,
115
- hint: w.description
116
- }));
117
-
118
- const validExistingHarnesses = existingHarnesses?.filter((h) =>
119
- allHarnessWriters.some((w) => w.id === h)
120
- );
121
-
122
- const selectedHarnesses = await clack.multiselect({
123
- message: "Harnesses — which coding agents should receive config?",
124
- options: harnessOptions,
125
- initialValues:
126
- validExistingHarnesses && validExistingHarnesses.length > 0
127
- ? validExistingHarnesses
128
- : ["universal"],
129
- required: false
130
- });
131
-
132
- if (typeof selectedHarnesses === "symbol") {
133
- clack.cancel("Setup cancelled.");
134
- return;
135
- }
136
-
137
- const harnesses = selectedHarnesses as string[];
138
-
139
- const userConfig: UserConfig = {
140
- choices,
141
- ...(excludedDocsets && { excluded_docsets: excludedDocsets }),
142
- ...(harnesses.length > 0 && { harnesses })
143
- };
144
- const registry = createDefaultRegistry();
145
- const logicalConfig = await resolve(userConfig, catalog, registry);
146
-
147
- await writeUserConfig(projectRoot, userConfig);
148
-
149
- const lockFile: LockFile = {
150
- version: 1,
151
- generated_at: new Date().toISOString(),
152
- choices: userConfig.choices,
153
- ...(harnesses.length > 0 && { harnesses }),
154
- logical_config: logicalConfig
155
- };
156
- await writeLockFile(projectRoot, lockFile);
157
-
158
- // Install to all selected harnesses
159
- for (const harnessId of harnesses) {
160
- const writer = getHarnessWriter(harnessId);
161
- if (writer) {
162
- await writer.install(logicalConfig, projectRoot);
163
- }
164
- }
165
-
166
- const modifiedSkills = await writeInlineSkills(logicalConfig, projectRoot);
167
- if (modifiedSkills.length > 0) {
168
- clack.log.warn(
169
- `The following skills have been locally modified and will NOT be updated:\n` +
170
- modifiedSkills.map((s) => ` - ${s}`).join("\n") +
171
- `\n\nTo use the latest defaults, remove .ade/skills/ and re-run setup.`
172
- );
173
- }
174
-
175
- await installSkills(logicalConfig.skills, projectRoot);
176
-
177
- if (logicalConfig.knowledge_sources.length > 0) {
178
- clack.log.info(
179
- "Knowledge sources selected. Initialize them separately:\n npx @codemcp/knowledge init"
180
- );
181
- }
182
-
183
- for (const note of logicalConfig.setup_notes) {
184
- clack.log.info(note);
185
- }
186
-
187
- clack.outro("Setup complete!");
188
- }
189
-
190
- function getValidInitialValue(
191
- facet: Facet,
192
- existingChoices: Record<string, string | string[]>
193
- ): string | undefined {
194
- const value = existingChoices[facet.id];
195
- if (typeof value !== "string") return undefined;
196
- // Only set initialValue if the option still exists in the catalog
197
- return facet.options.some((o) => o.id === value) ? value : undefined;
198
- }
199
-
200
- function getValidInitialValues(
201
- facet: Facet,
202
- existingChoices: Record<string, string | string[]>
203
- ): string[] | undefined {
204
- const value = existingChoices[facet.id];
205
- if (!Array.isArray(value)) return undefined;
206
- // Only include options that still exist in the catalog
207
- const valid = value.filter((v) => facet.options.some((o) => o.id === v));
208
- return valid.length > 0 ? valid : undefined;
209
- }
210
-
211
- function promptSelect(
212
- facet: Facet,
213
- existingChoices: Record<string, string | string[]>
214
- ) {
215
- const options = facet.options.map((o) => ({
216
- value: o.id,
217
- label: o.label,
218
- hint: o.description
219
- }));
220
-
221
- if (!facet.required) {
222
- options.push({ value: "__skip__", label: "Skip", hint: "" });
223
- }
224
-
225
- const initialValue = getValidInitialValue(facet, existingChoices);
226
-
227
- return clack.select({
228
- message: facet.label,
229
- options,
230
- ...(initialValue !== undefined && { initialValue })
231
- });
232
- }
233
-
234
- function promptMultiSelect(
235
- facet: Facet,
236
- existingChoices: Record<string, string | string[]>
237
- ) {
238
- const options = facet.options.map((o) => ({
239
- value: o.id,
240
- label: o.label,
241
- hint: o.description
242
- }));
243
-
244
- const initialValues = getValidInitialValues(facet, existingChoices);
245
-
246
- return clack.multiselect({
247
- message: facet.label,
248
- options,
249
- required: false,
250
- ...(initialValues !== undefined && { initialValues })
251
- });
252
- }