@oxygen-agent/cli 1.50.37 → 1.98.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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // skipcq: JS-0271 — bin entry source; build chmod+x on dist/index.js
3
3
  import { execFileSync } from "node:child_process";
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
7
  import { basename, dirname, extname, resolve } from "node:path";
@@ -14,13 +14,16 @@ import { inferRowsFileFormat, normalizeRowsForNewTable, normalizeRowsFormat, par
14
14
  import { assertRecipeBundleSafe, assertWorkflowManifest, buildRecipeManifest, compileWorkflowDefinition, isAnyWorkflowManifest, isRecipeManifest, isWorkflowDefinition, isWorkflowManifest, } from "@oxygen/workflows";
15
15
  import { isRecipeDefinition } from "@oxygen/recipe-sdk";
16
16
  import { createBrowserLoginSession, openBrowser } from "./browser-login.js";
17
- import { clearCredentials, defaultApiUrl, listCredentialProfiles, normalizeApiUrl, saveCredentials, switchCredentialProfile, } from "./credentials.js";
17
+ import { clearCredentials, defaultApiUrl, listCredentialProfiles, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
18
18
  import { requestOxygen } from "./http-client.js";
19
19
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
20
20
  import { addSessionOutput, addSessionStatus, getSessionUsage, startSession, updateSessionStep, } from "./session.js";
21
+ import { doctorAgentSkills, installAgentSkills, listAgentSkills, runAutomaticSkillsInstall, } from "./skills.js";
21
22
  import { updateCli } from "./update.js";
22
23
  const BROWSER_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
23
24
  const OXYGEN_SPINNER_INTERVAL_MS = 90;
25
+ const INITIAL_OXYGEN_PROFILE_ENV = process.env.OXYGEN_PROFILE?.trim() || null;
26
+ let globalProfileFlag = null;
24
27
  const OXYGEN_SPINNER_FRAMES = [
25
28
  "[Oxygen ]",
26
29
  "[ Oxygen ]",
@@ -109,6 +112,21 @@ function parseJsonArray(value) {
109
112
  }
110
113
  return parsed;
111
114
  }
115
+ function readCustomIntegrationManifest(options) {
116
+ const manifestPath = readOption(options.manifest);
117
+ const manifestJson = readOption(options.manifestJson);
118
+ if (manifestPath && manifestJson) {
119
+ throw new OxygenError("invalid_request", "Pass either --manifest or --manifest-json, not both.", {
120
+ exitCode: 1,
121
+ });
122
+ }
123
+ if (!manifestPath && !manifestJson) {
124
+ throw new OxygenError("invalid_request", "Pass --manifest or --manifest-json.", {
125
+ exitCode: 1,
126
+ });
127
+ }
128
+ return parseJsonObject(manifestJson ?? readFileSync(resolve(manifestPath ?? ""), "utf8"));
129
+ }
112
130
  function readFileIfPresent(value) {
113
131
  const candidate = resolve(value);
114
132
  try {
@@ -158,11 +176,17 @@ export function createProgram() {
158
176
  .name("oxygen")
159
177
  .description("CLI/API-first GTM platform for GTM tool and workflow primitives.")
160
178
  .version(OXYGEN_VERSION)
161
- .option("--profile <name>", "Use a stored CLI profile for this command.");
179
+ .option("--profile <name>", "Use a stored CLI profile for this command.")
180
+ .option("--org <organization>", "Use an organization id, Clerk org id, or slug for this command.");
162
181
  program.hook("preAction", () => {
163
182
  const options = program.opts();
164
- if (options.profile)
183
+ if (options.profile) {
184
+ globalProfileFlag = options.profile;
165
185
  process.env.OXYGEN_PROFILE = options.profile;
186
+ }
187
+ if (options.org) {
188
+ process.env.OXYGEN_ORG = options.org;
189
+ }
166
190
  });
167
191
  program
168
192
  .command("login")
@@ -209,6 +233,20 @@ export function createProgram() {
209
233
  .option("--json", "Print a JSON envelope.")
210
234
  .action(async (profile, options) => {
211
235
  await handleLogoutAction({ ...options, profile });
236
+ }))
237
+ .addCommand(new Command("env")
238
+ .description("Print shell `export` lines that pin this terminal to a stored CLI profile.")
239
+ .argument("<profile>", "Stored profile name.")
240
+ .option("--unset", "Print `unset` lines instead of `export` lines.")
241
+ .option("--json", "Print a JSON envelope.")
242
+ .action(async (profile, options) => {
243
+ await handleProfilesEnvAction(profile, options);
244
+ }))
245
+ .addCommand(new Command("current")
246
+ .description("Show the profile the next command will use, and where it came from.")
247
+ .option("--json", "Print a JSON envelope.")
248
+ .action(async (options) => {
249
+ await handleProfilesCurrentAction(options);
212
250
  }));
213
251
  program
214
252
  .command("logout")
@@ -263,8 +301,24 @@ export function createProgram() {
263
301
  .description("Show the current Oxygen CLI identity.")
264
302
  .option("--json", "Print a JSON envelope.")
265
303
  .action(async (options) => {
266
- await handleAsyncAction("whoami", options, async () => requestOxygen("/api/cli/whoami"));
304
+ await handleWhoamiAction(options);
267
305
  });
306
+ program
307
+ .command("onboarding")
308
+ .description("Onboarding helpers.")
309
+ .addCommand(new Command("start")
310
+ .description("Start onboarding and get the skill to load.")
311
+ .option("--json", "Print a JSON envelope.")
312
+ .action(async (options) => {
313
+ await handleOnboardingStartAction(options);
314
+ }))
315
+ .addCommand(new Command("reset")
316
+ .description("Clear the onboarding_started_at marker so onboarding re-runs from scratch. Company context data is preserved.")
317
+ .option("--confirm", "Required: confirm the reset. Without this flag the command refuses to run.")
318
+ .option("--json", "Print a JSON envelope.")
319
+ .action(async (options) => {
320
+ await handleOnboardingResetAction(options);
321
+ }));
268
322
  program
269
323
  .command("status")
270
324
  .description("Compare the local Oxygen CLI version against what's deployed in prod.")
@@ -289,16 +343,20 @@ export function createProgram() {
289
343
  .option("--json", "Print a JSON envelope.")
290
344
  .action(async (options) => {
291
345
  await handleAsyncAction("orgs list", options, async () => requestOxygen("/api/cli/orgs"));
346
+ }))
347
+ .addCommand(new Command("use")
348
+ .description("Select the active organization for this CLI profile.")
349
+ .argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
350
+ .option("--json", "Print a JSON envelope.")
351
+ .action(async (organization, options) => {
352
+ await handleOrgUseAction(organization, options, "orgs use");
292
353
  }))
293
354
  .addCommand(new Command("select")
294
- .description("Select the active organization for an OAuth-backed CLI session.")
355
+ .description("Alias for orgs use.")
295
356
  .argument("<organization>", "Organization id, Clerk org id, or slug returned by orgs list.")
296
357
  .option("--json", "Print a JSON envelope.")
297
358
  .action(async (organization, options) => {
298
- await handleAsyncAction("orgs select", options, async () => requestOxygen("/api/cli/orgs/select", {
299
- method: "POST",
300
- body: { organization },
301
- }));
359
+ await handleOrgUseAction(organization, options, "orgs select");
302
360
  }));
303
361
  program
304
362
  .command("db")
@@ -527,6 +585,27 @@ export function createProgram() {
527
585
  .option("--json", "Print a JSON envelope.")
528
586
  .action(async (table, options) => {
529
587
  await handleAsyncAction("tables export", options, async () => exportRows(table, options));
588
+ }))
589
+ .addCommand(new Command("export-bundle")
590
+ .description("Export a workspace table as a portable bundle (schema + every row) for cross-environment / cross-org copies.")
591
+ .argument("<table>", "Table id or slug to export.")
592
+ .option("--output <path>", "Write the bundle JSON to a file. Defaults to stdout.")
593
+ .option("--page-size <n>", "Rows per cursor-paginated request. Defaults to 500; hard cap is 1000.")
594
+ .option("--json", "Print a JSON envelope (omit row payload — use --output to keep the rows).")
595
+ .action(async (table, options) => {
596
+ await handleAsyncAction("tables export-bundle", options, async () => exportTableBundle(table, options));
597
+ }))
598
+ .addCommand(new Command("import-bundle")
599
+ .description("Recreate a workspace table from an export-bundle file in this org. Restores columns (incl. enrichment/tool definitions) and inserts every row. Pass --key to make the import idempotent (re-runnable), and --into to resume a failed import into the table it already created.")
600
+ .requiredOption("--file <path>", "Bundle JSON file produced by `tables export-bundle`.")
601
+ .option("--name <name>", "Override the table display name. Defaults to the bundle's table name. Ignored with --into.")
602
+ .option("--project <project>", "Project id or slug for the new table. Defaults to General. Ignored with --into.")
603
+ .option("--into <table>", "Resume into an existing table (id or slug) instead of creating a new one. Requires --key. Use the table id printed by the failed run.")
604
+ .option("--key <column>", "Upsert key column. When set, rows are matched by this key instead of blindly inserted, so re-running the import is idempotent (no duplicates). Required with --into.")
605
+ .option("--batch-size <n>", "Rows per write request. Defaults to 500; paid orgs may use up to 5000.")
606
+ .option("--json", "Print a JSON envelope.")
607
+ .action(async (options) => {
608
+ await handleAsyncAction("tables import-bundle", options, async () => importTableBundle(options));
530
609
  }))
531
610
  .addCommand(new Command("list")
532
611
  .description("List workspace tables in the current tenant database.")
@@ -629,6 +708,23 @@ export function createProgram() {
629
708
  method: "POST",
630
709
  body: { table, project: options.project },
631
710
  }));
711
+ }))
712
+ .addCommand(new Command("recover-pending")
713
+ .description("Re-poll BetterContact async enrichments that timed out mid-poll and write any newly-found phones back to the table.")
714
+ .argument("<table>", "Table id or slug.")
715
+ .option("--max-items <n>", "Max pending cache rows to poll in this run. Defaults to 1000.")
716
+ .option("--json", "Print a JSON envelope.")
717
+ .action(async (table, options) => {
718
+ await handleAsyncAction("tables recover-pending", options, async () => {
719
+ const maxItems = options.maxItems ? Number.parseInt(options.maxItems, 10) : undefined;
720
+ return requestOxygen("/api/cli/tables/recover-pending", {
721
+ method: "POST",
722
+ body: {
723
+ table,
724
+ ...(Number.isFinite(maxItems) ? { max_items: maxItems } : {}),
725
+ },
726
+ });
727
+ });
632
728
  }));
633
729
  tablesCommand.addCommand(new Command("webhook")
634
730
  .description("Create and manage direct table webhooks.")
@@ -661,6 +757,22 @@ export function createProgram() {
661
757
  program
662
758
  .command("context")
663
759
  .description("Workspace-level GTM context commands.")
760
+ .addCommand(new Command("resolve")
761
+ .description("Resolve task-scoped workspace GTM context with readiness and revision provenance.")
762
+ .option("--purpose <purpose>", "general, lead_sourcing, qualification, outbound_copy, or workflow_design.")
763
+ .option("--asset-type <csv>", "Comma-separated context asset types to include.")
764
+ .option("--asset-status <status>", "draft, active, archived, or all. Defaults to active.")
765
+ .option("--tags <csv>", "Comma-separated asset tags that must be present.")
766
+ .option("--include-archived", "Include archived assets when no asset status is set.")
767
+ .option("--max-assets <n>", "Maximum assets to include. Defaults to 10, max 50.")
768
+ .option("--require-ready", "Exit with a conflict error when required context sections are missing.")
769
+ .option("--json", "Print a JSON envelope.")
770
+ .action(async (options) => {
771
+ await handleAsyncAction("context resolve", options, async () => requestOxygen("/api/cli/context/resolve", {
772
+ method: "POST",
773
+ body: buildContextResolveBody(options),
774
+ }));
775
+ }))
664
776
  .addCommand(new Command("profile")
665
777
  .description("Company profile and ICP memory.")
666
778
  .addCommand(new Command("get")
@@ -734,7 +846,238 @@ export function createProgram() {
734
846
  }));
735
847
  })));
736
848
  program
737
- .command("templates")
849
+ .command("blueprints")
850
+ .description("Portable Oxygen blueprints: bundle a workflow + tables + columns + prompts as shareable JSON.")
851
+ .addCommand(new Command("list")
852
+ .description("List Oxygen blueprints visible in this workspace (seeds + saved).")
853
+ .argument("[query]", "Search text.")
854
+ .option("--tag <tag>", "Filter by tag.")
855
+ .option("--include-seeds", "Include the shipped seed blueprints (default true).")
856
+ .option("--json", "Print a JSON envelope.")
857
+ .action(async (query, options) => {
858
+ await handleAsyncAction("blueprints list", options, () => {
859
+ const params = new URLSearchParams();
860
+ if (query)
861
+ params.set("query", query);
862
+ const tag = readOption(options.tag);
863
+ if (tag)
864
+ params.set("tag", tag);
865
+ if (options.includeSeeds === false)
866
+ params.set("include_seeds", "false");
867
+ const qs = params.toString() ? `?${params.toString()}` : "";
868
+ return requestOxygen(`/api/cli/blueprints${qs}`);
869
+ });
870
+ }))
871
+ .addCommand(new Command("describe")
872
+ .description("Describe one blueprint (seed or saved) by slug.")
873
+ .argument("<slug>", "Blueprint slug.")
874
+ .option("--json", "Print a JSON envelope.")
875
+ .action(async (slug, options) => {
876
+ await handleAsyncAction("blueprints describe", options, () => requestOxygen("/api/cli/blueprints/get", {
877
+ method: "POST",
878
+ body: { slug },
879
+ }));
880
+ }))
881
+ .addCommand(new Command("export")
882
+ .description("Build a portable blueprint JSON from an existing workflow.")
883
+ .requiredOption("--workflow <id>", "Workflow id or slug to export.")
884
+ .option("--tables <ids>", "Comma-separated list of table ids/slugs to bundle.")
885
+ .option("--prompts <slugs>", "Comma-separated list of prompt template slugs to bundle.")
886
+ .option("--blueprint-id <slug>", "Override the output blueprint slug.")
887
+ .option("--blueprint-name <name>", "Override the output blueprint name.")
888
+ .option("--blueprint-summary <text>", "Override the output blueprint summary.")
889
+ .option("--blueprint-tags <tags>", "Comma-separated tags for the exported blueprint.")
890
+ .option("--out <path>", "Write the exported envelope to this file path.")
891
+ .option("--json", "Print a JSON envelope.")
892
+ .action(async (options) => {
893
+ await handleAsyncAction("blueprints export", options, async () => {
894
+ const body = { workflow_id: options.workflow };
895
+ const tables = readOption(options.tables);
896
+ if (tables)
897
+ body.table_ids = splitCsv(tables);
898
+ const prompts = readOption(options.prompts);
899
+ if (prompts)
900
+ body.prompt_slugs = splitCsv(prompts);
901
+ const blueprintId = readOption(options.blueprintId);
902
+ if (blueprintId)
903
+ body.blueprint_id = blueprintId;
904
+ const blueprintName = readOption(options.blueprintName);
905
+ if (blueprintName)
906
+ body.blueprint_name = blueprintName;
907
+ const blueprintSummary = readOption(options.blueprintSummary);
908
+ if (blueprintSummary)
909
+ body.blueprint_summary = blueprintSummary;
910
+ const blueprintTags = readOption(options.blueprintTags);
911
+ if (blueprintTags)
912
+ body.blueprint_tags = splitCsv(blueprintTags);
913
+ const result = await requestOxygen("/api/cli/blueprints/export", {
914
+ method: "POST",
915
+ body,
916
+ });
917
+ const outPath = readOption(options.out);
918
+ if (outPath) {
919
+ const fs = await import("node:fs/promises");
920
+ const blueprint = result.blueprint;
921
+ await fs.writeFile(outPath, JSON.stringify(blueprint, null, 2), "utf8");
922
+ }
923
+ return result;
924
+ });
925
+ }))
926
+ .addCommand(new Command("preflight")
927
+ .description("Preflight a blueprint (slug, local file, or shared URL) against this workspace.")
928
+ .argument("[slug]", "Blueprint slug (for stored or seed blueprints).")
929
+ .option("--file <path>", "Read a blueprint envelope from a local JSON file.")
930
+ .option("--from-url <url>", "Fetch a shared blueprint envelope from a public Oxygen share URL.")
931
+ .option("--input-json <json>", "Seed blueprint input (parameters) as JSON.")
932
+ .option("--table-ref <ref=id...>", "Reuse an existing table for a blueprint ref (repeatable).", collectMultiple, [])
933
+ .option("--json", "Print a JSON envelope.")
934
+ .action(async (slug, options) => {
935
+ await handleAsyncAction("blueprints preflight", options, async () => {
936
+ const body = await buildBlueprintRequestBody(slug, options);
937
+ return requestOxygen("/api/cli/blueprints/preflight", { method: "POST", body });
938
+ });
939
+ }))
940
+ .addCommand(new Command("apply")
941
+ .description("Apply a blueprint to this workspace. Creates tables, columns, prompts, and a disabled workflow.")
942
+ .argument("[slug]", "Blueprint slug (for stored or seed blueprints).")
943
+ .option("--file <path>", "Read a blueprint envelope from a local JSON file.")
944
+ .option("--from-url <url>", "Fetch a shared blueprint envelope from a public Oxygen share URL.")
945
+ .option("--input-json <json>", "Seed blueprint input (parameters) as JSON.")
946
+ .option("--table-ref <ref=id...>", "Reuse an existing table for a blueprint ref (repeatable).", collectMultiple, [])
947
+ .option("--workflow-id <id>", "Override the resulting workflow id.")
948
+ .option("--workflow-name <name>", "Override the resulting workflow name.")
949
+ .option("--json", "Print a JSON envelope.")
950
+ .action(async (slug, options) => {
951
+ await handleAsyncAction("blueprints apply", options, async () => {
952
+ const body = await buildBlueprintRequestBody(slug, options);
953
+ const workflowId = readOption(options.workflowId);
954
+ if (workflowId)
955
+ body.workflow_id = workflowId;
956
+ const workflowName = readOption(options.workflowName);
957
+ if (workflowName)
958
+ body.workflow_name = workflowName;
959
+ return requestOxygen("/api/cli/blueprints/apply", { method: "POST", body });
960
+ });
961
+ }))
962
+ .addCommand(new Command("save")
963
+ .description("Persist a blueprint envelope into this workspace's gallery.")
964
+ .option("--file <path>", "Read a blueprint envelope from a local JSON file.")
965
+ .option("--slug <slug>", "Override the stored blueprint slug.")
966
+ .option("--name <name>", "Override the stored blueprint name.")
967
+ .option("--summary <text>", "Override the stored blueprint summary.")
968
+ .option("--json", "Print a JSON envelope.")
969
+ .action(async (options) => {
970
+ await handleAsyncAction("blueprints save", options, async () => {
971
+ const filePath = readOption(options.file);
972
+ if (!filePath)
973
+ throw new Error("--file is required for blueprints save");
974
+ const fs = await import("node:fs/promises");
975
+ const raw = await fs.readFile(filePath, "utf8");
976
+ const envelope = JSON.parse(raw);
977
+ const body = { envelope };
978
+ const slug = readOption(options.slug);
979
+ if (slug)
980
+ body.slug = slug;
981
+ const name = readOption(options.name);
982
+ if (name)
983
+ body.name = name;
984
+ const summary = readOption(options.summary);
985
+ if (summary)
986
+ body.summary = summary;
987
+ return requestOxygen("/api/cli/blueprints/save", { method: "POST", body });
988
+ });
989
+ }))
990
+ .addCommand(new Command("archive")
991
+ .description("Archive a saved blueprint (seed blueprints cannot be archived).")
992
+ .argument("<slug>", "Blueprint slug or id.")
993
+ .option("--json", "Print a JSON envelope.")
994
+ .action(async (slug, options) => {
995
+ await handleAsyncAction("blueprints archive", options, () => requestOxygen("/api/cli/blueprints/archive", { method: "POST", body: { slug } }));
996
+ }))
997
+ .addCommand(new Command("share")
998
+ .description("Create a public share URL for a saved blueprint (oxygen-agent.com/b/<code>).")
999
+ .argument("<slug>", "Blueprint slug or id to share.")
1000
+ .option("--expires-in-days <days>", "Optional expiry (default: no expiry).")
1001
+ .option("--disabled", "Create the share in disabled state.")
1002
+ .option("--json", "Print a JSON envelope.")
1003
+ .action(async (slug, options) => {
1004
+ await handleAsyncAction("blueprints share", options, () => {
1005
+ const body = { slug };
1006
+ const expiresInDays = readOption(options.expiresInDays);
1007
+ if (expiresInDays)
1008
+ body.expires_in_days = Number(expiresInDays);
1009
+ if (options.disabled)
1010
+ body.enabled = false;
1011
+ return requestOxygen("/api/cli/blueprints/share", { method: "POST", body });
1012
+ });
1013
+ }))
1014
+ .addCommand(new Command("shares")
1015
+ .description("List existing share URLs for this workspace.")
1016
+ .option("--json", "Print a JSON envelope.")
1017
+ .action(async (options) => {
1018
+ await handleAsyncAction("blueprints shares", options, () => requestOxygen("/api/cli/blueprints/share"));
1019
+ }))
1020
+ .addCommand(new Command("unshare")
1021
+ .description("Revoke a public share URL by short code.")
1022
+ .argument("<code>", "Share short code (the slug after /b/ in the URL).")
1023
+ .option("--json", "Print a JSON envelope.")
1024
+ .action(async (code, options) => {
1025
+ await handleAsyncAction("blueprints unshare", options, () => requestOxygen("/api/cli/blueprints/share/revoke", {
1026
+ method: "POST",
1027
+ body: { short_code: code },
1028
+ }));
1029
+ }))
1030
+ .addCommand(new Command("publish")
1031
+ .description("List a blueprint on the public Oxygen marketplace. Auto-creates a share URL if none exists.")
1032
+ .argument("<slug>", "Blueprint slug to publish.")
1033
+ .option("--category <name>", "Optional free-text category (e.g. outbound, enrichment).")
1034
+ .option("--kind <kind>", "Listing kind: community (default) or official (Oxygen-curated; gated).")
1035
+ .option("--approved", "Acknowledge the publish-safety warnings (required if the preview is not clean).")
1036
+ .option("--json", "Print a JSON envelope.")
1037
+ .action(async (slug, options) => {
1038
+ await handleAsyncAction("blueprints publish", options, () => {
1039
+ const body = { slug };
1040
+ const category = readOption(options.category);
1041
+ if (category)
1042
+ body.category = category;
1043
+ const kind = readOption(options.kind);
1044
+ if (kind)
1045
+ body.kind = kind;
1046
+ if (options.approved)
1047
+ body.approved = true;
1048
+ return requestOxygen("/api/cli/blueprints/publish", { method: "POST", body });
1049
+ });
1050
+ }))
1051
+ .addCommand(new Command("unpublish")
1052
+ .description("Remove a blueprint from the public marketplace. The share URL stays valid for direct visitors.")
1053
+ .argument("<slug>", "Blueprint slug to unlist.")
1054
+ .option("--json", "Print a JSON envelope.")
1055
+ .action(async (slug, options) => {
1056
+ await handleAsyncAction("blueprints unpublish", options, () => requestOxygen("/api/cli/blueprints/unpublish", {
1057
+ method: "POST",
1058
+ body: { slug },
1059
+ }));
1060
+ }))
1061
+ .addCommand(new Command("marketplace")
1062
+ .description("Browse the public Oxygen blueprint marketplace.")
1063
+ .option("--category <name>", "Filter by listing category.")
1064
+ .option("--kind <kind>", "Filter by listing kind: community or official.")
1065
+ .option("--json", "Print a JSON envelope.")
1066
+ .action(async (options) => {
1067
+ await handleAsyncAction("blueprints marketplace", options, () => {
1068
+ const params = new URLSearchParams();
1069
+ const category = readOption(options.category);
1070
+ if (category)
1071
+ params.set("category", category);
1072
+ const kind = readOption(options.kind);
1073
+ if (kind)
1074
+ params.set("kind", kind);
1075
+ const qs = params.toString() ? `?${params.toString()}` : "";
1076
+ return requestOxygen(`/api/blueprints/marketplace${qs}`, { requireAuth: false });
1077
+ });
1078
+ }));
1079
+ program
1080
+ .command("prompts")
738
1081
  .description("Reusable prompt templates layered into AI columns at run time.")
739
1082
  .addCommand(new Command("list")
740
1083
  .description("List prompt templates in the workspace.")
@@ -742,10 +1085,85 @@ export function createProgram() {
742
1085
  .option("--include-archived", "Include archived templates.")
743
1086
  .option("--json", "Print a JSON envelope.")
744
1087
  .action(async (options) => {
745
- await handleAsyncAction("templates list", options, async () => {
1088
+ await handleAsyncAction("prompts list", options, () => {
746
1089
  const params = new URLSearchParams();
1090
+ const kind = readOption(options.kind);
1091
+ if (kind)
1092
+ params.set("kind", kind);
1093
+ if (options.includeArchived)
1094
+ params.set("include_archived", "true");
1095
+ const qs = params.toString() ? `?${params.toString()}` : "";
1096
+ return requestOxygen(`/api/cli/templates${qs}`);
1097
+ });
1098
+ }))
1099
+ .addCommand(new Command("get")
1100
+ .description("Read one prompt template by id or slug.")
1101
+ .argument("<id_or_slug>", "Template UUID or slug.")
1102
+ .option("--json", "Print a JSON envelope.")
1103
+ .action(async (idOrSlug, options) => {
1104
+ await handleAsyncAction("prompts get", options, () => requestOxygen("/api/cli/templates/get", {
1105
+ method: "POST",
1106
+ body: idOrSlug.includes("-") && idOrSlug.length >= 32
1107
+ ? { id: idOrSlug }
1108
+ : { slug: idOrSlug },
1109
+ }));
1110
+ }))
1111
+ .addCommand(new Command("upsert")
1112
+ .description("Create or update a prompt template.")
1113
+ .option("--id <id>", "Existing template UUID to update. Omit to create.")
1114
+ .option("--slug <slug>", "Stable slug (kebab-case).")
1115
+ .option("--name <name>", "Human-readable name.")
1116
+ .option("--description <text>", "Short description.")
1117
+ .option("--kind <kind>", "Template kind: ai_column_system, scoring_rubric, or other.")
1118
+ .option("--body <text>", "Prompt body.")
1119
+ .option("--body-file <path>", "Path to a file containing the prompt body.")
1120
+ .option("--json", "Print a JSON envelope.")
1121
+ .action(async (options) => {
1122
+ await handleAsyncAction("prompts upsert", options, async () => {
1123
+ const body = {};
1124
+ if (readOption(options.id))
1125
+ body.id = readOption(options.id);
1126
+ if (readOption(options.slug))
1127
+ body.slug = readOption(options.slug);
1128
+ if (readOption(options.name))
1129
+ body.name = readOption(options.name);
1130
+ if (readOption(options.description) !== undefined)
1131
+ body.description = readOption(options.description);
747
1132
  if (readOption(options.kind))
748
- params.set("kind", readOption(options.kind));
1133
+ body.kind = readOption(options.kind);
1134
+ if (readOption(options.body))
1135
+ body.body = readOption(options.body);
1136
+ else {
1137
+ const path = readOption(options.bodyFile);
1138
+ if (path) {
1139
+ const fs = await import("node:fs/promises");
1140
+ body.body = await fs.readFile(path, "utf8");
1141
+ }
1142
+ }
1143
+ return requestOxygen("/api/cli/templates/upsert", { method: "POST", body });
1144
+ });
1145
+ }))
1146
+ .addCommand(new Command("archive")
1147
+ .description("Archive a prompt template. Seeded templates cannot be archived.")
1148
+ .argument("<id>", "Template UUID.")
1149
+ .option("--json", "Print a JSON envelope.")
1150
+ .action(async (id, options) => {
1151
+ await handleAsyncAction("prompts archive", options, () => requestOxygen("/api/cli/templates/archive", { method: "POST", body: { id } }));
1152
+ }));
1153
+ program
1154
+ .command("templates")
1155
+ .description("Deprecated alias for 'oxygen prompts'. Will be removed in a future release.")
1156
+ .addCommand(new Command("list")
1157
+ .description("List prompt templates in the workspace.")
1158
+ .option("--kind <kind>", "Filter by ai_column_system, scoring_rubric, or other.")
1159
+ .option("--include-archived", "Include archived templates.")
1160
+ .option("--json", "Print a JSON envelope.")
1161
+ .action(async (options) => {
1162
+ await handleAsyncAction("templates list", options, () => {
1163
+ const params = new URLSearchParams();
1164
+ const kind = readOption(options.kind);
1165
+ if (kind)
1166
+ params.set("kind", kind);
749
1167
  if (options.includeArchived)
750
1168
  params.set("include_archived", "true");
751
1169
  const qs = params.toString() ? `?${params.toString()}` : "";
@@ -789,10 +1207,12 @@ export function createProgram() {
789
1207
  body.kind = readOption(options.kind);
790
1208
  if (readOption(options.body))
791
1209
  body.body = readOption(options.body);
792
- else if (readOption(options.bodyFile)) {
1210
+ else {
793
1211
  const path = readOption(options.bodyFile);
794
- const fs = await import("node:fs/promises");
795
- body.body = await fs.readFile(path, "utf8");
1212
+ if (path) {
1213
+ const fs = await import("node:fs/promises");
1214
+ body.body = await fs.readFile(path, "utf8");
1215
+ }
796
1216
  }
797
1217
  return requestOxygen("/api/cli/templates/upsert", { method: "POST", body });
798
1218
  });
@@ -814,14 +1234,17 @@ export function createProgram() {
814
1234
  .option("--limit <n>", "Max rows to return (default 50, max 200).")
815
1235
  .option("--json", "Print a JSON envelope.")
816
1236
  .action(async (options) => {
817
- await handleAsyncAction("reviews list", options, async () => {
1237
+ await handleAsyncAction("reviews list", options, () => {
818
1238
  const params = new URLSearchParams();
819
- if (readOption(options.status))
820
- params.set("status", readOption(options.status));
821
- if (readOption(options.table))
822
- params.set("table_id", readOption(options.table));
823
- if (readOption(options.limit))
824
- params.set("limit", readOption(options.limit));
1239
+ const status = readOption(options.status);
1240
+ const table = readOption(options.table);
1241
+ const limit = readOption(options.limit);
1242
+ if (status)
1243
+ params.set("status", status);
1244
+ if (table)
1245
+ params.set("table_id", table);
1246
+ if (limit)
1247
+ params.set("limit", limit);
825
1248
  const qs = params.toString() ? `?${params.toString()}` : "";
826
1249
  return requestOxygen(`/api/cli/message-reviews${qs}`);
827
1250
  });
@@ -831,10 +1254,11 @@ export function createProgram() {
831
1254
  .option("--table <table_id>", "Filter by table id.")
832
1255
  .option("--json", "Print a JSON envelope.")
833
1256
  .action(async (options) => {
834
- await handleAsyncAction("reviews next", options, async () => {
1257
+ await handleAsyncAction("reviews next", options, () => {
835
1258
  const params = new URLSearchParams();
836
- if (readOption(options.table))
837
- params.set("table_id", readOption(options.table));
1259
+ const table = readOption(options.table);
1260
+ if (table)
1261
+ params.set("table_id", table);
838
1262
  const qs = params.toString() ? `?${params.toString()}` : "";
839
1263
  return requestOxygen(`/api/cli/message-reviews/next${qs}`);
840
1264
  });
@@ -856,10 +1280,11 @@ export function createProgram() {
856
1280
  .option("--auto-rerun", "Trigger a single-row rerun of the column after rejecting.")
857
1281
  .option("--json", "Print a JSON envelope.")
858
1282
  .action(async (reviewId, options) => {
859
- await handleAsyncAction("reviews reject", options, async () => {
1283
+ await handleAsyncAction("reviews reject", options, () => {
860
1284
  const body = { id: reviewId, decision: "reject" };
861
- if (readOption(options.highlightsJson)) {
862
- body.highlights = JSON.parse(readOption(options.highlightsJson));
1285
+ const highlightsJson = readOption(options.highlightsJson);
1286
+ if (highlightsJson) {
1287
+ body.highlights = JSON.parse(highlightsJson);
863
1288
  }
864
1289
  if (options.autoRerun)
865
1290
  body.auto_rerun = true;
@@ -872,15 +1297,25 @@ export function createProgram() {
872
1297
  .addCommand(new Command("add")
873
1298
  .description("Add a nullable column to a workspace table.")
874
1299
  .argument("<table>", "Table id or slug.")
875
- .requiredOption("--label <label>", "Display label for the new column.")
1300
+ .option("--label <label>", "Display label for the new column. Required unless --prompt-key supplies a default title.")
876
1301
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
877
1302
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
878
1303
  .option("--kind <kind>", "Column kind. Defaults to manual.")
879
1304
  .option("--semantic-type <type>", "Optional semantic type such as company_domain.")
880
1305
  .option("--definition-json <json>", "Optional JSON object with column definition metadata.")
1306
+ .option("--prompt-key <key>", "OXYGEN prompt-library key (e.g. email_draft_v1). Materializes prompt + output_schema and forces kind=ai.")
1307
+ .option("--input-mapping <json>", "Required with --prompt-key. JSON object mapping prompt input names to column or literal refs.")
881
1308
  .option("--json", "Print a JSON envelope.")
882
1309
  .action(async (table, options) => {
883
- const column = { label: options.label };
1310
+ if (!options.promptKey && !options.label) {
1311
+ throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
1312
+ }
1313
+ if (options.promptKey && !options.inputMapping) {
1314
+ throw new OxygenError("missing_input_mapping", "--input-mapping is required when --prompt-key is provided.", { exitCode: 1 });
1315
+ }
1316
+ const column = {};
1317
+ if (options.label)
1318
+ column.label = options.label;
884
1319
  if (options.key)
885
1320
  column.key = options.key;
886
1321
  if (options.dataType)
@@ -891,12 +1326,14 @@ export function createProgram() {
891
1326
  column.semantic_type = options.semanticType;
892
1327
  if (options.definitionJson)
893
1328
  column.definition = parseJsonObject(options.definitionJson);
1329
+ const body = { table, column };
1330
+ if (options.promptKey)
1331
+ body.prompt_key = options.promptKey;
1332
+ if (options.inputMapping)
1333
+ body.input_mapping = parseJsonObject(options.inputMapping);
894
1334
  await handleAsyncAction("columns add", options, async () => requestOxygen("/api/cli/tables/columns", {
895
1335
  method: "POST",
896
- body: {
897
- table,
898
- column,
899
- },
1336
+ body,
900
1337
  }));
901
1338
  }))
902
1339
  .addCommand(new Command("run")
@@ -911,6 +1348,7 @@ export function createProgram() {
911
1348
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
912
1349
  .option("--background", "Create a durable background table action run instead of executing synchronously.")
913
1350
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for a background run.")
1351
+ .option("--max-concurrency <n>", "Maximum concurrent row items for a background run. Defaults to 50.")
914
1352
  .option("--local", "Run a custom HTTP column in this CLI process so env-var secrets stay local.")
915
1353
  .option("--local-concurrency <n>", "Maximum concurrent custom HTTP requests for --local. Defaults to 3.")
916
1354
  .option("--json", "Print a JSON envelope.")
@@ -918,6 +1356,7 @@ export function createProgram() {
918
1356
  const limit = readPositiveInt(options.limit);
919
1357
  const localConcurrency = readPositiveInt(options.localConcurrency);
920
1358
  const maxCredits = readPositiveNumber(options.maxCredits);
1359
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
921
1360
  const filterSelection = readFilterSelectionOption(options.filterJson);
922
1361
  const selectedModes = [
923
1362
  Boolean(options.all),
@@ -954,20 +1393,21 @@ export function createProgram() {
954
1393
  ...(localConcurrency ? { concurrency: localConcurrency } : {}),
955
1394
  });
956
1395
  }
957
- return requestOxygen("/api/cli/tables/columns/run", {
958
- method: "POST",
959
- body: {
960
- table,
961
- column,
962
- ...(options.all ? { selection: { mode: "all" } } : {}),
963
- ...(filterSelection ? { selection: filterSelection } : {}),
964
- ...(!options.all && readOption(options.rowId) ? { row_id: readOption(options.rowId) } : {}),
965
- ...(!options.all && !filterSelection && limit ? { limit } : {}),
966
- ...(options.force ? { force: true } : {}),
967
- ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
968
- ...(options.background ? { background: true } : {}),
969
- ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
970
- },
1396
+ const body = {
1397
+ table,
1398
+ column,
1399
+ ...(options.all ? { selection: { mode: "all" } } : {}),
1400
+ ...(filterSelection ? { selection: filterSelection } : {}),
1401
+ ...(!options.all && readOption(options.rowId) ? { row_id: readOption(options.rowId) } : {}),
1402
+ ...(!options.all && !filterSelection && limit ? { limit } : {}),
1403
+ ...(options.force ? { force: true } : {}),
1404
+ ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
1405
+ ...(options.background ? { background: true } : {}),
1406
+ ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1407
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
1408
+ };
1409
+ return requestColumnsRun(body, table, {
1410
+ background: Boolean(options.background),
971
1411
  });
972
1412
  });
973
1413
  }))
@@ -1035,10 +1475,11 @@ export function createProgram() {
1035
1475
  .argument("<column>", "Column id or key.")
1036
1476
  .option("--label <label>", "New display label.")
1037
1477
  .option("--semantic-type <type>", "New semantic type.")
1038
- .option("--definition-json <json>", "Definition metadata to merge into the column.")
1478
+ .option("--definition-json <json>", "Definition metadata to shallow-merge into the column; arrays are replaced wholesale and missing keys leave existing fields unchanged.")
1479
+ .option("--dry-run", "Return the would-be merged definition without writing.")
1039
1480
  .option("--json", "Print a JSON envelope.")
1040
1481
  .action(async (table, column, options) => {
1041
- await handleAsyncAction("columns update", options, async () => requestOxygen("/api/cli/tables/columns/update", {
1482
+ await handleAsyncAction("columns update", options, () => requestOxygen("/api/cli/tables/columns/update", {
1042
1483
  method: "POST",
1043
1484
  body: {
1044
1485
  table,
@@ -1046,6 +1487,7 @@ export function createProgram() {
1046
1487
  ...(readOption(options.label) ? { label: readOption(options.label) } : {}),
1047
1488
  ...(readOption(options.semanticType) ? { semantic_type: readOption(options.semanticType) } : {}),
1048
1489
  ...(options.definitionJson ? { definition: parseJsonObject(options.definitionJson) } : {}),
1490
+ ...(options.dryRun ? { dry_run: true } : {}),
1049
1491
  },
1050
1492
  }));
1051
1493
  }))
@@ -1129,10 +1571,12 @@ export function createProgram() {
1129
1571
  .option("--force", "Run even when the target cell already has a value.")
1130
1572
  .option("--connection-id <connection_id>", "Optional provider integration connection id.")
1131
1573
  .option("--max-credits <n>", "Maximum managed/provider credits to reserve for this run.")
1574
+ .option("--max-concurrency <n>", "Maximum concurrent row items for this run.")
1132
1575
  .option("--metadata-json <json>", "Optional metadata object to attach to the run.")
1133
1576
  .option("--json", "Print a JSON envelope.")
1134
1577
  .action(async (table, options) => {
1135
1578
  const maxCredits = readPositiveNumber(options.maxCredits);
1579
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
1136
1580
  await handleAsyncAction("table-runs create", options, async () => requestOxygen("/api/cli/table-action-runs", {
1137
1581
  method: "POST",
1138
1582
  body: {
@@ -1142,13 +1586,23 @@ export function createProgram() {
1142
1586
  ...(options.force ? { force: true } : {}),
1143
1587
  ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
1144
1588
  ...(maxCredits !== undefined ? { max_credits: maxCredits } : {}),
1589
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
1145
1590
  ...(options.metadataJson ? { metadata: parseJsonObject(options.metadataJson) } : {}),
1146
1591
  },
1147
1592
  }));
1593
+ }))
1594
+ .addCommand(new Command("list")
1595
+ .description("List durable table action runs for one table.")
1596
+ .requiredOption("--table <table>", "Table id or slug.")
1597
+ .option("--status <status>", "Filter by active, queued, running, paused, completed, completed_with_errors, failed, canceling, or canceled. Defaults to active.")
1598
+ .option("--limit <n>", "Maximum runs to return. Defaults to 20.")
1599
+ .option("--json", "Print a JSON envelope.")
1600
+ .action(async (options) => {
1601
+ await handleAsyncAction("table-runs list", options, () => requestOxygen(tableRunsListPath(options)));
1148
1602
  }))
1149
1603
  .addCommand(new Command("get")
1150
1604
  .description("Get one durable table action run.")
1151
- .argument("<run_id>", "Table action run UUID.")
1605
+ .argument("<run_id>", "Table action run UUID or parent workspace run UUID.")
1152
1606
  .option("--json", "Print a JSON envelope.")
1153
1607
  .action(async (runId, options) => {
1154
1608
  await handleAsyncAction("table-runs get", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}`));
@@ -1173,7 +1627,7 @@ export function createProgram() {
1173
1627
  }))
1174
1628
  .addCommand(new Command("wait")
1175
1629
  .description("Poll a durable table action run until it finishes.")
1176
- .argument("<run_id>", "Table action run UUID.")
1630
+ .argument("<run_id>", "Table action run UUID or parent workspace run UUID.")
1177
1631
  .option("--timeout-seconds <n>", "Maximum time to wait. Defaults to 600.")
1178
1632
  .option("--interval-seconds <n>", "Polling interval. Defaults to 5.")
1179
1633
  .option("--json", "Print a JSON envelope.")
@@ -1189,7 +1643,7 @@ export function createProgram() {
1189
1643
  }))
1190
1644
  .addCommand(new Command("cancel")
1191
1645
  .description("Request cancellation for a durable table action run.")
1192
- .argument("<run_id>", "Table action run UUID.")
1646
+ .argument("<run_id>", "Table action run UUID or parent workspace run UUID.")
1193
1647
  .option("--json", "Print a JSON envelope.")
1194
1648
  .action(async (runId, options) => {
1195
1649
  await handleAsyncAction("table-runs cancel", options, async () => requestOxygen(`/api/cli/table-action-runs/${encodeURIComponent(runId)}/cancel`, {
@@ -1427,7 +1881,7 @@ export function createProgram() {
1427
1881
  }))
1428
1882
  .addCommand(new Command("failures")
1429
1883
  .description("List failed background action and ingestion items.")
1430
- .option("--queue <queue>", "all, actions, or ingestions. Defaults to all.")
1884
+ .option("--queue <queue>", "all, actions, ingestions, or bullmq. Defaults to all.")
1431
1885
  .option("--limit <n>", "Maximum failed items per queue. Defaults to 25; server cap is 100.")
1432
1886
  .option("--json", "Print a JSON envelope.")
1433
1887
  .action(async (options) => {
@@ -1443,7 +1897,7 @@ export function createProgram() {
1443
1897
  });
1444
1898
  }))
1445
1899
  .addCommand(new Command("repair")
1446
- .description("Repair stale background action and ingestion queue state.")
1900
+ .description("Repair stale background action, ingestion, and workflow queue state.")
1447
1901
  .option("--json", "Print a JSON envelope.")
1448
1902
  .action(async (options) => {
1449
1903
  await handleAsyncAction("worker repair", options, async () => requestOxygen("/api/cli/worker/repair", {
@@ -1533,6 +1987,7 @@ export function createProgram() {
1533
1987
  .addCommand(new Command("costs")
1534
1988
  .description("Show provider costs (COGS) per workspace. Staff only.")
1535
1989
  .option("--top <n>", "Limit number of workspace columns. Defaults to all.")
1990
+ .option("--credential-mode <mode>", "managed (default; real COGS), byok (customer-supplied credentials), or all.", "managed")
1536
1991
  .option("--json", "Print a JSON envelope.")
1537
1992
  .action(async (options) => {
1538
1993
  await handleAsyncAction("admin costs", options, async () => {
@@ -1540,6 +1995,10 @@ export function createProgram() {
1540
1995
  const top = readPositiveInt(options.top);
1541
1996
  if (top)
1542
1997
  params.set("top", String(top));
1998
+ const credentialMode = readOption(options.credentialMode);
1999
+ if (credentialMode && credentialMode !== "managed") {
2000
+ params.set("credential_mode", credentialMode);
2001
+ }
1543
2002
  const suffix = params.toString() ? `?${params.toString()}` : "";
1544
2003
  return requestOxygen(`/api/cli/admin/costs${suffix}`);
1545
2004
  });
@@ -1621,6 +2080,27 @@ export function createProgram() {
1621
2080
  program
1622
2081
  .command("cells")
1623
2082
  .description("Workspace cell provenance commands.")
2083
+ .addCommand(new Command("inspect")
2084
+ .description("Inspect one cell's value, row context, and recent history.")
2085
+ .argument("<table>", "Table id or slug.")
2086
+ .argument("<row_id>", "Workspace row UUID.")
2087
+ .argument("<column>", "Column id or key.")
2088
+ .option("--history-limit <n>", "Maximum recent cell changes to include. Defaults to 10; cap 50.")
2089
+ .option("--json", "Print a JSON envelope.")
2090
+ .action(async (table, rowId, column, options) => {
2091
+ await handleAsyncAction("cells inspect", options, async () => {
2092
+ const historyLimit = readPositiveInt(options.historyLimit);
2093
+ return requestOxygen("/api/cli/tables/cells/inspect", {
2094
+ method: "POST",
2095
+ body: {
2096
+ table,
2097
+ row_id: rowId,
2098
+ column,
2099
+ ...(historyLimit ? { history_limit: historyLimit } : {}),
2100
+ },
2101
+ });
2102
+ });
2103
+ }))
1624
2104
  .addCommand(new Command("history")
1625
2105
  .description("Show cell change history.")
1626
2106
  .argument("<table>", "Table id or slug.")
@@ -1713,13 +2193,43 @@ export function createProgram() {
1713
2193
  .action(async (options) => {
1714
2194
  await handleAsyncAction("session usage", options, async () => getSessionUsage({ sessionId: readOption(options.sessionId) }));
1715
2195
  }));
2196
+ program
2197
+ .command("custom-integrations")
2198
+ .description("Custom HTTP integration commands.")
2199
+ .addCommand(new Command("apply")
2200
+ .description("Create or update a custom HTTP integration manifest. Store credentials from the web Connections page.")
2201
+ .option("--manifest <path>", "Path to a custom HTTP manifest JSON file.")
2202
+ .option("--manifest-json <json>", "Custom HTTP manifest as a JSON object.")
2203
+ .option("--json", "Print a JSON envelope.")
2204
+ .action(async (options) => {
2205
+ await handleAsyncAction("custom-integrations apply", options, () => {
2206
+ const manifest = readCustomIntegrationManifest(options);
2207
+ return requestOxygen("/api/cli/custom-integrations", {
2208
+ method: "POST",
2209
+ body: { manifest },
2210
+ });
2211
+ });
2212
+ }))
2213
+ .addCommand(new Command("list")
2214
+ .description("List custom HTTP integrations for the active organization.")
2215
+ .option("--slug <slug>", "Filter to one custom integration slug.")
2216
+ .option("--json", "Print a JSON envelope.")
2217
+ .action(async (options) => {
2218
+ await handleAsyncAction("custom-integrations list", options, () => {
2219
+ const params = new URLSearchParams();
2220
+ if (readOption(options.slug))
2221
+ params.set("slug", readOption(options.slug) ?? "");
2222
+ const suffix = params.toString();
2223
+ return requestOxygen(`/api/cli/custom-integrations${suffix ? `?${suffix}` : ""}`);
2224
+ });
2225
+ }));
1716
2226
  program
1717
2227
  .command("tools")
1718
2228
  .description("Tool catalog commands.")
1719
2229
  .addCommand(new Command("search")
1720
2230
  .description("Search the tool catalog.")
1721
2231
  .argument("[query]", "Search text.")
1722
- .option("--verbosity <verbosity>", "summary or full. Defaults to summary.")
2232
+ .option("--verbosity <verbosity>", "minimal, summary, or full. Defaults to summary. Use minimal for high-fanout discovery sweeps that need to stay under the MCP token budget.")
1723
2233
  .option("--only-runnable", "Only return tools runnable by the active organization.")
1724
2234
  .option("--capability <tag>", "Filter by capability tag, such as mobile_phone.")
1725
2235
  .option("--json", "Print a JSON envelope.")
@@ -1743,11 +2253,48 @@ export function createProgram() {
1743
2253
  .action(async (toolId, options) => {
1744
2254
  await handleAsyncAction("tools get", options, async () => requestOxygen(`/api/cli/tools/${encodeURIComponent(toolId)}`));
1745
2255
  }))
2256
+ .addCommand(new Command("enums")
2257
+ .description("Provider enum catalogs for fields that accept normalized values.")
2258
+ .addCommand(new Command("list")
2259
+ .description("List enum catalogs for a provider (or all providers).")
2260
+ .option("--provider <provider>", "Filter to a single provider, e.g. blitzapi.")
2261
+ .option("--json", "Print a JSON envelope.")
2262
+ .action(async (options) => {
2263
+ await handleAsyncAction("tools enums list", options, () => {
2264
+ const params = new URLSearchParams();
2265
+ const provider = readOption(options.provider);
2266
+ if (provider)
2267
+ params.set("provider", provider);
2268
+ const suffix = params.toString();
2269
+ return requestOxygen(`/api/cli/tools/enums${suffix ? `?${suffix}` : ""}`);
2270
+ });
2271
+ }))
2272
+ .addCommand(new Command("get")
2273
+ .description("Fetch the accepted values for one provider enum catalog. Use this before drafting payloads with enumRef fields.")
2274
+ .argument("<provider>", "Provider name, e.g. blitzapi.")
2275
+ .argument("<enum_ref>", "Enum reference id, e.g. industry, employee_range, country_code.")
2276
+ .option("-q, --query <query>", "Case-insensitive substring or token search across the catalog values.")
2277
+ .option("--limit <limit>", "Maximum number of values to return. Defaults to 100. Maximum 1000.")
2278
+ .option("--json", "Print a JSON envelope.")
2279
+ .action(async (provider, enumRef, options) => {
2280
+ await handleAsyncAction("tools enums get", options, () => {
2281
+ const params = new URLSearchParams();
2282
+ const query = readOption(options.query);
2283
+ const limit = readOption(options.limit);
2284
+ if (query)
2285
+ params.set("q", query);
2286
+ if (limit)
2287
+ params.set("limit", limit);
2288
+ const suffix = params.toString();
2289
+ return requestOxygen(`/api/cli/tools/enums/${encodeURIComponent(provider)}/${encodeURIComponent(enumRef)}${suffix ? `?${suffix}` : ""}`);
2290
+ });
2291
+ })))
1746
2292
  .addCommand(new Command("run")
1747
2293
  .description("Run an executable Oxygen tool through the HTTPS API.")
1748
2294
  .argument("<tool_id>", "Tool id.")
1749
2295
  .requiredOption("--input-json <json>", "Tool input as a JSON object.")
1750
2296
  .option("--mode <mode>", "Execution mode: live, dry-run, or dry_run. Mutating tools default to dry-run.")
2297
+ .option("--credential-mode <mode>", "Credential mode: managed, user_api_key, or user_oauth.")
1751
2298
  .option("--org <organization_id>", "Optional organization id. Must match the stored CLI token.")
1752
2299
  .option("--org-id <organization_id>", "Optional organization id. Must match the stored CLI token.")
1753
2300
  .option("--connection-id <connection_id>", "Specific integration connection id. Defaults to the org's active default connection.")
@@ -1757,12 +2304,13 @@ export function createProgram() {
1757
2304
  .option("--oxygen-cursor <cursor>", "Short Oxygen cursor returned as oxygen_next_cursor by a previous tool run.")
1758
2305
  .option("--json", "Print a JSON envelope.")
1759
2306
  .action(async (toolId, options) => {
1760
- await handleAsyncAction("tools run", options, async () => requestOxygen("/api/cli/tools/run", {
2307
+ await handleAsyncAction("tools run", options, () => requestOxygen("/api/cli/tools/run", {
1761
2308
  method: "POST",
1762
2309
  body: {
1763
2310
  tool_id: toolId,
1764
2311
  input: parseJsonObject(options.inputJson),
1765
2312
  ...(readOption(options.mode) ? { mode: readOption(options.mode) } : {}),
2313
+ ...(readOption(options.credentialMode) ? { credential_mode: readOption(options.credentialMode) } : {}),
1766
2314
  ...(readOption(options.org) ? { org_id: readOption(options.org) } : {}),
1767
2315
  ...(readOption(options.orgId) ? { org_id: readOption(options.orgId) } : {}),
1768
2316
  ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
@@ -1791,7 +2339,7 @@ export function createProgram() {
1791
2339
  .option("--capability <capability>", "Capability to enrich: mobile_phone or work_email. Defaults to mobile_phone.")
1792
2340
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
1793
2341
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
1794
- .option("--provider-order <providers>", "Comma-separated provider order. Work email defaults to blitzapi,prospeo,leadmagic,hunter,contactout,bettercontact; phone defaults to blitzapi,prospeo,leadmagic,contactout,bettercontact.")
2342
+ .option("--provider-order <providers>", "Comma-separated provider order. Work email defaults to blitzapi,prospeo,leadmagic,hunter,contactout,bettercontact,ai_ark; phone defaults to blitzapi,prospeo,leadmagic,contactout,bettercontact,ai_ark.")
1795
2343
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone before returning them.")
1796
2344
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
1797
2345
  .option("--limit <n>", "Rows to estimate. Defaults to 10.")
@@ -1822,7 +2370,7 @@ export function createProgram() {
1822
2370
  .option("--capability <capability>", "Capability to enrich: mobile_phone or work_email. Defaults to mobile_phone.")
1823
2371
  .option("--target-column <column>", "Target enrichment column key. Defaults to the capability payload column.")
1824
2372
  .option("--on-existing-manual-column <mode>", "How to handle an existing manual target: error, write_if_empty, or create_enrichment_column.")
1825
- .option("--provider-order <providers>", "Comma-separated provider order. Work email defaults to blitzapi,prospeo,leadmagic,hunter,contactout,bettercontact; phone defaults to blitzapi,prospeo,leadmagic,contactout,bettercontact.")
2373
+ .option("--provider-order <providers>", "Comma-separated provider order. Work email defaults to blitzapi,prospeo,leadmagic,hunter,contactout,bettercontact,ai_ark; phone defaults to blitzapi,prospeo,leadmagic,contactout,bettercontact,ai_ark.")
1826
2374
  .option("--verify-phone", "Validate found phone numbers with ClearoutPhone before returning them.")
1827
2375
  .option("--phone-verification-credential-mode <mode>", "ClearoutPhone credential mode for phone verification: managed or user_api_key.")
1828
2376
  .option("--limit <n>", "Rows to queue.")
@@ -1831,27 +2379,49 @@ export function createProgram() {
1831
2379
  .option("--selection-json <json>", "Full row selection object for server-side row selection.")
1832
2380
  .option("--only-missing", "Queue rows missing the capability's normalized output when no explicit selection is passed.")
1833
2381
  .option("--force", "Re-run rows with an existing target enrichment value.")
2382
+ .option("--max-concurrency <n>", "Maximum concurrent row items for this enrichment run. Defaults to 20.")
1834
2383
  .option("--json", "Print a JSON envelope.")
1835
2384
  .action(async (table, options) => {
2385
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
1836
2386
  await handleAsyncAction("enrich-column run", options, async () => requestOxygen("/api/cli/enrich-column/run", {
1837
2387
  method: "POST",
1838
2388
  body: {
1839
2389
  ...buildEnrichColumnBody(table, options),
1840
2390
  max_credits: readPositiveNumber(options.maxCredits),
2391
+ ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
1841
2392
  ...(options.force ? { force: true } : {}),
1842
2393
  },
1843
2394
  }));
1844
2395
  }));
1845
2396
  program
1846
- .command("integrations")
1847
- .description("Integration connection and event trigger commands.")
1848
- .addCommand(new Command("events")
1849
- .description("Configure provider events that can trigger workflows.")
1850
- .addCommand(new Command("list")
1851
- .description("List supported provider events and this org's enabled subscriptions.")
1852
- .option("--source <source>", "Filter by event source, such as hubspot or composio.gmail.")
1853
- .option("--event <event>", "Filter by event type, such as contact.created.")
1854
- .option("--toolkit <id>", "Filter by toolkit / integration id, such as gmail.")
2397
+ .command("enrichment")
2398
+ .description("High-level enrichment column definition helpers.")
2399
+ .addCommand(new Command("apply-default-cascade")
2400
+ .description("Patch an existing enrichment column to the server-side default provider cascade for its intent.")
2401
+ .argument("<table>", "Table id or slug.")
2402
+ .argument("<column>", "Enrichment column id or key.")
2403
+ .option("--dry-run", "Return the would-be merged definition without writing.")
2404
+ .option("--json", "Print a JSON envelope.")
2405
+ .action(async (table, column, options) => {
2406
+ await handleAsyncAction("enrichment apply-default-cascade", options, () => requestOxygen("/api/cli/enrichment/apply-default-cascade", {
2407
+ method: "POST",
2408
+ body: {
2409
+ table,
2410
+ column,
2411
+ ...(options.dryRun ? { dry_run: true } : {}),
2412
+ },
2413
+ }));
2414
+ }));
2415
+ program
2416
+ .command("integrations")
2417
+ .description("Integration connection and event trigger commands.")
2418
+ .addCommand(new Command("events")
2419
+ .description("Configure provider events that can trigger workflows.")
2420
+ .addCommand(new Command("list")
2421
+ .description("List supported provider events and this org's enabled subscriptions.")
2422
+ .option("--source <source>", "Filter by event source, such as hubspot or composio.gmail.")
2423
+ .option("--event <event>", "Filter by event type, such as contact.created.")
2424
+ .option("--toolkit <id>", "Filter by toolkit / integration id, such as gmail.")
1855
2425
  .option("--json", "Print a JSON envelope.")
1856
2426
  .action(async (options) => {
1857
2427
  await handleAsyncAction("integrations events list", options, async () => {
@@ -1917,6 +2487,33 @@ export function createProgram() {
1917
2487
  ...(readOption(options.connectionId) ? { connection_id: readOption(options.connectionId) } : {}),
1918
2488
  },
1919
2489
  }));
2490
+ }))
2491
+ .addCommand(new Command("deliveries")
2492
+ .description("List recent provider webhook deliveries (Composio triggers, HubSpot webhooks, etc.) and the workflow runs they kicked off.")
2493
+ .option("--source <source>", "Filter by event source, such as hubspot or composio.gmail.")
2494
+ .option("--event <event>", "Filter by event type, such as contact.created or GMAIL_NEW_GMAIL_MESSAGE.")
2495
+ .option("--toolkit <id>", "Filter by integration/toolkit id, such as gmail or hubspot.")
2496
+ .option("--connection-id <connection_id>", "Filter by a specific integration connection id.")
2497
+ .option("--result <result>", "Filter by delivery result: success or error.")
2498
+ .option("--limit <n>", "Maximum deliveries to return (1-200). Defaults to 50.")
2499
+ .option("--json", "Print a JSON envelope.")
2500
+ .action(async (options) => {
2501
+ await handleAsyncAction("integrations events deliveries", options, () => {
2502
+ const query = new URLSearchParams();
2503
+ const set = (key, value) => {
2504
+ const trimmed = readOption(value);
2505
+ if (trimmed)
2506
+ query.set(key, trimmed);
2507
+ };
2508
+ set("source", options.source);
2509
+ set("event", options.event);
2510
+ set("toolkit", options.toolkit);
2511
+ set("connection_id", options.connectionId);
2512
+ set("result", options.result);
2513
+ set("limit", options.limit);
2514
+ const suffix = query.toString() ? `?${query.toString()}` : "";
2515
+ return requestOxygen(`/api/cli/integrations/events/deliveries${suffix}`);
2516
+ });
1920
2517
  })))
1921
2518
  .addCommand(new Command("list")
1922
2519
  .description("List supported Composio integrations and this org's connections.")
@@ -1944,11 +2541,16 @@ export function createProgram() {
1944
2541
  .addCommand(new Command("disconnect")
1945
2542
  .description("Disconnect a Composio integration.")
1946
2543
  .argument("<integration_id>", "Integration id, such as 'slack'.")
2544
+ .option("--connection-id <id>", "Disconnect a specific connected account when multiple exist.")
1947
2545
  .option("--json", "Print a JSON envelope.")
1948
2546
  .action(async (integrationId, options) => {
1949
- await handleAsyncAction("integrations disconnect", options, async () => requestOxygen("/api/cli/integrations/composio/disconnect", {
2547
+ const connectionId = readOption(options.connectionId)?.trim();
2548
+ await handleAsyncAction("integrations disconnect", options, () => requestOxygen("/api/cli/integrations/composio/disconnect", {
1950
2549
  method: "POST",
1951
- body: { integration_id: integrationId },
2550
+ body: {
2551
+ integration_id: integrationId,
2552
+ ...(connectionId ? { connection_id: connectionId } : {}),
2553
+ },
1952
2554
  }));
1953
2555
  }))
1954
2556
  .addCommand(new Command("actions")
@@ -2217,6 +2819,7 @@ export function createProgram() {
2217
2819
  .addCommand(new Command("failures")
2218
2820
  .description("List failed workflow runs and trigger scheduler failures.")
2219
2821
  .option("--limit <n>", "Maximum failures per group. Defaults to 25; server cap is 100.")
2822
+ .option("--include-bundle", "Include durable recipe bundles in JSON output.")
2220
2823
  .option("--json", "Print a JSON envelope.")
2221
2824
  .action(async (options) => {
2222
2825
  await handleAsyncAction("workflows failures", options, async () => {
@@ -2224,8 +2827,10 @@ export function createProgram() {
2224
2827
  const limit = readPositiveInt(options.limit);
2225
2828
  if (limit)
2226
2829
  query.set("limit", String(limit));
2830
+ if (options.includeBundle)
2831
+ query.set("include_bundle", "true");
2227
2832
  const suffix = query.toString() ? `?${query.toString()}` : "";
2228
- return requestOxygen(`/api/cli/workflows/failures${suffix}`);
2833
+ return prepareWorkflowCliOutput(await requestOxygen(`/api/cli/workflows/failures${suffix}`), options);
2229
2834
  });
2230
2835
  }))
2231
2836
  .addCommand(new Command("run")
@@ -2281,14 +2886,28 @@ export function createProgram() {
2281
2886
  }));
2282
2887
  program
2283
2888
  .command("skills")
2284
- .description("Agent skill installation commands.")
2889
+ .description("Agent skill discovery and installation commands.")
2890
+ .addCommand(new Command("list")
2891
+ .description("List Oxygen agent skills available from the public skill index.")
2892
+ .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
2893
+ .option("--json", "Print a JSON envelope.")
2894
+ .action(async (options) => {
2895
+ await handleAsyncAction("skills list", options, async () => listAgentSkills(options));
2896
+ }))
2897
+ .addCommand(new Command("doctor")
2898
+ .description("Check Oxygen skill index reachability and local installer prerequisites.")
2899
+ .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
2900
+ .option("--json", "Print a JSON envelope.")
2901
+ .action(async (options) => {
2902
+ await handleAsyncAction("skills doctor", options, async () => doctorAgentSkills(options));
2903
+ }))
2285
2904
  .addCommand(new Command("install")
2286
2905
  .description("Install Oxygen agent skills into local agent skill directories.")
2287
2906
  .option("--api-url <url>", "Oxygen app URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
2288
- .option("--agents <agents>", "Space or comma separated agents. Defaults to codex, claude-code, and cursor.")
2907
+ .option("--agents <agents...>", "Space or comma separated agents. Defaults to codex, claude-code, and cursor.")
2289
2908
  .option("--skill <skill>", "Skill name or '*'. Defaults to '*'.")
2290
2909
  .option("--project", "Install into the current project instead of global agent scope.")
2291
- .option("--copy", "Copy skill files instead of symlinking when supported by npx skills.")
2910
+ .option("--copy", "Copy skill files instead of symlinking when supported by npx skills. Default on Windows, where symlinks need Developer Mode or admin.")
2292
2911
  .option("--json", "Print a JSON envelope.")
2293
2912
  .action(async (options) => {
2294
2913
  await handleAsyncAction("skills install", options, async () => installAgentSkills(options));
@@ -2466,11 +3085,11 @@ async function importTranspiledWorkflowModule(absolutePath, source) {
2466
3085
  esModuleInterop: true,
2467
3086
  },
2468
3087
  }).outputText;
2469
- const workflowsUrl = await import.meta.resolve("@oxygen/workflows");
3088
+ const dir = mkdtempSync(`${tmpdir()}/oxygen-workflow-`);
3089
+ const workflowsUrl = await resolveWorkflowRuntimeModuleUrl(dir);
2470
3090
  const rewritten = transpiled
2471
3091
  .replaceAll("from \"@oxygen/workflows\"", `from "${workflowsUrl}"`)
2472
3092
  .replaceAll("from '@oxygen/workflows'", `from "${workflowsUrl}"`);
2473
- const dir = mkdtempSync(`${tmpdir()}/oxygen-workflow-`);
2474
3093
  const compiledPath = `${dir}/workflow.mjs`;
2475
3094
  writeFileSync(compiledPath, rewritten, "utf8");
2476
3095
  try {
@@ -2480,6 +3099,84 @@ async function importTranspiledWorkflowModule(absolutePath, source) {
2480
3099
  rmSync(dir, { recursive: true, force: true });
2481
3100
  }
2482
3101
  }
3102
+ async function resolveWorkflowRuntimeModuleUrl(tempDir) {
3103
+ try {
3104
+ return await import.meta.resolve("@oxygen/workflows");
3105
+ }
3106
+ catch {
3107
+ const shimPath = `${tempDir}/oxygen-workflows-shim.mjs`;
3108
+ writeFileSync(shimPath, workflowRuntimeShimSource(), "utf8");
3109
+ return pathToFileURL(shimPath).href;
3110
+ }
3111
+ }
3112
+ function workflowRuntimeShimSource() {
3113
+ return `
3114
+ export function defineWorkflow(input) {
3115
+ return {
3116
+ __oxygen_workflow_definition: true,
3117
+ id: input.id,
3118
+ name: input.name,
3119
+ ...(input.status ? { status: input.status } : {}),
3120
+ ...(input.specification ? { specification: input.specification } : {}),
3121
+ ...(input.trigger ? { trigger: input.trigger } : {}),
3122
+ ...(input.inputSchema ? { inputSchema: input.inputSchema } : {}),
3123
+ steps: input.steps,
3124
+ };
3125
+ }
3126
+ export function apiTrigger(input = {}) {
3127
+ return { type: "api", ...(input.status ? { status: input.status } : {}) };
3128
+ }
3129
+ export function webhookTrigger(input) {
3130
+ return {
3131
+ type: "webhook",
3132
+ trigger_id: input.id,
3133
+ ...(input.name !== undefined ? { trigger_name: input.name } : {}),
3134
+ secret_required: input.secret ?? true,
3135
+ ...(input.idempotencyKeyPath !== undefined ? { idempotency_key_path: input.idempotencyKeyPath } : {}),
3136
+ ...(input.status ? { status: input.status } : {}),
3137
+ };
3138
+ }
3139
+ export function cronTrigger(input) {
3140
+ return {
3141
+ type: "cron",
3142
+ cron: input.cron,
3143
+ ...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
3144
+ ...(input.status ? { status: input.status } : {}),
3145
+ };
3146
+ }
3147
+ export function eventTrigger(input) {
3148
+ return {
3149
+ type: "event",
3150
+ source: input.source,
3151
+ event: input.event,
3152
+ ...(input.filters ? { filters: input.filters } : {}),
3153
+ ...(input.idempotencyKeyPath !== undefined ? { idempotency_key_path: input.idempotencyKeyPath } : {}),
3154
+ ...(input.status ? { status: input.status } : {}),
3155
+ };
3156
+ }
3157
+ export function transformStep(input) {
3158
+ return {
3159
+ __oxygen_workflow_step: true,
3160
+ kind: "transform",
3161
+ id: input.id,
3162
+ ...(input.description ? { description: input.description } : {}),
3163
+ run: input.run,
3164
+ };
3165
+ }
3166
+ export function toolStep(input) {
3167
+ return {
3168
+ __oxygen_workflow_step: true,
3169
+ kind: "tool",
3170
+ id: input.id,
3171
+ ...(input.description ? { description: input.description } : {}),
3172
+ tool: input.tool,
3173
+ ...(input.effect ? { effect: input.effect } : {}),
3174
+ ...(input.mode ? { mode: input.mode } : {}),
3175
+ payload: input.payload,
3176
+ };
3177
+ }
3178
+ `;
3179
+ }
2483
3180
  async function tailWorkflowRun(runId, options) {
2484
3181
  const timeoutSeconds = readPositiveInt(options.timeoutSeconds)
2485
3182
  ?? WORKFLOW_TAIL_DEFAULT_TIMEOUT_SECONDS;
@@ -2508,12 +3205,23 @@ async function tailWorkflowRun(runId, options) {
2508
3205
  }
2509
3206
  const remainingMs = deadline - Date.now();
2510
3207
  if (remainingMs <= 0) {
3208
+ const worker = isRecord(run.worker) ? run.worker : null;
3209
+ const queuedWithoutWorker = status === "queued"
3210
+ && worker !== null
3211
+ && worker.active === null
3212
+ && worker.lastClaim === null;
2511
3213
  throw new OxygenError("workflow_tail_timeout", "Timed out waiting for workflow run to finish.", {
2512
3214
  details: {
2513
3215
  workflow_run_id: runId,
2514
3216
  status: status ?? null,
2515
3217
  timeout_seconds: timeoutSeconds,
2516
3218
  polls,
3219
+ ...(queuedWithoutWorker
3220
+ ? {
3221
+ worker_status: "queued_no_worker",
3222
+ guidance: "No worker has claimed this workflow run. Check `oxygen worker queue-stats --json` for BullMQ and workflow queue health.",
3223
+ }
3224
+ : {}),
2517
3225
  },
2518
3226
  exitCode: 1,
2519
3227
  });
@@ -2613,6 +3321,72 @@ function readTableRunSelection(options) {
2613
3321
  exitCode: 1,
2614
3322
  });
2615
3323
  }
3324
+ function tableRunsListPath(options) {
3325
+ const table = readOption(options.table);
3326
+ if (!table) {
3327
+ throw new OxygenError("invalid_table_run", "Pass --table <table>.", {
3328
+ exitCode: 1,
3329
+ });
3330
+ }
3331
+ const query = new URLSearchParams({ table });
3332
+ const status = readOption(options.status);
3333
+ const limit = readPositiveInt(options.limit);
3334
+ if (status)
3335
+ query.set("status", status);
3336
+ if (limit)
3337
+ query.set("limit", String(limit));
3338
+ if (options.traceId)
3339
+ query.set("trace_id", options.traceId);
3340
+ return `/api/cli/table-action-runs?${query.toString()}`;
3341
+ }
3342
+ async function requestColumnsRun(body, table, options) {
3343
+ const traceId = randomUUID();
3344
+ try {
3345
+ return await requestOxygen("/api/cli/tables/columns/run", {
3346
+ method: "POST",
3347
+ body,
3348
+ traceId,
3349
+ });
3350
+ }
3351
+ catch (error) {
3352
+ if (!options.background || !isNetworkTimeoutError(error))
3353
+ throw error;
3354
+ const recovered = await recoverBackgroundColumnRun(table, traceId);
3355
+ if (!recovered)
3356
+ throw error;
3357
+ return recovered;
3358
+ }
3359
+ }
3360
+ async function recoverBackgroundColumnRun(table, traceId) {
3361
+ for (let attempt = 0; attempt < 3; attempt += 1) {
3362
+ const listed = await requestOxygen(tableRunsListPath({ table, status: "active", limit: "5", traceId }), { timeoutMs: 30_000 });
3363
+ const runs = Array.isArray(listed.runs)
3364
+ ? listed.runs.filter(isRecord)
3365
+ : [];
3366
+ const run = runs[0];
3367
+ if (run) {
3368
+ const actionRunId = readRecordString(run, "action_run_id") ?? readRecordString(run, "id");
3369
+ const workspaceRunId = readRecordString(run, "workspace_run_id") ?? readRecordString(run, "runId");
3370
+ return {
3371
+ ...run,
3372
+ ...(actionRunId ? { action_run_id: actionRunId } : {}),
3373
+ ...(workspaceRunId ? { workspace_run_id: workspaceRunId } : {}),
3374
+ timeout_recovered: true,
3375
+ recovery: {
3376
+ reason: "columns_run_background_timeout",
3377
+ trace_id: traceId,
3378
+ lookup_command: `oxygen table-runs list --table ${table} --status active --json`,
3379
+ },
3380
+ };
3381
+ }
3382
+ if (attempt < 2)
3383
+ await sleep(750);
3384
+ }
3385
+ return null;
3386
+ }
3387
+ function isNetworkTimeoutError(error) {
3388
+ return error instanceof OxygenError && error.code === "network_timeout";
3389
+ }
2616
3390
  function readCompaniesEnrichBody(table, options) {
2617
3391
  const body = { table };
2618
3392
  const fields = readCsvOption(options.missingFields);
@@ -2721,58 +3495,6 @@ function normalizeSessionStepStatus(value) {
2721
3495
  exitCode: 1,
2722
3496
  });
2723
3497
  }
2724
- function installAgentSkills(options) {
2725
- const apiUrl = normalizeApiUrl(readOption(options.apiUrl) ?? defaultApiUrl());
2726
- const indexUrl = `${apiUrl}/.well-known/skills/index.json`;
2727
- const agents = readWords(options.agents ?? "codex claude-code cursor");
2728
- const skill = readOption(options.skill) ?? "*";
2729
- const args = [
2730
- "skills",
2731
- "add",
2732
- indexUrl,
2733
- "--agents",
2734
- ...agents,
2735
- "--yes",
2736
- "--skill",
2737
- skill,
2738
- "--full-depth",
2739
- ];
2740
- if (!options.project)
2741
- args.push("--global");
2742
- if (options.copy)
2743
- args.push("--copy");
2744
- let output = "";
2745
- try {
2746
- output = execFileSync("npx", args, {
2747
- encoding: "utf8",
2748
- stdio: ["ignore", "pipe", "pipe"],
2749
- });
2750
- }
2751
- catch (error) {
2752
- const failure = error;
2753
- throw new OxygenError("skills_install_failed", "Unable to install Oxygen agent skills.", {
2754
- details: {
2755
- index_url: indexUrl,
2756
- stdout: failure.stdout?.slice(0, 2000) ?? "",
2757
- stderr: failure.stderr?.slice(0, 2000) ?? failure.message ?? "unknown",
2758
- },
2759
- exitCode: 1,
2760
- });
2761
- }
2762
- return {
2763
- indexUrl,
2764
- agents,
2765
- skill,
2766
- scope: options.project ? "project" : "global",
2767
- output,
2768
- };
2769
- }
2770
- function readWords(value) {
2771
- return value
2772
- .split(/[,\s]+/)
2773
- .map((entry) => entry.trim())
2774
- .filter(Boolean);
2775
- }
2776
3498
  async function importRows(table, options) {
2777
3499
  const format = normalizeRowsFormat(options.format, inferRowsFileFormat(options.file));
2778
3500
  const parsedRows = await readRowsFile(options.file, format, options.sheet);
@@ -2781,19 +3503,26 @@ async function importRows(table, options) {
2781
3503
  exitCode: 1,
2782
3504
  });
2783
3505
  }
2784
- const target = await prepareImportTarget(table, options, parsedRows);
2785
3506
  const batchSize = normalizeImportBatchSize(options.batchSize);
2786
3507
  const sourceHash = hashImportFile(options.file);
2787
3508
  const shouldUseBackground = options.background
2788
- || (!options.sync && target.rows.length > LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD);
3509
+ || (!options.sync && parsedRows.length > LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD);
2789
3510
  if (shouldUseBackground) {
2790
- return enqueueImportRows(target.tableRef, options, target.rows, format, batchSize, {
3511
+ // Prefer the object-storage fast path: upload the raw file once via a
3512
+ // presigned URL (no request-body limit) so the worker COPY-loads it.
3513
+ // Falls back to the inline multipart import when storage is unconfigured.
3514
+ const staged = await tryEnqueueStagedFileImport(table, options, format, parsedRows, {
3515
+ autoBackground: !options.background,
3516
+ sourceHash,
3517
+ });
3518
+ if (staged)
3519
+ return staged;
3520
+ return enqueueImportFile(table, options, format, batchSize, {
2791
3521
  autoBackground: !options.background,
2792
3522
  sourceHash,
2793
- createdTable: target.createdTable,
2794
- ...(target.upsertKey ? { upsertKey: target.upsertKey } : {}),
2795
3523
  });
2796
3524
  }
3525
+ const target = await prepareImportTarget(table, options, parsedRows);
2797
3526
  let rowCount = 0;
2798
3527
  let insertedCount = 0;
2799
3528
  let updatedCount = 0;
@@ -2859,6 +3588,7 @@ async function prepareImportTarget(table, options, parsedRows) {
2859
3588
  createdTable: null,
2860
3589
  tableWebUrl: null,
2861
3590
  upsertKey: options.upsertKey,
3591
+ sourceKeyMap: null,
2862
3592
  };
2863
3593
  }
2864
3594
  const normalized = normalizeRowsForNewTable(parsedRows);
@@ -2885,101 +3615,147 @@ async function prepareImportTarget(table, options, parsedRows) {
2885
3615
  createdTable: created,
2886
3616
  tableWebUrl: createdWebUrl ?? tableWebUrl(createdSlug),
2887
3617
  upsertKey: normalizeCreatedTableUpsertKey(options.upsertKey, normalized.keyBySource),
3618
+ sourceKeyMap: normalized.keyBySource,
2888
3619
  };
2889
3620
  }
2890
- async function enqueueImportRows(table, options, rows, format, batchSize, context) {
2891
- const batches = chunk(rows, batchSize);
2892
- const maxConcurrency = readPositiveInt(options.maxConcurrency);
2893
- const upsertKey = context.upsertKey ?? options.upsertKey;
2894
- const idempotencyKey = buildImportIdempotencyKey({
2895
- table,
2896
- sourceHash: context.sourceHash,
2897
- format,
2898
- mode: upsertKey ? "upsert" : "insert",
2899
- upsertKey: upsertKey ?? null,
2900
- batchSize,
2901
- });
2902
- const created = await requestOxygen("/api/cli/table-ingestion-runs", {
3621
+ async function tryEnqueueStagedFileImport(// skipcq: JS-R1005
3622
+ table, options, format, parsedRows, context) {
3623
+ if (options.create && table) {
3624
+ throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
3625
+ exitCode: 1,
3626
+ });
3627
+ }
3628
+ if (!options.create && !table) {
3629
+ throw new OxygenError("invalid_import_target", "Pass a table argument or --create <name>.", {
3630
+ exitCode: 1,
3631
+ });
3632
+ }
3633
+ const fileBuffer = readFileSync(options.file);
3634
+ const filename = basename(options.file);
3635
+ // Probe for a presigned upload URL. If object storage isn't configured the
3636
+ // server returns object_storage_not_configured; signal the caller to fall
3637
+ // back to the inline multipart import by returning null.
3638
+ let presigned;
3639
+ try {
3640
+ presigned = await requestOxygen("/api/cli/tables/import-url", {
3641
+ method: "POST",
3642
+ body: { file_name: filename, byte_length: fileBuffer.byteLength },
3643
+ });
3644
+ }
3645
+ catch (error) {
3646
+ if (error instanceof OxygenError && error.code === "object_storage_not_configured") {
3647
+ return null;
3648
+ }
3649
+ throw error;
3650
+ }
3651
+ const uploadUrl = readRecordString(presigned, "upload_url");
3652
+ const storageKey = readRecordString(presigned, "storage_key");
3653
+ const storageBucket = readRecordString(presigned, "storage_bucket");
3654
+ const storageProvider = readRecordString(presigned, "storage_provider") ?? "s3";
3655
+ if (!uploadUrl || !storageKey)
3656
+ return null;
3657
+ // Create the table for --create (or resolve the existing ref) before the
3658
+ // upload so a presign success always pairs with a real target.
3659
+ const target = await prepareImportTarget(table, options, parsedRows);
3660
+ const controller = new AbortController();
3661
+ const timer = setTimeout(() => controller.abort(), 300_000);
3662
+ let putResponse;
3663
+ try {
3664
+ putResponse = await fetch(uploadUrl, {
3665
+ method: "PUT",
3666
+ body: new Uint8Array(fileBuffer),
3667
+ signal: controller.signal,
3668
+ });
3669
+ }
3670
+ finally {
3671
+ clearTimeout(timer);
3672
+ }
3673
+ if (!putResponse.ok) {
3674
+ throw new OxygenError("import_upload_failed", `Uploading the import file to object storage failed (HTTP ${putResponse.status}).`, { details: { status: putResponse.status }, exitCode: 1 });
3675
+ }
3676
+ const result = await requestOxygen("/api/cli/tables/import-staged", {
2903
3677
  method: "POST",
3678
+ timeoutMs: 120_000,
2904
3679
  body: {
2905
- table,
2906
- source_type: `file.${format}`,
2907
- mode: upsertKey ? "upsert" : "insert",
2908
- ...(upsertKey ? { upsert_key: upsertKey } : {}),
2909
- expected_item_count: batches.length,
2910
- ...(maxConcurrency ? { max_concurrency: maxConcurrency } : {}),
2911
- metadata: {
2912
- command: "tables import",
2913
- file_name: basename(options.file),
2914
- format,
2915
- ...(options.sheet ? { sheet: options.sheet } : {}),
2916
- requested_row_count: rows.length,
2917
- batch_size: batchSize,
2918
- auto_background: context.autoBackground,
2919
- source_hash: context.sourceHash,
2920
- idempotency_key: idempotencyKey,
2921
- },
3680
+ table: target.tableRef,
3681
+ storage_provider: storageProvider,
3682
+ storage_bucket: storageBucket,
3683
+ storage_key: storageKey,
3684
+ format,
3685
+ file_name: filename,
3686
+ byte_length: fileBuffer.byteLength,
3687
+ sha256: context.sourceHash,
3688
+ row_count: parsedRows.length,
3689
+ ...(target.upsertKey ? { upsert_key: target.upsertKey } : {}),
3690
+ ...(target.sourceKeyMap ? { source_key_map: target.sourceKeyMap } : {}),
2922
3691
  },
2923
3692
  });
2924
- const ingestionRunId = readRequiredResponseString(created, "id");
2925
- if (isTerminalTableIngestionStatus(readRecordString(created, "status"))) {
2926
- return {
2927
- ingestionRun: created,
2928
- ingestionRunId,
2929
- ...(context.createdTable ? { createdTable: context.createdTable } : {}),
2930
- ...(readRecordString(created, "web_url") ? { web_url: readRecordString(created, "web_url") } : {}),
2931
- table_web_url: tableWebUrl(table),
2932
- background: true,
2933
- autoBackground: context.autoBackground,
2934
- sourceType: `file.${format}`,
2935
- rowCount: rows.length,
2936
- enqueuedItems: 0,
2937
- duplicatePositions: 0,
2938
- batchCount: batches.length,
2939
- batchSize,
2940
- mode: upsertKey ? "upsert" : "insert",
2941
- upsertKey: upsertKey ?? null,
2942
- idempotencyKey,
2943
- };
3693
+ emitQueueWaitStderrNote(readRecord(result, "queue_wait"));
3694
+ return {
3695
+ ...result,
3696
+ ...(target.createdTable ? { createdTable: target.createdTable } : {}),
3697
+ ...(target.tableWebUrl ? { table_web_url: target.tableWebUrl } : {}),
3698
+ background: true,
3699
+ autoBackground: context.autoBackground,
3700
+ import_engine: "bulk_file_v1",
3701
+ storage_provider: storageProvider,
3702
+ };
3703
+ }
3704
+ async function enqueueImportFile(table, options, format, batchSize, context) {
3705
+ if (options.create && table) {
3706
+ throw new OxygenError("invalid_import_target", "Pass either a table argument or --create, not both.", {
3707
+ exitCode: 1,
3708
+ });
2944
3709
  }
2945
- let insertedItems = 0;
2946
- let duplicatePositions = 0;
2947
- let latestRun = created;
2948
- for (const [index, batch] of batches.entries()) {
2949
- const appended = await requestOxygen(`/api/cli/table-ingestion-runs/${encodeURIComponent(ingestionRunId)}/items`, {
2950
- method: "POST",
2951
- body: {
2952
- items: [
2953
- {
2954
- position: index,
2955
- payload: { rows: batch },
2956
- },
2957
- ],
2958
- },
3710
+ if (!options.create && !table) {
3711
+ throw new OxygenError("invalid_import_target", "Pass a table argument or --create <name>.", {
3712
+ exitCode: 1,
2959
3713
  });
2960
- insertedItems += readCount(appended.inserted);
2961
- duplicatePositions += readCount(appended.duplicatePositions);
2962
- latestRun = appended.run ?? latestRun;
2963
3714
  }
3715
+ const fileBuffer = readFileSync(options.file);
3716
+ const filename = basename(options.file);
3717
+ const form = new FormData();
3718
+ const file = new File([fileBuffer], filename);
3719
+ form.append("file", file, filename);
3720
+ form.append("filename", filename);
3721
+ form.append("format", format);
3722
+ form.append("batch_size", String(batchSize));
3723
+ form.append("auto_background", String(context.autoBackground));
3724
+ form.append("source_hash", context.sourceHash);
3725
+ if (table)
3726
+ form.append("table", table);
3727
+ if (options.create)
3728
+ form.append("table_name", options.create);
3729
+ const project = readOption(options.project);
3730
+ if (project)
3731
+ form.append("project", project);
3732
+ const upsertKey = readOption(options.upsertKey);
3733
+ if (upsertKey)
3734
+ form.append("upsert_key", upsertKey);
3735
+ if (options.sheet)
3736
+ form.append("sheet", options.sheet);
3737
+ const maxConcurrency = readPositiveInt(options.maxConcurrency);
3738
+ if (maxConcurrency)
3739
+ form.append("max_concurrency", String(maxConcurrency));
3740
+ const result = await requestOxygen("/api/cli/tables/import-file", {
3741
+ method: "POST",
3742
+ timeoutMs: 300_000,
3743
+ formData: form,
3744
+ });
3745
+ emitQueueWaitStderrNote(readRecord(result, "queue_wait"));
2964
3746
  return {
2965
- ingestionRun: latestRun,
2966
- ingestionRunId,
2967
- ...(context.createdTable ? { createdTable: context.createdTable } : {}),
2968
- ...(readRecordString(latestRun, "web_url") ? { web_url: readRecordString(latestRun, "web_url") } : {}),
2969
- table_web_url: tableWebUrl(table),
3747
+ ...result,
2970
3748
  background: true,
2971
3749
  autoBackground: context.autoBackground,
2972
- sourceType: `file.${format}`,
2973
- rowCount: rows.length,
2974
- enqueuedItems: insertedItems,
2975
- duplicatePositions,
2976
- batchCount: batches.length,
2977
- batchSize,
2978
- mode: upsertKey ? "upsert" : "insert",
2979
- upsertKey: upsertKey ?? null,
2980
- idempotencyKey,
2981
3750
  };
2982
3751
  }
3752
+ function emitQueueWaitStderrNote(queueWait) {
3753
+ if (!queueWait)
3754
+ return;
3755
+ const note = readRecordString(queueWait, "note");
3756
+ if (note)
3757
+ process.stderr.write(`note: ${note}\n`);
3758
+ }
2983
3759
  async function waitForTableIngestionRun(runId, options) {
2984
3760
  const timeoutSeconds = readPositiveInt(options.timeoutSeconds)
2985
3761
  ?? TABLE_INGESTION_WAIT_DEFAULT_TIMEOUT_SECONDS;
@@ -3079,94 +3855,462 @@ async function exportRows(table, options) {
3079
3855
  ...(formatted.rescuedCount > 0 ? { rescuedNumericCells: formatted.rescuedCount } : {}),
3080
3856
  };
3081
3857
  }
3082
- async function readRowsFile(path, format, sheet) {
3083
- return await parseRowsFileBuffer(readFileSync(path), format, sheet ? { sheet } : {});
3084
- }
3085
- function normalizeCreatedTableUpsertKey(value, keyBySource) {
3086
- if (!value)
3087
- return undefined;
3088
- return keyBySource[value] ?? value;
3089
- }
3090
- function normalizeExportRowsFormat(value) {
3091
- const normalized = value?.trim().toLowerCase() || "json";
3092
- if (normalized === "json"
3093
- || normalized === "jsonl"
3094
- || normalized === "csv"
3095
- || normalized === "table")
3096
- return normalized;
3097
- throw new OxygenError("invalid_format", "Export format must be json, jsonl, csv, or table.", {
3098
- details: { format: value },
3099
- exitCode: 1,
3100
- });
3858
+ const TABLE_BUNDLE_SCHEMA_VERSION = 1;
3859
+ const TABLE_BUNDLE_MAX_PAGE_SIZE = 1000;
3860
+ const TABLE_BUNDLE_DEFAULT_PAGE_SIZE = 500;
3861
+ async function exportTableBundle(table, options) {
3862
+ const pageSize = Math.min(readPositiveInt(options.pageSize) ?? TABLE_BUNDLE_DEFAULT_PAGE_SIZE, TABLE_BUNDLE_MAX_PAGE_SIZE);
3863
+ // Pull the canonical schema (incl. enrichment/tool definitions and
3864
+ // semantic types) from `describe`; the paginated query response only
3865
+ // surfaces the user-visible projection.
3866
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table } });
3867
+ const tableMeta = describe.table ?? null;
3868
+ const columns = (describe.columns ?? []).map(toBundleColumn);
3869
+ const rows = [];
3870
+ let cursor = null;
3871
+ let expectedTotal = null;
3872
+ let pageCount = 0;
3873
+ let hasMoreFlag = false;
3874
+ do {
3875
+ const requestBody = { table, limit: pageSize };
3876
+ if (cursor)
3877
+ requestBody.cursor = cursor;
3878
+ const page = await requestOxygen("/api/cli/tables/query", { method: "POST", body: requestBody });
3879
+ pageCount += 1;
3880
+ if (expectedTotal === null) {
3881
+ expectedTotal = readBundleNumber(page.totalCount) ?? readBundleNumber(page.total_count) ?? null;
3882
+ }
3883
+ for (const row of page.rows ?? [])
3884
+ rows.push(row);
3885
+ cursor = typeof page.nextCursor === "string" && page.nextCursor.length > 0 ? page.nextCursor : null;
3886
+ hasMoreFlag = Boolean(page.hasMore);
3887
+ // Defensive: hasMore=false should always mean cursor=null. If a future
3888
+ // server change ever sets one without the other, stop iterating on
3889
+ // hasMore=false so we don't loop forever.
3890
+ if (!hasMoreFlag)
3891
+ cursor = null;
3892
+ } while (cursor);
3893
+ if (expectedTotal !== null && rows.length !== expectedTotal) {
3894
+ throw new OxygenError("bundle_export_incomplete", "Cursor pagination ended before every row was written. The bundle would be a silent under-export.", {
3895
+ details: {
3896
+ table,
3897
+ totalCount: expectedTotal,
3898
+ exportedCount: rows.length,
3899
+ missing: expectedTotal - rows.length,
3900
+ pages: pageCount,
3901
+ },
3902
+ exitCode: 1,
3903
+ });
3904
+ }
3905
+ const tableId = readRecordString(tableMeta, "id");
3906
+ const tableSlug = readRecordString(tableMeta, "slug");
3907
+ const projectSlug = readRecordString(tableMeta, "projectSlug");
3908
+ const bundle = {
3909
+ schemaVersion: TABLE_BUNDLE_SCHEMA_VERSION,
3910
+ exportedAt: new Date().toISOString(),
3911
+ table: {
3912
+ id: tableId ?? null,
3913
+ slug: tableSlug ?? null,
3914
+ name: readRecordString(tableMeta, "displayName") ?? readRecordString(tableMeta, "name") ?? null,
3915
+ projectSlug: projectSlug ?? null,
3916
+ },
3917
+ columns,
3918
+ rows,
3919
+ totals: {
3920
+ rowCount: rows.length,
3921
+ ...(expectedTotal !== null ? { sourceTotalCount: expectedTotal } : {}),
3922
+ pages: pageCount,
3923
+ pageSize,
3924
+ },
3925
+ };
3926
+ if (options.output) {
3927
+ writeFileSync(options.output, `${JSON.stringify(bundle, null, 2)}\n`);
3928
+ }
3929
+ const summary = {
3930
+ table_id: tableId ?? null,
3931
+ table_slug: tableSlug ?? null,
3932
+ schema_version: TABLE_BUNDLE_SCHEMA_VERSION,
3933
+ column_count: columns.length,
3934
+ row_count: rows.length,
3935
+ pages: pageCount,
3936
+ page_size: pageSize,
3937
+ output: options.output ?? null,
3938
+ ...(tableId ? { web_url: tableWebUrl(tableId) } : tableSlug ? { web_url: tableWebUrl(tableSlug) } : {}),
3939
+ };
3940
+ // When piping to stdout, also emit the full bundle so it can be redirected
3941
+ // into a file. With --output, the file is the source of truth; keep the
3942
+ // stdout response a small summary so it's readable.
3943
+ return options.output ? summary : { ...summary, bundle };
3101
3944
  }
3102
- function formatRows(rows, format, columns) {
3103
- if (format === "json") {
3104
- return { content: `${JSON.stringify(rows, null, 2)}\n`, rescuedCount: 0 };
3945
+ async function importTableBundle(// skipcq: JS-R1005
3946
+ options) {
3947
+ const path = options.file;
3948
+ if (!path || !path.trim()) {
3949
+ throw new OxygenError("invalid_input", "--file is required.", { exitCode: 1 });
3105
3950
  }
3106
- if (format === "jsonl") {
3107
- return {
3108
- content: `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`,
3109
- rescuedCount: 0,
3110
- };
3111
- } // skipcq: JS-0246
3112
- const keys = [
3113
- "_row_id",
3114
- "_created_at",
3115
- "_updated_at",
3116
- ...(columns?.map((column) => column.key) ?? []),
3117
- ...rows.flatMap((row) => Object.keys(row)),
3118
- ].filter((key, index, all) => all.indexOf(key) === index && rows.some((row) => key in row));
3119
- if (format === "csv") {
3120
- return {
3121
- content: [
3122
- keys.map(escapeCsvField).join(","),
3123
- ...rows.map((row) => keys.map((key) => escapeCsvField(row[key])).join(",")),
3124
- ].join("\n") + "\n",
3125
- rescuedCount: 0,
3126
- };
3951
+ const batchSize = normalizeImportBatchSize(options.batchSize);
3952
+ const raw = readFileSync(resolve(path), "utf8");
3953
+ const bundle = parseBundleFile(raw);
3954
+ const columns = bundle.columns.map(bundleColumnToCreateInput);
3955
+ if (columns.length === 0) {
3956
+ throw new OxygenError("invalid_bundle", "Bundle has no columns; nothing to import.", { exitCode: 1 });
3127
3957
  }
3128
- // "table": render with type-aware formatting and a Markdown-style frame.
3129
- const columnByKey = new Map(columns?.map((c) => [c.key, c]) ?? []);
3130
- let rescuedCount = 0;
3131
- const headers = keys.map((key) => columnByKey.get(key)?.label ?? key);
3132
- const formattedRows = rows.map((row) => keys.map((key) => {
3133
- const column = columnByKey.get(key) ?? null;
3134
- const formatted = formatCellForDisplay(row[key], column, {
3135
- surface: "cli",
3136
- onRescued: () => {
3137
- rescuedCount += 1;
3958
+ const validColumnKeys = new Set(columns.map((column) => column.key).filter((key) => Boolean(key)));
3959
+ const into = readOption(options.into);
3960
+ const upsertKey = readOption(options.key);
3961
+ // Resuming/appending into an existing table without an upsert key would
3962
+ // duplicate every already-imported row. Require --key so the resume matches
3963
+ // rows instead of re-inserting them.
3964
+ if (into && !upsertKey) {
3965
+ throw new OxygenError("invalid_input", "--into requires --key so rows already imported are matched instead of duplicated. Pass --key <uniqueColumn>.", { details: { into }, exitCode: 1 });
3966
+ }
3967
+ if (upsertKey) {
3968
+ if (upsertKey.startsWith("_")) {
3969
+ throw new OxygenError("invalid_input", "--key must be a workspace column key, not an internal field.", { details: { key: upsertKey }, exitCode: 1 });
3970
+ }
3971
+ if (validColumnKeys.size > 0 && !validColumnKeys.has(upsertKey)) {
3972
+ throw new OxygenError("invalid_input", `--key ${upsertKey} is not a column in this bundle.`, { details: { key: upsertKey, available_keys: [...validColumnKeys] }, exitCode: 1 });
3973
+ }
3974
+ }
3975
+ // Resolve the target table: resume into an existing one (--into), or create a
3976
+ // fresh one from the bundle schema.
3977
+ let newTableId;
3978
+ let newTableSlug;
3979
+ let createdTable;
3980
+ if (into) {
3981
+ const describe = await requestOxygen("/api/cli/tables/describe", { method: "POST", body: { table: into } });
3982
+ const tableMeta = describe.table ?? null;
3983
+ const resolvedId = tableMeta ? readRecordString(tableMeta, "id") : null;
3984
+ if (!resolvedId) {
3985
+ throw new OxygenError("table_not_found", `--into table ${into} was not found in this org.`, { details: { into }, exitCode: 1 });
3986
+ }
3987
+ newTableId = resolvedId;
3988
+ newTableSlug = tableMeta ? readRecordString(tableMeta, "slug") ?? null : null;
3989
+ createdTable = false;
3990
+ }
3991
+ else {
3992
+ const tableName = readOption(options.name) ?? bundle.tableName;
3993
+ if (!tableName) {
3994
+ throw new OxygenError("invalid_bundle", "Bundle has no table.name and no --name override was provided.", { exitCode: 1 });
3995
+ }
3996
+ const createResponse = await requestOxygen("/api/cli/tables", {
3997
+ method: "POST",
3998
+ body: {
3999
+ name: tableName,
4000
+ columns,
4001
+ ...(readOption(options.project) ? { project: readOption(options.project) } : {}),
3138
4002
  },
3139
4003
  });
3140
- // Pipe and any line-break char are the row/cell delimiters of the
3141
- // Markdown frame raw values containing them would corrupt the layout.
3142
- // Match on the line-break class (not just `\r?\n`) so a standalone `\r`
3143
- // doesn't slip through and split the row visually.
3144
- return formatted.replace(/[\r\n]+/g, " ").replace(/\|/g, "\\|");
3145
- }));
3146
- const widths = headers.map((header, columnIndex) => {
3147
- let max = header.length;
3148
- for (const row of formattedRows) {
3149
- const cell = row[columnIndex] ?? "";
3150
- if (cell.length > max)
3151
- max = cell.length;
4004
+ const created = readRecord(createResponse, "table");
4005
+ const createdId = readRecordString(createResponse, "table_id")
4006
+ ?? (created ? readRecordString(created, "id") : null);
4007
+ if (!createdId) {
4008
+ throw new OxygenError("invalid_response", "Table create response did not include an id; bundle import cannot proceed.", { exitCode: 1 });
3152
4009
  }
3153
- return Math.min(max, 60);
3154
- });
3155
- const renderRow = (cells) => "| " + cells.map((cell, i) => clipCell(cell, widths[i]).padEnd(widths[i])).join(" | ") + " |";
3156
- const separator = "|" + widths.map((w) => "-".repeat(w + 2)).join("|") + "|";
3157
- const lines = [renderRow(headers), separator, ...formattedRows.map(renderRow)];
3158
- if (rescuedCount > 0) {
4010
+ newTableId = createdId;
4011
+ newTableSlug = readRecordString(createResponse, "table_slug")
4012
+ ?? (created ? readRecordString(created, "slug") : null);
4013
+ createdTable = true;
4014
+ }
4015
+ const stagedRows = bundle.rows.map((row) => stripRowForImport(row, validColumnKeys));
4016
+ // In upsert mode every row must carry the key value; otherwise the upsert
4017
+ // route rejects the whole batch midway. Fail fast with a clear message before
4018
+ // writing anything so we never leave a half-imported table behind for a
4019
+ // predictable data problem.
4020
+ if (upsertKey) {
4021
+ const missingIndex = stagedRows.findIndex((row) => row[upsertKey] === undefined || row[upsertKey] === null);
4022
+ if (missingIndex >= 0) {
4023
+ throw new OxygenError("invalid_bundle", `Row ${missingIndex + 1} is missing a value for the upsert key "${upsertKey}".`, { details: { key: upsertKey, row_number: missingIndex + 1 }, exitCode: 1 });
4024
+ }
4025
+ }
4026
+ const resumeCommand = buildBundleResumeCommand({
4027
+ file: path,
4028
+ tableId: newTableId,
4029
+ key: upsertKey ?? null,
4030
+ batchSize,
4031
+ });
4032
+ let processed = 0;
4033
+ let insertedCount = 0;
4034
+ let updatedCount = 0;
4035
+ for (let offset = 0; offset < stagedRows.length; offset += batchSize) {
4036
+ const batch = stagedRows.slice(offset, offset + batchSize);
4037
+ if (batch.length === 0)
4038
+ continue;
4039
+ try {
4040
+ if (upsertKey) {
4041
+ const response = await requestOxygen("/api/cli/tables/rows/upsert", {
4042
+ method: "POST",
4043
+ body: { table: newTableId, key: upsertKey, rows: batch, return: "summary" },
4044
+ });
4045
+ insertedCount += readCount(response.insertedCount);
4046
+ updatedCount += readCount(response.updatedCount);
4047
+ }
4048
+ else {
4049
+ await requestOxygen("/api/cli/tables/rows", {
4050
+ method: "POST",
4051
+ body: { table: newTableId, rows: batch },
4052
+ });
4053
+ insertedCount += batch.length;
4054
+ }
4055
+ processed += batch.length;
4056
+ }
4057
+ catch (error) {
4058
+ // A mid-loop failure used to leave a partial table with no way forward.
4059
+ // Surface exactly where it stopped and how to resume so the import is
4060
+ // recoverable instead of a silent half-write.
4061
+ const cause = error instanceof OxygenError
4062
+ ? error.code
4063
+ : error instanceof Error
4064
+ ? error.message
4065
+ : "unknown";
4066
+ const recovery = upsertKey
4067
+ ? `Re-run with --into ${newTableId} --key ${upsertKey} to resume — already-imported rows are matched by "${upsertKey}", not duplicated.`
4068
+ : `Resume with: ${resumeCommand}`;
4069
+ throw new OxygenError("bundle_import_incomplete", `Bundle import stopped after ${processed}/${stagedRows.length} rows. ${recovery}`, {
4070
+ details: {
4071
+ table_id: newTableId,
4072
+ table_slug: newTableSlug,
4073
+ rows_total: stagedRows.length,
4074
+ rows_processed: processed,
4075
+ failed_batch_offset: offset,
4076
+ failed_batch_size: batch.length,
4077
+ mode: upsertKey ? "upsert" : "insert",
4078
+ ...(upsertKey ? { key: upsertKey } : {}),
4079
+ resume_command: resumeCommand,
4080
+ web_url: tableWebUrl(newTableSlug ?? newTableId),
4081
+ cause,
4082
+ },
4083
+ exitCode: 1,
4084
+ });
4085
+ }
4086
+ }
4087
+ return {
4088
+ table_id: newTableId,
4089
+ table_slug: newTableSlug ?? null,
4090
+ created_table: createdTable,
4091
+ column_count: columns.length,
4092
+ row_count: processed,
4093
+ mode: upsertKey ? "upsert" : "insert",
4094
+ ...(upsertKey
4095
+ ? { upsert_key: upsertKey, inserted_count: insertedCount, updated_count: updatedCount }
4096
+ : {}),
4097
+ source_table: bundle.tableSummary,
4098
+ schema_version: bundle.schemaVersion,
4099
+ web_url: tableWebUrl(newTableSlug ?? newTableId),
4100
+ };
4101
+ }
4102
+ // Builds the exact CLI command that resumes a failed `import-bundle` into the
4103
+ // table it already created. Always targets that table with --into + --key so the
4104
+ // resume is idempotent; falls back to a <uniqueColumn> placeholder when the
4105
+ // original run had no key (insert mode can't be safely auto-resumed).
4106
+ function buildBundleResumeCommand(input) {
4107
+ const fileArg = /\s/.test(input.file) ? `"${input.file}"` : input.file;
4108
+ const parts = [
4109
+ "oxygen tables import-bundle",
4110
+ `--file ${fileArg}`,
4111
+ `--into ${input.tableId}`,
4112
+ `--key ${input.key ?? "<uniqueColumn>"}`,
4113
+ ];
4114
+ // 500 is the default in normalizeImportBatchSize; only echo --batch-size when
4115
+ // the user picked something else, to keep the resume command minimal.
4116
+ if (input.batchSize !== 500) {
4117
+ parts.push(`--batch-size ${input.batchSize}`);
4118
+ }
4119
+ return parts.join(" ");
4120
+ }
4121
+ function toBundleColumn(column) {
4122
+ // Persist only the fields `tables create` knows how to ingest. Drop ids,
4123
+ // physical column names, archivedAt — those are tenant-local and would
4124
+ // collide on import.
4125
+ return {
4126
+ key: column.key,
4127
+ ...(column.label !== undefined ? { label: column.label } : {}),
4128
+ ...(column.dataType !== undefined ? { dataType: column.dataType } : {}),
4129
+ ...(column.kind !== undefined ? { kind: column.kind } : {}),
4130
+ ...(column.semanticType !== undefined ? { semanticType: column.semanticType } : {}),
4131
+ ...(column.definition !== undefined ? { definition: column.definition } : {}),
4132
+ ...(column.position !== undefined ? { position: column.position } : {}),
4133
+ };
4134
+ }
4135
+ function bundleColumnToCreateInput(column) {
4136
+ const label = typeof column.label === "string" && column.label.trim()
4137
+ ? column.label.trim()
4138
+ : typeof column.key === "string"
4139
+ ? column.key
4140
+ : null;
4141
+ if (!label) {
4142
+ throw new OxygenError("invalid_bundle", "Bundle column is missing label/key.", {
4143
+ details: { column },
4144
+ exitCode: 1,
4145
+ });
4146
+ }
4147
+ return {
4148
+ label,
4149
+ ...(typeof column.key === "string" ? { key: column.key } : {}),
4150
+ ...(typeof column.dataType === "string" ? { dataType: column.dataType } : {}),
4151
+ ...(typeof column.kind === "string" ? { kind: column.kind } : {}),
4152
+ ...(typeof column.semanticType === "string" ? { semanticType: column.semanticType } : {}),
4153
+ ...(column.definition && typeof column.definition === "object" && !Array.isArray(column.definition)
4154
+ ? { definition: column.definition }
4155
+ : {}),
4156
+ };
4157
+ }
4158
+ function parseBundleFile(text) {
4159
+ let parsed;
4160
+ try {
4161
+ parsed = JSON.parse(text);
4162
+ }
4163
+ catch (error) {
4164
+ throw new OxygenError("invalid_bundle", "Bundle file is not valid JSON.", {
4165
+ details: { reason: error instanceof Error ? error.message : "unknown" },
4166
+ exitCode: 1,
4167
+ });
4168
+ }
4169
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4170
+ throw new OxygenError("invalid_bundle", "Bundle file must be a JSON object.", { exitCode: 1 });
4171
+ }
4172
+ const record = parsed;
4173
+ const schemaVersion = typeof record.schemaVersion === "number" ? record.schemaVersion : 1;
4174
+ if (schemaVersion !== TABLE_BUNDLE_SCHEMA_VERSION) {
4175
+ throw new OxygenError("unsupported_bundle_version", `Bundle schema version ${schemaVersion} is not supported by this CLI.`, {
4176
+ details: { supported: TABLE_BUNDLE_SCHEMA_VERSION, got: schemaVersion },
4177
+ exitCode: 1,
4178
+ });
4179
+ }
4180
+ const rawColumns = record.columns;
4181
+ if (!Array.isArray(rawColumns)) {
4182
+ throw new OxygenError("invalid_bundle", "Bundle is missing a columns array.", { exitCode: 1 });
4183
+ }
4184
+ const rawRows = record.rows;
4185
+ if (!Array.isArray(rawRows)) {
4186
+ throw new OxygenError("invalid_bundle", "Bundle is missing a rows array.", { exitCode: 1 });
4187
+ }
4188
+ const tableSummary = record.table && typeof record.table === "object" && !Array.isArray(record.table)
4189
+ ? record.table
4190
+ : null;
4191
+ return {
4192
+ schemaVersion,
4193
+ tableName: tableSummary
4194
+ ? (readRecordString(tableSummary, "name") ?? readRecordString(tableSummary, "displayName"))
4195
+ : null,
4196
+ tableSummary,
4197
+ columns: rawColumns.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)),
4198
+ rows: rawRows.filter((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)),
4199
+ };
4200
+ }
4201
+ function stripRowForImport(row, validColumnKeys) {
4202
+ const out = {};
4203
+ for (const [key, value] of Object.entries(row)) {
4204
+ if (key === "_row_id" || key === "_created_at" || key === "_updated_at")
4205
+ continue;
4206
+ if (validColumnKeys.size > 0 && !validColumnKeys.has(key))
4207
+ continue;
4208
+ out[key] = value;
4209
+ }
4210
+ return out;
4211
+ }
4212
+ function readBundleNumber(value) {
4213
+ if (typeof value === "number" && Number.isFinite(value))
4214
+ return value;
4215
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value)))
4216
+ return Number(value);
4217
+ return null;
4218
+ }
4219
+ async function readRowsFile(path, format, sheet) {
4220
+ return await parseRowsFileBuffer(readFileSync(path), format, sheet ? { sheet } : {});
4221
+ }
4222
+ function normalizeCreatedTableUpsertKey(value, keyBySource) {
4223
+ if (!value)
4224
+ return undefined;
4225
+ return keyBySource[value] ?? value;
4226
+ }
4227
+ function normalizeExportRowsFormat(value) {
4228
+ const normalized = value?.trim().toLowerCase() || "json";
4229
+ if (normalized === "json"
4230
+ || normalized === "jsonl"
4231
+ || normalized === "csv"
4232
+ || normalized === "table")
4233
+ return normalized;
4234
+ throw new OxygenError("invalid_format", "Export format must be json, jsonl, csv, or table.", {
4235
+ details: { format: value },
4236
+ exitCode: 1,
4237
+ });
4238
+ }
4239
+ function formatRows(rows, format, columns) {
4240
+ if (format === "json") {
4241
+ return { content: `${JSON.stringify(rows, null, 2)}\n`, rescuedCount: 0 };
4242
+ }
4243
+ if (format === "jsonl") {
4244
+ return {
4245
+ content: `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`,
4246
+ rescuedCount: 0,
4247
+ };
4248
+ } // skipcq: JS-0246
4249
+ const keys = [
4250
+ "_row_id",
4251
+ "_created_at",
4252
+ "_updated_at",
4253
+ ...(columns?.map((column) => column.key) ?? []),
4254
+ ...rows.flatMap((row) => Object.keys(row)),
4255
+ ].filter((key, index, all) => all.indexOf(key) === index && rows.some((row) => key in row));
4256
+ if (format === "csv") {
4257
+ const csvBody = [
4258
+ keys.map(escapeCsvField).join(","),
4259
+ ...rows.map((row) => keys.map((key) => escapeCsvField(row[key])).join(",")),
4260
+ ].join("\n");
4261
+ return {
4262
+ content: `${csvBody}\n`,
4263
+ rescuedCount: 0,
4264
+ };
4265
+ }
4266
+ // "table": render with type-aware formatting and a Markdown-style frame.
4267
+ const columnByKey = new Map(columns?.map((column) => [column.key, column]) ?? []);
4268
+ let rescuedCount = 0;
4269
+ const headers = keys.map((key) => columnByKey.get(key)?.label ?? key);
4270
+ const formattedRows = rows.map((row) => keys.map((key) => {
4271
+ const column = columnByKey.get(key) ?? null;
4272
+ const formatted = formatCellForDisplay(row[key], column, {
4273
+ surface: "cli",
4274
+ onRescued: () => {
4275
+ rescuedCount += 1;
4276
+ },
4277
+ });
4278
+ // Pipe and any line-break char are the row/cell delimiters of the
4279
+ // Markdown frame — raw values containing them would corrupt the layout.
4280
+ // Match on the line-break class (not just `\r?\n`) so a standalone `\r`
4281
+ // doesn't slip through and split the row visually.
4282
+ return formatted.replace(/[\r\n]+/g, " ↵ ").replace(/\|/g, "\\|");
4283
+ }));
4284
+ const widths = headers.map((header, columnIndex) => {
4285
+ let max = header.length;
4286
+ for (const row of formattedRows) {
4287
+ const cell = row[columnIndex] ?? "";
4288
+ if (cell.length > max)
4289
+ max = cell.length;
4290
+ }
4291
+ return Math.min(max, 60);
4292
+ });
4293
+ const renderRow = (cells) => {
4294
+ const padded = cells.map((cell, i) => {
4295
+ const width = widths[i] ?? 0;
4296
+ return clipCell(cell, width).padEnd(width);
4297
+ });
4298
+ return `| ${padded.join(" | ")} |`;
4299
+ };
4300
+ const separator = `|${widths.map((width) => "-".repeat(width + 2)).join("|")}|`;
4301
+ const lines = [renderRow(headers), separator, ...formattedRows.map(renderRow)];
4302
+ if (rescuedCount > 0) {
3159
4303
  lines.push("");
3160
4304
  lines.push(`# Note: reformatted ${rescuedCount} cell${rescuedCount === 1 ? "" : "s"} that look numeric in text columns. Run \`oxygen columns retype --to numeric\` to make this permanent.`);
3161
4305
  }
3162
- return { content: lines.join("\n") + "\n", rescuedCount };
4306
+ return { content: `${lines.join("\n")}\n`, rescuedCount };
3163
4307
  }
3164
4308
  function clipCell(value, width) {
3165
4309
  if (value.length <= width)
3166
4310
  return value;
3167
4311
  if (width <= 1)
3168
4312
  return value.slice(0, width);
3169
- return value.slice(0, width - 1) + "…";
4313
+ return `${value.slice(0, width - 1)}…`;
3170
4314
  }
3171
4315
  function escapeCsvField(value) {
3172
4316
  const text = value === null || value === undefined
@@ -3204,15 +4348,6 @@ function isRecord(value) {
3204
4348
  function tableWebUrl(tableIdOrSlug) {
3205
4349
  return `https://oxygen-agent.com/tables/${encodeURIComponent(tableIdOrSlug)}`;
3206
4350
  }
3207
- function readRequiredResponseString(value, key) {
3208
- const entry = value[key];
3209
- if (typeof entry === "string" && entry.trim())
3210
- return entry.trim();
3211
- throw new OxygenError("invalid_response", `Oxygen API response is missing ${key}.`, {
3212
- details: { key },
3213
- exitCode: 1,
3214
- });
3215
- }
3216
4351
  function normalizeImportBatchSize(value) {
3217
4352
  const batchSize = readPositiveInt(value) ?? 500;
3218
4353
  if (batchSize > 5000) {
@@ -3226,17 +4361,6 @@ function normalizeImportBatchSize(value) {
3226
4361
  function hashImportFile(path) {
3227
4362
  return createHash("sha256").update(readFileSync(path)).digest("hex");
3228
4363
  }
3229
- function buildImportIdempotencyKey(input) {
3230
- return [
3231
- "tables-import",
3232
- input.table,
3233
- input.format,
3234
- input.mode,
3235
- input.upsertKey ?? "none",
3236
- String(input.batchSize),
3237
- input.sourceHash,
3238
- ].join(":");
3239
- }
3240
4364
  function isTerminalTableIngestionStatus(status) {
3241
4365
  return status === "completed"
3242
4366
  || status === "completed_with_errors"
@@ -3252,6 +4376,143 @@ function isTerminalTableActionRunStatus(status) {
3252
4376
  function sleep(ms) {
3253
4377
  return new Promise((resolve) => setTimeout(resolve, ms));
3254
4378
  }
4379
+ async function resolveActiveProfileWithSource() {
4380
+ const resolution = await resolveActiveProfile();
4381
+ let source;
4382
+ if (globalProfileFlag)
4383
+ source = "flag";
4384
+ else if (INITIAL_OXYGEN_PROFILE_ENV)
4385
+ source = "env";
4386
+ else
4387
+ source = resolution.source === "env" ? "env" : resolution.source;
4388
+ return { resolution, source };
4389
+ }
4390
+ async function handleWhoamiAction(options) {
4391
+ try {
4392
+ const identity = await requestOxygen("/api/cli/whoami");
4393
+ const context = await resolveActiveProfileWithSource();
4394
+ if (context.resolution.exists) {
4395
+ const existingCredentials = context.resolution.credentials;
4396
+ const cached = existingCredentials?.identity;
4397
+ const cachedOrg = existingCredentials?.activeOrganization ?? cached?.organization ?? null;
4398
+ const orgChanged = !cachedOrg || cachedOrg.id !== identity.organization.id;
4399
+ const userChanged = !cached || cached.user.id !== identity.user.id;
4400
+ if (existingCredentials && (orgChanged || userChanged)) {
4401
+ const refreshed = {
4402
+ token: existingCredentials.token,
4403
+ apiUrl: existingCredentials.apiUrl,
4404
+ activeOrganization: storedOrganizationFromOption(identity.organization),
4405
+ identity: identityFromWhoami(identity),
4406
+ };
4407
+ if (existingCredentials.authKind)
4408
+ refreshed.authKind = existingCredentials.authKind;
4409
+ await saveCredentials(refreshed, process.env, {
4410
+ profile: context.resolution.name,
4411
+ activate: false,
4412
+ }).catch(() => undefined);
4413
+ }
4414
+ }
4415
+ const data = {
4416
+ ...identity,
4417
+ profile: context.resolution.exists ? context.resolution.name : null,
4418
+ profile_source: context.source,
4419
+ };
4420
+ if (options.json) {
4421
+ writeJson(success("whoami", data));
4422
+ return;
4423
+ }
4424
+ process.stdout.write(formatWhoami(identity, context));
4425
+ }
4426
+ catch (error) {
4427
+ const failure = toFailure("whoami", error);
4428
+ writeJson(failure);
4429
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
4430
+ }
4431
+ }
4432
+ async function handleOnboardingStartAction(options) {
4433
+ try {
4434
+ const data = await requestOxygen("/api/cli/onboarding/start", { method: "POST" });
4435
+ if (options.json) {
4436
+ writeJson(success("onboarding start", data));
4437
+ return;
4438
+ }
4439
+ process.stdout.write(formatOnboardingStart(data));
4440
+ }
4441
+ catch (error) {
4442
+ const failure = toFailure("onboarding start", error);
4443
+ writeJson(failure);
4444
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
4445
+ }
4446
+ }
4447
+ async function handleOnboardingResetAction(options) {
4448
+ if (!options.confirm) {
4449
+ const failure = toFailure("onboarding reset", new OxygenError("confirmation_required", "Refusing to reset onboarding without --confirm. Re-run with `oxygen onboarding reset --confirm`.", { exitCode: 1 }));
4450
+ writeJson(failure);
4451
+ process.exitCode = 1;
4452
+ return;
4453
+ }
4454
+ try {
4455
+ const data = await requestOxygen("/api/cli/onboarding/reset", {
4456
+ method: "POST",
4457
+ body: { confirm: true },
4458
+ });
4459
+ if (options.json) {
4460
+ writeJson(success("onboarding reset", data));
4461
+ return;
4462
+ }
4463
+ process.stdout.write(formatOnboardingReset(data));
4464
+ }
4465
+ catch (error) {
4466
+ const failure = toFailure("onboarding reset", error);
4467
+ writeJson(failure);
4468
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
4469
+ }
4470
+ }
4471
+ function formatOnboardingReset(data) {
4472
+ const lines = [];
4473
+ lines.push(data.message);
4474
+ if (data.cleared && data.cleared_keys.length > 0) {
4475
+ lines.push(`Cleared keys: ${data.cleared_keys.join(", ")}`);
4476
+ }
4477
+ if (data.next_steps.length > 0) {
4478
+ lines.push("");
4479
+ lines.push("Next:");
4480
+ for (const step of data.next_steps)
4481
+ lines.push(` - ${step}`);
4482
+ }
4483
+ if (data.web_url) {
4484
+ lines.push("");
4485
+ lines.push(`Continue in the web app: ${data.web_url}`);
4486
+ }
4487
+ return `${lines.join("\n")}\n`;
4488
+ }
4489
+ function formatOnboardingStart(data) {
4490
+ const lines = [];
4491
+ lines.push(`Onboarding ${data.context.already_started ? "already started" : "started"} for ${data.organization.name} (${data.organization.slug ?? data.organization.id}).`);
4492
+ if (data.user.domain) {
4493
+ lines.push(`Inferred work domain: ${data.user.domain}`);
4494
+ }
4495
+ else if (data.user.email) {
4496
+ lines.push(`No work domain inferred from ${data.user.email}; the skill will ask you for one.`);
4497
+ }
4498
+ lines.push("");
4499
+ lines.push(`Filled context sections: ${data.context.filled_sections.length > 0 ? data.context.filled_sections.join(", ") : "(none)"}`);
4500
+ lines.push(`Empty context sections: ${data.context.empty_sections.length > 0 ? data.context.empty_sections.join(", ") : "(none)"}`);
4501
+ lines.push("");
4502
+ lines.push(`Next: load and follow the \`${data.skill_to_load}\` skill.`);
4503
+ lines.push(` Skill URL: ${data.skill_url}`);
4504
+ if (data.next_steps.length > 0) {
4505
+ lines.push("");
4506
+ lines.push("Steps:");
4507
+ for (const step of data.next_steps)
4508
+ lines.push(` - ${step}`);
4509
+ }
4510
+ if (data.web_url) {
4511
+ lines.push("");
4512
+ lines.push(`View context in the web app: ${data.web_url}`);
4513
+ }
4514
+ return `${lines.join("\n")}\n`;
4515
+ }
3255
4516
  async function handleLoginAction(options) {
3256
4517
  try {
3257
4518
  const data = await login(options);
@@ -3267,18 +4528,24 @@ async function handleLoginAction(options) {
3267
4528
  }
3268
4529
  async function handleAuthUseTokenAction(options) {
3269
4530
  try {
3270
- const data = await useAuthToken(options);
4531
+ const data = await applyAuthToken(options);
3271
4532
  if (options.json) {
3272
4533
  writeJson(success("auth use-token", {
3273
4534
  logged_in: true,
3274
4535
  profile: data.profile,
3275
4536
  api_url: data.credentials.apiUrl,
3276
- user: data.identity.user,
3277
- organization: data.identity.organization,
4537
+ user: data.loginIdentity.user,
4538
+ organization: data.loginIdentity.activeOrganization,
4539
+ organizations: data.loginIdentity.organizations,
4540
+ selection_required: data.loginIdentity.selectionRequired,
4541
+ renamed: data.renamed,
3278
4542
  }));
3279
4543
  return;
3280
4544
  }
3281
- process.stdout.write(formatLoginSuccess(data.identity, data.credentials, data.profile));
4545
+ process.stdout.write(formatLoginSuccessForResolved(data.loginIdentity, data.profile, { renamed: data.renamed }));
4546
+ const hint = await buildPostLoginHint(data.profile);
4547
+ if (hint)
4548
+ process.stdout.write(hint);
3282
4549
  }
3283
4550
  catch (error) {
3284
4551
  const failure = toFailure("auth use-token", error);
@@ -3289,6 +4556,38 @@ async function handleAuthUseTokenAction(options) {
3289
4556
  process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
3290
4557
  }
3291
4558
  }
4559
+ async function handleOrgUseAction(organization, options, command) {
4560
+ try {
4561
+ const data = await requestOxygen("/api/cli/orgs/select", {
4562
+ method: "POST",
4563
+ body: { organization },
4564
+ });
4565
+ const context = await resolveActiveProfileWithSource();
4566
+ const credentials = context.resolution.credentials;
4567
+ const canPersistSelection = Boolean(context.resolution.exists &&
4568
+ credentials &&
4569
+ (credentials.authKind === "user_session" || credentials.token.startsWith("oxy_user_")) &&
4570
+ data.currentOrganization);
4571
+ if (canPersistSelection && data.currentOrganization) {
4572
+ await updateActiveOrganizationForProfile(context.resolution.name, storedOrganizationFromOption(data.currentOrganization));
4573
+ }
4574
+ const result = {
4575
+ ...data,
4576
+ profile: context.resolution.exists ? context.resolution.name : null,
4577
+ selection_persisted: canPersistSelection,
4578
+ };
4579
+ if (options.json) {
4580
+ writeJson(success(command, result));
4581
+ return;
4582
+ }
4583
+ writeJson(result);
4584
+ }
4585
+ catch (error) {
4586
+ const failure = toFailure(command, error);
4587
+ writeJson(failure);
4588
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
4589
+ }
4590
+ }
3292
4591
  async function handleProfilesListAction(options) {
3293
4592
  try {
3294
4593
  const state = await listCredentialProfiles();
@@ -3311,6 +4610,8 @@ async function handleProfilesListAction(options) {
3311
4610
  async function handleProfilesUseAction(profile, options) {
3312
4611
  try {
3313
4612
  const activeProfile = await switchCredentialProfile(profile);
4613
+ const state = await listCredentialProfiles().catch(() => null);
4614
+ const totalProfiles = state?.profiles.length ?? 1;
3314
4615
  const data = {
3315
4616
  active_profile: activeProfile.name,
3316
4617
  profile: summarizeCredentialProfile(activeProfile, true),
@@ -3319,7 +4620,7 @@ async function handleProfilesUseAction(profile, options) {
3319
4620
  writeJson(success("profiles use", data));
3320
4621
  return;
3321
4622
  }
3322
- process.stdout.write(formatProfileUseSuccess(data.profile));
4623
+ process.stdout.write(formatProfileUseSuccess(data.profile, { totalProfiles }));
3323
4624
  }
3324
4625
  catch (error) {
3325
4626
  const failure = toFailure("profiles use", error);
@@ -3327,6 +4628,74 @@ async function handleProfilesUseAction(profile, options) {
3327
4628
  process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
3328
4629
  }
3329
4630
  }
4631
+ async function handleProfilesEnvAction(profile, options) {
4632
+ try {
4633
+ if (options.unset) {
4634
+ const data = {
4635
+ profile,
4636
+ unset: ["OXYGEN_PROFILE", "OXYGEN_API_URL"],
4637
+ };
4638
+ if (options.json) {
4639
+ writeJson(success("profiles env", data));
4640
+ return;
4641
+ }
4642
+ process.stdout.write("unset OXYGEN_PROFILE OXYGEN_API_URL\n");
4643
+ return;
4644
+ }
4645
+ const state = await listCredentialProfiles();
4646
+ const match = state.profiles.find((entry) => entry.name === profile);
4647
+ if (!match) {
4648
+ throw new OxygenError("profile_not_found", `Oxygen CLI profile "${profile}" is not stored.`, {
4649
+ details: { profile },
4650
+ exitCode: 1,
4651
+ });
4652
+ }
4653
+ const env = {
4654
+ OXYGEN_PROFILE: match.name,
4655
+ OXYGEN_API_URL: match.apiUrl,
4656
+ };
4657
+ if (options.json) {
4658
+ writeJson(success("profiles env", { profile: match.name, env }));
4659
+ return;
4660
+ }
4661
+ process.stdout.write(`export OXYGEN_PROFILE=${shellQuote(match.name)}\n`);
4662
+ process.stdout.write(`export OXYGEN_API_URL=${shellQuote(match.apiUrl)}\n`);
4663
+ }
4664
+ catch (error) {
4665
+ const failure = toFailure("profiles env", error);
4666
+ writeJson(failure);
4667
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
4668
+ }
4669
+ }
4670
+ async function handleProfilesCurrentAction(options) {
4671
+ try {
4672
+ const context = await resolveActiveProfileWithSource();
4673
+ const credentials = context.resolution.credentials;
4674
+ const data = {
4675
+ profile: context.resolution.exists ? context.resolution.name : null,
4676
+ profile_source: context.source,
4677
+ api_url: credentials?.apiUrl ?? null,
4678
+ organization: credentials?.identity?.organization ?? null,
4679
+ user: credentials?.identity?.user ?? null,
4680
+ stored: context.resolution.exists,
4681
+ };
4682
+ if (options.json) {
4683
+ writeJson(success("profiles current", data));
4684
+ return;
4685
+ }
4686
+ process.stdout.write(formatProfilesCurrent(context));
4687
+ }
4688
+ catch (error) {
4689
+ const failure = toFailure("profiles current", error);
4690
+ writeJson(failure);
4691
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
4692
+ }
4693
+ }
4694
+ function shellQuote(value) {
4695
+ if (/^[A-Za-z0-9_.\-:\/@%+=]+$/.test(value))
4696
+ return value;
4697
+ return `'${value.replace(/'/g, `'\\''`)}'`;
4698
+ }
3330
4699
  async function handleLogoutAction(options) {
3331
4700
  try {
3332
4701
  const clearOptions = {};
@@ -3413,27 +4782,39 @@ async function login(options) {
3413
4782
  browser: options.browser !== false,
3414
4783
  json: Boolean(options.json),
3415
4784
  });
3416
- const credentials = {
3417
- token,
3418
- apiUrl,
3419
- };
3420
- const identity = await requestOxygen("/api/cli/whoami", { credentials });
3421
- const saveOptions = {};
3422
- if (options.profile !== undefined)
3423
- saveOptions.profile = options.profile;
3424
- const profile = await saveCredentials(credentials, process.env, saveOptions);
4785
+ const loginIdentity = await resolveLoginIdentity(token, apiUrl);
4786
+ const credentials = loginIdentity.credentials;
4787
+ const explicitProfile = readOption(options.profile) ?? readEnvProfileName();
4788
+ let chosenProfile;
4789
+ let renamed = false;
4790
+ if (explicitProfile) {
4791
+ chosenProfile = explicitProfile;
4792
+ }
4793
+ else {
4794
+ const picked = await pickProfileForLogin(loginIdentity);
4795
+ chosenProfile = picked.name;
4796
+ renamed = picked.renamed;
4797
+ }
4798
+ const profile = await saveCredentials(credentials, process.env, { profile: chosenProfile });
4799
+ const skillsInstall = runAutomaticSkillsInstall({ apiUrl: credentials.apiUrl });
3425
4800
  if (!options.json) {
3426
- process.stdout.write(formatLoginSuccess(identity, credentials, profile));
4801
+ process.stdout.write(formatLoginSuccessForResolved(loginIdentity, profile, { renamed, skillsInstall }));
4802
+ const hint = await buildPostLoginHint(profile);
4803
+ if (hint)
4804
+ process.stdout.write(hint);
3427
4805
  }
3428
4806
  return {
3429
4807
  logged_in: true,
3430
4808
  profile,
3431
4809
  api_url: credentials.apiUrl,
3432
- user: identity.user,
3433
- organization: identity.organization,
4810
+ user: loginIdentity.user,
4811
+ organization: loginIdentity.activeOrganization,
4812
+ organizations: loginIdentity.organizations,
4813
+ selection_required: loginIdentity.selectionRequired,
4814
+ skills_install: skillsInstall,
3434
4815
  };
3435
4816
  }
3436
- async function useAuthToken(options) {
4817
+ async function applyAuthToken(options) {
3437
4818
  const token = readOption(options.token);
3438
4819
  if (!token) {
3439
4820
  throw new OxygenError("missing_token", "Pass a CLI API token with --token.", {
@@ -3444,18 +4825,131 @@ async function useAuthToken(options) {
3444
4825
  token,
3445
4826
  apiUrl: normalizeApiUrl(readOption(options.apiUrl) ?? defaultApiUrl()),
3446
4827
  };
4828
+ const loginIdentity = await resolveLoginIdentity(token, credentials.apiUrl);
4829
+ const explicitProfile = readOption(options.profile) ?? readEnvProfileName();
4830
+ let chosenProfile;
4831
+ let renamed = false;
4832
+ if (explicitProfile) {
4833
+ chosenProfile = explicitProfile;
4834
+ }
4835
+ else {
4836
+ const picked = await pickProfileForLogin(loginIdentity);
4837
+ chosenProfile = picked.name;
4838
+ renamed = picked.renamed;
4839
+ }
4840
+ const profile = await saveCredentials(loginIdentity.credentials, process.env, { profile: chosenProfile });
4841
+ return {
4842
+ identity: loginIdentity.whoami,
4843
+ loginIdentity,
4844
+ credentials: loginIdentity.credentials,
4845
+ api_url: loginIdentity.credentials.apiUrl,
4846
+ profile,
4847
+ renamed,
4848
+ };
4849
+ }
4850
+ async function resolveLoginIdentity(token, apiUrl) {
4851
+ if (isUserSessionToken(token)) {
4852
+ const credentials = {
4853
+ token,
4854
+ apiUrl,
4855
+ authKind: "user_session",
4856
+ };
4857
+ const orgs = await requestOxygen("/api/cli/orgs", { credentials });
4858
+ const activeOrganization = orgs.currentOrganization
4859
+ ? storedOrganizationFromOption(orgs.currentOrganization)
4860
+ : null;
4861
+ credentials.activeOrganization = activeOrganization;
4862
+ credentials.identity = identityFromUserAndOrganization(orgs.user, activeOrganization);
4863
+ return {
4864
+ credentials,
4865
+ whoami: null,
4866
+ user: orgs.user,
4867
+ activeOrganization,
4868
+ organizations: orgs.organizations,
4869
+ selectionRequired: Boolean(orgs.selectionRequired),
4870
+ };
4871
+ }
4872
+ const credentials = {
4873
+ token,
4874
+ apiUrl,
4875
+ };
4876
+ if (token.startsWith("oxy_live_"))
4877
+ credentials.authKind = "org_api_key";
3447
4878
  const identity = await requestOxygen("/api/cli/whoami", { credentials });
3448
- const saveOptions = {};
3449
- if (options.profile !== undefined)
3450
- saveOptions.profile = options.profile;
3451
- const profile = await saveCredentials(credentials, process.env, saveOptions);
4879
+ const activeOrganization = storedOrganizationFromOption(identity.organization);
4880
+ credentials.authKind = identity.authType === "user_session" ? "user_session" : "org_api_key";
4881
+ credentials.activeOrganization = activeOrganization;
4882
+ credentials.identity = identityFromWhoami(identity);
3452
4883
  return {
3453
- identity,
3454
4884
  credentials,
3455
- api_url: credentials.apiUrl,
3456
- profile,
4885
+ whoami: identity,
4886
+ user: identity.user,
4887
+ activeOrganization,
4888
+ organizations: [{ ...identity.organization, selected: true }],
4889
+ selectionRequired: false,
4890
+ };
4891
+ }
4892
+ function pickProfileForLogin(loginIdentity) {
4893
+ if (loginIdentity.credentials.authKind === "user_session") {
4894
+ const seed = loginIdentity.activeOrganization?.slug
4895
+ ?? loginIdentity.user.email
4896
+ ?? loginIdentity.user.id;
4897
+ return pickProfileNameForUserSession(loginIdentity.user.id, seed);
4898
+ }
4899
+ const organization = loginIdentity.activeOrganization;
4900
+ if (!organization) {
4901
+ return pickProfileNameForUserSession(loginIdentity.user.id, loginIdentity.user.email ?? loginIdentity.user.id);
4902
+ }
4903
+ const seed = organization.slug?.trim() || organization.id;
4904
+ return pickProfileNameForIdentity(organization.id, seed);
4905
+ }
4906
+ function readEnvProfileName() {
4907
+ const value = process.env.OXYGEN_PROFILE?.trim();
4908
+ return value ? value : null;
4909
+ }
4910
+ function identityFromWhoami(response) {
4911
+ return {
4912
+ organization: storedOrganizationFromOption(response.organization),
4913
+ user: {
4914
+ id: response.user.id,
4915
+ email: response.user.email ?? null,
4916
+ },
4917
+ capturedAt: new Date().toISOString(),
4918
+ };
4919
+ }
4920
+ function identityFromUserAndOrganization(user, organization) {
4921
+ return {
4922
+ organization,
4923
+ user: {
4924
+ id: user.id,
4925
+ email: user.email ?? null,
4926
+ },
4927
+ capturedAt: new Date().toISOString(),
4928
+ };
4929
+ }
4930
+ function storedOrganizationFromOption(organization) {
4931
+ return {
4932
+ id: organization.id,
4933
+ name: organization.name,
4934
+ slug: organization.slug ?? null,
3457
4935
  };
3458
4936
  }
4937
+ function isUserSessionToken(token) {
4938
+ return token.startsWith("oxy_user_");
4939
+ }
4940
+ async function buildPostLoginHint(activeProfile) {
4941
+ if (output.isTTY !== true || process.env.NO_COLOR) {
4942
+ const state = await listCredentialProfiles().catch(() => null);
4943
+ if (!state || state.profiles.length < 2)
4944
+ return "";
4945
+ return `\n Pin this terminal: eval "$(oxygen profiles env ${activeProfile})"\n\n`;
4946
+ }
4947
+ const state = await listCredentialProfiles().catch(() => null);
4948
+ if (!state || state.profiles.length < 2)
4949
+ return "";
4950
+ const styles = ansi(true);
4951
+ return `\n ${styles.dim("Pin this terminal:")} ${styles.bold(`eval "$(oxygen profiles env ${activeProfile})"`)}\n\n`;
4952
+ }
3459
4953
  async function promptForToken(options) {
3460
4954
  const fallbackLoginUrl = createCliLoginUrl(options.apiUrl);
3461
4955
  if (options.json) {
@@ -3541,7 +5035,7 @@ async function waitForBrowserToken(session) {
3541
5035
  }
3542
5036
  function createCliLoginUrl(apiUrl) {
3543
5037
  const url = new URL("/auth/sign-in", apiUrl);
3544
- url.searchParams.set("redirect_url", "/settings/api-keys");
5038
+ url.searchParams.set("redirect_url", "/settings/cli");
3545
5039
  url.searchParams.set("source", "oxygen_cli");
3546
5040
  return url.toString();
3547
5041
  }
@@ -3593,21 +5087,87 @@ function startOxygenSpinner(message) {
3593
5087
  },
3594
5088
  };
3595
5089
  }
3596
- function formatLoginSuccess(identity, credentials, profile) {
5090
+ function formatLoginSuccessForResolved(loginIdentity, profile, options = {}) {
5091
+ if (loginIdentity.whoami) {
5092
+ return formatLoginSuccess(loginIdentity.whoami, loginIdentity.credentials, profile, options);
5093
+ }
5094
+ if (loginIdentity.activeOrganization) {
5095
+ return formatLoginSuccess({
5096
+ user: loginIdentity.user,
5097
+ organization: loginIdentity.activeOrganization,
5098
+ apiKey: {
5099
+ id: "user_session",
5100
+ name: "CLI user session",
5101
+ tokenPrefix: loginIdentity.credentials.token.slice(0, 17),
5102
+ tokenSuffix: loginIdentity.credentials.token.slice(-4),
5103
+ kind: "user_session",
5104
+ },
5105
+ authType: "user_session",
5106
+ }, loginIdentity.credentials, profile, options);
5107
+ }
5108
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
5109
+ const email = loginIdentity.user.email ?? loginIdentity.user.id;
5110
+ const fingerprint = createHash("sha256")
5111
+ .update(`oxygen-cli:${loginIdentity.credentials.token}`)
5112
+ .digest("hex");
5113
+ const profileLabel = options.renamed
5114
+ ? `${profile} ${styles.dim("(auto-renamed to avoid collision)")}`
5115
+ : profile;
5116
+ const rows = [
5117
+ ["Account", email],
5118
+ ["Organization", "not selected"],
5119
+ ["Profile", profileLabel],
5120
+ ["API", loginIdentity.credentials.apiUrl],
5121
+ ["Token", `${loginIdentity.credentials.token.slice(0, 17)}...${loginIdentity.credentials.token.slice(-4)}`],
5122
+ ["Fingerprint", formatFingerprint(fingerprint)],
5123
+ ];
5124
+ const labelWidth = Math.max(...rows.map(([label]) => label.length));
5125
+ const wordmark = renderBox([
5126
+ "",
5127
+ ...OXYGEN_WORDMARK.split("\n").map(line => styles.green(line)),
5128
+ "",
5129
+ ]);
5130
+ const orgLines = loginIdentity.organizations.length > 0
5131
+ ? [
5132
+ "",
5133
+ "Available organizations:",
5134
+ ...loginIdentity.organizations.map((organization) => ` - ${organization.name}${organization.slug ? ` (${organization.slug})` : ""}`),
5135
+ "",
5136
+ `Select one: ${styles.bold("oxygen orgs use <organization>")}`,
5137
+ ]
5138
+ : [];
5139
+ return [
5140
+ "",
5141
+ wordmark,
5142
+ "",
5143
+ `${styles.green("[OK]")} ${styles.bold("CLI connected")}`,
5144
+ "",
5145
+ ...rows.map(([label, value]) => ` ${styles.dim(label.padEnd(labelWidth))} ${value}`),
5146
+ ...orgLines,
5147
+ "",
5148
+ ...formatAutomaticSkillsInstallStatusLines(options.skillsInstall),
5149
+ ].join("\n");
5150
+ }
5151
+ function formatLoginSuccess(identity, credentials, profile, options = {}) {
3597
5152
  const email = identity.user.email ?? identity.user.id;
3598
- const org = identity.organization.name || identity.organization.slug || identity.organization.id;
5153
+ const orgName = identity.organization.name || identity.organization.slug || identity.organization.id;
5154
+ const orgSlug = identity.organization.slug ?? null;
5155
+ const org = orgSlug && orgSlug !== orgName ? `${orgName} (${orgSlug})` : orgName;
3599
5156
  const key = identity.apiKey
3600
5157
  ? `${identity.apiKey.tokenPrefix}...${identity.apiKey.tokenSuffix}`
3601
5158
  : "stored";
3602
5159
  const fingerprint = createHash("sha256")
3603
5160
  .update(`oxygen-cli:${credentials.token}`)
3604
5161
  .digest("hex");
3605
- const c = ansi(output.isTTY === true && !process.env.NO_COLOR);
5162
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
5163
+ const profileLabel = options.renamed
5164
+ ? `${profile} ${styles.dim("(auto-renamed to avoid collision)")}`
5165
+ : profile;
3606
5166
  // skipcq: JS-0820 — not a React component; rule misfire on array of tuples
3607
5167
  const rows = [
3608
5168
  ["Account", email],
3609
5169
  ["Organization", org],
3610
- ["Profile", profile],
5170
+ ["Profile", profileLabel],
3611
5171
  ["API", credentials.apiUrl],
3612
5172
  ["Token", key],
3613
5173
  ["Fingerprint", formatFingerprint(fingerprint)],
@@ -3615,17 +5175,18 @@ function formatLoginSuccess(identity, credentials, profile) {
3615
5175
  const labelWidth = Math.max(...rows.map(([label]) => label.length));
3616
5176
  const wordmark = renderBox([
3617
5177
  "",
3618
- ...OXYGEN_WORDMARK.split("\n").map(line => c.green(line)),
5178
+ ...OXYGEN_WORDMARK.split("\n").map(line => styles.green(line)),
3619
5179
  "",
3620
5180
  ]);
3621
5181
  return [
3622
5182
  "",
3623
5183
  wordmark,
3624
5184
  "",
3625
- `${c.green("[OK]")} ${c.bold("CLI connected")}`,
5185
+ `${styles.green("[OK]")} ${styles.bold("CLI connected")}`,
3626
5186
  "",
3627
- ...rows.map(([label, value]) => ` ${c.dim(label.padEnd(labelWidth))} ${value}`),
5187
+ ...rows.map(([label, value]) => ` ${styles.dim(label.padEnd(labelWidth))} ${value}`),
3628
5188
  "",
5189
+ ...formatAutomaticSkillsInstallStatusLines(options.skillsInstall),
3629
5190
  ].join("\n");
3630
5191
  }
3631
5192
  function formatFingerprint(hex) {
@@ -3637,6 +5198,9 @@ function summarizeCredentialProfile(profile, active) {
3637
5198
  active,
3638
5199
  api_url: profile.apiUrl,
3639
5200
  token_fingerprint: formatFingerprint(createCredentialFingerprint(profile.token)),
5201
+ organization: profile.activeOrganization ?? profile.identity?.organization ?? null,
5202
+ user: profile.identity?.user ?? null,
5203
+ identity_captured_at: profile.identity?.capturedAt ?? null,
3640
5204
  };
3641
5205
  }
3642
5206
  function createCredentialFingerprint(token) {
@@ -3645,68 +5209,190 @@ function createCredentialFingerprint(token) {
3645
5209
  .digest("hex");
3646
5210
  }
3647
5211
  function formatProfilesList(data) {
3648
- const c = ansi(output.isTTY === true && !process.env.NO_COLOR);
5212
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
3649
5213
  if (data.profiles.length === 0) {
3650
5214
  return [
3651
5215
  "",
3652
- `${c.dim("No stored Oxygen CLI profiles.")}`,
5216
+ `${styles.dim("No stored Oxygen CLI profiles.")}`,
3653
5217
  "",
3654
5218
  ].join("\n");
3655
5219
  }
3656
- const labelWidth = Math.max(...data.profiles.map((profile) => profile.name.length));
3657
- return [
5220
+ const nameWidth = Math.max(...data.profiles.map((profile) => profile.name.length));
5221
+ const orgWidth = Math.max(...data.profiles.map((profile) => formatProfileOrgCell(profile).length));
5222
+ const lines = [
3658
5223
  "",
3659
- `${c.bold("Oxygen CLI Profiles")}`,
5224
+ `${styles.bold("Oxygen CLI Profiles")}`,
3660
5225
  "",
3661
5226
  ...data.profiles.map((profile) => {
3662
5227
  const marker = profile.active ? "*" : " ";
5228
+ const orgCell = formatProfileOrgCell(profile);
3663
5229
  return [
3664
- `${marker} ${profile.name.padEnd(labelWidth)}`,
3665
- c.dim(profile.api_url),
3666
- c.dim(profile.token_fingerprint),
5230
+ `${marker} ${profile.name.padEnd(nameWidth)}`,
5231
+ orgCell.padEnd(orgWidth),
5232
+ styles.dim(profile.api_url),
5233
+ styles.dim(profile.token_fingerprint),
3667
5234
  ].join(" ");
3668
5235
  }),
3669
5236
  "",
3670
- ].join("\n");
5237
+ ];
5238
+ if (data.profiles.length >= 2) {
5239
+ lines.push(` ${styles.dim("Pin a terminal:")} ${styles.bold('eval "$(oxygen profiles env <profile>)"')}`);
5240
+ lines.push("");
5241
+ }
5242
+ return lines.join("\n");
5243
+ }
5244
+ function formatProfileOrgCell(profile) {
5245
+ if (!profile.organization)
5246
+ return "(unknown org — run `oxygen whoami` to refresh)";
5247
+ const { slug, name } = profile.organization;
5248
+ if (slug && slug !== name)
5249
+ return `${name} (${slug})`;
5250
+ return name || slug || profile.organization.id;
5251
+ }
5252
+ function formatProfileUseSuccess(profile, options) {
5253
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
5254
+ const lines = [
5255
+ "",
5256
+ `${styles.green("[OK]")} ${styles.bold("CLI profile selected")}`,
5257
+ "",
5258
+ ` ${styles.dim("Profile")} ${profile.name}`,
5259
+ ` ${styles.dim("Organization")} ${formatProfileOrgCell(profile)}`,
5260
+ ` ${styles.dim("API")} ${profile.api_url}`,
5261
+ ` ${styles.dim("Fingerprint")} ${profile.token_fingerprint}`,
5262
+ "",
5263
+ ];
5264
+ if (options.totalProfiles >= 2) {
5265
+ lines.push(` ${styles.dim("Note: this changes the active profile for every shell.")}`);
5266
+ lines.push(` ${styles.dim("To pin this terminal only, run:")} ${styles.bold(`eval "$(oxygen profiles env ${profile.name})"`)}`);
5267
+ lines.push("");
5268
+ }
5269
+ return lines.join("\n");
3671
5270
  }
3672
- function formatProfileUseSuccess(profile) {
3673
- const c = ansi(output.isTTY === true && !process.env.NO_COLOR);
5271
+ function formatWhoami(identity, context) {
5272
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
5273
+ const email = identity.user.email ?? identity.user.id;
5274
+ const orgName = identity.organization.name || identity.organization.slug || identity.organization.id;
5275
+ const orgSlug = identity.organization.slug ?? null;
5276
+ const org = orgSlug && orgSlug !== orgName ? `${orgName} (${orgSlug})` : orgName;
5277
+ const sourceLabel = describeProfileSource(context.source);
5278
+ const profileName = context.resolution.exists ? context.resolution.name : "(no stored profile)";
5279
+ const apiUrl = context.resolution.credentials?.apiUrl ?? defaultApiUrl();
5280
+ const rows = [
5281
+ ["Account", email],
5282
+ ["Organization", org],
5283
+ ["Profile", `${profileName} ${styles.dim(`(${sourceLabel})`)}`],
5284
+ ["API", apiUrl],
5285
+ ];
5286
+ const labelWidth = Math.max(...rows.map(([label]) => label.length));
5287
+ const lines = [
5288
+ "",
5289
+ ...rows.map(([label, value]) => ` ${styles.dim(label.padEnd(labelWidth))} ${value}`),
5290
+ "",
5291
+ ];
5292
+ if (identity.onboarding && identity.onboarding.complete === false) {
5293
+ const missing = identity.onboarding.missing_sections.join(", ");
5294
+ lines.push(` ${styles.dim("Onboarding")} ${styles.bold("workspace context not loaded")} ${styles.dim(`(missing: ${missing})`)}`, ` ${styles.dim("→ Run")} ${styles.bold("oxygen onboarding start")} ${styles.dim("(or oxygen_onboarding_start in MCP)")}`, "");
5295
+ }
5296
+ return lines.join("\n");
5297
+ } // skipcq: JS-C1002
5298
+ function formatProfilesCurrent(context) {
5299
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
5300
+ const sourceLabel = describeProfileSource(context.source);
5301
+ if (!context.resolution.exists) {
5302
+ return [
5303
+ "",
5304
+ `${styles.dim("No stored profile resolves for this shell.")}`,
5305
+ ` ${styles.dim("Source:")} ${sourceLabel}`,
5306
+ ` ${styles.dim("Run")} ${styles.bold("oxygen login")} ${styles.dim("to create one.")}`,
5307
+ "",
5308
+ ].join("\n");
5309
+ }
5310
+ const credentials = context.resolution.credentials;
5311
+ if (!credentials) {
5312
+ return [
5313
+ "",
5314
+ `${styles.dim("No stored profile resolves for this shell.")}`,
5315
+ ` ${styles.dim("Source:")} ${sourceLabel}`,
5316
+ "",
5317
+ ].join("\n");
5318
+ }
5319
+ const organization = credentials.activeOrganization ?? credentials.identity?.organization ?? null;
5320
+ const orgCell = organization
5321
+ ? (organization.slug && organization.slug !== organization.name
5322
+ ? `${organization.name} (${organization.slug})`
5323
+ : organization.name)
5324
+ : "(unknown — run `oxygen whoami` to refresh)";
3674
5325
  return [
3675
5326
  "",
3676
- `${c.green("[OK]")} ${c.bold("CLI profile selected")}`,
5327
+ `${styles.bold("Active Oxygen CLI Profile")}`,
3677
5328
  "",
3678
- ` ${c.dim("Profile")} ${profile.name}`,
3679
- ` ${c.dim("API")} ${profile.api_url}`,
3680
- ` ${c.dim("Fingerprint")} ${profile.token_fingerprint}`,
5329
+ ` ${styles.dim("Profile")} ${context.resolution.name}`,
5330
+ ` ${styles.dim("Source")} ${sourceLabel}`,
5331
+ ` ${styles.dim("Organization")} ${orgCell}`,
5332
+ ` ${styles.dim("API")} ${credentials.apiUrl}`,
3681
5333
  "",
3682
5334
  ].join("\n");
3683
- } // skipcq: JS-C1002
5335
+ }
5336
+ function describeProfileSource(source) {
5337
+ switch (source) {
5338
+ case "flag": return "from --profile flag";
5339
+ case "env": return "from OXYGEN_PROFILE";
5340
+ case "file": return "from stored active profile";
5341
+ case "default": return "default fallback";
5342
+ }
5343
+ }
3684
5344
  function formatLogoutSuccess(result) {
3685
- const c = ansi(output.isTTY === true && !process.env.NO_COLOR);
5345
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
3686
5346
  const removed = result.removedProfile
3687
5347
  ? `profile "${result.removedProfile}" removed`
3688
5348
  : "removed";
3689
5349
  return [
3690
5350
  "",
3691
- `${c.green("[OK]")} ${c.bold("CLI logged out")}`,
5351
+ `${styles.green("[OK]")} ${styles.bold("CLI logged out")}`,
3692
5352
  "",
3693
- ` ${c.dim("Credentials")} ${removed}`,
3694
- ` ${c.dim("Profiles left")} ${String(result.remainingProfiles)}`, // skipcq: JS-C1002
5353
+ ` ${styles.dim("Credentials")} ${removed}`,
5354
+ ` ${styles.dim("Profiles left")} ${String(result.remainingProfiles)}`, // skipcq: JS-C1002
3695
5355
  "",
3696
5356
  ].join("\n");
3697
5357
  }
3698
5358
  function formatUpdateSuccess(result) {
3699
- const c = ansi(output.isTTY === true && !process.env.NO_COLOR);
5359
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
3700
5360
  return [
3701
5361
  "",
3702
- `${c.green("[OK]")} ${c.bold(result.dry_run ? "CLI update command ready" : "CLI update completed")}`,
5362
+ `${styles.green("[OK]")} ${styles.bold(result.dry_run ? "CLI update command ready" : "CLI update completed")}`,
3703
5363
  "",
3704
- ` ${c.dim("Current")} ${result.current_version}`,
3705
- ` ${c.dim("Package")} ${result.package}`,
3706
- ` ${c.dim("Command")} ${result.command}`,
5364
+ ` ${styles.dim("Current")} ${result.current_version}`,
5365
+ ` ${styles.dim("Package")} ${result.package}`,
5366
+ ` ${styles.dim("Command")} ${result.command}`,
3707
5367
  "",
5368
+ ...formatAutomaticSkillsInstallStatusLines(result.skills_install),
3708
5369
  ].join("\n");
3709
5370
  } // skipcq: JS-C1002
5371
+ function formatAutomaticSkillsInstallStatusLines(result) {
5372
+ if (!result)
5373
+ return [];
5374
+ const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
5375
+ if (!result.attempted) {
5376
+ if (result.reason === "dry_run")
5377
+ return [];
5378
+ return [
5379
+ `${styles.dim("Skills")} skipped (${result.reason ?? "not attempted"})`,
5380
+ "",
5381
+ ];
5382
+ }
5383
+ if (result.ok) {
5384
+ return [
5385
+ `${styles.green("[OK]")} ${styles.bold("Oxygen skills installed")}`,
5386
+ "",
5387
+ ];
5388
+ }
5389
+ return [
5390
+ `${styles.yellow("[WARN]")} Oxygen skills could not be installed; the CLI command still completed.`,
5391
+ ` ${styles.dim("Check")} ${"oxygen skills doctor --json"}`,
5392
+ ` ${styles.dim("Retry")} ${result.command}`,
5393
+ "",
5394
+ ];
5395
+ }
3710
5396
  function renderBox(lines) {
3711
5397
  const width = Math.max(...lines.map(visibleLength), 0);
3712
5398
  const border = `+${"-".repeat(width + 2)}+`;
@@ -3725,6 +5411,7 @@ function ansi(enabled) {
3725
5411
  bold: wrap(1, 22), // skipcq: JS-0117 // skipcq: JS-W1035
3726
5412
  dim: wrap(2, 22),
3727
5413
  green: wrap(32, 39),
5414
+ yellow: wrap(33, 39),
3728
5415
  };
3729
5416
  }
3730
5417
  function readOption(value) {
@@ -3739,6 +5426,62 @@ function readCsvOption(value) {
3739
5426
  .map((entry) => entry.trim())
3740
5427
  .filter(Boolean);
3741
5428
  }
5429
+ function splitCsv(value) {
5430
+ return value
5431
+ .split(",")
5432
+ .map((entry) => entry.trim())
5433
+ .filter(Boolean);
5434
+ }
5435
+ function collectMultiple(value, previous) {
5436
+ return [...previous, value];
5437
+ }
5438
+ async function buildBlueprintRequestBody(// skipcq: JS-R1005
5439
+ slug, options) {
5440
+ const body = {};
5441
+ if (slug?.trim())
5442
+ body.slug = slug.trim();
5443
+ const filePath = readOption(options.file);
5444
+ if (filePath) {
5445
+ const fs = await import("node:fs/promises");
5446
+ const raw = await fs.readFile(filePath, "utf8");
5447
+ body.envelope = JSON.parse(raw);
5448
+ }
5449
+ const fromUrl = readOption(options.fromUrl);
5450
+ if (fromUrl) {
5451
+ const response = await fetch(fromUrl, { headers: { Accept: "application/json" } });
5452
+ if (!response.ok) {
5453
+ throw new Error(`Failed to fetch blueprint from URL (${response.status} ${response.statusText}).`);
5454
+ }
5455
+ const payload = await response.json();
5456
+ body.envelope = payload.blueprint
5457
+ ?? payload.envelope
5458
+ ?? payload;
5459
+ }
5460
+ const inputJson = readOption(options.inputJson);
5461
+ if (inputJson) {
5462
+ try {
5463
+ body.inputs = JSON.parse(inputJson);
5464
+ }
5465
+ catch {
5466
+ throw new Error("--input-json must be valid JSON.");
5467
+ }
5468
+ }
5469
+ const tableRefEntries = Array.isArray(options.tableRef) ? options.tableRef : [];
5470
+ if (tableRefEntries.length > 0) {
5471
+ const refs = {};
5472
+ for (const entry of tableRefEntries) {
5473
+ const [key, value] = entry.split("=");
5474
+ if (key && value)
5475
+ refs[key.trim()] = value.trim();
5476
+ }
5477
+ if (Object.keys(refs).length > 0)
5478
+ body.table_refs = refs;
5479
+ }
5480
+ if (!body.slug && !body.envelope) {
5481
+ throw new Error("Pass a blueprint slug, --file, or --from-url.");
5482
+ }
5483
+ return body;
5484
+ }
3742
5485
  function contextAssetsQuery(options) {
3743
5486
  const query = new URLSearchParams();
3744
5487
  if (readOption(options.type))
@@ -3752,6 +5495,20 @@ function contextAssetsQuery(options) {
3752
5495
  const value = query.toString();
3753
5496
  return value ? `?${value}` : "";
3754
5497
  }
5498
+ function buildContextResolveBody(options) {
5499
+ const assetTypes = readCsvOption(options.assetType);
5500
+ const tags = readCsvOption(options.tags);
5501
+ const maxAssets = readNonNegativeInt(options.maxAssets);
5502
+ return {
5503
+ ...(readOption(options.purpose) ? { purpose: readOption(options.purpose) } : {}),
5504
+ ...(assetTypes.length > 0 ? { asset_types: assetTypes } : {}),
5505
+ ...(readOption(options.assetStatus) ? { asset_status: readOption(options.assetStatus) } : {}),
5506
+ ...(tags.length > 0 ? { tags } : {}),
5507
+ ...(options.includeArchived ? { include_archived: true } : {}),
5508
+ ...(maxAssets !== undefined ? { max_assets: maxAssets } : {}),
5509
+ ...(options.requireReady ? { require_ready: true } : {}),
5510
+ };
5511
+ }
3755
5512
  function buildContextAssetUpsertBody(options) {
3756
5513
  const asset = options.assetJson ? parseJsonObject(options.assetJson) : {};
3757
5514
  const tags = readCsvOption(options.tags);