@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.
- package/README.md +34 -0
- package/dist/cli.js +743 -37
- package/dist/core.js +306 -48
- 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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, {
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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")
|
|
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);
|