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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- package/surface-matrix.yaml +93 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Bundle selector - opt-in only, never forced (agreed 2026-07-05). foundry =
|
|
3
|
+
// whipforaweeb's recommended core (user may opt out); roles = whipforaweeb's
|
|
4
|
+
// role-based add-ons (tick zero, one, or many). Selecting a bundle just
|
|
5
|
+
// changes which recipe files feed BOTH install (scripts/load.mjs) and
|
|
6
|
+
// routing (router-cli/mcp-server/universal-hook) - the same list, so nothing
|
|
7
|
+
// can end up "installed but never routed" (see recipes/foundry.yaml's header
|
|
8
|
+
// for the predecessor-project gripe this avoids).
|
|
9
|
+
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
listCatalog,
|
|
13
|
+
readActiveSelection,
|
|
14
|
+
resolveBundlePaths,
|
|
15
|
+
resolveRecipePaths,
|
|
16
|
+
writeActiveSelection,
|
|
17
|
+
} from "../core/state/bundle-state.mjs";
|
|
18
|
+
import { mergeRawEntries } from "../core/registry/registry.mjs";
|
|
19
|
+
import { detectHosts } from "./hosts.mjs";
|
|
20
|
+
import { installEntries } from "./load.mjs";
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const args = {};
|
|
24
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
25
|
+
if (argv[i].startsWith("--")) {
|
|
26
|
+
const key = argv[i].slice(2);
|
|
27
|
+
const value = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : true;
|
|
28
|
+
args[key] = value;
|
|
29
|
+
if (value !== true) i += 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return args;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cmdList(root) {
|
|
36
|
+
const { foundry, roles } = listCatalog(root);
|
|
37
|
+
console.log("== foundry (recommended core; opt-out anytime with `select --none`) ==");
|
|
38
|
+
console.log(
|
|
39
|
+
foundry
|
|
40
|
+
? `${foundry.id}: ${foundry.description ?? "(no description)"} - entries: ${foundry.entryIds.join(", ")}`
|
|
41
|
+
: "(none defined yet - recipes/foundry.yaml missing)",
|
|
42
|
+
);
|
|
43
|
+
console.log("\n== roles (opt-in, pick any number) ==");
|
|
44
|
+
if (roles.length === 0) console.log("(none defined yet - recipes/roles/*.yaml missing)");
|
|
45
|
+
for (const role of roles) {
|
|
46
|
+
console.log(`${role.id}: ${role.description ?? "(no description)"} - entries: ${role.entryIds.join(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cmdStatus(root) {
|
|
51
|
+
const selection = readActiveSelection(root);
|
|
52
|
+
const paths = resolveRecipePaths(root, selection);
|
|
53
|
+
console.log(`foundry: ${selection.foundry ? "on" : "off"}`);
|
|
54
|
+
console.log(`roles: ${selection.roles.length ? selection.roles.join(", ") : "(none)"}`);
|
|
55
|
+
console.log("\nresolved recipe files (precedence: foundry -> roles -> this project's recipe.yaml):");
|
|
56
|
+
for (const p of paths) console.log(` - ${p}`);
|
|
57
|
+
if (paths.length === 0) console.log(" (none - nothing to route or install)");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// select does not just record intent — it installs the newly selected
|
|
61
|
+
// bundle(s) right away (agreed 2026-07-05: "select bundle นี่คือต้องโหลด
|
|
62
|
+
// tools ให้เลย"). Scoped to resolveBundlePaths (foundry + selected roles
|
|
63
|
+
// only), not the project's own recipe.yaml, so re-selecting doesn't
|
|
64
|
+
// reinstall everything that was already there before bundles existed.
|
|
65
|
+
// Installs are idempotent (mise/add-mcp/asm already re-verified this in
|
|
66
|
+
// Step 1), so re-running select with the same selection is a safe no-op.
|
|
67
|
+
async function cmdSelect(root, args) {
|
|
68
|
+
if (args.none) {
|
|
69
|
+
writeActiveSelection(root, { foundry: false, roles: [] });
|
|
70
|
+
console.log("selection cleared - back to just this project's recipe.yaml.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const selection = {
|
|
74
|
+
foundry: Boolean(args.foundry),
|
|
75
|
+
roles:
|
|
76
|
+
typeof args.role === "string"
|
|
77
|
+
? args.role
|
|
78
|
+
.split(",")
|
|
79
|
+
.map((r) => r.trim())
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
: [],
|
|
82
|
+
};
|
|
83
|
+
writeActiveSelection(root, selection);
|
|
84
|
+
console.log(`selection saved: foundry=${selection.foundry}, roles=${selection.roles.join(",") || "(none)"}`);
|
|
85
|
+
|
|
86
|
+
const bundlePaths = resolveBundlePaths(root, selection);
|
|
87
|
+
if (bundlePaths.length === 0) {
|
|
88
|
+
console.log("\nnothing to install (no matching recipes/foundry.yaml or recipes/roles/*.yaml found).");
|
|
89
|
+
cmdStatus(root);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const scope = args.scope === "project" ? "project" : "global";
|
|
94
|
+
console.log(`\n== installing selected bundle(s) (cli scope: ${scope}) ==`);
|
|
95
|
+
const entries = mergeRawEntries(bundlePaths);
|
|
96
|
+
const hosts = await detectHosts();
|
|
97
|
+
console.log(`detected MCP hosts: ${hosts.mcpHosts.join(", ") || "(none)"}`);
|
|
98
|
+
console.log(`detected skill hosts: ${hosts.skillHosts.join(", ") || "(none)"}`);
|
|
99
|
+
const results = installEntries(entries, hosts, scope);
|
|
100
|
+
|
|
101
|
+
console.log("\n== install summary ==");
|
|
102
|
+
for (const r of results) console.log(`${r.ok ? "OK " : "FAIL"} ${r.id}`);
|
|
103
|
+
|
|
104
|
+
console.log("");
|
|
105
|
+
cmdStatus(root);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function main() {
|
|
109
|
+
const [, , command, ...rest] = process.argv;
|
|
110
|
+
const args = parseArgs(rest);
|
|
111
|
+
const root = resolve(".");
|
|
112
|
+
|
|
113
|
+
if (command === "list") return cmdList(root);
|
|
114
|
+
if (command === "status") return cmdStatus(root);
|
|
115
|
+
if (command === "select") return cmdSelect(root, args);
|
|
116
|
+
|
|
117
|
+
console.error(
|
|
118
|
+
"usage: bundles.mjs <list|status|select [--foundry] [--role a,b] [--scope project|global] | select --none>",
|
|
119
|
+
);
|
|
120
|
+
process.exitCode = 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
import { pathToFileURL } from "node:url";
|
|
124
|
+
|
|
125
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
126
|
+
if (isMain) {
|
|
127
|
+
main().catch((error) => {
|
|
128
|
+
console.error(error.message);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unified status across 3 backends — proves "load however, verify one way."
|
|
3
|
+
// No re-implemented health logic: each check just calls that backend's
|
|
4
|
+
// own list/audit command and reports pass/fail.
|
|
5
|
+
|
|
6
|
+
import spawnSync from "cross-spawn";
|
|
7
|
+
import { collectSurfaceMatrix } from "../core/surface/surface-matrix.mjs";
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
return { json: argv.includes("--json"), heavy: argv.includes("--heavy") };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cellText(cell) {
|
|
14
|
+
const count = cell.count != null ? `(${cell.count})` : "";
|
|
15
|
+
return `${cell.status}${count}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function capture(cmd, args) {
|
|
19
|
+
const result = spawnSync.sync(cmd, args, { encoding: "utf8" });
|
|
20
|
+
return { ok: result.status === 0, output: (result.stdout || "") + (result.stderr || "") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function checkMcp() {
|
|
24
|
+
const r = capture("npx", ["--yes", "add-mcp", "list", "-g"]);
|
|
25
|
+
const found = r.output.includes("context7");
|
|
26
|
+
return { label: "context7 (mcp via add-mcp)", ok: r.ok && found };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkCli() {
|
|
30
|
+
const r = capture("mise", ["ls", "ripgrep"]);
|
|
31
|
+
const found = r.output.includes("ripgrep");
|
|
32
|
+
return { label: "ripgrep (cli via mise)", ok: r.ok && found };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function checkSkill() {
|
|
36
|
+
const r = capture("npx", ["--yes", "agent-skill-manager", "inspect", "pdf", "--json"]);
|
|
37
|
+
return { label: "pdf (skill via asm)", ok: r.ok };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Router/hooks/connectors added later than the 3 checks above (Step 1) —
|
|
41
|
+
// this file went stale until this pass. Same pattern: shell out to each
|
|
42
|
+
// piece's own authoritative command, don't re-implement its logic here.
|
|
43
|
+
function checkRouter() {
|
|
44
|
+
const r = capture(process.execPath, ["core/router/router-cli.mjs", "list"]);
|
|
45
|
+
let entries = [];
|
|
46
|
+
try {
|
|
47
|
+
entries = JSON.parse(r.output);
|
|
48
|
+
} catch {
|
|
49
|
+
entries = [];
|
|
50
|
+
}
|
|
51
|
+
return { label: "router registry (recipe + live discovery)", ok: r.ok && Array.isArray(entries) && entries.length > 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Both scopes, not just global — collapsing to one bool at global scope hid
|
|
55
|
+
// per-host detail that link-hooks.mjs/link-connectors.mjs status already
|
|
56
|
+
// print (path + linked/missing per host). Doctor's job is to surface that,
|
|
57
|
+
// not re-summarize it into a single OK/FAIL.
|
|
58
|
+
function checkHooks(scope) {
|
|
59
|
+
const r = capture(process.execPath, ["scripts/link/link-hooks.mjs", "status", "--scope", scope]);
|
|
60
|
+
return { label: `native hooks (link-hooks.mjs, ${scope} scope)`, ok: r.ok, detail: r.output.trim() };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkConnectors(scope) {
|
|
64
|
+
const r = capture(process.execPath, ["scripts/link/link-connectors.mjs", "status", "--scope", scope]);
|
|
65
|
+
return { label: `instruction connectors (link-connectors.mjs, ${scope} scope)`, ok: r.ok, detail: r.output.trim() };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { json, heavy } = parseArgs(process.argv.slice(2));
|
|
69
|
+
|
|
70
|
+
const checks = [
|
|
71
|
+
checkMcp(),
|
|
72
|
+
checkCli(),
|
|
73
|
+
checkSkill(),
|
|
74
|
+
checkRouter(),
|
|
75
|
+
checkHooks("project"),
|
|
76
|
+
checkHooks("global"),
|
|
77
|
+
checkConnectors("project"),
|
|
78
|
+
checkConnectors("global"),
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// Surface coverage matrix (Phase S0): live-probed read/write lane status per
|
|
82
|
+
// portable surface. Light by default so `doctor` stays fast; `--heavy` runs
|
|
83
|
+
// the full mcp/skill discovery counts.
|
|
84
|
+
const matrix = await collectSurfaceMatrix({ heavy });
|
|
85
|
+
|
|
86
|
+
if (json) {
|
|
87
|
+
const ok = checks.every((c) => c.ok);
|
|
88
|
+
console.log(JSON.stringify({ status: ok ? "ok" : "fail", checks, matrix }, null, 2));
|
|
89
|
+
process.exitCode = ok ? 0 : 1;
|
|
90
|
+
process.exit();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log("\n== doctor: unified status ==");
|
|
94
|
+
for (const c of checks) console.log(`${c.ok ? "OK " : "FAIL"} ${c.label}`);
|
|
95
|
+
|
|
96
|
+
console.log("\n== per-host detail (source of truth for what's loaded where) ==");
|
|
97
|
+
for (const c of checks) {
|
|
98
|
+
if (!c.detail) continue;
|
|
99
|
+
console.log(`\n--- ${c.label} ---`);
|
|
100
|
+
console.log(c.detail);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log("\n== surface coverage matrix (read=import, write=fan-out) ==");
|
|
104
|
+
for (const row of matrix.rows) {
|
|
105
|
+
console.log(`${row.label.padEnd(34)} read: ${cellText(row.read).padEnd(20)} write: ${cellText(row.write)}`);
|
|
106
|
+
}
|
|
107
|
+
if (matrix.summary.attention.length) {
|
|
108
|
+
console.log(`\nAttention (no lane / backend missing): ${matrix.summary.attention.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log(
|
|
112
|
+
"\nNote: add-mcp / agent-skill-manager entries are managed by those tools, not by\n" +
|
|
113
|
+
"this harness. `doctor` reports on them but `scripts/uninstall-all.mjs` cannot\n" +
|
|
114
|
+
"remove them — use each tool's own uninstall if you want those gone too.",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
process.exitCode = checks.every((c) => c.ok) ? 0 : 1;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// View the embedded-hook inventory (Phase S3, mode B). Read-only: lists hooks
|
|
3
|
+
// bundled inside installed skills/plugins so a human can see what runs on
|
|
4
|
+
// lifecycle events. Does NOT link or execute anything.
|
|
5
|
+
|
|
6
|
+
import { discoverEmbeddedHooks, summarizeEmbeddedHooks } from "../core/surface/discover-hooks.mjs";
|
|
7
|
+
|
|
8
|
+
function main() {
|
|
9
|
+
const json = process.argv.includes("--json");
|
|
10
|
+
const entries = discoverEmbeddedHooks();
|
|
11
|
+
const summary = summarizeEmbeddedHooks(entries);
|
|
12
|
+
if (json) {
|
|
13
|
+
console.log(JSON.stringify({ summary, entries }, null, 2));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
console.log(`embedded hooks: ${summary.total} (active ${summary.active}, templates ${summary.templates})`);
|
|
17
|
+
console.log(`by host: ${JSON.stringify(summary.byHost)}`);
|
|
18
|
+
console.log(`by package: ${JSON.stringify(summary.byPackage)}`);
|
|
19
|
+
console.log("\n(inventory only — these are NOT linked; run each package's own installer to activate)\n");
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
const tag = e.template ? " [template]" : "";
|
|
22
|
+
console.log(`${e.host} · ${e.package} · ${e.event}${e.matcher ? `/${e.matcher}` : ""}${tag}`);
|
|
23
|
+
console.log(` ${e.commandPreview}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
main();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Single source of truth for "which agent hosts exist on this machine."
|
|
2
|
+
// Both load.mjs and doctor.mjs import this — no host list is hardcoded
|
|
3
|
+
// in either of them.
|
|
4
|
+
//
|
|
5
|
+
// add-mcp's detectGlobalAgents() does real filesystem detection (checks
|
|
6
|
+
// for each agent's actual home config dir), so this list grows/shrinks
|
|
7
|
+
// automatically as the user installs/removes agent CLIs — no manual
|
|
8
|
+
// catalog maintenance.
|
|
9
|
+
|
|
10
|
+
import { detectGlobalAgents } from "add-mcp";
|
|
11
|
+
import spawnSync from "cross-spawn";
|
|
12
|
+
import { detectExtraHosts } from "../core/surface/extra-hosts.mjs";
|
|
13
|
+
|
|
14
|
+
// add-mcp and asm are independent projects that each picked their own id
|
|
15
|
+
// strings for the same real-world tool. This table is unavoidable glue
|
|
16
|
+
// data (not decision logic) translating one id space to the other; it
|
|
17
|
+
// only needs an entry for hosts that carry skill content, so it will
|
|
18
|
+
// always be shorter than add-mcp's full agent list.
|
|
19
|
+
const ADD_MCP_TO_ASM = {
|
|
20
|
+
"claude-code": "claude",
|
|
21
|
+
codex: "codex",
|
|
22
|
+
cursor: "cursor",
|
|
23
|
+
"gemini-cli": "gemini",
|
|
24
|
+
opencode: "opencode",
|
|
25
|
+
windsurf: "windsurf",
|
|
26
|
+
zed: "zed",
|
|
27
|
+
antigravity: "antigravity",
|
|
28
|
+
cline: "cline",
|
|
29
|
+
"cline-cli": "cline",
|
|
30
|
+
"github-copilot-cli": "copilot",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function asmEnabledProviders() {
|
|
34
|
+
const result = spawnSync.sync("npx", ["--yes", "agent-skill-manager", "config", "show"], {
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
});
|
|
37
|
+
if (result.status !== 0) return [];
|
|
38
|
+
try {
|
|
39
|
+
const config = JSON.parse(result.stdout);
|
|
40
|
+
return config.providers.filter((p) => p.enabled).map((p) => p.name);
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function detectHosts() {
|
|
47
|
+
const mcpHosts = await detectGlobalAgents(); // e.g. ["claude-code","codex","cursor",...]
|
|
48
|
+
const asmProviders = asmEnabledProviders();
|
|
49
|
+
const skillHosts = mcpHosts
|
|
50
|
+
.map((id) => ADD_MCP_TO_ASM[id])
|
|
51
|
+
.filter((name) => name && asmProviders.includes(name));
|
|
52
|
+
|
|
53
|
+
// Hosts add-mcp doesn't catalogue yet (Pi, Amp, …), detected by their own
|
|
54
|
+
// config dir. Kept in a separate field so add-mcp's MCP-linking path is
|
|
55
|
+
// never handed a host it doesn't know; the instruction/command lanes that
|
|
56
|
+
// opt in read this explicitly.
|
|
57
|
+
const extraHosts = detectExtraHosts();
|
|
58
|
+
|
|
59
|
+
return { mcpHosts, skillHosts, extraHosts };
|
|
60
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cross-host connector linker for harness-router.
|
|
3
|
+
//
|
|
4
|
+
// This does not install MCP servers itself; add-mcp remains the delegated
|
|
5
|
+
// installer. The script verifies the MCP link and upserts the small instruction
|
|
6
|
+
// connector block into every known host instruction surface for a selected
|
|
7
|
+
// scope (project by default, global when explicitly requested).
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { listInstalledServers } from "add-mcp";
|
|
12
|
+
import { detectHosts } from "../hosts.mjs";
|
|
13
|
+
import { blockForVariant, removeBlock, upsertBlock } from "../../adapters/instructions/generate.mjs";
|
|
14
|
+
import { CONNECTOR_TARGETS, targetsForHost } from "../../core/surface/connector-targets.mjs";
|
|
15
|
+
|
|
16
|
+
const VALID_COMMANDS = new Set(["status", "install", "remove"]);
|
|
17
|
+
const VALID_SCOPES = new Set(["project", "global"]);
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const args = { command: argv[2] ?? "status", scope: "project", json: false };
|
|
21
|
+
for (let i = 3; i < argv.length; i += 1) {
|
|
22
|
+
const arg = argv[i];
|
|
23
|
+
if (arg === "--scope") {
|
|
24
|
+
args.scope = argv[i + 1];
|
|
25
|
+
i += 1;
|
|
26
|
+
} else if (arg === "--all-scopes") {
|
|
27
|
+
args.scope = "all";
|
|
28
|
+
} else if (arg === "--json") {
|
|
29
|
+
args.json = true;
|
|
30
|
+
} else {
|
|
31
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!VALID_COMMANDS.has(args.command)) {
|
|
35
|
+
throw new Error(`usage: link-connectors.mjs <status|install|remove> [--scope project|global|--all-scopes]`);
|
|
36
|
+
}
|
|
37
|
+
if (args.scope !== "all" && !VALID_SCOPES.has(args.scope)) {
|
|
38
|
+
throw new Error(`invalid scope "${args.scope}"`);
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function hasHarnessBlock(path) {
|
|
44
|
+
if (!existsSync(path)) return false;
|
|
45
|
+
const content = readFileSync(path, "utf8");
|
|
46
|
+
return content.includes("<!-- harness-router:start -->") && content.includes("<!-- harness-router:end -->");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeTarget(target) {
|
|
50
|
+
return { ...target, path: resolve(target.path) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function targetsForScope(hostId, scope) {
|
|
54
|
+
if (scope === "all") {
|
|
55
|
+
return CONNECTOR_TARGETS[hostId]?.instructionTargets.map(normalizeTarget) ?? [];
|
|
56
|
+
}
|
|
57
|
+
return targetsForHost(hostId, { scope }).map(normalizeTarget);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rowHasHarnessRouter(row) {
|
|
61
|
+
return row.servers.some(
|
|
62
|
+
(server) => server.name === "harness-router" || server.serverName === "harness-router",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function mcpLinkedByHost(hostIds) {
|
|
67
|
+
const localRows = await listInstalledServers({ global: false, agents: hostIds, cwd: process.cwd() });
|
|
68
|
+
const globalRows = await listInstalledServers({ global: true, agents: hostIds, cwd: process.cwd() });
|
|
69
|
+
const linked = new Map(hostIds.map((hostId) => [hostId, false]));
|
|
70
|
+
for (const row of [...localRows, ...globalRows]) {
|
|
71
|
+
linked.set(row.agentType, linked.get(row.agentType) || rowHasHarnessRouter(row));
|
|
72
|
+
}
|
|
73
|
+
return linked;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A dedicated, harness-owned rule file (cursor .mdc, windsurf .md) carries
|
|
77
|
+
// frontmatter BEFORE the marker block, so marker-upsert is wrong for it:
|
|
78
|
+
// upsertBlock preserves the pre-marker preamble AND re-adds the block's own
|
|
79
|
+
// preamble, duplicating the frontmatter on every re-run. Own the whole file
|
|
80
|
+
// instead — install writes it verbatim (idempotent), remove deletes it (no
|
|
81
|
+
// orphan always-on rule left behind on uninstall).
|
|
82
|
+
function applyOwnedTarget(command, target) {
|
|
83
|
+
if (command === "remove") {
|
|
84
|
+
if (!existsSync(target.path)) return { changed: false, linked: false };
|
|
85
|
+
rmSync(target.path);
|
|
86
|
+
return { changed: true, linked: false };
|
|
87
|
+
}
|
|
88
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
89
|
+
const next = `${blockForVariant(target.variant)}\n`;
|
|
90
|
+
const current = existsSync(target.path) ? readFileSync(target.path, "utf8") : null;
|
|
91
|
+
const changed = current !== next;
|
|
92
|
+
if (changed) writeFileSync(target.path, next);
|
|
93
|
+
return { changed, linked: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function applyTarget(command, target) {
|
|
97
|
+
if (command === "status") return { changed: false, linked: hasHarnessBlock(target.path) };
|
|
98
|
+
if (target.owned) return applyOwnedTarget(command, target);
|
|
99
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
100
|
+
const changed =
|
|
101
|
+
command === "install" ? upsertBlock(target.path, blockForVariant(target.variant)) : removeBlock(target.path);
|
|
102
|
+
return { changed, linked: hasHarnessBlock(target.path) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function collectConnectorLinks({ command = "status", scope = "project" } = {}) {
|
|
106
|
+
const hosts = await detectHosts();
|
|
107
|
+
const hostIds = hosts.mcpHosts;
|
|
108
|
+
const mcpLinks = await mcpLinkedByHost(hostIds);
|
|
109
|
+
const rows = [];
|
|
110
|
+
|
|
111
|
+
for (const hostId of hostIds) {
|
|
112
|
+
const targets = targetsForScope(hostId, scope);
|
|
113
|
+
const mcpLinked = Boolean(mcpLinks.get(hostId));
|
|
114
|
+
const mcpStatus = mcpLinked ? "linked" : "missing";
|
|
115
|
+
if (targets.length === 0) {
|
|
116
|
+
rows.push({
|
|
117
|
+
type: "connector",
|
|
118
|
+
hostId,
|
|
119
|
+
scope,
|
|
120
|
+
mcpStatus,
|
|
121
|
+
instructionStatus: "mcp-only",
|
|
122
|
+
path: null,
|
|
123
|
+
supported: true,
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
for (const target of targets) {
|
|
128
|
+
const result = applyTarget(command, target);
|
|
129
|
+
const instructionStatus =
|
|
130
|
+
command === "status" ? (result.linked ? "linked" : "missing") : result.changed ? "changed" : "no-op";
|
|
131
|
+
rows.push({
|
|
132
|
+
type: "connector",
|
|
133
|
+
hostId,
|
|
134
|
+
scope,
|
|
135
|
+
mcpStatus,
|
|
136
|
+
instructionStatus,
|
|
137
|
+
path: target.path,
|
|
138
|
+
supported: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Extra hosts add-mcp doesn't catalogue (Pi, Amp, …), present on this
|
|
144
|
+
// machine: instruction linking only. mcpStatus is "n/a" — we never hand
|
|
145
|
+
// these to add-mcp's MCP linker, which doesn't know them.
|
|
146
|
+
for (const hostId of hosts.extraHosts ?? []) {
|
|
147
|
+
const targets = targetsForScope(hostId, scope);
|
|
148
|
+
for (const target of targets) {
|
|
149
|
+
const result = applyTarget(command, target);
|
|
150
|
+
const instructionStatus =
|
|
151
|
+
command === "status" ? (result.linked ? "linked" : "missing") : result.changed ? "changed" : "no-op";
|
|
152
|
+
rows.push({ type: "connector", hostId, scope, mcpStatus: "n/a", instructionStatus, path: target.path, supported: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { command, scope, hostIds, extraHostIds: hosts.extraHosts ?? [], rows };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function main() {
|
|
160
|
+
const { command, scope, json } = parseArgs(process.argv);
|
|
161
|
+
const result = await collectConnectorLinks({ command, scope });
|
|
162
|
+
if (json) {
|
|
163
|
+
console.log(JSON.stringify(result, null, 2));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(`connector command: ${command}`);
|
|
168
|
+
console.log(`scope: ${scope}`);
|
|
169
|
+
console.log(`detected MCP hosts: ${result.hostIds.join(", ") || "(none)"}`);
|
|
170
|
+
console.log("\n== connector links ==");
|
|
171
|
+
|
|
172
|
+
for (const row of result.rows) {
|
|
173
|
+
const mcpStatus = `mcp:${row.mcpStatus}`;
|
|
174
|
+
if (row.instructionStatus === "mcp-only") {
|
|
175
|
+
console.log(`${row.hostId}: ${mcpStatus}; instruction:mcp-only`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
console.log(`${row.hostId}: ${mcpStatus}; instruction:${row.instructionStatus}; ${row.path}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
import { pathToFileURL } from "node:url";
|
|
183
|
+
|
|
184
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
185
|
+
if (isMain) {
|
|
186
|
+
main().catch((error) => {
|
|
187
|
+
console.error(error.message);
|
|
188
|
+
process.exitCode = 1;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { applyTarget } from "./link-connectors.mjs";
|
|
7
|
+
|
|
8
|
+
test("link-connectors: install writes the harness-router marker block", () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-connectors-"));
|
|
10
|
+
const path = join(dir, "AGENTS.md");
|
|
11
|
+
try {
|
|
12
|
+
const result = applyTarget("install", { path, variant: "generic" });
|
|
13
|
+
assert.equal(result.changed, true);
|
|
14
|
+
assert.equal(result.linked, true);
|
|
15
|
+
const content = readFileSync(path, "utf8");
|
|
16
|
+
assert.ok(content.includes("harness-router:start"));
|
|
17
|
+
assert.ok(content.includes("route(task summary)"));
|
|
18
|
+
} finally {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("link-connectors: install is idempotent", () => {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-connectors-"));
|
|
25
|
+
const path = join(dir, "AGENTS.md");
|
|
26
|
+
try {
|
|
27
|
+
applyTarget("install", { path, variant: "generic" });
|
|
28
|
+
const before = readFileSync(path, "utf8");
|
|
29
|
+
const result = applyTarget("install", { path, variant: "generic" });
|
|
30
|
+
assert.equal(result.changed, false);
|
|
31
|
+
assert.equal(readFileSync(path, "utf8"), before);
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("link-connectors: status reports linked/missing without mutating the file", () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-connectors-"));
|
|
39
|
+
const path = join(dir, "AGENTS.md");
|
|
40
|
+
try {
|
|
41
|
+
assert.equal(applyTarget("status", { path, variant: "generic" }).linked, false);
|
|
42
|
+
applyTarget("install", { path, variant: "generic" });
|
|
43
|
+
assert.equal(applyTarget("status", { path, variant: "generic" }).linked, true);
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("link-connectors: remove strips the block and preserves surrounding content", () => {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-connectors-"));
|
|
51
|
+
const path = join(dir, "AGENTS.md");
|
|
52
|
+
try {
|
|
53
|
+
writeFileSync(path, "# My project\n\nSome existing notes.\n");
|
|
54
|
+
applyTarget("install", { path, variant: "generic" });
|
|
55
|
+
applyTarget("remove", { path, variant: "generic" });
|
|
56
|
+
const content = readFileSync(path, "utf8");
|
|
57
|
+
assert.ok(!content.includes("harness-router:start"));
|
|
58
|
+
assert.ok(content.includes("Some existing notes."));
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Owned dedicated rule files (cursor .mdc, windsurf .md) carry frontmatter
|
|
65
|
+
// BEFORE the marker block. Regression guard for the bug where marker-upsert
|
|
66
|
+
// duplicated that frontmatter on every re-run (see applyOwnedTarget).
|
|
67
|
+
test("link-connectors: owned cursor-rule install is idempotent (no duplicate frontmatter)", () => {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-connectors-"));
|
|
69
|
+
const path = join(dir, "harness-router.mdc");
|
|
70
|
+
try {
|
|
71
|
+
const target = { path, variant: "cursor-rule", owned: true };
|
|
72
|
+
assert.equal(applyTarget("install", target).changed, true);
|
|
73
|
+
assert.equal(applyTarget("install", target).changed, false);
|
|
74
|
+
applyTarget("install", target);
|
|
75
|
+
const content = readFileSync(path, "utf8");
|
|
76
|
+
assert.equal((content.match(/alwaysApply: true/g) || []).length, 1);
|
|
77
|
+
assert.ok(content.includes("harness-router:start"));
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("link-connectors: owned remove deletes the file, leaving no orphan always-on rule", () => {
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-connectors-"));
|
|
85
|
+
const path = join(dir, "harness-router.mdc");
|
|
86
|
+
try {
|
|
87
|
+
const target = { path, variant: "cursor-rule", owned: true };
|
|
88
|
+
applyTarget("install", target);
|
|
89
|
+
assert.ok(existsSync(path));
|
|
90
|
+
const result = applyTarget("remove", target);
|
|
91
|
+
assert.equal(result.changed, true);
|
|
92
|
+
assert.equal(existsSync(path), false);
|
|
93
|
+
// remove on an already-absent owned file is a clean no-op
|
|
94
|
+
assert.equal(applyTarget("remove", target).changed, false);
|
|
95
|
+
} finally {
|
|
96
|
+
rmSync(dir, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("link-connectors: owned windsurf-rule writes always_on frontmatter as the first bytes", () => {
|
|
101
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-connectors-"));
|
|
102
|
+
const path = join(dir, "harness-router.md");
|
|
103
|
+
try {
|
|
104
|
+
applyTarget("install", { path, variant: "windsurf-rule", owned: true });
|
|
105
|
+
const content = readFileSync(path, "utf8");
|
|
106
|
+
assert.ok(content.startsWith("---\ntrigger: always_on\n---"));
|
|
107
|
+
assert.ok(content.includes("route(task summary)"));
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|