ast-mcp-server 0.3.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/CHANGELOG.md +27 -0
- package/LICENSE +7 -0
- package/README.md +382 -0
- package/benchmark/README.md +86 -0
- package/dist/batch/runner.js +228 -0
- package/dist/batch/schema.js +159 -0
- package/dist/cli.js +300 -0
- package/dist/index.js +13 -0
- package/dist/server.js +28 -0
- package/dist/services/agent-setup.js +346 -0
- package/dist/services/diagnostics.js +51 -0
- package/dist/services/operation-plan-file.js +191 -0
- package/dist/services/operations.js +622 -0
- package/dist/services/outline.js +233 -0
- package/dist/services/pagination.js +33 -0
- package/dist/services/project.js +123 -0
- package/dist/services/runtime-state.js +75 -0
- package/dist/services/setup-wizard.js +113 -0
- package/dist/services/skill-installer.js +127 -0
- package/dist/services/symbols.js +117 -0
- package/dist/services/workspace.js +86 -0
- package/dist/tools/apply_operation.js +36 -0
- package/dist/tools/find_references.js +97 -0
- package/dist/tools/get_diagnostics.js +70 -0
- package/dist/tools/get_operation_preview.js +33 -0
- package/dist/tools/get_outline.js +58 -0
- package/dist/tools/get_symbol_source.js +43 -0
- package/dist/tools/list_files.js +51 -0
- package/dist/tools/operation-schema.js +64 -0
- package/dist/tools/rename_symbol.js +49 -0
- package/dist/tools/replace_symbol_body.js +49 -0
- package/dist/tools/result.js +14 -0
- package/dist/tools/search_symbols.js +101 -0
- package/package.json +78 -0
- package/skills/structural-code-editing/SKILL.md +119 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
4
|
+
import { createServer } from "../server.js";
|
|
5
|
+
import { MAX_BATCH_CONTEXT_BYTES, MAX_BATCH_INVOCATIONS, MAX_BATCH_OUTPUT_BYTES, MAX_FOREACH_ITEMS, isItemTemplate, isPrepareBatchTool, isReadBatchTool, isReferenceTemplate, parseBatchDocument as parseDocument, } from "./schema.js";
|
|
6
|
+
export { parseDocument as parseBatchDocument };
|
|
7
|
+
export class BatchExecutionError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
stepId;
|
|
10
|
+
constructor(message, code, stepId, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.stepId = stepId;
|
|
14
|
+
this.name = "BatchExecutionError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function decodePointerSegment(segment) {
|
|
18
|
+
if (/~(?![01])/.test(segment)) {
|
|
19
|
+
throw new BatchExecutionError(`Invalid JSON Pointer escape in segment "${segment}".`, "INVALID_REFERENCE");
|
|
20
|
+
}
|
|
21
|
+
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
22
|
+
}
|
|
23
|
+
function resolvePointer(root, pointer, label) {
|
|
24
|
+
const fragment = pointer.startsWith("#") ? pointer.slice(1) : pointer;
|
|
25
|
+
if (fragment === "")
|
|
26
|
+
return root;
|
|
27
|
+
if (!fragment.startsWith("/")) {
|
|
28
|
+
throw new BatchExecutionError(`${label} must be empty or start with "/".`, "INVALID_REFERENCE");
|
|
29
|
+
}
|
|
30
|
+
let current = root;
|
|
31
|
+
for (const encodedSegment of fragment.slice(1).split("/")) {
|
|
32
|
+
const segment = decodePointerSegment(encodedSegment);
|
|
33
|
+
if (segment === "__proto__" || segment === "prototype" || segment === "constructor") {
|
|
34
|
+
throw new BatchExecutionError(`${label} contains a forbidden segment.`, "INVALID_REFERENCE");
|
|
35
|
+
}
|
|
36
|
+
if ((typeof current !== "object" || current === null) && !Array.isArray(current)) {
|
|
37
|
+
throw new BatchExecutionError(`${label} does not resolve to an object value.`, "MISSING_REFERENCE");
|
|
38
|
+
}
|
|
39
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
40
|
+
throw new BatchExecutionError(`${label} does not contain segment "${segment}".`, "MISSING_REFERENCE");
|
|
41
|
+
}
|
|
42
|
+
current = current[segment];
|
|
43
|
+
}
|
|
44
|
+
return current;
|
|
45
|
+
}
|
|
46
|
+
function resolveTemplate(value, context, item) {
|
|
47
|
+
if (isReferenceTemplate(value)) {
|
|
48
|
+
return resolvePointer(context, value.$ref, `$ref ${value.$ref}`);
|
|
49
|
+
}
|
|
50
|
+
if (isItemTemplate(value)) {
|
|
51
|
+
if (item === undefined) {
|
|
52
|
+
throw new BatchExecutionError("$item is unavailable outside foreach.", "INVALID_ITEM_REFERENCE");
|
|
53
|
+
}
|
|
54
|
+
return resolvePointer(item, value.$item, `$item ${value.$item}`);
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(value))
|
|
57
|
+
return value.map((child) => resolveTemplate(child, context, item));
|
|
58
|
+
if (typeof value === "object" && value !== null) {
|
|
59
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, resolveTemplate(child, context, item)]));
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
function injectProjectRoot(resolved, projectRoot, step) {
|
|
64
|
+
if (typeof resolved !== "object" || resolved === null || Array.isArray(resolved)) {
|
|
65
|
+
throw new BatchExecutionError(`Step "${step.id}" input must resolve to an object.`, "INVALID_STEP_INPUT", step.id);
|
|
66
|
+
}
|
|
67
|
+
const input = resolved;
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(input, "project_root") &&
|
|
69
|
+
input.project_root !== projectRoot) {
|
|
70
|
+
throw new BatchExecutionError(`Step "${step.id}" resolves a conflicting project_root.`, "PROJECT_ROOT_CONFLICT", step.id);
|
|
71
|
+
}
|
|
72
|
+
return { ...input, project_root: projectRoot };
|
|
73
|
+
}
|
|
74
|
+
async function connectBatchClient() {
|
|
75
|
+
const server = createServer();
|
|
76
|
+
const client = new Client({ name: "ast-tool-batch", version: "0.3.0" });
|
|
77
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
78
|
+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
79
|
+
return { client, server };
|
|
80
|
+
}
|
|
81
|
+
function toolErrorMessage(result) {
|
|
82
|
+
const content = typeof result === "object" && result !== null
|
|
83
|
+
? result.content
|
|
84
|
+
: undefined;
|
|
85
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
86
|
+
const text = blocks
|
|
87
|
+
.flatMap((block) => {
|
|
88
|
+
if (typeof block === "object" &&
|
|
89
|
+
block !== null &&
|
|
90
|
+
block.type === "text" &&
|
|
91
|
+
typeof block.text === "string") {
|
|
92
|
+
return [block.text];
|
|
93
|
+
}
|
|
94
|
+
return [];
|
|
95
|
+
})
|
|
96
|
+
.join("\n");
|
|
97
|
+
return text || "Tool returned an unspecified error.";
|
|
98
|
+
}
|
|
99
|
+
async function callStructuredTool(client, step, input) {
|
|
100
|
+
const result = await client.callTool({ name: step.tool, arguments: input });
|
|
101
|
+
if (result.isError) {
|
|
102
|
+
throw new BatchExecutionError(`Step "${step.id}" failed: ${toolErrorMessage(result)}`, "TOOL_ERROR", step.id);
|
|
103
|
+
}
|
|
104
|
+
if (typeof result.structuredContent !== "object" ||
|
|
105
|
+
result.structuredContent === null ||
|
|
106
|
+
Array.isArray(result.structuredContent)) {
|
|
107
|
+
throw new BatchExecutionError(`Step "${step.id}" returned no structured object result.`, "INVALID_TOOL_RESULT", step.id);
|
|
108
|
+
}
|
|
109
|
+
return result.structuredContent;
|
|
110
|
+
}
|
|
111
|
+
async function mapLimit(items, concurrency, callback) {
|
|
112
|
+
const results = new Array(items.length);
|
|
113
|
+
let nextIndex = 0;
|
|
114
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
115
|
+
while (nextIndex < items.length) {
|
|
116
|
+
const index = nextIndex++;
|
|
117
|
+
results[index] = await callback(items[index], index);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await Promise.all(workers);
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
export async function runBatchDocument(document, options = {}) {
|
|
124
|
+
const startedAt = performance.now();
|
|
125
|
+
const context = { steps: Object.create(null) };
|
|
126
|
+
let invocationCount = 0;
|
|
127
|
+
let retainedContextBytes = 0;
|
|
128
|
+
const connection = options.invokeTool ? undefined : await connectBatchClient();
|
|
129
|
+
const invoke = async (step, input) => {
|
|
130
|
+
if (!options.invokeTool)
|
|
131
|
+
return callStructuredTool(connection.client, step, input);
|
|
132
|
+
const result = await options.invokeTool(step.tool, input);
|
|
133
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
134
|
+
throw new BatchExecutionError(`Step "${step.id}" returned no structured object result.`, "INVALID_TOOL_RESULT", step.id);
|
|
135
|
+
}
|
|
136
|
+
return result;
|
|
137
|
+
};
|
|
138
|
+
const reserveInvocations = (count, step) => {
|
|
139
|
+
if (invocationCount + count > MAX_BATCH_INVOCATIONS) {
|
|
140
|
+
throw new BatchExecutionError(`Step "${step.id}" would exceed the ${MAX_BATCH_INVOCATIONS}-invocation batch limit.`, "INVOCATION_LIMIT", step.id);
|
|
141
|
+
}
|
|
142
|
+
invocationCount += count;
|
|
143
|
+
};
|
|
144
|
+
const retainResult = (result, step) => {
|
|
145
|
+
let bytes;
|
|
146
|
+
try {
|
|
147
|
+
bytes = Buffer.byteLength(JSON.stringify(result));
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw new BatchExecutionError(`Step "${step.id}" returned a result that cannot be serialized as JSON.`, "INVALID_TOOL_RESULT", step.id, { cause: error });
|
|
151
|
+
}
|
|
152
|
+
if (bytes > MAX_BATCH_OUTPUT_BYTES) {
|
|
153
|
+
throw new BatchExecutionError(`Step "${step.id}" returned ${bytes} bytes; maximum per result is ${MAX_BATCH_OUTPUT_BYTES}.`, "RESULT_LIMIT", step.id);
|
|
154
|
+
}
|
|
155
|
+
retainedContextBytes += bytes;
|
|
156
|
+
if (retainedContextBytes > MAX_BATCH_CONTEXT_BYTES) {
|
|
157
|
+
throw new BatchExecutionError(`Batch retained context exceeds ${MAX_BATCH_CONTEXT_BYTES} bytes.`, "CONTEXT_LIMIT", step.id);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
try {
|
|
161
|
+
for (const step of document.steps) {
|
|
162
|
+
if (step.foreach) {
|
|
163
|
+
if (!isReadBatchTool(step.tool)) {
|
|
164
|
+
throw new BatchExecutionError(`Step "${step.id}" cannot expand non-read tool "${step.tool}".`, "FORBIDDEN_FOREACH_TOOL", step.id);
|
|
165
|
+
}
|
|
166
|
+
const items = resolveTemplate(step.foreach, context);
|
|
167
|
+
if (!Array.isArray(items)) {
|
|
168
|
+
throw new BatchExecutionError(`Step "${step.id}" foreach must resolve to an array.`, "INVALID_FOREACH", step.id);
|
|
169
|
+
}
|
|
170
|
+
if (items.length > MAX_FOREACH_ITEMS) {
|
|
171
|
+
throw new BatchExecutionError(`Step "${step.id}" expands to ${items.length} items; maximum is ${MAX_FOREACH_ITEMS}.`, "FOREACH_LIMIT", step.id);
|
|
172
|
+
}
|
|
173
|
+
reserveInvocations(items.length, step);
|
|
174
|
+
const results = await mapLimit(items, document.limits.concurrency, async (item) => {
|
|
175
|
+
const resolved = resolveTemplate(step.input, context, item);
|
|
176
|
+
return invoke(step, injectProjectRoot(resolved, document.project_root, step));
|
|
177
|
+
});
|
|
178
|
+
retainResult(results, step);
|
|
179
|
+
context.steps[step.id] = results;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
reserveInvocations(1, step);
|
|
183
|
+
const resolved = resolveTemplate(step.input, context);
|
|
184
|
+
const result = await invoke(step, injectProjectRoot(resolved, document.project_root, step));
|
|
185
|
+
if (isPrepareBatchTool(step.tool) && options.persistPreparedOperation) {
|
|
186
|
+
const operationId = result.operation_id;
|
|
187
|
+
if (typeof operationId !== "string") {
|
|
188
|
+
throw new BatchExecutionError(`Prepare step "${step.id}" returned no operation_id.`, "INVALID_TOOL_RESULT", step.id);
|
|
189
|
+
}
|
|
190
|
+
result.plan_file = await options.persistPreparedOperation(operationId);
|
|
191
|
+
}
|
|
192
|
+
retainResult(result, step);
|
|
193
|
+
context.steps[step.id] = result;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const finalStep = document.steps.at(-1);
|
|
197
|
+
const projected = document.emit === undefined
|
|
198
|
+
? context.steps[finalStep.id]
|
|
199
|
+
: resolveTemplate(document.emit, context);
|
|
200
|
+
const output = {
|
|
201
|
+
version: 1,
|
|
202
|
+
status: "ok",
|
|
203
|
+
duration_ms: performance.now() - startedAt,
|
|
204
|
+
step_count: document.steps.length,
|
|
205
|
+
invocation_count: invocationCount,
|
|
206
|
+
result: projected,
|
|
207
|
+
};
|
|
208
|
+
if (isPrepareBatchTool(finalStep.tool)) {
|
|
209
|
+
const prepared = context.steps[finalStep.id];
|
|
210
|
+
if (typeof prepared.operation_id === "string" &&
|
|
211
|
+
typeof prepared.plan_hash === "string" &&
|
|
212
|
+
typeof prepared.plan_file === "string") {
|
|
213
|
+
output.operation_id = prepared.operation_id;
|
|
214
|
+
output.plan_hash = prepared.plan_hash;
|
|
215
|
+
output.plan_file = prepared.plan_file;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const outputBytes = Buffer.byteLength(JSON.stringify(output));
|
|
219
|
+
if (outputBytes > MAX_BATCH_OUTPUT_BYTES) {
|
|
220
|
+
throw new BatchExecutionError(`Batch output is ${outputBytes} bytes; maximum is ${MAX_BATCH_OUTPUT_BYTES}. Narrow emit or pagination.`, "OUTPUT_LIMIT");
|
|
221
|
+
}
|
|
222
|
+
return output;
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
if (connection)
|
|
226
|
+
await Promise.allSettled([connection.client.close(), connection.server.close()]);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const BATCH_SCHEMA_VERSION = 1;
|
|
3
|
+
export const MAX_BATCH_STEPS = 50;
|
|
4
|
+
export const MAX_BATCH_INVOCATIONS = 500;
|
|
5
|
+
export const MAX_FOREACH_ITEMS = 200;
|
|
6
|
+
export const DEFAULT_BATCH_CONCURRENCY = 4;
|
|
7
|
+
export const MAX_BATCH_CONCURRENCY = 16;
|
|
8
|
+
export const MAX_BATCH_INPUT_BYTES = 1024 * 1024;
|
|
9
|
+
export const MAX_BATCH_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
export const MAX_BATCH_CONTEXT_BYTES = 50 * 1024 * 1024;
|
|
11
|
+
export const READ_BATCH_TOOLS = [
|
|
12
|
+
"ast_list_files",
|
|
13
|
+
"ast_get_outline",
|
|
14
|
+
"ast_get_symbol_source",
|
|
15
|
+
"ast_search_symbols",
|
|
16
|
+
"ast_find_references",
|
|
17
|
+
"ast_get_diagnostics",
|
|
18
|
+
];
|
|
19
|
+
export const PREPARE_BATCH_TOOLS = ["ast_rename_symbol", "ast_replace_symbol_body"];
|
|
20
|
+
export const BATCH_TOOLS = [...READ_BATCH_TOOLS, ...PREPARE_BATCH_TOOLS];
|
|
21
|
+
const BatchToolSchema = z.enum(BATCH_TOOLS);
|
|
22
|
+
const BatchStepSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
id: z.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/),
|
|
25
|
+
tool: BatchToolSchema,
|
|
26
|
+
input: z.record(z.unknown()).default({}),
|
|
27
|
+
foreach: z
|
|
28
|
+
.object({ $ref: z.string().min(1) })
|
|
29
|
+
.strict()
|
|
30
|
+
.optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict();
|
|
33
|
+
const BatchDocumentSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
version: z.literal(BATCH_SCHEMA_VERSION),
|
|
36
|
+
project_root: z.string().min(1),
|
|
37
|
+
limits: z
|
|
38
|
+
.object({
|
|
39
|
+
concurrency: z
|
|
40
|
+
.number()
|
|
41
|
+
.int()
|
|
42
|
+
.min(1)
|
|
43
|
+
.max(MAX_BATCH_CONCURRENCY)
|
|
44
|
+
.default(DEFAULT_BATCH_CONCURRENCY),
|
|
45
|
+
})
|
|
46
|
+
.strict()
|
|
47
|
+
.default({ concurrency: DEFAULT_BATCH_CONCURRENCY }),
|
|
48
|
+
steps: z.array(BatchStepSchema).min(1).max(MAX_BATCH_STEPS),
|
|
49
|
+
emit: z.unknown().optional(),
|
|
50
|
+
})
|
|
51
|
+
.strict();
|
|
52
|
+
function isTemplateObject(value, key) {
|
|
53
|
+
return (typeof value === "object" &&
|
|
54
|
+
value !== null &&
|
|
55
|
+
!Array.isArray(value) &&
|
|
56
|
+
Object.keys(value).length === 1 &&
|
|
57
|
+
typeof value[key] === "string");
|
|
58
|
+
}
|
|
59
|
+
function decodePointerSegment(segment) {
|
|
60
|
+
if (/~(?![01])/u.test(segment)) {
|
|
61
|
+
throw new Error(`Invalid JSON Pointer escape in segment "${segment}".`);
|
|
62
|
+
}
|
|
63
|
+
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
64
|
+
}
|
|
65
|
+
function referencedStepId(pointer) {
|
|
66
|
+
if (!pointer.startsWith("#/steps/")) {
|
|
67
|
+
throw new Error(`Batch $ref must start with "#/steps/": ${pointer}`);
|
|
68
|
+
}
|
|
69
|
+
const remainder = pointer.slice("#/steps/".length);
|
|
70
|
+
const encodedId = remainder.split("/", 1)[0];
|
|
71
|
+
if (!encodedId)
|
|
72
|
+
throw new Error(`Batch $ref is missing a step id: ${pointer}`);
|
|
73
|
+
return decodePointerSegment(encodedId);
|
|
74
|
+
}
|
|
75
|
+
function visitTemplate(value, options) {
|
|
76
|
+
if (isTemplateObject(value, "$ref")) {
|
|
77
|
+
const id = referencedStepId(value.$ref);
|
|
78
|
+
if (!options.availableSteps.has(id)) {
|
|
79
|
+
throw new Error(`${options.location} references step "${id}" before it is available; only prior steps may be referenced.`);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (isTemplateObject(value, "$item")) {
|
|
84
|
+
if (!options.allowItem) {
|
|
85
|
+
throw new Error(`${options.location} uses $item outside a foreach step.`);
|
|
86
|
+
}
|
|
87
|
+
if (value.$item !== "" && !value.$item.startsWith("/")) {
|
|
88
|
+
throw new Error(`${options.location} $item must be an empty pointer or start with "/".`);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(value)) {
|
|
93
|
+
value.forEach((item, index) => visitTemplate(item, { ...options, location: `${options.location}/${index}` }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (typeof value === "object" && value !== null) {
|
|
97
|
+
for (const [key, child] of Object.entries(value)) {
|
|
98
|
+
visitTemplate(child, { ...options, location: `${options.location}/${key}` });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export function isPrepareBatchTool(tool) {
|
|
103
|
+
return PREPARE_BATCH_TOOLS.includes(tool);
|
|
104
|
+
}
|
|
105
|
+
export function isReadBatchTool(tool) {
|
|
106
|
+
return READ_BATCH_TOOLS.includes(tool);
|
|
107
|
+
}
|
|
108
|
+
export function parseBatchDocument(input) {
|
|
109
|
+
const parsed = BatchDocumentSchema.parse(input);
|
|
110
|
+
const ids = new Set();
|
|
111
|
+
const prepareIndexes = [];
|
|
112
|
+
for (const [index, step] of parsed.steps.entries()) {
|
|
113
|
+
if (ids.has(step.id))
|
|
114
|
+
throw new Error(`Duplicate batch step id "${step.id}".`);
|
|
115
|
+
if (Object.hasOwn(step.input, "project_root")) {
|
|
116
|
+
if (step.input.project_root !== parsed.project_root) {
|
|
117
|
+
throw new Error(`Step "${step.id}" project_root conflicts with the pipeline project_root.`);
|
|
118
|
+
}
|
|
119
|
+
delete step.input.project_root;
|
|
120
|
+
}
|
|
121
|
+
if (isPrepareBatchTool(step.tool)) {
|
|
122
|
+
prepareIndexes.push(index);
|
|
123
|
+
if (step.foreach)
|
|
124
|
+
throw new Error(`Prepare step "${step.id}" cannot use foreach.`);
|
|
125
|
+
}
|
|
126
|
+
if (step.foreach) {
|
|
127
|
+
visitTemplate(step.foreach, {
|
|
128
|
+
availableSteps: ids,
|
|
129
|
+
allowItem: false,
|
|
130
|
+
location: `step "${step.id}" foreach`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
visitTemplate(step.input, {
|
|
134
|
+
availableSteps: ids,
|
|
135
|
+
allowItem: Boolean(step.foreach),
|
|
136
|
+
location: `step "${step.id}" input`,
|
|
137
|
+
});
|
|
138
|
+
ids.add(step.id);
|
|
139
|
+
}
|
|
140
|
+
if (prepareIndexes.length > 1)
|
|
141
|
+
throw new Error("A batch may contain at most one prepare step.");
|
|
142
|
+
if (prepareIndexes.length === 1 && prepareIndexes[0] !== parsed.steps.length - 1) {
|
|
143
|
+
throw new Error("A prepare step must be the final batch step.");
|
|
144
|
+
}
|
|
145
|
+
if (parsed.emit !== undefined) {
|
|
146
|
+
visitTemplate(parsed.emit, {
|
|
147
|
+
availableSteps: ids,
|
|
148
|
+
allowItem: false,
|
|
149
|
+
location: "emit",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return parsed;
|
|
153
|
+
}
|
|
154
|
+
export function isReferenceTemplate(value) {
|
|
155
|
+
return isTemplateObject(value, "$ref");
|
|
156
|
+
}
|
|
157
|
+
export function isItemTemplate(value) {
|
|
158
|
+
return isTemplateObject(value, "$item");
|
|
159
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { ZodError } from "zod";
|
|
6
|
+
import { BatchExecutionError, parseBatchDocument, runBatchDocument } from "./batch/runner.js";
|
|
7
|
+
import { MAX_BATCH_INPUT_BYTES } from "./batch/schema.js";
|
|
8
|
+
import { applyPersistedOperation, persistOperationPlan } from "./services/operation-plan-file.js";
|
|
9
|
+
import { AgentSetupError, detectInstalledAgents, resolveServerEntryPath, runAgentSetup, } from "./services/agent-setup.js";
|
|
10
|
+
import { installBundledSkill, resolveBundledSkillPath, } from "./services/skill-installer.js";
|
|
11
|
+
import { parseAgentsArgument, promptForAgentSelection, } from "./services/setup-wizard.js";
|
|
12
|
+
class CliError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
exitCode;
|
|
15
|
+
command;
|
|
16
|
+
stepId;
|
|
17
|
+
details;
|
|
18
|
+
constructor(message, code, exitCode, command, stepId, options, details) {
|
|
19
|
+
super(message, options);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.exitCode = exitCode;
|
|
22
|
+
this.command = command;
|
|
23
|
+
this.stepId = stepId;
|
|
24
|
+
this.details = details;
|
|
25
|
+
this.name = "CliError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function usage() {
|
|
29
|
+
return [
|
|
30
|
+
"Usage:",
|
|
31
|
+
" ast-tool run <pipeline.json|->",
|
|
32
|
+
" ast-tool validate <pipeline.json|->",
|
|
33
|
+
" ast-tool apply <plan.astplan> --plan-hash <sha256>",
|
|
34
|
+
" ast-tool install-skill [claude|hermes|all] [--scope user|project] [--project-root <path>] [--force]",
|
|
35
|
+
" ast-tool setup [--agents claude,hermes|all --yes] [--force-skill]",
|
|
36
|
+
].join("\n");
|
|
37
|
+
}
|
|
38
|
+
async function readStdinBounded() {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
let bytes = 0;
|
|
41
|
+
for await (const chunk of process.stdin) {
|
|
42
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
43
|
+
bytes += buffer.length;
|
|
44
|
+
if (bytes > MAX_BATCH_INPUT_BYTES) {
|
|
45
|
+
throw new CliError(`Batch input exceeds ${MAX_BATCH_INPUT_BYTES} bytes.`, "INPUT_LIMIT", 2, null);
|
|
46
|
+
}
|
|
47
|
+
chunks.push(buffer);
|
|
48
|
+
}
|
|
49
|
+
return Buffer.concat(chunks);
|
|
50
|
+
}
|
|
51
|
+
async function readBatchInput(source) {
|
|
52
|
+
const bytes = source === "-" ? await readStdinBounded() : await readFile(source);
|
|
53
|
+
if (bytes.length > MAX_BATCH_INPUT_BYTES) {
|
|
54
|
+
throw new CliError(`Batch input is ${bytes.length} bytes; maximum is ${MAX_BATCH_INPUT_BYTES}.`, "INPUT_LIMIT", 2, null);
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(bytes.toString("utf8"));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new CliError("Batch input is not valid JSON.", "INVALID_JSON", 2, null, undefined, {
|
|
61
|
+
cause: error,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function parseApplyArgs(args) {
|
|
66
|
+
if (args.length !== 3 || args[1] !== "--plan-hash" || !args[0] || !args[2]) {
|
|
67
|
+
throw new CliError(usage(), "USAGE", 2, "apply");
|
|
68
|
+
}
|
|
69
|
+
return { planFile: args[0], planHash: args[2] };
|
|
70
|
+
}
|
|
71
|
+
function parseInstallSkillArgs(args) {
|
|
72
|
+
let target = "all";
|
|
73
|
+
let targetSeen = false;
|
|
74
|
+
let scope = "user";
|
|
75
|
+
let scopeSeen = false;
|
|
76
|
+
let projectRoot;
|
|
77
|
+
let force = false;
|
|
78
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
79
|
+
const argument = args[index];
|
|
80
|
+
if (argument === "--force") {
|
|
81
|
+
if (force)
|
|
82
|
+
throw new CliError(usage(), "USAGE", 2, "install-skill");
|
|
83
|
+
force = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (argument === "--scope") {
|
|
87
|
+
const value = args[index + 1];
|
|
88
|
+
if (scopeSeen || (value !== "user" && value !== "project")) {
|
|
89
|
+
throw new CliError(usage(), "USAGE", 2, "install-skill");
|
|
90
|
+
}
|
|
91
|
+
scope = value;
|
|
92
|
+
scopeSeen = true;
|
|
93
|
+
index += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (argument === "--project-root") {
|
|
97
|
+
const value = args[index + 1];
|
|
98
|
+
if (projectRoot !== undefined || !value || value.startsWith("--")) {
|
|
99
|
+
throw new CliError(usage(), "USAGE", 2, "install-skill");
|
|
100
|
+
}
|
|
101
|
+
projectRoot = value;
|
|
102
|
+
index += 1;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (!targetSeen && (argument === "claude" || argument === "hermes" || argument === "all")) {
|
|
106
|
+
target = argument;
|
|
107
|
+
targetSeen = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
throw new CliError(usage(), "USAGE", 2, "install-skill");
|
|
111
|
+
}
|
|
112
|
+
if (projectRoot !== undefined && scope !== "project") {
|
|
113
|
+
throw new CliError(usage(), "USAGE", 2, "install-skill");
|
|
114
|
+
}
|
|
115
|
+
return { target, scope, ...(projectRoot ? { projectRoot } : {}), force };
|
|
116
|
+
}
|
|
117
|
+
function parseSetupArgs(args) {
|
|
118
|
+
let agents;
|
|
119
|
+
let yes = false;
|
|
120
|
+
let forceSkill = false;
|
|
121
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
122
|
+
const argument = args[index];
|
|
123
|
+
if (argument === "--agents") {
|
|
124
|
+
const value = args[index + 1];
|
|
125
|
+
if (agents !== undefined || !value || value.startsWith("--")) {
|
|
126
|
+
throw new CliError(usage(), "USAGE", 2, "setup");
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
agents = parseAgentsArgument(value);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw new CliError(error instanceof Error ? error.message : String(error), "USAGE", 2, "setup", undefined, { cause: error });
|
|
133
|
+
}
|
|
134
|
+
index += 1;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (argument === "--yes") {
|
|
138
|
+
if (yes)
|
|
139
|
+
throw new CliError(usage(), "USAGE", 2, "setup");
|
|
140
|
+
yes = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (argument === "--force-skill") {
|
|
144
|
+
if (forceSkill)
|
|
145
|
+
throw new CliError(usage(), "USAGE", 2, "setup");
|
|
146
|
+
forceSkill = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
throw new CliError(usage(), "USAGE", 2, "setup");
|
|
150
|
+
}
|
|
151
|
+
if ((agents === undefined) === yes) {
|
|
152
|
+
throw new CliError("Non-interactive setup requires both --agents and --yes; interactive setup accepts neither.", "USAGE", 2, "setup");
|
|
153
|
+
}
|
|
154
|
+
return { ...(agents === undefined ? {} : { agents }), yes, forceSkill };
|
|
155
|
+
}
|
|
156
|
+
async function parseDocumentForCommand(source, command) {
|
|
157
|
+
try {
|
|
158
|
+
return parseBatchDocument(await readBatchInput(source));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error instanceof CliError) {
|
|
162
|
+
throw new CliError(error.message, error.code, 2, command, error.stepId, { cause: error });
|
|
163
|
+
}
|
|
164
|
+
const message = error instanceof ZodError ? error.issues.map((issue) => issue.message).join("; ") : error;
|
|
165
|
+
throw new CliError(message instanceof Error ? message.message : String(message), "INVALID_BATCH", 2, command, undefined, { cause: error });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function runCli(args) {
|
|
169
|
+
const [command, ...commandArgs] = args;
|
|
170
|
+
if (command === "run" || command === "validate") {
|
|
171
|
+
if (commandArgs.length !== 1 || !commandArgs[0]) {
|
|
172
|
+
throw new CliError(usage(), "USAGE", 2, command);
|
|
173
|
+
}
|
|
174
|
+
const document = await parseDocumentForCommand(commandArgs[0], command);
|
|
175
|
+
if (command === "validate") {
|
|
176
|
+
return {
|
|
177
|
+
version: 1,
|
|
178
|
+
status: "valid",
|
|
179
|
+
step_count: document.steps.length,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
return await runBatchDocument(document, {
|
|
184
|
+
persistPreparedOperation: (operationId) => persistOperationPlan(operationId),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof BatchExecutionError) {
|
|
189
|
+
throw new CliError(error.message, error.code, 1, command, error.stepId, {
|
|
190
|
+
cause: error,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
throw new CliError(error instanceof Error ? error.message : String(error), "EXECUTION_ERROR", 1, command, undefined, { cause: error });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (command === "apply") {
|
|
197
|
+
const { planFile, planHash } = parseApplyArgs(commandArgs);
|
|
198
|
+
try {
|
|
199
|
+
return await applyPersistedOperation(planFile, planHash);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
throw new CliError(error instanceof Error ? error.message : String(error), "APPLY_ERROR", 1, command, undefined, { cause: error });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (command === "setup") {
|
|
206
|
+
const options = parseSetupArgs(commandArgs);
|
|
207
|
+
try {
|
|
208
|
+
const detections = await detectInstalledAgents();
|
|
209
|
+
let agents = options.agents;
|
|
210
|
+
if (agents === undefined) {
|
|
211
|
+
if (process.stdin.isTTY !== true || process.stderr.isTTY !== true) {
|
|
212
|
+
throw new CliError("Interactive setup requires a TTY. Use --agents claude,hermes --yes for automation.", "USAGE", 2, command);
|
|
213
|
+
}
|
|
214
|
+
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
215
|
+
try {
|
|
216
|
+
agents = await promptForAgentSelection(detections, (question) => prompt.question(question));
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
prompt.close();
|
|
220
|
+
}
|
|
221
|
+
if (agents.length === 0) {
|
|
222
|
+
return { status: "cancelled", command: "setup", agents: [] };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const executablePath = process.argv[1];
|
|
226
|
+
if (!executablePath) {
|
|
227
|
+
throw new Error("Cannot resolve the ast-tool executable path.");
|
|
228
|
+
}
|
|
229
|
+
return await runAgentSetup({
|
|
230
|
+
agents,
|
|
231
|
+
detections,
|
|
232
|
+
sourceSkillPath: await resolveBundledSkillPath(executablePath),
|
|
233
|
+
serverEntryPath: await resolveServerEntryPath(executablePath),
|
|
234
|
+
forceSkill: options.forceSkill,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error instanceof CliError) {
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
if (error instanceof AgentSetupError) {
|
|
242
|
+
throw new CliError(error.message, error.code, 1, command, undefined, { cause: error }, error.details);
|
|
243
|
+
}
|
|
244
|
+
throw new CliError(error instanceof Error ? error.message : String(error), "SETUP_ERROR", 1, command, undefined, { cause: error });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (command === "install-skill") {
|
|
248
|
+
const options = parseInstallSkillArgs(commandArgs);
|
|
249
|
+
try {
|
|
250
|
+
const executablePath = process.argv[1];
|
|
251
|
+
if (!executablePath) {
|
|
252
|
+
throw new Error("Cannot resolve the ast-tool executable path.");
|
|
253
|
+
}
|
|
254
|
+
return await installBundledSkill({
|
|
255
|
+
...options,
|
|
256
|
+
sourceSkillPath: await resolveBundledSkillPath(executablePath),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
throw new CliError(error instanceof Error ? error.message : String(error), "SKILL_INSTALL_ERROR", 1, command, undefined, { cause: error });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
throw new CliError(usage(), "USAGE", 2, command ?? null);
|
|
264
|
+
}
|
|
265
|
+
function failure(error) {
|
|
266
|
+
if (error instanceof CliError) {
|
|
267
|
+
return {
|
|
268
|
+
value: {
|
|
269
|
+
status: "error",
|
|
270
|
+
command: error.command,
|
|
271
|
+
code: error.code,
|
|
272
|
+
...(error.stepId ? { step_id: error.stepId } : {}),
|
|
273
|
+
message: error.message,
|
|
274
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
275
|
+
},
|
|
276
|
+
exitCode: error.exitCode,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
value: {
|
|
281
|
+
status: "error",
|
|
282
|
+
command: null,
|
|
283
|
+
code: "INTERNAL_ERROR",
|
|
284
|
+
message: error instanceof Error ? error.message : String(error),
|
|
285
|
+
},
|
|
286
|
+
exitCode: 1,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
async function main() {
|
|
290
|
+
try {
|
|
291
|
+
const result = await runCli(process.argv.slice(2));
|
|
292
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
const failed = failure(error);
|
|
296
|
+
process.stderr.write(`${JSON.stringify(failed.value)}\n`);
|
|
297
|
+
process.exitCode = failed.exitCode;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await main();
|