@oxygen-agent/cli 1.1011.326 → 1.1011.440

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +1 -1
  2. package/dist/command-manifest.js +15 -4
  3. package/dist/connectors-commands.d.ts +4 -1
  4. package/dist/connectors-commands.js +84 -10
  5. package/dist/contract-flags.d.ts +2 -0
  6. package/dist/contract-flags.js +26 -11
  7. package/dist/crm-support-flags.d.ts +11 -0
  8. package/dist/crm-support-flags.js +60 -0
  9. package/dist/customer-operation-commands.d.ts +5 -0
  10. package/dist/customer-operation-commands.js +101 -0
  11. package/dist/database-query-flags.d.ts +2 -0
  12. package/dist/database-query-flags.js +6 -0
  13. package/dist/help.d.ts +3 -1
  14. package/dist/help.js +43 -0
  15. package/dist/http-client.d.ts +2 -0
  16. package/dist/http-client.js +41 -1
  17. package/dist/index.js +640 -234
  18. package/dist/knowledge-repository-commands.js +12 -14
  19. package/dist/mailbox-ramp-notice.d.ts +11 -0
  20. package/dist/mailbox-ramp-notice.js +53 -0
  21. package/dist/settings-commands.d.ts +11 -0
  22. package/dist/settings-commands.js +73 -0
  23. package/dist/skills.d.ts +5 -4
  24. package/dist/skills.js +106 -79
  25. package/dist/tool-catalog-detail-command.d.ts +7 -0
  26. package/dist/tool-catalog-detail-command.js +19 -0
  27. package/dist/tool-catalog-search-window.d.ts +6 -0
  28. package/dist/tool-catalog-search-window.js +12 -0
  29. package/dist/transcript.js +1 -0
  30. package/node_modules/@oxygen/shared/dist/bounded-read-result.d.ts +4 -0
  31. package/node_modules/@oxygen/shared/dist/bounded-read-result.js +25 -0
  32. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +47 -1
  33. package/node_modules/@oxygen/shared/dist/capability-discovery.js +625 -39
  34. package/node_modules/@oxygen/shared/dist/connection-approval.d.ts +2 -0
  35. package/node_modules/@oxygen/shared/dist/connection-approval.js +2 -0
  36. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.d.ts +2 -0
  37. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +110 -0
  38. package/node_modules/@oxygen/shared/dist/contracts/connectors.d.ts +32 -6
  39. package/node_modules/@oxygen/shared/dist/contracts/connectors.js +56 -4
  40. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.d.ts +4 -0
  41. package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.js +39 -0
  42. package/node_modules/@oxygen/shared/dist/contracts/crm-enrichment-read.d.ts +21 -0
  43. package/node_modules/@oxygen/shared/dist/contracts/crm-enrichment-read.js +15 -0
  44. package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +117 -0
  45. package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +43 -0
  46. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.d.ts +2 -0
  47. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +131 -0
  48. package/node_modules/@oxygen/shared/dist/contracts/dashboard-tabs.d.ts +3 -0
  49. package/node_modules/@oxygen/shared/dist/contracts/dashboard-tabs.js +2 -0
  50. package/node_modules/@oxygen/shared/dist/contracts/database.d.ts +30 -0
  51. package/node_modules/@oxygen/shared/dist/contracts/database.js +29 -0
  52. package/node_modules/@oxygen/shared/dist/contracts/integration-connect.d.ts +4 -0
  53. package/node_modules/@oxygen/shared/dist/contracts/integration-connect.js +7 -0
  54. package/node_modules/@oxygen/shared/dist/contracts/knowledge-content-reads.d.ts +43 -0
  55. package/node_modules/@oxygen/shared/dist/contracts/knowledge-content-reads.js +20 -0
  56. package/node_modules/@oxygen/shared/dist/contracts/linkedin-invites-list.d.ts +12 -0
  57. package/node_modules/@oxygen/shared/dist/contracts/linkedin-invites-list.js +8 -0
  58. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +48 -2
  59. package/node_modules/@oxygen/shared/dist/contracts/operations.js +68 -2
  60. package/node_modules/@oxygen/shared/dist/contracts/table-grid-query.d.ts +4 -0
  61. package/node_modules/@oxygen/shared/dist/contracts/table-grid-query.js +6 -0
  62. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +21 -0
  63. package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +27 -0
  64. package/node_modules/@oxygen/shared/dist/contracts/tool-catalog-detail.d.ts +19 -0
  65. package/node_modules/@oxygen/shared/dist/contracts/tool-catalog-detail.js +10 -0
  66. package/node_modules/@oxygen/shared/dist/contracts/tool-catalog-search-window.d.ts +12 -0
  67. package/node_modules/@oxygen/shared/dist/contracts/tool-catalog-search-window.js +7 -0
  68. package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.d.ts +17 -0
  69. package/node_modules/@oxygen/shared/dist/contracts/workspace-pages.js +49 -0
  70. package/node_modules/@oxygen/shared/dist/decision-ledger-parser.d.ts +32 -0
  71. package/node_modules/@oxygen/shared/dist/decision-ledger-parser.js +303 -0
  72. package/node_modules/@oxygen/shared/dist/decision-ledger.d.ts +10 -0
  73. package/node_modules/@oxygen/shared/dist/decision-ledger.generated.d.ts +2 -0
  74. package/node_modules/@oxygen/shared/dist/decision-ledger.generated.js +717 -0
  75. package/node_modules/@oxygen/shared/dist/decision-ledger.js +20 -0
  76. package/node_modules/@oxygen/shared/dist/email-campaign-ramp.d.ts +104 -0
  77. package/node_modules/@oxygen/shared/dist/email-campaign-ramp.js +250 -0
  78. package/node_modules/@oxygen/shared/dist/mcp-event-contract.d.ts +10 -7
  79. package/node_modules/@oxygen/shared/dist/mcp-event-contract.js +11 -7
  80. package/node_modules/@oxygen/shared/dist/mcp-table-widget.d.ts +50 -0
  81. package/node_modules/@oxygen/shared/dist/mcp-table-widget.js +24 -0
  82. package/node_modules/@oxygen/shared/dist/plan-band.d.ts +8 -0
  83. package/node_modules/@oxygen/shared/dist/plan-band.js +12 -0
  84. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +20 -0
  85. package/node_modules/@oxygen/shared/dist/plan-limits.js +41 -0
  86. package/node_modules/@oxygen/shared/dist/provider-input-value.d.ts +5 -0
  87. package/node_modules/@oxygen/shared/dist/provider-input-value.js +10 -0
  88. package/node_modules/@oxygen/shared/dist/sequences.d.ts +33 -4
  89. package/node_modules/@oxygen/shared/dist/sequences.js +48 -13
  90. package/node_modules/@oxygen/shared/dist/user-capability-routing.js +17 -0
  91. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  92. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  93. package/node_modules/@oxygen/shared/package.json +8 -6
  94. package/package.json +1 -1
  95. package/node_modules/@oxygen/shared/dist/copilot-skills.d.ts +0 -15
  96. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +0 -31
  97. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +0 -41
  98. package/node_modules/@oxygen/shared/dist/copilot-skills.js +0 -6
@@ -1,6 +1,7 @@
1
1
  import { readFile, writeFile, stat } from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import { resolve } from "node:path";
4
+ import { OxygenError } from "@oxygen/shared/cli-result";
4
5
  import { requestOxygen } from "./http-client.js";
5
6
  const post = (path, body) => {
6
7
  if (Buffer.byteLength(JSON.stringify(body)) > 2_000_000)
@@ -99,21 +100,14 @@ export function registerKnowledgeRepositoryCommands(program, handle) {
99
100
  .option("--cursor <cursor>", "Continue the previous page.")
100
101
  .option("--limit <number>", "Maximum files, up to 500.", "100")
101
102
  .option("--json", "Print a JSON envelope.")
102
- .action((o) => handle("knowledge files list", o, () => post("files/list", {
103
- mountId: o.mountId,
104
- limit: Number(o.limit),
105
- ...(o.cursor ? { cursor: o.cursor } : {}),
106
- })));
103
+ .action((o) => handle("knowledge files list", o, () => requestOxygen(`/api/cli/knowledge/files/list?${new URLSearchParams({ mountId: String(o.mountId), limit: String(o.limit), ...(o.cursor ? { cursor: String(o.cursor) } : {}) })}`)));
107
104
  files
108
105
  .command("get")
109
106
  .description("Read original file content, properties and download link at an optional immutable revision.")
110
107
  .requiredOption("--id <id>", "File ID from files list.")
111
108
  .option("--revision <number>", "Specific saved file revision.")
112
109
  .option("--json", "Print a JSON envelope.")
113
- .action((o) => handle("knowledge files get", o, () => post("files/get", {
114
- id: o.id,
115
- ...(o.revision ? { revision: Number(o.revision) } : {}),
116
- })));
110
+ .action((o) => handle("knowledge files get", o, () => requestOxygen(`/api/cli/knowledge/files/get?${new URLSearchParams({ id: String(o.id), ...(o.revision ? { revision: String(o.revision) } : {}) })}`)));
117
111
  files
118
112
  .command("upsert")
119
113
  .description("Create or replace an original file, or update frontmatter properties. Canonical copy still requires a proposal.")
@@ -122,11 +116,15 @@ export function registerKnowledgeRepositoryCommands(program, handle) {
122
116
  .option("--path <path>", "Exact repository-relative path; required for new files.")
123
117
  .requiredOption("--expected-revision <number>", "Reviewed file revision; 0 creates a new file.")
124
118
  .option("--file <path>", "Complete local file, including any frontmatter or binary bytes.")
125
- .option("--properties-json <json>", "Frontmatter properties to change; mutually exclusive with --file.")
119
+ .option("--text <text>", "Exact UTF-8 content; exclusive with --file and --properties-json.")
120
+ .option("--properties-json <json>", "Frontmatter properties to change; exclusive with --file and --text.")
126
121
  .option("--json", "Print a JSON envelope.")
127
122
  .action((o) => handle("knowledge files upsert", o, async () => {
128
- if (Boolean(o.file) === Boolean(o.propertiesJson))
129
- throw new Error("Provide exactly one of --file or --properties-json.");
123
+ if ([o.file, o.text, o.propertiesJson].filter((value) => value !== undefined).length !== 1)
124
+ throw new OxygenError("invalid_request", "Provide exactly one of --file, --text or --properties-json.", { exitCode: 2 });
125
+ const expectedRevision = Number(o.expectedRevision);
126
+ if (!String(o.expectedRevision ?? "").trim() || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0)
127
+ throw new OxygenError("invalid_request", "--expected-revision must be a nonnegative integer.", { exitCode: 2 });
130
128
  if (o.file && (await stat(String(o.file))).size > 1_500_000)
131
129
  throw new Error("Inline file uploads must fit a 2 MB JSON request (including base64). Add larger attachments through the connected GitHub repository.");
132
130
  const patch = o.propertiesJson
@@ -139,12 +137,12 @@ export function registerKnowledgeRepositoryCommands(program, handle) {
139
137
  mountId: o.mountId,
140
138
  ...(o.id ? { id: o.id } : {}),
141
139
  ...(o.path ? { path: o.path } : {}),
142
- expectedFileRevision: Number(o.expectedRevision),
140
+ expectedFileRevision: expectedRevision,
143
141
  ...(o.file
144
142
  ? {
145
143
  bytesBase64: (await readFile(String(o.file))).toString("base64"),
146
144
  }
147
- : { propertyPatch: patch }),
145
+ : o.text !== undefined ? { text: String(o.text) } : { propertyPatch: patch }),
148
146
  });
149
147
  }));
150
148
  files
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A mailbox's campaign ramp-up as one terminal line, after `mailboxes get`,
3
+ * `mailboxes ramp set` and `mailboxes cap set`. The blind baselines (2026-10-01)
4
+ * had to work out by hand on which sending day a custom ramp reaches the daily
5
+ * cap, and agents read the end of the output, so the schedule, today's limit and
6
+ * that day are restated here from the envelope's `effective_cap`.
7
+ *
8
+ * Returns null unless the envelope carries `effective_cap.ramp_schedule`, so
9
+ * every other command prints nothing.
10
+ */
11
+ export declare function formatMailboxRampNotice(data: unknown): string | null;
@@ -0,0 +1,53 @@
1
+ import { isRecord } from "./util.js";
2
+ /**
3
+ * A mailbox's campaign ramp-up as one terminal line, after `mailboxes get`,
4
+ * `mailboxes ramp set` and `mailboxes cap set`. The blind baselines (2026-10-01)
5
+ * had to work out by hand on which sending day a custom ramp reaches the daily
6
+ * cap, and agents read the end of the output, so the schedule, today's limit and
7
+ * that day are restated here from the envelope's `effective_cap`.
8
+ *
9
+ * Returns null unless the envelope carries `effective_cap.ramp_schedule`, so
10
+ * every other command prints nothing.
11
+ */
12
+ export function formatMailboxRampNotice(data) {
13
+ if (!isRecord(data) || !isRecord(data.effective_cap))
14
+ return null;
15
+ const cap = data.effective_cap;
16
+ if (!Array.isArray(cap.ramp_schedule))
17
+ return null;
18
+ const steps = cap.ramp_schedule.flatMap((step) => isRecord(step) && typeof step.per_day === "number" ? [step.per_day] : []);
19
+ const configured = numberOrNull(cap.configured_daily_cap);
20
+ const today = numberOrNull(cap.effective_daily_cap);
21
+ const fullOn = numberOrNull(cap.full_cap_on_sending_day);
22
+ const sendingDays = numberOrNull(cap.sending_days) ?? 0;
23
+ if (configured === null || today === null || steps.length === 0)
24
+ return null;
25
+ const address = isRecord(data.mailbox) && typeof data.mailbox.emailAddress === "string"
26
+ ? ` for ${data.mailbox.emailAddress}`
27
+ : "";
28
+ const todayLine = `today up to ${today}.`;
29
+ if (cap.ramp_mode === "off") {
30
+ return `Campaign ramp-up${address}: off, the daily cap of ${configured} applies from the first sending day; ${todayLine}`;
31
+ }
32
+ const mode = cap.ramp_mode === "custom" ? "custom" : "standard";
33
+ const currentDay = sendingDays + 1;
34
+ if (fullOn !== null && currentDay >= fullOn) {
35
+ return `Campaign ramp-up${address} (${mode}) is complete: ${todayLine}`;
36
+ }
37
+ const schedule = `${formatSteps(steps)} a day`;
38
+ const end = fullOn === null
39
+ ? `It holds at ${steps[steps.length - 1]} a day and never reaches the daily cap of ${configured}.`
40
+ : `It reaches the daily cap of ${configured} on sending day ${fullOn}.`;
41
+ return `Campaign ramp-up${address} (${mode}): ${schedule}. Today is sending day ${currentDay}: up to ${today}. ${end}`
42
+ + " A day counts toward the ramp once it has accepted campaign sends (UTC).";
43
+ }
44
+ function numberOrNull(value) {
45
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
46
+ }
47
+ /** "5 → 10 → 20 → 25", shortened to its first three and last two levels past eight. */
48
+ function formatSteps(levels) {
49
+ const shown = levels.length > 8
50
+ ? [...levels.slice(0, 3).map(String), "…", ...levels.slice(-2).map(String)]
51
+ : levels.map(String);
52
+ return shown.join(" → ");
53
+ }
@@ -0,0 +1,11 @@
1
+ import { Command } from "commander";
2
+ import { type ContractFlagOverlay } from "./contract-flags.js";
3
+ type JsonOptions = {
4
+ json?: boolean;
5
+ };
6
+ type Handle = (command: string, options: JsonOptions, action: () => Promise<unknown>) => Promise<void>;
7
+ export declare const WORKSPACE_PAGES_FLAG_OVERLAY: ContractFlagOverlay;
8
+ /** The POST body for the flags given; --hide, --show or --show-all is required. */
9
+ export declare function buildWorkspacePagesBody(options: Record<string, unknown>): Record<string, unknown>;
10
+ export declare function registerSettingsCommands(program: Command, handle: Handle): void;
11
+ export {};
@@ -0,0 +1,73 @@
1
+ // `oxygen settings`: workspace settings every member reads and admins change.
2
+ // Its family today is `settings pages`, the web app's sidebar pages a workspace
3
+ // hides for everyone (Settings → Customise). The set command's flags come from
4
+ // the shared contract that also generates `oxygen_settings_pages_update` and
5
+ // validates the route, so the CLI and MCP cannot disagree about what can be set.
6
+ import { Command } from "commander";
7
+ import { OxygenError } from "@oxygen/shared/cli-result";
8
+ import { WORKSPACE_PAGES_GET_OPERATION, WORKSPACE_PAGES_UPDATE_OPERATION } from "@oxygen/shared/contracts/workspace-pages";
9
+ import { addContractFlags, readContractFlags } from "./contract-flags.js";
10
+ import { requestOxygen } from "./http-client.js";
11
+ const list = WORKSPACE_PAGES_GET_OPERATION;
12
+ const update = WORKSPACE_PAGES_UPDATE_OPERATION;
13
+ export const WORKSPACE_PAGES_FLAG_OVERLAY = {
14
+ hide: {
15
+ help: "Pages to hide from the sidebar for everyone, comma-separated, each by its path (/unibox) or name (Unibox) from `settings pages list`. Hiding a section hides its sub-pages.",
16
+ valueName: "<pages>",
17
+ },
18
+ show: {
19
+ help: "Hidden pages to show again, comma-separated, by path or name.",
20
+ valueName: "<pages>",
21
+ },
22
+ show_all: {
23
+ help: "Show every page again. With --hide, only those pages stay hidden.",
24
+ // `--no-show-all` would change nothing, and reads like "hide all".
25
+ noNegate: true,
26
+ },
27
+ };
28
+ function names(value) {
29
+ return Array.isArray(value) && value.length > 0;
30
+ }
31
+ /** The POST body for the flags given; --hide, --show or --show-all is required. */
32
+ export function buildWorkspacePagesBody(options) {
33
+ const body = readContractFlags(options, update.contract, WORKSPACE_PAGES_FLAG_OVERLAY);
34
+ if (!names(body.hide) && !names(body.show) && body.show_all !== true) {
35
+ throw new OxygenError("invalid_request", "Nothing to change: pass --hide <pages> to hide pages for everyone, --show <pages> to show hidden ones again, or --show-all. `settings pages list` names every page.", { exitCode: 1 });
36
+ }
37
+ return body;
38
+ }
39
+ export function registerSettingsCommands(program, handle) {
40
+ const settings = program
41
+ .command("settings")
42
+ .description("Workspace settings: what every member of this workspace sees. "
43
+ + "`settings pages` is Settings → Customise in the web app: hide or show pages in the sidebar navigation (the left menu) for everyone. "
44
+ + "`settings pages list` shows each page and whether it is hidden; an admin or owner changes it with `settings pages set --hide <pages>`, `--show <pages>` or `--show-all`. "
45
+ + "Nothing is deleted: a hidden page stays reachable by URL. Free.");
46
+ const pages = new Command("pages")
47
+ .description("The web app's sidebar pages this workspace hides for every member (Settings → Customise): page visibility in the navigation menu. "
48
+ + "`list` shows every page with its section and whether it is hidden or visible; `set` hides or shows pages (admins and owners only). "
49
+ + "Hiding a section hides its sub-pages. Presentation only: nothing is deleted, and a hidden page stays reachable by URL and deep-link. Every response carries its `web_url`.");
50
+ pages.addCommand(new Command(list.cli.path.at(-1) ?? "list")
51
+ .description(list.cli.summary)
52
+ .option("--json", "Print a JSON envelope.")
53
+ .action(async (options) => {
54
+ await handle("settings pages list", options, () => requestOxygen(list.route, { method: list.method }));
55
+ }));
56
+ pages.addCommand(addContractFlags(new Command(update.cli.path.at(-1) ?? "set").description(update.cli.summary), update.contract, WORKSPACE_PAGES_FLAG_OVERLAY)
57
+ .option("--json", "Print a JSON envelope.")
58
+ .addHelpText("after", `
59
+ Examples:
60
+ oxygen settings pages list
61
+ oxygen settings pages set --hide Unibox,Publishing
62
+ oxygen settings pages set --show /unibox
63
+ oxygen settings pages set --show-all
64
+ oxygen settings pages set --show-all --hide Workflows
65
+ `)
66
+ .action(async (options) => {
67
+ await handle("settings pages set", options, () => requestOxygen(update.route, {
68
+ method: update.method,
69
+ body: buildWorkspacePagesBody(options),
70
+ }));
71
+ }));
72
+ settings.addCommand(pages);
73
+ }
package/dist/skills.d.ts CHANGED
@@ -18,7 +18,9 @@ export type SkillsListOptions = {
18
18
  export type SkillsSearchOptions = SkillsListOptions & {
19
19
  limit?: string;
20
20
  };
21
- export type SkillsGetOptions = SkillsListOptions;
21
+ export type SkillsGetOptions = SkillsListOptions & {
22
+ file?: string;
23
+ };
22
24
  export type SkillsDoctorOptions = {
23
25
  json?: boolean;
24
26
  apiUrl?: string;
@@ -49,9 +51,8 @@ export type SkillsSourceRuntime = {
49
51
  };
50
52
  /**
51
53
  * Resolves where to install skills from. Logged-in CLIs mint a short-lived
52
- * org-bound access URL (the skills surface is gated; anonymous serving is
53
- * being retired). Any failure falls back to the plain well-known URL so
54
- * installs never regress while older deployments are still in circulation.
54
+ * org-bound access URL. Any failure leaves public metadata discovery usable;
55
+ * downloading instruction bodies always requires the authenticated source.
55
56
  */
56
57
  export declare function resolveSkillsInstallSource(options: {
57
58
  apiUrl: string;
package/dist/skills.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
4
+ import { dirname, join, posix, resolve } from "node:path";
5
5
  import { OxygenError, errorMessage, inferUserCapabilityRoute, serializeCapabilityRoute, toFailure, } from "@oxygen/shared";
6
6
  import { defaultApiUrl, loadCredentials, normalizeApiUrl } from "./credentials.js";
7
7
  import { requestOxygen } from "./http-client.js";
@@ -22,9 +22,8 @@ const AGENT_DISPLAY_NAMES = {
22
22
  };
23
23
  /**
24
24
  * Resolves where to install skills from. Logged-in CLIs mint a short-lived
25
- * org-bound access URL (the skills surface is gated; anonymous serving is
26
- * being retired). Any failure falls back to the plain well-known URL so
27
- * installs never regress while older deployments are still in circulation.
25
+ * org-bound access URL. Any failure leaves public metadata discovery usable;
26
+ * downloading instruction bodies always requires the authenticated source.
28
27
  */
29
28
  export async function resolveSkillsInstallSource(options) {
30
29
  const publicSource = (reason) => ({
@@ -51,7 +50,7 @@ export async function resolveSkillsInstallSource(options) {
51
50
  credentials,
52
51
  ...(options.fetch ? { fetch: options.fetch } : {}),
53
52
  });
54
- if (!access?.base_url?.trim() || !access?.index_url?.trim()) {
53
+ if (!isValidSkillsAccessResponse(access, options.apiUrl)) {
55
54
  return publicSource("invalid_access_response");
56
55
  }
57
56
  return {
@@ -66,8 +65,21 @@ export async function resolveSkillsInstallSource(options) {
66
65
  return publicSource(`access_unavailable:${code}`);
67
66
  }
68
67
  }
68
+ function isValidSkillsAccessResponse(access, apiUrl) {
69
+ try {
70
+ const base = new URL(access.base_url);
71
+ const api = new URL(apiUrl);
72
+ return base.origin === api.origin && !base.username && !base.password && !base.search && !base.hash
73
+ && /^\/skills-access\/[^/]+$/.test(base.pathname)
74
+ && access.index_url === `${access.base_url}/.well-known/skills/index.json`
75
+ && Number.isFinite(Date.parse(access.expires_at)) && Date.parse(access.expires_at) > Date.now();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
69
81
  function requireSkillContentAccess(index, source) {
70
- if (!index.authentication_required)
82
+ if (source.authenticated && !index.authentication_required)
71
83
  return;
72
84
  throw new OxygenError("skills_authentication_required", "Sign in to download Oxygen agent instructions.", {
73
85
  details: {
@@ -122,7 +134,7 @@ export async function searchAgentSkills(query, options, runtime = {}) {
122
134
  .sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name));
123
135
  return {
124
136
  query: normalized,
125
- route: serializeCapabilityRoute(route),
137
+ route: serializeCapabilityRoute(route, { visibleSkills: index.skill_names }),
126
138
  total_matches: matches.length,
127
139
  returned: Math.min(limit, matches.length),
128
140
  skills: matches.slice(0, limit).map(({ skill, score, matching_documents }) => ({
@@ -140,41 +152,27 @@ export async function searchAgentSkills(query, options, runtime = {}) {
140
152
  }
141
153
  export async function getAgentSkill(skillName, options, runtime = {}) {
142
154
  const apiUrl = normalizeApiUrl(readOption(options.apiUrl) ?? defaultApiUrl());
143
- const source = await resolveSkillsInstallSource({ apiUrl, ...runtime });
144
- const index = await inspectSkillIndex(source.index_url, runtime.fetch);
145
- if (!index.reachable) {
146
- throw new OxygenError("skills_index_unreachable", "Oxygen skill index is not reachable.", {
147
- details: { index_url: source.index_url, error: index.error },
148
- exitCode: 1,
155
+ const credentials = runtime.credentials !== undefined ? runtime.credentials : await loadCredentials();
156
+ if (!credentials || normalizeApiUrl(credentials.apiUrl) !== apiUrl) {
157
+ throw new OxygenError("skills_authentication_required", "Sign in to download Oxygen agent instructions.", {
158
+ details: { next_step: "Run `oxygen login`, then retry `oxygen skills get`." }, exitCode: 1,
149
159
  });
150
160
  }
151
- requireSkillContentAccess(index, source);
152
161
  const normalized = skillName.trim();
153
- const skill = index.skills.find((entry) => entry.name === normalized);
154
- if (!skill) {
155
- throw new OxygenError("skill_not_found", `Unknown Oxygen skill: ${normalized}`, {
156
- details: { available_names: index.skill_names },
162
+ const file = readOption(options.file)?.trim() ?? "SKILL.md";
163
+ if (!isSafeSkillName(normalized) || !isSafeSkillFile(file)) {
164
+ throw new OxygenError("skill_file_not_found", `Unknown file in Oxygen skill ${normalized}.`, {
165
+ details: { file },
157
166
  exitCode: 2,
158
167
  });
159
168
  }
160
- if (!skill.entrypoint) {
161
- throw new OxygenError("skill_entrypoint_missing", `Oxygen skill ${normalized} has no entrypoint.`, {
162
- exitCode: 1,
163
- });
164
- }
165
- const fetchImpl = runtime.fetch ?? fetch;
166
- const response = await fetchImpl(skill.entrypoint, { headers: { Accept: "text/markdown" } });
167
- if (!response.ok) {
168
- throw new OxygenError("skill_entrypoint_unreachable", `Unable to hydrate Oxygen skill ${normalized}.`, {
169
- details: { status: response.status },
170
- exitCode: 1,
171
- });
172
- }
173
- const content = await response.text();
169
+ const params = new URLSearchParams({ name: normalized, file });
170
+ const skill = await requestOxygen(`/api/cli/skills/get?${params}`, {
171
+ method: "GET", credentials, ...(runtime.fetch ? { fetch: runtime.fetch } : {}),
172
+ });
174
173
  return {
175
174
  ...skill,
176
- content,
177
- install: `oxygen skills install --skill ${skill.name} --json`,
175
+ install: `oxygen skills install --skill ${normalized} --json`,
178
176
  };
179
177
  }
180
178
  export async function doctorAgentSkills(options, runtime = {}) {
@@ -215,12 +213,10 @@ options, runtime = {}) {
215
213
  ...(runtime.credentials !== undefined ? { credentials: runtime.credentials } : {}),
216
214
  ...(runtime.fetch ? { fetch: runtime.fetch } : {}),
217
215
  });
218
- if (!source.authenticated) {
219
- // Public discovery remains usable. New servers explicitly mark that their
220
- // instruction bodies require a signed workspace link; never invoke the
221
- // installer against that discovery-only catalog.
222
- requireSkillContentAccess(await inspectSkillIndex(source.index_url, runtime.fetch), source);
223
- }
216
+ // The client also fails closed against older servers whose catalog predates
217
+ // authentication_required. Metadata can never authorize a content download.
218
+ if (!source.authenticated)
219
+ requireSkillContentAccess(emptySkillIndexInspection({ status: null, error: { code: "skills_authentication_required", message: "Sign in to download skills." } }), source);
224
220
  const agents = readWords(options.agents ?? DEFAULT_SKILL_AGENTS);
225
221
  const skill = readOption(options.skill) ?? "*";
226
222
  const env = runtime.env ?? process.env;
@@ -236,9 +232,8 @@ options, runtime = {}) {
236
232
  .join(", ")}\n`);
237
233
  // A specific shortcut skill can declare prerequisite skills that its SKILL.md
238
234
  // invokes (e.g. oxygen-clay-migration → oxygen-gtm, whose recipes it lives in).
239
- // Installing it alone would leave those missing, so pull them in too. Best-
240
- // effort (an unreachable/unparseable index just installs the requested skill),
241
- // and skipped for "*", which already installs everything.
235
+ // Installing it alone would leave those missing, so resolve its full graph
236
+ // before writing files. "*" already installs every visible skill.
242
237
  const dependencySkills = skill === "*"
243
238
  ? []
244
239
  : await resolveSkillInstallDependencies(skill, source.index_url, runtime.fetch);
@@ -247,18 +242,15 @@ options, runtime = {}) {
247
242
  // reach the installer's argv — any local process can read a child's
248
243
  // arguments. Stage the requested skill content to a private temp dir (the
249
244
  // tokenized URLs stay inside this process on the fetches) and hand the
250
- // installer that local path instead. The public, non-secret index URL is
251
- // passed straight through as before.
245
+ // installer that local path instead.
252
246
  let stagingDir = null;
253
247
  const outputs = [];
254
248
  const skippedTargets = [];
255
249
  try {
256
250
  let installSource = source.install_url;
257
- if (source.authenticated) {
258
- stagingDir = mkdtempSync(join(tmpdir(), "oxygen-skills-"));
259
- await stageAuthenticatedSkills(stagingDir, source, targets, runtime.fetch ?? fetch);
260
- installSource = stagingDir;
261
- }
251
+ stagingDir = mkdtempSync(join(tmpdir(), "oxygen-skills-"));
252
+ await stageAuthenticatedSkills(stagingDir, source, targets, runtime.fetch ?? fetch);
253
+ installSource = stagingDir;
262
254
  for (const target of targets) {
263
255
  const args = installerAddArgs(installSource, agents, target);
264
256
  if (!options.project)
@@ -393,6 +385,10 @@ async function stageAuthenticatedSkills(dir, source, targets, fetchImpl) {
393
385
  const wanted = targets.includes("*")
394
386
  ? entries
395
387
  : entries.filter((entry) => targets.includes(entry.name));
388
+ const missing = targets.includes("*") ? [] : targets.filter((target) => !wanted.some((entry) => entry.name === target));
389
+ if (wanted.length === 0 || missing.length > 0) {
390
+ throw stagingFailure("select_skills", { missing_skills: missing });
391
+ }
396
392
  // `<base>/.well-known/skills/index.json` → `<base>/.well-known/skills`; the
397
393
  // installer reads a skill's files from `<root>/<name>/<file>`.
398
394
  const skillsRoot = source.index_url.replace(/\/index\.json$/i, "");
@@ -412,7 +408,7 @@ async function stageAuthenticatedSkills(dir, source, targets, fetchImpl) {
412
408
  const content = await fetchStagedSkillFile(`${skillsRoot}/${entry.name}/${file}`, fetchImpl);
413
409
  const destination = join(dir, entry.name, ...file.split("/"));
414
410
  mkdirSync(dirname(destination), { recursive: true });
415
- writeFileSync(destination, content);
411
+ writeFileSync(destination, localizeSkillLinks(content, skillsRoot, entry.name, file));
416
412
  }
417
413
  catch (error) {
418
414
  failure ??= error;
@@ -430,7 +426,7 @@ const STAGED_FILE_RETRY_DELAYS_MS = [250, 1_000];
430
426
  async function fetchStagedSkillEntries(indexUrl, fetchImpl) {
431
427
  let response;
432
428
  try {
433
- response = await fetchImpl(indexUrl, { headers: { Accept: "application/json" } });
429
+ response = await fetchImpl(indexUrl, { headers: { Accept: "application/json" }, redirect: "error" });
434
430
  }
435
431
  catch (error) {
436
432
  throw stagingFailure("fetch_index", { index_url: indexUrl, message: errorMessage(error) });
@@ -448,14 +444,21 @@ async function fetchStagedSkillEntries(indexUrl, fetchImpl) {
448
444
  if (!isRecord(parsed) || !Array.isArray(parsed.skills)) {
449
445
  throw stagingFailure("parse_index", { index_url: indexUrl, message: "skills array missing" });
450
446
  }
447
+ if (parsed.authentication_required === true) {
448
+ throw stagingFailure("fetch_index", { message: "Authenticated skill content is unavailable." });
449
+ }
451
450
  const entries = [];
452
451
  for (const entry of parsed.skills) {
453
- if (!isRecord(entry) || typeof entry.name !== "string" || !entry.name.trim())
454
- continue;
452
+ if (!isRecord(entry) || typeof entry.name !== "string" || !isSafeSkillName(entry.name)) {
453
+ throw stagingFailure("parse_index", { message: "Invalid skill name." });
454
+ }
455
455
  const files = Array.isArray(entry.files)
456
456
  ? entry.files.filter((file) => typeof file === "string" && file.trim().length > 0)
457
457
  : [];
458
- entries.push({ name: entry.name.trim(), files });
458
+ if (!files.includes("SKILL.md") || files.some((file) => !isSafeSkillFile(file)) || new Set(files).size !== files.length || entries.some((candidate) => candidate.name === entry.name)) {
459
+ throw stagingFailure("parse_index", { message: "Invalid skill files.", skill: entry.name });
460
+ }
461
+ entries.push({ name: entry.name, files });
459
462
  }
460
463
  return entries;
461
464
  }
@@ -464,7 +467,7 @@ async function fetchStagedSkillFile(url, fetchImpl) {
464
467
  for (let attempt = 0;; attempt += 1) {
465
468
  let details;
466
469
  try {
467
- const response = await fetchImpl(url, { signal: AbortSignal.timeout(STAGED_FILE_TIMEOUT_MS) });
470
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(STAGED_FILE_TIMEOUT_MS), redirect: "error" });
468
471
  if (response.ok)
469
472
  return await response.text();
470
473
  if (response.status !== 429 && response.status < 500) {
@@ -483,6 +486,28 @@ async function fetchStagedSkillFile(url, fetchImpl) {
483
486
  await new Promise((resolve) => setTimeout(resolve, delay));
484
487
  }
485
488
  }
489
+ function isSafeSkillName(name) {
490
+ return name.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name);
491
+ }
492
+ function isSafeSkillFile(file) {
493
+ return /^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)*$/.test(file)
494
+ && file.split("/").every((part) => part !== "." && part !== "..");
495
+ }
496
+ /** Installed references survive token expiry and stay within installed skills. */
497
+ function localizeSkillLinks(content, skillsRoot, skill, file) {
498
+ if (!file.endsWith(".md"))
499
+ return content;
500
+ return content.replace(/\]\((https?:\/\/[^)\s]+)\)/g, (match, url) => {
501
+ if (!url.startsWith(`${skillsRoot}/`))
502
+ return match;
503
+ const target = url.slice(skillsRoot.length + 1);
504
+ const [name, ...parts] = target.split("/");
505
+ const relativeFile = parts.join("/").split("#")[0];
506
+ if (!name || !isSafeSkillName(name) || !isSafeSkillFile(relativeFile))
507
+ return match;
508
+ return `](${posix.relative(posix.dirname(`${skill}/${file}`), target)})`;
509
+ });
510
+ }
486
511
  // Mirror the installer-failure error so a staging failure surfaces identically
487
512
  // (code, message, exitCode) to the caller — never as a silent URL fallback.
488
513
  function stagingFailure(stage, details) {
@@ -491,31 +516,33 @@ function stagingFailure(stage, details) {
491
516
  exitCode: 1,
492
517
  });
493
518
  }
494
- // Best-effort resolution of a skill's declared prerequisite skills from the
495
- // index. Any failure (unreachable index, parse error, unknown skill) yields no
496
- // dependencies so a dependency lookup can never block or fail an install.
519
+ // A dependency is part of the selected skill's operable contract. Resolve the
520
+ // full graph before installation; missing dependencies and cycles fail closed.
497
521
  async function resolveSkillInstallDependencies(skill, indexUrl, fetchImpl) {
498
- try {
499
- const index = await inspectSkillIndex(indexUrl, fetchImpl ?? fetch);
500
- if (!index.reachable)
501
- return [];
502
- const entry = index.skills.find((candidate) => candidate.name === skill);
522
+ const index = await inspectSkillIndex(indexUrl, fetchImpl ?? fetch);
523
+ if (!index.reachable)
524
+ throw stagingFailure("fetch_index", { message: "Unable to resolve skill dependencies." });
525
+ const resolved = [];
526
+ const visited = new Set();
527
+ const visiting = new Set();
528
+ const visit = (name) => {
529
+ if (visiting.has(name))
530
+ throw stagingFailure("skill_dependencies", { message: "Circular skill dependency.", skill: name });
531
+ if (visited.has(name))
532
+ return;
533
+ const entry = index.skills.find((candidate) => candidate.name === name);
503
534
  if (!entry)
504
- return [];
505
- // Drop self-references and duplicates; preserve declared order.
506
- const seen = new Set([skill]);
507
- const resolved = [];
508
- for (const dep of entry.dependencies) {
509
- if (seen.has(dep))
510
- continue;
511
- seen.add(dep);
512
- resolved.push(dep);
513
- }
514
- return resolved;
515
- }
516
- catch {
517
- return [];
518
- }
535
+ throw stagingFailure("select_skills", { missing_skills: [name] });
536
+ visiting.add(name);
537
+ for (const dependency of entry.dependencies)
538
+ visit(dependency);
539
+ visiting.delete(name);
540
+ visited.add(name);
541
+ if (name !== skill)
542
+ resolved.push(name);
543
+ };
544
+ visit(skill);
545
+ return resolved;
519
546
  }
520
547
  // Adapt the legacy `execFileSync` runtime hook into a spawnSync-shaped return so
521
548
  // existing test fixtures (update.test.ts) keep working without a coordinated
@@ -0,0 +1,7 @@
1
+ import { Command } from "commander";
2
+ type Handle = (command: string, options: {
3
+ json?: boolean;
4
+ }, action: () => Promise<unknown>) => Promise<void>;
5
+ /** Legacy no-flag invocation keeps the complete descriptor. */
6
+ export declare function createToolCatalogDetailCommand(handle: Handle): Command;
7
+ export {};
@@ -0,0 +1,19 @@
1
+ import { Command } from "commander";
2
+ import { toolCatalogDetailQueryContract } from "@oxygen/shared/contracts/tool-catalog-detail";
3
+ import { addContractFlags, readContractFlags } from "./contract-flags.js";
4
+ import { requestOxygen } from "./http-client.js";
5
+ /** Legacy no-flag invocation keeps the complete descriptor. */
6
+ export function createToolCatalogDetailCommand(handle) {
7
+ return addContractFlags(new Command("get")
8
+ .description("Get native tool metadata. Full by default; explicit views report omissions and retain exact schemas. Executable MCP definitions use capabilities schema.")
9
+ .argument("<tool_id>", "Tool id.")
10
+ .option("--json", "Print a JSON envelope."), toolCatalogDetailQueryContract)
11
+ .action(async (toolId, options) => {
12
+ await handle("tools get", options, async () => {
13
+ const query = readContractFlags(options, toolCatalogDetailQueryContract);
14
+ const params = new URLSearchParams(Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value.join(",") : String(value)]));
15
+ const suffix = params.size ? `?${params.toString()}` : "";
16
+ return requestOxygen(`/api/cli/tools/${encodeURIComponent(toolId)}${suffix}`);
17
+ });
18
+ });
19
+ }
@@ -0,0 +1,6 @@
1
+ import type { Command } from "commander";
2
+ export declare function addToolCatalogSearchWindowFlag(command: Command): Command;
3
+ export declare function readToolCatalogSearchWindow(options: Record<string, unknown>): {
4
+ max_chars?: number;
5
+ include_native?: boolean;
6
+ };
@@ -0,0 +1,12 @@
1
+ import { toolCatalogSearchWindowContract } from "@oxygen/shared/contracts/tool-catalog-search-window";
2
+ import { addContractFlags, readContractFlags } from "./contract-flags.js";
3
+ export function addToolCatalogSearchWindowFlag(command) {
4
+ return addContractFlags(command, toolCatalogSearchWindowContract, { workflow_eligible: { omit: true } });
5
+ }
6
+ export function readToolCatalogSearchWindow(options) {
7
+ const body = readContractFlags(options, toolCatalogSearchWindowContract, { workflow_eligible: { omit: true } });
8
+ return {
9
+ ...(typeof body.max_chars === "number" ? { max_chars: body.max_chars } : {}),
10
+ ...(typeof body.include_native === "boolean" ? { include_native: body.include_native } : {}),
11
+ };
12
+ }
@@ -129,6 +129,7 @@ const SECRET_PATTERNS = [
129
129
  [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "ghx_[REDACTED]"],
130
130
  [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "xox-[REDACTED]"],
131
131
  [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}/g, "[REDACTED_JWT]"],
132
+ [/\/skills-access\/v1\.[A-Za-z0-9_-]+\.\d+\.[A-Za-z0-9_-]+/g, "/skills-access/[REDACTED_GRANT]"],
132
133
  [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{16,}/gi, "$1 [REDACTED]"],
133
134
  // "secret-ish key": "value" / secret_ish_key=value (JSON + env shapes)
134
135
  [/("?(?:api[_-]?key|apikey|secret|password|passwd|token|access[_-]?token|refresh[_-]?token|client[_-]?secret|authorization|auth[_-]?token|private[_-]?key)"?\s*[:=]\s*"?)([^"\s,}]{6,})/gi, "$1[REDACTED]"],
@@ -0,0 +1,4 @@
1
+ /** Complete native receipt sizing for optional bounded JSON reads. Binary
2
+ * windows have their own outer-envelope budget and must not use this helper.
3
+ * The surface is a formatting hint, never authorization or spend authority. */
4
+ export declare function boundedReadResultFits(command: string, data: Record<string, unknown>, maxChars: number | undefined, surface: string | null | undefined, maxAgentResultChars?: number): boolean;