@codemcp/ade 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.beads/issues.jsonl +14 -0
  2. package/.beads/last-touched +1 -1
  3. package/.vibe/beads-state-ade-main-iazal7.json +29 -0
  4. package/.vibe/development-plan-extensibility.md +169 -0
  5. package/ade.extensions.mjs +66 -0
  6. package/docs/adr/0002-extension-file-type-safety.md +97 -0
  7. package/docs/guide/extensions.md +187 -0
  8. package/package.json +3 -2
  9. package/packages/cli/dist/index.js +166 -32
  10. package/packages/cli/package.json +4 -2
  11. package/packages/cli/src/commands/extensions.integration.spec.ts +122 -0
  12. package/packages/cli/src/commands/install.spec.ts +21 -1
  13. package/packages/cli/src/commands/install.ts +10 -5
  14. package/packages/cli/src/commands/setup.ts +8 -4
  15. package/packages/cli/src/extensions.spec.ts +128 -0
  16. package/packages/cli/src/extensions.ts +71 -0
  17. package/packages/cli/src/index.ts +10 -5
  18. package/packages/core/package.json +3 -2
  19. package/packages/core/src/catalog/facets/process.ts +10 -1
  20. package/packages/core/src/catalog/index.ts +38 -1
  21. package/packages/core/src/extensions.spec.ts +169 -0
  22. package/packages/core/src/index.ts +3 -1
  23. package/packages/core/src/registry.ts +3 -2
  24. package/packages/core/src/resolver.spec.ts +29 -0
  25. package/packages/core/src/types.ts +71 -0
  26. package/packages/core/src/writers/mcp-server.spec.ts +62 -0
  27. package/packages/core/src/writers/mcp-server.ts +25 -0
  28. package/packages/core/src/writers/workflows.spec.ts +22 -0
  29. package/packages/core/src/writers/workflows.ts +5 -2
  30. package/packages/harnesses/package.json +1 -1
  31. package/packages/harnesses/src/index.spec.ts +48 -1
  32. package/packages/harnesses/src/index.ts +10 -0
  33. package/packages/harnesses/src/writers/copilot.spec.ts +2 -6
  34. package/packages/harnesses/src/writers/copilot.ts +2 -9
  35. package/packages/harnesses/src/writers/kiro.spec.ts +32 -0
  36. package/packages/harnesses/src/writers/kiro.ts +22 -5
  37. package/packages/harnesses/src/writers/opencode.spec.ts +66 -0
  38. package/packages/harnesses/src/writers/opencode.ts +30 -3
  39. package/pnpm-workspace.yaml +2 -0
  40. /package/docs/{adrs → adr}/0001-tui-framework-selection.md +0 -0
@@ -11805,14 +11805,34 @@ var instructionWriter = {
11805
11805
  var workflowsWriter = {
11806
11806
  id: "workflows",
11807
11807
  async write(config) {
11808
- const { package: pkg, ref, env: env2 } = config;
11808
+ const { package: pkg, ref, env: env2, allowedTools } = config;
11809
11809
  return {
11810
11810
  mcp_servers: [
11811
11811
  {
11812
11812
  ref: ref ?? pkg,
11813
11813
  command: "npx",
11814
11814
  args: [pkg],
11815
- env: env2 ?? {}
11815
+ env: env2 ?? {},
11816
+ ...allowedTools !== void 0 ? { allowedTools } : {}
11817
+ }
11818
+ ]
11819
+ };
11820
+ }
11821
+ };
11822
+
11823
+ // ../core/dist/writers/mcp-server.js
11824
+ var mcpServerWriter = {
11825
+ id: "mcp-server",
11826
+ async write(config) {
11827
+ const { ref, command: command2, args: args2, env: env2, allowedTools } = config;
11828
+ return {
11829
+ mcp_servers: [
11830
+ {
11831
+ ref,
11832
+ command: command2,
11833
+ args: args2,
11834
+ env: env2 ?? {},
11835
+ ...allowedTools !== void 0 ? { allowedTools } : {}
11816
11836
  }
11817
11837
  ]
11818
11838
  };
@@ -11882,12 +11902,13 @@ function createDefaultRegistry() {
11882
11902
  const registry2 = createRegistry();
11883
11903
  registerProvisionWriter(registry2, instructionWriter);
11884
11904
  registerProvisionWriter(registry2, workflowsWriter);
11905
+ registerProvisionWriter(registry2, mcpServerWriter);
11885
11906
  registerProvisionWriter(registry2, skillsWriter);
11886
11907
  registerProvisionWriter(registry2, knowledgeWriter);
11887
11908
  registerProvisionWriter(registry2, gitHooksWriter);
11888
11909
  registerProvisionWriter(registry2, setupNoteWriter);
11889
11910
  registerProvisionWriter(registry2, permissionPolicyWriter);
11890
- for (const id of ["mcp-server", "installable"]) {
11911
+ for (const id of ["installable"]) {
11891
11912
  registerProvisionWriter(registry2, {
11892
11913
  id,
11893
11914
  write: async () => ({})
@@ -11912,7 +11933,16 @@ var processFacet = {
11912
11933
  writer: "workflows",
11913
11934
  config: {
11914
11935
  package: "@codemcp/workflows-server@latest",
11915
- ref: "workflows"
11936
+ ref: "workflows",
11937
+ env: {
11938
+ VIBE_WORKFLOW_DOMAINS: "skilled"
11939
+ },
11940
+ allowedTools: [
11941
+ "whats_next",
11942
+ "conduct_review",
11943
+ "list_workflows",
11944
+ "get_tool_info"
11945
+ ]
11916
11946
  }
11917
11947
  },
11918
11948
  {
@@ -12763,6 +12793,22 @@ function getVisibleOptions(facet, choices, catalog) {
12763
12793
  return option.available(deps);
12764
12794
  });
12765
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
+ }
12766
12812
 
12767
12813
  // ../core/dist/resolver.js
12768
12814
  async function resolve(userConfig, catalog, registry2) {
@@ -12918,6 +12964,23 @@ function collectDocsets(choices, catalog) {
12918
12964
  return Array.from(seen.values());
12919
12965
  }
12920
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
+
12921
12984
  // ../harnesses/dist/skills-installer.js
12922
12985
  import { join as join8 } from "path";
12923
12986
 
@@ -18050,7 +18113,7 @@ var V2 = "[";
18050
18113
  var nD = "]";
18051
18114
  var G2 = "m";
18052
18115
  var _2 = `${nD}8;;`;
18053
- var z3 = (e2) => `${d.values().next().value}${V2}${e2}${G2}`;
18116
+ var z4 = (e2) => `${d.values().next().value}${V2}${e2}${G2}`;
18054
18117
  var K2 = (e2) => `${d.values().next().value}${_2}${e2}${y3}`;
18055
18118
  var aD = (e2) => e2.split(" ").map((u2) => p(u2));
18056
18119
  var k2 = (e2, u2, t2) => {
@@ -18111,8 +18174,8 @@ var lD = (e2, u2, t2 = {}) => {
18111
18174
  }
18112
18175
  const o2 = ED.codes.get(Number(s));
18113
18176
  n[E + 1] === `
18114
- ` ? (i && (F2 += K2("")), s && o2 && (F2 += z3(o2))) : a === `
18115
- ` && (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)));
18116
18179
  }
18117
18180
  return F2;
18118
18181
  };
@@ -22295,13 +22358,7 @@ function getBuiltInTools(profile) {
22295
22358
  }
22296
22359
  }
22297
22360
  function getForwardedMcpTools(servers) {
22298
- return servers.flatMap((server) => {
22299
- const allowedTools = server.allowedTools ?? ["*"];
22300
- if (allowedTools.includes("*")) {
22301
- return [`${server.ref}/*`];
22302
- }
22303
- return allowedTools.map((tool) => `${server.ref}/${tool}`);
22304
- });
22361
+ return servers.map((server) => `${server.ref}/*`);
22305
22362
  }
22306
22363
  function renderCopilotAgentMcpServers(servers) {
22307
22364
  if (servers.length === 0) {
@@ -22313,7 +22370,7 @@ function renderCopilotAgentMcpServers(servers) {
22313
22370
  lines.push(" type: stdio");
22314
22371
  lines.push(` command: ${JSON.stringify(server.command)}`);
22315
22372
  lines.push(` args: ${JSON.stringify(server.args)}`);
22316
- lines.push(` tools: ${JSON.stringify(server.allowedTools ?? ["*"])}`);
22373
+ lines.push(` tools: ${JSON.stringify(["*"])}`);
22317
22374
  if (Object.keys(server.env).length > 0) {
22318
22375
  lines.push(" env:");
22319
22376
  for (const [key, value] of Object.entries(server.env)) {
@@ -22460,20 +22517,21 @@ var kiroWriter = {
22460
22517
  })
22461
22518
  });
22462
22519
  const tools = getKiroTools(getAutonomyProfile(config), config.mcp_servers);
22520
+ const allowedTools = getKiroAllowedTools(getAutonomyProfile(config), config.mcp_servers);
22463
22521
  await writeJson(join17(projectRoot, ".kiro", "agents", "ade.json"), {
22464
22522
  name: "ade",
22465
22523
  description: "ADE \u2014 Agentic Development Environment agent with project conventions and tools.",
22466
22524
  prompt: config.instructions.join("\n\n") || "ADE \u2014 Agentic Development Environment agent.",
22467
22525
  mcpServers: getKiroAgentMcpServers(config.mcp_servers),
22468
22526
  tools,
22469
- allowedTools: tools,
22527
+ allowedTools,
22470
22528
  useLegacyMcpJson: true
22471
22529
  });
22472
22530
  await writeGitHooks(config.git_hooks, projectRoot);
22473
22531
  }
22474
22532
  };
22475
22533
  function getKiroTools(profile, servers) {
22476
- const mcpTools = getKiroForwardedMcpTools(servers);
22534
+ const mcpTools = servers.map((server) => `@${server.ref}/*`);
22477
22535
  switch (profile) {
22478
22536
  case "rigid":
22479
22537
  return ["read", "shell", "spec", ...mcpTools];
@@ -22485,14 +22543,24 @@ function getKiroTools(profile, servers) {
22485
22543
  return ["read", "write", "shell", "spec", ...mcpTools];
22486
22544
  }
22487
22545
  }
22488
- function getKiroForwardedMcpTools(servers) {
22489
- return servers.flatMap((server) => {
22546
+ function getKiroAllowedTools(profile, servers) {
22547
+ const mcpAllowedTools = servers.flatMap((server) => {
22490
22548
  const allowedTools = server.allowedTools ?? ["*"];
22491
22549
  if (allowedTools.includes("*")) {
22492
22550
  return [`@${server.ref}/*`];
22493
22551
  }
22494
22552
  return allowedTools.map((tool) => `@${server.ref}/${tool}`);
22495
22553
  });
22554
+ switch (profile) {
22555
+ case "rigid":
22556
+ return ["read", "shell", "spec", ...mcpAllowedTools];
22557
+ case "sensible-defaults":
22558
+ return ["read", "write", "shell", "spec", ...mcpAllowedTools];
22559
+ case "max-autonomy":
22560
+ return ["read", "write", "shell(*)", "spec", ...mcpAllowedTools];
22561
+ default:
22562
+ return ["read", "write", "shell", "spec", ...mcpAllowedTools];
22563
+ }
22496
22564
  }
22497
22565
  function getKiroAgentMcpServers(servers) {
22498
22566
  return Object.fromEntries(servers.map((server) => [
@@ -22630,6 +22698,19 @@ var MAX_AUTONOMY_RULES = {
22630
22698
  codesearch: "ask",
22631
22699
  doom_loop: "deny"
22632
22700
  };
22701
+ function getMcpPermissions(servers) {
22702
+ const entries = servers.flatMap((server) => {
22703
+ const allowedTools = server.allowedTools ?? ["*"];
22704
+ if (allowedTools.includes("*")) {
22705
+ return [[`${server.ref}*`, "allow"]];
22706
+ }
22707
+ return [
22708
+ [`${server.ref}*`, "ask"],
22709
+ ...allowedTools.map((tool) => [`${server.ref}_${tool}`, "allow"])
22710
+ ];
22711
+ });
22712
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
22713
+ }
22633
22714
  function getPermissionRules(profile) {
22634
22715
  switch (profile) {
22635
22716
  case "rigid":
@@ -22658,9 +22739,11 @@ var opencodeWriter = {
22658
22739
  defaults: { $schema: "https://opencode.ai/config.json" }
22659
22740
  });
22660
22741
  const permission = getPermissionRules(getAutonomyProfile(config));
22742
+ const mcpPermissions = getMcpPermissions(config.mcp_servers);
22743
+ const mergedPermission = permission || mcpPermissions ? { ...mcpPermissions ?? {}, ...permission ?? {} } : void 0;
22661
22744
  await writeAgentMd(config, {
22662
22745
  path: join18(projectRoot, ".opencode", "agents", "ade.md"),
22663
- extraFrontmatter: permission ? renderYamlMapping("permission", permission) : void 0,
22746
+ extraFrontmatter: mergedPermission ? renderYamlMapping("permission", mergedPermission) : void 0,
22664
22747
  fallbackBody: "ADE \u2014 Agentic Development Environment agent with project conventions and tools."
22665
22748
  });
22666
22749
  await writeGitHooks(config.git_hooks, projectRoot);
@@ -22697,9 +22780,12 @@ function getHarnessWriter(id) {
22697
22780
  function getHarnessIds() {
22698
22781
  return allHarnessWriters.map((w2) => w2.id);
22699
22782
  }
22783
+ function buildHarnessWriters(extensions) {
22784
+ return [...allHarnessWriters, ...extensions.harnessWriters ?? []];
22785
+ }
22700
22786
 
22701
22787
  // src/commands/setup.ts
22702
- async function runSetup(projectRoot, catalog) {
22788
+ async function runSetup(projectRoot, catalog, harnessWriters = allHarnessWriters) {
22703
22789
  let lineIndex = 0;
22704
22790
  const LOGO_LINES = [
22705
22791
  "\n",
@@ -22793,13 +22879,13 @@ async function runSetup(projectRoot, catalog) {
22793
22879
  }
22794
22880
  }
22795
22881
  const existingHarnesses = existingConfig?.harnesses;
22796
- const harnessOptions = allHarnessWriters.map((w2) => ({
22882
+ const harnessOptions = harnessWriters.map((w2) => ({
22797
22883
  value: w2.id,
22798
22884
  label: w2.label,
22799
22885
  hint: w2.description
22800
22886
  }));
22801
22887
  const validExistingHarnesses = existingHarnesses?.filter(
22802
- (h3) => allHarnessWriters.some((w2) => w2.id === h3)
22888
+ (h3) => harnessWriters.some((w2) => w2.id === h3)
22803
22889
  );
22804
22890
  const selectedHarnesses = await Lt2({
22805
22891
  message: "Which coding agents should receive config?\nADE generates config files for each agent you select.\n",
@@ -22829,7 +22915,7 @@ async function runSetup(projectRoot, catalog) {
22829
22915
  };
22830
22916
  await writeLockFile(projectRoot, lockFile);
22831
22917
  for (const harnessId of harnesses) {
22832
- const writer = getHarnessWriter(harnessId);
22918
+ const writer = harnessWriters.find((w2) => w2.id === harnessId) ?? getHarnessWriter(harnessId);
22833
22919
  if (writer) {
22834
22920
  await writer.install(logicalConfig, projectRoot);
22835
22921
  }
@@ -22917,24 +23003,25 @@ function promptMultiSelect(facet, existingChoices) {
22917
23003
  }
22918
23004
 
22919
23005
  // src/commands/install.ts
22920
- async function runInstall(projectRoot, harnessIds) {
23006
+ async function runInstall(projectRoot, harnessIds, harnessWriters = allHarnessWriters) {
22921
23007
  Wt2("ade install");
22922
23008
  const lockFile = await readLockFile(projectRoot);
22923
23009
  if (!lockFile) {
22924
23010
  throw new Error("config.lock.yaml not found. Run `ade setup` first.");
22925
23011
  }
22926
23012
  const ids = harnessIds ?? lockFile.harnesses ?? ["universal"];
22927
- const validIds = getHarnessIds();
23013
+ const validIds = [...getHarnessIds(), ...harnessWriters.map((w2) => w2.id)];
23014
+ const uniqueValidIds = [...new Set(validIds)];
22928
23015
  for (const id of ids) {
22929
- if (!validIds.includes(id)) {
23016
+ if (!uniqueValidIds.includes(id)) {
22930
23017
  throw new Error(
22931
- `Unknown harness "${id}". Available: ${validIds.join(", ")}`
23018
+ `Unknown harness "${id}". Available: ${uniqueValidIds.join(", ")}`
22932
23019
  );
22933
23020
  }
22934
23021
  }
22935
23022
  const logicalConfig = lockFile.logical_config;
22936
23023
  for (const id of ids) {
22937
- const writer = getHarnessWriter(id);
23024
+ const writer = harnessWriters.find((w2) => w2.id === id) ?? getHarnessWriter(id);
22938
23025
  if (writer) {
22939
23026
  await writer.install(logicalConfig, projectRoot);
22940
23027
  }
@@ -22973,15 +23060,62 @@ To use the latest defaults, remove .ade/skills/ and re-run install.`
22973
23060
  Gt("Install complete!");
22974
23061
  }
22975
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
+
22976
23106
  // src/index.ts
22977
23107
  var args = process.argv.slice(2);
22978
23108
  var command = args[0];
22979
23109
  if (command === "setup") {
22980
23110
  const projectRoot = args[1] ?? process.cwd();
22981
- const catalog = getDefaultCatalog();
22982
- 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);
22983
23115
  } else if (command === "install") {
22984
23116
  const projectRoot = args[1] ?? process.cwd();
23117
+ const extensions = await loadExtensions(projectRoot);
23118
+ const harnessWriters = buildHarnessWriters(extensions);
22985
23119
  let harnessIds;
22986
23120
  if (args.includes("--harness")) {
22987
23121
  const val = args[args.indexOf("--harness") + 1];
@@ -22989,7 +23123,7 @@ if (command === "setup") {
22989
23123
  harnessIds = val.split(",").map((s) => s.trim());
22990
23124
  }
22991
23125
  }
22992
- await runInstall(projectRoot, harnessIds);
23126
+ await runInstall(projectRoot, harnessIds, harnessWriters);
22993
23127
  } else if (command === "--version" || command === "-v") {
22994
23128
  console.log(version);
22995
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.4.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
  }