@bigknoxy/hashpilot 4.6.3
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/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { ErrorCode, recordEvent, getRecordedEventCount } from "./telemetry";
|
|
2
|
+
import { wrap, currentCommandName } from "./envelope";
|
|
3
|
+
import { resolveFormat, renderText, OutputFormat } from "./format";
|
|
4
|
+
import { isQuiet } from "./output";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Process exit codes. Agents branch on these, so the numbers are a contract —
|
|
8
|
+
* see docs/ADAPTER-CONTRACT.md. Never renumber an existing code.
|
|
9
|
+
*/
|
|
10
|
+
export enum ExitCode {
|
|
11
|
+
/** Operation succeeded. */
|
|
12
|
+
OK = 0,
|
|
13
|
+
/** Bad invocation: missing/malformed flag, denied path, unsupported operation. Retrying verbatim will not help. */
|
|
14
|
+
USAGE = 1,
|
|
15
|
+
/** The edit itself failed (symbol not found, parse error, ambiguous match). */
|
|
16
|
+
EDIT_FAILED = 2,
|
|
17
|
+
/** A precondition no longer holds (stale anchor, hash mismatch). Agent-retryable after a fresh read. */
|
|
18
|
+
PRECONDITION = 3,
|
|
19
|
+
/** The edit applied but verification (format/lint/test) failed. */
|
|
20
|
+
VERIFY_FAILED = 4,
|
|
21
|
+
/** Filesystem-level failure: file missing, unreadable, or unwritable. */
|
|
22
|
+
IO = 5,
|
|
23
|
+
/** Uncaught internal error — a bug in HashPilot. */
|
|
24
|
+
INTERNAL = 70,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ERROR_CODE_EXITS: Record<string, ExitCode> = {
|
|
28
|
+
[ErrorCode.STALE_ANCHOR]: ExitCode.PRECONDITION,
|
|
29
|
+
[ErrorCode.LOCK_TIMEOUT]: ExitCode.PRECONDITION,
|
|
30
|
+
[ErrorCode.HASH_MISMATCH]: ExitCode.PRECONDITION,
|
|
31
|
+
[ErrorCode.FILE_NOT_FOUND]: ExitCode.IO,
|
|
32
|
+
[ErrorCode.WRITE_FAILED]: ExitCode.IO,
|
|
33
|
+
[ErrorCode.READ_FAILED]: ExitCode.IO,
|
|
34
|
+
[ErrorCode.PATH_DENIED]: ExitCode.USAGE,
|
|
35
|
+
[ErrorCode.INVALID_ARGUMENT]: ExitCode.USAGE,
|
|
36
|
+
[ErrorCode.UNSUPPORTED_OPERATION]: ExitCode.USAGE,
|
|
37
|
+
[ErrorCode.SYMBOL_NOT_FOUND]: ExitCode.EDIT_FAILED,
|
|
38
|
+
[ErrorCode.PARSE_ERROR]: ExitCode.EDIT_FAILED,
|
|
39
|
+
[ErrorCode.DUPLICATE_MATCH]: ExitCode.EDIT_FAILED,
|
|
40
|
+
// "Same name binds more than one symbol in this file" is a failed edit
|
|
41
|
+
// attempt (a precondition the caller didn't meet), not a stale/retryable
|
|
42
|
+
// anchor — so it shares the EDIT_FAILED band with SYMBOL_NOT_FOUND.
|
|
43
|
+
[ErrorCode.AMBIGUOUS_SYMBOL]: ExitCode.EDIT_FAILED,
|
|
44
|
+
[ErrorCode.UNSUPPORTED_LANGUAGE]: ExitCode.EDIT_FAILED,
|
|
45
|
+
// The file is fine and the operation exists; this particular import cannot
|
|
46
|
+
// be written into this particular module system. Same band as
|
|
47
|
+
// UNSUPPORTED_LANGUAGE: the edit failed on a precondition the caller can
|
|
48
|
+
// fix, and retrying the same call verbatim will not help (#139).
|
|
49
|
+
[ErrorCode.MODULE_SYSTEM_MISMATCH]: ExitCode.EDIT_FAILED,
|
|
50
|
+
// The search did not complete, so the edit did not happen for a reason the
|
|
51
|
+
// caller can act on (raise the cap, target a shallower node) — an edit
|
|
52
|
+
// failure, not a retryable precondition (#39).
|
|
53
|
+
[ErrorCode.SEARCH_TRUNCATED]: ExitCode.EDIT_FAILED,
|
|
54
|
+
[ErrorCode.VERIFY_FAILED]: ExitCode.VERIFY_FAILED,
|
|
55
|
+
// A timeout shares the verification band — the edit applied, verification did
|
|
56
|
+
// not conclude — but carries its own error code so an agent can tell "your
|
|
57
|
+
// change broke the tests" from "the suite ran out of time".
|
|
58
|
+
[ErrorCode.VERIFY_TIMEOUT]: ExitCode.VERIFY_FAILED,
|
|
59
|
+
// Same band again: the edit applied, verification produced no verdict. A
|
|
60
|
+
// separate code so an agent can tell "nothing was checked" from "the checks
|
|
61
|
+
// failed" and re-run with the flags it forgot (#106).
|
|
62
|
+
[ErrorCode.VERIFY_NO_CHECKS]: ExitCode.VERIFY_FAILED,
|
|
63
|
+
// Deliberately IO, not VERIFY_FAILED: a half-reverted tree is a filesystem
|
|
64
|
+
// problem the agent must stop and inspect, not a retryable test failure.
|
|
65
|
+
[ErrorCode.ROLLBACK_INCOMPLETE]: ExitCode.IO,
|
|
66
|
+
[ErrorCode.INTERNAL_ERROR]: ExitCode.INTERNAL,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Shape every command result is inspected through. All fields optional — results vary by command. */
|
|
70
|
+
export interface ResultLike {
|
|
71
|
+
success?: boolean;
|
|
72
|
+
passed?: boolean;
|
|
73
|
+
error?: string | { code?: string };
|
|
74
|
+
errorCode?: string;
|
|
75
|
+
stale?: boolean;
|
|
76
|
+
[key: string]: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive an exit code from a command result. Falls back to EDIT_FAILED for an
|
|
81
|
+
* unrecognized failure rather than OK — an unmapped error must never exit 0.
|
|
82
|
+
*/
|
|
83
|
+
export function exitCodeFor(result: ResultLike | ResultLike[] | undefined): ExitCode {
|
|
84
|
+
if (result === undefined) return ExitCode.OK;
|
|
85
|
+
|
|
86
|
+
if (Array.isArray(result)) {
|
|
87
|
+
// Batch: worst (highest) code wins, so a single failure is never masked.
|
|
88
|
+
return result.reduce<ExitCode>((worst, r) => {
|
|
89
|
+
const code = exitCodeFor(r);
|
|
90
|
+
return code > worst ? code : worst;
|
|
91
|
+
}, ExitCode.OK);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Wrapper results (route-edit, batch, plan steps) carry the real outcome in
|
|
95
|
+
// `result`. Ignoring it made a failed edit exit 0.
|
|
96
|
+
if (result.success === undefined && result.passed === undefined && result.error === undefined
|
|
97
|
+
&& result.result && typeof result.result === "object") {
|
|
98
|
+
return exitCodeFor(result.result as ResultLike);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const explicit = result.errorCode ?? (typeof result.error === "object" ? result.error?.code : undefined);
|
|
102
|
+
if (explicit && ERROR_CODE_EXITS[explicit]) return ERROR_CODE_EXITS[explicit];
|
|
103
|
+
|
|
104
|
+
const failed =
|
|
105
|
+
result.success === false ||
|
|
106
|
+
result.passed === false ||
|
|
107
|
+
(result.error !== undefined && result.error !== null);
|
|
108
|
+
if (!failed) return ExitCode.OK;
|
|
109
|
+
|
|
110
|
+
if (result.stale === true) return ExitCode.PRECONDITION;
|
|
111
|
+
return ExitCode.EDIT_FAILED;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ── Output format (#19 B16) ───────────────────────────────────────────── */
|
|
115
|
+
let outputFormat: OutputFormat = "json";
|
|
116
|
+
let currentCommand = "";
|
|
117
|
+
|
|
118
|
+
/** Called once by preAction; sets the global output format + running command name. */
|
|
119
|
+
export function setOutputFormat(fmt: OutputFormat, command: string): void {
|
|
120
|
+
outputFormat = fmt;
|
|
121
|
+
currentCommand = command;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Current output format — used by action handlers to branch on text vs. JSON. */
|
|
125
|
+
export function getOutputFormat(): OutputFormat {
|
|
126
|
+
return outputFormat;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Re-export for cli.ts convenience. */
|
|
130
|
+
export { resolveFormat, renderText };
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Single exit point for every CLI command.
|
|
134
|
+
*
|
|
135
|
+
* #19 (B16): when `outputFormat === "text"` the success payload is rendered as
|
|
136
|
+
* compact human-readable output via per-command renderers. Error payloads are
|
|
137
|
+
* ALWAYS JSON — the API contract (apiVersion 1) is the canonical machine output
|
|
138
|
+
* and must not be degraded. Commands without a renderer fall back to a compact
|
|
139
|
+
* key/value dump; never raw JSON in text mode.
|
|
140
|
+
*/
|
|
141
|
+
// `telemetry *` reads, clears, and prunes the log; recording an event there
|
|
142
|
+
// would grow the very file the command is inspecting and skew its own report.
|
|
143
|
+
// `uninstall` removes the log directory outright.
|
|
144
|
+
const TELEMETRY_EXEMPT_COMMANDS = ["uninstall"];
|
|
145
|
+
|
|
146
|
+
const processStart = Date.now();
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* CLAUDE.md says every command records a telemetry event, but a dozen commands
|
|
150
|
+
* (`capabilities`, `route`, `config`, `undo`, `provenance query`, …) recorded
|
|
151
|
+
* nothing, leaving silent holes in the health report's operation coverage.
|
|
152
|
+
* Rather than thread a recordEvent call through every action and its several
|
|
153
|
+
* finish() call sites, emit a fallback here — the one choke point every command
|
|
154
|
+
* already funnels through — but only when the action recorded nothing itself,
|
|
155
|
+
* so commands with richer events are not double-counted (#51).
|
|
156
|
+
*/
|
|
157
|
+
function recordFallbackEvent(exit: ExitCode): void {
|
|
158
|
+
const command = currentCommandName();
|
|
159
|
+
if (!command) return;
|
|
160
|
+
if (getRecordedEventCount() > 0) return;
|
|
161
|
+
if (command.startsWith("telemetry") || TELEMETRY_EXEMPT_COMMANDS.includes(command)) return;
|
|
162
|
+
recordEvent({
|
|
163
|
+
operation: command,
|
|
164
|
+
route: "other",
|
|
165
|
+
success: exit === ExitCode.OK,
|
|
166
|
+
elapsed_ms: Date.now() - processStart,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function finish(payload: unknown, code?: ExitCode): void {
|
|
171
|
+
const exit = code ?? exitCodeFor(payload as ResultLike);
|
|
172
|
+
recordFallbackEvent(exit);
|
|
173
|
+
// In text mode: success payloads get the compact renderer; errors always emit JSON.
|
|
174
|
+
if (outputFormat === "text" && (payload as { success?: boolean }).success !== false) {
|
|
175
|
+
// `--quiet` drops the human-readable success line. It deliberately does not
|
|
176
|
+
// drop the JSON envelope below: that is the apiVersion 1 contract, and a
|
|
177
|
+
// caller who asked for JSON and got silence cannot tell ok from a crash (#47).
|
|
178
|
+
if (isQuiet()) {
|
|
179
|
+
process.exitCode = exit;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// `wrap()` produces { apiVersion, ok, command, data, ... }; `data` carries
|
|
183
|
+
// the per-command payload. Render `data` when present, else the raw payload.
|
|
184
|
+
const data = (payload as { data?: Record<string, unknown> }).data;
|
|
185
|
+
const rendererTarget = data || (payload as Record<string, unknown>);
|
|
186
|
+
renderText(currentCommand, rendererTarget as Record<string, unknown>);
|
|
187
|
+
process.exitCode = exit;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// JSON path: emit the envelope (apiVersion 1)
|
|
191
|
+
console.log(JSON.stringify(wrap(payload, exit), null, 2));
|
|
192
|
+
process.exitCode = exit;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Emit a usage error and exit 1. For malformed flags and missing required options. */
|
|
196
|
+
export function usageError(message: string, extra: Record<string, unknown> = {}): void {
|
|
197
|
+
finish({ success: false, errorCode: ErrorCode.INVALID_ARGUMENT, message, ...extra }, ExitCode.USAGE);
|
|
198
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output format selection for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* #19 (B16) — a global `--format <json|text>` flag, with TTY-aware defaults:
|
|
5
|
+
* - `--format json` : always JSON (agent default, CI safe)
|
|
6
|
+
* - `--format text` : compact human-readable output (human default, interactive shell)
|
|
7
|
+
* - no `--format` : JSON if stdout is piped/redirected or `$CI` true; otherwise text
|
|
8
|
+
*
|
|
9
|
+
* The JSON envelope remains the canonical machine-readable contract (apiVersion 1);
|
|
10
|
+
* `text` mode is a human convenience layer that must never replace it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { colorizeGlyphs } from "./output";
|
|
14
|
+
|
|
15
|
+
/* ── Output format type ──────────────────────────────────────────────── */
|
|
16
|
+
|
|
17
|
+
export type OutputFormat = "json" | "text";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Single stdout choke point for every renderer (#47). Status glyphs are
|
|
21
|
+
* colorized here — and only here — so no renderer has to know whether color is
|
|
22
|
+
* enabled, and so an escape sequence can never reach a JSON path.
|
|
23
|
+
*/
|
|
24
|
+
function write(text: string): void {
|
|
25
|
+
process.stdout.write(colorizeGlyphs(text));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the output format from an explicit option, CI detection, and TTY state.
|
|
30
|
+
*
|
|
31
|
+
* Precedence:
|
|
32
|
+
* 1. Explicit `--format <json|text>` → wins always.
|
|
33
|
+
* 2. `--json` (deprecated alias for `--format json`) → emits a one-time stderr
|
|
34
|
+
* deprecation warning.
|
|
35
|
+
* 3. `$CI` environment variable is truthy → JSON.
|
|
36
|
+
* 4. stdout is a TTY (interactive shell) → text.
|
|
37
|
+
* 5. Fall back → JSON (safe default for anything not detected above).
|
|
38
|
+
*/
|
|
39
|
+
export function resolveFormat(
|
|
40
|
+
opts: { format?: string; json?: boolean },
|
|
41
|
+
ctx: { isTTY?: boolean; ci?: boolean } = {}
|
|
42
|
+
): { format: OutputFormat; warnDeprecate?: boolean } {
|
|
43
|
+
// 1. explicit --format
|
|
44
|
+
if (opts.format === "json" || opts.format === "text") return { format: opts.format };
|
|
45
|
+
// 2. deprecated --json alias
|
|
46
|
+
if (opts.json === true) return { format: "json", warnDeprecate: true };
|
|
47
|
+
// 3. CI env
|
|
48
|
+
const ci = ctx.ci ?? process.env.CI;
|
|
49
|
+
if (ci === "true" || ci === "1") return { format: "json" };
|
|
50
|
+
// 4. TTY
|
|
51
|
+
const isTTY = ctx.isTTY ?? process.stdout.isTTY;
|
|
52
|
+
if (isTTY) return { format: "text" };
|
|
53
|
+
// 5. default to JSON
|
|
54
|
+
return { format: "json" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ── Text renderers ────────────────────────────────────────────────────
|
|
58
|
+
*
|
|
59
|
+
* Each renderer receives a `ResultPayload` (the `data` field that `wrap()`
|
|
60
|
+
* would produce) and prints a compact, human-readable line to stdout.
|
|
61
|
+
* Rendered lines never carry diagnostic noise — errors go through the same
|
|
62
|
+
* `finish()` path so they emit JSON, not text.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
type ResultPayload = {
|
|
66
|
+
success: boolean;
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Render a result payload as compact text for human readers.
|
|
72
|
+
* Each command registers its own renderer via the `registerRenderer` map;
|
|
73
|
+
* unmatched commands fall back to a generic key/value dump.
|
|
74
|
+
*/
|
|
75
|
+
export function renderText(command: string, payload: ResultPayload): void {
|
|
76
|
+
const renderer = renderers[command];
|
|
77
|
+
if (renderer) {
|
|
78
|
+
renderer(payload);
|
|
79
|
+
} else {
|
|
80
|
+
// Generic fallback: compact key/value dump
|
|
81
|
+
renderGenericDump(payload);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Generic renderer: print a single-line summary of the result.
|
|
87
|
+
*
|
|
88
|
+
* `finish()` only reaches a renderer when `success !== false`, so a payload with
|
|
89
|
+
* no `success` field at all (every read-only command: find-symbols, route,
|
|
90
|
+
* capabilities) is a success. Testing `payload.success` for truthiness instead
|
|
91
|
+
* marked all of them "✗ error" (#132).
|
|
92
|
+
*/
|
|
93
|
+
function renderGenericDump(payload: ResultPayload): void {
|
|
94
|
+
const lines: string[] = [];
|
|
95
|
+
lines.push(payload.success !== false ? "✓ ok" : "✗ " + (String(payload.errorCode || "error")));
|
|
96
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
97
|
+
if (k === "success" || k === "apiVersion" || k === "error") continue;
|
|
98
|
+
if (v === null || v === undefined) continue;
|
|
99
|
+
if (Array.isArray(v)) {
|
|
100
|
+
lines.push(` ${k}: ${v.length} item${v.length === 1 ? "" : "s"}`);
|
|
101
|
+
} else if (typeof v === "object") {
|
|
102
|
+
lines.push(` ${k}: ${summarize(v)}`);
|
|
103
|
+
} else {
|
|
104
|
+
lines.push(` ${k}: ${String(v)}`);
|
|
105
|
+
}
|
|
106
|
+
if (lines.length >= 8) {
|
|
107
|
+
lines.push(" …");
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
write(lines.join("\n") + "\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Compact one-line summary of a value for inline display. */
|
|
115
|
+
function summarize(v: unknown): string {
|
|
116
|
+
if (v === null || v === undefined) return "none";
|
|
117
|
+
if (typeof v === "string") return `"${v.length > 60 ? v.slice(0, 57) + "…" : v}"`;
|
|
118
|
+
if (typeof v === "number") return String(v);
|
|
119
|
+
if (typeof v === "boolean") return v ? "true" : "false";
|
|
120
|
+
if (Array.isArray(v)) return `[${v.length}]`;
|
|
121
|
+
return "...";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ── Per-command renderers ────────────────────────────────────────────── */
|
|
125
|
+
|
|
126
|
+
const renderers: Record<string, (p: ResultPayload) => void> = {};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Register a text renderer for a specific command.
|
|
130
|
+
* Called at module init for each command that has a dedicated renderer.
|
|
131
|
+
*/
|
|
132
|
+
export function registerRenderer(command: string, fn: (p: ResultPayload) => void): void {
|
|
133
|
+
renderers[command] = fn;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ── Common renderer registrations ───────────────────────────────────── */
|
|
137
|
+
|
|
138
|
+
// doctor — a checklist. Reads `status`/`message`, the fields DoctorCheck
|
|
139
|
+
// actually has; the original read `ok`/`detail`, so every check rendered as a
|
|
140
|
+
// nameless failure (#46).
|
|
141
|
+
registerRenderer("doctor", (p) => {
|
|
142
|
+
const checks = (p.checks || []) as { name: string; status: string; message: string; remediation?: string }[];
|
|
143
|
+
const s = (p.summary || {}) as Record<string, number>;
|
|
144
|
+
const glyph: Record<string, string> = { pass: "✓", fail: "✗", warn: "!", skip: "·" };
|
|
145
|
+
write(`HashPilot ${p.version} — ${p.healthy ? "✓ healthy" : "✗ issues found"} (${p.installMode} install)\n`);
|
|
146
|
+
write(` pass ${s.pass ?? 0} fail ${s.fail ?? 0} warn ${s.warn ?? 0} skip ${s.skip ?? 0}\n`);
|
|
147
|
+
for (const c of checks) {
|
|
148
|
+
write(` ${glyph[c.status] || "?"} ${c.name}: ${c.message}\n`);
|
|
149
|
+
if (c.remediation) write(` fix: ${c.remediation}\n`);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// read / read-many — print the first few lines + hash
|
|
154
|
+
registerRenderer("read", (p) => {
|
|
155
|
+
printReadResult(p);
|
|
156
|
+
});
|
|
157
|
+
registerRenderer("read-many", (p) => {
|
|
158
|
+
const results = (p.results || p) as unknown;
|
|
159
|
+
if (Array.isArray(results)) {
|
|
160
|
+
for (const r of results as ResultPayload[]) printReadResult(r);
|
|
161
|
+
} else printReadResult(p);
|
|
162
|
+
});
|
|
163
|
+
registerRenderer("read-hash", (p) => printReadResult(p));
|
|
164
|
+
|
|
165
|
+
/** Shared renderer for any "read" variant: show line count + hash. */
|
|
166
|
+
function printReadResult(p: ResultPayload) {
|
|
167
|
+
if (p.error) {
|
|
168
|
+
write("✗ " + p.error + "\n");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const lines = (p.lines as string[]) || (p.content ? [p.content] : []);
|
|
172
|
+
const n = lines.length;
|
|
173
|
+
const hash = p.hash || p.lineHash || "";
|
|
174
|
+
const filePath = p.file || p.path || "";
|
|
175
|
+
write(
|
|
176
|
+
`${filePath ? basePath(filePath) + ": " : ""}${n} line${n === 1 ? "" : "s"}${hash ? " " + hash.slice(0, 8) : ""}\n`
|
|
177
|
+
);
|
|
178
|
+
if (lines.length > 0 && lines.length <= 5) {
|
|
179
|
+
for (const l of lines) write(" " + l + "\n");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ast-replace / hash-replace / diff-apply — print success + file:line + hash
|
|
184
|
+
registerRenderer("ast-replace", (p) => printEditResult(p));
|
|
185
|
+
registerRenderer("hash-replace", (p) => printEditResult(p));
|
|
186
|
+
registerRenderer("diff-apply", (p) => printEditResult(p));
|
|
187
|
+
registerRenderer("structured-edit", (p) => printEditResult(p));
|
|
188
|
+
registerRenderer("edit-many", (p) => {
|
|
189
|
+
const results = (p.results || []) as ResultPayload[];
|
|
190
|
+
const ok = results.filter((r) => r.success).length;
|
|
191
|
+
write(`${ok}/${results.length} edits succeeded\n`);
|
|
192
|
+
for (const r of results.filter((r) => !r.success)) {
|
|
193
|
+
write("✗ " + basePath(r.file || r.path || "?") + ": " + (r.error || "failed") + "\n");
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Shared renderer for any single edit result. */
|
|
198
|
+
function printEditResult(p: ResultPayload) {
|
|
199
|
+
const f = p.file || p.path || "";
|
|
200
|
+
const route = p.route || "?";
|
|
201
|
+
write(
|
|
202
|
+
`${p.success ? "✓" : "✗"} ${route} ${f ? basePath(f) : ""}${p.line ? ":" + p.line : ""}\n`
|
|
203
|
+
);
|
|
204
|
+
if (!p.success) write(" " + (p.error || p.message || "failed") + "\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// grep / grep-many — print N matches across M files
|
|
208
|
+
registerRenderer("grep", (p) => {
|
|
209
|
+
const r = p.results || p;
|
|
210
|
+
const matches = (r?.matches || []) as unknown[];
|
|
211
|
+
write(`${matches.length} match${matches.length === 1 ? "" : "es"}\n`);
|
|
212
|
+
for (const m of (matches as any[]).slice(0, 10)) {
|
|
213
|
+
write(
|
|
214
|
+
" " + basePath(m.file || m.path || "?") + ":" + (m.line || "?") + " " + truncate(m.content, 60) + "\n"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (matches.length > 10) write(` … ${matches.length - 10} more\n`);
|
|
218
|
+
});
|
|
219
|
+
registerRenderer("grep-many", (p) => {
|
|
220
|
+
const r = p.results || p;
|
|
221
|
+
const matches = (r?.matches || []) as unknown[];
|
|
222
|
+
write(`${matches.length} match${matches.length === 1 ? "" : "es"}\n`);
|
|
223
|
+
for (const m of (matches as any[]).slice(0, 10)) {
|
|
224
|
+
write(
|
|
225
|
+
" " + basePath(m.file || m.path || "?") + ":" + (m.line || "?") + " " + truncate(m.content, 60) + "\n"
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// symbol-lookup
|
|
231
|
+
registerRenderer("symbol-lookup", (p) => {
|
|
232
|
+
const results = (p.results || p.symbols || []) as ResultPayload[];
|
|
233
|
+
write(`${results.length} symbol${results.length === 1 ? "" : "s"}` + "\n");
|
|
234
|
+
for (const r of results.slice(0, 10)) {
|
|
235
|
+
write(
|
|
236
|
+
` ${r.name || r.symbol || "?"} ${r.kind || "?"} ${basePath(r.file || r.path || "")}:${r.line || "?"}\n`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// intent
|
|
242
|
+
registerRenderer("intent", (p) => {
|
|
243
|
+
const plan = p.plan as ResultPayload | undefined;
|
|
244
|
+
if (p.success !== false) {
|
|
245
|
+
write("✓ plan succeeded\n");
|
|
246
|
+
if (plan?.impactSummary) write(" " + plan.impactSummary + "\n");
|
|
247
|
+
} else {
|
|
248
|
+
write("✗ " + (p.errorCode || p.error || "failed") + "\n");
|
|
249
|
+
}
|
|
250
|
+
const unresolved = plan?.unresolved;
|
|
251
|
+
if (Array.isArray(unresolved) && unresolved.length > 0) {
|
|
252
|
+
write(` ${unresolved.length} unresolved item(s):\n`);
|
|
253
|
+
for (const u of unresolved as ResultPayload[]) {
|
|
254
|
+
write(" " + basePath(u.file || "") + ": " + (u.reason || "") + "\n");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// verify
|
|
260
|
+
registerRenderer("verify", (p) => {
|
|
261
|
+
// "no checks ran" is its own line: printing "all checks passed" over an empty
|
|
262
|
+
// check set is exactly the false green of #106.
|
|
263
|
+
if (p.overall === "skipped") write("⚠ no checks ran — nothing was verified\n");
|
|
264
|
+
else if (p.success !== false) write("✓ all checks passed\n");
|
|
265
|
+
else write("✗ " + (p.errorCode || "checks failed") + "\n");
|
|
266
|
+
const checks = (p.checks || p.results || []) as ResultPayload[];
|
|
267
|
+
for (const c of checks.slice(0, 5)) {
|
|
268
|
+
write(` ${c.overall === "pass" || c.success ? "✓" : "✗"} ${c.command || c.name || "?"}\n`);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// verify-changes — the CLI command name; the "verify" renderer above serves the
|
|
273
|
+
// plan/step payloads that embed a list of checks.
|
|
274
|
+
registerRenderer("verify-changes", (p) => {
|
|
275
|
+
const ran = (p.checksRun || []) as string[];
|
|
276
|
+
if (p.overall === "skipped") {
|
|
277
|
+
// Never "all checks passed" over an empty check set — that false green is
|
|
278
|
+
// the whole of #106.
|
|
279
|
+
write("⚠ no checks ran — nothing was verified\n");
|
|
280
|
+
write(" " + String(p.message || "") + "\n");
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const mark = p.overall === "pass" ? "✓" : "✗";
|
|
284
|
+
write(`${mark} ${p.overall} (${ran.length} check${ran.length === 1 ? "" : "s"}: ${ran.join(", ")})\n`);
|
|
285
|
+
for (const name of ran) {
|
|
286
|
+
const run = p[name] as ResultPayload | undefined;
|
|
287
|
+
if (run) write(` ${run.passed ? "✓" : "✗"} ${name}\n`);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// telemetry subcommands
|
|
292
|
+
registerRenderer("telemetry-show", (p) => {
|
|
293
|
+
const events = (p.events || p) as ResultPayload[];
|
|
294
|
+
const arr = Array.isArray(events) ? events : [events];
|
|
295
|
+
write(`${arr.length} event${arr.length === 1 ? "" : "s"}\n`);
|
|
296
|
+
for (const e of arr.slice(0, 10)) {
|
|
297
|
+
write(
|
|
298
|
+
" " +
|
|
299
|
+
(e.operation || "?") +
|
|
300
|
+
" " +
|
|
301
|
+
(e.success ? "✓" : "✗") +
|
|
302
|
+
" " +
|
|
303
|
+
(e.route || "") +
|
|
304
|
+
(e.elapsed_ms ? " " + e.elapsed_ms + "ms" : "") +
|
|
305
|
+
"\n"
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
registerRenderer("telemetry-export", (p) => write("✓ exported " + (p.count || 0) + " event(s)\n"));
|
|
310
|
+
registerRenderer("telemetry-prune", (p) => write("✓ pruned " + (p.count || 0) + " event(s)\n"));
|
|
311
|
+
|
|
312
|
+
// route-query
|
|
313
|
+
registerRenderer("route", (p) => {
|
|
314
|
+
write((p.success !== false ? "✓ " + (p.route || "ok") : "✗ " + (p.errorCode || "routing failed")) + "\n");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ast-capabilities
|
|
318
|
+
registerRenderer("ast-capabilities", (p) => {
|
|
319
|
+
const langs = (p.languages || []) as string[];
|
|
320
|
+
write(`${langs.length} language(s) supported\n`);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// health / telemetry-summary
|
|
324
|
+
registerRenderer("health", (p) => {
|
|
325
|
+
write((p.success !== false ? "✓ " : "✗ ") + (p.message || "") + "\n");
|
|
326
|
+
});
|
|
327
|
+
registerRenderer("telemetry-summary", (p) => {
|
|
328
|
+
write((p.success !== false ? "✓ " : "✗ ") + (p.message || JSON.stringify(Object.keys(p)).slice(0, 120)) + "\n");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ── helpers ────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
function basePath(p: string): string {
|
|
334
|
+
return p.includes("/") ? p.split("/").pop() || p : p;
|
|
335
|
+
}
|
|
336
|
+
function truncate(s: unknown, n: number): string {
|
|
337
|
+
const str = String(s);
|
|
338
|
+
return str.length > n ? str.slice(0, n - 1) + "…" : str;
|
|
339
|
+
}
|