deepcell-cli 0.6.1__py3-none-any.whl

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 (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,529 @@
1
+ """``deepcell doc`` — read and edit a `<Document>` prose section.
2
+
3
+ Mirrors ``backend/jingwei_api/routers/document.py`` 1:1. Every subcommand
4
+ answers from the server-built RenderPlan, so what the CLI prints is exactly
5
+ what the viewer renders — a reference is resolved once, server-side, and never
6
+ re-resolved per surface.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import click
12
+
13
+ from deepcell_cli.commands.defs import _apply
14
+ from deepcell_cli.commands._write_opts import WriteCommand
15
+ from deepcell_cli.context import Ctx, pass_ctx
16
+ from deepcell_cli.output import echo_info, echo_success, output, print_plain
17
+
18
+
19
+ @click.group()
20
+ def doc() -> None:
21
+ """Read and edit the prose documents in a .deepcell file."""
22
+
23
+
24
+ def _body(ctx: Ctx, filename: str, **extra) -> dict:
25
+ body: dict = {
26
+ "workspace_slug": ctx.require_workspace(),
27
+ "source_filename": filename,
28
+ }
29
+ body.update({key: value for key, value in extra.items() if value is not None})
30
+ return body
31
+
32
+
33
+ @doc.command("list")
34
+ @click.argument("filename")
35
+ @pass_ctx
36
+ def list_docs(ctx: Ctx, filename: str) -> None:
37
+ """List every <Document> in the file."""
38
+ data = ctx.client.post("/document/list", json=_body(ctx, filename))
39
+ documents = data.get("documents", [])
40
+ if ctx.fmt != "plain":
41
+ output(data, ctx.fmt)
42
+ return
43
+ if not documents:
44
+ echo_info("This file has no <Document> section.")
45
+ return
46
+ for document in documents:
47
+ print_plain(
48
+ f"{document['doc_id']} {document.get('name') or ''}"
49
+ f" ({document.get('block_count', 0)} blocks,"
50
+ f" {document.get('link_count', 0)} links,"
51
+ f" {document.get('warning_count', 0)} warnings)"
52
+ )
53
+
54
+
55
+ @doc.command("show")
56
+ @click.argument("filename")
57
+ @click.option("--doc", "doc_id", default=None, help="Document ID (default: the first).")
58
+ @click.option("--scenario", "scenario_id", default=None, help="Scenario to resolve values under.")
59
+ @click.option(
60
+ # NOT `--format`: that is a ROOT option (`-f json|table|plain`), parsed
61
+ # before the subcommand by `FlexibleGroup._GLOBAL_VALUE_OPTS`. A per-command
62
+ # `--format` with a different vocabulary both shadows it and can be taken
63
+ # for the global while the subcommand is still being located.
64
+ # `test_surface_walker.py::test_globals_are_the_root_group_s_options` pins
65
+ # this for every command.
66
+ "--as", "rendering", type=click.Choice(["text", "markdown"]),
67
+ default="text",
68
+ help="markdown keeps the [[deepcell:...]] source; text shows resolved values.",
69
+ )
70
+ @click.option(
71
+ "--with-ids", is_flag=True, default=False,
72
+ help="Prefix each block with the id it can be edited by.",
73
+ )
74
+ @pass_ctx
75
+ def show_doc(ctx: Ctx, filename: str, doc_id: str | None, scenario_id: str | None,
76
+ rendering: str, with_ids: bool) -> None:
77
+ """Print one document. `text` shows what a reader sees; `markdown` the source.
78
+
79
+ JSON comes from the root flag, `deepcell -f json doc show …`, like every
80
+ other command — this option chooses which *prose* to print, not which
81
+ envelope to print it in.
82
+ """
83
+ data = ctx.client.post(
84
+ "/document/show",
85
+ json=_body(ctx, filename, doc_id=doc_id, scenario_id=scenario_id),
86
+ )
87
+ if ctx.fmt != "plain":
88
+ output(data, ctx.fmt)
89
+ return
90
+ key = "markdown" if rendering == "markdown" else "text"
91
+ for block in data.get("blocks", []):
92
+ if with_ids:
93
+ # `(unstamped)` rather than a blank: a block with no id is exactly
94
+ # the one no per-block command can address, and saying so beats
95
+ # printing a gap the reader has to interpret.
96
+ print_plain(f"[{block.get('block_id') or '(unstamped)'}]")
97
+ print_plain(block.get(key, ""))
98
+ print_plain("")
99
+ for warning in data.get("warnings", []):
100
+ echo_info(f"{warning['code']}: {warning['message']}")
101
+
102
+
103
+ @doc.command("blocks")
104
+ @click.argument("filename")
105
+ @click.option("--doc", "doc_id", default=None, help="Document ID (default: the first).")
106
+ @pass_ctx
107
+ def doc_blocks(ctx: Ctx, filename: str, doc_id: str | None) -> None:
108
+ """List every block and the id it can be edited by.
109
+
110
+ `outline` shows headings and `show` shows prose; this is the one that
111
+ answers "what can I edit, and what do I call it?". Every per-block command
112
+ below takes an id from here.
113
+
114
+ A block printed as `(unstamped)` has no id yet and cannot be addressed —
115
+ run `deepcell doc stamp-ids` to mint one for every such block.
116
+ """
117
+ data = ctx.client.post("/document/blocks", json=_body(ctx, filename, doc_id=doc_id))
118
+ if ctx.fmt != "plain":
119
+ output(data, ctx.fmt)
120
+ return
121
+ blocks = data.get("blocks", [])
122
+ if not blocks:
123
+ echo_info("This document has no blocks.")
124
+ return
125
+ for block in blocks:
126
+ block_id = block.get("block_id") or "(unstamped)"
127
+ print_plain(f"{block_id:14} {block.get('kind', ''):8} {block.get('preview', '')}")
128
+ unstamped = data.get("unstamped") or 0
129
+ if unstamped:
130
+ echo_info(
131
+ f"{unstamped} block(s) have no id and cannot be edited individually. "
132
+ f"Run: deepcell doc stamp-ids {filename}"
133
+ )
134
+
135
+
136
+ @doc.command("outline")
137
+ @click.argument("filename")
138
+ @click.option("--doc", "doc_id", default=None, help="Document ID (default: the first).")
139
+ @pass_ctx
140
+ def outline_doc(ctx: Ctx, filename: str, doc_id: str | None) -> None:
141
+ """Print the headings and the anchor each one is addressable by."""
142
+ data = ctx.client.post(
143
+ "/document/outline", json=_body(ctx, filename, doc_id=doc_id)
144
+ )
145
+ if ctx.fmt != "plain":
146
+ output(data, ctx.fmt)
147
+ return
148
+ for heading in data.get("headings", []):
149
+ indent = " " * max(0, int(heading.get("level", 1)) - 1)
150
+ print_plain(f"{indent}{heading.get('text', '')} #{heading.get('anchor')}")
151
+
152
+
153
+ @doc.command("links")
154
+ @click.argument("filename")
155
+ @click.option("--doc", "doc_id", default=None, help="Restrict to one document.")
156
+ @click.option("--unresolved", "unresolved_only", is_flag=True, default=False, help="Only references that did not resolve.")
157
+ @pass_ctx
158
+ def doc_links(ctx: Ctx, filename: str, doc_id: str | None, unresolved_only: bool) -> None:
159
+ """List every deepcell: reference, and whether it resolved."""
160
+ data = ctx.client.post(
161
+ "/document/links",
162
+ json=_body(ctx, filename, doc_id=doc_id, unresolved_only=unresolved_only or None),
163
+ )
164
+ if ctx.fmt != "plain":
165
+ output(data, ctx.fmt)
166
+ return
167
+ links = data.get("links", [])
168
+ if not links:
169
+ echo_info("No unresolved references." if unresolved_only else "No references.")
170
+ return
171
+ for link in links:
172
+ mark = "ok " if link.get("resolved") else "MISS"
173
+ anchor = f"#{link['anchor']}" if link.get("anchor") else ""
174
+ print_plain(f"{mark} {link['doc_id']}{anchor} {link['ref']} -> {link.get('text','')}")
175
+ if link.get("hint"):
176
+ print_plain(f" hint: {link['hint']}")
177
+
178
+
179
+ @doc.command("lint")
180
+ @click.argument("filename")
181
+ @click.option("--doc", "doc_id", default=None, help="Restrict to one document.")
182
+ @click.option(
183
+ "--strict", is_flag=True, default=False,
184
+ help="Also flag numerals that match a modelled value but are typed, not linked.",
185
+ )
186
+ @pass_ctx
187
+ def doc_lint(ctx: Ctx, filename: str, doc_id: str | None, strict: bool) -> None:
188
+ """Check a document's references. Exits non-zero on any error finding.
189
+
190
+ `--strict` adds `unlinked_numeral`, which is opt-in because the general
191
+ form is not specifiable — the same number matches base, bull and bear, and
192
+ nothing separates a modelled value from a year or a headcount. The narrow
193
+ form it does check: a numeral matching the resolved default-scenario value
194
+ of a cell whose item label appears within ten words.
195
+ """
196
+ data = ctx.client.post(
197
+ "/document/lint",
198
+ json=_body(ctx, filename, doc_id=doc_id, strict=strict or None),
199
+ )
200
+ if ctx.fmt != "plain":
201
+ output(data, ctx.fmt)
202
+ else:
203
+ findings = data.get("findings", [])
204
+ if not findings:
205
+ echo_info("No findings.")
206
+ for finding in findings:
207
+ anchor = f"#{finding['anchor']}" if finding.get("anchor") else ""
208
+ print_plain(
209
+ f"[{finding.get('severity', 'warn')}] {finding.get('code')} "
210
+ f"{finding.get('doc_id')}{anchor}: {finding.get('message')}"
211
+ )
212
+ if data.get("error_count"):
213
+ raise SystemExit(1)
214
+
215
+
216
+ @doc.command("backlinks")
217
+ @click.argument("filename")
218
+ @click.option("--target", required=True, help="Reference to invert, e.g. 'claim/t_hold'.")
219
+ @pass_ctx
220
+ def doc_backlinks(ctx: Ctx, filename: str, target: str) -> None:
221
+ """Show what cites a reference: prose, slides and reasoning, this file only.
222
+
223
+ --target is matched exactly, on the canonical form of the reference. The
224
+ kind is part of that form: `item/Revenue` finds citations of the row and
225
+ never a `cell/Revenue[FY2025]#projected` citation of one of its cells, so
226
+ ask once per address a reader may have written. A cell reference is
227
+ canonically status-qualified, so a bare one finds nothing — on zero
228
+ matches the suffixed variants in the index are printed as suggestions.
229
+
230
+ Use this in the cross-surface reassessment described by
231
+ `deepcell guide revise/premise-change`; `deepcell impact show` inverts
232
+ every changed address at once.
233
+ """
234
+ data = ctx.client.post(
235
+ "/document/backlinks", json=_body(ctx, filename, target=target)
236
+ )
237
+ if ctx.fmt != "plain":
238
+ output(data, ctx.fmt)
239
+ return
240
+ resolved = data.get("target", target)
241
+ coverage = ", ".join(data.get("coverage") or ()) or "doc"
242
+ backlinks = data.get("backlinks", [])
243
+ if not backlinks:
244
+ # A zero is stated, never hidden — and it is stated together with what
245
+ # was searched. "Nothing cites this" and "nothing I looked at cites
246
+ # this" are different answers, and only one of them is honest here.
247
+ echo_info(f"Nothing in this file cites {resolved} (searched: {coverage}).")
248
+ # A third answer the zero used to swallow: the index holds the same
249
+ # reference with a status suffix. Matching is exact, and a cell
250
+ # reference is canonically status-qualified, so a reader inverting
251
+ # "what cites Revenue in FY2025" gets zero while the suffixed variant
252
+ # sits there. Suggested, never substituted — a status-qualified
253
+ # reference is a different address, and only the caller can say the
254
+ # two mean the same cell.
255
+ for near in data.get("near_targets") or ():
256
+ echo_info(f" Indexed with a status: {near}")
257
+ return
258
+ print_plain(f"{resolved} — cited in this file by ({coverage}):")
259
+ for link in backlinks:
260
+ kind = link.get("source_kind") or "doc"
261
+ where = link.get("source_id") or link.get("source_doc_id") or "?"
262
+ anchor = f"#{link['source_anchor']}" if link.get("source_anchor") else ""
263
+ mode = f" ({link['mode']})" if link.get("mode") else ""
264
+ print_plain(f" {kind}: {where}{anchor}{mode}")
265
+
266
+
267
+ @doc.command("set-body", cls=WriteCommand)
268
+ @click.argument("filename")
269
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
270
+ @click.option("--body-file", type=click.Path(exists=True, dir_okay=False), required=True,
271
+ help="File holding the new body.")
272
+ @click.option("--notation", type=click.Choice(["markdown", "text"]), default=None,
273
+ help="What the body IS. Omitted keeps whatever the document "
274
+ "already declared, so editing text never retypes it.")
275
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
276
+ help="Commit message recording why.")
277
+ @click.option("--revision", default=None,
278
+ help="Compare-and-swap token from `cat` (refuses if the file moved).")
279
+ @pass_ctx
280
+ def set_body(ctx: Ctx, filename: str, doc_id: str, body_file: str,
281
+ notation: str | None, rationale: str | None,
282
+ revision: str | None) -> None:
283
+ """Replace a document's whole body.
284
+
285
+ A body is ONE string, so two agents editing one document is a lost update
286
+ rather than a merge — pass `--revision` (from `deepcell cat`) and a
287
+ concurrent write is refused with the current revision instead of silently
288
+ winning.
289
+
290
+ `--notation` says what the bytes are: `markdown` or `text`. Omit it and the
291
+ document keeps whatever it already declared, so replacing the text of a
292
+ markdown memo cannot silently demote it to plain text. To change only the
293
+ notation, use `doc set-notation`, which leaves the bytes alone.
294
+ """
295
+ with open(body_file, "r", encoding="utf-8") as handle:
296
+ body = handle.read()
297
+ op = {"kind": "set_document_body", "docId": doc_id, "body": body}
298
+ if notation:
299
+ op["notation"] = notation
300
+ _apply_doc_op(ctx, filename, op, rationale, revision)
301
+ echo_success(f"Replaced the body of '{doc_id}'.")
302
+
303
+
304
+ @doc.command("patch-body", cls=WriteCommand)
305
+ @click.argument("filename")
306
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
307
+ @click.option("--anchor", required=True,
308
+ help="Explicit {#id} anchor of the section to replace.")
309
+ @click.option("--markdown", required=True, help="Replacement markdown for that section.")
310
+ @click.option("--notation", type=click.Choice(["markdown", "text"]), default=None,
311
+ help="What the body IS. Omitted keeps whatever the document "
312
+ "already declared, so editing text never retypes it.")
313
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
314
+ help="Commit message recording why.")
315
+ @click.option("--revision", default=None, help="Compare-and-swap token from `cat`.")
316
+ @pass_ctx
317
+ def patch_body(ctx: Ctx, filename: str, doc_id: str, anchor: str, markdown: str,
318
+ notation: str | None, rationale: str | None,
319
+ revision: str | None) -> None:
320
+ """Replace one anchored section of a document.
321
+
322
+ The anchor must be an explicit `{#id}`, not a heading slug: a slug is
323
+ derived from the heading text, so a retitle between read and write would
324
+ relocate the patch. Run `deepcell doc outline` to see which anchors are
325
+ explicit.
326
+ """
327
+ op = {
328
+ "kind": "patch_document_body",
329
+ "docId": doc_id,
330
+ "anchor": anchor,
331
+ "markdown": markdown,
332
+ }
333
+ if notation:
334
+ op["notation"] = notation
335
+ _apply_doc_op(ctx, filename, op, rationale, revision)
336
+ echo_success(f"Patched '{doc_id}' at #{anchor}.")
337
+
338
+
339
+ @doc.command("set-notation", cls=WriteCommand)
340
+ @click.argument("filename")
341
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
342
+ @click.option("--notation", type=click.Choice(["markdown", "text"]), required=True,
343
+ help="What the body IS.")
344
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
345
+ help="Commit message recording why.")
346
+ @click.option("--revision", default=None, help="Compare-and-swap token from `cat`.")
347
+ @pass_ctx
348
+ def set_notation(ctx: Ctx, filename: str, doc_id: str, notation: str,
349
+ rationale: str | None, revision: str | None) -> None:
350
+ """Say what a document's body IS, without touching a byte of it.
351
+
352
+ Use this to declare an existing plain-text body as markdown, or to demote
353
+ one back. Because it never re-sends the text, the change review headlines
354
+ the notation rather than showing the whole body as rewritten.
355
+
356
+ Notation is never sniffed: a body full of `**bold**` stays plain text until
357
+ something says otherwise. A body that looks like markdown and a body that
358
+ merely contains an asterisk are indistinguishable, and guessing is how a
359
+ reader ends up disagreeing with the file.
360
+ """
361
+ _apply_doc_op(
362
+ ctx, filename,
363
+ {"kind": "set_body_notation", "docId": doc_id, "notation": notation},
364
+ rationale, revision,
365
+ )
366
+ echo_success(f"'{doc_id}' now declares its body as {notation}.")
367
+
368
+
369
+ @doc.command("stamp-ids", cls=WriteCommand)
370
+ @click.argument("filename")
371
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
372
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
373
+ help="Commit message recording why.")
374
+ @click.option("--revision", default=None, help="Compare-and-swap token from `cat`.")
375
+ @pass_ctx
376
+ def stamp_ids(ctx: Ctx, filename: str, doc_id: str, rationale: str | None,
377
+ revision: str | None) -> None:
378
+ """Give every block without an id a stable `{#id}`, so it can be edited.
379
+
380
+ Idempotent, and it never touches an anchor an author wrote — `{#valuation}`
381
+ keeps meaning what they meant. Only blocks with no anchor at all get one.
382
+
383
+ This rewrites the whole body once, which is a large diff on a document that
384
+ has never been stamped and no diff at all on one that has. Every later edit
385
+ touches only the block it names.
386
+ """
387
+ _apply_doc_op(
388
+ ctx, filename,
389
+ {"kind": "stamp_document_ids", "docId": doc_id},
390
+ rationale or "Stamp block ids", revision,
391
+ )
392
+ echo_success(f"Stamped block ids in '{doc_id}'.")
393
+
394
+
395
+ @doc.command("replace-block", cls=WriteCommand)
396
+ @click.argument("filename")
397
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
398
+ @click.option("--block", "block_id", required=True,
399
+ help="Block id from `deepcell doc blocks`.")
400
+ @click.option("--markdown-file", type=click.Path(exists=True, dir_okay=False),
401
+ required=True, help="File holding the replacement markdown.")
402
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
403
+ help="Commit message recording why.")
404
+ @click.option("--revision", default=None, help="Compare-and-swap token from `cat`.")
405
+ @pass_ctx
406
+ def replace_block(ctx: Ctx, filename: str, doc_id: str, block_id: str,
407
+ markdown_file: str, rationale: str | None,
408
+ revision: str | None) -> None:
409
+ """Replace one block, addressed by its id.
410
+
411
+ The id is kept for you: a replacement that does not repeat `{#id}` has it
412
+ re-stamped, because the id is how the block is linked and bookmarked. A
413
+ replacement carrying a *different* id is refused rather than performed —
414
+ renaming strands every reference to the old one.
415
+ """
416
+ with open(markdown_file, "r", encoding="utf-8") as handle:
417
+ markdown = handle.read()
418
+ _apply_doc_op(
419
+ ctx, filename,
420
+ {"kind": "replace_document_block", "docId": doc_id, "blockId": block_id,
421
+ "markdown": markdown},
422
+ rationale, revision,
423
+ )
424
+ echo_success(f"Replaced block '{block_id}' in '{doc_id}'.")
425
+
426
+
427
+ @doc.command("insert-block", cls=WriteCommand)
428
+ @click.argument("filename")
429
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
430
+ @click.option("--after", "after_block_id", default=None,
431
+ help="Insert after this block id. Omit to insert at the top.")
432
+ @click.option("--markdown-file", type=click.Path(exists=True, dir_okay=False),
433
+ required=True, help="File holding the new block's markdown.")
434
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
435
+ help="Commit message recording why.")
436
+ @click.option("--revision", default=None, help="Compare-and-swap token from `cat`.")
437
+ @pass_ctx
438
+ def insert_block(ctx: Ctx, filename: str, doc_id: str, after_block_id: str | None,
439
+ markdown_file: str, rationale: str | None,
440
+ revision: str | None) -> None:
441
+ """Insert a new block after another one, or at the top of the document.
442
+
443
+ Position is named, never numbered: there is no `--at N`, because an index
444
+ is stale the moment anything above it moves. The new block is stamped with
445
+ an id, which is printed so you can address it next.
446
+ """
447
+ with open(markdown_file, "r", encoding="utf-8") as handle:
448
+ markdown = handle.read()
449
+ op: dict = {"kind": "insert_document_block", "docId": doc_id, "markdown": markdown}
450
+ if after_block_id:
451
+ op["afterBlockId"] = after_block_id
452
+ _apply_doc_op(ctx, filename, op, rationale, revision)
453
+ where = f"after '{after_block_id}'" if after_block_id else "at the top"
454
+ echo_success(f"Inserted a block {where} of '{doc_id}'.")
455
+
456
+
457
+ @doc.command("delete-block", cls=WriteCommand)
458
+ @click.argument("filename")
459
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
460
+ @click.option("--block", "block_id", required=True, help="Block id to delete.")
461
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
462
+ help="Commit message recording why.")
463
+ @click.option("--revision", default=None, help="Compare-and-swap token from `cat`.")
464
+ @pass_ctx
465
+ def delete_block(ctx: Ctx, filename: str, doc_id: str, block_id: str,
466
+ rationale: str | None, revision: str | None) -> None:
467
+ """Delete one block.
468
+
469
+ Refused when something still cites the block's id — a dangling
470
+ `[[deepcell:doc/…#id]]` is the defect the rename cascade exists to prevent,
471
+ so the citers are named instead and you decide what happens to them.
472
+ """
473
+ _apply_doc_op(
474
+ ctx, filename,
475
+ {"kind": "delete_document_block", "docId": doc_id, "blockId": block_id},
476
+ rationale, revision,
477
+ )
478
+ echo_success(f"Deleted block '{block_id}' from '{doc_id}'.")
479
+
480
+
481
+ @doc.command("move-block", cls=WriteCommand)
482
+ @click.argument("filename")
483
+ @click.option("--doc", "doc_id", required=True, help="Document ID.")
484
+ @click.option("--block", "block_id", required=True, help="Block id to move.")
485
+ @click.option("--after", "after_block_id", default=None,
486
+ help="Move after this block id. Omit to move to the top.")
487
+ @click.option("-m", "--message", "--rationale", "rationale", default=None,
488
+ help="Commit message recording why.")
489
+ @click.option("--revision", default=None, help="Compare-and-swap token from `cat`.")
490
+ @pass_ctx
491
+ def move_block(ctx: Ctx, filename: str, doc_id: str, block_id: str,
492
+ after_block_id: str | None, rationale: str | None,
493
+ revision: str | None) -> None:
494
+ """Move a block after another one, or to the top.
495
+
496
+ Both ends are named rather than numbered, which is the only reason a
497
+ reorder is expressible at all: with indices it would be a delete plus an
498
+ insert against numbers the delete had just invalidated.
499
+ """
500
+ op: dict = {"kind": "move_document_block", "docId": doc_id, "blockId": block_id}
501
+ if after_block_id:
502
+ op["afterBlockId"] = after_block_id
503
+ _apply_doc_op(ctx, filename, op, rationale, revision)
504
+ where = f"after '{after_block_id}'" if after_block_id else "to the top"
505
+ echo_success(f"Moved block '{block_id}' {where}.")
506
+
507
+
508
+ def _apply_doc_op(ctx: Ctx, filename: str, op: dict, rationale: str | None,
509
+ revision: str | None) -> None:
510
+ """Send one op through the shared defs-ops envelope.
511
+
512
+ Delegates rather than building the body here. Spelling it a second time is
513
+ what broke both write commands: `filename` was sent as `source_filename`
514
+ (a 422 on every call) and `expected_revision` as `revision`, which pydantic
515
+ ignored — so the compare-and-swap these commands document never armed and a
516
+ concurrent write silently won the lost update they promise to refuse.
517
+ """
518
+ _apply(
519
+ ctx,
520
+ ctx.require_workspace(),
521
+ filename,
522
+ [op],
523
+ revision=revision,
524
+ rationale=rationale,
525
+ # `doc` commands are not `_DefsCommand`, so there is no parked
526
+ # `--dry-run` to inherit; say so rather than reading another
527
+ # command's meta.
528
+ dry_run=False,
529
+ )