@oxygen-agent/cli 1.233.8 → 1.244.2

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,8 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
- import { basename, dirname, extname, resolve } from "node:path";
5
+ import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { stdin as input, stdout as output } from "node:process";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -337,6 +337,21 @@ function readFileIfPresent(value) {
337
337
  throw error;
338
338
  }
339
339
  }
340
+ // `--suppress-list <comma ids or @file>`: a comma-separated list of lead provider
341
+ // ids, or `@<path>` to a file of ids (comma/newline/whitespace separated). Returns
342
+ // the trimmed, de-duplicated, blank-free ids; an absent value yields an empty list.
343
+ function parseSuppressListOption(value) {
344
+ const raw = readOption(value);
345
+ if (!raw)
346
+ return [];
347
+ const text = raw.startsWith("@") ? readFileSync(resolve(raw.slice(1)), "utf8") : raw;
348
+ return [
349
+ ...new Set(text
350
+ .split(/[\s,]+/)
351
+ .map((id) => id.trim())
352
+ .filter((id) => id.length > 0)),
353
+ ];
354
+ }
340
355
  function resolveComposioRunMode(options) {
341
356
  if (options.live === true && options.dryRun === true) {
342
357
  throw new OxygenError("conflicting_flags", "Pass either --live or --dry-run, not both.", { exitCode: 1 });
@@ -620,6 +635,56 @@ function buildCrmDuplicatesPath(object, options) {
620
635
  const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/duplicates`;
621
636
  return query ? `${base}?${query}` : base;
622
637
  }
638
+ function buildCrmSignalRecordBody(options) {
639
+ const event = readOption(options.event);
640
+ if (!event) {
641
+ throw new OxygenError("invalid_request", "--event is required.", { exitCode: 1 });
642
+ }
643
+ const sessionId = readOption(options.sessionId);
644
+ // The redelivery/idempotency guard: an explicit id, else derived from the
645
+ // provider session id so a repeat reveal still dedupes.
646
+ const externalEventId = readOption(options.externalEventId) ?? (sessionId ? `signal:${sessionId}` : null);
647
+ if (!externalEventId) {
648
+ throw new OxygenError("invalid_request", "--external-event-id is required (or pass --session-id to derive it).", { exitCode: 1 });
649
+ }
650
+ const payload = {};
651
+ const email = readOption(options.email);
652
+ if (email)
653
+ payload.email = email;
654
+ const linkedinUrl = readOption(options.linkedinUrl);
655
+ if (linkedinUrl)
656
+ payload.linkedin_url = linkedinUrl;
657
+ const companyDomain = readOption(options.companyDomain);
658
+ if (companyDomain)
659
+ payload.company_domain = companyDomain;
660
+ const companyName = readOption(options.companyName);
661
+ if (companyName)
662
+ payload.company_name = companyName;
663
+ const firstName = readOption(options.firstName);
664
+ if (firstName)
665
+ payload.first_name = firstName;
666
+ const lastName = readOption(options.lastName);
667
+ if (lastName)
668
+ payload.last_name = lastName;
669
+ const pageUrl = readOption(options.pageUrl);
670
+ if (pageUrl)
671
+ payload.page_url = pageUrl;
672
+ if (sessionId)
673
+ payload.session_id = sessionId;
674
+ // An explicit --payload-json wins over the convenience flags above.
675
+ if (options.payloadJson)
676
+ Object.assign(payload, parseJsonObject(options.payloadJson));
677
+ return { event, external_event_id: externalEventId, payload };
678
+ }
679
+ function buildCrmLeadsTodayPath(options) {
680
+ const params = new URLSearchParams();
681
+ if (options.limit)
682
+ params.set("limit", options.limit);
683
+ if (options.withinDays)
684
+ params.set("within_days", options.withinDays);
685
+ const query = params.toString();
686
+ return query ? `/api/cli/crm/signals/leads-today?${query}` : "/api/cli/crm/signals/leads-today";
687
+ }
623
688
  function buildCrmAutomationSetBody(templateId, options) {
624
689
  if (options.armed === true && options.disarmed === true) {
625
690
  throw new OxygenError("conflicting_flags", "Pass either --armed or --disarmed, not both.", { exitCode: 1 });
@@ -1588,6 +1653,36 @@ export function createProgram() {
1588
1653
  .action(async (options) => {
1589
1654
  await handleAsyncAction("crm pipeline", options, () => requestOxygen(buildCrmPipelinePath(options)));
1590
1655
  }))
1656
+ .addCommand(new Command("signals")
1657
+ .description("Record inbound GTM intent signals and read the ranked leads-to-contact-today list.")
1658
+ .addCommand(new Command("record")
1659
+ .description("Record one inbound GTM signal (website_visit, profile_view, post_engager, new_follower) into the CRM. Internal write — free, no approval. Idempotent on --external-event-id.")
1660
+ .requiredOption("--event <event>", "Signal event: website_visit | profile_view | post_engager | new_follower.")
1661
+ .option("--external-event-id <id>", "Stable provider event id (redelivery/idempotency guard). Derived from --session-id when omitted.")
1662
+ .option("--email <email>", "Person email (identity).")
1663
+ .option("--linkedin-url <url>", "Person LinkedIn URL (identity when no email).")
1664
+ .option("--company-domain <domain>", "Company domain for the company assert.")
1665
+ .option("--company-name <name>", "Company name.")
1666
+ .option("--first-name <name>", "Person first name.")
1667
+ .option("--last-name <name>", "Person last name.")
1668
+ .option("--page-url <url>", "Page the signal fired on (website visit).")
1669
+ .option("--session-id <id>", "Provider session id (provenance + idempotency).")
1670
+ .option("--payload-json <json>", "JSON object merged into the signal payload (wins over the flags above).")
1671
+ .option("--json", "Print a JSON envelope.")
1672
+ .action(async (options) => {
1673
+ await handleAsyncAction("crm signals record", options, () => requestOxygen("/api/cli/crm/signals", {
1674
+ method: "POST",
1675
+ body: buildCrmSignalRecordBody(options),
1676
+ }));
1677
+ }))
1678
+ .addCommand(new Command("leads-today")
1679
+ .description("List people to contact today, ranked by their most recent GTM intent signal. Do-not-contact filtering is best-effort (email-only leads surface unchecked).")
1680
+ .option("--limit <limit>", "Maximum leads to return. Defaults to 25, max 100.")
1681
+ .option("--within-days <days>", "Signal look-back window in days. Defaults to 7, max 30.")
1682
+ .option("--json", "Print a JSON envelope.")
1683
+ .action(async (options) => {
1684
+ await handleAsyncAction("crm signals leads-today", options, () => requestOxygen(buildCrmLeadsTodayPath(options)));
1685
+ })))
1591
1686
  .addCommand(new Command("lists")
1592
1687
  .description("Static and dynamic CRM lists over companies, people, deals, and custom objects.")
1593
1688
  .addCommand(new Command("create")
@@ -1701,7 +1796,7 @@ export function createProgram() {
1701
1796
  }));
1702
1797
  }))
1703
1798
  .addCommand(new Command("configure")
1704
- .description("Configure a standing bidirectional sync for one CRM object pair. Defaults to dry-run, which returns a real preview of the next cycle.")
1799
+ .description("Configure a standing bidirectional sync for one CRM object pair. Defaults to dry-run, which returns a real preview of the next cycle; inbound and bidirectional previews perform bounded CRM provider reads and may use external API quota.")
1705
1800
  .requiredOption("--provider <provider>", "Connected CRM: hubspot or attio.")
1706
1801
  .requiredOption("--object <object>", "Provider object: hubspot contacts|companies, or attio people|companies.")
1707
1802
  .option("--direction <direction>", "Sync direction: inbound, outbound, or bidirectional. Defaults to bidirectional.")
@@ -1710,7 +1805,7 @@ export function createProgram() {
1710
1805
  .option("--max-rows <n>", "Per-cycle row cap. The real volume brake on provider writes (BYOK writes cost 0 Oxygen credits).")
1711
1806
  .option("--max-credits <n>", "Managed-credit + external-quota cap per cycle.")
1712
1807
  .option("--approved", "Confirm a live configuration after inspecting a dry run.")
1713
- .option("--dry-run", "Preview the configuration and next cycle without writing. Default.")
1808
+ .option("--dry-run", "Preview the configuration and next cycle without writing. Inbound/bidirectional previews perform bounded CRM reads and may use external API quota. Default.")
1714
1809
  .option("--live", "Write the configuration. Requires --approved and --max-credits.")
1715
1810
  .option("--json", "Print a JSON envelope.")
1716
1811
  .action(async (options) => {
@@ -2330,6 +2425,21 @@ export function createProgram() {
2330
2425
  .option("--json", "Print a JSON envelope.")
2331
2426
  .action(async (options) => {
2332
2427
  await handleAsyncAction("context assets list", options, () => requestOxygen(`/api/cli/context/assets${contextAssetsQuery(options)}`));
2428
+ }))
2429
+ .addCommand(new Command("search")
2430
+ .description("Full-text 'second brain' search across context assets, ranked by relevance.")
2431
+ .requiredOption("--query <text>", "Search text. Supports quoted phrases, OR, and -exclude.")
2432
+ .option("--type <type>", "Filter by playbook, strategy, campaign, positioning, brand, voice, persona, competitor, research_note, message_playbook, or other.")
2433
+ .option("--status <status>", "Filter by draft, active, or archived.")
2434
+ .option("--tags <csv>", "Comma-separated tags that must be present.")
2435
+ .option("--include-archived", "Include archived assets when no status filter is set.")
2436
+ .option("--limit <n>", "Maximum assets to return. Defaults to 100; hard cap is 500.")
2437
+ .option("--json", "Print a JSON envelope.")
2438
+ .action(async (options) => {
2439
+ await handleAsyncAction("context assets search", options, () => requestOxygen("/api/cli/context/assets/search", {
2440
+ method: "POST",
2441
+ body: buildContextAssetSearchBody(options),
2442
+ }));
2333
2443
  }))
2334
2444
  .addCommand(new Command("get")
2335
2445
  .description("Read one context asset.")
@@ -2965,7 +3075,7 @@ export function createProgram() {
2965
3075
  .option("--json", "Print a JSON envelope.")
2966
3076
  .action(async (table, column, options) => {
2967
3077
  await handleAsyncAction("columns reorder", options, async () => {
2968
- const position = await resolveColumnReorderPosition(table, options);
3078
+ const position = await resolveColumnReorderPosition(table, column, options);
2969
3079
  return requestOxygen("/api/cli/tables/columns/reorder", {
2970
3080
  method: "POST",
2971
3081
  body: {
@@ -4610,6 +4720,13 @@ export function createProgram() {
4610
4720
  .option("--json", "Print a JSON envelope.")
4611
4721
  .action(async (id, options) => {
4612
4722
  await handleAsyncAction("senders get", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}`));
4723
+ }))
4724
+ .addCommand(new Command("health")
4725
+ .description("Show a one-call health snapshot for a LinkedIn sender: status, the latest error reason, any open security checkpoint, today's usage, the warm-up ramp, and when the daily quota resets. Read-only — no provider call, no credits. <id> accepts a sender account id, connection id, or Unipile account id.")
4726
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
4727
+ .option("--json", "Print a JSON envelope.")
4728
+ .action(async (id, options) => {
4729
+ await handleAsyncAction("senders health", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/health`));
4613
4730
  }))
4614
4731
  .addCommand(new Command("sync")
4615
4732
  .description("Refresh LinkedIn account state (status, profile) from Unipile. Pass --connection-id to sync one account, or omit it to sync all.")
@@ -4635,6 +4752,29 @@ export function createProgram() {
4635
4752
  method: "POST",
4636
4753
  }));
4637
4754
  }))
4755
+ .addCommand(new Command("checkpoint")
4756
+ .description("Resolve a LinkedIn security checkpoint (2FA / OTP / in-app validation) so a stuck sender can return to active. Account administration — no credits, no approval gate.")
4757
+ .option("--json", "Print a JSON envelope.")
4758
+ .addCommand(new Command("solve")
4759
+ .description("Submit the LinkedIn checkpoint code Unipile is waiting on to finish authentication. <id> accepts a sender account id, connection id, or Unipile account id; <code> is the verification code LinkedIn sent.")
4760
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
4761
+ .argument("<code>", "The verification code LinkedIn sent (2FA / OTP / in-app validation).")
4762
+ .option("--json", "Print a JSON envelope.")
4763
+ .action(async (id, code, options) => {
4764
+ await handleAsyncAction("senders checkpoint solve", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/checkpoint/solve`, {
4765
+ method: "POST",
4766
+ body: { code },
4767
+ }));
4768
+ }))
4769
+ .addCommand(new Command("resend")
4770
+ .description("Ask LinkedIn to resend the checkpoint code so you can read a fresh one, then submit it with `senders checkpoint solve`. <id> accepts a sender account id, connection id, or Unipile account id.")
4771
+ .argument("<id>", "Sender account id, connection id, or Unipile account id.")
4772
+ .option("--json", "Print a JSON envelope.")
4773
+ .action(async (id, options) => {
4774
+ await handleAsyncAction("senders checkpoint resend", options, () => requestOxygen(`/api/cli/senders/${encodeURIComponent(id)}/checkpoint/resend`, {
4775
+ method: "POST",
4776
+ }));
4777
+ })))
4638
4778
  .addCommand(new Command("limits")
4639
4779
  .description("View and adjust per-account daily action limits and the daily-reset timezone.")
4640
4780
  .option("--json", "Print a JSON envelope.")
@@ -4712,6 +4852,80 @@ export function createProgram() {
4712
4852
  return requestOxygen(`/api/cli/linkedin/connections/status${suffix ? `?${suffix}` : ""}`);
4713
4853
  });
4714
4854
  })));
4855
+ program.addCommand(new Command("followers")
4856
+ .description("Read a LinkedIn account's followers into a Clay-like workspace table and an enrollable CRM list you can enrich, qualify, and enroll from. Imports run as a slow, durable background drip under a dedicated conservative read budget — they never burst and never starve the sequencer's own reads.")
4857
+ .addCommand(new Command("import")
4858
+ .description("Start (or re-arm) a followers import. Followers drip into a workspace table + the deduped mirror over many ticks, and newly-seen people are bridged into an enrollable CRM static list; poll `followers status` to watch them accrue. The import keeps itself fresh by periodically re-walking followers in the background (new followers dedupe in). No messages are sent and no Oxygen credits are charged.")
4859
+ .option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id). Omit to import every active LinkedIn account.")
4860
+ .option("--table <ref>", "Existing workspace table (id or slug) to import into. Omit to create or reuse a 'LinkedIn Followers' table.")
4861
+ .option("--list <slug>", "Enrollable CRM static list slug to bridge new followers into. Omit for the default 'linkedin-followers' list.")
4862
+ .option("--json", "Print a JSON envelope.")
4863
+ .action(async (options) => {
4864
+ await handleAsyncAction("followers import", options, () => {
4865
+ const account = readOption(options.account);
4866
+ const table = readOption(options.table);
4867
+ const list = readOption(options.list);
4868
+ return requestOxygen("/api/cli/linkedin/followers/import", {
4869
+ method: "POST",
4870
+ body: {
4871
+ ...(account ? { account } : {}),
4872
+ ...(table ? { table } : {}),
4873
+ ...(list ? { list } : {}),
4874
+ },
4875
+ });
4876
+ });
4877
+ }))
4878
+ .addCommand(new Command("status")
4879
+ .description("Show followers-import progress (per-sender drip status, followers ingested, last error) plus a preview of the followers table, the enrollable CRM list, and a deep-link to open it.")
4880
+ .option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
4881
+ .option("--json", "Print a JSON envelope.")
4882
+ .action(async (options) => {
4883
+ await handleAsyncAction("followers status", options, () => {
4884
+ const account = readOption(options.account);
4885
+ const params = new URLSearchParams();
4886
+ if (account)
4887
+ params.set("account", account);
4888
+ const suffix = params.toString();
4889
+ return requestOxygen(`/api/cli/linkedin/followers/status${suffix ? `?${suffix}` : ""}`);
4890
+ });
4891
+ })));
4892
+ program.addCommand(new Command("viewers")
4893
+ .description("Read the named LinkedIn 'who viewed my profile' (WVMP) viewers into a Clay-like workspace table and an enrollable CRM list. Imports run as a slow, durable background drip under a dedicated conservative read budget. LinkedIn hides anonymous/private viewers, so the list is always a partial sample.")
4894
+ .addCommand(new Command("import")
4895
+ .description("Start (or re-arm) a durable profile-viewers (WVMP) import. Named viewers drip into a workspace table + the deduped mirror (view_count / last_viewed_at accumulate) over many ticks, and newly-seen viewers are bridged into an enrollable CRM static list; poll `viewers status` to watch them accrue. No messages are sent and no Oxygen credits are charged.")
4896
+ .option("--account <ref>", "Sender account to import (sender id, connection id, or Unipile account id). Omit to import every active LinkedIn account.")
4897
+ .option("--table <ref>", "Existing workspace table (id or slug) to import into. Omit to create or reuse a 'LinkedIn Profile Viewers' table.")
4898
+ .option("--list <slug>", "Enrollable CRM static list slug to bridge new viewers into. Omit for the default 'linkedin-profile-viewers' list.")
4899
+ .option("--json", "Print a JSON envelope.")
4900
+ .action(async (options) => {
4901
+ await handleAsyncAction("viewers import", options, () => {
4902
+ const account = readOption(options.account);
4903
+ const table = readOption(options.table);
4904
+ const list = readOption(options.list);
4905
+ return requestOxygen("/api/cli/linkedin/viewers/import", {
4906
+ method: "POST",
4907
+ body: {
4908
+ ...(account ? { account } : {}),
4909
+ ...(table ? { table } : {}),
4910
+ ...(list ? { list } : {}),
4911
+ },
4912
+ });
4913
+ });
4914
+ }))
4915
+ .addCommand(new Command("status")
4916
+ .description("Show durable profile-viewers (WVMP) import progress (per-sender drip status, viewers ingested, last error, the preserved privacy ceiling note) plus a preview of the viewers table, the enrollable CRM list, and a deep-link to open it.")
4917
+ .option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
4918
+ .option("--json", "Print a JSON envelope.")
4919
+ .action(async (options) => {
4920
+ await handleAsyncAction("viewers status", options, () => {
4921
+ const account = readOption(options.account);
4922
+ const params = new URLSearchParams();
4923
+ if (account)
4924
+ params.set("account", account);
4925
+ const suffix = params.toString();
4926
+ return requestOxygen(`/api/cli/linkedin/viewers/status${suffix ? `?${suffix}` : ""}`);
4927
+ });
4928
+ })));
4715
4929
  program.addCommand(new Command("engagement")
4716
4930
  .description("Harvest a LinkedIn post's engagers (reactors + commenters) into an enrollable CRM static list. The harvest runs as a slow, durable background drip under a dedicated conservative read budget — it never bursts and never starves the sequencer's own reads.")
4717
4931
  .addCommand(new Command("harvest")
@@ -4768,6 +4982,67 @@ export function createProgram() {
4768
4982
  const suffix = params.toString();
4769
4983
  return requestOxygen(`/api/cli/linkedin/engagement/harvest${suffix ? `?${suffix}` : ""}`);
4770
4984
  });
4985
+ }))
4986
+ .addCommand(new Command("react")
4987
+ .description("React to a LinkedIn post (or to a comment with --comment-id). A real external LinkedIn write that everyone can see and that counts against the sender account's daily action quota, gated behind approval: prints a preview by default and only reacts when you pass --approved. --post is the composite post social_id from `oxygen posts get` (NOT the activity URN).")
4988
+ .requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
4989
+ .option("--reaction-type <type>", "Reaction kind: like, celebrate, support, love, insightful, or funny (default like).")
4990
+ .option("--comment-id <id>", "React to this comment id instead of the post itself.")
4991
+ .option("--as-organization <org>", "LinkedIn organization id to react as a company page.")
4992
+ .option("--account <ref>", "Sender account that reacts (sender id, connection id, or Unipile account id). Omit for the org default.")
4993
+ .option("--approved", "Actually add the reaction. Omit to preview only.")
4994
+ .option("--json", "Print a JSON envelope.")
4995
+ .action(async (options) => {
4996
+ await handleAsyncAction("engagement react", options, () => {
4997
+ const post = readOption(options.post);
4998
+ const reactionType = readOption(options.reactionType);
4999
+ const commentId = readOption(options.commentId);
5000
+ const asOrganization = readOption(options.asOrganization);
5001
+ const account = readOption(options.account);
5002
+ return requestOxygen("/api/cli/linkedin/engagement/react", {
5003
+ method: "POST",
5004
+ body: {
5005
+ ...(post ? { post } : {}),
5006
+ ...(reactionType ? { reaction_type: reactionType } : {}),
5007
+ ...(commentId ? { comment_id: commentId } : {}),
5008
+ ...(asOrganization ? { as_organization: asOrganization } : {}),
5009
+ ...(account ? { account } : {}),
5010
+ ...(options.approved ? { approved: true } : {}),
5011
+ },
5012
+ });
5013
+ });
5014
+ }))
5015
+ .addCommand(new Command("comment")
5016
+ .description("Comment on a LinkedIn post (or reply to a comment with --comment-id). A real external LinkedIn write that everyone can see and that counts against the sender account's daily action quota, gated behind approval: prints a preview by default and only posts when you pass --approved. --post is the composite post social_id from `oxygen posts get` (NOT the activity URN).")
5017
+ .requiredOption("--post <social_id>", "Composite post social_id from `oxygen posts get` (NOT the activity URN).")
5018
+ .requiredOption("--text <text>", "Comment text to post.")
5019
+ .option("--comment-id <id>", "Reply under this comment id instead of commenting on the post.")
5020
+ .option("--as-organization <org>", "LinkedIn organization id to comment as a company page.")
5021
+ .option("--external-link <url>", "URL to attach as a link preview on the comment.")
5022
+ .option("--account <ref>", "Sender account that comments (sender id, connection id, or Unipile account id). Omit for the org default.")
5023
+ .option("--approved", "Actually post the comment. Omit to preview only.")
5024
+ .option("--json", "Print a JSON envelope.")
5025
+ .action(async (options) => {
5026
+ await handleAsyncAction("engagement comment", options, () => {
5027
+ const post = readOption(options.post);
5028
+ const text = readOption(options.text);
5029
+ const commentId = readOption(options.commentId);
5030
+ const asOrganization = readOption(options.asOrganization);
5031
+ const externalLink = readOption(options.externalLink);
5032
+ const account = readOption(options.account);
5033
+ return requestOxygen("/api/cli/linkedin/engagement/comment", {
5034
+ method: "POST",
5035
+ body: {
5036
+ ...(post ? { post } : {}),
5037
+ ...(text ? { text } : {}),
5038
+ ...(commentId ? { comment_id: commentId } : {}),
5039
+ ...(asOrganization ? { as_organization: asOrganization } : {}),
5040
+ ...(externalLink ? { external_link: externalLink } : {}),
5041
+ ...(account ? { account } : {}),
5042
+ ...(options.approved ? { approved: true } : {}),
5043
+ },
5044
+ });
5045
+ });
4771
5046
  })));
4772
5047
  program.addCommand(new Command("linkedin")
4773
5048
  .description("LinkedIn read-into-workspace ingestion controls (Goal-3): the unified status of the connections / engagement / message-history drips and each account's remaining ingest read budget.")
@@ -4786,6 +5061,74 @@ export function createProgram() {
4786
5061
  const suffix = params.toString();
4787
5062
  return requestOxygen(`/api/cli/linkedin/ingestion/status${suffix ? `?${suffix}` : ""}`);
4788
5063
  });
5064
+ })))
5065
+ .addCommand(new Command("connections")
5066
+ .description("Send LinkedIn connection invitations from a sender account.")
5067
+ .addCommand(new Command("invite")
5068
+ .description("Send a LinkedIn connection invitation. A real external social action gated behind approval: prints a preview by default and only sends when you pass --approved. The recipient can be a member provider id (ACo…) or a profile URL / public identifier. The per-account invite quota and warm-up ramp are enforced server-side, and already-invited recipients are de-duplicated automatically.")
5069
+ .requiredOption("--recipient <id_or_url>", "Recipient LinkedIn provider id (ACo…) or profile URL / public identifier.")
5070
+ .option("--account <ref>", "Sender account that sends the invite (sender id, connection id, or Unipile account id). Omit for the org default.")
5071
+ .option("--message <text>", "Optional invitation note (~300 chars max).")
5072
+ .option("--approved", "Actually send the invite. Omit to preview only.")
5073
+ .option("--json", "Print a JSON envelope.")
5074
+ .action(async (options) => {
5075
+ await handleAsyncAction("linkedin connections invite", options, () => {
5076
+ const recipient = readOption(options.recipient);
5077
+ const account = readOption(options.account);
5078
+ const message = readOption(options.message);
5079
+ return requestOxygen("/api/cli/linkedin/connections/invite", {
5080
+ method: "POST",
5081
+ body: {
5082
+ ...(recipient ? { recipient } : {}),
5083
+ ...(account ? { account } : {}),
5084
+ ...(message ? { message } : {}),
5085
+ ...(options.approved ? { approved: true } : {}),
5086
+ },
5087
+ });
5088
+ });
5089
+ })))
5090
+ .addCommand(new Command("invitations")
5091
+ .description("List and withdraw the pending LinkedIn connection invitations a sender account has sent.")
5092
+ .addCommand(new Command("withdraw")
5093
+ .description("Withdraw pending sent LinkedIn invitations. A real external social action gated behind approval: prints the resolved target list by default and only withdraws when you pass --approved. Select targets with --ids (a comma-separated id list from `linkedin invitations list`) OR --older-than <days> (pending invites older than N days, among the 100 most recent sent) — not both. Idempotent: an invite that is already gone counts as withdrawn.")
5094
+ .option("--ids <a,b>", "Comma-separated invitation ids to withdraw (from `linkedin invitations list`).")
5095
+ .option("--older-than <days>", "Withdraw pending sent invites older than this many days (among the 100 most recent sent) instead of an explicit id list.")
5096
+ .option("--account <ref>", "Sender account (sender id, connection id, or Unipile account id). Omit for the org default.")
5097
+ .option("--approved", "Actually withdraw. Omit to preview the target list only.")
5098
+ .option("--json", "Print a JSON envelope.")
5099
+ .action(async (options) => {
5100
+ await handleAsyncAction("linkedin invitations withdraw", options, () => {
5101
+ const ids = (options.ids ?? "").split(",").map((id) => id.trim()).filter(Boolean);
5102
+ const olderThan = readPositiveInteger(options.olderThan);
5103
+ const account = readOption(options.account);
5104
+ return requestOxygen("/api/cli/linkedin/connections/invite/withdraw", {
5105
+ method: "POST",
5106
+ body: {
5107
+ ...(ids.length > 0 ? { invitation_ids: ids } : {}),
5108
+ ...(olderThan !== undefined ? { older_than_days: olderThan } : {}),
5109
+ ...(account ? { account } : {}),
5110
+ ...(options.approved ? { approved: true } : {}),
5111
+ },
5112
+ });
5113
+ });
5114
+ }))
5115
+ .addCommand(new Command("list")
5116
+ .description("List the account's pending sent LinkedIn invitations with their invitation ids — the ids `linkedin invitations withdraw --ids` cancels. Read-only: no provider write, no credits.")
5117
+ .option("--account <ref>", "Scope to one sender account (sender id, connection id, or Unipile account id).")
5118
+ .option("--limit <n>", "Maximum invitations to return.")
5119
+ .option("--json", "Print a JSON envelope.")
5120
+ .action(async (options) => {
5121
+ await handleAsyncAction("linkedin invitations list", options, () => {
5122
+ const account = readOption(options.account);
5123
+ const limit = readPositiveInteger(options.limit);
5124
+ const params = new URLSearchParams();
5125
+ if (account)
5126
+ params.set("account", account);
5127
+ if (limit !== undefined)
5128
+ params.set("limit", String(limit));
5129
+ const suffix = params.toString();
5130
+ return requestOxygen(`/api/cli/linkedin/connections/invites-sent${suffix ? `?${suffix}` : ""}`);
5131
+ });
4789
5132
  }))));
4790
5133
  program.addCommand(new Command("whatsapp")
4791
5134
  .description("Manage the org's connected WhatsApp accounts for Sequencer: list, connect, sync, inspect, disconnect, tune daily limits, and warm-send into an existing conversation. Cold outbound runs through a WhatsApp sequence (sequences create --channels whatsapp).")
@@ -5531,9 +5874,11 @@ export function createProgram() {
5531
5874
  await handleAsyncAction("sequences get", options, () => requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`));
5532
5875
  }))
5533
5876
  .addCommand(new Command("enroll")
5534
- .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row.")
5877
+ .description("Enroll leads into a sequence from a JSON file of { leads: [...] }. When the sequence is bound to a source table, a lead's table_row_id auto-snapshots that row's columns (incl. AI/tool outputs) into row_values for {{column}} copy — explicit row_values win. Idempotent per table row. The org do-not-contact list is always enforced; --exclude-contacted and --suppress-list add further opt-in skips (reported under skipped_by_reason).")
5535
5878
  .argument("<sequence>", "Sequence id or slug.")
5536
5879
  .requiredOption("--leads-file <path>", "Path to a JSON file: { \"leads\": [{ lead_provider_id, lead_name, table_row_id, row_values }] }.")
5880
+ .option("--exclude-contacted", "Also skip leads any OTHER active sequence is already contacting (cross-campaign exclusion). Off by default.")
5881
+ .option("--suppress-list <ids>", "Per-call do-not-enroll lead provider ids dropped for this enroll only: a comma-separated list, or @<path> to a file of ids (comma/whitespace separated).")
5537
5882
  .option("--json", "Print a JSON envelope.")
5538
5883
  .action(async (sequence, options) => {
5539
5884
  await handleAsyncAction("sequences enroll", options, () => {
@@ -5541,9 +5886,14 @@ export function createProgram() {
5541
5886
  if (!leadsPath)
5542
5887
  throw new Error("--leads-file is required.");
5543
5888
  const parsed = readJsonFileValue(resolve(leadsPath), "--leads-file");
5889
+ const suppressList = parseSuppressListOption(options.suppressList);
5544
5890
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}/enroll`, {
5545
5891
  method: "POST",
5546
- body: { leads: parsed.leads ?? [] },
5892
+ body: {
5893
+ leads: parsed.leads ?? [],
5894
+ ...(options.excludeContacted ? { exclude_contacted: true } : {}),
5895
+ ...(suppressList.length > 0 ? { suppress_list: suppressList } : {}),
5896
+ },
5547
5897
  });
5548
5898
  });
5549
5899
  }))
@@ -5640,6 +5990,71 @@ export function createProgram() {
5640
5990
  .action(async (sequence, options) => {
5641
5991
  await handleSequenceVariantsAction(sequence, options);
5642
5992
  })));
5993
+ program.addCommand(new Command("suppressions")
5994
+ .description("Org do-not-contact list (people/multichannel): lead provider ids the sequencer enroller always skips at plan time. list | add | remove. Consumes 0 credits.")
5995
+ .addCommand(new Command("list")
5996
+ .description("List the org's do-not-contact suppressions, newest first. Filter by --reason or by --search (case-insensitive substring of the lead provider id).")
5997
+ .option("--reason <reason>", "Filter by reason: manual, replied, unsubscribed, bounced, do_not_contact, friends.")
5998
+ .option("--search <text>", "Case-insensitive substring match on the lead provider id.")
5999
+ .option("--limit <n>", "Maximum suppressions to return (1-500; default 100).")
6000
+ .option("--offset <n>", "Pagination offset (0-based).")
6001
+ .option("--json", "Print a JSON envelope.")
6002
+ .action(async (options) => {
6003
+ await handleAsyncAction("suppressions list", options, () => {
6004
+ const params = new URLSearchParams();
6005
+ const reason = readOption(options.reason);
6006
+ if (reason)
6007
+ params.set("reason", reason);
6008
+ const search = readOption(options.search);
6009
+ if (search)
6010
+ params.set("search", search);
6011
+ const limit = readOption(options.limit);
6012
+ if (limit)
6013
+ params.set("limit", limit);
6014
+ const offset = readOption(options.offset);
6015
+ if (offset)
6016
+ params.set("offset", offset);
6017
+ const suffix = params.toString();
6018
+ return requestOxygen(`/api/cli/suppressions${suffix ? `?${suffix}` : ""}`);
6019
+ });
6020
+ }))
6021
+ .addCommand(new Command("add")
6022
+ .description("Add (or refresh) a lead provider id on the org do-not-contact list so the sequencer never enrolls them again. Idempotent. --reason defaults to manual.")
6023
+ .requiredOption("--lead <provider_id>", "The lead provider id (e.g. a LinkedIn ACo... id) to suppress.")
6024
+ .option("--reason <reason>", "Why: manual (default), replied, unsubscribed, bounced, do_not_contact, friends.")
6025
+ .option("--detail <text>", "Optional free-text note stored with the suppression.")
6026
+ .option("--json", "Print a JSON envelope.")
6027
+ .action(async (options) => {
6028
+ await handleAsyncAction("suppressions add", options, () => {
6029
+ const lead = readOption(options.lead);
6030
+ if (!lead)
6031
+ throw new Error("--lead is required.");
6032
+ const reason = readOption(options.reason);
6033
+ const detail = readOption(options.detail);
6034
+ return requestOxygen("/api/cli/suppressions", {
6035
+ method: "POST",
6036
+ body: {
6037
+ lead_provider_id: lead,
6038
+ ...(reason ? { reason } : {}),
6039
+ ...(detail ? { detail } : {}),
6040
+ },
6041
+ });
6042
+ });
6043
+ }))
6044
+ .addCommand(new Command("remove")
6045
+ .description("Remove a lead provider id from the org do-not-contact list (re-enable contact).")
6046
+ .requiredOption("--lead <provider_id>", "The lead provider id to un-suppress.")
6047
+ .option("--json", "Print a JSON envelope.")
6048
+ .action(async (options) => {
6049
+ await handleAsyncAction("suppressions remove", options, () => {
6050
+ const lead = readOption(options.lead);
6051
+ if (!lead)
6052
+ throw new Error("--lead is required.");
6053
+ return requestOxygen(`/api/cli/suppressions?lead_provider_id=${encodeURIComponent(lead)}`, {
6054
+ method: "DELETE",
6055
+ });
6056
+ });
6057
+ })));
5643
6058
  program.addCommand(new Command("email")
5644
6059
  .description("Ad-hoc email from the org's sending fleet: one-off sends with no sequence, enrollment, or contact sync. Zapbox-connected mailboxes send through Zapmail's API (no Google/Microsoft consent); BYOK — 0 Oxygen credits.")
5645
6060
  .addCommand(new Command("send")
@@ -5684,6 +6099,13 @@ export function createProgram() {
5684
6099
  const suffix = params.toString();
5685
6100
  return requestOxygen(`/api/cli/mailboxes${suffix ? `?${suffix}` : ""}`);
5686
6101
  });
6102
+ }))
6103
+ .addCommand(new Command("get")
6104
+ .description("Get one sending mailbox's detail (provider, status, daily cap, warmup state, auth mode) plus a one-row pool summary. <mailbox> accepts a mailbox id or email address.")
6105
+ .argument("<mailbox>", "Mailbox id or email address.")
6106
+ .option("--json", "Print a JSON envelope.")
6107
+ .action(async (mailbox, options) => {
6108
+ await handleAsyncAction("mailboxes get", options, () => requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}`));
5687
6109
  }))
5688
6110
  .addCommand(new Command("import")
5689
6111
  .description("Register (or refresh) sending mailboxes in bulk. Provide --file (a JSON file: { \"mailboxes\": [{ email_address, provider, workspace_external_id? }] }) or --from zapmail to pull the connected Zapmail workspace's mailbox list. Upsert is keyed by address, so re-importing never duplicates a mailbox.")
@@ -5735,6 +6157,24 @@ export function createProgram() {
5735
6157
  });
5736
6158
  });
5737
6159
  }))
6160
+ .addCommand(new Command("cap")
6161
+ .description("View and adjust a single mailbox's daily send cap (throttle a problem inbox or ramp a newly warmed one without pausing it).")
6162
+ .addCommand(new Command("set")
6163
+ .description("Set one sending mailbox's daily send cap (sends per day). --daily-cap must be a positive whole number. Consumes no Oxygen credits. <mailbox> accepts a mailbox id or email address.")
6164
+ .argument("<mailbox>", "Mailbox id or email address.")
6165
+ .requiredOption("--daily-cap <n>", "New daily send cap (a positive whole number of sends per day).")
6166
+ .option("--json", "Print a JSON envelope.")
6167
+ .action(async (mailbox, options) => {
6168
+ await handleAsyncAction("mailboxes cap set", options, () => {
6169
+ const raw = readOption(options.dailyCap);
6170
+ if (!raw)
6171
+ throw new Error("--daily-cap is required.");
6172
+ return requestOxygen(`/api/cli/mailboxes/${encodeURIComponent(mailbox)}/cap`, {
6173
+ method: "PATCH",
6174
+ body: { daily_cap: Number(raw) },
6175
+ });
6176
+ });
6177
+ })))
5738
6178
  .addCommand(new Command("connect-oauth")
5739
6179
  .description("Provision Zapmail Custom OAuth across the mailbox pool: registers Oxygen's own OAuth app with Zapmail, which authorizes it across the Workspaces so native send needs no per-Workspace delegation. BYOK — Zapmail bills your account, 0 Oxygen credits. Pass --status <export_id> to poll a run.")
5740
6180
  .option("--provider <provider>", "Mailbox provider to provision: google or microsoft.")
@@ -6011,6 +6451,16 @@ export function createProgram() {
6011
6451
  .option("--json", "Print a JSON envelope.")
6012
6452
  .action(async (options) => {
6013
6453
  await handleAsyncAction("workflows schema", options, () => requestOxygen(`/api/cli/workflows/schema?subject=${encodeURIComponent(options.subject ?? "all")}`));
6454
+ }))
6455
+ .addCommand(new Command("init")
6456
+ .description("Scaffold a local durable-recipe project (starter recipe, vendored @oxygen/recipe-sdk types, tsconfig) so your editor resolves the SDK without an npm install.")
6457
+ .option("--dir <path>", "Directory to scaffold into. Defaults to the current directory.")
6458
+ .option("--id <id>", "Recipe id for the starter file. Defaults to 'my-recipe'.")
6459
+ .option("--name <name>", "Recipe display name. Defaults to 'My recipe'.")
6460
+ .option("--force", "Overwrite existing files instead of skipping them.")
6461
+ .option("--json", "Print a JSON envelope.")
6462
+ .action(async (options) => {
6463
+ await handleAsyncAction("workflows init", options, async () => scaffoldRecipeProject(options));
6014
6464
  }))
6015
6465
  .addCommand(new Command("lint")
6016
6466
  .description("Compile and lint a workflow file without saving it.")
@@ -6330,6 +6780,131 @@ function asWorkflowCompileError(error, filePath) {
6330
6780
  exitCode: 1,
6331
6781
  });
6332
6782
  }
6783
+ // ---- oxygen workflows init: scaffold a local durable-recipe project ----
6784
+ //
6785
+ // @oxygen/recipe-sdk is never published to npm — it ships bundled inside this CLI
6786
+ // (scripts/build-cli-npm-package.mjs → bundledDependencies). `init` vendors the SDK's
6787
+ // compiled type declarations into the user's project so their editor resolves
6788
+ // `@oxygen/recipe-sdk` (and the `@oxygen/workflows` types it re-exports) without an npm
6789
+ // install. We read the .d.ts from the same node paths esbuild uses to bundle the runtime
6790
+ // at `apply` time, so the vendored types stay locked to the CLI actually installed.
6791
+ const RECIPE_TYPES_VENDOR_DIR = ".oxygen/types";
6792
+ const RECIPE_INIT_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
6793
+ function readBundledSdkDts(pkg) {
6794
+ for (const base of RECIPE_ESBUILD_NODE_PATHS) {
6795
+ const candidate = join(base, "@oxygen", pkg, "dist", "index.d.ts");
6796
+ if (existsSync(candidate)) {
6797
+ try {
6798
+ return readFileSync(candidate, "utf8");
6799
+ }
6800
+ catch {
6801
+ // Unreadable copy — fall through to the next node path.
6802
+ }
6803
+ }
6804
+ }
6805
+ return null;
6806
+ }
6807
+ function renderStarterRecipe(id, name) {
6808
+ return [
6809
+ `import { defineRecipe, type RecipeContext } from "@oxygen/recipe-sdk";`,
6810
+ ``,
6811
+ `// A durable recipe is the default export of defineRecipe(...); it compiles into an`,
6812
+ `// Oxygen workflow. Recipes reach external systems ONLY through catalog tools —`,
6813
+ `// raw fetch/network is disabled in the recipe sandbox.`,
6814
+ `//`,
6815
+ `// Deploy: oxygen workflows apply --file ./${id}.ts`,
6816
+ `// Dry-run: oxygen workflows call <workflow-id> --mode dry_run --json`,
6817
+ `// Docs: https://oxygen-agent.com/docs/authoring/recipes`,
6818
+ `export default defineRecipe({`,
6819
+ ` id: ${JSON.stringify(id)},`,
6820
+ ` name: ${JSON.stringify(name)},`,
6821
+ ` // Allowlist of every tool id this recipe may call — including native table`,
6822
+ ` // ops, which dispatch through oxygen.* tools (e.g. oxygen.rows_upsert for`,
6823
+ ` // ctx.rows.upsert). Discover provider tools with: oxygen tools search <query>`,
6824
+ ` tools: ["firecrawl.scrape", "oxygen.rows_upsert"],`,
6825
+ ` trigger: { type: "api" },`,
6826
+ ` inputSchema: {`,
6827
+ ` type: "object",`,
6828
+ ` properties: {},`,
6829
+ ` },`,
6830
+ ` async run(ctx: RecipeContext) {`,
6831
+ ` ctx.log("info", "recipe started", { mode: ctx.mode });`,
6832
+ ` // Example: pull data through a tool, then persist it to a table.`,
6833
+ ` // const page = await ctx.tools.run("firecrawl.scrape", { url }, { key: "scrape" });`,
6834
+ ` // await ctx.rows.upsert("my_table", rows, { key: "save", upsertKey: "id" });`,
6835
+ ` return { ok: true, at: await ctx.now() };`,
6836
+ ` },`,
6837
+ `});`,
6838
+ ``,
6839
+ ].join("\n");
6840
+ }
6841
+ function renderRecipeTsconfig() {
6842
+ const tsconfig = {
6843
+ compilerOptions: {
6844
+ target: "ES2022",
6845
+ module: "ESNext",
6846
+ moduleResolution: "Bundler",
6847
+ strict: true,
6848
+ noEmit: true,
6849
+ skipLibCheck: true,
6850
+ types: [],
6851
+ // paths are resolved relative to this tsconfig (moduleResolution "Bundler"
6852
+ // needs no baseUrl — and baseUrl is deprecated in TypeScript 6+).
6853
+ paths: {
6854
+ "@oxygen/recipe-sdk": [`./${RECIPE_TYPES_VENDOR_DIR}/@oxygen/recipe-sdk/index.d.ts`],
6855
+ "@oxygen/workflows": [`./${RECIPE_TYPES_VENDOR_DIR}/@oxygen/workflows/index.d.ts`],
6856
+ },
6857
+ },
6858
+ include: ["*.ts"],
6859
+ };
6860
+ return `${JSON.stringify(tsconfig, null, 2)}\n`;
6861
+ }
6862
+ function scaffoldRecipeProject(options) {
6863
+ const recipeId = readOption(options.id) ?? "my-recipe";
6864
+ if (!RECIPE_INIT_ID_PATTERN.test(recipeId)) {
6865
+ throw new OxygenError("invalid_recipe_id", "Recipe id must start with a letter or digit and contain only letters, digits, dashes, or underscores.", {
6866
+ details: { id: recipeId },
6867
+ exitCode: 1,
6868
+ });
6869
+ }
6870
+ const recipeName = readOption(options.name) ?? "My recipe";
6871
+ const directory = resolve(readOption(options.dir) ?? ".");
6872
+ const force = options.force === true;
6873
+ const recipeDts = readBundledSdkDts("recipe-sdk");
6874
+ const workflowsDts = readBundledSdkDts("workflows");
6875
+ if (!recipeDts || !workflowsDts) {
6876
+ throw new OxygenError("recipe_sdk_types_unavailable", "Could not locate the @oxygen/recipe-sdk types bundled with this CLI. Update the CLI (npm install -g @oxygen-agent/cli@latest) and retry.", { exitCode: 1 });
6877
+ }
6878
+ const recipeFileName = `${recipeId}.ts`;
6879
+ const targets = [
6880
+ { path: join(directory, recipeFileName), content: renderStarterRecipe(recipeId, recipeName) },
6881
+ { path: join(directory, "tsconfig.json"), content: renderRecipeTsconfig() },
6882
+ { path: join(directory, RECIPE_TYPES_VENDOR_DIR, "@oxygen", "recipe-sdk", "index.d.ts"), content: recipeDts },
6883
+ { path: join(directory, RECIPE_TYPES_VENDOR_DIR, "@oxygen", "workflows", "index.d.ts"), content: workflowsDts },
6884
+ ];
6885
+ const filesWritten = [];
6886
+ const filesSkipped = [];
6887
+ for (const target of targets) {
6888
+ if (existsSync(target.path) && !force) {
6889
+ filesSkipped.push(target.path);
6890
+ continue;
6891
+ }
6892
+ mkdirSync(dirname(target.path), { recursive: true });
6893
+ writeFileSync(target.path, target.content, "utf8");
6894
+ filesWritten.push(target.path);
6895
+ }
6896
+ return {
6897
+ directory,
6898
+ recipe_file: join(directory, recipeFileName),
6899
+ files_written: filesWritten,
6900
+ files_skipped: filesSkipped,
6901
+ next_steps: [
6902
+ `Edit ${recipeFileName}, then find tools with: oxygen tools search <query>`,
6903
+ `Deploy it: oxygen workflows apply --file ./${recipeFileName}`,
6904
+ `Dry-run it: oxygen workflows call <workflow-id> --mode dry_run --json`,
6905
+ ],
6906
+ };
6907
+ }
6333
6908
  async function compileWorkflowFile(filePath) {
6334
6909
  try {
6335
6910
  return await loadWorkflowManifestFromFile(filePath);
@@ -9799,7 +10374,7 @@ function formatSequenceVariants(data) {
9799
10374
  // (fetched via /api/cli/tables/describe — the same describe the other column
9800
10375
  // commands read) so the server only ever receives a single numeric position.
9801
10376
  // Exactly one of the three flags must be supplied.
9802
- async function resolveColumnReorderPosition(table, options) {
10377
+ async function resolveColumnReorderPosition(table, column, options) {
9803
10378
  const before = readOption(options.before);
9804
10379
  const after = readOption(options.after);
9805
10380
  const explicit = readNonNegativeInt(options.position);
@@ -9820,7 +10395,15 @@ async function resolveColumnReorderPosition(table, options) {
9820
10395
  throw new OxygenError("invalid_request", `Column "${reference}" was not found on table "${table}".`, { details: { table, column: reference }, exitCode: 1 });
9821
10396
  }
9822
10397
  // --before lands the column at the reference's index; --after lands it one slot later.
9823
- return before ? index : index + 1;
10398
+ const target = before ? index : index + 1;
10399
+ // The server removes the source column before splicing it back in at `target`.
10400
+ // When the source currently sits before the reference, that removal shifts the
10401
+ // reference (and everything after it) down one slot, so decrement to compensate.
10402
+ const sourceIndex = columns.findIndex((candidate) => candidate.id === column || candidate.key === column);
10403
+ if (sourceIndex >= 0 && sourceIndex < index) {
10404
+ return target - 1;
10405
+ }
10406
+ return target;
9824
10407
  }
9825
10408
  // `tables tidy-suggest` is a read-only analysis (no credits, no writes): it
9826
10409
  // surfaces duplicate columns, formatting inconsistencies, hide candidates, and
@@ -9860,6 +10443,19 @@ function formatTidyPercent(value) {
9860
10443
  return "—";
9861
10444
  return `${(value * 100).toFixed(0)}%`;
9862
10445
  }
10446
+ // Render one tidy-suggest section (header + count, then either a dim "None" or
10447
+ // one formatted line per item) into the shared `lines` buffer. Extracted so the
10448
+ // four sections share a single control-flow shape instead of repeating it.
10449
+ function pushTidySection(lines, styles, title, items, renderItem) {
10450
+ lines.push("", styles.bold(`${title} (${items.length})`));
10451
+ if (items.length === 0) {
10452
+ lines.push(` ${styles.dim("None")}`);
10453
+ return;
10454
+ }
10455
+ for (const item of items) {
10456
+ lines.push(renderItem(item));
10457
+ }
10458
+ }
9863
10459
  function formatTidySuggestions(data) {
9864
10460
  const styles = ansi(output.isTTY === true && !process.env.NO_COLOR);
9865
10461
  const duplicates = Array.isArray(data.duplicateColumns) ? data.duplicateColumns : [];
@@ -9870,48 +10466,24 @@ function formatTidySuggestions(data) {
9870
10466
  "",
9871
10467
  `${styles.bold("Table tidy suggestions")} ${styles.dim(`(sampled ${formatTidyCount(data.sampledRows)} of ${formatTidyCount(data.rowCount)} rows)`)}`,
9872
10468
  ];
9873
- lines.push("", styles.bold(`Duplicate columns (${duplicates.length})`));
9874
- if (duplicates.length === 0) {
9875
- lines.push(` ${styles.dim("None")}`);
9876
- }
9877
- else {
9878
- for (const dup of duplicates) {
9879
- const loser = dup.loserKey ?? dup.loserColumnId ?? "?";
9880
- const survivor = dup.survivorKey ?? dup.survivorColumnId ?? "?";
9881
- lines.push(` ${loser} ${styles.dim("→ duplicate of")} ${survivor} ${styles.dim(`(${formatTidyPercent(dup.similarity)} match, ${dup.recommendation ?? "review"})`)}`);
9882
- }
9883
- }
9884
- lines.push("", styles.bold(`Formatting issues (${formatting.length})`));
9885
- if (formatting.length === 0) {
9886
- lines.push(` ${styles.dim("None")}`);
9887
- }
9888
- else {
9889
- for (const issue of formatting) {
9890
- const sampleCount = Array.isArray(issue.sampleRowIds) ? issue.sampleRowIds.length : 0;
9891
- const column = issue.columnKey ?? issue.columnId ?? "?";
9892
- lines.push(` ${column} ${styles.dim(`— ${issue.issue ?? "issue"}`)}${issue.detail ? `: ${issue.detail}` : ""} ${styles.dim(`(${sampleCount} sample row(s))`)}`);
9893
- }
9894
- }
9895
- lines.push("", styles.bold(`Hide suggestions (${hide.length})`));
9896
- if (hide.length === 0) {
9897
- lines.push(` ${styles.dim("None")}`);
9898
- }
9899
- else {
9900
- for (const suggestion of hide) {
9901
- const column = suggestion.columnKey ?? suggestion.columnId ?? "?";
9902
- lines.push(` ${column} ${styles.dim(`— ${suggestion.reason ?? "hide"} (${formatTidyPercent(suggestion.fillRate)} filled)`)}`);
9903
- }
9904
- }
9905
- lines.push("", styles.bold(`Value-sanity findings (${valueSanity.length})`));
9906
- if (valueSanity.length === 0) {
9907
- lines.push(` ${styles.dim("None")}`);
9908
- }
9909
- else {
9910
- for (const finding of valueSanity) {
9911
- const column = finding.columnKey ?? finding.columnId ?? "?";
9912
- lines.push(` ${column} ${styles.dim(`row ${finding.rowId ?? "?"} — ${finding.reason ?? "check value"}`)}`);
9913
- }
9914
- }
10469
+ pushTidySection(lines, styles, "Duplicate columns", duplicates, (dup) => {
10470
+ const loser = dup.loserKey ?? dup.loserColumnId ?? "?";
10471
+ const survivor = dup.survivorKey ?? dup.survivorColumnId ?? "?";
10472
+ return ` ${loser} ${styles.dim("→ duplicate of")} ${survivor} ${styles.dim(`(${formatTidyPercent(dup.similarity)} match, ${dup.recommendation ?? "review"})`)}`;
10473
+ });
10474
+ pushTidySection(lines, styles, "Formatting issues", formatting, (issue) => {
10475
+ const sampleCount = Array.isArray(issue.sampleRowIds) ? issue.sampleRowIds.length : 0;
10476
+ const column = issue.columnKey ?? issue.columnId ?? "?";
10477
+ return ` ${column} ${styles.dim(`— ${issue.issue ?? "issue"}`)}${issue.detail ? `: ${issue.detail}` : ""} ${styles.dim(`(${sampleCount} sample row(s))`)}`;
10478
+ });
10479
+ pushTidySection(lines, styles, "Hide suggestions", hide, (suggestion) => {
10480
+ const column = suggestion.columnKey ?? suggestion.columnId ?? "?";
10481
+ return ` ${column} ${styles.dim(`— ${suggestion.reason ?? "hide"} (${formatTidyPercent(suggestion.fillRate)} filled)`)}`;
10482
+ });
10483
+ pushTidySection(lines, styles, "Value-sanity findings", valueSanity, (finding) => {
10484
+ const column = finding.columnKey ?? finding.columnId ?? "?";
10485
+ return ` ${column} ${styles.dim(`row ${finding.rowId ?? "?"} — ${finding.reason ?? "check value"}`)}`;
10486
+ });
9915
10487
  const link = data.deepLink ?? data.web_url ?? data.url;
9916
10488
  if (link) {
9917
10489
  lines.push("", styles.dim(`View table: ${link}`));
@@ -10237,6 +10809,18 @@ function contextAssetsQuery(options) {
10237
10809
  const value = query.toString();
10238
10810
  return value ? `?${value}` : "";
10239
10811
  }
10812
+ function buildContextAssetSearchBody(options) {
10813
+ const tags = readCsvOption(options.tags);
10814
+ const limit = readPositiveInt(options.limit);
10815
+ return {
10816
+ query: (readOption(options.query) ?? "").trim(),
10817
+ ...(readOption(options.type) ? { type: readOption(options.type) } : {}),
10818
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
10819
+ ...(tags.length > 0 ? { tags } : {}),
10820
+ ...(options.includeArchived ? { include_archived: true } : {}),
10821
+ ...(limit !== undefined ? { limit } : {}),
10822
+ };
10823
+ }
10240
10824
  function buildBillingLedgerParams(options) {
10241
10825
  const params = new URLSearchParams();
10242
10826
  const days = readPositiveInt(options.days);