@codemcp/ade 0.5.0 → 0.6.1

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.
Files changed (33) hide show
  1. package/.beads/issues.jsonl +24 -0
  2. package/.beads/last-touched +1 -1
  3. package/.vibe/beads-state-ade-fix-zod-7eypxn.json +34 -0
  4. package/.vibe/beads-state-ade-main-iazal7.json +29 -0
  5. package/.vibe/beads-state-ade-partially-skilled-ywlqhb.json +24 -0
  6. package/.vibe/development-plan-extensibility.md +169 -0
  7. package/.vibe/development-plan-fix-zod.md +72 -0
  8. package/.vibe/development-plan-partially-skilled.md +44 -0
  9. package/ade.extensions.mjs +66 -0
  10. package/docs/adr/0002-extension-file-type-safety.md +97 -0
  11. package/docs/guide/extensions.md +187 -0
  12. package/package.json +3 -2
  13. package/packages/cli/dist/index.js +33333 -12021
  14. package/packages/cli/package.json +4 -2
  15. package/packages/cli/src/commands/extensions.integration.spec.ts +122 -0
  16. package/packages/cli/src/commands/install.spec.ts +21 -1
  17. package/packages/cli/src/commands/install.ts +10 -5
  18. package/packages/cli/src/commands/setup.ts +8 -4
  19. package/packages/cli/src/extensions.spec.ts +128 -0
  20. package/packages/cli/src/extensions.ts +71 -0
  21. package/packages/cli/src/index.ts +10 -5
  22. package/packages/cli/tsup.config.ts +7 -1
  23. package/packages/core/package.json +3 -2
  24. package/packages/core/src/catalog/facets/process.ts +174 -0
  25. package/packages/core/src/catalog/index.ts +38 -1
  26. package/packages/core/src/extensions.spec.ts +169 -0
  27. package/packages/core/src/index.ts +3 -1
  28. package/packages/core/src/types.ts +71 -0
  29. package/packages/harnesses/package.json +1 -1
  30. package/packages/harnesses/src/index.spec.ts +48 -1
  31. package/packages/harnesses/src/index.ts +10 -0
  32. package/pnpm-workspace.yaml +2 -0
  33. /package/docs/{adrs → adr}/0001-tui-framework-selection.md +0 -0
@@ -28,7 +28,9 @@
28
28
  "@clack/prompts": "^1.1.0",
29
29
  "@codemcp/ade-core": "workspace:*",
30
30
  "@codemcp/ade-harnesses": "workspace:*",
31
- "yaml": "^2.8.2"
31
+ "jiti": "2.6.1",
32
+ "yaml": "^2.8.2",
33
+ "zod": "catalog:"
32
34
  },
33
35
  "devDependencies": {
34
36
  "@codemcp/knowledge": "2.1.0",
@@ -39,5 +41,5 @@
39
41
  "typescript": "catalog:",
40
42
  "vitest": "catalog:"
41
43
  },
42
- "version": "0.5.0"
44
+ "version": "0.6.1"
43
45
  }
@@ -0,0 +1,122 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdtemp, rm, readFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // Mock only the TUI — everything else (catalog, registry, resolver, config I/O, writers) is real
7
+ vi.mock("@clack/prompts", () => ({
8
+ intro: vi.fn(),
9
+ outro: vi.fn(),
10
+ note: vi.fn(),
11
+ select: vi.fn(),
12
+ multiselect: vi.fn(),
13
+ confirm: vi.fn().mockResolvedValue(false), // decline skill install prompt
14
+ isCancel: vi.fn().mockReturnValue(false),
15
+ cancel: vi.fn(),
16
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn() },
17
+ spinner: vi.fn().mockReturnValue({ start: vi.fn(), stop: vi.fn() })
18
+ }));
19
+
20
+ import * as clack from "@clack/prompts";
21
+ import { runSetup } from "./setup.js";
22
+ import {
23
+ readLockFile,
24
+ getDefaultCatalog,
25
+ mergeExtensions
26
+ } from "@codemcp/ade-core";
27
+ import type { AdeExtensions } from "@codemcp/ade-core";
28
+
29
+ describe("extension e2e — option contributes skills and knowledge to setup output", () => {
30
+ let dir: string;
31
+
32
+ beforeEach(async () => {
33
+ vi.clearAllMocks();
34
+ vi.mocked(clack.confirm).mockResolvedValue(false); // don't install skills
35
+ dir = await mkdtemp(join(tmpdir(), "ade-ext-e2e-"));
36
+ });
37
+
38
+ afterEach(async () => {
39
+ await rm(dir, { recursive: true, force: true });
40
+ });
41
+
42
+ it(
43
+ "extension-contributed architecture option writes inline skill and knowledge source",
44
+ { timeout: 60_000 },
45
+ async () => {
46
+ // Build an extension with a SAP option that has an inline skill + knowledge
47
+ const extensions: AdeExtensions = {
48
+ facetContributions: {
49
+ architecture: [
50
+ {
51
+ id: "sap-abap",
52
+ label: "SAP BTP / ABAP",
53
+ description: "SAP BTP ABAP Cloud development",
54
+ recipe: [
55
+ {
56
+ writer: "skills",
57
+ config: {
58
+ skills: [
59
+ {
60
+ name: "sap-abap-code",
61
+ description: "SAP ABAP coding guidelines",
62
+ body: "# SAP ABAP Code\nUse ABAP Cloud APIs only."
63
+ }
64
+ ]
65
+ }
66
+ },
67
+ {
68
+ writer: "knowledge",
69
+ config: {
70
+ name: "sap-abap-docs",
71
+ origin: "https://help.sap.com/docs/abap-cloud",
72
+ description: "SAP ABAP Cloud documentation"
73
+ }
74
+ }
75
+ ]
76
+ }
77
+ ]
78
+ }
79
+ };
80
+
81
+ const catalog = mergeExtensions(getDefaultCatalog(), extensions);
82
+
83
+ // Facet order from sortFacets: process → architecture → practices → backpressure → autonomy
84
+ vi.mocked(clack.select)
85
+ .mockResolvedValueOnce("native-agents-md") // process
86
+ .mockResolvedValueOnce("sap-abap"); // architecture — the extended option
87
+ vi.mocked(clack.multiselect)
88
+ .mockResolvedValueOnce([]) // practices: none
89
+ // backpressure: sap-abap has no matching options so skipped
90
+ .mockResolvedValueOnce([]); // harnesses
91
+
92
+ await runSetup(dir, catalog);
93
+
94
+ // ── Skill should be staged to .ade/skills/sap-abap-code/SKILL.md ────
95
+ const skillMd = await readFile(
96
+ join(dir, ".ade", "skills", "sap-abap-code", "SKILL.md"),
97
+ "utf-8"
98
+ );
99
+ expect(skillMd).toContain("name: sap-abap-code");
100
+ expect(skillMd).toContain("SAP ABAP Code");
101
+ expect(skillMd).toContain("ABAP Cloud APIs only");
102
+
103
+ // ── Knowledge source should appear in the lock file ──────────────────
104
+ const lock = await readLockFile(dir);
105
+ expect(lock).not.toBeNull();
106
+ const knowledgeSources = lock!.logical_config.knowledge_sources;
107
+ expect(knowledgeSources).toHaveLength(1);
108
+ expect(knowledgeSources[0].name).toBe("sap-abap-docs");
109
+ expect(knowledgeSources[0].origin).toBe(
110
+ "https://help.sap.com/docs/abap-cloud"
111
+ );
112
+ expect(knowledgeSources[0].description).toBe(
113
+ "SAP ABAP Cloud documentation"
114
+ );
115
+
116
+ // ── config.yaml should record the extension option as the choice ──────
117
+ const { readUserConfig } = await import("@codemcp/ade-core");
118
+ const config = await readUserConfig(dir);
119
+ expect(config!.choices.architecture).toBe("sap-abap");
120
+ }
121
+ );
122
+ });
@@ -27,9 +27,29 @@ vi.mock("@codemcp/ade-core", async (importOriginal) => {
27
27
  };
28
28
  });
29
29
 
30
- const mockInstall = vi.fn().mockResolvedValue(undefined);
30
+ const mockInstall = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
31
31
 
32
32
  vi.mock("@codemcp/ade-harnesses", () => ({
33
+ allHarnessWriters: [
34
+ {
35
+ id: "universal",
36
+ label: "Universal",
37
+ description: "Universal",
38
+ install: mockInstall
39
+ },
40
+ {
41
+ id: "claude-code",
42
+ label: "Claude Code",
43
+ description: "Claude Code",
44
+ install: mockInstall
45
+ },
46
+ {
47
+ id: "cursor",
48
+ label: "Cursor",
49
+ description: "Cursor",
50
+ install: mockInstall
51
+ }
52
+ ],
33
53
  getHarnessWriter: vi.fn().mockImplementation((id: string) => {
34
54
  if (id === "universal" || id === "claude-code" || id === "cursor") {
35
55
  return { id, install: mockInstall };
@@ -1,6 +1,8 @@
1
1
  import * as clack from "@clack/prompts";
2
2
  import { readLockFile } from "@codemcp/ade-core";
3
3
  import {
4
+ type HarnessWriter,
5
+ allHarnessWriters,
4
6
  getHarnessWriter,
5
7
  getHarnessIds,
6
8
  installSkills,
@@ -9,7 +11,8 @@ import {
9
11
 
10
12
  export async function runInstall(
11
13
  projectRoot: string,
12
- harnessIds?: string[]
14
+ harnessIds?: string[],
15
+ harnessWriters: HarnessWriter[] = allHarnessWriters
13
16
  ): Promise<void> {
14
17
  clack.intro("ade install");
15
18
 
@@ -24,11 +27,12 @@ export async function runInstall(
24
27
  // 3. default: universal
25
28
  const ids = harnessIds ?? lockFile.harnesses ?? ["universal"];
26
29
 
27
- const validIds = getHarnessIds();
30
+ const validIds = [...getHarnessIds(), ...harnessWriters.map((w) => w.id)];
31
+ const uniqueValidIds = [...new Set(validIds)];
28
32
  for (const id of ids) {
29
- if (!validIds.includes(id)) {
33
+ if (!uniqueValidIds.includes(id)) {
30
34
  throw new Error(
31
- `Unknown harness "${id}". Available: ${validIds.join(", ")}`
35
+ `Unknown harness "${id}". Available: ${uniqueValidIds.join(", ")}`
32
36
  );
33
37
  }
34
38
  }
@@ -36,7 +40,8 @@ export async function runInstall(
36
40
  const logicalConfig = lockFile.logical_config;
37
41
 
38
42
  for (const id of ids) {
39
- const writer = getHarnessWriter(id);
43
+ const writer =
44
+ harnessWriters.find((w) => w.id === id) ?? getHarnessWriter(id);
40
45
  if (writer) {
41
46
  await writer.install(logicalConfig, projectRoot);
42
47
  }
@@ -16,6 +16,7 @@ import {
16
16
  getVisibleOptions
17
17
  } from "@codemcp/ade-core";
18
18
  import {
19
+ type HarnessWriter,
19
20
  allHarnessWriters,
20
21
  getHarnessWriter,
21
22
  installSkills,
@@ -24,7 +25,8 @@ import {
24
25
 
25
26
  export async function runSetup(
26
27
  projectRoot: string,
27
- catalog: Catalog
28
+ catalog: Catalog,
29
+ harnessWriters: HarnessWriter[] = allHarnessWriters
28
30
  ): Promise<void> {
29
31
  let lineIndex = 0;
30
32
  const LOGO_LINES = [
@@ -138,14 +140,14 @@ export async function runSetup(
138
140
 
139
141
  // Harness selection — multi-select from all available harnesses
140
142
  const existingHarnesses = existingConfig?.harnesses;
141
- const harnessOptions = allHarnessWriters.map((w) => ({
143
+ const harnessOptions = harnessWriters.map((w) => ({
142
144
  value: w.id,
143
145
  label: w.label,
144
146
  hint: w.description
145
147
  }));
146
148
 
147
149
  const validExistingHarnesses = existingHarnesses?.filter((h) =>
148
- allHarnessWriters.some((w) => w.id === h)
150
+ harnessWriters.some((w) => w.id === h)
149
151
  );
150
152
 
151
153
  const selectedHarnesses = await clack.multiselect({
@@ -188,7 +190,9 @@ export async function runSetup(
188
190
 
189
191
  // Install to all selected harnesses
190
192
  for (const harnessId of harnesses) {
191
- const writer = getHarnessWriter(harnessId);
193
+ const writer =
194
+ harnessWriters.find((w) => w.id === harnessId) ??
195
+ getHarnessWriter(harnessId);
192
196
  if (writer) {
193
197
  await writer.install(logicalConfig, projectRoot);
194
198
  }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { loadExtensions } from "./extensions.js";
3
+ import { tmpdir } from "node:os";
4
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ describe("loadExtensions", () => {
8
+ it("returns an empty object when no extensions file exists", async () => {
9
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
10
+ try {
11
+ const result = await loadExtensions(dir);
12
+ expect(result).toEqual({});
13
+ } finally {
14
+ await rm(dir, { recursive: true });
15
+ }
16
+ });
17
+
18
+ it("loads and validates a valid .mjs extensions file", async () => {
19
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
20
+ try {
21
+ await writeFile(
22
+ join(dir, "ade.extensions.mjs"),
23
+ `export default {
24
+ facetContributions: {
25
+ architecture: [
26
+ {
27
+ id: "sap",
28
+ label: "SAP BTP / ABAP",
29
+ description: "SAP BTP ABAP development",
30
+ recipe: [{ writer: "skills", config: { skills: [] } }]
31
+ }
32
+ ]
33
+ }
34
+ };`
35
+ );
36
+ const result = await loadExtensions(dir);
37
+ expect(result.facetContributions?.architecture).toHaveLength(1);
38
+ expect(result.facetContributions?.architecture?.[0].id).toBe("sap");
39
+ } finally {
40
+ await rm(dir, { recursive: true });
41
+ }
42
+ });
43
+
44
+ it("throws a descriptive error when the extensions file exports an invalid shape", async () => {
45
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
46
+ try {
47
+ await writeFile(
48
+ join(dir, "ade.extensions.mjs"),
49
+ `export default { facetContributions: "not-an-object" };`
50
+ );
51
+ await expect(loadExtensions(dir)).rejects.toThrow(/invalid/i);
52
+ } finally {
53
+ await rm(dir, { recursive: true });
54
+ }
55
+ });
56
+
57
+ it("loads a .js file when only .js exists", async () => {
58
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
59
+ try {
60
+ await writeFile(
61
+ join(dir, "ade.extensions.js"),
62
+ `export default {
63
+ facets: [
64
+ {
65
+ id: "js-only-facet",
66
+ label: "JS Only",
67
+ description: "From .js fallback",
68
+ required: false,
69
+ options: []
70
+ }
71
+ ]
72
+ };`
73
+ );
74
+ const result = await loadExtensions(dir);
75
+ expect(result.facets).toHaveLength(1);
76
+ expect(result.facets?.[0].id).toBe("js-only-facet");
77
+ } finally {
78
+ await rm(dir, { recursive: true });
79
+ }
80
+ });
81
+
82
+ it("prefers .mjs over .js when both exist", async () => {
83
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
84
+ try {
85
+ await writeFile(
86
+ join(dir, "ade.extensions.mjs"),
87
+ `export default { facets: [{ id: "from-mjs", label: "MJS", description: "MJS wins", required: false, options: [] }] };`
88
+ );
89
+ await writeFile(
90
+ join(dir, "ade.extensions.js"),
91
+ `export default { facets: [{ id: "from-js", label: "JS", description: "JS loses", required: false, options: [] }] };`
92
+ );
93
+ const result = await loadExtensions(dir);
94
+ expect(result.facets?.[0].id).toBe("from-mjs");
95
+ } finally {
96
+ await rm(dir, { recursive: true });
97
+ }
98
+ });
99
+
100
+ it("loads from an absolute path — simulating npx run from a different cwd", async () => {
101
+ // This is the published-package scenario:
102
+ // The CLI binary lives in ~/.npm/_npx/... but projectRoot is the user's cwd.
103
+ // loadExtensions(projectRoot) must look in projectRoot, not in the CLI package dir.
104
+ const userProjectDir = await mkdtemp(join(tmpdir(), "ade-user-project-"));
105
+ const cliPackageDir = await mkdtemp(join(tmpdir(), "ade-cli-package-"));
106
+ try {
107
+ // Simulate: user project has ade.extensions.mjs
108
+ await writeFile(
109
+ join(userProjectDir, "ade.extensions.mjs"),
110
+ `export default {
111
+ facets: [{ id: "user-project-facet", label: "User", description: "From user project", required: false, options: [] }]
112
+ };`
113
+ );
114
+ // CLI package dir has no extension file (it shouldn't be used)
115
+
116
+ // loadExtensions is called with the user's project dir as projectRoot
117
+ const result = await loadExtensions(userProjectDir);
118
+ expect(result.facets?.[0].id).toBe("user-project-facet");
119
+
120
+ // CLI package dir produces empty extensions — it is never consulted
121
+ const cliResult = await loadExtensions(cliPackageDir);
122
+ expect(cliResult).toEqual({});
123
+ } finally {
124
+ await rm(userProjectDir, { recursive: true });
125
+ await rm(cliPackageDir, { recursive: true });
126
+ }
127
+ });
128
+ });
@@ -0,0 +1,71 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { AdeExtensionsSchema, type AdeExtensions } from "@codemcp/ade-core";
5
+
6
+ const SEARCH_ORDER = [
7
+ "ade.extensions.ts",
8
+ "ade.extensions.mjs",
9
+ "ade.extensions.js"
10
+ ] as const;
11
+
12
+ /**
13
+ * Loads and validates the project's `ade.extensions` file (if any).
14
+ *
15
+ * Search order: ade.extensions.ts → ade.extensions.mjs → ade.extensions.js
16
+ *
17
+ * - `.ts` files are loaded via `jiti` for TypeScript support.
18
+ * - `.mjs` / `.js` files are loaded via native dynamic `import()`.
19
+ * - Returns `{}` when no extensions file exists.
20
+ * - Throws with a descriptive message when the file exports an invalid shape.
21
+ */
22
+ export async function loadExtensions(
23
+ projectRoot: string
24
+ ): Promise<AdeExtensions> {
25
+ for (const filename of SEARCH_ORDER) {
26
+ const filePath = join(projectRoot, filename);
27
+
28
+ if (!(await fileExists(filePath))) continue;
29
+
30
+ // eslint-disable-next-line no-await-in-loop
31
+ const mod = await loadModule(filePath, filename);
32
+ const raw = mod?.default ?? mod;
33
+
34
+ const result = AdeExtensionsSchema.safeParse(raw);
35
+ if (!result.success) {
36
+ throw new Error(
37
+ `Invalid ade.extensions file at ${filePath}:\n${result.error.message}`
38
+ );
39
+ }
40
+
41
+ return result.data;
42
+ }
43
+
44
+ return {};
45
+ }
46
+
47
+ async function fileExists(filePath: string): Promise<boolean> {
48
+ try {
49
+ await access(filePath);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ async function loadModule(
57
+ filePath: string,
58
+ filename: string
59
+ ): Promise<Record<string, unknown>> {
60
+ if (filename.endsWith(".ts")) {
61
+ // Use jiti for TypeScript support
62
+ const { createJiti } = await import("jiti");
63
+ const jiti = createJiti(import.meta.url);
64
+ return jiti.import(filePath) as Promise<Record<string, unknown>>;
65
+ }
66
+
67
+ // Native ESM for .mjs / .js
68
+ return import(pathToFileURL(filePath).href) as Promise<
69
+ Record<string, unknown>
70
+ >;
71
+ }
@@ -3,18 +3,23 @@
3
3
  import { version } from "./version.js";
4
4
  import { runSetup } from "./commands/setup.js";
5
5
  import { runInstall } from "./commands/install.js";
6
- import { getDefaultCatalog } from "@codemcp/ade-core";
7
- import { getHarnessIds } from "@codemcp/ade-harnesses";
6
+ import { getDefaultCatalog, mergeExtensions } from "@codemcp/ade-core";
7
+ import { getHarnessIds, buildHarnessWriters } from "@codemcp/ade-harnesses";
8
+ import { loadExtensions } from "./extensions.js";
8
9
 
9
10
  const args = process.argv.slice(2);
10
11
  const command = args[0];
11
12
 
12
13
  if (command === "setup") {
13
14
  const projectRoot = args[1] ?? process.cwd();
14
- const catalog = getDefaultCatalog();
15
- await runSetup(projectRoot, catalog);
15
+ const extensions = await loadExtensions(projectRoot);
16
+ const catalog = mergeExtensions(getDefaultCatalog(), extensions);
17
+ const harnessWriters = buildHarnessWriters(extensions);
18
+ await runSetup(projectRoot, catalog, harnessWriters);
16
19
  } else if (command === "install") {
17
20
  const projectRoot = args[1] ?? process.cwd();
21
+ const extensions = await loadExtensions(projectRoot);
22
+ const harnessWriters = buildHarnessWriters(extensions);
18
23
 
19
24
  let harnessIds: string[] | undefined;
20
25
 
@@ -26,7 +31,7 @@ if (command === "setup") {
26
31
  }
27
32
  }
28
33
 
29
- await runInstall(projectRoot, harnessIds);
34
+ await runInstall(projectRoot, harnessIds, harnessWriters);
30
35
  } else if (command === "--version" || command === "-v") {
31
36
  console.log(version);
32
37
  } else {
@@ -7,7 +7,13 @@ export default defineConfig({
7
7
  tsconfig: "tsconfig.build.json",
8
8
  target: "node22",
9
9
  clean: true,
10
- noExternal: ["@clack/prompts", "@codemcp/ade-core", "@codemcp/ade-harnesses"],
10
+ noExternal: [
11
+ "@clack/prompts",
12
+ "@codemcp/ade-core",
13
+ "@codemcp/ade-harnesses",
14
+ "yaml",
15
+ "zod"
16
+ ],
11
17
  esbuildOptions(options) {
12
18
  options.banner = {
13
19
  js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`
@@ -28,7 +28,8 @@
28
28
  "typecheck": "tsc --noEmit"
29
29
  },
30
30
  "dependencies": {
31
- "yaml": "^2.8.2"
31
+ "yaml": "^2.8.2",
32
+ "zod": "catalog:"
32
33
  },
33
34
  "devDependencies": {
34
35
  "oxlint": "catalog:",
@@ -38,5 +39,5 @@
38
39
  "typescript": "catalog:",
39
40
  "vitest": "catalog:"
40
41
  },
41
- "version": "0.5.0"
42
+ "version": "0.6.1"
42
43
  }