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,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sync native lifecycle hooks across agent hosts from hooks.manifest.yaml.
|
|
3
|
+
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { detectHosts } from "../hosts.mjs";
|
|
8
|
+
import { HOOK_TARGETS, LOGICAL_HOOKS, LOGICAL_EVENT_TO_MANIFEST, hookTargetForHost } from "../../core/surface/hook-targets.mjs";
|
|
9
|
+
|
|
10
|
+
const VALID_COMMANDS = new Set(["status", "install", "remove"]);
|
|
11
|
+
const VALID_SCOPES = new Set(["project", "global"]);
|
|
12
|
+
const RUNNER = resolve("adapters", "hooks", "universal-hook.mjs");
|
|
13
|
+
const MARKER = "universal-hook.mjs";
|
|
14
|
+
|
|
15
|
+
// HARNESS_ROUTER_STUB_HOME override exists only so tests can point this at a
|
|
16
|
+
// tmp dir instead of touching the real home directory.
|
|
17
|
+
const STUB_HOME = process.env.HARNESS_ROUTER_STUB_HOME || homedir();
|
|
18
|
+
const STUB_PATH = resolve(STUB_HOME, ".harness-router", "hook-stub.mjs");
|
|
19
|
+
|
|
20
|
+
function ensureStub() {
|
|
21
|
+
const source = readFileSync(resolve("adapters", "hooks", "hook-stub.mjs"), "utf8");
|
|
22
|
+
mkdirSync(dirname(STUB_PATH), { recursive: true });
|
|
23
|
+
const current = existsSync(STUB_PATH) ? readFileSync(STUB_PATH, "utf8") : null;
|
|
24
|
+
if (current !== source) writeFileSync(STUB_PATH, source);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const args = { command: argv[2] ?? "status", scope: "project", json: false };
|
|
29
|
+
for (let i = 3; i < argv.length; i += 1) {
|
|
30
|
+
if (argv[i] === "--scope") {
|
|
31
|
+
args.scope = argv[i + 1];
|
|
32
|
+
i += 1;
|
|
33
|
+
} else if (argv[i] === "--json") {
|
|
34
|
+
args.json = true;
|
|
35
|
+
} else {
|
|
36
|
+
throw new Error(`unknown argument: ${argv[i]}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!VALID_COMMANDS.has(args.command)) {
|
|
40
|
+
throw new Error("usage: link-hooks.mjs <status|install|remove> [--scope project|global]");
|
|
41
|
+
}
|
|
42
|
+
if (!VALID_SCOPES.has(args.scope)) throw new Error(`invalid scope "${args.scope}"`);
|
|
43
|
+
return args;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJson(path) {
|
|
47
|
+
if (!existsSync(path)) return {};
|
|
48
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeJson(path, value) {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function commandFor(hostId, logicalEvent, nativeEvent) {
|
|
57
|
+
return `"${process.execPath}" "${STUB_PATH}" --target "${RUNNER}" --host ${hostId} --event ${logicalEvent} --nativeEvent ${nativeEvent}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hookObject(hostId, logicalEvent, nativeEvent, timeout) {
|
|
61
|
+
return {
|
|
62
|
+
type: "command",
|
|
63
|
+
command: commandFor(hostId, logicalEvent, nativeEvent),
|
|
64
|
+
timeout,
|
|
65
|
+
statusMessage: `harness-router ${logicalEvent}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function eventGroup(hostId, logicalEvent, nativeEvent, format) {
|
|
70
|
+
const base = {
|
|
71
|
+
matcher: logicalEvent === "post_tool" ? "*" : undefined,
|
|
72
|
+
hooks: [hookObject(hostId, logicalEvent, nativeEvent, format === "gemini-settings-json" ? 5000 : 30)],
|
|
73
|
+
};
|
|
74
|
+
if (format === "gemini-settings-json") {
|
|
75
|
+
base.hooks[0].name = `harness-router-${logicalEvent}`;
|
|
76
|
+
const manifestKey = LOGICAL_EVENT_TO_MANIFEST[logicalEvent];
|
|
77
|
+
if (manifestKey) base.hooks[0].description = LOGICAL_HOOKS[manifestKey].description;
|
|
78
|
+
base.sequential = false;
|
|
79
|
+
}
|
|
80
|
+
return Object.fromEntries(Object.entries(base).filter(([, value]) => value !== undefined));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasHook(config, nativeEvent) {
|
|
84
|
+
return (config.hooks?.[nativeEvent] ?? []).some((group) =>
|
|
85
|
+
(group.hooks ?? []).some((hook) => typeof hook.command === "string" && hook.command.includes(MARKER)),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function removeHook(config, nativeEvent) {
|
|
90
|
+
if (!config.hooks?.[nativeEvent]) return false;
|
|
91
|
+
const before = JSON.stringify(config.hooks[nativeEvent]);
|
|
92
|
+
config.hooks[nativeEvent] = config.hooks[nativeEvent]
|
|
93
|
+
.map((group) => ({
|
|
94
|
+
...group,
|
|
95
|
+
hooks: (group.hooks ?? []).filter(
|
|
96
|
+
(hook) => !(typeof hook.command === "string" && hook.command.includes(MARKER)),
|
|
97
|
+
),
|
|
98
|
+
}))
|
|
99
|
+
.filter((group) => (group.hooks ?? []).length > 0);
|
|
100
|
+
if (config.hooks[nativeEvent].length === 0) delete config.hooks[nativeEvent];
|
|
101
|
+
return JSON.stringify(config.hooks[nativeEvent] ?? []) !== before;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function installJsonHooks(hostId, target) {
|
|
105
|
+
ensureStub();
|
|
106
|
+
const config = readJson(target.path);
|
|
107
|
+
config.hooks = config.hooks ?? {};
|
|
108
|
+
let changed = false;
|
|
109
|
+
|
|
110
|
+
for (const [logicalEvent, nativeEvent] of Object.entries(target.events)) {
|
|
111
|
+
if (!nativeEvent) continue;
|
|
112
|
+
if (hasHook(config, nativeEvent)) continue;
|
|
113
|
+
config.hooks[nativeEvent] = config.hooks[nativeEvent] ?? [];
|
|
114
|
+
config.hooks[nativeEvent].push(eventGroup(hostId, logicalEvent, nativeEvent, target.format));
|
|
115
|
+
changed = true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (changed) writeJson(target.path, config);
|
|
119
|
+
return changed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function removeJsonHooks(target) {
|
|
123
|
+
if (!existsSync(target.path)) return false;
|
|
124
|
+
const config = readJson(target.path);
|
|
125
|
+
let changed = false;
|
|
126
|
+
for (const nativeEvent of Object.values(target.events)) {
|
|
127
|
+
if (nativeEvent) changed = removeHook(config, nativeEvent) || changed;
|
|
128
|
+
}
|
|
129
|
+
if (changed) writeJson(target.path, config);
|
|
130
|
+
return changed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function statusJsonHooks(target) {
|
|
134
|
+
if (!existsSync(target.path)) return { linked: false, details: [] };
|
|
135
|
+
const config = readJson(target.path);
|
|
136
|
+
const details = Object.entries(target.events)
|
|
137
|
+
.filter(([, nativeEvent]) => nativeEvent)
|
|
138
|
+
.map(([logicalEvent, nativeEvent]) => `${logicalEvent}:${hasHook(config, nativeEvent) ? "linked" : "missing"}`);
|
|
139
|
+
return { linked: details.length > 0 && details.every((item) => item.endsWith(":linked")), details };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function opencodePluginSource() {
|
|
143
|
+
const runner = RUNNER.replace(/\\/g, "\\\\");
|
|
144
|
+
return `// Generated by portable-harness-v2. Do not hand-edit unless you also update scripts/link-hooks.mjs.
|
|
145
|
+
import { spawnSync } from "node:child_process";
|
|
146
|
+
import { existsSync } from "node:fs";
|
|
147
|
+
|
|
148
|
+
function run(event, payload) {
|
|
149
|
+
if (!existsSync("${runner}")) return; // harness repo deleted - silent no-op, not an error
|
|
150
|
+
spawnSync(process.execPath, ["${runner}", "--host", "opencode", "--event", event, "--nativeEvent", event], {
|
|
151
|
+
input: JSON.stringify(payload ?? {}),
|
|
152
|
+
encoding: "utf8",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const HarnessRouterPlugin = async () => ({
|
|
157
|
+
"tool.execute.after": async (input, output) => {
|
|
158
|
+
run("post_tool", {
|
|
159
|
+
tool_name: input?.tool,
|
|
160
|
+
tool_input: output?.args,
|
|
161
|
+
session_id: input?.sessionID ?? null,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function applyTarget(command, hostId, target) {
|
|
169
|
+
if (target.kind === "plugin-file") {
|
|
170
|
+
if (command === "status") return { action: existsSync(target.path) ? "linked" : "missing" };
|
|
171
|
+
if (command === "remove") {
|
|
172
|
+
return { action: existsSync(target.path) ? "manual-remove" : "no-op" };
|
|
173
|
+
}
|
|
174
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
175
|
+
const next = opencodePluginSource();
|
|
176
|
+
const changed = !existsSync(target.path) || readFileSync(target.path, "utf8") !== next;
|
|
177
|
+
if (changed) writeFileSync(target.path, next);
|
|
178
|
+
return { action: changed ? "changed" : "no-op" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (command === "status") {
|
|
182
|
+
const status = statusJsonHooks(target);
|
|
183
|
+
return { action: status.linked ? "linked" : "missing", details: status.details };
|
|
184
|
+
}
|
|
185
|
+
const changed = command === "install" ? installJsonHooks(hostId, target) : removeJsonHooks(target);
|
|
186
|
+
return { action: changed ? "changed" : "no-op" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function collectHookLinks({ command = "status", scope = "project" } = {}) {
|
|
190
|
+
const hosts = await detectHosts();
|
|
191
|
+
const hostIds = hosts.mcpHosts;
|
|
192
|
+
const rows = [];
|
|
193
|
+
|
|
194
|
+
for (const hostId of hostIds) {
|
|
195
|
+
const target = hookTargetForHost(hostId, { scope });
|
|
196
|
+
if (!target) {
|
|
197
|
+
rows.push({
|
|
198
|
+
type: "hook",
|
|
199
|
+
hostId,
|
|
200
|
+
scope,
|
|
201
|
+
supported: false,
|
|
202
|
+
action: "unsupported",
|
|
203
|
+
status: "unsupported",
|
|
204
|
+
path: null,
|
|
205
|
+
details: [],
|
|
206
|
+
note: null,
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const result = applyTarget(command, hostId, target);
|
|
211
|
+
rows.push({
|
|
212
|
+
type: "hook",
|
|
213
|
+
hostId,
|
|
214
|
+
scope,
|
|
215
|
+
supported: true,
|
|
216
|
+
action: result.action,
|
|
217
|
+
status: result.action,
|
|
218
|
+
path: resolve(target.path),
|
|
219
|
+
details: result.details ?? [],
|
|
220
|
+
note: target.note ?? null,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { command, scope, hostIds, rows };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function main() {
|
|
228
|
+
const { command, scope, json } = parseArgs(process.argv);
|
|
229
|
+
const result = await collectHookLinks({ command, scope });
|
|
230
|
+
if (json) {
|
|
231
|
+
console.log(JSON.stringify(result, null, 2));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log(`hook command: ${command}`);
|
|
236
|
+
console.log(`scope: ${scope}`);
|
|
237
|
+
console.log(`detected MCP hosts: ${result.hostIds.join(", ") || "(none)"}`);
|
|
238
|
+
console.log("\n== hook links ==");
|
|
239
|
+
|
|
240
|
+
for (const row of result.rows) {
|
|
241
|
+
if (!row.supported) {
|
|
242
|
+
console.log(`${row.hostId}: hooks:unsupported`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const detail = row.details.length ? ` (${row.details.join(", ")})` : "";
|
|
246
|
+
const note = row.note ? `; ${row.note}` : "";
|
|
247
|
+
console.log(`${row.hostId}: hooks:${row.action}; ${row.path}${detail}${note}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
import { pathToFileURL } from "node:url";
|
|
252
|
+
|
|
253
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
254
|
+
if (isMain) {
|
|
255
|
+
main().catch((error) => {
|
|
256
|
+
console.error(error.message);
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { test, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
// Must be set before link-hooks.mjs is imported (STUB_HOME is read once at
|
|
8
|
+
// module load) so ensureStub() never touches the real home directory.
|
|
9
|
+
const stubHome = mkdtempSync(join(tmpdir(), "harness-stub-home-"));
|
|
10
|
+
process.env.HARNESS_ROUTER_STUB_HOME = stubHome;
|
|
11
|
+
after(() => rmSync(stubHome, { recursive: true, force: true }));
|
|
12
|
+
|
|
13
|
+
const { installJsonHooks, removeJsonHooks, statusJsonHooks } = await import("./link-hooks.mjs");
|
|
14
|
+
|
|
15
|
+
function fakeTarget(path, overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
path,
|
|
18
|
+
events: { user_prompt: "UserPromptSubmit", post_tool: "PostToolUse" },
|
|
19
|
+
format: "claude-code",
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("link-hooks: install writes a hook containing the universal-hook marker", () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-hooks-"));
|
|
26
|
+
const path = join(dir, "settings.json");
|
|
27
|
+
try {
|
|
28
|
+
const target = fakeTarget(path);
|
|
29
|
+
const changed = installJsonHooks("test-host", target);
|
|
30
|
+
assert.equal(changed, true);
|
|
31
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
32
|
+
assert.ok(config.hooks.UserPromptSubmit[0].hooks[0].command.includes("universal-hook.mjs"));
|
|
33
|
+
assert.ok(config.hooks.PostToolUse[0].hooks[0].command.includes("universal-hook.mjs"));
|
|
34
|
+
} finally {
|
|
35
|
+
rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("link-hooks: install routes the command through the outside-repo stub, not the repo file directly", () => {
|
|
40
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-hooks-"));
|
|
41
|
+
const path = join(dir, "settings.json");
|
|
42
|
+
try {
|
|
43
|
+
const target = fakeTarget(path);
|
|
44
|
+
installJsonHooks("test-host", target);
|
|
45
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
46
|
+
const command = config.hooks.UserPromptSubmit[0].hooks[0].command;
|
|
47
|
+
|
|
48
|
+
const stubPath = resolve(stubHome, ".harness-router", "hook-stub.mjs");
|
|
49
|
+
assert.ok(command.includes(stubPath), "command should invoke the stub path");
|
|
50
|
+
assert.ok(command.includes("--target"), "command should pass the real runner via --target");
|
|
51
|
+
|
|
52
|
+
const stubContent = readFileSync(stubPath, "utf8");
|
|
53
|
+
const sourceContent = readFileSync(resolve("adapters", "hooks", "hook-stub.mjs"), "utf8");
|
|
54
|
+
assert.equal(stubContent, sourceContent);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(dir, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("link-hooks: install is idempotent (second run is a no-op)", () => {
|
|
61
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-hooks-"));
|
|
62
|
+
const path = join(dir, "settings.json");
|
|
63
|
+
try {
|
|
64
|
+
const target = fakeTarget(path);
|
|
65
|
+
installJsonHooks("test-host", target);
|
|
66
|
+
const before = readFileSync(path, "utf8");
|
|
67
|
+
const changedAgain = installJsonHooks("test-host", target);
|
|
68
|
+
assert.equal(changedAgain, false);
|
|
69
|
+
assert.equal(readFileSync(path, "utf8"), before);
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(dir, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("link-hooks: status reflects install/remove transitions", () => {
|
|
76
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-hooks-"));
|
|
77
|
+
const path = join(dir, "settings.json");
|
|
78
|
+
try {
|
|
79
|
+
const target = fakeTarget(path);
|
|
80
|
+
assert.equal(statusJsonHooks(target).linked, false);
|
|
81
|
+
installJsonHooks("test-host", target);
|
|
82
|
+
assert.equal(statusJsonHooks(target).linked, true);
|
|
83
|
+
removeJsonHooks(target);
|
|
84
|
+
assert.equal(statusJsonHooks(target).linked, false);
|
|
85
|
+
} finally {
|
|
86
|
+
rmSync(dir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("link-hooks: remove only strips our hook, leaves unrelated hooks in the same event untouched", () => {
|
|
91
|
+
const dir = mkdtempSync(join(tmpdir(), "harness-hooks-"));
|
|
92
|
+
const path = join(dir, "settings.json");
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(dir, { recursive: true });
|
|
95
|
+
writeFileSync(
|
|
96
|
+
path,
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
hooks: {
|
|
99
|
+
UserPromptSubmit: [{ hooks: [{ type: "command", command: "some-other-hook.mjs" }] }],
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
const target = fakeTarget(path);
|
|
104
|
+
installJsonHooks("test-host", target);
|
|
105
|
+
removeJsonHooks(target);
|
|
106
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
107
|
+
const commands = config.hooks.UserPromptSubmit.flatMap((g) => g.hooks.map((h) => h.command));
|
|
108
|
+
assert.deepEqual(commands, ["some-other-hook.mjs"]);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Managed-copy fan-out for slash commands + subagent defs (Phase S2 write).
|
|
3
|
+
//
|
|
4
|
+
// mise/asm don't install these surfaces, so this lane copies each canonical
|
|
5
|
+
// command/agent markdown file into every markdown-compatible host's native
|
|
6
|
+
// dir, exactly the link-hooks pattern: a managed marker makes install
|
|
7
|
+
// idempotent and remove precise, and hosts whose format differs (gemini TOML)
|
|
8
|
+
// are reported unsupported instead of fed a file that wouldn't load.
|
|
9
|
+
//
|
|
10
|
+
// Canonical source = command/agent entries in the resolved recipe set
|
|
11
|
+
// (recipe.yaml + recipes/imported.yaml + bundles) that carry a `path`. The
|
|
12
|
+
// file already living under one host's dir is never copied back onto itself.
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
|
15
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
import { readActiveSelection, resolveRecipePaths } from "../../core/state/bundle-state.mjs";
|
|
17
|
+
import { readRawEntries } from "../../core/registry/registry.mjs";
|
|
18
|
+
import { SURFACE_COPY_TARGETS } from "../../core/surface/surface-copy-targets.mjs";
|
|
19
|
+
import { detectHosts } from "../hosts.mjs";
|
|
20
|
+
|
|
21
|
+
// Present hosts = whatever add-mcp detects plus the extra hosts we detect
|
|
22
|
+
// ourselves, as a Set for the copy-target presence gate.
|
|
23
|
+
export async function presentHostSet() {
|
|
24
|
+
const hosts = await detectHosts();
|
|
25
|
+
return new Set([...(hosts.mcpHosts ?? []), ...(hosts.extraHosts ?? [])]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const VALID_COMMANDS = new Set(["status", "install", "remove"]);
|
|
29
|
+
const VALID_SCOPES = new Set(["project", "global"]);
|
|
30
|
+
const SURFACE_TYPES = ["command", "agent"];
|
|
31
|
+
export const MARKER = "portawhip-managed";
|
|
32
|
+
|
|
33
|
+
function markerLine(id) {
|
|
34
|
+
return `<!-- ${MARKER}: ${id} -->`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Pure: canonical command/agent entries (with a source path) from raw recipe
|
|
38
|
+
// entries. Deduped first-seen, matching the rest of the registry.
|
|
39
|
+
export function canonicalSurfaceEntries(rawEntries) {
|
|
40
|
+
const seen = new Map();
|
|
41
|
+
for (const entry of rawEntries) {
|
|
42
|
+
if (!SURFACE_TYPES.includes(entry.type)) continue;
|
|
43
|
+
if (!entry.path) continue; // nothing to copy without a source file
|
|
44
|
+
if (!seen.has(entry.id)) seen.set(entry.id, entry);
|
|
45
|
+
}
|
|
46
|
+
return [...seen.values()];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Pure: does this target dir already contain the source file (i.e. this is the
|
|
50
|
+
// host the file came from)? Then it's the source, never a copy destination.
|
|
51
|
+
export function isSourceDir(sourcePath, targetDir) {
|
|
52
|
+
const src = resolve(sourcePath).replace(/\\/g, "/");
|
|
53
|
+
const dir = resolve(targetDir).replace(/\\/g, "/");
|
|
54
|
+
return src.startsWith(`${dir}/`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pure: the file content with a managed marker injected right after the
|
|
58
|
+
// frontmatter block (or at the very top if there is none). Idempotent — an
|
|
59
|
+
// already-marked file is returned unchanged.
|
|
60
|
+
export function withMarker(content, id) {
|
|
61
|
+
if (content.includes(markerLine(id)) || content.includes(`<!-- ${MARKER}:`)) return content;
|
|
62
|
+
const fm = content.match(/^(---\r?\n[\s\S]*?\r?\n---\r?\n)/);
|
|
63
|
+
if (fm) return `${fm[1]}${markerLine(id)}\n${content.slice(fm[1].length)}`;
|
|
64
|
+
return `${markerLine(id)}\n${content}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isManaged(content) {
|
|
68
|
+
return typeof content === "string" && content.includes(`<!-- ${MARKER}:`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function targetFileFor(dir, sourcePath) {
|
|
72
|
+
return join(dir, basename(sourcePath));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// One (entry x host x target) plan row. Pure given fs facts injected.
|
|
76
|
+
function planRow({ entry, hostId, type, target }) {
|
|
77
|
+
const row = { hostId, type, id: entry.id, scope: target.scope, dir: target.dir ?? null };
|
|
78
|
+
if (target.unsupported) return { ...row, status: "unsupported", format: target.format };
|
|
79
|
+
if (isSourceDir(entry.path, target.dir)) return { ...row, status: "source" };
|
|
80
|
+
const file = targetFileFor(target.dir, entry.path);
|
|
81
|
+
const present = existsSync(file) && isManaged(safeRead(file));
|
|
82
|
+
return { ...row, file, status: present ? "linked" : "missing" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function safeRead(path) {
|
|
86
|
+
try {
|
|
87
|
+
return readFileSync(path, "utf8");
|
|
88
|
+
} catch {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function collectSurfaceLinks({
|
|
94
|
+
command = "status",
|
|
95
|
+
scope = "global",
|
|
96
|
+
root = resolve("."),
|
|
97
|
+
targets = SURFACE_COPY_TARGETS,
|
|
98
|
+
entries = null,
|
|
99
|
+
presentHosts = null,
|
|
100
|
+
} = {}) {
|
|
101
|
+
const surfaceEntries =
|
|
102
|
+
entries ??
|
|
103
|
+
canonicalSurfaceEntries(
|
|
104
|
+
resolveRecipePaths(root, readActiveSelection(root)).flatMap((p) => (existsSync(p) ? readRawEntries(p) : [])),
|
|
105
|
+
);
|
|
106
|
+
const rows = [];
|
|
107
|
+
|
|
108
|
+
for (const entry of surfaceEntries) {
|
|
109
|
+
for (const hostId of Object.keys(targets)) {
|
|
110
|
+
// Only fan out to hosts actually present, so we never create a config
|
|
111
|
+
// dir (e.g. ~/.pi) for a host that isn't installed. null = don't gate
|
|
112
|
+
// (tests inject their own targets and want every row).
|
|
113
|
+
if (presentHosts && !presentHosts.has(hostId)) continue;
|
|
114
|
+
const perType = (targets[hostId]?.[entry.type] ?? []).filter((t) => t.scope === scope);
|
|
115
|
+
for (const target of perType) {
|
|
116
|
+
const row = planRow({ entry, hostId, type: entry.type, target });
|
|
117
|
+
if ((command === "install" || command === "remove") && row.file) {
|
|
118
|
+
applySurface(command, entry, row);
|
|
119
|
+
}
|
|
120
|
+
rows.push(row);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { root, command, scope, rows };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function applySurface(command, entry, row) {
|
|
128
|
+
if (row.status === "source" || row.status === "unsupported") return;
|
|
129
|
+
if (command === "install") {
|
|
130
|
+
if (row.status === "linked") return; // idempotent
|
|
131
|
+
mkdirSync(dirname(row.file), { recursive: true });
|
|
132
|
+
writeFileSync(row.file, withMarker(safeRead(entry.path), entry.id));
|
|
133
|
+
row.status = "linked";
|
|
134
|
+
row.changed = true;
|
|
135
|
+
} else if (command === "remove") {
|
|
136
|
+
if (existsSync(row.file) && isManaged(safeRead(row.file))) {
|
|
137
|
+
rmSync(row.file, { force: true });
|
|
138
|
+
row.status = "missing";
|
|
139
|
+
row.changed = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseArgs(argv) {
|
|
145
|
+
const args = { command: argv[2] ?? "status", scope: "global", json: false };
|
|
146
|
+
for (let i = 3; i < argv.length; i += 1) {
|
|
147
|
+
if (argv[i] === "--scope") {
|
|
148
|
+
args.scope = argv[i + 1];
|
|
149
|
+
i += 1;
|
|
150
|
+
} else if (argv[i] === "--json") {
|
|
151
|
+
args.json = true;
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`unknown argument: ${argv[i]}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (!VALID_COMMANDS.has(args.command)) throw new Error("usage: link-surfaces.mjs <status|install|remove> [--scope project|global]");
|
|
157
|
+
if (!VALID_SCOPES.has(args.scope)) throw new Error(`invalid scope "${args.scope}"`);
|
|
158
|
+
return args;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function main() {
|
|
162
|
+
const args = parseArgs(process.argv);
|
|
163
|
+
const result = collectSurfaceLinks({
|
|
164
|
+
command: args.command,
|
|
165
|
+
scope: args.scope,
|
|
166
|
+
presentHosts: await presentHostSet(),
|
|
167
|
+
});
|
|
168
|
+
if (args.json) {
|
|
169
|
+
console.log(JSON.stringify(result, null, 2));
|
|
170
|
+
} else {
|
|
171
|
+
console.log(`link-surfaces ${result.command} (${result.scope}): ${result.rows.length} target(s)`);
|
|
172
|
+
for (const r of result.rows) {
|
|
173
|
+
console.log(`${r.status.padEnd(11)} ${r.type} ${r.id} -> ${r.hostId} ${r.dir ?? r.format ?? ""}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const ok = result.rows.every((r) => r.status !== "error");
|
|
177
|
+
process.exitCode = ok ? 0 : 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
import { pathToFileURL } from "node:url";
|
|
181
|
+
|
|
182
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
183
|
+
if (isMain) {
|
|
184
|
+
main().catch((error) => {
|
|
185
|
+
console.error(error.message);
|
|
186
|
+
process.exitCode = 1;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
canonicalSurfaceEntries,
|
|
8
|
+
collectSurfaceLinks,
|
|
9
|
+
isManaged,
|
|
10
|
+
isSourceDir,
|
|
11
|
+
withMarker,
|
|
12
|
+
} from "./link-surfaces.mjs";
|
|
13
|
+
|
|
14
|
+
test("canonicalSurfaceEntries: only command/agent with a path, deduped", () => {
|
|
15
|
+
const out = canonicalSurfaceEntries([
|
|
16
|
+
{ id: "a", type: "command", path: "/x/a.md" },
|
|
17
|
+
{ id: "a", type: "command", path: "/y/a.md" }, // dup id -> first wins
|
|
18
|
+
{ id: "b", type: "agent", path: "/x/b.md" },
|
|
19
|
+
{ id: "c", type: "cli" }, // wrong type
|
|
20
|
+
{ id: "d", type: "command" }, // no path
|
|
21
|
+
]);
|
|
22
|
+
assert.deepEqual(out.map((e) => e.id), ["a", "b"]);
|
|
23
|
+
assert.equal(out[0].path, "/x/a.md");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("isSourceDir: a file under the target dir is its own source", () => {
|
|
27
|
+
assert.equal(isSourceDir("/home/.claude/commands/a.md", "/home/.claude/commands"), true);
|
|
28
|
+
assert.equal(isSourceDir("/home/.codex/prompts/a.md", "/home/.claude/commands"), false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("withMarker/isManaged: injects after frontmatter, idempotent", () => {
|
|
32
|
+
const md = "---\nname: a\n---\n# body\n";
|
|
33
|
+
const marked = withMarker(md, "a");
|
|
34
|
+
assert.ok(isManaged(marked));
|
|
35
|
+
assert.match(marked, /---\r?\n<!-- portawhip-managed: a -->/);
|
|
36
|
+
assert.equal(withMarker(marked, "a"), marked, "second pass is a no-op");
|
|
37
|
+
assert.ok(!isManaged(md));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("withMarker: prepends when there is no frontmatter", () => {
|
|
41
|
+
const marked = withMarker("# just a heading\n", "x");
|
|
42
|
+
assert.match(marked, /^<!-- portawhip-managed: x -->/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("install then remove roundtrip via injected temp catalog (no host writes)", () => {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), "link-surfaces-"));
|
|
47
|
+
const srcDir = join(dir, "src");
|
|
48
|
+
const destDir = join(dir, "codex", "agents");
|
|
49
|
+
mkdirSync(srcDir, { recursive: true });
|
|
50
|
+
writeFileSync(join(srcDir, "reviewer.md"), "---\nname: reviewer\n---\n# reviewer\n");
|
|
51
|
+
|
|
52
|
+
const entries = [{ id: "reviewer", type: "agent", path: join(srcDir, "reviewer.md") }];
|
|
53
|
+
const targets = {
|
|
54
|
+
"claude-code": { agent: [{ scope: "global", dir: srcDir, format: "md" }] }, // source host
|
|
55
|
+
codex: { agent: [{ scope: "global", dir: destDir, format: "md" }] },
|
|
56
|
+
"gemini-cli": { agent: [{ scope: "global", format: "unknown", unsupported: true }] },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const status = collectSurfaceLinks({ command: "status", scope: "global", entries, targets });
|
|
60
|
+
const byHost = Object.fromEntries(status.rows.map((r) => [r.hostId, r.status]));
|
|
61
|
+
assert.equal(byHost["claude-code"], "source", "source host is not a copy target");
|
|
62
|
+
assert.equal(byHost.codex, "missing");
|
|
63
|
+
assert.equal(byHost["gemini-cli"], "unsupported");
|
|
64
|
+
|
|
65
|
+
collectSurfaceLinks({ command: "install", scope: "global", entries, targets });
|
|
66
|
+
const destFile = join(destDir, "reviewer.md");
|
|
67
|
+
assert.ok(existsSync(destFile), "copied to codex dir");
|
|
68
|
+
assert.ok(isManaged(readFileSync(destFile, "utf8")), "copy carries the managed marker");
|
|
69
|
+
|
|
70
|
+
// idempotent install
|
|
71
|
+
const again = collectSurfaceLinks({ command: "install", scope: "global", entries, targets });
|
|
72
|
+
assert.ok(again.rows.every((r) => !r.changed), "second install is a no-op");
|
|
73
|
+
|
|
74
|
+
collectSurfaceLinks({ command: "remove", scope: "global", entries, targets });
|
|
75
|
+
assert.ok(!existsSync(destFile), "remove strips the managed copy");
|
|
76
|
+
});
|