maybeai-sheet-cli 0.13.2__tar.gz → 0.14.0__tar.gz

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 (62) hide show
  1. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/PKG-INFO +7 -3
  2. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/README.md +6 -2
  3. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/pyproject.toml +1 -1
  4. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/__init__.py +1 -1
  5. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/db_table.py +317 -1
  6. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/excel_worksheet.py +12 -0
  7. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/worksheet.py +42 -0
  8. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/endpoints.py +16 -1
  9. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_endpoints.py +1 -0
  10. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_phase2_cli.py +47 -0
  11. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_two_engine_cli.py +182 -0
  12. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/.gitignore +0 -0
  13. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/chart_config.py +0 -0
  14. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/chart_style_summary.py +0 -0
  15. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/cli.py +0 -0
  16. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/client.py +0 -0
  17. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/__init__.py +0 -0
  18. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/_phase2.py +0 -0
  19. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/cell.py +0 -0
  20. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/chart.py +0 -0
  21. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/column.py +0 -0
  22. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/dashboard.py +0 -0
  23. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/excel_table.py +0 -0
  24. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/file.py +0 -0
  25. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/formula.py +0 -0
  26. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/history.py +0 -0
  27. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/image.py +0 -0
  28. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/range.py +0 -0
  29. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/raw.py +0 -0
  30. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/row.py +0 -0
  31. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/share.py +0 -0
  32. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/sheet.py +0 -0
  33. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/style.py +0 -0
  34. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/table.py +0 -0
  35. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/commands/workbook.py +0 -0
  36. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/config.py +0 -0
  37. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/dashboard_interactions.py +0 -0
  38. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/dashboard_plan.py +0 -0
  39. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/errors.py +0 -0
  40. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/formatters.py +0 -0
  41. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/models/__init__.py +0 -0
  42. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/models/share.py +0 -0
  43. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/models/sheet.py +0 -0
  44. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/models/workbook.py +0 -0
  45. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/options.py +0 -0
  46. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/protocol.py +0 -0
  47. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/resolver.py +0 -0
  48. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/table_resolver.py +0 -0
  49. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/src/maybeai_sheet/update.py +0 -0
  50. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/fixtures/benchmarks/build_fixtures.py +0 -0
  51. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/fixtures/benchmarks/flat-records.xlsx +0 -0
  52. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/fixtures/benchmarks/mixed-engine.xlsx +0 -0
  53. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/fixtures/benchmarks/multi-table-layout.xlsx +0 -0
  54. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/fixtures/benchmarks/rows.json +0 -0
  55. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/fixtures/benchmarks/simple-grid.xlsx +0 -0
  56. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/fixtures/benchmarks/simple-values.json +0 -0
  57. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_benchmark_runner.py +0 -0
  58. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_cli.py +0 -0
  59. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_dashboard_plan.py +0 -0
  60. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_formatters.py +0 -0
  61. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_resolver.py +0 -0
  62. {maybeai_sheet_cli-0.13.2 → maybeai_sheet_cli-0.14.0}/tests/test_update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maybeai-sheet-cli
3
- Version: 0.13.2
3
+ Version: 0.14.0
4
4
  Summary: CLI for common MaybeAI spreadsheet operations
5
5
  Project-URL: Homepage, https://github.com/OmniMCP-AI/maybeai-sheet-cli
6
6
  Project-URL: Repository, https://github.com/OmniMCP-AI/maybeai-sheet-cli
@@ -293,6 +293,7 @@ mbs
293
293
  ├── db_worksheet(AKA db_table)
294
294
  │ ├── create API Create a PG-backed table from JSON row data.
295
295
  │ ├── create-from-query API Calculate SQL and create a PG-backed table from tabular results.
296
+ │ ├── create-from-range API Read a source worksheet range and create a PG-backed table on a target workbook.
296
297
  │ ├── update PLAN Update PG-backed rows from JSON row data.
297
298
  │ ├── delete PLAN Delete PG-backed rows/table; requires --yes unless --dry-run.
298
299
  │ ├── schema API Infer table schema from a bounded PG read.
@@ -410,6 +411,7 @@ mbs db-table read --doc-id abc123 --name orders-large --limit 100 --offset 200
410
411
  mbs db-table insert --doc-id abc123 --name orders-large --rows rows.json # API
411
412
  mbs db-table create --doc-id abc123 --name Orders --rows orders.json # API
412
413
  mbs db-table create-from-query --doc-id abc123 --name OrderSummary --sql-file order_summary.sql # API
414
+ mbs db-table create-from-range --doc-id target123 --name R_OrderLines_Store1 --source-doc-id source123 --worksheet-name "1店" --range A2:AR423 --header-row 0 --use-header-names --if-exists adopt --verify # API
413
415
  mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json # PLAN
414
416
  mbs db-table update --doc-id abc123 --name Orders --row-index 42 --rows row_patch.json # PLAN
415
417
  mbs db-table update --doc-id abc123 --name Orders --where '{"status":"open"}' --rows status_patch.json # PLAN
@@ -502,8 +504,10 @@ mbs share create --doc-id abc123 --email teammate@example.com --role viewer # A
502
504
  limit is 100 rows, and `--limit` / `--offset` are capped at 10,000 to avoid
503
505
  accidental large PG scans. `db-table create-from-query` is CLI-composed: it
504
506
  calculates a tabular `=SQL(...)` result through `calc-formula`, then creates a
505
- PG/SheetTable-backed table through `db_table/create`. Use a keyset-style backend
506
- contract for deeper pagination when it becomes available.
507
+ PG/SheetTable-backed table through `db_table/create`. `db-table create-from-range`
508
+ is also CLI-composed: it reads a source worksheet range, reshapes header/data
509
+ rows, then creates a PG/SheetTable on the target workbook. Use a keyset-style
510
+ backend contract for deeper pagination when it becomes available.
507
511
 
508
512
  Refactor planning is tracked separately from this README:
509
513
  `docs/superpowers/plans/2026-07-01-mbs-two-engine-command-refactor.md`.
@@ -269,6 +269,7 @@ mbs
269
269
  ├── db_worksheet(AKA db_table)
270
270
  │ ├── create API Create a PG-backed table from JSON row data.
271
271
  │ ├── create-from-query API Calculate SQL and create a PG-backed table from tabular results.
272
+ │ ├── create-from-range API Read a source worksheet range and create a PG-backed table on a target workbook.
272
273
  │ ├── update PLAN Update PG-backed rows from JSON row data.
273
274
  │ ├── delete PLAN Delete PG-backed rows/table; requires --yes unless --dry-run.
274
275
  │ ├── schema API Infer table schema from a bounded PG read.
@@ -386,6 +387,7 @@ mbs db-table read --doc-id abc123 --name orders-large --limit 100 --offset 200
386
387
  mbs db-table insert --doc-id abc123 --name orders-large --rows rows.json # API
387
388
  mbs db-table create --doc-id abc123 --name Orders --rows orders.json # API
388
389
  mbs db-table create-from-query --doc-id abc123 --name OrderSummary --sql-file order_summary.sql # API
390
+ mbs db-table create-from-range --doc-id target123 --name R_OrderLines_Store1 --source-doc-id source123 --worksheet-name "1店" --range A2:AR423 --header-row 0 --use-header-names --if-exists adopt --verify # API
389
391
  mbs db-table update --doc-id abc123 --name Orders --key order_id --rows orders_patch.json # PLAN
390
392
  mbs db-table update --doc-id abc123 --name Orders --row-index 42 --rows row_patch.json # PLAN
391
393
  mbs db-table update --doc-id abc123 --name Orders --where '{"status":"open"}' --rows status_patch.json # PLAN
@@ -478,8 +480,10 @@ mbs share create --doc-id abc123 --email teammate@example.com --role viewer # A
478
480
  limit is 100 rows, and `--limit` / `--offset` are capped at 10,000 to avoid
479
481
  accidental large PG scans. `db-table create-from-query` is CLI-composed: it
480
482
  calculates a tabular `=SQL(...)` result through `calc-formula`, then creates a
481
- PG/SheetTable-backed table through `db_table/create`. Use a keyset-style backend
482
- contract for deeper pagination when it becomes available.
483
+ PG/SheetTable-backed table through `db_table/create`. `db-table create-from-range`
484
+ is also CLI-composed: it reads a source worksheet range, reshapes header/data
485
+ rows, then creates a PG/SheetTable on the target workbook. Use a keyset-style
486
+ backend contract for deeper pagination when it becomes available.
483
487
 
484
488
  Refactor planning is tracked separately from this README:
485
489
  `docs/superpowers/plans/2026-07-01-mbs-two-engine-command-refactor.md`.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "maybeai-sheet-cli"
7
- version = "0.13.2"
7
+ version = "0.14.0"
8
8
  description = "CLI for common MaybeAI spreadsheet operations"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.13.2"
5
+ __version__ = "0.14.0"
@@ -12,7 +12,13 @@ from ..endpoints import get_endpoint_metadata
12
12
  from ..errors import APIRequestError, UsageError
13
13
  from ..formatters import build_command_output, handle_cli_error, load_json_file, render_output
14
14
  from ..options import output_option, resolve_command_state, timeout_option, verbose_option
15
- from ..resolver import ResolvedTarget, append_gid, parse_document_target, resolve_target
15
+ from ..resolver import (
16
+ ResolvedTarget,
17
+ append_gid,
18
+ build_document_url,
19
+ parse_document_target,
20
+ resolve_target,
21
+ )
16
22
  from ..table_resolver import (
17
23
  ResolvedTableTarget,
18
24
  bounded_a1_range,
@@ -176,6 +182,148 @@ def _load_query(sql: str | None, sql_file: Path | None) -> str:
176
182
  return sql if sql is not None else sql_file.read_text()
177
183
 
178
184
 
185
+ def _positional_column_name(index: int) -> str:
186
+ return f"raw_col_{index + 1:03d}"
187
+
188
+
189
+ def _header_fallback_column_name(index: int) -> str:
190
+ return f"col_{index + 1:03d}"
191
+
192
+
193
+ def _values_matrix_from_read_response(response: dict[str, Any]) -> list[list[Any]]:
194
+ for key in ("values", "data", "rows"):
195
+ candidate = response.get(key)
196
+ if isinstance(candidate, list) and (
197
+ not candidate or all(isinstance(row, list) for row in candidate)
198
+ ):
199
+ return [list(row) if isinstance(row, list) else [] for row in candidate]
200
+ raise UsageError("Source range readback did not contain a values matrix.")
201
+
202
+
203
+ def _unique_column_names(names: list[str]) -> list[str]:
204
+ seen: dict[str, int] = {}
205
+ unique: list[str] = []
206
+ for name in names:
207
+ count = seen.get(name, 0)
208
+ seen[name] = count + 1
209
+ unique.append(name if count == 0 else f"{name}_{count + 1}")
210
+ return unique
211
+
212
+
213
+ def _is_blank_matrix_row(row: list[Any]) -> bool:
214
+ if not row:
215
+ return True
216
+ return all(value is None or str(value).strip() == "" for value in row)
217
+
218
+
219
+ def _surface_from_values_matrix(
220
+ values: list[list[Any]],
221
+ *,
222
+ header_row: int = 0,
223
+ use_header_names: bool = False,
224
+ data_start: int | None = None,
225
+ data_end: int | None = None,
226
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]:
227
+ if not values:
228
+ raise UsageError("Source range readback is empty.")
229
+ if header_row < 0:
230
+ raise UsageError("--header-row must be at least 0.")
231
+ if header_row >= len(values):
232
+ raise UsageError(f"--header-row {header_row} is out of range (total={len(values)}).")
233
+
234
+ resolved_data_start = header_row + 1 if data_start is None else data_start
235
+ resolved_data_end = len(values) if data_end is None else data_end
236
+ if resolved_data_start < 0:
237
+ raise UsageError("--data-start must be at least 0.")
238
+ if resolved_data_end < resolved_data_start:
239
+ raise UsageError("--data-end must be greater than or equal to --data-start.")
240
+ if resolved_data_end > len(values):
241
+ raise UsageError(f"--data-end {resolved_data_end} is out of range (total={len(values)}).")
242
+
243
+ header_cells = list(values[header_row] or [])
244
+ width = len(header_cells)
245
+ for row in values[resolved_data_start:resolved_data_end]:
246
+ if isinstance(row, list):
247
+ width = max(width, len(row))
248
+ if width == 0:
249
+ raise UsageError("Source range header row did not contain any columns.")
250
+
251
+ while len(header_cells) < width:
252
+ header_cells.append(None)
253
+
254
+ if use_header_names:
255
+ raw_names = [
256
+ str(cell or "").strip() or _header_fallback_column_name(index)
257
+ for index, cell in enumerate(header_cells)
258
+ ]
259
+ column_names = _unique_column_names(raw_names)
260
+ else:
261
+ column_names = [_positional_column_name(index) for index in range(width)]
262
+
263
+ rows: list[dict[str, Any]] = []
264
+ dropped_blank = 0
265
+ for raw_row in values[resolved_data_start:resolved_data_end]:
266
+ cells = list(raw_row) if isinstance(raw_row, list) else []
267
+ if _is_blank_matrix_row(cells):
268
+ dropped_blank += 1
269
+ continue
270
+ row: dict[str, Any] = {}
271
+ for index, name in enumerate(column_names):
272
+ value = cells[index] if index < len(cells) else None
273
+ row[name] = "" if value is None else value
274
+ rows.append(row)
275
+
276
+ columns = [
277
+ {
278
+ "name": name,
279
+ "logical_type": "text",
280
+ "index": index,
281
+ }
282
+ for index, name in enumerate(column_names)
283
+ ]
284
+ summary = {
285
+ "header_row": header_row,
286
+ "use_header_names": use_header_names,
287
+ "data_start": resolved_data_start,
288
+ "data_end": resolved_data_end,
289
+ "column_count": len(columns),
290
+ "row_count": len(rows),
291
+ "rows_dropped_blank": dropped_blank,
292
+ "column_names": column_names,
293
+ }
294
+ return columns, rows, summary
295
+
296
+
297
+ def _resolve_source_workbook_target(
298
+ *,
299
+ source_doc_id: str | None,
300
+ source_url: str | None,
301
+ worksheet_name: str,
302
+ ) -> ResolvedTarget:
303
+ if bool(source_doc_id) == bool(source_url):
304
+ raise UsageError("Provide exactly one of --source-doc-id or --source-url.")
305
+ if source_url:
306
+ document_id, _gid = parse_document_target(source_url)
307
+ if not document_id:
308
+ raise UsageError("--source-url must include a MaybeAI document id.")
309
+ return ResolvedTarget(
310
+ document_id=document_id,
311
+ url=source_url,
312
+ uri=source_url,
313
+ gid=None,
314
+ worksheet_name=worksheet_name,
315
+ )
316
+ assert source_doc_id is not None
317
+ uri = build_document_url(source_doc_id)
318
+ return ResolvedTarget(
319
+ document_id=source_doc_id,
320
+ url=uri,
321
+ uri=uri,
322
+ gid=None,
323
+ worksheet_name=worksheet_name,
324
+ )
325
+
326
+
179
327
  def _first_tabular_value(value: Any) -> Any:
180
328
  if isinstance(value, list):
181
329
  if not value:
@@ -885,6 +1033,174 @@ def create_from_query(
885
1033
  handle_cli_error(error)
886
1034
 
887
1035
 
1036
+ DB_TABLE_CREATE_FROM_RANGE_EXAMPLE = """Example:
1037
+
1038
+ mbs db-table create-from-range \\
1039
+ --doc-id <target_doc_id> \\
1040
+ --name R_OrderLines_Store1 \\
1041
+ --source-doc-id <source_doc_id> \\
1042
+ --worksheet-name "1店" \\
1043
+ --range A2:AR423 \\
1044
+ --header-row 0 \\
1045
+ --use-header-names \\
1046
+ --if-exists adopt \\
1047
+ --verify \\
1048
+ --output json
1049
+
1050
+ Reads a source worksheet range, treats --header-row as a 0-based index inside
1051
+ the returned values matrix, drops blank rows, and creates a PG/SheetTable on
1052
+ the target --doc-id. Use --use-header-names for semantic headers (merged-title
1053
+ sheets); omit it for positional raw_col_NNN columns.
1054
+ """
1055
+
1056
+
1057
+ @app.command(
1058
+ "create-from-range",
1059
+ help="Create a PG/SheetTable-backed table from a source worksheet range readback.",
1060
+ epilog=DB_TABLE_CREATE_FROM_RANGE_EXAMPLE,
1061
+ )
1062
+ def create_from_range(
1063
+ ctx: typer.Context,
1064
+ doc_id: Optional[str] = typer.Option(None, "--doc-id", help="Target MaybeAI document ID."),
1065
+ url: Optional[str] = typer.Option(None, "--url", help="Target MaybeAI workbook URL."),
1066
+ uri: Optional[str] = typer.Option(None, "--uri", help="Resolved target workbook URI."),
1067
+ name: str = typer.Option(..., "--name", help="New PG-backed table name on the target workbook."),
1068
+ source_doc_id: Optional[str] = typer.Option(
1069
+ None,
1070
+ "--source-doc-id",
1071
+ help="Source MaybeAI document ID to read the range from.",
1072
+ ),
1073
+ source_url: Optional[str] = typer.Option(
1074
+ None,
1075
+ "--source-url",
1076
+ help="Source MaybeAI workbook URL to read the range from.",
1077
+ ),
1078
+ worksheet_name: str = typer.Option(
1079
+ ...,
1080
+ "--worksheet-name",
1081
+ help="Source worksheet name to read.",
1082
+ ),
1083
+ cell_range: str = typer.Option(
1084
+ ...,
1085
+ "--range",
1086
+ help="Source A1-style range to read (include the semantic header row).",
1087
+ ),
1088
+ header_row: int = typer.Option(
1089
+ 0,
1090
+ "--header-row",
1091
+ min=0,
1092
+ help="0-based header row index inside the returned values matrix (not an Excel row number).",
1093
+ ),
1094
+ use_header_names: bool = typer.Option(
1095
+ False,
1096
+ "--use-header-names",
1097
+ help="Use header cell text as column names; blank cells become col_NNN. Default uses raw_col_NNN.",
1098
+ ),
1099
+ data_start: Optional[int] = typer.Option(
1100
+ None,
1101
+ "--data-start",
1102
+ min=0,
1103
+ help="Optional 0-based data start index inside the values matrix; defaults to header-row+1.",
1104
+ ),
1105
+ data_end: Optional[int] = typer.Option(
1106
+ None,
1107
+ "--data-end",
1108
+ min=0,
1109
+ help="Optional 0-based exclusive data end index inside the values matrix; defaults to end.",
1110
+ ),
1111
+ if_exists: str = typer.Option("error", "--if-exists", help="Existing table policy: error or adopt."),
1112
+ adopt_existing: bool = typer.Option(False, "--adopt-existing", help="Alias for --if-exists adopt."),
1113
+ verify: bool = typer.Option(False, "--verify", help="Verify PG registry state after create/adopt."),
1114
+ output: Optional[str] = output_option(),
1115
+ verbose: Optional[bool] = verbose_option(),
1116
+ timeout: Optional[float] = timeout_option(),
1117
+ ) -> None:
1118
+ try:
1119
+ policy = "adopt" if adopt_existing else if_exists.strip().lower()
1120
+ if policy not in {"error", "adopt"}:
1121
+ raise UsageError("--if-exists must be 'error' or 'adopt'.")
1122
+ state = _state(
1123
+ ctx,
1124
+ doc_id=doc_id,
1125
+ url=url,
1126
+ uri=uri,
1127
+ output=output,
1128
+ verbose=verbose,
1129
+ timeout=timeout,
1130
+ )
1131
+ target_workbook = resolve_target(state)
1132
+ source_workbook = _resolve_source_workbook_target(
1133
+ source_doc_id=source_doc_id,
1134
+ source_url=source_url,
1135
+ worksheet_name=worksheet_name,
1136
+ )
1137
+ client = MaybeAIClient(state)
1138
+ read_endpoint = "/api/v1/excel/read_sheet"
1139
+ read_payload = {
1140
+ "uri": source_workbook.uri,
1141
+ "worksheet_name": worksheet_name,
1142
+ "range_address": cell_range,
1143
+ "value_render_option": "FORMATTED_VALUE",
1144
+ }
1145
+ read_response = client.post_json(read_endpoint, read_payload)
1146
+ values = _values_matrix_from_read_response(read_response)
1147
+ loaded_columns, loaded_rows, surface_summary = _surface_from_values_matrix(
1148
+ values,
1149
+ header_row=header_row,
1150
+ use_header_names=use_header_names,
1151
+ data_start=data_start,
1152
+ data_end=data_end,
1153
+ )
1154
+ create_endpoint = "/api/v1/excel/db_table/create"
1155
+ create_payload = {
1156
+ "document_id": target_workbook.document_id,
1157
+ "uri": target_workbook.uri,
1158
+ "name": name,
1159
+ "columns": loaded_columns,
1160
+ "rows": loaded_rows,
1161
+ "if_exists": policy,
1162
+ }
1163
+ try:
1164
+ response = client.post_json(create_endpoint, create_payload)
1165
+ except APIRequestError as error:
1166
+ response = (
1167
+ _adopt_existing_create_response(error, name=name, columns=loaded_columns)
1168
+ if policy == "adopt"
1169
+ else None
1170
+ )
1171
+ if response is None:
1172
+ raise
1173
+ if verify:
1174
+ resolved = _verify_created_table(client, target_workbook, name)
1175
+ response = {
1176
+ **response,
1177
+ "verified": True,
1178
+ "verified_gid": resolved.gid,
1179
+ "verified_engine": resolved.engine,
1180
+ }
1181
+ context = {
1182
+ "source_command": "db-table.create-from-range",
1183
+ "read_endpoint": read_endpoint,
1184
+ "create_endpoint": create_endpoint,
1185
+ "source_document_id": source_workbook.document_id,
1186
+ "source_worksheet_name": worksheet_name,
1187
+ "source_range": cell_range,
1188
+ **surface_summary,
1189
+ }
1190
+ render_output(
1191
+ build_command_output(
1192
+ endpoint=f"{read_endpoint} + {create_endpoint}",
1193
+ target=_created_table_target(target_workbook, response),
1194
+ result=response,
1195
+ context=context,
1196
+ metadata=get_endpoint_metadata("db-table.create-from-range") if state.verbose else None,
1197
+ ),
1198
+ state.output,
1199
+ )
1200
+ except Exception as error: # pragma: no cover - CLI boundary
1201
+ handle_cli_error(error)
1202
+
1203
+
888
1204
  @app.command(
889
1205
  "update",
890
1206
  help="Planned: update PG/SheetTable-backed rows from JSON row data.",
@@ -454,6 +454,16 @@ def copy(
454
454
  gid: Optional[int] = typer.Option(None, "--gid", help="Worksheet gid."),
455
455
  worksheet_name: Optional[str] = typer.Option(None, "--worksheet-name", help="Worksheet name to copy."),
456
456
  new_name: str = typer.Option(..., "--new-name", help="Name for copied worksheet."),
457
+ target_doc_id: Optional[str] = typer.Option(
458
+ None,
459
+ "--target-doc-id",
460
+ help="Optional target document ID. Cross-document copy is currently unsupported.",
461
+ ),
462
+ target_url: Optional[str] = typer.Option(
463
+ None,
464
+ "--target-url",
465
+ help="Optional target workbook URL. Cross-document copy is currently unsupported.",
466
+ ),
457
467
  dry_run: bool = typer.Option(False, "--dry-run", help="Show the request without sending it."),
458
468
  output: Optional[str] = output_option(),
459
469
  verbose: Optional[bool] = verbose_option(),
@@ -467,6 +477,8 @@ def copy(
467
477
  gid=gid,
468
478
  worksheet_name=worksheet_name,
469
479
  new_name=new_name,
480
+ target_doc_id=target_doc_id,
481
+ target_url=target_url,
470
482
  dry_run=dry_run,
471
483
  output=output,
472
484
  verbose=verbose,
@@ -6,8 +6,10 @@ from typing import Optional
6
6
  import typer
7
7
 
8
8
  from ..client import MaybeAIClient
9
+ from ..errors import UsageError
9
10
  from ..formatters import handle_cli_error
10
11
  from ..options import output_option, timeout_option, verbose_option
12
+ from ..resolver import parse_document_target
11
13
  from ._phase2 import (
12
14
  command_target,
13
15
  common_options,
@@ -163,6 +165,31 @@ def delete(
163
165
  handle_cli_error(error)
164
166
 
165
167
 
168
+ def _reject_cross_document_worksheet_copy(
169
+ *,
170
+ source_document_id: str | None,
171
+ target_doc_id: str | None,
172
+ target_url: str | None,
173
+ ) -> None:
174
+ if not target_doc_id and not target_url:
175
+ return
176
+ resolved_target_doc_id = target_doc_id
177
+ if target_url:
178
+ parsed_doc_id, _gid = parse_document_target(target_url)
179
+ resolved_target_doc_id = resolved_target_doc_id or parsed_doc_id
180
+ if not resolved_target_doc_id:
181
+ raise UsageError("--target-url must include a MaybeAI document id.")
182
+ if source_document_id and resolved_target_doc_id == source_document_id:
183
+ return
184
+ raise UsageError(
185
+ "Cross-document excel-worksheet copy is unsupported: "
186
+ "/api/v1/excel/copy_worksheet only copies within one workbook "
187
+ "(uri, sheet, new_name). Use `mbs db-table create-from-range` to "
188
+ "materialize a PG/SheetTable raw surface from a source worksheet "
189
+ "range into the target workbook."
190
+ )
191
+
192
+
166
193
  @app.command("copy")
167
194
  def copy(
168
195
  ctx: typer.Context,
@@ -172,6 +199,16 @@ def copy(
172
199
  gid: Optional[int] = typer.Option(None, "--gid", help="Worksheet gid."),
173
200
  worksheet_name: Optional[str] = typer.Option(None, "--worksheet-name", help="Worksheet name to copy."),
174
201
  new_name: str = typer.Option(..., "--new-name", help="Name for copied worksheet."),
202
+ target_doc_id: Optional[str] = typer.Option(
203
+ None,
204
+ "--target-doc-id",
205
+ help="Optional target document ID. Cross-document copy is currently unsupported.",
206
+ ),
207
+ target_url: Optional[str] = typer.Option(
208
+ None,
209
+ "--target-url",
210
+ help="Optional target workbook URL. Cross-document copy is currently unsupported.",
211
+ ),
175
212
  dry_run: bool = typer.Option(False, "--dry-run", help="Show the request without sending it."),
176
213
  output: Optional[str] = output_option(),
177
214
  verbose: Optional[bool] = verbose_option(),
@@ -190,6 +227,11 @@ def copy(
190
227
  timeout=timeout,
191
228
  )
192
229
  target = command_target(state)
230
+ _reject_cross_document_worksheet_copy(
231
+ source_document_id=target.document_id,
232
+ target_doc_id=target_doc_id,
233
+ target_url=target_url,
234
+ )
193
235
  endpoint = "/api/v1/excel/copy_worksheet"
194
236
  payload = {
195
237
  "uri": target.uri,
@@ -70,6 +70,14 @@ COMMAND_ENDPOINTS: dict[str, EndpointMetadata] = {
70
70
  "db-table.insert": _meta("db-table.insert", "API", "POST", "/api/v1/excel/append_rows", verify_supported=True),
71
71
  "db-table.create": _meta("db-table.create", "API", "POST", "/api/v1/excel/db_table/create", verify_supported=True, notes="Creates a PG/SheetTable-backed table from JSON row data; --if-exists adopt verifies existing PG registry state."),
72
72
  "db-table.create-from-query": _meta("db-table.create-from-query", "API", "POST", "/api/v1/excel/calc-formula + /api/v1/excel/db_table/create", verify_supported=True, notes="CLI-composed SQL materialization: calculate =SQL(...) then create a PG/SheetTable table from the tabular result."),
73
+ "db-table.create-from-range": _meta(
74
+ "db-table.create-from-range",
75
+ "API",
76
+ "POST",
77
+ "/api/v1/excel/read_sheet + /api/v1/excel/db_table/create",
78
+ verify_supported=True,
79
+ notes="CLI-composed raw-surface import: read a source worksheet range, reshape header/data rows, then create a PG/SheetTable on the target workbook.",
80
+ ),
73
81
  "db-table.update": _meta("db-table.update", "PLAN", None, None, notes="Planned native db-table update route; CLI help documents keyed JSON row patches."),
74
82
  "db-table.delete": _meta("db-table.delete", "PLAN", None, None, destructive=True, confirmation_required=True, notes="Planned native db-table delete route."),
75
83
  "worksheet.list": _meta("worksheet.list", "NOW", "POST", "/api/v1/excel/list_worksheets"),
@@ -100,7 +108,14 @@ COMMAND_ENDPOINTS: dict[str, EndpointMetadata] = {
100
108
  dry_run_supported=True,
101
109
  confirmation_required=True,
102
110
  ),
103
- "worksheet.copy": _meta("worksheet.copy", "API", "POST", "/api/v1/excel/copy_worksheet", dry_run_supported=True),
111
+ "worksheet.copy": _meta(
112
+ "worksheet.copy",
113
+ "API",
114
+ "POST",
115
+ "/api/v1/excel/copy_worksheet",
116
+ dry_run_supported=True,
117
+ notes="Same-document copy only. --target-doc-id/--target-url fail fast; use db-table create-from-range for cross-document raw surfaces.",
118
+ ),
104
119
  "worksheet.move": _meta("worksheet.move", "API", "POST", "/api/v1/excel/move_worksheet", dry_run_supported=True),
105
120
  "row.insert": _meta("row.insert", "API", "POST", "/api/v1/excel/insert_rows", dry_run_supported=True),
106
121
  "row.delete": _meta(
@@ -90,6 +90,7 @@ class EndpointRegistryTests(unittest.TestCase):
90
90
  "db-table.insert": ("API", "POST", "/api/v1/excel/append_rows"),
91
91
  "db-table.create": ("API", "POST", "/api/v1/excel/db_table/create"),
92
92
  "db-table.create-from-query": ("API", "POST", "/api/v1/excel/calc-formula + /api/v1/excel/db_table/create"),
93
+ "db-table.create-from-range": ("API", "POST", "/api/v1/excel/read_sheet + /api/v1/excel/db_table/create"),
93
94
  "db-table.update": ("PLAN", None, None),
94
95
  "db-table.delete": ("PLAN", None, None),
95
96
  }
@@ -125,6 +125,53 @@ class Phase2CLITests(unittest.TestCase):
125
125
  self.assertEqual(move_payload["sheet"], "Copy")
126
126
  self.assertEqual(move_payload["index"], 1)
127
127
 
128
+ def test_excel_worksheet_copy_rejects_cross_document_target(self) -> None:
129
+ with patch("maybeai_sheet.commands.worksheet.MaybeAIClient.post_json") as post_json:
130
+ result = self.runner.invoke(
131
+ app,
132
+ [
133
+ "excel-worksheet",
134
+ "copy",
135
+ "--doc-id",
136
+ "source123",
137
+ "--worksheet-name",
138
+ "1店",
139
+ "--target-doc-id",
140
+ "target123",
141
+ "--new-name",
142
+ "R_OrderLines_Store1",
143
+ ],
144
+ env=self.env,
145
+ )
146
+
147
+ self.assertNotEqual(result.exit_code, 0)
148
+ self.assertIn("Cross-document excel-worksheet copy is unsupported", result.output)
149
+ self.assertIn("db-table create-from-range", result.output)
150
+ post_json.assert_not_called()
151
+
152
+ def test_excel_worksheet_copy_allows_same_document_target_doc_id(self) -> None:
153
+ with patch("maybeai_sheet.commands.worksheet.MaybeAIClient.post_json") as post_json:
154
+ post_json.return_value = {"success": True}
155
+ result = self.runner.invoke(
156
+ app,
157
+ [
158
+ "excel-worksheet",
159
+ "copy",
160
+ "--doc-id",
161
+ "abc123",
162
+ "--worksheet-name",
163
+ "Source",
164
+ "--target-doc-id",
165
+ "abc123",
166
+ "--new-name",
167
+ "Copy",
168
+ ],
169
+ env=self.env,
170
+ )
171
+
172
+ self.assertEqual(result.exit_code, 0, result.output)
173
+ self.assertEqual(post_json.call_args.args[0], "/api/v1/excel/copy_worksheet")
174
+
128
175
  def test_row_commands_use_verified_endpoints(self) -> None:
129
176
  with patch("maybeai_sheet.commands.row.MaybeAIClient.post_json") as post_json:
130
177
  post_json.return_value = {"success": True}
@@ -15,6 +15,7 @@ if str(SRC) not in sys.path:
15
15
  sys.path.insert(0, str(SRC))
16
16
 
17
17
  from maybeai_sheet.cli import app
18
+ from maybeai_sheet.commands.db_table import _surface_from_values_matrix
18
19
  from maybeai_sheet.errors import APIRequestError, UsageError
19
20
  from maybeai_sheet.protocol import cli_group_for_model_label
20
21
  from maybeai_sheet.table_resolver import (
@@ -1135,6 +1136,187 @@ class TwoEngineCLITests(unittest.TestCase):
1135
1136
  self.assertEqual(trace["error"]["status_code"], 409)
1136
1137
  self.assertEqual(trace["error"]["body"], {"detail": "cannot persist formula in A1"})
1137
1138
 
1139
+ def test_surface_from_values_matrix_header_names_and_blank_drop(self) -> None:
1140
+ values = [
1141
+ ["Merged Title", "", ""],
1142
+ ["order_id", "amount", ""],
1143
+ ["A1", 10, ""],
1144
+ ["", "", ""],
1145
+ ["A2", 20, "x"],
1146
+ ]
1147
+ columns, rows, summary = _surface_from_values_matrix(
1148
+ values,
1149
+ header_row=1,
1150
+ use_header_names=True,
1151
+ )
1152
+ self.assertEqual([column["name"] for column in columns], ["order_id", "amount", "col_003"])
1153
+ self.assertEqual(rows, [{"order_id": "A1", "amount": 10, "col_003": ""}, {"order_id": "A2", "amount": 20, "col_003": "x"}])
1154
+ self.assertEqual(summary["rows_dropped_blank"], 1)
1155
+ self.assertEqual(summary["data_start"], 2)
1156
+
1157
+ positional_columns, positional_rows, _summary = _surface_from_values_matrix(
1158
+ values,
1159
+ header_row=1,
1160
+ use_header_names=False,
1161
+ )
1162
+ self.assertEqual(
1163
+ [column["name"] for column in positional_columns],
1164
+ ["raw_col_001", "raw_col_002", "raw_col_003"],
1165
+ )
1166
+ self.assertEqual(positional_rows[0]["raw_col_001"], "A1")
1167
+
1168
+ def test_db_table_create_from_range_reads_source_and_creates_on_target(self) -> None:
1169
+ with patch("maybeai_sheet.commands.db_table.MaybeAIClient.post_json") as post_json:
1170
+ post_json.side_effect = [
1171
+ {
1172
+ "success": True,
1173
+ "values": [
1174
+ ["order_id", "amount", ""],
1175
+ ["A1", 10, ""],
1176
+ ["", "", ""],
1177
+ ["A2", 20, "note"],
1178
+ ],
1179
+ },
1180
+ {
1181
+ "success": True,
1182
+ "document_id": "target123",
1183
+ "worksheet_name": "R_OrderLines_Store1",
1184
+ "gid": 11,
1185
+ "engine": "pg",
1186
+ "created_rows": 2,
1187
+ "spreadsheet_url": "https://www.maybe.ai/docs/spreadsheets/d/target123?gid=11",
1188
+ },
1189
+ {
1190
+ "success": True,
1191
+ "worksheets": [
1192
+ {
1193
+ "gid": 11,
1194
+ "worksheet_name": "R_OrderLines_Store1",
1195
+ "data_engine": "pg",
1196
+ }
1197
+ ],
1198
+ },
1199
+ ]
1200
+ result = self.runner.invoke(
1201
+ app,
1202
+ [
1203
+ "db-table",
1204
+ "create-from-range",
1205
+ "--doc-id",
1206
+ "target123",
1207
+ "--name",
1208
+ "R_OrderLines_Store1",
1209
+ "--source-doc-id",
1210
+ "source123",
1211
+ "--worksheet-name",
1212
+ "1店",
1213
+ "--range",
1214
+ "A2:C5",
1215
+ "--header-row",
1216
+ "0",
1217
+ "--use-header-names",
1218
+ "--if-exists",
1219
+ "adopt",
1220
+ "--verify",
1221
+ "--output",
1222
+ "json",
1223
+ ],
1224
+ env=self.env,
1225
+ )
1226
+
1227
+ self.assertEqual(result.exit_code, 0, result.output)
1228
+ self.assertEqual(post_json.call_args_list[0].args[0], "/api/v1/excel/read_sheet")
1229
+ read_payload = post_json.call_args_list[0].args[1]
1230
+ self.assertEqual(read_payload["uri"], "https://www.maybe.ai/docs/spreadsheets/d/source123")
1231
+ self.assertEqual(read_payload["worksheet_name"], "1店")
1232
+ self.assertEqual(read_payload["range_address"], "A2:C5")
1233
+ self.assertEqual(post_json.call_args_list[1].args[0], "/api/v1/excel/db_table/create")
1234
+ create_payload = post_json.call_args_list[1].args[1]
1235
+ self.assertEqual(create_payload["document_id"], "target123")
1236
+ self.assertEqual(create_payload["uri"], "https://www.maybe.ai/docs/spreadsheets/d/target123")
1237
+ self.assertEqual(create_payload["name"], "R_OrderLines_Store1")
1238
+ self.assertEqual(create_payload["if_exists"], "adopt")
1239
+ self.assertEqual(
1240
+ create_payload["columns"],
1241
+ [
1242
+ {"name": "order_id", "logical_type": "text", "index": 0},
1243
+ {"name": "amount", "logical_type": "text", "index": 1},
1244
+ {"name": "col_003", "logical_type": "text", "index": 2},
1245
+ ],
1246
+ )
1247
+ self.assertEqual(
1248
+ create_payload["rows"],
1249
+ [
1250
+ {"order_id": "A1", "amount": 10, "col_003": ""},
1251
+ {"order_id": "A2", "amount": 20, "col_003": "note"},
1252
+ ],
1253
+ )
1254
+ data = json.loads(result.output)
1255
+ self.assertEqual(data["endpoint"], "/api/v1/excel/read_sheet + /api/v1/excel/db_table/create")
1256
+ self.assertEqual(data["target"]["document_id"], "target123")
1257
+ self.assertEqual(data["context"]["source_document_id"], "source123")
1258
+ self.assertEqual(data["context"]["source_worksheet_name"], "1店")
1259
+ self.assertEqual(data["context"]["source_range"], "A2:C5")
1260
+ self.assertEqual(data["context"]["header_row"], 0)
1261
+ self.assertTrue(data["context"]["use_header_names"])
1262
+ self.assertEqual(data["context"]["row_count"], 2)
1263
+ self.assertEqual(data["context"]["rows_dropped_blank"], 1)
1264
+ self.assertTrue(data["result"]["verified"])
1265
+
1266
+ def test_db_table_create_from_range_uses_positional_columns_by_default(self) -> None:
1267
+ with patch("maybeai_sheet.commands.db_table.MaybeAIClient.post_json") as post_json:
1268
+ post_json.side_effect = [
1269
+ {"success": True, "values": [["h1", "h2"], ["v1", "v2"]]},
1270
+ {
1271
+ "success": True,
1272
+ "document_id": "target123",
1273
+ "worksheet_name": "R_Positional",
1274
+ "gid": 12,
1275
+ "engine": "pg",
1276
+ "created_rows": 1,
1277
+ },
1278
+ ]
1279
+ result = self.runner.invoke(
1280
+ app,
1281
+ [
1282
+ "db-table",
1283
+ "create-from-range",
1284
+ "--doc-id",
1285
+ "target123",
1286
+ "--name",
1287
+ "R_Positional",
1288
+ "--source-doc-id",
1289
+ "source123",
1290
+ "--worksheet-name",
1291
+ "Sheet1",
1292
+ "--range",
1293
+ "A1:B2",
1294
+ "--output",
1295
+ "json",
1296
+ ],
1297
+ env=self.env,
1298
+ )
1299
+
1300
+ self.assertEqual(result.exit_code, 0, result.output)
1301
+ create_payload = post_json.call_args_list[1].args[1]
1302
+ self.assertEqual(
1303
+ [column["name"] for column in create_payload["columns"]],
1304
+ ["raw_col_001", "raw_col_002"],
1305
+ )
1306
+ self.assertEqual(create_payload["rows"], [{"raw_col_001": "v1", "raw_col_002": "v2"}])
1307
+ data = json.loads(result.output)
1308
+ self.assertFalse(data["context"]["use_header_names"])
1309
+
1310
+ def test_db_table_create_from_range_help_lists_options(self) -> None:
1311
+ result = self.runner.invoke(app, ["db-table", "create-from-range", "--help"], env=self.env)
1312
+ self.assertEqual(result.exit_code, 0, result.output)
1313
+ self.assertIn("--source-doc-id", result.output)
1314
+ self.assertIn("--header-row", result.output)
1315
+ self.assertIn("--use-header-names", result.output)
1316
+ self.assertIn("--range", result.output)
1317
+ self.assertIn("--if-exists", result.output)
1318
+ self.assertIn("--verify", result.output)
1319
+
1138
1320
  def test_db_table_create_adopts_existing_pg_table_with_verify(self) -> None:
1139
1321
  with tempfile.NamedTemporaryFile(mode="w+", suffix=".json") as rows_file, tempfile.NamedTemporaryFile(mode="w+", suffix=".json") as columns_file:
1140
1322
  json.dump([], rows_file)