@arrhq/crate-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/bin/crate.mjs +13 -0
- package/package.json +22 -0
- package/src/cli.mjs +1473 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,1473 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
12
|
+
const DEFAULT_MAX_WAIT_SECONDS = 180;
|
|
13
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 3;
|
|
14
|
+
|
|
15
|
+
const UUID_PATTERN =
|
|
16
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9._:@/-]{1,128}$/;
|
|
18
|
+
const CONTEXT_LABEL_PATTERN = /^(?:checkpoint:)?[a-z0-9][a-z0-9._/:-]{1,95}$/;
|
|
19
|
+
const CORRELATION_ID_PATTERN = /^[A-Za-z0-9._:@/-]{1,128}$/;
|
|
20
|
+
const GITHUB_REPO_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
21
|
+
const CHECKPOINT_PENDING_PATTERN =
|
|
22
|
+
/task close failed: checkpoint_status (?:must be persisted before close|wait timed out before persisted) \(status=([^,\s)]+), decision=([^\s)]+)\)\./i;
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
const HELP_TEXT = `Usage:
|
|
27
|
+
crate <command> [options]
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
tool list
|
|
31
|
+
tool call
|
|
32
|
+
tasks list
|
|
33
|
+
tasks create
|
|
34
|
+
tasks update
|
|
35
|
+
tasks close
|
|
36
|
+
checkpoint status
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
crate tool list
|
|
40
|
+
crate tool call --name list_project_tasks --args-json '{"status":"open","limit":20}'
|
|
41
|
+
crate tasks list --status in_progress --limit 50
|
|
42
|
+
crate tasks create --title "設計task" --description "詳細設計"
|
|
43
|
+
crate tasks update --task-id <task_uuid> --status in_progress
|
|
44
|
+
crate tasks close --task-id <task_uuid> --client-event-id <checkpoint_uuid>
|
|
45
|
+
crate checkpoint status --client-event-id <checkpoint_uuid>
|
|
46
|
+
|
|
47
|
+
Global options:
|
|
48
|
+
--mcp-url <url> MCP endpoint URL
|
|
49
|
+
--mcp-ingest-token <token> MCP token (x-crate-ingest-token)
|
|
50
|
+
--server-name <name> mcp server key in ~/.codex/config.toml
|
|
51
|
+
--timeout-ms <ms> RPC timeout (default: 20000)
|
|
52
|
+
--json JSON output
|
|
53
|
+
--help Show help`;
|
|
54
|
+
|
|
55
|
+
const COMMAND_HELP = {
|
|
56
|
+
"tool list": `Usage: crate tool list [options]
|
|
57
|
+
Options:
|
|
58
|
+
--mcp-url <url>
|
|
59
|
+
--mcp-ingest-token <token>
|
|
60
|
+
--server-name <name>
|
|
61
|
+
--timeout-ms <ms>
|
|
62
|
+
--json`,
|
|
63
|
+
"tool call": `Usage: crate tool call --name <tool> [options]
|
|
64
|
+
Options:
|
|
65
|
+
--name <tool_name> MCP tool name (required)
|
|
66
|
+
--args-json <json> Tool arguments as JSON object (default: {})
|
|
67
|
+
--args-file <path> Path to JSON file for tool arguments
|
|
68
|
+
--mcp-url <url>
|
|
69
|
+
--mcp-ingest-token <token>
|
|
70
|
+
--server-name <name>
|
|
71
|
+
--timeout-ms <ms>
|
|
72
|
+
--json`,
|
|
73
|
+
"tasks list": `Usage: crate tasks list [options]
|
|
74
|
+
Options:
|
|
75
|
+
--project-id <uuid>
|
|
76
|
+
--status <open|in_progress|close|canceled>
|
|
77
|
+
--limit <n>
|
|
78
|
+
--mcp-url <url>
|
|
79
|
+
--mcp-ingest-token <token>
|
|
80
|
+
--server-name <name>
|
|
81
|
+
--timeout-ms <ms>
|
|
82
|
+
--json`,
|
|
83
|
+
"tasks create": `Usage: crate tasks create --title <text> [options]
|
|
84
|
+
Options:
|
|
85
|
+
--title <text> Task title (required)
|
|
86
|
+
--description <text>
|
|
87
|
+
--project-id <uuid>
|
|
88
|
+
--github-repo-full-name <owner/repo>
|
|
89
|
+
--session-id <id> default: CODEX_THREAD_ID
|
|
90
|
+
--occurred-at <iso8601> default: now
|
|
91
|
+
--client-event-id <uuid> default: generated
|
|
92
|
+
--correlation-id <id> default: task/create/<epoch_ms>
|
|
93
|
+
--context-label <label>
|
|
94
|
+
--actor-context <text>
|
|
95
|
+
--source-context <text>
|
|
96
|
+
--mcp-url <url>
|
|
97
|
+
--mcp-ingest-token <token>
|
|
98
|
+
--server-name <name>
|
|
99
|
+
--timeout-ms <ms>
|
|
100
|
+
--json`,
|
|
101
|
+
"tasks update": `Usage: crate tasks update --task-id <uuid> [options]
|
|
102
|
+
Options:
|
|
103
|
+
--task-id <uuid> Target task id (required)
|
|
104
|
+
--title <text>
|
|
105
|
+
--description <text>
|
|
106
|
+
--status <open|in_progress|close|canceled>
|
|
107
|
+
--project-id <uuid>
|
|
108
|
+
--github-repo-full-name <owner/repo>
|
|
109
|
+
--session-id <id> default: CODEX_THREAD_ID
|
|
110
|
+
--occurred-at <iso8601> default: now
|
|
111
|
+
--client-event-id <uuid> default: generated (except status=close with --context-label)
|
|
112
|
+
--correlation-id <id> default: task/update/<task-id>/<epoch_ms>
|
|
113
|
+
--context-label <label> required fallback when status=close and client_event_id omitted
|
|
114
|
+
--actor-context <text>
|
|
115
|
+
--source-context <text>
|
|
116
|
+
--mcp-url <url>
|
|
117
|
+
--mcp-ingest-token <token>
|
|
118
|
+
--server-name <name>
|
|
119
|
+
--timeout-ms <ms>
|
|
120
|
+
--json`,
|
|
121
|
+
"tasks close": `Usage: crate tasks close --task-id <uuid> [options]
|
|
122
|
+
Options:
|
|
123
|
+
--task-id <uuid> Task ID to close (required)
|
|
124
|
+
--session-id <id> default: CODEX_THREAD_ID
|
|
125
|
+
--occurred-at <iso8601> default: now
|
|
126
|
+
--correlation-id <id> default: task/close/<task-id>/<epoch_ms>
|
|
127
|
+
--client-event-id <uuid> Checkpoint event ID (preferred)
|
|
128
|
+
--context-label <label> Fallback checkpoint label (checkpoint:<note>:<work_slug>)
|
|
129
|
+
--project-id <uuid>
|
|
130
|
+
--github-repo-full-name <owner/repo>
|
|
131
|
+
--max-wait-seconds <n> default: 180
|
|
132
|
+
--poll-interval-seconds <n> default: 3
|
|
133
|
+
--mcp-url <url>
|
|
134
|
+
--mcp-ingest-token <token>
|
|
135
|
+
--server-name <name>
|
|
136
|
+
--timeout-ms <ms>
|
|
137
|
+
--json`,
|
|
138
|
+
"checkpoint status": `Usage: crate checkpoint status [options]
|
|
139
|
+
Options:
|
|
140
|
+
--client-event-id <uuid> Preferred lookup key
|
|
141
|
+
--session-id <id> Required when client_event_id omitted
|
|
142
|
+
--context-label <label> Required when client_event_id omitted
|
|
143
|
+
--project-id <uuid>
|
|
144
|
+
--mcp-url <url>
|
|
145
|
+
--mcp-ingest-token <token>
|
|
146
|
+
--server-name <name>
|
|
147
|
+
--timeout-ms <ms>
|
|
148
|
+
--json`,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const parseArgValue = (arg, prefix) => {
|
|
152
|
+
if (arg === prefix) return null;
|
|
153
|
+
if (arg.startsWith(`${prefix}=`)) return arg.slice(prefix.length + 1);
|
|
154
|
+
return undefined;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const decodeTomlString = (value) =>
|
|
158
|
+
value
|
|
159
|
+
.replace(/\\n/g, "\n")
|
|
160
|
+
.replace(/\\r/g, "\r")
|
|
161
|
+
.replace(/\\t/g, "\t")
|
|
162
|
+
.replace(/\\"/g, '"')
|
|
163
|
+
.replace(/\\\\/g, "\\");
|
|
164
|
+
|
|
165
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
|
+
|
|
167
|
+
const toTrimmed = (value) => (typeof value === "string" ? value.trim() : "");
|
|
168
|
+
|
|
169
|
+
const normalizeSessionId = (value) => {
|
|
170
|
+
const trimmed = toTrimmed(value);
|
|
171
|
+
if (!trimmed) return "";
|
|
172
|
+
return SESSION_ID_PATTERN.test(trimmed) ? trimmed : "";
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const normalizeClientEventId = (value) => {
|
|
176
|
+
const trimmed = toTrimmed(value).toLowerCase();
|
|
177
|
+
if (!trimmed) return "";
|
|
178
|
+
return UUID_PATTERN.test(trimmed) ? trimmed : "";
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const normalizeContextLabel = (value) => {
|
|
182
|
+
const trimmed = toTrimmed(value).toLowerCase();
|
|
183
|
+
if (!trimmed) return "";
|
|
184
|
+
if (!CONTEXT_LABEL_PATTERN.test(trimmed)) return "";
|
|
185
|
+
return trimmed.startsWith("checkpoint:") ? trimmed : `checkpoint:${trimmed}`;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const normalizeCorrelationId = (value) => {
|
|
189
|
+
const trimmed = toTrimmed(value);
|
|
190
|
+
if (!trimmed) return "";
|
|
191
|
+
return CORRELATION_ID_PATTERN.test(trimmed) ? trimmed : "";
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const normalizeOccurredAt = (value) => {
|
|
195
|
+
const trimmed = toTrimmed(value);
|
|
196
|
+
if (!trimmed) return "";
|
|
197
|
+
const parsed = Date.parse(trimmed);
|
|
198
|
+
if (Number.isNaN(parsed)) return "";
|
|
199
|
+
return new Date(parsed).toISOString();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const normalizeGithubRepoFullName = (value) => {
|
|
203
|
+
const raw = toTrimmed(value);
|
|
204
|
+
if (!raw) return "";
|
|
205
|
+
|
|
206
|
+
const asDirect = raw.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
|
|
207
|
+
if (GITHUB_REPO_PATTERN.test(asDirect)) return asDirect;
|
|
208
|
+
|
|
209
|
+
const sshMatch = raw.match(/^git@[^:]+:([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
210
|
+
if (sshMatch?.[1]) {
|
|
211
|
+
const candidate = sshMatch[1].replace(/\.git$/i, "");
|
|
212
|
+
return GITHUB_REPO_PATTERN.test(candidate) ? candidate : "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const parsed = new URL(raw);
|
|
217
|
+
const segments = parsed.pathname
|
|
218
|
+
.split("/")
|
|
219
|
+
.map((segment) => segment.trim())
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
if (segments.length !== 2) return "";
|
|
222
|
+
const candidate = `${segments[0]}/${segments[1]}`.replace(/\.git$/i, "");
|
|
223
|
+
return GITHUB_REPO_PATTERN.test(candidate) ? candidate : "";
|
|
224
|
+
} catch {
|
|
225
|
+
return "";
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const parsePositiveInteger = (value, flagName) => {
|
|
230
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
231
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
232
|
+
throw new Error(`${flagName} must be a positive integer.`);
|
|
233
|
+
}
|
|
234
|
+
return parsed;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const parseBooleanInline = (value, flagName) => {
|
|
238
|
+
const normalized = toTrimmed(value).toLowerCase();
|
|
239
|
+
if (normalized === "" || normalized === "1" || normalized === "true") return true;
|
|
240
|
+
if (normalized === "0" || normalized === "false") return false;
|
|
241
|
+
throw new Error(`${flagName} only accepts true/false.`);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const parseFlags = (argv, schema, defaults = {}) => {
|
|
245
|
+
const options = { ...defaults };
|
|
246
|
+
const seenFlags = new Set();
|
|
247
|
+
|
|
248
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
249
|
+
const arg = argv[index];
|
|
250
|
+
if (arg === "--") continue;
|
|
251
|
+
if (!arg.startsWith("--")) {
|
|
252
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const eqIndex = arg.indexOf("=");
|
|
256
|
+
const flag = eqIndex === -1 ? arg : arg.slice(0, eqIndex);
|
|
257
|
+
const inlineValue = eqIndex === -1 ? undefined : arg.slice(eqIndex + 1);
|
|
258
|
+
const rule = schema[flag];
|
|
259
|
+
if (!rule) {
|
|
260
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
seenFlags.add(flag);
|
|
264
|
+
|
|
265
|
+
if (rule.type === "boolean") {
|
|
266
|
+
options[rule.key] =
|
|
267
|
+
inlineValue === undefined ? true : parseBooleanInline(inlineValue, flag);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let rawValue = inlineValue;
|
|
272
|
+
if (rawValue === undefined) {
|
|
273
|
+
const next = argv[index + 1];
|
|
274
|
+
if (!next || next.startsWith("--")) {
|
|
275
|
+
throw new Error(`${flag} requires a value.`);
|
|
276
|
+
}
|
|
277
|
+
rawValue = next;
|
|
278
|
+
index += 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (rule.type === "integer") {
|
|
282
|
+
options[rule.key] = parsePositiveInteger(rawValue, flag);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
options[rule.key] = rawValue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { options, seenFlags };
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const getCommonDefaults = () => ({
|
|
293
|
+
mcpUrl: process.env.CRATE_MCP_URL?.trim() ?? "",
|
|
294
|
+
mcpIngestToken: process.env.CRATE_MCP_INGEST_TOKEN?.trim() ?? "",
|
|
295
|
+
serverName: process.env.CRATE_MCP_SERVER_NAME?.trim() ?? "",
|
|
296
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
297
|
+
outputJson: false,
|
|
298
|
+
help: false,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const getWriteDefaults = () => ({
|
|
302
|
+
sessionId: process.env.CODEX_THREAD_ID?.trim() ?? "",
|
|
303
|
+
occurredAt: process.env.CRATE_EVENT_OCCURRED_AT?.trim() ?? "",
|
|
304
|
+
correlationId: process.env.CRATE_EVENT_CORRELATION_ID?.trim() ?? "",
|
|
305
|
+
clientEventId: process.env.CRATE_EVENT_CLIENT_EVENT_ID?.trim() ?? "",
|
|
306
|
+
contextLabel: process.env.CRATE_EVENT_CONTEXT_LABEL?.trim() ?? "",
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const COMMON_SCHEMA = {
|
|
310
|
+
"--mcp-url": { key: "mcpUrl", type: "string" },
|
|
311
|
+
"--mcp-ingest-token": { key: "mcpIngestToken", type: "string" },
|
|
312
|
+
"--server-name": { key: "serverName", type: "string" },
|
|
313
|
+
"--timeout-ms": { key: "timeoutMs", type: "integer" },
|
|
314
|
+
"--json": { key: "outputJson", type: "boolean" },
|
|
315
|
+
"--help": { key: "help", type: "boolean" },
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const WRITE_SCHEMA = {
|
|
319
|
+
"--session-id": { key: "sessionId", type: "string" },
|
|
320
|
+
"--occurred-at": { key: "occurredAt", type: "string" },
|
|
321
|
+
"--correlation-id": { key: "correlationId", type: "string" },
|
|
322
|
+
"--client-event-id": { key: "clientEventId", type: "string" },
|
|
323
|
+
"--context-label": { key: "contextLabel", type: "string" },
|
|
324
|
+
"--actor-context": { key: "actorContext", type: "string" },
|
|
325
|
+
"--source-context": { key: "sourceContext", type: "string" },
|
|
326
|
+
"--project-id": { key: "projectId", type: "string" },
|
|
327
|
+
"--github-repo-full-name": { key: "githubRepoFullName", type: "string" },
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const normalizeCommonOptions = (options) => {
|
|
331
|
+
options.mcpUrl = toTrimmed(options.mcpUrl);
|
|
332
|
+
options.mcpIngestToken = toTrimmed(options.mcpIngestToken);
|
|
333
|
+
options.serverName = toTrimmed(options.serverName);
|
|
334
|
+
options.timeoutMs = parsePositiveInteger(options.timeoutMs, "--timeout-ms");
|
|
335
|
+
return options;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const normalizeWriteEventOptions = ({
|
|
339
|
+
options,
|
|
340
|
+
seenFlags,
|
|
341
|
+
defaultCorrelationId,
|
|
342
|
+
requireClientEventId,
|
|
343
|
+
}) => {
|
|
344
|
+
options.sessionId = normalizeSessionId(options.sessionId);
|
|
345
|
+
if (seenFlags.has("--session-id") && !options.sessionId) {
|
|
346
|
+
throw new Error("--session-id must match [A-Za-z0-9._:@/-]{1,128}.");
|
|
347
|
+
}
|
|
348
|
+
if (!options.sessionId) {
|
|
349
|
+
throw new Error("--session-id is required (or set CODEX_THREAD_ID).");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const normalizedOccurredAt = normalizeOccurredAt(options.occurredAt);
|
|
353
|
+
if (seenFlags.has("--occurred-at") && !normalizedOccurredAt) {
|
|
354
|
+
throw new Error("--occurred-at must be a valid timestamp.");
|
|
355
|
+
}
|
|
356
|
+
options.occurredAt = normalizedOccurredAt || new Date().toISOString();
|
|
357
|
+
|
|
358
|
+
options.correlationId = normalizeCorrelationId(options.correlationId);
|
|
359
|
+
if (seenFlags.has("--correlation-id") && !options.correlationId) {
|
|
360
|
+
throw new Error("--correlation-id must match [A-Za-z0-9._:@/-]{1,128}.");
|
|
361
|
+
}
|
|
362
|
+
if (!options.correlationId) {
|
|
363
|
+
options.correlationId = defaultCorrelationId;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
options.clientEventId = normalizeClientEventId(options.clientEventId);
|
|
367
|
+
if (seenFlags.has("--client-event-id") && !options.clientEventId) {
|
|
368
|
+
throw new Error("--client-event-id must be UUID.");
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
options.contextLabel = normalizeContextLabel(options.contextLabel);
|
|
372
|
+
if (seenFlags.has("--context-label") && !options.contextLabel) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
"--context-label must be checkpoint:<note>:<work_slug> or <note>:<work_slug>.",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (requireClientEventId) {
|
|
379
|
+
options.clientEventId = options.clientEventId || randomUUID();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
options.projectId = toTrimmed(options.projectId);
|
|
383
|
+
options.githubRepoFullName = normalizeGithubRepoFullName(options.githubRepoFullName);
|
|
384
|
+
if (seenFlags.has("--github-repo-full-name") && !options.githubRepoFullName) {
|
|
385
|
+
throw new Error("--github-repo-full-name must be owner/repo.");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
options.actorContext = toTrimmed(options.actorContext);
|
|
389
|
+
options.sourceContext = toTrimmed(options.sourceContext);
|
|
390
|
+
|
|
391
|
+
return options;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const readCodexServerConfigs = async () => {
|
|
395
|
+
const configPath = join(homedir(), ".codex", "config.toml");
|
|
396
|
+
let raw = "";
|
|
397
|
+
try {
|
|
398
|
+
raw = await readFile(configPath, "utf8");
|
|
399
|
+
} catch {
|
|
400
|
+
return [];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const servers = new Map();
|
|
404
|
+
let currentSection = "";
|
|
405
|
+
|
|
406
|
+
const ensureServer = (name) => {
|
|
407
|
+
if (!servers.has(name)) {
|
|
408
|
+
servers.set(name, {
|
|
409
|
+
name,
|
|
410
|
+
url: "",
|
|
411
|
+
token: "",
|
|
412
|
+
enabled: true,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return servers.get(name);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
for (const sourceLine of raw.split(/\r?\n/)) {
|
|
419
|
+
const line = sourceLine.trim();
|
|
420
|
+
if (!line || line.startsWith("#")) continue;
|
|
421
|
+
|
|
422
|
+
const sectionMatch = line.match(/^\[([^\]]+)\]$/);
|
|
423
|
+
if (sectionMatch) {
|
|
424
|
+
currentSection = sectionMatch[1];
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!currentSection.startsWith("mcp_servers.")) continue;
|
|
429
|
+
|
|
430
|
+
const relativeSection = currentSection.slice("mcp_servers.".length);
|
|
431
|
+
const isHeaderSection = relativeSection.endsWith(".http_headers");
|
|
432
|
+
const serverName = isHeaderSection
|
|
433
|
+
? relativeSection.slice(0, -".http_headers".length)
|
|
434
|
+
: relativeSection;
|
|
435
|
+
if (!serverName) continue;
|
|
436
|
+
|
|
437
|
+
const server = ensureServer(serverName);
|
|
438
|
+
|
|
439
|
+
const boolMatch = sourceLine.match(/^[\s]*([A-Za-z0-9._-]+)[\s]*=[\s]*(true|false)[\s]*$/i);
|
|
440
|
+
if (boolMatch && !isHeaderSection) {
|
|
441
|
+
if (boolMatch[1] === "enabled") {
|
|
442
|
+
server.enabled = boolMatch[2].toLowerCase() === "true";
|
|
443
|
+
}
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const pairMatch = sourceLine.match(
|
|
448
|
+
/^[\s]*([A-Za-z0-9._-]+)[\s]*=[\s]*"((?:[^"\\]|\\.)*)"[\s]*$/,
|
|
449
|
+
);
|
|
450
|
+
if (!pairMatch) continue;
|
|
451
|
+
|
|
452
|
+
const key = pairMatch[1];
|
|
453
|
+
const value = decodeTomlString(pairMatch[2]);
|
|
454
|
+
|
|
455
|
+
if (!isHeaderSection && key === "url") {
|
|
456
|
+
server.url = value;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (isHeaderSection && key === "x-crate-ingest-token") {
|
|
460
|
+
server.token = value;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return [...servers.values()];
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const parseGithubRepoFromRemoteUrl = (remoteUrl) => {
|
|
468
|
+
const raw = toTrimmed(remoteUrl);
|
|
469
|
+
if (!raw) return "";
|
|
470
|
+
return normalizeGithubRepoFullName(raw);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
const inferGithubRepoFromGitRemote = async () => {
|
|
474
|
+
try {
|
|
475
|
+
const { stdout } = await execFileAsync("git", ["remote", "get-url", "origin"], {
|
|
476
|
+
timeout: 5_000,
|
|
477
|
+
maxBuffer: 64 * 1024,
|
|
478
|
+
});
|
|
479
|
+
return parseGithubRepoFromRemoteUrl(stdout);
|
|
480
|
+
} catch {
|
|
481
|
+
return "";
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const resolveMcpConnection = async (options) => {
|
|
486
|
+
if (options.mcpUrl && options.mcpIngestToken) {
|
|
487
|
+
return {
|
|
488
|
+
mcpUrl: options.mcpUrl,
|
|
489
|
+
mcpIngestToken: options.mcpIngestToken,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (options.mcpUrl && !options.mcpIngestToken) {
|
|
494
|
+
throw new Error("MCP token is missing. Provide --mcp-ingest-token.");
|
|
495
|
+
}
|
|
496
|
+
if (!options.mcpUrl && options.mcpIngestToken) {
|
|
497
|
+
throw new Error("MCP URL is missing. Provide --mcp-url.");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const servers = await readCodexServerConfigs();
|
|
501
|
+
const enabledComplete = servers.filter(
|
|
502
|
+
(server) => server.enabled !== false && server.url && server.token,
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
if (options.serverName) {
|
|
506
|
+
const matched = enabledComplete.find((server) => server.name === options.serverName);
|
|
507
|
+
if (!matched) {
|
|
508
|
+
throw new Error(
|
|
509
|
+
`MCP server '${options.serverName}' was not found or incomplete in ~/.codex/config.toml.`,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
mcpUrl: matched.url,
|
|
514
|
+
mcpIngestToken: matched.token,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (enabledComplete.length === 1) {
|
|
519
|
+
return {
|
|
520
|
+
mcpUrl: enabledComplete[0].url,
|
|
521
|
+
mcpIngestToken: enabledComplete[0].token,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
throw new Error(
|
|
526
|
+
"Unable to resolve MCP server automatically. Provide --server-name, or --mcp-url and --mcp-ingest-token.",
|
|
527
|
+
);
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const parseEventStreamBody = (rawText) => {
|
|
531
|
+
if (typeof rawText !== "string" || rawText.trim() === "") return null;
|
|
532
|
+
|
|
533
|
+
const events = rawText.split(/\r?\n\r?\n/);
|
|
534
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
535
|
+
const chunk = events[index];
|
|
536
|
+
if (!chunk.includes("data:")) continue;
|
|
537
|
+
const dataLines = chunk
|
|
538
|
+
.split(/\r?\n/)
|
|
539
|
+
.filter((line) => line.startsWith("data:"))
|
|
540
|
+
.map((line) => line.slice("data:".length).trimStart());
|
|
541
|
+
if (dataLines.length === 0) continue;
|
|
542
|
+
const eventData = dataLines.join("\n").trim();
|
|
543
|
+
if (!eventData || eventData === "[DONE]") continue;
|
|
544
|
+
try {
|
|
545
|
+
return JSON.parse(eventData);
|
|
546
|
+
} catch {
|
|
547
|
+
// Ignore parse errors.
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return null;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const parseRpcResponseBody = async (response) => {
|
|
555
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
556
|
+
if (contentType.includes("application/json")) {
|
|
557
|
+
return response.json().catch(() => null);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const rawText = await response.text().catch(() => "");
|
|
561
|
+
if (!rawText) return null;
|
|
562
|
+
|
|
563
|
+
const fromSse = parseEventStreamBody(rawText);
|
|
564
|
+
if (fromSse) return fromSse;
|
|
565
|
+
|
|
566
|
+
try {
|
|
567
|
+
return JSON.parse(rawText);
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
const rpcCall = async ({ url, payload, timeoutMs, token, sessionId }) => {
|
|
574
|
+
const controller = new AbortController();
|
|
575
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
576
|
+
try {
|
|
577
|
+
const headers = {
|
|
578
|
+
"Content-Type": "application/json",
|
|
579
|
+
Accept: "application/json, text/event-stream",
|
|
580
|
+
"x-crate-ingest-token": token,
|
|
581
|
+
};
|
|
582
|
+
if (sessionId) {
|
|
583
|
+
headers["mcp-session-id"] = sessionId;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const response = await fetch(url, {
|
|
587
|
+
method: "POST",
|
|
588
|
+
headers,
|
|
589
|
+
body: JSON.stringify(payload),
|
|
590
|
+
signal: controller.signal,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const body = await parseRpcResponseBody(response);
|
|
594
|
+
const nextSessionId = response.headers.get("mcp-session-id") ?? sessionId ?? "";
|
|
595
|
+
return {
|
|
596
|
+
ok: response.ok,
|
|
597
|
+
status: response.status,
|
|
598
|
+
body,
|
|
599
|
+
sessionId: nextSessionId,
|
|
600
|
+
};
|
|
601
|
+
} finally {
|
|
602
|
+
clearTimeout(timer);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const extractToolText = (rpcBody) => {
|
|
607
|
+
const content = rpcBody?.result?.content;
|
|
608
|
+
if (!Array.isArray(content)) return "";
|
|
609
|
+
const textItem = content.find((item) => item && item.type === "text");
|
|
610
|
+
return typeof textItem?.text === "string" ? textItem.text : "";
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const assertRpcSuccess = (response, actionName) => {
|
|
614
|
+
if (!response.body) {
|
|
615
|
+
throw new Error(`${actionName}: empty response body (status=${response.status}).`);
|
|
616
|
+
}
|
|
617
|
+
if (response.body.error) {
|
|
618
|
+
const message = response.body.error?.message ?? "RPC error";
|
|
619
|
+
throw new Error(`${actionName}: ${message}`);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const createMcpClient = ({ url, token, timeoutMs, clientName, clientVersion }) => {
|
|
624
|
+
let rpcId = 1;
|
|
625
|
+
let sessionId = "";
|
|
626
|
+
|
|
627
|
+
const call = async (method, params) => {
|
|
628
|
+
const response = await rpcCall({
|
|
629
|
+
url,
|
|
630
|
+
payload: {
|
|
631
|
+
jsonrpc: "2.0",
|
|
632
|
+
id: rpcId,
|
|
633
|
+
method,
|
|
634
|
+
params,
|
|
635
|
+
},
|
|
636
|
+
timeoutMs,
|
|
637
|
+
token,
|
|
638
|
+
sessionId,
|
|
639
|
+
});
|
|
640
|
+
rpcId += 1;
|
|
641
|
+
if (response.sessionId) {
|
|
642
|
+
sessionId = response.sessionId;
|
|
643
|
+
}
|
|
644
|
+
return response;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const initialize = async () => {
|
|
648
|
+
const response = await call("initialize", {
|
|
649
|
+
protocolVersion: "2025-06-18",
|
|
650
|
+
capabilities: {},
|
|
651
|
+
clientInfo: {
|
|
652
|
+
name: clientName,
|
|
653
|
+
version: clientVersion,
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
assertRpcSuccess(response, "initialize");
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
const listTools = async () => {
|
|
660
|
+
const response = await call("tools/list", {});
|
|
661
|
+
assertRpcSuccess(response, "tools/list");
|
|
662
|
+
return response.body?.result ?? {};
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const callTool = async (name, args) => {
|
|
666
|
+
const response = await call("tools/call", {
|
|
667
|
+
name,
|
|
668
|
+
arguments: args,
|
|
669
|
+
});
|
|
670
|
+
assertRpcSuccess(response, `tools/call:${name}`);
|
|
671
|
+
|
|
672
|
+
const text = extractToolText(response.body) || "";
|
|
673
|
+
const isError = Boolean(response.body?.result?.isError);
|
|
674
|
+
return {
|
|
675
|
+
isError,
|
|
676
|
+
text,
|
|
677
|
+
result: response.body?.result ?? null,
|
|
678
|
+
};
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
return { initialize, listTools, callTool };
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const parseJsonText = (text, label) => {
|
|
685
|
+
try {
|
|
686
|
+
return JSON.parse(text);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
689
|
+
throw new Error(`${label}: failed to parse JSON response (${message}).`);
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const tryParseJsonText = (text) => {
|
|
694
|
+
try {
|
|
695
|
+
return JSON.parse(text);
|
|
696
|
+
} catch {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const callToolExpectJson = async (client, toolName, args) => {
|
|
702
|
+
const result = await client.callTool(toolName, args);
|
|
703
|
+
if (result.isError) {
|
|
704
|
+
throw new Error(`${toolName} failed: ${result.text}`);
|
|
705
|
+
}
|
|
706
|
+
return {
|
|
707
|
+
payload: parseJsonText(result.text, toolName),
|
|
708
|
+
text: result.text,
|
|
709
|
+
rawResult: result.result,
|
|
710
|
+
};
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const classifyTaskCloseError = (message) => {
|
|
714
|
+
const normalized = toTrimmed(message);
|
|
715
|
+
const matched = normalized.match(CHECKPOINT_PENDING_PATTERN);
|
|
716
|
+
if (!matched) {
|
|
717
|
+
return {
|
|
718
|
+
retryable: false,
|
|
719
|
+
status: null,
|
|
720
|
+
decision: null,
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const status = toTrimmed(matched[1]).toLowerCase();
|
|
725
|
+
const decision = toTrimmed(matched[2]).toLowerCase();
|
|
726
|
+
const retryableStatuses = new Set(["queued", "processing", "retrying"]);
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
retryable: decision === "pending" && retryableStatuses.has(status),
|
|
730
|
+
status: status || null,
|
|
731
|
+
decision: decision || null,
|
|
732
|
+
};
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const toCheckpointLookupArgs = (options) => {
|
|
736
|
+
const args = {};
|
|
737
|
+
if (options.projectId) args.project_id = options.projectId;
|
|
738
|
+
if (options.clientEventId) {
|
|
739
|
+
args.client_event_id = options.clientEventId;
|
|
740
|
+
return args;
|
|
741
|
+
}
|
|
742
|
+
args.session_id = options.sessionId;
|
|
743
|
+
args.context_label = options.contextLabel;
|
|
744
|
+
return args;
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const toTaskCloseArgs = (options) => {
|
|
748
|
+
const args = {
|
|
749
|
+
task_id: options.taskId,
|
|
750
|
+
status: "close",
|
|
751
|
+
session_id: options.sessionId,
|
|
752
|
+
occurred_at: options.occurredAt,
|
|
753
|
+
correlation_id: options.correlationId,
|
|
754
|
+
};
|
|
755
|
+
if (options.projectId) args.project_id = options.projectId;
|
|
756
|
+
if (options.githubRepoFullName) args.github_repo_full_name = options.githubRepoFullName;
|
|
757
|
+
if (options.clientEventId) args.client_event_id = options.clientEventId;
|
|
758
|
+
if (options.contextLabel) args.context_label = options.contextLabel;
|
|
759
|
+
if (options.actorContext) args.actor_context = options.actorContext;
|
|
760
|
+
if (options.sourceContext) args.source_context = options.sourceContext;
|
|
761
|
+
return args;
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
const tryTaskClose = async (client, options) => {
|
|
765
|
+
const result = await client.callTool("update_project_task", toTaskCloseArgs(options));
|
|
766
|
+
if (result.isError) {
|
|
767
|
+
return {
|
|
768
|
+
ok: false,
|
|
769
|
+
error: result.text,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
ok: true,
|
|
775
|
+
payload: parseJsonText(result.text, "update_project_task"),
|
|
776
|
+
};
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const waitForCheckpointPersisted = async (client, options) => {
|
|
780
|
+
const maxWaitMs = options.maxWaitSeconds * 1000;
|
|
781
|
+
const pollIntervalMs = options.pollIntervalSeconds * 1000;
|
|
782
|
+
const startedAt = Date.now();
|
|
783
|
+
const history = [];
|
|
784
|
+
|
|
785
|
+
while (Date.now() - startedAt <= maxWaitMs) {
|
|
786
|
+
const statusResult = await client.callTool(
|
|
787
|
+
"checkpoint_status",
|
|
788
|
+
toCheckpointLookupArgs(options),
|
|
789
|
+
);
|
|
790
|
+
if (statusResult.isError) {
|
|
791
|
+
throw new Error(`checkpoint_status failed: ${statusResult.text}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const payload = parseJsonText(statusResult.text, "checkpoint_status");
|
|
795
|
+
const record = {
|
|
796
|
+
checked_at: new Date().toISOString(),
|
|
797
|
+
status: toTrimmed(payload?.status),
|
|
798
|
+
decision: toTrimmed(payload?.gate?.decision),
|
|
799
|
+
passed: Boolean(payload?.gate?.passed),
|
|
800
|
+
matched_by: toTrimmed(payload?.matched_by) || null,
|
|
801
|
+
snapshot_id: toTrimmed(payload?.snapshot?.id) || null,
|
|
802
|
+
};
|
|
803
|
+
history.push(record);
|
|
804
|
+
|
|
805
|
+
if (record.passed || record.status === "persisted") {
|
|
806
|
+
return {
|
|
807
|
+
ok: true,
|
|
808
|
+
history,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (record.status === "failed" || record.decision === "fail") {
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
history,
|
|
816
|
+
reason: "checkpoint_failed",
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
await sleep(pollIntervalMs);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
ok: false,
|
|
825
|
+
history,
|
|
826
|
+
reason: "timeout",
|
|
827
|
+
};
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const parseCommand = (argv) => {
|
|
831
|
+
const normalizedArgv = [...argv];
|
|
832
|
+
while (normalizedArgv[0] === "--") {
|
|
833
|
+
normalizedArgv.shift();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (normalizedArgv.length === 0) {
|
|
837
|
+
return { key: "help", args: [] };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const first = toTrimmed(normalizedArgv[0]).toLowerCase();
|
|
841
|
+
const second = toTrimmed(normalizedArgv[1]).toLowerCase();
|
|
842
|
+
|
|
843
|
+
if (first === "help" || first === "--help" || first === "-h") {
|
|
844
|
+
return { key: "help", args: normalizedArgv.slice(1) };
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const compound = `${first} ${second}`;
|
|
848
|
+
const knownCompound = new Set([
|
|
849
|
+
"tool list",
|
|
850
|
+
"tool call",
|
|
851
|
+
"tasks list",
|
|
852
|
+
"tasks create",
|
|
853
|
+
"tasks update",
|
|
854
|
+
"tasks close",
|
|
855
|
+
"checkpoint status",
|
|
856
|
+
]);
|
|
857
|
+
|
|
858
|
+
if (knownCompound.has(compound)) {
|
|
859
|
+
return {
|
|
860
|
+
key: compound,
|
|
861
|
+
args: normalizedArgv.slice(2),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
throw new Error(`Unknown command: ${normalizedArgv.slice(0, 2).join(" ")}`);
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const createClient = async (options, runtime = {}) => {
|
|
869
|
+
const connection = await resolveMcpConnection(options);
|
|
870
|
+
const client = createMcpClient({
|
|
871
|
+
url: connection.mcpUrl,
|
|
872
|
+
token: connection.mcpIngestToken,
|
|
873
|
+
timeoutMs: options.timeoutMs,
|
|
874
|
+
clientName: runtime.clientName ?? "crate-cli",
|
|
875
|
+
clientVersion: runtime.clientVersion ?? "0.1.0",
|
|
876
|
+
});
|
|
877
|
+
await client.initialize();
|
|
878
|
+
return client;
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
const outputMaybeJson = (options, payload, textRenderer) => {
|
|
882
|
+
if (options.outputJson) {
|
|
883
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
process.stdout.write(textRenderer(payload));
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
const parseToolArgsObject = async (options) => {
|
|
890
|
+
const hasInline = Boolean(toTrimmed(options.argsJson));
|
|
891
|
+
const hasFile = Boolean(toTrimmed(options.argsFile));
|
|
892
|
+
if (hasInline && hasFile) {
|
|
893
|
+
throw new Error("Specify only one of --args-json or --args-file.");
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
let raw = "";
|
|
897
|
+
if (hasInline) {
|
|
898
|
+
raw = options.argsJson;
|
|
899
|
+
} else if (hasFile) {
|
|
900
|
+
raw = await readFile(options.argsFile, "utf8");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (!raw.trim()) {
|
|
904
|
+
return {};
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const parsed = tryParseJsonText(raw);
|
|
908
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
909
|
+
throw new Error("Tool arguments must be a JSON object.");
|
|
910
|
+
}
|
|
911
|
+
return parsed;
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const handleToolList = async (argv, runtime) => {
|
|
915
|
+
const { options } = parseFlags(argv, COMMON_SCHEMA, getCommonDefaults());
|
|
916
|
+
normalizeCommonOptions(options);
|
|
917
|
+
|
|
918
|
+
if (options.help) {
|
|
919
|
+
process.stdout.write(`${COMMAND_HELP["tool list"]}\n`);
|
|
920
|
+
return 0;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const client = await createClient(options, runtime);
|
|
924
|
+
const payload = await client.listTools();
|
|
925
|
+
const tools = Array.isArray(payload?.tools) ? payload.tools : [];
|
|
926
|
+
|
|
927
|
+
outputMaybeJson(options, { count: tools.length, tools: payload.tools ?? [] }, (data) => {
|
|
928
|
+
const lines = [`tools: ${data.count}`];
|
|
929
|
+
for (const tool of tools) {
|
|
930
|
+
const name = toTrimmed(tool?.name);
|
|
931
|
+
if (name) lines.push(`- ${name}`);
|
|
932
|
+
}
|
|
933
|
+
return `${lines.join("\n")}\n`;
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
return 0;
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const handleToolCall = async (argv, runtime) => {
|
|
940
|
+
const schema = {
|
|
941
|
+
...COMMON_SCHEMA,
|
|
942
|
+
"--name": { key: "toolName", type: "string" },
|
|
943
|
+
"--args-json": { key: "argsJson", type: "string" },
|
|
944
|
+
"--args-file": { key: "argsFile", type: "string" },
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
const { options } = parseFlags(argv, schema, {
|
|
948
|
+
...getCommonDefaults(),
|
|
949
|
+
toolName: "",
|
|
950
|
+
argsJson: "",
|
|
951
|
+
argsFile: "",
|
|
952
|
+
});
|
|
953
|
+
normalizeCommonOptions(options);
|
|
954
|
+
options.toolName = toTrimmed(options.toolName);
|
|
955
|
+
|
|
956
|
+
if (options.help) {
|
|
957
|
+
process.stdout.write(`${COMMAND_HELP["tool call"]}\n`);
|
|
958
|
+
return 0;
|
|
959
|
+
}
|
|
960
|
+
if (!options.toolName) {
|
|
961
|
+
throw new Error("--name is required.");
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const args = await parseToolArgsObject(options);
|
|
965
|
+
const client = await createClient(options, runtime);
|
|
966
|
+
const result = await client.callTool(options.toolName, args);
|
|
967
|
+
|
|
968
|
+
if (result.isError) {
|
|
969
|
+
throw new Error(`${options.toolName} failed: ${result.text}`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
const parsed = tryParseJsonText(result.text);
|
|
973
|
+
outputMaybeJson(
|
|
974
|
+
options,
|
|
975
|
+
{
|
|
976
|
+
tool: options.toolName,
|
|
977
|
+
args,
|
|
978
|
+
output: parsed ?? result.text,
|
|
979
|
+
raw_result: result.result,
|
|
980
|
+
},
|
|
981
|
+
(payload) => {
|
|
982
|
+
const lines = [`tool call succeeded: ${payload.tool}`];
|
|
983
|
+
if (typeof payload.output === "string") {
|
|
984
|
+
lines.push(payload.output);
|
|
985
|
+
} else {
|
|
986
|
+
lines.push(JSON.stringify(payload.output, null, 2));
|
|
987
|
+
}
|
|
988
|
+
return `${lines.join("\n")}\n`;
|
|
989
|
+
},
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
return 0;
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
const handleTasksList = async (argv, runtime) => {
|
|
996
|
+
const schema = {
|
|
997
|
+
...COMMON_SCHEMA,
|
|
998
|
+
"--project-id": { key: "projectId", type: "string" },
|
|
999
|
+
"--status": { key: "status", type: "string" },
|
|
1000
|
+
"--limit": { key: "limit", type: "integer" },
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
const { options } = parseFlags(argv, schema, {
|
|
1004
|
+
...getCommonDefaults(),
|
|
1005
|
+
projectId: "",
|
|
1006
|
+
status: "",
|
|
1007
|
+
limit: 50,
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
normalizeCommonOptions(options);
|
|
1011
|
+
options.projectId = toTrimmed(options.projectId);
|
|
1012
|
+
options.status = toTrimmed(options.status);
|
|
1013
|
+
|
|
1014
|
+
if (options.help) {
|
|
1015
|
+
process.stdout.write(`${COMMAND_HELP["tasks list"]}\n`);
|
|
1016
|
+
return 0;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const args = {};
|
|
1020
|
+
if (options.projectId) args.project_id = options.projectId;
|
|
1021
|
+
if (options.status) args.status = options.status;
|
|
1022
|
+
if (options.limit) args.limit = options.limit;
|
|
1023
|
+
|
|
1024
|
+
const client = await createClient(options, runtime);
|
|
1025
|
+
const { payload } = await callToolExpectJson(client, "list_project_tasks", args);
|
|
1026
|
+
|
|
1027
|
+
outputMaybeJson(options, payload, (data) => {
|
|
1028
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
1029
|
+
const lines = [`tasks: ${tasks.length}`];
|
|
1030
|
+
for (const task of tasks) {
|
|
1031
|
+
const id = toTrimmed(task?.id);
|
|
1032
|
+
const status = toTrimmed(task?.status) || "unknown";
|
|
1033
|
+
const title = toTrimmed(task?.title) || "(no title)";
|
|
1034
|
+
lines.push(`- [${status}] ${id} ${title}`);
|
|
1035
|
+
}
|
|
1036
|
+
return `${lines.join("\n")}\n`;
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
return 0;
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const handleTasksCreate = async (argv, runtime) => {
|
|
1043
|
+
const schema = {
|
|
1044
|
+
...COMMON_SCHEMA,
|
|
1045
|
+
...WRITE_SCHEMA,
|
|
1046
|
+
"--title": { key: "title", type: "string" },
|
|
1047
|
+
"--description": { key: "description", type: "string" },
|
|
1048
|
+
};
|
|
1049
|
+
|
|
1050
|
+
const { options, seenFlags } = parseFlags(argv, schema, {
|
|
1051
|
+
...getCommonDefaults(),
|
|
1052
|
+
...getWriteDefaults(),
|
|
1053
|
+
title: "",
|
|
1054
|
+
description: "",
|
|
1055
|
+
actorContext: "agent",
|
|
1056
|
+
sourceContext: "crate-cli",
|
|
1057
|
+
projectId: "",
|
|
1058
|
+
githubRepoFullName: process.env.CRATE_GITHUB_REPO_FULL_NAME?.trim() ?? "",
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
normalizeCommonOptions(options);
|
|
1062
|
+
options.title = toTrimmed(options.title);
|
|
1063
|
+
options.description = toTrimmed(options.description);
|
|
1064
|
+
|
|
1065
|
+
if (options.help) {
|
|
1066
|
+
process.stdout.write(`${COMMAND_HELP["tasks create"]}\n`);
|
|
1067
|
+
return 0;
|
|
1068
|
+
}
|
|
1069
|
+
if (!options.title) {
|
|
1070
|
+
throw new Error("--title is required.");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
normalizeWriteEventOptions({
|
|
1074
|
+
options,
|
|
1075
|
+
seenFlags,
|
|
1076
|
+
defaultCorrelationId: `task/create/${Date.now()}`,
|
|
1077
|
+
requireClientEventId: true,
|
|
1078
|
+
});
|
|
1079
|
+
|
|
1080
|
+
if (!options.githubRepoFullName) {
|
|
1081
|
+
options.githubRepoFullName = await inferGithubRepoFromGitRemote();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
const args = {
|
|
1085
|
+
title: options.title,
|
|
1086
|
+
session_id: options.sessionId,
|
|
1087
|
+
occurred_at: options.occurredAt,
|
|
1088
|
+
client_event_id: options.clientEventId,
|
|
1089
|
+
correlation_id: options.correlationId,
|
|
1090
|
+
};
|
|
1091
|
+
if (options.description) args.description = options.description;
|
|
1092
|
+
if (options.projectId) args.project_id = options.projectId;
|
|
1093
|
+
if (options.contextLabel) args.context_label = options.contextLabel;
|
|
1094
|
+
if (options.actorContext) args.actor_context = options.actorContext;
|
|
1095
|
+
if (options.sourceContext) args.source_context = options.sourceContext;
|
|
1096
|
+
if (options.githubRepoFullName) args.github_repo_full_name = options.githubRepoFullName;
|
|
1097
|
+
|
|
1098
|
+
const client = await createClient(options, runtime);
|
|
1099
|
+
const { payload } = await callToolExpectJson(client, "create_project_task", args);
|
|
1100
|
+
|
|
1101
|
+
outputMaybeJson(options, payload, (data) => {
|
|
1102
|
+
const task = data?.task ?? {};
|
|
1103
|
+
const lines = ["task created"];
|
|
1104
|
+
if (task.id) lines.push(`- task_id: ${task.id}`);
|
|
1105
|
+
if (task.status) lines.push(`- status: ${task.status}`);
|
|
1106
|
+
if (task.title) lines.push(`- title: ${task.title}`);
|
|
1107
|
+
return `${lines.join("\n")}\n`;
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
return 0;
|
|
1111
|
+
};
|
|
1112
|
+
|
|
1113
|
+
const handleTasksUpdate = async (argv, runtime) => {
|
|
1114
|
+
const schema = {
|
|
1115
|
+
...COMMON_SCHEMA,
|
|
1116
|
+
...WRITE_SCHEMA,
|
|
1117
|
+
"--task-id": { key: "taskId", type: "string" },
|
|
1118
|
+
"--title": { key: "title", type: "string" },
|
|
1119
|
+
"--description": { key: "description", type: "string" },
|
|
1120
|
+
"--status": { key: "status", type: "string" },
|
|
1121
|
+
};
|
|
1122
|
+
|
|
1123
|
+
const { options, seenFlags } = parseFlags(argv, schema, {
|
|
1124
|
+
...getCommonDefaults(),
|
|
1125
|
+
...getWriteDefaults(),
|
|
1126
|
+
taskId: "",
|
|
1127
|
+
title: "",
|
|
1128
|
+
description: "",
|
|
1129
|
+
status: "",
|
|
1130
|
+
actorContext: "agent",
|
|
1131
|
+
sourceContext: "crate-cli",
|
|
1132
|
+
projectId: "",
|
|
1133
|
+
githubRepoFullName: process.env.CRATE_GITHUB_REPO_FULL_NAME?.trim() ?? "",
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
normalizeCommonOptions(options);
|
|
1137
|
+
options.taskId = toTrimmed(options.taskId);
|
|
1138
|
+
options.title = toTrimmed(options.title);
|
|
1139
|
+
options.description = toTrimmed(options.description);
|
|
1140
|
+
options.status = toTrimmed(options.status);
|
|
1141
|
+
|
|
1142
|
+
if (options.help) {
|
|
1143
|
+
process.stdout.write(`${COMMAND_HELP["tasks update"]}\n`);
|
|
1144
|
+
return 0;
|
|
1145
|
+
}
|
|
1146
|
+
if (!options.taskId) {
|
|
1147
|
+
throw new Error("--task-id is required.");
|
|
1148
|
+
}
|
|
1149
|
+
if (!options.title && !options.description && !options.status) {
|
|
1150
|
+
throw new Error("Specify at least one of --title, --description, --status.");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const isClose = options.status === "close";
|
|
1154
|
+
normalizeWriteEventOptions({
|
|
1155
|
+
options,
|
|
1156
|
+
seenFlags,
|
|
1157
|
+
defaultCorrelationId: `task/update/${options.taskId}/${Date.now()}`,
|
|
1158
|
+
requireClientEventId: !isClose,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
if (isClose && !options.clientEventId && !options.contextLabel) {
|
|
1162
|
+
throw new Error(
|
|
1163
|
+
"status=close requires checkpoint evidence: --client-event-id (preferred) or --context-label.",
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (!options.githubRepoFullName) {
|
|
1168
|
+
options.githubRepoFullName = await inferGithubRepoFromGitRemote();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const args = {
|
|
1172
|
+
task_id: options.taskId,
|
|
1173
|
+
session_id: options.sessionId,
|
|
1174
|
+
occurred_at: options.occurredAt,
|
|
1175
|
+
correlation_id: options.correlationId,
|
|
1176
|
+
};
|
|
1177
|
+
if (options.title) args.title = options.title;
|
|
1178
|
+
if (options.description) args.description = options.description;
|
|
1179
|
+
if (options.status) args.status = options.status;
|
|
1180
|
+
if (options.projectId) args.project_id = options.projectId;
|
|
1181
|
+
if (options.clientEventId) args.client_event_id = options.clientEventId;
|
|
1182
|
+
if (options.contextLabel) args.context_label = options.contextLabel;
|
|
1183
|
+
if (options.actorContext) args.actor_context = options.actorContext;
|
|
1184
|
+
if (options.sourceContext) args.source_context = options.sourceContext;
|
|
1185
|
+
if (options.githubRepoFullName) args.github_repo_full_name = options.githubRepoFullName;
|
|
1186
|
+
|
|
1187
|
+
const client = await createClient(options, runtime);
|
|
1188
|
+
const { payload } = await callToolExpectJson(client, "update_project_task", args);
|
|
1189
|
+
|
|
1190
|
+
outputMaybeJson(options, payload, (data) => {
|
|
1191
|
+
const task = data?.task ?? {};
|
|
1192
|
+
const lines = ["task updated"];
|
|
1193
|
+
if (task.id) lines.push(`- task_id: ${task.id}`);
|
|
1194
|
+
if (task.status) lines.push(`- status: ${task.status}`);
|
|
1195
|
+
if (task.title) lines.push(`- title: ${task.title}`);
|
|
1196
|
+
return `${lines.join("\n")}\n`;
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
return 0;
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
const handleCheckpointStatus = async (argv, runtime) => {
|
|
1203
|
+
const schema = {
|
|
1204
|
+
...COMMON_SCHEMA,
|
|
1205
|
+
"--project-id": { key: "projectId", type: "string" },
|
|
1206
|
+
"--client-event-id": { key: "clientEventId", type: "string" },
|
|
1207
|
+
"--session-id": { key: "sessionId", type: "string" },
|
|
1208
|
+
"--context-label": { key: "contextLabel", type: "string" },
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
const { options, seenFlags } = parseFlags(argv, schema, {
|
|
1212
|
+
...getCommonDefaults(),
|
|
1213
|
+
projectId: "",
|
|
1214
|
+
clientEventId: process.env.CRATE_EVENT_CLIENT_EVENT_ID?.trim() ?? "",
|
|
1215
|
+
sessionId: process.env.CODEX_THREAD_ID?.trim() ?? "",
|
|
1216
|
+
contextLabel: process.env.CRATE_EVENT_CONTEXT_LABEL?.trim() ?? "",
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
normalizeCommonOptions(options);
|
|
1220
|
+
options.projectId = toTrimmed(options.projectId);
|
|
1221
|
+
options.clientEventId = normalizeClientEventId(options.clientEventId);
|
|
1222
|
+
options.sessionId = normalizeSessionId(options.sessionId);
|
|
1223
|
+
options.contextLabel = normalizeContextLabel(options.contextLabel);
|
|
1224
|
+
|
|
1225
|
+
if (options.help) {
|
|
1226
|
+
process.stdout.write(`${COMMAND_HELP["checkpoint status"]}\n`);
|
|
1227
|
+
return 0;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (seenFlags.has("--client-event-id") && !options.clientEventId) {
|
|
1231
|
+
throw new Error("--client-event-id must be UUID.");
|
|
1232
|
+
}
|
|
1233
|
+
if (seenFlags.has("--session-id") && !options.sessionId) {
|
|
1234
|
+
throw new Error("--session-id must match [A-Za-z0-9._:@/-]{1,128}.");
|
|
1235
|
+
}
|
|
1236
|
+
if (seenFlags.has("--context-label") && !options.contextLabel) {
|
|
1237
|
+
throw new Error(
|
|
1238
|
+
"--context-label must be checkpoint:<note>:<work_slug> or <note>:<work_slug>.",
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
if (!options.clientEventId && (!options.sessionId || !options.contextLabel)) {
|
|
1243
|
+
throw new Error(
|
|
1244
|
+
"checkpoint lookup requires --client-event-id, or both --session-id and --context-label.",
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const args = toCheckpointLookupArgs(options);
|
|
1249
|
+
const client = await createClient(options, runtime);
|
|
1250
|
+
const { payload } = await callToolExpectJson(client, "checkpoint_status", args);
|
|
1251
|
+
|
|
1252
|
+
outputMaybeJson(options, payload, (data) => {
|
|
1253
|
+
const lines = ["checkpoint status"];
|
|
1254
|
+
lines.push(`- status: ${toTrimmed(data?.status) || "unknown"}`);
|
|
1255
|
+
lines.push(`- decision: ${toTrimmed(data?.gate?.decision) || "unknown"}`);
|
|
1256
|
+
lines.push(`- passed: ${Boolean(data?.gate?.passed)}`);
|
|
1257
|
+
if (data?.matched_by) lines.push(`- matched_by: ${data.matched_by}`);
|
|
1258
|
+
return `${lines.join("\n")}\n`;
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
return 0;
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
const parseTasksCloseOptions = (argv) => {
|
|
1265
|
+
const schema = {
|
|
1266
|
+
...COMMON_SCHEMA,
|
|
1267
|
+
...WRITE_SCHEMA,
|
|
1268
|
+
"--task-id": { key: "taskId", type: "string" },
|
|
1269
|
+
"--max-wait-seconds": { key: "maxWaitSeconds", type: "integer" },
|
|
1270
|
+
"--poll-interval-seconds": { key: "pollIntervalSeconds", type: "integer" },
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
const { options, seenFlags } = parseFlags(argv, schema, {
|
|
1274
|
+
...getCommonDefaults(),
|
|
1275
|
+
...getWriteDefaults(),
|
|
1276
|
+
taskId: "",
|
|
1277
|
+
projectId: process.env.CRATE_PROJECT_ID?.trim() ?? "",
|
|
1278
|
+
githubRepoFullName: process.env.CRATE_GITHUB_REPO_FULL_NAME?.trim() ?? "",
|
|
1279
|
+
maxWaitSeconds: DEFAULT_MAX_WAIT_SECONDS,
|
|
1280
|
+
pollIntervalSeconds: DEFAULT_POLL_INTERVAL_SECONDS,
|
|
1281
|
+
actorContext: "agent",
|
|
1282
|
+
sourceContext: "crate-cli",
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
normalizeCommonOptions(options);
|
|
1286
|
+
if (options.help) {
|
|
1287
|
+
return options;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
options.taskId = toTrimmed(options.taskId);
|
|
1291
|
+
if (!options.taskId) {
|
|
1292
|
+
throw new Error("--task-id is required.");
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
normalizeWriteEventOptions({
|
|
1296
|
+
options,
|
|
1297
|
+
seenFlags,
|
|
1298
|
+
defaultCorrelationId: `task/close/${options.taskId || "unknown"}/${Date.now()}`,
|
|
1299
|
+
requireClientEventId: false,
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
options.maxWaitSeconds = parsePositiveInteger(options.maxWaitSeconds, "--max-wait-seconds");
|
|
1303
|
+
options.pollIntervalSeconds = parsePositiveInteger(
|
|
1304
|
+
options.pollIntervalSeconds,
|
|
1305
|
+
"--poll-interval-seconds",
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1308
|
+
if (!options.clientEventId && !options.contextLabel) {
|
|
1309
|
+
throw new Error(
|
|
1310
|
+
"checkpoint evidence is required: --client-event-id (preferred) or --context-label.",
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
return options;
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
const handleTasksClose = async (argv, runtime) => {
|
|
1318
|
+
const options = parseTasksCloseOptions(argv);
|
|
1319
|
+
if (options.help) {
|
|
1320
|
+
process.stdout.write(`${COMMAND_HELP["tasks close"]}\n`);
|
|
1321
|
+
return 0;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (!options.githubRepoFullName) {
|
|
1325
|
+
options.githubRepoFullName = await inferGithubRepoFromGitRemote();
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const client = await createClient(options, runtime);
|
|
1329
|
+
|
|
1330
|
+
const closeAttempts = [];
|
|
1331
|
+
let closeResult = await tryTaskClose(client, options);
|
|
1332
|
+
closeAttempts.push({
|
|
1333
|
+
attempted_at: new Date().toISOString(),
|
|
1334
|
+
ok: closeResult.ok,
|
|
1335
|
+
error: closeResult.ok ? null : closeResult.error,
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
let checkpointWait = null;
|
|
1339
|
+
|
|
1340
|
+
if (!closeResult.ok) {
|
|
1341
|
+
const classified = classifyTaskCloseError(closeResult.error);
|
|
1342
|
+
if (!classified.retryable) {
|
|
1343
|
+
throw new Error(closeResult.error || "task close failed.");
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
checkpointWait = await waitForCheckpointPersisted(client, options);
|
|
1347
|
+
if (!checkpointWait.ok) {
|
|
1348
|
+
const tail = checkpointWait.history.at(-1);
|
|
1349
|
+
throw new Error(
|
|
1350
|
+
`task close retry aborted: reason=${checkpointWait.reason}, last_status=${tail?.status ?? "n/a"}, last_decision=${tail?.decision ?? "n/a"}`,
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
closeResult = await tryTaskClose(client, options);
|
|
1355
|
+
closeAttempts.push({
|
|
1356
|
+
attempted_at: new Date().toISOString(),
|
|
1357
|
+
ok: closeResult.ok,
|
|
1358
|
+
error: closeResult.ok ? null : closeResult.error,
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
if (!closeResult.ok) {
|
|
1362
|
+
throw new Error(closeResult.error || "task close failed after checkpoint persisted.");
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
const payload = {
|
|
1367
|
+
status: "closed",
|
|
1368
|
+
task_id: options.taskId,
|
|
1369
|
+
session_id: options.sessionId,
|
|
1370
|
+
event_contract: {
|
|
1371
|
+
occurred_at: options.occurredAt,
|
|
1372
|
+
correlation_id: options.correlationId,
|
|
1373
|
+
client_event_id: options.clientEventId || null,
|
|
1374
|
+
context_label: options.contextLabel || null,
|
|
1375
|
+
},
|
|
1376
|
+
checkpoint_key: options.clientEventId
|
|
1377
|
+
? { client_event_id: options.clientEventId }
|
|
1378
|
+
: { session_id: options.sessionId, context_label: options.contextLabel },
|
|
1379
|
+
attempts: closeAttempts,
|
|
1380
|
+
checkpoint_wait: checkpointWait,
|
|
1381
|
+
result: closeResult.payload,
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
outputMaybeJson(options, payload, (data) => {
|
|
1385
|
+
const lines = ["task close completed with checkpoint-aware retry"];
|
|
1386
|
+
lines.push(`- task_id: ${data.task_id}`);
|
|
1387
|
+
lines.push(`- session_id: ${data.session_id}`);
|
|
1388
|
+
lines.push(`- close_attempts: ${data.attempts.length}`);
|
|
1389
|
+
if (data.checkpoint_wait) {
|
|
1390
|
+
lines.push(`- checkpoint_polls: ${data.checkpoint_wait.history.length}`);
|
|
1391
|
+
}
|
|
1392
|
+
return `${lines.join("\n")}\n`;
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
return 0;
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
const runWithCommand = async (command, args, runtime) => {
|
|
1399
|
+
switch (command) {
|
|
1400
|
+
case "tool list":
|
|
1401
|
+
return handleToolList(args, runtime);
|
|
1402
|
+
case "tool call":
|
|
1403
|
+
return handleToolCall(args, runtime);
|
|
1404
|
+
case "tasks list":
|
|
1405
|
+
return handleTasksList(args, runtime);
|
|
1406
|
+
case "tasks create":
|
|
1407
|
+
return handleTasksCreate(args, runtime);
|
|
1408
|
+
case "tasks update":
|
|
1409
|
+
return handleTasksUpdate(args, runtime);
|
|
1410
|
+
case "tasks close":
|
|
1411
|
+
return handleTasksClose(args, runtime);
|
|
1412
|
+
case "checkpoint status":
|
|
1413
|
+
return handleCheckpointStatus(args, runtime);
|
|
1414
|
+
case "help":
|
|
1415
|
+
{
|
|
1416
|
+
const normalized = args
|
|
1417
|
+
.map((value) => toTrimmed(value).toLowerCase())
|
|
1418
|
+
.filter((value) => value.length > 0);
|
|
1419
|
+
const compound =
|
|
1420
|
+
normalized.length >= 2 ? `${normalized[0]} ${normalized[1]}` : normalized[0] || "";
|
|
1421
|
+
if (COMMAND_HELP[compound]) {
|
|
1422
|
+
process.stdout.write(`${COMMAND_HELP[compound]}\n`);
|
|
1423
|
+
return 0;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
process.stdout.write(`${HELP_TEXT}\n`);
|
|
1427
|
+
return 0;
|
|
1428
|
+
default:
|
|
1429
|
+
if (COMMAND_HELP[command]) {
|
|
1430
|
+
process.stdout.write(`${COMMAND_HELP[command]}\n`);
|
|
1431
|
+
return 0;
|
|
1432
|
+
}
|
|
1433
|
+
process.stdout.write(`${HELP_TEXT}\n`);
|
|
1434
|
+
return 0;
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
export const runCrateCli = async (argv = process.argv.slice(2), runtime = {}) => {
|
|
1439
|
+
const parsed = parseCommand(argv);
|
|
1440
|
+
return runWithCommand(parsed.key, parsed.args, runtime);
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
export const __testables = {
|
|
1444
|
+
classifyTaskCloseError,
|
|
1445
|
+
normalizeContextLabel,
|
|
1446
|
+
normalizeClientEventId,
|
|
1447
|
+
normalizeSessionId,
|
|
1448
|
+
parseCommand,
|
|
1449
|
+
parseTasksCloseOptions,
|
|
1450
|
+
parseFlags,
|
|
1451
|
+
parseGithubRepoFromRemoteUrl,
|
|
1452
|
+
};
|
|
1453
|
+
|
|
1454
|
+
const isExecutedAsScript = () => {
|
|
1455
|
+
if (!process.argv[1]) return false;
|
|
1456
|
+
try {
|
|
1457
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
1458
|
+
} catch {
|
|
1459
|
+
return false;
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
|
|
1463
|
+
if (isExecutedAsScript()) {
|
|
1464
|
+
runCrateCli()
|
|
1465
|
+
.then((code) => {
|
|
1466
|
+
process.exitCode = code;
|
|
1467
|
+
})
|
|
1468
|
+
.catch((error) => {
|
|
1469
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1470
|
+
console.error(`crate-cli failed: ${message}`);
|
|
1471
|
+
process.exitCode = 1;
|
|
1472
|
+
});
|
|
1473
|
+
}
|