ashlr-plugin 1.36.4 → 1.37.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.
Files changed (81) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +49 -8
  5. package/commands/ashlr-doctor.md +29 -0
  6. package/commands/ashlr-status.md +1 -1
  7. package/docs/PHANTOM-USAGE.md +58 -0
  8. package/docs/architecture.md +5 -5
  9. package/docs/ashlr-handshake.md +36 -0
  10. package/docs/assets/og.svg +1 -1
  11. package/docs/benchmarks-v2.json +226 -177
  12. package/docs/benchmarks.md +20 -10
  13. package/docs/competitive-wozcode.md +2 -2
  14. package/docs/embeddings-gateway.md +39 -0
  15. package/docs/index.html +3 -3
  16. package/docs/legal.md +1 -1
  17. package/docs/llm-providers.md +15 -1
  18. package/docs/multi-host-mcp.md +25 -3
  19. package/docs/phantom-preflight.md +23 -0
  20. package/docs/team-genome.md +2 -1
  21. package/hooks/_hook-timing-ledger.ts +2 -9
  22. package/hooks/hooks.json +3 -106
  23. package/hooks/posttooluse-stale-result.ts +3 -0
  24. package/hooks/pretooluse-common.ts +1 -1
  25. package/hooks/pretooluse-notebookedit.ts +1 -1
  26. package/hooks/pretooluse-write.ts +1 -1
  27. package/hooks/session-start.ts +48 -26
  28. package/package.json +9 -4
  29. package/ports/cursor/mcp.json +1 -1
  30. package/scripts/_read-thresholds.ts +119 -0
  31. package/scripts/ashlr-mcp.mjs +15 -0
  32. package/scripts/ashlr-mcp.ts +3 -0
  33. package/scripts/ashlr-plugin.mjs +59 -0
  34. package/scripts/bench-read-real.ts +90 -0
  35. package/scripts/bootstrap.mjs +14 -4
  36. package/scripts/cli.ts +35 -10
  37. package/scripts/context-status.ts +7 -2
  38. package/scripts/deploy-smoke.sh +8 -4
  39. package/scripts/doctor.ts +171 -42
  40. package/scripts/genome-auto-consolidate.ts +4 -2
  41. package/scripts/genome-init.ts +3 -3
  42. package/scripts/genome-key.ts +64 -7
  43. package/scripts/handshake.mjs +51 -0
  44. package/scripts/hook-bootstrap.mjs +170 -17
  45. package/scripts/mcp-entrypoint.ts +3 -0
  46. package/scripts/ollama-setup.ts +5 -4
  47. package/scripts/run-benchmark.ts +28 -18
  48. package/scripts/upgrade-flow.ts +9 -14
  49. package/servers/_base-urls.ts +48 -0
  50. package/servers/_companions.ts +258 -0
  51. package/servers/_cwd-clamp.ts +38 -1
  52. package/servers/_edit-match.ts +12 -2
  53. package/servers/_embedding-model.ts +77 -0
  54. package/servers/_gateway-target.ts +179 -0
  55. package/servers/_llm-providers/anthropic.ts +10 -4
  56. package/servers/_llm-providers/cloud.ts +9 -7
  57. package/servers/_llm-providers/index.ts +8 -6
  58. package/servers/_llm-providers/local.ts +9 -5
  59. package/servers/_mcp-host.ts +6 -0
  60. package/servers/_miss-tracker.ts +121 -0
  61. package/servers/_phantom-exec.ts +135 -0
  62. package/servers/_phantom-preflight.ts +130 -0
  63. package/servers/_pid-alive.ts +40 -0
  64. package/servers/_pro-token-keychain.ts +149 -0
  65. package/servers/_redact-keys.ts +75 -0
  66. package/servers/_rg-text.ts +111 -0
  67. package/servers/_router.ts +20 -2
  68. package/servers/_run-with-timeout.ts +56 -12
  69. package/servers/_test-watch.ts +1 -10
  70. package/servers/_token-measure.ts +3 -1
  71. package/servers/_tool-base.ts +6 -0
  72. package/servers/bash-server.ts +1 -10
  73. package/servers/edit-server.ts +24 -1
  74. package/servers/efficiency-server.ts +12 -2
  75. package/servers/genome-server-handlers.ts +2 -2
  76. package/servers/genome-server.ts +17 -17
  77. package/servers/grep-server.ts +6 -2
  78. package/servers/orient-server-handlers.ts +3 -2
  79. package/servers/orient-server.ts +16 -9
  80. package/servers/read-server.ts +1 -1
  81. package/servers/write-server-handlers.ts +10 -2
package/scripts/doctor.ts CHANGED
@@ -10,11 +10,15 @@
10
10
  * signatures stable.
11
11
  */
12
12
 
13
+ import { describePhantomExec, detectPhantomExec } from "../servers/_phantom-exec.ts";
14
+ import { type CompanionResult, type CompanionRunner, detectCompanions } from "../servers/_companions.ts";
15
+ import { preflightMode, runPhantomPreflight, type PreflightRunner } from "../servers/_phantom-preflight.ts";
13
16
  import { spawn } from "bun";
17
+ import { runWithTimeout } from "../servers/_run-with-timeout.ts";
14
18
  import { existsSync, statSync } from "fs";
15
19
  import { readFile } from "fs/promises";
16
20
  import { devNull, homedir } from "os";
17
- import { dirname, join, resolve } from "path";
21
+ import { basename, dirname, join, resolve } from "path";
18
22
  import { fileURLToPath } from "url";
19
23
  import { c, sym, box, isColorEnabled } from "./ui.ts";
20
24
  import { bunBinaryOnDisk } from "./bun-resolve.mjs";
@@ -23,7 +27,8 @@ import { selectProvider } from "../servers/_llm-providers/index.ts";
23
27
  import { proTokenCachePath, isProAssumeEnabled } from "../servers/_pro.ts";
24
28
  import { getDoctorHookHealth } from "./hook-timings-report.ts";
25
29
 
26
- export type Status = "ok" | "warn" | "fail";
30
+ /** `info` is shown with its fix hint but never counted as a warning or failure. */
31
+ export type Status = "ok" | "warn" | "fail" | "info";
27
32
  export interface Line {
28
33
  status: Status;
29
34
  label: string;
@@ -37,11 +42,44 @@ export interface Section {
37
42
  export interface Report {
38
43
  header: string;
39
44
  sections: Section[];
45
+ companions: CompanionResult[];
40
46
  warnings: number;
41
47
  failures: number;
42
48
  }
43
49
 
44
- const GLYPH: Record<Status, string> = { ok: sym.check, warn: sym.warn, fail: sym.cross };
50
+ /**
51
+ * The shared `ashlr.doctor` v1 wrapper (ashlr-stack docs/ashlr-handshake.md,
52
+ * "Doctor JSON") for `--json`. Additive: header, sections, companions,
53
+ * warnings and failures stay, and the exit code does not change. Mapping:
54
+ * failures > 0 is fail, else warnings > 0 is warn. `checks` flatten the
55
+ * section lines (`id` is the section and label in kebab case).
56
+ */
57
+ export function withDoctorEnvelope(report: Report) {
58
+ const kebab = (s: string) =>
59
+ s
60
+ .toLowerCase()
61
+ .replace(/[^a-z0-9]+/g, "-")
62
+ .replace(/^-+|-+$/g, "") || "check";
63
+ const checks = report.sections.flatMap((sec) =>
64
+ sec.lines.map((l) => ({
65
+ id: kebab(`${sec.title}-${l.label}`),
66
+ status: l.status,
67
+ summary: l.detail ? `${l.label}: ${l.detail}` : l.label,
68
+ })),
69
+ );
70
+ const status = report.failures > 0 ? "fail" : report.warnings > 0 ? "warn" : "ok";
71
+ return {
72
+ ...report,
73
+ kind: "ashlr.doctor" as const,
74
+ schema_version: 1 as const,
75
+ tool: "ashlr-plugin",
76
+ status,
77
+ native_status: report.failures > 0 ? "failures" : report.warnings > 0 ? "warnings" : "ok",
78
+ checks,
79
+ };
80
+ }
81
+
82
+ const GLYPH: Record<Status, string> = { ok: sym.check, warn: sym.warn, fail: sym.cross, info: sym.dot };
45
83
 
46
84
  /** Colored glyph for TTY rendering; falls back to the plain glyph otherwise. */
47
85
  function coloredGlyph(status: Status): string {
@@ -49,6 +87,7 @@ function coloredGlyph(status: Status): string {
49
87
  if (!isColorEnabled()) return g;
50
88
  if (status === "ok") return c.green(g);
51
89
  if (status === "warn") return c.yellow(g);
90
+ if (status === "info") return c.dim(g);
52
91
  return c.red(g);
53
92
  }
54
93
 
@@ -108,10 +147,22 @@ export interface ProbeResult {
108
147
  error?: string;
109
148
  }
110
149
 
150
+ /**
151
+ * The bun binary to run probes with. Prefer the binary running doctor right
152
+ * now (`process.execPath`) over a PATH lookup: a PATH `bun` can be a shell
153
+ * shim that runs the real binary as a grandchild. Falls back to "bun" when
154
+ * doctor itself is not running under bun.
155
+ */
156
+ export function realBunBinary(execPath: string = process.execPath): string {
157
+ const base = basename(execPath).toLowerCase();
158
+ return base === "bun" || base === "bun.exe" || base.startsWith("bun-") ? execPath : "bun";
159
+ }
160
+
111
161
  export async function probeServer(
112
162
  name: string,
113
163
  scriptPath: string,
114
164
  perServerTimeoutMs = 2000,
165
+ signal?: AbortSignal,
115
166
  ): Promise<ProbeResult> {
116
167
  if (!existsSync(scriptPath)) {
117
168
  return { server: name, ok: false, tools: [], error: "server script missing" };
@@ -125,34 +176,20 @@ export async function probeServer(
125
176
  const listReq = { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} };
126
177
  const payload = JSON.stringify(initReq) + "\n" + JSON.stringify(listReq) + "\n";
127
178
 
128
- const proc = spawn({
129
- cmd: ["bun", "run", scriptPath],
130
- stdin: "pipe",
131
- stdout: "pipe",
132
- stderr: "pipe",
133
- env: { ...process.env },
179
+ // runWithTimeout spawns the probe in its own process group and kills the
180
+ // whole group on timeout or abort. `proc.kill()` on a Bun.spawn child only
181
+ // reached the direct child, so when `bun` on PATH is a shell shim (pnpm and
182
+ // npm global installs) the real bun ran as a grandchild and survived,
183
+ // spinning at full CPU long after doctor or the test runner exited.
184
+ const res = await runWithTimeout({
185
+ command: realBunBinary(),
186
+ args: ["run", scriptPath],
187
+ env: { ...process.env } as Record<string, string>,
188
+ input: payload,
189
+ timeoutMs: perServerTimeoutMs,
190
+ signal,
134
191
  });
135
-
136
- const timer = setTimeout(() => {
137
- try { proc.kill(); } catch {}
138
- }, perServerTimeoutMs);
139
-
140
- try {
141
- proc.stdin.write(payload);
142
- await proc.stdin.end();
143
- } catch {
144
- // proc may already be dead
145
- }
146
-
147
- let out = "";
148
- try {
149
- out = await new Response(proc.stdout).text();
150
- } catch {
151
- // stream errored
152
- }
153
- // best-effort: we've already captured stdout and only need the process to settle; exit-code and signal reasons are not used in the probe.
154
- await proc.exited.catch(() => {});
155
- clearTimeout(timer);
192
+ const out = res.stdout;
156
193
 
157
194
  const tools: string[] = [];
158
195
  let sawListResult = false;
@@ -179,17 +216,26 @@ export async function probeAll(
179
216
  totalCapMs = 5000,
180
217
  perServerTimeoutMs = 2000,
181
218
  ): Promise<ProbeResult[]> {
182
- const work = Promise.all(servers.map((s) => probeServer(s.name, s.script, perServerTimeoutMs)));
183
- const cap = new Promise<ProbeResult[]>((resolveP) =>
184
- setTimeout(
185
- () =>
186
- resolveP(
187
- servers.map((s) => ({ server: s.name, ok: false, tools: [], error: "total probe cap exceeded" })),
188
- ),
189
- totalCapMs,
190
- ),
219
+ // When the total cap wins the race, abort every probe still running so no
220
+ // server process (or its descendants) outlives this call.
221
+ const abort = new AbortController();
222
+ const work = Promise.all(
223
+ servers.map((s) => probeServer(s.name, s.script, perServerTimeoutMs, abort.signal)),
191
224
  );
192
- return await Promise.race([work, cap]);
225
+ let capTimer: ReturnType<typeof setTimeout> | undefined;
226
+ const cap = new Promise<ProbeResult[]>((resolveP) => {
227
+ capTimer = setTimeout(() => {
228
+ abort.abort();
229
+ resolveP(
230
+ servers.map((s) => ({ server: s.name, ok: false, tools: [], error: "total probe cap exceeded" })),
231
+ );
232
+ }, totalCapMs);
233
+ });
234
+ try {
235
+ return await Promise.race([work, cap]);
236
+ } finally {
237
+ clearTimeout(capTimer);
238
+ }
193
239
  }
194
240
 
195
241
  // ---------- project hint check ----------
@@ -299,6 +345,25 @@ export interface CloudPingResult {
299
345
  error?: string;
300
346
  }
301
347
 
348
+ /**
349
+ * Metadata-only probe for the optional Phantom CLI. Runs `phantom --version`
350
+ * and nothing else: it never runs reveal, never reads the vault or .env
351
+ * files, and never touches secret values. Returns the first line of output,
352
+ * or null when the binary is missing or fails.
353
+ */
354
+ export async function getPhantomVersion(bin = "phantom"): Promise<string | null> {
355
+ try {
356
+ const proc = spawn([bin, "--version"], { stdout: "pipe", stderr: "ignore", stdin: "ignore" });
357
+ const out = await new Response(proc.stdout).text();
358
+ const code = await proc.exited;
359
+ if (code !== 0) return null;
360
+ const line = out.trim().split("\n")[0]?.trim();
361
+ return line ? line.slice(0, 80) : null;
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+
302
367
  export interface BuildOpts {
303
368
  root: string;
304
369
  home?: string;
@@ -309,6 +374,14 @@ export interface BuildOpts {
309
374
  probe?: (servers: Array<{ name: string; script: string }>) => Promise<ProbeResult[]>;
310
375
  bunVersion?: () => Promise<string | null>;
311
376
  bunOffPath?: () => Promise<string | null>;
377
+ /** Override for testing the phantom check (returns version string or null). */
378
+ phantomVersion?: () => Promise<string | null>;
379
+ /** Environment to inspect for a phantom exec session (defaults to process.env). */
380
+ env?: Record<string, string | undefined>;
381
+ /** Runner for the opt-in `phantom agent doctor --json --strict` preflight (tests). */
382
+ phantomPreflight?: PreflightRunner;
383
+ /** Runner for the `--ashlr-handshake` companion probes (tests). */
384
+ companionRunner?: CompanionRunner;
312
385
  /** Override for testing cloud ping without real network calls */
313
386
  pingCloud?: (url: string, timeoutMs: number) => Promise<CloudPingResult>;
314
387
  }
@@ -376,6 +449,7 @@ export async function buildReport(opts: BuildOpts): Promise<Report> {
376
449
  const probe = opts.probe ?? ((servers) => probeAll(servers));
377
450
  const bunVersionFn = opts.bunVersion ?? getBunVersion;
378
451
  const bunOffPathFn = opts.bunOffPath ?? getBunOffPath;
452
+ const phantomVersionFn = opts.phantomVersion ?? (() => getPhantomVersion());
379
453
 
380
454
  const plugin = await readPluginJson(root);
381
455
  const currentVersion = plugin?.version ?? "unknown";
@@ -445,8 +519,59 @@ export async function buildReport(opts: BuildOpts): Promise<Report> {
445
519
  detail: `@modelcontextprotocol/sdk@${mcpSdkPkg.version ?? "?"} · @ashlr/core-efficiency@${corePkg.version ?? "?"}`,
446
520
  });
447
521
  }
522
+ // Optional Phantom CLI: presence and version only, never secret values.
523
+ const phantomVersion = await phantomVersionFn();
524
+ install.push(
525
+ phantomVersion
526
+ ? { status: "ok", label: "phantom", detail: `${phantomVersion} (installed; run tools via \`phantom exec -- <cmd>\`)` }
527
+ : { status: "ok", label: "phantom", detail: "not installed (optional)" },
528
+ );
529
+ // Value-free: names, phm prefixes and proxy host/port only; never reveals.
530
+ {
531
+ const pe = detectPhantomExec(opts.env ?? process.env);
532
+ install.push({
533
+ status: pe.warnings.length ? "warn" : "ok",
534
+ label: "phantom exec",
535
+ detail: describePhantomExec(pe),
536
+ ...(pe.warnings.length ? { fix: pe.warnings.join(" ") } : {}),
537
+ });
538
+ }
539
+ // Opt-in (ASHLR_PHANTOM_PREFLIGHT=1 or strict): Phantom's value-free agent
540
+ // preflight. Verdict and check ids only.
541
+ {
542
+ const pf = runPhantomPreflight(preflightMode(opts.env ?? process.env), opts.phantomPreflight);
543
+ if (pf) {
544
+ install.push({
545
+ status: pf.status,
546
+ label: "phantom preflight",
547
+ detail: pf.detail,
548
+ ...(pf.status !== "ok" ? { fix: "run `phantom agent doctor` for details and fix commands" } : {}),
549
+ });
550
+ }
551
+ }
448
552
  sections.push({ title: "install", lines: install });
449
553
 
554
+ // ----- ashlr companions (informational, never warn/fail) -----
555
+ const companions = await detectCompanions(opts.companionRunner);
556
+ sections.push({
557
+ title: "companions",
558
+ lines: companions.map((cmp): Line => {
559
+ const caps = cmp.capabilities.length ? ` [${cmp.capabilities.join(", ")}]` : "";
560
+ const detail =
561
+ cmp.state === "ready" ? `${cmp.version ?? "?"} ready${caps}`
562
+ : cmp.state === "degraded" ? `${cmp.version ?? "?"} health: ${cmp.health ?? "unknown"}${caps}`
563
+ : cmp.state === "outdated" ? "installed, too old for --ashlr-handshake"
564
+ : cmp.state === "unknown" ? "installed, handshake unreadable or timed out"
565
+ : "not installed";
566
+ return {
567
+ status: cmp.state === "ready" ? "ok" : "info",
568
+ label: cmp.name,
569
+ detail,
570
+ ...(cmp.fix ? { fix: cmp.fix } : {}),
571
+ };
572
+ }),
573
+ });
574
+
450
575
  // ----- mcp servers -----
451
576
  const servers = [
452
577
  { name: "efficiency", script: join(root, "servers/efficiency-server.ts") },
@@ -818,7 +943,7 @@ export async function buildReport(opts: BuildOpts): Promise<Report> {
818
943
  detail = `provider: onnx (offline summarizer)${overrideNote}`;
819
944
  break;
820
945
  case "local":
821
- detail = `provider: local (${process.env.ASHLR_LLM_URL ?? "http://localhost:1234/v1"})${overrideNote}`;
946
+ detail = `provider: local (${process.env.ASHLR_LLM_URL ?? "http://127.0.0.1:1234/v1"})${overrideNote}`;
822
947
  break;
823
948
  case "none":
824
949
  default:
@@ -917,7 +1042,7 @@ export async function buildReport(opts: BuildOpts): Promise<Report> {
917
1042
  }
918
1043
  }
919
1044
 
920
- return { header, sections, warnings, failures };
1045
+ return { header, sections, companions, warnings, failures };
921
1046
  }
922
1047
 
923
1048
  // ---------- formatting ----------
@@ -983,6 +1108,10 @@ async function main(): Promise<number> {
983
1108
  return 2;
984
1109
  }
985
1110
  const report = await buildReport({ root });
1111
+ if (process.argv.includes("--json")) {
1112
+ process.stdout.write(JSON.stringify(withDoctorEnvelope(report), null, 2) + "\n");
1113
+ return report.failures > 0 ? 1 : 0;
1114
+ }
986
1115
  process.stdout.write(formatReport(report) + "\n");
987
1116
  return report.failures > 0 ? 1 : 0;
988
1117
  }
@@ -30,12 +30,14 @@ import {
30
30
  writeFileSync,
31
31
  } from "fs";
32
32
  import { homedir } from "os";
33
+ import { anthropicBaseUrl, openaiBaseUrl } from "../servers/_base-urls.ts";
33
34
  import { dirname, join, resolve } from "path";
34
35
  // Imported for API-surface parity with the MCP server; the auto-consolidator
35
36
  // uses the direct-fallback path so it stays offline and deterministic.
36
37
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
37
38
  import { consolidateProposals } from "@ashlr/core-efficiency/genome";
38
39
  import { summarizeIfLarge as _summarizeIfLargeImpl, PROMPTS, type SummarizeResult, type SummarizeOpts } from "../servers/_summarize";
40
+ import { safeKeyedFetch } from "../servers/_gateway-target.ts";
39
41
 
40
42
  // Mutable hook container so tests can inject stubs:
41
43
  // _hooks.summarizeIfLarge — controls LLM synthesis
@@ -77,7 +79,7 @@ async function isLlmReachable(): Promise<boolean> {
77
79
  // ANTHROPIC_API_KEY — probe the models endpoint
78
80
  if (process.env["ANTHROPIC_API_KEY"]) {
79
81
  try {
80
- const res = await fetch("https://api.anthropic.com/v1/models", {
82
+ const res = await safeKeyedFetch(`${anthropicBaseUrl()}/v1/models`, {
81
83
  method: "GET",
82
84
  headers: { "x-api-key": process.env["ANTHROPIC_API_KEY"]!, "anthropic-version": "2023-06-01" },
83
85
  signal: AbortSignal.timeout(2_000),
@@ -91,7 +93,7 @@ async function isLlmReachable(): Promise<boolean> {
91
93
  // OPENAI_API_KEY — probe the models endpoint
92
94
  if (process.env["OPENAI_API_KEY"]) {
93
95
  try {
94
- const res = await fetch("https://api.openai.com/v1/models", {
96
+ const res = await safeKeyedFetch(`${openaiBaseUrl()}/models`, {
95
97
  method: "GET",
96
98
  headers: { "Authorization": `Bearer ${process.env["OPENAI_API_KEY"]!}` },
97
99
  signal: AbortSignal.timeout(2_000),
@@ -24,6 +24,7 @@ import {
24
24
  writeSection,
25
25
  } from "@ashlr/core-efficiency/genome";
26
26
  import { scan, type Baseline } from "./baseline-scan.ts";
27
+ import { ollamaBaseUrl } from "../servers/_base-urls.ts";
27
28
  import { Spinner, ProgressBar, c, sym, isColorEnabled } from "./ui.ts";
28
29
 
29
30
  // ---------------------------------------------------------------------------
@@ -214,7 +215,6 @@ export interface WorkspaceGraph {
214
215
  // Local model summarization (Ollama)
215
216
  // ---------------------------------------------------------------------------
216
217
 
217
- const OLLAMA_BASE_URL = "http://localhost:11434";
218
218
  const OLLAMA_TIMEOUT_MS = 2000;
219
219
  const OLLAMA_GENERATE_TIMEOUT_MS = 30000;
220
220
  /** Max chars of CLAUDE.md to pass to the LLM. Avoids loading huge files. */
@@ -234,7 +234,7 @@ async function resolveOllamaModel(): Promise<string | null> {
234
234
  try {
235
235
  const controller = new AbortController();
236
236
  const timer = setTimeout(() => controller.abort(), OLLAMA_TIMEOUT_MS);
237
- const res = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
237
+ const res = await fetch(`${ollamaBaseUrl()}/api/tags`, {
238
238
  signal: controller.signal,
239
239
  });
240
240
  clearTimeout(timer);
@@ -269,7 +269,7 @@ async function summarizeWithOllama(
269
269
  try {
270
270
  const controller = new AbortController();
271
271
  const timer = setTimeout(() => controller.abort(), OLLAMA_GENERATE_TIMEOUT_MS);
272
- const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
272
+ const res = await fetch(`${ollamaBaseUrl()}/api/chat`, {
273
273
  method: "POST",
274
274
  headers: { "Content-Type": "application/json" },
275
275
  body: JSON.stringify({
@@ -5,12 +5,14 @@
5
5
  * Usage:
6
6
  * bun run scripts/genome-key.ts generate <genomeId>
7
7
  * bun run scripts/genome-key.ts export <genomeId>
8
- * bun run scripts/genome-key.ts import <genomeId> <base32>
8
+ * bun run scripts/genome-key.ts import <genomeId> (key from stdin: paste, or pipe it)
9
9
  * bun run scripts/genome-key.ts rotate <genomeId>
10
10
  *
11
11
  * Keys are stored at ~/.ashlr/team-keys/<genomeId>.key (mode 0600, 32 raw bytes).
12
12
  * Export/import uses base32 (RFC 4648) for human-friendly sharing over Signal,
13
- * 1Password, etc.
13
+ * 1Password, etc. `import` reads the key from stdin (hidden when typed or
14
+ * pasted at a terminal) so it never lands in argv or shell history. A key
15
+ * given as a third argument still works but prints a warning.
14
16
  *
15
17
  * Key rotation procedure:
16
18
  * 1. Reads all sections from the remote genome via the API.
@@ -75,6 +77,50 @@ async function promptConfirm(question: string): Promise<boolean> {
75
77
  });
76
78
  }
77
79
 
80
+ /**
81
+ * Read one key line from stdin. At a terminal the input is not echoed (raw
82
+ * mode); piped input is read to the first newline. The key never touches
83
+ * argv, the environment or any output.
84
+ */
85
+ async function readKeyFromStdin(): Promise<string> {
86
+ const stdin = process.stdin;
87
+ const tty = stdin.isTTY === true && typeof stdin.setRawMode === "function";
88
+ if (tty) process.stderr.write("Paste the team genome key, then press Enter (input is hidden): ");
89
+ return new Promise((resolve) => {
90
+ let buf = "";
91
+ const finish = () => {
92
+ stdin.removeListener("data", onData);
93
+ stdin.removeListener("end", finish);
94
+ if (tty) {
95
+ stdin.setRawMode(false);
96
+ process.stderr.write("\n");
97
+ }
98
+ stdin.pause();
99
+ resolve(buf.trim());
100
+ };
101
+ const onData = (chunk: Buffer | string) => {
102
+ for (const ch of String(chunk)) {
103
+ if (ch === "\n" || ch === "\r") return finish();
104
+ if (tty && ch === "\u0003") {
105
+ stdin.setRawMode(false);
106
+ process.stderr.write("\n");
107
+ process.exit(130);
108
+ }
109
+ if (tty && (ch === "\u007f" || ch === "\b")) {
110
+ buf = buf.slice(0, -1);
111
+ continue;
112
+ }
113
+ buf += ch;
114
+ }
115
+ };
116
+ if (tty) stdin.setRawMode(true);
117
+ stdin.setEncoding("utf-8");
118
+ stdin.on("data", onData);
119
+ stdin.once("end", finish);
120
+ stdin.resume();
121
+ });
122
+ }
123
+
78
124
  // ---------------------------------------------------------------------------
79
125
  // Commands
80
126
  // ---------------------------------------------------------------------------
@@ -109,7 +155,8 @@ async function cmdExport(genomeId: string): Promise<void> {
109
155
  info("=====================================");
110
156
  info("");
111
157
  info("Share this with team members. They import it with:");
112
- info(` bun run scripts/genome-key.ts import ${genomeId} <key>`);
158
+ info(` bun run scripts/genome-key.ts import ${genomeId}`);
159
+ info(" (then paste the key; or pipe it in, e.g. from a password manager CLI)");
113
160
  }
114
161
 
115
162
  async function cmdImport(genomeId: string, base32Key: string): Promise<void> {
@@ -117,7 +164,9 @@ async function cmdImport(genomeId: string, base32Key: string): Promise<void> {
117
164
  try {
118
165
  key = decodeBase32(base32Key);
119
166
  } catch (err) {
120
- die(`Invalid base32 key: ${String(err)}`);
167
+ // The decoder's message quotes the offending character; never echo key material.
168
+ void err;
169
+ die("Invalid base32 key. Make sure you copied the full key.");
121
170
  }
122
171
  if (key.length !== 32) {
123
172
  die(`Key must be 32 bytes when decoded (got ${key.length}). Make sure you copied the full key.`);
@@ -206,7 +255,7 @@ if (!command || !genomeId) {
206
255
  "Usage:\n" +
207
256
  " bun run scripts/genome-key.ts generate <genomeId>\n" +
208
257
  " bun run scripts/genome-key.ts export <genomeId>\n" +
209
- " bun run scripts/genome-key.ts import <genomeId> <base32>\n" +
258
+ " bun run scripts/genome-key.ts import <genomeId> (key from stdin)\n" +
210
259
  " bun run scripts/genome-key.ts rotate <genomeId>\n",
211
260
  );
212
261
  process.exit(1);
@@ -220,8 +269,16 @@ switch (command) {
220
269
  await cmdExport(genomeId);
221
270
  break;
222
271
  case "import": {
223
- const b32 = rest[0];
224
- if (!b32) die("Missing base32 key argument.");
272
+ let b32 = rest[0];
273
+ if (b32) {
274
+ process.stderr.write(
275
+ "warning: a key passed as an argument is visible to other local users (ps) and is kept in shell history. " +
276
+ `Next time run \`bun run scripts/genome-key.ts import ${genomeId}\` and paste or pipe the key on stdin.\n`,
277
+ );
278
+ } else {
279
+ b32 = await readKeyFromStdin();
280
+ }
281
+ if (!b32) die("No key given. Paste the key, or pipe it on stdin.");
225
282
  await cmdImport(genomeId, b32);
226
283
  break;
227
284
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Ashlr companion handshake v1 (`ashlr-plugin --ashlr-handshake`): one JSON
3
+ * object naming the plugin, its protocols and capabilities, local health and
4
+ * the files it may edit, so `phm companions` / `phm doctor` stop guessing
5
+ * from `--help`. Contract: docs/ashlr-handshake.md (shared with ashlr-stack
6
+ * and locus).
7
+ *
8
+ * Answered by the node launcher before it looks for bun, so it works (and
9
+ * reports bun as missing) on a machine without bun. No network, no token
10
+ * files read, no secret values.
11
+ */
12
+
13
+ /**
14
+ * @param {string} version
15
+ * @param {{ bun: boolean, phantom: boolean }} found
16
+ */
17
+ export function buildHandshake(version, found) {
18
+ return {
19
+ kind: "ashlr.handshake",
20
+ schema_version: 1,
21
+ name: "ashlr-plugin",
22
+ command: "ashlr-plugin",
23
+ version,
24
+ protocols: { "ashlr.handshake": 1, "phantom.agent.preflight": 1 },
25
+ capabilities: [
26
+ "token-efficient-tools",
27
+ "mcp-server",
28
+ "claude-code-plugin",
29
+ "codex-install",
30
+ "savings-stats",
31
+ "agent-preflight",
32
+ "doctor",
33
+ ],
34
+ health: {
35
+ status: found.bun ? "ok" : "degraded",
36
+ checks: [
37
+ // The CLI, hooks and MCP server run under bun.
38
+ { id: "bun-on-path", status: found.bun ? "ok" : "missing" },
39
+ // Only ASHLR_PHANTOM_PREFLIGHT needs it.
40
+ { id: "phantom-on-path", status: found.phantom ? "ok" : "absent" },
41
+ ],
42
+ },
43
+ edits: ["~/.ashlr/", "~/.claude/settings.json", "~/.codex/config.toml"],
44
+ receipts: [],
45
+ };
46
+ }
47
+
48
+ /** True when argv (without node and the script) asks for the handshake. */
49
+ export function wantsHandshake(argv) {
50
+ return argv.includes("--ashlr-handshake");
51
+ }