@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,303 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { loadConfig } from "./config";
|
|
4
|
+
import { diskUsage, DISK_WARN_BYTES } from "./telemetry";
|
|
5
|
+
import { probeParsers } from "./ast-edit";
|
|
6
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
7
|
+
|
|
8
|
+
const HOME = process.env.HOME || "/root";
|
|
9
|
+
const AGENTIC_TOOLS = join(HOME, ".agentic-tools");
|
|
10
|
+
const CORE_DIR = join(AGENTIC_TOOLS, "structured-editing");
|
|
11
|
+
const BIN_DIR = join(AGENTIC_TOOLS, "bin");
|
|
12
|
+
const CLI_LAUNCHER = join(BIN_DIR, "hashpilot");
|
|
13
|
+
const LOG_DIR = join(AGENTIC_TOOLS, "logs");
|
|
14
|
+
const MANIFEST = join(AGENTIC_TOOLS, "manifest.json");
|
|
15
|
+
const CONFIG_DIR = join(HOME, ".config", "hashpilot");
|
|
16
|
+
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
17
|
+
const CLAUDE_FILE = join(HOME, ".claude", "CLAUDE.md");
|
|
18
|
+
const OPENCODE_SKILL = join(HOME, ".config", "opencode", "skills", "hashpilot", "SKILL.md");
|
|
19
|
+
const OPENCODE_AGENT = join(HOME, ".config", "opencode", "agent", "hashpilot.md");
|
|
20
|
+
const PI_EXTENSION = join(HOME, ".pi", "agent", "extensions", "hashpilot.ts");
|
|
21
|
+
const PI_SKILL = join(HOME, ".pi", "agent", "skills", "hashpilot", "SKILL.md");
|
|
22
|
+
|
|
23
|
+
export interface DoctorCheck {
|
|
24
|
+
name: string;
|
|
25
|
+
status: "pass" | "fail" | "warn" | "skip";
|
|
26
|
+
message: string;
|
|
27
|
+
/** Exact command that fixes this check. Required on every `fail` (#46). */
|
|
28
|
+
remediation?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How this HashPilot is being run. The `~/.agentic-tools` layout checks only
|
|
33
|
+
* mean something for an `installed` copy — reporting them as failures when a
|
|
34
|
+
* contributor runs `bun run src/cli.ts doctor` from a checkout, or when a user
|
|
35
|
+
* installed via `npm i -g`, is a false alarm that makes the exit code useless
|
|
36
|
+
* as a gate (#46).
|
|
37
|
+
*/
|
|
38
|
+
export type InstallMode = "installed" | "source" | "package";
|
|
39
|
+
|
|
40
|
+
export interface DoctorReport {
|
|
41
|
+
checks: DoctorCheck[];
|
|
42
|
+
/** True when no check failed. Warnings and skips do not make an install unhealthy. */
|
|
43
|
+
healthy: boolean;
|
|
44
|
+
timestamp: string;
|
|
45
|
+
version: string;
|
|
46
|
+
installMode: InstallMode;
|
|
47
|
+
summary: { pass: number; fail: number; warn: number; skip: number };
|
|
48
|
+
versions: Record<string, string>;
|
|
49
|
+
parsers: { lang: string; loaded: boolean; error?: string }[];
|
|
50
|
+
configPaths: { global: string; project: string; inUse: string[] };
|
|
51
|
+
/**
|
|
52
|
+
* 0 healthy · 1 warnings only · 2 one or more failures. Doctor is meant to be
|
|
53
|
+
* a CI gate and the installer's final verification step, so the health has to
|
|
54
|
+
* reach the shell (#46).
|
|
55
|
+
*/
|
|
56
|
+
exitCode: 0 | 1 | 2;
|
|
57
|
+
errorCode?: string;
|
|
58
|
+
message?: string;
|
|
59
|
+
recovery?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const HASH_VERSION: string = pkg.version;
|
|
63
|
+
const CLAUDE_MARKER = "HashPilot Claude — Structured Editing Integration";
|
|
64
|
+
|
|
65
|
+
function checkFile(path: string, label: string, remediation?: string): DoctorCheck {
|
|
66
|
+
if (existsSync(path)) {
|
|
67
|
+
return { name: label, status: "pass", message: `Found: ${path}` };
|
|
68
|
+
}
|
|
69
|
+
return { name: label, status: "fail", message: `Missing: ${path}`, remediation };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const checkDir = checkFile;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A check that only applies to an installed layout. Outside one it reports
|
|
76
|
+
* `skip` with the reason, so `bun run src/cli.ts doctor` in a checkout and
|
|
77
|
+
* `hashpilot doctor` from an npm install both stay exit 0 (#46).
|
|
78
|
+
*/
|
|
79
|
+
function whenInstalled(mode: InstallMode, label: string, run: () => DoctorCheck): DoctorCheck {
|
|
80
|
+
if (mode === "installed") return run();
|
|
81
|
+
return {
|
|
82
|
+
name: label,
|
|
83
|
+
status: "skip",
|
|
84
|
+
message: mode === "source"
|
|
85
|
+
? "Running from a source checkout — installed layout not expected"
|
|
86
|
+
: "Running from an npm package — ~/.agentic-tools layout not expected",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* An adapter integration that is simply not installed is not a broken
|
|
92
|
+
* HashPilot. Nobody has every agent host on one machine, so a missing Pi
|
|
93
|
+
* extension used to fail an otherwise-perfect install (#46).
|
|
94
|
+
*/
|
|
95
|
+
function checkOptionalFile(path: string, label: string, what: string): DoctorCheck {
|
|
96
|
+
if (existsSync(path)) return { name: label, status: "pass", message: `Found: ${path}` };
|
|
97
|
+
return { name: label, status: "skip", message: `${what} not installed` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where this HashPilot is running from. `installed` means the script lives
|
|
102
|
+
* under `~/.agentic-tools`; `package` means a node_modules tree (npm install);
|
|
103
|
+
* anything else is a working checkout.
|
|
104
|
+
*/
|
|
105
|
+
export function detectInstallMode(dir: string = import.meta.dir): InstallMode {
|
|
106
|
+
if (dir.startsWith(AGENTIC_TOOLS + "/")) return "installed";
|
|
107
|
+
if (dir.includes("/node_modules/")) return "package";
|
|
108
|
+
return existsSync(CORE_DIR) ? "installed" : "source";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function checkWritable(path: string, label: string): DoctorCheck {
|
|
112
|
+
try {
|
|
113
|
+
if (!existsSync(path)) {
|
|
114
|
+
mkdirSync(path, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
const testFile = join(path, `.doctor-write-test-${Date.now()}`);
|
|
117
|
+
writeFileSync(testFile, "");
|
|
118
|
+
try { rmSync(testFile); } catch {}
|
|
119
|
+
return { name: label, status: "pass", message: `Writable: ${path}` };
|
|
120
|
+
} catch {
|
|
121
|
+
return { name: label, status: "fail", message: `Not writable: ${path}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function doctor(): DoctorReport {
|
|
126
|
+
const checks: DoctorCheck[] = [];
|
|
127
|
+
const timestamp = new Date().toISOString();
|
|
128
|
+
const installMode = detectInstallMode();
|
|
129
|
+
const install = (label: string, run: () => DoctorCheck) => checks.push(whenInstalled(installMode, label, run));
|
|
130
|
+
|
|
131
|
+
// 1-3. Installed layout: core files, launcher, PATH, and a live CLI probe.
|
|
132
|
+
install("core-directory", () => checkDir(CORE_DIR, "core-directory", "Reinstall: curl -fsSL https://raw.githubusercontent.com/bigknoxy/HashPilot/main/scripts/install.sh | bash"));
|
|
133
|
+
install("core-cli.ts", () => checkFile(join(CORE_DIR, "src", "cli.ts"), "core-cli.ts", "Reinstall HashPilot"));
|
|
134
|
+
install("core-package.json", () => checkFile(join(CORE_DIR, "package.json"), "core-package.json", "Reinstall HashPilot"));
|
|
135
|
+
install("cli-launcher", () => checkFile(CLI_LAUNCHER, "cli-launcher", "Run `bun run install-cli`"));
|
|
136
|
+
install("bin-on-path", checkPathEntry);
|
|
137
|
+
install("cli-executable", checkCLIExecutable);
|
|
138
|
+
|
|
139
|
+
// 4. Config
|
|
140
|
+
checks.push(...checkConfig());
|
|
141
|
+
|
|
142
|
+
// 5. Agent host integrations — optional by nature.
|
|
143
|
+
checks.push(checkClaudeIntegration());
|
|
144
|
+
checks.push(checkOptionalFile(OPENCODE_SKILL, "opencode-skill", "OpenCode"));
|
|
145
|
+
checks.push(checkOptionalFile(OPENCODE_AGENT, "opencode-agent", "OpenCode"));
|
|
146
|
+
checks.push(checkOptionalFile(PI_EXTENSION, "pi-extension", "Pi"));
|
|
147
|
+
checks.push(checkOptionalFile(PI_SKILL, "pi-skill", "Pi"));
|
|
148
|
+
|
|
149
|
+
// 6. Telemetry store
|
|
150
|
+
checks.push(checkWritable(LOG_DIR, "telemetry-writable"));
|
|
151
|
+
checks.push(checkTelemetrySize());
|
|
152
|
+
install("manifest", () => checkFile(MANIFEST, "manifest", "Reinstall HashPilot"));
|
|
153
|
+
|
|
154
|
+
// 7. tree-sitter bindings. `getParser` swallows load errors and the router
|
|
155
|
+
// silently downgrades AST -> diff, so this is the only place a broken native
|
|
156
|
+
// build is visible before edit quality quietly drops (#46).
|
|
157
|
+
const parsers = probeParsers();
|
|
158
|
+
const broken = parsers.filter((p) => !p.loaded);
|
|
159
|
+
checks.push(
|
|
160
|
+
broken.length === 0
|
|
161
|
+
? { name: "ast-parsers", status: "pass", message: `All ${parsers.length} tree-sitter parsers load` }
|
|
162
|
+
: {
|
|
163
|
+
name: "ast-parsers",
|
|
164
|
+
status: "fail",
|
|
165
|
+
message: `${broken.length}/${parsers.length} parsers failed to load: ${broken.map((p) => `${p.lang} (${p.error})`).join(", ")}`,
|
|
166
|
+
remediation: "Run `bun install` to rebuild the tree-sitter native bindings",
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const summary = {
|
|
171
|
+
pass: checks.filter((c) => c.status === "pass").length,
|
|
172
|
+
fail: checks.filter((c) => c.status === "fail").length,
|
|
173
|
+
warn: checks.filter((c) => c.status === "warn").length,
|
|
174
|
+
skip: checks.filter((c) => c.status === "skip").length,
|
|
175
|
+
};
|
|
176
|
+
// A skip is "does not apply here", not "broken". Requiring every check to
|
|
177
|
+
// pass marked a perfectly good install unhealthy whenever the user had no
|
|
178
|
+
// config file or no Pi (#46).
|
|
179
|
+
const healthy = summary.fail === 0;
|
|
180
|
+
const exitCode: 0 | 1 | 2 = summary.fail > 0 ? 2 : summary.warn > 0 ? 1 : 0;
|
|
181
|
+
|
|
182
|
+
const failed = checks.filter((c) => c.status === "fail");
|
|
183
|
+
return {
|
|
184
|
+
checks,
|
|
185
|
+
healthy,
|
|
186
|
+
timestamp,
|
|
187
|
+
version: HASH_VERSION,
|
|
188
|
+
installMode,
|
|
189
|
+
summary,
|
|
190
|
+
versions: { hashpilot: HASH_VERSION, bun: Bun.version, node: process.versions.node },
|
|
191
|
+
parsers,
|
|
192
|
+
configPaths: {
|
|
193
|
+
global: CONFIG_FILE,
|
|
194
|
+
project: join(process.cwd(), ".hashpilot.json"),
|
|
195
|
+
inUse: [CONFIG_FILE, join(process.cwd(), ".hashpilot.json")].filter((p) => existsSync(p)),
|
|
196
|
+
},
|
|
197
|
+
exitCode,
|
|
198
|
+
...(failed.length > 0
|
|
199
|
+
? {
|
|
200
|
+
errorCode: "DOCTOR_FAILED",
|
|
201
|
+
message: `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(", ")}`,
|
|
202
|
+
recovery: failed.find((c) => c.remediation)?.remediation,
|
|
203
|
+
}
|
|
204
|
+
: {}),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Report the telemetry store's size. Retention is enforced automatically, but
|
|
210
|
+
* a machine that was never pruned by an older version — or one running with a
|
|
211
|
+
* long `retentionDays` — can still be carrying hundreds of megabytes nobody
|
|
212
|
+
* knows about (#50). A warn, never a fail: a large log is not a broken install.
|
|
213
|
+
*/
|
|
214
|
+
function checkTelemetrySize(): DoctorCheck {
|
|
215
|
+
const bytes = diskUsage();
|
|
216
|
+
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
|
217
|
+
if (bytes > DISK_WARN_BYTES) {
|
|
218
|
+
return {
|
|
219
|
+
name: "telemetry-size",
|
|
220
|
+
status: "warn",
|
|
221
|
+
message: `Telemetry store is ${mb} MB (threshold ${(DISK_WARN_BYTES / (1024 * 1024)).toFixed(0)} MB) — run 'hashpilot telemetry prune' or lower telemetry.retentionDays`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { name: "telemetry-size", status: "pass", message: `Telemetry store is ${mb} MB` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The launcher existing is not the same as it being runnable. `install-cli`
|
|
229
|
+
* used to create the symlink and stop, leaving `hashpilot` unresolvable in a
|
|
230
|
+
* fresh shell while every other check passed. Report that as its own failure
|
|
231
|
+
* with the exact line to add.
|
|
232
|
+
*/
|
|
233
|
+
export function checkPathEntry(): DoctorCheck {
|
|
234
|
+
const entries = (process.env.PATH || "").split(":").filter(Boolean);
|
|
235
|
+
if (entries.includes(BIN_DIR)) {
|
|
236
|
+
return { name: "bin-on-path", status: "pass", message: `${BIN_DIR} is on PATH` };
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
name: "bin-on-path",
|
|
240
|
+
status: "fail",
|
|
241
|
+
message: `${BIN_DIR} is not on PATH. Run \`bun run install-cli\`, or add: export PATH="$HOME/.agentic-tools/bin:$PATH"`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function checkCLIExecutable(): DoctorCheck {
|
|
246
|
+
try {
|
|
247
|
+
// Invoke the launcher by absolute path: this check is about whether the
|
|
248
|
+
// CLI runs at all. PATH resolution is `checkPathEntry`'s job, and injecting
|
|
249
|
+
// BIN_DIR here is what used to hide a broken install.
|
|
250
|
+
const proc = Bun.spawnSync([CLI_LAUNCHER, "--version"]);
|
|
251
|
+
if (proc.exitCode === 0) {
|
|
252
|
+
return { name: "cli-executable", status: "pass", message: `CLI works: ${proc.stdout.toString().trim()}` };
|
|
253
|
+
}
|
|
254
|
+
return { name: "cli-executable", status: "fail", message: `CLI exited with code ${proc.exitCode}: ${proc.stderr.toString().trim()}` };
|
|
255
|
+
} catch (e: any) {
|
|
256
|
+
return { name: "cli-executable", status: "fail", message: `Cannot run CLI: ${e.message}` };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function checkConfig(): DoctorCheck[] {
|
|
261
|
+
const results: DoctorCheck[] = [];
|
|
262
|
+
const cfgExists = existsSync(CONFIG_FILE);
|
|
263
|
+
if (cfgExists) {
|
|
264
|
+
results.push(checkFile(CONFIG_FILE, "config-file"));
|
|
265
|
+
try {
|
|
266
|
+
const cfg = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
267
|
+
results.push({ name: "config-parseable", status: "pass", message: "Config is valid JSON" });
|
|
268
|
+
if (cfg.telemetry && typeof cfg.telemetry.enabled !== "boolean") {
|
|
269
|
+
results.push({ name: "config-telemetry-type", status: "warn", message: "telemetry.enabled should be boolean" });
|
|
270
|
+
}
|
|
271
|
+
if (cfg.routePolicy) {
|
|
272
|
+
results.push({ name: "config-has-policy", status: "pass", message: "Route policy configured" });
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
results.push({ name: "config-parseable", status: "fail", message: "Config is not valid JSON" });
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
results.push({ name: "config-file", status: "skip", message: "No config file — using defaults" });
|
|
279
|
+
}
|
|
280
|
+
// Verify loadConfig() works regardless
|
|
281
|
+
try {
|
|
282
|
+
const cfg = loadConfig();
|
|
283
|
+
results.push({ name: "config-loadable", status: "pass", message: "Config defaults load correctly" });
|
|
284
|
+
} catch {
|
|
285
|
+
results.push({ name: "config-loadable", status: "fail", message: "Cannot load config" });
|
|
286
|
+
}
|
|
287
|
+
return results;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function checkClaudeIntegration(): DoctorCheck {
|
|
291
|
+
if (!existsSync(CLAUDE_FILE)) {
|
|
292
|
+
return { name: "claude-integration", status: "skip", message: "Claude CLAUDE.md not found — not installed" };
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
const content = readFileSync(CLAUDE_FILE, "utf-8");
|
|
296
|
+
if (content.includes(CLAUDE_MARKER)) {
|
|
297
|
+
return { name: "claude-integration", status: "pass", message: "HashPilot section found in CLAUDE.md" };
|
|
298
|
+
}
|
|
299
|
+
return { name: "claude-integration", status: "warn", message: "CLAUDE.md exists but HashPilot section missing" };
|
|
300
|
+
} catch {
|
|
301
|
+
return { name: "claude-integration", status: "fail", message: "Cannot read CLAUDE.md" };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-fidelity for file content (issue #30).
|
|
3
|
+
*
|
|
4
|
+
* A structured-editing tool has exactly one non-negotiable property: it must
|
|
5
|
+
* not change bytes it was not asked to change. Reading a file with
|
|
6
|
+
* `.split("\n")` and writing it back with `.join("\n")` breaks that three ways
|
|
7
|
+
* — it deletes `\r` from every line of a CRLF file, folds a BOM into line 1
|
|
8
|
+
* where it corrupts that line's hash, and drops or invents a trailing newline.
|
|
9
|
+
* The result is a one-line edit that produces a diff touching every line.
|
|
10
|
+
*
|
|
11
|
+
* The fix is to normalize at the boundary: `decodeText` strips the BOM and
|
|
12
|
+
* converts every line ending to `\n`, all the editing tiers operate on that
|
|
13
|
+
* plain-LF text, and `encodeText` puts the original bytes back. Everything in
|
|
14
|
+
* between stays simple, and only this module knows about `\r`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** How a file's bytes were laid out, so a write can reproduce them. */
|
|
18
|
+
export interface FileEncoding {
|
|
19
|
+
/** File began with U+FEFF. */
|
|
20
|
+
bom: boolean;
|
|
21
|
+
/** Dominant line ending, used for any line the edit created. */
|
|
22
|
+
eol: "\n" | "\r\n" | "\r";
|
|
23
|
+
/**
|
|
24
|
+
* The original ending of each line, when the file mixed styles. Lines the
|
|
25
|
+
* edit did not add keep their own ending; anything past the end of this
|
|
26
|
+
* array falls back to `eol`. Absent when the file was consistent.
|
|
27
|
+
*/
|
|
28
|
+
endings?: string[];
|
|
29
|
+
/** File ended with a line ending. */
|
|
30
|
+
trailingNewline: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BOM = "";
|
|
34
|
+
|
|
35
|
+
/** Matches every line terminator we preserve: CRLF, lone LF, lone CR. */
|
|
36
|
+
const EOL_RE = /\r\n|\n|\r/g;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Split raw file text into plain-LF content plus the information needed to
|
|
40
|
+
* write the original bytes back. `encodeText(decodeText(raw))` is the
|
|
41
|
+
* identity for any input.
|
|
42
|
+
*/
|
|
43
|
+
export function decodeText(raw: string): { text: string; encoding: FileEncoding } {
|
|
44
|
+
const bom = raw.startsWith(BOM);
|
|
45
|
+
const body = bom ? raw.slice(BOM.length) : raw;
|
|
46
|
+
|
|
47
|
+
const endings: string[] = [];
|
|
48
|
+
let counts = { "\n": 0, "\r\n": 0, "\r": 0 };
|
|
49
|
+
let text = "";
|
|
50
|
+
let last = 0;
|
|
51
|
+
EOL_RE.lastIndex = 0;
|
|
52
|
+
let m: RegExpExecArray | null;
|
|
53
|
+
while ((m = EOL_RE.exec(body)) !== null) {
|
|
54
|
+
text += body.slice(last, m.index) + "\n";
|
|
55
|
+
endings.push(m[0]);
|
|
56
|
+
counts[m[0] as keyof typeof counts]++;
|
|
57
|
+
last = m.index + m[0].length;
|
|
58
|
+
}
|
|
59
|
+
text += body.slice(last);
|
|
60
|
+
|
|
61
|
+
const trailingNewline = endings.length > 0 && last === body.length;
|
|
62
|
+
|
|
63
|
+
// Dominant style, with LF as the tiebreak: a file with no line endings at
|
|
64
|
+
// all has no evidence either way, and LF is what a new line should use.
|
|
65
|
+
let eol: FileEncoding["eol"] = "\n";
|
|
66
|
+
let best = 0;
|
|
67
|
+
for (const style of ["\n", "\r\n", "\r"] as const) {
|
|
68
|
+
if (counts[style] > best) {
|
|
69
|
+
best = counts[style];
|
|
70
|
+
eol = style;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const consistent = endings.every((e) => e === eol);
|
|
75
|
+
return {
|
|
76
|
+
text,
|
|
77
|
+
encoding: { bom, eol, trailingNewline, ...(consistent ? {} : { endings }) },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reassemble plain-LF text into the file's original byte layout.
|
|
83
|
+
*
|
|
84
|
+
* Line endings are restored by position, so an edit that rewrites line 3 of a
|
|
85
|
+
* mixed-ending file leaves lines 1, 2, and 4 exactly as they were. Lines the
|
|
86
|
+
* edit added take the dominant style — there is no original ending to copy.
|
|
87
|
+
*
|
|
88
|
+
* Trailing-newline presence follows the original file, not the edit: an agent
|
|
89
|
+
* that hands back content with or without a final newline is describing the
|
|
90
|
+
* lines it wants, not asking to change how the file terminates.
|
|
91
|
+
*/
|
|
92
|
+
export function encodeText(text: string, encoding: FileEncoding): string {
|
|
93
|
+
// Emptying a file means an empty file. Restoring the trailing newline here
|
|
94
|
+
// would turn a deletion into a file containing one blank line.
|
|
95
|
+
if (text === "") return encoding.bom ? BOM : "";
|
|
96
|
+
|
|
97
|
+
const hadTrailing = text.endsWith("\n");
|
|
98
|
+
const body = hadTrailing ? text.slice(0, -1) : text;
|
|
99
|
+
const lines = body.split("\n");
|
|
100
|
+
|
|
101
|
+
let out = encoding.bom ? BOM : "";
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
out += lines[i];
|
|
104
|
+
const isLast = i === lines.length - 1;
|
|
105
|
+
if (isLast && !encoding.trailingNewline) break;
|
|
106
|
+
out += encoding.endings?.[i] ?? encoding.eol;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Read a file and decode it in one step. */
|
|
112
|
+
export async function readDecoded(
|
|
113
|
+
filePath: string
|
|
114
|
+
): Promise<{ text: string; encoding: FileEncoding }> {
|
|
115
|
+
return decodeText(await Bun.file(filePath).text());
|
|
116
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ErrorCode } from "./telemetry";
|
|
2
|
+
import { ExitCode, exitCodeFor, type ResultLike } from "./exit-codes";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single JSON shape every command writes to stdout.
|
|
6
|
+
*
|
|
7
|
+
* Before this existed, each of the 24 commands returned its own ad-hoc shape —
|
|
8
|
+
* some a bare array, some an object — so an adapter had to special-case the
|
|
9
|
+
* command it had just run, and had no field to detect a contract change with.
|
|
10
|
+
* See docs/ADAPTER-CONTRACT.md and schema/hashpilot-envelope.schema.json.
|
|
11
|
+
*/
|
|
12
|
+
export interface Envelope<T = unknown> {
|
|
13
|
+
/** Bumped only on a breaking change to the envelope itself. */
|
|
14
|
+
apiVersion: string;
|
|
15
|
+
/** The one boolean an adapter checks. Always agrees with the exit code. */
|
|
16
|
+
ok: boolean;
|
|
17
|
+
/** The subcommand path that produced this, e.g. `telemetry show`. */
|
|
18
|
+
command: string;
|
|
19
|
+
/** The per-command payload — the shape commands used to return at top level. */
|
|
20
|
+
data: T | null;
|
|
21
|
+
error: EnvelopeError | null;
|
|
22
|
+
/** Non-fatal notices (route fallback, anchor relocation, corrupt log lines). */
|
|
23
|
+
warnings: EnvelopeWarning[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface EnvelopeError {
|
|
27
|
+
/** A member of `ErrorCode`. Adapters branch on this, never on `message`. */
|
|
28
|
+
code: string;
|
|
29
|
+
message: string;
|
|
30
|
+
/**
|
|
31
|
+
* The literal next command to run, where one exists — not prose. This is what
|
|
32
|
+
* turns a dead end into a retry, so prefer a runnable string over advice.
|
|
33
|
+
*/
|
|
34
|
+
recovery?: string;
|
|
35
|
+
details?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EnvelopeWarning {
|
|
39
|
+
code: string;
|
|
40
|
+
message: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Current envelope version. Bump only when the envelope's own shape breaks. */
|
|
45
|
+
export const API_VERSION = "1";
|
|
46
|
+
|
|
47
|
+
let currentCommand = "";
|
|
48
|
+
let warnings: EnvelopeWarning[] = [];
|
|
49
|
+
|
|
50
|
+
/** Records which subcommand is running, so `wrap` can name it. Set by the CLI's preAction hook. */
|
|
51
|
+
export function setCommand(name: string): void {
|
|
52
|
+
currentCommand = name;
|
|
53
|
+
warnings = [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function currentCommandName(): string {
|
|
57
|
+
return currentCommand;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Attach a non-fatal notice to the envelope the current command will emit.
|
|
62
|
+
*
|
|
63
|
+
* Route fallbacks and anchor relocations used to be entirely invisible: an AST
|
|
64
|
+
* edit that silently became a diff edit looked identical to one that did not.
|
|
65
|
+
*/
|
|
66
|
+
export function addWarning(warning: EnvelopeWarning): void {
|
|
67
|
+
warnings.push(warning);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function takeWarnings(): EnvelopeWarning[] {
|
|
71
|
+
const taken = warnings;
|
|
72
|
+
warnings = [];
|
|
73
|
+
return taken;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Fields a command result may carry that the envelope lifts out of `data`. */
|
|
77
|
+
interface ErrorBearing {
|
|
78
|
+
success?: boolean;
|
|
79
|
+
passed?: boolean;
|
|
80
|
+
error?: unknown;
|
|
81
|
+
errorCode?: string;
|
|
82
|
+
message?: string;
|
|
83
|
+
recovery?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function firstFailure(payload: unknown): ErrorBearing | undefined {
|
|
87
|
+
if (Array.isArray(payload)) {
|
|
88
|
+
// Batch results: report the first failing element, since the exit code
|
|
89
|
+
// already reflects the worst one.
|
|
90
|
+
for (const item of payload) {
|
|
91
|
+
const found = firstFailure(item);
|
|
92
|
+
if (found) return found;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
97
|
+
const p = payload as ErrorBearing & { result?: unknown };
|
|
98
|
+
// `errorCode` alone counts as a failure. `verify-changes` returns neither
|
|
99
|
+
// `success` nor `error` — only `errorCode` — so its failures used to reach the
|
|
100
|
+
// envelope as `{code: "UNKNOWN", message: "Operation failed."}` beside a
|
|
101
|
+
// perfectly specific `data.errorCode` (#106).
|
|
102
|
+
const failed =
|
|
103
|
+
p.success === false || p.passed === false || (p.error !== undefined && p.error !== null) || !!p.errorCode;
|
|
104
|
+
if (failed) return p;
|
|
105
|
+
// Wrapper shapes (route-edit, batch) nest the real outcome under `result`.
|
|
106
|
+
return p.result ? firstFailure(p.result) : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function messageOf(p: ErrorBearing): string {
|
|
110
|
+
if (typeof p.message === "string" && p.message) return p.message;
|
|
111
|
+
if (typeof p.error === "string" && p.error) return p.error;
|
|
112
|
+
if (p.error && typeof p.error === "object") {
|
|
113
|
+
const nested = (p.error as { message?: string }).message;
|
|
114
|
+
if (nested) return nested;
|
|
115
|
+
}
|
|
116
|
+
return "Operation failed.";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function codeOf(p: ErrorBearing): string {
|
|
120
|
+
if (p.errorCode) return p.errorCode;
|
|
121
|
+
if (p.error && typeof p.error === "object") {
|
|
122
|
+
const nested = (p.error as { code?: string }).code;
|
|
123
|
+
if (nested) return nested;
|
|
124
|
+
}
|
|
125
|
+
// An unmapped failure is still a failure. Naming it beats emitting `ok: false`
|
|
126
|
+
// with no code for an adapter to branch on.
|
|
127
|
+
return ErrorCode.UNKNOWN;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wrap a command result in the envelope.
|
|
132
|
+
*
|
|
133
|
+
* `ok` is derived from the same exit code the process will use, so the two
|
|
134
|
+
* cannot disagree — an adapter that trusts `ok` and one that trusts `$?` reach
|
|
135
|
+
* the same conclusion.
|
|
136
|
+
*/
|
|
137
|
+
export function wrap<T>(payload: T, code: ExitCode, command = currentCommand): Envelope<T> {
|
|
138
|
+
const failure = code === ExitCode.OK ? undefined : firstFailure(payload);
|
|
139
|
+
const error: EnvelopeError | null =
|
|
140
|
+
code === ExitCode.OK
|
|
141
|
+
? null
|
|
142
|
+
: failure
|
|
143
|
+
? {
|
|
144
|
+
code: codeOf(failure),
|
|
145
|
+
message: messageOf(failure),
|
|
146
|
+
...(failure.recovery ? { recovery: failure.recovery } : {}),
|
|
147
|
+
}
|
|
148
|
+
: { code: ErrorCode.UNKNOWN, message: "Operation failed." };
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
apiVersion: API_VERSION,
|
|
152
|
+
ok: code === ExitCode.OK,
|
|
153
|
+
command,
|
|
154
|
+
data: payload ?? null,
|
|
155
|
+
error,
|
|
156
|
+
warnings: takeWarnings(),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Convenience for callers that have a result but not yet an exit code. */
|
|
161
|
+
export function wrapResult<T>(payload: T, command?: string): Envelope<T> {
|
|
162
|
+
return wrap(payload, exitCodeFor(payload as ResultLike), command);
|
|
163
|
+
}
|