@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,255 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
findSymbolsDetailed,
|
|
4
|
+
renameSymbol,
|
|
5
|
+
replaceBody,
|
|
6
|
+
addImport,
|
|
7
|
+
removeImport,
|
|
8
|
+
insertBeforeSymbol,
|
|
9
|
+
insertAfterSymbol,
|
|
10
|
+
detectLanguage,
|
|
11
|
+
recordEvent,
|
|
12
|
+
astCapabilities,
|
|
13
|
+
toPreview,
|
|
14
|
+
ErrorCode,
|
|
15
|
+
buildProvenanceFields,
|
|
16
|
+
safeWrite,
|
|
17
|
+
finish,
|
|
18
|
+
} from "../core/index";
|
|
19
|
+
import type { TelemetryEvent } from "../core/telemetry";
|
|
20
|
+
import { withPreview, withProvenance } from "./shared";
|
|
21
|
+
|
|
22
|
+
/** Register the `ast` command group. */
|
|
23
|
+
export function register(program: Command): void {
|
|
24
|
+
const astCmd = program
|
|
25
|
+
.command("ast")
|
|
26
|
+
.description("Syntax-aware editing via tree-sitter");
|
|
27
|
+
|
|
28
|
+
astCmd
|
|
29
|
+
.command("capabilities")
|
|
30
|
+
.description("Show supported AST languages, operations, and limitations")
|
|
31
|
+
.action(() => {
|
|
32
|
+
finish(astCapabilities());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
astCmd
|
|
36
|
+
.command("find-symbols")
|
|
37
|
+
.description("List symbols in a file")
|
|
38
|
+
.argument("<file>", "File path")
|
|
39
|
+
.action(async (file: string) => {
|
|
40
|
+
const content = await Bun.file(file).text();
|
|
41
|
+
// Report the truncation flag rather than an array that looks complete (#39).
|
|
42
|
+
const { symbols, truncated } = findSymbolsDetailed(content, file);
|
|
43
|
+
finish({ symbols, truncated });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function recordProvenanceEvent(opts: {
|
|
47
|
+
operation: string; route: TelemetryEvent["route"]; file: string; success: boolean; elapsed_ms: number;
|
|
48
|
+
source?: string; newSource?: string; errorCode?: ErrorCode; language?: string;
|
|
49
|
+
actor?: string; taskId?: string; reason?: string; filePath?: string;
|
|
50
|
+
}) {
|
|
51
|
+
const provFields = buildProvenanceFields({
|
|
52
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
|
|
53
|
+
source: opts.source, newSource: opts.newSource, filePath: opts.filePath,
|
|
54
|
+
});
|
|
55
|
+
recordEvent({
|
|
56
|
+
operation: opts.operation, route: opts.route, file: opts.file,
|
|
57
|
+
language: opts.language, success: opts.success, elapsed_ms: opts.elapsed_ms,
|
|
58
|
+
errorCode: opts.errorCode, ...provFields,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
withProvenance(
|
|
63
|
+
withPreview(
|
|
64
|
+
astCmd
|
|
65
|
+
.command("rename-symbol")
|
|
66
|
+
.description(
|
|
67
|
+
"File-scoped, binding-aware rename of a symbol and its references. " +
|
|
68
|
+
"Refuses with AMBIGUOUS_SYMBOL when the name binds more than one symbol " +
|
|
69
|
+
"in the file (a shadowed local, a foreign import, or a duplicate declaration).",
|
|
70
|
+
)
|
|
71
|
+
.argument("<file>", "File path")
|
|
72
|
+
.argument("<old-name>", "Current symbol name")
|
|
73
|
+
.argument("<new-name>", "New symbol name"),
|
|
74
|
+
"Preview only",
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
.action(async (file: string, oldName: string, newName: string, opts) => {
|
|
79
|
+
const start = Date.now();
|
|
80
|
+
const content = await Bun.file(file).text();
|
|
81
|
+
const result = renameSymbol(content, file, oldName, newName);
|
|
82
|
+
if (result.success && result.newSource && !opts.dryRun) {
|
|
83
|
+
await safeWrite(file, result.newSource);
|
|
84
|
+
}
|
|
85
|
+
recordProvenanceEvent({
|
|
86
|
+
operation: "rename-symbol", route: "ast", file,
|
|
87
|
+
language: detectLanguage(file) || undefined,
|
|
88
|
+
success: result.success, elapsed_ms: Date.now() - start,
|
|
89
|
+
errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
|
|
90
|
+
source: content, newSource: result.newSource, filePath: file,
|
|
91
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
|
|
92
|
+
});
|
|
93
|
+
// A dry run previews the change; it does not hand back the whole file (#98).
|
|
94
|
+
finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
withProvenance(
|
|
98
|
+
withPreview(
|
|
99
|
+
astCmd
|
|
100
|
+
.command("replace-body")
|
|
101
|
+
.description("Replace function/method body")
|
|
102
|
+
.argument("<file>", "File path")
|
|
103
|
+
.argument("<symbol>", "Symbol name")
|
|
104
|
+
.argument("<new-body>", "New body statements only — no braces, no indentation (or @file)"),
|
|
105
|
+
"Preview only",
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
.action(async (file: string, symbol: string, newBody: string, opts) => {
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
let body = newBody;
|
|
112
|
+
if (newBody.startsWith("@")) body = await Bun.file(newBody.slice(1)).text();
|
|
113
|
+
const content = await Bun.file(file).text();
|
|
114
|
+
const result = replaceBody(content, file, symbol, body);
|
|
115
|
+
if (result.success && result.newSource && !opts.dryRun) {
|
|
116
|
+
await safeWrite(file, result.newSource);
|
|
117
|
+
}
|
|
118
|
+
recordProvenanceEvent({
|
|
119
|
+
operation: "replace-body", route: "ast", file,
|
|
120
|
+
language: detectLanguage(file) || undefined,
|
|
121
|
+
success: result.success, elapsed_ms: Date.now() - start,
|
|
122
|
+
errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
|
|
123
|
+
source: content, newSource: result.newSource, filePath: file,
|
|
124
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
|
|
125
|
+
});
|
|
126
|
+
// A dry run previews the change; it does not hand back the whole file (#98).
|
|
127
|
+
finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
withProvenance(
|
|
131
|
+
withPreview(
|
|
132
|
+
astCmd
|
|
133
|
+
.command("add-import")
|
|
134
|
+
.description("Add an import statement")
|
|
135
|
+
.argument("<file>", "File path")
|
|
136
|
+
.argument("<import-spec>", 'Import spec, module path quoted: \'{ Foo } from "./bar"\''),
|
|
137
|
+
"Preview only",
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
.action(async (file: string, importSpec: string, opts) => {
|
|
142
|
+
const start = Date.now();
|
|
143
|
+
const content = await Bun.file(file).text();
|
|
144
|
+
const result = addImport(content, file, importSpec);
|
|
145
|
+
if (result.success && result.newSource && !opts.dryRun) {
|
|
146
|
+
await safeWrite(file, result.newSource);
|
|
147
|
+
}
|
|
148
|
+
recordProvenanceEvent({
|
|
149
|
+
operation: "add-import", route: "ast", file,
|
|
150
|
+
language: detectLanguage(file) || undefined,
|
|
151
|
+
success: result.success, elapsed_ms: Date.now() - start,
|
|
152
|
+
errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
|
|
153
|
+
source: content, newSource: result.newSource, filePath: file,
|
|
154
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
|
|
155
|
+
});
|
|
156
|
+
// A dry run previews the change; it does not hand back the whole file (#98).
|
|
157
|
+
finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
withProvenance(
|
|
161
|
+
withPreview(
|
|
162
|
+
astCmd
|
|
163
|
+
.command("remove-import")
|
|
164
|
+
.description("Remove an import statement")
|
|
165
|
+
.argument("<file>", "File path")
|
|
166
|
+
.argument("<import-spec>", 'Import spec to remove, e.g. \'{ Foo } from "./bar"\' or a bare binding name'),
|
|
167
|
+
"Preview only",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
.action(async (file: string, importSpec: string, opts) => {
|
|
172
|
+
const start = Date.now();
|
|
173
|
+
const content = await Bun.file(file).text();
|
|
174
|
+
const result = removeImport(content, file, importSpec);
|
|
175
|
+
if (result.success && result.newSource && !opts.dryRun) {
|
|
176
|
+
await safeWrite(file, result.newSource);
|
|
177
|
+
}
|
|
178
|
+
recordProvenanceEvent({
|
|
179
|
+
operation: "remove-import", route: "ast", file,
|
|
180
|
+
language: detectLanguage(file) || undefined,
|
|
181
|
+
success: result.success, elapsed_ms: Date.now() - start,
|
|
182
|
+
errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
|
|
183
|
+
source: content, newSource: result.newSource, filePath: file,
|
|
184
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
|
|
185
|
+
});
|
|
186
|
+
// A dry run previews the change; it does not hand back the whole file (#98).
|
|
187
|
+
finish(opts.dryRun ? toPreview(result, content, file, opts.includeSource) : result);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
withProvenance(
|
|
191
|
+
withPreview(
|
|
192
|
+
astCmd
|
|
193
|
+
.command("insert-before")
|
|
194
|
+
.description("Insert content before a symbol")
|
|
195
|
+
.argument("<file>", "File path")
|
|
196
|
+
.argument("<symbol>", "Symbol name")
|
|
197
|
+
.argument("<content>", "Content to insert (or @file)"),
|
|
198
|
+
"Preview only",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
.action(async (file: string, symbol: string, content: string, opts) => {
|
|
203
|
+
const start = Date.now();
|
|
204
|
+
let c = content;
|
|
205
|
+
if (c.startsWith("@")) c = await Bun.file(c.slice(1)).text();
|
|
206
|
+
const src = await Bun.file(file).text();
|
|
207
|
+
const result = insertBeforeSymbol(src, file, symbol, c);
|
|
208
|
+
if (result.success && result.newSource && !opts.dryRun) {
|
|
209
|
+
await safeWrite(file, result.newSource);
|
|
210
|
+
}
|
|
211
|
+
recordProvenanceEvent({
|
|
212
|
+
operation: "insert-before", route: "ast", file,
|
|
213
|
+
language: detectLanguage(file) || undefined,
|
|
214
|
+
success: result.success, elapsed_ms: Date.now() - start,
|
|
215
|
+
errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
|
|
216
|
+
source: src, newSource: result.newSource, filePath: file,
|
|
217
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
|
|
218
|
+
});
|
|
219
|
+
// A dry run previews the change; it does not hand back the whole file (#98).
|
|
220
|
+
finish(opts.dryRun ? toPreview(result, src, file, opts.includeSource) : result);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
withProvenance(
|
|
224
|
+
withPreview(
|
|
225
|
+
astCmd
|
|
226
|
+
.command("insert-after")
|
|
227
|
+
.description("Insert content after a symbol")
|
|
228
|
+
.argument("<file>", "File path")
|
|
229
|
+
.argument("<symbol>", "Symbol name")
|
|
230
|
+
.argument("<content>", "Content to insert (or @file)"),
|
|
231
|
+
"Preview only",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
.action(async (file: string, symbol: string, content: string, opts) => {
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
let c = content;
|
|
238
|
+
if (c.startsWith("@")) c = await Bun.file(c.slice(1)).text();
|
|
239
|
+
const src = await Bun.file(file).text();
|
|
240
|
+
const result = insertAfterSymbol(src, file, symbol, c);
|
|
241
|
+
if (result.success && result.newSource && !opts.dryRun) {
|
|
242
|
+
await safeWrite(file, result.newSource);
|
|
243
|
+
}
|
|
244
|
+
recordProvenanceEvent({
|
|
245
|
+
operation: "insert-after", route: "ast", file,
|
|
246
|
+
language: detectLanguage(file) || undefined,
|
|
247
|
+
success: result.success, elapsed_ms: Date.now() - start,
|
|
248
|
+
errorCode: result.success ? undefined : ErrorCode.PARSE_ERROR,
|
|
249
|
+
source: src, newSource: result.newSource, filePath: file,
|
|
250
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason,
|
|
251
|
+
});
|
|
252
|
+
// A dry run previews the change; it does not hand back the whole file (#98).
|
|
253
|
+
finish(opts.dryRun ? toPreview(result, src, file, opts.includeSource) : result);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
detectLanguage,
|
|
4
|
+
recordEvent,
|
|
5
|
+
generateUnifiedDiff,
|
|
6
|
+
applyPatch,
|
|
7
|
+
buildProvenanceFields,
|
|
8
|
+
finish,
|
|
9
|
+
usageError,
|
|
10
|
+
ExitCode,
|
|
11
|
+
} from "../core/index";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { parseIntFlag, withProvenance } from "./shared";
|
|
14
|
+
|
|
15
|
+
/** Register the `diff` command group. */
|
|
16
|
+
export function register(program: Command): void {
|
|
17
|
+
const diffCmd = program
|
|
18
|
+
.command("diff")
|
|
19
|
+
.description("Unified diff generation and patch application");
|
|
20
|
+
|
|
21
|
+
diffCmd
|
|
22
|
+
.command("generate")
|
|
23
|
+
.description("Generate a unified diff between old and new content")
|
|
24
|
+
.argument("<file>", "File path (for diff header)")
|
|
25
|
+
.argument("<old-content>", "Old content (or @file)")
|
|
26
|
+
.argument("<new-content>", "New content (or @file)")
|
|
27
|
+
.option("-c, --context <n>", "Context lines", "3")
|
|
28
|
+
.option("--raw", "Print the diff text alone, without the JSON envelope")
|
|
29
|
+
.action(async (file: string, oldContent: string, newContent: string, opts) => {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
let oldSrc = oldContent;
|
|
32
|
+
let newSrc = newContent;
|
|
33
|
+
if (oldContent.startsWith("@")) oldSrc = await Bun.file(oldContent.slice(1)).text();
|
|
34
|
+
if (newContent.startsWith("@")) newSrc = await Bun.file(newContent.slice(1)).text();
|
|
35
|
+
const diff = generateUnifiedDiff(oldSrc, newSrc, file, parseInt(opts.context));
|
|
36
|
+
recordEvent({
|
|
37
|
+
operation: "diff-generate",
|
|
38
|
+
route: "diff",
|
|
39
|
+
file,
|
|
40
|
+
success: true,
|
|
41
|
+
elapsed_ms: Date.now() - start,
|
|
42
|
+
});
|
|
43
|
+
// The diff itself is the payload; it rides the envelope like every other
|
|
44
|
+
// command so a consumer has one parse path (`--raw` prints it bare).
|
|
45
|
+
if (opts.raw) {
|
|
46
|
+
console.log(diff || "(no changes)");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
finish({ path: file, changed: diff.length > 0, diff }, ExitCode.OK);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
withProvenance(
|
|
53
|
+
diffCmd
|
|
54
|
+
.command("apply")
|
|
55
|
+
.description("Apply a unified diff patch to a file")
|
|
56
|
+
.argument("<file>", "File to patch")
|
|
57
|
+
.option("--patch <file>", "Patch file to apply (or '-' for stdin)")
|
|
58
|
+
.option("--dry-run", "Preview without writing")
|
|
59
|
+
.option("-f, --fuzzy <n>", "Fuzzy match tolerance in lines; 0 = strict (exact offset and content, refuses otherwise)", "3")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
.action(async (file: string, opts) => {
|
|
63
|
+
const start = Date.now();
|
|
64
|
+
let patchText: string;
|
|
65
|
+
if (opts.patch === "-") {
|
|
66
|
+
// Read from stdin
|
|
67
|
+
const chunks: string[] = [];
|
|
68
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
69
|
+
chunks.push(Buffer.from(chunk).toString());
|
|
70
|
+
}
|
|
71
|
+
patchText = chunks.join("");
|
|
72
|
+
} else if (opts.patch) {
|
|
73
|
+
patchText = await Bun.file(opts.patch).text();
|
|
74
|
+
} else {
|
|
75
|
+
// Must return: without it, patchText is unassigned and applyPatch throws.
|
|
76
|
+
return usageError("--patch is required", { path: file });
|
|
77
|
+
}
|
|
78
|
+
const fuzzy = parseIntFlag(opts.fuzzy, "--fuzzy", 3);
|
|
79
|
+
if (typeof fuzzy === "object") return usageError(fuzzy.error, { path: file });
|
|
80
|
+
const result = await applyPatch(file, patchText, {
|
|
81
|
+
dryRun: opts.dryRun,
|
|
82
|
+
fuzzyMatch: fuzzy,
|
|
83
|
+
});
|
|
84
|
+
const provFields = buildProvenanceFields({
|
|
85
|
+
actor: opts.actor, taskId: opts.taskId, reason: opts.reason, filePath: file,
|
|
86
|
+
});
|
|
87
|
+
recordEvent({
|
|
88
|
+
operation: "diff-apply",
|
|
89
|
+
route: "diff",
|
|
90
|
+
file,
|
|
91
|
+
language: detectLanguage(file) || undefined,
|
|
92
|
+
success: result.success,
|
|
93
|
+
elapsed_ms: Date.now() - start,
|
|
94
|
+
...provFields,
|
|
95
|
+
});
|
|
96
|
+
finish(result);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
routeEdit,
|
|
4
|
+
editMany,
|
|
5
|
+
editManySerial,
|
|
6
|
+
resolveContent,
|
|
7
|
+
finish,
|
|
8
|
+
} from "../core/index";
|
|
9
|
+
import { withEditFlags, withProvenance } from "./shared";
|
|
10
|
+
|
|
11
|
+
/** Register the `edit` command group. */
|
|
12
|
+
export function register(program: Command): void {
|
|
13
|
+
withProvenance(
|
|
14
|
+
withEditFlags(
|
|
15
|
+
program
|
|
16
|
+
.command("route-edit")
|
|
17
|
+
.description("Auto-routed structured edit through AST → Hash → Diff pipeline")
|
|
18
|
+
.argument("<file>", "File path")
|
|
19
|
+
.argument("<operation>", "Operation (rename-symbol, replace-body, add-import, remove-import, insert-before, insert-after, replace-hash, replace-content)")
|
|
20
|
+
)
|
|
21
|
+
.option("--dry-run", "Preview without writing")
|
|
22
|
+
.option("--include-source", "On a dry run, return the whole post-edit file instead of a diff")
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
.action(async (file: string, operation: string, opts) => {
|
|
26
|
+
const result = await routeEdit({
|
|
27
|
+
filePath: file,
|
|
28
|
+
operation,
|
|
29
|
+
method: opts.method,
|
|
30
|
+
oldHash: opts.oldHash,
|
|
31
|
+
newContent: await resolveContent(opts.newContent),
|
|
32
|
+
oldContent: opts.oldContent,
|
|
33
|
+
range: opts.range ? (([s, e]: number[]) => ({ start: s, end: e }))(opts.range.split(":").map(Number)) : undefined,
|
|
34
|
+
oldName: opts.oldName,
|
|
35
|
+
newName: opts.newName,
|
|
36
|
+
symbolName: opts.symbol,
|
|
37
|
+
newBody: await resolveContent(opts.newBody),
|
|
38
|
+
importSpec: opts.importSpec,
|
|
39
|
+
content: await resolveContent(opts.content),
|
|
40
|
+
policy: opts.policy ? JSON.parse(opts.policy) : undefined,
|
|
41
|
+
dryRun: opts.dryRun,
|
|
42
|
+
includeSource: opts.includeSource,
|
|
43
|
+
actor: opts.actor,
|
|
44
|
+
taskId: opts.taskId,
|
|
45
|
+
reason: opts.reason,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
finish(result);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
withProvenance(
|
|
52
|
+
withEditFlags(
|
|
53
|
+
program
|
|
54
|
+
.command("batch")
|
|
55
|
+
.description("Apply the same edit to multiple files in parallel")
|
|
56
|
+
.argument("<operation>", "Operation (rename-symbol, replace-body, add-import, remove-import, insert-before, insert-after, replace-hash, replace-content)")
|
|
57
|
+
.argument("<files...>", "Files to edit")
|
|
58
|
+
)
|
|
59
|
+
.option("--serial", "Execute sequentially instead of parallel")
|
|
60
|
+
.option("--dry-run", "Preview without writing")
|
|
61
|
+
.option("--include-source", "On a dry run, return the whole post-edit file instead of a diff")
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
.action(async (operation: string, files: string[], opts) => {
|
|
65
|
+
const batchParams = {
|
|
66
|
+
files,
|
|
67
|
+
operation,
|
|
68
|
+
method: opts.method,
|
|
69
|
+
oldHash: opts.oldHash,
|
|
70
|
+
newContent: await resolveContent(opts.newContent),
|
|
71
|
+
oldContent: opts.oldContent,
|
|
72
|
+
range: opts.range ? (([s, e]: number[]) => ({ start: s, end: e }))(opts.range.split(":").map(Number)) : undefined,
|
|
73
|
+
oldName: opts.oldName,
|
|
74
|
+
newName: opts.newName,
|
|
75
|
+
symbolName: opts.symbol,
|
|
76
|
+
newBody: await resolveContent(opts.newBody),
|
|
77
|
+
importSpec: opts.importSpec,
|
|
78
|
+
content: await resolveContent(opts.content),
|
|
79
|
+
policy: opts.policy ? JSON.parse(opts.policy) : undefined,
|
|
80
|
+
dryRun: opts.dryRun,
|
|
81
|
+
includeSource: opts.includeSource,
|
|
82
|
+
actor: opts.actor,
|
|
83
|
+
taskId: opts.taskId,
|
|
84
|
+
reason: opts.reason,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const result = opts.serial
|
|
88
|
+
? await editManySerial(batchParams)
|
|
89
|
+
: await editMany(batchParams);
|
|
90
|
+
|
|
91
|
+
finish(result);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
replaceHash,
|
|
4
|
+
detectLanguage,
|
|
5
|
+
recordEvent,
|
|
6
|
+
buildProvenanceFields,
|
|
7
|
+
finish,
|
|
8
|
+
usageError,
|
|
9
|
+
} from "../core/index";
|
|
10
|
+
import { parseRange, withProvenance } from "./shared";
|
|
11
|
+
|
|
12
|
+
/** Register the `hash` command group. */
|
|
13
|
+
export function register(program: Command): void {
|
|
14
|
+
withProvenance(
|
|
15
|
+
program
|
|
16
|
+
.command("replace-hash")
|
|
17
|
+
.description("Replace content identified by hash anchor")
|
|
18
|
+
.argument("<file>", "File path")
|
|
19
|
+
.argument("<old-hash>", "Hash of content to replace")
|
|
20
|
+
.argument("<new-content>", "New content (or @file to read from file)")
|
|
21
|
+
.option("--range <start:end>", "Line range (1-indexed). N or N:M")
|
|
22
|
+
.option("--no-recover", "Fail immediately on a stale anchor instead of attempting relocation")
|
|
23
|
+
.option("--dry-run", "Preview without writing")
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
.action(async (file: string, oldHash: string, newContent: string, opts) => {
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
let content = newContent;
|
|
29
|
+
if (newContent.startsWith("@")) {
|
|
30
|
+
content = await Bun.file(newContent.slice(1)).text();
|
|
31
|
+
}
|
|
32
|
+
let range: { start: number; end: number } | undefined;
|
|
33
|
+
if (opts.range) {
|
|
34
|
+
const parsed = parseRange(opts.range);
|
|
35
|
+
if ("error" in parsed) return usageError(parsed.error, { path: file });
|
|
36
|
+
range = parsed.range;
|
|
37
|
+
}
|
|
38
|
+
const result = await replaceHash(file, oldHash, content, {
|
|
39
|
+
range,
|
|
40
|
+
dryRun: opts.dryRun,
|
|
41
|
+
// Commander maps --no-recover to opts.recover === false.
|
|
42
|
+
recovery: opts.recover === false ? "off" : "relocate",
|
|
43
|
+
skipParseCheck: Boolean(program.opts().allowParseErrors),
|
|
44
|
+
});
|
|
45
|
+
const provFields = buildProvenanceFields({
|
|
46
|
+
actor: opts.actor,
|
|
47
|
+
taskId: opts.taskId,
|
|
48
|
+
reason: opts.reason,
|
|
49
|
+
filePath: file,
|
|
50
|
+
});
|
|
51
|
+
recordEvent({
|
|
52
|
+
operation: "replace-hash",
|
|
53
|
+
route: "hash",
|
|
54
|
+
file,
|
|
55
|
+
language: detectLanguage(file) || undefined,
|
|
56
|
+
success: result.success,
|
|
57
|
+
fallback_reason: result.stale ? "stale-anchor" : undefined,
|
|
58
|
+
retries: result.retries ?? 0,
|
|
59
|
+
elapsed_ms: Date.now() - start,
|
|
60
|
+
...provFields,
|
|
61
|
+
});
|
|
62
|
+
finish(result);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import {
|
|
3
|
+
executeIntent,
|
|
4
|
+
finish,
|
|
5
|
+
exitCodeFor,
|
|
6
|
+
getOutputFormat,
|
|
7
|
+
} from "../core/index";
|
|
8
|
+
import { withProvenance } from "./shared";
|
|
9
|
+
|
|
10
|
+
/** Register the `intent` command group. */
|
|
11
|
+
export function register(program: Command): void {
|
|
12
|
+
withProvenance(
|
|
13
|
+
program
|
|
14
|
+
.command("intent")
|
|
15
|
+
.description("Execute an editing intent — one command, full blast radius")
|
|
16
|
+
.argument("<intent>", "Intent as JSON: {\"operation\":\"add-parameter\",\"symbol\":\"fn\",\"param\":{\"name\":\"x\"}}")
|
|
17
|
+
.option("--project-root <dir>", "Project root directory")
|
|
18
|
+
.option("--dry-run", "Preview plan without modifying files")
|
|
19
|
+
.option("--yes", "Apply the plan even though part of the intent could not be resolved")
|
|
20
|
+
.option("--no-verify", "Skip verification after execution")
|
|
21
|
+
.option("--no-revert", "Don't roll back on failure")
|
|
22
|
+
.option("--timeout <ms>", "Timeout per operation in ms", "30000")
|
|
23
|
+
)
|
|
24
|
+
.option("--context <text>", "Agent prompt/context (or @file)")
|
|
25
|
+
|
|
26
|
+
.action(async (intent: string, opts) => {
|
|
27
|
+
try {
|
|
28
|
+
let context = opts.context;
|
|
29
|
+
if (context && context.startsWith("@")) {
|
|
30
|
+
context = await Bun.file(context.slice(1)).text();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const result = await executeIntent(intent, {
|
|
34
|
+
projectRoot: opts.projectRoot || process.cwd(),
|
|
35
|
+
dryRun: opts.dryRun,
|
|
36
|
+
yes: Boolean(opts.yes),
|
|
37
|
+
verify: opts.verify,
|
|
38
|
+
revertOnFailure: opts.revert,
|
|
39
|
+
timeout: parseInt(opts.timeout),
|
|
40
|
+
actor: opts.actor,
|
|
41
|
+
taskId: opts.taskId,
|
|
42
|
+
reason: opts.reason,
|
|
43
|
+
context,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
if (getOutputFormat() === "json") {
|
|
47
|
+
finish(result);
|
|
48
|
+
} else {
|
|
49
|
+
console.log(`Intent: ${result.plan.intent.operation} on '${result.plan.definition.name}'`);
|
|
50
|
+
console.log(`Impact: ${result.plan.impactSummary}`);
|
|
51
|
+
for (const u of result.plan.unresolved) {
|
|
52
|
+
console.log(`Unresolved (${u.file}): ${u.reason}`);
|
|
53
|
+
console.log(` → ${u.resolution}`);
|
|
54
|
+
}
|
|
55
|
+
console.log(`Success: ${result.success}`);
|
|
56
|
+
if (result.execution.verification) {
|
|
57
|
+
console.log(`Verification: ${result.execution.verification.overall}`);
|
|
58
|
+
}
|
|
59
|
+
// Human output still has to carry the exit contract — an agent may run
|
|
60
|
+
// without --json and branch on the code.
|
|
61
|
+
process.exitCode = exitCodeFor(result);
|
|
62
|
+
}
|
|
63
|
+
} catch (err: any) {
|
|
64
|
+
console.error(`Intent failed: ${err.message}`);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|