@oxygen-agent/cli 1.256.13 → 1.272.83

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
@@ -14,6 +14,7 @@ import { isRecipeDefinition } from "@oxygen/recipe-sdk";
14
14
  import { createBrowserLoginSession, openBrowser } from "./browser-login.js";
15
15
  import { clearCredentials, defaultApiUrl, listCredentialProfiles, loadCredentials, normalizeApiUrl, pickProfileNameForIdentity, pickProfileNameForUserSession, resolveActiveProfile, saveCredentials, switchCredentialProfile, updateActiveOrganizationForProfile, } from "./credentials.js";
16
16
  import { ensureFreshCliForApiUrl, requestOxygen } from "./http-client.js";
17
+ import { acquireMirrorLock, clearConflictFiles, deletePageFile, emptyMirrorState, findMirrorSlugByPageId, isFileDirty, listConflictFiles, markMirrorStale, mirrorExists, purgeMirror, resetMirrorForFullResync, quarantineDirtyFile, readMirrorState, releaseMirrorLock, resolveDefaultConfigDir, resolveMirrorDir, writeGeneratedIndexFile, writeGeneratedLogFile, writeMirrorState, writePageFile, } from "./knowledge-mirror.js";
17
18
  import { waitForCliRun } from "./run-wait.js";
18
19
  import { runLocalCustomHttpColumn } from "./local-custom-http-column.js";
19
20
  import { captureCurrentTranscript, collectFeedbackEnvironment, TranscriptCaptureError, } from "./transcript.js";
@@ -308,6 +309,7 @@ function normalizeDeleteRowIds(values) {
308
309
  }
309
310
  return rowIds;
310
311
  }
312
+ const CUSTOM_HTTP_MANIFEST_DOCS_URL = "https://oxygen-agent.com/docs/providers/custom-http";
311
313
  function readCustomIntegrationManifest(options) {
312
314
  const manifestPath = readOption(options.manifest);
313
315
  const manifestJson = readOption(options.manifestJson);
@@ -321,21 +323,38 @@ function readCustomIntegrationManifest(options) {
321
323
  exitCode: 1,
322
324
  });
323
325
  }
324
- return parseJsonObject(manifestJson ?? readFileSync(resolve(manifestPath ?? ""), "utf8"));
326
+ if (manifestJson)
327
+ return parseJsonObject(manifestJson);
328
+ const filePath = resolve(manifestPath ?? "");
329
+ let content;
330
+ try {
331
+ content = readFileSync(filePath, "utf8");
332
+ }
333
+ catch (error) {
334
+ const notFound = error?.code === "ENOENT";
335
+ const reason = error instanceof Error ? error.message : String(error);
336
+ throw new OxygenError("invalid_request", notFound
337
+ ? `Manifest file not found: ${filePath}. Pass --manifest <path.json> or --manifest-json '<json>'. See ${CUSTOM_HTTP_MANIFEST_DOCS_URL}`
338
+ : `Could not read manifest file ${filePath}: ${reason}. See ${CUSTOM_HTTP_MANIFEST_DOCS_URL}`, { details: { path: filePath }, exitCode: 1 });
339
+ }
340
+ try {
341
+ return parseJsonObject(content);
342
+ }
343
+ catch (error) {
344
+ if (error instanceof OxygenError) {
345
+ throw new OxygenError(error.code, `Manifest file ${filePath} is invalid: ${error.message} See ${CUSTOM_HTTP_MANIFEST_DOCS_URL}`, {
346
+ details: { path: filePath, ...(isRecord(error.details) ? error.details : {}) },
347
+ exitCode: error.exitCode,
348
+ });
349
+ }
350
+ throw error;
351
+ }
325
352
  }
326
353
  const CUSTOM_INTEGRATION_SECRET_KEYS = ["API_KEY", "TOKEN", "PASSWORD", "SECRET"];
327
354
  function readCustomIntegrationSecret(options) {
328
- const apiKey = readOption(options.apiKey);
329
355
  const secretsFile = readOption(options.secretsFile);
330
- if (apiKey && secretsFile) {
331
- throw new OxygenError("invalid_request", "Pass either --api-key or --secrets-file, not both.", {
332
- exitCode: 1,
333
- });
334
- }
335
- if (apiKey)
336
- return apiKey;
337
356
  if (!secretsFile) {
338
- throw new OxygenError("invalid_request", "Pass --api-key or --secrets-file.", {
357
+ throw new OxygenError("invalid_request", "Pass --secrets-file.", {
339
358
  exitCode: 1,
340
359
  });
341
360
  }
@@ -618,6 +637,146 @@ function resolveNotetakerMode(options) {
618
637
  }
619
638
  return options.live === true ? "live" : "dry_run";
620
639
  }
640
+ function buildPublishingPostsListPath(options) {
641
+ const query = new URLSearchParams();
642
+ const status = readOption(options.status);
643
+ const limit = readOption(options.limit);
644
+ if (status)
645
+ query.set("status", status);
646
+ if (limit)
647
+ query.set("limit", limit);
648
+ const suffix = query.toString();
649
+ return suffix ? `/api/cli/publishing/posts?${suffix}` : "/api/cli/publishing/posts";
650
+ }
651
+ function buildPublishingPostCreateBody(options) {
652
+ const status = resolvePublishingCreateStatus(options);
653
+ const body = {
654
+ content_text: readPublishingPostText(options, true),
655
+ publish_at: options.publishAt,
656
+ status,
657
+ approved: options.approved === true,
658
+ };
659
+ const sender = readOption(options.sender);
660
+ const provider = readOption(options.provider);
661
+ const channel = readOption(options.channel);
662
+ const providerConnection = readOption(options.providerConnection);
663
+ const title = readOption(options.title);
664
+ const timezone = readOption(options.timezone);
665
+ const content = buildPublishingContent(options);
666
+ if (provider)
667
+ body.provider = provider;
668
+ if (channel)
669
+ body.channel = channel;
670
+ if (sender)
671
+ body.sender_account_id = sender;
672
+ if (providerConnection)
673
+ body.provider_connection_id = providerConnection;
674
+ if (title)
675
+ body.title = title;
676
+ if (timezone)
677
+ body.timezone = timezone;
678
+ if (content)
679
+ body.content = content;
680
+ return body;
681
+ }
682
+ function buildPublishingPostUpdateBody(options) {
683
+ const body = {};
684
+ const sender = readOption(options.sender);
685
+ const provider = readOption(options.provider);
686
+ const channel = readOption(options.channel);
687
+ const providerConnection = readOption(options.providerConnection);
688
+ const title = readOption(options.title);
689
+ const text = readPublishingPostText(options, false);
690
+ const publishAt = readOption(options.publishAt);
691
+ const timezone = readOption(options.timezone);
692
+ const status = readOption(options.status);
693
+ const content = buildPublishingContent(options);
694
+ if (provider)
695
+ body.provider = provider;
696
+ if (channel)
697
+ body.channel = channel;
698
+ if (sender)
699
+ body.sender_account_id = sender;
700
+ if (providerConnection)
701
+ body.provider_connection_id = providerConnection;
702
+ if (title)
703
+ body.title = title;
704
+ if (text !== null)
705
+ body.content_text = text;
706
+ if (publishAt)
707
+ body.publish_at = publishAt;
708
+ if (timezone)
709
+ body.timezone = timezone;
710
+ if (status)
711
+ body.status = status;
712
+ if (content)
713
+ body.content = content;
714
+ return body;
715
+ }
716
+ function buildPublishingMediaUploadUrlBody(options) {
717
+ const fileName = readOption(options.fileName);
718
+ const contentType = readOption(options.contentType);
719
+ const byteLength = readPositiveInt(options.byteLength);
720
+ if (!fileName) {
721
+ throw new OxygenError("invalid_request", "--file-name is required.", { exitCode: 1 });
722
+ }
723
+ if (!contentType) {
724
+ throw new OxygenError("invalid_request", "--content-type is required.", { exitCode: 1 });
725
+ }
726
+ if (!byteLength) {
727
+ throw new OxygenError("invalid_request", "--byte-length must be a positive integer.", { exitCode: 1 });
728
+ }
729
+ const body = {
730
+ file_name: fileName,
731
+ content_type: contentType,
732
+ byte_length: byteLength,
733
+ };
734
+ const scheduledPost = readOption(options.scheduledPost);
735
+ const metadataJson = readOption(options.metadataJson);
736
+ if (scheduledPost)
737
+ body.scheduled_post_id = scheduledPost;
738
+ if (metadataJson)
739
+ body.metadata = parseJsonObject(metadataJson);
740
+ return body;
741
+ }
742
+ function buildPublishingMediaUploadedBody(options) {
743
+ const sha256 = readOption(options.sha256);
744
+ return sha256 ? { sha256 } : {};
745
+ }
746
+ function buildPublishingContent(options) {
747
+ const contentJson = readOption(options.contentJson);
748
+ const composioAction = readOption(options.composioAction);
749
+ if (!contentJson && !composioAction)
750
+ return null;
751
+ const content = contentJson ? parseJsonObject(contentJson) : {};
752
+ if (composioAction) {
753
+ const existing = content.composio && typeof content.composio === "object" && !Array.isArray(content.composio)
754
+ ? content.composio
755
+ : {};
756
+ content.composio = { ...existing, action_slug: composioAction };
757
+ }
758
+ return content;
759
+ }
760
+ function resolvePublishingCreateStatus(options) {
761
+ const status = readOption(options.status);
762
+ if (options.draft === true && status && status !== "draft") {
763
+ throw new OxygenError("conflicting_flags", "Pass either --draft or --status, not both.", { exitCode: 1 });
764
+ }
765
+ return options.draft === true ? "draft" : status ?? "scheduled";
766
+ }
767
+ function readPublishingPostText(options, required) {
768
+ const inlineText = readOption(options.text);
769
+ const textFile = readOption(options.textFile);
770
+ if (inlineText && textFile) {
771
+ throw new OxygenError("conflicting_flags", "Pass either --text or --text-file, not both.", { exitCode: 1 });
772
+ }
773
+ const text = textFile ? readFileSync(resolve(textFile), "utf8").trimEnd() : inlineText;
774
+ if (text?.trim())
775
+ return text;
776
+ if (!required)
777
+ return null;
778
+ throw new OxygenError("invalid_request", "Pass --text or --text-file.", { exitCode: 1 });
779
+ }
621
780
  function buildCrmRelationshipUpsertBody(object, rowId, options) {
622
781
  return {
623
782
  object,
@@ -627,6 +786,7 @@ function buildCrmRelationshipUpsertBody(object, rowId, options) {
627
786
  ...(readOption(options.targetObject) ? { object: readOption(options.targetObject) } : {}),
628
787
  row_id: options.targetRowId,
629
788
  },
789
+ ...(readOption(options.role) ? { role: readOption(options.role) } : {}),
630
790
  mode: resolveCrmSetupMode(options),
631
791
  };
632
792
  }
@@ -1004,8 +1164,8 @@ export function createProgram() {
1004
1164
  .option("--package <npm_spec>", "Override the npm package spec.")
1005
1165
  .option("--dry-run", "Print the update command without running it.")
1006
1166
  .option("--json", "Print a JSON envelope.")
1007
- .action((options) => {
1008
- handleUpdateAction(options);
1167
+ .action(async (options) => {
1168
+ await handleUpdateAction(options);
1009
1169
  });
1010
1170
  program
1011
1171
  .command("api-keys")
@@ -1498,6 +1658,125 @@ export function createProgram() {
1498
1658
  .action(async (sessionId, options) => {
1499
1659
  await handleAsyncAction("notetaker status", options, () => requestOxygen(`/api/cli/notetaker/sessions/${encodeURIComponent(sessionId)}`));
1500
1660
  }));
1661
+ program
1662
+ .command("publishing")
1663
+ .description("Deterministic social post scheduling commands.")
1664
+ .addCommand(new Command("posts")
1665
+ .description("Manage scheduled Publishing posts.")
1666
+ .addCommand(new Command("list")
1667
+ .description("List scheduled posts.")
1668
+ .option("--status <status>", "Filter by draft, scheduled, queued, publishing, published, failed, or canceled.")
1669
+ .option("--limit <n>", "Maximum posts to return.")
1670
+ .option("--json", "Print a JSON envelope.")
1671
+ .action(async (options) => {
1672
+ await handleAsyncAction("publishing posts list", options, () => requestOxygen(buildPublishingPostsListPath(options)));
1673
+ }))
1674
+ .addCommand(new Command("create")
1675
+ .description("Create a scheduled post. Publishing requires approval before the worker can send.")
1676
+ .requiredOption("--publish-at <iso>", "ISO date-time when the post should publish.")
1677
+ .option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube. Defaults to linkedin.")
1678
+ .option("--channel <channel>", "Publishing channel override. Defaults to provider.")
1679
+ .option("--sender <sender_account_id>", "LinkedIn sender account id. Required for LinkedIn before the worker can publish.")
1680
+ .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
1681
+ .option("--title <title>", "Internal title for the queue.")
1682
+ .option("--text <text>", "Post text.")
1683
+ .option("--text-file <path>", "Read post text from a local file.")
1684
+ .option("--content-json <json>", "Optional structured provider content. For Composio providers, pass provider_arguments or composio.arguments.")
1685
+ .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
1686
+ .option("--timezone <tz>", "Display timezone for the scheduled date. Defaults to UTC.")
1687
+ .option("--status <status>", "draft or scheduled. Defaults to scheduled.")
1688
+ .option("--draft", "Create as a draft instead of scheduled.")
1689
+ .option("--approved", "Mark the post approved for the scheduler.")
1690
+ .option("--json", "Print a JSON envelope.")
1691
+ .action(async (options) => {
1692
+ await handleAsyncAction("publishing posts create", options, () => requestOxygen("/api/cli/publishing/posts", {
1693
+ method: "POST",
1694
+ body: buildPublishingPostCreateBody(options),
1695
+ }));
1696
+ }))
1697
+ .addCommand(new Command("get")
1698
+ .description("Get one scheduled post with attempt history.")
1699
+ .argument("<post_id>", "Scheduled post id.")
1700
+ .option("--json", "Print a JSON envelope.")
1701
+ .action(async (postId, options) => {
1702
+ await handleAsyncAction("publishing posts get", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`));
1703
+ }))
1704
+ .addCommand(new Command("update")
1705
+ .description("Update an editable draft, scheduled, or failed post.")
1706
+ .argument("<post_id>", "Scheduled post id.")
1707
+ .option("--provider <provider>", "Publishing provider: linkedin, x, instagram, tiktok, facebook, or youtube.")
1708
+ .option("--channel <channel>", "Publishing channel override.")
1709
+ .option("--sender <sender_account_id>", "LinkedIn sender account id.")
1710
+ .option("--provider-connection <connection_id>", "Oxygen integration connection id for Composio-backed providers.")
1711
+ .option("--title <title>", "Internal title for the queue.")
1712
+ .option("--text <text>", "Post text.")
1713
+ .option("--text-file <path>", "Read post text from a local file.")
1714
+ .option("--content-json <json>", "Optional structured provider content. For Composio providers, pass provider_arguments or composio.arguments.")
1715
+ .option("--composio-action <slug>", "Override the Composio action slug for this scheduled post.")
1716
+ .option("--publish-at <iso>", "ISO date-time when the post should publish.")
1717
+ .option("--timezone <tz>", "Display timezone for the scheduled date.")
1718
+ .option("--status <status>", "draft or scheduled.")
1719
+ .option("--json", "Print a JSON envelope.")
1720
+ .action(async (postId, options) => {
1721
+ await handleAsyncAction("publishing posts update", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}`, {
1722
+ method: "PATCH",
1723
+ body: buildPublishingPostUpdateBody(options),
1724
+ }));
1725
+ }))
1726
+ .addCommand(new Command("approve")
1727
+ .description("Approve a scheduled post so the worker can publish it when due.")
1728
+ .argument("<post_id>", "Scheduled post id.")
1729
+ .option("--json", "Print a JSON envelope.")
1730
+ .action(async (postId, options) => {
1731
+ await handleAsyncAction("publishing posts approve", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/approve`, {
1732
+ method: "POST",
1733
+ }));
1734
+ }))
1735
+ .addCommand(new Command("cancel")
1736
+ .description("Cancel a draft, scheduled, queued, or failed post.")
1737
+ .argument("<post_id>", "Scheduled post id.")
1738
+ .option("--json", "Print a JSON envelope.")
1739
+ .action(async (postId, options) => {
1740
+ await handleAsyncAction("publishing posts cancel", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/cancel`, {
1741
+ method: "POST",
1742
+ }));
1743
+ }))
1744
+ .addCommand(new Command("retry")
1745
+ .description("Move a failed post back to scheduled for another worker attempt.")
1746
+ .argument("<post_id>", "Scheduled post id.")
1747
+ .option("--json", "Print a JSON envelope.")
1748
+ .action(async (postId, options) => {
1749
+ await handleAsyncAction("publishing posts retry", options, () => requestOxygen(`/api/cli/publishing/posts/${encodeURIComponent(postId)}/retry`, {
1750
+ method: "POST",
1751
+ }));
1752
+ })))
1753
+ .addCommand(new Command("media")
1754
+ .description("Manage Publishing media uploads.")
1755
+ .addCommand(new Command("upload-url")
1756
+ .description("Create a Publishing media asset and presigned object-storage upload URL.")
1757
+ .requiredOption("--file-name <name>", "Original media filename.")
1758
+ .requiredOption("--content-type <type>", "Media MIME type, such as image/png or video/mp4.")
1759
+ .requiredOption("--byte-length <bytes>", "Exact media byte length.")
1760
+ .option("--scheduled-post <post_id>", "Optional scheduled post id to associate with the media.")
1761
+ .option("--metadata-json <json>", "Optional metadata object to store with the media asset.")
1762
+ .option("--json", "Print a JSON envelope.")
1763
+ .action(async (options) => {
1764
+ await handleAsyncAction("publishing media upload-url", options, () => requestOxygen("/api/cli/publishing/media/upload-url", {
1765
+ method: "POST",
1766
+ body: buildPublishingMediaUploadUrlBody(options),
1767
+ }));
1768
+ }))
1769
+ .addCommand(new Command("uploaded")
1770
+ .description("Verify an uploaded Publishing media object and mark the media asset uploaded.")
1771
+ .argument("<media_id>", "Publishing media asset id.")
1772
+ .option("--sha256 <hex>", "Optional SHA-256 digest of the uploaded bytes.")
1773
+ .option("--json", "Print a JSON envelope.")
1774
+ .action(async (mediaId, options) => {
1775
+ await handleAsyncAction("publishing media uploaded", options, () => requestOxygen(`/api/cli/publishing/media/${encodeURIComponent(mediaId)}/uploaded`, {
1776
+ method: "POST",
1777
+ body: buildPublishingMediaUploadedBody(options),
1778
+ }));
1779
+ })));
1501
1780
  program
1502
1781
  .command("dashboards")
1503
1782
  .description("Default GTM dashboards: the stitched Command Center funnel across the sequencer, unibox, and CRM.")
@@ -1636,6 +1915,7 @@ export function createProgram() {
1636
1915
  .requiredOption("--relationship <slug>", "Relationship slug from the source object, such as team or company.")
1637
1916
  .option("--target-object <object>", "Target CRM object slug. Optional when the relationship has one target.")
1638
1917
  .requiredOption("--target-row-id <row_id>", "Target CRM record row id.")
1918
+ .option("--role <role>", "Buying-committee role for a deals.stakeholders link: champion, economic_buyer, decision_maker, influencer, or end_user.")
1639
1919
  .option("--dry-run", "Preview the relationship write without changing edges.")
1640
1920
  .option("--live", "Apply the relationship write. Default is dry-run.")
1641
1921
  .option("--json", "Print a JSON envelope.")
@@ -2571,6 +2851,315 @@ export function createProgram() {
2571
2851
  body: { id: assetId },
2572
2852
  }));
2573
2853
  })));
2854
+ program
2855
+ .command("knowledge")
2856
+ .description("Company knowledge wiki: slug-addressed pages, a [[wikilink]] graph, and a decision log.")
2857
+ .addCommand(new Command("page")
2858
+ .description("Knowledge wiki pages (slug-addressed, revision-guarded).")
2859
+ .addCommand(new Command("upsert")
2860
+ .description("Create or update a knowledge wiki page.")
2861
+ .option("--slug <slug>", "Stable page slug to create or address. Omit to create by title.")
2862
+ .option("--id <page_id>", "Existing page UUID to update. Omit to create.")
2863
+ .option("--type <type>", "Page type (positioning, competitor, research_note, playbook, strategy, other). Defaults to other on create.")
2864
+ .option("--title <title>", "Page title. Required on create.")
2865
+ .option("--status <status>", "draft, active, or archived.")
2866
+ .option("--tags <csv>", "Comma-separated page tags.")
2867
+ .option("--summary <text>", "Short page summary.")
2868
+ .option("--body <text>", "Page body (Markdown, supports [[wikilinks]]).")
2869
+ .option("--data-json <json>", "Flexible JSON object for structured page data.")
2870
+ .option("--expected-revision <n>", "Fail the write if the current page revision differs (optimistic concurrency guard).")
2871
+ .option("--approved", "Mark the write as human-approved.")
2872
+ .option("--json", "Print a JSON envelope.")
2873
+ .action(async (options) => {
2874
+ await handleAsyncAction("knowledge page upsert", options, async () => {
2875
+ const data = await requestOxygen("/api/cli/knowledge/pages/upsert", {
2876
+ method: "POST",
2877
+ body: buildKnowledgePageUpsertBody(options),
2878
+ });
2879
+ await markKnowledgeMirrorStaleAfterWrite();
2880
+ return data;
2881
+ });
2882
+ }))
2883
+ .addCommand(new Command("get")
2884
+ .description("Read one knowledge wiki page by slug or UUID.")
2885
+ .argument("<slug_or_id>", "Page slug or UUID.")
2886
+ .option("--json", "Print a JSON envelope.")
2887
+ .action(async (slugOrId, options) => {
2888
+ await handleAsyncAction("knowledge page get", options, () => requestOxygen("/api/cli/knowledge/pages/get", {
2889
+ method: "POST",
2890
+ body: { slug: slugOrId },
2891
+ }));
2892
+ }))
2893
+ .addCommand(new Command("list")
2894
+ .description("List knowledge wiki pages.")
2895
+ .option("--type <type>", "Filter by page type.")
2896
+ .option("--status <status>", "Filter by draft, active, or archived.")
2897
+ .option("--tags <csv>", "Comma-separated tags that must be present.")
2898
+ .option("--limit <n>", "Maximum pages to return.")
2899
+ .option("--json", "Print a JSON envelope.")
2900
+ .action(async (options) => {
2901
+ await handleAsyncAction("knowledge pages", options, () => requestOxygen("/api/cli/knowledge/pages", {
2902
+ method: "POST",
2903
+ body: buildKnowledgePageListBody(options),
2904
+ }));
2905
+ }))
2906
+ .addCommand(new Command("archive")
2907
+ .description("Archive a knowledge wiki page.")
2908
+ .argument("<slug_or_id>", "Page slug or UUID.")
2909
+ .option("--json", "Print a JSON envelope.")
2910
+ .action(async (slugOrId, options) => {
2911
+ await handleAsyncAction("knowledge page archive", options, async () => {
2912
+ const data = await requestOxygen("/api/cli/knowledge/pages/archive", {
2913
+ method: "POST",
2914
+ body: knowledgePageRefBody(slugOrId),
2915
+ });
2916
+ await markKnowledgeMirrorStaleAfterWrite();
2917
+ return data;
2918
+ });
2919
+ }))
2920
+ .addCommand(new Command("pin")
2921
+ .description("Pin a knowledge wiki page as the canonical default for its type. Canonical voice/brand/positioning pages are auto-applied to AI copy generation.")
2922
+ .argument("<slug_or_id>", "Page slug or UUID.")
2923
+ .option("--canonical", "Pin as the canonical default for its type (default).")
2924
+ .option("--no-canonical", "Unpin instead of pin (clears the canonical default for this type).")
2925
+ .option("--approved", "Mark the pin as human-approved (required for sensitive types: voice, brand, positioning).")
2926
+ .option("--json", "Print a JSON envelope.")
2927
+ .action(async (slugOrId, options) => {
2928
+ await handleAsyncAction("knowledge page pin", options, async () => {
2929
+ const data = await requestOxygen("/api/cli/knowledge/pages/pin", {
2930
+ method: "POST",
2931
+ body: {
2932
+ ...knowledgePageRefBody(slugOrId),
2933
+ canonical: options.canonical !== false,
2934
+ ...(options.approved ? { approved: true } : {}),
2935
+ },
2936
+ });
2937
+ await markKnowledgeMirrorStaleAfterWrite();
2938
+ return data;
2939
+ });
2940
+ }))
2941
+ .addCommand(new Command("revisions")
2942
+ .description("List the revision history of a knowledge wiki page, newest first.")
2943
+ .argument("<slug_or_id>", "Page slug or UUID.")
2944
+ .option("--limit <n>", "Maximum revisions to return.")
2945
+ .option("--json", "Print a JSON envelope.")
2946
+ .action(async (slugOrId, options) => {
2947
+ await handleAsyncAction("knowledge page revisions", options, () => {
2948
+ const limit = readPositiveInt(options.limit);
2949
+ return requestOxygen("/api/cli/knowledge/pages/revisions", {
2950
+ method: "POST",
2951
+ body: {
2952
+ ...knowledgePageRefBody(slugOrId),
2953
+ ...(limit !== undefined ? { limit } : {}),
2954
+ },
2955
+ });
2956
+ });
2957
+ }))
2958
+ .addCommand(new Command("revision")
2959
+ .description("Read one full revision snapshot of a knowledge wiki page.")
2960
+ .argument("<slug_or_id>", "Page slug or UUID.")
2961
+ .argument("<revision>", "Revision number.")
2962
+ .option("--json", "Print a JSON envelope.")
2963
+ .action(async (slugOrId, revision, options) => {
2964
+ await handleAsyncAction("knowledge page revision", options, () => {
2965
+ const revisionNumber = readPositiveInt(revision);
2966
+ if (revisionNumber === undefined) {
2967
+ throw new OxygenError("invalid_request", "Revision must be a positive integer.", {
2968
+ details: { revision },
2969
+ });
2970
+ }
2971
+ return requestOxygen("/api/cli/knowledge/pages/revisions/get", {
2972
+ method: "POST",
2973
+ body: {
2974
+ ...knowledgePageRefBody(slugOrId),
2975
+ revision: revisionNumber,
2976
+ },
2977
+ });
2978
+ });
2979
+ })))
2980
+ .addCommand(new Command("search")
2981
+ .description("Full-text search across knowledge wiki pages, ranked by relevance.")
2982
+ .argument("<query>", "Search text. Supports quoted phrases, OR, and -exclude.")
2983
+ .option("--type <type>", "Filter by page type.")
2984
+ .option("--status <status>", "Filter by draft, active, or archived.")
2985
+ .option("--tags <csv>", "Comma-separated tags that must be present.")
2986
+ .option("--limit <n>", "Maximum pages to return.")
2987
+ .option("--json", "Print a JSON envelope.")
2988
+ .action(async (query, options) => {
2989
+ await handleAsyncAction("knowledge search", options, () => requestOxygen("/api/cli/knowledge/search", {
2990
+ method: "POST",
2991
+ body: buildKnowledgeSearchBody(query, options),
2992
+ }));
2993
+ }))
2994
+ .addCommand(new Command("index")
2995
+ .description("Compact wiki index: every page's slug, one-liner, tags, link degree, plus type/status counts.")
2996
+ .option("--json", "Print a JSON envelope.")
2997
+ .action(async (options) => {
2998
+ await handleAsyncAction("knowledge index", options, () => requestOxygen("/api/cli/knowledge/index"));
2999
+ }))
3000
+ .addCommand(new Command("graph")
3001
+ .description("Knowledge graph of pages and their [[wikilink]] edges.")
3002
+ .option("--max-nodes <n>", "Maximum nodes to include in the graph.")
3003
+ .option("--json", "Print a JSON envelope.")
3004
+ .action(async (options) => {
3005
+ await handleAsyncAction("knowledge graph", options, () => requestOxygen("/api/cli/knowledge/graph", {
3006
+ method: "POST",
3007
+ body: buildKnowledgeGraphBody(options),
3008
+ }));
3009
+ }))
3010
+ .addCommand(new Command("lint")
3011
+ .description("Structural wiki health report: orphans, unresolved links, missing canonicals, stale/oversized pages, aged proposals.")
3012
+ .option("--json", "Print a JSON envelope.")
3013
+ .action(async (options) => {
3014
+ await handleAsyncAction("knowledge lint", options, () => requestOxygen("/api/cli/knowledge/lint"));
3015
+ })
3016
+ .addCommand(new Command("relink")
3017
+ .description("Recompute [[wikilink]] edges (and gap-fill unlinked mentions) across wiki pages.")
3018
+ .option("--slugs <csv>", "Comma-separated page slugs to relink. Omit to relink every page.")
3019
+ .option("--json", "Print a JSON envelope.")
3020
+ .action(async (options) => {
3021
+ await handleAsyncAction("knowledge lint relink", options, () => {
3022
+ const slugs = readCsvOption(options.slugs);
3023
+ return requestOxygen("/api/cli/knowledge/lint/relink", {
3024
+ method: "POST",
3025
+ body: slugs.length > 0 ? { slugs } : {},
3026
+ });
3027
+ });
3028
+ })))
3029
+ .addCommand(new Command("log")
3030
+ .description("Knowledge decision and activity log.")
3031
+ .addCommand(new Command("list")
3032
+ .description("List knowledge log entries, newest first.")
3033
+ .option("--cursor <cursor>", "Pagination cursor from a previous page.")
3034
+ .option("--limit <n>", "Maximum log entries to return.")
3035
+ .option("--json", "Print a JSON envelope.")
3036
+ .action(async (options) => {
3037
+ await handleAsyncAction("knowledge log", options, () => requestOxygen("/api/cli/knowledge/log", {
3038
+ method: "POST",
3039
+ body: buildKnowledgeLogListBody(options),
3040
+ }));
3041
+ }))
3042
+ .addCommand(new Command("append")
3043
+ .description("Append an entry to the knowledge log.")
3044
+ .requiredOption("--event <event>", "note, query_filed, or decision.")
3045
+ .option("--slug <slug>", "Related page slug.")
3046
+ .option("--summary <text>", "Short summary of the log entry.")
3047
+ .option("--data-json <json>", "Flexible JSON object for structured log data.")
3048
+ .option("--json", "Print a JSON envelope.")
3049
+ .action(async (options) => {
3050
+ await handleAsyncAction("knowledge log append", options, () => requestOxygen("/api/cli/knowledge/log/append", {
3051
+ method: "POST",
3052
+ body: buildKnowledgeLogAppendBody(options),
3053
+ }));
3054
+ })))
3055
+ .addCommand(new Command("proposals")
3056
+ .description("Draft change proposals against wiki pages, awaiting human review.")
3057
+ .option("--status <status>", "open, decided, or all. Defaults to open.")
3058
+ .option("--json", "Print a JSON envelope.")
3059
+ .action(async (options) => {
3060
+ await handleAsyncAction("knowledge proposals", options, () => requestOxygen("/api/cli/knowledge/proposals", {
3061
+ method: "POST",
3062
+ body: {
3063
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
3064
+ },
3065
+ }));
3066
+ })
3067
+ .addCommand(new Command("approve")
3068
+ .description("Approve a knowledge proposal and apply it to the target page.")
3069
+ .argument("<id_or_slug>", "Proposal UUID or proposal page slug.")
3070
+ .option("--force", "Apply even if the target page changed since the proposal was drafted.")
3071
+ .option("--json", "Print a JSON envelope.")
3072
+ .action(async (idOrSlug, options) => {
3073
+ await handleAsyncAction("knowledge proposals approve", options, () => requestOxygen("/api/cli/knowledge/proposals/approve", {
3074
+ method: "POST",
3075
+ body: {
3076
+ ...knowledgeProposalRefBody(idOrSlug),
3077
+ ...(options.force ? { force: true } : {}),
3078
+ },
3079
+ }));
3080
+ }))
3081
+ .addCommand(new Command("reject")
3082
+ .description("Reject a knowledge proposal without changing the target page.")
3083
+ .argument("<id_or_slug>", "Proposal UUID or proposal page slug.")
3084
+ .option("--json", "Print a JSON envelope.")
3085
+ .action(async (idOrSlug, options) => {
3086
+ await handleAsyncAction("knowledge proposals reject", options, () => requestOxygen("/api/cli/knowledge/proposals/reject", {
3087
+ method: "POST",
3088
+ body: knowledgeProposalRefBody(idOrSlug),
3089
+ }));
3090
+ })))
3091
+ .addCommand(new Command("resolve")
3092
+ .description("Resolve task-scoped workspace GTM context with readiness and revision provenance.")
3093
+ .option("--purpose <purpose>", "general, lead_sourcing, qualification, outbound_copy, or workflow_design.")
3094
+ .option("--asset-type <csv>", "Comma-separated context asset types to include.")
3095
+ .option("--asset-status <status>", "draft, active, archived, or all. Defaults to active.")
3096
+ .option("--tags <csv>", "Comma-separated asset tags that must be present.")
3097
+ .option("--include-archived", "Include archived assets when no asset status is set.")
3098
+ .option("--max-assets <n>", "Maximum assets to include. Defaults to 10, max 50.")
3099
+ .option("--require-ready", "Exit with a conflict error when required context sections are missing.")
3100
+ .option("--json", "Print a JSON envelope.")
3101
+ .action(async (options) => {
3102
+ await handleAsyncAction("knowledge resolve", options, () => requestOxygen("/api/cli/knowledge/resolve", {
3103
+ method: "POST",
3104
+ body: buildContextResolveBody(options),
3105
+ }));
3106
+ }))
3107
+ .addCommand(new Command("profile")
3108
+ .description("Company profile and ICP memory.")
3109
+ .option("--json", "Print a JSON envelope.")
3110
+ .action(async (options) => {
3111
+ await handleAsyncAction("knowledge profile", options, () => requestOxygen("/api/cli/knowledge/profile"));
3112
+ })
3113
+ .addCommand(new Command("update")
3114
+ .description("Merge one or more profile sections into workspace GTM memory.")
3115
+ .requiredOption("--data-json <json>", "JSON object with company, offering, icp, market, gtm_stack, or custom sections.")
3116
+ .option("--summary <text>", "Optional concise summary for the profile.")
3117
+ .option("--json", "Print a JSON envelope.")
3118
+ .action(async (options) => {
3119
+ await handleAsyncAction("knowledge profile update", options, () => requestOxygen("/api/cli/knowledge/profile/update", {
3120
+ method: "POST",
3121
+ body: {
3122
+ data: parseJsonObject(options.dataJson ?? "{}"),
3123
+ ...(readOption(options.summary) ? { summary: readOption(options.summary) } : {}),
3124
+ },
3125
+ }));
3126
+ })))
3127
+ .addCommand(new Command("seed")
3128
+ .description("Seed the knowledge wiki with starter pages.")
3129
+ .option("--json", "Print a JSON envelope.")
3130
+ .action(async (options) => {
3131
+ await handleAsyncAction("knowledge seed", options, () => requestOxygen("/api/cli/knowledge/seed", {
3132
+ method: "POST",
3133
+ body: {},
3134
+ }));
3135
+ }))
3136
+ .addCommand(new Command("sync")
3137
+ .description("Clone or refresh the local knowledge mirror: server-rendered page markdown under the CLI config dir.")
3138
+ .option("--full", "Purge the mirror (including its cursor) and re-clone every page.")
3139
+ .option("--if-stale", "Skip when the mirror completed a sync within the TTL (or one is already running).")
3140
+ .option("--ttl <minutes>", "Freshness window for --if-stale, in minutes. Defaults to 15.")
3141
+ .option("--json", "Print a JSON envelope.")
3142
+ .action(async (options) => {
3143
+ await handleAsyncAction("knowledge sync", options, () => runKnowledgeMirrorSync(options));
3144
+ }))
3145
+ .addCommand(new Command("status")
3146
+ .description("Report the local knowledge mirror: path, page count, last sync, quarantined conflicts.")
3147
+ .option("--print-path", "Print only the mirror directory path.")
3148
+ .option("--clear-conflicts", "Delete quarantined conflict copies from the mirror sidecar.")
3149
+ .option("--json", "Print a JSON envelope.")
3150
+ .action(async (options) => {
3151
+ if (options.printPath) {
3152
+ await handleKnowledgeMirrorPrintPathAction();
3153
+ return;
3154
+ }
3155
+ await handleAsyncAction("knowledge status", options, () => runKnowledgeMirrorStatus(options));
3156
+ }))
3157
+ .addCommand(new Command("purge")
3158
+ .description("Delete the whole local knowledge mirror for the active API host and organization.")
3159
+ .option("--json", "Print a JSON envelope.")
3160
+ .action(async (options) => {
3161
+ await handleAsyncAction("knowledge purge", options, () => runKnowledgeMirrorPurge());
3162
+ }));
2574
3163
  program
2575
3164
  .command("blueprints")
2576
3165
  .description("Portable Oxygen blueprints: bundle a workflow + tables + columns + prompts as shareable JSON.")
@@ -3248,9 +3837,11 @@ export function createProgram() {
3248
3837
  .requiredOption("--table <table>", "Table id or slug.")
3249
3838
  .option("--status <status>", "Filter by active, queued, running, paused, completed, completed_with_errors, failed, canceling, or canceled. Defaults to active.")
3250
3839
  .option("--limit <n>", "Maximum runs to return. Defaults to 20.")
3840
+ .option("--format <format>", "Format run history as json, jsonl, csv, or table (same serializer as `tables export`). Omit to keep the default envelope output.")
3841
+ .option("--output <path>", "Write the formatted run history to a file instead of embedding it in the response.")
3251
3842
  .option("--json", "Print a JSON envelope.")
3252
3843
  .action(async (options) => {
3253
- await handleAsyncAction("table-runs list", options, () => requestOxygen(tableRunsListPath(options)));
3844
+ await handleAsyncAction("table-runs list", options, () => listTableRuns(options));
3254
3845
  }))
3255
3846
  .addCommand(new Command("get")
3256
3847
  .description("Get one durable table action run.")
@@ -4459,7 +5050,6 @@ export function createProgram() {
4459
5050
  .addCommand(new Command("connect")
4460
5051
  .description("Attach the credential (bearer token, API key, or basic password) for an applied custom HTTP integration.")
4461
5052
  .argument("<slug>", "Custom integration slug from the applied manifest.")
4462
- .option("--api-key <value>", "Credential value. Prefer --secrets-file to keep it out of shell history.")
4463
5053
  .option("--secrets-file <path>", "Path to a .env-style file holding the credential (a single entry, or one named API_KEY/TOKEN/PASSWORD/SECRET).")
4464
5054
  .option("--json", "Print a JSON envelope.")
4465
5055
  .action(async (slug, options) => {
@@ -6272,7 +6862,7 @@ export function createProgram() {
6272
6862
  });
6273
6863
  }))
6274
6864
  .addCommand(new Command("update")
6275
- .description("Update a DRAFT sequence's name, journey, channels, senders, email binding, or LinkedIn credit cap. The journey is re-validated; the email binding stays editable only while the sequence is a draft. --max-credits is locked once the sequence has been started (re-run `sequences start`). Pass only the fields you want to change.")
6865
+ .description("Update a DRAFT sequence's name, journey, channels, senders, email binding, or LinkedIn credit cap, plus the always-editable open/click tracking toggles (--[no-]tracking-opens / --[no-]tracking-clicks, default on). The journey is re-validated; the email binding stays editable only while the sequence is a draft. --max-credits is locked once the sequence has been started (re-run `sequences start`). Pass only the fields you want to change.")
6276
6866
  .argument("<sequence>", "Sequence id or slug.")
6277
6867
  .option("--name <name>", "New human-readable sequence name.")
6278
6868
  .option("--steps-file <path>", "Path to a JSON file: { \"steps\": [...] } replacing the journey.")
@@ -6294,6 +6884,10 @@ export function createProgram() {
6294
6884
  .option("--schedule-template <name>", "Name of a saved schedule template (oxygen schedules) backing the sending window.")
6295
6885
  .option("--esp-matching <mode>", "Native-email ESP matching: 'off' (default) rotates mailboxes freely; 'prefer' biases toward a mailbox on the recipient's own provider; 'strict' requires a same-provider mailbox and defers the send when none exists.")
6296
6886
  .option("--no-stop-on-bounce", "Keep a lead's enrollment running after a hard bounce (default: stop it). The bounce is still recorded as an email_bounced signal either way.")
6887
+ .option("--tracking-opens", "Turn open-pixel tracking ON for this sequence's native email sends (the default; injection still needs a verified tracking domain — see `oxygen domains tracking`).")
6888
+ .option("--no-tracking-opens", "Turn OFF the open pixel for this sequence's native email sends (a deliverability knob — a pixel is spam-filter surface).")
6889
+ .option("--tracking-clicks", "Turn click-link tracking ON for this sequence's native email sends (the default; same verified-tracking-domain gate as opens).")
6890
+ .option("--no-tracking-clicks", "Turn OFF click-link rewriting for this sequence's native email sends (links go out untouched).")
6297
6891
  .option("--json", "Print a JSON envelope.")
6298
6892
  .action(async (sequence, options) => {
6299
6893
  await handleAsyncAction("sequences update", options, () => {
@@ -6320,6 +6914,12 @@ export function createProgram() {
6320
6914
  const settings = readSequenceSettings(options);
6321
6915
  if (settings)
6322
6916
  body.settings = settings;
6917
+ // Tri-state tracking toggles: only send the key when a flag was
6918
+ // explicitly passed, so an unrelated update never flips tracking.
6919
+ if (options.trackingOpens !== undefined)
6920
+ body.tracking_opens = options.trackingOpens;
6921
+ if (options.trackingClicks !== undefined)
6922
+ body.tracking_clicks = options.trackingClicks;
6323
6923
  if (options.clearEmail) {
6324
6924
  body.email = null;
6325
6925
  }
@@ -6329,7 +6929,7 @@ export function createProgram() {
6329
6929
  body.email = email;
6330
6930
  }
6331
6931
  if (Object.keys(body).length === 0) {
6332
- throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, or --no-stop-on-bounce).");
6932
+ throw new Error("Provide at least one field to update (--name, --steps-file, --channels, --senders, --email-*, --clear-email, --max-credits, --max-live-sends, --max-emails-per-mailbox-per-day, --send-window-file, --no-stop-on-bounce, or --[no-]tracking-opens / --[no-]tracking-clicks).");
6333
6933
  }
6334
6934
  return requestOxygen(`/api/cli/sequences/${encodeURIComponent(sequence)}`, {
6335
6935
  method: "PATCH",
@@ -6597,7 +7197,7 @@ export function createProgram() {
6597
7197
  to: readOption(options.to),
6598
7198
  subject: readOption(options.subject),
6599
7199
  body: bodyText,
6600
- ...(htmlBody && htmlBody.trim() ? { html: htmlBody } : {}),
7200
+ ...(htmlBody?.trim() ? { html: htmlBody } : {}),
6601
7201
  ...(readOption(options.displayName) ? { display_name: readOption(options.displayName) } : {}),
6602
7202
  ...(cc.length > 0 ? { cc } : {}),
6603
7203
  ...(bcc.length > 0 ? { bcc } : {}),
@@ -7033,6 +7633,26 @@ export function createProgram() {
7033
7633
  .option("--json", "Print a JSON envelope.")
7034
7634
  .action(async (domain, options) => {
7035
7635
  await handleAsyncAction("domains tracking status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/tracking`));
7636
+ })))
7637
+ .addCommand(new Command("postmaster")
7638
+ .description("Google Postmaster Tools onboarding for a sending domain: register it with Postmaster, publish the DNS verification TXT via its Cloudflare zone, and check verification status. FREE — 0 Oxygen credits.")
7639
+ .addCommand(new Command("onboard")
7640
+ .description("Register the domain with Google Postmaster Tools and publish the DNS verification TXT automatically via the domain's Cloudflare zone (or return the record for manual DNS); verification then completes automatically in the background. Without --approve, returns a preview. FREE — 0 Oxygen credits.")
7641
+ .argument("<domain>", "Sending domain, such as acme.com.")
7642
+ .option("--approve", "Perform the onboarding. Without this flag, returns a preview only.")
7643
+ .option("--json", "Print a JSON envelope.")
7644
+ .action(async (domain, options) => {
7645
+ await handleAsyncAction("domains postmaster onboard", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/postmaster`, {
7646
+ method: "POST",
7647
+ body: { ...(options.approve ? { approved: true } : {}) },
7648
+ }));
7649
+ }))
7650
+ .addCommand(new Command("status")
7651
+ .description("Show the Google Postmaster Tools onboarding status for a domain: registration, DNS verification state, and whether reputation data is available. Read-only — 0 Oxygen credits.")
7652
+ .argument("<domain>", "Sending domain, such as acme.com.")
7653
+ .option("--json", "Print a JSON envelope.")
7654
+ .action(async (domain, options) => {
7655
+ await handleAsyncAction("domains postmaster status", options, () => requestOxygen(`/api/cli/domains/${encodeURIComponent(domain)}/postmaster`));
7036
7656
  })))
7037
7657
  .addCommand(new Command("add")
7038
7658
  .description("Onboard a domain registered elsewhere by creating a Cloudflare zone for it, so its DNS can be managed here. Returns the nameservers to set at your registrar. Idempotent.")
@@ -7489,8 +8109,7 @@ export function createProgram() {
7489
8109
  .option("--copy", "Copy skill files instead of symlinking when supported by npx skills. Default on Windows, where symlinks need Developer Mode or admin.")
7490
8110
  .option("--json", "Print a JSON envelope.")
7491
8111
  .action(async (options) => {
7492
- // skipcq: JS-0116 — async Promise-wraps the synchronous installAgentSkills helper to satisfy handleAsyncAction's Promise<unknown> action
7493
- await handleAsyncAction("skills install", options, async () => installAgentSkills(options));
8112
+ await handleAsyncAction("skills install", options, () => installAgentSkills(options));
7494
8113
  }));
7495
8114
  return program;
7496
8115
  }
@@ -7551,36 +8170,36 @@ function readBundledSdkDts(pkg) {
7551
8170
  }
7552
8171
  function renderStarterRecipe(id, name) {
7553
8172
  return [
7554
- `import { defineRecipe, type RecipeContext } from "@oxygen/recipe-sdk";`,
7555
- ``,
7556
- `// A durable recipe is the default export of defineRecipe(...); it compiles into an`,
7557
- `// Oxygen workflow. Recipes reach external systems ONLY through catalog tools —`,
7558
- `// raw fetch/network is disabled in the recipe sandbox.`,
7559
- `//`,
8173
+ 'import { defineRecipe, type RecipeContext } from "@oxygen/recipe-sdk";',
8174
+ "",
8175
+ "// A durable recipe is the default export of defineRecipe(...); it compiles into an",
8176
+ "// Oxygen workflow. Recipes reach external systems ONLY through catalog tools —",
8177
+ "// raw fetch/network is disabled in the recipe sandbox.",
8178
+ "//",
7560
8179
  `// Deploy: oxygen workflows apply --file ./${id}.ts`,
7561
- `// Dry-run: oxygen workflows call <workflow-id> --mode dry_run --json`,
7562
- `// Docs: https://oxygen-agent.com/docs/authoring/recipes`,
7563
- `export default defineRecipe({`,
8180
+ "// Dry-run: oxygen workflows call <workflow-id> --mode dry_run --json",
8181
+ "// Docs: https://oxygen-agent.com/docs/authoring/recipes",
8182
+ "export default defineRecipe({",
7564
8183
  ` id: ${JSON.stringify(id)},`,
7565
8184
  ` name: ${JSON.stringify(name)},`,
7566
- ` // Allowlist of every tool id this recipe may call — including native table`,
7567
- ` // ops, which dispatch through oxygen.* tools (e.g. oxygen.rows_upsert for`,
7568
- ` // ctx.rows.upsert). Discover provider tools with: oxygen tools search <query>`,
7569
- ` tools: ["firecrawl.scrape", "oxygen.rows_upsert"],`,
7570
- ` trigger: { type: "api" },`,
7571
- ` inputSchema: {`,
7572
- ` type: "object",`,
7573
- ` properties: {},`,
7574
- ` },`,
7575
- ` async run(ctx: RecipeContext) {`,
7576
- ` ctx.log("info", "recipe started", { mode: ctx.mode });`,
7577
- ` // Example: pull data through a tool, then persist it to a table.`,
7578
- ` // const page = await ctx.tools.run("firecrawl.scrape", { url }, { key: "scrape" });`,
7579
- ` // await ctx.rows.upsert("my_table", rows, { key: "save", upsertKey: "id" });`,
7580
- ` return { ok: true, at: await ctx.now() };`,
7581
- ` },`,
7582
- `});`,
7583
- ``,
8185
+ " // Allowlist of every tool id this recipe may call — including native table",
8186
+ " // ops, which dispatch through oxygen.* tools (e.g. oxygen.rows_upsert for",
8187
+ " // ctx.rows.upsert). Discover provider tools with: oxygen tools search <query>",
8188
+ ' tools: ["firecrawl.scrape", "oxygen.rows_upsert"],',
8189
+ ' trigger: { type: "api" },',
8190
+ " inputSchema: {",
8191
+ ' type: "object",',
8192
+ " properties: {},",
8193
+ " },",
8194
+ " async run(ctx: RecipeContext) {",
8195
+ ' ctx.log("info", "recipe started", { mode: ctx.mode });',
8196
+ " // Example: pull data through a tool, then persist it to a table.",
8197
+ ' // const page = await ctx.tools.run("firecrawl.scrape", { url }, { key: "scrape" });',
8198
+ ' // await ctx.rows.upsert("my_table", rows, { key: "save", upsertKey: "id" });',
8199
+ " return { ok: true, at: await ctx.now() };",
8200
+ " },",
8201
+ "});",
8202
+ "",
7584
8203
  ].join("\n");
7585
8204
  }
7586
8205
  function renderRecipeTsconfig() {
@@ -7646,7 +8265,7 @@ function scaffoldRecipeProject(options) {
7646
8265
  next_steps: [
7647
8266
  `Edit ${recipeFileName}, then find tools with: oxygen tools search <query>`,
7648
8267
  `Deploy it: oxygen workflows apply --file ./${recipeFileName}`,
7649
- `Dry-run it: oxygen workflows call <workflow-id> --mode dry_run --json`,
8268
+ "Dry-run it: oxygen workflows call <workflow-id> --mode dry_run --json",
7650
8269
  ],
7651
8270
  };
7652
8271
  }
@@ -9375,7 +9994,7 @@ async function runDomainsDnsPlan(domain, options) {
9375
9994
  }
9376
9995
  // Apply an approved DNS plan (writes records). Requires --plan <hash> with
9377
9996
  // --approved, mirroring `domains buy --quote`.
9378
- async function runDomainsDnsApply(domain, options) {
9997
+ function runDomainsDnsApply(domain, options) {
9379
9998
  const planHash = readOption(options.plan);
9380
9999
  if (options.approved && !planHash) {
9381
10000
  throw new Error("--plan <hash> is required with --approved. Run `domains dns plan` first to get the plan and its hash.");
@@ -9503,6 +10122,33 @@ async function exportRows(table, options) {
9503
10122
  ...(formatted.rescuedCount > 0 ? { rescuedNumericCells: formatted.rescuedCount } : {}),
9504
10123
  };
9505
10124
  }
10125
+ // `table-runs list --format` routes run history through the same serializer
10126
+ // `tables export` uses, so csv/jsonl/table run exports behave exactly like row
10127
+ // exports (header = union of run keys; nested objects like counts are
10128
+ // JSON-stringified by escapeCsvField). Without --format/--output the command
10129
+ // keeps its original envelope response untouched.
10130
+ async function listTableRuns(options) {
10131
+ const rawFormat = readOption(options.format);
10132
+ const outputPath = readOption(options.output);
10133
+ if (!rawFormat && !outputPath)
10134
+ return requestOxygen(tableRunsListPath(options));
10135
+ // Validate the format before spending the API call.
10136
+ const format = normalizeExportRowsFormat(rawFormat ?? undefined);
10137
+ const result = await requestOxygen(tableRunsListPath(options));
10138
+ const runs = Array.isArray(result.runs) ? result.runs.filter(isRecord) : [];
10139
+ const formatted = formatRows(runs, format);
10140
+ if (outputPath)
10141
+ writeFileSync(outputPath, formatted.content);
10142
+ return {
10143
+ table: result.table ?? null,
10144
+ status: result.status ?? null,
10145
+ format,
10146
+ runCount: runs.length,
10147
+ output: outputPath ?? null,
10148
+ ...(outputPath ? {} : { content: formatted.content }),
10149
+ ...(typeof result.web_url === "string" ? { web_url: result.web_url } : {}),
10150
+ };
10151
+ }
9506
10152
  const TABLE_BUNDLE_SCHEMA_VERSION = 1;
9507
10153
  const TABLE_BUNDLE_MAX_PAGE_SIZE = 1000;
9508
10154
  const TABLE_BUNDLE_DEFAULT_PAGE_SIZE = 500;
@@ -10445,9 +11091,9 @@ async function handleLogoutAction(options) {
10445
11091
  process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
10446
11092
  }
10447
11093
  }
10448
- function handleUpdateAction(options) {
11094
+ async function handleUpdateAction(options) {
10449
11095
  try {
10450
- const result = updateCli(options);
11096
+ const result = await updateCli(options);
10451
11097
  if (options.json) {
10452
11098
  writeJson(success("update", result));
10453
11099
  return;
@@ -10522,7 +11168,7 @@ async function login(options) {
10522
11168
  renamed = picked.renamed;
10523
11169
  }
10524
11170
  const profile = await saveCredentials(credentials, process.env, { profile: chosenProfile });
10525
- const skillsInstall = runAutomaticSkillsInstall({ apiUrl: credentials.apiUrl });
11171
+ const skillsInstall = await runAutomaticSkillsInstall({ apiUrl: credentials.apiUrl, credentials });
10526
11172
  if (!options.json) {
10527
11173
  process.stdout.write(formatLoginSuccessForResolved(loginIdentity, profile, { renamed, skillsInstall }));
10528
11174
  const hint = await buildPostLoginHint(profile);
@@ -11900,6 +12546,332 @@ function buildContextAssetUpsertBody(options) {
11900
12546
  ...(options.default ? { is_default: true } : {}),
11901
12547
  };
11902
12548
  }
12549
+ function buildKnowledgePageUpsertBody(options) {
12550
+ const tags = readCsvOption(options.tags);
12551
+ const expectedRevision = readPositiveInt(options.expectedRevision);
12552
+ return {
12553
+ ...(readOption(options.slug) ? { slug: readOption(options.slug) } : {}),
12554
+ ...(readOption(options.id) ? { id: readOption(options.id) } : {}),
12555
+ ...(readOption(options.type) ? { type: readOption(options.type) } : {}),
12556
+ ...(readOption(options.title) ? { title: readOption(options.title) } : {}),
12557
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
12558
+ ...(tags.length > 0 ? { tags } : {}),
12559
+ ...(readOption(options.summary) ? { summary: readOption(options.summary) } : {}),
12560
+ ...(readOption(options.body) ? { body: readOption(options.body) } : {}),
12561
+ ...(options.dataJson ? { data: parseJsonObject(options.dataJson) } : {}),
12562
+ ...(expectedRevision !== undefined ? { expected_revision: expectedRevision } : {}),
12563
+ ...(options.approved ? { approved: true } : {}),
12564
+ };
12565
+ }
12566
+ function buildKnowledgePageListBody(options) {
12567
+ const tags = readCsvOption(options.tags);
12568
+ const limit = readPositiveInt(options.limit);
12569
+ return {
12570
+ ...(readOption(options.type) ? { type: readOption(options.type) } : {}),
12571
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
12572
+ ...(tags.length > 0 ? { tags } : {}),
12573
+ ...(limit !== undefined ? { limit } : {}),
12574
+ };
12575
+ }
12576
+ function buildKnowledgeGraphBody(options) {
12577
+ const maxNodes = readPositiveInt(options.maxNodes);
12578
+ return {
12579
+ ...(maxNodes !== undefined ? { max_nodes: maxNodes } : {}),
12580
+ };
12581
+ }
12582
+ function buildKnowledgeLogListBody(options) {
12583
+ const limit = readPositiveInt(options.limit);
12584
+ return {
12585
+ ...(readOption(options.cursor) ? { cursor: readOption(options.cursor) } : {}),
12586
+ ...(limit !== undefined ? { limit } : {}),
12587
+ };
12588
+ }
12589
+ function buildKnowledgeLogAppendBody(options) {
12590
+ return {
12591
+ ...(readOption(options.event) ? { event: readOption(options.event) } : {}),
12592
+ ...(readOption(options.slug) ? { slug: readOption(options.slug) } : {}),
12593
+ ...(readOption(options.summary) ? { summary: readOption(options.summary) } : {}),
12594
+ ...(options.dataJson ? { data: parseJsonObject(options.dataJson) } : {}),
12595
+ };
12596
+ }
12597
+ function buildKnowledgeSearchBody(query, options) {
12598
+ const tags = readCsvOption(options.tags);
12599
+ const limit = readPositiveInt(options.limit);
12600
+ return {
12601
+ query: query.trim(),
12602
+ ...(readOption(options.type) ? { type: readOption(options.type) } : {}),
12603
+ ...(readOption(options.status) ? { status: readOption(options.status) } : {}),
12604
+ ...(tags.length > 0 ? { tags } : {}),
12605
+ ...(limit !== undefined ? { limit } : {}),
12606
+ };
12607
+ }
12608
+ // ── Knowledge mirror (local wiki clone) ─────────────────────────────────────────
12609
+ // `oxygen knowledge sync` maintains <configDir>/knowledge/<api-host>/<org-id>/ with
12610
+ // SERVER-rendered page markdown written verbatim — the server hashes those exact
12611
+ // bytes (content_sha256) and the client never re-renders, so renderer drift can't
12612
+ // mass-quarantine mirrors. Pure directory mechanics live in knowledge-mirror.ts.
12613
+ const KNOWLEDGE_SYNC_DEFAULT_TTL_MINUTES = 15;
12614
+ // 500 batches × the server's 100-page default is far above any v1 wiki; the cap
12615
+ // only exists so a misbehaving server can never wedge the CLI in a loop.
12616
+ const KNOWLEDGE_SYNC_MAX_BATCHES = 500;
12617
+ // Envelope error codes meaning this org can no longer be read with the stored
12618
+ // token (revoked membership, expired key, org mismatch). The mirror is a local
12619
+ // cache of tenant data and must not outlive access to it.
12620
+ const KNOWLEDGE_SYNC_ACCESS_REVOKED_CODES = new Set([
12621
+ "unauthorized",
12622
+ "forbidden",
12623
+ "forbidden_origin",
12624
+ "organization_not_found",
12625
+ "organization_mismatch",
12626
+ ]);
12627
+ function knowledgeMirrorTargetFor(apiUrl, orgId) {
12628
+ const apiHost = new URL(apiUrl).host;
12629
+ return {
12630
+ dir: resolveMirrorDir({ configDir: resolveDefaultConfigDir(), apiHost, orgId }),
12631
+ apiHost,
12632
+ orgId,
12633
+ webUrl: `${apiUrl.replace(/\/$/, "")}/knowledge`,
12634
+ };
12635
+ }
12636
+ // The org id every other command resolves implicitly (X-Oxygen-Organization from
12637
+ // OXYGEN_ORG / the stored profile) must be pinned explicitly here because it names
12638
+ // the mirror directory. Null when the cached identity can't answer for the
12639
+ // selected org — e.g. env-token logins or an OXYGEN_ORG/--org override.
12640
+ function knowledgeMirrorTargetFromCredentials(credentials) {
12641
+ const cached = credentials.activeOrganization ?? credentials.identity?.organization ?? null;
12642
+ if (!cached)
12643
+ return null;
12644
+ const selected = process.env.OXYGEN_ORG?.trim();
12645
+ if (selected && selected !== cached.id && selected !== cached.slug)
12646
+ return null;
12647
+ return knowledgeMirrorTargetFor(credentials.apiUrl, cached.id);
12648
+ }
12649
+ async function resolveKnowledgeMirrorTarget() {
12650
+ const credentials = await loadCredentials();
12651
+ if (!credentials) {
12652
+ throw new OxygenError("not_logged_in", "Run `oxygen login` before using CLI commands.", {
12653
+ exitCode: 1,
12654
+ });
12655
+ }
12656
+ const local = knowledgeMirrorTargetFromCredentials(credentials);
12657
+ if (local)
12658
+ return local;
12659
+ const identity = await requestOxygen("/api/cli/whoami", {
12660
+ enforceMinimumCliVersion: false,
12661
+ });
12662
+ return knowledgeMirrorTargetFor(credentials.apiUrl, identity.organization.id);
12663
+ }
12664
+ // Post-write staleness hook for `knowledge page upsert|pin|archive`: clearing
12665
+ // last_sync_at makes the next `knowledge sync --if-stale` run for real. A byte-
12666
+ // perfect single-page refresh needs a dedicated sync endpoint (deferred); the
12667
+ // hook is deliberately local-only — no whoami round-trip — and best-effort.
12668
+ async function markKnowledgeMirrorStaleAfterWrite() {
12669
+ try {
12670
+ const credentials = await loadCredentials().catch(() => null);
12671
+ const target = credentials ? knowledgeMirrorTargetFromCredentials(credentials) : null;
12672
+ if (!target || !mirrorExists(target.dir))
12673
+ return;
12674
+ markMirrorStale(target.dir);
12675
+ }
12676
+ catch {
12677
+ // A broken local mirror must never fail the page write itself.
12678
+ }
12679
+ }
12680
+ function knowledgeSyncSummary(target, state, extra) {
12681
+ return {
12682
+ synced: 0,
12683
+ deleted: 0,
12684
+ quarantined: 0,
12685
+ pages_total: state ? Object.keys(state.pages).length : 0,
12686
+ cursor: state?.cursor ?? null,
12687
+ last_sync_at: state?.last_sync_at ?? null,
12688
+ mirror_path: target.dir,
12689
+ web_url: target.webUrl,
12690
+ ...extra,
12691
+ };
12692
+ }
12693
+ function applyKnowledgeSyncPage(dir, state, page, counters) {
12694
+ if (page.rendered_markdown === undefined || page.content_sha256 === undefined || !page.slug) {
12695
+ // Tombstone (archived or slug-less): delete the file this page owns locally,
12696
+ // matching by slug first and falling back to the page id (slug re-use).
12697
+ const localSlug = page.slug && state.pages[page.slug]?.id === page.id
12698
+ ? page.slug
12699
+ : findMirrorSlugByPageId(state, page.id);
12700
+ if (localSlug) {
12701
+ deletePageFile(dir, localSlug);
12702
+ delete state.pages[localSlug];
12703
+ counters.deleted += 1;
12704
+ }
12705
+ return;
12706
+ }
12707
+ // Slug renames: drop the file this page previously owned under its old slug.
12708
+ const previousSlug = findMirrorSlugByPageId(state, page.id);
12709
+ if (previousSlug && previousSlug !== page.slug) {
12710
+ deletePageFile(dir, previousSlug);
12711
+ delete state.pages[previousSlug];
12712
+ counters.deleted += 1;
12713
+ }
12714
+ // Quarantine only bytes WE didn't write (differ from the manifest's recorded
12715
+ // sha) that this write would destroy (differ from the incoming server bytes).
12716
+ const recordedSha = state.pages[page.slug]?.content_sha256;
12717
+ if (isFileDirty(dir, page.slug, recordedSha ?? page.content_sha256)
12718
+ && isFileDirty(dir, page.slug, page.content_sha256)) {
12719
+ quarantineDirtyFile(dir, page.slug);
12720
+ counters.quarantined += 1;
12721
+ }
12722
+ writePageFile(dir, page.slug, page.rendered_markdown);
12723
+ state.pages[page.slug] = {
12724
+ id: page.id,
12725
+ revision: page.revision,
12726
+ content_sha256: page.content_sha256,
12727
+ };
12728
+ counters.synced += 1;
12729
+ }
12730
+ async function runKnowledgeMirrorSync(options) {
12731
+ const target = await resolveKnowledgeMirrorTarget();
12732
+ const ttlMinutes = readPositiveInt(options.ttl) ?? KNOWLEDGE_SYNC_DEFAULT_TTL_MINUTES;
12733
+ if (options.ifStale && !options.full) {
12734
+ const state = readMirrorState(target.dir);
12735
+ const lastSyncMs = state?.last_sync_at ? Date.parse(state.last_sync_at) : Number.NaN;
12736
+ if (Number.isFinite(lastSyncMs) && Date.now() - lastSyncMs < ttlMinutes * 60_000) {
12737
+ return knowledgeSyncSummary(target, state, { skipped: true, reason: "fresh" });
12738
+ }
12739
+ }
12740
+ if (!acquireMirrorLock(target.dir)) {
12741
+ if (options.ifStale) {
12742
+ return knowledgeSyncSummary(target, readMirrorState(target.dir), { skipped: true, reason: "locked" });
12743
+ }
12744
+ throw new OxygenError("knowledge_sync_locked", "Another knowledge sync is already running for this mirror. Retry shortly; a crashed sync's lock self-clears after 10 minutes.", {
12745
+ details: { mirror_path: target.dir },
12746
+ exitCode: 1,
12747
+ });
12748
+ }
12749
+ try {
12750
+ // --full re-clones from scratch but keeps the never-silently-clobber promise:
12751
+ // dirty local files are quarantined into .oxygen/conflicts/ (which survives)
12752
+ // first. Done AFTER acquiring the mirror lock above so a concurrent --full
12753
+ // can't delete files out from under another running sync before locking.
12754
+ const fullResetQuarantined = options.full
12755
+ ? resetMirrorForFullResync(target.dir).quarantined
12756
+ : 0;
12757
+ const state = readMirrorState(target.dir)
12758
+ ?? emptyMirrorState({ apiHost: target.apiHost, orgId: target.orgId });
12759
+ const counters = { synced: 0, deleted: 0, quarantined: fullResetQuarantined };
12760
+ let webUrl = target.webUrl;
12761
+ let cursor = state.cursor;
12762
+ let lastSeen = null;
12763
+ let completed = false;
12764
+ for (let batch = 0; batch < KNOWLEDGE_SYNC_MAX_BATCHES; batch += 1) {
12765
+ const delta = await requestOxygen("/api/cli/knowledge/sync/delta", {
12766
+ method: "POST",
12767
+ body: cursor ? { cursor } : {},
12768
+ });
12769
+ if (delta.web_url)
12770
+ webUrl = delta.web_url;
12771
+ for (const page of delta.pages) {
12772
+ applyKnowledgeSyncPage(target.dir, state, page, counters);
12773
+ lastSeen = { updated_at: page.updated_at, id: page.id };
12774
+ }
12775
+ if (!delta.has_more) {
12776
+ // Completed. Persist the watermark AFTER the last seen page — the delta
12777
+ // keyset is (updated_at, id) ascending and updated_at is the server's own
12778
+ // ISO rendering, so `<updated_at>|<id>` is exactly the server cursor and
12779
+ // the next sync fetches only pages updated since.
12780
+ if (lastSeen)
12781
+ state.cursor = `${lastSeen.updated_at}|${lastSeen.id}`;
12782
+ completed = true;
12783
+ break;
12784
+ }
12785
+ if (!delta.next_cursor || delta.next_cursor === cursor) {
12786
+ throw new OxygenError("knowledge_sync_stalled", "The sync cursor did not advance between batches; aborting to avoid a loop.", {
12787
+ details: { cursor, mirror_path: target.dir },
12788
+ exitCode: 1,
12789
+ });
12790
+ }
12791
+ cursor = delta.next_cursor;
12792
+ // Persist per batch so an interrupted clone resumes exactly where it stopped.
12793
+ state.cursor = cursor;
12794
+ writeMirrorState(target.dir, state);
12795
+ }
12796
+ if (!completed) {
12797
+ throw new OxygenError("knowledge_sync_incomplete", "The sync batch cap was reached before the wiki finished; re-run `knowledge sync` to resume from the stored cursor.", {
12798
+ details: { cursor: state.cursor, mirror_path: target.dir, batch_cap: KNOWLEDGE_SYNC_MAX_BATCHES },
12799
+ exitCode: 1,
12800
+ });
12801
+ }
12802
+ // Generated summaries come from the server's own projections, not a client-side
12803
+ // recomputation. A real wiki page may own the `index`/`log` slug — page bytes win.
12804
+ const index = await requestOxygen("/api/cli/knowledge/index");
12805
+ const logPage = await requestOxygen("/api/cli/knowledge/log", {
12806
+ method: "POST",
12807
+ body: {},
12808
+ });
12809
+ if (!state.pages.index)
12810
+ writeGeneratedIndexFile(target.dir, index.index);
12811
+ if (!state.pages.log)
12812
+ writeGeneratedLogFile(target.dir, logPage.entries);
12813
+ state.last_sync_at = new Date().toISOString();
12814
+ writeMirrorState(target.dir, state);
12815
+ return knowledgeSyncSummary(target, state, { ...counters, ...(options.full ? { full: true } : {}), web_url: webUrl });
12816
+ }
12817
+ catch (error) {
12818
+ if (error instanceof OxygenError && KNOWLEDGE_SYNC_ACCESS_REVOKED_CODES.has(error.code)) {
12819
+ purgeMirror(target.dir);
12820
+ throw new OxygenError("knowledge_mirror_access_revoked", "This organization can no longer be read with the stored credentials (membership revoked or token expired). The local knowledge mirror was purged.", {
12821
+ details: { org_id: target.orgId, mirror_path: target.dir, cause: error.code },
12822
+ exitCode: 1,
12823
+ });
12824
+ }
12825
+ throw error;
12826
+ }
12827
+ finally {
12828
+ releaseMirrorLock(target.dir);
12829
+ }
12830
+ }
12831
+ async function runKnowledgeMirrorStatus(options) {
12832
+ const target = await resolveKnowledgeMirrorTarget();
12833
+ const clearedConflicts = options.clearConflicts ? clearConflictFiles(target.dir) : undefined;
12834
+ const state = readMirrorState(target.dir);
12835
+ const conflicts = listConflictFiles(target.dir);
12836
+ return {
12837
+ mirror_path: target.dir,
12838
+ exists: mirrorExists(target.dir),
12839
+ pages_total: state ? Object.keys(state.pages).length : 0,
12840
+ last_sync_at: state?.last_sync_at ?? null,
12841
+ cursor: state?.cursor ?? null,
12842
+ conflicts,
12843
+ conflict_count: conflicts.length,
12844
+ ...(clearedConflicts !== undefined ? { cleared_conflicts: clearedConflicts } : {}),
12845
+ web_url: target.webUrl,
12846
+ };
12847
+ }
12848
+ async function runKnowledgeMirrorPurge() {
12849
+ const target = await resolveKnowledgeMirrorTarget();
12850
+ const existed = existsSync(target.dir);
12851
+ purgeMirror(target.dir);
12852
+ return { purged: existed, mirror_path: target.dir, web_url: target.webUrl };
12853
+ }
12854
+ // `--print-path` emits the bare directory path (for shell substitution and
12855
+ // editor tooling), not a JSON envelope — errors still use the failure envelope.
12856
+ async function handleKnowledgeMirrorPrintPathAction() {
12857
+ try {
12858
+ const target = await resolveKnowledgeMirrorTarget();
12859
+ process.stdout.write(`${target.dir}\n`);
12860
+ }
12861
+ catch (error) {
12862
+ const failure = toFailure("knowledge status", error);
12863
+ writeJson(failure);
12864
+ process.exitCode = error instanceof OxygenError ? error.exitCode : 1;
12865
+ }
12866
+ }
12867
+ // Knowledge page routes accept either a UUID (`id`) or a slug (`slug`); split
12868
+ // the single CLI argument the same way the templates commands do.
12869
+ function knowledgePageRefBody(slugOrId) {
12870
+ return isUuid(slugOrId) ? { id: slugOrId } : { slug: slugOrId };
12871
+ }
12872
+ function knowledgeProposalRefBody(idOrSlug) {
12873
+ return isUuid(idOrSlug) ? { proposal_id: idOrSlug } : { slug: idOrSlug };
12874
+ }
11903
12875
  function buildEnrichColumnBody(// skipcq: JS-R1005 -- CLI body builder maps enrichment aliases, selection, provider order, and safety caps.
11904
12876
  table, options) {
11905
12877
  const limit = readPositiveInt(options.limit);