@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.
- package/.beads/issues.jsonl +14 -0
- package/.beads/last-touched +1 -1
- package/.vibe/beads-state-ade-main-iazal7.json +29 -0
- package/.vibe/development-plan-extensibility.md +169 -0
- package/ade.extensions.mjs +66 -0
- package/docs/adr/0002-extension-file-type-safety.md +97 -0
- package/docs/guide/extensions.md +187 -0
- package/package.json +3 -2
- package/packages/cli/dist/index.js +166 -32
- package/packages/cli/package.json +4 -2
- package/packages/cli/src/commands/extensions.integration.spec.ts +122 -0
- package/packages/cli/src/commands/install.spec.ts +21 -1
- package/packages/cli/src/commands/install.ts +10 -5
- package/packages/cli/src/commands/setup.ts +8 -4
- package/packages/cli/src/extensions.spec.ts +128 -0
- package/packages/cli/src/extensions.ts +71 -0
- package/packages/cli/src/index.ts +10 -5
- package/packages/core/package.json +3 -2
- package/packages/core/src/catalog/facets/process.ts +10 -1
- package/packages/core/src/catalog/index.ts +38 -1
- package/packages/core/src/extensions.spec.ts +169 -0
- package/packages/core/src/index.ts +3 -1
- package/packages/core/src/registry.ts +3 -2
- package/packages/core/src/resolver.spec.ts +29 -0
- package/packages/core/src/types.ts +71 -0
- package/packages/core/src/writers/mcp-server.spec.ts +62 -0
- package/packages/core/src/writers/mcp-server.ts +25 -0
- package/packages/core/src/writers/workflows.spec.ts +22 -0
- package/packages/core/src/writers/workflows.ts +5 -2
- package/packages/harnesses/package.json +1 -1
- package/packages/harnesses/src/index.spec.ts +48 -1
- package/packages/harnesses/src/index.ts +10 -0
- package/packages/harnesses/src/writers/copilot.spec.ts +2 -6
- package/packages/harnesses/src/writers/copilot.ts +2 -9
- package/packages/harnesses/src/writers/kiro.spec.ts +32 -0
- package/packages/harnesses/src/writers/kiro.ts +22 -5
- package/packages/harnesses/src/writers/opencode.spec.ts +66 -0
- package/packages/harnesses/src/writers/opencode.ts +30 -3
- package/pnpm-workspace.yaml +2 -0
- /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
|
|
15
|
-
|
|
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.
|
|
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 ["
|
|
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>;
|