@oxygen-agent/cli 1.1014.618 → 1.1014.619

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.1014.618
37
+ Version: 1.1014.619
@@ -391,6 +391,8 @@ function buildCrmDuplicatesPath(object, options) {
391
391
  const params = new URLSearchParams();
392
392
  if (options.limit)
393
393
  params.set("limit", options.limit);
394
+ if (options.cursor)
395
+ params.set("cursor", options.cursor);
394
396
  const query = params.toString();
395
397
  const base = `/api/cli/crm/objects/${encodeURIComponent(object)}/duplicates`;
396
398
  return query ? `${base}?${query}` : base;
@@ -1393,9 +1395,10 @@ export function registerCrmFamily(program, handleAsyncAction) {
1393
1395
  await handleAsyncAction("crm describe", options, () => requestOxygen(`/api/cli/crm/objects/${encodeURIComponent(object)}${field ? `?attribute=${encodeURIComponent(field)}` : ""}`));
1394
1396
  }))
1395
1397
  .addCommand(new Command("duplicates")
1396
- .description("List ranked duplicate-candidate record pairs for one CRM object (identity-overlap + fuzzy-label). Read-only.")
1398
+ .description("List ranked duplicate-candidate record pairs for one CRM object (identity-overlap + fuzzy-label), across every record. Read-only.")
1397
1399
  .argument("<object>", "CRM object slug, such as companies or people.")
1398
- .option("--limit <limit>", "Maximum candidate pairs to return. Defaults to 25, max 100.")
1400
+ .option("--limit <limit>", "Maximum candidate pairs per page. Defaults to 25, max 100.")
1401
+ .option("--cursor <cursor>", "Pagination cursor from a previous page (its nextCursor).")
1399
1402
  .option("--json", "Print a JSON envelope.")
1400
1403
  .action(async (object, options) => {
1401
1404
  await handleAsyncAction("crm duplicates", options, () => requestOxygen(buildCrmDuplicatesPath(object, options)));
@@ -14,7 +14,7 @@ import { PIPEDRIVE_CATALOG_QUERY_CONTRACT, pipedriveCatalogQueryParams } from "@
14
14
  import { TABLE_LIST_QUERY_CONTRACT, tableListQueryParams } from "@oxygen/shared/contracts/table-list";
15
15
  import { TABLE_QUERY_GRID_CONTRACT } from "@oxygen/shared/contracts/table-query-grid";
16
16
  import { TABLE_QUERY_CLI_DEFAULT_LIMIT, TABLE_QUERY_CLI_DEFAULT_MAX_CHARS, tableQueryRawResultFits, tableQueryRawRecovery } from "@oxygen/shared/contracts/table-query-raw";
17
- import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer } from "@oxygen/shared/file-import";
17
+ import { MAX_BUFFERED_IMPORT_PARSE_BYTES, assertImportFormatWithinBufferedLimit, dropBlankImportCells, inferImportColumnLabels, inferRowsFileFormat, normalizeImportColumnKey, normalizeRowsForNewTable, normalizeRowsFormat, parseRowsFileBuffer } from "@oxygen/shared/file-import";
18
18
  import { PUBLIC_WEBHOOK_INGRESS_PER_TARGET_PER_MINUTE } from "@oxygen/shared/plan-band";
19
19
  import { MAX_CLI_JSON_BODY_BYTES, PLAN_LIMITS } from "@oxygen/shared/plan-limits";
20
20
  import { FILTER_TREE_OPERATORS } from "@oxygen/shared/table-filter-operators";
@@ -608,6 +608,9 @@ async function importRowsFromUrl(table, options) {
608
608
  return withImportWaitNextStep({ ...result, background: true, autoBackground: true });
609
609
  }
610
610
  async function importRows(table, options) {
611
+ if (options.url && options.skipBlankCells) {
612
+ throw new OxygenError("invalid_request", "--skip-blank-cells works on --file imports. Download the link and import the file.", { exitCode: 1 });
613
+ }
611
614
  if (options.url)
612
615
  return importRowsFromUrl(table, { ...options, url: options.url });
613
616
  if (!options.file) {
@@ -661,7 +664,11 @@ async function importRowsFromFile(table, options) {
661
664
  sourceHash,
662
665
  }, preparedBackgroundTarget));
663
666
  }
664
- const target = await prepareImportTarget(table, options, parsedRows);
667
+ const prepared = await prepareImportTarget(table, options, parsedRows);
668
+ // The foreground writes rows itself: a row without a key leaves that value alone.
669
+ const target = options.skipBlankCells
670
+ ? { ...prepared, rows: prepared.rows.map((row) => dropBlankImportCells(row, prepared.upsertKey)) }
671
+ : prepared;
665
672
  let rowCount = 0;
666
673
  let insertedCount = 0;
667
674
  let updatedCount = 0;
@@ -1022,6 +1029,7 @@ table, options, format, file, context, preparedTarget = null) {
1022
1029
  : {}),
1023
1030
  ...(target.upsertKey ? { upsert_key: target.upsertKey } : {}),
1024
1031
  ...(target.sourceKeyMap ? { source_key_map: target.sourceKeyMap } : {}),
1032
+ ...(options.skipBlankCells ? { skip_blank_cells: true } : {}),
1025
1033
  },
1026
1034
  });
1027
1035
  emitQueueWaitStderrNote(readRecord(result, "queue_wait"));
@@ -1108,6 +1116,10 @@ async function enqueueImportFile(table, options, format, batchSize, context, pre
1108
1116
  exitCode: 1,
1109
1117
  });
1110
1118
  }
1119
+ if (options.skipBlankCells) {
1120
+ // The multipart import cannot leave blank cells out; failing beats clearing them.
1121
+ throw new OxygenError("skip_blank_cells_unavailable", "--skip-blank-cells needs the staged file upload, which this OXYGEN server has not configured. Add --sync to write the rows from this machine instead.", { exitCode: 1 });
1122
+ }
1111
1123
  const fileBuffer = readFileSync(options.file);
1112
1124
  const filename = basename(options.file);
1113
1125
  const form = new FormData();
@@ -2441,6 +2453,7 @@ export function registerTablesFamily(program, handleAsyncAction, binaryName) {
2441
2453
  .option("--create <name>", "Create a new table with columns inferred from the file before importing.")
2442
2454
  .option("--project <project>", "Project id or slug for --create. Defaults to General.")
2443
2455
  .option("--upsert-key <key>", "Column key used to upsert instead of inserting.")
2456
+ .option("--skip-blank-cells", "Leave a row's existing value where the file's cell is blank instead of clearing it, so a partial update needs one file.")
2444
2457
  .option("--batch-size <n>", `Requested rows per import chunk, not a limit on the file. Defaults to 500; values up to 5000 are accepted, but Oxygen splits writes to at most 500 rows (and smaller for wide rows, about ${formatJsonBodyLimit()} of JSON per request) so they fit request and worker lease budgets.`)
2445
2458
  .option("--background", "Enqueue durable import chunks for the background worker.")
2446
2459
  .option("--sync", `Force foreground import even for files above ${LARGE_IMPORT_BACKGROUND_ROW_THRESHOLD} rows. That threshold picks foreground vs background; it does not cap how many rows a file may hold.`)
@@ -13,6 +13,7 @@ const text = (description) => str(description === undefined ? {} : { description
13
13
  const list = () => arr(str());
14
14
  const json = { type: "object", additionalProperties: true };
15
15
  const rows = () => arr(json);
16
+ const skipBlankCells = () => bool({ description: "Keep existing values where a cell is blank." });
16
17
  function operation(id, route, method, cli, mcp, pack, summary, fields = {}, required = [], options = {}) {
17
18
  const { annotations, ...behavior } = options;
18
19
  const parameters = [...route.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
@@ -87,10 +88,10 @@ export const CUSTOMER_OPERATIONS = [
87
88
  operation("table_ingestions.append", "/api/cli/table-ingestion-runs/{ingestion_run_id}/items", "POST", "table-ingestions append", "oxygen_table_ingestions_append_items", "runs", "Append generated row chunks with items [{position:0,payload:{rows:[{column:value}]}}]. For capped provider paging, create a new approved tool_page run from the last completed item.nextCursor; preserve the Table, filters/mapping/upsert key and use a new credit cap.", { items: rows(), max_attempts: int({ minimum: 1 }) }, ["items"], cliOnly),
88
89
  operation("tables.favorites.get", "/api/cli/tables/favorite", "GET", "tables favorites", "oxygen_tables_favorites", "tables", "Read starred Tables/folders (per-member preference; free (0 credits)); next_cursor. Catalog stars differ.", TABLE_FAVORITES_LIST_CONTRACT.properties, [], { generated: { cli: true, mcp: false } }),
89
90
  operation("tables.favorite.set", "/api/cli/tables/favorite", "POST", "tables favorite", "oxygen_tables_favorite", "tables", "Star/unstar exactly one table or folder (saved per-member preference). Free (0 credits). Catalog stars differ.", TABLE_FAVORITE_CONTRACT.properties, ["favorite"]),
90
- operation("tables.import.csv", "/api/cli/tables/import-csv", "POST", "tables import-csv", "oxygen_tables_import_csv", "tables", "Import inline CSV into a hosted Table through its durable ingestion run. Use staged upload for large files.", { csv_text: text(), csv_base64: text(), table: text(), table_name: text(), project: text(), filename: text(), upsert_key: text(), batch_size: int({ minimum: 1, maximum: 5000 }) }, [], cliOnly),
91
+ operation("tables.import.csv", "/api/cli/tables/import-csv", "POST", "tables import-csv", "oxygen_tables_import_csv", "tables", "Import inline CSV into a hosted Table through its durable ingestion run. Use staged upload for large files.", { csv_text: text(), csv_base64: text(), table: text(), table_name: text(), project: text(), filename: text(), upsert_key: text(), skip_blank_cells: skipBlankCells(), batch_size: int({ minimum: 1, maximum: 5000 }) }, [], cliOnly),
91
92
  operation("tables.import.prepare", "/api/cli/tables/import-url", "POST", "tables import-upload", "oxygen_tables_import_upload", "tables", "Prepare a scoped storage upload for a hosted Table import; uploads and imports are separate, inspectable steps.", { file_name: text(), content_type: text(), byte_length: int({ minimum: 1 }), trace_id: text(), format: enumOf(["csv", "json", "xlsx"]) }, ["file_name", "byte_length"]),
92
93
  operation("tables.import.preview", "/api/cli/tables/import-preview", "POST", "tables import-preview", "oxygen_tables_import_preview", "tables", "Preview a staged file's headers, rows and existing-table mapping; writes no imported rows.", { storage_key: text(), file_name: text(), format: enumOf(["csv", "json", "xlsx"]), byte_length: int({ minimum: 1 }), table: text(), upsert_key: text() }, ["storage_key", "byte_length"], { mutates: false }),
93
- operation("tables.import.staged", "/api/cli/tables/import-staged", "POST", "tables import-staged", "oxygen_tables_import_staged", "tables", "Start a durable hosted import from this workspace's staged file; choose an existing table or create_name.", { table: text(), create_name: text(), project: text(), storage_key: text(), storage_bucket: text(), storage_provider: enumOf(["s3"]), file_name: text(), format: enumOf(["csv", "json", "xlsx"]), sha256: text(), upsert_key: text(), source_key_map: json, byte_length: int({ minimum: 1 }), row_count: int({ minimum: 1 }), max_concurrency: int({ minimum: 1, maximum: 1000 }), source: enumOf(["cli", "mcp", "web"]), batch_size: int({ minimum: 1, maximum: 5000 }) }, ["storage_key", "byte_length"]),
94
+ operation("tables.import.staged", "/api/cli/tables/import-staged", "POST", "tables import-staged", "oxygen_tables_import_staged", "tables", "Start a durable hosted import from this workspace's staged file; choose an existing table or create_name.", { table: text(), create_name: text(), project: text(), storage_key: text(), storage_bucket: text(), storage_provider: enumOf(["s3"]), file_name: text(), format: enumOf(["csv", "json", "xlsx"]), sha256: text(), upsert_key: text(), skip_blank_cells: skipBlankCells(), source_key_map: json, byte_length: int({ minimum: 1 }), row_count: int({ minimum: 1 }), max_concurrency: int({ minimum: 1, maximum: 1000 }), source: enumOf(["cli", "mcp", "web"]), batch_size: int({ minimum: 1, maximum: 5000 }) }, ["storage_key", "byte_length"]),
94
95
  operation("tables.import.file", "/api/cli/tables/import-file", "POST", "tables import-file", "oxygen_tables_import_file", "tables", "Import small base64 file bytes through the shared multipart import API; use staged upload for large files.", { content_base64: text(), file_name: text(), content_type: text(), table: text(), table_name: text(), project: text(), format: enumOf(["csv", "json", "xlsx"]), sheet: text(), upsert_key: text(), source_key_map: json, source_hash: text(), batch_size: int({ minimum: 1, maximum: 5000 }), max_concurrency: int({ minimum: 1 }) }, ["content_base64", "file_name"], { multipart: { contentField: "content_base64", nameField: "file_name", typeField: "content_type", fileField: "file" } }),
95
96
  operation("tables.link.suggest", "/api/cli/tables/link/suggest", "POST", "tables link-suggest", "oxygen_tables_link_suggest", "tables", "Plan column links and matching row counts between Tables; creates no relation. Apply with tables link --to (MCP oxygen_tables_link_bulk).", TABLE_LINK_SUGGEST_CONTRACT.properties, ["table", "to"], { mutates: false }),
96
97
  operation("tables.recover_pending", "/api/cli/tables/recover-pending", "POST", "tables recover-pending", "oxygen_tables_recover_pending", "tables", "Recover BetterContact email/phone requests and read verification blockers; no new orders or imports.", TABLE_RECOVER_PENDING_CONTRACT.properties, ["table"], { ...mcpOnly, annotations: { openWorldHint: true } }),
@@ -67,6 +67,14 @@ export declare function parseRowsSampleText(text: string, format: Exclude<RowsFi
67
67
  }): RowsSample;
68
68
  export declare function inferImportColumnLabels(rows: Record<string, unknown>[]): string[];
69
69
  export declare function normalizeRowsForNewTable(rows: Record<string, unknown>[]): NewTableImportRows;
70
+ /**
71
+ * The row without its blank cells, for an import that must leave a record's
72
+ * existing value alone where the file has nothing ("skip blank cells"): an
73
+ * upsert writes only the keys a row carries. Blank is null, undefined or text
74
+ * that is only whitespace. The upsert key always stays, so a row without one is
75
+ * still reported as missing its key instead of quietly dropping out.
76
+ */
77
+ export declare function dropBlankImportCells(row: Record<string, unknown>, keepKey?: string | null): Record<string, unknown>;
70
78
  export declare function normalizeImportColumnKey(value: string): string;
71
79
  /**
72
80
  * Refuse a JSON or XLSX file over the buffered-parse ceiling before anything is
@@ -217,6 +217,22 @@ export function normalizeRowsForNewTable(rows) {
217
217
  // it MATCHES imported file headers back to the column key MINTED by tenant-db's
218
218
  // toSnakeIdentifier, and has call sites across web/CLI/API. The two must never
219
219
  // diverge \u2014 see packages/shared/src/identifiers.ts + identifiers.test.ts.
220
+ /**
221
+ * The row without its blank cells, for an import that must leave a record's
222
+ * existing value alone where the file has nothing ("skip blank cells"): an
223
+ * upsert writes only the keys a row carries. Blank is null, undefined or text
224
+ * that is only whitespace. The upsert key always stays, so a row without one is
225
+ * still reported as missing its key instead of quietly dropping out.
226
+ */
227
+ export function dropBlankImportCells(row, keepKey) {
228
+ const kept = {};
229
+ for (const [key, value] of Object.entries(row)) {
230
+ const blank = value === null || value === undefined || (typeof value === "string" && value.trim() === "");
231
+ if (!blank || key === keepKey)
232
+ kept[key] = value;
233
+ }
234
+ return kept;
235
+ }
220
236
  export function normalizeImportColumnKey(value) {
221
237
  return toSnakeIdentifier(value, "column");
222
238
  }
@@ -1 +1 @@
1
- export declare const OXYGEN_BUILD_VERSION = "1.1014.618";
1
+ export declare const OXYGEN_BUILD_VERSION = "1.1014.619";
@@ -1,2 +1,2 @@
1
1
  // GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
2
- export const OXYGEN_BUILD_VERSION = "1.1014.618";
2
+ export const OXYGEN_BUILD_VERSION = "1.1014.619";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxygen-agent/cli",
3
- "version": "1.1014.618",
3
+ "version": "1.1014.619",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",