@corvio/cli 0.1.0-beta.4 → 0.1.0-beta.6

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.
Files changed (4) hide show
  1. package/README.md +34 -0
  2. package/dist/cli.js +743 -37
  3. package/dist/core.js +306 -48
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { spawn } from "node:child_process";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { lstat, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
@@ -9,6 +10,8 @@ import {
9
10
  ApiClient,
10
11
  CliError,
11
12
  EXIT,
13
+ assertKnownOptions,
14
+ assertPositionalCount,
12
15
  clearWorkspaceContext,
13
16
  clearWorkspaceCredential,
14
17
  idempotencyKey,
@@ -18,6 +21,7 @@ import {
18
21
  openBrowser,
19
22
  option,
20
23
  readTextFile,
24
+ readAgentCredential,
21
25
  readWorkspaceContext,
22
26
  readWorkspaceCredential,
23
27
  requireConfirmation,
@@ -31,22 +35,90 @@ import {
31
35
  writeTextFile,
32
36
  } from "./core.js";
33
37
 
34
- const VERSION = "0.1.0-beta.4";
38
+ const VERSION = "0.1.0-beta.6";
35
39
  const DEFAULT_API_BASE = "https://api.corvio.ai/v1";
36
40
  const DEFAULT_APP_BASE = "https://corvio.ai";
37
41
  const USER_AGENT = `corvio-cli/${VERSION}`;
42
+ const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
43
+ const MAX_SYNC_FILES = 2_000;
44
+ const MAX_MARKDOWN_CHARACTERS = 120_000;
45
+ const GLOBAL_OPTIONS = Object.freeze([
46
+ "api-base",
47
+ "workspace",
48
+ "workspace-id",
49
+ "json",
50
+ "no-input",
51
+ "yes",
52
+ "help",
53
+ "version",
54
+ ]);
55
+
56
+ const COMMAND_OPTIONS = Object.freeze({
57
+ "auth:login": ["force"],
58
+ "auth:status": [],
59
+ "auth:logout": [],
60
+ "workspaces:list": [],
61
+ "workspaces:current": [],
62
+ "workspaces:use": ["id"],
63
+ "workspaces:create": ["name", "kind", "description", "idempotency-key"],
64
+ "capabilities:": [],
65
+ "ask:": ["prompt", "conversation-id", "sources", "allow-actions", "context-file", "idempotency-key"],
66
+ "agents:list": ["include-revoked"],
67
+ "agents:create": ["handle", "display-name", "avatar-url", "provider", "permissions"],
68
+ "agents:update": ["id", "display-name", "avatar-url", "status", "permissions"],
69
+ "agents:rotate": ["id"],
70
+ "agent:claim": ["worker-id", "lease-seconds", "wait-seconds"],
71
+ "agent:complete": ["id", "lease-token", "result-file", "question-id", "comment-entry-id", "no-change-reason"],
72
+ "agent:renew": ["id", "lease-token", "lease-seconds"],
73
+ "agent:fail": ["id", "lease-token", "error", "retryable"],
74
+ "agent:comments": ["document-id"],
75
+ "agent:comment": ["document-id", "body", "selection-file", "start-new-chain"],
76
+ "agent:reply": ["document-id", "id", "body", "source-event-id", "source-event-lease-token", "start-new-chain"],
77
+ "agent:status": ["document-id", "id", "status"],
78
+ "agent:run": ["once", "handler", "worker-id", "lease-seconds", "wait-seconds", "sources"],
79
+ "questions:list": ["limit", "cursor", "conversation-id"],
80
+ "questions:get": ["id"],
81
+ "conversations:list": ["limit", "cursor"],
82
+ "conversations:get": ["id"],
83
+ "search:": ["query", "limit", "page-id", "node-id"],
84
+ "docs:list": ["limit", "cursor"],
85
+ "docs:get": ["id", "output"],
86
+ "docs:create": ["title", "file", "markdown", "parent-node-id", "idempotency-key"],
87
+ "docs:update": ["id", "title", "file", "markdown", "expected-revision"],
88
+ "docs:move": ["id", "parent-node-id", "before-node-id", "after-node-id", "idempotency-key"],
89
+ "docs:archive": ["id"],
90
+ "docs:share": ["id", "display-name"],
91
+ "docs:open": ["id"],
92
+ "projects:create": ["title", "idempotency-key"],
93
+ "files:list": ["limit"],
94
+ "files:get": ["id"],
95
+ "files:download": ["id", "output"],
96
+ "files:upload": ["file", "mime-type"],
97
+ "files:organize": ["id", "instruction", "target-root-node-id"],
98
+ "files:open": ["id"],
99
+ "sync:init": ["root-node-id", "dir", "name", "force"],
100
+ "sync:status": ["dir"],
101
+ "sync:plan": ["dir"],
102
+ "sync:pull": ["dir"],
103
+ "sync:push": ["dir"],
104
+ "sync:resolve": ["dir", "conflict-id", "strategy"],
105
+ "update:check": [],
106
+ });
38
107
 
39
108
  const HELP = `Corvio Workspace CLI ${VERSION}
40
109
 
41
110
  Usage:
42
111
  corvio auth login|status|logout
43
- corvio workspaces list|use|current [id]
112
+ corvio workspaces list|use|current|create [id]
44
113
  corvio capabilities
45
114
  corvio ask --prompt <text> [--conversation-id <id>] [--allow-actions]
115
+ corvio agents list|create|update|rotate [id]
116
+ corvio agent claim|renew|complete|fail|comments|comment|reply|status|run [id]
46
117
  corvio questions list|get [id]
47
118
  corvio conversations list|get [id]
48
119
  corvio search <query>
49
- corvio docs list|get|create|update|archive|share|open [id]
120
+ corvio docs list|get|create|update|move|archive|share|open [id]
121
+ corvio projects create --title <title>
50
122
  corvio files list|get|download|upload|organize|open [id]
51
123
  corvio sync init|status|plan|pull|push|resolve
52
124
  corvio update check
@@ -57,14 +129,117 @@ Global options:
57
129
  Authentication:
58
130
  CORVIO_API_KEY=cvu_... takes precedence over the stored device-link login.
59
131
  Legacy Workspace-bound cvk_ keys remain supported.
132
+ Agent runtime commands use CORVIO_AGENT_API_KEY=cvg_... and never borrow the user's credential.
60
133
  Select the target with 'corvio workspaces use <id>', --workspace, or CORVIO_WORKSPACE_ID.
61
134
  Run 'corvio auth login' when no environment key is configured.
62
135
  `;
63
136
 
137
+ const COMMAND_HELP = Object.freeze({
138
+ auth: `Usage: corvio auth login|status|logout\n\nAuthenticate with a user-level device link, inspect the current principal, or revoke the stored credential.`,
139
+ workspaces: `Usage:\n corvio workspaces list\n corvio workspaces use <workspace-id>\n corvio workspaces current\n corvio workspaces create --name <name> [--kind personal|team]\n\nWorkspace status and blocked capability reasons are returned with each item.`,
140
+ capabilities: `Usage: corvio capabilities [--json]\n\nShow live API resources, principal scopes, selected Workspace status, and CLI compatibility.`,
141
+ ask: `Usage: corvio ask --prompt <text> [--conversation-id <id>] [--sources workspace,web,memory] [--allow-actions] [--context-file <json>] [--idempotency-key <key>]`,
142
+ agents: `Usage:\n corvio agents list\n corvio agents create --handle <handle> --display-name <name> [--provider codex|claude_code|custom] [--permissions read,comment,edit]\n corvio agents update <id> [--display-name <name>] [--status active|paused|revoked] [--permissions <list>]\n corvio agents rotate <id>\n\nAgent creation and rotation print the cvg_ secret once.`,
143
+ agent: `Usage:\n corvio agent claim [--worker-id <id>] [--wait-seconds <0-30>]\n corvio agent renew <event-id> --lease-token <token> [--lease-seconds <30-900>]\n corvio agent complete <event-id> --lease-token <token> [--result-file <json>]\n corvio agent fail <event-id> --lease-token <token> --error <message>\n corvio agent comments --document-id <id>\n corvio agent comment --document-id <id> --body <text> [--selection-file <json>]\n corvio agent reply <thread-id> --document-id <id> --body <text>\n corvio agent status <thread-id> --document-id <id> --status open|resolved\n corvio agent run --handler <executable> [--once]\n\nThe runner leases durable events and renews its lease while work is active. The handler reads event JSON from stdin and returns {"instruction":"...","reply":"..."}; Corvio Query owns document changes and returns the durable receipt.`,
144
+ questions: `Usage:\n corvio questions list [--limit <n>] [--cursor <cursor>] [--conversation-id <id>]\n corvio questions get <question-id>`,
145
+ conversations: `Usage:\n corvio conversations list [--limit <n>] [--cursor <cursor>]\n corvio conversations get <conversation-id>`,
146
+ search: `Usage: corvio search <query> [--limit <n>] [--page-id <id>] [--node-id <id>]`,
147
+ docs: `Usage:\n corvio docs list [--limit <n>] [--cursor <cursor>]\n corvio docs get <id> [--output <path>]\n corvio docs create --title <title> [--file <path>|--markdown <text>] [--parent-node-id <id>] [--idempotency-key <key>]\n corvio docs update <id> [--title <title>] [--file <path>|--markdown <text>] [--expected-revision <n>]\n corvio docs move <id> --parent-node-id <id>\n corvio docs archive <id> --yes\n corvio docs share <id> --yes [--display-name <name>]\n corvio docs open <id>`,
148
+ projects: `Usage: corvio projects create --title <title> [--idempotency-key <key>]\n\nProjects are the user-visible folder/container owner in the Corvio Docs tree. 'corvio folders' is an alias.`,
149
+ files: `Usage:\n corvio files list [--limit <n>]\n corvio files get <id>\n corvio files download <id> [--output <path>]\n corvio files upload --file <path> [--mime-type <type>]\n corvio files organize <id> --instruction <text> --yes [--target-root-node-id <id>]\n corvio files open <id>`,
150
+ sync: `Usage:\n corvio sync init --root-node-id <id> [--dir <path>] [--name <name>]\n corvio sync status|plan|pull [--dir <path>]\n corvio sync push --yes [--dir <path>]\n corvio sync resolve --conflict-id <id> --strategy use-remote|keep-local --yes [--dir <path>]`,
151
+ update: `Usage: corvio update check [--json]\n\nCompare this executable with the reviewed API policy and the npm beta dist-tag.`,
152
+ });
153
+
154
+ function contextualHelp(command, action) {
155
+ const normalizedCommand = command === "workspace" ? "workspaces" : command === "folders" ? "projects" : command;
156
+ const commandHelp = COMMAND_HELP[normalizedCommand];
157
+ if (!commandHelp) return HELP.trimEnd();
158
+ if (!action) return commandHelp;
159
+ const matchingLine = commandHelp
160
+ .split("\n")
161
+ .find((line) => line.trim().startsWith(`corvio ${normalizedCommand} ${action}`));
162
+ return matchingLine ? `Usage:\n ${matchingLine.trim()}\n\n${commandHelp}` : commandHelp;
163
+ }
164
+
165
+ function normalizeInvocation(command, action) {
166
+ const normalizedCommand = command === "workspace" ? "workspaces" : command === "folders" ? "projects" : command;
167
+ const defaultActions = {
168
+ workspaces: "list",
169
+ questions: "list",
170
+ conversations: "list",
171
+ docs: "list",
172
+ projects: "create",
173
+ files: "list",
174
+ agents: "list",
175
+ agent: "claim",
176
+ sync: "status",
177
+ update: "check",
178
+ };
179
+ return {
180
+ command: normalizedCommand,
181
+ action: ["ask", "search", "capabilities"].includes(normalizedCommand)
182
+ ? ""
183
+ : action || defaultActions[normalizedCommand] || "",
184
+ };
185
+ }
186
+
187
+ function validateInvocation(options, positionals) {
188
+ const [rawCommand, rawAction] = positionals;
189
+ if (!rawCommand || rawCommand === "help" || rawCommand === "version" || options.help || options.version) return;
190
+ const { command, action } = normalizeInvocation(rawCommand, rawAction);
191
+ const schema = COMMAND_OPTIONS[`${command}:${action}`];
192
+ if (!schema) throw new CliError(`Unknown command: ${positionals.join(" ")}`, { code: "unknown_command" });
193
+ assertKnownOptions(options, [...GLOBAL_OPTIONS, ...schema]);
194
+ if (command !== "ask" && command !== "search") {
195
+ const actionsWithId = new Set([
196
+ "workspaces:use",
197
+ "questions:get",
198
+ "conversations:get",
199
+ "docs:get",
200
+ "docs:update",
201
+ "docs:move",
202
+ "docs:archive",
203
+ "docs:share",
204
+ "docs:open",
205
+ "files:get",
206
+ "files:download",
207
+ "files:organize",
208
+ "files:open",
209
+ "agents:update",
210
+ "agents:rotate",
211
+ "agent:complete",
212
+ "agent:renew",
213
+ "agent:fail",
214
+ "agent:reply",
215
+ "agent:status",
216
+ ]);
217
+ assertPositionalCount(positionals, actionsWithId.has(`${command}:${action}`) ? 3 : command === "capabilities" ? 1 : 2);
218
+ }
219
+ }
220
+
221
+ function rejectTogether(options, left, right) {
222
+ if (options[left] !== undefined && options[right] !== undefined) {
223
+ throw new CliError(`Options --${left} and --${right} cannot be used together.`, {
224
+ code: "mutually_exclusive_options",
225
+ });
226
+ }
227
+ }
228
+
64
229
  function baseUrl(options) {
65
230
  return normalizeApiBase(options["api-base"] || process.env.CORVIO_API_BASE, DEFAULT_API_BASE);
66
231
  }
67
232
 
233
+ function resourcePath(value) {
234
+ return encodeURIComponent(String(value));
235
+ }
236
+
237
+ function safeRemoteFileName(value, fallback) {
238
+ const normalized = String(value || "").replaceAll("\\", "/");
239
+ const name = basename(normalized).trim();
240
+ return name && name !== "." && name !== ".." ? name : fallback;
241
+ }
242
+
68
243
  async function resolveWorkspaceSelection(options, credential) {
69
244
  const explicit = String(options.workspace || options["workspace-id"] || "").trim();
70
245
  if (explicit) return { workspaceId: explicit, workspaceName: null, source: "option" };
@@ -110,6 +285,30 @@ async function client(options, { auth = true, workspace = "required" } = {}) {
110
285
  };
111
286
  }
112
287
 
288
+ function agentClient(options) {
289
+ const credential = readAgentCredential();
290
+ return new ApiClient({
291
+ baseUrl: baseUrl(options),
292
+ credential: credential.key,
293
+ userAgent: USER_AGENT,
294
+ });
295
+ }
296
+
297
+ async function readJsonObject(path, label) {
298
+ let value;
299
+ try {
300
+ value = JSON.parse(await readFile(resolve(String(path)), "utf8"));
301
+ } catch (error) {
302
+ throw new CliError(`${label} must be a readable JSON file: ${error instanceof Error ? error.message : String(error)}`, {
303
+ code: "invalid_json_file",
304
+ });
305
+ }
306
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
307
+ throw new CliError(`${label} must contain one JSON object.`, { code: "invalid_json_file" });
308
+ }
309
+ return value;
310
+ }
311
+
113
312
  async function authLogin(options) {
114
313
  const current = await readWorkspaceCredential();
115
314
  if (current?.source === "environment" && !options.force) {
@@ -136,6 +335,7 @@ async function authLogin(options) {
136
335
  "assets:write",
137
336
  "sync:read",
138
337
  "sync:write",
338
+ "workspaces:write",
139
339
  ],
140
340
  },
141
341
  });
@@ -296,13 +496,40 @@ async function workspaces(options, action, id) {
296
496
  await storeWorkspaceContext({ workspaceId: identity.workspace.id, workspaceName: identity.workspace.name });
297
497
  return writeResult({ selected: true, workspace: identity.workspace }, { json: options.json });
298
498
  }
299
- throw new CliError("workspaces action must be list, use, or current.");
499
+ if (action === "create") {
500
+ if (!credential.key.startsWith("cvu_")) {
501
+ throw new CliError("Creating a Workspace requires a user-level cvu_ credential.", {
502
+ code: "user_api_key_required",
503
+ exitCode: EXIT.AUTH,
504
+ });
505
+ }
506
+ const api = new ApiClient({ baseUrl: baseUrl(options), credential: credential.key, userAgent: USER_AGENT });
507
+ const kind = String(options.kind || "personal");
508
+ if (!new Set(["personal", "team"]).has(kind)) {
509
+ throw new CliError("--kind must be personal or team.", { code: "workspace_kind_invalid" });
510
+ }
511
+ const result = await api.request("/workspaces", {
512
+ method: "POST",
513
+ headers: { "idempotency-key": idempotencyKey(options) },
514
+ body: {
515
+ name: option(options, "name", { required: true }),
516
+ kind,
517
+ description: options.description || null,
518
+ },
519
+ });
520
+ await storeWorkspaceContext({ workspaceId: result.id, workspaceName: result.name });
521
+ return writeResult({ created: true, selected: true, workspace: result }, { json: options.json });
522
+ }
523
+ throw new CliError("workspaces action must be list, use, current, or create.");
300
524
  }
301
525
 
302
526
  async function ask(options, positionals) {
303
527
  const prompt = String(options.prompt || positionals.join(" ")).trim();
304
528
  if (!prompt) throw new CliError("Provide --prompt <text> or a positional prompt.");
305
529
  const { api } = await client(options);
530
+ const context = options["context-file"]
531
+ ? await readJsonObject(options["context-file"], "--context-file")
532
+ : null;
306
533
  const result = await api.request("/questions", {
307
534
  method: "POST",
308
535
  headers: { "idempotency-key": idempotencyKey(options) },
@@ -311,14 +538,334 @@ async function ask(options, positionals) {
311
538
  conversation_id: options["conversation-id"] || null,
312
539
  mode: options["allow-actions"] ? "allow_actions" : "answer_only",
313
540
  sources: listOption(options, "sources", ["workspace", "web", "memory"]),
541
+ context,
542
+ },
543
+ timeoutMs: 330_000,
544
+ });
545
+ writeResult(result, { json: options.json });
546
+ }
547
+
548
+ async function agents(options, action, id) {
549
+ const { api } = await client(options);
550
+ let result;
551
+ if (action === "list") {
552
+ result = await api.request("/agents", { query: { include_revoked: options["include-revoked"] || undefined } });
553
+ } else if (action === "create") {
554
+ result = await api.request("/agents", {
555
+ method: "POST",
556
+ body: {
557
+ handle: option(options, "handle", { required: true }),
558
+ display_name: option(options, "display-name", { required: true }),
559
+ avatar_url: options["avatar-url"] || null,
560
+ provider: options.provider || "custom",
561
+ permissions: listOption(options, "permissions", ["read", "comment"]),
562
+ },
563
+ });
564
+ } else if (action === "update") {
565
+ const agentId = id || option(options, "id", { required: true });
566
+ const body = {};
567
+ if (options["display-name"] !== undefined) body.display_name = options["display-name"];
568
+ if (options["avatar-url"] !== undefined) body.avatar_url = options["avatar-url"];
569
+ if (options.status !== undefined) body.status = options.status;
570
+ if (options.permissions !== undefined) body.permissions = listOption(options, "permissions");
571
+ if (Object.keys(body).length === 0) throw new CliError("Provide an Agent field to update.", { code: "agent_update_required" });
572
+ result = await api.request(`/agents/${resourcePath(agentId)}`, { method: "PATCH", body });
573
+ } else if (action === "rotate") {
574
+ const agentId = id || option(options, "id", { required: true });
575
+ result = await api.request(`/agents/${resourcePath(agentId)}/credentials:rotate`, { method: "POST" });
576
+ } else {
577
+ throw new CliError("agents action must be list, create, update, or rotate.");
578
+ }
579
+ writeResult(result, { json: options.json });
580
+ }
581
+
582
+ async function claimAgentEvent(api, options, waitFallback = 0) {
583
+ return api.request("/agent-events:claim", {
584
+ method: "POST",
585
+ body: {
586
+ worker_id: options["worker-id"] || `corvio-cli-${process.pid}`,
587
+ lease_seconds: numberOption(options, "lease-seconds", 300, { minimum: 30, maximum: 900 }),
588
+ wait_seconds: numberOption(options, "wait-seconds", waitFallback, { minimum: 0, maximum: 30 }),
314
589
  },
590
+ timeoutMs: 45_000,
315
591
  });
592
+ }
593
+
594
+ function runAgentHandler(executable, event) {
595
+ return new Promise((resolvePromise, rejectPromise) => {
596
+ const handlerEvent = { ...event, delivery: { ...(event?.delivery || {}) } };
597
+ delete handlerEvent.delivery.lease_token;
598
+ const handlerEnv = { ...process.env, CORVIO_AGENT_EVENT_ID: String(event.id || "") };
599
+ delete handlerEnv.CORVIO_API_KEY;
600
+ const child = spawn(String(executable), [], {
601
+ shell: false,
602
+ stdio: ["pipe", "pipe", "pipe"],
603
+ env: handlerEnv,
604
+ });
605
+ let stdout = "";
606
+ let stderr = "";
607
+ const maximumBytes = 1024 * 1024;
608
+ child.stdout.setEncoding("utf8");
609
+ child.stderr.setEncoding("utf8");
610
+ child.stdout.on("data", (chunk) => {
611
+ stdout += chunk;
612
+ if (Buffer.byteLength(stdout) > maximumBytes) child.kill("SIGTERM");
613
+ });
614
+ child.stderr.on("data", (chunk) => {
615
+ stderr = `${stderr}${chunk}`.slice(-20_000);
616
+ });
617
+ child.on("error", (error) => rejectPromise(new CliError(`Agent handler could not start: ${error.message}`, { code: "agent_handler_failed" })));
618
+ child.on("close", (code) => {
619
+ if (code !== 0) {
620
+ rejectPromise(new CliError(`Agent handler exited with ${code}: ${stderr.trim() || "no error output"}`, { code: "agent_handler_failed" }));
621
+ return;
622
+ }
623
+ let decision;
624
+ try {
625
+ decision = JSON.parse(stdout.trim());
626
+ } catch {
627
+ rejectPromise(new CliError("Agent handler must print one JSON object.", { code: "agent_handler_result_invalid" }));
628
+ return;
629
+ }
630
+ const instruction = String(decision?.instruction || "").trim();
631
+ if (!instruction) {
632
+ rejectPromise(new CliError("Agent handler result requires a non-empty instruction.", { code: "agent_handler_result_invalid" }));
633
+ return;
634
+ }
635
+ resolvePromise({ instruction, reply: String(decision?.reply || "").trim() || null });
636
+ });
637
+ child.stdin.end(`${JSON.stringify(handlerEvent)}\n`);
638
+ });
639
+ }
640
+
641
+ async function startAgentLeaseHeartbeat(api, event, leaseToken, leaseSeconds) {
642
+ let stopped = false;
643
+ let timer = null;
644
+ let inFlight = null;
645
+ let failure = null;
646
+ const intervalMs = Math.max(10_000, Math.min(60_000, Math.floor(leaseSeconds * 1_000 / 3)));
647
+ const renew = async () => {
648
+ if (stopped || failure) return;
649
+ try {
650
+ await api.request(`/agent-events/${resourcePath(event.id)}:renew`, {
651
+ method: "POST",
652
+ body: { lease_token: leaseToken, lease_seconds: leaseSeconds },
653
+ });
654
+ } catch (error) {
655
+ failure = error;
656
+ return;
657
+ }
658
+ if (!stopped) timer = setTimeout(() => { inFlight = renew(); }, intervalMs);
659
+ };
660
+ inFlight = renew();
661
+ await inFlight;
662
+ if (failure) throw failure;
663
+ return {
664
+ async stop() {
665
+ stopped = true;
666
+ if (timer) clearTimeout(timer);
667
+ if (inFlight) await inFlight;
668
+ },
669
+ assertHealthy() {
670
+ if (failure) throw failure;
671
+ },
672
+ };
673
+ }
674
+
675
+ async function processClaimedAgentEvent(api, event, options) {
676
+ const leaseToken = String(event?.delivery?.lease_token || "");
677
+ if (!event?.id || !leaseToken) throw new CliError("Claimed event is missing its lease receipt.", { code: "agent_event_invalid" });
678
+ const leaseSeconds = numberOption(options, "lease-seconds", 300, { minimum: 30, maximum: 900 });
679
+ let heartbeat = null;
680
+ try {
681
+ heartbeat = await startAgentLeaseHeartbeat(api, event, leaseToken, leaseSeconds);
682
+ const decision = await runAgentHandler(option(options, "handler", { required: true }), event);
683
+ const question = await api.request("/questions", {
684
+ method: "POST",
685
+ headers: { "idempotency-key": `agent-event:${event.id}:query` },
686
+ body: {
687
+ prompt: decision.instruction,
688
+ mode: "allow_actions",
689
+ sources: listOption(options, "sources", ["workspace", "web", "memory"]),
690
+ context: {
691
+ agent_event: {
692
+ id: event.id,
693
+ kind: event.kind,
694
+ object_kind: event.object_kind,
695
+ object_id: event.object_id,
696
+ actor_user_id: event.actor_user_id,
697
+ actor_agent_id: event.actor_agent_id,
698
+ lineage: event.lineage,
699
+ },
700
+ collaboration: event.context,
701
+ },
702
+ },
703
+ timeoutMs: 330_000,
704
+ });
705
+ if (String(question.status || "") !== "completed") {
706
+ throw new CliError(`Corvio Query did not complete durably (status: ${question.status || "unknown"}).`, {
707
+ code: "agent_query_not_completed",
708
+ });
709
+ }
710
+ const documentId = String(event?.context?.document?.document_id || "");
711
+ const threadId = String(event?.context?.comment?.thread_id || "");
712
+ let commentEntryId = null;
713
+ if (documentId && threadId) {
714
+ const reply = String(decision.reply || question.answer || "Completed the requested review.").trim().slice(0, 12_000);
715
+ const thread = await api.request(`/documents/${resourcePath(documentId)}/comments/${resourcePath(threadId)}/replies`, {
716
+ method: "POST",
717
+ body: {
718
+ body: reply,
719
+ entry_point: "external_agent_runner",
720
+ source_event_id: event.id,
721
+ source_event_lease_token: leaseToken,
722
+ },
723
+ });
724
+ const entries = Array.isArray(thread?.entries) ? thread.entries : [];
725
+ commentEntryId = entries.at(-1)?.id || null;
726
+ }
727
+ await heartbeat.stop();
728
+ heartbeat.assertHealthy();
729
+ const completed = await api.request(`/agent-events/${resourcePath(event.id)}:complete`, {
730
+ method: "POST",
731
+ body: {
732
+ lease_token: leaseToken,
733
+ result: {
734
+ question_id: question.id,
735
+ ...(commentEntryId ? { comment_entry_id: commentEntryId } : {}),
736
+ status: question.status,
737
+ artifact_ids: Array.isArray(question.artifacts) ? question.artifacts.map((item) => item.document_id).filter(Boolean) : [],
738
+ },
739
+ },
740
+ });
741
+ return { event: completed, question, comment_entry_id: commentEntryId };
742
+ } catch (error) {
743
+ if (heartbeat) {
744
+ try {
745
+ await heartbeat.stop();
746
+ } catch {
747
+ // The original execution error remains the failure authority.
748
+ }
749
+ }
750
+ try {
751
+ await api.request(`/agent-events/${resourcePath(event.id)}:fail`, {
752
+ method: "POST",
753
+ body: { lease_token: leaseToken, error: error instanceof Error ? error.message : String(error), retryable: true },
754
+ });
755
+ } catch {
756
+ // Preserve the original execution failure; the expired lease remains reclaimable.
757
+ }
758
+ throw error;
759
+ }
760
+ }
761
+
762
+ async function runAgent(options) {
763
+ const api = agentClient(options);
764
+ while (true) {
765
+ const claimed = await claimAgentEvent(api, options, options.once ? 0 : 30);
766
+ if (!claimed.event) {
767
+ if (options.once) return writeResult({ processed: false, reason: "no_event", retry_after_seconds: claimed.retry_after_seconds }, { json: options.json });
768
+ continue;
769
+ }
770
+ const receipt = await processClaimedAgentEvent(api, claimed.event, options);
771
+ if (options.once) return writeResult({ processed: true, ...receipt }, { json: options.json });
772
+ process.stdout.write(`${JSON.stringify({ processed: true, ...receipt })}\n`);
773
+ }
774
+ }
775
+
776
+ async function agentCommand(options, action, id) {
777
+ if (action === "run") return runAgent(options);
778
+ const api = agentClient(options);
779
+ let result;
780
+ if (action === "claim") {
781
+ result = await claimAgentEvent(api, options);
782
+ } else if (action === "renew") {
783
+ const eventId = id || option(options, "id", { required: true });
784
+ result = await api.request(`/agent-events/${resourcePath(eventId)}:renew`, {
785
+ method: "POST",
786
+ body: {
787
+ lease_token: option(options, "lease-token", { required: true }),
788
+ lease_seconds: numberOption(options, "lease-seconds", 300, { minimum: 30, maximum: 900 }),
789
+ },
790
+ });
791
+ } else if (action === "complete") {
792
+ const eventId = id || option(options, "id", { required: true });
793
+ const receipt = options["result-file"]
794
+ ? await readJsonObject(options["result-file"], "--result-file")
795
+ : {
796
+ ...(options["question-id"] ? { question_id: options["question-id"] } : {}),
797
+ ...(options["comment-entry-id"] ? { comment_entry_id: options["comment-entry-id"] } : {}),
798
+ ...(options["no-change-reason"] ? { no_change_reason: options["no-change-reason"] } : {}),
799
+ };
800
+ if (Object.keys(receipt).length === 0) throw new CliError("Provide a durable completion receipt.", { code: "agent_event_receipt_required" });
801
+ result = await api.request(`/agent-events/${resourcePath(eventId)}:complete`, {
802
+ method: "POST",
803
+ body: { lease_token: option(options, "lease-token", { required: true }), result: receipt },
804
+ });
805
+ } else if (action === "fail") {
806
+ const eventId = id || option(options, "id", { required: true });
807
+ result = await api.request(`/agent-events/${resourcePath(eventId)}:fail`, {
808
+ method: "POST",
809
+ body: {
810
+ lease_token: option(options, "lease-token", { required: true }),
811
+ error: option(options, "error", { required: true }),
812
+ retryable: String(options.retryable ?? "true").toLowerCase() !== "false",
813
+ },
814
+ });
815
+ } else if (action === "comments") {
816
+ result = await api.request(`/documents/${resourcePath(option(options, "document-id", { required: true }))}/comments`);
817
+ } else if (action === "comment") {
818
+ const selection = options["selection-file"]
819
+ ? await readJsonObject(options["selection-file"], "--selection-file")
820
+ : { target_scope: "document" };
821
+ result = await api.request(`/documents/${resourcePath(option(options, "document-id", { required: true }))}/comments`, {
822
+ method: "POST",
823
+ body: {
824
+ body: option(options, "body", { required: true }),
825
+ selection,
826
+ entry_point: "external_agent_cli",
827
+ start_new_chain: Boolean(options["start-new-chain"]),
828
+ },
829
+ });
830
+ } else if (action === "reply") {
831
+ const threadId = id || option(options, "id", { required: true });
832
+ const hasSourceEventId = Boolean(options["source-event-id"]);
833
+ const hasSourceEventLease = Boolean(options["source-event-lease-token"]);
834
+ if (hasSourceEventId !== hasSourceEventLease) {
835
+ throw new CliError("Provide --source-event-id and --source-event-lease-token together.", {
836
+ code: "agent_reply_source_incomplete",
837
+ });
838
+ }
839
+ if (hasSourceEventId && options["start-new-chain"]) {
840
+ throw new CliError("A source event reply cannot also start a new chain.", {
841
+ code: "agent_reply_source_conflict",
842
+ });
843
+ }
844
+ result = await api.request(`/documents/${resourcePath(option(options, "document-id", { required: true }))}/comments/${resourcePath(threadId)}/replies`, {
845
+ method: "POST",
846
+ body: {
847
+ body: option(options, "body", { required: true }),
848
+ entry_point: "external_agent_cli_reply",
849
+ ...(options["source-event-id"] ? { source_event_id: options["source-event-id"] } : {}),
850
+ ...(options["source-event-lease-token"] ? { source_event_lease_token: options["source-event-lease-token"] } : {}),
851
+ start_new_chain: Boolean(options["start-new-chain"]),
852
+ },
853
+ });
854
+ } else if (action === "status") {
855
+ const threadId = id || option(options, "id", { required: true });
856
+ result = await api.request(`/documents/${resourcePath(option(options, "document-id", { required: true }))}/comments/${resourcePath(threadId)}/status`, {
857
+ method: "POST",
858
+ body: { status: option(options, "status", { required: true }), entry_point: "external_agent_cli_status" },
859
+ });
860
+ } else {
861
+ throw new CliError("Unknown agent runtime action.");
862
+ }
316
863
  writeResult(result, { json: options.json });
317
864
  }
318
865
 
319
866
  function pageQuery(options) {
320
867
  return {
321
- limit: numberOption(options, "limit", 20),
868
+ limit: numberOption(options, "limit", 20, { maximum: 100 }),
322
869
  cursor: options.cursor,
323
870
  conversation_id: options["conversation-id"],
324
871
  };
@@ -328,7 +875,7 @@ async function questions(options, action, id) {
328
875
  const { api } = await client(options);
329
876
  const result = action === "list"
330
877
  ? await api.request("/questions", { query: pageQuery(options) })
331
- : await api.request(`/questions/${id || option(options, "id", { required: true })}`);
878
+ : await api.request(`/questions/${resourcePath(id || option(options, "id", { required: true }))}`);
332
879
  writeResult(result, { json: options.json });
333
880
  }
334
881
 
@@ -336,7 +883,7 @@ async function conversations(options, action, id) {
336
883
  const { api } = await client(options);
337
884
  const result = action === "list"
338
885
  ? await api.request("/conversations", { query: pageQuery(options) })
339
- : await api.request(`/conversations/${id || option(options, "id", { required: true })}`);
886
+ : await api.request(`/conversations/${resourcePath(id || option(options, "id", { required: true }))}`);
340
887
  writeResult(result, { json: options.json });
341
888
  }
342
889
 
@@ -346,9 +893,10 @@ async function search(options, positionals) {
346
893
  const { api } = await client(options);
347
894
  const result = await api.request("/search", {
348
895
  method: "POST",
896
+ retrySafe: true,
349
897
  body: {
350
898
  query,
351
- limit: numberOption(options, "limit", 10),
899
+ limit: numberOption(options, "limit", 10, { maximum: 50 }),
352
900
  page_id: options["page-id"] || null,
353
901
  node_id: options["node-id"] || null,
354
902
  },
@@ -363,12 +911,13 @@ async function docs(options, action, id) {
363
911
  if (action === "list") {
364
912
  result = await api.request("/documents", { query: pageQuery(options) });
365
913
  } else if (action === "get") {
366
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}`);
914
+ result = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}`);
367
915
  if (options.output) {
368
916
  await writeTextFile(options.output, String(result.markdown || ""));
369
917
  result = { ...result, output: options.output };
370
918
  }
371
919
  } else if (action === "create") {
920
+ rejectTogether(options, "file", "markdown");
372
921
  const markdown = options.file ? await readTextFile(options.file) : String(options.markdown || "");
373
922
  result = await api.request("/documents", {
374
923
  method: "POST",
@@ -381,36 +930,81 @@ async function docs(options, action, id) {
381
930
  },
382
931
  });
383
932
  } else if (action === "update") {
933
+ rejectTogether(options, "file", "markdown");
384
934
  const markdown = options.file ? await readTextFile(options.file) : options.markdown;
385
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}`, {
935
+ if (options.title === undefined && markdown === undefined) {
936
+ throw new CliError("Document update requires --title, --file, or --markdown.", {
937
+ code: "document_update_empty",
938
+ });
939
+ }
940
+ const targetId = documentId || option(options, "id", { required: true });
941
+ let expectedRevision;
942
+ if (options["expected-revision"] !== undefined) {
943
+ expectedRevision = numberOption(options, "expected-revision", undefined, { minimum: 0 });
944
+ } else {
945
+ const current = await api.request(`/documents/${resourcePath(targetId)}`);
946
+ if (!Number.isSafeInteger(current?.content_revision) || current.content_revision < 0) {
947
+ throw new CliError("Corvio did not return a valid document content revision.", {
948
+ code: "document_revision_missing",
949
+ exitCode: EXIT.REMOTE,
950
+ });
951
+ }
952
+ expectedRevision = current.content_revision;
953
+ }
954
+ result = await api.request(`/documents/${resourcePath(targetId)}`, {
386
955
  method: "PUT",
387
956
  body: {
388
- ...(options.title ? { title: options.title } : {}),
957
+ ...(options.title !== undefined ? { title: options.title } : {}),
389
958
  ...(markdown !== undefined ? { markdown } : {}),
390
- ...(options["expected-revision"] !== undefined
391
- ? { expected_content_revision: Number.parseInt(options["expected-revision"], 10) }
392
- : {}),
959
+ expected_content_revision: expectedRevision,
960
+ },
961
+ });
962
+ } else if (action === "move") {
963
+ rejectTogether(options, "before-node-id", "after-node-id");
964
+ const current = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}`);
965
+ result = await api.request(`/nodes/${resourcePath(current.node_id)}:move`, {
966
+ method: "POST",
967
+ headers: { "idempotency-key": idempotencyKey(options) },
968
+ body: {
969
+ parent_node_id: option(options, "parent-node-id", { required: true }),
970
+ before_node_id: options["before-node-id"] || null,
971
+ after_node_id: options["after-node-id"] || null,
393
972
  },
394
973
  });
395
974
  } else if (action === "archive") {
396
975
  requireConfirmation(options, "Archiving a document");
397
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}`, { method: "DELETE" });
976
+ result = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}`, { method: "DELETE" });
398
977
  } else if (action === "share") {
399
978
  requireConfirmation(options, "Creating a document share link");
400
- result = await api.request(`/documents/${documentId || option(options, "id", { required: true })}/shares`, {
979
+ result = await api.request(`/documents/${resourcePath(documentId || option(options, "id", { required: true }))}/shares`, {
401
980
  method: "POST",
981
+ retrySafe: true,
402
982
  body: { public_display_name: options["display-name"] || null },
403
983
  });
404
984
  } else if (action === "open") {
405
- const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/d/${documentId || option(options, "id", { required: true })}`;
985
+ const targetId = documentId || option(options, "id", { required: true });
986
+ const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/d/${resourcePath(targetId)}`;
406
987
  const opened = options["no-input"] ? false : await openBrowser(target);
407
- result = { document_id: documentId, url: target, opened };
988
+ result = { document_id: targetId, url: target, opened };
408
989
  } else {
409
- throw new CliError("docs action must be list, get, create, update, archive, share, or open.");
990
+ throw new CliError("docs action must be list, get, create, update, move, archive, share, or open.");
410
991
  }
411
992
  writeResult(result, { json: options.json });
412
993
  }
413
994
 
995
+ async function projects(options, action) {
996
+ if (action !== "create") {
997
+ throw new CliError("projects action must be create.");
998
+ }
999
+ const { api } = await client(options);
1000
+ const result = await api.request("/projects", {
1001
+ method: "POST",
1002
+ headers: { "idempotency-key": idempotencyKey(options) },
1003
+ body: { title: option(options, "title", { required: true }) },
1004
+ });
1005
+ writeResult(result, { json: options.json });
1006
+ }
1007
+
414
1008
  async function buildUpdateStatus(index, { allowRegistryFailure = false } = {}) {
415
1009
  return resolveNpmUpdateStatus({
416
1010
  currentVersion: VERSION,
@@ -511,30 +1105,50 @@ async function files(options, action, id) {
511
1105
  const assetId = id || options.id;
512
1106
  let result;
513
1107
  if (action === "list") {
514
- result = await api.request("/files", { query: { limit: numberOption(options, "limit", 50) } });
1108
+ result = await api.request("/files", { query: { limit: numberOption(options, "limit", 50, { maximum: 100 }) } });
515
1109
  } else if (action === "get") {
516
- result = await api.request(`/files/${assetId || option(options, "id", { required: true })}`);
1110
+ result = await api.request(`/files/${resourcePath(assetId || option(options, "id", { required: true }))}`);
517
1111
  } else if (action === "download") {
518
1112
  const targetId = assetId || option(options, "id", { required: true });
519
- const metadata = await api.request(`/files/${targetId}`);
520
- const bytes = await api.requestBytes(`/files/${targetId}/content`);
521
- const output = options.output || metadata.original_file_name || `${targetId}.bin`;
1113
+ const metadata = await api.request(`/files/${resourcePath(targetId)}`);
1114
+ const bytes = await api.requestBytes(`/files/${resourcePath(targetId)}/content`);
1115
+ if (metadata.content_sha256 && sha256(bytes) !== metadata.content_sha256) {
1116
+ throw new CliError("Downloaded file content did not match Corvio's SHA-256 receipt.", {
1117
+ code: "download_integrity_mismatch",
1118
+ exitCode: EXIT.REMOTE,
1119
+ });
1120
+ }
1121
+ const output = options.output || safeRemoteFileName(metadata.original_file_name, `${targetId}.bin`);
522
1122
  const written = await writeBinaryFile(output, bytes);
523
1123
  result = { ...metadata, output: written };
524
1124
  } else if (action === "upload") {
525
1125
  const input = resolve(String(option(options, "file", { required: true })));
526
1126
  const info = await stat(input);
527
1127
  if (!info.isFile()) throw new CliError("--file must point to a regular file.", { code: "upload_file_invalid" });
1128
+ if (info.size < 1 || info.size > MAX_UPLOAD_BYTES) {
1129
+ throw new CliError(`--file must be between 1 byte and ${MAX_UPLOAD_BYTES} bytes.`, {
1130
+ code: "upload_file_size_invalid",
1131
+ });
1132
+ }
528
1133
  const bytes = await readFile(input);
1134
+ if (bytes.length < 1 || bytes.length > MAX_UPLOAD_BYTES) {
1135
+ throw new CliError(`--file must be between 1 byte and ${MAX_UPLOAD_BYTES} bytes.`, {
1136
+ code: "upload_file_size_invalid",
1137
+ });
1138
+ }
529
1139
  const fileName = basename(input);
530
1140
  const mimeType = String(options["mime-type"] || mimeTypeFor(input));
531
1141
  const initialized = await api.request("/files/uploads", {
532
1142
  method: "POST",
533
1143
  body: { file_name: fileName, mime_type: mimeType, size_bytes: bytes.length },
534
1144
  });
535
- await api.putUrl(initialized.upload_url, bytes, { headers: initialized.upload_headers || {} });
1145
+ await api.putUrl(initialized.upload_url, bytes, {
1146
+ headers: initialized.upload_headers || {},
1147
+ timeoutMs: 5 * 60_000,
1148
+ });
536
1149
  result = await api.request("/files/uploads:finalize", {
537
1150
  method: "POST",
1151
+ retrySafe: true,
538
1152
  body: {
539
1153
  upload_id: initialized.upload_id,
540
1154
  blob_name: initialized.blob_name,
@@ -543,9 +1157,15 @@ async function files(options, action, id) {
543
1157
  size_bytes: bytes.length,
544
1158
  },
545
1159
  });
1160
+ if (result.content_sha256 && result.content_sha256 !== sha256(bytes)) {
1161
+ throw new CliError("Uploaded file content did not match Corvio's SHA-256 receipt.", {
1162
+ code: "upload_integrity_mismatch",
1163
+ exitCode: EXIT.REMOTE,
1164
+ });
1165
+ }
546
1166
  } else if (action === "organize") {
547
1167
  requireConfirmation(options, "Starting a file organization run");
548
- result = await api.request(`/files/${assetId || option(options, "id", { required: true })}:organize`, {
1168
+ result = await api.request(`/files/${resourcePath(assetId || option(options, "id", { required: true }))}:organize`, {
549
1169
  method: "POST",
550
1170
  body: {
551
1171
  instruction: option(options, "instruction", { required: true }),
@@ -554,7 +1174,7 @@ async function files(options, action, id) {
554
1174
  });
555
1175
  } else if (action === "open") {
556
1176
  const targetId = assetId || option(options, "id", { required: true });
557
- const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/a/${targetId}`;
1177
+ const target = `${String(process.env.CORVIO_APP_BASE || DEFAULT_APP_BASE).replace(/\/+$/, "")}/app/a/${resourcePath(targetId)}`;
558
1178
  result = { file_id: targetId, url: target, opened: options["no-input"] ? false : await openBrowser(target) };
559
1179
  } else {
560
1180
  throw new CliError("files action must be list, get, download, upload, organize, or open.");
@@ -609,6 +1229,9 @@ async function writeSyncState(root, state) {
609
1229
  }
610
1230
 
611
1231
  function localSyncPath(root, relativePath) {
1232
+ if (String(relativePath).includes("\0")) {
1233
+ throw new CliError("Unsafe sync path returned by Corvio.", { code: "sync_path_unsafe" });
1234
+ }
612
1235
  const parts = String(relativePath).replaceAll("\\", "/").split("/").filter(Boolean);
613
1236
  if (parts[0] !== "corvio_docs" || parts.some((part) => part === "." || part === "..")) {
614
1237
  throw new CliError(`Unsafe sync path returned by Corvio: ${relativePath}`, { code: "sync_path_unsafe" });
@@ -620,9 +1243,56 @@ function localSyncPath(root, relativePath) {
620
1243
  return target;
621
1244
  }
622
1245
 
1246
+ async function assertNoSyncSymlink(root, target) {
1247
+ const rootInfo = await lstat(root);
1248
+ if (rootInfo.isSymbolicLink()) {
1249
+ throw new CliError(`Sync root must not be a symbolic link: ${root}`, {
1250
+ code: "sync_symlink_unsafe",
1251
+ });
1252
+ }
1253
+ const relativeTarget = relative(root, target);
1254
+ if (relativeTarget === "" || relativeTarget.startsWith(`..${sep}`) || relativeTarget === "..") {
1255
+ throw new CliError(`Unsafe local sync target: ${target}`, { code: "sync_path_unsafe" });
1256
+ }
1257
+ let current = root;
1258
+ for (const part of relativeTarget.split(sep).filter(Boolean)) {
1259
+ current = join(current, part);
1260
+ try {
1261
+ const info = await lstat(current);
1262
+ if (info.isSymbolicLink()) {
1263
+ throw new CliError(`Sync path crosses a symbolic link: ${current}`, {
1264
+ code: "sync_symlink_unsafe",
1265
+ });
1266
+ }
1267
+ } catch (error) {
1268
+ if (error?.code === "ENOENT") return;
1269
+ throw error;
1270
+ }
1271
+ }
1272
+ }
1273
+
1274
+ function verifiedRemoteMarkdown(item) {
1275
+ const markdown = String(item?.markdown || "");
1276
+ if (markdown.length > MAX_MARKDOWN_CHARACTERS) {
1277
+ throw new CliError("Remote Markdown exceeds the supported sync size.", {
1278
+ code: "sync_markdown_too_large",
1279
+ exitCode: EXIT.REMOTE,
1280
+ });
1281
+ }
1282
+ const contentHash = sha256(Buffer.from(markdown, "utf8"));
1283
+ if (contentHash !== item?.content_sha256) {
1284
+ throw new CliError("Remote Markdown did not match Corvio's SHA-256 receipt.", {
1285
+ code: "sync_integrity_mismatch",
1286
+ exitCode: EXIT.REMOTE,
1287
+ });
1288
+ }
1289
+ return { markdown, contentHash };
1290
+ }
1291
+
623
1292
  async function scanMarkdown(root) {
624
1293
  const mirror = join(root, "corvio_docs");
625
1294
  await mkdir(mirror, { recursive: true });
1295
+ await assertNoSyncSymlink(root, mirror);
626
1296
  const manifest = [];
627
1297
  async function walk(directory) {
628
1298
  for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -639,6 +1309,11 @@ async function scanMarkdown(root) {
639
1309
  size_bytes: bytes.length,
640
1310
  mtime_ms: Math.trunc(info.mtimeMs),
641
1311
  });
1312
+ if (manifest.length > MAX_SYNC_FILES) {
1313
+ throw new CliError(`Sync supports at most ${MAX_SYNC_FILES} Markdown files per source.`, {
1314
+ code: "sync_file_limit_exceeded",
1315
+ });
1316
+ }
642
1317
  }
643
1318
  }
644
1319
  }
@@ -682,6 +1357,7 @@ async function syncManifest(root, state) {
682
1357
  async function syncPlan(api, state, root) {
683
1358
  return api.request("/sync/plan", {
684
1359
  method: "POST",
1360
+ retrySafe: true,
685
1361
  body: { source_id: state.source_id, local: await syncManifest(root, state) },
686
1362
  });
687
1363
  }
@@ -735,7 +1411,7 @@ async function syncCommand(options, action) {
735
1411
  );
736
1412
  }
737
1413
  if (action === "status") {
738
- const remote = await api.request(`/sync/sources/${state.source_id}`);
1414
+ const remote = await api.request(`/sync/sources/${resourcePath(state.source_id)}`);
739
1415
  return writeResult({ ...remote, directory: root, state_file: syncStatePath(root) }, { json: options.json });
740
1416
  }
741
1417
  if (action === "plan") {
@@ -757,9 +1433,12 @@ async function syncCommand(options, action) {
757
1433
  const item = actionItem.remote;
758
1434
  if (!item) continue;
759
1435
  const target = localSyncPath(root, item.relative_path);
1436
+ await assertNoSyncSymlink(root, target);
1437
+ const verified = verifiedRemoteMarkdown(item);
760
1438
  const previous = mappings[item.page_id];
761
1439
  if (previous?.relative_path && previous.relative_path !== item.relative_path) {
762
1440
  const oldTarget = localSyncPath(root, previous.relative_path);
1441
+ await assertNoSyncSymlink(root, oldTarget);
763
1442
  try {
764
1443
  const oldBytes = await readFile(oldTarget);
765
1444
  if (sha256(oldBytes) === previous.content_sha256) await unlink(oldTarget);
@@ -767,13 +1446,13 @@ async function syncCommand(options, action) {
767
1446
  if (cause?.code !== "ENOENT") throw cause;
768
1447
  }
769
1448
  }
770
- await writeTextFile(target, item.markdown || "");
1449
+ await writeTextFile(target, verified.markdown);
771
1450
  const info = await stat(target);
772
1451
  committed.push({
773
1452
  page_id: item.page_id,
774
1453
  node_id: item.node_id,
775
1454
  relative_path: item.relative_path,
776
- content_sha256: item.content_sha256,
1455
+ content_sha256: verified.contentHash,
777
1456
  mtime_ms: Math.trunc(info.mtimeMs),
778
1457
  });
779
1458
  mappings[item.page_id] = {
@@ -813,7 +1492,14 @@ async function syncCommand(options, action) {
813
1492
  const mappings = { ...(state.mappings || {}) };
814
1493
  for (const actionItem of plan.actions.filter((item) => item.action === "push")) {
815
1494
  const path = localSyncPath(root, actionItem.relative_path);
1495
+ await assertNoSyncSymlink(root, path);
816
1496
  const bytes = await readFile(path);
1497
+ const markdown = bytes.toString("utf8");
1498
+ if (markdown.length > MAX_MARKDOWN_CHARACTERS) {
1499
+ throw new CliError(`Markdown exceeds ${MAX_MARKDOWN_CHARACTERS} characters: ${actionItem.relative_path}`, {
1500
+ code: "sync_markdown_too_large",
1501
+ });
1502
+ }
817
1503
  const info = await stat(path);
818
1504
  const previous = actionItem.page_id ? mappings[actionItem.page_id] : null;
819
1505
  const result = await api.request("/sync/push", {
@@ -821,7 +1507,7 @@ async function syncCommand(options, action) {
821
1507
  body: {
822
1508
  source_id: state.source_id,
823
1509
  relative_path: actionItem.relative_path,
824
- markdown: bytes.toString("utf8"),
1510
+ markdown,
825
1511
  content_sha256: sha256(bytes),
826
1512
  mtime_ms: Math.trunc(info.mtimeMs),
827
1513
  page_id: actionItem.page_id || null,
@@ -835,6 +1521,9 @@ async function syncCommand(options, action) {
835
1521
  content_sha256: result.item.content_sha256,
836
1522
  content_revision: result.item.content_revision,
837
1523
  };
1524
+ state.mappings = mappings;
1525
+ state.last_push_at = new Date().toISOString();
1526
+ await writeSyncState(root, state);
838
1527
  }
839
1528
  state.mappings = mappings;
840
1529
  state.last_push_at = new Date().toISOString();
@@ -849,12 +1538,14 @@ async function syncCommand(options, action) {
849
1538
  requireConfirmation(options, "Resolving a sync conflict");
850
1539
  const conflictId = option(options, "conflict-id", { required: true });
851
1540
  const strategy = option(options, "strategy", { required: true });
852
- const conflict = await api.request(`/sync/conflicts/${conflictId}`);
1541
+ const conflict = await api.request(`/sync/conflicts/${resourcePath(conflictId)}`);
853
1542
  if (strategy === "use-remote") {
854
1543
  if (!conflict.remote) throw new CliError("The remote document is no longer available.", { code: "sync_remote_missing" });
855
1544
  const item = conflict.remote;
856
1545
  const target = localSyncPath(root, item.relative_path);
857
- await writeTextFile(target, item.markdown || "");
1546
+ await assertNoSyncSymlink(root, target);
1547
+ const verified = verifiedRemoteMarkdown(item);
1548
+ await writeTextFile(target, verified.markdown);
858
1549
  const info = await stat(target);
859
1550
  await api.request("/sync/commit", {
860
1551
  method: "POST",
@@ -864,12 +1555,12 @@ async function syncCommand(options, action) {
864
1555
  page_id: item.page_id,
865
1556
  node_id: item.node_id,
866
1557
  relative_path: item.relative_path,
867
- content_sha256: item.content_sha256,
1558
+ content_sha256: verified.contentHash,
868
1559
  mtime_ms: Math.trunc(info.mtimeMs),
869
1560
  }],
870
1561
  },
871
1562
  });
872
- const result = await api.request(`/sync/conflicts/${conflictId}:resolve`, {
1563
+ const result = await api.request(`/sync/conflicts/${resourcePath(conflictId)}:resolve`, {
873
1564
  method: "POST",
874
1565
  body: { strategy: "use_remote" },
875
1566
  });
@@ -884,14 +1575,21 @@ async function syncCommand(options, action) {
884
1575
  }
885
1576
  if (strategy === "keep-local") {
886
1577
  const target = localSyncPath(root, conflict.relative_path);
1578
+ await assertNoSyncSymlink(root, target);
887
1579
  const bytes = await readFile(target);
1580
+ const markdown = bytes.toString("utf8");
1581
+ if (markdown.length > MAX_MARKDOWN_CHARACTERS) {
1582
+ throw new CliError(`Markdown exceeds ${MAX_MARKDOWN_CHARACTERS} characters: ${conflict.relative_path}`, {
1583
+ code: "sync_markdown_too_large",
1584
+ });
1585
+ }
888
1586
  const info = await stat(target);
889
1587
  const result = await api.request("/sync/push", {
890
1588
  method: "POST",
891
1589
  body: {
892
1590
  source_id: state.source_id,
893
1591
  relative_path: conflict.relative_path,
894
- markdown: bytes.toString("utf8"),
1592
+ markdown,
895
1593
  content_sha256: sha256(bytes),
896
1594
  mtime_ms: Math.trunc(info.mtimeMs),
897
1595
  page_id: conflict.remote?.page_id || null,
@@ -915,7 +1613,12 @@ async function syncCommand(options, action) {
915
1613
  async function main({ options, positionals }) {
916
1614
  const [command, action, id, ...rest] = positionals;
917
1615
  if (options.version || command === "version") return writeResult(VERSION, { json: options.json });
918
- if (options.help || !command || command === "help") return writeResult(HELP.trimEnd(), { json: false });
1616
+ if (options.help || !command || command === "help") {
1617
+ const helpCommand = command === "help" ? action : command;
1618
+ const helpAction = command === "help" ? id : action;
1619
+ return writeResult(contextualHelp(helpCommand, helpAction), { json: false });
1620
+ }
1621
+ validateInvocation(options, positionals);
919
1622
  if (command !== "update") await maybeNotifyUpdate(options);
920
1623
  if (command === "auth") {
921
1624
  if (action === "login") return authLogin(options);
@@ -928,10 +1631,13 @@ async function main({ options, positionals }) {
928
1631
  }
929
1632
  if (command === "capabilities") return capabilities(options);
930
1633
  if (command === "ask") return ask(options, [action, id, ...rest].filter(Boolean));
1634
+ if (command === "agents") return agents(options, action || "list", id);
1635
+ if (command === "agent") return agentCommand(options, action || "claim", id);
931
1636
  if (command === "questions") return questions(options, action || "list", id);
932
1637
  if (command === "conversations") return conversations(options, action || "list", id);
933
1638
  if (command === "search") return search(options, [action, id, ...rest].filter(Boolean));
934
1639
  if (command === "docs") return docs(options, action || "list", id);
1640
+ if (command === "projects" || command === "folders") return projects(options, action || "create");
935
1641
  if (command === "files") return files(options, action || "list", id);
936
1642
  if (command === "sync") return syncCommand(options, action || "status");
937
1643
  if (command === "update" && (action || "check") === "check") return updateCheck(options);