heedvane 0.1.0 → 0.2.1
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 +42 -7
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +205 -37
- package/dist/dispatchers/posix.d.ts +9 -0
- package/dist/dispatchers/posix.js +17 -0
- package/dist/doctor.js +7 -20
- package/dist/heed-file.d.ts +11 -0
- package/dist/heed-file.js +21 -0
- package/dist/installer.d.ts +47 -0
- package/dist/installer.js +174 -0
- package/dist/listener/background.d.ts +8 -0
- package/dist/listener/background.js +36 -0
- package/dist/listener/endpoint.d.ts +5 -0
- package/dist/listener/endpoint.js +13 -0
- package/dist/listener/server.d.ts +2 -0
- package/dist/listener/server.js +23 -3
- package/dist/render.d.ts +2 -0
- package/dist/render.js +2 -1
- package/dist/uninstall.js +27 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,28 +1,63 @@
|
|
|
1
1
|
# heedvane
|
|
2
2
|
|
|
3
|
-
Connect the coding agents on your machine to your Heedvane organization:
|
|
3
|
+
Connect the coding agents on your machine to your Heedvane organization: guardrails, approved-skill
|
|
4
4
|
checks and team memory for Claude Code, Codex, Cursor, Gemini CLI, Hermes, OpenCode and more.
|
|
5
5
|
|
|
6
6
|
```sh
|
|
7
|
-
npx heedvane
|
|
7
|
+
npx heedvane
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
The installer shows the agents it found on your machine. Tick the ones you want, choose what Heedvane
|
|
11
|
+
adds and does, confirm. It installs, starts Heedvane in the background and signs you in through the
|
|
12
|
+
browser. It needs Node.js 22 or newer.
|
|
13
|
+
|
|
14
|
+
Your dashboard's Agents page has the same command with your server and organization filled in:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx heedvane connect --server https://app.heedvane.com --org <organization id>
|
|
18
|
+
```
|
|
11
19
|
|
|
12
20
|
## What it changes
|
|
13
21
|
|
|
14
|
-
- Writes `~/.heed/heed.yaml`, your control file
|
|
15
|
-
|
|
22
|
+
- Writes `~/.heed/heed.yaml`, your control file, with your answers. Run `npx heedvane` again to change
|
|
23
|
+
them.
|
|
24
|
+
- Adds Heedvane's hooks, MCP server entry and skill to each agent you ticked. A config file it edits is
|
|
16
25
|
backed up first.
|
|
17
|
-
-
|
|
26
|
+
- Starts Heedvane's local listener, which answers the hooks. After a restart the hooks start it again by
|
|
27
|
+
themselves, at most once a minute. Set `HEED_LISTENER_AUTOSTART=0` to turn that off.
|
|
18
28
|
|
|
19
29
|
Run `npx heedvane connect --dry-run` to see every file it would touch without writing anything.
|
|
20
30
|
|
|
31
|
+
## Software factories, CI and containers
|
|
32
|
+
|
|
33
|
+
Agents that run unattended (a factory looping on a server, a CI job, a container on AWS) use the same
|
|
34
|
+
package without the installer:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
# Once, when you build the image: install the hooks for the agent the factory runs
|
|
38
|
+
npx -y heedvane connect --server https://app.heedvane.com --org <organization id> --agent claude --yes
|
|
39
|
+
|
|
40
|
+
# When the container starts: sign in with an organization key from your secret store
|
|
41
|
+
printf '%s' "$HEEDVANE_ORG_KEY" | npx heedvane auth org
|
|
42
|
+
|
|
43
|
+
# Every agent run: Heedvane answers the hooks for exactly this run
|
|
44
|
+
npx heedvane run -- claude -p "fix the failing tests"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`run` keeps the agent's terminal output and exit code, so your pipeline reads them as before. Heedvane's
|
|
48
|
+
own lines go to stderr and `~/.heed/listener.log`.
|
|
49
|
+
|
|
50
|
+
- Claude Code runs the hooks in print mode (`claude -p`), but not with `--bare`.
|
|
51
|
+
- Codex runs hooks only after they are trusted.
|
|
52
|
+
- The installer does not set up Pi or Flue. Connect them with a hook of your own that sends each action to `POST /api/agent/steering` with the organization key; Heedvane answers whether to refuse it.
|
|
53
|
+
|
|
21
54
|
## Everyday commands
|
|
22
55
|
|
|
23
56
|
| Command | What it does |
|
|
24
57
|
| --- | --- |
|
|
25
|
-
| `npx heedvane
|
|
58
|
+
| `npx heedvane` | Opens the installer: change agents and features any time. |
|
|
26
59
|
| `npx heedvane doctor` | Checks the install and prints a fix for each problem. |
|
|
60
|
+
| `npx heedvane listener start` | Runs the listener in the foreground. |
|
|
61
|
+
| `npx heedvane run -- <command>` | Runs one agent with Heedvane, for CI and containers. |
|
|
27
62
|
| `npx heedvane uninstall` | Removes only the entries Heedvane added. `heed.yaml` stays. |
|
|
28
63
|
| `npx heedvane --help` | Lists every command and flag. |
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { type PromptStreams } from "./installer.js";
|
|
2
3
|
export interface MainOptions {
|
|
3
4
|
/** Env override (tests inject HEED_HOME fixtures). Defaults to process.env. */
|
|
4
5
|
env?: Record<string, string | undefined>;
|
|
@@ -12,6 +13,10 @@ export interface MainOptions {
|
|
|
12
13
|
cwd?: string;
|
|
13
14
|
/** May init/setup start a browser sign-in? Defaults to whether a person is at the terminal. */
|
|
14
15
|
interactive?: boolean;
|
|
16
|
+
/** Where the interactive installer reads keys and draws; defaults to the terminal (tests inject streams). */
|
|
17
|
+
prompts?: PromptStreams;
|
|
18
|
+
/** Starts the listener in the background for the installer; defaults to a detached `listener start` (tests pass a stand-in). */
|
|
19
|
+
startListener?: (home: string) => Promise<boolean>;
|
|
15
20
|
}
|
|
16
21
|
declare function main(argv: string[], opts?: MainOptions): Promise<number>;
|
|
17
22
|
/** All of stdin as text (where `auth` reads its key). */
|
package/dist/cli.js
CHANGED
|
@@ -4,16 +4,16 @@
|
|
|
4
4
|
* listener | status | mcp. Plain argv parsing (no commander); all state
|
|
5
5
|
* mutations flow through plan → apply; --dry-run writes nothing.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
-
import { homedir, platform } from "node:os";
|
|
10
|
-
import { join } from "node:path";
|
|
9
|
+
import { constants as osConstants, homedir, platform } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
11
|
import { supportedClients } from "./clients/definitions.js";
|
|
12
12
|
import { installHermesPlugin } from "./clients/hermes-plugin.js";
|
|
13
13
|
import { applyEntry } from "./apply.js";
|
|
14
|
-
import { DEFAULT_HEED_YAML_TEMPLATE, loadHeedFile, loadHeedFileAt, withServerUrl } from "./heed-file.js";
|
|
14
|
+
import { DEFAULT_HEED_YAML_TEMPLATE, heedvaneConfigAt, loadHeedFile, loadHeedFileAt, withInstallChoices, withServerUrl } from "./heed-file.js";
|
|
15
15
|
import { planActions, desiredEntriesFor, isClientDetected } from "./plan.js";
|
|
16
|
-
import { appliedFailed, renderApplied, renderDetection, renderPlan, wouldInstallSummary } from "./render.js";
|
|
16
|
+
import { appliedFailed, capabilitySummary, renderApplied, renderDetection, renderPlan, wouldInstallSummary } from "./render.js";
|
|
17
17
|
import { runDoctor } from "./doctor.js";
|
|
18
18
|
import { installSkill } from "./skill/bundle.js";
|
|
19
19
|
import { canonicalSkillFiles, skillDigest } from "./skill/content.js";
|
|
@@ -23,17 +23,21 @@ import { removeDeferredHookGroups, removeStaleHookGroups, uninstall } from "./un
|
|
|
23
23
|
import { statusText, runMcpStdio } from "./mcp-server.js";
|
|
24
24
|
import { errorMessage } from "./guards.js";
|
|
25
25
|
import { ensureEndpoint } from "./listener/endpoint.js";
|
|
26
|
+
import { listenerLogPath, startListenerInBackground } from "./listener/background.js";
|
|
26
27
|
import { serveHooks } from "./listener/serve.js";
|
|
27
28
|
import { cmdAuth, cmdLogin, cmdSkills } from "./commands/heedvane.js";
|
|
28
29
|
import { cmdMemory } from "./commands/memory.js";
|
|
29
30
|
import { backendAt } from "./backend/credentials.js";
|
|
30
31
|
import { readLogin } from "./backend/login.js";
|
|
32
|
+
import { agentLines, askChoices, brandIntro, confirmInstall, installCancelled, installFinished, installWithSpinner, listenerStep, promptLine, showInstalled } from "./installer.js";
|
|
31
33
|
const USAGE = ` heedvane-hook — cross-agent steering, guardrails & analysis
|
|
32
34
|
|
|
33
|
-
Usage: npx heedvane
|
|
35
|
+
Usage: npx heedvane [command] [flags] (installed: heedvane <command>)
|
|
36
|
+
npx heedvane on its own, at a terminal, opens the installer: tick agents and features, confirm.
|
|
34
37
|
|
|
35
38
|
Commands:
|
|
36
|
-
connect
|
|
39
|
+
connect set up this machine: the installer at a terminal; with --yes (or in a script) every detected agent at once
|
|
40
|
+
run -- <cmd> CI, containers and software factories: Heedvane answers the hooks for the length of one agent run
|
|
37
41
|
init write ~/.heed/heed.yaml (if missing), plan, apply
|
|
38
42
|
setup plan + apply from an existing heed.yaml
|
|
39
43
|
doctor scoped, remediation-carrying health check
|
|
@@ -94,15 +98,63 @@ function parseInvocation(args) {
|
|
|
94
98
|
clients: parseRepeatedFlag(args, "--client"),
|
|
95
99
|
server: flagValue(args, "--server") ?? flagValue(args, "--url"),
|
|
96
100
|
org: flagValue(args, "--org"),
|
|
97
|
-
positional: positionalArgs(args,
|
|
101
|
+
positional: positionalArgs(args, VALUE_FLAGS),
|
|
98
102
|
};
|
|
99
103
|
}
|
|
104
|
+
/** Flags without a value. */
|
|
105
|
+
const SWITCH_FLAGS = ["--dry-run", "--yes", "--json", "--purge-config", "--project", "--help", "-h"];
|
|
106
|
+
/** Flags followed by a value. */
|
|
107
|
+
const VALUE_FLAGS = ["--agent", "--client", "--folder", "--offset", "--url", "--server", "--org", "--token"];
|
|
108
|
+
/** Heedvane's own arguments: everything before `--`, after which `run` passes the rest to the agent. */
|
|
109
|
+
function ownArgs(args) {
|
|
110
|
+
const separator = args.indexOf("--");
|
|
111
|
+
return separator < 0 ? args : args.slice(0, separator);
|
|
112
|
+
}
|
|
113
|
+
/** The first argument that looks like a flag but is not one of ours (values of value flags skipped). */
|
|
114
|
+
function unknownFlag(args) {
|
|
115
|
+
for (let i = 0; i < args.length; i++) {
|
|
116
|
+
if (VALUE_FLAGS.includes(args[i]))
|
|
117
|
+
i += 1;
|
|
118
|
+
else if (args[i].startsWith("-") && !SWITCH_FLAGS.includes(args[i]))
|
|
119
|
+
return args[i];
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
/** One command's usage line from USAGE plus the flags, or the whole USAGE for no or an unknown command. */
|
|
124
|
+
function helpFor(command) {
|
|
125
|
+
const lines = USAGE.split("\n");
|
|
126
|
+
const own = command === undefined ? [] : lines.filter((line) => line.trim().startsWith(`${command} `));
|
|
127
|
+
if (own.length === 0)
|
|
128
|
+
return USAGE;
|
|
129
|
+
const flags = lines.slice(lines.findIndex((line) => line.trim().startsWith("Flags:")));
|
|
130
|
+
return [` Usage: npx heedvane ${command} [flags]`, "", ...own, "", ...flags].join("\n");
|
|
131
|
+
}
|
|
132
|
+
/** The command help is asked about: `help <command>`, `<command> --help`, or none for `-h` alone. */
|
|
133
|
+
function helpTopic([first, second]) {
|
|
134
|
+
if (first === "help")
|
|
135
|
+
return second;
|
|
136
|
+
return first === "-h" ? undefined : first;
|
|
137
|
+
}
|
|
100
138
|
async function main(argv, opts = {}) {
|
|
101
139
|
const ctx = commandContext(argv, opts);
|
|
102
140
|
const command = ctx.positional[0];
|
|
103
|
-
|
|
141
|
+
const own = ownArgs(ctx.args);
|
|
142
|
+
// Help never runs anything: `uninstall --help` must not uninstall.
|
|
143
|
+
if (command === "help" || own.includes("--help") || own.includes("-h")) {
|
|
144
|
+
ctx.out(helpFor(helpTopic(ctx.positional)));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
const unknown = unknownFlag(own);
|
|
148
|
+
if (unknown !== undefined) {
|
|
149
|
+
ctx.out(` ✗ unknown flag: ${unknown} (nothing was changed)`);
|
|
150
|
+
ctx.out(helpFor(command));
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
if (command === undefined && takesInstaller(ctx))
|
|
154
|
+
return cmdInstall(ctx);
|
|
155
|
+
if (!command) {
|
|
104
156
|
ctx.out(USAGE);
|
|
105
|
-
return
|
|
157
|
+
return 2;
|
|
106
158
|
}
|
|
107
159
|
return routeCommand(command, ctx);
|
|
108
160
|
}
|
|
@@ -119,12 +171,14 @@ function commandContext(argv, opts) {
|
|
|
119
171
|
openBrowser: opts.openBrowser ?? openSystemBrowser,
|
|
120
172
|
interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
|
|
121
173
|
cwd: opts.cwd ?? process.cwd(),
|
|
174
|
+
prompts: opts.prompts ?? {},
|
|
175
|
+
startListener: opts.startListener ?? ((home) => startListenerInBackground(home, fileURLToPath(import.meta.url), env)),
|
|
122
176
|
};
|
|
123
177
|
}
|
|
124
178
|
async function routeCommand(command, ctx) {
|
|
125
179
|
switch (command) {
|
|
126
180
|
case "connect":
|
|
127
|
-
return cmdInit({ ...ctx, flags: { ...ctx.flags, yes: true } });
|
|
181
|
+
return takesInstaller(ctx) ? cmdInstall(ctx) : cmdInit({ ...ctx, flags: { ...ctx.flags, yes: true } });
|
|
128
182
|
case "init":
|
|
129
183
|
return cmdInit(ctx);
|
|
130
184
|
case "setup":
|
|
@@ -150,6 +204,8 @@ async function routeServiceCommand(command, ctx) {
|
|
|
150
204
|
case "listener": {
|
|
151
205
|
return cmdListener({ home: ctx.home, out: ctx.out, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
|
|
152
206
|
}
|
|
207
|
+
case "run":
|
|
208
|
+
return cmdRun({ home: ctx.home, env: ctx.env, command: ctx.args.slice(ctx.args.indexOf("--") + 1), separated: ctx.args.includes("--") });
|
|
153
209
|
case "login":
|
|
154
210
|
return cmdLogin(backendCommandContext(ctx));
|
|
155
211
|
case "auth":
|
|
@@ -235,15 +291,39 @@ async function cmdInit(ctx) {
|
|
|
235
291
|
}
|
|
236
292
|
/** heed.yaml (or the template) with `--server` as `heedvane.url`; null when it already names that server. Throws on an unusable URL. */
|
|
237
293
|
function withServer(home, loaded, server) {
|
|
238
|
-
const
|
|
239
|
-
const text = withServerUrl(current, server);
|
|
294
|
+
const text = withServerUrl(heedText(home, loaded.existed), server);
|
|
240
295
|
const heedFile = loadHeedFile(text);
|
|
241
296
|
if (loaded.existed && heedFile.heedvane.url === loaded.heedFile.heedvane.url)
|
|
242
297
|
return null;
|
|
243
298
|
return { heedFile, existed: loaded.existed, text };
|
|
244
299
|
}
|
|
245
300
|
async function applyAndReport(ctx, spec) {
|
|
246
|
-
const {
|
|
301
|
+
const { out, flags } = ctx;
|
|
302
|
+
const { actions, applied } = await installPlan(ctx, spec);
|
|
303
|
+
for (const line of renderApplied(applied))
|
|
304
|
+
out(line);
|
|
305
|
+
if (!flags.dryRun)
|
|
306
|
+
await signInIfNeeded(ctx, spec.heedFile.heedvane.url);
|
|
307
|
+
out("");
|
|
308
|
+
out(" Next: open a new agent session and run /heedvane-status.");
|
|
309
|
+
out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
|
|
310
|
+
const failed = applied.some(appliedFailed);
|
|
311
|
+
if (flags.json)
|
|
312
|
+
outJson(out, { status: failed ? "failed" : "applied", plannedActions: actions, appliedActions: applied });
|
|
313
|
+
return failed ? 1 : 0;
|
|
314
|
+
}
|
|
315
|
+
/** Writes heed.yaml when it is missing, or when `--server` changed its URL. */
|
|
316
|
+
function writeControlFile({ home, out }, { heedFile, existed, text }) {
|
|
317
|
+
if (existed && text === undefined)
|
|
318
|
+
return;
|
|
319
|
+
mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
|
|
320
|
+
writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
|
|
321
|
+
out(existed ? ` ✓ Set heedvane.url in ~/.heed/heed.yaml to ${heedFile.heedvane.url}` : ` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
|
|
322
|
+
}
|
|
323
|
+
/** 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. */
|
|
324
|
+
/** Writes heed.yaml, applies the plan, issues the listener endpoint and records the machine state. Prints only what applying prints. */
|
|
325
|
+
async function installPlan(ctx, spec) {
|
|
326
|
+
const { home, env, out } = ctx;
|
|
247
327
|
const { heedFile, agents } = spec;
|
|
248
328
|
// The control file comes first (mock order): "✓ Wrote ~/.heed/heed.yaml".
|
|
249
329
|
writeControlFile(ctx, spec);
|
|
@@ -251,12 +331,11 @@ async function applyAndReport(ctx, spec) {
|
|
|
251
331
|
const dispatcher = {
|
|
252
332
|
denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
|
|
253
333
|
gating: heedFile.gating.mode,
|
|
334
|
+
nodeDir: dirname(process.execPath),
|
|
254
335
|
};
|
|
255
336
|
const applied = await applyPlan(actions, { home, env, out, dispatcher });
|
|
256
337
|
// The listener port and token every dispatcher reads; kept once issued.
|
|
257
338
|
await ensureEndpoint(join(home, ".heed"));
|
|
258
|
-
for (const line of renderApplied(applied))
|
|
259
|
-
out(line);
|
|
260
339
|
// Machine state: CLI-owned; preserve an existing endpoint.
|
|
261
340
|
const root = join(home, ".heed");
|
|
262
341
|
const existing = await readState(root);
|
|
@@ -266,25 +345,8 @@ async function applyAndReport(ctx, spec) {
|
|
|
266
345
|
if (skillInstalled)
|
|
267
346
|
state.skill = { version: "1", digest: skillDigest(canonicalSkillFiles()) };
|
|
268
347
|
await writeStateAtomic(root, state);
|
|
269
|
-
|
|
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;
|
|
348
|
+
return { actions, applied };
|
|
278
349
|
}
|
|
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
350
|
async function signInIfNeeded(ctx, url) {
|
|
289
351
|
const root = join(ctx.home, ".heed");
|
|
290
352
|
if ((await backendAt(root, url)) !== null && (await onRequestedOrg(root, ctx.org)))
|
|
@@ -335,6 +397,67 @@ async function cmdSetup(ctx) {
|
|
|
335
397
|
}
|
|
336
398
|
return applyAndReport(ctx, { heedFile, agents: ctx.agents, existed: true });
|
|
337
399
|
}
|
|
400
|
+
/** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`). */
|
|
401
|
+
function takesInstaller(ctx) {
|
|
402
|
+
return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json;
|
|
403
|
+
}
|
|
404
|
+
/** heed.yaml's text, or the template when there is none yet. */
|
|
405
|
+
function heedText(home, existed) {
|
|
406
|
+
return existed ? readFileSync(join(home, ".heed", "heed.yaml"), "utf8") : DEFAULT_HEED_YAML_TEMPLATE;
|
|
407
|
+
}
|
|
408
|
+
/** The installer: pick agents, integrations and features, confirm, install, start the listener, sign in. */
|
|
409
|
+
async function cmdInstall(ctx) {
|
|
410
|
+
const base = installerBase(ctx);
|
|
411
|
+
if (typeof base === "string") {
|
|
412
|
+
ctx.out(` ✗ ${base}`);
|
|
413
|
+
return 1;
|
|
414
|
+
}
|
|
415
|
+
const { prompts } = ctx;
|
|
416
|
+
brandIntro(base.heedFile.heedvane.url, ctx.org, prompts);
|
|
417
|
+
const choices = await askChoices(agentOptions(ctx), base.heedFile, prompts);
|
|
418
|
+
if (choices === null)
|
|
419
|
+
return installCancelled(prompts);
|
|
420
|
+
const text = withInstallChoices(base.text, choices);
|
|
421
|
+
const spec = { heedFile: loadHeedFile(text), agents: choices.agents === "auto" ? [] : choices.agents, existed: base.existed, text };
|
|
422
|
+
const names = new Map(detectionRows(ctx.home, ctx.env).map((row) => [row.client.id, row.client.displayName]));
|
|
423
|
+
const planned = agentLines(planFor({ home: ctx.home, env: ctx.env, ...spec }), names);
|
|
424
|
+
if (!(await confirmInstall(planned, prompts)))
|
|
425
|
+
return installCancelled(prompts);
|
|
426
|
+
const notes = [];
|
|
427
|
+
const { actions, applied } = await installWithSpinner(planned.length, () => installPlan({ ...ctx, out: (line) => notes.push(line) }, spec), prompts);
|
|
428
|
+
notes.forEach(promptLine(prompts));
|
|
429
|
+
showInstalled(agentLines(actions, names, applied), prompts);
|
|
430
|
+
const listening = await listenerStep(() => ctx.startListener(ctx.home), listenerLogPath(join(ctx.home, ".heed")), prompts);
|
|
431
|
+
await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane.url);
|
|
432
|
+
installFinished(nextSteps(listening, spec.heedFile.heedvane.url), prompts);
|
|
433
|
+
return applied.some(appliedFailed) ? 1 : 0;
|
|
434
|
+
}
|
|
435
|
+
/** heed.yaml's current text (or the template) with `--server` applied, parsed; a message when either cannot be used. */
|
|
436
|
+
function installerBase({ home, server }) {
|
|
437
|
+
const loaded = loadOrTemplate(home);
|
|
438
|
+
if (loaded.error)
|
|
439
|
+
return `~/.heed/heed.yaml does not parse: ${loaded.error}`;
|
|
440
|
+
try {
|
|
441
|
+
const current = heedText(home, loaded.existed);
|
|
442
|
+
const text = server === undefined ? current : withServerUrl(current, server);
|
|
443
|
+
return { heedFile: loadHeedFile(text), existed: loaded.existed, text };
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
return `--server ${server} cannot be used: ${errorMessage(err)}`;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function agentOptions({ home, env }) {
|
|
450
|
+
return detectionRows(home, env).map(({ client, detected }) => ({ id: client.id, name: client.displayName, detected, capability: capabilitySummary(client) }));
|
|
451
|
+
}
|
|
452
|
+
function nextSteps(listening, server) {
|
|
453
|
+
return [
|
|
454
|
+
"Open a new agent session: Heedvane joins it at session start.",
|
|
455
|
+
`Watch your sessions: ${server}`,
|
|
456
|
+
...(listening ? [] : ["Heedvane starts by itself with your next agent session. To see why it did not start now: npx heedvane listener start"]),
|
|
457
|
+
"Check the install: npx heedvane doctor",
|
|
458
|
+
"Change your choices: npx heedvane",
|
|
459
|
+
];
|
|
460
|
+
}
|
|
338
461
|
/** Hermes' hooks capability rides its native plugin; installHermesPlugin owns creation and ownership checks. */
|
|
339
462
|
async function applyHermesPlugin(client, ctx) {
|
|
340
463
|
const path = join(ctx.home, ".hermes", "plugins", "heedvane-hooks", "plugin.yaml");
|
|
@@ -492,13 +615,58 @@ async function cmdListener(ctx) {
|
|
|
492
615
|
}
|
|
493
616
|
const started = await serveHooks(ctx.home, ctx.out);
|
|
494
617
|
ctx.out(` ✓ listener on 127.0.0.1:${started.port}, ${started.replayed} spooled event(s) replayed (Ctrl-C to stop)`);
|
|
618
|
+
// Ends on Ctrl-C, or when uninstall asks it to stop (POST /shutdown).
|
|
495
619
|
await new Promise((resolve) => {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
});
|
|
620
|
+
started.server.on("close", resolve);
|
|
621
|
+
process.on("SIGINT", () => started.server.close());
|
|
499
622
|
});
|
|
500
623
|
return 0;
|
|
501
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* `run -- <agent command>`: a listener for exactly one agent run, for CI, containers and software
|
|
627
|
+
* factories where nothing stays running. The agent keeps stdin, stdout and stderr; the listener writes to
|
|
628
|
+
* ~/.heed/listener.log and Heedvane's own lines go to stderr, so an agent's JSON output stays parseable.
|
|
629
|
+
* Returns the agent's exit code (128 + signal number when a signal ended it).
|
|
630
|
+
*/
|
|
631
|
+
async function cmdRun(ctx) {
|
|
632
|
+
const say = (line) => void process.stderr.write(`heedvane: ${line}\n`);
|
|
633
|
+
if (!ctx.separated || ctx.command.length === 0) {
|
|
634
|
+
say('usage: npx heedvane run -- <agent command>, e.g. npx heedvane run -- claude -p "fix the failing tests"');
|
|
635
|
+
return 2;
|
|
636
|
+
}
|
|
637
|
+
const root = join(ctx.home, ".heed");
|
|
638
|
+
mkdirSync(root, { recursive: true, mode: 0o700 }); // a fresh container has no ~/.heed yet
|
|
639
|
+
const log = createWriteStream(listenerLogPath(root), { flags: "a", mode: 0o600 });
|
|
640
|
+
const started = await serveHooks(ctx.home, (line) => void log.write(`${line}\n`));
|
|
641
|
+
if ((await backendAt(root, heedvaneConfigAt(root).url)) === null)
|
|
642
|
+
say("not signed in, so guardrails are off: printf '%s' \"$HEEDVANE_ORG_KEY\" | npx heedvane auth org");
|
|
643
|
+
say(`listening on 127.0.0.1:${started.port}; log: ${listenerLogPath(root)}`);
|
|
644
|
+
try {
|
|
645
|
+
// The agent's hooks find this listener's endpoint through HEED_AGENT_HOOK_ROOT, also when HEED_HOME is not $HOME.
|
|
646
|
+
return await runAgent(ctx.command, { ...ctx.env, HEED_HOME: ctx.home, HEED_AGENT_HOOK_ROOT: root }, say);
|
|
647
|
+
}
|
|
648
|
+
finally {
|
|
649
|
+
await new Promise((resolve) => started.server.close(() => resolve()));
|
|
650
|
+
await new Promise((resolve) => log.end(resolve));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
/** Runs the agent with this process's terminal, passes Ctrl-C and `docker stop` on to it, and resolves with its exit code. */
|
|
654
|
+
function runAgent([program, ...args], env, say) {
|
|
655
|
+
return new Promise((resolve) => {
|
|
656
|
+
const child = spawn(program, args, { stdio: "inherit", env });
|
|
657
|
+
const forward = (signal) => void child.kill(signal);
|
|
658
|
+
process.on("SIGINT", forward).on("SIGTERM", forward);
|
|
659
|
+
const settle = (code) => {
|
|
660
|
+
process.off("SIGINT", forward).off("SIGTERM", forward);
|
|
661
|
+
resolve(code);
|
|
662
|
+
};
|
|
663
|
+
child.on("error", (err) => {
|
|
664
|
+
say(`could not start ${program}: ${errorMessage(err)}`);
|
|
665
|
+
settle(127);
|
|
666
|
+
});
|
|
667
|
+
child.on("exit", (code, signal) => settle(code ?? 128 + (signal === null ? 0 : osConstants.signals[signal])));
|
|
668
|
+
});
|
|
669
|
+
}
|
|
502
670
|
function backendCommandContext(ctx) {
|
|
503
671
|
return { home: ctx.home, out: ctx.out, words: ctx.positional.slice(1), project: ctx.args.includes("--project"), readStdin: ctx.readStdin, openBrowser: ctx.openBrowser };
|
|
504
672
|
}
|
|
@@ -17,6 +17,12 @@ import type { HeedGatingConfig } from "../heed-file.js";
|
|
|
17
17
|
* X-Heed-Agent-Hook-Token and X-Heed-Hook-Version: 1, 0.5s connect / 0.8s max, falling back to spool
|
|
18
18
|
* on any failure. The listener answers from this machine alone; only a session start waits (2s max)
|
|
19
19
|
* for the session's one fetch of the steering policy.
|
|
20
|
+
*
|
|
21
|
+
* Start on the fly: when nothing listens on the port (curl exit 7, connection refused), the event is
|
|
22
|
+
* spooled and `npx -y heedvane listener start` is started in the background, at most once a minute
|
|
23
|
+
* (a `listener.starting` lock directory older than a minute is cleared). The listener replays the
|
|
24
|
+
* spool when it starts, so steering comes back by itself after a restart; no login service is needed.
|
|
25
|
+
* `HEED_LISTENER_AUTOSTART=0` in the agent's environment turns this off.
|
|
20
26
|
*/
|
|
21
27
|
export interface DispatcherOptions {
|
|
22
28
|
/** heed.yaml `heedvane.skills.whenUnknown: deny`: with no listener to check it, deny a skill use. */
|
|
@@ -24,6 +30,9 @@ export interface DispatcherOptions {
|
|
|
24
30
|
/** heed.yaml `gating.mode`: `pane` (the default) runs the hooks only in sessions launched with
|
|
25
31
|
* HEED_SESSION set; `always` runs them in every session. */
|
|
26
32
|
gating?: HeedGatingConfig["mode"];
|
|
33
|
+
/** The folder holding node and npx at install time, appended to PATH for the start on the fly:
|
|
34
|
+
* an agent launched from a desktop app may run its hooks without them on PATH. */
|
|
35
|
+
nodeDir?: string;
|
|
27
36
|
}
|
|
28
37
|
export declare function dispatcherScript(agentId: string, options?: DispatcherOptions): string;
|
|
29
38
|
/** The on-disk dispatcher directory under the heed root. */
|
|
@@ -15,6 +15,7 @@ export function dispatcherScript(agentId, options = {}) {
|
|
|
15
15
|
// Only Claude Code's deny shape is verified; other agents keep the fail-open fallback.
|
|
16
16
|
const denySkills = options.denySkillsWhenUnchecked === true && agentId === "claude";
|
|
17
17
|
const gating = options.gating ?? "always";
|
|
18
|
+
const nodeDir = (options.nodeDir ?? "").replaceAll("'", "'\\''");
|
|
18
19
|
return `#!/bin/sh
|
|
19
20
|
# heedvane-agent-hook dispatcher for ${agentId} — generated by heedvane-hook. Do not hand-edit.
|
|
20
21
|
# Rewritten wholesale by \`npx heedvane setup\`; policy updates never touch agent configs.
|
|
@@ -29,6 +30,7 @@ HEED_HOOK_AGENT="${agentId}"
|
|
|
29
30
|
HEED_HOOK_VERSION=1
|
|
30
31
|
HEED_HOOK_SPOOL_CAP=5242880
|
|
31
32
|
HEED_HOOK_SPOOL_TTL_DAYS=7
|
|
33
|
+
HEED_HOOK_NODE_DIR='${nodeDir}'
|
|
32
34
|
|
|
33
35
|
[ -n "\${HOME:-}" ] || exit 0
|
|
34
36
|
|
|
@@ -93,6 +95,19 @@ spool_hook_event() {
|
|
|
93
95
|
return 0
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
# Nothing listens (connection refused): start the listener in the background, at most one try a
|
|
99
|
+
# minute, so steering comes back by itself after a restart. It replays the spooled event at its start.
|
|
100
|
+
heed_start_listener() {
|
|
101
|
+
[ "\${HEED_LISTENER_AUTOSTART:-1}" != "0" ] || return 0
|
|
102
|
+
_root="\${HEED_HOOK_ENDPOINT_FILE%/*}"
|
|
103
|
+
case "$_root" in */.heed) ;; *) return 0 ;; esac
|
|
104
|
+
_lock="$_root/listener.starting"
|
|
105
|
+
find "$_lock" -maxdepth 0 -mmin +1 -exec rmdir {} \\; 2>/dev/null || :
|
|
106
|
+
mkdir "$_lock" 2>/dev/null || return 0
|
|
107
|
+
(PATH="$PATH:$HEED_HOOK_NODE_DIR" HEED_HOME="\${_root%/.heed}" nohup npx -y heedvane listener start < /dev/null >> "$_root/listener.log" 2>&1 &) 2>/dev/null || :
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
# The hook payload arrives on stdin. command -p keeps cat working under a stripped PATH.
|
|
97
112
|
payload="$(command -p cat 2>/dev/null || cat)"
|
|
98
113
|
|
|
@@ -152,7 +167,9 @@ reply="$(printf '%s' "$payload" | curl -sS --fail -X POST "http://127.0.0.1:\${H
|
|
|
152
167
|
-H "X-Heed-Hook-Version: \${HEED_HOOK_VERSION}" \\
|
|
153
168
|
-H "X-Heed-Event-Id: \${heed_event_id:-}" \\
|
|
154
169
|
--data-binary @- 2>/dev/null)" || {
|
|
170
|
+
heed_curl_exit=$?
|
|
155
171
|
spool_hook_event "$payload"
|
|
172
|
+
[ "$heed_curl_exit" -ne 7 ] || heed_start_listener
|
|
156
173
|
heed_answer "$heed_fallback"
|
|
157
174
|
}
|
|
158
175
|
|
package/dist/doctor.js
CHANGED
|
@@ -12,7 +12,7 @@ import { desiredEntriesFor, hooksDeferredTo, isClientDetected } from "./plan.js"
|
|
|
12
12
|
import { readInstalledSkillDigest } from "./skill/bundle.js";
|
|
13
13
|
import { HeedStateCorruptError, readState } from "./state.js";
|
|
14
14
|
import { readApprovedSkills } from "./skills/approved.js";
|
|
15
|
-
import { readEndpoint } from "./listener/endpoint.js";
|
|
15
|
+
import { pingListener, readEndpoint } from "./listener/endpoint.js";
|
|
16
16
|
import { errorMessage } from "./guards.js";
|
|
17
17
|
const INIT_REMEDIATION = "run npx heedvane init";
|
|
18
18
|
const SETUP_REMEDIATION = "run npx heedvane setup";
|
|
@@ -294,23 +294,10 @@ async function checkListener(root) {
|
|
|
294
294
|
if (!endpoint.port) {
|
|
295
295
|
return { ...base, detail: "endpoint.env incomplete (no port) — re-run npx heedvane setup" };
|
|
296
296
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
});
|
|
304
|
-
if (res.ok || res.status === 400 || res.status === 401) {
|
|
305
|
-
// Reachable is the point; auth/payload validation is the server's business.
|
|
306
|
-
return { ...base, detail: `reachable on 127.0.0.1:${endpoint.port}` };
|
|
307
|
-
}
|
|
308
|
-
return { ...base, detail: `responded ${res.status} on 127.0.0.1:${endpoint.port} (reachable)` };
|
|
309
|
-
}
|
|
310
|
-
catch {
|
|
311
|
-
return {
|
|
312
|
-
...base,
|
|
313
|
-
detail: `not reachable on 127.0.0.1:${endpoint.port} — ${ENFORCEMENT_OFF}; events will spool (warn-only; never fails the report)`,
|
|
314
|
-
};
|
|
315
|
-
}
|
|
297
|
+
if (await pingListener(endpoint))
|
|
298
|
+
return { ...base, detail: `reachable on 127.0.0.1:${endpoint.port}` };
|
|
299
|
+
return {
|
|
300
|
+
...base,
|
|
301
|
+
detail: `not reachable on 127.0.0.1:${endpoint.port} — ${ENFORCEMENT_OFF}; events will spool (warn-only; never fails the report)`,
|
|
302
|
+
};
|
|
316
303
|
}
|
package/dist/heed-file.d.ts
CHANGED
|
@@ -57,6 +57,17 @@ export declare const DEFAULT_HEED_YAML_TEMPLATE = "# ~/.heed/heed.yaml \u2014 He
|
|
|
57
57
|
export declare function loadHeedFile(text: string): HeedFile;
|
|
58
58
|
/** `text` with `heedvane.url` set to `url` (`connect --server`); the file's comments stay. Validate with {@link loadHeedFile}. */
|
|
59
59
|
export declare function withServerUrl(text: string, url: string): string;
|
|
60
|
+
/** What the interactive installer asks: which agents, which integrations, and which Heedvane features. */
|
|
61
|
+
export interface InstallChoices {
|
|
62
|
+
/** Chosen agent ids, or "auto" for every detected agent. */
|
|
63
|
+
agents: "auto" | string[];
|
|
64
|
+
install: HeedInstallConfig;
|
|
65
|
+
steering: boolean;
|
|
66
|
+
memory: boolean;
|
|
67
|
+
skillChecks: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** `text` with the installer's choices written into the fields that already hold them; the file's comments stay. */
|
|
70
|
+
export declare function withInstallChoices(text: string, choices: InstallChoices): string;
|
|
60
71
|
/**
|
|
61
72
|
* Load the heed.yaml at `path`. A retired key an earlier version wrote (`init` put `steeringTimeoutMs`
|
|
62
73
|
* in every heed.yaml) is removed from the file in place, its value never read, and one line says so; if
|
package/dist/heed-file.js
CHANGED
|
@@ -235,6 +235,27 @@ export function withServerUrl(text, url) {
|
|
|
235
235
|
document.setIn(["heedvane", "url"], url);
|
|
236
236
|
return String(document);
|
|
237
237
|
}
|
|
238
|
+
/** `text` with the installer's choices written into the fields that already hold them; the file's comments stay. */
|
|
239
|
+
export function withInstallChoices(text, choices) {
|
|
240
|
+
const document = parseDocument(text);
|
|
241
|
+
const fields = [
|
|
242
|
+
[["agents"], choices.agents],
|
|
243
|
+
[["install", "hooks"], choices.install.hooks],
|
|
244
|
+
[["install", "skill"], choices.install.skill],
|
|
245
|
+
[["install", "mcp"], choices.install.mcp],
|
|
246
|
+
[["heedvane", "steering"], choices.steering],
|
|
247
|
+
[["heedvane", "memory"], choices.memory],
|
|
248
|
+
[["heedvane", "skills", "enforce"], choices.skillChecks],
|
|
249
|
+
];
|
|
250
|
+
for (const [path, value] of fields) {
|
|
251
|
+
const current = document.getIn(path, true);
|
|
252
|
+
if (isScalar(current) && typeof value !== "object")
|
|
253
|
+
current.value = value;
|
|
254
|
+
else
|
|
255
|
+
document.setIn(path, value);
|
|
256
|
+
}
|
|
257
|
+
return String(document);
|
|
258
|
+
}
|
|
238
259
|
/** Keys earlier versions wrote into heed.yaml that nothing reads any more (policies are assigned on the server). */
|
|
239
260
|
const RETIRED_KEYS = [["heedvane", "steeringTimeoutMs"], ["policy"]];
|
|
240
261
|
const toStderr = (line) => void process.stderr.write(`${line}\n`);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Readable, Writable } from "node:stream";
|
|
2
|
+
import type { HeedFile, InstallChoices } from "./heed-file.js";
|
|
3
|
+
import type { PlannedAction } from "./plan.js";
|
|
4
|
+
import { type AppliedResult } from "./render.js";
|
|
5
|
+
/** Where the questions are read and drawn: the process's terminal unless a test injects streams. */
|
|
6
|
+
export interface PromptStreams {
|
|
7
|
+
input?: Readable;
|
|
8
|
+
output?: Writable;
|
|
9
|
+
}
|
|
10
|
+
/** One coding agent the installer can offer. */
|
|
11
|
+
export interface AgentOption {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
detected: boolean;
|
|
15
|
+
/** What Heedvane installs into it, e.g. "hooks + MCP + skill". */
|
|
16
|
+
capability: string;
|
|
17
|
+
}
|
|
18
|
+
/** One agent's line in the summary or the result: what is, or was, installed into it. */
|
|
19
|
+
export interface AgentLine {
|
|
20
|
+
name: string;
|
|
21
|
+
parts: string[];
|
|
22
|
+
/** What failed, each with its reason; empty when everything worked. */
|
|
23
|
+
failed: string[];
|
|
24
|
+
}
|
|
25
|
+
/** The header: the name, what Heedvane does, and which server and organization this install connects to. */
|
|
26
|
+
export declare function brandIntro(server: string, org: string | undefined, streams: PromptStreams): void;
|
|
27
|
+
/** The person's choices, or null when they cancelled (Ctrl-C) at any question. */
|
|
28
|
+
export declare function askChoices(agents: AgentOption[], current: HeedFile, streams: PromptStreams): Promise<InstallChoices | null>;
|
|
29
|
+
/** Shows what the install will do and asks to go ahead; false when the person declines or cancels. */
|
|
30
|
+
export declare function confirmInstall(agents: AgentLine[], streams: PromptStreams): Promise<boolean>;
|
|
31
|
+
/** Runs the install behind a spinner; the spinner ends with the outcome either way. */
|
|
32
|
+
export declare function installWithSpinner<T>(agentCount: number, work: () => Promise<T>, streams: PromptStreams): Promise<T>;
|
|
33
|
+
/** One line per agent: a green tick and what it got, or what failed and why. */
|
|
34
|
+
export declare function showInstalled(agents: AgentLine[], streams: PromptStreams): void;
|
|
35
|
+
/** A line the install prints along the way (the sign-in link, for one), shown inside the installer's frame. */
|
|
36
|
+
export declare function promptLine(streams: PromptStreams): (line: string) => void;
|
|
37
|
+
/** Starts the listener in the background behind a spinner; true when it answers. */
|
|
38
|
+
export declare function listenerStep(start: () => Promise<boolean>, logPath: string, streams: PromptStreams): Promise<boolean>;
|
|
39
|
+
/**
|
|
40
|
+
* One line per agent from the plan, in plan order: what it gets ("hooks · MCP · skill"), "already set up"
|
|
41
|
+
* when nothing changes, or why it gets nothing. `applied` adds what failed when the install ran.
|
|
42
|
+
*/
|
|
43
|
+
export declare function agentLines(actions: PlannedAction[], names: ReadonlyMap<string, string>, applied?: AppliedResult[]): AgentLine[];
|
|
44
|
+
/** The closing line after a cancel or a "no": nothing was written. Returns the exit code, 1. */
|
|
45
|
+
export declare function installCancelled(streams: PromptStreams): number;
|
|
46
|
+
/** The closing lines after an install. */
|
|
47
|
+
export declare function installFinished(lines: string[], streams: PromptStreams): void;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive installer's questions (`npx heedvane`, or `connect` at a terminal): which coding agents,
|
|
3
|
+
* what to install into them, which Heedvane features to turn on, then one confirmation. It only asks:
|
|
4
|
+
* the answers become `InstallChoices`, which cli.ts writes into heed.yaml and applies through the same
|
|
5
|
+
* plan as every other install, so a second run starts from the last answers.
|
|
6
|
+
*/
|
|
7
|
+
import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, spinner } from "@clack/prompts";
|
|
8
|
+
import { styleText } from "node:util";
|
|
9
|
+
import { appliedFailed } from "./render.js";
|
|
10
|
+
const INTEGRATION_LABEL = { hooks: "hooks", mcp: "MCP server", skill: "skill", skip: "" };
|
|
11
|
+
/** The header: the name, what Heedvane does, and which server and organization this install connects to. */
|
|
12
|
+
export function brandIntro(server, org, streams) {
|
|
13
|
+
intro(brandBadge(" Heedvane ", streams.output ?? process.stdout), streams);
|
|
14
|
+
const target = org === undefined ? server : `${server} · organization ${org}`;
|
|
15
|
+
log.message([styleText("bold", "Guardrails, skill checks and team memory for your coding agents."), styleText("dim", `Connecting to ${target}`)], streams);
|
|
16
|
+
}
|
|
17
|
+
/** The person's choices, or null when they cancelled (Ctrl-C) at any question. */
|
|
18
|
+
export async function askChoices(agents, current, streams) {
|
|
19
|
+
const picked = await pickAgents(agents, current, streams);
|
|
20
|
+
if (picked === null)
|
|
21
|
+
return null;
|
|
22
|
+
const install = await pickIntegrations(current.install, streams);
|
|
23
|
+
if (install === null)
|
|
24
|
+
return null;
|
|
25
|
+
const features = await pickFeatures(current, streams);
|
|
26
|
+
if (features === null)
|
|
27
|
+
return null;
|
|
28
|
+
return {
|
|
29
|
+
agents: sameAsDetected(picked, agents) ? "auto" : picked,
|
|
30
|
+
install,
|
|
31
|
+
steering: features.includes("steering"),
|
|
32
|
+
skillChecks: features.includes("skillChecks"),
|
|
33
|
+
memory: features.includes("memory"),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Shows what the install will do and asks to go ahead; false when the person declines or cancels. */
|
|
37
|
+
export async function confirmInstall(agents, streams) {
|
|
38
|
+
const footer = styleText("dim", "Choices are saved in ~/.heed/heed.yaml. Each config file is backed up before it changes.");
|
|
39
|
+
note([...agentRows(agents), "", footer].join("\n"), "Ready to install", streams);
|
|
40
|
+
const answer = await confirm({ message: "Install now?", initialValue: true, ...streams });
|
|
41
|
+
return !isCancel(answer) && answer;
|
|
42
|
+
}
|
|
43
|
+
/** Runs the install behind a spinner; the spinner ends with the outcome either way. */
|
|
44
|
+
export async function installWithSpinner(agentCount, work, streams) {
|
|
45
|
+
const progress = spinner({ output: streams.output });
|
|
46
|
+
progress.start(`Installing into ${agentCount} ${agentCount === 1 ? "agent" : "agents"}`);
|
|
47
|
+
try {
|
|
48
|
+
const result = await work();
|
|
49
|
+
progress.stop("Installed");
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
progress.error("The install stopped");
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** One line per agent: a green tick and what it got, or what failed and why. */
|
|
58
|
+
export function showInstalled(agents, streams) {
|
|
59
|
+
const width = Math.max(...agents.map((agent) => agent.name.length));
|
|
60
|
+
for (const agent of agents) {
|
|
61
|
+
const name = styleText("bold", agent.name.padEnd(width));
|
|
62
|
+
if (agent.failed.length > 0)
|
|
63
|
+
log.error(`${name} ${agent.failed.join("; ")}`, streams);
|
|
64
|
+
else
|
|
65
|
+
log.success(`${name} ${agent.parts.join(styleText("dim", " · "))}`, streams);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** A line the install prints along the way (the sign-in link, for one), shown inside the installer's frame. */
|
|
69
|
+
export function promptLine(streams) {
|
|
70
|
+
return (line) => {
|
|
71
|
+
if (line.trim() !== "")
|
|
72
|
+
log.message(line.trim(), streams);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Starts the listener in the background behind a spinner; true when it answers. */
|
|
76
|
+
export async function listenerStep(start, logPath, streams) {
|
|
77
|
+
const progress = spinner({ output: streams.output });
|
|
78
|
+
progress.start("Starting Heedvane in the background");
|
|
79
|
+
const started = await start().catch(() => false);
|
|
80
|
+
if (started)
|
|
81
|
+
progress.stop("Heedvane is running in the background");
|
|
82
|
+
else
|
|
83
|
+
progress.error(`Heedvane did not start; see ${logPath}`);
|
|
84
|
+
return started;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* One line per agent from the plan, in plan order: what it gets ("hooks · MCP · skill"), "already set up"
|
|
88
|
+
* when nothing changes, or why it gets nothing. `applied` adds what failed when the install ran.
|
|
89
|
+
*/
|
|
90
|
+
export function agentLines(actions, names, applied = []) {
|
|
91
|
+
const ids = [...new Set(actions.map((action) => action.clientId))];
|
|
92
|
+
return ids.map((id) => {
|
|
93
|
+
const parts = agentParts(id, actions.filter((action) => action.clientId === id));
|
|
94
|
+
const failed = applied.filter((result) => result.clientId === id && appliedFailed(result))
|
|
95
|
+
.map((result) => `${result.kind}: ${result.reason ?? result.status}`);
|
|
96
|
+
return { name: names.get(id) ?? id, parts, failed };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** The closing line after a cancel or a "no": nothing was written. Returns the exit code, 1. */
|
|
100
|
+
export function installCancelled(streams) {
|
|
101
|
+
cancel("Nothing was installed. Run npx heedvane again any time.", streams);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
/** The closing lines after an install. */
|
|
105
|
+
export function installFinished(lines, streams) {
|
|
106
|
+
note(lines.join("\n"), "Next", streams);
|
|
107
|
+
outro("Heedvane is set up.", streams);
|
|
108
|
+
}
|
|
109
|
+
async function pickAgents(agents, current, streams) {
|
|
110
|
+
const ordered = [...agents.filter((agent) => agent.detected), ...agents.filter((agent) => !agent.detected)];
|
|
111
|
+
const chosenBefore = current.agents === "auto" ? agents.filter((agent) => agent.detected).map((agent) => agent.id) : current.agents;
|
|
112
|
+
const answer = await multiselect({
|
|
113
|
+
message: "Which coding agents should Heedvane connect to?",
|
|
114
|
+
options: ordered.map((agent) => ({ value: agent.id, label: agent.name, hint: agent.detected ? agent.capability : "not found on this machine" })),
|
|
115
|
+
initialValues: chosenBefore.filter((id) => agents.some((agent) => agent.id === id)),
|
|
116
|
+
required: true,
|
|
117
|
+
...streams,
|
|
118
|
+
});
|
|
119
|
+
return isCancel(answer) ? null : answer;
|
|
120
|
+
}
|
|
121
|
+
async function pickIntegrations(install, streams) {
|
|
122
|
+
const answer = await multiselect({
|
|
123
|
+
message: "What should Heedvane add to each agent?",
|
|
124
|
+
options: [
|
|
125
|
+
{ value: "hooks", label: "Hooks", hint: "guardrails, skill checks and memory in every session" },
|
|
126
|
+
{ value: "mcp", label: "MCP server", hint: "Heedvane's tools inside the agent" },
|
|
127
|
+
{ value: "skill", label: "Skill", hint: "teaches the agent how to use Heedvane" },
|
|
128
|
+
],
|
|
129
|
+
initialValues: ["hooks", "mcp", "skill"].filter((kind) => install[kind]),
|
|
130
|
+
required: true,
|
|
131
|
+
...streams,
|
|
132
|
+
});
|
|
133
|
+
if (isCancel(answer))
|
|
134
|
+
return null;
|
|
135
|
+
return { hooks: answer.includes("hooks"), mcp: answer.includes("mcp"), skill: answer.includes("skill") };
|
|
136
|
+
}
|
|
137
|
+
async function pickFeatures(current, streams) {
|
|
138
|
+
const on = { steering: current.heedvane.steering, skillChecks: current.heedvane.skills.enforce, memory: current.heedvane.memory };
|
|
139
|
+
const answer = await multiselect({
|
|
140
|
+
message: "What should Heedvane do in each session?",
|
|
141
|
+
options: [
|
|
142
|
+
{ value: "steering", label: "Guardrails", hint: "your organization's rules stop risky tool calls" },
|
|
143
|
+
{ value: "skillChecks", label: "Skill checks", hint: "only skills your organization approved run" },
|
|
144
|
+
{ value: "memory", label: "Private memory", hint: "your notes in ~/.heed/memory at session start" },
|
|
145
|
+
],
|
|
146
|
+
initialValues: Object.keys(on).filter((feature) => on[feature]),
|
|
147
|
+
required: false,
|
|
148
|
+
...streams,
|
|
149
|
+
});
|
|
150
|
+
return isCancel(answer) ? null : answer;
|
|
151
|
+
}
|
|
152
|
+
/** `text` on Heedvane's indigo (the dashboard's --brand) where the terminal shows 24-bit color, else on magenta. */
|
|
153
|
+
function brandBadge(text, output) {
|
|
154
|
+
const truecolor = "hasColors" in output && typeof output.hasColors === "function" && output.hasColors(2 ** 24) === true;
|
|
155
|
+
return truecolor ? `\x1b[48;2;86;71;210m\x1b[1;97m${text}\x1b[0m` : styleText(["bgMagenta", "white", "bold"], text);
|
|
156
|
+
}
|
|
157
|
+
/** What one agent gets: its integrations, "already set up" when nothing changes, or why it gets nothing. */
|
|
158
|
+
function agentParts(id, own) {
|
|
159
|
+
const installs = own.filter((action) => action.kind !== "skip");
|
|
160
|
+
if (installs.length === 0)
|
|
161
|
+
return own.map((action) => styleText("dim", action.reason?.replace(`${id}: `, "") ?? "nothing to add"));
|
|
162
|
+
if (installs.every((action) => action.operation === "unchanged"))
|
|
163
|
+
return [styleText("dim", "already set up")];
|
|
164
|
+
return installs.map((action) => INTEGRATION_LABEL[action.kind]);
|
|
165
|
+
}
|
|
166
|
+
function agentRows(agents) {
|
|
167
|
+
const width = Math.max(...agents.map((agent) => agent.name.length));
|
|
168
|
+
return agents.map((agent) => `${styleText("bold", agent.name.padEnd(width))} ${agent.parts.join(styleText("dim", " · "))}`);
|
|
169
|
+
}
|
|
170
|
+
/** True when exactly the detected agents were chosen: heed.yaml then keeps `auto`, so a newly installed agent joins later. */
|
|
171
|
+
function sameAsDetected(picked, agents) {
|
|
172
|
+
const detected = agents.filter((agent) => agent.detected).map((agent) => agent.id);
|
|
173
|
+
return picked.length === detected.length && detected.every((id) => picked.includes(id));
|
|
174
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Where a background listener writes what it prints. */
|
|
2
|
+
export declare function listenerLogPath(root: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Starts `listener start` as a process that outlives this one, unless a listener already answers.
|
|
5
|
+
* True once the listener answers its probe; false when it did not within 5 s (its log says why).
|
|
6
|
+
* It stops at logout or restart; the hooks then start it again on the fly (src/dispatchers/posix.ts).
|
|
7
|
+
*/
|
|
8
|
+
export declare function startListenerInBackground(home: string, cliPath: string, env: NodeJS.ProcessEnv): Promise<boolean>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, openSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { ensureEndpoint, pingListener } from "./endpoint.js";
|
|
6
|
+
/** How long a freshly started listener gets to answer before the start counts as failed. */
|
|
7
|
+
const START_WAIT_MS = 5_000;
|
|
8
|
+
const START_POLL_MS = 250;
|
|
9
|
+
/** Where a background listener writes what it prints. */
|
|
10
|
+
export function listenerLogPath(root) {
|
|
11
|
+
return join(root, "listener.log");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Starts `listener start` as a process that outlives this one, unless a listener already answers.
|
|
15
|
+
* True once the listener answers its probe; false when it did not within 5 s (its log says why).
|
|
16
|
+
* It stops at logout or restart; the hooks then start it again on the fly (src/dispatchers/posix.ts).
|
|
17
|
+
*/
|
|
18
|
+
export async function startListenerInBackground(home, cliPath, env) {
|
|
19
|
+
const root = join(home, ".heed");
|
|
20
|
+
const endpoint = await ensureEndpoint(root);
|
|
21
|
+
if (await pingListener(endpoint))
|
|
22
|
+
return true;
|
|
23
|
+
const log = openSync(listenerLogPath(root), "a", 0o600);
|
|
24
|
+
try {
|
|
25
|
+
spawn(process.execPath, [cliPath, "listener", "start"], { detached: true, stdio: ["ignore", log, log], env: { ...env, HEED_HOME: home } }).unref();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
closeSync(log);
|
|
29
|
+
}
|
|
30
|
+
for (let waited = 0; waited < START_WAIT_MS; waited += START_POLL_MS) {
|
|
31
|
+
await delay(START_POLL_MS);
|
|
32
|
+
if (await pingListener(endpoint))
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
@@ -19,3 +19,8 @@ export declare function writeEndpoint(root: string, endpoint: Endpoint): Promise
|
|
|
19
19
|
* (dispatchers already point at it); otherwise a free loopback port and a fresh 256-bit token are written.
|
|
20
20
|
*/
|
|
21
21
|
export declare function ensureEndpoint(root: string): Promise<Endpoint>;
|
|
22
|
+
/**
|
|
23
|
+
* Whether a listener answers at `endpoint` within 750 ms. The probe is a GET, which the listener refuses
|
|
24
|
+
* with 405 before it reads a token or records anything, so probing never adds an event.
|
|
25
|
+
*/
|
|
26
|
+
export declare function pingListener(endpoint: Endpoint): Promise<boolean>;
|
|
@@ -110,3 +110,16 @@ export async function ensureEndpoint(root) {
|
|
|
110
110
|
await writeEndpoint(root, endpoint);
|
|
111
111
|
return endpoint;
|
|
112
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether a listener answers at `endpoint` within 750 ms. The probe is a GET, which the listener refuses
|
|
115
|
+
* with 405 before it reads a token or records anything, so probing never adds an event.
|
|
116
|
+
*/
|
|
117
|
+
export async function pingListener(endpoint) {
|
|
118
|
+
try {
|
|
119
|
+
await fetch(`http://127.0.0.1:${endpoint.port}/hook/claude`, { signal: AbortSignal.timeout(750) });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -39,6 +39,8 @@ export interface StartedListener {
|
|
|
39
39
|
* length-leaking early return would defeat the point).
|
|
40
40
|
*/
|
|
41
41
|
export declare function safeTokenEqual(a: string, b: string): boolean;
|
|
42
|
+
/** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
|
|
43
|
+
export declare const SHUTDOWN_ROUTE = "/shutdown";
|
|
42
44
|
/**
|
|
43
45
|
* Start the loopback hook listener: binds 127.0.0.1 only (never 0.0.0.0),
|
|
44
46
|
* serves POST /hook/<agentId>, auths via x-heed-agent-hook-token against
|
package/dist/listener/server.js
CHANGED
|
@@ -71,6 +71,20 @@ function sendJson(res, status, body) {
|
|
|
71
71
|
res.end(JSON.stringify(body));
|
|
72
72
|
}
|
|
73
73
|
const ROUTE_PATTERN = /^\/hook\/([A-Za-z0-9_-]+)$/;
|
|
74
|
+
/** `POST /shutdown` with the token: uninstall stops the listener it no longer has hooks for. */
|
|
75
|
+
export const SHUTDOWN_ROUTE = "/shutdown";
|
|
76
|
+
/** True after answering a token-carrying `POST /shutdown`; the listener closes once the answer is sent. */
|
|
77
|
+
function handledShutdown(req, res, deps) {
|
|
78
|
+
if (req.method !== "POST" || new URL(req.url ?? "/", "http://127.0.0.1").pathname !== SHUTDOWN_ROUTE)
|
|
79
|
+
return false;
|
|
80
|
+
if (!hasValidToken(req, deps.token)) {
|
|
81
|
+
sendJson(res, 401, { error: "invalid or missing x-heed-agent-hook-token" });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
res.on("finish", deps.stop);
|
|
85
|
+
sendJson(res, 200, { stopping: true });
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
74
88
|
/** The route's agent id, or null after answering 404/405. */
|
|
75
89
|
function routeAgent(req, res, agents) {
|
|
76
90
|
const route = ROUTE_PATTERN.exec(new URL(req.url ?? "/", "http://127.0.0.1").pathname);
|
|
@@ -150,6 +164,8 @@ function internalErrorResponder(res) {
|
|
|
150
164
|
};
|
|
151
165
|
}
|
|
152
166
|
async function handleRequest(req, res, deps) {
|
|
167
|
+
if (handledShutdown(req, res, deps))
|
|
168
|
+
return;
|
|
153
169
|
const agent = routeAgent(req, res, deps.agents);
|
|
154
170
|
if (agent === null)
|
|
155
171
|
return;
|
|
@@ -194,16 +210,20 @@ export async function startListener(options) {
|
|
|
194
210
|
}
|
|
195
211
|
const log = options.log ?? ((message) => console.log(message));
|
|
196
212
|
const agents = options.knownAgents ?? KNOWN_AGENTS;
|
|
213
|
+
const server = createServer((req, res) => {
|
|
214
|
+
handleRequest(req, res, deps).catch(internalErrorResponder(res));
|
|
215
|
+
});
|
|
197
216
|
const deps = {
|
|
198
217
|
agents,
|
|
199
218
|
token: endpoint.token,
|
|
200
219
|
onEvent: options.onEvent,
|
|
201
220
|
log,
|
|
202
221
|
warned: new Set(),
|
|
222
|
+
stop: () => {
|
|
223
|
+
server.close();
|
|
224
|
+
server.closeAllConnections();
|
|
225
|
+
},
|
|
203
226
|
};
|
|
204
|
-
const server = createServer((req, res) => {
|
|
205
|
-
handleRequest(req, res, deps).catch(internalErrorResponder(res));
|
|
206
|
-
});
|
|
207
227
|
// Node-level slowloris hardening (covers stalls before the request handler
|
|
208
228
|
// fires); requestTimeout must be larger than headersTimeout.
|
|
209
229
|
server.requestTimeout = REQUEST_TIMEOUT_MS;
|
package/dist/render.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface DetectionRow {
|
|
|
6
6
|
/** Marker path relative to home, e.g. ~/.claude */
|
|
7
7
|
markerRel: string;
|
|
8
8
|
}
|
|
9
|
+
/** What Heedvane installs into an agent, e.g. "hooks + MCP + skill". */
|
|
10
|
+
export declare function capabilitySummary(client: ResolvedClient): string;
|
|
9
11
|
export declare function renderDetection(rows: DetectionRow[]): string[];
|
|
10
12
|
/**
|
|
11
13
|
* The summary line from the spec:
|
package/dist/render.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { basename } from "node:path";
|
|
6
6
|
import { wouldInstallCount } from "./plan.js";
|
|
7
|
-
|
|
7
|
+
/** What Heedvane installs into an agent, e.g. "hooks + MCP + skill". */
|
|
8
|
+
export function capabilitySummary(client) {
|
|
8
9
|
const parts = [];
|
|
9
10
|
if (client.capabilities.includes("hooks"))
|
|
10
11
|
parts.push("hooks");
|
package/dist/uninstall.js
CHANGED
|
@@ -16,6 +16,9 @@ import { hooksTarget, isClientDetected } from "./plan.js";
|
|
|
16
16
|
import { skillInstallDir } from "./skill/bundle.js";
|
|
17
17
|
import { statePath } from "./state.js";
|
|
18
18
|
import { unlinkOptional } from "./best-effort.js";
|
|
19
|
+
import { pingListener, readEndpoint } from "./listener/endpoint.js";
|
|
20
|
+
import { SHUTDOWN_ROUTE } from "./listener/server.js";
|
|
21
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
19
22
|
/** Hook events carrying one of our groups, with the user's own groups that must survive. */
|
|
20
23
|
function ourHookEvents(doc) {
|
|
21
24
|
const hooks = doc["hooks"];
|
|
@@ -150,10 +153,34 @@ async function removeSharedFiles(home, scoped) {
|
|
|
150
153
|
await rm(skillDir, { recursive: true, force: true });
|
|
151
154
|
await unlinkOptional(statePath(join(home, ".heed")));
|
|
152
155
|
return [
|
|
156
|
+
await stopListener(join(home, ".heed")),
|
|
153
157
|
{ clientId: "*", name: "skill bundle", path: skillDir, status: "removed" },
|
|
154
158
|
{ clientId: "*", name: "state.json", path: statePath(join(home, ".heed")), status: "removed" },
|
|
155
159
|
];
|
|
156
160
|
}
|
|
161
|
+
/** How long uninstall waits for the listener to stop answering after asking it to stop. */
|
|
162
|
+
const STOP_WAIT_MS = 3_000;
|
|
163
|
+
const STOP_POLL_MS = 100;
|
|
164
|
+
/** The background listener answers hooks that are gone now: ask it to stop (POST /shutdown) and wait until it has. */
|
|
165
|
+
async function stopListener(root) {
|
|
166
|
+
const endpoint = await readEndpoint(root).catch(() => null);
|
|
167
|
+
const item = { clientId: "*", name: "listener", path: `127.0.0.1:${endpoint?.port ?? 0}` };
|
|
168
|
+
if (endpoint === null || !(await pingListener(endpoint)))
|
|
169
|
+
return { ...item, status: "absent" };
|
|
170
|
+
const asked = await fetch(`http://127.0.0.1:${endpoint.port}${SHUTDOWN_ROUTE}`, {
|
|
171
|
+
method: "POST",
|
|
172
|
+
headers: { "x-heed-agent-hook-token": endpoint.token },
|
|
173
|
+
signal: AbortSignal.timeout(STOP_WAIT_MS),
|
|
174
|
+
}).catch(() => null);
|
|
175
|
+
for (let waited = 0; waited < STOP_WAIT_MS; waited += STOP_POLL_MS) {
|
|
176
|
+
if (!(await pingListener(endpoint)))
|
|
177
|
+
return { ...item, status: "removed" };
|
|
178
|
+
await delay(STOP_POLL_MS);
|
|
179
|
+
}
|
|
180
|
+
// A listener from 0.2.0 or earlier has no stop route (404); it ends at logout or restart.
|
|
181
|
+
const detail = asked?.status === 404 ? "an older listener is still running; it stops at logout or restart" : "the listener did not stop; it stops at logout or restart";
|
|
182
|
+
return { ...item, status: "failed", detail };
|
|
183
|
+
}
|
|
157
184
|
function existsMarker(client) {
|
|
158
185
|
return isClientDetected(client);
|
|
159
186
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heedvane",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Heedvane for coding agents: one command installs steering, guardrails, team memory and skill checks into Claude Code, Codex, Cursor, Gemini CLI and more.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"prepack": "tsc -p tsconfig.json"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
+
"@clack/prompts": "^1.8.1",
|
|
26
27
|
"jsonc-parser": "^3.3.1",
|
|
27
28
|
"smol-toml": "^1.9.1",
|
|
28
29
|
"write-file-atomic": "^5.0.1",
|