@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.
@@ -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 z3 = (e2) => `${d.values().next().value}${V2}${e2}${G2}`;
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 += z3(o2))) : a === `
18145
- ` && (s && o2 && (F2 += z3(s)), i && (F2 += K2(i)));
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 = allHarnessWriters.map((w2) => ({
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) => allHarnessWriters.some((w2) => w2.id === 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 (!validIds.includes(id)) {
23016
+ if (!uniqueValidIds.includes(id)) {
22980
23017
  throw new Error(
22981
- `Unknown harness "${id}". Available: ${validIds.join(", ")}`
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 catalog = getDefaultCatalog();
23032
- await runSetup(projectRoot, catalog);
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
- "yaml": "^2.8.2"
31
+ "jiti": "2.6.1",
32
+ "yaml": "^2.8.2",
33
+ "zod": "catalog:"
32
34
  },
33
35
  "devDependencies": {
34
36
  "@codemcp/knowledge": "2.1.0",
@@ -39,5 +41,5 @@
39
41
  "typescript": "catalog:",
40
42
  "vitest": "catalog:"
41
43
  },
42
- "version": "0.5.0"
44
+ "version": "0.6.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 (!validIds.includes(id)) {
33
+ if (!uniqueValidIds.includes(id)) {
30
34
  throw new Error(
31
- `Unknown harness "${id}". Available: ${validIds.join(", ")}`
35
+ `Unknown harness "${id}". Available: ${uniqueValidIds.join(", ")}`
32
36
  );
33
37
  }
34
38
  }
@@ -36,7 +40,8 @@ export async function runInstall(
36
40
  const logicalConfig = lockFile.logical_config;
37
41
 
38
42
  for (const id of ids) {
39
- const writer = getHarnessWriter(id);
43
+ const writer =
44
+ harnessWriters.find((w) => w.id === id) ?? getHarnessWriter(id);
40
45
  if (writer) {
41
46
  await writer.install(logicalConfig, projectRoot);
42
47
  }
@@ -16,6 +16,7 @@ import {
16
16
  getVisibleOptions
17
17
  } from "@codemcp/ade-core";
18
18
  import {
19
+ type HarnessWriter,
19
20
  allHarnessWriters,
20
21
  getHarnessWriter,
21
22
  installSkills,
@@ -24,7 +25,8 @@ import {
24
25
 
25
26
  export async function runSetup(
26
27
  projectRoot: string,
27
- catalog: Catalog
28
+ catalog: Catalog,
29
+ harnessWriters: HarnessWriter[] = allHarnessWriters
28
30
  ): Promise<void> {
29
31
  let lineIndex = 0;
30
32
  const LOGO_LINES = [
@@ -138,14 +140,14 @@ export async function runSetup(
138
140
 
139
141
  // Harness selection — multi-select from all available harnesses
140
142
  const existingHarnesses = existingConfig?.harnesses;
141
- const harnessOptions = allHarnessWriters.map((w) => ({
143
+ const harnessOptions = harnessWriters.map((w) => ({
142
144
  value: w.id,
143
145
  label: w.label,
144
146
  hint: w.description
145
147
  }));
146
148
 
147
149
  const validExistingHarnesses = existingHarnesses?.filter((h) =>
148
- allHarnessWriters.some((w) => w.id === h)
150
+ harnessWriters.some((w) => w.id === h)
149
151
  );
150
152
 
151
153
  const selectedHarnesses = await clack.multiselect({
@@ -188,7 +190,9 @@ export async function runSetup(
188
190
 
189
191
  // Install to all selected harnesses
190
192
  for (const harnessId of harnesses) {
191
- const writer = getHarnessWriter(harnessId);
193
+ const writer =
194
+ harnessWriters.find((w) => w.id === harnessId) ??
195
+ getHarnessWriter(harnessId);
192
196
  if (writer) {
193
197
  await writer.install(logicalConfig, projectRoot);
194
198
  }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { loadExtensions } from "./extensions.js";
3
+ import { tmpdir } from "node:os";
4
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ describe("loadExtensions", () => {
8
+ it("returns an empty object when no extensions file exists", async () => {
9
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
10
+ try {
11
+ const result = await loadExtensions(dir);
12
+ expect(result).toEqual({});
13
+ } finally {
14
+ await rm(dir, { recursive: true });
15
+ }
16
+ });
17
+
18
+ it("loads and validates a valid .mjs extensions file", async () => {
19
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
20
+ try {
21
+ await writeFile(
22
+ join(dir, "ade.extensions.mjs"),
23
+ `export default {
24
+ facetContributions: {
25
+ architecture: [
26
+ {
27
+ id: "sap",
28
+ label: "SAP BTP / ABAP",
29
+ description: "SAP BTP ABAP development",
30
+ recipe: [{ writer: "skills", config: { skills: [] } }]
31
+ }
32
+ ]
33
+ }
34
+ };`
35
+ );
36
+ const result = await loadExtensions(dir);
37
+ expect(result.facetContributions?.architecture).toHaveLength(1);
38
+ expect(result.facetContributions?.architecture?.[0].id).toBe("sap");
39
+ } finally {
40
+ await rm(dir, { recursive: true });
41
+ }
42
+ });
43
+
44
+ it("throws a descriptive error when the extensions file exports an invalid shape", async () => {
45
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
46
+ try {
47
+ await writeFile(
48
+ join(dir, "ade.extensions.mjs"),
49
+ `export default { facetContributions: "not-an-object" };`
50
+ );
51
+ await expect(loadExtensions(dir)).rejects.toThrow(/invalid/i);
52
+ } finally {
53
+ await rm(dir, { recursive: true });
54
+ }
55
+ });
56
+
57
+ it("loads a .js file when only .js exists", async () => {
58
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
59
+ try {
60
+ await writeFile(
61
+ join(dir, "ade.extensions.js"),
62
+ `export default {
63
+ facets: [
64
+ {
65
+ id: "js-only-facet",
66
+ label: "JS Only",
67
+ description: "From .js fallback",
68
+ required: false,
69
+ options: []
70
+ }
71
+ ]
72
+ };`
73
+ );
74
+ const result = await loadExtensions(dir);
75
+ expect(result.facets).toHaveLength(1);
76
+ expect(result.facets?.[0].id).toBe("js-only-facet");
77
+ } finally {
78
+ await rm(dir, { recursive: true });
79
+ }
80
+ });
81
+
82
+ it("prefers .mjs over .js when both exist", async () => {
83
+ const dir = await mkdtemp(join(tmpdir(), "ade-ext-test-"));
84
+ try {
85
+ await writeFile(
86
+ join(dir, "ade.extensions.mjs"),
87
+ `export default { facets: [{ id: "from-mjs", label: "MJS", description: "MJS wins", required: false, options: [] }] };`
88
+ );
89
+ await writeFile(
90
+ join(dir, "ade.extensions.js"),
91
+ `export default { facets: [{ id: "from-js", label: "JS", description: "JS loses", required: false, options: [] }] };`
92
+ );
93
+ const result = await loadExtensions(dir);
94
+ expect(result.facets?.[0].id).toBe("from-mjs");
95
+ } finally {
96
+ await rm(dir, { recursive: true });
97
+ }
98
+ });
99
+
100
+ it("loads from an absolute path — simulating npx run from a different cwd", async () => {
101
+ // This is the published-package scenario:
102
+ // The CLI binary lives in ~/.npm/_npx/... but projectRoot is the user's cwd.
103
+ // loadExtensions(projectRoot) must look in projectRoot, not in the CLI package dir.
104
+ const userProjectDir = await mkdtemp(join(tmpdir(), "ade-user-project-"));
105
+ const cliPackageDir = await mkdtemp(join(tmpdir(), "ade-cli-package-"));
106
+ try {
107
+ // Simulate: user project has ade.extensions.mjs
108
+ await writeFile(
109
+ join(userProjectDir, "ade.extensions.mjs"),
110
+ `export default {
111
+ facets: [{ id: "user-project-facet", label: "User", description: "From user project", required: false, options: [] }]
112
+ };`
113
+ );
114
+ // CLI package dir has no extension file (it shouldn't be used)
115
+
116
+ // loadExtensions is called with the user's project dir as projectRoot
117
+ const result = await loadExtensions(userProjectDir);
118
+ expect(result.facets?.[0].id).toBe("user-project-facet");
119
+
120
+ // CLI package dir produces empty extensions — it is never consulted
121
+ const cliResult = await loadExtensions(cliPackageDir);
122
+ expect(cliResult).toEqual({});
123
+ } finally {
124
+ await rm(userProjectDir, { recursive: true });
125
+ await rm(cliPackageDir, { recursive: true });
126
+ }
127
+ });
128
+ });
@@ -0,0 +1,71 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { AdeExtensionsSchema, type AdeExtensions } from "@codemcp/ade-core";
5
+
6
+ const SEARCH_ORDER = [
7
+ "ade.extensions.ts",
8
+ "ade.extensions.mjs",
9
+ "ade.extensions.js"
10
+ ] as const;
11
+
12
+ /**
13
+ * Loads and validates the project's `ade.extensions` file (if any).
14
+ *
15
+ * Search order: ade.extensions.ts → ade.extensions.mjs → ade.extensions.js
16
+ *
17
+ * - `.ts` files are loaded via `jiti` for TypeScript support.
18
+ * - `.mjs` / `.js` files are loaded via native dynamic `import()`.
19
+ * - Returns `{}` when no extensions file exists.
20
+ * - Throws with a descriptive message when the file exports an invalid shape.
21
+ */
22
+ export async function loadExtensions(
23
+ projectRoot: string
24
+ ): Promise<AdeExtensions> {
25
+ for (const filename of SEARCH_ORDER) {
26
+ const filePath = join(projectRoot, filename);
27
+
28
+ if (!(await fileExists(filePath))) continue;
29
+
30
+ // eslint-disable-next-line no-await-in-loop
31
+ const mod = await loadModule(filePath, filename);
32
+ const raw = mod?.default ?? mod;
33
+
34
+ const result = AdeExtensionsSchema.safeParse(raw);
35
+ if (!result.success) {
36
+ throw new Error(
37
+ `Invalid ade.extensions file at ${filePath}:\n${result.error.message}`
38
+ );
39
+ }
40
+
41
+ return result.data;
42
+ }
43
+
44
+ return {};
45
+ }
46
+
47
+ async function fileExists(filePath: string): Promise<boolean> {
48
+ try {
49
+ await access(filePath);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ async function loadModule(
57
+ filePath: string,
58
+ filename: string
59
+ ): Promise<Record<string, unknown>> {
60
+ if (filename.endsWith(".ts")) {
61
+ // Use jiti for TypeScript support
62
+ const { createJiti } = await import("jiti");
63
+ const jiti = createJiti(import.meta.url);
64
+ return jiti.import(filePath) as Promise<Record<string, unknown>>;
65
+ }
66
+
67
+ // Native ESM for .mjs / .js
68
+ return import(pathToFileURL(filePath).href) as Promise<
69
+ Record<string, unknown>
70
+ >;
71
+ }