portawhip 0.1.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 (105) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/SECURITY.md +15 -0
  5. package/VISION.md +281 -0
  6. package/adapters/hooks/hook-stub.mjs +21 -0
  7. package/adapters/hooks/hook-stub.test.mjs +43 -0
  8. package/adapters/hooks/universal-hook.mjs +348 -0
  9. package/adapters/hooks/universal-hook.test.mjs +294 -0
  10. package/adapters/instructions/generate.mjs +114 -0
  11. package/core/fixtures/bad-recipe.yaml +5 -0
  12. package/core/fixtures/capability-graph.json +10 -0
  13. package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
  14. package/core/registry/capability-docs.mjs +145 -0
  15. package/core/registry/capability-graph-compiler.mjs +90 -0
  16. package/core/registry/capability-graph.mjs +53 -0
  17. package/core/registry/capability-kind.mjs +9 -0
  18. package/core/registry/cli-enrich.mjs +316 -0
  19. package/core/registry/cli-enrich.test.mjs +119 -0
  20. package/core/registry/discover.mjs +379 -0
  21. package/core/registry/enrich.mjs +213 -0
  22. package/core/registry/enrich.test.mjs +53 -0
  23. package/core/registry/eval-harvest.mjs +93 -0
  24. package/core/registry/eval-harvest.test.mjs +64 -0
  25. package/core/registry/registry.mjs +197 -0
  26. package/core/router/concept-vector.mjs +91 -0
  27. package/core/router/concept-vector.test.mjs +36 -0
  28. package/core/router/curated-trigger.test.mjs +28 -0
  29. package/core/router/dense-embedder.mjs +242 -0
  30. package/core/router/fusion.mjs +18 -0
  31. package/core/router/hybrid-router.mjs +375 -0
  32. package/core/router/intent-evidence.mjs +60 -0
  33. package/core/router/prompt-hygiene.mjs +31 -0
  34. package/core/router/route-entry.mjs +74 -0
  35. package/core/router/router-cli.mjs +218 -0
  36. package/core/router/router-cli.test.mjs +25 -0
  37. package/core/router/router-eval.mjs +166 -0
  38. package/core/router/router-live.test.mjs +89 -0
  39. package/core/router/router.test.mjs +838 -0
  40. package/core/router/scorer.mjs +72 -0
  41. package/core/router/sparse-retriever.mjs +79 -0
  42. package/core/router/tokenize.mjs +42 -0
  43. package/core/state/bundle-state.mjs +124 -0
  44. package/core/state/bundle-state.test.mjs +175 -0
  45. package/core/state/config.mjs +115 -0
  46. package/core/state/feedback.mjs +129 -0
  47. package/core/state/feedback.test.mjs +181 -0
  48. package/core/state/runtime-root.test.mjs +22 -0
  49. package/core/state/stack-detect.mjs +102 -0
  50. package/core/state/stack-detect.test.mjs +64 -0
  51. package/core/surface/config-sync-backends.mjs +224 -0
  52. package/core/surface/connector-targets.mjs +205 -0
  53. package/core/surface/discover-hooks.mjs +151 -0
  54. package/core/surface/discover-hooks.test.mjs +63 -0
  55. package/core/surface/discover-surface.test.mjs +49 -0
  56. package/core/surface/extra-hosts.mjs +48 -0
  57. package/core/surface/extra-hosts.test.mjs +24 -0
  58. package/core/surface/hook-targets.mjs +102 -0
  59. package/core/surface/surface-copy-targets.mjs +37 -0
  60. package/core/surface/surface-inventory.mjs +106 -0
  61. package/core/surface/surface-matrix.mjs +133 -0
  62. package/core/surface/surface-matrix.test.mjs +90 -0
  63. package/docs/router-eval-set.jsonl +38 -0
  64. package/hooks.manifest.yaml +16 -0
  65. package/package.json +101 -0
  66. package/recipe.yaml +247 -0
  67. package/recipes/foundry.yaml +27 -0
  68. package/recipes/manifest.yaml +18 -0
  69. package/recipes/roles/backend-data.yaml +26 -0
  70. package/recipes/roles/coding.yaml +26 -0
  71. package/recipes/roles/frontend.yaml +14 -0
  72. package/recipes/roles/research.yaml +31 -0
  73. package/recipes/roles/secure.yaml +27 -0
  74. package/router.config.yaml +120 -0
  75. package/scripts/bundles.mjs +131 -0
  76. package/scripts/doctor.mjs +117 -0
  77. package/scripts/embedded-hooks.mjs +27 -0
  78. package/scripts/hosts.mjs +60 -0
  79. package/scripts/link/link-connectors.mjs +190 -0
  80. package/scripts/link/link-connectors.test.mjs +111 -0
  81. package/scripts/link/link-hooks.mjs +259 -0
  82. package/scripts/link/link-hooks.test.mjs +112 -0
  83. package/scripts/link/link-surfaces.mjs +188 -0
  84. package/scripts/link/link-surfaces.test.mjs +76 -0
  85. package/scripts/load.mjs +143 -0
  86. package/scripts/package-contract.test.mjs +26 -0
  87. package/scripts/surface-inventory.mjs +6 -0
  88. package/scripts/sync/agents-surface.mjs +55 -0
  89. package/scripts/sync/agents-surface.test.mjs +18 -0
  90. package/scripts/sync/auto-sync.mjs +135 -0
  91. package/scripts/sync/auto-sync.test.mjs +41 -0
  92. package/scripts/sync/import-surfaces.mjs +331 -0
  93. package/scripts/sync/import-surfaces.test.mjs +106 -0
  94. package/scripts/sync/sync-config.mjs +230 -0
  95. package/scripts/sync/sync-config.test.mjs +141 -0
  96. package/scripts/sync/sync-surfaces.mjs +229 -0
  97. package/scripts/sync/sync-surfaces.test.mjs +66 -0
  98. package/scripts/tui-actions.mjs +59 -0
  99. package/scripts/tui-actions.test.mjs +53 -0
  100. package/scripts/tui.mjs +782 -0
  101. package/scripts/uninstall-all.mjs +39 -0
  102. package/server/mcp-server.mjs +129 -0
  103. package/server/mcp-server.test.mjs +175 -0
  104. package/skills/portawhip/SKILL.md +80 -0
  105. package/surface-matrix.yaml +93 -0
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ // Import direction (Phase S1): read what's actually installed across hosts
3
+ // (via the same core/discover.mjs the router already uses) and PROPOSE it as
4
+ // persistent, shareable canonical entries — CLI/skill into recipes/
5
+ // imported.yaml, MCP into .agents/agents.json's mcp.servers block (which
6
+ // @agents-dev/cli then fans out to other hosts).
7
+ //
8
+ // This owns no discovery or sync logic: discovery is discover.mjs, fan-out is
9
+ // the existing loader/agents-dotdir lanes. Import only diffs "installed
10
+ // somewhere" against "already canonical" and writes the gap where the user
11
+ // approves it. Same status -> preview -> apply gate as sync-config.mjs;
12
+ // silence (nothing new) is a valid, expected result.
13
+
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+ import yaml from "js-yaml";
17
+ import { readActiveSelection, resolveRecipePaths } from "../../core/state/bundle-state.mjs";
18
+ import { readRawEntries } from "../../core/registry/registry.mjs";
19
+ import { discoverAll } from "../../core/registry/discover.mjs";
20
+ import { enrichCliLadder } from "../../core/registry/cli-enrich.mjs";
21
+
22
+ const VALID_ACTIONS = new Set(["status", "preview", "apply"]);
23
+ const IMPORTED_RECIPE = ["cli", "skill", "command", "agent"]; // land in imported.yaml
24
+ const AGENTS_MCP = ["mcp"]; // land in .agents/agents.json
25
+ // Surfaces shown in full (every id listed) vs summarized (count + sample);
26
+ // nothing is hidden — large groups just don't flood the preview. No type is
27
+ // suppressed by default: display groups by surface, apply still needs an
28
+ // explicit selector for the big groups so nothing pours in unintentionally.
29
+ const FULL_LIST_TYPES = new Set(["cli", "mcp"]);
30
+ const SAMPLE = 6;
31
+
32
+ export function parseArgs(argv) {
33
+ const args = {
34
+ action: argv[2] ?? "status",
35
+ types: null,
36
+ include: null,
37
+ json: false,
38
+ allowApply: false,
39
+ };
40
+ for (let i = 3; i < argv.length; i += 1) {
41
+ const arg = argv[i];
42
+ if (arg === "--type" || arg === "--types") {
43
+ args.types = String(argv[i + 1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
44
+ i += 1;
45
+ } else if (arg === "--include") {
46
+ args.include = String(argv[i + 1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
47
+ i += 1;
48
+ } else if (arg === "--json") {
49
+ args.json = true;
50
+ } else if (arg === "--apply") {
51
+ args.allowApply = true;
52
+ } else {
53
+ throw new Error(`unknown argument: ${arg}`);
54
+ }
55
+ }
56
+ if (!VALID_ACTIONS.has(args.action)) {
57
+ throw new Error("usage: import-surfaces.mjs <status|preview|apply> [--type cli,mcp,skill,command,agent] [--include id,id] [--json] [--apply]");
58
+ }
59
+ if (args.action === "apply" && !args.allowApply) {
60
+ throw new Error("apply requires an explicit --apply flag; run preview first");
61
+ }
62
+ return args;
63
+ }
64
+
65
+ // Ids already canonical: curated recipe/bundle/imported entries + MCP servers
66
+ // already declared in .agents/agents.json. A candidate matching any of these
67
+ // is not "new" and is skipped.
68
+ export function knownIds({ root, recipePaths, agentsJson }) {
69
+ const ids = new Set();
70
+ for (const path of recipePaths) {
71
+ if (!existsSync(path)) continue;
72
+ for (const entry of readRawEntries(path)) if (entry.id) ids.add(entry.id);
73
+ }
74
+ const servers = agentsJson?.mcp?.servers ?? {};
75
+ for (const name of Object.keys(servers)) ids.add(name);
76
+ return ids;
77
+ }
78
+
79
+ // Pure: filter discovered entries down to the ones worth proposing. No type
80
+ // is suppressed by default (grouped display handles volume); an explicit
81
+ // --type or --include narrows it.
82
+ export function computeCandidates({ discovered, known, types, include }) {
83
+ const typeSet = types && types.length ? new Set(types) : null;
84
+ const includeSet = include && include.length ? new Set(include) : null;
85
+ return discovered.filter((entry) => {
86
+ if (known.has(entry.id)) return false;
87
+ if (includeSet) return includeSet.has(entry.id) || includeSet.has(`${entry.type}:${entry.id}`);
88
+ if (typeSet) return typeSet.has(entry.type);
89
+ return true;
90
+ });
91
+ }
92
+
93
+ // Pure: a discovered entry is "bare" if discovery gave it no real trigger
94
+ // surface beyond its own name (the dead-weight case the enrich ladder exists
95
+ // to fix). Used as the auto-junk gate for CLI import.
96
+ export function isBare(entry) {
97
+ const triggers = entry.route?.triggers ?? [];
98
+ const nonSelf = triggers.filter((t) => t !== entry.id && t !== entry.source);
99
+ return nonSelf.length === 0;
100
+ }
101
+
102
+ // Pure: shape a discovered entry into a recipe-file entry, recording
103
+ // provenance so a later forget/doctor can see it was imported (and when).
104
+ // `enrichment` (optional) is the CLI ladder result — when present its
105
+ // triggers/description win (promote bare -> useful). Returns null to HOLD
106
+ // BACK a still-bare CLI entry: the auto-junk gate that replaces manual
107
+ // approval (an entry that would only ever match its own name isn't worth
108
+ // fanning out to every host).
109
+ export function toRecipeEntry(entry, enrichment = null) {
110
+ const route = enrichment
111
+ ? {
112
+ triggers: enrichment.triggers,
113
+ description: enrichment.description,
114
+ when: entry.route?.when ?? ["user_prompt"],
115
+ inject: entry.route?.inject ?? "hint",
116
+ }
117
+ : {
118
+ triggers: entry.route?.triggers ?? [entry.id],
119
+ description: entry.route?.description ?? `${entry.type}: ${entry.id}`,
120
+ when: entry.route?.when ?? ["user_prompt"],
121
+ inject: entry.route?.inject ?? "hint",
122
+ };
123
+ if (entry.type === "cli" && !enrichment && isBare(entry)) return null;
124
+ const out = {
125
+ id: entry.id,
126
+ type: entry.type,
127
+ source: entry.source ?? entry.id,
128
+ route,
129
+ imported: {
130
+ at: new Date().toISOString(),
131
+ via: `discover:${entry.type}`,
132
+ ...(enrichment ? { enriched: enrichment.sources } : {}),
133
+ },
134
+ };
135
+ if (entry.path) out.path = entry.path;
136
+ return out;
137
+ }
138
+
139
+ // Pure: merge new recipe entries into an existing imported.yaml list,
140
+ // first-seen (existing) wins so re-running apply is idempotent.
141
+ export function mergeImported(existing, additions) {
142
+ const byId = new Map();
143
+ for (const entry of existing) byId.set(entry.id, entry);
144
+ for (const entry of additions) if (!byId.has(entry.id)) byId.set(entry.id, entry);
145
+ return [...byId.values()];
146
+ }
147
+
148
+ function readYamlList(path) {
149
+ if (!existsSync(path)) return [];
150
+ const raw = yaml.load(readFileSync(path, "utf8"));
151
+ return Array.isArray(raw) ? raw : [];
152
+ }
153
+
154
+ function readAgentsJson(root) {
155
+ const path = join(root, ".agents", "agents.json");
156
+ if (!existsSync(path)) return { path, json: null };
157
+ try {
158
+ return { path, json: JSON.parse(readFileSync(path, "utf8")) };
159
+ } catch {
160
+ return { path, json: null };
161
+ }
162
+ }
163
+
164
+ // MCP launch config isn't carried by discoverMcp (it dedups to id+source), so
165
+ // fetch it from add-mcp the same way core/enrich.mjs does, only for the
166
+ // servers actually being imported.
167
+ async function mcpConfigs(serverNames) {
168
+ if (!serverNames.length) return {};
169
+ const { listInstalledServers } = await import("add-mcp");
170
+ const hosts = await listInstalledServers({ global: true });
171
+ const configs = {};
172
+ for (const host of hosts) {
173
+ for (const server of host.servers ?? []) {
174
+ if (serverNames.includes(server.serverName) && server.config && !configs[server.serverName]) {
175
+ configs[server.serverName] = server.config;
176
+ }
177
+ }
178
+ }
179
+ return configs;
180
+ }
181
+
182
+ // Pure: add imported MCP servers into an agents.json object's mcp.servers.
183
+ export function mergeAgentsMcp(agentsJson, mcpEntries, configs) {
184
+ const json = agentsJson ? structuredClone(agentsJson) : { schemaVersion: 3, mcp: { servers: {} } };
185
+ json.mcp = json.mcp ?? {};
186
+ json.mcp.servers = json.mcp.servers ?? {};
187
+ const added = [];
188
+ for (const entry of mcpEntries) {
189
+ if (json.mcp.servers[entry.id]) continue;
190
+ const config = configs[entry.id];
191
+ if (!config) continue; // no launch config recoverable -> skip, report
192
+ json.mcp.servers[entry.id] = {
193
+ transport: config.url ? "http" : "stdio",
194
+ enabled: true,
195
+ description: entry.route?.description ?? `Imported MCP server: ${entry.id}`,
196
+ ...(config.url ? { url: config.url } : { command: config.command, args: config.args ?? [] }),
197
+ imported: { at: new Date().toISOString() },
198
+ };
199
+ added.push(entry.id);
200
+ }
201
+ return { json, added };
202
+ }
203
+
204
+ export async function collectImport({ root = resolve("."), action = "status", types = null, include = null } = {}) {
205
+ const selection = readActiveSelection(root);
206
+ const recipePaths = resolveRecipePaths(root, selection);
207
+ const { json: agentsJson, path: agentsPath } = readAgentsJson(root);
208
+ const known = knownIds({ root, recipePaths, agentsJson });
209
+ const discovered = await discoverAll();
210
+ const candidates = computeCandidates({ discovered, known, types, include });
211
+
212
+ const byLane = { recipe: [], agentsMcp: [] };
213
+ for (const entry of candidates) {
214
+ if (AGENTS_MCP.includes(entry.type)) byLane.agentsMcp.push(entry);
215
+ else if (IMPORTED_RECIPE.includes(entry.type)) byLane.recipe.push(entry);
216
+ }
217
+
218
+ const result = {
219
+ root,
220
+ action,
221
+ discoveredCount: discovered.length,
222
+ knownCount: known.size,
223
+ candidates: candidates.map((e) => ({ id: e.id, type: e.type })),
224
+ lanes: {
225
+ recipe: byLane.recipe.map((e) => e.id),
226
+ agentsMcp: byLane.agentsMcp.map((e) => e.id),
227
+ },
228
+ applied: null,
229
+ };
230
+
231
+ // Group counts for display are useful in every mode.
232
+ result.groups = groupByType(candidates);
233
+
234
+ if (action !== "apply") return result;
235
+
236
+ // apply
237
+ const importedPath = join(root, "recipes", "imported.yaml");
238
+ const existing = readYamlList(importedPath);
239
+
240
+ // Auto-enrich CLI candidates inline (promote bare -> useful) so imported
241
+ // entries route on natural phrasing, not just their own name. The ladder is
242
+ // the quality gate: a CLI that can't be enriched and is still bare is held
243
+ // back by toRecipeEntry (returns null).
244
+ const cliIds = byLane.recipe.filter((e) => e.type === "cli").map((e) => e.id);
245
+ const enrichment = cliIds.length ? await enrichCliLadder(cliIds) : {};
246
+
247
+ const additions = [];
248
+ const heldBack = [];
249
+ for (const entry of byLane.recipe) {
250
+ const built = toRecipeEntry(entry, entry.type === "cli" ? enrichment[entry.id] ?? null : null);
251
+ if (built) additions.push(built);
252
+ else heldBack.push(entry.id);
253
+ }
254
+ const merged = mergeImported(existing, additions);
255
+ const configs = await mcpConfigs(byLane.agentsMcp.map((e) => e.id));
256
+ const { json: newAgents, added: mcpAdded } = mergeAgentsMcp(agentsJson, byLane.agentsMcp, configs);
257
+
258
+ const wrote = [];
259
+ if (additions.length) {
260
+ writeFileSync(importedPath, yaml.dump(merged, { lineWidth: 100 }));
261
+ wrote.push({ path: importedPath, added: additions.map((e) => e.id) });
262
+ }
263
+ if (mcpAdded.length) {
264
+ writeFileSync(agentsPath, `${JSON.stringify(newAgents, null, 2)}\n`);
265
+ wrote.push({ path: agentsPath, added: mcpAdded });
266
+ }
267
+ const mcpSkipped = byLane.agentsMcp.map((e) => e.id).filter((id) => !mcpAdded.includes(id));
268
+ result.applied = { wrote, mcpSkipped, heldBack };
269
+ return result;
270
+ }
271
+
272
+ // Pure: group candidate {id,type} rows by surface type.
273
+ export function groupByType(candidates) {
274
+ const groups = {};
275
+ for (const c of candidates) (groups[c.type] ??= []).push(c.id);
276
+ return groups;
277
+ }
278
+
279
+ function printText(result) {
280
+ console.log(`import ${result.action}`);
281
+ console.log(`discovered ${result.discoveredCount} across hosts; ${result.knownCount} already canonical`);
282
+ console.log(`new candidates: ${result.candidates.length}`);
283
+ // Grouped by surface with real counts; nothing hidden. Small groups list
284
+ // every id; large groups show a sample + how to import fully.
285
+ const groups = result.groups ?? {};
286
+ for (const type of Object.keys(groups).sort()) {
287
+ const ids = groups[type];
288
+ if (FULL_LIST_TYPES.has(type) || ids.length <= SAMPLE) {
289
+ console.log(` ${type} (${ids.length}): ${ids.join(", ")}`);
290
+ } else {
291
+ console.log(` ${type} (${ids.length}): ${ids.slice(0, SAMPLE).join(", ")}, … +${ids.length - SAMPLE}`);
292
+ console.log(` import all: --type ${type} | one: --include ${type}:<id>`);
293
+ }
294
+ }
295
+ if (!result.candidates.length) console.log(" (nothing new to import — silence is a valid result)");
296
+ if (result.applied) {
297
+ for (const w of result.applied.wrote) console.log(`WROTE ${w.path}: +${w.added.length} (${w.added.join(", ")})`);
298
+ if (result.applied.heldBack?.length) {
299
+ console.log(`HELD BACK (bare, could not enrich — not worth fanning out): ${result.applied.heldBack.join(", ")}`);
300
+ }
301
+ if (result.applied.mcpSkipped.length) {
302
+ console.log(`SKIPPED (no recoverable launch config): ${result.applied.mcpSkipped.join(", ")}`);
303
+ }
304
+ console.log("next: run `npm run sync-surfaces sync` (or agents sync) to fan out to other hosts.");
305
+ } else if (result.action === "preview") {
306
+ console.log("next: re-run with `apply --apply` to write these; hand-curated recipe.yaml always wins on id collision.");
307
+ }
308
+ }
309
+
310
+ async function main() {
311
+ const args = parseArgs(process.argv);
312
+ const result = await collectImport({
313
+ root: resolve("."),
314
+ action: args.action,
315
+ types: args.types,
316
+ include: args.include,
317
+ });
318
+ if (args.json) console.log(JSON.stringify(result, null, 2));
319
+ else printText(result);
320
+ process.exitCode = 0;
321
+ }
322
+
323
+ import { pathToFileURL } from "node:url";
324
+
325
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
326
+ if (isMain) {
327
+ main().catch((error) => {
328
+ console.error(error.message);
329
+ process.exitCode = 1;
330
+ });
331
+ }
@@ -0,0 +1,106 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ computeCandidates,
5
+ groupByType,
6
+ isBare,
7
+ mergeAgentsMcp,
8
+ mergeImported,
9
+ parseArgs,
10
+ toRecipeEntry,
11
+ } from "./import-surfaces.mjs";
12
+
13
+ const disc = (id, type, extra = {}) => ({ id, type, source: id, route: { triggers: [id], description: `${type}: ${id}` }, ...extra });
14
+
15
+ test("parseArgs: apply without --apply is rejected", () => {
16
+ assert.throws(() => parseArgs(["node", "import.mjs", "apply"]), /requires an explicit --apply/);
17
+ assert.equal(parseArgs(["node", "import.mjs", "apply", "--apply"]).action, "apply");
18
+ });
19
+
20
+ test("computeCandidates: no filter = all surfaces, only known excluded", () => {
21
+ const discovered = [disc("rg", "cli"), disc("srv", "mcp"), disc("pdf", "skill"), disc("dup", "cli")];
22
+ const known = new Set(["dup"]);
23
+ const out = computeCandidates({ discovered, known, types: null, include: null });
24
+ assert.deepEqual(out.map((e) => e.id).sort(), ["pdf", "rg", "srv"]);
25
+ });
26
+
27
+ test("computeCandidates: --type narrows to the given surfaces", () => {
28
+ const discovered = [disc("rg", "cli"), disc("srv", "mcp"), disc("pdf", "skill")];
29
+ const out = computeCandidates({ discovered, known: new Set(), types: ["cli"], include: null });
30
+ assert.deepEqual(out.map((e) => e.id), ["rg"]);
31
+ });
32
+
33
+ test("isBare: only-self triggers = bare; extra trigger = not bare", () => {
34
+ assert.equal(isBare(disc("rg", "cli")), true);
35
+ assert.equal(isBare({ id: "rg", source: "ripgrep", type: "cli", route: { triggers: ["rg", "search"] } }), false);
36
+ });
37
+
38
+ test("groupByType: buckets ids by surface", () => {
39
+ const g = groupByType([{ id: "a", type: "cli" }, { id: "b", type: "cli" }, { id: "c", type: "mcp" }]);
40
+ assert.deepEqual(g, { cli: ["a", "b"], mcp: ["c"] });
41
+ });
42
+
43
+ test("toRecipeEntry: bare CLI with no enrichment is held back (null)", () => {
44
+ assert.equal(toRecipeEntry(disc("rg", "cli"), null), null);
45
+ });
46
+
47
+ test("toRecipeEntry: enrichment promotes a bare CLI (triggers/desc win)", () => {
48
+ const enr = { triggers: ["rg", "search", "grep"], description: "CLI tool: rg — fast search", sources: { description: "package" } };
49
+ const entry = toRecipeEntry(disc("rg", "cli"), enr);
50
+ assert.deepEqual(entry.route.triggers, ["rg", "search", "grep"]);
51
+ assert.equal(entry.imported.enriched.description, "package");
52
+ });
53
+
54
+ test("toRecipeEntry: non-CLI keeps its discovered route, never gated", () => {
55
+ const entry = toRecipeEntry(disc("pdf", "skill", { route: { triggers: ["pdf"], description: "PDF skill" } }), null);
56
+ assert.ok(entry);
57
+ assert.equal(entry.type, "skill");
58
+ });
59
+
60
+ test("computeCandidates: --include overrides the type filter and matches type:id", () => {
61
+ const discovered = [disc("pdf", "skill"), disc("rg", "cli")];
62
+ const known = new Set();
63
+ assert.deepEqual(
64
+ computeCandidates({ discovered, known, types: null, include: ["skill:pdf"] }).map((e) => e.id),
65
+ ["pdf"],
66
+ );
67
+ assert.deepEqual(
68
+ computeCandidates({ discovered, known, types: null, include: ["pdf"] }).map((e) => e.id),
69
+ ["pdf"],
70
+ );
71
+ });
72
+
73
+ test("toRecipeEntry: carries route + provenance, keeps path when present", () => {
74
+ const entry = toRecipeEntry(disc("pdf", "skill", { path: "/x/pdf" }));
75
+ assert.equal(entry.id, "pdf");
76
+ assert.equal(entry.path, "/x/pdf");
77
+ assert.equal(entry.imported.via, "discover:skill");
78
+ assert.ok(entry.route.triggers.includes("pdf"));
79
+ });
80
+
81
+ test("mergeImported: existing wins on id, so re-apply is idempotent", () => {
82
+ const existing = [{ id: "rg", type: "cli", note: "kept" }];
83
+ const additions = [{ id: "rg", type: "cli", note: "new" }, { id: "jq", type: "cli" }];
84
+ const merged = mergeImported(existing, additions);
85
+ assert.equal(merged.length, 2);
86
+ assert.equal(merged.find((e) => e.id === "rg").note, "kept");
87
+ });
88
+
89
+ test("mergeAgentsMcp: adds stdio server with config, skips one with no recoverable config", () => {
90
+ const agents = { schemaVersion: 3, mcp: { servers: { existing: {} } } };
91
+ const entries = [disc("withcfg", "mcp"), disc("nocfg", "mcp"), disc("existing", "mcp")];
92
+ const configs = { withcfg: { command: "node", args: ["x.js"] } };
93
+ const { json, added } = mergeAgentsMcp(agents, entries, configs);
94
+ assert.deepEqual(added, ["withcfg"]);
95
+ assert.equal(json.mcp.servers.withcfg.transport, "stdio");
96
+ assert.equal(json.mcp.servers.withcfg.command, "node");
97
+ assert.ok(!json.mcp.servers.nocfg, "no-config server is not written");
98
+ assert.ok(json.mcp.servers.existing, "pre-existing server untouched");
99
+ });
100
+
101
+ test("mergeAgentsMcp: http transport when config has url", () => {
102
+ const { json, added } = mergeAgentsMcp(null, [disc("remote", "mcp")], { remote: { url: "https://x/y" } });
103
+ assert.deepEqual(added, ["remote"]);
104
+ assert.equal(json.mcp.servers.remote.transport, "http");
105
+ assert.equal(json.mcp.servers.remote.url, "https://x/y");
106
+ });
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+ // Cross-host config sync facade.
3
+ //
4
+ // This script deliberately delegates real sync to existing backends instead of
5
+ // inventing another config reconciler here. It gives portawhip one stable
6
+ // command surface for status/preview/apply while preserving backend ownership.
7
+
8
+ import { CONFIG_SYNC_BACKENDS, backendById, normalizeBackendId, runBackend } from "../../core/surface/config-sync-backends.mjs";
9
+
10
+ const VALID_ACTIONS = new Set(["status", "preview", "apply"]);
11
+ const VALID_SCOPES = new Set(["all", "project", "global"]);
12
+ const PROFILES = {
13
+ "ai-project-instructions": {
14
+ backends: ["ai-config-sync"],
15
+ scope: "project",
16
+ include: "instructions",
17
+ },
18
+ "ai-global-instructions": {
19
+ backends: ["ai-config-sync"],
20
+ scope: "global",
21
+ include: "instructions",
22
+ },
23
+ "ai-project-mcp": {
24
+ backends: ["ai-config-sync"],
25
+ scope: "project",
26
+ include: "mcp",
27
+ },
28
+ "asm-status": {
29
+ backends: ["agent-skill-manager"],
30
+ },
31
+ "agents-check": {
32
+ backends: ["agents-dotdir"],
33
+ },
34
+ };
35
+
36
+ function parseCsv(value) {
37
+ return String(value ?? "")
38
+ .split(",")
39
+ .map((item) => item.trim())
40
+ .filter(Boolean);
41
+ }
42
+
43
+ export function parseArgs(argv) {
44
+ const args = {
45
+ action: argv[2] ?? "status",
46
+ backends: ["ai-config-sync"],
47
+ json: false,
48
+ allowApply: false,
49
+ allowNpx: false,
50
+ scope: "all",
51
+ include: null,
52
+ exclude: null,
53
+ from: null,
54
+ to: null,
55
+ profile: null,
56
+ };
57
+
58
+ for (let i = 3; i < argv.length; i += 1) {
59
+ const arg = argv[i];
60
+ if (arg === "--backend") {
61
+ args.backends = [argv[i + 1]];
62
+ i += 1;
63
+ } else if (arg === "--backends") {
64
+ args.backends = parseCsv(argv[i + 1]);
65
+ i += 1;
66
+ } else if (arg === "--all-backends") {
67
+ args.backends = Object.keys(CONFIG_SYNC_BACKENDS);
68
+ } else if (arg === "--json") {
69
+ args.json = true;
70
+ } else if (arg === "--apply") {
71
+ args.allowApply = true;
72
+ } else if (arg === "--allow-npx") {
73
+ args.allowNpx = true;
74
+ } else if (arg === "--profile") {
75
+ args.profile = argv[i + 1];
76
+ i += 1;
77
+ } else if (arg === "--scope") {
78
+ args.scope = argv[i + 1];
79
+ i += 1;
80
+ } else if (arg === "--include") {
81
+ args.include = argv[i + 1];
82
+ i += 1;
83
+ } else if (arg === "--exclude") {
84
+ args.exclude = argv[i + 1];
85
+ i += 1;
86
+ } else if (arg === "--from") {
87
+ args.from = argv[i + 1];
88
+ i += 1;
89
+ } else if (arg === "--to") {
90
+ args.to = argv[i + 1];
91
+ i += 1;
92
+ } else {
93
+ throw new Error(`unknown argument: ${arg}`);
94
+ }
95
+ }
96
+
97
+ if (!VALID_ACTIONS.has(args.action)) {
98
+ throw new Error(
99
+ "usage: sync-config.mjs <status|preview|apply> [--backend id|--profile name|--all-backends] [--json] [--allow-npx]",
100
+ );
101
+ }
102
+ applyProfile(args);
103
+ if (!VALID_SCOPES.has(args.scope)) {
104
+ throw new Error(`invalid scope "${args.scope}"`);
105
+ }
106
+ if (args.action === "apply" && !args.allowApply) {
107
+ throw new Error("apply requires an explicit --apply flag; run preview first");
108
+ }
109
+ if (args.action === "apply") validateApplySafety(args);
110
+ args.backends = args.backends.map(normalizeBackendId);
111
+ for (const backend of args.backends) backendById(backend);
112
+ return args;
113
+ }
114
+
115
+ function applyProfile(args) {
116
+ if (!args.profile) return;
117
+ const profile = PROFILES[args.profile];
118
+ if (!profile) throw new Error(`unknown profile "${args.profile}". valid: ${Object.keys(PROFILES).join(", ")}`);
119
+ args.backends = profile.backends ?? args.backends;
120
+ args.scope = profile.scope ?? args.scope;
121
+ args.include = profile.include ?? args.include;
122
+ args.exclude = profile.exclude ?? args.exclude;
123
+ }
124
+
125
+ function validateApplySafety(args) {
126
+ if (!args.include) {
127
+ throw new Error("apply requires --include or a --profile; broad all-area apply is blocked");
128
+ }
129
+ const selectors = parseCsv(args.include);
130
+ if (selectors.some((selector) => selector === "skills")) {
131
+ throw new Error("apply of all skills is blocked; use item selectors like --include skills:pdf");
132
+ }
133
+ }
134
+
135
+ function optionsFromArgs(args) {
136
+ return {
137
+ scope: args.scope,
138
+ include: args.include,
139
+ exclude: args.exclude,
140
+ from: args.from,
141
+ to: args.to,
142
+ cwd: process.cwd(),
143
+ allowNpx: args.allowNpx,
144
+ };
145
+ }
146
+
147
+ export function collectSyncConfig({
148
+ action = "status",
149
+ backends = ["ai-config-sync"],
150
+ options = {},
151
+ runner = null,
152
+ } = {}) {
153
+ const rows = [];
154
+ for (const backend of backends) {
155
+ try {
156
+ rows.push(runner ? runBackend(backend, action, options, runner) : runBackend(backend, action, options));
157
+ } catch (error) {
158
+ const unsupported = /does not support/.test(error.message);
159
+ rows.push({
160
+ backend: normalizeBackendId(backend),
161
+ label: normalizeBackendId(backend),
162
+ action,
163
+ command: [],
164
+ ok: unsupported,
165
+ status: unsupported ? "unsupported" : "error",
166
+ summary: error.message,
167
+ output: "",
168
+ installHint: null,
169
+ next_actions: unsupported ? ["Use status for probe-only backends."] : ["Use a supported backend/action combination."],
170
+ artifacts: [],
171
+ });
172
+ }
173
+ }
174
+ const ok = rows.every((row) => row.ok);
175
+ return {
176
+ status: ok ? "success" : "warning",
177
+ summary: ok ? `${action} succeeded for ${rows.length} backend(s)` : `${action} needs attention`,
178
+ action,
179
+ rows,
180
+ next_actions: ok ? ["Review backend output for drift details."] : summarizeNextActions(rows),
181
+ artifacts: [],
182
+ };
183
+ }
184
+
185
+ function summarizeNextActions(rows) {
186
+ const actions = rows.flatMap((row) => row.next_actions ?? []);
187
+ return [...new Set(actions.length ? actions : ["Inspect failed backend output."])];
188
+ }
189
+
190
+ function printText(result) {
191
+ console.log(`sync-config action: ${result.action}`);
192
+ console.log(`status: ${result.status}`);
193
+ console.log(result.summary);
194
+ console.log("\n== backends ==");
195
+ for (const row of result.rows) {
196
+ const command = row.command.length ? ` (${row.command.join(" ")})` : "";
197
+ console.log(`${row.backend}: ${row.ok ? "OK" : "FAIL"} ${row.summary}${command}`);
198
+ if (!row.ok && row.installHint) console.log(` install: ${row.installHint}`);
199
+ if (row.output.trim()) {
200
+ const lines = row.output.trim().split(/\r?\n/).slice(0, 20);
201
+ for (const line of lines) console.log(` ${line}`);
202
+ if (row.output.trim().split(/\r?\n/).length > lines.length) console.log(" ...");
203
+ }
204
+ }
205
+ }
206
+
207
+ async function main() {
208
+ const args = parseArgs(process.argv);
209
+ const result = collectSyncConfig({
210
+ action: args.action,
211
+ backends: args.backends,
212
+ options: optionsFromArgs(args),
213
+ });
214
+ if (args.json) {
215
+ console.log(JSON.stringify(result, null, 2));
216
+ } else {
217
+ printText(result);
218
+ }
219
+ process.exitCode = result.status === "success" ? 0 : 1;
220
+ }
221
+
222
+ import { pathToFileURL } from "node:url";
223
+
224
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
225
+ if (isMain) {
226
+ main().catch((error) => {
227
+ console.error(error.message);
228
+ process.exitCode = 1;
229
+ });
230
+ }