heedvane 0.0.0-stage → 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/README.md +27 -2
- package/dist/apply.d.ts +104 -0
- package/dist/apply.js +388 -0
- package/dist/backend/client.d.ts +40 -0
- package/dist/backend/client.js +32 -0
- package/dist/backend/credentials.d.ts +15 -0
- package/dist/backend/credentials.js +76 -0
- package/dist/backend/login.d.ts +33 -0
- package/dist/backend/login.js +265 -0
- package/dist/backend/repo-remote.d.ts +15 -0
- package/dist/backend/repo-remote.js +61 -0
- package/dist/backend/sign-in-change.d.ts +12 -0
- package/dist/backend/sign-in-change.js +72 -0
- package/dist/best-effort.d.ts +4 -0
- package/dist/best-effort.js +25 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +544 -0
- package/dist/clients/definitions.d.ts +113 -0
- package/dist/clients/definitions.js +427 -0
- package/dist/clients/hermes-plugin.d.ts +33 -0
- package/dist/clients/hermes-plugin.js +247 -0
- package/dist/commands/heedvane.d.ts +15 -0
- package/dist/commands/heedvane.js +74 -0
- package/dist/commands/memory.d.ts +10 -0
- package/dist/commands/memory.js +106 -0
- package/dist/config-doc.d.ts +97 -0
- package/dist/config-doc.js +465 -0
- package/dist/dispatchers/posix.d.ts +32 -0
- package/dist/dispatchers/posix.js +191 -0
- package/dist/dispatchers/snippets.d.ts +6 -0
- package/dist/dispatchers/snippets.js +39 -0
- package/dist/dispatchers/windows.d.ts +15 -0
- package/dist/dispatchers/windows.js +50 -0
- package/dist/doctor.d.ts +37 -0
- package/dist/doctor.js +316 -0
- package/dist/events/contract.d.ts +54 -0
- package/dist/events/contract.js +87 -0
- package/dist/events/normalize.d.ts +36 -0
- package/dist/events/normalize.js +143 -0
- package/dist/guards.d.ts +18 -0
- package/dist/guards.js +28 -0
- package/dist/heed-file.d.ts +67 -0
- package/dist/heed-file.js +295 -0
- package/dist/listener/endpoint.d.ts +21 -0
- package/dist/listener/endpoint.js +112 -0
- package/dist/listener/forward.d.ts +36 -0
- package/dist/listener/forward.js +257 -0
- package/dist/listener/serve.d.ts +2 -0
- package/dist/listener/serve.js +155 -0
- package/dist/listener/server.d.ts +48 -0
- package/dist/listener/server.js +251 -0
- package/dist/listener/spool.d.ts +28 -0
- package/dist/listener/spool.js +60 -0
- package/dist/mcp-server.d.ts +9 -0
- package/dist/mcp-server.js +187 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +83 -0
- package/dist/memory/private-memory.d.ts +12 -0
- package/dist/memory/private-memory.js +54 -0
- package/dist/memory/table-of-contents.d.ts +10 -0
- package/dist/memory/table-of-contents.js +80 -0
- package/dist/memory/team-memory.d.ts +60 -0
- package/dist/memory/team-memory.js +54 -0
- package/dist/plan.d.ts +77 -0
- package/dist/plan.js +288 -0
- package/dist/render.d.ts +30 -0
- package/dist/render.js +131 -0
- package/dist/reply/harness-output.d.ts +57 -0
- package/dist/reply/harness-output.js +88 -0
- package/dist/reply/reply.d.ts +65 -0
- package/dist/reply/reply.js +169 -0
- package/dist/reply/verdict.d.ts +28 -0
- package/dist/reply/verdict.js +17 -0
- package/dist/skill/bundle.d.ts +23 -0
- package/dist/skill/bundle.js +102 -0
- package/dist/skill/content.d.ts +16 -0
- package/dist/skill/content.js +182 -0
- package/dist/skills/approved.d.ts +31 -0
- package/dist/skills/approved.js +123 -0
- package/dist/skills/check.d.ts +21 -0
- package/dist/skills/check.js +83 -0
- package/dist/state.d.ts +51 -0
- package/dist/state.js +118 -0
- package/dist/steering/rules.d.ts +49 -0
- package/dist/steering/rules.js +165 -0
- package/dist/steering/steer.d.ts +29 -0
- package/dist/steering/steer.js +64 -0
- package/dist/steering/violations.d.ts +27 -0
- package/dist/steering/violations.js +24 -0
- package/dist/uninstall.d.ts +23 -0
- package/dist/uninstall.js +159 -0
- package/package.json +34 -4
- package/skills/heedvane-hooks/SKILL.md +87 -0
- package/skills/heedvane-hooks/references/events.md +69 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* heedvane-hook — command routing: init | setup | doctor | uninstall |
|
|
4
|
+
* listener | status | mcp. Plain argv parsing (no commander); all state
|
|
5
|
+
* mutations flow through plan → apply; --dry-run writes nothing.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { homedir, platform } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { supportedClients } from "./clients/definitions.js";
|
|
12
|
+
import { installHermesPlugin } from "./clients/hermes-plugin.js";
|
|
13
|
+
import { applyEntry } from "./apply.js";
|
|
14
|
+
import { DEFAULT_HEED_YAML_TEMPLATE, loadHeedFile, loadHeedFileAt, withServerUrl } from "./heed-file.js";
|
|
15
|
+
import { planActions, desiredEntriesFor, isClientDetected } from "./plan.js";
|
|
16
|
+
import { appliedFailed, renderApplied, renderDetection, renderPlan, wouldInstallSummary } from "./render.js";
|
|
17
|
+
import { runDoctor } from "./doctor.js";
|
|
18
|
+
import { installSkill } from "./skill/bundle.js";
|
|
19
|
+
import { canonicalSkillFiles, skillDigest } from "./skill/content.js";
|
|
20
|
+
import { emptyState, readState, writeStateAtomic } from "./state.js";
|
|
21
|
+
import { writeDispatcherScript } from "./dispatchers/posix.js";
|
|
22
|
+
import { removeDeferredHookGroups, removeStaleHookGroups, uninstall } from "./uninstall.js";
|
|
23
|
+
import { statusText, runMcpStdio } from "./mcp-server.js";
|
|
24
|
+
import { errorMessage } from "./guards.js";
|
|
25
|
+
import { ensureEndpoint } from "./listener/endpoint.js";
|
|
26
|
+
import { serveHooks } from "./listener/serve.js";
|
|
27
|
+
import { cmdAuth, cmdLogin, cmdSkills } from "./commands/heedvane.js";
|
|
28
|
+
import { cmdMemory } from "./commands/memory.js";
|
|
29
|
+
import { backendAt } from "./backend/credentials.js";
|
|
30
|
+
import { readLogin } from "./backend/login.js";
|
|
31
|
+
const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
|
|
32
|
+
|
|
33
|
+
Usage: npx heedvane <command> [flags] (installed: heedvane <command>)
|
|
34
|
+
|
|
35
|
+
Commands:
|
|
36
|
+
connect one-step setup: write ~/.heed/heed.yaml, configure detected CLI & GUI agents, and sign in
|
|
37
|
+
init write ~/.heed/heed.yaml (if missing), plan, apply
|
|
38
|
+
setup plan + apply from an existing heed.yaml
|
|
39
|
+
doctor scoped, remediation-carrying health check
|
|
40
|
+
uninstall marker-verified removal (never touches heed.yaml)
|
|
41
|
+
listener listener controls (start): answers hooks with skill checks, steering and memory
|
|
42
|
+
login sign in to Heedvane in the browser (~/.heed/login.json, 0600; refreshed automatically)
|
|
43
|
+
auth org CI/headless: store an organization API key read from stdin (~/.heed/credentials.json, 0600)
|
|
44
|
+
skills sync install the organization's approved skills and cache the list hooks enforce
|
|
45
|
+
memory list|show|search browse this repository's team memory (a folder, one memory, or a question)
|
|
46
|
+
status quick state summary
|
|
47
|
+
mcp run the MCP stdio server (what agents register)
|
|
48
|
+
|
|
49
|
+
Flags: --dry-run --yes --json --agent <id> (repeatable) --client <id> --purge-config --project
|
|
50
|
+
--server <url> (connect/init: the Heedvane app to use, saved as heedvane.url; --url also works)
|
|
51
|
+
--org <id> (connect/init: the organization the sign-in must belong to)`;
|
|
52
|
+
function resolveHome(env) {
|
|
53
|
+
return env.HEED_HOME ?? homedir();
|
|
54
|
+
}
|
|
55
|
+
/** Repeated flag values, comma-split: `--agent a,b` and `--agent a --agent b` both feed the list. */
|
|
56
|
+
function parseRepeatedFlag(args, flag) {
|
|
57
|
+
const values = [];
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
if (args[i] === flag) {
|
|
60
|
+
values.push(...(args[i + 1] ?? "").split(",").filter(Boolean));
|
|
61
|
+
i += 1;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return values;
|
|
65
|
+
}
|
|
66
|
+
/** The value after `name` in the raw arguments, if the flag is there. */
|
|
67
|
+
function flagValue(args, name) {
|
|
68
|
+
const at = args.indexOf(name);
|
|
69
|
+
return at < 0 ? undefined : args[at + 1];
|
|
70
|
+
}
|
|
71
|
+
/** Positional (non-flag) arguments, skipping the values of the repeatable flags. */
|
|
72
|
+
function positionalArgs(args, repeatableFlags) {
|
|
73
|
+
const positional = [];
|
|
74
|
+
for (let i = 0; i < args.length; i++) {
|
|
75
|
+
if (repeatableFlags.includes(args[i])) {
|
|
76
|
+
i += 1; // skip the value
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!args[i].startsWith("--"))
|
|
80
|
+
positional.push(args[i]);
|
|
81
|
+
}
|
|
82
|
+
return positional;
|
|
83
|
+
}
|
|
84
|
+
/** The parsed argv: boolean flags, repeatable flag values, and positional command words. */
|
|
85
|
+
function parseInvocation(args) {
|
|
86
|
+
return {
|
|
87
|
+
flags: {
|
|
88
|
+
dryRun: args.includes("--dry-run"),
|
|
89
|
+
yes: args.includes("--yes"),
|
|
90
|
+
json: args.includes("--json"),
|
|
91
|
+
purge: args.includes("--purge-config"),
|
|
92
|
+
},
|
|
93
|
+
agents: parseRepeatedFlag(args, "--agent"),
|
|
94
|
+
clients: parseRepeatedFlag(args, "--client"),
|
|
95
|
+
server: flagValue(args, "--server") ?? flagValue(args, "--url"),
|
|
96
|
+
org: flagValue(args, "--org"),
|
|
97
|
+
positional: positionalArgs(args, ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--token"]),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function main(argv, opts = {}) {
|
|
101
|
+
const ctx = commandContext(argv, opts);
|
|
102
|
+
const command = ctx.positional[0];
|
|
103
|
+
if (!command || command === "help" || command === "--help") {
|
|
104
|
+
ctx.out(USAGE);
|
|
105
|
+
return command ? 0 : 2;
|
|
106
|
+
}
|
|
107
|
+
return routeCommand(command, ctx);
|
|
108
|
+
}
|
|
109
|
+
function commandContext(argv, opts) {
|
|
110
|
+
const env = opts.env ?? process.env;
|
|
111
|
+
const args = [...argv];
|
|
112
|
+
return {
|
|
113
|
+
...parseInvocation(args),
|
|
114
|
+
home: resolveHome(env),
|
|
115
|
+
env,
|
|
116
|
+
out: opts.write ?? ((line) => console.log(line)),
|
|
117
|
+
args,
|
|
118
|
+
readStdin: opts.readStdin ?? readProcessStdin,
|
|
119
|
+
openBrowser: opts.openBrowser ?? openSystemBrowser,
|
|
120
|
+
interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
|
|
121
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
async function routeCommand(command, ctx) {
|
|
125
|
+
switch (command) {
|
|
126
|
+
case "connect":
|
|
127
|
+
return cmdInit({ ...ctx, flags: { ...ctx.flags, yes: true } });
|
|
128
|
+
case "init":
|
|
129
|
+
return cmdInit(ctx);
|
|
130
|
+
case "setup":
|
|
131
|
+
return cmdSetup(ctx);
|
|
132
|
+
case "doctor":
|
|
133
|
+
return cmdDoctor(ctx);
|
|
134
|
+
case "uninstall":
|
|
135
|
+
return cmdUninstall({ home: ctx.home, env: ctx.env, out: ctx.out, agents: ctx.agents, purge: ctx.flags.purge });
|
|
136
|
+
default:
|
|
137
|
+
return routeServiceCommand(command, ctx);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function routeServiceCommand(command, ctx) {
|
|
141
|
+
switch (command) {
|
|
142
|
+
case "status": {
|
|
143
|
+
ctx.out(await statusText());
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
case "mcp": {
|
|
147
|
+
runMcpStdio({ write: (line) => ctx.out(line) });
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
case "listener": {
|
|
151
|
+
return cmdListener({ home: ctx.home, out: ctx.out, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
|
|
152
|
+
}
|
|
153
|
+
case "login":
|
|
154
|
+
return cmdLogin(backendCommandContext(ctx));
|
|
155
|
+
case "auth":
|
|
156
|
+
case "skills":
|
|
157
|
+
return (command === "auth" ? cmdAuth : cmdSkills)(backendCommandContext(ctx));
|
|
158
|
+
case "memory":
|
|
159
|
+
return cmdMemory({ home: ctx.home, out: ctx.out, words: ctx.positional.slice(1), flag: (name) => flagValue(ctx.args, name), cwd: ctx.cwd });
|
|
160
|
+
default:
|
|
161
|
+
ctx.out(` ✗ unknown command: ${command}`);
|
|
162
|
+
ctx.out(USAGE);
|
|
163
|
+
return 2;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function banner(out) {
|
|
167
|
+
out(" Heedvane Agent Hooks — cross-agent steering, guardrails & analysis");
|
|
168
|
+
out("");
|
|
169
|
+
}
|
|
170
|
+
function detectionRows(home, env) {
|
|
171
|
+
return supportedClients({ home, platform: platform(), env }).map((client) => ({
|
|
172
|
+
client,
|
|
173
|
+
detected: isClientDetected(client),
|
|
174
|
+
markerRel: client.markerPath.replace(home, "~"),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
function loadOrTemplate(home) {
|
|
178
|
+
const path = join(home, ".heed", "heed.yaml");
|
|
179
|
+
if (!existsSync(path))
|
|
180
|
+
return { heedFile: loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE), existed: false };
|
|
181
|
+
try {
|
|
182
|
+
return { heedFile: loadHeedFileAt(path), existed: true };
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
return { heedFile: loadHeedFile(DEFAULT_HEED_YAML_TEMPLATE), existed: true, error: errorMessage(err) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function planFor(plan) {
|
|
189
|
+
const effective = plan.agents.length > 0 ? { ...plan.heedFile, agents: [...plan.agents] } : plan.heedFile;
|
|
190
|
+
return planActions({ home: plan.home, platform: platform(), env: plan.env, heedFile: effective, forced: plan.agents, skillDigest: skillDigest(canonicalSkillFiles()) });
|
|
191
|
+
}
|
|
192
|
+
async function cmdInit(ctx) {
|
|
193
|
+
const { home, env, out, flags, agents } = ctx;
|
|
194
|
+
banner(out);
|
|
195
|
+
const rows = detectionRows(home, env);
|
|
196
|
+
for (const line of renderDetection(rows))
|
|
197
|
+
out(line);
|
|
198
|
+
const loaded = loadOrTemplate(home);
|
|
199
|
+
if (loaded.error) {
|
|
200
|
+
out("");
|
|
201
|
+
out(` ✗ ~/.heed/heed.yaml does not parse: ${loaded.error}`);
|
|
202
|
+
out(` remediation: fix or remove it, then run npx heedvane init`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
let serverEdit;
|
|
206
|
+
try {
|
|
207
|
+
serverEdit = ctx.server === undefined ? null : withServer(home, loaded, ctx.server);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
out("");
|
|
211
|
+
out(` ✗ --server ${ctx.server} cannot be used: ${errorMessage(err)}`);
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
const { heedFile, existed } = serverEdit ?? loaded;
|
|
215
|
+
const actions = planFor({ home, env, heedFile, agents });
|
|
216
|
+
// Plain init (no --yes, no --dry-run): show the plan summary, write nothing.
|
|
217
|
+
if (!flags.yes && !flags.dryRun) {
|
|
218
|
+
out("");
|
|
219
|
+
out(wouldInstallSummary(actions));
|
|
220
|
+
out(" Re-run with --yes to apply, or --dry-run for the full file-by-file plan.");
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
223
|
+
if (flags.dryRun) {
|
|
224
|
+
out("");
|
|
225
|
+
for (const line of renderPlan(actions))
|
|
226
|
+
out(line);
|
|
227
|
+
out("");
|
|
228
|
+
out(wouldInstallSummary(actions));
|
|
229
|
+
if (flags.json)
|
|
230
|
+
outJson(out, { status: "planned", plannedActions: actions, appliedActions: [] });
|
|
231
|
+
return 0; // zero writes — the fixture home is untouched
|
|
232
|
+
}
|
|
233
|
+
// --yes: first non-dry run has approval; proceed to write.
|
|
234
|
+
return applyAndReport(ctx, { heedFile, agents, existed, ...(serverEdit === null ? {} : { text: serverEdit.text }) });
|
|
235
|
+
}
|
|
236
|
+
/** heed.yaml (or the template) with `--server` as `heedvane.url`; null when it already names that server. Throws on an unusable URL. */
|
|
237
|
+
function withServer(home, loaded, server) {
|
|
238
|
+
const current = loaded.existed ? readFileSync(join(home, ".heed", "heed.yaml"), "utf8") : DEFAULT_HEED_YAML_TEMPLATE;
|
|
239
|
+
const text = withServerUrl(current, server);
|
|
240
|
+
const heedFile = loadHeedFile(text);
|
|
241
|
+
if (loaded.existed && heedFile.heedvane.url === loaded.heedFile.heedvane.url)
|
|
242
|
+
return null;
|
|
243
|
+
return { heedFile, existed: loaded.existed, text };
|
|
244
|
+
}
|
|
245
|
+
async function applyAndReport(ctx, spec) {
|
|
246
|
+
const { home, env, out, flags } = ctx;
|
|
247
|
+
const { heedFile, agents } = spec;
|
|
248
|
+
// The control file comes first (mock order): "✓ Wrote ~/.heed/heed.yaml".
|
|
249
|
+
writeControlFile(ctx, spec);
|
|
250
|
+
const actions = planFor({ home, env, heedFile, agents });
|
|
251
|
+
const dispatcher = {
|
|
252
|
+
denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
|
|
253
|
+
gating: heedFile.gating.mode,
|
|
254
|
+
};
|
|
255
|
+
const applied = await applyPlan(actions, { home, env, out, dispatcher });
|
|
256
|
+
// The listener port and token every dispatcher reads; kept once issued.
|
|
257
|
+
await ensureEndpoint(join(home, ".heed"));
|
|
258
|
+
for (const line of renderApplied(applied))
|
|
259
|
+
out(line);
|
|
260
|
+
// Machine state: CLI-owned; preserve an existing endpoint.
|
|
261
|
+
const root = join(home, ".heed");
|
|
262
|
+
const existing = await readState(root);
|
|
263
|
+
const state = existing ?? emptyState();
|
|
264
|
+
// Record the skill only when this run installed it (or found it current).
|
|
265
|
+
const skillInstalled = applied.some((a) => a.kind === "skill" && (a.status === "installed" || a.status === "unchanged"));
|
|
266
|
+
if (skillInstalled)
|
|
267
|
+
state.skill = { version: "1", digest: skillDigest(canonicalSkillFiles()) };
|
|
268
|
+
await writeStateAtomic(root, state);
|
|
269
|
+
if (!flags.dryRun)
|
|
270
|
+
await signInIfNeeded(ctx, heedFile.heedvane.url);
|
|
271
|
+
out("");
|
|
272
|
+
out(" Next: open a new agent session and run /heedvane-status.");
|
|
273
|
+
out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
|
|
274
|
+
const failed = applied.some(appliedFailed);
|
|
275
|
+
if (flags.json)
|
|
276
|
+
outJson(out, { status: failed ? "failed" : "applied", plannedActions: actions, appliedActions: applied });
|
|
277
|
+
return failed ? 1 : 0;
|
|
278
|
+
}
|
|
279
|
+
/** Writes heed.yaml when it is missing, or when `--server` changed its URL. */
|
|
280
|
+
function writeControlFile({ home, out }, { heedFile, existed, text }) {
|
|
281
|
+
if (existed && text === undefined)
|
|
282
|
+
return;
|
|
283
|
+
mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
|
|
284
|
+
writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
|
|
285
|
+
out(existed ? ` ✓ Set heedvane.url in ~/.heed/heed.yaml to ${heedFile.heedvane.url}` : ` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
|
|
286
|
+
}
|
|
287
|
+
/** Without a sign-in to `url` (and to `--org`, when named), sign in now when a person is at the terminal, else say how. Never fails setup. */
|
|
288
|
+
async function signInIfNeeded(ctx, url) {
|
|
289
|
+
const root = join(ctx.home, ".heed");
|
|
290
|
+
if ((await backendAt(root, url)) !== null && (await onRequestedOrg(root, ctx.org)))
|
|
291
|
+
return;
|
|
292
|
+
if (!ctx.interactive) {
|
|
293
|
+
ctx.out(" Next: sign in with `npx heedvane login` (CI and headless machines: npx heedvane auth org < key-file).");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
await cmdLogin({ home: ctx.home, out: ctx.out, words: [], project: false, readStdin: async () => "", openBrowser: ctx.openBrowser });
|
|
297
|
+
if (!(await onRequestedOrg(root, ctx.org))) {
|
|
298
|
+
ctx.out(` ✗ This sign-in belongs to another organization than ${ctx.org}. Run \`npx heedvane login\` and pick that organization on the consent page.`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** True without `--org`; else whether the saved login was granted for that organization. */
|
|
302
|
+
async function onRequestedOrg(root, org) {
|
|
303
|
+
if (org === undefined)
|
|
304
|
+
return true;
|
|
305
|
+
const saved = await readLogin(root).catch(() => null);
|
|
306
|
+
return saved?.organizationId === org;
|
|
307
|
+
}
|
|
308
|
+
async function cmdSetup(ctx) {
|
|
309
|
+
const { home, out } = ctx;
|
|
310
|
+
const path = join(home, ".heed", "heed.yaml");
|
|
311
|
+
if (!existsSync(path)) {
|
|
312
|
+
out(" ✗ ~/.heed/heed.yaml not found — setup compiles your control file into agent configs.");
|
|
313
|
+
out(" remediation: run npx heedvane init");
|
|
314
|
+
return 1;
|
|
315
|
+
}
|
|
316
|
+
let heedFile;
|
|
317
|
+
try {
|
|
318
|
+
heedFile = loadHeedFileAt(path, out);
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
out(` ✗ ~/.heed/heed.yaml does not parse: ${errorMessage(err)}`);
|
|
322
|
+
out(" remediation: fix it, then re-run npx heedvane setup");
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
banner(out);
|
|
326
|
+
if (ctx.flags.dryRun) {
|
|
327
|
+
const actions = planFor({ home, env: ctx.env, heedFile, agents: ctx.agents });
|
|
328
|
+
for (const line of renderPlan(actions))
|
|
329
|
+
out(line);
|
|
330
|
+
out("");
|
|
331
|
+
out(wouldInstallSummary(actions));
|
|
332
|
+
if (ctx.flags.json)
|
|
333
|
+
outJson(out, { status: "planned", plannedActions: actions, appliedActions: [] });
|
|
334
|
+
return 0; // zero writes
|
|
335
|
+
}
|
|
336
|
+
return applyAndReport(ctx, { heedFile, agents: ctx.agents, existed: true });
|
|
337
|
+
}
|
|
338
|
+
/** Hermes' hooks capability rides its native plugin; installHermesPlugin owns creation and ownership checks. */
|
|
339
|
+
async function applyHermesPlugin(client, ctx) {
|
|
340
|
+
const path = join(ctx.home, ".hermes", "plugins", "heedvane-hooks", "plugin.yaml");
|
|
341
|
+
const status = await installHermesPlugin(ctx.home, ctx.dispatcher.gating);
|
|
342
|
+
if (status === "skipped") {
|
|
343
|
+
return { clientId: client.id, kind: "hooks", status: "skipped", path, reason: "another plugin owns ~/.hermes/plugins/heedvane-hooks — left untouched" };
|
|
344
|
+
}
|
|
345
|
+
return { clientId: client.id, kind: "hooks", status: status === "installed" ? "create" : "unchanged", path };
|
|
346
|
+
}
|
|
347
|
+
/** Drop our groups under event keys this client no longer installs; a failed removal becomes the result. */
|
|
348
|
+
async function withoutStaleHookGroups(client, writes, result) {
|
|
349
|
+
let worst = result;
|
|
350
|
+
for (const { target, containerKey } of writes) {
|
|
351
|
+
if (containerKey !== "hooks")
|
|
352
|
+
continue;
|
|
353
|
+
for (const item of await removeStaleHookGroups(client, target)) {
|
|
354
|
+
if (item.status === "failed")
|
|
355
|
+
return { status: "failed", path: item.path, reason: item.detail };
|
|
356
|
+
if (worst.status === "unchanged")
|
|
357
|
+
worst = { status: "update", path: item.path };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return worst;
|
|
361
|
+
}
|
|
362
|
+
/** Patch every desired entry into the client's config files, then refresh the dispatcher script. */
|
|
363
|
+
async function applyConfigEntries(action, client, ctx) {
|
|
364
|
+
const desireds = desiredEntriesFor(client, action.kind === "hooks" ? "hooks" : "mcp", { home: ctx.home, env: ctx.env });
|
|
365
|
+
// One write per file and container: every hook event lands (and is backed up) together.
|
|
366
|
+
const writes = new Map();
|
|
367
|
+
for (const d of desireds) {
|
|
368
|
+
const key = `${d.path}\0${d.containerKey}`;
|
|
369
|
+
const write = writes.get(key) ?? { target: { path: d.path, format: d.format }, containerKey: d.containerKey, entries: {} };
|
|
370
|
+
write.entries[d.entryName] = d.entry;
|
|
371
|
+
writes.set(key, write);
|
|
372
|
+
}
|
|
373
|
+
let worst = { status: "unchanged", path: action.path };
|
|
374
|
+
for (const { target, containerKey, entries } of writes.values()) {
|
|
375
|
+
const res = await applyEntry(target, { containerKey, entries });
|
|
376
|
+
if (res.status === "failed" || res.status === "invalid") {
|
|
377
|
+
worst = res;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (res.status !== "unchanged")
|
|
381
|
+
worst = res;
|
|
382
|
+
}
|
|
383
|
+
if (action.kind === "hooks" && worst.status !== "failed" && worst.status !== "invalid") {
|
|
384
|
+
worst = await withoutStaleHookGroups(client, writes.values(), worst);
|
|
385
|
+
}
|
|
386
|
+
// The dispatcher script the snippet delegates to.
|
|
387
|
+
await writeDispatcherScript(ctx.home, client.id, ctx.dispatcher);
|
|
388
|
+
return {
|
|
389
|
+
clientId: action.clientId,
|
|
390
|
+
kind: action.kind,
|
|
391
|
+
status: worst.status,
|
|
392
|
+
path: worst.path,
|
|
393
|
+
backupPath: worst.backupPath,
|
|
394
|
+
reason: worst.reason,
|
|
395
|
+
remediation: worst.remediation,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** One planned action → one applied result. */
|
|
399
|
+
async function applyAction(action, client, ctx) {
|
|
400
|
+
if (action.kind === "skill") {
|
|
401
|
+
const status = await installSkill(join(ctx.home, ".agents", "skills"));
|
|
402
|
+
return { clientId: action.clientId, kind: "skill", status, path: action.path };
|
|
403
|
+
}
|
|
404
|
+
if (client.id === "hermes" && action.kind === "hooks") {
|
|
405
|
+
return applyHermesPlugin(client, ctx);
|
|
406
|
+
}
|
|
407
|
+
return applyConfigEntries(action, client, ctx);
|
|
408
|
+
}
|
|
409
|
+
async function recordDeferredHookRemovals(client, results) {
|
|
410
|
+
if (!client)
|
|
411
|
+
return;
|
|
412
|
+
for (const item of await removeDeferredHookGroups(client)) {
|
|
413
|
+
if (item.status === "removed") {
|
|
414
|
+
results.push({ clientId: client.id, kind: "hooks", status: "update", path: item.path });
|
|
415
|
+
}
|
|
416
|
+
else if (item.status === "failed") {
|
|
417
|
+
results.push({ clientId: client.id, kind: "hooks", status: "failed", path: item.path, reason: item.detail });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async function applyPlan(actions, ctx) {
|
|
422
|
+
const results = [];
|
|
423
|
+
const done = new Set(); // unique (kind,path) — skill is shared across clients
|
|
424
|
+
const clients = supportedClients({ home: ctx.home, platform: platform(), env: ctx.env });
|
|
425
|
+
for (const action of actions) {
|
|
426
|
+
const client = clients.find((c) => c.id === action.clientId);
|
|
427
|
+
if (action.kind === "skip") {
|
|
428
|
+
await recordDeferredHookRemovals(client, results);
|
|
429
|
+
results.push({ clientId: action.clientId, kind: "skip", status: "skipped", path: action.path, reason: action.reason });
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const key = `${action.kind}:${action.path}`;
|
|
433
|
+
if (done.has(key))
|
|
434
|
+
continue;
|
|
435
|
+
done.add(key);
|
|
436
|
+
if (!client)
|
|
437
|
+
continue;
|
|
438
|
+
results.push(await applyAction(action, client, ctx));
|
|
439
|
+
}
|
|
440
|
+
return results;
|
|
441
|
+
}
|
|
442
|
+
function outJson(out, envelope) {
|
|
443
|
+
out(JSON.stringify(envelope));
|
|
444
|
+
}
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// doctor / uninstall / listener
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
/** Glyph for a doctor check line: pass, warn-only, or fail. */
|
|
449
|
+
function checkGlyph(check) {
|
|
450
|
+
if (check.ok)
|
|
451
|
+
return "✓";
|
|
452
|
+
return check.warnOnly ? "·" : "✗";
|
|
453
|
+
}
|
|
454
|
+
async function cmdDoctor(ctx) {
|
|
455
|
+
const report = await runDoctor({ home: ctx.home, env: ctx.env, clients: ctx.clients.length > 0 ? ctx.clients : undefined, json: ctx.flags.json });
|
|
456
|
+
if (ctx.flags.json) {
|
|
457
|
+
ctx.out(JSON.stringify({ status: report.healthy ? "healthy" : "unhealthy", ...report }));
|
|
458
|
+
return report.healthy ? 0 : 1;
|
|
459
|
+
}
|
|
460
|
+
ctx.out(` Heedvane doctor — ${report.scopedTo ? `scoped to ${report.scopedTo.join(", ")}` : "all checks"}`);
|
|
461
|
+
ctx.out("");
|
|
462
|
+
for (const check of report.checks) {
|
|
463
|
+
ctx.out(` ${checkGlyph(check)} ${check.name}: ${check.detail}`);
|
|
464
|
+
if (check.remediation)
|
|
465
|
+
ctx.out(` remediation: ${check.remediation}`);
|
|
466
|
+
}
|
|
467
|
+
ctx.out("");
|
|
468
|
+
ctx.out(` ${report.healthy ? "All scoped checks pass." : "Some checks failed — see remediations above."}`);
|
|
469
|
+
return report.healthy ? 0 : 1;
|
|
470
|
+
}
|
|
471
|
+
/** Glyph for an uninstall item line: removed, failed, or skipped/absent. */
|
|
472
|
+
function uninstallGlyph(status) {
|
|
473
|
+
if (status === "removed")
|
|
474
|
+
return "✓";
|
|
475
|
+
return status === "failed" ? "✗" : "·";
|
|
476
|
+
}
|
|
477
|
+
async function cmdUninstall(ctx) {
|
|
478
|
+
const items = await uninstall({ home: ctx.home, env: ctx.env, agents: ctx.agents.length > 0 ? ctx.agents : undefined, purgeConfig: ctx.purge });
|
|
479
|
+
for (const item of items) {
|
|
480
|
+
ctx.out(` ${uninstallGlyph(item.status)} ${item.name} ${item.path}${item.detail ? ` (${item.detail})` : ""}`);
|
|
481
|
+
}
|
|
482
|
+
ctx.out("");
|
|
483
|
+
ctx.out(" heed.yaml was left untouched (your file). Pass --purge-config to delete it too.");
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
async function cmdListener(ctx) {
|
|
487
|
+
const sub = ctx.rest[0] ?? "";
|
|
488
|
+
if (sub !== "start") {
|
|
489
|
+
ctx.out(" usage: npx heedvane listener start");
|
|
490
|
+
ctx.out(" v1 runs the listener in the foreground; service install (launchd/systemd) is opt-in later.");
|
|
491
|
+
return sub === "" ? 2 : 1;
|
|
492
|
+
}
|
|
493
|
+
const started = await serveHooks(ctx.home, ctx.out);
|
|
494
|
+
ctx.out(` ✓ listener on 127.0.0.1:${started.port}, ${started.replayed} spooled event(s) replayed (Ctrl-C to stop)`);
|
|
495
|
+
await new Promise((resolve) => {
|
|
496
|
+
process.on("SIGINT", () => {
|
|
497
|
+
started.server.close(() => resolve());
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
return 0;
|
|
501
|
+
}
|
|
502
|
+
function backendCommandContext(ctx) {
|
|
503
|
+
return { home: ctx.home, out: ctx.out, words: ctx.positional.slice(1), project: ctx.args.includes("--project"), readStdin: ctx.readStdin, openBrowser: ctx.openBrowser };
|
|
504
|
+
}
|
|
505
|
+
/** All of stdin as text (where `auth` reads its key). */
|
|
506
|
+
/** Open `url` in the system browser; the URL is printed too, so a failure here only costs a click. */
|
|
507
|
+
/**
|
|
508
|
+
* The command that opens `url` in the default browser. Windows goes through rundll32, not `cmd /c start`:
|
|
509
|
+
* cmd would cut the sign-in URL at its first `&`.
|
|
510
|
+
*/
|
|
511
|
+
export function browserOpener(platform, url) {
|
|
512
|
+
const openers = { darwin: ["open", [url]], win32: ["rundll32", ["url.dll,FileProtocolHandler", url]] };
|
|
513
|
+
return openers[platform] ?? ["xdg-open", [url]];
|
|
514
|
+
}
|
|
515
|
+
function openSystemBrowser(url) {
|
|
516
|
+
const [command, args] = browserOpener(process.platform, url);
|
|
517
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
518
|
+
child.on("error", () => undefined); // no browser opener: the printed URL is the way in
|
|
519
|
+
child.unref();
|
|
520
|
+
}
|
|
521
|
+
async function readProcessStdin() {
|
|
522
|
+
const chunks = [];
|
|
523
|
+
for await (const chunk of process.stdin)
|
|
524
|
+
chunks.push(chunk);
|
|
525
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
526
|
+
}
|
|
527
|
+
export { main };
|
|
528
|
+
// Bin entry: when executed directly (npx / ./dist/cli.js), run the CLI.
|
|
529
|
+
// When imported (tests, programmatic use), main is just exported.
|
|
530
|
+
import { fileURLToPath } from "node:url";
|
|
531
|
+
import { argv } from "node:process";
|
|
532
|
+
const invokedDirectly = (() => {
|
|
533
|
+
try {
|
|
534
|
+
return argv[1] !== undefined && realpathSync(argv[1]) === fileURLToPath(import.meta.url);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
if (invokedDirectly) {
|
|
541
|
+
void main(argv.slice(2)).then((code) => {
|
|
542
|
+
process.exitCode = code;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative table of supported coding-agent clients.
|
|
3
|
+
*
|
|
4
|
+
* One source of truth for every agent `heedvane-hook` can integrate with:
|
|
5
|
+
* detection marker, config targets (path + format + purpose), capabilities,
|
|
6
|
+
* and the agent's native hook-event set. Path fragments are home-relative;
|
|
7
|
+
* {@link supportedClients} resolves them into absolute per-platform paths
|
|
8
|
+
* from a pure `{ home, platform, env }` context (no process state, so the
|
|
9
|
+
* table is trivially testable against fixture homes).
|
|
10
|
+
*
|
|
11
|
+
* This module imports nothing outside `node:path` — the package must stay
|
|
12
|
+
* standalone-publishable (no intra-monorepo imports, no Effect).
|
|
13
|
+
*/
|
|
14
|
+
/** Env passed to resolvers: { home, platform, env } — pure, testable. */
|
|
15
|
+
export type Env = Record<string, string | undefined>;
|
|
16
|
+
/** Config document dialects the package knows how to parse and patch. */
|
|
17
|
+
export type ClientFormat = "json" | "toml" | "jsonc" | "opencode" | "vscode" | "yaml";
|
|
18
|
+
/** Integration channels per client. `plugin-install` = Hermes-style native plugin. */
|
|
19
|
+
export type Capability = "hooks" | "mcp" | "skill" | "plugin-install";
|
|
20
|
+
/** What a config target is written for. Skill bundles install to the universal
|
|
21
|
+
* `~/.agents/skills/` directory and therefore carry no per-client target. */
|
|
22
|
+
export type ConfigPurpose = "hooks" | "mcp" | "plugin";
|
|
23
|
+
/** Platforms whose path semantics the resolver implements. */
|
|
24
|
+
export type Platform = NodeJS.Platform;
|
|
25
|
+
/** Pure resolution context — callers pass fixture/real values explicitly. */
|
|
26
|
+
export interface ResolveContext {
|
|
27
|
+
/** User home directory (fixture or real). */
|
|
28
|
+
readonly home: string;
|
|
29
|
+
/** Platform whose path semantics to apply. */
|
|
30
|
+
readonly platform: Platform;
|
|
31
|
+
/** Environment variables (only the documented override names are read). */
|
|
32
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
33
|
+
}
|
|
34
|
+
/** Root a client's directory or detection marker is resolved against. */
|
|
35
|
+
export type ClientPathBase = "home" | "xdg-config" | "platform-config";
|
|
36
|
+
/** Base a writable config target is resolved against. */
|
|
37
|
+
export type PathBase = "client" | "home" | "platform-config";
|
|
38
|
+
/** One writable config file for a client. */
|
|
39
|
+
export interface ConfigTarget {
|
|
40
|
+
/** Path segments relative to {@link PathBase}. */
|
|
41
|
+
readonly segments: readonly string[];
|
|
42
|
+
readonly base: PathBase;
|
|
43
|
+
readonly format: ClientFormat;
|
|
44
|
+
readonly purpose: ConfigPurpose;
|
|
45
|
+
/** Honest caveat surfaced by plan/doctor instead of a fake write. */
|
|
46
|
+
readonly limitation?: string;
|
|
47
|
+
}
|
|
48
|
+
/** A resolved, absolute-path config target. */
|
|
49
|
+
export interface ResolvedConfigTarget {
|
|
50
|
+
readonly path: string;
|
|
51
|
+
readonly format: ClientFormat;
|
|
52
|
+
readonly purpose: ConfigPurpose;
|
|
53
|
+
readonly limitation?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Static (unresolved) client definition — the declarative table row. */
|
|
56
|
+
export interface ClientDef {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
readonly displayName: string;
|
|
59
|
+
/** Home-relative marker whose existence means "detected" (dir or file). */
|
|
60
|
+
readonly markerSegments: readonly string[];
|
|
61
|
+
readonly configs: readonly ConfigTarget[];
|
|
62
|
+
readonly capabilities: readonly Capability[];
|
|
63
|
+
/** The agent's native hook events. Empty = no native hooks. */
|
|
64
|
+
readonly events: readonly string[];
|
|
65
|
+
/** Root key MCP server entries are written under for this dialect. */
|
|
66
|
+
readonly mcpServersKey: string;
|
|
67
|
+
/** Directory base used for marker + `client`-base config targets. */
|
|
68
|
+
readonly base: ClientPathBase;
|
|
69
|
+
/** App name inside the per-platform config dir (base `platform-config`). */
|
|
70
|
+
readonly platformConfigAppName?: string;
|
|
71
|
+
/** Vendor env var that replaces the client directory itself. */
|
|
72
|
+
readonly homeOverrideEnv?: string;
|
|
73
|
+
/** Base for the marker when it differs from {@link ClientDef.base}. */
|
|
74
|
+
readonly markerBase?: ClientPathBase;
|
|
75
|
+
/** Honest capability caveat (e.g. Warp's hookless MCP-only story). */
|
|
76
|
+
readonly limitation?: string;
|
|
77
|
+
/** Mechanism note that is not a limitation (e.g. Hermes plugin install). */
|
|
78
|
+
readonly notes?: string;
|
|
79
|
+
/** How the hooks file holds one hook: Claude Code's `{ matcher, hooks: [...] }` group (default) or a bare `{ command }`. */
|
|
80
|
+
readonly hookEntries?: HookEntryShape;
|
|
81
|
+
/** A schema version the hooks file must declare at its top level (`version`). */
|
|
82
|
+
readonly hooksVersion?: number;
|
|
83
|
+
/** Another client whose installed hooks this one also runs; when both are managed, only that client's are installed. */
|
|
84
|
+
readonly runsHooksOf?: string;
|
|
85
|
+
/** Additional detection markers (e.g. GUI ADE app-support directory when CLI and GUI share configs). */
|
|
86
|
+
readonly altMarkers?: readonly {
|
|
87
|
+
readonly segments: readonly string[];
|
|
88
|
+
readonly base: ClientPathBase;
|
|
89
|
+
}[];
|
|
90
|
+
}
|
|
91
|
+
/** How a hooks file holds one hook command. */
|
|
92
|
+
export type HookEntryShape = "group" | "flat";
|
|
93
|
+
/** A fully resolved client: absolute marker + config paths for one platform. */
|
|
94
|
+
export interface ResolvedClient {
|
|
95
|
+
readonly id: string;
|
|
96
|
+
readonly displayName: string;
|
|
97
|
+
readonly markerPath: string;
|
|
98
|
+
readonly altMarkerPaths?: readonly string[];
|
|
99
|
+
readonly configs: readonly ResolvedConfigTarget[];
|
|
100
|
+
readonly capabilities: readonly Capability[];
|
|
101
|
+
readonly events: readonly string[];
|
|
102
|
+
readonly mcpServersKey: string;
|
|
103
|
+
readonly limitation?: string;
|
|
104
|
+
readonly notes?: string;
|
|
105
|
+
readonly hookEntries?: HookEntryShape;
|
|
106
|
+
readonly hooksVersion?: number;
|
|
107
|
+
readonly runsHooksOf?: string;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolve every client definition into absolute marker/config paths for the
|
|
111
|
+
* given context. Pure: reads only `ctx` — never `process.env`, `os`, or disk.
|
|
112
|
+
*/
|
|
113
|
+
export declare function supportedClients(ctx: ResolveContext): ResolvedClient[];
|