@oxygen-agent/cli 1.853.1 → 1.861.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.853.1
37
+ Version: 1.861.0
@@ -2,6 +2,22 @@
2
2
  export declare function readPositiveInt(value: string | undefined): number | undefined;
3
3
  /** Read a string field from an unknown record-shaped value, or null when it is missing/non-string. */
4
4
  export declare function readRecordString(value: unknown, key: string): string | null;
5
+ /**
6
+ * Parse repeated `key=value` flags into an object. Call this INSIDE an action,
7
+ * never as a Commander `parseArg`.
8
+ *
9
+ * That placement is the whole point. A parser passed to Commander runs during
10
+ * `parseOptions`, OUTSIDE the action's try/catch, so throwing there escapes as
11
+ * an uncaught exception: a raw Node stack trace instead of the JSON envelope,
12
+ * and exit code 0 — a failure reporting success. Collect with the total
13
+ * `collectRepeatable`, then validate here where handleAsyncAction can shape the
14
+ * error. (Same shape as index.ts's readNamedCredentials, which predates this.)
15
+ *
16
+ * Later occurrences of the same key win, which is what a user retyping one
17
+ * binding expects. Only the first `=` splits, so a value containing `=`
18
+ * survives intact.
19
+ */
20
+ export declare function parseKeyValuePairs(values: readonly string[], example?: string): Record<string, string>;
5
21
  /** Parse a JSON string that must decode to a plain object, throwing a typed invalid_json error otherwise. */
6
22
  export declare function parseJsonObject(value: string): Record<string, unknown>;
7
23
  /** Read a JSON-object CLI option (e.g. `--selection-json`, `--filter-tree-json`), or undefined when absent. */
@@ -27,6 +27,37 @@ export function readRecordString(value, key) {
27
27
  const entry = value[key];
28
28
  return typeof entry === "string" ? entry : null;
29
29
  }
30
+ /**
31
+ * Parse repeated `key=value` flags into an object. Call this INSIDE an action,
32
+ * never as a Commander `parseArg`.
33
+ *
34
+ * That placement is the whole point. A parser passed to Commander runs during
35
+ * `parseOptions`, OUTSIDE the action's try/catch, so throwing there escapes as
36
+ * an uncaught exception: a raw Node stack trace instead of the JSON envelope,
37
+ * and exit code 0 — a failure reporting success. Collect with the total
38
+ * `collectRepeatable`, then validate here where handleAsyncAction can shape the
39
+ * error. (Same shape as index.ts's readNamedCredentials, which predates this.)
40
+ *
41
+ * Later occurrences of the same key win, which is what a user retyping one
42
+ * binding expects. Only the first `=` splits, so a value containing `=`
43
+ * survives intact.
44
+ */
45
+ export function parseKeyValuePairs(values, example = "url=linkedin_url") {
46
+ const out = {};
47
+ for (const value of values) {
48
+ const separator = value.indexOf("=");
49
+ const key = separator > 0 ? value.slice(0, separator).trim() : "";
50
+ const entry = separator > 0 ? value.slice(separator + 1).trim() : "";
51
+ if (!key || !entry) {
52
+ throw new OxygenError("invalid_request", `Expected key=value, got "${value}".`, {
53
+ details: { value, example },
54
+ exitCode: 1,
55
+ });
56
+ }
57
+ out[key] = entry;
58
+ }
59
+ return out;
60
+ }
30
61
  /** Parse a JSON string that must decode to a plain object, throwing a typed invalid_json error otherwise. */
31
62
  export function parseJsonObject(value) {
32
63
  let parsed;
@@ -114,6 +114,7 @@ export function searchCommandManifest(manifest, query, requestedLimit = DEFAULT_
114
114
  const matches = tokens.length === 0
115
115
  ? []
116
116
  : manifest.commands
117
+ .filter((command) => !command.hidden)
117
118
  .map((command) => ({ command, score: scoreCommand(command, tokens, route) }))
118
119
  .filter((entry) => entry.score > 0)
119
120
  .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name));
@@ -144,6 +145,7 @@ export function getCommandManifestEntry(manifest, exactName) {
144
145
  export function suggestCommandNames(manifest, query, limit = 3) {
145
146
  const tokens = tokenizeCommandQuery(query);
146
147
  return manifest.commands
148
+ .filter((command) => !command.hidden)
147
149
  .map((command) => ({ command, score: scoreCommand(command, tokens, null) }))
148
150
  .filter((entry) => entry.score > 0)
149
151
  .sort((a, b) => b.score - a.score || a.command.name.localeCompare(b.command.name))
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredential
20
20
  import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
21
21
  import { acquireMirrorLock, clearConflictFiles, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, listConflictFiles, listLocalMirrors, localPageSha256, markMirrorStale, mirrorExists, pageFilePath, planMirrorPush, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile, } from "./knowledge-mirror.js";
22
22
  import { waitForCliRun } from "./run-wait.js";
23
- import { assertModeFlagsExclusive, parseJsonObject, readJsonObjectOption, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
23
+ import { assertModeFlagsExclusive, parseKeyValuePairs, parseJsonObject, readJsonObjectOption, readPositiveInt, readRecordString, resolveLiveDryRunMode, } from "./cli-values.js";
24
24
  import { formatAiPromptPreviewNotice } from "./column-run-notices.js";
25
25
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
26
26
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
@@ -452,6 +452,20 @@ function writeDryRunNotice(data) {
452
452
  if (typeof block.next_step === "string")
453
453
  process.stderr.write(`${block.next_step}\n`);
454
454
  }
455
+ // An `oxy_live_` key can only ever answer for the one workspace it is bound to,
456
+ // so `orgs list` returns a single row and `orgs use` refuses — both truthfully,
457
+ // and both indistinguishable from "this account has one workspace" if you only
458
+ // read stdout. The server says which credential answered and whether it can
459
+ // switch; mirror that on stderr, the same split writeDryRunNotice uses, so the
460
+ // machine-read envelope stays clean. This is the line that would have stopped a
461
+ // user from minting a 21st key that also could not switch.
462
+ function writeCredentialNotice(credential) {
463
+ if (!credential || credential.switchable !== false)
464
+ return;
465
+ if (typeof credential.message === "string" && credential.message.trim()) {
466
+ process.stderr.write(`${credential.message}\n`);
467
+ }
468
+ }
455
469
  // A sender-profile write that could not durably host the photo still succeeds —
456
470
  // the profile is saved, just with a picture no inbox vendor will ever be able to
457
471
  // fetch. That is the one failure mode nobody notices until the mailboxes ship
@@ -2494,6 +2508,7 @@ export function createProgram() {
2494
2508
  .option("--token <token>", "CLI API token created in the Oxygen dashboard.")
2495
2509
  .option("--api-url <url>", "Oxygen API URL. Defaults to OXYGEN_API_URL or https://oxygen-agent.com.")
2496
2510
  .option("--profile <name>", "Store credentials under a named CLI profile and make it active.")
2511
+ .option("--org <organization>", "Workspace to land in after logging in (id or slug). Needs a user key, which works across every workspace you belong to; a workspace-bound key can only land in its own.")
2497
2512
  .option("--force", "Repoint an existing profile at a different API host. Refused without this flag.")
2498
2513
  .option("--no-browser", "Skip the browser handoff and paste the token manually.")
2499
2514
  .option("--json", "Print a JSON envelope.")
@@ -2638,7 +2653,7 @@ export function createProgram() {
2638
2653
  .action(async (options) => {
2639
2654
  const outputOptions = { ...options, json: options.json || Boolean(activationCommand.opts().json) };
2640
2655
  await handleAsyncAction("activation state", outputOptions, () => requestOxygen("/api/cli/activation/state"));
2641
- }));
2656
+ }), { hidden: true });
2642
2657
  const commandsCommand = program
2643
2658
  .command("commands")
2644
2659
  .description("Discover CLI commands. With no subcommand, print the backwards-compatible full manifest.")
@@ -2760,7 +2775,7 @@ export function createProgram() {
2760
2775
  .description("List organizations available to the current CLI identity.")
2761
2776
  .option("--json", "Print a JSON envelope.")
2762
2777
  .action(async (options) => {
2763
- await handleAsyncAction("orgs list", options, () => requestOxygen("/api/cli/orgs"));
2778
+ await handleOrgsListAction(options);
2764
2779
  }))
2765
2780
  .addCommand(new Command("use")
2766
2781
  .description("Select the active organization for this CLI profile.")
@@ -7093,6 +7108,8 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7093
7108
  .addCommand(new Command("add")
7094
7109
  .description("Add a nullable column to a workspace table. Writes the definition only — this never runs the column and never spends credits; use `columns run` for that, with --dry-run first to see the cost.")
7095
7110
  .argument("<table>", "Table id or slug.")
7111
+ .option("--preset <preset>", "Add a pre-built enrichment bundle instead of one column: `person_enrich` (one LinkedIn profile lookup, then headline, bio, location and followers for free) or `company_enrich` (domain, LinkedIn page, headcount, industry and full profile, cascading across providers until one answers). Oxygen finds the identity column itself — override with --input. Creating the columns is free; run them afterwards, --dry-run first.")
7112
+ .option("--input <slot=column...>", "Bind a preset input to an exact column, e.g. --input url=linkedin_url, or --input company_name=account --input domain=website. Repeatable. Only needed when the automatic match is wrong or missing.", collectRepeatable, [])
7096
7113
  .option("--label <label>", "Display label for the new column. Required unless --prompt-key supplies a default title.")
7097
7114
  .option("--key <key>", "Optional stable column key. Defaults to a normalized label.")
7098
7115
  .option("--data-type <type>", "Column data type: text, numeric, boolean, jsonb, or timestamptz.")
@@ -7128,6 +7145,23 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
7128
7145
  // skipcq: JS-R1005 — intentional per-option branching to assemble the columns-add request body
7129
7146
  .action(async (table, options) => {
7130
7147
  await handleAsyncAction("columns add", options, async () => {
7148
+ // A preset names its own columns, so --label does not apply to it.
7149
+ const preset = readOption(options.preset);
7150
+ if (preset) {
7151
+ if (options.label || options.promptKey || options.definitionJson) {
7152
+ throw new OxygenError("invalid_request", "--preset builds its own column set. Drop --label/--prompt-key/--definition-json, or add the column by hand without --preset.", { exitCode: 1 });
7153
+ }
7154
+ const presetBody = { table, preset };
7155
+ const inputs = parseKeyValuePairs(options.input ?? []);
7156
+ if (Object.keys(inputs).length > 0)
7157
+ presetBody.inputs = inputs;
7158
+ if (options.key)
7159
+ presetBody.payload_column = options.key;
7160
+ return requestOxygen("/api/cli/tables/columns", {
7161
+ method: "POST",
7162
+ body: presetBody,
7163
+ });
7164
+ }
7131
7165
  if (!options.promptKey && !options.label) {
7132
7166
  throw new OxygenError("invalid_request", "--label is required.", { exitCode: 1 });
7133
7167
  }
@@ -12619,6 +12653,53 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
12619
12653
  const suffix = params.toString();
12620
12654
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enrollments${suffix ? `?${suffix}` : ""}`);
12621
12655
  });
12656
+ }))
12657
+ .addCommand(new Command("contacts")
12658
+ .description("List a launched campaign's table-backed contacts with full source variables and durable contacted/not-contacted status. Contacts removed from this campaign are hidden; source rows remain intact.")
12659
+ .argument("<sequence>", "Sequence id or slug.")
12660
+ .option("--contact-state <state>", "Filter: all, contacted, or not_contacted.", "all")
12661
+ .option("--limit <n>", "Maximum contacts to return (1-500).", "100")
12662
+ .option("--json", "Print a JSON envelope.")
12663
+ .action(async (sequence, options) => {
12664
+ await handleAsyncAction("sequences contacts", options, () => {
12665
+ const contactState = readOption(options.contactState) ?? "all";
12666
+ if (!["all", "contacted", "not_contacted"].includes(contactState)) {
12667
+ throw new Error("--contact-state must be all, contacted, or not_contacted.");
12668
+ }
12669
+ const limit = readPositiveInt(options.limit);
12670
+ if (limit === undefined || limit > 500) {
12671
+ throw new Error("--limit must be between 1 and 500.");
12672
+ }
12673
+ const params = new URLSearchParams({
12674
+ contact_state: contactState,
12675
+ limit: String(limit),
12676
+ });
12677
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/contacts?${params.toString()}`);
12678
+ });
12679
+ }))
12680
+ .addCommand(new Command("contacts-remove")
12681
+ .description("Remove selected source-table rows from this campaign only. Preview by default; --approved stops pending enrollment work and queues delegated-provider cleanup while preserving source rows and historical analytics.")
12682
+ .argument("<sequence>", "Sequence id or slug.")
12683
+ .requiredOption("--table-row-ids <ids>", "Comma-separated source table row UUIDs (maximum 500).")
12684
+ .option("--approved", "Apply the previewed campaign-only removal.")
12685
+ .option("--json", "Print a JSON envelope.")
12686
+ .action(async (sequence, options) => {
12687
+ await handleAsyncAction("sequences contacts-remove", options, () => {
12688
+ const tableRowIds = options.tableRowIds
12689
+ .split(",")
12690
+ .map((value) => value.trim())
12691
+ .filter(Boolean);
12692
+ if (tableRowIds.length === 0 || tableRowIds.length > 500) {
12693
+ throw new Error("--table-row-ids must contain between 1 and 500 row UUIDs.");
12694
+ }
12695
+ return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/contacts`, {
12696
+ method: "POST",
12697
+ body: {
12698
+ table_row_ids: tableRowIds,
12699
+ approved: options.approved === true,
12700
+ },
12701
+ });
12702
+ });
12622
12703
  }))
12623
12704
  .addCommand(new Command("reconcile")
12624
12705
  .description("Safely reconcile one ambiguous LinkedIn invite/message from provider reads. Preview-only unless --approved; never resends.")
@@ -13944,6 +14025,50 @@ fixed scan; watermark_cursor starts a later bounded replay. Consumers must retai
13944
14025
  },
13945
14026
  });
13946
14027
  });
14028
+ }))
14029
+ .addCommand(new Command("auto-enroll")
14030
+ .description("Standing permission to connect NEW eligible mailboxes to EmailGuard automatically, so monitoring keeps up with the fleet instead of needing a manual connect per inbox. Enrolment is a RECURRING monthly charge per inbox, so arming is approval-gated and always bounded by a per-cycle credit ceiling. Without --arm/--disarm/--run this reports the current state (0 credits).")
14031
+ .option("--arm", "Arm the standing permission. Requires --max-credits; previews unless --approved.")
14032
+ .option("--disarm", "Turn it off. Existing subscriptions are untouched. 0 credits, no approval needed.")
14033
+ .option("--run", "Enrol one bounded batch now.")
14034
+ .option("--dry-run", "With --run: report what would be enrolled without connecting or charging.")
14035
+ .option("--max-credits <n>", "Per-billing-cycle credit ceiling this permission may spend (required with --arm).")
14036
+ .option("--approved", "Actually arm (otherwise --arm returns a preview).")
14037
+ .option("--json", "Print a JSON envelope.")
14038
+ .action(async (options) => {
14039
+ await handleAsyncAction("mailboxes emailguard auto-enroll", options, () => {
14040
+ const path = "/api/cli/mailboxes/emailguard/auto-enroll";
14041
+ if (options.disarm) {
14042
+ return requestOxygen(path, {
14043
+ method: "POST",
14044
+ body: { enabled: false },
14045
+ });
14046
+ }
14047
+ if (options.arm) {
14048
+ const ceiling = readOption(options.maxCredits);
14049
+ if (!ceiling) {
14050
+ throw new Error("--max-credits <n> is required with --arm: a standing permission without a ceiling would be unlimited recurring spend authority.");
14051
+ }
14052
+ return requestOxygen(path, {
14053
+ method: "POST",
14054
+ body: {
14055
+ enabled: true,
14056
+ max_credits_per_cycle: Number(ceiling),
14057
+ ...(options.approved ? { approved: true } : {}),
14058
+ },
14059
+ });
14060
+ }
14061
+ if (options.run) {
14062
+ return requestOxygen(path, {
14063
+ method: "POST",
14064
+ body: {
14065
+ run: true,
14066
+ ...(options.dryRun ? { dry_run: true } : {}),
14067
+ },
14068
+ });
14069
+ }
14070
+ return requestOxygen(path);
14071
+ });
13947
14072
  })))
13948
14073
  .addCommand(new Command("delegation")
13949
14074
  .description("Google sending-domain delegation only — not Microsoft OAuth and not a credential-file handoff to OXYGEN Warm-up or EmailGuard. Reports covered domains plus the exact client id/scopes; --probe mints a real token per mailbox. Read-only, sends no mail, 0 Oxygen credits.")
@@ -19576,6 +19701,12 @@ async function runAuthDoctor() {
19576
19701
  }))
19577
19702
  : { ok: false, skipped: true, reason: "no_credentials" };
19578
19703
  const cachedIdentity = credentials?.identity ?? null;
19704
+ const cachedOrganization = credentials?.activeOrganization ?? cachedIdentity?.organization ?? null;
19705
+ // The live read wins over the stored `authKind`: a profile written by an older
19706
+ // CLI can carry no kind at all, and the server always knows.
19707
+ const liveAuthType = "ok" in identity && identity.ok && typeof identity.data.authType === "string"
19708
+ ? identity.data.authType
19709
+ : null;
19579
19710
  return {
19580
19711
  profile: context?.resolution.exists ? context.resolution.name : null,
19581
19712
  profile_source: context?.source ?? null,
@@ -19583,13 +19714,24 @@ async function runAuthDoctor() {
19583
19714
  api_url: apiUrl,
19584
19715
  auth_kind: credentials?.authKind ?? null,
19585
19716
  token_fingerprint: credentials ? formatFingerprint(createCredentialFingerprint(credentials.token)) : null,
19586
- cached_organization: credentials?.activeOrganization ?? cachedIdentity?.organization ?? null,
19717
+ cached_organization: cachedOrganization,
19587
19718
  cached_user: cachedIdentity?.user ?? null,
19588
19719
  profile_error: profileError,
19720
+ org_switching: {
19721
+ switchable: resolveOrgSwitchability(liveAuthType ?? credentials?.authKind ?? null),
19722
+ current_organization: cachedOrganization,
19723
+ },
19589
19724
  health,
19590
19725
  identity,
19591
19726
  };
19592
19727
  }
19728
+ function resolveOrgSwitchability(authKind) {
19729
+ if (authKind === "user_session" || authKind === "oauth")
19730
+ return true;
19731
+ if (authKind === "org_api_key")
19732
+ return false;
19733
+ return null;
19734
+ }
19593
19735
  async function runAuthDoctorCheck(fn) {
19594
19736
  try {
19595
19737
  return { ok: true, data: await fn() };
@@ -19606,31 +19748,79 @@ async function runAuthDoctorCheck(fn) {
19606
19748
  };
19607
19749
  }
19608
19750
  }
19751
+ async function handleOrgsListAction(options) {
19752
+ try {
19753
+ const data = await requestOxygen("/api/cli/orgs");
19754
+ emitSuccess("orgs list", data, options);
19755
+ writeCredentialNotice(data.credential);
19756
+ }
19757
+ catch (error) {
19758
+ emitCliFailure("orgs list", error);
19759
+ }
19760
+ }
19761
+ /**
19762
+ * Write a completed org selection back onto the stored profile.
19763
+ *
19764
+ * Shared by `orgs use` and `oxygen login --org` so the two-step (log in, then
19765
+ * switch) and the one-step collapse cannot drift apart.
19766
+ *
19767
+ * Gated on the credential being one that can actually carry a selection: an
19768
+ * org-bound key's active workspace is a property of the key, not of the profile,
19769
+ * so persisting a different one would only produce a profile that lies about
19770
+ * where its commands land.
19771
+ */
19772
+ async function persistOrganizationSelection(input) {
19773
+ const { profile, credentials, organization } = input;
19774
+ if (!profile || !credentials || !organization)
19775
+ return false;
19776
+ if (credentials.authKind !== "user_session" && !credentials.token.startsWith("oxy_user_")) {
19777
+ return false;
19778
+ }
19779
+ await updateActiveOrganizationForProfile(profile, storedOrganizationFromOption(organization));
19780
+ return true;
19781
+ }
19782
+ /** Select an organization server-side, then persist it. Used by `login --org`. */
19783
+ async function selectOrganizationForProfile(input) {
19784
+ const data = await requestOxygen("/api/cli/orgs/select", {
19785
+ method: "POST",
19786
+ body: { organization: input.organization },
19787
+ credentials: input.credentials,
19788
+ });
19789
+ const selectionPersisted = await persistOrganizationSelection({
19790
+ profile: input.profile,
19791
+ credentials: input.credentials,
19792
+ organization: data.currentOrganization,
19793
+ });
19794
+ return { data, selectionPersisted };
19795
+ }
19609
19796
  async function handleOrgUseAction(organization, options, command) {
19610
19797
  try {
19798
+ // Credentials stay ambient here: an OXYGEN_API_KEY in the environment
19799
+ // outranks the stored profile inside requestOxygen, and passing the profile
19800
+ // explicitly would silently select with the wrong one.
19611
19801
  const data = await requestOxygen("/api/cli/orgs/select", {
19612
19802
  method: "POST",
19613
19803
  body: { organization },
19614
19804
  });
19615
19805
  const context = await resolveActiveProfileWithSource();
19616
- const credentials = context.resolution.credentials;
19617
- const canPersistSelection = Boolean(context.resolution.exists &&
19618
- credentials &&
19619
- (credentials.authKind === "user_session" || credentials.token.startsWith("oxy_user_")) &&
19620
- data.currentOrganization);
19621
- if (canPersistSelection && data.currentOrganization) {
19622
- await updateActiveOrganizationForProfile(context.resolution.name, storedOrganizationFromOption(data.currentOrganization));
19623
- }
19806
+ const profile = context.resolution.exists ? context.resolution.name : null;
19807
+ const selectionPersisted = await persistOrganizationSelection({
19808
+ profile,
19809
+ credentials: context.resolution.credentials,
19810
+ organization: data.currentOrganization,
19811
+ });
19624
19812
  const result = {
19625
19813
  ...data,
19626
- profile: context.resolution.exists ? context.resolution.name : null,
19627
- selection_persisted: canPersistSelection,
19814
+ profile,
19815
+ selection_persisted: selectionPersisted,
19628
19816
  };
19629
19817
  if (options.json) {
19630
19818
  writeJson(success(command, result));
19631
- return;
19632
19819
  }
19633
- writeJson(result);
19820
+ else {
19821
+ writeJson(result);
19822
+ }
19823
+ writeCredentialNotice(data.credential);
19634
19824
  }
19635
19825
  catch (error) {
19636
19826
  emitCliFailure(command, error);
@@ -19906,14 +20096,39 @@ async function login(options) {
19906
20096
  profile: chosenProfile,
19907
20097
  allowHostChange: options.force === true,
19908
20098
  });
20099
+ // `--org` collapses the two-step every agency user was doing by hand (`login`,
20100
+ // then `orgs use`). It is skipped when the credential already landed in the
20101
+ // requested workspace, so a workspace-bound key naming its own workspace
20102
+ // succeeds instead of being refused for asking to stay where it is.
20103
+ const requestedOrg = readOption(options.org) ?? process.env.OXYGEN_ORG?.trim() ?? null;
20104
+ let activeOrganization = loginIdentity.activeOrganization;
20105
+ let organizations = loginIdentity.organizations;
20106
+ if (requestedOrg && !matchesStoredOrganization(activeOrganization, requestedOrg)) {
20107
+ const selected = await selectOrganizationForProfile({
20108
+ organization: requestedOrg,
20109
+ profile,
20110
+ credentials,
20111
+ });
20112
+ activeOrganization = storedOrganizationFromOption(selected.data.currentOrganization);
20113
+ organizations = organizations.map((organization) => ({
20114
+ ...organization,
20115
+ selected: organization.id === selected.data.currentOrganization.id,
20116
+ }));
20117
+ }
19909
20118
  const skillsInstall = await runAutomaticSkillsInstall({ apiUrl: credentials.apiUrl, credentials });
19910
20119
  // Opt-in eager mirror of every reachable workspace, off by default so login
19911
20120
  // stays fast and non-surprising. Best-effort: a sync failure never fails login.
19912
20121
  if (process.env.OXYGEN_KNOWLEDGE_AUTOSYNC === "1") {
19913
20122
  await runKnowledgeMirrorSyncAll({ ifStale: true }).catch(() => undefined);
19914
20123
  }
20124
+ const resolved = {
20125
+ ...loginIdentity,
20126
+ activeOrganization,
20127
+ organizations,
20128
+ ...(requestedOrg ? { selectionRequired: false } : {}),
20129
+ };
19915
20130
  if (!options.json) {
19916
- process.stdout.write(formatLoginSuccessForResolved(loginIdentity, profile, { renamed, skillsInstall }));
20131
+ process.stdout.write(formatLoginSuccessForResolved(resolved, profile, { renamed, skillsInstall }));
19917
20132
  const hint = await buildPostLoginHint(profile);
19918
20133
  if (hint)
19919
20134
  process.stdout.write(hint);
@@ -19922,13 +20137,24 @@ async function login(options) {
19922
20137
  logged_in: true,
19923
20138
  profile,
19924
20139
  api_url: credentials.apiUrl,
19925
- user: loginIdentity.user,
19926
- organization: loginIdentity.activeOrganization,
19927
- organizations: loginIdentity.organizations,
19928
- selection_required: loginIdentity.selectionRequired,
20140
+ user: resolved.user,
20141
+ organization: resolved.activeOrganization,
20142
+ organizations: resolved.organizations,
20143
+ selection_required: resolved.selectionRequired,
19929
20144
  skills_install: skillsInstall,
19930
20145
  };
19931
20146
  }
20147
+ // `--org` accepts whatever `orgs list` prints, so the match has to accept the
20148
+ // same handles the server does — minus clerkOrgId, which stored profiles do not
20149
+ // carry.
20150
+ function matchesStoredOrganization(organization, ref) {
20151
+ if (!organization)
20152
+ return false;
20153
+ const normalized = ref.trim();
20154
+ if (!normalized)
20155
+ return false;
20156
+ return organization.id === normalized || organization.slug === normalized;
20157
+ }
19932
20158
  async function applyAuthToken(options) {
19933
20159
  const token = readOption(options.token);
19934
20160
  if (!token) {
@@ -20416,6 +20642,7 @@ function formatAuthDoctor(data) {
20416
20642
  const org = data.cached_organization.slug ?? data.cached_organization.id;
20417
20643
  lines.push(` ${styles.dim("Cached org")} ${data.cached_organization.name} (${org})`);
20418
20644
  }
20645
+ lines.push(` ${styles.dim("Org switching")} ${formatOrgSwitching(data.org_switching)}`);
20419
20646
  if (data.profile_error) {
20420
20647
  lines.push(` ${styles.dim("Profile error")} ${data.profile_error.code}: ${data.profile_error.message}`);
20421
20648
  }
@@ -20432,6 +20659,15 @@ function formatAuthDoctor(data) {
20432
20659
  lines.push("");
20433
20660
  return lines.join("\n");
20434
20661
  }
20662
+ function formatOrgSwitching(orgSwitching) {
20663
+ if (orgSwitching.switchable === true)
20664
+ return "available — `oxygen orgs use <organization>`";
20665
+ if (orgSwitching.switchable === false) {
20666
+ const name = orgSwitching.current_organization?.name;
20667
+ return `locked to ${name ?? "this workspace"} — \`oxygen login\` for a switchable user key`;
20668
+ }
20669
+ return "unknown";
20670
+ }
20435
20671
  function formatProfileOrgCell(profile) {
20436
20672
  if (!profile.organization)
20437
20673
  return "(unknown org — run `oxygen whoami` to refresh)";
@@ -37,21 +37,19 @@ export const OXYGEN_CAPABILITY_ROUTES = [
37
37
  primitive: null,
38
38
  owns: "First-run setup and interactive Workspace Copilot sessions over the shared primitive contracts.",
39
39
  notFor: "Recurring deterministic automation or standing adaptive jobs; use Workflows or Agents.",
40
- execution: "Start onboarding or a hosted Copilot session; every resulting action still uses its native primitive gate.",
40
+ execution: "Resolve factual onboarding state, then let the user choose a Recipe, Blueprint, or their own outcome before starting a hosted Copilot session.",
41
41
  posture: "mixed",
42
- gatewayTools: ["oxygen_whoami", "oxygen_activation_state", "oxygen_context_resolve", "oxygen_copilot_start"],
43
- gatewayCommands: ["whoami", "activation state", "context resolve", "copilot start"],
42
+ gatewayTools: ["oxygen_whoami", "oxygen_recipes_list", "oxygen_blueprints_list", "oxygen_context_resolve", "oxygen_copilot_start"],
43
+ gatewayCommands: ["whoami", "recipes list", "blueprints list", "context resolve", "copilot start"],
44
44
  skills: ["oxygen-quickstart", "oxygen-gtm"],
45
- // `activation` carries the prescribed-play step state and the one next step. It
46
- // belongs here rather than under a primitive because "where am I in setup" is a
47
- // Control question — and without the section on this card, the Copilot's very
48
- // first tool call routes past the only read that answers it.
45
+ // `activation` remains an exact legacy API family for saved clients, but it
46
+ // has no recommended gateway and is not an intent term or first-touch path.
49
47
  endpointSections: ["activation", "copilot", "home"],
50
48
  // "next step" alone is deliberately absent: it is a phrase every primitive's
51
49
  // users say ("next step of this workflow run"), and a 6-point multi-word hit
52
50
  // would drag those queries here. The terms kept are the ones only a founder
53
51
  // asking where they are in setup actually types.
54
- intentTerms: ["onboard", "onboarding", "first run", "first day", "setup oxygen", "copilot", "assistant", "guided", "activation", "where am i", "next step in setup", "what do i do next"],
52
+ intentTerms: ["onboard", "onboarding", "first run", "first day", "setup oxygen", "copilot", "assistant", "guided", "where am i", "next step in setup", "what do i do next"],
55
53
  },
56
54
  {
57
55
  id: "billing-and-budgets",
@@ -171,7 +169,7 @@ export const OXYGEN_CAPABILITY_ROUTES = [
171
169
  gatewayCommands: ["recipes list", "recipes show", "recipes install"],
172
170
  skills: ["oxygen-recipes"],
173
171
  endpointSections: ["blueprints", "recipes", "templates"],
174
- intentTerms: ["recipe", "playbook", "proven play", "journey", "day 1", "day 7", "day 30", "blueprint", "template", "what should i do"],
172
+ intentTerms: ["recipe", "playbook", "proven play", "activation play", "setup play", "journey", "day 1", "day 7", "day 30", "blueprint", "template", "what should i do"],
175
173
  },
176
174
  {
177
175
  id: "records",
@@ -7,14 +7,7 @@ export type CopilotJourney = {
7
7
  /** The first user message a journey card seeds into a new session. */
8
8
  seedMessage: string;
9
9
  };
10
- /**
11
- * The one journey a workspace with no play yet is pointed at. Exported as a named
12
- * constant because every consumer that needs "the prescribed play" must key on the
13
- * slug: the array is display-ordered, and `COPILOT_JOURNEYS[0]` would silently
14
- * re-designate a different journey the first time someone reorders the cards.
15
- * `home/suggestions.tsx` keys its glyphs the same way, for the same reason.
16
- */
17
- export declare const PRESCRIBED_COPILOT_JOURNEY_SLUG = "inbound-led-outbound";
10
+ export declare const LINKEDIN_NETWORK_COPILOT_JOURNEY_SLUG = "inbound-led-outbound";
18
11
  export declare const COPILOT_JOURNEYS: readonly CopilotJourney[];
19
12
  export type CopilotJourneySlug = (typeof COPILOT_JOURNEYS)[number]["slug"];
20
13
  export declare function getCopilotJourney(slug: string): CopilotJourney | null;
@@ -4,17 +4,12 @@
4
4
  // Each journey is a thin descriptor over EXISTING advisory catalog Recipes:
5
5
  // journeys add no runtime behavior, and every guardrail (never enroll/send/buy/
6
6
  // change DNS without approval) rides those recipes' own approval gates.
7
- /**
8
- * The one journey a workspace with no play yet is pointed at. Exported as a named
9
- * constant because every consumer that needs "the prescribed play" must key on the
10
- * slug: the array is display-ordered, and `COPILOT_JOURNEYS[0]` would silently
11
- * re-designate a different journey the first time someone reorders the cards.
12
- * `home/suggestions.tsx` keys its glyphs the same way, for the same reason.
13
- */
14
- export const PRESCRIBED_COPILOT_JOURNEY_SLUG = "inbound-led-outbound";
7
+ // Stable slug for sessions deliberately launched from the matching Recipe.
8
+ // It is never a default: first-touch surfaces stay outcome-neutral.
9
+ export const LINKEDIN_NETWORK_COPILOT_JOURNEY_SLUG = "inbound-led-outbound";
15
10
  export const COPILOT_JOURNEYS = [
16
11
  {
17
- slug: PRESCRIBED_COPILOT_JOURNEY_SLUG,
12
+ slug: LINKEDIN_NETWORK_COPILOT_JOURNEY_SLUG,
18
13
  title: "Inbound-led outbound",
19
14
  description: "Work the LinkedIn network you already have — connections and post engagers — as a capped warm motion.",
20
15
  // The network-first play leads: connections and post engagers are the two
@@ -29,10 +24,8 @@ export const COPILOT_JOURNEYS = [
29
24
  // Deliberately free of steps, commands, table names, and caps: encoding
30
25
  // mechanics here would fork a second, unversioned copy of the play that no
31
26
  // catalog gate can lint (ADR 0013 §11 — a journey is a thin pointer at
32
- // advisory recipes, never a hardcoded kit). The activation-state instruction
33
- // is the one piece of procedure that belongs here rather than in the recipe:
34
- // it is about where THIS workspace already stands, which the catalog cannot know.
35
- seedMessage: "I want to turn the LinkedIn network I already have — my connections and the people who engage with my posts — into a small, safe, approval-gated outreach motion. First read my workspace's current activation state and tell me where I already am, then resume from there instead of starting over. Follow the linkedin-network-first-motion recipe for the play itself. Do not connect an account, enroll anyone, send anything, or spend credits without asking me first.",
27
+ // advisory recipes, never a hardcoded kit).
28
+ seedMessage: "I chose the linkedin-network-first-motion recipe. Help me turn the LinkedIn network I already have — my connections and the people who engage with my posts — into a small, safe, approval-gated outreach motion. Inspect the relevant workspace primitives, follow the recipe, and resume from durable evidence instead of starting over. Do not connect an account, enroll anyone, send anything, or spend credits without asking me first.",
36
29
  },
37
30
  {
38
31
  slug: "tam-sourcing",
@@ -61,3 +61,31 @@ export declare function sendingSeatMonthlyPriceCents(key: SendingSeatKey): numbe
61
61
  export declare function sendingSeatSubtotalCents(key: SendingSeatKey, quantity: number): number;
62
62
  /** "$30.00" — seat prices are whole cents, so this never rounds. */
63
63
  export declare function formatSeatPrice(cents: number): string;
64
+ /**
65
+ * Whether the connect-time seat requirement is armed.
66
+ *
67
+ * FAIL-CLOSED TO OFF, deliberately, and shipped that way. Arming this refuses a
68
+ * sender connection that succeeds today, so it is a customer-visible enforcement
69
+ * change and belongs to a human decision, not to whenever the code happens to
70
+ * deploy. Same posture as the free-tier entitlement flag.
71
+ *
72
+ * It is also the switch that decides whether grandfathered organizations are
73
+ * capped at their cutover count or effectively unlimited: with the gate off,
74
+ * everyone connects freely; with it on, an org may add senders beyond its legacy
75
+ * entitlement only by buying seats. Do not arm it until that call is made and
76
+ * the legacy backfill has run for every channel being enforced.
77
+ */
78
+ export declare function sendingSeatGateEnabled(env?: NodeJS.ProcessEnv): boolean;
79
+ export declare const SENDING_SEAT_GATE_ENABLED_ENV_VAR = "OXYGEN_SENDING_SEAT_GATE_ENABLED";
80
+ /**
81
+ * Which seat kind a connect request consumes, by CLI path prefix.
82
+ *
83
+ * MAILBOXES ARE DELIBERATELY ABSENT. Email mailboxes live in the TENANT
84
+ * database, so the control-plane grandfathering backfill cannot see them and did
85
+ * not cover them. Enforcing a seat on mailbox connect today would refuse
86
+ * customers who already have mailboxes attached — the precise failure this whole
87
+ * grandfathering design exists to prevent. Add mailboxes here only once a
88
+ * tenant-aware backfill has granted their legacy entitlements.
89
+ */
90
+ export declare const SEAT_KIND_BY_CONNECT_PREFIX: ReadonlyArray<readonly [prefix: string, seatKey: SendingSeatKey]>;
91
+ export declare function seatKindForConnectPath(pathname: string): SendingSeatKey | null;
@@ -99,3 +99,46 @@ export function sendingSeatSubtotalCents(key, quantity) {
99
99
  export function formatSeatPrice(cents) {
100
100
  return `$${(cents / 100).toFixed(2)}`;
101
101
  }
102
+ /**
103
+ * Whether the connect-time seat requirement is armed.
104
+ *
105
+ * FAIL-CLOSED TO OFF, deliberately, and shipped that way. Arming this refuses a
106
+ * sender connection that succeeds today, so it is a customer-visible enforcement
107
+ * change and belongs to a human decision, not to whenever the code happens to
108
+ * deploy. Same posture as the free-tier entitlement flag.
109
+ *
110
+ * It is also the switch that decides whether grandfathered organizations are
111
+ * capped at their cutover count or effectively unlimited: with the gate off,
112
+ * everyone connects freely; with it on, an org may add senders beyond its legacy
113
+ * entitlement only by buying seats. Do not arm it until that call is made and
114
+ * the legacy backfill has run for every channel being enforced.
115
+ */
116
+ export function sendingSeatGateEnabled(env = process.env) {
117
+ const raw = env[SENDING_SEAT_GATE_ENABLED_ENV_VAR];
118
+ if (!raw)
119
+ return false;
120
+ const normalized = raw.trim().toLowerCase();
121
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
122
+ }
123
+ export const SENDING_SEAT_GATE_ENABLED_ENV_VAR = "OXYGEN_SENDING_SEAT_GATE_ENABLED";
124
+ /**
125
+ * Which seat kind a connect request consumes, by CLI path prefix.
126
+ *
127
+ * MAILBOXES ARE DELIBERATELY ABSENT. Email mailboxes live in the TENANT
128
+ * database, so the control-plane grandfathering backfill cannot see them and did
129
+ * not cover them. Enforcing a seat on mailbox connect today would refuse
130
+ * customers who already have mailboxes attached — the precise failure this whole
131
+ * grandfathering design exists to prevent. Add mailboxes here only once a
132
+ * tenant-aware backfill has granted their legacy entitlements.
133
+ */
134
+ export const SEAT_KIND_BY_CONNECT_PREFIX = [
135
+ ["/api/cli/senders/connect", "linkedin"],
136
+ ["/api/cli/whatsapp/connect", "whatsapp"],
137
+ ];
138
+ export function seatKindForConnectPath(pathname) {
139
+ for (const [prefix, seatKey] of SEAT_KIND_BY_CONNECT_PREFIX) {
140
+ if (pathname === prefix || pathname.startsWith(`${prefix}/`))
141
+ return seatKey;
142
+ }
143
+ return null;
144
+ }
@@ -1,4 +1,4 @@
1
- export declare const OXYGEN_VERSION = "1.853.1";
1
+ export declare const OXYGEN_VERSION = "1.861.0";
2
2
  export declare const OXYGEN_MINIMUM_CLI_VERSION = "1.181.0";
3
3
  export declare const MANAGED_INBOX_MINIMUM_CLI_VERSION = "1.326.2";
4
4
  export declare const SUPPORT_AGENT_REPLY_MINIMUM_CLI_VERSION = "1.747.0";
@@ -1,4 +1,4 @@
1
- export const OXYGEN_VERSION = "1.853.1";
1
+ export const OXYGEN_VERSION = "1.861.0";
2
2
  // The GLOBAL CLI compatibility floor: the oldest CLI allowed to call any
3
3
  // operational route. Raising it hard-rejects every older CLI from the entire
4
4
  // product, so it obeys one law, enforced by scripts/ci/cli-min-version-gate.mjs:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.853.1",
3
+ "version": "1.861.0",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",