assent-cli 0.0.0-stage → 0.6.1

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/dist/cli.js ADDED
@@ -0,0 +1,636 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseWhen = parseWhen;
4
+ exports.parseAction = parseAction;
5
+ exports.parseItem = parseItem;
6
+ exports.parseRef = parseRef;
7
+ exports.redactedTail = redactedTail;
8
+ exports.itemState = itemState;
9
+ exports.commandName = commandName;
10
+ exports.runCli = runCli;
11
+ const node_crypto_1 = require("node:crypto");
12
+ const node_path_1 = require("node:path");
13
+ const args_1 = require("./args");
14
+ const client_1 = require("./client");
15
+ const config_1 = require("./config");
16
+ const exit_codes_1 = require("./exit-codes");
17
+ const redact_1 = require("./redact");
18
+ const usage_1 = require("./usage");
19
+ const hook_1 = require("./hook");
20
+ const claude_code_1 = require("./claude-code");
21
+ const mcp_bridge_1 = require("./mcp-bridge");
22
+ const STRING = { type: "string" };
23
+ const MANY = { type: "string", multiple: true };
24
+ const FLAG = { type: "boolean" };
25
+ const KINDS = ["todo", "approve", "pay", "login", "code", "reply"];
26
+ const STYLES = ["primary", "secondary", "danger"];
27
+ const TASK_OPTIONS = { title: STRING, detail: STRING, url: STRING, due: STRING, remind: STRING, dedupe: STRING, run: STRING, why: STRING, project: STRING, track: STRING, question: STRING, "answer-type": STRING, effort: STRING, series: STRING, "idempotency-key": STRING, "allow-duplicate": FLAG };
28
+ const ANSWER_TYPES = ["yes_no", "choice", "text", "number", "done"];
29
+ const BARE_DATE = /^\d{4}-\d{2}-\d{2}$/;
30
+ const LOCAL_DATE_TIME = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2})?)$/;
31
+ const CLEAR_WORDS = new Set(["none", "clear"]);
32
+ const ITEM_OUTCOMES = ["done", "skipped", "needs_you", "failed"];
33
+ const ITEM_TYPES = ["note", "task", "run", "agent"];
34
+ const RELATIONS = ["produced", "about", "blocks", "related"];
35
+ const DEFAULT_TAIL_CHARS = 8000;
36
+ const SUMMARY_LIMIT = 1000;
37
+ const LOG_MARGIN_CHARS = 1024;
38
+ function text(values, name) {
39
+ const value = values[name];
40
+ return typeof value === "string" ? value : undefined;
41
+ }
42
+ function required(values, name) {
43
+ const value = text(values, name);
44
+ if (!value)
45
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--${name} is required`);
46
+ return value;
47
+ }
48
+ function positional(args, index, label) {
49
+ const value = args.positionals[index];
50
+ if (!value)
51
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `${label} is required`);
52
+ return value;
53
+ }
54
+ function many(values, name) {
55
+ const value = values[name];
56
+ return Array.isArray(value) ? value : [];
57
+ }
58
+ function wholeNumber(values, name, min, max) {
59
+ const raw = text(values, name);
60
+ if (raw === undefined)
61
+ return undefined;
62
+ const value = Number(raw);
63
+ if (!Number.isInteger(value) || value < min || value > max)
64
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--${name} must be a whole number from ${min} to ${max}`);
65
+ return value;
66
+ }
67
+ function parseWhen(raw, flag, warn) {
68
+ const value = raw.trim();
69
+ if (BARE_DATE.test(value) && !Number.isNaN(Date.parse(value))) {
70
+ warn(`--${flag} ${value} has no time, so it means 09:00 on that day in your Assent time zone. Pass a time like ${value}T17:00 to choose one.`);
71
+ return value;
72
+ }
73
+ const local = LOCAL_DATE_TIME.exec(value);
74
+ const ms = local ? new Date(`${local[1]}T${local[2]}`).getTime() : Date.parse(value);
75
+ if (Number.isNaN(ms) || !(local || /T/.test(value)))
76
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--${flag} must be a date and time like 2026-10-15T17:00, 2026-10-15 17:00 or 2026-10-15T17:00:00+01:00, or a date like 2026-10-15 for 09:00`);
77
+ return new Date(ms).toISOString();
78
+ }
79
+ function when(values, flag, deps) {
80
+ const raw = text(values, flag);
81
+ return raw === undefined ? undefined : parseWhen(raw, flag, deps.stderr);
82
+ }
83
+ function whenOrClear(values, flag, deps) {
84
+ const raw = text(values, flag);
85
+ if (raw !== undefined && CLEAR_WORDS.has(raw.trim().toLowerCase()))
86
+ return null;
87
+ return when(values, flag, deps);
88
+ }
89
+ function parseAction(raw) {
90
+ const [id, ...rest] = raw.split(":");
91
+ const last = rest[rest.length - 1];
92
+ const style = rest.length > 1 && STYLES.includes(last) ? last : undefined;
93
+ const label = (style ? rest.slice(0, -1) : rest).join(":");
94
+ if (!id || !label)
95
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--action must look like id:label or id:label:primary, got ${raw}`);
96
+ return style ? { id, label, style } : { id, label };
97
+ }
98
+ function parseItem(raw) {
99
+ const [id, ...rest] = raw.split(":");
100
+ const last = rest[rest.length - 1];
101
+ const flag = rest.length > 1 && (last === "selected" || last === "unselected") ? last : undefined;
102
+ const title = (flag ? rest.slice(0, -1) : rest).join(":");
103
+ if (!id || !title)
104
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--item must look like id:title or id:title:unselected, got ${raw}`);
105
+ return { id, title, selected: flag !== "unselected" };
106
+ }
107
+ function runUuid(values, env) {
108
+ return text(values, "run") ?? (0, config_1.assentEnv)(env, "RUN_UUID");
109
+ }
110
+ function parseRef(raw, flag) {
111
+ const [type, uuid] = (raw ?? "").split(":");
112
+ if (!ITEM_TYPES.includes(type) || !uuid)
113
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--${flag} must look like type:uuid with type one of ${ITEM_TYPES.join(", ")}`);
114
+ return { type, uuid };
115
+ }
116
+ function parseReason(raw) {
117
+ const at = raw.indexOf(":");
118
+ if (at < 1)
119
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--reason must look like id:text");
120
+ return [raw.slice(0, at), raw.slice(at + 1)];
121
+ }
122
+ const MAX_BADGES = 4;
123
+ const MAX_LINKS = 4;
124
+ function splitItemFlag(raw, flag, shape) {
125
+ const at = raw.indexOf(":");
126
+ if (at < 1 || at === raw.length - 1)
127
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--${flag} must look like ${shape}`);
128
+ return [raw.slice(0, at), raw.slice(at + 1)];
129
+ }
130
+ function groupByItem(entries, flag, base) {
131
+ const grouped = new Map();
132
+ for (const [id, value] of entries) {
133
+ if (!base.some((item) => item.id === id))
134
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--${flag} ${id} does not match any item id`);
135
+ grouped.set(id, [...(grouped.get(id) ?? []), value]);
136
+ }
137
+ return grouped;
138
+ }
139
+ function appended(item, key, added, max) {
140
+ const all = [...(item[key] ?? []), ...added];
141
+ if (all.length > max)
142
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Item ${String(item.id)} can have at most ${max} ${key}`);
143
+ return all;
144
+ }
145
+ async function taskItems(values, deps) {
146
+ const fromJson = text(values, "item-json");
147
+ const base = fromJson === undefined ? many(values, "item").map(parseItem) : await (0, args_1.readItemsJson)(fromJson, (path) => deps.readFile(path), () => deps.readStdin());
148
+ const recommended = new Set(many(values, "recommend"));
149
+ const reasons = new Map(many(values, "reason").map(parseReason));
150
+ for (const id of recommended)
151
+ if (!base.some((item) => item.id === id))
152
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--recommend ${id} does not match any item id`);
153
+ for (const id of reasons.keys())
154
+ if (!base.some((item) => item.id === id))
155
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--reason ${id} does not match any item id`);
156
+ const badges = groupByItem(many(values, "badge").map((raw) => { const [id, rest] = splitItemFlag(raw, "badge", "itemId:label[:tone]"); return [id, (0, args_1.parseBadge)(rest)]; }), "badge", base);
157
+ const links = groupByItem(many(values, "link").map((raw) => { const [id, rest] = splitItemFlag(raw, "link", "itemId:label:url"); const [label, ...url] = rest.split(":"); return [id, (0, args_1.parseLink)(`${label}=${url.join(":")}`)]; }), "link", base);
158
+ return base.map((item) => ({
159
+ ...item,
160
+ ...(badges.has(String(item.id)) ? { badges: appended(item, "badges", badges.get(String(item.id)), MAX_BADGES) } : {}),
161
+ ...(links.has(String(item.id)) ? { links: appended(item, "links", links.get(String(item.id)), MAX_LINKS) } : {}),
162
+ ...(recommended.has(String(item.id)) ? { recommended: true } : {}),
163
+ ...(reasons.has(String(item.id)) ? { reason: (0, redact_1.redact)(reasons.get(String(item.id))) } : {}),
164
+ }));
165
+ }
166
+ const MAX_ATTACHMENTS = 4;
167
+ async function uploadFile(api, deps, path, name) {
168
+ const bytes = deps.readBytes(path);
169
+ if (bytes === null)
170
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Cannot read ${path}`);
171
+ return api.attach(name ?? (0, node_path_1.basename)(path), bytes);
172
+ }
173
+ async function withAttachments(items, values, api, deps) {
174
+ const wanted = groupByItem(many(values, "attach").map((raw) => splitItemFlag(raw, "attach", "itemId:path")), "attach", items);
175
+ for (const [id, paths] of wanted) {
176
+ const item = items.find((candidate) => candidate.id === id);
177
+ if (paths.length + (item.attachments?.length ?? 0) > MAX_ATTACHMENTS)
178
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Item ${id} can have at most ${MAX_ATTACHMENTS} attachments`);
179
+ for (const path of paths)
180
+ if (deps.readBytes(path) === null)
181
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Cannot read ${path}`);
182
+ }
183
+ const uploaded = new Map();
184
+ for (const [id, paths] of wanted)
185
+ uploaded.set(id, await Promise.all(paths.map(async (path) => ({ uuid: (await uploadFile(api, deps, path)).uuid }))));
186
+ return items.map((item) => (uploaded.has(String(item.id)) ? { ...item, attachments: [...(item.attachments ?? []), ...uploaded.get(String(item.id))] } : item));
187
+ }
188
+ async function taskBody(values, kind, ctx) {
189
+ const { api, deps } = ctx;
190
+ const actions = many(values, "action").map(parseAction);
191
+ const items = await withAttachments(await taskItems(values, deps), values, api, deps);
192
+ const fields = many(values, "field").map(args_1.parseField);
193
+ const group = text(values, "group");
194
+ const parsedGroup = group === undefined ? undefined : (0, args_1.parseGroup)(group);
195
+ return {
196
+ kind,
197
+ title: (0, redact_1.redact)(required(values, "title")),
198
+ detail: redactOptional(text(values, "detail")),
199
+ why: redactOptional(text(values, "why")),
200
+ url: text(values, "url"),
201
+ actions: actions.length ? actions : undefined,
202
+ items: items.length ? items : undefined,
203
+ fields: fields.length ? fields : undefined,
204
+ group: parsedGroup === undefined ? undefined : { ...parsedGroup, title: (0, redact_1.redact)(parsedGroup.title) },
205
+ rules: (0, args_1.parseRules)(many(values, "conflict-on"), text(values, "total-of"), text(values, "decline-action")),
206
+ dueAt: when(values, "due", deps),
207
+ remindAt: when(values, "remind", deps),
208
+ dedupeKey: text(values, "dedupe"),
209
+ runUuid: runUuid(values, deps.env),
210
+ projectUuid: text(values, "project"),
211
+ projectTrack: text(values, "track"),
212
+ question: redactOptional(text(values, "question")),
213
+ answerType: answerType(values),
214
+ effortMinutes: effortMinutes(values),
215
+ seriesKey: text(values, "series"),
216
+ allowDuplicate: values["allow-duplicate"] === true ? true : undefined,
217
+ action: text(values, "rule-action"),
218
+ target: redactOptional(text(values, "target")),
219
+ };
220
+ }
221
+ function answerType(values) {
222
+ const value = text(values, "answer-type");
223
+ if (value !== undefined && !ANSWER_TYPES.includes(value))
224
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--answer-type must be one of ${ANSWER_TYPES.join(", ")}`);
225
+ return value;
226
+ }
227
+ function effortMinutes(values) {
228
+ const value = text(values, "effort");
229
+ if (value === undefined)
230
+ return undefined;
231
+ const minutes = Number(value);
232
+ if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440)
233
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--effort must be whole minutes from 1 to 1440");
234
+ return minutes;
235
+ }
236
+ function askKey(values) {
237
+ return text(values, "idempotency-key");
238
+ }
239
+ const resolveTask = async ({ api, args }) => {
240
+ const uuid = positional(args, 0, "The task uuid");
241
+ return { output: await api.resolve(uuid, { outcome: args.values.withdraw === true ? "withdrawn" : "resolved", note: redactOptional(text(args.values, "note")) }) };
242
+ };
243
+ function redactOptional(value) {
244
+ return value === undefined ? undefined : (0, redact_1.redact)(value);
245
+ }
246
+ function lockPath(ctx, resource) {
247
+ const keyId = (0, node_crypto_1.createHash)("sha256").update(ctx.agentKey).digest("hex").slice(0, 12);
248
+ return (0, node_path_1.join)(ctx.deps.home, ".ordia", "locks", `${keyId}-${encodeURIComponent(resource)}.json`);
249
+ }
250
+ function cachedToken(raw) {
251
+ if (raw === null)
252
+ return undefined;
253
+ try {
254
+ const parsed = JSON.parse(raw);
255
+ return typeof parsed?.token === "string" ? parsed.token : undefined;
256
+ }
257
+ catch {
258
+ return undefined;
259
+ }
260
+ }
261
+ const NUMBER_FRAGMENT = /^[\d()+-]+$/;
262
+ const NEXT_WORD = /^\s{0,64}\S*/;
263
+ const NUMBER_RUN = /^[\d()\- \t]*/;
264
+ function dropCutWord(cut) {
265
+ const boundary = /\s/.exec(cut.slice(0, LOG_MARGIN_CHARS));
266
+ if (!boundary)
267
+ return cut.slice(LOG_MARGIN_CHARS);
268
+ const fragment = cut.slice(0, boundary.index);
269
+ const rest = cut.slice(boundary.index + 1);
270
+ if (fragment !== "" && "bearer".endsWith(fragment.toLowerCase()))
271
+ return rest.replace(NEXT_WORD, "");
272
+ if (NUMBER_FRAGMENT.test(fragment))
273
+ return rest.replace(NUMBER_RUN, "");
274
+ return rest;
275
+ }
276
+ function redactedTail(log, tailChars) {
277
+ const window = tailChars + LOG_MARGIN_CHARS;
278
+ if (log.length <= window)
279
+ return (0, redact_1.redact)(log).slice(-tailChars);
280
+ return (0, redact_1.redact)(dropCutWord(log.slice(-window))).slice(-tailChars);
281
+ }
282
+ const runStart = async ({ api, args }) => ({
283
+ output: await api.runStart({ summary: text(args.values, "summary"), maxMinutes: wholeNumber(args.values, "max-minutes", 1, 1440) }),
284
+ });
285
+ const runProgress = async ({ api, args }) => {
286
+ const uuid = positional(args, 0, "The run uuid");
287
+ const total = wholeNumber(args.values, "total", 1, 1000000);
288
+ const done = wholeNumber(args.values, "done", 0, 1000000);
289
+ if (total === undefined || done === undefined)
290
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --done n and --total m");
291
+ const label = text(args.values, "label");
292
+ return { output: await api.runProgress(uuid, { done: Math.min(done, total), total, label: label === undefined ? undefined : (0, redact_1.redact)(label).slice(0, 120) }) };
293
+ };
294
+ const taskUpdate = async ({ api, args, deps }) => {
295
+ const uuid = positional(args, 0, "The task uuid");
296
+ const patch = { dueAt: whenOrClear(args.values, "due", deps), remindAt: whenOrClear(args.values, "remind", deps) };
297
+ if (patch.dueAt === undefined && patch.remindAt === undefined)
298
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --due and/or --remind (a date and time, or none to clear)");
299
+ return { output: await api.taskUpdate(uuid, patch) };
300
+ };
301
+ const RESULT_KEYS = [["done", "done"], ["skipped", "skipped"], ["needs_you", "needs-you"], ["failed", "failed"]];
302
+ const MAX_RESULT_COUNT = 1000;
303
+ async function runResults(ctx) {
304
+ const source = text(ctx.args.values, "result-json");
305
+ const parsed = source === undefined ? {} : await (0, args_1.readJsonInput)(source, "result-json", (path) => ctx.deps.readFile(path), () => ctx.deps.readStdin());
306
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
307
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--result-json must be a JSON object like {\"done\":8,\"needs_you\":1}");
308
+ const given = parsed;
309
+ const results = {};
310
+ for (const [key, flag] of RESULT_KEYS) {
311
+ const typed = wholeNumber(ctx.args.values, flag, 0, MAX_RESULT_COUNT);
312
+ const value = typed ?? given[key];
313
+ if (value === undefined)
314
+ continue;
315
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > MAX_RESULT_COUNT)
316
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `${key} must be a whole number from 0 to ${MAX_RESULT_COUNT}`);
317
+ results[key] = value;
318
+ }
319
+ return Object.keys(results).length ? results : undefined;
320
+ }
321
+ const runDefer = async ({ api, args, deps }) => {
322
+ const uuid = positional(args, 0, "The run uuid");
323
+ const retryAt = when(args.values, "retry-at", deps);
324
+ if (retryAt === undefined)
325
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --retry-at with the date and time the job will try again");
326
+ return { output: await api.runDefer(uuid, { retryAt: new Date(retryAt).toISOString() }) };
327
+ };
328
+ const ISSUE_TEXT_LIMIT = 500;
329
+ const runFinish = async (ctx) => {
330
+ const { api, args, deps } = ctx;
331
+ const uuid = positional(args, 0, "The run uuid");
332
+ const exitCode = wholeNumber(args.values, "exit-code", -2147483648, 2147483647);
333
+ const status = text(args.values, "status") ?? (exitCode === undefined ? undefined : exitCode === 0 ? "ok" : "fail");
334
+ if (status !== "ok" && status !== "fail" && status !== "deferred")
335
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --exit-code n or --status ok|fail|deferred");
336
+ const reason = text(args.values, "issue-reason");
337
+ const impact = text(args.values, "impact");
338
+ const action = text(args.values, "action-needed");
339
+ if ((reason || impact || action) && (!reason || !impact || !action || !["needs_action", "recovery_exhausted", "deadline_at_risk"].includes(reason)))
340
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --issue-reason needs_action|recovery_exhausted|deadline_at_risk with --impact and --action-needed");
341
+ if ((impact?.length ?? 0) > ISSUE_TEXT_LIMIT || (action?.length ?? 0) > ISSUE_TEXT_LIMIT)
342
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--impact and --action-needed take at most ${ISSUE_TEXT_LIMIT} characters each`);
343
+ const retryAt = when(args.values, "retry-at", deps);
344
+ if (retryAt !== undefined && status !== "deferred")
345
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "A retry time requires --status deferred");
346
+ if (reason && status === "ok")
347
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "A successful run cannot have an unresolved issue");
348
+ const logFile = text(args.values, "log-file");
349
+ const log = logFile === undefined ? null : deps.readFile(logFile);
350
+ const tailChars = wholeNumber(args.values, "log-tail-bytes", 1, 200000) ?? DEFAULT_TAIL_CHARS;
351
+ const summary = text(args.values, "summary");
352
+ return {
353
+ output: await api.runFinish(uuid, {
354
+ status,
355
+ ...(retryAt === undefined ? {} : { retryAt: new Date(retryAt).toISOString() }),
356
+ ...(reason && impact && action ? { issue: { reason, impact: (0, redact_1.redact)(impact), action: (0, redact_1.redact)(action) } } : {}),
357
+ exitCode,
358
+ taskUuid: text(args.values, "task"),
359
+ results: await runResults(ctx),
360
+ summary: summary === undefined ? undefined : (0, redact_1.redact)(summary).slice(0, SUMMARY_LIMIT),
361
+ logTail: log === null ? undefined : redactedTail(log, tailChars),
362
+ }),
363
+ };
364
+ };
365
+ async function pickUp(api, task) {
366
+ try {
367
+ return { output: await api.pickedUp(task.uuid) };
368
+ }
369
+ catch (err) {
370
+ if (err instanceof exit_codes_1.CliError && err.exitCode === exit_codes_1.EXIT.conflict)
371
+ return { output: { ...task, resumedBy: "helper" }, exitCode: exit_codes_1.EXIT.conflict };
372
+ throw err;
373
+ }
374
+ }
375
+ async function waitForAnswer(ctx, task) {
376
+ const timeoutMs = (wholeNumber(ctx.args.values, "timeout", 1, 604800) ?? 3600) * 1000;
377
+ const intervalMs = (wholeNumber(ctx.args.values, "interval", 1, 3600) ?? 15) * 1000;
378
+ const deadline = ctx.deps.now() + timeoutMs;
379
+ let current = task;
380
+ while (current.status === "open") {
381
+ if (current.answerExpiresAt !== null && Date.parse(current.answerExpiresAt) <= ctx.deps.now())
382
+ return { output: { ...current, status: "expired" }, exitCode: exit_codes_1.EXIT.conflict };
383
+ if (ctx.deps.now() >= deadline)
384
+ return { output: current, exitCode: exit_codes_1.EXIT.timeout };
385
+ await ctx.deps.sleep(intervalMs);
386
+ current = await ctx.api.task(current.uuid);
387
+ }
388
+ if (current.status !== "answered")
389
+ return { output: current, exitCode: current.status === "done" ? exit_codes_1.EXIT.ok : exit_codes_1.EXIT.conflict };
390
+ if (ctx.args.values["no-pickup"] === true)
391
+ return { output: current };
392
+ return pickUp(ctx.api, current);
393
+ }
394
+ async function replyDraft(values, kind, deps) {
395
+ const inline = text(values, "draft");
396
+ const file = text(values, "draft-file");
397
+ if (kind !== "reply") {
398
+ if (inline !== undefined || file !== undefined)
399
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--draft and --draft-file are only for --kind reply");
400
+ return undefined;
401
+ }
402
+ if (inline !== undefined && file !== undefined)
403
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --draft or --draft-file, not both");
404
+ const draft = inline ?? (file === undefined ? undefined : file === "-" ? await deps.readStdin() : deps.readFile(file));
405
+ if (file !== undefined && draft === null)
406
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Cannot read ${file}`);
407
+ if (!draft?.trim())
408
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--kind reply needs --draft, or --draft-file with a path or - for stdin");
409
+ return draft.replace(/\r?\n$/, "");
410
+ }
411
+ const ask = async (ctx) => {
412
+ const kind = text(ctx.args.values, "kind") ?? "approve";
413
+ if (!KINDS.includes(kind))
414
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--kind must be one of ${KINDS.join(", ")}`);
415
+ const draft = await replyDraft(ctx.args.values, kind, ctx.deps);
416
+ const task = await ctx.api.ask({ ...(await taskBody(ctx.args.values, kind, ctx)), draft }, askKey(ctx.args.values));
417
+ return ctx.args.values.wait === true ? waitForAnswer(ctx, task) : { output: task };
418
+ };
419
+ const note = async ({ api, args, deps }) => {
420
+ const title = required(args.values, "title");
421
+ const inline = text(args.values, "content");
422
+ const file = text(args.values, "content-file");
423
+ const content = inline ?? (file === undefined ? undefined : file === "-" ? await deps.readStdin() : deps.readFile(file));
424
+ if (content === undefined || content === null)
425
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --content, or --content-file with a readable path or - for stdin");
426
+ const tags = many(args.values, "tag");
427
+ return { output: await api.note({ title: (0, redact_1.redact)(title), content: (0, redact_1.redact)(content), tags: tags.length ? tags : undefined, runUuid: runUuid(args.values, deps.env) }) };
428
+ };
429
+ const noteUpdate = async ({ api, args, deps }) => {
430
+ const uuid = positional(args, 0, "The note uuid");
431
+ const file = text(args.values, "content-file");
432
+ const content = text(args.values, "content") ?? (file === undefined ? undefined : file === "-" ? await deps.readStdin() : deps.readFile(file));
433
+ if (content === null)
434
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Cannot read ${file}`);
435
+ const tags = many(args.values, "tag");
436
+ const patch = { title: redactOptional(text(args.values, "title")), content: redactOptional(content), tags: tags.length ? tags : undefined };
437
+ if (Object.values(patch).every((v) => v === undefined))
438
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --title, --content, --content-file or --tag");
439
+ return { output: await api.noteUpdate(uuid, patch) };
440
+ };
441
+ const link = async ({ api, args }) => {
442
+ const relation = text(args.values, "relation") ?? "related";
443
+ if (!RELATIONS.includes(relation))
444
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--relation must be one of ${RELATIONS.join(", ")}`);
445
+ return { output: await api.link({ from: parseRef(text(args.values, "from"), "from"), to: parseRef(text(args.values, "to"), "to"), relation }) };
446
+ };
447
+ const handoff = async ({ api, args, deps }) => {
448
+ const toAgentName = text(args.values, "to");
449
+ const toAgentUuid = text(args.values, "to-uuid");
450
+ if (!toAgentName && !toAgentUuid)
451
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "Pass --to <agent name> or --to-uuid <agent uuid>");
452
+ return { output: await api.handoff({ toAgentName, toAgentUuid, title: required(args.values, "title"), detail: text(args.values, "detail"), runUuid: runUuid(args.values, deps.env) }) };
453
+ };
454
+ const lockAcquire = async (ctx) => {
455
+ const resource = positional(ctx.args, 0, "The resource, for example deploy:vps,");
456
+ const ttlMinutes = wholeNumber(ctx.args.values, "ttl", 1, 720) ?? 30;
457
+ const waitMs = (wholeNumber(ctx.args.values, "wait", 0, 86400) ?? 0) * 1000;
458
+ const intervalMs = (wholeNumber(ctx.args.values, "interval", 1, 3600) ?? 15) * 1000;
459
+ const deadline = ctx.deps.now() + waitMs;
460
+ for (;;) {
461
+ const result = await ctx.api.lockAcquire({ resource, ttlMinutes });
462
+ if (result.acquired) {
463
+ ctx.deps.writeFile(lockPath(ctx, resource), JSON.stringify({ resource, token: result.token, expiresAt: result.expiresAt }));
464
+ return { output: result };
465
+ }
466
+ if (ctx.deps.now() + intervalMs > deadline)
467
+ return { output: result, exitCode: exit_codes_1.EXIT.conflict };
468
+ await ctx.deps.sleep(intervalMs);
469
+ }
470
+ };
471
+ const lockRelease = async (ctx) => {
472
+ const resource = positional(ctx.args, 0, "The resource, for example deploy:vps,");
473
+ const path = lockPath(ctx, resource);
474
+ const token = text(ctx.args.values, "token") ?? cachedToken(ctx.deps.readFile(path));
475
+ if (!token)
476
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `No saved token for ${resource}; pass --token`);
477
+ try {
478
+ const output = await ctx.api.lockRelease({ resource, token });
479
+ ctx.deps.removeFile(path);
480
+ return { output };
481
+ }
482
+ catch (err) {
483
+ if (err instanceof exit_codes_1.CliError && err.exitCode === exit_codes_1.EXIT.notFound)
484
+ ctx.deps.removeFile(path);
485
+ throw err;
486
+ }
487
+ };
488
+ function itemState(task, itemId) {
489
+ if (task.withdrawnItemIds?.includes(itemId))
490
+ return "withdrawn";
491
+ if (task.heldItemIds?.includes(itemId))
492
+ return "held";
493
+ const selected = task.answer?.selectedItemIds ?? [];
494
+ if (!selected.includes(itemId))
495
+ return "not_selected";
496
+ return task.itemOutcomes?.find((entry) => entry.itemId === itemId)?.outcome ?? "go";
497
+ }
498
+ const itemResult = async ({ api, args }) => {
499
+ const uuid = positional(args, 0, "The task uuid");
500
+ const itemId = positional(args, 1, "The item id");
501
+ const outcome = positional(args, 2, "The outcome");
502
+ if (!ITEM_OUTCOMES.includes(outcome))
503
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `outcome must be one of ${ITEM_OUTCOMES.join(", ")}`);
504
+ const url = text(args.values, "url");
505
+ if (url !== undefined && !/^https?:\/\//.test(url))
506
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--url must start with http:// or https://");
507
+ return { output: await api.itemResult(uuid, itemId, { outcome, reason: redactOptional(text(args.values, "reason")), url }) };
508
+ };
509
+ const itemStatus = async ({ api, args }) => {
510
+ const uuid = positional(args, 0, "The task uuid");
511
+ const itemId = positional(args, 1, "The item id");
512
+ return { output: { itemId, state: itemState(await api.task(uuid), itemId) } };
513
+ };
514
+ async function planBody(ctx) {
515
+ const source = required(ctx.args.values, "file");
516
+ const parsed = await (0, args_1.readJsonInput)(source, "file", (path) => ctx.deps.readFile(path), () => ctx.deps.readStdin());
517
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
518
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--file must be a JSON object");
519
+ return parsed;
520
+ }
521
+ const planCreate = async (ctx) => ({
522
+ output: await ctx.api.planCreate(await planBody(ctx), text(ctx.args.values, "idempotency-key") ?? (0, node_crypto_1.randomUUID)()),
523
+ });
524
+ const planSessions = async (ctx) => {
525
+ const uuid = positional(ctx.args, 0, "The plan uuid");
526
+ return { output: await ctx.api.planSessions(uuid, await planBody(ctx)) };
527
+ };
528
+ const RULE_EXPIRIES = ["1d", "7d", "30d", "none"];
529
+ const RULE_EXIT = { allow: exit_codes_1.EXIT.ok, deny: exit_codes_1.EXIT.ruleDeny };
530
+ const ALLOW_FROM_PERSON = "Agents can only add deny rules. For an allow rule, ask with --rule-action and --target and the person can answer Always in Assent.";
531
+ const rulesCheck = async ({ api, args }) => {
532
+ const output = await api.rulesCheck({ action: required(args.values, "action"), target: redactOptional(text(args.values, "target")), summary: redactOptional(text(args.values, "summary")) });
533
+ return { output, exitCode: RULE_EXIT[output.decision] ?? exit_codes_1.EXIT.ruleAsk };
534
+ };
535
+ const rulesAdd = async ({ api, args }) => {
536
+ if (args.values.allow === true || args.values.deny !== true)
537
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, ALLOW_FROM_PERSON);
538
+ const expiresIn = text(args.values, "expires") ?? "none";
539
+ if (!RULE_EXPIRIES.includes(expiresIn))
540
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `--expires must be one of ${RULE_EXPIRIES.join(", ")}`);
541
+ return { output: await api.rulesAdd({ effect: "deny", action: required(args.values, "action"), target: text(args.values, "target"), reason: redactOptional(text(args.values, "reason")), expiresIn }) };
542
+ };
543
+ const taskAdd = async (ctx) => {
544
+ const parentTaskUuid = text(ctx.args.values, "parent");
545
+ const parentItemId = text(ctx.args.values, "parent-item");
546
+ if ((parentTaskUuid === undefined) !== (parentItemId === undefined))
547
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, "--parent and --parent-item go together");
548
+ return { output: await ctx.api.ask({ ...(await taskBody(ctx.args.values, "todo", ctx)), parentTaskUuid, parentItemId }, askKey(ctx.args.values)) };
549
+ };
550
+ const COMMANDS = {
551
+ whoami: { options: {}, handler: async ({ api }) => ({ output: await api.me() }) },
552
+ "run start": { options: { summary: STRING, "max-minutes": STRING }, handler: runStart },
553
+ "run progress": { options: { done: STRING, total: STRING, label: STRING }, handler: runProgress },
554
+ "run finish": { options: { "issue-reason": STRING, impact: STRING, "action-needed": STRING, status: STRING, "retry-at": STRING, "exit-code": STRING, summary: STRING, "log-file": STRING, "log-tail-bytes": STRING, task: STRING, "result-json": STRING, done: STRING, skipped: STRING, "needs-you": STRING, failed: STRING }, handler: runFinish },
555
+ "run defer": { options: { "retry-at": STRING }, handler: runDefer },
556
+ ask: { options: { ...TASK_OPTIONS, kind: STRING, draft: STRING, "draft-file": STRING, action: MANY, item: MANY, attach: MANY, "item-json": STRING, recommend: MANY, reason: MANY, badge: MANY, link: MANY, field: MANY, group: STRING, "conflict-on": MANY, "total-of": STRING, "decline-action": STRING, "rule-action": STRING, target: STRING, wait: FLAG, "no-pickup": FLAG, timeout: STRING, interval: STRING }, handler: ask },
557
+ attach: { options: { name: STRING }, handler: async ({ api, args, deps }) => ({ output: await uploadFile(api, deps, positional(args, 0, "The file path"), text(args.values, "name")) }) },
558
+ answers: { options: {}, handler: async ({ api }) => ({ output: await api.answers() }) },
559
+ "picked-up": { options: {}, handler: async ({ api, args }) => ({ output: await api.pickedUp(positional(args, 0, "The task uuid")) }) },
560
+ "task add": { options: { ...TASK_OPTIONS, parent: STRING, "parent-item": STRING }, handler: taskAdd },
561
+ "task resolve": { options: { withdraw: FLAG, note: STRING }, handler: resolveTask },
562
+ "task update": { options: { due: STRING, remind: STRING }, handler: taskUpdate },
563
+ "task item-result": { options: { reason: STRING, url: STRING }, handler: itemResult },
564
+ "task item-status": { options: {}, handler: itemStatus },
565
+ "task get": { options: {}, handler: async ({ api, args }) => ({ output: await api.task(positional(args, 0, "The task uuid")) }) },
566
+ note: { options: { title: STRING, content: STRING, "content-file": STRING, tag: MANY, run: STRING }, handler: note },
567
+ "note update": { options: { title: STRING, content: STRING, "content-file": STRING, tag: MANY }, handler: noteUpdate },
568
+ link: { options: { from: STRING, to: STRING, relation: STRING }, handler: link },
569
+ "memory get": { options: {}, handler: async ({ api }) => ({ output: await api.memory() }) },
570
+ "lock acquire": { options: { ttl: STRING, wait: STRING, interval: STRING }, handler: lockAcquire },
571
+ "lock release": { options: { token: STRING }, handler: lockRelease },
572
+ handoff: { options: { to: STRING, "to-uuid": STRING, title: STRING, detail: STRING, run: STRING }, handler: handoff },
573
+ handoffs: { options: {}, handler: async ({ api }) => ({ output: await api.handoffs() }) },
574
+ "plan create": { options: { file: STRING, "idempotency-key": STRING }, handler: planCreate },
575
+ "project get": { options: {}, handler: async ({ api, args }) => ({ output: await api.projectGet(positional(args, 0, "The project uuid")) }) },
576
+ "project overview": { options: {}, handler: async ({ api, args }) => ({ output: await api.projectOverview(positional(args, 0, "The project uuid")) }) },
577
+ "plan get": { options: {}, handler: async ({ api, args }) => ({ output: await api.planGet(positional(args, 0, "The plan uuid")) }) },
578
+ "plan sessions": { options: { file: STRING }, handler: planSessions },
579
+ "rules check": { options: { action: STRING, target: STRING, summary: STRING }, handler: rulesCheck },
580
+ "rules list": { options: {}, handler: async ({ api }) => ({ output: await api.rulesList() }) },
581
+ "rules add": { options: { deny: FLAG, allow: FLAG, action: STRING, target: STRING, reason: STRING, expires: STRING }, handler: rulesAdd },
582
+ "rules remove": { options: {}, handler: async ({ api, args }) => ({ output: await api.rulesRemove(positional(args, 0, "The rule uuid")) }) },
583
+ "handoffs done": { options: {}, handler: async ({ api, args }) => ({ output: await api.completeTask(positional(args, 0, "The task uuid")) }) },
584
+ };
585
+ function resolveCommand(argv, name) {
586
+ const two = `${argv[0]} ${argv[1] ?? ""}`;
587
+ if (Object.hasOwn(COMMANDS, two))
588
+ return { def: COMMANDS[two], rest: argv.slice(2) };
589
+ if (Object.hasOwn(COMMANDS, argv[0]))
590
+ return { def: COMMANDS[argv[0]], rest: argv.slice(1) };
591
+ throw new exit_codes_1.CliError(exit_codes_1.EXIT.usage, `Unknown command: ${argv.slice(0, 2).join(" ")}. Run ${name} help`);
592
+ }
593
+ function print(deps, output, field) {
594
+ if (field === undefined) {
595
+ deps.stdout(JSON.stringify(output));
596
+ return;
597
+ }
598
+ const value = output[field];
599
+ deps.stdout(typeof value === "string" ? value : JSON.stringify(value ?? null));
600
+ }
601
+ function commandName(startedAs) {
602
+ return startedAs !== undefined && (0, node_path_1.basename)(startedAs) === usage_1.LEGACY_COMMAND ? usage_1.LEGACY_COMMAND : usage_1.COMMAND;
603
+ }
604
+ async function runCli(argv, deps) {
605
+ if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
606
+ deps.stdout((0, usage_1.usage)(deps.name ?? usage_1.COMMAND));
607
+ return exit_codes_1.EXIT.ok;
608
+ }
609
+ if (argv[0] === "hook" && argv[1] === "claude-code")
610
+ return (0, claude_code_1.runClaudeCodeSetup)(argv.slice(2), deps);
611
+ if (argv[0] === "hook")
612
+ return (0, hook_1.runHook)(argv.slice(1), deps);
613
+ if (argv[0] === "mcp")
614
+ return (0, mcp_bridge_1.runMcpBridge)(deps);
615
+ if (argv[0] === "--version") {
616
+ deps.stdout(usage_1.VERSION);
617
+ return exit_codes_1.EXIT.ok;
618
+ }
619
+ try {
620
+ const { def, rest } = resolveCommand(argv, deps.name ?? usage_1.COMMAND);
621
+ const args = (0, args_1.parseCommandArgs)(rest, def.options);
622
+ const config = (0, config_1.loadCliConfig)({ env: deps.env, home: deps.home, readFile: deps.readFile, exists: (path) => deps.readFile(path) !== null });
623
+ const api = client_1.AgentApi.fromConfig(config, deps.fetchImpl);
624
+ const { output, exitCode = exit_codes_1.EXIT.ok } = await def.handler({ api, deps, args, agentKey: config.agentKey });
625
+ print(deps, output, text(args.values, "get"));
626
+ return exitCode;
627
+ }
628
+ catch (err) {
629
+ if (err instanceof exit_codes_1.CliError) {
630
+ deps.stderr(JSON.stringify({ error: err.message, exitCode: err.exitCode, status: err.status ?? null }));
631
+ return err.exitCode;
632
+ }
633
+ deps.stderr(JSON.stringify({ error: err.message, exitCode: exit_codes_1.EXIT.failure, status: null }));
634
+ return exit_codes_1.EXIT.failure;
635
+ }
636
+ }