heedvane 0.1.0 → 0.2.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 CHANGED
@@ -1,28 +1,63 @@
1
1
  # heedvane
2
2
 
3
- Connect the coding agents on your machine to your Heedvane organization: steering rules, approved-skill
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 connect --server https://app.heedvane.com --org <organization id>
7
+ npx heedvane
8
8
  ```
9
9
 
10
- Copy the exact command from the Agents page of your Heedvane dashboard. It needs Node.js 22 or newer.
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. Edit it any time, then run `npx heedvane setup`.
15
- - Adds Heedvane's hooks, MCP server entry and skill to each agent it detects. A config file it edits is
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
- - Signs you in through the browser. Headless machines use `npx heedvane auth org < key-file`.
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
+ - Pi and Flue agents cannot be connected yet.
53
+
21
54
  ## Everyday commands
22
55
 
23
56
  | Command | What it does |
24
57
  | --- | --- |
25
- | `npx heedvane listener start` | Runs the local listener that answers the hooks. Keep it running. |
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 <command> [flags] (installed: heedvane <command>)
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 one-step setup: write ~/.heed/heed.yaml, configure detected CLI & GUI agents, and sign in
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
@@ -100,6 +104,8 @@ function parseInvocation(args) {
100
104
  async function main(argv, opts = {}) {
101
105
  const ctx = commandContext(argv, opts);
102
106
  const command = ctx.positional[0];
107
+ if (command === undefined && takesInstaller(ctx))
108
+ return cmdInstall(ctx);
103
109
  if (!command || command === "help" || command === "--help") {
104
110
  ctx.out(USAGE);
105
111
  return command ? 0 : 2;
@@ -119,12 +125,14 @@ function commandContext(argv, opts) {
119
125
  openBrowser: opts.openBrowser ?? openSystemBrowser,
120
126
  interactive: opts.interactive ?? (process.stdin.isTTY === true && process.stdout.isTTY === true),
121
127
  cwd: opts.cwd ?? process.cwd(),
128
+ prompts: opts.prompts ?? {},
129
+ startListener: opts.startListener ?? ((home) => startListenerInBackground(home, fileURLToPath(import.meta.url), env)),
122
130
  };
123
131
  }
124
132
  async function routeCommand(command, ctx) {
125
133
  switch (command) {
126
134
  case "connect":
127
- return cmdInit({ ...ctx, flags: { ...ctx.flags, yes: true } });
135
+ return takesInstaller(ctx) ? cmdInstall(ctx) : cmdInit({ ...ctx, flags: { ...ctx.flags, yes: true } });
128
136
  case "init":
129
137
  return cmdInit(ctx);
130
138
  case "setup":
@@ -150,6 +158,8 @@ async function routeServiceCommand(command, ctx) {
150
158
  case "listener": {
151
159
  return cmdListener({ home: ctx.home, out: ctx.out, rest: ctx.args.slice(ctx.args.indexOf("listener") + 1) });
152
160
  }
161
+ case "run":
162
+ return cmdRun({ home: ctx.home, env: ctx.env, command: ctx.args.slice(ctx.args.indexOf("--") + 1), separated: ctx.args.includes("--") });
153
163
  case "login":
154
164
  return cmdLogin(backendCommandContext(ctx));
155
165
  case "auth":
@@ -235,15 +245,39 @@ async function cmdInit(ctx) {
235
245
  }
236
246
  /** heed.yaml (or the template) with `--server` as `heedvane.url`; null when it already names that server. Throws on an unusable URL. */
237
247
  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);
248
+ const text = withServerUrl(heedText(home, loaded.existed), server);
240
249
  const heedFile = loadHeedFile(text);
241
250
  if (loaded.existed && heedFile.heedvane.url === loaded.heedFile.heedvane.url)
242
251
  return null;
243
252
  return { heedFile, existed: loaded.existed, text };
244
253
  }
245
254
  async function applyAndReport(ctx, spec) {
246
- const { home, env, out, flags } = ctx;
255
+ const { out, flags } = ctx;
256
+ const { actions, applied } = await installPlan(ctx, spec);
257
+ for (const line of renderApplied(applied))
258
+ out(line);
259
+ if (!flags.dryRun)
260
+ await signInIfNeeded(ctx, spec.heedFile.heedvane.url);
261
+ out("");
262
+ out(" Next: open a new agent session and run /heedvane-status.");
263
+ out(" Re-run `npx heedvane doctor` after any heed.yaml edit.");
264
+ const failed = applied.some(appliedFailed);
265
+ if (flags.json)
266
+ outJson(out, { status: failed ? "failed" : "applied", plannedActions: actions, appliedActions: applied });
267
+ return failed ? 1 : 0;
268
+ }
269
+ /** Writes heed.yaml when it is missing, or when `--server` changed its URL. */
270
+ function writeControlFile({ home, out }, { heedFile, existed, text }) {
271
+ if (existed && text === undefined)
272
+ return;
273
+ mkdirSync(join(home, ".heed"), { recursive: true, mode: 0o700 });
274
+ writeFileSync(join(home, ".heed", "heed.yaml"), text ?? DEFAULT_HEED_YAML_TEMPLATE, { mode: 0o600 });
275
+ out(existed ? ` ✓ Set heedvane.url in ~/.heed/heed.yaml to ${heedFile.heedvane.url}` : ` ✓ Wrote ~/.heed/heed.yaml (your control file — edit any time)`);
276
+ }
277
+ /** 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. */
278
+ /** Writes heed.yaml, applies the plan, issues the listener endpoint and records the machine state. Prints only what applying prints. */
279
+ async function installPlan(ctx, spec) {
280
+ const { home, env, out } = ctx;
247
281
  const { heedFile, agents } = spec;
248
282
  // The control file comes first (mock order): "✓ Wrote ~/.heed/heed.yaml".
249
283
  writeControlFile(ctx, spec);
@@ -251,12 +285,11 @@ async function applyAndReport(ctx, spec) {
251
285
  const dispatcher = {
252
286
  denySkillsWhenUnchecked: heedFile.heedvane.skills.enforce && heedFile.heedvane.skills.whenUnknown === "deny",
253
287
  gating: heedFile.gating.mode,
288
+ nodeDir: dirname(process.execPath),
254
289
  };
255
290
  const applied = await applyPlan(actions, { home, env, out, dispatcher });
256
291
  // The listener port and token every dispatcher reads; kept once issued.
257
292
  await ensureEndpoint(join(home, ".heed"));
258
- for (const line of renderApplied(applied))
259
- out(line);
260
293
  // Machine state: CLI-owned; preserve an existing endpoint.
261
294
  const root = join(home, ".heed");
262
295
  const existing = await readState(root);
@@ -266,25 +299,8 @@ async function applyAndReport(ctx, spec) {
266
299
  if (skillInstalled)
267
300
  state.skill = { version: "1", digest: skillDigest(canonicalSkillFiles()) };
268
301
  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;
302
+ return { actions, applied };
278
303
  }
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
304
  async function signInIfNeeded(ctx, url) {
289
305
  const root = join(ctx.home, ".heed");
290
306
  if ((await backendAt(root, url)) !== null && (await onRequestedOrg(root, ctx.org)))
@@ -335,6 +351,67 @@ async function cmdSetup(ctx) {
335
351
  }
336
352
  return applyAndReport(ctx, { heedFile, agents: ctx.agents, existed: true });
337
353
  }
354
+ /** A person at a terminal who did not ask for the one-shot path (`--yes`, `--dry-run`, `--json`) or for help. */
355
+ function takesInstaller(ctx) {
356
+ return ctx.interactive && !ctx.flags.yes && !ctx.flags.dryRun && !ctx.flags.json && !ctx.args.includes("--help");
357
+ }
358
+ /** heed.yaml's text, or the template when there is none yet. */
359
+ function heedText(home, existed) {
360
+ return existed ? readFileSync(join(home, ".heed", "heed.yaml"), "utf8") : DEFAULT_HEED_YAML_TEMPLATE;
361
+ }
362
+ /** The installer: pick agents, integrations and features, confirm, install, start the listener, sign in. */
363
+ async function cmdInstall(ctx) {
364
+ const base = installerBase(ctx);
365
+ if (typeof base === "string") {
366
+ ctx.out(` ✗ ${base}`);
367
+ return 1;
368
+ }
369
+ const { prompts } = ctx;
370
+ brandIntro(base.heedFile.heedvane.url, ctx.org, prompts);
371
+ const choices = await askChoices(agentOptions(ctx), base.heedFile, prompts);
372
+ if (choices === null)
373
+ return installCancelled(prompts);
374
+ const text = withInstallChoices(base.text, choices);
375
+ const spec = { heedFile: loadHeedFile(text), agents: choices.agents === "auto" ? [] : choices.agents, existed: base.existed, text };
376
+ const names = new Map(detectionRows(ctx.home, ctx.env).map((row) => [row.client.id, row.client.displayName]));
377
+ const planned = agentLines(planFor({ home: ctx.home, env: ctx.env, ...spec }), names);
378
+ if (!(await confirmInstall(planned, prompts)))
379
+ return installCancelled(prompts);
380
+ const notes = [];
381
+ const { actions, applied } = await installWithSpinner(planned.length, () => installPlan({ ...ctx, out: (line) => notes.push(line) }, spec), prompts);
382
+ notes.forEach(promptLine(prompts));
383
+ showInstalled(agentLines(actions, names, applied), prompts);
384
+ const listening = await listenerStep(() => ctx.startListener(ctx.home), listenerLogPath(join(ctx.home, ".heed")), prompts);
385
+ await signInIfNeeded({ ...ctx, out: promptLine(prompts) }, spec.heedFile.heedvane.url);
386
+ installFinished(nextSteps(listening, spec.heedFile.heedvane.url), prompts);
387
+ return applied.some(appliedFailed) ? 1 : 0;
388
+ }
389
+ /** heed.yaml's current text (or the template) with `--server` applied, parsed; a message when either cannot be used. */
390
+ function installerBase({ home, server }) {
391
+ const loaded = loadOrTemplate(home);
392
+ if (loaded.error)
393
+ return `~/.heed/heed.yaml does not parse: ${loaded.error}`;
394
+ try {
395
+ const current = heedText(home, loaded.existed);
396
+ const text = server === undefined ? current : withServerUrl(current, server);
397
+ return { heedFile: loadHeedFile(text), existed: loaded.existed, text };
398
+ }
399
+ catch (err) {
400
+ return `--server ${server} cannot be used: ${errorMessage(err)}`;
401
+ }
402
+ }
403
+ function agentOptions({ home, env }) {
404
+ return detectionRows(home, env).map(({ client, detected }) => ({ id: client.id, name: client.displayName, detected, capability: capabilitySummary(client) }));
405
+ }
406
+ function nextSteps(listening, server) {
407
+ return [
408
+ "Open a new agent session: Heedvane joins it at session start.",
409
+ `Watch your sessions: ${server}`,
410
+ ...(listening ? [] : ["Heedvane starts by itself with your next agent session. To see why it did not start now: npx heedvane listener start"]),
411
+ "Check the install: npx heedvane doctor",
412
+ "Change your choices: npx heedvane",
413
+ ];
414
+ }
338
415
  /** Hermes' hooks capability rides its native plugin; installHermesPlugin owns creation and ownership checks. */
339
416
  async function applyHermesPlugin(client, ctx) {
340
417
  const path = join(ctx.home, ".hermes", "plugins", "heedvane-hooks", "plugin.yaml");
@@ -499,6 +576,51 @@ async function cmdListener(ctx) {
499
576
  });
500
577
  return 0;
501
578
  }
579
+ /**
580
+ * `run -- <agent command>`: a listener for exactly one agent run, for CI, containers and software
581
+ * factories where nothing stays running. The agent keeps stdin, stdout and stderr; the listener writes to
582
+ * ~/.heed/listener.log and Heedvane's own lines go to stderr, so an agent's JSON output stays parseable.
583
+ * Returns the agent's exit code (128 + signal number when a signal ended it).
584
+ */
585
+ async function cmdRun(ctx) {
586
+ const say = (line) => void process.stderr.write(`heedvane: ${line}\n`);
587
+ if (!ctx.separated || ctx.command.length === 0) {
588
+ say('usage: npx heedvane run -- <agent command>, e.g. npx heedvane run -- claude -p "fix the failing tests"');
589
+ return 2;
590
+ }
591
+ const root = join(ctx.home, ".heed");
592
+ mkdirSync(root, { recursive: true, mode: 0o700 }); // a fresh container has no ~/.heed yet
593
+ const log = createWriteStream(listenerLogPath(root), { flags: "a", mode: 0o600 });
594
+ const started = await serveHooks(ctx.home, (line) => void log.write(`${line}\n`));
595
+ if ((await backendAt(root, heedvaneConfigAt(root).url)) === null)
596
+ say("not signed in, so guardrails are off: printf '%s' \"$HEEDVANE_ORG_KEY\" | npx heedvane auth org");
597
+ say(`listening on 127.0.0.1:${started.port}; log: ${listenerLogPath(root)}`);
598
+ try {
599
+ // The agent's hooks find this listener's endpoint through HEED_AGENT_HOOK_ROOT, also when HEED_HOME is not $HOME.
600
+ return await runAgent(ctx.command, { ...ctx.env, HEED_HOME: ctx.home, HEED_AGENT_HOOK_ROOT: root }, say);
601
+ }
602
+ finally {
603
+ await new Promise((resolve) => started.server.close(() => resolve()));
604
+ await new Promise((resolve) => log.end(resolve));
605
+ }
606
+ }
607
+ /** Runs the agent with this process's terminal, passes Ctrl-C and `docker stop` on to it, and resolves with its exit code. */
608
+ function runAgent([program, ...args], env, say) {
609
+ return new Promise((resolve) => {
610
+ const child = spawn(program, args, { stdio: "inherit", env });
611
+ const forward = (signal) => void child.kill(signal);
612
+ process.on("SIGINT", forward).on("SIGTERM", forward);
613
+ const settle = (code) => {
614
+ process.off("SIGINT", forward).off("SIGTERM", forward);
615
+ resolve(code);
616
+ };
617
+ child.on("error", (err) => {
618
+ say(`could not start ${program}: ${errorMessage(err)}`);
619
+ settle(127);
620
+ });
621
+ child.on("exit", (code, signal) => settle(code ?? 128 + (signal === null ? 0 : osConstants.signals[signal])));
622
+ });
623
+ }
502
624
  function backendCommandContext(ctx) {
503
625
  return { home: ctx.home, out: ctx.out, words: ctx.positional.slice(1), project: ctx.args.includes("--project"), readStdin: ctx.readStdin, openBrowser: ctx.openBrowser };
504
626
  }
@@ -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
- try {
298
- const res = await fetch(`http://127.0.0.1:${endpoint.port}/hook/claude`, {
299
- method: "POST",
300
- headers: { "content-type": "application/json", ...(endpoint.token ? { "x-heed-agent-hook-token": endpoint.token } : {}) },
301
- body: JSON.stringify({ v: 1, event: "doctor_probe", agent: "claude", receivedAt: new Date().toISOString() }),
302
- signal: AbortSignal.timeout(750),
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
  }
@@ -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
+ }
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
- function capabilitySummary(client) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "heedvane",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
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",