@oxygen-agent/cli 1.1012.2 → 1.1013.27

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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/customer-operation-commands.js +6 -2
  3. package/dist/diagnostic-upload-main.d.ts +1 -0
  4. package/dist/diagnostic-upload-main.js +2 -0
  5. package/dist/diagnostic-uploader.d.ts +3 -0
  6. package/dist/diagnostic-uploader.js +68 -0
  7. package/dist/diagnostics.d.ts +22 -0
  8. package/dist/diagnostics.js +219 -0
  9. package/dist/http-client.d.ts +7 -0
  10. package/dist/http-client.js +12 -2
  11. package/dist/index.js +16 -4
  12. package/node_modules/@oxygen/cli-ugc/dist/commands.js +14 -2
  13. package/node_modules/@oxygen/shared/dist/capability-discovery.js +4 -4
  14. package/node_modules/@oxygen/shared/dist/column-operation.d.ts +5 -0
  15. package/node_modules/@oxygen/shared/dist/column-operation.js +6 -0
  16. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -1
  17. package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +1 -0
  18. package/node_modules/@oxygen/shared/dist/contracts/geo.js +30 -8
  19. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +1 -0
  20. package/node_modules/@oxygen/shared/dist/diagnostic-context.d.ts +3 -0
  21. package/node_modules/@oxygen/shared/dist/diagnostic-context.js +17 -0
  22. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +4 -0
  23. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +12 -0
  24. package/node_modules/@oxygen/shared/dist/diagnostic-lock.d.ts +2 -0
  25. package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +60 -0
  26. package/node_modules/@oxygen/shared/dist/diagnostic-receipt.d.ts +6 -0
  27. package/node_modules/@oxygen/shared/dist/diagnostic-receipt.js +22 -0
  28. package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.d.ts +2 -0
  29. package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +39 -0
  30. package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +31 -0
  31. package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +233 -0
  32. package/node_modules/@oxygen/shared/dist/diagnostics.d.ts +49 -0
  33. package/node_modules/@oxygen/shared/dist/diagnostics.js +253 -0
  34. package/node_modules/@oxygen/shared/dist/future-signup-events.d.ts +7 -0
  35. package/node_modules/@oxygen/shared/dist/future-signup-events.js +11 -0
  36. package/node_modules/@oxygen/shared/dist/geo.d.ts +17 -0
  37. package/node_modules/@oxygen/shared/dist/geo.js +31 -3
  38. package/node_modules/@oxygen/shared/dist/knowledge-constants.d.ts +7 -0
  39. package/node_modules/@oxygen/shared/dist/knowledge-constants.js +9 -0
  40. package/node_modules/@oxygen/shared/dist/onboarding-first-leads.d.ts +19 -0
  41. package/node_modules/@oxygen/shared/dist/onboarding-first-leads.js +25 -0
  42. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +1 -1
  43. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +4 -0
  44. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +9 -0
  45. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +9 -0
  46. package/node_modules/@oxygen/shared/dist/ugc.d.ts +9 -0
  47. package/node_modules/@oxygen/shared/dist/ugc.js +12 -0
  48. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -0
  49. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  50. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  51. package/node_modules/@oxygen/shared/dist/version.js +3 -0
  52. package/package.json +1 -1
package/README.md CHANGED
@@ -34,4 +34,4 @@ oxygen update
34
34
 
35
35
  For product documentation, visit https://oxygen-agent.com/docs. For support, visit https://oxygen-agent.com.
36
36
 
37
- Version: 1.1012.2
37
+ Version: 1.1013.27
@@ -116,7 +116,11 @@ export function registerCustomerOperationCommands(program, handle) {
116
116
  if (spec.binary) {
117
117
  if (spec.binary.stream)
118
118
  return requestOxygen(request.path, { method: request.method, binaryOutputPath: resolve(String(options.output)) });
119
- const bytes = await requestOxygen(request.path, { method: request.method, binaryMaxBytes: spec.binary.maxBytes });
119
+ const download = await requestOxygen(request.path, { method: request.method, binaryMaxBytes: spec.binary.maxBytes, ...(spec.binary.paginated ? { binaryMetadata: true } : {}) });
120
+ const bytes = download instanceof Uint8Array ? download : download.bytes;
121
+ const pagination = download instanceof Uint8Array ? undefined : download.pagination;
122
+ if (spec.binary.paginated && !pagination)
123
+ throw new OxygenError("invalid_response", "The export did not include its evidence pagination. Retry the same read.");
120
124
  const output = resolve(String(options.output));
121
125
  try {
122
126
  await writeFile(output, bytes, { flag: "wx" });
@@ -127,7 +131,7 @@ export function registerCustomerOperationCommands(program, handle) {
127
131
  }
128
132
  throw error;
129
133
  }
130
- return { path: output, bytes: bytes.byteLength };
134
+ return { path: output, bytes: bytes.byteLength, ...(pagination ? { pagination } : {}) };
131
135
  }
132
136
  if (spec.multipart) {
133
137
  let formData;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { uploadCliDiagnostics } from "./diagnostic-uploader.js";
2
+ await uploadCliDiagnostics().catch(() => undefined);
@@ -0,0 +1,3 @@
1
+ import { type StoredCredentials } from "./credentials.js";
2
+ /** Dedicated detached process: credential values never enter argv or the spool. */
3
+ export declare function uploadCliDiagnostics(directory?: string, credentials?: StoredCredentials): Promise<number>;
@@ -0,0 +1,68 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { acquireDiagnosticLock } from "@oxygen/shared/diagnostic-lock";
4
+ import { join } from "node:path";
5
+ import { gunzipSync, gzipSync } from "node:zlib";
6
+ import { DIAGNOSTIC_RECORD_MAX_BYTES, DIAGNOSTIC_UPLOAD_CHUNK_BYTES, parseDiagnosticRecord } from "@oxygen/shared/diagnostics";
7
+ import { loadCredentialsWithEndpoint } from "./credentials.js";
8
+ import { cliDiagnosticDirectory, credentialFingerprint } from "./diagnostics.js";
9
+ import { requestOxygen } from "./http-client.js";
10
+ /** Dedicated detached process: credential values never enter argv or the spool. */
11
+ export async function uploadCliDiagnostics(directory = cliDiagnosticDirectory(), credentials) {
12
+ if (process.env.OXYGEN_DIAGNOSTICS_ENABLED !== "1")
13
+ return 0;
14
+ const loaded = credentials ?? (await loadCredentialsWithEndpoint()).credentials;
15
+ if (!loaded)
16
+ return 0;
17
+ const lockPath = join(directory, ".upload.lock");
18
+ const release = await acquireDiagnosticLock(lockPath);
19
+ if (!release)
20
+ return 0;
21
+ let uploaded = 0;
22
+ try {
23
+ for (const name of (await readdir(directory)).filter((name) => /^[0-9a-f-]+\.json\.gz$/.test(name)).slice(0, 20)) {
24
+ const path = join(directory, name);
25
+ try {
26
+ const entry = JSON.parse(gunzipSync(await readFile(path), { maxOutputLength: DIAGNOSTIC_RECORD_MAX_BYTES }).toString("utf8"));
27
+ parseDiagnosticRecord(entry.record);
28
+ if (Date.now() - Date.parse(entry.record.started_at) >= 72 * 60 * 60 * 1000)
29
+ continue;
30
+ // Never move an old invocation to a different endpoint, user or workspace.
31
+ if (new URL(entry.scope.api_url).origin !== new URL(loaded.apiUrl).origin
32
+ || (entry.scope.fingerprint && entry.scope.fingerprint !== credentialFingerprint(loaded)))
33
+ continue;
34
+ const bytes = gzipSync(Buffer.from(JSON.stringify(entry)));
35
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
36
+ const total = Math.ceil(bytes.length / DIAGNOSTIC_UPLOAD_CHUNK_BYTES);
37
+ const selectedOrganization = entry.scope.organization ?? undefined;
38
+ const progressPath = path + ".upload.json";
39
+ const progress = JSON.parse(await readFile(progressPath, "utf8").catch(() => "null"));
40
+ // Staging expires after one day. Restart from zero after that boundary.
41
+ const next = progress?.sha256 === sha256 && Date.now() - progress.updated_at < 23 * 3_600_000
42
+ && Number.isInteger(progress.next) && progress.next >= 0 && progress.next <= total ? progress.next : 0;
43
+ for (let index = next; index < total; index++) {
44
+ await requestOxygen("/api/cli/observability/diagnostics", { method: "POST", credentials: loaded,
45
+ ...(selectedOrganization ? { selectedOrganization } : {}), timeoutMs: 15_000,
46
+ body: { id: entry.record.id, index, total, sha256,
47
+ chunk: bytes.subarray(index * DIAGNOSTIC_UPLOAD_CHUNK_BYTES, (index + 1) * DIAGNOSTIC_UPLOAD_CHUNK_BYTES).toString("base64") },
48
+ });
49
+ const temporary = progressPath + ".tmp";
50
+ await writeFile(temporary, JSON.stringify({ sha256, next: index + 1, updated_at: Date.now() }), { mode: 0o600 });
51
+ await rename(temporary, progressPath);
52
+ }
53
+ const receipt = await requestOxygen("/api/cli/observability/diagnostics", { method: "POST", credentials: loaded,
54
+ ...(selectedOrganization ? { selectedOrganization } : {}), timeoutMs: 15_000, body: { id: entry.record.id, total, sha256, complete: true } });
55
+ if (receipt.accepted !== true || receipt.complete !== true || receipt.diagnostic_id !== entry.record.id)
56
+ continue;
57
+ await unlink(path);
58
+ await unlink(progressPath).catch(() => undefined);
59
+ uploaded++;
60
+ }
61
+ catch { /* leave the original for a later invocation; never replay product actions */ }
62
+ }
63
+ }
64
+ finally {
65
+ await release();
66
+ }
67
+ return uploaded;
68
+ }
@@ -0,0 +1,22 @@
1
+ import { type DiagnosticRecord } from "@oxygen/shared/diagnostics";
2
+ import { type StoredCredentials } from "./credentials.js";
3
+ export type CliDiagnosticScope = {
4
+ api_url: string;
5
+ fingerprint: string | null;
6
+ organization: string | null;
7
+ user: string | null;
8
+ };
9
+ export type CliDiagnosticSpoolEntry = {
10
+ scope: CliDiagnosticScope;
11
+ record: DiagnosticRecord;
12
+ };
13
+ export declare function cliDiagnosticDirectory(): string;
14
+ export declare function credentialFingerprint(credentials: StoredCredentials): string;
15
+ export declare function beginCliDiagnostics(argv?: string[]): void;
16
+ export declare function setCliDiagnosticOperation(command: string): void;
17
+ export declare function cliDiagnosticInvocation(): string | undefined;
18
+ export declare function cliDiagnosticHeaders(): Record<string, string>;
19
+ export declare function recordCliDiagnosticRequest(input: unknown): void;
20
+ export declare function recordCliDiagnosticFailure(error: unknown): void;
21
+ export declare function finishCliDiagnostics(exitCode: number): Promise<string | null>;
22
+ export declare function writeCliDiagnosticSpool(directory: string, entry: CliDiagnosticSpoolEntry): Promise<string | null>;
@@ -0,0 +1,219 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdir, open, readdir, readFile, rename, stat, unlink } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { acquireDiagnosticLock } from "@oxygen/shared/diagnostic-lock";
8
+ import { StringDecoder } from "node:string_decoder";
9
+ import { gzipSync } from "node:zlib";
10
+ import { createDiagnosticRecord, credentialSafeDiagnostic, diagnosticId, DIAGNOSTIC_INVOCATION_HEADER, DIAGNOSTIC_MAX_BYTES, DIAGNOSTIC_RECORD_MAX_BYTES, diagnosticOutcome, sanitizeDiagnosticArgv, sanitizeDiagnosticContent } from "@oxygen/shared/diagnostics";
11
+ import { OXYGEN_VERSION } from "@oxygen/shared/version";
12
+ import { loadCredentialsWithEndpoint } from "./credentials.js";
13
+ let active;
14
+ export function cliDiagnosticDirectory() {
15
+ return join(process.env.OXYGEN_CONFIG_DIR?.trim() || (process.platform === "win32"
16
+ ? join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "oxygen")
17
+ : join(homedir(), ".config", "oxygen")), "diagnostics", "v1");
18
+ }
19
+ export function credentialFingerprint(credentials) {
20
+ return createHash("sha256").update(credentials.token).digest("hex");
21
+ }
22
+ export function beginCliDiagnostics(argv = process.argv.slice(2)) {
23
+ if (active || process.env.OXYGEN_DIAGNOSTICS_ENABLED !== "1")
24
+ return;
25
+ const invocation = createDiagnosticRecord({ surface: "cli", operation: "cli parse",
26
+ client_version: OXYGEN_VERSION, input: { argv: sanitizeDiagnosticArgv(argv) },
27
+ trace_id: randomUUID().replaceAll("-", ""),
28
+ });
29
+ invocation.trace_id = invocation.invocation_id.replaceAll("-", "");
30
+ invocation.traceparent = `00-${invocation.trace_id}-${randomUUID().replaceAll("-", "").slice(0, 16)}-01`;
31
+ const credentialEnv = diagnosticCredentialEnvironment(argv);
32
+ const context = { record: invocation, credentialEnv, scope: loadCredentialsWithEndpoint(credentialEnv).catch(() => null),
33
+ organizationRef: credentialEnv.OXYGEN_ORG?.trim() || null, signalHandlers: [],
34
+ requests: [], bytes: 0, droppedBytes: 0, stdout: [], stderr: [], restore: () => undefined };
35
+ active = context;
36
+ const restorers = [];
37
+ for (const [stream, output] of [[process.stdout, context.stdout], [process.stderr, context.stderr]]) {
38
+ const original = stream.write;
39
+ const decoder = new StringDecoder("utf8");
40
+ const wrapped = function (...args) {
41
+ try {
42
+ const chunk = args[0];
43
+ const bytes = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk);
44
+ if (context.bytes + bytes.length <= DIAGNOSTIC_RECORD_MAX_BYTES) {
45
+ context.bytes += bytes.length;
46
+ output.push(decoder.write(bytes));
47
+ }
48
+ else
49
+ context.droppedBytes += bytes.length;
50
+ }
51
+ catch {
52
+ context.droppedBytes++;
53
+ }
54
+ return original.apply(stream, args);
55
+ };
56
+ stream.write = wrapped;
57
+ restorers.push(() => { output.push(decoder.end()); stream.write = original; });
58
+ }
59
+ context.restore = () => { for (const restore of restorers)
60
+ restore(); };
61
+ for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
62
+ const handler = () => {
63
+ recordCliDiagnosticFailure({ code: "interrupted" });
64
+ void finishCliDiagnostics(code).finally(() => process.exit(code));
65
+ };
66
+ process.once(signal, handler);
67
+ context.signalHandlers.push(() => process.removeListener(signal, handler));
68
+ }
69
+ }
70
+ export function setCliDiagnosticOperation(command) {
71
+ if (active)
72
+ active.record.operation = command.slice(0, 512);
73
+ }
74
+ export function cliDiagnosticInvocation() { return active?.record.invocation_id; }
75
+ export function cliDiagnosticHeaders() {
76
+ return active ? { [DIAGNOSTIC_INVOCATION_HEADER]: active.record.invocation_id, traceparent: active.record.traceparent } : {};
77
+ }
78
+ export function recordCliDiagnosticRequest(input) {
79
+ if (!active)
80
+ return;
81
+ try {
82
+ const sanitized = sanitizeDiagnosticContent(input);
83
+ const size = Buffer.byteLength(JSON.stringify(sanitized));
84
+ if (active.bytes + size <= DIAGNOSTIC_RECORD_MAX_BYTES) {
85
+ active.requests.push(sanitized);
86
+ active.bytes += size;
87
+ }
88
+ else
89
+ active.droppedBytes += size;
90
+ }
91
+ catch {
92
+ active.droppedBytes++;
93
+ }
94
+ }
95
+ export function recordCliDiagnosticFailure(error) {
96
+ if (!active)
97
+ return;
98
+ const code = error && typeof error === "object" && "code" in error ? String(error.code) : "unexpected_error";
99
+ active.record.error_code = code;
100
+ active.record.outcome = diagnosticOutcome(false, null, code);
101
+ recordCliDiagnosticRequest({ error });
102
+ }
103
+ export async function finishCliDiagnostics(exitCode) {
104
+ const context = active;
105
+ if (!context)
106
+ return null;
107
+ context.restore();
108
+ for (const remove of context.signalHandlers)
109
+ remove();
110
+ active = undefined;
111
+ try {
112
+ const loaded = await context.scope;
113
+ const credentials = loaded?.credentials;
114
+ const storedOrganization = credentials?.activeOrganization ?? credentials?.identity?.organization;
115
+ const organization = !context.organizationRef ? storedOrganization?.id ?? null
116
+ : context.organizationRef === storedOrganization?.id || context.organizationRef === storedOrganization?.slug
117
+ ? storedOrganization.id : diagnosticId(context.organizationRef);
118
+ const user = credentials?.identity?.user.id ?? null;
119
+ const record = context.record;
120
+ record.ended_at = new Date().toISOString();
121
+ if (record.outcome === "pending")
122
+ record.outcome = exitCode === 0 ? "ok" : "invalid_input";
123
+ record.status = exitCode;
124
+ record.organization_id = organization;
125
+ record.user_id = user;
126
+ record.capture_state = context.droppedBytes ? "partial" : "complete";
127
+ record.output = { stdout: context.stdout.join(""), stderr: context.stderr.join("") };
128
+ record.metadata = { requests: context.requests, dropped_bytes: context.droppedBytes, client_reported: true,
129
+ original_organization_ref: context.organizationRef };
130
+ const entry = { scope: { api_url: loaded?.endpoint.apiUrl ?? process.env.OXYGEN_API_URL?.trim() ?? "https://oxygen-agent.com",
131
+ fingerprint: credentials ? credentialFingerprint(credentials) : null, organization: organization ?? context.organizationRef, user },
132
+ record: sanitizeDiagnosticContent(credentialSafeDiagnostic(record), [credentials?.token ?? ""]) };
133
+ const directory = cliDiagnosticDirectory();
134
+ const path = await writeCliDiagnosticSpool(directory, entry);
135
+ if (path && process.env.OXYGEN_DIAGNOSTICS_UPLOAD_DISABLED !== "1") {
136
+ const child = spawn(process.execPath, [fileURLToPath(new URL("./diagnostic-upload-main.js", import.meta.url))], { detached: true, stdio: "ignore", env: context.credentialEnv });
137
+ child.on("error", () => undefined);
138
+ child.unref();
139
+ }
140
+ return path;
141
+ }
142
+ catch {
143
+ return null;
144
+ }
145
+ }
146
+ export async function writeCliDiagnosticSpool(directory, entry) {
147
+ await mkdir(directory, { recursive: true, mode: 0o700 });
148
+ const lockPath = join(directory, ".admission.lock");
149
+ const release = await acquireDiagnosticLock(lockPath);
150
+ if (!release)
151
+ return null;
152
+ try {
153
+ let used = 0;
154
+ let dropped = 0;
155
+ for (const name of await readdir(directory)) {
156
+ if (!/^[0-9a-f-]+\.json\.gz$/.test(name) && !/^[0-9a-f-]+\.json\.gz\.[0-9a-f-]+\.tmp$/.test(name))
157
+ continue;
158
+ const path = join(directory, name);
159
+ const info = await stat(path).catch(() => null);
160
+ if (!info)
161
+ continue;
162
+ if (Date.now() - info.mtimeMs >= 72 * 60 * 60 * 1000) {
163
+ await unlink(path);
164
+ dropped += info.size;
165
+ }
166
+ else
167
+ used += info.size;
168
+ }
169
+ const lossPath = join(directory, "loss.json");
170
+ const previous = JSON.parse(await readFile(lossPath, "utf8").catch(() => "{\"bytes\":0}"));
171
+ let bytes = Buffer.from(JSON.stringify({ ...entry, record: sanitizeDiagnosticContent({ ...entry.record,
172
+ metadata: { ...entry.record.metadata, prior_spool_lost_bytes: previous.bytes + dropped } }) }));
173
+ if (bytes.length > DIAGNOSTIC_RECORD_MAX_BYTES) {
174
+ bytes = Buffer.from(JSON.stringify({ ...entry, record: { ...entry.record, input: null, output: null,
175
+ capture_state: "partial", metadata: { capture_reason: "oversized_content", dropped_bytes: bytes.length } } }));
176
+ }
177
+ const compressed = gzipSync(bytes);
178
+ if (compressed.length + used > DIAGNOSTIC_MAX_BYTES)
179
+ dropped += compressed.length;
180
+ if (dropped) {
181
+ const loss = await open(lossPath, "w", 0o600);
182
+ try {
183
+ await loss.writeFile(JSON.stringify({ bytes: previous.bytes + dropped }));
184
+ await loss.sync();
185
+ }
186
+ finally {
187
+ await loss.close();
188
+ }
189
+ }
190
+ if (compressed.length + used > DIAGNOSTIC_MAX_BYTES)
191
+ return null;
192
+ const path = join(directory, `${entry.record.id}.json.gz`);
193
+ const temporary = path + `.${randomUUID()}.tmp`;
194
+ const handle = await open(temporary, "wx", 0o600);
195
+ try {
196
+ await handle.writeFile(compressed);
197
+ await handle.sync();
198
+ }
199
+ finally {
200
+ await handle.close();
201
+ }
202
+ await rename(temporary, path);
203
+ return path;
204
+ }
205
+ finally {
206
+ await release();
207
+ }
208
+ }
209
+ function diagnosticCredentialEnvironment(argv) {
210
+ const env = { ...process.env };
211
+ for (const [flag, name] of [["--profile", "OXYGEN_PROFILE"], ["--org", "OXYGEN_ORG"]]) {
212
+ const at = argv.indexOf(flag);
213
+ const equals = argv.find(value => value.startsWith(flag + "="));
214
+ const value = equals?.slice(flag.length + 1) ?? (at >= 0 ? argv[at + 1] : undefined);
215
+ if (value && !value.startsWith("-"))
216
+ env[name] = value;
217
+ }
218
+ return env;
219
+ }
@@ -1,8 +1,11 @@
1
+ import { type GeoExportPagination } from "@oxygen/shared/geo";
1
2
  import { type StoredCredentials } from "./credentials.js";
2
3
  import { type CliEndpoint } from "./runtime.js";
3
4
  type RequestOptions = {
4
5
  /** Internal bounded file download; never follows a caller-supplied URL. */
5
6
  binaryMaxBytes?: number;
7
+ /** Return pagination beside bytes for operations advertising paginated downloads. */
8
+ binaryMetadata?: boolean;
6
9
  /** Stream a native authenticated download to an exclusively created file. */
7
10
  binaryOutputPath?: string;
8
11
  method?: "GET" | "POST" | "PATCH" | "DELETE";
@@ -25,6 +28,10 @@ type RequestOptions = {
25
28
  };
26
29
  export declare function requestOxygen<T>(// skipcq: JS-R1005
27
30
  path: string, options?: RequestOptions): Promise<T>;
31
+ export type PaginatedFileDownload = {
32
+ bytes: Uint8Array;
33
+ pagination?: GeoExportPagination;
34
+ };
28
35
  export declare function ensureFreshCliForApiUrl(apiUrl: string, options?: {
29
36
  fetch?: typeof fetch;
30
37
  endpoint?: CliEndpoint;
@@ -1,5 +1,7 @@
1
+ import { readGeoExportPagination } from "@oxygen/shared/geo";
1
2
  import { OXYGEN_VERSION, OxygenError, isCliResult, isVersionGreater, parseSemver, readEnvelopeCompatibility, vercelProtectionBypassHeaders, withRetryAfterDetails, } from "@oxygen/shared";
2
3
  import { randomUUID } from "node:crypto";
4
+ import { cliDiagnosticHeaders, cliDiagnosticInvocation, recordCliDiagnosticRequest } from "./diagnostics.js";
3
5
  import { open, unlink } from "node:fs/promises";
4
6
  import { recordMutatingRequestAnswered, recoverFromCliFloorRejection } from "./auto-update.js";
5
7
  import { defaultApiUrl, loadCredentialsWithEndpoint } from "./credentials.js";
@@ -49,8 +51,9 @@ path, options = {}) {
49
51
  }
50
52
  }
51
53
  options.signal?.throwIfAborted();
52
- const traceId = options.traceId ?? randomUUID();
54
+ const traceId = options.traceId ?? cliDiagnosticInvocation() ?? randomUUID();
53
55
  const headers = {
56
+ ...cliDiagnosticHeaders(),
54
57
  Accept: "application/json",
55
58
  "X-Oxygen-Trace-Id": traceId,
56
59
  // Advertise the CLI version so the server can enforce its minimum-CLI floor
@@ -104,15 +107,19 @@ path, options = {}) {
104
107
  else if (timeout.signal)
105
108
  requestInit.signal = timeout.signal;
106
109
  try {
110
+ recordCliDiagnosticRequest({ event: "request", trace_id: traceId, api_url: apiUrl, path, method,
111
+ input: options.body ?? (options.formData ? { withheld: "multipart_binary" } : null) });
107
112
  response = await fetchImpl(`${apiUrl}${path}`, requestInit);
108
113
  if (response.ok && options.binaryOutputPath !== undefined) {
109
114
  return await streamBinaryResponse(response, options.binaryOutputPath);
110
115
  }
111
116
  if (response.ok && options.binaryMaxBytes !== undefined) {
112
- return await readBoundedBinaryResponse(response, options.binaryMaxBytes);
117
+ const bytes = await readBoundedBinaryResponse(response, options.binaryMaxBytes);
118
+ return (options.binaryMetadata ? { bytes, pagination: readGeoExportPagination(response.headers) } : bytes);
113
119
  }
114
120
  }
115
121
  catch (error) {
122
+ recordCliDiagnosticRequest({ event: "transport_failure", trace_id: traceId, error });
116
123
  if (error instanceof OxygenError)
117
124
  throw error;
118
125
  if (timeout.timedOut() || (timeoutMs !== undefined && isAbortError(error))) {
@@ -163,6 +170,7 @@ path, options = {}) {
163
170
  });
164
171
  });
165
172
  const compatibility = readEnvelopeCompatibility(envelope);
173
+ recordCliDiagnosticRequest({ event: "response", trace_id: traceId, status: response.status, output: envelope });
166
174
  const failed = !response.ok || !envelope.ok;
167
175
  try {
168
176
  assertCliMeetsMinimumApiVersion(compatibility, options, endpoint);
@@ -452,6 +460,7 @@ async function readEnvelope(response) {
452
460
  parsed = text ? JSON.parse(text) : null;
453
461
  }
454
462
  catch {
463
+ recordCliDiagnosticRequest({ event: "invalid_response", status: response.status, output: text });
455
464
  throw new OxygenError("invalid_response", "Oxygen API returned a non-JSON response.", {
456
465
  details: {
457
466
  status: response.status,
@@ -462,6 +471,7 @@ async function readEnvelope(response) {
462
471
  }
463
472
  if (isCliResult(parsed))
464
473
  return parsed;
474
+ recordCliDiagnosticRequest({ event: "invalid_response", status: response.status, output: parsed });
465
475
  throw new OxygenError("invalid_response", "Oxygen API returned an invalid response envelope.", {
466
476
  details: { status: response.status },
467
477
  exitCode: 1,
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { registerCrmFileReadCommands } from "./crm-file-read-commands.js";
13
13
  import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath, buildCrmRecordPath } from "./crm-support-flags.js";
14
14
  import { registerCustomerOperationCommands } from "./customer-operation-commands.js";
15
15
  import { DATABASE_QUERY_FLAG_OVERLAY } from "./database-query-flags.js";
16
+ import { beginCliDiagnostics, finishCliDiagnostics, setCliDiagnosticOperation, recordCliDiagnosticFailure } from "./diagnostics.js";
16
17
  import { addFeedReadFlags, feedReadWindowParams } from "./feed-read-flags.js";
17
18
  import { registerFunctionsCommands } from "./functions-commands.js";
18
19
  import { applyOxygenHelp, argvRequestsJson, commanderUsageFailure, commandNameForArgv, enableCommandSuggestions, unknownCommandHint, unknownOptionHint, withoutHelpForUnknownCommand, validateRequiredOptionValues, } from "./help.js";
@@ -961,6 +962,7 @@ function writeManagedProviderAvailabilityNotice(data) {
961
962
  // process exit code from the error. Command handlers that don't route through
962
963
  // handleAsyncAction catch straight into this so the shape can't drift.
963
964
  function emitCliFailure(command, error) {
965
+ recordCliDiagnosticFailure(error);
964
966
  writeJson(toFailure(command, error));
965
967
  writeMaxCreditsHint(error);
966
968
  process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
@@ -11926,7 +11928,7 @@ Examples:
11926
11928
  .command("admin")
11927
11929
  .description("Staff-only commands.")
11928
11930
  .addCommand(new Command("kpis")
11929
- .description("Show the company conversion funnel (visitors → signups → agent connected → CLI/MCP activation → paid, with step rates), self-reported signup-channel attribution, new and positive-paid MRR/ARR, and paid churn separated from trial loss. OXYGEN staff only; workspace admin role alone does not grant access.")
11931
+ .description("Show the company conversion funnel (visitors → signups → activation on any surface, web, CLI or MCP → paid, with step rates and agent connected beside it), self-reported signup-channel attribution, new and positive-paid MRR/ARR, and paid churn separated from trial loss. OXYGEN staff only; workspace admin role alone does not grant access.")
11930
11932
  .addOption(new Option("--range <range>", "Range for acquisition, the funnel's signup cohort, new revenue, and churn; current MRR/ARR and scheduled cancellations remain point-in-time.")
11931
11933
  .choices(["7d", "30d", "90d"])
11932
11934
  .default("30d"))
@@ -30337,20 +30339,27 @@ function muteTokenEcho() {
30337
30339
  // head -c 20`) otherwise crashes with a raw EPIPE stack after the consumer
30338
30340
  // closes the pipe; a closed pipe is normal shell behavior, not an error.
30339
30341
  process.stdout.on("error", (error) => {
30340
- if (error.code === "EPIPE")
30341
- process.exit(0);
30342
+ if (error.code === "EPIPE") {
30343
+ void finishCliDiagnostics(0).finally(() => process.exit(0));
30344
+ return;
30345
+ }
30342
30346
  process.stderr.write(`stdout error: ${error.message}\n`);
30343
- process.exit(1);
30347
+ recordCliDiagnosticFailure(error);
30348
+ void finishCliDiagnostics(1).finally(() => process.exit(1));
30344
30349
  });
30350
+ beginCliDiagnostics();
30345
30351
  maybeScheduleBackgroundUpdate();
30346
30352
  const program = createProgram();
30347
30353
  installCommanderExitOverride(program);
30348
30354
  const argv = withoutHelpForUnknownCommand(program, process.argv);
30355
+ setCliDiagnosticOperation(commandNameForArgv(program, argv.slice(2)));
30349
30356
  try {
30350
30357
  await program.parseAsync(argv);
30351
30358
  }
30352
30359
  catch (error) {
30353
30360
  if (error instanceof CommanderError) {
30361
+ if (error.exitCode !== 0)
30362
+ recordCliDiagnosticFailure(error);
30354
30363
  const cliArgs = argv.slice(2);
30355
30364
  // Every hint goes to stderr as it always has; the first, the most specific,
30356
30365
  // also becomes the --json envelope's details.next_step below.
@@ -30417,6 +30426,8 @@ catch (error) {
30417
30426
  emitCliFailure(commandNameForArgv(program, argv.slice(2)), error);
30418
30427
  }
30419
30428
  else {
30429
+ recordCliDiagnosticFailure(error);
30430
+ await finishCliDiagnostics(1);
30420
30431
  throw error;
30421
30432
  }
30422
30433
  }
@@ -30424,6 +30435,7 @@ catch (error) {
30424
30435
  // pipe, wait for the queued write callback before Node exits or the tail of
30425
30436
  // `oxygen --help` can be truncated at the pipe high-water mark.
30426
30437
  await Promise.all([flushWritable(process.stdout), flushWritable(process.stderr)]);
30438
+ await finishCliDiagnostics(Number(process.exitCode ?? 0));
30427
30439
  function flushWritable(stream) {
30428
30440
  return new Promise((resolve) => stream.write("", () => resolve()));
30429
30441
  }
@@ -318,7 +318,13 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
318
318
  {
319
319
  name: "knowledge_slugs",
320
320
  kind: "array",
321
- description: "Selected live master wiki slugs shared with creators; empty array clears sharing.",
321
+ description: "Knowledge file slugs, skills included, shared live with creators and loaded into their AI context (up to 30); empty array clears sharing.",
322
+ required: false,
323
+ },
324
+ {
325
+ name: "knowledge_folder_ids",
326
+ kind: "array",
327
+ description: "Knowledge folder ids (from knowledge folders list) shared live with creators, skill folders included: files added to them later reach existing creators too (up to 10); empty array clears.",
322
328
  required: false,
323
329
  },
324
330
  {
@@ -373,7 +379,13 @@ export function registerUgcCommands(program, { request: requestOxygen, handle })
373
379
  {
374
380
  name: "knowledge_slugs",
375
381
  kind: "array",
376
- description: "Selected live master wiki slugs shared with creators; empty array clears sharing.",
382
+ description: "Knowledge file slugs, skills included, shared live with creators and loaded into their AI context (up to 30); empty array clears sharing.",
383
+ required: false,
384
+ },
385
+ {
386
+ name: "knowledge_folder_ids",
387
+ kind: "array",
388
+ description: "Knowledge folder ids (from knowledge folders list) shared live with creators, skill folders included: files added to them later reach existing creators too (up to 10); empty array clears.",
377
389
  required: false,
378
390
  },
379
391
  {
@@ -9,10 +9,10 @@ export const OXYGEN_CAPABILITY_ROUTES = [
9
9
  owns: "GEO / AEO AI-search tracking: buyer prompts, consumer answers, citations, competitive visibility, customer chat evidence and optimization experiments. Experimental staff/dev access.",
10
10
  notFor: "Direct model API benchmarks, automatic public edits, autonomous publishing or personalized chat attribution.",
11
11
  execution: "Staff in dev workspaces only. Preview consumer collection and cost, start within the $10 shared pilot cap, inspect answers and citations, then record reviewed changes and compare later samples. Reads never collect or spend.",
12
- gatewayTools: ["oxygen_geo_projects_list", "oxygen_geo_engines_list", "oxygen_geo_runs_preview"],
13
- gatewayCommands: ["geo projects list", "geo engines list", "geo runs preview"],
12
+ gatewayTools: ["oxygen_geo_projects_list", "oxygen_geo_engines_list", "oxygen_geo_analytics_get", "oxygen_geo_runs_preview"],
13
+ gatewayCommands: ["geo projects list", "geo engines list", "geo analytics get", "geo runs preview"],
14
14
  endpointSections: ["geo"], skills: ["oxygen-geo"],
15
- intentTerms: ["GEO", "AEO", "answer engine optimization", "generative engine optimization", "AI search visibility", "AI visibility", "consumer AI answers", "answer engines", "brand visibility", "brand citations", "chatgpt mentions", "citation gaps"],
15
+ intentTerms: ["GEO", "AEO", "answer engine optimization", "generative engine optimization", "AI search visibility", "AI answer visibility", "AI visibility", "consumer AI answers", "answer engines", "brand visibility", "brand citations", "chatgpt mentions", "citation gaps"],
16
16
  },
17
17
  {
18
18
  id: "visual-rendering",
@@ -1791,7 +1791,7 @@ function isSpintaxIntent(query) {
1791
1791
  }
1792
1792
  function explicitCapabilityIntent(query) {
1793
1793
  // Importing an AI answer is GEO evidence, rather than a Table file import.
1794
- if (/\b(?:geo|aeo|ai (?:search )?visibility|consumer ai answers?|answer engine optimization|generative engine optimization)\b/.test(query))
1794
+ if (/\b(?:geo|aeo|ai (?:(?:search|answer) )?visibility|consumer ai answers?|answer engine optimization|generative engine optimization)\b/.test(query))
1795
1795
  return ROUTE_BY_ID.get("geo") ?? null;
1796
1796
  if (isConnectionPriceIntent(query))
1797
1797
  return ROUTE_BY_ID.get("billing-and-budgets") ?? null;
@@ -0,0 +1,5 @@
1
+ /** A named column operation constrains execution; it never grants authority. */
2
+ export declare const COLUMN_OPERATION_HEADER = "X-Oxygen-Column-Operation";
3
+ export declare const COLUMN_OPERATIONS: readonly ["ai", "research", "formula", "enrichment", "bind", "lookup"];
4
+ export type ColumnOperation = typeof COLUMN_OPERATIONS[number];
5
+ export declare function isColumnOperation(value: unknown): value is ColumnOperation;
@@ -0,0 +1,6 @@
1
+ /** A named column operation constrains execution; it never grants authority. */
2
+ export const COLUMN_OPERATION_HEADER = "X-Oxygen-Column-Operation";
3
+ export const COLUMN_OPERATIONS = ["ai", "research", "formula", "enrichment", "bind", "lookup"];
4
+ export function isColumnOperation(value) {
5
+ return typeof value === "string" && COLUMN_OPERATIONS.includes(value);
6
+ }
@@ -120,5 +120,5 @@ export const BROWSER_OPERATIONS = [
120
120
  operation("notetaker.calendar.schedule", "/api/cli/notetaker/calendar", "POST", "notetaker calendar schedule", "oxygen_notetaker_calendar_schedule", "crm", "Schedule recording for an existing calendar event after approval; an external CLI or MCP caller must supply an explicit max_credits ceiling.", { event_id: text(), approved: bool(), max_credits: num({ minimum: 0 }) }, ["event_id", "approved"], { defaults: { action: "schedule" }, spends: true }),
121
121
  operation("notetaker.note.edit", "/api/cli/notetaker/sessions/{session}/note", "PATCH", "notetaker notes edit", "oxygen_notetaker_note_edit", "crm", "Save the markdown note for one meeting session and return the committed note.", { markdown: text() }, ["markdown"]),
122
122
  operation("orgs.create", "/api/cli/orgs/create", "POST", "orgs create", "oxygen_workspace_create", "workspace", "Create a workspace for the authenticated person's account and begin native company setup. Workspace API keys cannot create personal workspaces.", { name: text(), company_domain: text("Company website or domain, or its LinkedIn company page URL when it has no website."), linkedin_profile_url: text("Creator's LinkedIn profile URL, separate from the company's page.") }, ["name", "company_domain"]),
123
- operation("onboarding.complete", "/api/cli/onboarding/complete", "POST", "onboarding complete", "oxygen_onboarding_complete", "workspace", "Complete the active workspace's onboarding step and record its discovery-source choices. The other choice requires source_other.", { sources: arr(enumOf(["linkedin_content", "linkedin_dm", "linkedin_ads", "youtube", "google", "email", "word_of_mouth", "referral", "event", "other"])), source_other: text(), redirect_url: text() }, ["sources"]),
123
+ operation("onboarding.complete", "/api/cli/onboarding/complete", "POST", "onboarding complete", "oxygen_onboarding_complete", "workspace", "Complete the active workspace's onboarding step and record its discovery-source choices. The other choice requires source_other.", { sources: arr(enumOf(["linkedin_content", "linkedin_dm", "linkedin_ads", "x", "youtube", "google", "email", "word_of_mouth", "referral", "event", "other"])), source_other: text(), redirect_url: text() }, ["sources"]),
124
124
  ];
@@ -27,6 +27,7 @@ const mcpOnly = { generated: { cli: false, mcp: true } };
27
27
  const cliOnly = { generated: { cli: true, mcp: false } };
28
28
  export const CUSTOMER_OPERATIONS = [
29
29
  operation("activation.state", "/api/cli/activation/state", "GET", "activation state", "oxygen_activation_state", "workspace", "Read this workspace's onboarding activation state and next available actions.", {}, [], mcpOnly),
30
+ operation("activation.checklist", "/api/cli/activation/checklist", "GET", "activation checklist", "oxygen_activation_checklist", "workspace", "Read this workspace's getting-started checklist: leads in a table, a first sequence, a connected sending account and a launched sequence, each with whether it is done and a link to do it; eligible says whether the sidebar shows it. Read-only, 0 credits."),
30
31
  operation("api_keys.list", "/api/cli/api-keys", "GET", "api-keys list", "oxygen_api_keys_list", "workspace", "List workspace API keys without revealing their tokens.", {}, [], mcpOnly),
31
32
  operation("api_keys.create", "/api/cli/api-keys", "POST", "api-keys create", "oxygen_api_keys_create", "workspace", "Create a workspace API key; requires the caller's personal user session. The token is returned once.", { name: text(), expires_at: text("ISO timestamp; omit for no expiry.") }, [], mcpOnly),
32
33
  operation("api_keys.revoke", "/api/cli/api-keys/{key_id}", "DELETE", "api-keys revoke", "oxygen_api_keys_revoke", "workspace", "Revoke a workspace API key; requires the caller's personal user session.", {}, [], mcpOnly),