harnery 0.3.2 → 0.5.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 +20 -7
- package/dist/commander.js +2 -2
- package/dist/commands/completion.d.ts.map +1 -1
- package/dist/commands/completion.js +48 -10
- package/dist/commands/deinit.d.ts +51 -0
- package/dist/commands/deinit.d.ts.map +1 -0
- package/dist/commands/{uninstall.js → deinit.js} +83 -14
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +47 -9
- package/dist/commands/init.d.ts +2 -21
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +3 -15
- package/dist/core/agents/render/session-context.d.ts +12 -2
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +75 -36
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +38 -12
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +7 -1
- package/dist/core/config.d.ts +7 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +4 -10
- package/dist/core/hooks/guard-path.d.ts +29 -0
- package/dist/core/hooks/guard-path.d.ts.map +1 -0
- package/dist/core/hooks/guard-path.js +38 -0
- package/dist/core/hooks/harness/wiring.d.ts +85 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -0
- package/dist/core/hooks/harness/wiring.js +137 -0
- package/dist/lib/completion/bash.d.ts +15 -0
- package/dist/lib/completion/bash.d.ts.map +1 -1
- package/dist/lib/completion/bash.js +35 -0
- package/dist/lib/completion/fish.d.ts +10 -0
- package/dist/lib/completion/fish.d.ts.map +1 -1
- package/dist/lib/completion/fish.js +21 -0
- package/dist/lib/completion/index.d.ts +4 -3
- package/dist/lib/completion/index.d.ts.map +1 -1
- package/dist/lib/completion/index.js +4 -3
- package/dist/lib/completion/resolve.d.ts +52 -0
- package/dist/lib/completion/resolve.d.ts.map +1 -0
- package/dist/lib/completion/resolve.js +171 -0
- package/dist/lib/completion/zsh.d.ts +8 -0
- package/dist/lib/completion/zsh.d.ts.map +1 -1
- package/dist/lib/completion/zsh.js +33 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +6 -0
- package/package.json +1 -1
- package/schemas/config.schema.json +4 -0
- package/src/commander.ts +2 -2
- package/src/commands/completion.ts +62 -9
- package/src/commands/{uninstall.ts → deinit.ts} +107 -15
- package/src/commands/doctor.ts +47 -9
- package/src/commands/init.ts +12 -39
- package/src/core/agents/render/session-context.ts +74 -34
- package/src/core/agents/rules/claim-conflict.ts +37 -12
- package/src/core/agents/state/heartbeat-writer.ts +8 -1
- package/src/core/config.ts +21 -0
- package/src/core/hooks/cli.ts +4 -8
- package/src/core/hooks/guard-path.ts +34 -0
- package/src/core/hooks/harness/wiring.ts +185 -0
- package/src/lib/completion/bash.ts +36 -0
- package/src/lib/completion/fish.ts +22 -0
- package/src/lib/completion/index.ts +12 -3
- package/src/lib/completion/resolve.ts +210 -0
- package/src/lib/completion/zsh.ts +34 -0
- package/src/lib/docs-lint.ts +5 -0
- package/dist/commands/uninstall.d.ts +0 -22
- package/dist/commands/uninstall.d.ts.map +0 -1
package/src/core/hooks/cli.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
} from "./effects/index.ts";
|
|
43
43
|
import { emit } from "./events/emit.ts";
|
|
44
44
|
import type { Harness } from "./events/schema.ts";
|
|
45
|
+
import { canonicalize } from "./guard-path.ts";
|
|
45
46
|
import { detectHarness } from "./harness/detect.ts";
|
|
46
47
|
import {
|
|
47
48
|
extractBashCommand,
|
|
@@ -774,7 +775,9 @@ async function runPreToolUseGuard(
|
|
|
774
775
|
harness: Harness,
|
|
775
776
|
): Promise<void> {
|
|
776
777
|
const toolName = (data.tool_name as string | undefined) ?? "";
|
|
777
|
-
const targets = collectGuardTargets(toolName, data)
|
|
778
|
+
const targets = collectGuardTargets(toolName, data)
|
|
779
|
+
.map((p) => canonicalize(coordRoot, p))
|
|
780
|
+
.filter((p): p is string => p !== null);
|
|
778
781
|
if (targets.length === 0) return;
|
|
779
782
|
|
|
780
783
|
const agentCoordBin = join(coordRoot, "harnery", "bin", "agent-coord");
|
|
@@ -822,13 +825,6 @@ async function runPreToolUseGuard(
|
|
|
822
825
|
/** Canonicalize a path to monorepo-relative form. Absolute paths under
|
|
823
826
|
* coordRoot get the prefix stripped; relative paths pass through (assumed
|
|
824
827
|
* already canonical). */
|
|
825
|
-
function canonicalize(coordRoot: string, p: string): string {
|
|
826
|
-
if (!p) return p;
|
|
827
|
-
if (p.startsWith(`${coordRoot}/`)) return p.slice(coordRoot.length + 1);
|
|
828
|
-
if (p === coordRoot) return ".";
|
|
829
|
-
return p;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
828
|
/** Pull the candidate path(s) out of a write-tool payload. Empty array when
|
|
833
829
|
* the tool isn't a write or no path could be derived. */
|
|
834
830
|
function collectGuardTargets(toolName: string, data: Record<string, unknown>): string[] {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only harness-hook wiring inspection — the inverse of `harn init`'s
|
|
3
|
+
* writer (commands/init.ts `wireHooks`). Compares what `init` would wire
|
|
4
|
+
* (HARNESS_SPECS) against what's actually present in a project's harness
|
|
5
|
+
* settings file, so `harn doctor` and the SessionStart nudge can tell an agent
|
|
6
|
+
* when a harnery upgrade changed the hook set but the project hasn't been
|
|
7
|
+
* re-wired yet.
|
|
8
|
+
*
|
|
9
|
+
* The shared types + matcher live here (not in init.ts) so the writer, the
|
|
10
|
+
* doctor check, and the session-start renderer all agree on what "wired" means
|
|
11
|
+
* — there's exactly one definition of the `agent-hook <subcommand>` match.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
HARNESS_SPECS,
|
|
19
|
+
type HarnessId,
|
|
20
|
+
type HarnessSpec,
|
|
21
|
+
type HookEntryShape,
|
|
22
|
+
type HookEvent,
|
|
23
|
+
} from "./events.ts";
|
|
24
|
+
|
|
25
|
+
/** Claude Code + Codex entry: `{ hooks: [{ type, command }] }`. */
|
|
26
|
+
export interface ClaudeHookGroup {
|
|
27
|
+
matcher?: string;
|
|
28
|
+
hooks: { type: string; command: string }[];
|
|
29
|
+
}
|
|
30
|
+
/** Cursor entry: a flat `{ command }`. */
|
|
31
|
+
export interface CursorHookGroup {
|
|
32
|
+
command: string;
|
|
33
|
+
type?: string;
|
|
34
|
+
matcher?: string;
|
|
35
|
+
}
|
|
36
|
+
export type HookGroup = ClaudeHookGroup | CursorHookGroup;
|
|
37
|
+
|
|
38
|
+
export interface SettingsFile {
|
|
39
|
+
version?: number;
|
|
40
|
+
hooks?: Record<string, HookGroup[]>;
|
|
41
|
+
[k: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build a hook entry in the harness's shape. */
|
|
45
|
+
export function makeEntry(shape: HookEntryShape, command: string): HookGroup {
|
|
46
|
+
return shape === "cursor" ? { command } : { hooks: [{ type: "command", command }] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pull every command string out of a hook entry, regardless of shape. */
|
|
50
|
+
export function groupCommands(group: HookGroup): string[] {
|
|
51
|
+
if ("command" in group && typeof group.command === "string") return [group.command];
|
|
52
|
+
if ("hooks" in group && Array.isArray(group.hooks)) {
|
|
53
|
+
return group.hooks.map((h) => h.command).filter((c): c is string => typeof c === "string");
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a hook command string wires the given agent-hook subcommand. The
|
|
60
|
+
* trailing space is load-bearing: it keeps `stop` from matching `stop-failure`.
|
|
61
|
+
*/
|
|
62
|
+
export function commandWiresSubcommand(command: string, subcommand: string): boolean {
|
|
63
|
+
return command.includes(`agent-hook ${subcommand} `);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pull the agent-hook subcommand out of a command string, or null if none. */
|
|
67
|
+
function commandSubcommand(command: string): string | null {
|
|
68
|
+
const m = command.match(/agent-hook\s+([a-z][a-z-]*)\s/);
|
|
69
|
+
return m ? m[1]! : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface WiringDiff {
|
|
73
|
+
/** Spec events not wired in the settings file. */
|
|
74
|
+
missing: HookEvent[];
|
|
75
|
+
/** Spec events already wired. */
|
|
76
|
+
present: HookEvent[];
|
|
77
|
+
/**
|
|
78
|
+
* agent-hook subcommands wired in the file that are NOT in the current spec
|
|
79
|
+
* (e.g. an event renamed/removed by an upgrade). Additive re-init won't clean
|
|
80
|
+
* these — they need explicit removal — so they're surfaced separately.
|
|
81
|
+
*/
|
|
82
|
+
orphans: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure diff of one settings object against one harness spec. Read-only inverse
|
|
87
|
+
* of `wireHooks`; no fs, so it's unit-testable.
|
|
88
|
+
*/
|
|
89
|
+
export function diffWiring(settings: SettingsFile, spec: HarnessSpec): WiringDiff {
|
|
90
|
+
const missing: HookEvent[] = [];
|
|
91
|
+
const present: HookEvent[] = [];
|
|
92
|
+
const hooks = settings.hooks ?? {};
|
|
93
|
+
|
|
94
|
+
for (const event of spec.events) {
|
|
95
|
+
const groups = hooks[event.settingsKey] ?? [];
|
|
96
|
+
const wired = groups.some((g) =>
|
|
97
|
+
groupCommands(g).some((c) => commandWiresSubcommand(c, event.subcommand)),
|
|
98
|
+
);
|
|
99
|
+
(wired ? present : missing).push(event);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const specSubcommands = new Set(spec.events.map((e) => e.subcommand));
|
|
103
|
+
const orphans = new Set<string>();
|
|
104
|
+
for (const groups of Object.values(hooks)) {
|
|
105
|
+
if (!Array.isArray(groups)) continue;
|
|
106
|
+
for (const g of groups) {
|
|
107
|
+
for (const c of groupCommands(g)) {
|
|
108
|
+
const sub = commandSubcommand(c);
|
|
109
|
+
if (sub && !specSubcommands.has(sub)) orphans.add(sub);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { missing, present, orphans: [...orphans].sort() };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface HarnessWiringStatus {
|
|
118
|
+
harness: HarnessId;
|
|
119
|
+
/** Settings file path, relative to the project root. */
|
|
120
|
+
settingsFile: string;
|
|
121
|
+
missing: HookEvent[];
|
|
122
|
+
orphans: string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Inspect every harness whose settings file exists under `projectRoot` and
|
|
127
|
+
* return only those with *drift*. Read-only; never writes.
|
|
128
|
+
*
|
|
129
|
+
* Drift is reported only for a harness the project has **already opted into** —
|
|
130
|
+
* i.e. at least one harnery hook is already wired. A settings file with zero
|
|
131
|
+
* harnery hooks just means this harness isn't harnery-wired here (a bare
|
|
132
|
+
* `.claude/settings.json` is a generic Claude Code file); that's `harn init`'s
|
|
133
|
+
* job to surface on first run, not drift to nag about every session. A harness
|
|
134
|
+
* with no settings file at all, or an unparseable one, is skipped.
|
|
135
|
+
*/
|
|
136
|
+
export function loadHarnessWiring(projectRoot: string): HarnessWiringStatus[] {
|
|
137
|
+
const out: HarnessWiringStatus[] = [];
|
|
138
|
+
for (const [id, spec] of Object.entries(HARNESS_SPECS) as [HarnessId, HarnessSpec][]) {
|
|
139
|
+
const settingsPath = resolve(projectRoot, spec.settingsFile);
|
|
140
|
+
if (!existsSync(settingsPath)) continue;
|
|
141
|
+
let settings: SettingsFile;
|
|
142
|
+
try {
|
|
143
|
+
settings = JSON.parse(readFileSync(settingsPath, "utf8")) as SettingsFile;
|
|
144
|
+
} catch {
|
|
145
|
+
// Unparseable settings file: can't tell opt-in from noise, and the
|
|
146
|
+
// harness itself will complain about its own malformed config. Skip.
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const diff = diffWiring(settings, spec);
|
|
150
|
+
if (diff.present.length === 0) continue; // not opted in → not drift
|
|
151
|
+
if (diff.missing.length === 0 && diff.orphans.length === 0) continue; // current
|
|
152
|
+
out.push({
|
|
153
|
+
harness: id,
|
|
154
|
+
settingsFile: spec.settingsFile,
|
|
155
|
+
missing: diff.missing,
|
|
156
|
+
orphans: diff.orphans,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the harnery package version for context in nudges/checks. Walks up
|
|
164
|
+
* from this module to the package root (works under Bun from `src/` and Node
|
|
165
|
+
* from `dist/`). Returns "" if unresolved — callers omit it from the message.
|
|
166
|
+
*/
|
|
167
|
+
export function harneryVersion(): string {
|
|
168
|
+
try {
|
|
169
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
170
|
+
for (let i = 0; i < 8; i++) {
|
|
171
|
+
try {
|
|
172
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
173
|
+
if (pkg.name === "harnery" && typeof pkg.version === "string") return pkg.version;
|
|
174
|
+
} catch {
|
|
175
|
+
/* no package.json here, or not ours; keep walking up */
|
|
176
|
+
}
|
|
177
|
+
const parent = dirname(dir);
|
|
178
|
+
if (parent === dir) break;
|
|
179
|
+
dir = parent;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
/* import.meta.url unavailable or fs error */
|
|
183
|
+
}
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -142,6 +142,40 @@ function truncate(s: string, n: number): string {
|
|
|
142
142
|
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* DYNAMIC zsh completion: a thin, tree-independent shim that calls
|
|
147
|
+
* `<bin> __complete-line` on every <Tab> (see bash.ts generateBashDynamic for
|
|
148
|
+
* the rationale). Candidate lines are `value\tdescription`; the shim converts
|
|
149
|
+
* the tab to `:` so `_describe` renders descriptions. The trailing `\x1f:<n>`
|
|
150
|
+
* line carries the directive (bit 0 = file fallback → `_files`).
|
|
151
|
+
*/
|
|
152
|
+
export function generateZshDynamic(binName: string): string {
|
|
153
|
+
const fn = fnPrefix(binName);
|
|
154
|
+
return `#compdef ${binName}
|
|
155
|
+
# ${binName} zsh completion (dynamic; install once, never stale). Do not edit.
|
|
156
|
+
${fn}() {
|
|
157
|
+
emulate -L zsh
|
|
158
|
+
local line directive=0
|
|
159
|
+
local -a raw display
|
|
160
|
+
raw=( "\${(@f)$(${binName} __complete-line $((CURRENT-1)) -- "\${words[@]}" 2>/dev/null)}" )
|
|
161
|
+
for line in $raw; do
|
|
162
|
+
[ -z "$line" ] && continue
|
|
163
|
+
if [[ "$line" == $'\\x1f:'* ]]; then
|
|
164
|
+
directive="\${line#$'\\x1f:'}"
|
|
165
|
+
else
|
|
166
|
+
display+=( "\${line//$'\\t'/:}" )
|
|
167
|
+
fi
|
|
168
|
+
done
|
|
169
|
+
if (( directive & 1 )); then
|
|
170
|
+
_files
|
|
171
|
+
return
|
|
172
|
+
fi
|
|
173
|
+
_describe -t values 'completions' display
|
|
174
|
+
}
|
|
175
|
+
compdef ${fn} ${binName}
|
|
176
|
+
`;
|
|
177
|
+
}
|
|
178
|
+
|
|
145
179
|
/**
|
|
146
180
|
* The driver walks `$words` to determine the current command path, then
|
|
147
181
|
* dispatches to subcommand / option / value completion. `fn` is the
|
package/src/lib/docs-lint.ts
CHANGED
|
@@ -246,6 +246,11 @@ function checkNamingConvention(repoName: string, _repoPath: string, files: strin
|
|
|
246
246
|
const name = basename(rel);
|
|
247
247
|
// Allowlisted names
|
|
248
248
|
if (ROOT_FILE_ALLOWLIST.has(name)) continue;
|
|
249
|
+
// Leading-underscore files are deliberate templates / meta files
|
|
250
|
+
// (e.g. _template.md), not content. The underscore is a convention
|
|
251
|
+
// marking "copy me, don't read me as a real doc", so exempt them
|
|
252
|
+
// from naming discipline rather than forcing kebab-case.
|
|
253
|
+
if (name.startsWith("_")) continue;
|
|
249
254
|
// Dated files (audits/issues)
|
|
250
255
|
if (DATED_FILE_PATTERN.test(name)) continue;
|
|
251
256
|
// Changelogs
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `harn uninstall`: reverse what `harn init` wired into a project.
|
|
3
|
-
*
|
|
4
|
-
* `init` makes two kinds of change outside the harnery package:
|
|
5
|
-
* 1. Merges `agent-hook` entries into the harness settings file
|
|
6
|
-
* (Claude Code `.claude/settings.json`, Cursor `.cursor/hooks.json`, or
|
|
7
|
-
* Codex `.codex/hooks.json`).
|
|
8
|
-
* 2. Creates the `.harnery/` coord root (runtime state: events, councils,
|
|
9
|
-
* identities, scratch) and stamps the host bin name into
|
|
10
|
-
* `.harnery/config.jsonc`.
|
|
11
|
-
*
|
|
12
|
-
* `uninstall` undoes (1) by default: it removes only harnery's hook entries from
|
|
13
|
-
* the settings file, preserving any other hooks the consumer added, and deletes
|
|
14
|
-
* the settings file outright when it's left harnery-only. It does NOT touch the
|
|
15
|
-
* `.harnery/` coord root unless `--purge-state` is passed, because that directory
|
|
16
|
-
* holds session history a consumer may want to keep. Idempotent + `--dry-run`,
|
|
17
|
-
* mirroring `init`.
|
|
18
|
-
*/
|
|
19
|
-
import type { Command } from "commander";
|
|
20
|
-
import type { EmitContext } from "../commander.js";
|
|
21
|
-
export declare function registerUninstallCommand(program: Command, emit: EmitContext): void;
|
|
22
|
-
//# sourceMappingURL=uninstall.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"uninstall.d.ts","sourceRoot":"","sources":["../../src/commands/uninstall.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAKH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAWnD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CAoFlF"}
|