@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.
- 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 +99 -15
- 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/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/types.ts +71 -0
- 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/pnpm-workspace.yaml +2 -0
- /package/docs/{adrs → adr}/0001-tui-framework-selection.md +0 -0
|
@@ -12793,6 +12793,22 @@ function getVisibleOptions(facet, choices, catalog) {
|
|
|
12793
12793
|
return option.available(deps);
|
|
12794
12794
|
});
|
|
12795
12795
|
}
|
|
12796
|
+
function mergeExtensions(catalog, extensions) {
|
|
12797
|
+
let facets = catalog.facets.map((f) => ({
|
|
12798
|
+
...f,
|
|
12799
|
+
options: [...f.options]
|
|
12800
|
+
}));
|
|
12801
|
+
for (const [facetId, newOptions] of Object.entries(extensions.facetContributions ?? {})) {
|
|
12802
|
+
const facet = facets.find((f) => f.id === facetId);
|
|
12803
|
+
if (facet) {
|
|
12804
|
+
facet.options = [...facet.options, ...newOptions];
|
|
12805
|
+
}
|
|
12806
|
+
}
|
|
12807
|
+
if (extensions.facets && extensions.facets.length > 0) {
|
|
12808
|
+
facets = [...facets, ...extensions.facets];
|
|
12809
|
+
}
|
|
12810
|
+
return { facets };
|
|
12811
|
+
}
|
|
12796
12812
|
|
|
12797
12813
|
// ../core/dist/resolver.js
|
|
12798
12814
|
async function resolve(userConfig, catalog, registry2) {
|
|
@@ -12948,6 +12964,23 @@ function collectDocsets(choices, catalog) {
|
|
|
12948
12964
|
return Array.from(seen.values());
|
|
12949
12965
|
}
|
|
12950
12966
|
|
|
12967
|
+
// ../core/dist/types.js
|
|
12968
|
+
import { z as z3 } from "zod";
|
|
12969
|
+
var OptionSchema = z3.custom((val) => typeof val === "object" && val !== null && typeof val.id === "string" && typeof val.label === "string" && typeof val.description === "string" && Array.isArray(val.recipe), { message: "Option must have id, label, description and recipe fields" });
|
|
12970
|
+
var FacetSchema = z3.custom((val) => typeof val === "object" && val !== null && typeof val.id === "string" && typeof val.label === "string" && typeof val.description === "string" && typeof val.required === "boolean" && Array.isArray(val.options), { message: "Facet must have id, label, description, required and options" });
|
|
12971
|
+
var HarnessWriterSchema = z3.custom((val) => typeof val === "object" && val !== null && typeof val.id === "string" && typeof val.label === "string" && typeof val.description === "string" && typeof val.install === "function", { message: "HarnessWriter must have id, label, description and install()" });
|
|
12972
|
+
var ProvisionWriterDefSchema = z3.custom((val) => typeof val === "object" && val !== null && typeof val.id === "string" && typeof val.write === "function", { message: "ProvisionWriterDef must have id and write()" });
|
|
12973
|
+
var AdeExtensionsSchema = z3.object({
|
|
12974
|
+
/** Add new options to existing facets, keyed by facet id */
|
|
12975
|
+
facetContributions: z3.record(z3.string(), z3.array(OptionSchema)).optional(),
|
|
12976
|
+
/** Add entirely new facets */
|
|
12977
|
+
facets: z3.array(FacetSchema).optional(),
|
|
12978
|
+
/** Add new provision writers */
|
|
12979
|
+
provisionWriters: z3.array(ProvisionWriterDefSchema).optional(),
|
|
12980
|
+
/** Add new harness writers */
|
|
12981
|
+
harnessWriters: z3.array(HarnessWriterSchema).optional()
|
|
12982
|
+
});
|
|
12983
|
+
|
|
12951
12984
|
// ../harnesses/dist/skills-installer.js
|
|
12952
12985
|
import { join as join8 } from "path";
|
|
12953
12986
|
|
|
@@ -18080,7 +18113,7 @@ var V2 = "[";
|
|
|
18080
18113
|
var nD = "]";
|
|
18081
18114
|
var G2 = "m";
|
|
18082
18115
|
var _2 = `${nD}8;;`;
|
|
18083
|
-
var
|
|
18116
|
+
var z4 = (e2) => `${d.values().next().value}${V2}${e2}${G2}`;
|
|
18084
18117
|
var K2 = (e2) => `${d.values().next().value}${_2}${e2}${y3}`;
|
|
18085
18118
|
var aD = (e2) => e2.split(" ").map((u2) => p(u2));
|
|
18086
18119
|
var k2 = (e2, u2, t2) => {
|
|
@@ -18141,8 +18174,8 @@ var lD = (e2, u2, t2 = {}) => {
|
|
|
18141
18174
|
}
|
|
18142
18175
|
const o2 = ED.codes.get(Number(s));
|
|
18143
18176
|
n[E + 1] === `
|
|
18144
|
-
` ? (i && (F2 += K2("")), s && o2 && (F2 +=
|
|
18145
|
-
` && (s && o2 && (F2 +=
|
|
18177
|
+
` ? (i && (F2 += K2("")), s && o2 && (F2 += z4(o2))) : a === `
|
|
18178
|
+
` && (s && o2 && (F2 += z4(s)), i && (F2 += K2(i)));
|
|
18146
18179
|
}
|
|
18147
18180
|
return F2;
|
|
18148
18181
|
};
|
|
@@ -22747,9 +22780,12 @@ function getHarnessWriter(id) {
|
|
|
22747
22780
|
function getHarnessIds() {
|
|
22748
22781
|
return allHarnessWriters.map((w2) => w2.id);
|
|
22749
22782
|
}
|
|
22783
|
+
function buildHarnessWriters(extensions) {
|
|
22784
|
+
return [...allHarnessWriters, ...extensions.harnessWriters ?? []];
|
|
22785
|
+
}
|
|
22750
22786
|
|
|
22751
22787
|
// src/commands/setup.ts
|
|
22752
|
-
async function runSetup(projectRoot, catalog) {
|
|
22788
|
+
async function runSetup(projectRoot, catalog, harnessWriters = allHarnessWriters) {
|
|
22753
22789
|
let lineIndex = 0;
|
|
22754
22790
|
const LOGO_LINES = [
|
|
22755
22791
|
"\n",
|
|
@@ -22843,13 +22879,13 @@ async function runSetup(projectRoot, catalog) {
|
|
|
22843
22879
|
}
|
|
22844
22880
|
}
|
|
22845
22881
|
const existingHarnesses = existingConfig?.harnesses;
|
|
22846
|
-
const harnessOptions =
|
|
22882
|
+
const harnessOptions = harnessWriters.map((w2) => ({
|
|
22847
22883
|
value: w2.id,
|
|
22848
22884
|
label: w2.label,
|
|
22849
22885
|
hint: w2.description
|
|
22850
22886
|
}));
|
|
22851
22887
|
const validExistingHarnesses = existingHarnesses?.filter(
|
|
22852
|
-
(h3) =>
|
|
22888
|
+
(h3) => harnessWriters.some((w2) => w2.id === h3)
|
|
22853
22889
|
);
|
|
22854
22890
|
const selectedHarnesses = await Lt2({
|
|
22855
22891
|
message: "Which coding agents should receive config?\nADE generates config files for each agent you select.\n",
|
|
@@ -22879,7 +22915,7 @@ async function runSetup(projectRoot, catalog) {
|
|
|
22879
22915
|
};
|
|
22880
22916
|
await writeLockFile(projectRoot, lockFile);
|
|
22881
22917
|
for (const harnessId of harnesses) {
|
|
22882
|
-
const writer = getHarnessWriter(harnessId);
|
|
22918
|
+
const writer = harnessWriters.find((w2) => w2.id === harnessId) ?? getHarnessWriter(harnessId);
|
|
22883
22919
|
if (writer) {
|
|
22884
22920
|
await writer.install(logicalConfig, projectRoot);
|
|
22885
22921
|
}
|
|
@@ -22967,24 +23003,25 @@ function promptMultiSelect(facet, existingChoices) {
|
|
|
22967
23003
|
}
|
|
22968
23004
|
|
|
22969
23005
|
// src/commands/install.ts
|
|
22970
|
-
async function runInstall(projectRoot, harnessIds) {
|
|
23006
|
+
async function runInstall(projectRoot, harnessIds, harnessWriters = allHarnessWriters) {
|
|
22971
23007
|
Wt2("ade install");
|
|
22972
23008
|
const lockFile = await readLockFile(projectRoot);
|
|
22973
23009
|
if (!lockFile) {
|
|
22974
23010
|
throw new Error("config.lock.yaml not found. Run `ade setup` first.");
|
|
22975
23011
|
}
|
|
22976
23012
|
const ids = harnessIds ?? lockFile.harnesses ?? ["universal"];
|
|
22977
|
-
const validIds = getHarnessIds();
|
|
23013
|
+
const validIds = [...getHarnessIds(), ...harnessWriters.map((w2) => w2.id)];
|
|
23014
|
+
const uniqueValidIds = [...new Set(validIds)];
|
|
22978
23015
|
for (const id of ids) {
|
|
22979
|
-
if (!
|
|
23016
|
+
if (!uniqueValidIds.includes(id)) {
|
|
22980
23017
|
throw new Error(
|
|
22981
|
-
`Unknown harness "${id}". Available: ${
|
|
23018
|
+
`Unknown harness "${id}". Available: ${uniqueValidIds.join(", ")}`
|
|
22982
23019
|
);
|
|
22983
23020
|
}
|
|
22984
23021
|
}
|
|
22985
23022
|
const logicalConfig = lockFile.logical_config;
|
|
22986
23023
|
for (const id of ids) {
|
|
22987
|
-
const writer = getHarnessWriter(id);
|
|
23024
|
+
const writer = harnessWriters.find((w2) => w2.id === id) ?? getHarnessWriter(id);
|
|
22988
23025
|
if (writer) {
|
|
22989
23026
|
await writer.install(logicalConfig, projectRoot);
|
|
22990
23027
|
}
|
|
@@ -23023,15 +23060,62 @@ To use the latest defaults, remove .ade/skills/ and re-run install.`
|
|
|
23023
23060
|
Gt("Install complete!");
|
|
23024
23061
|
}
|
|
23025
23062
|
|
|
23063
|
+
// src/extensions.ts
|
|
23064
|
+
import { access as access3 } from "fs/promises";
|
|
23065
|
+
import { join as join19 } from "path";
|
|
23066
|
+
import { pathToFileURL } from "url";
|
|
23067
|
+
var SEARCH_ORDER = [
|
|
23068
|
+
"ade.extensions.ts",
|
|
23069
|
+
"ade.extensions.mjs",
|
|
23070
|
+
"ade.extensions.js"
|
|
23071
|
+
];
|
|
23072
|
+
async function loadExtensions(projectRoot) {
|
|
23073
|
+
for (const filename of SEARCH_ORDER) {
|
|
23074
|
+
const filePath = join19(projectRoot, filename);
|
|
23075
|
+
if (!await fileExists(filePath)) continue;
|
|
23076
|
+
const mod = await loadModule(filePath, filename);
|
|
23077
|
+
const raw = mod?.default ?? mod;
|
|
23078
|
+
const result = AdeExtensionsSchema.safeParse(raw);
|
|
23079
|
+
if (!result.success) {
|
|
23080
|
+
throw new Error(
|
|
23081
|
+
`Invalid ade.extensions file at ${filePath}:
|
|
23082
|
+
${result.error.message}`
|
|
23083
|
+
);
|
|
23084
|
+
}
|
|
23085
|
+
return result.data;
|
|
23086
|
+
}
|
|
23087
|
+
return {};
|
|
23088
|
+
}
|
|
23089
|
+
async function fileExists(filePath) {
|
|
23090
|
+
try {
|
|
23091
|
+
await access3(filePath);
|
|
23092
|
+
return true;
|
|
23093
|
+
} catch {
|
|
23094
|
+
return false;
|
|
23095
|
+
}
|
|
23096
|
+
}
|
|
23097
|
+
async function loadModule(filePath, filename) {
|
|
23098
|
+
if (filename.endsWith(".ts")) {
|
|
23099
|
+
const { createJiti } = await import("jiti");
|
|
23100
|
+
const jiti = createJiti(import.meta.url);
|
|
23101
|
+
return jiti.import(filePath);
|
|
23102
|
+
}
|
|
23103
|
+
return import(pathToFileURL(filePath).href);
|
|
23104
|
+
}
|
|
23105
|
+
|
|
23026
23106
|
// src/index.ts
|
|
23027
23107
|
var args = process.argv.slice(2);
|
|
23028
23108
|
var command = args[0];
|
|
23029
23109
|
if (command === "setup") {
|
|
23030
23110
|
const projectRoot = args[1] ?? process.cwd();
|
|
23031
|
-
const
|
|
23032
|
-
|
|
23111
|
+
const extensions = await loadExtensions(projectRoot);
|
|
23112
|
+
const catalog = mergeExtensions(getDefaultCatalog(), extensions);
|
|
23113
|
+
const harnessWriters = buildHarnessWriters(extensions);
|
|
23114
|
+
await runSetup(projectRoot, catalog, harnessWriters);
|
|
23033
23115
|
} else if (command === "install") {
|
|
23034
23116
|
const projectRoot = args[1] ?? process.cwd();
|
|
23117
|
+
const extensions = await loadExtensions(projectRoot);
|
|
23118
|
+
const harnessWriters = buildHarnessWriters(extensions);
|
|
23035
23119
|
let harnessIds;
|
|
23036
23120
|
if (args.includes("--harness")) {
|
|
23037
23121
|
const val = args[args.indexOf("--harness") + 1];
|
|
@@ -23039,7 +23123,7 @@ if (command === "setup") {
|
|
|
23039
23123
|
harnessIds = val.split(",").map((s) => s.trim());
|
|
23040
23124
|
}
|
|
23041
23125
|
}
|
|
23042
|
-
await runInstall(projectRoot, harnessIds);
|
|
23126
|
+
await runInstall(projectRoot, harnessIds, harnessWriters);
|
|
23043
23127
|
} else if (command === "--version" || command === "-v") {
|
|
23044
23128
|
console.log(version);
|
|
23045
23129
|
} else {
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
"@clack/prompts": "^1.1.0",
|
|
29
29
|
"@codemcp/ade-core": "workspace:*",
|
|
30
30
|
"@codemcp/ade-harnesses": "workspace:*",
|
|
31
|
-
"
|
|
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.
|
|
44
|
+
"version": "0.6.0"
|
|
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 (!
|
|
33
|
+
if (!uniqueValidIds.includes(id)) {
|
|
30
34
|
throw new Error(
|
|
31
|
-
`Unknown harness "${id}". Available: ${
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|