@oxygen-agent/cli 1.1012.2 → 1.1013.15

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 (42) 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 +15 -3
  12. package/node_modules/@oxygen/shared/dist/capability-discovery.js +4 -4
  13. package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -1
  14. package/node_modules/@oxygen/shared/dist/contracts/geo.js +30 -8
  15. package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +1 -0
  16. package/node_modules/@oxygen/shared/dist/diagnostic-context.d.ts +3 -0
  17. package/node_modules/@oxygen/shared/dist/diagnostic-context.js +17 -0
  18. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +4 -0
  19. package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +12 -0
  20. package/node_modules/@oxygen/shared/dist/diagnostic-lock.d.ts +2 -0
  21. package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +60 -0
  22. package/node_modules/@oxygen/shared/dist/diagnostic-receipt.d.ts +6 -0
  23. package/node_modules/@oxygen/shared/dist/diagnostic-receipt.js +22 -0
  24. package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.d.ts +2 -0
  25. package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +39 -0
  26. package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +31 -0
  27. package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +233 -0
  28. package/node_modules/@oxygen/shared/dist/diagnostics.d.ts +49 -0
  29. package/node_modules/@oxygen/shared/dist/diagnostics.js +253 -0
  30. package/node_modules/@oxygen/shared/dist/geo.d.ts +17 -0
  31. package/node_modules/@oxygen/shared/dist/geo.js +31 -3
  32. package/node_modules/@oxygen/shared/dist/onboarding-first-leads.d.ts +19 -0
  33. package/node_modules/@oxygen/shared/dist/onboarding-first-leads.js +25 -0
  34. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +1 -1
  35. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +4 -0
  36. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +2 -0
  37. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +2 -0
  38. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -0
  39. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
  40. package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
  41. package/node_modules/@oxygen/shared/dist/version.js +3 -0
  42. 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.15
@@ -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;
@@ -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
  }
@@ -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;
@@ -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
  ];
@@ -1,7 +1,10 @@
1
1
  import { arr, bool, enumOf, int, obj, str, } from "./schema.js";
2
2
  import { GEO_ENGINES } from "../geo.js";
3
3
  const text = (maxLength = 1000) => str({ maxLength });
4
- const strings = () => arr(text(), { maxItems: 100 });
4
+ const strings = () => arr(text(), {
5
+ maxItems: 100,
6
+ description: "List of strings. CLI: comma-separated values; MCP: a JSON array.",
7
+ });
5
8
  const engine = enumOf(GEO_ENGINES);
6
9
  const market = obj({
7
10
  country: str({ minLength: 2, maxLength: 2 }),
@@ -10,10 +13,22 @@ const market = obj({
10
13
  const project = {
11
14
  name: str({ minLength: 1, maxLength: 120 }),
12
15
  aliases: strings(),
13
- domains: arr(text(), { minItems: 1, maxItems: 100 }),
16
+ domains: arr(text(), {
17
+ minItems: 1,
18
+ maxItems: 100,
19
+ description: "Owned public domains. CLI: comma-separated values; MCP: a JSON array.",
20
+ }),
14
21
  icp: text(5000),
15
- engines: arr(engine, { minItems: 1, maxItems: 7 }),
16
- markets: arr(market, { minItems: 1, maxItems: 10 }),
22
+ engines: arr(engine, {
23
+ minItems: 1,
24
+ maxItems: 7,
25
+ description: "Engine names. CLI: comma-separated values; MCP: a JSON array. Omit to select ready collectors, or Claude import-only if none are ready.",
26
+ }),
27
+ markets: arr(market, {
28
+ minItems: 1,
29
+ maxItems: 10,
30
+ description: 'JSON array of country/language pairs, e.g. [{"country":"US","language":"en"}].',
31
+ }),
17
32
  repetitions: int({ minimum: 1, maximum: 5 }),
18
33
  archived: bool(),
19
34
  };
@@ -61,7 +76,7 @@ function operation(id, suffix, method, command, summary, fields = {}, required =
61
76
  }
62
77
  export const GEO_OPERATIONS = [
63
78
  operation("projects.list", "/projects", "GET", "projects list", "List GEO / AEO AI-search tracking projects."),
64
- operation("projects.create", "/projects", "POST", "projects create", "Create a GEO / AEO brand tracking project.", project, ["name", "domains"]),
79
+ operation("projects.create", "/projects", "POST", "projects create", "Create a GEO / AEO brand tracking project, private to the selected workspace and its authorized staff in dev. This never publishes data.", project, ["name", "domains"]),
65
80
  operation("projects.get", "/projects/{project_id}", "GET", "projects get", "Read a GEO / AEO project and its tracking settings."),
66
81
  operation("projects.update", "/projects/{project_id}", "PATCH", "projects update", "Change brand aliases, ICP, markets or tracking settings.", project),
67
82
  operation("context.get", "/context", "GET", "context get", "Read editable brand, ICP and offering context from workspace Knowledge."),
@@ -83,6 +98,7 @@ export const GEO_OPERATIONS = [
83
98
  operation("runs.list", "/projects/{project_id}/runs", "GET", "runs list", "List tracking runs with completion and costs."),
84
99
  operation("runs.get", "/projects/{project_id}/runs/{run_id}", "GET", "runs get", "Inspect a tracking run, provider tasks, costs, failures and retries."),
85
100
  operation("runs.retry", "/projects/{project_id}/runs/{run_id}/retry", "POST", "runs retry", "Resume failed reads of saved provider tasks without a new paid submission."),
101
+ operation("runs.reconcile", "/projects/{project_id}/runs/{run_id}/reconcile", "POST", "runs reconcile", "Reconcile uncertain submissions using a matching saved no-job supplier receipt. No new collection or paid submission."),
86
102
  operation("runs.cancel", "/projects/{project_id}/runs/{run_id}/cancel", "POST", "runs cancel", "Stop unsubmitted samples; submitted collection retains its spend reservation."),
87
103
  operation("schedules.set", "/projects/{project_id}/schedule", "PATCH", "schedule set", "Pause or configure daily/weekly GEO tracking within a reviewed cost ceiling.", {
88
104
  cadence: enumOf(["paused", "daily", "weekly"]),
@@ -92,10 +108,16 @@ export const GEO_OPERATIONS = [
92
108
  operation("answers.get", "/projects/{project_id}/answers/{document_id}", "GET", "answers get", "Read the immutable original answer and full provider payload."),
93
109
  operation("answers.import", "/projects/{project_id}/answers", "POST", "answers import", "Import a manually observed consumer answer, separate from controlled tracker metrics.", {
94
110
  engine,
111
+ prompt_id: str({
112
+ description: "Optional tracked question ID. The prompt text must match its current revision; the import stays outside controlled measurements.",
113
+ }),
95
114
  prompt: text(500),
96
115
  text: text(100000),
97
116
  urls: strings(),
98
- captured_at: text(40),
117
+ captured_at: str({
118
+ maxLength: 40,
119
+ description: "ISO 8601 capture timestamp, e.g. 2026-10-09T00:00:00Z. Omit to record the import time.",
120
+ }),
99
121
  }, ["engine", "prompt", "text"]),
100
122
  operation("analytics.get", "/projects/{project_id}/analytics", "GET", "analytics get", "Read AI visibility, citations, share of voice and trends with sample denominators.", filters),
101
123
  operation("sources.list", "/projects/{project_id}/sources", "GET", "sources list", "Find domains and pages cited by AI, including competitive citation gaps.", filters),
@@ -132,7 +154,7 @@ export const GEO_OPERATIONS = [
132
154
  notes: text(10000),
133
155
  }),
134
156
  {
135
- ...operation("exports.get", "/projects/{project_id}/export", "GET", "export", "Download GEO evidence, metrics or an internal PDF report.", { format: enumOf(["json", "csv", "pdf"]), ...filters }),
136
- binary: { maxBytes: 20_000_000 },
157
+ ...operation("exports.get", "/projects/{project_id}/export", "GET", "export", "Download a bounded page of GEO evidence or a PDF report. Follow next_cursor in the result with --cursor; metrics describe all filtered answers.", { format: enumOf(["json", "csv", "pdf"]), ...filters }),
158
+ binary: { maxBytes: 20_000_000, paginated: true },
137
159
  },
138
160
  ];
@@ -32,6 +32,7 @@ export type OperationSpec<C extends ContractObject = ContractObject> = {
32
32
  binary?: {
33
33
  maxBytes: number;
34
34
  stream?: never;
35
+ paginated?: true;
35
36
  } | {
36
37
  stream: true;
37
38
  resourceParameter: string;
@@ -0,0 +1,3 @@
1
+ export declare function enterDiagnosticInvocation(value: unknown): void;
2
+ export declare function withDiagnosticInvocation<T>(value: string, task: () => T): T;
3
+ export declare function activeDiagnosticInvocation(): string | undefined;