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

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 +85 -4
  2. package/dist/cli.js +3524 -132
  3. package/dist/core.js +554 -65
  4. package/package.json +1 -1
package/dist/core.js CHANGED
@@ -32,6 +32,59 @@ function exitCodeForStatus(status) {
32
32
  return EXIT.REMOTE;
33
33
  }
34
34
 
35
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
36
+ const MAX_REQUEST_TIMEOUT_MS = 10 * 60_000;
37
+ const RETRYABLE_HTTP_STATUSES = new Set([408, 429, 502, 503, 504]);
38
+
39
+ function normalizeRequestTimeout(value, fallback = DEFAULT_REQUEST_TIMEOUT_MS) {
40
+ const raw = value === undefined || value === null || value === "" ? fallback : Number(value);
41
+ if (!Number.isSafeInteger(raw) || raw < 1 || raw > MAX_REQUEST_TIMEOUT_MS) {
42
+ throw new CliError(
43
+ `Request timeout must be an integer between 1 and ${MAX_REQUEST_TIMEOUT_MS} milliseconds.`,
44
+ { code: "invalid_request_timeout" },
45
+ );
46
+ }
47
+ return raw;
48
+ }
49
+
50
+ function networkFailureDetails({
51
+ cause,
52
+ url,
53
+ method,
54
+ requestHeaders = {},
55
+ body = undefined,
56
+ retrySafe = false,
57
+ }) {
58
+ const nested = cause && typeof cause === "object" ? cause.cause : null;
59
+ const causeCode = String(nested?.code || cause?.code || "").trim() || null;
60
+ const syscall = String(nested?.syscall || cause?.syscall || "").trim() || null;
61
+ const normalizedMethod = String(method || "GET").toUpperCase();
62
+ const operationKey = String(requestHeaders["idempotency-key"] || requestHeaders["Idempotency-Key"] || "").trim();
63
+ const revision = body?.expected_content_revision;
64
+ const revisionGuarded = normalizedMethod === "PUT" && Number.isSafeInteger(revision) && revision >= 0;
65
+ const safeToRetry = retrySafe || ["GET", "HEAD"].includes(normalizedMethod) || Boolean(operationKey) || revisionGuarded;
66
+ const timedOut = cause?.name === "TimeoutError" || cause?.name === "AbortError";
67
+ return {
68
+ method: normalizedMethod,
69
+ origin: new URL(String(url)).origin,
70
+ response_received: false,
71
+ timed_out: timedOut,
72
+ safe_to_retry: safeToRetry,
73
+ retry_safety: operationKey
74
+ ? "idempotency_key"
75
+ : revisionGuarded
76
+ ? "revision_guard"
77
+ : retrySafe
78
+ ? "operation_contract"
79
+ : safeToRetry
80
+ ? "read_only"
81
+ : "unknown_commit_state",
82
+ ...(operationKey ? { idempotency_key: operationKey } : {}),
83
+ ...(causeCode ? { cause_code: causeCode } : {}),
84
+ ...(syscall ? { syscall } : {}),
85
+ };
86
+ }
87
+
35
88
  export function parseArgv(argv) {
36
89
  const options = Object.create(null);
37
90
  const positionals = [];
@@ -40,21 +93,42 @@ export function parseArgv(argv) {
40
93
  "no-input",
41
94
  "yes",
42
95
  "allow-actions",
96
+ "background",
97
+ "content",
98
+ "organize",
43
99
  "private-link",
44
100
  "force",
101
+ "once",
102
+ "include-revoked",
103
+ "recover-credential",
104
+ "no-listen",
105
+ "start-new-chain",
106
+ "wait-until-terminal",
45
107
  "help",
46
108
  "version",
47
109
  ]);
48
110
  for (let index = 0; index < argv.length; index += 1) {
49
111
  const value = argv[index];
112
+ if (value === "--") {
113
+ positionals.push(...argv.slice(index + 1));
114
+ break;
115
+ }
50
116
  if (!value.startsWith("--")) {
51
117
  positionals.push(value);
52
118
  continue;
53
119
  }
54
120
  const separator = value.indexOf("=");
55
121
  const name = value.slice(2, separator === -1 ? undefined : separator);
122
+ if (!name) throw new CliError("Option name must not be empty.", { code: "invalid_option" });
123
+ if (Object.hasOwn(options, name)) {
124
+ throw new CliError(`Option --${name} was provided more than once.`, { code: "duplicate_option" });
125
+ }
56
126
  if (booleanOptions.has(name)) {
57
- options[name] = separator === -1 ? true : value.slice(separator + 1) !== "false";
127
+ const booleanValue = separator === -1 ? "true" : value.slice(separator + 1);
128
+ if (booleanValue !== "true" && booleanValue !== "false") {
129
+ throw new CliError(`Option --${name} must be true or false.`, { code: "invalid_boolean_option" });
130
+ }
131
+ options[name] = booleanValue === "true";
58
132
  continue;
59
133
  }
60
134
  if (separator !== -1) {
@@ -71,6 +145,21 @@ export function parseArgv(argv) {
71
145
  return { options, positionals };
72
146
  }
73
147
 
148
+ export function assertKnownOptions(options, allowed) {
149
+ const allowedSet = new Set(allowed);
150
+ for (const name of Object.keys(options)) {
151
+ if (!allowedSet.has(name)) {
152
+ throw new CliError(`Unknown option --${name}.`, { code: "unknown_option" });
153
+ }
154
+ }
155
+ }
156
+
157
+ export function assertPositionalCount(positionals, maximum) {
158
+ if (positionals.length > maximum) {
159
+ throw new CliError(`Unexpected argument: ${positionals[maximum]}`, { code: "unexpected_argument" });
160
+ }
161
+ }
162
+
74
163
  export function option(options, name, { required = false, fallback = undefined } = {}) {
75
164
  const value = options[name] ?? fallback;
76
165
  if (required && (value === undefined || String(value).trim() === "")) {
@@ -85,28 +174,43 @@ export function listOption(options, name, fallback = []) {
85
174
  return String(value).split(",").map((item) => item.trim()).filter(Boolean);
86
175
  }
87
176
 
88
- export function numberOption(options, name, fallback) {
177
+ export function numberOption(options, name, fallback, { minimum = 1, maximum = Number.MAX_SAFE_INTEGER } = {}) {
89
178
  const value = options[name];
90
179
  if (value === undefined) return fallback;
91
- const parsed = Number.parseInt(String(value), 10);
92
- if (!Number.isFinite(parsed) || parsed < 1) {
93
- throw new CliError(`Option --${name} must be a positive integer.`, { code: "invalid_number_option" });
180
+ const normalized = String(value).trim();
181
+ const parsed = /^\d+$/.test(normalized) ? Number(normalized) : Number.NaN;
182
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
183
+ throw new CliError(
184
+ `Option --${name} must be an integer between ${minimum} and ${maximum}.`,
185
+ { code: "invalid_number_option" },
186
+ );
94
187
  }
95
188
  return parsed;
96
189
  }
97
190
 
98
191
  export function normalizeApiBase(value, fallback) {
99
192
  const raw = String(value || fallback).trim().replace(/\/+$/, "");
100
- if (!/^https:\/\//i.test(raw) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(raw)) {
193
+ let parsed;
194
+ try {
195
+ parsed = new URL(raw);
196
+ } catch {
197
+ throw new CliError("Corvio API base must be a valid URL.", { code: "invalid_api_base" });
198
+ }
199
+ const localhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
200
+ if (parsed.username || parsed.password || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && localhost))) {
101
201
  throw new CliError("Corvio API base must use HTTPS (localhost is allowed for development).", {
102
202
  code: "insecure_api_base",
103
203
  });
104
204
  }
105
- return raw.endsWith("/v1") ? raw : `${raw}/v1`;
205
+ if (parsed.search || parsed.hash) {
206
+ throw new CliError("Corvio API base must not include a query string or fragment.", { code: "invalid_api_base" });
207
+ }
208
+ const normalized = parsed.toString().replace(/\/+$/, "");
209
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
106
210
  }
107
211
 
108
212
  function parseSemver(value) {
109
- const match = String(value || "").trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
213
+ const match = String(value || "").trim().match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
110
214
  if (!match) return null;
111
215
  return {
112
216
  core: match.slice(1, 4).map((item) => Number.parseInt(item, 10)),
@@ -142,6 +246,7 @@ export async function resolveNpmUpdateStatus({
142
246
  policy,
143
247
  userAgent,
144
248
  allowRegistryFailure = false,
249
+ checkRegistry = true,
145
250
  fetchImpl = globalThis.fetch,
146
251
  registryBase = process.env.CORVIO_NPM_REGISTRY_BASE || "https://registry.npmjs.org",
147
252
  }) {
@@ -151,51 +256,76 @@ export async function resolveNpmUpdateStatus({
151
256
  const encoded = encodeURIComponent(packageName).replace("%40", "@");
152
257
  let latestVersion = null;
153
258
  let registryError = null;
154
- try {
155
- const registry = String(registryBase).replace(/\/+$/, "");
156
- const response = await fetchImpl(`${registry}/${encoded}`, {
157
- headers: { accept: "application/json", "user-agent": userAgent },
158
- signal: AbortSignal.timeout(2500),
159
- });
160
- if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
161
- const metadata = await response.json();
162
- latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
163
- } catch (error) {
164
- registryError = error instanceof Error ? error.message : String(error);
165
- if (!allowRegistryFailure) {
166
- throw new CliError(`Unable to check npm for updates: ${registryError}`, {
167
- code: "update_check_failed",
168
- exitCode: EXIT.REMOTE,
259
+ if (checkRegistry) {
260
+ try {
261
+ const registry = String(registryBase).replace(/\/+$/, "");
262
+ const response = await fetchImpl(`${registry}/${encoded}`, {
263
+ headers: { accept: "application/json", "user-agent": userAgent },
264
+ signal: AbortSignal.timeout(2500),
169
265
  });
266
+ if (!response.ok) throw new Error(`npm registry returned ${response.status}`);
267
+ const metadata = await response.json();
268
+ latestVersion = String(metadata?.["dist-tags"]?.[distTag] || "").trim() || null;
269
+ } catch (error) {
270
+ registryError = error instanceof Error ? error.message : String(error);
271
+ if (!allowRegistryFailure) {
272
+ throw new CliError(`Unable to check npm for updates: ${registryError}`, {
273
+ code: "update_check_failed",
274
+ exitCode: EXIT.REMOTE,
275
+ });
276
+ }
170
277
  }
171
278
  }
172
279
  const minimum = String(policy.minimum_supported_version || "").trim() || null;
280
+ const installCommand = latestVersion
281
+ ? `npm install --global ${packageName}@${latestVersion}`
282
+ : String(policy.install_command || `npm install --global ${packageName}@${distTag}`);
283
+ const executable = packageName === "@corvio/admin-cli" ? "corvio-admin" : "corvio";
173
284
  return {
174
285
  package: packageName,
175
286
  channel: String(policy.channel || distTag),
176
287
  dist_tag: distTag,
177
288
  current_version: currentVersion,
178
289
  latest_published_version: latestVersion,
290
+ registry_checked: checkRegistry,
179
291
  reviewed_version: String(policy.reviewed_version || "").trim() || null,
180
292
  minimum_supported_version: minimum,
181
293
  update_available: Boolean(latestVersion && compareVersions(latestVersion, currentVersion) > 0),
182
294
  supported: minimum ? compareVersions(currentVersion, minimum) >= 0 : true,
183
- install_command: String(policy.install_command || `npm install --global ${packageName}@${distTag}`),
295
+ install_command: installCommand,
296
+ verify_command: `${executable} --version`,
297
+ expected_version: latestVersion,
184
298
  automatic_install: false,
185
299
  ...(registryError ? { registry_error: registryError } : {}),
186
300
  };
187
301
  }
188
302
 
189
303
  export class ApiClient {
190
- constructor({ baseUrl, credential = null, workspaceId = null, userAgent, fetchImpl = globalThis.fetch }) {
304
+ constructor({
305
+ baseUrl,
306
+ credential = null,
307
+ workspaceId = null,
308
+ userAgent,
309
+ fetchImpl = globalThis.fetch,
310
+ timeoutMs = process.env.CORVIO_REQUEST_TIMEOUT_MS,
311
+ }) {
191
312
  this.baseUrl = baseUrl.replace(/\/+$/, "");
192
313
  this.credential = credential;
193
314
  this.workspaceId = String(workspaceId || "").trim() || null;
194
315
  this.userAgent = userAgent;
195
316
  this.fetchImpl = fetchImpl;
317
+ this.timeoutMs = normalizeRequestTimeout(timeoutMs);
196
318
  }
197
319
 
198
- async request(path, { method = "GET", body, query, headers = {}, auth = true } = {}) {
320
+ async request(path, {
321
+ method = "GET",
322
+ body,
323
+ query,
324
+ headers = {},
325
+ auth = true,
326
+ retrySafe = false,
327
+ timeoutMs = this.timeoutMs,
328
+ } = {}) {
199
329
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
200
330
  for (const [key, value] of Object.entries(query || {})) {
201
331
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -216,18 +346,56 @@ export class ApiClient {
216
346
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
217
347
  }
218
348
  }
349
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
219
350
  let response;
220
- try {
221
- response = await this.fetchImpl(url, {
222
- method,
223
- headers: requestHeaders,
224
- body: body === undefined ? undefined : JSON.stringify(body),
225
- });
226
- } catch (cause) {
227
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
228
- code: "network_error",
229
- exitCode: EXIT.REMOTE,
230
- });
351
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
352
+ try {
353
+ response = await this.fetchImpl(url, {
354
+ method,
355
+ headers: requestHeaders,
356
+ body: body === undefined ? undefined : JSON.stringify(body),
357
+ signal: AbortSignal.timeout(normalizedTimeout),
358
+ });
359
+ const safety = networkFailureDetails({
360
+ cause: null,
361
+ url,
362
+ method,
363
+ requestHeaders,
364
+ body,
365
+ retrySafe,
366
+ });
367
+ if (attempt === 1 && safety.safe_to_retry && RETRYABLE_HTTP_STATUSES.has(response.status)) {
368
+ await response.body?.cancel().catch(() => {});
369
+ const retryAfterValue = response.headers.get("retry-after");
370
+ const retryAfterSeconds = retryAfterValue === null ? Number.NaN : Number(retryAfterValue);
371
+ const delayMs = Number.isFinite(retryAfterSeconds)
372
+ ? Math.min(2_000, Math.max(0, retryAfterSeconds * 1000))
373
+ : 150;
374
+ await sleep(delayMs);
375
+ continue;
376
+ }
377
+ break;
378
+ } catch (cause) {
379
+ const details = networkFailureDetails({ cause, url, method, requestHeaders, body, retrySafe });
380
+ if (attempt === 1 && details.safe_to_retry) {
381
+ await sleep(150);
382
+ continue;
383
+ }
384
+ throw new CliError(
385
+ details.timed_out
386
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
387
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
388
+ {
389
+ code: details.timed_out ? "request_timeout" : "network_error",
390
+ exitCode: EXIT.REMOTE,
391
+ details: {
392
+ ...details,
393
+ attempts: attempt,
394
+ automatic_retry_attempted: attempt > 1,
395
+ },
396
+ },
397
+ );
398
+ }
231
399
  }
232
400
  const text = await response.text();
233
401
  let payload = null;
@@ -244,13 +412,18 @@ export class ApiClient {
244
412
  code: String(envelope.code || `http_${response.status}`),
245
413
  status: response.status,
246
414
  exitCode: exitCodeForStatus(response.status),
247
- details: envelope.details || null,
415
+ details: {
416
+ ...(envelope.details || {}),
417
+ ...(envelope.request_id || response.headers.get("x-request-id")
418
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
419
+ : {}),
420
+ },
248
421
  });
249
422
  }
250
423
  return payload;
251
424
  }
252
425
 
253
- async requestBytes(path, { query, headers = {}, auth = true } = {}) {
426
+ async requestBytes(path, { query, headers = {}, auth = true, timeoutMs = this.timeoutMs } = {}) {
254
427
  const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`);
255
428
  for (const [key, value] of Object.entries(query || {})) {
256
429
  if (value !== undefined && value !== null && value !== "") url.searchParams.set(key, String(value));
@@ -270,14 +443,38 @@ export class ApiClient {
270
443
  requestHeaders["X-Corvio-Workspace-Context-Contract"] = "required-v1";
271
444
  }
272
445
  }
446
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
273
447
  let response;
274
- try {
275
- response = await this.fetchImpl(url, { method: "GET", headers: requestHeaders });
276
- } catch (cause) {
277
- throw new CliError(`Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`, {
278
- code: "network_error",
279
- exitCode: EXIT.REMOTE,
280
- });
448
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
449
+ try {
450
+ response = await this.fetchImpl(url, {
451
+ method: "GET",
452
+ headers: requestHeaders,
453
+ signal: AbortSignal.timeout(normalizedTimeout),
454
+ });
455
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
456
+ await response.body?.cancel().catch(() => {});
457
+ await sleep(150);
458
+ continue;
459
+ }
460
+ break;
461
+ } catch (cause) {
462
+ const details = networkFailureDetails({ cause, url, method: "GET", requestHeaders });
463
+ if (attempt === 1) {
464
+ await sleep(150);
465
+ continue;
466
+ }
467
+ throw new CliError(
468
+ details.timed_out
469
+ ? `Corvio did not respond within ${normalizedTimeout} ms.`
470
+ : `Unable to reach Corvio: ${cause instanceof Error ? cause.message : String(cause)}`,
471
+ {
472
+ code: details.timed_out ? "request_timeout" : "network_error",
473
+ exitCode: EXIT.REMOTE,
474
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
475
+ },
476
+ );
477
+ }
281
478
  }
282
479
  const bytes = new Uint8Array(await response.arrayBuffer());
283
480
  if (!response.ok) {
@@ -293,25 +490,57 @@ export class ApiClient {
293
490
  code: String(envelope.code || `http_${response.status}`),
294
491
  status: response.status,
295
492
  exitCode: exitCodeForStatus(response.status),
296
- details: envelope.details || null,
493
+ details: {
494
+ ...(envelope.details || {}),
495
+ ...(envelope.request_id || response.headers.get("x-request-id")
496
+ ? { request_id: envelope.request_id || response.headers.get("x-request-id") }
497
+ : {}),
498
+ },
297
499
  });
298
500
  }
299
501
  return bytes;
300
502
  }
301
503
 
302
- async putUrl(url, content, { headers = {} } = {}) {
504
+ async putUrl(url, content, { headers = {}, timeoutMs = this.timeoutMs } = {}) {
505
+ const normalizedTimeout = normalizeRequestTimeout(timeoutMs, this.timeoutMs);
303
506
  let response;
304
- try {
305
- response = await this.fetchImpl(String(url), {
306
- method: "PUT",
307
- headers,
308
- body: content,
309
- });
310
- } catch (cause) {
311
- throw new CliError(`Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`, {
312
- code: "upload_network_error",
313
- exitCode: EXIT.REMOTE,
314
- });
507
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
508
+ try {
509
+ response = await this.fetchImpl(String(url), {
510
+ method: "PUT",
511
+ headers,
512
+ body: content,
513
+ signal: AbortSignal.timeout(normalizedTimeout),
514
+ });
515
+ if (attempt === 1 && RETRYABLE_HTTP_STATUSES.has(response.status)) {
516
+ await response.body?.cancel().catch(() => {});
517
+ await sleep(150);
518
+ continue;
519
+ }
520
+ break;
521
+ } catch (cause) {
522
+ const details = networkFailureDetails({
523
+ cause,
524
+ url,
525
+ method: "PUT",
526
+ requestHeaders: headers,
527
+ retrySafe: true,
528
+ });
529
+ if (attempt === 1) {
530
+ await sleep(150);
531
+ continue;
532
+ }
533
+ throw new CliError(
534
+ details.timed_out
535
+ ? `The file upload did not respond within ${normalizedTimeout} ms.`
536
+ : `Unable to upload the file: ${cause instanceof Error ? cause.message : String(cause)}`,
537
+ {
538
+ code: details.timed_out ? "upload_timeout" : "upload_network_error",
539
+ exitCode: EXIT.REMOTE,
540
+ details: { ...details, attempts: attempt, automatic_retry_attempted: true },
541
+ },
542
+ );
543
+ }
315
544
  }
316
545
  if (!response.ok) {
317
546
  const message = (await response.text()).slice(0, 500);
@@ -343,6 +572,14 @@ export function adminCredentialPath(env = process.env) {
343
572
  return join(configRoot(env), "admin-credentials.json");
344
573
  }
345
574
 
575
+ export function agentCredentialPath(env = process.env) {
576
+ return join(configRoot(env), "agent-credentials.json");
577
+ }
578
+
579
+ export function conversationRoutingPath(env = process.env) {
580
+ return join(configRoot(env), "conversation-routing.json");
581
+ }
582
+
346
583
  export async function readWorkspaceCredential(env = process.env) {
347
584
  const environmentKey = String(env.CORVIO_API_KEY || "").trim();
348
585
  if (environmentKey) {
@@ -375,6 +612,153 @@ export async function readWorkspaceCredential(env = process.env) {
375
612
  return { key, source: "device_link", profile: profileName, metadata };
376
613
  }
377
614
 
615
+ export async function readAgentCredential(
616
+ env = process.env,
617
+ {
618
+ required = true,
619
+ bindingId = null,
620
+ agentId = null,
621
+ projectRoot = null,
622
+ fallbackToActive = true,
623
+ } = {},
624
+ ) {
625
+ const key = String(env.CORVIO_AGENT_API_KEY || "").trim();
626
+ if (key) {
627
+ if (!key.startsWith("cvg_")) {
628
+ throw new CliError("CORVIO_AGENT_API_KEY must be an Agent cvg_ key.", {
629
+ code: "invalid_agent_key",
630
+ exitCode: EXIT.AUTH,
631
+ });
632
+ }
633
+ return { key, source: "environment", profile: null, metadata: null };
634
+ }
635
+ const path = agentCredentialPath(env);
636
+ let parsed;
637
+ try {
638
+ parsed = JSON.parse(await readFile(path, "utf8"));
639
+ } catch (cause) {
640
+ if (cause?.code === "ENOENT" && !required) return null;
641
+ if (cause?.code === "ENOENT") {
642
+ throw new CliError(
643
+ "No Agent credential found. Run `corvio agents connect` or set CORVIO_AGENT_API_KEY.",
644
+ { code: "agent_auth_required", exitCode: EXIT.AUTH },
645
+ );
646
+ }
647
+ throw new CliError("Stored Agent credentials are unreadable.", {
648
+ code: "agent_credential_store_invalid",
649
+ exitCode: EXIT.AUTH,
650
+ });
651
+ }
652
+ let selected = parsed;
653
+ let selectedProfile = "default";
654
+ if (Number(parsed?.version) >= 2 && parsed?.credentials && typeof parsed.credentials === "object") {
655
+ const entries = Object.entries(parsed.credentials);
656
+ let match = null;
657
+ if (bindingId) {
658
+ match = entries.find(([key, value]) => key === bindingId || value?.binding_id === bindingId) || null;
659
+ }
660
+ if (!match && agentId) {
661
+ match = entries.find(([, value]) => String(value?.agent_id || "") === String(agentId)) || null;
662
+ }
663
+ if (!match && projectRoot) {
664
+ const normalizedRoot = resolve(String(projectRoot));
665
+ const candidates = entries
666
+ .filter(([, value]) => {
667
+ const storedRoot = String(value?.project_root || "").trim();
668
+ if (!storedRoot) return false;
669
+ const normalizedStored = resolve(storedRoot);
670
+ return normalizedRoot === normalizedStored || normalizedRoot.startsWith(`${normalizedStored}${process.platform === "win32" ? "\\" : "/"}`);
671
+ })
672
+ .sort((left, right) => String(right[1]?.project_root || "").length - String(left[1]?.project_root || "").length);
673
+ match = candidates[0] || null;
674
+ }
675
+ if (!match && fallbackToActive && parsed.active_binding_id) {
676
+ const value = parsed.credentials[parsed.active_binding_id];
677
+ if (value) match = [parsed.active_binding_id, value];
678
+ }
679
+ if (!match && fallbackToActive && entries.length === 1) match = entries[0];
680
+ if (!match) {
681
+ if (!required) return null;
682
+ throw new CliError(
683
+ "Multiple Agent project bindings are stored. Run from a connected project or pass --agent <id>.",
684
+ { code: "agent_routing_ambiguous", exitCode: EXIT.AUTH },
685
+ );
686
+ }
687
+ [selectedProfile, selected] = match;
688
+ }
689
+ const storedKey = String(selected?.api_key || "").trim();
690
+ if (!storedKey.startsWith("cvg_")) {
691
+ throw new CliError(
692
+ "Stored Agent credentials are invalid; run `corvio agents connect --recover-credential`.",
693
+ { code: "agent_credential_store_invalid", exitCode: EXIT.AUTH },
694
+ );
695
+ }
696
+ return { key: storedKey, source: "secure_store", profile: selectedProfile, metadata: selected };
697
+ }
698
+
699
+ export async function storeAgentCredential(payload, env = process.env) {
700
+ const secret = String(payload?.secret || "").trim();
701
+ if (!secret.startsWith("cvg_")) {
702
+ throw new CliError("Agent connection did not return a valid cvg_ credential.", {
703
+ code: "agent_credential_invalid",
704
+ exitCode: EXIT.AUTH,
705
+ });
706
+ }
707
+ const path = agentCredentialPath(env);
708
+ const root = dirname(path);
709
+ await mkdir(root, { recursive: true, mode: 0o700 });
710
+ await chmod(root, 0o700).catch(() => {});
711
+ const credential = {
712
+ api_key: secret,
713
+ agent_id: String(payload.agent_id || ""),
714
+ binding_id: String(payload.binding_id || "").trim() || null,
715
+ project_root: String(payload.project_root || "").trim() || null,
716
+ project_label: String(payload.project_label || "").trim() || null,
717
+ project_fingerprint: String(payload.project_fingerprint || "").trim() || null,
718
+ display_name: String(payload.display_name || ""),
719
+ provider: String(payload.provider || "custom"),
720
+ permission_profile: String(payload.permission_profile || "read_only"),
721
+ trigger_policy: String(payload.trigger_policy || "owner_only"),
722
+ skills_scope: String(payload.skills_scope || "project"),
723
+ memory_scope: String(payload.memory_scope || "off"),
724
+ connector_scope: String(payload.connector_scope || "off"),
725
+ display_prefix: String(payload.display_prefix || "cvg"),
726
+ created_at: payload.created_at || new Date().toISOString(),
727
+ source: "agent_connect",
728
+ };
729
+ let current = null;
730
+ try {
731
+ current = JSON.parse(await readFile(path, "utf8"));
732
+ } catch (error) {
733
+ if (error?.code !== "ENOENT") {
734
+ throw new CliError("Stored Agent credentials are unreadable.", {
735
+ code: "agent_credential_store_invalid",
736
+ exitCode: EXIT.AUTH,
737
+ });
738
+ }
739
+ }
740
+ const credentials = Number(current?.version) >= 2 && current?.credentials && typeof current.credentials === "object"
741
+ ? { ...current.credentials }
742
+ : {};
743
+ if (String(current?.api_key || "").startsWith("cvg_") && current?.agent_id) {
744
+ const legacyKey = String(current.binding_id || `legacy:${current.agent_id}`);
745
+ credentials[legacyKey] = { ...current, binding_id: current.binding_id || null };
746
+ }
747
+ const profile = credential.binding_id || `agent:${credential.agent_id}`;
748
+ credentials[profile] = credential;
749
+ const document = {
750
+ version: 2,
751
+ active_binding_id: profile,
752
+ credentials,
753
+ };
754
+ const temporary = `${path}.${randomUUID()}.tmp`;
755
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
756
+ await chmod(temporary, 0o600);
757
+ await rename(temporary, path);
758
+ await chmod(path, 0o600);
759
+ return { path, profile, metadata: credential };
760
+ }
761
+
378
762
  export async function storeWorkspaceCredential(payload, env = process.env) {
379
763
  const path = workspaceCredentialPath(env);
380
764
  const root = dirname(path);
@@ -412,6 +796,7 @@ export async function readWorkspaceContext(env = process.env) {
412
796
  workspaceId,
413
797
  workspaceName: String(parsed.active_workspace_name || "").trim() || null,
414
798
  source: "stored",
799
+ routingPreference: parsed.routing_preference || null,
415
800
  };
416
801
  } catch (cause) {
417
802
  if (cause?.code === "ENOENT") return null;
@@ -422,7 +807,7 @@ export async function readWorkspaceContext(env = process.env) {
422
807
  }
423
808
  }
424
809
 
425
- export async function storeWorkspaceContext({ workspaceId, workspaceName = null }, env = process.env) {
810
+ export async function storeWorkspaceContext({ workspaceId, workspaceName = null, routingPreference = null }, env = process.env) {
426
811
  const normalizedId = String(workspaceId || "").trim();
427
812
  if (!normalizedId) throw new CliError("Workspace ID is required.", { code: "workspace_selection_required" });
428
813
  const path = workspaceContextPath(env);
@@ -433,6 +818,13 @@ export async function storeWorkspaceContext({ workspaceId, workspaceName = null
433
818
  version: 1,
434
819
  active_workspace_id: normalizedId,
435
820
  active_workspace_name: String(workspaceName || "").trim() || null,
821
+ routing_preference: routingPreference || {
822
+ workspace_id: normalizedId,
823
+ provenance: "user_confirmed_cli",
824
+ confidence: 1,
825
+ scope: "global",
826
+ expires_at: null,
827
+ },
436
828
  updated_at: new Date().toISOString(),
437
829
  };
438
830
  const temporary = `${path}.${randomUUID()}.tmp`;
@@ -443,6 +835,75 @@ export async function storeWorkspaceContext({ workspaceId, workspaceName = null
443
835
  return { path, workspaceId: normalizedId };
444
836
  }
445
837
 
838
+ export async function readConversationRoutingReceipt(conversationId, env = process.env) {
839
+ const normalizedId = String(conversationId || "").trim();
840
+ if (!normalizedId) return null;
841
+ const path = conversationRoutingPath(env);
842
+ try {
843
+ const parsed = JSON.parse(await readFile(path, "utf8"));
844
+ const receipt = parsed?.receipts?.[normalizedId];
845
+ const workspaceId = String(receipt?.workspace_id || "").trim();
846
+ if (!workspaceId) return null;
847
+ return {
848
+ conversationId: normalizedId,
849
+ workspaceId,
850
+ agentId: String(receipt.agent_id || "").trim() || null,
851
+ apiBase: String(receipt.api_base || "").trim() || null,
852
+ recordedAt: String(receipt.recorded_at || "").trim() || null,
853
+ };
854
+ } catch (cause) {
855
+ if (cause?.code === "ENOENT") return null;
856
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
857
+ code: "conversation_routing_store_invalid",
858
+ exitCode: EXIT.AUTH,
859
+ });
860
+ }
861
+ }
862
+
863
+ export async function storeConversationRoutingReceipt({ conversationId, workspaceId, agentId = null, apiBase = null }, env = process.env) {
864
+ const normalizedConversationId = String(conversationId || "").trim();
865
+ const normalizedWorkspaceId = String(workspaceId || "").trim();
866
+ if (!normalizedConversationId || !normalizedWorkspaceId) {
867
+ throw new CliError("Conversation and Workspace IDs are required for routing continuity.", {
868
+ code: "conversation_routing_receipt_invalid",
869
+ });
870
+ }
871
+ const path = conversationRoutingPath(env);
872
+ const root = dirname(path);
873
+ await mkdir(root, { recursive: true, mode: 0o700 });
874
+ await chmod(root, 0o700).catch(() => {});
875
+ let existing = {};
876
+ try {
877
+ const parsed = JSON.parse(await readFile(path, "utf8"));
878
+ if (parsed?.receipts && typeof parsed.receipts === "object" && !Array.isArray(parsed.receipts)) {
879
+ existing = parsed.receipts;
880
+ }
881
+ } catch (cause) {
882
+ if (cause?.code !== "ENOENT") {
883
+ throw new CliError("Stored Corvio conversation routing receipts are unreadable.", {
884
+ code: "conversation_routing_store_invalid",
885
+ exitCode: EXIT.AUTH,
886
+ });
887
+ }
888
+ }
889
+ const ordered = Object.entries({
890
+ ...existing,
891
+ [normalizedConversationId]: {
892
+ workspace_id: normalizedWorkspaceId,
893
+ agent_id: String(agentId || "").trim() || null,
894
+ api_base: String(apiBase || "").trim() || null,
895
+ recorded_at: new Date().toISOString(),
896
+ },
897
+ }).sort((left, right) => String(right[1]?.recorded_at || "").localeCompare(String(left[1]?.recorded_at || "")));
898
+ const document = { version: 1, receipts: Object.fromEntries(ordered.slice(0, 500)) };
899
+ const temporary = `${path}.${randomUUID()}.tmp`;
900
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600, flag: "wx" });
901
+ await chmod(temporary, 0o600);
902
+ await rename(temporary, path);
903
+ await chmod(path, 0o600);
904
+ return { path, conversationId: normalizedConversationId, workspaceId: normalizedWorkspaceId };
905
+ }
906
+
446
907
  export async function clearWorkspaceContext(env = process.env) {
447
908
  const path = workspaceContextPath(env);
448
909
  await rm(path, { force: true });
@@ -534,7 +995,21 @@ export function writeError(error, { json = false, stderr = process.stderr } = {}
534
995
  ...(error instanceof CliError && error.details ? { details: error.details } : {}),
535
996
  },
536
997
  };
537
- stderr.write(json ? `${JSON.stringify(safe)}\n` : `Error: ${safe.error.message}\n`);
998
+ if (json) {
999
+ stderr.write(`${JSON.stringify(safe)}\n`);
1000
+ return;
1001
+ }
1002
+ stderr.write(`Error: ${safe.error.message}\n`);
1003
+ if (safe.error.details?.request_id) stderr.write(`Request ID: ${safe.error.details.request_id}\n`);
1004
+ if (safe.error.details?.safe_to_retry && safe.error.details?.idempotency_key) {
1005
+ stderr.write(
1006
+ `Safe retry: rerun the command with --idempotency-key ${safe.error.details.idempotency_key}\n`,
1007
+ );
1008
+ } else if (safe.error.details?.retry_safety === "revision_guard") {
1009
+ stderr.write("Safe retry: rerun with the same --expected-revision; Corvio will reject an already-committed replacement.\n");
1010
+ } else if (safe.error.details?.retry_safety === "unknown_commit_state") {
1011
+ stderr.write("Retry state is unknown; read the remote object before repeating this mutation.\n");
1012
+ }
538
1013
  }
539
1014
 
540
1015
  export async function runMain(main, argv = process.argv.slice(2)) {
@@ -551,8 +1026,8 @@ export async function runMain(main, argv = process.argv.slice(2)) {
551
1026
 
552
1027
  export async function openBrowser(url) {
553
1028
  const target = String(url);
554
- const command = platform() === "darwin" ? "open" : platform() === "win32" ? "cmd" : "xdg-open";
555
- const args = platform() === "win32" ? ["/c", "start", "", target] : [target];
1029
+ const command = platform() === "darwin" ? "open" : platform() === "win32" ? "rundll32.exe" : "xdg-open";
1030
+ const args = platform() === "win32" ? ["url.dll,FileProtocolHandler", target] : [target];
556
1031
  return new Promise((resolvePromise) => {
557
1032
  let settled = false;
558
1033
  const finish = (opened) => {
@@ -584,14 +1059,28 @@ export async function readTextFile(path) {
584
1059
  export async function writeTextFile(path, content) {
585
1060
  const target = resolve(String(path));
586
1061
  await mkdir(dirname(target), { recursive: true });
587
- await writeFile(target, content, "utf8");
1062
+ const temporary = `${target}.${randomUUID()}.tmp`;
1063
+ try {
1064
+ await writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
1065
+ await rename(temporary, target);
1066
+ } catch (error) {
1067
+ await rm(temporary, { force: true }).catch(() => {});
1068
+ throw error;
1069
+ }
588
1070
  return target;
589
1071
  }
590
1072
 
591
1073
  export async function writeBinaryFile(path, content) {
592
1074
  const target = resolve(String(path));
593
1075
  await mkdir(dirname(target), { recursive: true });
594
- await writeFile(target, content);
1076
+ const temporary = `${target}.${randomUUID()}.tmp`;
1077
+ try {
1078
+ await writeFile(temporary, content, { flag: "wx" });
1079
+ await rename(temporary, target);
1080
+ } catch (error) {
1081
+ await rm(temporary, { force: true }).catch(() => {});
1082
+ throw error;
1083
+ }
595
1084
  return target;
596
1085
  }
597
1086