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,224 @@
1
+ import spawnSync from "cross-spawn";
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+
5
+ const BACKEND_ALIASES = {
6
+ ai: "ai-config-sync",
7
+ "ai-config": "ai-config-sync",
8
+ "ai-config-sync-manager": "ai-config-sync",
9
+ asm: "agent-skill-manager",
10
+ "agent-skills": "agent-skill-manager",
11
+ agents: "agents-dotdir",
12
+ ".agents": "agents-dotdir",
13
+ };
14
+
15
+ const MAX_OUTPUT_CHARS = 12000;
16
+
17
+ export const CONFIG_SYNC_BACKENDS = {
18
+ "ai-config-sync": {
19
+ id: "ai-config-sync",
20
+ label: "ai-config-sync-manager",
21
+ command: "ai-config-sync",
22
+ npxPackage: "ai-config-sync-manager",
23
+ installHint: "npm install -g ai-config-sync-manager",
24
+ supports: {
25
+ status: true,
26
+ preview: true,
27
+ apply: true,
28
+ },
29
+ description: "Bidirectional Claude Code/Codex sync for instructions, skills, agents, MCP, hooks, and permissions.",
30
+ },
31
+ "agent-skill-manager": {
32
+ id: "agent-skill-manager",
33
+ label: "agent-skill-manager",
34
+ command: "agent-skill-manager",
35
+ npxPackage: "agent-skill-manager",
36
+ installHint: "npm install agent-skill-manager or run through npx --yes agent-skill-manager",
37
+ supports: {
38
+ status: true,
39
+ preview: false,
40
+ apply: false,
41
+ },
42
+ description: "Skill provider inventory/probe backend already used by the loader for skill installs.",
43
+ },
44
+ "agents-dotdir": {
45
+ id: "agents-dotdir",
46
+ label: "@agents-dev/cli",
47
+ command: "agents",
48
+ npxPackage: "@agents-dev/cli",
49
+ installHint: "npm install -g @agents-dev/cli",
50
+ supports: {
51
+ status: true,
52
+ preview: true,
53
+ apply: true,
54
+ },
55
+ description: "Project .agents/ source-of-truth sync for MCP servers, skills, and instructions across many hosts.",
56
+ },
57
+ };
58
+
59
+ export function normalizeBackendId(id) {
60
+ const raw = id ?? "ai-config-sync";
61
+ return BACKEND_ALIASES[raw] ?? raw;
62
+ }
63
+
64
+ export function backendById(id) {
65
+ const normalized = normalizeBackendId(id);
66
+ const backend = CONFIG_SYNC_BACKENDS[normalized];
67
+ if (!backend) {
68
+ throw new Error(`unknown backend "${id}". valid: ${Object.keys(CONFIG_SYNC_BACKENDS).join(", ")}`);
69
+ }
70
+ return backend;
71
+ }
72
+
73
+ export function buildBackendArgs(backendId, action, options = {}) {
74
+ const backend = backendById(backendId);
75
+ if (!backend.supports[action]) {
76
+ throw new Error(`${backend.id} does not support ${action}; use status for probe-only backends`);
77
+ }
78
+
79
+ const scope = options.scope && options.scope !== "all" ? ["--scope", options.scope] : [];
80
+ const selectors = [
81
+ ...(options.include ? ["--include", options.include] : []),
82
+ ...(options.exclude ? ["--exclude", options.exclude] : []),
83
+ ];
84
+ const direction = [
85
+ ...(options.from ? ["--from", options.from] : []),
86
+ ...(options.to ? ["--to", options.to] : []),
87
+ ];
88
+
89
+ if (backend.id === "ai-config-sync") {
90
+ if (action === "status") return ["status", "--json", ...scope, ...selectors];
91
+ if (action === "preview") return ["sync", "--dry-run", "--plan-json", ...direction, ...scope, ...selectors];
92
+ return [
93
+ "sync",
94
+ "--apply",
95
+ "--ledger-json",
96
+ ...direction,
97
+ ...scope,
98
+ ...selectors,
99
+ ];
100
+ }
101
+
102
+ if (backend.id === "agent-skill-manager") {
103
+ return ["config", "show"];
104
+ }
105
+
106
+ if (backend.id === "agents-dotdir") {
107
+ if (action === "status") return ["status", "--fast"];
108
+ if (action === "preview") return ["sync", "--check"];
109
+ return ["sync"];
110
+ }
111
+
112
+ throw new Error(`unhandled backend ${backend.id}`);
113
+ }
114
+
115
+ export function runBackend(backendId, action, options = {}, runner = spawnSync.sync) {
116
+ const backend = backendById(backendId);
117
+ const args = buildBackendArgs(backend.id, action, options);
118
+ const invocation = resolveBackendInvocation(backend, args, options);
119
+ const result = runner(invocation.command, invocation.args, {
120
+ cwd: options.cwd,
121
+ encoding: "utf8",
122
+ });
123
+ const errorText = result.error ? `${result.error.code || "ERROR"}: ${result.error.message}` : "";
124
+ const rawOutput = `${result.stdout || ""}${result.stderr || ""}${errorText ? `${errorText}\n` : ""}`;
125
+ const output = trimOutput(rawOutput);
126
+ const parsed = parseLeadingJson(rawOutput);
127
+ const innerErrors = parsed?.summary?.error ?? 0;
128
+ const plannedChanges = isPlannedChangeExit(backend.id, action, result.status, rawOutput);
129
+ const ok = (result.status === 0 || plannedChanges) && innerErrors === 0;
130
+ return {
131
+ backend: backend.id,
132
+ label: backend.label,
133
+ action,
134
+ command: [invocation.command, ...invocation.args],
135
+ ok,
136
+ status: plannedChanges ? "changed" : ok ? "success" : "error",
137
+ summary: ok
138
+ ? plannedChanges
139
+ ? `${backend.label} ${action} found planned changes`
140
+ : `${backend.label} ${action} completed`
141
+ : innerErrors > 0
142
+ ? `${backend.label} ${action} reported ${innerErrors} inner error(s)`
143
+ : `${backend.label} ${action} failed`,
144
+ output,
145
+ outputTruncated: output.length < rawOutput.length,
146
+ parsedSummary: parsed?.summary ?? null,
147
+ installHint: ok || innerErrors > 0 ? null : backend.installHint,
148
+ next_actions: ok
149
+ ? nextActionsForSuccess(backend.id, action)
150
+ : innerErrors > 0
151
+ ? ["Inspect the backend ledger/output; the command ran but reported failed operations."]
152
+ : [`Install or expose the backend command: ${backend.installHint}`, "Re-run sync-config status after install."],
153
+ artifacts: [],
154
+ };
155
+ }
156
+
157
+ function isPlannedChangeExit(backendId, action, status, output) {
158
+ return (
159
+ backendId === "agents-dotdir" &&
160
+ action === "preview" &&
161
+ status !== 0 &&
162
+ /\bWould (create|update|remove) \d+ item\(s\):/.test(output)
163
+ );
164
+ }
165
+
166
+ function trimOutput(output) {
167
+ if (output.length <= MAX_OUTPUT_CHARS) return output;
168
+ return `${output.slice(0, MAX_OUTPUT_CHARS)}\n...[truncated ${output.length - MAX_OUTPUT_CHARS} chars]`;
169
+ }
170
+
171
+ function parseLeadingJson(output) {
172
+ const text = String(output ?? "").trimStart();
173
+ if (!text.startsWith("{")) return null;
174
+ let depth = 0;
175
+ let inString = false;
176
+ let escaped = false;
177
+ for (let i = 0; i < text.length; i += 1) {
178
+ const ch = text[i];
179
+ if (inString) {
180
+ if (escaped) {
181
+ escaped = false;
182
+ } else if (ch === "\\") {
183
+ escaped = true;
184
+ } else if (ch === "\"") {
185
+ inString = false;
186
+ }
187
+ continue;
188
+ }
189
+ if (ch === "\"") {
190
+ inString = true;
191
+ } else if (ch === "{") {
192
+ depth += 1;
193
+ } else if (ch === "}") {
194
+ depth -= 1;
195
+ if (depth === 0) {
196
+ try {
197
+ return JSON.parse(text.slice(0, i + 1));
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+
207
+ function resolveBackendInvocation(backend, args, options = {}) {
208
+ const suffix = process.platform === "win32" ? ".cmd" : "";
209
+ const local = resolve("node_modules", ".bin", `${backend.command}${suffix}`);
210
+ if (existsSync(local)) return { command: local, args };
211
+ if (options.allowNpx || process.env.PORTAWHIP_ALLOW_NPX === "1") {
212
+ return { command: "npx", args: ["--yes", backend.npxPackage, ...args] };
213
+ }
214
+ return { command: backend.command, args };
215
+ }
216
+
217
+ function nextActionsForSuccess(backendId, action) {
218
+ if (backendId === "agent-skill-manager") {
219
+ return ["Use load.mjs for actual skill installs; this backend is probe-only here."];
220
+ }
221
+ if (action === "status") return ["Run preview before apply to inspect drift."];
222
+ if (action === "preview") return ["Review the plan, then run apply only if the changes are intended."];
223
+ return ["Run status again to confirm drift is gone."];
224
+ }
@@ -0,0 +1,205 @@
1
+ import { join } from "node:path";
2
+ import { homedir } from "node:os";
3
+
4
+ const HOME = homedir();
5
+
6
+ function homePath(...parts) {
7
+ return join(HOME, ...parts);
8
+ }
9
+
10
+ function projectPath(...parts) {
11
+ return join(...parts);
12
+ }
13
+
14
+ // Connector targets are data, not decision logic. MCP installation is still
15
+ // delegated to add-mcp; this table only records the instruction surfaces we
16
+ // can safely upsert for hosts that need a reminder to call harness-router.
17
+ export const CONNECTOR_TARGETS = {
18
+ "claude-code": {
19
+ instructionTargets: [
20
+ {
21
+ scope: "global",
22
+ path: homePath(".claude", "CLAUDE.md"),
23
+ variant: "claude-code",
24
+ note: "Claude Code can defer MCP tool schemas, so this block names the ToolSearch fallback.",
25
+ },
26
+ {
27
+ scope: "project",
28
+ path: projectPath("CLAUDE.md"),
29
+ variant: "claude-code",
30
+ },
31
+ ],
32
+ },
33
+ codex: {
34
+ instructionTargets: [
35
+ {
36
+ scope: "global",
37
+ path: homePath(".codex", "AGENTS.md"),
38
+ variant: "generic",
39
+ },
40
+ {
41
+ scope: "project",
42
+ path: projectPath("AGENTS.md"),
43
+ variant: "generic",
44
+ },
45
+ ],
46
+ },
47
+ "gemini-cli": {
48
+ instructionTargets: [
49
+ {
50
+ scope: "global",
51
+ path: homePath(".gemini", "GEMINI.md"),
52
+ variant: "generic",
53
+ },
54
+ {
55
+ scope: "project",
56
+ path: projectPath("GEMINI.md"),
57
+ variant: "generic",
58
+ },
59
+ ],
60
+ },
61
+ cursor: {
62
+ instructionTargets: [
63
+ {
64
+ scope: "project",
65
+ path: projectPath(".cursor", "rules", "harness-router.mdc"),
66
+ variant: "cursor-rule",
67
+ owned: true,
68
+ note: "Cursor project rules live under .cursor/rules; this rule is always applied.",
69
+ },
70
+ {
71
+ scope: "project",
72
+ path: projectPath("AGENTS.md"),
73
+ variant: "generic",
74
+ note: "Cursor also documents AGENTS.md support; keep this as a shared fallback.",
75
+ },
76
+ ],
77
+ },
78
+ // opencode already carries a native hook target (hook-targets.mjs); it also
79
+ // reads AGENTS.md at the project root and a personal global at
80
+ // ~/.config/opencode/AGENTS.md. Both are shared user files -> marker-upsert.
81
+ opencode: {
82
+ instructionTargets: [
83
+ {
84
+ scope: "global",
85
+ path: homePath(".config", "opencode", "AGENTS.md"),
86
+ variant: "generic",
87
+ },
88
+ {
89
+ scope: "project",
90
+ path: projectPath("AGENTS.md"),
91
+ variant: "generic",
92
+ },
93
+ ],
94
+ },
95
+ // Zed reads AGENTS.md as its primary instruction file (project + a personal
96
+ // global alongside settings at ~/.config/zed/AGENTS.md). No documented
97
+ // lifecycle hook API -> reported unsupported by link-hooks, not faked.
98
+ zed: {
99
+ instructionTargets: [
100
+ {
101
+ scope: "global",
102
+ path: homePath(".config", "zed", "AGENTS.md"),
103
+ variant: "generic",
104
+ },
105
+ {
106
+ scope: "project",
107
+ path: projectPath("AGENTS.md"),
108
+ variant: "generic",
109
+ },
110
+ ],
111
+ },
112
+ // Windsurf: a dedicated always-on workspace rule under .windsurf/rules
113
+ // (harness-owned whole file, frontmatter trigger: always_on), plus AGENTS.md
114
+ // as the shared project fallback, plus the always-on personal global memories
115
+ // file. No documented lifecycle hook API -> reported unsupported.
116
+ windsurf: {
117
+ instructionTargets: [
118
+ {
119
+ scope: "project",
120
+ path: projectPath(".windsurf", "rules", "harness-router.md"),
121
+ variant: "windsurf-rule",
122
+ owned: true,
123
+ note: "Windsurf workspace rule; trigger: always_on.",
124
+ },
125
+ {
126
+ scope: "project",
127
+ path: projectPath("AGENTS.md"),
128
+ variant: "generic",
129
+ note: "Windsurf also reads AGENTS.md; keep this as a shared fallback.",
130
+ },
131
+ {
132
+ scope: "global",
133
+ path: homePath(".codeium", "windsurf", "memories", "global_rules.md"),
134
+ variant: "generic",
135
+ note: "Windsurf global memories file is always on.",
136
+ },
137
+ ],
138
+ },
139
+ // Cline combines every .md/.txt file under .clinerules/ into its always-on
140
+ // rule set, so a dedicated harness-owned file drops in cleanly without a
141
+ // frontmatter activation mode. No documented lifecycle hook API.
142
+ cline: {
143
+ instructionTargets: [
144
+ {
145
+ scope: "project",
146
+ path: projectPath(".clinerules", "harness-router.md"),
147
+ variant: "generic",
148
+ owned: true,
149
+ note: "Cline reads all files under .clinerules/ as active rules.",
150
+ },
151
+ ],
152
+ },
153
+ "cline-cli": {
154
+ instructionTargets: [
155
+ {
156
+ scope: "project",
157
+ path: projectPath(".clinerules", "harness-router.md"),
158
+ variant: "generic",
159
+ owned: true,
160
+ note: "Cline CLI shares the .clinerules/ convention.",
161
+ },
162
+ ],
163
+ },
164
+ "github-copilot-cli": {
165
+ instructionTargets: [
166
+ {
167
+ scope: "project",
168
+ path: projectPath(".github", "copilot-instructions.md"),
169
+ variant: "generic",
170
+ },
171
+ ],
172
+ },
173
+ // Pi (earendil-works/pi): reads AGENTS.md walking up from cwd, plus a global
174
+ // ~/.pi/agent/AGENTS.md. Both are shared instruction files -> marker upsert.
175
+ // Source: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md
176
+ pi: {
177
+ instructionTargets: [
178
+ { scope: "global", path: homePath(".pi", "agent", "AGENTS.md"), variant: "generic" },
179
+ { scope: "project", path: projectPath("AGENTS.md"), variant: "generic" },
180
+ ],
181
+ },
182
+ // Sourcegraph Amp: reads project AGENTS.md and a personal ~/.config/AGENTS.md.
183
+ // Source: https://ampcode.com/manual
184
+ amp: {
185
+ instructionTargets: [
186
+ { scope: "global", path: homePath(".config", "AGENTS.md"), variant: "generic" },
187
+ { scope: "project", path: projectPath("AGENTS.md"), variant: "generic" },
188
+ ],
189
+ },
190
+ vscode: {
191
+ instructionTargets: [
192
+ {
193
+ scope: "project",
194
+ path: projectPath(".github", "copilot-instructions.md"),
195
+ variant: "generic",
196
+ },
197
+ ],
198
+ },
199
+ };
200
+
201
+ export function targetsForHost(hostId, { scope = "project" } = {}) {
202
+ const targets = CONNECTOR_TARGETS[hostId]?.instructionTargets ?? [];
203
+ return targets.filter((target) => target.scope === scope);
204
+ }
205
+
@@ -0,0 +1,151 @@
1
+ // Embedded-hook inventory (Phase S3, mode B — scan + inventory only, NO
2
+ // linking). Hooks bundled inside skills/plugins run third-party commands on
3
+ // lifecycle events; today they are invisible to portawhip's own hook sync.
4
+ // This module finds them and reports what/where they are so a later,
5
+ // per-item-approved link step (S3 full) has something concrete to act on.
6
+ //
7
+ // Owns no linking and executes nothing — it only reads hooks.json files and
8
+ // extracts each declared command. Command bodies are surfaced verbatim
9
+ // (truncated for display, full source path kept) precisely because a human
10
+ // must see the exact command before ever choosing to activate it.
11
+
12
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { homedir } from "node:os";
15
+
16
+ const HOOK_SCAN_MAX_DEPTH = 9;
17
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
18
+ const COMMAND_PREVIEW_CHARS = 160;
19
+
20
+ export function defaultHookScanRoots() {
21
+ return [
22
+ join(homedir(), ".claude", "plugins", "cache"),
23
+ join(homedir(), ".claude", "plugins", "marketplaces"),
24
+ join(homedir(), ".claude", "skills"),
25
+ join(homedir(), ".codex", "skills"),
26
+ ];
27
+ }
28
+
29
+ // Pure: which host a hooks.json belongs to + whether it looks like a live
30
+ // hook or a shipped template, inferred from its path segments (data, not a
31
+ // decision that changes behavior — inventory metadata only).
32
+ export function classifyHookPath(sourcePath) {
33
+ const segs = String(sourcePath).split(/[\\/]/);
34
+ const host = segs.includes(".codex")
35
+ ? "codex"
36
+ : segs.includes(".cursor")
37
+ ? "cursor"
38
+ : segs.includes(".gemini")
39
+ ? "gemini-cli"
40
+ : "claude-code";
41
+ const template = segs.includes("scaffolds") || segs.includes("templates") || segs.includes("schemas");
42
+ const pkg = inferPackage(segs);
43
+ return { host, template, pkg };
44
+ }
45
+
46
+ function inferPackage(segs) {
47
+ const i = segs.lastIndexOf("plugins");
48
+ if (i !== -1 && segs[i + 1]) {
49
+ // .../plugins/cache/<pkg>/... or .../plugins/marketplaces/<mp>/plugins/<pkg>/...
50
+ const after = segs.slice(i + 1).filter((s) => s !== "cache" && s !== "marketplaces");
51
+ return after[0] ?? "unknown";
52
+ }
53
+ const s = segs.lastIndexOf("skills");
54
+ if (s !== -1 && segs[s + 1]) return segs[s + 1];
55
+ return "unknown";
56
+ }
57
+
58
+ function truncate(text, max = COMMAND_PREVIEW_CHARS) {
59
+ const t = String(text ?? "").replace(/\s+/g, " ").trim();
60
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
61
+ }
62
+
63
+ // Pure: extract every command hook from a parsed hooks.json object.
64
+ export function parseEmbeddedHooks(json, sourcePath) {
65
+ if (!json || typeof json.hooks !== "object" || json.hooks === null) return [];
66
+ const { host, template, pkg } = classifyHookPath(sourcePath);
67
+ const out = [];
68
+ for (const [event, groups] of Object.entries(json.hooks)) {
69
+ if (!Array.isArray(groups)) continue;
70
+ for (const group of groups) {
71
+ const matcher = group?.matcher ?? null;
72
+ for (const hook of group?.hooks ?? []) {
73
+ if (hook?.type !== "command" || typeof hook.command !== "string") continue;
74
+ out.push({
75
+ id: group?.id ?? `${pkg}:${event}${matcher ? `:${matcher}` : ""}`,
76
+ package: pkg,
77
+ host,
78
+ event,
79
+ matcher,
80
+ template,
81
+ commandPreview: truncate(hook.command),
82
+ commandLength: hook.command.length,
83
+ timeout: hook.timeout ?? null,
84
+ source: sourcePath,
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function hookJsonFilesUnder(root, maxDepth = HOOK_SCAN_MAX_DEPTH) {
93
+ const found = [];
94
+ if (!existsSync(root)) return found;
95
+ const stack = [{ dir: root, depth: 0 }];
96
+ while (stack.length > 0) {
97
+ const { dir, depth } = stack.pop();
98
+ let dirents;
99
+ try {
100
+ dirents = readdirSync(dir, { withFileTypes: true });
101
+ } catch {
102
+ continue;
103
+ }
104
+ for (const dirent of dirents) {
105
+ const path = join(dir, dirent.name);
106
+ if (dirent.isFile() && dirent.name === "hooks.json") {
107
+ found.push(path);
108
+ continue;
109
+ }
110
+ if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
111
+ if (SKIP_DIRS.has(dirent.name)) continue;
112
+ if (depth >= maxDepth) continue;
113
+ stack.push({ dir: path, depth: depth + 1 });
114
+ }
115
+ }
116
+ return found;
117
+ }
118
+
119
+ // Inventory across all roots. Deduped by (source-relative) identity so the
120
+ // same package mirrored in cache + marketplaces isn't double-counted.
121
+ export function discoverEmbeddedHooks(roots = defaultHookScanRoots()) {
122
+ const seen = new Map();
123
+ for (const root of roots) {
124
+ for (const file of hookJsonFilesUnder(root)) {
125
+ let json;
126
+ try {
127
+ json = JSON.parse(readFileSync(file, "utf8"));
128
+ } catch {
129
+ continue; // a malformed hooks.json must not break the whole scan
130
+ }
131
+ for (const entry of parseEmbeddedHooks(json, file)) {
132
+ const key = `${entry.package}:${entry.host}:${entry.event}:${entry.matcher ?? ""}:${entry.commandPreview}`;
133
+ if (!seen.has(key)) seen.set(key, entry);
134
+ }
135
+ }
136
+ }
137
+ return [...seen.values()];
138
+ }
139
+
140
+ // Compact summary for doctor / surface matrix.
141
+ export function summarizeEmbeddedHooks(entries = discoverEmbeddedHooks()) {
142
+ const byPackage = {};
143
+ const byHost = {};
144
+ let templates = 0;
145
+ for (const e of entries) {
146
+ byPackage[e.package] = (byPackage[e.package] ?? 0) + 1;
147
+ byHost[e.host] = (byHost[e.host] ?? 0) + 1;
148
+ if (e.template) templates += 1;
149
+ }
150
+ return { total: entries.length, active: entries.length - templates, templates, byHost, byPackage };
151
+ }
@@ -0,0 +1,63 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseEmbeddedHooks, classifyHookPath, summarizeEmbeddedHooks } from "./discover-hooks.mjs";
4
+
5
+ const HOOKS_JSON = {
6
+ description: "demo",
7
+ hooks: {
8
+ PreToolUse: [
9
+ {
10
+ matcher: "Bash",
11
+ id: "pre:bash",
12
+ hooks: [{ type: "command", command: "node dispatch.js", timeout: 30 }],
13
+ },
14
+ ],
15
+ UserPromptSubmit: [
16
+ { hooks: [{ type: "command", command: "bash remind.sh" }, { type: "other", command: "ignored" }] },
17
+ ],
18
+ },
19
+ };
20
+
21
+ test("parseEmbeddedHooks: extracts command hooks with id/event/matcher, skips non-command", () => {
22
+ const out = parseEmbeddedHooks(HOOKS_JSON, "/home/.claude/plugins/cache/ecc/ecc/2.0.0/hooks/hooks.json");
23
+ assert.equal(out.length, 2);
24
+ const pre = out.find((h) => h.event === "PreToolUse");
25
+ assert.equal(pre.id, "pre:bash");
26
+ assert.equal(pre.matcher, "Bash");
27
+ assert.equal(pre.package, "ecc");
28
+ assert.equal(pre.host, "claude-code");
29
+ assert.equal(pre.commandPreview, "node dispatch.js");
30
+ // synthesized id when none is declared
31
+ const ups = out.find((h) => h.event === "UserPromptSubmit");
32
+ assert.equal(ups.id, "ecc:UserPromptSubmit");
33
+ });
34
+
35
+ test("parseEmbeddedHooks: no hooks key or non-object -> empty", () => {
36
+ assert.deepEqual(parseEmbeddedHooks({}, "/x/hooks.json"), []);
37
+ assert.deepEqual(parseEmbeddedHooks(null, "/x/hooks.json"), []);
38
+ });
39
+
40
+ test("classifyHookPath: host + template + package from path segments", () => {
41
+ assert.equal(classifyHookPath("/h/.codex/hooks.json").host, "codex");
42
+ assert.equal(classifyHookPath("/h/.cursor/x/hooks.json").host, "cursor");
43
+ assert.equal(classifyHookPath("/h/.claude/plugins/cache/ecc/ecc/2.0.0/hooks/hooks.json").host, "claude-code");
44
+ assert.equal(classifyHookPath("/h/.claude/plugins/cache/ecc/ecc/2.0.0/scaffolds/cursor/hooks.json").template, true);
45
+ assert.equal(
46
+ classifyHookPath("/h/.claude/plugins/marketplaces/claude-plugins-official/plugins/hookify/hooks/hooks.json").pkg,
47
+ "hookify",
48
+ );
49
+ });
50
+
51
+ test("summarizeEmbeddedHooks: counts total/active/templates and groups", () => {
52
+ const entries = [
53
+ { package: "a", host: "claude-code", template: false },
54
+ { package: "a", host: "claude-code", template: false },
55
+ { package: "b", host: "codex", template: true },
56
+ ];
57
+ const s = summarizeEmbeddedHooks(entries);
58
+ assert.equal(s.total, 3);
59
+ assert.equal(s.active, 2);
60
+ assert.equal(s.templates, 1);
61
+ assert.deepEqual(s.byPackage, { a: 2, b: 1 });
62
+ assert.deepEqual(s.byHost, { "claude-code": 2, codex: 1 });
63
+ });
@@ -0,0 +1,49 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { discoverCommands, discoverAgents, defaultCommandRoots, defaultAgentRoots } from "../registry/discover.mjs";
7
+ import { homedir } from "node:os";
8
+
9
+ function fixtureRoot() {
10
+ return mkdtempSync(join(tmpdir(), "discover-surface-"));
11
+ }
12
+
13
+ test("discoverCommands: reads a host-native leaf dir directly (basename == segment)", () => {
14
+ const root = fixtureRoot();
15
+ const commandsDir = join(root, "commands");
16
+ mkdirSync(commandsDir, { recursive: true });
17
+ writeFileSync(join(commandsDir, "deploy.md"), "---\nname: deploy\ndescription: Ship it\n---\n# deploy\n");
18
+ const found = discoverCommands([commandsDir]);
19
+ const deploy = found.find((c) => c.id === "deploy");
20
+ assert.ok(deploy, "leaf-dir command discovered");
21
+ assert.equal(deploy.type, "command");
22
+ assert.ok(deploy.route.triggers.includes("/deploy"));
23
+ });
24
+
25
+ test("discoverAgents: reads a host-native leaf dir directly", () => {
26
+ const root = fixtureRoot();
27
+ const agentsDir = join(root, "agents");
28
+ mkdirSync(agentsDir, { recursive: true });
29
+ writeFileSync(join(agentsDir, "reviewer.md"), "---\nname: reviewer\ndescription: Reviews code\n---\n");
30
+ const found = discoverAgents([agentsDir]);
31
+ assert.ok(found.find((a) => a.id === "reviewer" && a.type === "agent"));
32
+ });
33
+
34
+ test("discoverCommands: still finds a segment dir nested under a plugin root", () => {
35
+ const root = fixtureRoot();
36
+ const nested = join(root, "someplugin", "commands");
37
+ mkdirSync(nested, { recursive: true });
38
+ writeFileSync(join(nested, "build.md"), "---\nname: build\ndescription: Build it\n---\n");
39
+ const found = discoverCommands([root]);
40
+ assert.ok(found.find((c) => c.id === "build"), "nested segment-walk still works");
41
+ });
42
+
43
+ test("defaultCommandRoots/defaultAgentRoots include host-native claude dirs", () => {
44
+ const cmd = defaultCommandRoots();
45
+ const ag = defaultAgentRoots();
46
+ assert.ok(cmd.some((p) => p.endsWith(join(".claude", "commands"))));
47
+ assert.ok(cmd.some((p) => p === join(homedir(), ".claude", "commands")));
48
+ assert.ok(ag.some((p) => p.endsWith(join(".claude", "agents"))));
49
+ });