harnery 0.4.0 → 0.6.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.
Files changed (100) hide show
  1. package/dist/commander.d.ts +10 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +2 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +43 -29
  6. package/dist/commands/browse-ai.js +1 -1
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +41 -9
  9. package/dist/commands/completion.d.ts.map +1 -1
  10. package/dist/commands/completion.js +48 -10
  11. package/dist/commands/cookies.js +1 -1
  12. package/dist/commands/decision.d.ts +4 -0
  13. package/dist/commands/decision.d.ts.map +1 -0
  14. package/dist/commands/decision.js +354 -0
  15. package/dist/commands/docs.d.ts.map +1 -1
  16. package/dist/commands/docs.js +5 -1
  17. package/dist/commands/fetch.js +1 -1
  18. package/dist/core/agents/events/consume.d.ts +25 -2
  19. package/dist/core/agents/events/consume.d.ts.map +1 -1
  20. package/dist/core/agents/events/consume.js +55 -7
  21. package/dist/core/agents/events/emit.d.ts +2 -1
  22. package/dist/core/agents/events/emit.d.ts.map +1 -1
  23. package/dist/core/agents/events/emit.js +2 -1
  24. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  25. package/dist/core/agents/rules/claim-conflict.js +10 -2
  26. package/dist/core/agents/state/scratch.d.ts +1 -1
  27. package/dist/core/agents/state/scratch.js +2 -2
  28. package/dist/core/hooks/cli.js +4 -10
  29. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  30. package/dist/core/hooks/effects/index.js +2 -1
  31. package/dist/core/hooks/guard-path.d.ts +29 -0
  32. package/dist/core/hooks/guard-path.d.ts.map +1 -0
  33. package/dist/core/hooks/guard-path.js +38 -0
  34. package/dist/lib/agent-browser/client.js +1 -1
  35. package/dist/lib/browser/client.d.ts +14 -0
  36. package/dist/lib/browser/client.d.ts.map +1 -1
  37. package/dist/lib/browser/client.js +20 -0
  38. package/dist/lib/browser/index.d.ts +1 -0
  39. package/dist/lib/browser/index.d.ts.map +1 -1
  40. package/dist/lib/browser/runts.d.ts +44 -0
  41. package/dist/lib/browser/runts.d.ts.map +1 -0
  42. package/dist/lib/browser/runts.js +193 -0
  43. package/dist/lib/completion/bash.d.ts +15 -0
  44. package/dist/lib/completion/bash.d.ts.map +1 -1
  45. package/dist/lib/completion/bash.js +35 -0
  46. package/dist/lib/completion/fish.d.ts +10 -0
  47. package/dist/lib/completion/fish.d.ts.map +1 -1
  48. package/dist/lib/completion/fish.js +21 -0
  49. package/dist/lib/completion/index.d.ts +4 -3
  50. package/dist/lib/completion/index.d.ts.map +1 -1
  51. package/dist/lib/completion/index.js +4 -3
  52. package/dist/lib/completion/resolve.d.ts +52 -0
  53. package/dist/lib/completion/resolve.d.ts.map +1 -0
  54. package/dist/lib/completion/resolve.js +171 -0
  55. package/dist/lib/completion/walk.js +1 -1
  56. package/dist/lib/completion/zsh.d.ts +8 -0
  57. package/dist/lib/completion/zsh.d.ts.map +1 -1
  58. package/dist/lib/completion/zsh.js +33 -0
  59. package/dist/lib/cookies/client.d.ts +1 -1
  60. package/dist/lib/cookies/client.d.ts.map +1 -1
  61. package/dist/lib/cookies/client.js +1 -1
  62. package/dist/lib/decision/index.d.ts +212 -0
  63. package/dist/lib/decision/index.d.ts.map +1 -0
  64. package/dist/lib/decision/index.js +523 -0
  65. package/dist/lib/docs-lint.d.ts +1 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +55 -0
  68. package/dist/lib/tunnel/gate.js +1 -1
  69. package/package.json +3 -1
  70. package/src/commander.ts +12 -0
  71. package/src/commands/agents.ts +47 -26
  72. package/src/commands/browse-ai.ts +1 -1
  73. package/src/commands/browse.ts +63 -8
  74. package/src/commands/completion.ts +62 -9
  75. package/src/commands/cookies.ts +1 -1
  76. package/src/commands/decision.ts +438 -0
  77. package/src/commands/docs.ts +5 -1
  78. package/src/commands/fetch.ts +1 -1
  79. package/src/core/agents/events/consume.ts +65 -7
  80. package/src/core/agents/events/emit.ts +2 -1
  81. package/src/core/agents/rules/claim-conflict.ts +10 -2
  82. package/src/core/agents/state/scratch.ts +2 -2
  83. package/src/core/config.ts +1 -1
  84. package/src/core/hooks/cli.ts +4 -8
  85. package/src/core/hooks/effects/index.ts +2 -1
  86. package/src/core/hooks/guard-path.ts +34 -0
  87. package/src/lib/agent-browser/client.ts +1 -1
  88. package/src/lib/browser/client.ts +28 -0
  89. package/src/lib/browser/index.ts +4 -0
  90. package/src/lib/browser/runts.ts +218 -0
  91. package/src/lib/completion/bash.ts +36 -0
  92. package/src/lib/completion/fish.ts +22 -0
  93. package/src/lib/completion/index.ts +12 -3
  94. package/src/lib/completion/resolve.ts +210 -0
  95. package/src/lib/completion/walk.ts +1 -1
  96. package/src/lib/completion/zsh.ts +34 -0
  97. package/src/lib/cookies/client.ts +2 -2
  98. package/src/lib/decision/index.ts +685 -0
  99. package/src/lib/docs-lint.ts +49 -0
  100. package/src/lib/tunnel/gate.ts +1 -1
@@ -17,6 +17,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
17
17
  import os from "node:os";
18
18
  import { join } from "node:path";
19
19
  import { applyDetection } from "../../../lib/presence.ts";
20
+ import { resolveBinName } from "../../config.ts";
20
21
 
21
22
  export type { CaptureContext } from "./image-capture.ts";
22
23
  export { captureImages, imageJanitor } from "./image-capture.ts";
@@ -126,7 +127,7 @@ export function scratchArchive(repoRoot: string, owner: string): void {
126
127
  */
127
128
  export function syncClaudeSessions(repoRoot: string, force: boolean): void {
128
129
  try {
129
- const bin = join(repoRoot, "bin", "bp");
130
+ const bin = join(repoRoot, "bin", resolveBinName(repoRoot));
130
131
  if (!existsSync(bin)) return;
131
132
  const env: Record<string, string | undefined> = {
132
133
  ...process.env,
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Canonicalize a write-tool target path for the claim guard.
3
+ *
4
+ * Returns the monorepo-relative path, or `null` when the target lies OUTSIDE the
5
+ * repo (an absolute path not under coordRoot, e.g. a `/tmp` scratchpad or other
6
+ * session-temp file).
7
+ *
8
+ * The claim system is intentionally repo-scoped: it coordinates monorepo files,
9
+ * not arbitrary absolute paths, so the guard skips out-of-repo targets. Skipping
10
+ * them is right on two counts. First, it keeps non-repo paths out of a
11
+ * heartbeat's `files_touched`. Second, the ordering rule compares raw path
12
+ * strings, and an absolute `/tmp/…` sorts before every repo-relative path
13
+ * (`/` = 0x2F < any letter), so without this a scratchpad write would spuriously
14
+ * "block" a legitimately-held repo file. Returning null keeps such paths out of
15
+ * the claim system entirely.
16
+ *
17
+ * Accepted tradeoff: this also means shared out-of-repo files (a user-level
18
+ * memory or plans directory) are not coordinated across agents. The alternative,
19
+ * normalizing every path to one consistent key so those stay coordinated, was
20
+ * rejected as gold-plating a rare, merge-disciplined race in a deadlock-critical
21
+ * path. Coordinate shared state by keeping it in the repo, not out of it.
22
+ *
23
+ * Relative inputs are assumed already-repo-relative (Codex `apply_patch` emits
24
+ * cwd-relative paths). The in-repo check requires the `<root>/` separator, so a
25
+ * sibling dir that merely shares a prefix (`/repo-other` vs `/repo`) is treated
26
+ * as out-of-repo, not stripped.
27
+ */
28
+ export function canonicalize(coordRoot: string, p: string): string | null {
29
+ if (!p) return null;
30
+ if (p === coordRoot) return ".";
31
+ if (p.startsWith(`${coordRoot}/`)) return p.slice(coordRoot.length + 1);
32
+ if (p.startsWith("/")) return null; // absolute + not under coordRoot → out-of-repo
33
+ return p; // relative → treat as repo-relative
34
+ }
@@ -124,7 +124,7 @@ export class AgentBrowser {
124
124
  return;
125
125
  }
126
126
  const stateFile =
127
- this.opts.stateFilePath ?? `/tmp/bp-agent-browser-state-${process.pid}.json`;
127
+ this.opts.stateFilePath ?? `/tmp/harn-agent-browser-state-${process.pid}.json`;
128
128
  writeFileSync(stateFile, JSON.stringify(jarStore, null, 2));
129
129
  this.exec(["state", "load", stateFile], 10_000);
130
130
  this.cookiesSeeded = true;
@@ -18,6 +18,12 @@ import {
18
18
  type OverflowResult,
19
19
  type WidthResult,
20
20
  } from "./layout.js";
21
+ import {
22
+ buildClearRuntsAnnotationsScript,
23
+ buildRuntsAnnotateScript,
24
+ buildRuntsCheck,
25
+ type RuntsResult,
26
+ } from "./runts.js";
21
27
  import {
22
28
  buildAnnotateScript,
23
29
  buildClearAnnotationsScript,
@@ -357,6 +363,28 @@ export class Browser {
357
363
  });
358
364
  }
359
365
 
366
+ /**
367
+ * Scan text blocks for runts — a single word alone on a block's last
368
+ * visual line. Word-count per line via per-word Range rects (the width
369
+ * of the last line is deliberately NOT the signal; see runts.ts).
370
+ */
371
+ async checkRunts(opts: { scope?: string | null; minChars?: number } = {}): Promise<RuntsResult> {
372
+ return await this.currentPage.evaluate(buildRuntsCheck(), {
373
+ scope: opts.scope ?? null,
374
+ minChars: opts.minChars ?? 40,
375
+ });
376
+ }
377
+
378
+ /** Inject annotation overlays for runt hits. Used before screenshot. */
379
+ async annotateRunts(result: RuntsResult): Promise<void> {
380
+ await this.currentPage.evaluate(buildRuntsAnnotateScript(), { runts: result.runts });
381
+ }
382
+
383
+ /** Remove runt annotation overlays. */
384
+ async clearRuntsAnnotations(): Promise<void> {
385
+ await this.currentPage.evaluate(buildClearRuntsAnnotationsScript());
386
+ }
387
+
360
388
  /** Inject annotation overlays for width + overflow results. Used before screenshot. */
361
389
  async annotateLayout(args: {
362
390
  widths: WidthResult[];
@@ -17,6 +17,10 @@ export type {
17
17
  OverflowResult,
18
18
  WidthResult,
19
19
  } from "./layout.js";
20
+ export type {
21
+ RuntHit,
22
+ RuntsResult,
23
+ } from "./runts.js";
20
24
  export type {
21
25
  CheckVisibilityOptions,
22
26
  VisibilityResult,
@@ -0,0 +1,218 @@
1
+ // Runt detection for `harn browse` — a runt (a.k.a. "widow word") is a single
2
+ // short word sitting alone on the LAST visual line of a text block. Not a
3
+ // page-break issue; it happens mid-page and just looks sloppy.
4
+ //
5
+ // Detection is by COUNTING WORDS on the last visual line (== 1 word), NOT by
6
+ // relative width: a width threshold misses runts in narrow columns (how-to
7
+ // steps, a right-aligned contact box) where one short word still fills a big
8
+ // slice of the skinny column. "Text block" = element with real text whose
9
+ // element children are all inline — a <p>-only pass silently skips
10
+ // pull-quotes, blurbs and captions that ship as <div>/<blockquote>.
11
+ //
12
+ // Atomic tokens (URLs, emails, phone numbers) are excluded — they can't be
13
+ // rebalanced and usually sit on their own line by design. The detector can't
14
+ // catch a runt that is itself one long word; eyeball as a final pass.
15
+ //
16
+ // The word-level line mapping (per-word Range rects grouped by line top) was
17
+ // first built for the paged.js print-book lint; this is the generalized
18
+ // live-page port.
19
+
20
+ export interface RuntHit {
21
+ /** Human label for the owning block: `tag#id` / `tag.class` / `tag`. */
22
+ block: string;
23
+ /** The lone word on the last visual line (trailing punctuation included). */
24
+ word: string;
25
+ /** First ~80 chars of the block's text, for locating it. */
26
+ snippet: string;
27
+ /** Document-relative rect of the runt word (scroll offsets applied). */
28
+ rect: { x: number; y: number; width: number; height: number };
29
+ /** Number of visual lines in the block. */
30
+ lines: number;
31
+ }
32
+
33
+ export interface RuntsResult {
34
+ /** Text blocks scanned (post length/inline-children filters). */
35
+ scannedBlocks: number;
36
+ /** True when the block sweep hit the internal cap (very large page). */
37
+ truncated: boolean;
38
+ runts: RuntHit[];
39
+ }
40
+
41
+ /**
42
+ * Build the JS function passed to `page.evaluate`. `scope` narrows the sweep
43
+ * to one container (null = whole body); `minChars` filters out tiny labels
44
+ * that can't meaningfully wrap (default 40).
45
+ */
46
+ export function buildRuntsCheck(): (args: {
47
+ scope: string | null;
48
+ minChars: number;
49
+ }) => RuntsResult {
50
+ return ({ scope, minChars }) => {
51
+ const INLINE = new Set([
52
+ "B",
53
+ "I",
54
+ "EM",
55
+ "STRONG",
56
+ "SPAN",
57
+ "A",
58
+ "BR",
59
+ "SMALL",
60
+ "SUP",
61
+ "SUB",
62
+ "U",
63
+ "MARK",
64
+ "CODE",
65
+ "ABBR",
66
+ "TIME",
67
+ "WBR",
68
+ ]);
69
+ const ATOMIC = /@|https?:|\.(com|org|net|io|co)\b|\d-\d{3}/;
70
+ const BLOCK_CAP = 5000;
71
+ const RUNT_CAP = 50;
72
+
73
+ const root = scope ? document.querySelector(scope) : document.body;
74
+ if (!root) return { scannedBlocks: 0, truncated: false, runts: [] };
75
+
76
+ const sx = window.scrollX;
77
+ const sy = window.scrollY;
78
+
79
+ const label = (el: Element): string => {
80
+ const tag = el.tagName.toLowerCase();
81
+ if (el.id) return `${tag}#${el.id}`;
82
+ const cls = typeof el.className === "string" ? el.className.trim() : "";
83
+ if (cls) return `${tag}.${cls.split(/\s+/).slice(0, 2).join(".")}`;
84
+ return tag;
85
+ };
86
+
87
+ const all = root.querySelectorAll("*");
88
+ const runts: RuntHit[] = [];
89
+ let scanned = 0;
90
+ let truncated = false;
91
+
92
+ for (let i = 0; i < all.length; i++) {
93
+ const el = all[i];
94
+ if (!el) continue;
95
+ const text = el.textContent ?? "";
96
+ if (text.trim().length < minChars) continue;
97
+ let allInline = true;
98
+ for (const c of el.children) {
99
+ if (!INLINE.has(c.tagName)) {
100
+ allInline = false;
101
+ break;
102
+ }
103
+ }
104
+ if (!allInline) continue;
105
+ const elRect = el.getBoundingClientRect();
106
+ if (elRect.width <= 0 || elRect.height <= 0) continue;
107
+
108
+ if (scanned >= BLOCK_CAP) {
109
+ truncated = true;
110
+ break;
111
+ }
112
+ scanned++;
113
+
114
+ // Map every word to its visual line by rounding the Range rect top.
115
+ const lines = new Map<number, { words: string[]; last: DOMRect }>();
116
+ const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
117
+ for (let n = tw.nextNode(); n; n = tw.nextNode()) {
118
+ const value = n.nodeValue ?? "";
119
+ for (const m of value.matchAll(/\S+/g)) {
120
+ const r = document.createRange();
121
+ r.setStart(n, m.index);
122
+ r.setEnd(n, m.index + m[0].length);
123
+ const rect = r.getBoundingClientRect();
124
+ if (!rect.width) continue;
125
+ const top = Math.round(rect.top);
126
+ const entry = lines.get(top);
127
+ if (entry) {
128
+ entry.words.push(m[0]);
129
+ entry.last = rect;
130
+ } else {
131
+ lines.set(top, { words: [m[0]], last: rect });
132
+ }
133
+ }
134
+ }
135
+ const tops = [...lines.keys()].sort((a, b) => a - b);
136
+ if (tops.length < 2) continue; // single line, no runt risk
137
+ const lastTop = tops[tops.length - 1];
138
+ const last = lastTop === undefined ? undefined : lines.get(lastTop);
139
+ if (last?.words.length !== 1) continue;
140
+ const word = last.words[0] ?? "";
141
+ if (ATOMIC.test(word)) continue;
142
+
143
+ runts.push({
144
+ block: label(el),
145
+ word,
146
+ snippet: text.replace(/\s+/g, " ").trim().slice(0, 80),
147
+ rect: {
148
+ x: Math.round(last.last.x + sx),
149
+ y: Math.round(last.last.y + sy),
150
+ width: Math.round(last.last.width),
151
+ height: Math.round(last.last.height),
152
+ },
153
+ lines: tops.length,
154
+ });
155
+ if (runts.length >= RUNT_CAP) {
156
+ truncated = true;
157
+ break;
158
+ }
159
+ }
160
+
161
+ return { scannedBlocks: scanned, truncated, runts };
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Annotate runt hits onto the live page before the screenshot. Uses a
167
+ * document-absolute root (not fixed-inset like the other check overlays)
168
+ * because runts are usually below the fold and the rects are document
169
+ * coordinates — this keeps boxes aligned on full-page captures.
170
+ */
171
+ export function buildRuntsAnnotateScript(): (args: { runts: RuntHit[] }) => void {
172
+ return ({ runts }) => {
173
+ const ROOT_ID = "__bp-check-runt-annotations__";
174
+ document.getElementById(ROOT_ID)?.remove();
175
+ const root = document.createElement("div");
176
+ root.id = ROOT_ID;
177
+ root.style.cssText =
178
+ "position: absolute; top: 0; left: 0; width: 100%; height: 0; overflow: visible; pointer-events: none; z-index: 2147483646;";
179
+ document.body.appendChild(root);
180
+
181
+ for (const hit of runts) {
182
+ const box = document.createElement("div");
183
+ box.style.cssText = `
184
+ position: absolute;
185
+ left: ${hit.rect.x - 3}px;
186
+ top: ${hit.rect.y - 3}px;
187
+ width: ${hit.rect.width + 6}px;
188
+ height: ${hit.rect.height + 6}px;
189
+ border: 2px solid #d946ef;
190
+ box-sizing: border-box;
191
+ background: #d946ef1a;
192
+ pointer-events: none;
193
+ `;
194
+ const tag = document.createElement("div");
195
+ tag.textContent = `runt: …${hit.word} (${hit.block})`;
196
+ tag.style.cssText = `
197
+ position: absolute;
198
+ left: 0;
199
+ top: -18px;
200
+ background: #d946ef;
201
+ color: #fff;
202
+ font: 12px/1.4 -apple-system, system-ui, sans-serif;
203
+ padding: 1px 6px;
204
+ border-radius: 3px;
205
+ white-space: nowrap;
206
+ pointer-events: none;
207
+ `;
208
+ box.appendChild(tag);
209
+ root.appendChild(box);
210
+ }
211
+ };
212
+ }
213
+
214
+ export function buildClearRuntsAnnotationsScript(): () => void {
215
+ return () => {
216
+ document.getElementById("__bp-check-runt-annotations__")?.remove();
217
+ };
218
+ }
@@ -162,6 +162,42 @@ function bashEscape(s: string): string {
162
162
  * to subcommand / option / value completion. `fn` is the function-name prefix
163
163
  * and `binName` is the CLI name used for the `__complete` callback.
164
164
  */
165
+ /**
166
+ * DYNAMIC bash completion: a thin, tree-independent shim. Instead of baking the
167
+ * command tree into case-tables (which go stale on any command/flag change),
168
+ * it hands the live command line to `<bin> __complete-line` on every <Tab> and
169
+ * lets the binary — which always knows its own current tree — answer. Install
170
+ * once; never regenerate. The trailing `\x1f:<n>` line carries the directive
171
+ * bitmask (bit 0 = fall back to file completion).
172
+ */
173
+ export function generateBashDynamic(binName: string): string {
174
+ const fn = fnPrefix(binName);
175
+ return `# ${binName} bash completion (dynamic; install once, never stale). Do not edit.
176
+ # Source via: eval "$(${binName} completion bash --dynamic)"
177
+ ${fn}() {
178
+ local cur="\${COMP_WORDS[COMP_CWORD]}"
179
+ local raw line directive=0
180
+ local -a cands=()
181
+ raw="$(${binName} __complete-line "$COMP_CWORD" -- "\${COMP_WORDS[@]}" 2>/dev/null)" || return
182
+ while IFS= read -r line; do
183
+ [ -z "$line" ] && continue
184
+ if [ "\${line:0:2}" = $'\\x1f:' ]; then
185
+ directive="\${line:2}"
186
+ else
187
+ cands+=( "\${line%%$'\\t'*}" )
188
+ fi
189
+ done <<< "$raw"
190
+ if (( directive & 1 )); then
191
+ COMPREPLY=( $(compgen -f -- "$cur") )
192
+ return
193
+ fi
194
+ local IFS=$'\\n'
195
+ COMPREPLY=( $(compgen -W "\${cands[*]}" -- "$cur") )
196
+ }
197
+ complete -F ${fn} ${binName}
198
+ `;
199
+ }
200
+
165
201
  function bashDriver(fn: string, binName: string): string {
166
202
  return `${fn}() {
167
203
  local cur prev words cword
@@ -67,6 +67,28 @@ function valueAction(
67
67
  return { flag: "-r", arg: "" };
68
68
  }
69
69
 
70
+ /**
71
+ * DYNAMIC fish completion: a thin, tree-independent shim that calls
72
+ * `<bin> __complete-line` on every <Tab> (see bash.ts generateBashDynamic for
73
+ * the rationale). Fish renders `value\tdescription` lines natively, so the
74
+ * helper just strips the trailing `\x1f:<n>` directive line. Note: fish's
75
+ * declarative `complete -a` cannot switch to file completion from inside the
76
+ * helper, so the File directive is a no-op here — use static fish completion
77
+ * (`completion fish`) if you need file fallback on a path-valued positional.
78
+ */
79
+ export function generateFishDynamic(binName: string): string {
80
+ const fn = `__${binName.replace(/[^a-zA-Z0-9_]/g, "_")}_complete`;
81
+ return `# ${binName} fish completion (dynamic; install once, never stale). Do not edit.
82
+ # Source via: ${binName} completion fish --dynamic | source
83
+ function ${fn}
84
+ set -l toks (commandline -opc)
85
+ set -l cur (commandline -ct)
86
+ ${binName} __complete-line (count $toks) -- $toks $cur 2>/dev/null | string match -rv '^\\x1f:'
87
+ end
88
+ complete -c ${binName} -f -a '(${fn})'
89
+ `;
90
+ }
91
+
70
92
  export function generateFish(root: CommandSpec, binName: string): string {
71
93
  const out: string[] = [];
72
94
  out.push(
@@ -1,5 +1,14 @@
1
- export { generateBash } from "./bash.js";
2
- export { generateFish } from "./fish.js";
1
+ export { generateBash, generateBashDynamic } from "./bash.js";
2
+ export { generateFish, generateFishDynamic } from "./fish.js";
3
+ export {
4
+ type Candidate,
5
+ type CompletionProviderRunner,
6
+ type CompletionResult,
7
+ DIRECTIVE_PREFIX,
8
+ Directive,
9
+ encodeResult,
10
+ resolveCompletions,
11
+ } from "./resolve.js";
3
12
  export {
4
13
  type CommandSpec,
5
14
  type CompletionContextLookup,
@@ -7,4 +16,4 @@ export {
7
16
  type PositionalSpec,
8
17
  walkProgram,
9
18
  } from "./walk.js";
10
- export { generateZsh } from "./zsh.js";
19
+ export { generateZsh, generateZshDynamic } from "./zsh.js";
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Runtime completion resolver — the authoritative, in-process answer to
3
+ * "what should I suggest at this cursor position?".
4
+ *
5
+ * The static bash/zsh/fish generators bake the whole command tree into shell
6
+ * case-tables, which go stale the moment a command/flag is added (the script
7
+ * must be regenerated + reinstalled). The DYNAMIC completion path instead
8
+ * installs a thin, tree-independent shim that, on every <Tab>, hands the live
9
+ * command line back to the binary and calls THIS function. Because the binary
10
+ * always knows its own current command tree, a dynamic shim never goes stale —
11
+ * install once, ever.
12
+ *
13
+ * This mirrors the path-walking the static bash driver does, but in one place
14
+ * and in TypeScript, so all three shells share identical behavior. It is
15
+ * deliberately decoupled from Commander internals via the CommandSpec tree
16
+ * (walkProgram), exactly like the static generators.
17
+ */
18
+
19
+ import type { Command } from "commander";
20
+ import { type CommandSpec, type CompletionContextLookup, walkProgram } from "./walk.js";
21
+
22
+ /** Bitmask of post-processing hints for the shell shim (Cobra-style). */
23
+ export const Directive = {
24
+ /** Use the returned candidates as completion values. */
25
+ Default: 0,
26
+ /** No candidates apply here — the shell should fall back to file completion. */
27
+ File: 1,
28
+ } as const;
29
+
30
+ export interface Candidate {
31
+ value: string;
32
+ description?: string;
33
+ }
34
+
35
+ export interface CompletionResult {
36
+ candidates: Candidate[];
37
+ directive: number;
38
+ }
39
+
40
+ /** Provider runner injected by the host CLI (same contract as `__complete`). */
41
+ export type CompletionProviderRunner = (provider: string, partial: string) => Promise<string[]>;
42
+
43
+ interface PathTables {
44
+ subcommandsByPath: Map<string, CommandSpec[]>;
45
+ /** path → flag (long or short) → spec, for both forms. */
46
+ optionsByPath: Map<string, CommandSpec["options"]>;
47
+ positionalsByPath: Map<string, CommandSpec["positionals"]>;
48
+ }
49
+
50
+ function buildTables(root: CommandSpec): PathTables {
51
+ const subcommandsByPath = new Map<string, CommandSpec[]>();
52
+ const optionsByPath = new Map<string, CommandSpec["options"]>();
53
+ const positionalsByPath = new Map<string, CommandSpec["positionals"]>();
54
+ const walk = (s: CommandSpec): void => {
55
+ subcommandsByPath.set(s.path, s.subcommands);
56
+ optionsByPath.set(s.path, s.options);
57
+ positionalsByPath.set(s.path, s.positionals);
58
+ for (const sub of s.subcommands) walk(sub);
59
+ };
60
+ walk(root);
61
+ return { subcommandsByPath, optionsByPath, positionalsByPath };
62
+ }
63
+
64
+ function optionAt(tables: PathTables, path: string, flag: string) {
65
+ const opts = tables.optionsByPath.get(path) ?? [];
66
+ return opts.find((o) => o.long === flag || o.short === flag);
67
+ }
68
+
69
+ function isKnownSubcommand(tables: PathTables, path: string, name: string): boolean {
70
+ return (tables.subcommandsByPath.get(path) ?? []).some((c) => c.name === name);
71
+ }
72
+
73
+ /**
74
+ * Walk the words before the cursor to determine the current command path,
75
+ * skipping options and the values they consume (mirrors the static driver).
76
+ * Returns the resolved command path ("" = root).
77
+ */
78
+ function resolvePath(tables: PathTables, words: string[], cword: number): string {
79
+ let path = "";
80
+ let i = 1; // words[0] is the bin name
81
+ while (i < cword) {
82
+ const w = words[i] ?? "";
83
+ if (w.startsWith("-")) {
84
+ const opt = optionAt(tables, path, w);
85
+ if (opt?.takesValue) i += 1; // skip the option's value
86
+ } else if (isKnownSubcommand(tables, path, w)) {
87
+ path = path ? `${path} ${w}` : w;
88
+ }
89
+ i += 1;
90
+ }
91
+ return path;
92
+ }
93
+
94
+ /** Count positional args consumed at the resolved path (non-option, non-subcommand words). */
95
+ function positionalIndex(tables: PathTables, words: string[], cword: number): number {
96
+ let seen = "";
97
+ let after = 0;
98
+ let j = 1;
99
+ while (j < cword) {
100
+ const w = words[j] ?? "";
101
+ if (w.startsWith("-")) {
102
+ const opt = optionAt(tables, seen, w);
103
+ if (opt?.takesValue) j += 1;
104
+ } else if (isKnownSubcommand(tables, seen, w)) {
105
+ seen = seen ? `${seen} ${w}` : w;
106
+ } else {
107
+ after += 1;
108
+ }
109
+ j += 1;
110
+ }
111
+ return after;
112
+ }
113
+
114
+ /** Resolve the value source (enum / dynamic-provider / file) for a value slot. */
115
+ async function valueCandidates(
116
+ source: { valueChoices?: string[]; dynamicProvider?: string },
117
+ partial: string,
118
+ runProvider: CompletionProviderRunner,
119
+ ): Promise<CompletionResult> {
120
+ if (source.dynamicProvider) {
121
+ try {
122
+ const values = await runProvider(source.dynamicProvider, partial);
123
+ return { candidates: values.map((value) => ({ value })), directive: Directive.Default };
124
+ } catch {
125
+ return { candidates: [], directive: Directive.File };
126
+ }
127
+ }
128
+ if (source.valueChoices && source.valueChoices.length > 0) {
129
+ return {
130
+ candidates: source.valueChoices.map((value) => ({ value })),
131
+ directive: Directive.Default,
132
+ };
133
+ }
134
+ // A value is expected but we have nothing to suggest → let the shell try files.
135
+ return { candidates: [], directive: Directive.File };
136
+ }
137
+
138
+ /**
139
+ * Compute completion candidates for `words` with the cursor at `cword`.
140
+ * `words[0]` is the bin name; `words[cword]` is the (possibly empty) token
141
+ * being completed. The shell shim does the final prefix-filtering against that
142
+ * token, so this returns the full candidate set for the slot.
143
+ */
144
+ export async function resolveCompletions(
145
+ program: Command,
146
+ words: string[],
147
+ cword: number,
148
+ lookup: CompletionContextLookup,
149
+ runProvider: CompletionProviderRunner,
150
+ ): Promise<CompletionResult> {
151
+ const tables = buildTables(walkProgram(program, lookup));
152
+ const path = resolvePath(tables, words, cword);
153
+ const cur = words[cword] ?? "";
154
+ const prev = cword > 0 ? (words[cword - 1] ?? "") : "";
155
+
156
+ // Case 1: previous word is an option that takes a value → complete the value.
157
+ if (prev.startsWith("-")) {
158
+ const opt = optionAt(tables, path, prev);
159
+ if (opt?.takesValue) return valueCandidates(opt, cur, runProvider);
160
+ }
161
+
162
+ // Case 2: completing an option name (cur starts with "-").
163
+ if (cur.startsWith("-")) {
164
+ const opts = tables.optionsByPath.get(path) ?? [];
165
+ const candidates: Candidate[] = [];
166
+ for (const o of opts) {
167
+ if (o.long) candidates.push({ value: o.long, description: o.description });
168
+ if (o.short) candidates.push({ value: o.short, description: o.description });
169
+ }
170
+ return { candidates, directive: Directive.Default };
171
+ }
172
+
173
+ // Case 3: subcommands available at this path → suggest them.
174
+ const subs = tables.subcommandsByPath.get(path) ?? [];
175
+ if (subs.length > 0) {
176
+ return {
177
+ candidates: subs.map((c) => ({ value: c.name, description: c.description })),
178
+ directive: Directive.Default,
179
+ };
180
+ }
181
+
182
+ // Case 4: positional value slot.
183
+ const positionals = tables.positionalsByPath.get(path) ?? [];
184
+ const idx = positionalIndex(tables, words, cword);
185
+ const pos =
186
+ positionals[idx] ??
187
+ (positionals[positionals.length - 1]?.variadic
188
+ ? positionals[positionals.length - 1]
189
+ : undefined);
190
+ if (pos) return valueCandidates(pos, cur, runProvider);
191
+
192
+ // Nothing structured to suggest → file completion.
193
+ return { candidates: [], directive: Directive.File };
194
+ }
195
+
196
+ /** Sentinel prefix for the trailing directive line in the wire protocol. */
197
+ export const DIRECTIVE_PREFIX = "\x1f:";
198
+
199
+ /**
200
+ * Serialize a result for the shell shim: one `value\tdescription` line per
201
+ * candidate, then a final `\x1f:<directive>` line. The \x1f (unit separator)
202
+ * prefix makes the directive line unambiguous against real candidate values.
203
+ */
204
+ export function encodeResult(result: CompletionResult): string {
205
+ const lines = result.candidates.map((c) =>
206
+ c.description ? `${c.value}\t${c.description}` : c.value,
207
+ );
208
+ lines.push(`${DIRECTIVE_PREFIX}${result.directive}`);
209
+ return `${lines.join("\n")}\n`;
210
+ }
@@ -84,7 +84,7 @@ function walkCommand(
84
84
  // The root program represents `harn` itself; its path stays empty so that
85
85
  // top-level subcommands have paths like "anthropic", not "harn anthropic".
86
86
  // The shell driver walks COMP_WORDS[1..], so it's already operating in
87
- // post-`harn` space and shouldn't see "bp" in its lookup keys.
87
+ // post-`harn` space and shouldn't see the host bin name in its lookup keys.
88
88
  const path = isRoot ? "" : parentPath ? `${parentPath} ${name}` : name;
89
89
 
90
90
  const subcommands: CommandSpec[] = cmd.commands