@codemcp/ade 0.4.0 → 0.6.0

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 (40) hide show
  1. package/.beads/issues.jsonl +14 -0
  2. package/.beads/last-touched +1 -1
  3. package/.vibe/beads-state-ade-main-iazal7.json +29 -0
  4. package/.vibe/development-plan-extensibility.md +169 -0
  5. package/ade.extensions.mjs +66 -0
  6. package/docs/adr/0002-extension-file-type-safety.md +97 -0
  7. package/docs/guide/extensions.md +187 -0
  8. package/package.json +3 -2
  9. package/packages/cli/dist/index.js +166 -32
  10. package/packages/cli/package.json +4 -2
  11. package/packages/cli/src/commands/extensions.integration.spec.ts +122 -0
  12. package/packages/cli/src/commands/install.spec.ts +21 -1
  13. package/packages/cli/src/commands/install.ts +10 -5
  14. package/packages/cli/src/commands/setup.ts +8 -4
  15. package/packages/cli/src/extensions.spec.ts +128 -0
  16. package/packages/cli/src/extensions.ts +71 -0
  17. package/packages/cli/src/index.ts +10 -5
  18. package/packages/core/package.json +3 -2
  19. package/packages/core/src/catalog/facets/process.ts +10 -1
  20. package/packages/core/src/catalog/index.ts +38 -1
  21. package/packages/core/src/extensions.spec.ts +169 -0
  22. package/packages/core/src/index.ts +3 -1
  23. package/packages/core/src/registry.ts +3 -2
  24. package/packages/core/src/resolver.spec.ts +29 -0
  25. package/packages/core/src/types.ts +71 -0
  26. package/packages/core/src/writers/mcp-server.spec.ts +62 -0
  27. package/packages/core/src/writers/mcp-server.ts +25 -0
  28. package/packages/core/src/writers/workflows.spec.ts +22 -0
  29. package/packages/core/src/writers/workflows.ts +5 -2
  30. package/packages/harnesses/package.json +1 -1
  31. package/packages/harnesses/src/index.spec.ts +48 -1
  32. package/packages/harnesses/src/index.ts +10 -0
  33. package/packages/harnesses/src/writers/copilot.spec.ts +2 -6
  34. package/packages/harnesses/src/writers/copilot.ts +2 -9
  35. package/packages/harnesses/src/writers/kiro.spec.ts +32 -0
  36. package/packages/harnesses/src/writers/kiro.ts +22 -5
  37. package/packages/harnesses/src/writers/opencode.spec.ts +66 -0
  38. package/packages/harnesses/src/writers/opencode.ts +30 -3
  39. package/pnpm-workspace.yaml +2 -0
  40. /package/docs/{adrs → adr}/0001-tui-framework-selection.md +0 -0
@@ -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 {
@@ -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.4.0"
42
+ "version": "0.6.0"
42
43
  }
@@ -16,7 +16,16 @@ export const processFacet: Facet = {
16
16
  writer: "workflows",
17
17
  config: {
18
18
  package: "@codemcp/workflows-server@latest",
19
- ref: "workflows"
19
+ ref: "workflows",
20
+ env: {
21
+ VIBE_WORKFLOW_DOMAINS: "skilled"
22
+ },
23
+ allowedTools: [
24
+ "whats_next",
25
+ "conduct_review",
26
+ "list_workflows",
27
+ "get_tool_info"
28
+ ]
20
29
  }
21
30
  },
22
31
  {
@@ -1,4 +1,4 @@
1
- import type { Catalog, Facet, Option } from "../types.js";
1
+ import type { Catalog, Facet, Option, AdeExtensions } from "../types.js";
2
2
  import { processFacet } from "./facets/process.js";
3
3
  import { architectureFacet } from "./facets/architecture.js";
4
4
  import { practicesFacet } from "./facets/practices.js";
@@ -91,3 +91,40 @@ export function getVisibleOptions(
91
91
  return option.available(deps);
92
92
  });
93
93
  }
94
+
95
+ /**
96
+ * Merges extension contributions into a catalog, returning a new catalog
97
+ * without mutating the original.
98
+ *
99
+ * - `extensions.facetContributions`: appends new options to existing facets
100
+ * (silently ignores contributions for unknown facet ids)
101
+ * - `extensions.facets`: appends entirely new facets
102
+ */
103
+ export function mergeExtensions(
104
+ catalog: Catalog,
105
+ extensions: AdeExtensions
106
+ ): Catalog {
107
+ // Deep-clone the facets array (shallow-clone each facet with a new options array)
108
+ let facets: Facet[] = catalog.facets.map((f) => ({
109
+ ...f,
110
+ options: [...f.options]
111
+ }));
112
+
113
+ // Append contributed options to existing facets
114
+ for (const [facetId, newOptions] of Object.entries(
115
+ extensions.facetContributions ?? {}
116
+ )) {
117
+ const facet = facets.find((f) => f.id === facetId);
118
+ if (facet) {
119
+ facet.options = [...facet.options, ...newOptions];
120
+ }
121
+ // Unknown facet ids are silently ignored
122
+ }
123
+
124
+ // Append entirely new facets
125
+ if (extensions.facets && extensions.facets.length > 0) {
126
+ facets = [...facets, ...extensions.facets];
127
+ }
128
+
129
+ return { facets };
130
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { AdeExtensions } from "./types.js";
3
+ import { AdeExtensionsSchema } from "./types.js";
4
+ import { mergeExtensions } from "./catalog/index.js";
5
+ import { getDefaultCatalog, getFacet, getOption } from "./catalog/index.js";
6
+
7
+ // ─── AdeExtensionsSchema (Zod validation) ──────────────────────────────────
8
+
9
+ describe("AdeExtensionsSchema", () => {
10
+ it("accepts an empty object (all fields optional)", () => {
11
+ const result = AdeExtensionsSchema.safeParse({});
12
+ expect(result.success).toBe(true);
13
+ });
14
+
15
+ it("accepts a valid facetContributions map", () => {
16
+ const ext: AdeExtensions = {
17
+ facetContributions: {
18
+ architecture: [
19
+ {
20
+ id: "sap",
21
+ label: "SAP",
22
+ description: "SAP BTP ABAP development",
23
+ recipe: [{ writer: "skills", config: { skills: [] } }]
24
+ }
25
+ ]
26
+ }
27
+ };
28
+ const result = AdeExtensionsSchema.safeParse(ext);
29
+ expect(result.success).toBe(true);
30
+ });
31
+
32
+ it("accepts a valid facets array (new facets)", () => {
33
+ const ext: AdeExtensions = {
34
+ facets: [
35
+ {
36
+ id: "custom-facet",
37
+ label: "Custom",
38
+ description: "A custom facet",
39
+ required: false,
40
+ options: []
41
+ }
42
+ ]
43
+ };
44
+ const result = AdeExtensionsSchema.safeParse(ext);
45
+ expect(result.success).toBe(true);
46
+ });
47
+
48
+ it("accepts harnessWriters", () => {
49
+ const ext: AdeExtensions = {
50
+ harnessWriters: [
51
+ {
52
+ id: "my-harness",
53
+ label: "My Harness",
54
+ description: "Custom harness",
55
+ install: async () => {}
56
+ }
57
+ ]
58
+ };
59
+ const result = AdeExtensionsSchema.safeParse(ext);
60
+ expect(result.success).toBe(true);
61
+ });
62
+
63
+ it("rejects an invalid facetContributions value (wrong type)", () => {
64
+ const result = AdeExtensionsSchema.safeParse({
65
+ facetContributions: "not-an-object"
66
+ });
67
+ expect(result.success).toBe(false);
68
+ });
69
+
70
+ it("rejects a facetContributions option missing required fields", () => {
71
+ const result = AdeExtensionsSchema.safeParse({
72
+ facetContributions: {
73
+ architecture: [
74
+ { id: "sap" } // missing label, description, recipe
75
+ ]
76
+ }
77
+ });
78
+ expect(result.success).toBe(false);
79
+ });
80
+ });
81
+
82
+ // ─── mergeExtensions ────────────────────────────────────────────────────────
83
+
84
+ describe("mergeExtensions", () => {
85
+ it("returns the original catalog unchanged when extensions is empty", () => {
86
+ const original = getDefaultCatalog();
87
+ const merged = mergeExtensions(original, {});
88
+ expect(merged.facets).toHaveLength(original.facets.length);
89
+ expect(merged.facets.map((f) => f.id)).toEqual(
90
+ original.facets.map((f) => f.id)
91
+ );
92
+ });
93
+
94
+ it("adds new options to an existing facet via facetContributions", () => {
95
+ const catalog = getDefaultCatalog();
96
+ const sapOption = {
97
+ id: "sap",
98
+ label: "SAP BTP / ABAP",
99
+ description: "SAP BTP ABAP development",
100
+ recipe: [{ writer: "skills" as const, config: { skills: [] } }]
101
+ };
102
+
103
+ const merged = mergeExtensions(catalog, {
104
+ facetContributions: { architecture: [sapOption] }
105
+ });
106
+
107
+ const arch = getFacet(merged, "architecture")!;
108
+ expect(arch).toBeDefined();
109
+ const sap = getOption(arch, "sap");
110
+ expect(sap).toBeDefined();
111
+ expect(sap!.label).toBe("SAP BTP / ABAP");
112
+ });
113
+
114
+ it("does not mutate the original catalog", () => {
115
+ const catalog = getDefaultCatalog();
116
+ const originalArchOptionCount = getFacet(catalog, "architecture")!.options
117
+ .length;
118
+
119
+ mergeExtensions(catalog, {
120
+ facetContributions: {
121
+ architecture: [
122
+ {
123
+ id: "sap",
124
+ label: "SAP",
125
+ description: "SAP",
126
+ recipe: [{ writer: "skills" as const, config: { skills: [] } }]
127
+ }
128
+ ]
129
+ }
130
+ });
131
+
132
+ expect(getFacet(catalog, "architecture")!.options).toHaveLength(
133
+ originalArchOptionCount
134
+ );
135
+ });
136
+
137
+ it("appends entirely new facets from extensions.facets", () => {
138
+ const catalog = getDefaultCatalog();
139
+ const newFacet = {
140
+ id: "sap-specific",
141
+ label: "SAP Specific",
142
+ description: "SAP-specific choices",
143
+ required: false,
144
+ options: []
145
+ };
146
+
147
+ const merged = mergeExtensions(catalog, { facets: [newFacet] });
148
+ expect(merged.facets.map((f) => f.id)).toContain("sap-specific");
149
+ expect(merged.facets).toHaveLength(catalog.facets.length + 1);
150
+ });
151
+
152
+ it("ignores facetContributions for unknown facet ids (no crash)", () => {
153
+ const catalog = getDefaultCatalog();
154
+ const merged = mergeExtensions(catalog, {
155
+ facetContributions: {
156
+ "totally-unknown-facet": [
157
+ {
158
+ id: "x",
159
+ label: "X",
160
+ description: "X",
161
+ recipe: [{ writer: "skills" as const, config: { skills: [] } }]
162
+ }
163
+ ]
164
+ }
165
+ });
166
+ // Should not throw; catalog unchanged
167
+ expect(merged.facets).toHaveLength(catalog.facets.length);
168
+ });
169
+ });
@@ -45,8 +45,10 @@ export {
45
45
  getFacet,
46
46
  getOption,
47
47
  sortFacets,
48
- getVisibleOptions
48
+ getVisibleOptions,
49
+ mergeExtensions
49
50
  } from "./catalog/index.js";
51
+ export { type AdeExtensions, AdeExtensionsSchema } from "./types.js";
50
52
  export { skillsWriter } from "./writers/skills.js";
51
53
  export { knowledgeWriter } from "./writers/knowledge.js";
52
54
  export { permissionPolicyWriter } from "./writers/permission-policy.js";
@@ -5,6 +5,7 @@ import type {
5
5
  } from "./types.js";
6
6
  import { instructionWriter } from "./writers/instruction.js";
7
7
  import { workflowsWriter } from "./writers/workflows.js";
8
+ import { mcpServerWriter } from "./writers/mcp-server.js";
8
9
  import { skillsWriter } from "./writers/skills.js";
9
10
  import { knowledgeWriter } from "./writers/knowledge.js";
10
11
  import { gitHooksWriter } from "./writers/git-hooks.js";
@@ -51,15 +52,15 @@ export function createDefaultRegistry(): WriterRegistry {
51
52
 
52
53
  registerProvisionWriter(registry, instructionWriter);
53
54
  registerProvisionWriter(registry, workflowsWriter);
55
+ registerProvisionWriter(registry, mcpServerWriter);
54
56
  registerProvisionWriter(registry, skillsWriter);
55
-
56
57
  registerProvisionWriter(registry, knowledgeWriter);
57
58
  registerProvisionWriter(registry, gitHooksWriter);
58
59
  registerProvisionWriter(registry, setupNoteWriter);
59
60
  registerProvisionWriter(registry, permissionPolicyWriter);
60
61
 
61
62
  // Stub writers for types not yet implemented
62
- for (const id of ["mcp-server", "installable"]) {
63
+ for (const id of ["installable"]) {
63
64
  registerProvisionWriter(registry, {
64
65
  id,
65
66
  write: async () => ({})
@@ -120,6 +120,35 @@ describe("resolve", () => {
120
120
  expect(result.mcp_servers.length).toBeGreaterThanOrEqual(1);
121
121
  expect(result.instructions).toContain("Extra instruction");
122
122
  });
123
+
124
+ it("env set in the catalog option config is forwarded to the resolved mcp_server entry", async () => {
125
+ const userConfig: UserConfig = {
126
+ choices: { process: "codemcp-workflows" }
127
+ };
128
+
129
+ // Patch the catalog option's provision config to include env
130
+ const processFacet = catalog.facets.find((f) => f.id === "process")!;
131
+ const option = processFacet.options.find(
132
+ (o) => o.id === "codemcp-workflows"
133
+ )!;
134
+ const workflowsProvision = option.recipe.find(
135
+ (p) => p.writer === "workflows"
136
+ )!;
137
+ workflowsProvision.config = {
138
+ ...workflowsProvision.config,
139
+ env: { VIBE_WORKFLOWS_DOMAIN: "skilled" }
140
+ };
141
+
142
+ const result = await resolve(userConfig, catalog, registry);
143
+
144
+ const workflowsServer = result.mcp_servers.find(
145
+ (s) => s.ref === "workflows"
146
+ );
147
+ expect(workflowsServer).toBeDefined();
148
+ expect(workflowsServer!.env).toEqual({
149
+ VIBE_WORKFLOWS_DOMAIN: "skilled"
150
+ });
151
+ });
123
152
  });
124
153
 
125
154
  describe("unknown facet in choices", () => {
@@ -1,3 +1,5 @@
1
+ import { z } from "zod";
2
+
1
3
  // --- Catalog types ---
2
4
 
3
5
  export interface Catalog {
@@ -157,3 +159,72 @@ export interface WriterRegistry {
157
159
  provisions: Map<string, ProvisionWriterDef>;
158
160
  agents: Map<string, AgentWriterDef>;
159
161
  }
162
+
163
+ // --- Extension types ---
164
+
165
+ /**
166
+ * Runtime validation helpers for extension file loading.
167
+ *
168
+ * We use z.custom<T>() for Option, Facet, HarnessWriter and ProvisionWriterDef
169
+ * because their TypeScript interfaces contain function types that Zod cannot
170
+ * faithfully represent without losing the concrete signature. z.custom<T>
171
+ * gives us the correct TS type while still letting us write a runtime check.
172
+ */
173
+ const OptionSchema = z.custom<Option>(
174
+ (val) =>
175
+ typeof val === "object" &&
176
+ val !== null &&
177
+ typeof (val as Record<string, unknown>).id === "string" &&
178
+ typeof (val as Record<string, unknown>).label === "string" &&
179
+ typeof (val as Record<string, unknown>).description === "string" &&
180
+ Array.isArray((val as Record<string, unknown>).recipe),
181
+ { message: "Option must have id, label, description and recipe fields" }
182
+ );
183
+
184
+ const FacetSchema = z.custom<Facet>(
185
+ (val) =>
186
+ typeof val === "object" &&
187
+ val !== null &&
188
+ typeof (val as Record<string, unknown>).id === "string" &&
189
+ typeof (val as Record<string, unknown>).label === "string" &&
190
+ typeof (val as Record<string, unknown>).description === "string" &&
191
+ typeof (val as Record<string, unknown>).required === "boolean" &&
192
+ Array.isArray((val as Record<string, unknown>).options),
193
+ { message: "Facet must have id, label, description, required and options" }
194
+ );
195
+
196
+ const HarnessWriterSchema = z.custom<
197
+ AgentWriterDef & { label: string; description: string }
198
+ >(
199
+ (val) =>
200
+ typeof val === "object" &&
201
+ val !== null &&
202
+ typeof (val as Record<string, unknown>).id === "string" &&
203
+ typeof (val as Record<string, unknown>).label === "string" &&
204
+ typeof (val as Record<string, unknown>).description === "string" &&
205
+ typeof (val as Record<string, unknown>).install === "function",
206
+ { message: "HarnessWriter must have id, label, description and install()" }
207
+ );
208
+
209
+ const ProvisionWriterDefSchema = z.custom<ProvisionWriterDef>(
210
+ (val) =>
211
+ typeof val === "object" &&
212
+ val !== null &&
213
+ typeof (val as Record<string, unknown>).id === "string" &&
214
+ typeof (val as Record<string, unknown>).write === "function",
215
+ { message: "ProvisionWriterDef must have id and write()" }
216
+ );
217
+
218
+ export const AdeExtensionsSchema = z.object({
219
+ /** Add new options to existing facets, keyed by facet id */
220
+ facetContributions: z.record(z.string(), z.array(OptionSchema)).optional(),
221
+ /** Add entirely new facets */
222
+ facets: z.array(FacetSchema).optional(),
223
+ /** Add new provision writers */
224
+ provisionWriters: z.array(ProvisionWriterDefSchema).optional(),
225
+ /** Add new harness writers */
226
+ harnessWriters: z.array(HarnessWriterSchema).optional()
227
+ });
228
+
229
+ /** The shape of a consumer's `ade.extensions.mjs` default export. */
230
+ export type AdeExtensions = z.infer<typeof AdeExtensionsSchema>;