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,628 @@
1
+ """The CLI surface — the command tree as data, with exit semantics and examples.
2
+
3
+ One module, two readers: ``deepcell help -f json`` serves it directly (no
4
+ network — the Click tree is local, and so is everything here), and
5
+ ``scripts/gen_cli_surface.py`` imports it to emit ``docs/cli-surface.json``,
6
+ which the published doc renders. So the human page and the agent's ``help``
7
+ dump are not *kept in sync*; they are the same bytes rendered twice.
8
+ See ``docs/cli-agent-surface.md`` §3 and §9.
9
+
10
+ Three things this adds to what Click already knows:
11
+
12
+ **Exit semantics per command.** ``ref exit`` documents centrally, which
13
+ means an agent must know to go read a separate topic *before* it can interpret
14
+ a failure it has already hit. Exit 1 means five different things and the
15
+ difference is whether anything was written — so it belongs in the help of the
16
+ command that can return it. Expressed as ordered patterns rather than 131
17
+ entries: the taxonomy is genuinely per-*family*, and a per-command list would
18
+ be 131 chances to disagree with itself.
19
+
20
+ **One example per command.** Agents pattern-match on invocations far more
21
+ reliably than they parse flag tables. Examples are *derived* from the Click
22
+ signature by default and *authored* where a derived one would mislead; the
23
+ manifest marks which, so a derived example is never mistaken for a tested one.
24
+
25
+ **Typed ``see_also`` ids**, resolved by ``deepcell ref``.
26
+
27
+ ⚠ Nothing here may name a defs-op kind. ``cli/tests/test_op_reachability.py``
28
+ scans this package for op-kind strings in real code positions and would read
29
+ them as "the CLI can emit this op", silently disabling the reachability guard.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import fnmatch
35
+ import inspect
36
+ from typing import Any
37
+
38
+ import click
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Exit semantics
42
+ # ---------------------------------------------------------------------------
43
+
44
+ #: ``(glob, sense-id)``, first match wins. Sense ids are defined once, in the
45
+ #: backend's ``ref:exit`` namespace; ``test_cli_surface_codegen.py`` asserts
46
+ #: every id used here exists there, so the two halves cannot drift apart.
47
+ #:
48
+ #: Order matters: the more specific pattern must come first — ``merge *``
49
+ #: before a bare ``merge`` would swallow it, and so on.
50
+ #:
51
+ #: ⚠ These are command PATHS, so a sense that depends on a *flag* cannot be
52
+ #: expressed here at all. That constraint is why ``replace`` is a command and
53
+ #: not a mode: string replacement writes first and validates second
54
+ #: (``written-but-invalid``) while ``edit`` applies a batch row by row
55
+ #: (``partial``), and one command carrying two senses had nowhere to go in this
56
+ #: table. Splitting it made the taxonomy expressible with no schema change —
57
+ #: both are plain entries below. ``edit --replace`` survives as a deprecated
58
+ #: alias, so a caller who uses it reads ``edit``'s sense; the alias warns and
59
+ #: names the command whose exit codes actually describe it.
60
+ EXIT_SENSE_RULES: list[tuple[str, str]] = [
61
+ # Read-only checks — nothing was ever going to change.
62
+ ("describe", "read-only-check"),
63
+ ("reasoning lint", "read-only-check"),
64
+ ("reasoning-diff", "read-only-check"),
65
+ # ⚠ Must precede `defs *`: the two defs READS (#1291) write nothing, so the
66
+ # write family's "nothing-changed, re-send the whole batch" sense would
67
+ # misdescribe them.
68
+ ("defs list", "read-only-check"),
69
+ ("defs show", "read-only-check"),
70
+ # Conflict — your state is preserved.
71
+ ("pull", "conflict"),
72
+ ("merge *", "conflict"),
73
+ ("variant merge", "conflict"),
74
+ # Written, then validated: a failure means it is already on disk.
75
+ ("write", "written-but-invalid"),
76
+ ("replace", "written-but-invalid"),
77
+ ("push", "written-but-invalid"),
78
+ ("commit", "written-but-invalid"),
79
+ # ⚠ Glob-matched, first match wins — `reasoning add-*` does NOT cover
80
+ # `set-conclusion`, so without its own entry it would exit 1 with no sense
81
+ # attached. That is a silent quality loss, not a test failure.
82
+ ("reasoning set-conclusion", "written-but-invalid"),
83
+ ("reasoning add-*", "written-but-invalid"),
84
+ ("reasoning update-*", "written-but-invalid"),
85
+ ("reasoning supersede-*", "written-but-invalid"),
86
+ ("reasoning delete-*", "written-but-invalid"),
87
+ # Whole-batch atomic: nothing was applied, re-send everything.
88
+ ("defs *", "nothing-changed"),
89
+ # Partially applied — read results and errors.
90
+ ("edit", "partial"),
91
+ ("import", "partial"),
92
+ ]
93
+
94
+ #: Commands that exit 2 on an unparseable document, distinct from exit 1
95
+ #: ("parsed fine, id not found"). Everything else's exit 2 is Click's own
96
+ #: usage error, which needs no per-command note.
97
+ EXIT_TWO_ON_UNPARSEABLE = frozenset({
98
+ "claim history",
99
+ "claim variant",
100
+ "claim falsified",
101
+ "assumption impact",
102
+ "reasoning-diff",
103
+ })
104
+
105
+
106
+ def exit_sense_for(command: str) -> str | None:
107
+ """The exit-1 sense id for *command*, or ``None`` when no rule matches."""
108
+ for pattern, sense in EXIT_SENSE_RULES:
109
+ if fnmatch.fnmatch(command, pattern):
110
+ return sense
111
+ return None
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Examples and cross-references
116
+ # ---------------------------------------------------------------------------
117
+
118
+ #: Authored examples. ``docs/cli-agent-surface.md`` §3.2 asks for "one runnable
119
+ #: example per command, using a workspace file — **not a placeholder**. Agents
120
+ #: pattern-match on invocations far more reliably than they parse flag tables."
121
+ #:
122
+ #: A derived example satisfies the *shape* and not that requirement: an agent
123
+ #: reading ``--sheet <sheet_id> --member <member>`` learns nothing it could not
124
+ #: read off the flag table, so it falls back to `--help` anyway. The guide-eval
125
+ #: run measured what that costs — 87 of 161 commands rendered as placeholders,
126
+ #: and 108 of its 178 findings were some topic being blamed for not shipping
127
+ #: the example that belongs here.
128
+ #:
129
+ #: So: author one wherever an agent has to *choose* a value — a status
130
+ #: archetype, an axis, a rel, a target selector, a JSON payload. Leave it
131
+ #: derived only where the signature already says everything (``ls``, ``logout``).
132
+ #: Values are illustrative but must be **legal**: every flag exists on the
133
+ #: command and every enum value is one it accepts, pinned by
134
+ #: ``cli/tests/test_surface_examples.py`` against the live Click tree.
135
+ COMMAND_EXAMPLES: dict[str, str] = {
136
+ "doctor": "deepcell doctor -f json",
137
+ "ls": "deepcell ls",
138
+ "cat": "deepcell cat model.deepcell",
139
+ "write": "deepcell write model.deepcell --file ./model.deepcell",
140
+ "describe": "deepcell describe model.deepcell --lint",
141
+ "query": "deepcell query model.deepcell Revenue FY2026E",
142
+ "defs list": "deepcell defs list model.deepcell",
143
+ "defs show": "deepcell defs show model.deepcell Revenue",
144
+ "cell-meta": "deepcell cell-meta model.deepcell Revenue FY2026E",
145
+ "edit": "deepcell edit model.deepcell Growth_Rate FY2026E 0.12",
146
+ "replace": 'deepcell replace model.deepcell "<Old>text</Old>" "<New>text</New>"',
147
+ "grep": "deepcell grep Revenue",
148
+ # `orient/start` is the front door. It was `quick-start` for a while, on
149
+ # the strength of a description that read "Start here" — that topic was a
150
+ # finance template index and has since been deleted (`doctor.py` reached
151
+ # the same conclusion independently).
152
+ "guide": "deepcell guide orient/start",
153
+ "rules": "deepcell rules R2",
154
+ "ref": "deepcell ref lint/hardcoded_literal_in_calc",
155
+ "relationships": "deepcell relationships model.deepcell --type business",
156
+ "to-excel": "deepcell to-excel model.deepcell -o model.xlsx",
157
+ "share create": "deepcell share create model.deepcell --permission view",
158
+ "viewer": "deepcell viewer model.deepcell",
159
+ "reasoning lint": "deepcell reasoning lint model.deepcell --strict",
160
+ "reasoning graph": "deepcell reasoning graph model.deepcell --syntax mermaid",
161
+ "project use": "deepcell project use my-project",
162
+
163
+ # ---- Structure -------------------------------------------------------
164
+ "defs add-item": (
165
+ "deepcell defs add-item model.deepcell --name Revenue --label Revenue "
166
+ "--data-type monetary --scale 6 --currency USD --order-mode append"
167
+ ),
168
+ "defs update-item": (
169
+ "deepcell defs update-item model.deepcell Revenue --scale 3 --currency USD"
170
+ ),
171
+ "defs rename-item": "deepcell defs rename-item model.deepcell Revenue Total_Revenue",
172
+ "defs delete-item": "deepcell defs delete-item model.deepcell Revenue --cascade",
173
+ "defs reorder-item": "deepcell defs reorder-item model.deepcell Revenue --to-index 0",
174
+ "defs add-calc": (
175
+ "deepcell defs add-calc model.deepcell --item Revenue --calc-id calc_revenue "
176
+ "--formula 'Units_Sold[CURRENT] * Unit_Price[CURRENT]' --status projected"
177
+ ),
178
+ "defs update-calc": (
179
+ "deepcell defs update-calc model.deepcell calc_revenue "
180
+ "--formula 'Units_Sold[CURRENT] * Unit_Price[CURRENT] * 1.02'"
181
+ ),
182
+ # --archetype is what the status MEANS; omitting it leaves the meaning to
183
+ # be guessed from the id's spelling (house rule R13).
184
+ "defs add-status": (
185
+ "deepcell defs add-status model.deepcell --name Budget --ref budget "
186
+ "--archetype budget --authority derived"
187
+ ),
188
+ "defs update-status": (
189
+ "deepcell defs update-status model.deepcell budget --archetype budget --label Budget"
190
+ ),
191
+ # --state is what says whether the period is over — the question a trailing
192
+ # A/E on the id used to answer by accident.
193
+ "defs add-context": (
194
+ "deepcell defs add-context model.deepcell --name FY2025 --context FY2025 "
195
+ "--kind period --state closed --status actual"
196
+ ),
197
+ "defs add-period": (
198
+ "deepcell defs add-period model.deepcell --name FY2026 --context FY2026 "
199
+ "--state future --status projected"
200
+ ),
201
+ "defs update-context": (
202
+ "deepcell defs update-context model.deepcell FY2025 --state closed --as-of 2025-12-31"
203
+ ),
204
+ # No --label here: `add-scenario` takes the id only, and the display label
205
+ # is set afterwards with `update-scenario --label`.
206
+ "defs add-scenario": "deepcell defs add-scenario model.deepcell --name downside",
207
+ # --name changes the id every scenarioRef points at; --label is display only.
208
+ "defs update-scenario": (
209
+ "deepcell defs update-scenario model.deepcell downside --label 'Bear case'"
210
+ ),
211
+ "defs rename-scenario": "deepcell defs rename-scenario model.deepcell downside bear",
212
+ "defs apply": "deepcell defs apply model.deepcell --ops-file ops.json --dry-run",
213
+
214
+ # ---- Layout ----------------------------------------------------------
215
+ "defs add-sheet": "deepcell defs add-sheet model.deepcell --label 'Variance review' --index 1",
216
+ "defs add-block": (
217
+ "deepcell defs add-block model.deepcell --sheet variance --block-type table "
218
+ "--name 'Revenue vs budget' --item-orders Revenue,COGS --context-refs FY2025,FY2026"
219
+ ),
220
+ # --member says what kind of id --ref is: 'item' with rows, 'context' with columns.
221
+ "defs add-axis-member": (
222
+ "deepcell defs add-axis-member model.deepcell --sheet variance --block revenue_table "
223
+ "--axis rows --member item --ref Gross_Profit"
224
+ ),
225
+ "defs delete-axis-member": (
226
+ "deepcell defs delete-axis-member model.deepcell --sheet variance "
227
+ "--block revenue_table --axis rows --ref Gross_Profit"
228
+ ),
229
+ "defs reorder-axis-member": (
230
+ "deepcell defs reorder-axis-member model.deepcell --sheet variance "
231
+ "--block revenue_table --axis rows --ref Gross_Profit --to-index 0"
232
+ ),
233
+ "defs reorder-block": (
234
+ "deepcell defs reorder-block model.deepcell --sheet variance --block revenue_table --to-index 0"
235
+ ),
236
+ "defs reorder-sheets": "deepcell defs reorder-sheets model.deepcell variance --to-index 0",
237
+ "defs set-block-attrs": (
238
+ "deepcell defs set-block-attrs model.deepcell --sheet variance --block revenue_table "
239
+ "--status-refs actual,budget --status-expansion columns"
240
+ ),
241
+ "defs add-sensitivity": (
242
+ "deepcell defs add-sensitivity model.deepcell --sheet summary --name 'IRR sensitivity' "
243
+ "--spec-file sensitivity.json"
244
+ ),
245
+ # --block-id scopes the format to one block; without it the rule is global
246
+ # to the sheet and every other block inherits it too.
247
+ "defs set-format": (
248
+ "deepcell defs set-format model.deepcell --sheet-id ic --block-id deal_summary "
249
+ "--item-ref Entry_Multiple --scope item --number-format '0.0x' --bold"
250
+ ),
251
+ "defs add-format": (
252
+ "deepcell defs add-format model.deepcell fmt_statement --rule 'default:fontName=Arial'"
253
+ ),
254
+ "defs add-rule": (
255
+ "deepcell defs add-rule model.deepcell fmt_statement --target 'level:0:item' "
256
+ "--font-weight bold --number-format '#,##0'"
257
+ ),
258
+
259
+ # ---- Prose -----------------------------------------------------------
260
+ "defs add-doc": (
261
+ "deepcell defs add-doc model.deepcell --doc-id variance_note "
262
+ "--name 'July variance note' --lang en --body-file variance-note.md"
263
+ ),
264
+ "doc set-body": (
265
+ "deepcell doc set-body model.deepcell --doc variance_note "
266
+ "--body-file variance-note.md -m 'Rewrite the July variance note'"
267
+ ),
268
+ # --anchor must be an explicit {#id}, never a heading slug.
269
+ "doc patch-body": (
270
+ "deepcell doc patch-body model.deepcell --doc variance_note --anchor outlook "
271
+ "--markdown '## Outlook {#outlook}\\n\\nQ4 pipeline covers the gap.'"
272
+ ),
273
+ "doc outline": "deepcell doc outline model.deepcell --doc variance_note",
274
+ "doc links": "deepcell doc links model.deepcell --doc variance_note --unresolved",
275
+ "doc show": "deepcell doc show model.deepcell --doc variance_note --as markdown",
276
+ "doc lint": "deepcell doc lint model.deepcell --doc variance_note --strict",
277
+ "to-docx": "deepcell to-docx model.deepcell --doc variance_note -o variance-note.docx",
278
+ "to-pptx": "deepcell to-pptx model.deepcell --deck board -o board.pptx",
279
+
280
+ # ---- Reasoning -------------------------------------------------------
281
+ "reasoning add-claim": (
282
+ "deepcell reasoning add-claim model.deepcell --id t_gm --kind thesis "
283
+ "--label 'Gross margin expands' --body 'Mix shift to subscription lifts GM 200bps.' "
284
+ "--item-refs Gross_Margin_Pct --confidence 0.62"
285
+ ),
286
+ "reasoning add-assumption": (
287
+ "deepcell reasoning add-assumption model.deepcell --id a_hiring --label 'Hiring lands on plan' "
288
+ "--body 'Sales headcount reaches 40 by Q3.' --item-refs Headcount"
289
+ ),
290
+ "reasoning add-evidence": (
291
+ "deepcell reasoning add-evidence model.deepcell --id e_10k --source-ref src_aapl_10k_fy25 "
292
+ "--excerpt 'Gross margin of 46.2%' --retrieved-at 2026-02-19T10:30:00Z"
293
+ ),
294
+ # weight is qualitative confidence in the EDGE — never a probability, and
295
+ # never copied from the risk claim's @probability.
296
+ "reasoning add-argument": (
297
+ "deepcell reasoning add-argument model.deepcell --from-id r_pricing --to-id t_gm "
298
+ "--rel refutes --weight 0.6"
299
+ ),
300
+ "reasoning set-conclusion": "deepcell reasoning set-conclusion model.deepcell t_gm",
301
+ "reasoning supersede-claim": (
302
+ "deepcell reasoning supersede-claim model.deepcell t_gm --id t_gm_v2 "
303
+ "--label 'Gross margin expands, slower' --body 'Mix shift lifts GM 120bps.' --confidence 0.55"
304
+ ),
305
+ "reasoning supersede-assumption": (
306
+ "deepcell reasoning supersede-assumption model.deepcell a_hiring --id a_hiring_v2 "
307
+ "--label 'Hiring lands one quarter late'"
308
+ ),
309
+ # A premise actuals disproved is marked broken in place, not rewritten —
310
+ # the wording is the record of what was believed.
311
+ "reasoning update-assumption": (
312
+ "deepcell reasoning update-assumption model.deepcell a_hiring --status broken --broken-at 2026-07-31"
313
+ ),
314
+ "reasoning update-claim": (
315
+ "deepcell reasoning update-claim model.deepcell t_gm --status falsified"
316
+ ),
317
+ "reasoning update-argument": (
318
+ "deepcell reasoning update-argument model.deepcell arg_pricing --weight 0.6"
319
+ ),
320
+ # The edge's address is its id OR its triple; a bare edge has no id to type.
321
+ "reasoning delete-argument": (
322
+ "deepcell reasoning delete-argument model.deepcell --from-id r_pricing --edge-rel refutes --to-id t_gm"
323
+ ),
324
+ # --cascade takes the edges with the node; --allow-dangling leaves them.
325
+ "reasoning delete-claim": "deepcell reasoning delete-claim model.deepcell t_gm --cascade",
326
+ "reasoning impact": "deepcell reasoning impact model.deepcell a_hiring",
327
+ "assumption impact": "deepcell assumption impact model.deepcell a_hiring",
328
+
329
+ # ---- Ingest ----------------------------------------------------------
330
+ # Items need id/name/level (+ row and sheet to auto-map cells); contexts
331
+ # need id/name/period_type (+ column). These are snake_case JSON keys, not
332
+ # the XML spellings (itemId / contextId / statusRef).
333
+ "import": (
334
+ "deepcell import variance.xlsx --name variance_import "
335
+ """--items '[{"id":"Revenue","name":"Revenue","level":0,"row":2,"sheet":"Variance"}]' """
336
+ """--contexts '[{"id":"FY25A","name":"FY25 actual","period_type":"annual","status":"actual","column":"B"}]'"""
337
+ ),
338
+ "ingest cn extract": (
339
+ "deepcell ingest cn extract https://static.cninfo.com.cn/finalpage/2026-03-28/1224567890.PDF "
340
+ "--statement income --persist"
341
+ ),
342
+ "ingest cn search": "deepcell ingest cn search 600519",
343
+ "ingest cn filings": "deepcell ingest cn filings 600519 --type annual --count 5",
344
+ }
345
+
346
+ #: Typed ids per command, resolved by ``deepcell ref``. Deliberately sparse:
347
+ #: a pointer is worth printing only where it answers the question the command's
348
+ #: own help cannot.
349
+ COMMAND_SEE_ALSO: dict[str, list[str]] = {
350
+ "write": ["guide:generate/whole-doc", "rule:R1", "ref:exit/1"],
351
+ "describe": ["ref:lint/unrendered_value", "rule:R9"],
352
+ "edit": ["guide:generate/values", "ref:exit/1"],
353
+ "replace": ["guide:revise/values", "ref:exit/1"],
354
+ "query": ["guide:verify/query-back", "rule:R4"],
355
+ "defs apply": ["guide:generate/calcs", "ref:exit/1"],
356
+ "defs list": ["guide:revise/structure", "rule:R4"],
357
+ "defs show": ["guide:revise/structure", "rule:R4"],
358
+ "defs add-calc": ["guide:generate/calcs", "rule:R2", "ref:function/NPV"],
359
+ "defs add-item": ["guide:generate/structure", "rule:R7"],
360
+ "defs add-sensitivity": ["ref:op/add_sensitivity_block", "rule:R9"],
361
+ "reasoning lint": ["rule:R8", "ref:lint/unanchored_claim"],
362
+ "reasoning add-claim": ["guide:revise/reasoning", "ref:claim", "rule:R8"],
363
+ "reasoning set-conclusion": ["rule:R14", "ref:lint/dangling_conclusion_ref"],
364
+ "reasoning set-key-question": [
365
+ "rule:R19",
366
+ "ref:lint/dangling_key_question_ref",
367
+ "ref:lint/ambiguous_key_question",
368
+ ],
369
+ "to-excel": ["guide:present/layout", "rule:R9"],
370
+ "rules": ["guide:house-rules"],
371
+ }
372
+
373
+
374
+ def _derive_example(path: str, command: click.Command) -> str:
375
+ """Build a runnable-shaped invocation from the command's own signature.
376
+
377
+ Uses the real required parameter names, so the shape is right even though
378
+ the values are illustrative. Marked ``derived`` in the manifest — an
379
+ example nobody ran should never look like one that was tested.
380
+ """
381
+ parts = [f"deepcell {path}"]
382
+ for param in command.params:
383
+ if not isinstance(param, click.Argument) or not param.required:
384
+ continue
385
+ name = param.name or "arg"
386
+ if "file" in name or name in {"filename", "path"}:
387
+ parts.append("model.deepcell")
388
+ elif param.nargs == -1:
389
+ parts.append(f"<{name}...>")
390
+ else:
391
+ parts.append(f"<{name}>")
392
+ for param in command.params:
393
+ if isinstance(param, click.Option) and param.required:
394
+ flag = max(param.opts, key=len)
395
+ parts.append(f"{flag} <{param.name}>")
396
+ return " ".join(parts)
397
+
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Walking the Click tree
401
+ # ---------------------------------------------------------------------------
402
+
403
+
404
+ def _json_safe(value: Any) -> Any:
405
+ """Return *value* if it survives a JSON round trip, else ``None``.
406
+
407
+ Click represents "no default" with a sentinel object, and some defaults are
408
+ callables. The manifest is committed and byte-compared by a drift test, so
409
+ an unserialisable default must be dropped rather than stringified — a repr
410
+ would change between Python versions and show up as spurious drift.
411
+ """
412
+ if isinstance(value, (str, int, float, bool)):
413
+ return value
414
+ if isinstance(value, (list, tuple)):
415
+ items = [_json_safe(item) for item in value]
416
+ return items if all(item is not None for item in items) else None
417
+ return None
418
+
419
+
420
+ def _type_name(param: click.Parameter) -> str:
421
+ param_type = getattr(param, "type", None)
422
+ name = getattr(param_type, "name", None)
423
+ if name:
424
+ return str(name)
425
+ return type(param_type).__name__ if param_type else "text"
426
+
427
+
428
+ def _describe_param(param: click.Parameter) -> dict[str, Any]:
429
+ """One argument or option as data.
430
+
431
+ For an **option**, ``name`` is the flag you would actually type
432
+ (``--order-mode``), not Click's Python identifier (``order_mode``). The
433
+ identifier is kept as ``param`` for anyone mapping back to the callback.
434
+ Reporting the identifier as ``name`` was a live trap: it looks like a flag,
435
+ so an agent reading the manifest would construct ``--order_mode`` and get a
436
+ usage error with no hint why.
437
+ """
438
+ is_option = isinstance(param, click.Option)
439
+ entry: dict[str, Any] = {
440
+ "name": (max(param.opts, key=len) if is_option and param.opts else param.name),
441
+ "type": _type_name(param),
442
+ "required": bool(param.required),
443
+ }
444
+ if is_option:
445
+ entry["param"] = param.name
446
+ # `--inherit/--no-inherit` is one Click option with the off-switch in
447
+ # `secondary_opts`. Listing only `opts` published half of every boolean
448
+ # pair: `deepcell ref` printed `--inherit` and nothing else, and the
449
+ # guide-eval flag check called a reference command that used
450
+ # `--no-inherit` a defect in the task. The flag is real; the manifest
451
+ # was the thing that did not know about it.
452
+ entry["flags"] = list(param.opts) + list(getattr(param, "secondary_opts", []))
453
+ entry["is_flag"] = bool(getattr(param, "is_flag", False))
454
+ default = _json_safe(param.default)
455
+ if default is not None and not entry["is_flag"]:
456
+ entry["default"] = default
457
+ if param.help:
458
+ entry["help"] = " ".join(str(param.help).split())
459
+ choices = getattr(param.type, "choices", None)
460
+ if choices:
461
+ entry["choices"] = list(choices)
462
+ else:
463
+ entry["variadic"] = param.nargs == -1
464
+ return entry
465
+
466
+
467
+ def _summary(command: click.Command) -> str:
468
+ """The first line of the help text — what the index shows."""
469
+ text = command.get_short_help_str(limit=200) or ""
470
+ return " ".join(str(text).split())
471
+
472
+
473
+ def _describe_command(path: str, command: click.Command) -> dict[str, Any]:
474
+ args = [p for p in command.params if isinstance(p, click.Argument)]
475
+ opts = [p for p in command.params if isinstance(p, click.Option)]
476
+
477
+ exit_codes: dict[str, str] = {"0": "completed"}
478
+ sense = exit_sense_for(path)
479
+ if sense:
480
+ exit_codes["1"] = sense
481
+ if path in EXIT_TWO_ON_UNPARSEABLE:
482
+ exit_codes["2"] = "unparseable-document"
483
+
484
+ authored = COMMAND_EXAMPLES.get(path)
485
+ # The stage is a property of the top-level command, not of each leaf:
486
+ # `defs add-calc` is generate because `defs` is. Carrying it per entry is
487
+ # what lets every other catalog — the MCP tool description, the published
488
+ # docs, `deepcell help` — group identically without restating the table.
489
+ from deepcell_cli.stages import tier_for
490
+
491
+ return {
492
+ "id": f"cmd:{path}",
493
+ "name": path,
494
+ "stage": tier_for(path.split(" ", 1)[0]),
495
+ "summary": _summary(command),
496
+ "args": [_describe_param(p) for p in args],
497
+ "flags": [_describe_param(p) for p in opts],
498
+ "exit_codes": exit_codes,
499
+ "example": authored or _derive_example(path, command),
500
+ "example_source": "authored" if authored else "derived",
501
+ "see_also": COMMAND_SEE_ALSO.get(path, []),
502
+ }
503
+
504
+
505
+ def _walk(command: click.Command, path: str, out: dict[str, dict[str, Any]]) -> None:
506
+ if isinstance(command, click.Group):
507
+ # An `invoke_without_command=True` group is a command *as well as* a
508
+ # group: `deepcell example --pack finance` runs, and the `--pack` is
509
+ # declared on the group, not on any leaf. Walking only the leaves left
510
+ # it out of the manifest entirely — so `deepcell help` never mentioned
511
+ # it, `cmd:example` resolved to nothing, and the flag existed in the
512
+ # CLI while every generated surface said it did not.
513
+ if command.invoke_without_command:
514
+ out[path] = _describe_command(path, command)
515
+ ctx = click.Context(command)
516
+ for name in command.list_commands(ctx):
517
+ sub = command.get_command(ctx, name)
518
+ if sub is not None and not sub.hidden:
519
+ _walk(sub, f"{path} {name}".strip(), out)
520
+ return
521
+ out[path] = _describe_command(path, command)
522
+
523
+
524
+ def build_commands() -> dict[str, dict[str, Any]]:
525
+ """Every invocable command, keyed by its full invocation path.
526
+
527
+ Groups are walked rather than listed: ``defs`` is not a command, ``defs
528
+ add-calc`` is, and it is the leaf an agent actually invokes. A group that
529
+ is *itself* invocable appears too — see ``_walk``.
530
+
531
+ ⚠ A group that declares options but is **not** invocable has nowhere to put
532
+ them: its options must be typed before the subcommand, so attributing them
533
+ to each leaf would misdescribe where they go, and the group has no entry of
534
+ its own. ``test_surface_group_options`` fails on that shape rather than
535
+ letting the option vanish the way ``example --pack`` did — the fix is to
536
+ move the option onto the leaves, or make the group invocable.
537
+ """
538
+ from deepcell_cli.main import cli
539
+
540
+ out: dict[str, dict[str, Any]] = {}
541
+ ctx = click.Context(cli)
542
+ for name in cli.list_commands(ctx):
543
+ sub = cli.get_command(ctx, name)
544
+ if sub is not None and not sub.hidden:
545
+ _walk(sub, name, out)
546
+ return dict(sorted(out.items()))
547
+
548
+
549
+ def build_stages() -> list[dict[str, str]]:
550
+ """The tiers a command's ``stage`` names, in the order the work happens.
551
+
552
+ The order is the one thing a consumer *cannot* recover from the per-command
553
+ ``stage`` field. ``commands`` is sorted by invocation path, so a renderer
554
+ grouping by first appearance would lead with ``cat``'s tier and print
555
+ ``present`` before ``generate`` — teaching the wrong shape of the work with
556
+ no way to tell it had.
557
+
558
+ That gap is not hypothetical. The web CLI reference reads this manifest and
559
+ was the one catalog still ignoring the stage vocabulary, because grouping
560
+ by it would have meant hardcoding this list in TypeScript — a sixth private
561
+ copy of exactly the table ``stages.py`` exists to be the only one of.
562
+ """
563
+ from deepcell_cli import stages
564
+
565
+ return [
566
+ {"name": tier, "summary": stages.TIER_SUMMARY[tier]}
567
+ for tier in stages.ALL_TIERS
568
+ ]
569
+
570
+
571
+ def build_root() -> dict[str, str]:
572
+ """What ``deepcell --help`` prints around the command list.
573
+
574
+ The command *list* has always been derivable from ``build_commands()``, but
575
+ the prose either side of it was reachable only by running the CLI: the root
576
+ group's docstring above it, and ``_EPILOG`` — the getting-started,
577
+ file-handling, sync and learn-more recipes — below. Anything rendering a
578
+ root help pane therefore had to hand-copy them, which is the drift this
579
+ module exists to remove.
580
+
581
+ Both are static. The epilog is an f-string, but the only value it
582
+ interpolates is ``DEFAULT_API_URL``, a module constant — so the output is
583
+ deterministic and safe to bake into a byte-compared artifact. If a
584
+ *runtime* value is ever interpolated in here, this stops being generable
585
+ and the drift test will say so on the next unrelated commit.
586
+ """
587
+ from deepcell_cli.main import cli
588
+
589
+ return {
590
+ "help": _help_text(cli.help),
591
+ "epilog": _help_text(cli.epilog),
592
+ }
593
+
594
+
595
+ def _help_text(raw: str | None) -> str:
596
+ """Dedent, and drop Click's ``\\b`` no-rewrap markers.
597
+
598
+ A lone ``\\b`` line tells Click's formatter not to re-wrap the paragraph
599
+ below it; Click consumes it and it never reaches the terminal. Carrying it
600
+ into the artifact would put a literal backspace control character in front
601
+ of five paragraphs of every consumer's output — so stripping it is what
602
+ keeps the artifact faithful, not a liberty taken with it.
603
+ """
604
+ text = inspect.cleandoc(raw or "")
605
+ lines = [line for line in text.split("\n") if line.strip("\b \t") or not line.strip()]
606
+ return "\n".join(line for line in lines if line.strip() != "\b").strip("\n")
607
+
608
+
609
+ def build_globals() -> list[dict[str, Any]]:
610
+ """The root group's options — the ones that work on *every* command.
611
+
612
+ ``-f/--format``, ``-v/--verbose``, ``--workspace`` and ``--version`` are
613
+ declared once, on the root group, and ``FlexibleGroup`` deliberately
614
+ accepts them *after* the subcommand too (`deepcell describe x -f json`).
615
+ They were the same blind spot as `example --pack`, one level up: real,
616
+ usable on all 139 commands, and absent from every generated surface.
617
+
618
+ Emitted once rather than copied onto each command — a per-command copy
619
+ would be 139 places for one fact, and would also imply they are
620
+ command-specific, which is the opposite of what they are.
621
+ """
622
+ from deepcell_cli.main import cli
623
+
624
+ return [
625
+ _describe_param(param)
626
+ for param in cli.params
627
+ if isinstance(param, click.Option)
628
+ ]