@opencomputer/cli 0.6.5 → 0.6.7

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 (53) hide show
  1. package/README.md +21 -17
  2. package/dist/api.d.ts +96 -2
  3. package/dist/api.js +60 -7
  4. package/dist/api.js.map +1 -1
  5. package/dist/api.test.d.ts +1 -0
  6. package/dist/api.test.js +21 -0
  7. package/dist/api.test.js.map +1 -0
  8. package/dist/binding.d.ts +0 -2
  9. package/dist/binding.js +7 -31
  10. package/dist/binding.js.map +1 -1
  11. package/dist/binding.test.js +24 -3
  12. package/dist/binding.test.js.map +1 -1
  13. package/dist/commands.d.ts +1 -0
  14. package/dist/commands.js +387 -42
  15. package/dist/commands.js.map +1 -1
  16. package/dist/dev.d.ts +0 -8
  17. package/dist/dev.js +1 -159
  18. package/dist/dev.js.map +1 -1
  19. package/dist/dev.test.js +2 -90
  20. package/dist/dev.test.js.map +1 -1
  21. package/dist/doctor.d.ts +19 -0
  22. package/dist/doctor.js +207 -0
  23. package/dist/doctor.js.map +1 -0
  24. package/dist/doctor.test.d.ts +1 -0
  25. package/dist/doctor.test.js +71 -0
  26. package/dist/doctor.test.js.map +1 -0
  27. package/dist/errors.d.ts +13 -0
  28. package/dist/errors.js +74 -0
  29. package/dist/errors.js.map +1 -0
  30. package/dist/errors.test.d.ts +1 -0
  31. package/dist/errors.test.js +14 -0
  32. package/dist/errors.test.js.map +1 -0
  33. package/dist/index.js +21 -4
  34. package/dist/index.js.map +1 -1
  35. package/dist/project-local.d.ts +7 -0
  36. package/dist/project-local.js +106 -0
  37. package/dist/project-local.js.map +1 -0
  38. package/dist/project-local.test.d.ts +1 -0
  39. package/dist/project-local.test.js +30 -0
  40. package/dist/project-local.test.js.map +1 -0
  41. package/dist/project.js +10 -10
  42. package/dist/project.test.js +1 -1
  43. package/dist/project.test.js.map +1 -1
  44. package/dist/template-local.d.ts +6 -0
  45. package/dist/template-local.js +57 -0
  46. package/dist/template-local.js.map +1 -0
  47. package/dist/template.d.ts +72 -0
  48. package/dist/template.js +326 -0
  49. package/dist/template.js.map +1 -0
  50. package/dist/template.test.d.ts +1 -0
  51. package/dist/template.test.js +109 -0
  52. package/dist/template.test.js.map +1 -0
  53. package/package.json +1 -1
package/dist/commands.js CHANGED
@@ -7,6 +7,12 @@ import { ensureProjectBinding, findOpenComputerProjectRoot, } from "./binding.js
7
7
  import { assertStarterTarget, buildAgentArtifact, findAgentRoot, initializeAgentProject, } from "./project.js";
8
8
  import { developmentAgentReference, parseSessionCommand, resolveProjectAgent, } from "./session-command.js";
9
9
  import { formatSessionEvent } from "./session-prompt.js";
10
+ import { buildTemplateProject, normalizeTemplateRepositoryUrl, readTemplateManifest, templateDeployUrl, } from "./template.js";
11
+ import { defaultTemplateDirectory, materializeTemplateCheckout, } from "./template-local.js";
12
+ import { materializeProjectArchive } from "./project-local.js";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { doctorProject } from "./doctor.js";
15
+ import { CLIError } from "./errors.js";
10
16
  export function deploymentAlias(requestedAlias) {
11
17
  return requestedAlias ?? "development";
12
18
  }
@@ -16,6 +22,9 @@ export function shouldBindModelAccessProject(projectReference, currentAgentRoot)
16
22
  function printJSON(value) {
17
23
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
18
24
  }
25
+ function printJSONLine(value) {
26
+ process.stdout.write(`${JSON.stringify(value)}\n`);
27
+ }
19
28
  function flag(args, name) {
20
29
  const index = args.indexOf(name);
21
30
  if (index < 0)
@@ -56,15 +65,13 @@ function consumeModelAccessProvider(args) {
56
65
  }
57
66
  return "codex";
58
67
  }
59
- async function readSecretValue() {
68
+ async function readTemplateSecretValue() {
60
69
  if (!process.stdin.isTTY) {
61
70
  const chunks = [];
62
71
  for await (const chunk of process.stdin) {
63
72
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
64
73
  }
65
- const value = Buffer.concat(chunks)
66
- .toString("utf8")
67
- .replace(/\r?\n$/, "");
74
+ const value = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
68
75
  if (!value)
69
76
  throw new Error("Secret value was empty");
70
77
  return value;
@@ -101,7 +108,33 @@ async function readSecretValue() {
101
108
  process.stdin.on("data", onData);
102
109
  });
103
110
  }
104
- async function selectedProject(client, config, reference, interactive = true) {
111
+ async function readStdinValue(enabled) {
112
+ if (!enabled) {
113
+ throw new CLIError("value_stdin_required", "A value must be supplied through standard input.", "Pipe the value into this command and add `--value-stdin`.");
114
+ }
115
+ if (process.stdin.isTTY) {
116
+ throw new CLIError("value_stdin_required", "--value-stdin requires piped standard input.", "Use `printf %s \"$VALUE\" | opencomputer ... --value-stdin`.");
117
+ }
118
+ const chunks = [];
119
+ for await (const chunk of process.stdin) {
120
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
121
+ }
122
+ const value = Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
123
+ if (!value)
124
+ throw new Error("Standard-input value was empty");
125
+ return value;
126
+ }
127
+ function printDoctor(result, json) {
128
+ if (json)
129
+ return printJSON(result);
130
+ for (const item of result.diagnostics) {
131
+ process.stdout.write(`${item.severity.toUpperCase()} ${item.code} ${item.file}${item.line ? `:${item.line}` : ""}\n` +
132
+ ` ${item.message}\n fix: ${item.hint}\n`);
133
+ }
134
+ process.stdout.write(`${result.ok ? "Doctor passed" : "Doctor failed"}: ${result.summary.errors} errors, ` +
135
+ `${result.summary.warnings} warnings in ${result.durationMs}ms.\n`);
136
+ }
137
+ async function selectedProject(client, config, reference) {
105
138
  if (reference) {
106
139
  const project = (await client.projects()).find((candidate) => candidate.id === reference || candidate.slug === reference);
107
140
  if (!project)
@@ -112,9 +145,7 @@ async function selectedProject(client, config, reference, interactive = true) {
112
145
  return { projectId: project.id, agentId: agent.id };
113
146
  }
114
147
  const root = await findOpenComputerProjectRoot(process.cwd());
115
- const binding = await ensureProjectBinding(client, config, root, {
116
- interactive,
117
- });
148
+ const binding = await ensureProjectBinding(client, config, root);
118
149
  return { projectId: binding.projectId, agentId: binding.agentId };
119
150
  }
120
151
  async function selectedSessionAgent(client, project, selector) {
@@ -200,7 +231,7 @@ function printAgentEvent(event, json) {
200
231
  printToolProgress(event);
201
232
  }
202
233
  }
203
- async function sendAgentTurn(client, sessionId, prompt, keep, json) {
234
+ async function sendAgentTurn(client, sessionId, prompt, keep, json, idempotencyKey) {
204
235
  const existing = await client.events(sessionId, 0);
205
236
  let cursor = existing.at(-1)?.seq ?? 0;
206
237
  const session = await client.session(sessionId);
@@ -210,7 +241,7 @@ async function sendAgentTurn(client, sessionId, prompt, keep, json) {
210
241
  const connected = await waitForEvent(client, sessionId, cursor, (event) => event.type === "runtime.connected", () => undefined, 90_000);
211
242
  cursor = connected.cursor;
212
243
  }
213
- const turn = await client.createTurn(sessionId, prompt);
244
+ const turn = await client.createTurn(sessionId, prompt, idempotencyKey);
214
245
  let streamedText = "";
215
246
  let completedText = "";
216
247
  const completed = await waitForEvent(client, sessionId, cursor, (event) => event.type === "turn.completed" || event.type === "turn.failed", (event) => {
@@ -274,6 +305,33 @@ async function attachSession(client, sessionId, json) {
274
305
  process.off("SIGINT", stop);
275
306
  }
276
307
  }
308
+ async function tailSession(client, sessionId, after, follow, json) {
309
+ let cursor = after;
310
+ let stopped = false;
311
+ const stop = () => {
312
+ stopped = true;
313
+ };
314
+ process.once("SIGINT", stop);
315
+ try {
316
+ do {
317
+ const events = await client.events(sessionId, cursor);
318
+ for (const event of events) {
319
+ cursor = Math.max(cursor, event.seq);
320
+ if (json)
321
+ printJSONLine({ sessionId, cursor: event.seq, ...event });
322
+ else {
323
+ process.stdout.write(`${event.seq} ${event.type} ${JSON.stringify(event.data)}\n`);
324
+ }
325
+ }
326
+ if (follow && !stopped) {
327
+ await new Promise((resolve) => setTimeout(resolve, 500));
328
+ }
329
+ } while (follow && !stopped);
330
+ }
331
+ finally {
332
+ process.off("SIGINT", stop);
333
+ }
334
+ }
277
335
  export function nextAgentEventDeadline(deadline, timeoutMs, receivedEvents, now = Date.now()) {
278
336
  return receivedEvents > 0 ? now + timeoutMs : deadline;
279
337
  }
@@ -301,11 +359,11 @@ async function waitForEvent(client, sessionId, after, terminal, onEvent, timeout
301
359
  }
302
360
  throw new Error("Timed out waiting for the agent.");
303
361
  }
304
- async function runAgent(client, agent, prompt, keep, json, verbose) {
362
+ async function runAgent(client, agent, prompt, keep, json, verbose, idempotencyKey) {
305
363
  const created = await client.createSession(agent);
306
364
  process.stderr.write(`Starting ${agent}…\n`);
307
365
  const connected = await waitForEvent(client, created.session.id, 0, (event) => event.type === "runtime.connected", (event) => printSessionProgress(event, json, verbose), 90_000);
308
- const turn = await client.createTurn(created.session.id, prompt);
366
+ const turn = await client.createTurn(created.session.id, prompt, idempotencyKey);
309
367
  let streamed = false;
310
368
  let streamedText = "";
311
369
  let completedText = "";
@@ -347,8 +405,224 @@ async function runAgent(client, agent, prompt, keep, json, verbose) {
347
405
  }
348
406
  export async function runCommand(command, rawArgs, globals) {
349
407
  const args = [...rawArgs];
408
+ if (command === "template" && args[0] === "validate") {
409
+ args.shift();
410
+ const repositoryUrl = option(args, "--repository-url");
411
+ const appUrl = option(args, "--app-url");
412
+ const directory = args.shift() ?? process.cwd();
413
+ if (args.length)
414
+ throw new Error(`Unexpected argument: ${args[0]}`);
415
+ const manifest = await readTemplateManifest(directory);
416
+ const deployUrl = repositoryUrl
417
+ ? templateDeployUrl(repositoryUrl, appUrl)
418
+ : undefined;
419
+ if (globals.json) {
420
+ printJSON({ file: "oc-template.toml", manifest, deployUrl });
421
+ }
422
+ else {
423
+ process.stdout.write(`Valid template: ${manifest.template.name}\n` +
424
+ (deployUrl ? `Deploy URL: ${deployUrl}\n` : ""));
425
+ }
426
+ return;
427
+ }
428
+ if (command === "template" && args[0] === "build") {
429
+ args.shift();
430
+ const output = option(args, "--output");
431
+ const directory = args.shift() ?? process.cwd();
432
+ if (args.length)
433
+ throw new Error(`Unexpected argument: ${args[0]}`);
434
+ const bundle = await buildTemplateProject(directory);
435
+ const serialized = `${JSON.stringify(bundle)}\n`;
436
+ if (output) {
437
+ const { writeFile } = await import("node:fs/promises");
438
+ await writeFile(output, serialized, { mode: 0o600 });
439
+ if (!globals.json)
440
+ process.stdout.write(`Built template bundle: ${output}\n`);
441
+ }
442
+ else {
443
+ process.stdout.write(serialized);
444
+ }
445
+ return;
446
+ }
350
447
  const config = await resolveConfig(globals);
351
- const client = new OpenComputerClient(config);
448
+ const client = new OpenComputerClient(config, globals.idempotencyKey);
449
+ if (command === "project" && args[0] === "clone") {
450
+ args.shift();
451
+ const directory = option(args, "--directory");
452
+ const projectId = args.shift();
453
+ if (!projectId || args.length) {
454
+ throw new Error("Usage: opencomputer project clone <project-id> [--directory <path>]");
455
+ }
456
+ const project = (await client.projects()).find((candidate) => candidate.id === projectId);
457
+ if (!project)
458
+ throw new Error(`Project not found: ${projectId}`);
459
+ const checkout = await materializeProjectArchive({
460
+ response: await client.projectSourceArchive(project.id),
461
+ directory: directory ?? project.slug,
462
+ });
463
+ const binding = await ensureProjectBinding(client, config, checkout.directory, {
464
+ project: project.id,
465
+ });
466
+ if (globals.json)
467
+ printJSON({ checkout, binding });
468
+ else {
469
+ process.stdout.write(`Cloned ${binding.projectName} (${binding.projectId}).\n\n` +
470
+ `Next:\n cd ${checkout.directory}\n npm install\n` +
471
+ ` npm run deploy -- --watch --api-url ${config.apiUrl}\n`);
472
+ }
473
+ return;
474
+ }
475
+ if (command === "template") {
476
+ const action = args.shift();
477
+ if (action === "clone") {
478
+ const commitSha = option(args, "--commit");
479
+ const project = option(args, "--project");
480
+ const directory = option(args, "--directory");
481
+ const repositoryUrl = args.shift();
482
+ if (!repositoryUrl || !commitSha || !project || args.length) {
483
+ throw new Error("Usage: opencomputer template clone <repository-url> --commit <sha> --project <id> [--directory <path>]");
484
+ }
485
+ const normalized = normalizeTemplateRepositoryUrl(repositoryUrl);
486
+ const checkout = await materializeTemplateCheckout(normalized, commitSha, directory);
487
+ const binding = await ensureProjectBinding(client, config, checkout.directory, { project });
488
+ if (globals.json)
489
+ printJSON({ checkout, binding });
490
+ else {
491
+ process.stdout.write(`Cloned ${normalized}@${commitSha.slice(0, 12)}\n` +
492
+ `Linked to ${binding.projectName} (${binding.projectId}).\n\n` +
493
+ `Next:\n cd ${checkout.directory}\n npm install\n npm run deploy -- --watch\n`);
494
+ }
495
+ return;
496
+ }
497
+ if (action !== "deploy") {
498
+ throw new Error("Usage: opencomputer template <deploy|clone> ...");
499
+ }
500
+ const projectNameOption = option(args, "--project-name");
501
+ const directoryOption = option(args, "--directory");
502
+ const confirmed = flag(args, "--yes");
503
+ const repositoryUrl = args.shift();
504
+ if (!repositoryUrl) {
505
+ throw new Error("A GitHub repository URL is required");
506
+ }
507
+ if (args.length)
508
+ throw new Error(`Unexpected argument: ${args[0]}`);
509
+ const inspection = await client.inspectTemplate(normalizeTemplateRepositoryUrl(repositoryUrl));
510
+ if (!globals.json) {
511
+ process.stdout.write(`\n${inspection.template.name}\n${inspection.template.description}\n` +
512
+ `Source: ${inspection.repository.fullName}@${inspection.repository.commitSha.slice(0, 12)}\n` +
513
+ `Agents: ${inspection.agents.map((agent) => agent.name).join(", ")}\n` +
514
+ `Target: Development\n\n`);
515
+ }
516
+ if (inspection.requirements.connections.length) {
517
+ throw new Error("This template needs an interactive provider connection. Open its deploy URL in the dashboard to continue.");
518
+ }
519
+ const requiredSecrets = inspection.requirements.secrets.filter((requirement) => requirement.required !== false);
520
+ if (!process.stdin.isTTY && requiredSecrets.length) {
521
+ throw new Error(`Secret ${requiredSecrets[0].name} requires an interactive terminal; secret values are never accepted as command-line arguments`);
522
+ }
523
+ if ((!process.stdin.isTTY || !process.stdout.isTTY) && !projectNameOption) {
524
+ throw new Error("--project-name is required in non-interactive mode");
525
+ }
526
+ const terminal = process.stdin.isTTY && process.stdout.isTTY
527
+ ? createInterface({ input: process.stdin, output: process.stdout })
528
+ : undefined;
529
+ let projectName = projectNameOption ??
530
+ inspection.template.defaultProjectName ??
531
+ inspection.template.name;
532
+ if (terminal && !projectNameOption) {
533
+ projectName =
534
+ (await terminal.question(`Project name [${projectName}]: `)).trim() ||
535
+ projectName;
536
+ }
537
+ if (terminal && !confirmed) {
538
+ const answer = (await terminal.question("Create this Development project? [y/N] "))
539
+ .trim()
540
+ .toLowerCase();
541
+ if (answer !== "y" && answer !== "yes") {
542
+ terminal.close();
543
+ throw new Error("Template deployment cancelled");
544
+ }
545
+ }
546
+ const checkout = await materializeTemplateCheckout(inspection.repository.url, inspection.repository.commitSha, directoryOption ?? defaultTemplateDirectory(inspection.repository.url));
547
+ const runtimeValues = new Map();
548
+ for (const requirement of inspection.requirements.runtimeVariables) {
549
+ if (!terminal) {
550
+ if (requirement.required) {
551
+ throw new Error(`Runtime variable ${requirement.name} requires an interactive terminal`);
552
+ }
553
+ continue;
554
+ }
555
+ const hint = requirement.example ? ` [${requirement.example}]` : "";
556
+ const value = (await terminal.question(`${requirement.name}${hint}: `)).trim() ||
557
+ requirement.example ||
558
+ "";
559
+ if (requirement.required && !value) {
560
+ terminal.close();
561
+ throw new Error(`${requirement.name} is required`);
562
+ }
563
+ if (value)
564
+ runtimeValues.set(requirement.name, value);
565
+ }
566
+ terminal?.close();
567
+ const installation = await client.createTemplateInstallation({
568
+ inspectionId: inspection.id,
569
+ projectName,
570
+ idempotencyKey: crypto.randomUUID(),
571
+ });
572
+ const installationAgentId = (localAgentId) => !localAgentId || localAgentId === inspection.agents[0]?.id
573
+ ? installation.projectAgentId
574
+ : `${installation.projectAgentId}--${localAgentId}`;
575
+ for (const requirement of requiredSecrets) {
576
+ process.stderr.write(`${requirement.name}\n`);
577
+ const value = await readTemplateSecretValue();
578
+ await client.putSecret({
579
+ projectId: installation.projectId,
580
+ name: requirement.name,
581
+ value,
582
+ environment: "development",
583
+ agentId: installationAgentId(requirement.agentId),
584
+ allowedOrigins: requirement.allowedOrigins,
585
+ });
586
+ }
587
+ for (const requirement of inspection.requirements.runtimeVariables) {
588
+ const value = runtimeValues.get(requirement.name);
589
+ if (!value)
590
+ continue;
591
+ await client.putRuntimeVariable({
592
+ projectId: installation.projectId,
593
+ name: requirement.name,
594
+ value,
595
+ environment: "development",
596
+ agentId: installationAgentId(requirement.agentId),
597
+ });
598
+ }
599
+ let current = await client.finalizeTemplateInstallation(installation.id);
600
+ const deadline = Date.now() + 10 * 60_000;
601
+ while (!["ready", "failed"].includes(current.state) &&
602
+ Date.now() < deadline) {
603
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 1_000));
604
+ current = await client.templateInstallation(current.id);
605
+ if (!globals.json)
606
+ process.stderr.write(`Template installation: ${current.state}\r`);
607
+ }
608
+ if (current.state === "failed") {
609
+ throw new Error(current.error?.message ?? "Template installation failed");
610
+ }
611
+ if (current.state !== "ready") {
612
+ throw new Error(`Template installation is still ${current.state}; ${current.projectUrl}`);
613
+ }
614
+ const binding = await ensureProjectBinding(client, config, checkout.directory, {
615
+ project: current.projectId,
616
+ });
617
+ if (globals.json)
618
+ printJSON({ installation: current, checkout, binding });
619
+ else {
620
+ process.stdout.write(`\nReady: ${current.projectUrl}\n` +
621
+ `Local: ${checkout.directory}\n\n` +
622
+ `Next:\n cd ${checkout.directory}\n npm install\n npm run deploy -- --watch\n`);
623
+ }
624
+ return;
625
+ }
352
626
  if (command === "login") {
353
627
  const identity = await login(config, {
354
628
  noBrowser: flag(args, "--no-browser"),
@@ -409,7 +683,7 @@ export async function runCommand(command, rawArgs, globals) {
409
683
  const enterDirectory = directory === "." ? "" : ` cd ${directory}\n`;
410
684
  process.stdout.write(`Created the ${initialized.manifest.name} OpenComputer app\n` +
411
685
  `Directory: ${initialized.root}\n` +
412
- `Project: choose or create one on the first watched deployment\n` +
686
+ `Project: link explicitly with --project or --create-project\n` +
413
687
  `Agents: opencomputer/\n` +
414
688
  (spa
415
689
  ? `Web app: src/ (separate lifecycle)\n\n`
@@ -442,13 +716,28 @@ export async function runCommand(command, rawArgs, globals) {
442
716
  }
443
717
  return;
444
718
  }
719
+ if (command === "doctor") {
720
+ if (args.length)
721
+ throw new Error(`Unexpected argument: ${args[0]}`);
722
+ const root = await findOpenComputerProjectRoot(process.cwd());
723
+ const result = await doctorProject(root);
724
+ if (!result.ok) {
725
+ if (!globals.json)
726
+ printDoctor(result, false);
727
+ throw new CLIError("doctor_failed", "Local project diagnostics failed.", "Fix the reported errors and rerun `opencomputer doctor --json`.", result);
728
+ }
729
+ printDoctor(result, globals.json);
730
+ return;
731
+ }
445
732
  if (command === "link") {
733
+ const project = option(args, "--project");
734
+ const createProjectName = option(args, "--create-project");
446
735
  if (args.length)
447
736
  throw new Error(`Unexpected argument: ${args[0]}`);
448
737
  const root = await findOpenComputerProjectRoot(process.cwd());
449
738
  const binding = await ensureProjectBinding(client, config, root, {
450
- interactive: !globals.json,
451
- select: true,
739
+ project,
740
+ createProjectName,
452
741
  });
453
742
  if (globals.json)
454
743
  printJSON(binding);
@@ -468,6 +757,10 @@ export async function runCommand(command, rawArgs, globals) {
468
757
  if (!root) {
469
758
  throw new Error("No OpenComputer project found. Run `opencomputer init <directory>` first.");
470
759
  }
760
+ const diagnosis = await doctorProject(root);
761
+ if (!diagnosis.ok) {
762
+ throw new CLIError("doctor_failed", "Deploy stopped because local project diagnostics failed.", "Run `opencomputer doctor --json`, fix the errors, and deploy again.", diagnosis);
763
+ }
471
764
  if (watch) {
472
765
  if (requestedAlias && requestedAlias !== "development") {
473
766
  throw new Error("--watch deploys only to development; omit --alias or use --alias development");
@@ -475,7 +768,6 @@ export async function runCommand(command, rawArgs, globals) {
475
768
  await runDeploymentWatch(client, config, root, {
476
769
  project,
477
770
  createProjectName,
478
- interactive: !globals.json,
479
771
  });
480
772
  return;
481
773
  }
@@ -483,10 +775,7 @@ export async function runCommand(command, rawArgs, globals) {
483
775
  throw new Error("--project and --create-project require --watch");
484
776
  }
485
777
  const alias = deploymentAlias(requestedAlias);
486
- const binding = await ensureProjectBinding(client, config, root, {
487
- interactive: !globals.json,
488
- select: true,
489
- });
778
+ const binding = await ensureProjectBinding(client, config, root);
490
779
  const results = await publishProjectDeployment(client, root, binding, alias);
491
780
  for (const { built } of results) {
492
781
  process.stderr.write(`Built ${built.agentId} in ${String(built.elapsedMs)}ms\n`);
@@ -509,7 +798,7 @@ export async function runCommand(command, rawArgs, globals) {
509
798
  if (!agent || !prompt) {
510
799
  throw new Error("Usage: opencomputer run <agent> <prompt>");
511
800
  }
512
- const result = await runAgent(client, agent, prompt, keep, globals.json, globals.verbose === true);
801
+ const result = await runAgent(client, agent, prompt, keep, globals.json, globals.verbose === true, globals.idempotencyKey);
513
802
  if (globals.json)
514
803
  printJSON(result);
515
804
  return;
@@ -520,10 +809,14 @@ export async function runCommand(command, rawArgs, globals) {
520
809
  if (args.length)
521
810
  throw new Error(`Unexpected argument: ${args[0]}`);
522
811
  process.stderr.write("`opencomputer dev` is deprecated. Use `opencomputer deploy --watch`; start any web app separately.\n");
523
- await runCloudDevelopment(client, config, await findOpenComputerProjectRoot(process.cwd()), {
812
+ const root = await findOpenComputerProjectRoot(process.cwd());
813
+ const diagnosis = await doctorProject(root);
814
+ if (!diagnosis.ok) {
815
+ throw new CLIError("doctor_failed", "Deploy stopped because local project diagnostics failed.", "Run `opencomputer doctor --json`, fix the errors, and deploy again.", diagnosis);
816
+ }
817
+ await runCloudDevelopment(client, config, root, {
524
818
  project,
525
819
  createProjectName,
526
- interactive: !globals.json,
527
820
  });
528
821
  return;
529
822
  }
@@ -532,7 +825,7 @@ export async function runCommand(command, rawArgs, globals) {
532
825
  const projectReference = option(args, "--project");
533
826
  const agentOption = option(args, "--agent");
534
827
  const environment = environmentOption(option(args, "--environment"));
535
- const project = await selectedProject(client, config, projectReference, !globals.json);
828
+ const project = await selectedProject(client, config, projectReference);
536
829
  const agentId = agentOption
537
830
  ? agentOption === "current"
538
831
  ? project.agentId
@@ -565,6 +858,7 @@ export async function runCommand(command, rawArgs, globals) {
565
858
  }
566
859
  if (action === "set") {
567
860
  const explicitOrigins = options(args, "--allow-origin");
861
+ const valueStdin = flag(args, "--value-stdin");
568
862
  if (args.length)
569
863
  throw new Error(`Unexpected argument: ${args[0]}`);
570
864
  let allowedOrigins = explicitOrigins;
@@ -584,7 +878,7 @@ export async function runCommand(command, rawArgs, globals) {
584
878
  const secret = await client.putSecret({
585
879
  projectId: project.projectId,
586
880
  name,
587
- value: await readSecretValue(),
881
+ value: await readStdinValue(valueStdin),
588
882
  environment,
589
883
  ...(agentId ? { agentId } : {}),
590
884
  allowedOrigins,
@@ -636,7 +930,7 @@ export async function runCommand(command, rawArgs, globals) {
636
930
  throw new Error(`Unexpected argument: ${args[0]}`);
637
931
  const currentAgentRoot = projectReference ? null : await findAgentRoot();
638
932
  const project = shouldBindModelAccessProject(projectReference, currentAgentRoot)
639
- ? await selectedProject(client, config, projectReference, false)
933
+ ? await selectedProject(client, config, projectReference)
640
934
  : undefined;
641
935
  const environments = project
642
936
  ? ["development", "production"]
@@ -709,7 +1003,7 @@ export async function runCommand(command, rawArgs, globals) {
709
1003
  const projectReference = option(args, "--project");
710
1004
  const agentOption = option(args, "--agent");
711
1005
  const environment = environmentOption(option(args, "--environment"));
712
- const project = await selectedProject(client, config, projectReference, !globals.json);
1006
+ const project = await selectedProject(client, config, projectReference);
713
1007
  const agentId = agentOption
714
1008
  ? agentOption === "current"
715
1009
  ? project.agentId
@@ -740,12 +1034,13 @@ export async function runCommand(command, rawArgs, globals) {
740
1034
  throw new Error("Use `opencomputer env set|list|remove <name>`.");
741
1035
  }
742
1036
  if (action === "set") {
1037
+ const valueStdin = flag(args, "--value-stdin");
743
1038
  if (args.length)
744
1039
  throw new Error(`Unexpected argument: ${args[0]}`);
745
1040
  const variable = await client.putRuntimeVariable({
746
1041
  projectId: project.projectId,
747
1042
  name,
748
- value: await readSecretValue(),
1043
+ value: await readStdinValue(valueStdin),
749
1044
  environment,
750
1045
  ...(agentId ? { agentId } : {}),
751
1046
  });
@@ -779,7 +1074,7 @@ export async function runCommand(command, rawArgs, globals) {
779
1074
  const projectReference = option(args, "--project");
780
1075
  const agentOption = option(args, "--agent");
781
1076
  const environment = environmentOption(option(args, "--environment"));
782
- const project = await selectedProject(client, config, projectReference, !globals.json);
1077
+ const project = await selectedProject(client, config, projectReference);
783
1078
  const agentId = await selectedSessionAgent(client, project, !agentOption || agentOption === "current" ? undefined : agentOption);
784
1079
  if (action === "list") {
785
1080
  if (args.length)
@@ -806,19 +1101,26 @@ export async function runCommand(command, rawArgs, globals) {
806
1101
  if (!name || args.length) {
807
1102
  throw new Error("Use `opencomputer webhooks create <name>`.");
808
1103
  }
809
- const webhook = await client.createWebhook({
1104
+ const existing = (await client.webhooks({
810
1105
  projectId: project.projectId,
811
- name,
812
1106
  environment,
813
1107
  agentId,
814
- });
1108
+ })).find((candidate) => candidate.name === name);
1109
+ const webhook = existing ??
1110
+ (await client.createWebhook({
1111
+ projectId: project.projectId,
1112
+ name,
1113
+ environment,
1114
+ agentId,
1115
+ }));
815
1116
  if (globals.json)
816
- printJSON(webhook);
1117
+ printJSON({ ...webhook, reused: Boolean(existing) });
817
1118
  else {
818
- process.stdout.write(`Created ${webhook.name} (${webhook.id}) for ${agentId}@${environment}.\n` +
1119
+ process.stdout.write(`${existing ? "Reused" : "Created"} ${webhook.name} (${webhook.id}) for ${agentId}@${environment}.\n` +
819
1120
  `URL: ${webhook.invocationUrl}\n` +
820
- `Token: ${webhook.token ?? "unavailable"}\n` +
821
- "Save this token now. It will not be shown again.\n");
1121
+ (existing
1122
+ ? "The existing token remains unchanged.\n"
1123
+ : `Token: ${webhook.token ?? "unavailable"}\nSave this token now. It will not be shown again.\n`));
822
1124
  }
823
1125
  return;
824
1126
  }
@@ -886,7 +1188,7 @@ export async function runCommand(command, rawArgs, globals) {
886
1188
  insideProject = false;
887
1189
  }
888
1190
  if (insideProject) {
889
- agentId = (await selectedProject(client, config, undefined, !globals.json)).agentId;
1191
+ agentId = (await selectedProject(client, config)).agentId;
890
1192
  }
891
1193
  }
892
1194
  let cursor = "";
@@ -916,7 +1218,50 @@ export async function runCommand(command, rawArgs, globals) {
916
1218
  }
917
1219
  return;
918
1220
  }
919
- if (command === "session") {
1221
+ if (command === "channels") {
1222
+ const action = args.shift();
1223
+ if (action !== "status") {
1224
+ throw new Error("Use `opencomputer channels status`.");
1225
+ }
1226
+ const projectReference = option(args, "--project");
1227
+ const agentOption = option(args, "--agent");
1228
+ const environment = environmentOption(option(args, "--environment"));
1229
+ if (args.length)
1230
+ throw new Error(`Unexpected argument: ${args[0]}`);
1231
+ const project = await selectedProject(client, config, projectReference);
1232
+ const agentId = await selectedSessionAgent(client, project, !agentOption || agentOption === "current" ? undefined : agentOption);
1233
+ const channels = (await client.channels()).filter((channel) => channel.agentId === agentId && channel.alias === environment);
1234
+ if (globals.json)
1235
+ printJSON({ projectId: project.projectId, environment, channels });
1236
+ else if (!channels.length)
1237
+ process.stdout.write("No matching channels.\n");
1238
+ else {
1239
+ for (const channel of channels) {
1240
+ process.stdout.write(`${channel.id} ${channel.status} ${channel.channel} ${channel.agentId}@${channel.alias}\n` +
1241
+ ` last event: ${channel.lastEventAt ?? "—"}\n` +
1242
+ ` last delivery: ${channel.lastDelivery ? `${channel.lastDelivery.status} at ${channel.lastDelivery.at}` : "—"}\n` +
1243
+ ` last error: ${channel.lastError ? `${channel.lastError.category} at ${channel.lastError.at}` : "—"}\n`);
1244
+ }
1245
+ }
1246
+ return;
1247
+ }
1248
+ if (command === "session" || command === "sessions") {
1249
+ if (args[0] === "tail") {
1250
+ args.shift();
1251
+ const sessionId = args.shift();
1252
+ const afterValue = option(args, "--after");
1253
+ const follow = !flag(args, "--no-follow");
1254
+ if (!sessionId)
1255
+ throw new Error("A session ID is required.");
1256
+ if (args.length)
1257
+ throw new Error(`Unexpected argument: ${args[0]}`);
1258
+ const after = afterValue ? Number.parseInt(afterValue, 10) : 0;
1259
+ if (!Number.isFinite(after) || after < 0) {
1260
+ throw new Error("--after must be a non-negative event cursor");
1261
+ }
1262
+ await tailSession(client, sessionId, after, follow, globals.json);
1263
+ return;
1264
+ }
920
1265
  const session = parseSessionCommand(args);
921
1266
  const sessionArgs = session.args;
922
1267
  if (session.action === "list") {
@@ -933,11 +1278,11 @@ export async function runCommand(command, rawArgs, globals) {
933
1278
  }
934
1279
  if (session.action === "create") {
935
1280
  const prompt = sessionArgs.join(" ").trim();
936
- const project = await selectedProject(client, config, undefined, !globals.json);
1281
+ const project = await selectedProject(client, config, undefined);
937
1282
  const agentId = await selectedSessionAgent(client, project, session.agent);
938
1283
  const agent = developmentAgentReference(agentId);
939
1284
  if (prompt) {
940
- const result = await runAgent(client, agent, prompt, session.keep, globals.json, globals.verbose === true);
1285
+ const result = await runAgent(client, agent, prompt, session.keep, globals.json, globals.verbose === true, globals.idempotencyKey);
941
1286
  if (globals.json)
942
1287
  printJSON(result);
943
1288
  return;
@@ -983,7 +1328,7 @@ export async function runCommand(command, rawArgs, globals) {
983
1328
  const prompt = sessionArgs.join(" ").trim();
984
1329
  if (!prompt)
985
1330
  throw new Error("A prompt is required.");
986
- const result = await sendAgentTurn(client, sessionId, prompt, session.keep, globals.json);
1331
+ const result = await sendAgentTurn(client, sessionId, prompt, session.keep, globals.json, globals.idempotencyKey);
987
1332
  if (globals.json) {
988
1333
  printJSON({ sessionId, ...result, status: "completed" });
989
1334
  }