@codemcp/ade 0.5.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.
@@ -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.5.0"
42
+ "version": "0.6.0"
42
43
  }
@@ -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";
@@ -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>;
@@ -40,5 +40,5 @@
40
40
  "typescript": "catalog:",
41
41
  "vitest": "catalog:"
42
42
  },
43
- "version": "0.5.0"
43
+ "version": "0.6.0"
44
44
  }
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { allHarnessWriters, getHarnessWriter, getHarnessIds } from "./index.js";
2
+ import {
3
+ allHarnessWriters,
4
+ getHarnessWriter,
5
+ getHarnessIds,
6
+ buildHarnessWriters
7
+ } from "./index.js";
8
+ import type { HarnessWriter } from "./types.js";
3
9
 
4
10
  describe("harness registry", () => {
5
11
  it("exports all harness writers", () => {
@@ -43,3 +49,44 @@ describe("harness registry", () => {
43
49
  }
44
50
  });
45
51
  });
52
+
53
+ describe("buildHarnessWriters", () => {
54
+ it("returns all built-in writers when no extensions provided", () => {
55
+ const writers = buildHarnessWriters({});
56
+ expect(writers).toHaveLength(allHarnessWriters.length);
57
+ expect(writers.map((w) => w.id)).toEqual(
58
+ allHarnessWriters.map((w) => w.id)
59
+ );
60
+ });
61
+
62
+ it("appends extension harness writers after built-ins", () => {
63
+ const customWriter: HarnessWriter = {
64
+ id: "sap-copilot",
65
+ label: "SAP Copilot",
66
+ description: "SAP internal Copilot harness",
67
+ install: async () => {}
68
+ };
69
+
70
+ const writers = buildHarnessWriters({ harnessWriters: [customWriter] });
71
+ expect(writers).toHaveLength(allHarnessWriters.length + 1);
72
+ expect(writers.map((w) => w.id)).toContain("sap-copilot");
73
+ // built-ins come first
74
+ expect(writers[0].id).toBe("universal");
75
+ expect(writers[writers.length - 1].id).toBe("sap-copilot");
76
+ });
77
+
78
+ it("does not mutate allHarnessWriters", () => {
79
+ const originalLength = allHarnessWriters.length;
80
+ buildHarnessWriters({
81
+ harnessWriters: [
82
+ {
83
+ id: "ephemeral",
84
+ label: "Ephemeral",
85
+ description: "Should not persist",
86
+ install: async () => {}
87
+ }
88
+ ]
89
+ });
90
+ expect(allHarnessWriters).toHaveLength(originalLength);
91
+ });
92
+ });
@@ -45,3 +45,13 @@ export function getHarnessWriter(id: string): HarnessWriter | undefined {
45
45
  export function getHarnessIds(): string[] {
46
46
  return allHarnessWriters.map((w) => w.id);
47
47
  }
48
+
49
+ /**
50
+ * Returns the full list of harness writers: built-ins first, then any
51
+ * additional writers contributed via extensions. Does not mutate allHarnessWriters.
52
+ */
53
+ export function buildHarnessWriters(extensions: {
54
+ harnessWriters?: HarnessWriter[];
55
+ }): HarnessWriter[] {
56
+ return [...allHarnessWriters, ...(extensions.harnessWriters ?? [])];
57
+ }
@@ -17,3 +17,5 @@ catalog:
17
17
  rimraf: "^6.1.3"
18
18
  tsx: "^4.19.0"
19
19
  tsup: "^8.3.0"
20
+ # Runtime
21
+ zod: "4.3.6"