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,141 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { buildBackendArgs, runBackend } from "../../core/surface/config-sync-backends.mjs";
|
|
4
|
+
import { collectSyncConfig, parseArgs } from "./sync-config.mjs";
|
|
5
|
+
|
|
6
|
+
test("sync-config: ai-config-sync preview builds a dry-run plan command", () => {
|
|
7
|
+
assert.deepEqual(
|
|
8
|
+
buildBackendArgs("ai-config-sync", "preview", {
|
|
9
|
+
from: "claude",
|
|
10
|
+
to: "codex",
|
|
11
|
+
scope: "project",
|
|
12
|
+
include: "instructions,skills",
|
|
13
|
+
exclude: "mcp",
|
|
14
|
+
}),
|
|
15
|
+
[
|
|
16
|
+
"sync",
|
|
17
|
+
"--dry-run",
|
|
18
|
+
"--plan-json",
|
|
19
|
+
"--from",
|
|
20
|
+
"claude",
|
|
21
|
+
"--to",
|
|
22
|
+
"codex",
|
|
23
|
+
"--scope",
|
|
24
|
+
"project",
|
|
25
|
+
"--include",
|
|
26
|
+
"instructions,skills",
|
|
27
|
+
"--exclude",
|
|
28
|
+
"mcp",
|
|
29
|
+
],
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("sync-config: apply is guarded by an explicit flag", () => {
|
|
34
|
+
assert.throws(() => parseArgs(["node", "sync-config.mjs", "apply"]), /explicit --apply/);
|
|
35
|
+
assert.throws(() => parseArgs(["node", "sync-config.mjs", "apply", "--apply"]), /requires --include/);
|
|
36
|
+
assert.equal(parseArgs(["node", "sync-config.mjs", "apply", "--apply", "--include", "mcp:notion"]).allowApply, true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("sync-config: apply blocks broad all-skills writes", () => {
|
|
40
|
+
assert.throws(
|
|
41
|
+
() => parseArgs(["node", "sync-config.mjs", "apply", "--apply", "--include", "skills"]),
|
|
42
|
+
/all skills is blocked/,
|
|
43
|
+
);
|
|
44
|
+
assert.equal(
|
|
45
|
+
parseArgs(["node", "sync-config.mjs", "apply", "--apply", "--include", "skills:pdf"]).include,
|
|
46
|
+
"skills:pdf",
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("sync-config: profiles fill safe defaults", () => {
|
|
51
|
+
const args = parseArgs(["node", "sync-config.mjs", "preview", "--profile", "ai-project-instructions"]);
|
|
52
|
+
assert.equal(args.backends[0], "ai-config-sync");
|
|
53
|
+
assert.equal(args.scope, "project");
|
|
54
|
+
assert.equal(args.include, "instructions");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("sync-config: agent-skill-manager is probe-only", () => {
|
|
58
|
+
assert.deepEqual(buildBackendArgs("asm", "status"), ["config", "show"]);
|
|
59
|
+
assert.throws(() => buildBackendArgs("asm", "preview"), /does not support preview/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("sync-config: .agents preview maps to sync --check", () => {
|
|
63
|
+
assert.deepEqual(buildBackendArgs(".agents", "preview"), ["sync", "--check"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("sync-config: .agents preview planned changes are not install failures", () => {
|
|
67
|
+
const runner = () => ({
|
|
68
|
+
status: 1,
|
|
69
|
+
stdout: "\u001b[36m[info]\u001b[39m Would update 1 item(s):\n -> .agents/agents.json\n",
|
|
70
|
+
stderr: "",
|
|
71
|
+
});
|
|
72
|
+
const row = runBackend("agents-dotdir", "preview", {}, runner);
|
|
73
|
+
assert.equal(row.ok, true);
|
|
74
|
+
assert.equal(row.status, "changed");
|
|
75
|
+
assert.equal(row.installHint, null);
|
|
76
|
+
assert.match(row.summary, /planned changes/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("sync-config: collect marks probe-only unsupported actions without failing all", () => {
|
|
80
|
+
const result = collectSyncConfig({
|
|
81
|
+
action: "preview",
|
|
82
|
+
backends: ["agent-skill-manager"],
|
|
83
|
+
options: {},
|
|
84
|
+
});
|
|
85
|
+
assert.equal(result.status, "success");
|
|
86
|
+
assert.equal(result.rows[0].ok, true);
|
|
87
|
+
assert.equal(result.rows[0].status, "unsupported");
|
|
88
|
+
assert.match(result.rows[0].summary, /does not support preview/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("sync-config: collect reports backend output in stable shape", () => {
|
|
92
|
+
const calls = [];
|
|
93
|
+
const runner = (cmd, args) => {
|
|
94
|
+
calls.push([cmd, args]);
|
|
95
|
+
return { status: 0, stdout: "{\"ok\":true}\n", stderr: "" };
|
|
96
|
+
};
|
|
97
|
+
const row = runBackend("ai-config-sync", "status", {}, runner);
|
|
98
|
+
assert.equal(row.status, "success");
|
|
99
|
+
assert.equal(row.backend, "ai-config-sync");
|
|
100
|
+
assert.match(row.command[0].replace(/\\/g, "/"), /(node_modules\/\.bin\/ai-config-sync|ai-config-sync)(\.cmd)?$/);
|
|
101
|
+
assert.deepEqual(row.command.slice(1), ["status", "--json"]);
|
|
102
|
+
|
|
103
|
+
const result = collectSyncConfig({
|
|
104
|
+
action: "status",
|
|
105
|
+
backends: ["agent-skill-manager"],
|
|
106
|
+
options: {},
|
|
107
|
+
runner,
|
|
108
|
+
});
|
|
109
|
+
assert.equal(result.rows.length, 1);
|
|
110
|
+
assert.equal(result.rows[0].backend, "agent-skill-manager");
|
|
111
|
+
assert.match(calls[0][0].replace(/\\/g, "/"), /(node_modules\/\.bin\/ai-config-sync|ai-config-sync)(\.cmd)?$/);
|
|
112
|
+
assert.deepEqual(calls[0][1], ["status", "--json"]);
|
|
113
|
+
assert.match(calls[1][0].replace(/\\/g, "/"), /agent-skill-manager(\.cmd)?$/);
|
|
114
|
+
assert.deepEqual(calls[1][1], ["config", "show"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("sync-config: pinned local backend wins even when npx fallback is allowed", () => {
|
|
118
|
+
const calls = [];
|
|
119
|
+
const runner = (cmd, args) => {
|
|
120
|
+
calls.push([cmd, args]);
|
|
121
|
+
return { status: 0, stdout: "{\"ok\":true}\n", stderr: "" };
|
|
122
|
+
};
|
|
123
|
+
const args = parseArgs(["node", "sync-config.mjs", "status", "--allow-npx"]);
|
|
124
|
+
assert.equal(args.allowNpx, true);
|
|
125
|
+
runBackend("ai-config-sync", "status", { allowNpx: true }, runner);
|
|
126
|
+
assert.match(calls[0][0].replace(/\\/g, "/"), /(node_modules\/\.bin\/ai-config-sync|ai-config-sync)(\.cmd)?$/);
|
|
127
|
+
assert.deepEqual(calls[0][1], ["status", "--json"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("sync-config: backend ledger inner errors make the wrapper fail", () => {
|
|
131
|
+
const runner = () => ({
|
|
132
|
+
status: 0,
|
|
133
|
+
stdout: '{"summary":{"applied":0,"error":1}}\nextra human output\n',
|
|
134
|
+
stderr: "",
|
|
135
|
+
});
|
|
136
|
+
const row = runBackend("ai-config-sync", "apply", { include: "instructions" }, runner);
|
|
137
|
+
assert.equal(row.ok, false);
|
|
138
|
+
assert.equal(row.status, "error");
|
|
139
|
+
assert.match(row.summary, /inner error/);
|
|
140
|
+
assert.deepEqual(row.parsedSummary, { applied: 0, error: 1 });
|
|
141
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import spawnSync from "cross-spawn";
|
|
6
|
+
import { activeSelectionPathFor, readActiveSelection, resolveRecipePaths } from "../../core/state/bundle-state.mjs";
|
|
7
|
+
import { mergeRawEntries } from "../../core/registry/registry.mjs";
|
|
8
|
+
import { detectHosts } from "../hosts.mjs";
|
|
9
|
+
import { installEntries } from "../load.mjs";
|
|
10
|
+
import { collectSurfaceLinks, presentHostSet } from "../link/link-surfaces.mjs";
|
|
11
|
+
|
|
12
|
+
const VALID_COMMANDS = new Set(["sync", "check", "watch"]);
|
|
13
|
+
const INSTALL_TYPES = new Set(["cli", "skill"]);
|
|
14
|
+
|
|
15
|
+
function localBin(root, name) {
|
|
16
|
+
const suffix = process.platform === "win32" ? ".cmd" : "";
|
|
17
|
+
const candidate = join(root, "node_modules", ".bin", `${name}${suffix}`);
|
|
18
|
+
return existsSync(candidate) ? candidate : name;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseArgs(argv) {
|
|
22
|
+
const args = {
|
|
23
|
+
command: argv[2] ?? "sync",
|
|
24
|
+
scope: "project",
|
|
25
|
+
intervalMs: 1200,
|
|
26
|
+
once: false,
|
|
27
|
+
json: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
for (let i = 3; i < argv.length; i += 1) {
|
|
31
|
+
const arg = argv[i];
|
|
32
|
+
if (arg === "--scope") {
|
|
33
|
+
args.scope = argv[i + 1];
|
|
34
|
+
i += 1;
|
|
35
|
+
} else if (arg === "--interval") {
|
|
36
|
+
args.intervalMs = Number(argv[i + 1]);
|
|
37
|
+
i += 1;
|
|
38
|
+
} else if (arg === "--once") {
|
|
39
|
+
args.once = true;
|
|
40
|
+
} else if (arg === "--json") {
|
|
41
|
+
args.json = true;
|
|
42
|
+
} else {
|
|
43
|
+
throw new Error(`unknown argument: ${arg}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!VALID_COMMANDS.has(args.command)) {
|
|
48
|
+
throw new Error("usage: sync-surfaces.mjs <sync|check|watch> [--scope project|global] [--interval ms] [--once] [--json]");
|
|
49
|
+
}
|
|
50
|
+
if (!["project", "global"].includes(args.scope)) throw new Error(`invalid scope "${args.scope}"`);
|
|
51
|
+
if (!Number.isFinite(args.intervalMs) || args.intervalMs <= 0) throw new Error("invalid --interval");
|
|
52
|
+
return args;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function installableEntries(entries) {
|
|
56
|
+
return entries.filter((entry) => entry.install !== false && INSTALL_TYPES.has(entry.type));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function recipeEntries(root) {
|
|
60
|
+
const recipePaths = resolveRecipePaths(root, readActiveSelection(root));
|
|
61
|
+
return {
|
|
62
|
+
recipePaths,
|
|
63
|
+
entries: mergeRawEntries(recipePaths),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function run(command, args, { cwd }) {
|
|
68
|
+
const result = spawnSync.sync(command, args, { cwd, stdio: "inherit" });
|
|
69
|
+
return result.status === 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function syncSurfaces({ root = resolve("."), scope = "project", check = false, runner = run, hosts = null } = {}) {
|
|
73
|
+
const absoluteRoot = resolve(root);
|
|
74
|
+
const { recipePaths, entries } = recipeEntries(absoluteRoot);
|
|
75
|
+
const installEntriesForSync = installableEntries(entries);
|
|
76
|
+
const result = {
|
|
77
|
+
root: absoluteRoot,
|
|
78
|
+
mode: check ? "check" : "sync",
|
|
79
|
+
recipePaths,
|
|
80
|
+
lanes: [],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const agentsArgs = ["sync", "--verbose"];
|
|
84
|
+
if (check) agentsArgs.push("--check");
|
|
85
|
+
const mcpOk = runner(localBin(absoluteRoot, "agents"), agentsArgs, { cwd: absoluteRoot });
|
|
86
|
+
result.lanes.push({
|
|
87
|
+
lane: "mcp",
|
|
88
|
+
backend: "agents",
|
|
89
|
+
ok: mcpOk,
|
|
90
|
+
action: check ? "check" : "sync",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (check) {
|
|
94
|
+
result.lanes.push({
|
|
95
|
+
lane: "cli+skills",
|
|
96
|
+
backend: "mise+agent-skill-manager",
|
|
97
|
+
ok: true,
|
|
98
|
+
action: "planned",
|
|
99
|
+
count: installEntriesForSync.length,
|
|
100
|
+
});
|
|
101
|
+
const surfacePlan = collectSurfaceLinks({ command: "status", scope, root: absoluteRoot, presentHosts: await presentHostSet() });
|
|
102
|
+
result.lanes.push(surfaceLane(surfacePlan, "planned"));
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const detectedHosts = hosts ?? (await detectHosts());
|
|
107
|
+
const installResults = installEntries(installEntriesForSync, detectedHosts, scope);
|
|
108
|
+
result.lanes.push({
|
|
109
|
+
lane: "cli+skills",
|
|
110
|
+
backend: "mise+agent-skill-manager",
|
|
111
|
+
ok: installResults.every((item) => item.ok),
|
|
112
|
+
action: "sync",
|
|
113
|
+
count: installResults.length,
|
|
114
|
+
results: installResults,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Commands + agents: mise/asm don't install these, so fan them out by
|
|
118
|
+
// managed-copy into each markdown-host's native dir (Phase S2 write).
|
|
119
|
+
const surfaceResult = collectSurfaceLinks({ command: "install", scope, root: absoluteRoot, presentHosts: await presentHostSet() });
|
|
120
|
+
result.lanes.push(surfaceLane(surfaceResult, "sync"));
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A copy target only "fails" if it errored; source/unsupported/linked/missing
|
|
125
|
+
// are all expected, non-failing states.
|
|
126
|
+
function surfaceLane(plan, action) {
|
|
127
|
+
const copied = plan.rows.filter((r) => r.changed).length;
|
|
128
|
+
const unsupported = plan.rows.filter((r) => r.status === "unsupported").length;
|
|
129
|
+
return {
|
|
130
|
+
lane: "commands+agents",
|
|
131
|
+
backend: "link-surfaces (managed-copy)",
|
|
132
|
+
ok: plan.rows.every((r) => r.status !== "error"),
|
|
133
|
+
action,
|
|
134
|
+
count: plan.rows.length,
|
|
135
|
+
copied,
|
|
136
|
+
unsupported,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sourceFiles(root) {
|
|
141
|
+
const files = [
|
|
142
|
+
join(root, ".agents", "agents.json"),
|
|
143
|
+
join(root, ".agents", "local.json"),
|
|
144
|
+
join(root, "recipe.yaml"),
|
|
145
|
+
activeSelectionPathFor(root),
|
|
146
|
+
];
|
|
147
|
+
const dirs = [join(root, ".agents", "skills"), join(root, "recipes")];
|
|
148
|
+
for (const dir of dirs) collectFiles(dir, files);
|
|
149
|
+
return files.filter(existsSync);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collectFiles(path, files) {
|
|
153
|
+
if (!existsSync(path)) return;
|
|
154
|
+
const stat = statSync(path);
|
|
155
|
+
if (stat.isFile()) {
|
|
156
|
+
files.push(path);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!stat.isDirectory()) return;
|
|
160
|
+
for (const child of readdirSync(path)) collectFiles(join(path, child), files);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function fingerprint(root) {
|
|
164
|
+
return sourceFiles(root)
|
|
165
|
+
.sort()
|
|
166
|
+
.map((path) => {
|
|
167
|
+
const stat = statSync(path);
|
|
168
|
+
return `${path}:${stat.mtimeMs}:${stat.size}`;
|
|
169
|
+
})
|
|
170
|
+
.join("|");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function printResult(result, json = false) {
|
|
174
|
+
if (json) {
|
|
175
|
+
console.log(JSON.stringify(result, null, 2));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
console.log(`surface ${result.mode}: ${result.lanes.every((lane) => lane.ok) ? "success" : "warning"}`);
|
|
179
|
+
for (const lane of result.lanes) {
|
|
180
|
+
const count = typeof lane.count === "number" ? ` (${lane.count} item(s))` : "";
|
|
181
|
+
console.log(`${lane.ok ? "OK " : "FAIL"} ${lane.lane}: ${lane.backend} ${lane.action}${count}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function runWatch({ root, scope, intervalMs, once, json }) {
|
|
186
|
+
let last = fingerprint(root);
|
|
187
|
+
let syncing = false;
|
|
188
|
+
const runOne = async () => {
|
|
189
|
+
if (syncing) return;
|
|
190
|
+
syncing = true;
|
|
191
|
+
try {
|
|
192
|
+
printResult(await syncSurfaces({ root, scope }), json);
|
|
193
|
+
} finally {
|
|
194
|
+
syncing = false;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
await runOne();
|
|
199
|
+
if (once) return;
|
|
200
|
+
console.log(`watching surface sources every ${intervalMs}ms`);
|
|
201
|
+
setInterval(async () => {
|
|
202
|
+
const next = fingerprint(root);
|
|
203
|
+
if (next === last) return;
|
|
204
|
+
last = next;
|
|
205
|
+
await runOne();
|
|
206
|
+
}, intervalMs);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function main() {
|
|
210
|
+
const args = parseArgs(process.argv);
|
|
211
|
+
const root = resolve(".");
|
|
212
|
+
if (args.command === "watch") {
|
|
213
|
+
await runWatch({ root, scope: args.scope, intervalMs: args.intervalMs, once: args.once, json: args.json });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const result = await syncSurfaces({ root, scope: args.scope, check: args.command === "check" });
|
|
217
|
+
printResult(result, args.json);
|
|
218
|
+
process.exitCode = result.lanes.every((lane) => lane.ok) ? 0 : 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
import { pathToFileURL } from "node:url";
|
|
222
|
+
|
|
223
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
224
|
+
if (isMain) {
|
|
225
|
+
main().catch((error) => {
|
|
226
|
+
console.error(error.message);
|
|
227
|
+
process.exitCode = 1;
|
|
228
|
+
});
|
|
229
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { installableEntries, parseArgs, syncSurfaces } from "./sync-surfaces.mjs";
|
|
7
|
+
|
|
8
|
+
test("sync-surfaces: parses sync/check/watch commands", () => {
|
|
9
|
+
assert.equal(parseArgs(["node", "sync-surfaces.mjs"]).command, "sync");
|
|
10
|
+
assert.equal(parseArgs(["node", "sync-surfaces.mjs", "check", "--json"]).json, true);
|
|
11
|
+
assert.equal(parseArgs(["node", "sync-surfaces.mjs", "watch", "--once", "--interval", "50"]).once, true);
|
|
12
|
+
assert.throws(() => parseArgs(["node", "sync-surfaces.mjs", "wat"]), /usage/);
|
|
13
|
+
assert.throws(() => parseArgs(["node", "sync-surfaces.mjs", "sync", "--scope", "all"]), /invalid scope/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("sync-surfaces: only CLI and skill entries are installable", () => {
|
|
17
|
+
const entries = [
|
|
18
|
+
{ id: "docs", type: "mcp" },
|
|
19
|
+
{ id: "rg", type: "cli" },
|
|
20
|
+
{ id: "pdf", type: "skill" },
|
|
21
|
+
{ id: "route-only", type: "skill", install: false },
|
|
22
|
+
{ id: "sync", type: "config-sync" },
|
|
23
|
+
];
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
installableEntries(entries).map((entry) => entry.id),
|
|
26
|
+
["rg", "pdf"],
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("sync-surfaces: check runs agents sync --check and does not install CLI/skills", async () => {
|
|
31
|
+
const root = mkdtempSync(join(tmpdir(), "surface-check-"));
|
|
32
|
+
writeFileSync(
|
|
33
|
+
join(root, "recipe.yaml"),
|
|
34
|
+
[
|
|
35
|
+
"- id: rg",
|
|
36
|
+
" type: cli",
|
|
37
|
+
" source: ripgrep",
|
|
38
|
+
"- id: pdf",
|
|
39
|
+
" type: skill",
|
|
40
|
+
" source: pdf",
|
|
41
|
+
"- id: docs",
|
|
42
|
+
" type: mcp",
|
|
43
|
+
" source: context7",
|
|
44
|
+
"",
|
|
45
|
+
].join("\n"),
|
|
46
|
+
);
|
|
47
|
+
const calls = [];
|
|
48
|
+
const result = await syncSurfaces({
|
|
49
|
+
root,
|
|
50
|
+
check: true,
|
|
51
|
+
runner: (cmd, args) => {
|
|
52
|
+
calls.push([cmd, args]);
|
|
53
|
+
return true;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
assert.equal(result.lanes.length, 3);
|
|
57
|
+
assert.equal(result.lanes[0].lane, "mcp");
|
|
58
|
+
assert.deepEqual(calls[0][1], ["sync", "--verbose", "--check"]);
|
|
59
|
+
assert.equal(result.lanes[1].lane, "cli+skills");
|
|
60
|
+
assert.equal(result.lanes[1].action, "planned");
|
|
61
|
+
assert.equal(result.lanes[1].count, 2);
|
|
62
|
+
// Phase S2: commands+agents managed-copy lane. The temp recipe has no
|
|
63
|
+
// command/agent entries, so it plans zero targets but the lane is present.
|
|
64
|
+
assert.equal(result.lanes[2].lane, "commands+agents");
|
|
65
|
+
assert.equal(result.lanes[2].action, "planned");
|
|
66
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { collectConnectorLinks } from "./link/link-connectors.mjs";
|
|
2
|
+
import { collectHookLinks } from "./link/link-hooks.mjs";
|
|
3
|
+
|
|
4
|
+
export const LINK_SCOPES = ["project", "global", "all"];
|
|
5
|
+
|
|
6
|
+
const LINK_TABS = new Set(["connectors", "hooks"]);
|
|
7
|
+
const LINK_COMMANDS = new Set(["status", "install", "remove"]);
|
|
8
|
+
const COMMAND_BY_INPUT = new Map([
|
|
9
|
+
["s", "status"],
|
|
10
|
+
["l", "install"],
|
|
11
|
+
["x", "remove"],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const defaultCollectors = {
|
|
15
|
+
connectors: collectConnectorLinks,
|
|
16
|
+
hooks: collectHookLinks,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function linkCommandForInput(tab, input) {
|
|
20
|
+
return LINK_TABS.has(tab) ? (COMMAND_BY_INPUT.get(input) ?? null) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function linkActionNeedsConfirmation(command) {
|
|
24
|
+
return command === "install" || command === "remove";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function summarizeLinkAction(tab, result) {
|
|
28
|
+
const statuses = result.rows.map((row) => row.instructionStatus ?? row.status ?? "unknown");
|
|
29
|
+
const changed = statuses.filter((status) => status === "changed").length;
|
|
30
|
+
const unchanged = statuses.filter((status) => status === "no-op" || status === "linked").length;
|
|
31
|
+
const missing = statuses.filter((status) => status === "missing").length;
|
|
32
|
+
const unsupported = statuses.filter((status) => status === "unsupported").length;
|
|
33
|
+
const details = [];
|
|
34
|
+
|
|
35
|
+
if (changed) details.push(`${changed} changed`);
|
|
36
|
+
if (unchanged) details.push(`${unchanged} already current`);
|
|
37
|
+
if (missing) details.push(`${missing} missing`);
|
|
38
|
+
if (unsupported) details.push(`${unsupported} unsupported`);
|
|
39
|
+
return `${tab} ${result.command} ${result.scope}: ${details.join(", ") || "no targets found"}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runLinkAction({ tab, command, scope, collectors = defaultCollectors } = {}) {
|
|
43
|
+
if (!LINK_TABS.has(tab)) throw new Error(`unsupported TUI action tab: ${tab}`);
|
|
44
|
+
if (!LINK_COMMANDS.has(command)) throw new Error(`unsupported link action: ${command}`);
|
|
45
|
+
if (!LINK_SCOPES.includes(scope)) throw new Error(`unsupported link scope: ${scope}`);
|
|
46
|
+
|
|
47
|
+
const collect = collectors[tab];
|
|
48
|
+
if (typeof collect !== "function") throw new Error(`missing ${tab} collector`);
|
|
49
|
+
|
|
50
|
+
const scopes = scope === "all" ? ["project", "global"] : [scope];
|
|
51
|
+
const runs = await Promise.all(scopes.map((selectedScope) => collect({ command, scope: selectedScope })));
|
|
52
|
+
const result = {
|
|
53
|
+
command,
|
|
54
|
+
scope,
|
|
55
|
+
rows: runs.flatMap((run) => run.rows ?? []),
|
|
56
|
+
runs,
|
|
57
|
+
};
|
|
58
|
+
return { ...result, summary: summarizeLinkAction(tab, result) };
|
|
59
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { LINK_SCOPES, linkCommandForInput, runLinkAction } from "./tui-actions.mjs";
|
|
5
|
+
|
|
6
|
+
test("connector and hook tabs expose status, repair, and remove actions", () => {
|
|
7
|
+
assert.deepEqual(LINK_SCOPES, ["project", "global", "all"]);
|
|
8
|
+
assert.equal(linkCommandForInput("connectors", "s"), "status");
|
|
9
|
+
assert.equal(linkCommandForInput("hooks", "l"), "install");
|
|
10
|
+
assert.equal(linkCommandForInput("connectors", "x"), "remove");
|
|
11
|
+
assert.equal(linkCommandForInput("sync", "l"), null);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("runs a connector repair in both scopes when all is selected", async () => {
|
|
15
|
+
const calls = [];
|
|
16
|
+
const result = await runLinkAction({
|
|
17
|
+
tab: "connectors",
|
|
18
|
+
command: "install",
|
|
19
|
+
scope: "all",
|
|
20
|
+
collectors: {
|
|
21
|
+
connectors: async (options) => {
|
|
22
|
+
calls.push(options);
|
|
23
|
+
return {
|
|
24
|
+
rows: [
|
|
25
|
+
{ hostId: "codex", scope: options.scope, instructionStatus: "changed" },
|
|
26
|
+
{ hostId: "claude", scope: options.scope, instructionStatus: "no-op" },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(calls, [
|
|
34
|
+
{ command: "install", scope: "project" },
|
|
35
|
+
{ command: "install", scope: "global" },
|
|
36
|
+
]);
|
|
37
|
+
assert.equal(result.rows.length, 4);
|
|
38
|
+
assert.match(result.summary, /connectors install all: 2 changed, 2 already current/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("runs the selected hook action and rejects unsupported tabs", async () => {
|
|
42
|
+
const result = await runLinkAction({
|
|
43
|
+
tab: "hooks",
|
|
44
|
+
command: "remove",
|
|
45
|
+
scope: "global",
|
|
46
|
+
collectors: {
|
|
47
|
+
hooks: async (options) => ({ rows: [{ hostId: "codex", ...options, status: "changed" }] }),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
assert.match(result.summary, /hooks remove global: 1 changed/);
|
|
52
|
+
await assert.rejects(() => runLinkAction({ tab: "sync", command: "install", scope: "project" }), /unsupported TUI action tab/);
|
|
53
|
+
});
|