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,594 @@
1
+ """``deepcell deck`` — the Deck surface's front door.
2
+
3
+ A .deepcell file connects four surfaces: Reasoning (ideas), Spreadsheet
4
+ (calculations), Document (prose) and Deck (slides). Three of them had an
5
+ authoring command group. Deck had none — twelve deck and slide ops sat in
6
+ ``test_op_reachability.py``'s ``_DELIBERATELY_UNREACHABLE`` under one shared
7
+ justification:
8
+
9
+ the payloads carry whole HTML/CSS documents — flags are the wrong shape
10
+
11
+ Checked against the committed ``openapi.json`` the reachability test itself
12
+ reads from, that is true of **three** of the twelve. ``add_presentation_slide``
13
+ carries ``html``, ``set_presentation_slide_html`` carries ``html``, and
14
+ ``set_presentation_deck_style`` carries ``style``. Those three stay on ``defs
15
+ apply``, where a heredoc is the right shape for a document-sized argument.
16
+
17
+ The other nine take ids, names and indices — ``deckId``, ``slideId``,
18
+ ``newName``, ``toIndex`` — which is exactly what flags are for. They are wired
19
+ here. The allowlist's own header says why this is the right direction:
20
+ *"Adding an entry is a deliberate act: prefer wiring the op up."*
21
+
22
+ So: **structure here, content through `defs apply`.** Creating a deck, naming
23
+ it, ordering its slides, removing one, and pointing a binding at a value are
24
+ all structural. Writing the slide's HTML is not.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import Any
30
+
31
+ import click
32
+
33
+ from deepcell_cli.commands.defs import _DefsGroup, _apply
34
+ from deepcell_cli.context import Ctx, pass_ctx
35
+
36
+ #: Mirrors ``UpsertPresentationBindingOp.bindingKind`` in
37
+ #: ``backend/jingwei_api/routers/defs/models/_presentation.py``, which declares
38
+ #: it as a ``Literal``. Declared here as a ``Choice`` so a typo is refused
39
+ #: locally rather than coming back as a 422 — the same standard
40
+ #: ``test_deck_op_shape.py`` applies to the required fields. That test pins
41
+ #: both tuples to the committed schema, so neither can drift silently.
42
+ BINDING_KINDS = (
43
+ "value", "series", "chart", "document_stat", "reasoning", "source",
44
+ "htmlblock",
45
+ )
46
+
47
+ #: Mirrors the same op's ``metric``. These count elements of the document's
48
+ #: provenance and reasoning graphs; they are not derived from the values the
49
+ #: binding reads, which is what this flag's help used to say.
50
+ BINDING_METRICS = (
51
+ "dataSourceCount",
52
+ "claimCount",
53
+ "assumptionCount",
54
+ "evidenceCount",
55
+ )
56
+
57
+ #: Mirrors ``AddPresentationDeckOp.aspectRatio``, which is an enum with a
58
+ #: server-side default of ``16:9``. Free text here meant `--aspect-ratio 16:10`
59
+ #: reached the server as a 422 the CLI already had everything to refuse.
60
+ ASPECT_RATIOS = ("16:9", "4:3")
61
+
62
+
63
+ def _slug(ctx: Ctx, workspace_slug: str | None) -> str:
64
+ return workspace_slug or ctx.require_workspace()
65
+
66
+
67
+ @click.group(cls=_DefsGroup)
68
+ def deck() -> None:
69
+ """Build and reorder decks and slides — the Deck surface.
70
+
71
+ \b
72
+ Structure lives here: deepcell deck add-slide report.deepcell --deck d1 --name "Outlook"
73
+ Content goes through: deepcell defs apply report.deepcell --ops '[{"kind": "set_presentation_slide_html", ...}]'
74
+
75
+ A slide's HTML and a deck's CSS are document-sized arguments, so they stay
76
+ on `defs apply` rather than becoming shell flags. Everything structural —
77
+ creating, naming, ordering, deleting, binding — is here.
78
+
79
+ Every command here posts to the same endpoint `defs` does, so every one
80
+ takes `--dry-run` to pre-flight it and `-m` to name the commit it writes.
81
+
82
+ Read `deepcell guide present/decks` for how a deck stays linked to the
83
+ values it presents.
84
+ """
85
+
86
+
87
+ # ── Decks ───────────────────────────────────────────────────
88
+
89
+
90
+ @deck.command("add")
91
+ @click.argument("filename")
92
+ @click.option("--deck-id", default=None, help="Deck id. Omit to let the server assign one.")
93
+ @click.option("--name", required=True, help="Display name for the deck.")
94
+ @click.option(
95
+ "--aspect-ratio",
96
+ default=None,
97
+ type=click.Choice(ASPECT_RATIOS),
98
+ help="Slide aspect ratio. Omit for the format default (16:9).",
99
+ )
100
+ @click.option("--index", type=int, default=None, help="Position among decks (0-based).")
101
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
102
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
103
+ @pass_ctx
104
+ def add_deck(
105
+ ctx: Ctx,
106
+ filename: str,
107
+ deck_id: str | None,
108
+ name: str,
109
+ aspect_ratio: str | None,
110
+ index: int | None,
111
+ revision: str | None,
112
+ workspace_slug: str | None,
113
+ ) -> None:
114
+ """Add a Deck to a .deepcell file.
115
+
116
+ The deck starts empty. Add slides with `deepcell deck add-slide`, then set
117
+ each slide's HTML with `defs apply`.
118
+ """
119
+ op: dict[str, Any] = {"name": name}
120
+ if deck_id is not None:
121
+ op["deckId"] = deck_id
122
+ if aspect_ratio is not None:
123
+ op["aspectRatio"] = aspect_ratio
124
+ if index is not None:
125
+ op["index"] = index
126
+ _apply(
127
+ ctx,
128
+ _slug(ctx, workspace_slug),
129
+ filename,
130
+ [{"kind": "add_presentation_deck", **op}],
131
+ revision=revision,
132
+ )
133
+
134
+
135
+ @deck.command("rename")
136
+ @click.argument("filename")
137
+ @click.argument("deck_id")
138
+ @click.argument("new_name")
139
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
140
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
141
+ @pass_ctx
142
+ def rename_deck(
143
+ ctx: Ctx,
144
+ filename: str,
145
+ deck_id: str,
146
+ new_name: str,
147
+ revision: str | None,
148
+ workspace_slug: str | None,
149
+ ) -> None:
150
+ """Change a deck's display NAME. The deckId is unchanged.
151
+
152
+ Renaming the id instead would redirect every reference that points at it.
153
+ """
154
+ _apply(
155
+ ctx,
156
+ _slug(ctx, workspace_slug),
157
+ filename,
158
+ [
159
+ {
160
+ "kind": "rename_presentation_deck",
161
+ "deckId": deck_id,
162
+ "newName": new_name,
163
+ }
164
+ ],
165
+ revision=revision,
166
+ )
167
+
168
+
169
+ @deck.command("reorder")
170
+ @click.argument("filename")
171
+ @click.argument("deck_id")
172
+ @click.argument("to_index", type=int)
173
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
174
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
175
+ @pass_ctx
176
+ def reorder_decks(
177
+ ctx: Ctx,
178
+ filename: str,
179
+ deck_id: str,
180
+ to_index: int,
181
+ revision: str | None,
182
+ workspace_slug: str | None,
183
+ ) -> None:
184
+ """Move a deck to TO_INDEX in the deck order (0-based)."""
185
+ _apply(
186
+ ctx,
187
+ _slug(ctx, workspace_slug),
188
+ filename,
189
+ [
190
+ {
191
+ "kind": "reorder_presentation_decks",
192
+ "deckId": deck_id,
193
+ "toIndex": to_index,
194
+ }
195
+ ],
196
+ revision=revision,
197
+ )
198
+
199
+
200
+ @deck.command("delete")
201
+ @click.argument("filename")
202
+ @click.argument("deck_id")
203
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
204
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
205
+ @pass_ctx
206
+ def delete_deck(
207
+ ctx: Ctx,
208
+ filename: str,
209
+ deck_id: str,
210
+ revision: str | None,
211
+ workspace_slug: str | None,
212
+ ) -> None:
213
+ """Delete a deck and every slide on it.
214
+
215
+ This removes the slides' HTML with them. Pass --dry-run first if you are
216
+ not sure which deck the id names — `deepcell describe` lists them.
217
+ """
218
+ _apply(
219
+ ctx,
220
+ _slug(ctx, workspace_slug),
221
+ filename,
222
+ [{"kind": "delete_presentation_deck", "deckId": deck_id}],
223
+ revision=revision,
224
+ )
225
+
226
+
227
+ # ── Slides ──────────────────────────────────────────────────
228
+
229
+
230
+ @deck.command("add-slide")
231
+ @click.argument("filename")
232
+ @click.option("--deck", "deck_id", required=True, help="Deck to add the slide to.")
233
+ @click.option("--slide-id", default=None, help="Slide id. Omit to let the server assign one.")
234
+ @click.option("--name", required=True, help="Display name for the slide.")
235
+ @click.option("--index", type=int, default=None, help="Position within the deck (0-based).")
236
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
237
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
238
+ @pass_ctx
239
+ def add_slide(
240
+ ctx: Ctx,
241
+ filename: str,
242
+ deck_id: str,
243
+ slide_id: str | None,
244
+ name: str,
245
+ index: int | None,
246
+ revision: str | None,
247
+ workspace_slug: str | None,
248
+ ) -> None:
249
+ """Add an empty slide to a deck.
250
+
251
+ The slide's HTML is a document-sized argument, so this command never takes
252
+ it. Set it afterwards, or create and fill the slide in one op — the same
253
+ `add_presentation_slide` op also accepts `html` on `defs apply`. Start
254
+ the body from a `deepcell ref deck-layout` entry: class-only HTML on the
255
+ vocabulary every style pack defines.
256
+
257
+ \b
258
+ deepcell defs apply FILE --ops '[{"kind": "set_presentation_slide_html",
259
+ "deckId": "d1", "slideId": "s1", "html": "<section>...</section>"}]'
260
+ deepcell defs apply FILE --ops '[{"kind": "add_presentation_slide",
261
+ "deckId": "d1", "slideId": "s2", "name": "Risks", "html": "<section>...</section>"}]'
262
+ """
263
+ op: dict[str, Any] = {"deckId": deck_id, "name": name}
264
+ if slide_id is not None:
265
+ op["slideId"] = slide_id
266
+ if index is not None:
267
+ op["index"] = index
268
+ _apply(
269
+ ctx,
270
+ _slug(ctx, workspace_slug),
271
+ filename,
272
+ [{"kind": "add_presentation_slide", **op}],
273
+ revision=revision,
274
+ )
275
+
276
+
277
+ @deck.command("rename-slide")
278
+ @click.argument("filename")
279
+ @click.argument("slide_id")
280
+ @click.argument("new_name")
281
+ @click.option("--deck", "deck_id", required=True, help="Deck the slide belongs to.")
282
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
283
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
284
+ @pass_ctx
285
+ def rename_slide(
286
+ ctx: Ctx,
287
+ filename: str,
288
+ slide_id: str,
289
+ new_name: str,
290
+ deck_id: str,
291
+ revision: str | None,
292
+ workspace_slug: str | None,
293
+ ) -> None:
294
+ """Change a slide's display NAME. The slideId is unchanged."""
295
+ _apply(
296
+ ctx,
297
+ _slug(ctx, workspace_slug),
298
+ filename,
299
+ [
300
+ {
301
+ "kind": "rename_presentation_slide",
302
+ "deckId": deck_id,
303
+ "slideId": slide_id,
304
+ "newName": new_name,
305
+ }
306
+ ],
307
+ revision=revision,
308
+ )
309
+
310
+
311
+ @deck.command("reorder-slide")
312
+ @click.argument("filename")
313
+ @click.argument("slide_id")
314
+ @click.argument("to_index", type=int)
315
+ @click.option("--deck", "deck_id", required=True, help="Deck the slide belongs to.")
316
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
317
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
318
+ @pass_ctx
319
+ def reorder_slide(
320
+ ctx: Ctx,
321
+ filename: str,
322
+ slide_id: str,
323
+ to_index: int,
324
+ deck_id: str,
325
+ revision: str | None,
326
+ workspace_slug: str | None,
327
+ ) -> None:
328
+ """Move a slide to TO_INDEX within its deck (0-based)."""
329
+ _apply(
330
+ ctx,
331
+ _slug(ctx, workspace_slug),
332
+ filename,
333
+ [
334
+ {
335
+ "kind": "reorder_presentation_slides",
336
+ "deckId": deck_id,
337
+ "slideId": slide_id,
338
+ "toIndex": to_index,
339
+ }
340
+ ],
341
+ revision=revision,
342
+ )
343
+
344
+
345
+ @deck.command("delete-slide")
346
+ @click.argument("filename")
347
+ @click.argument("slide_id")
348
+ @click.option("--deck", "deck_id", required=True, help="Deck the slide belongs to.")
349
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
350
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
351
+ @pass_ctx
352
+ def delete_slide(
353
+ ctx: Ctx,
354
+ filename: str,
355
+ slide_id: str,
356
+ deck_id: str,
357
+ revision: str | None,
358
+ workspace_slug: str | None,
359
+ ) -> None:
360
+ """Delete one slide, and the HTML on it."""
361
+ _apply(
362
+ ctx,
363
+ _slug(ctx, workspace_slug),
364
+ filename,
365
+ [
366
+ {
367
+ "kind": "delete_presentation_slide",
368
+ "deckId": deck_id,
369
+ "slideId": slide_id,
370
+ }
371
+ ],
372
+ revision=revision,
373
+ )
374
+
375
+
376
+ # ── Bindings ────────────────────────────────────────────────
377
+
378
+
379
+ @deck.command("bind")
380
+ @click.argument("filename")
381
+ @click.option("--deck", "deck_id", required=True, help="Deck the binding lives on.")
382
+ @click.option("--binding-id", required=True, help="Binding id. Reusing one updates it in place.")
383
+ @click.option(
384
+ "--binding-kind",
385
+ required=True,
386
+ type=click.Choice(BINDING_KINDS),
387
+ help="What the binding renders. Decides which address flag it takes.",
388
+ )
389
+ @click.option(
390
+ "--field",
391
+ default=None,
392
+ help="Which text of the addressed thing to render, not an address itself.",
393
+ )
394
+ @click.option("--ref", default=None, help="Single coordinate the binding reads.")
395
+ @click.option(
396
+ "--refs",
397
+ default=None,
398
+ help=(
399
+ "Comma-separated coordinates — the address a `series` binding takes. "
400
+ "Single-quote an item id that contains a comma."
401
+ ),
402
+ )
403
+ @click.option(
404
+ "--metric",
405
+ default=None,
406
+ type=click.Choice(BINDING_METRICS),
407
+ help="For --binding-kind document_stat only: which element type to count.",
408
+ )
409
+ @click.option(
410
+ "--number-format",
411
+ default=None,
412
+ help=(
413
+ "Excel-style number pattern the value renders with — `#,##0.0`, `0.0%`, "
414
+ "`#,##0;(#,##0)`. It labels the stored value and never divides it."
415
+ ),
416
+ )
417
+ @click.option("--prefix", default=None, help="Text rendered before the value.")
418
+ @click.option("--suffix", default=None, help="Text rendered after the value.")
419
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
420
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
421
+ @pass_ctx
422
+ def bind(
423
+ ctx: Ctx,
424
+ filename: str,
425
+ deck_id: str,
426
+ binding_id: str,
427
+ binding_kind: str,
428
+ field: str | None,
429
+ ref: str | None,
430
+ refs: str | None,
431
+ metric: str | None,
432
+ number_format: str | None,
433
+ prefix: str | None,
434
+ suffix: str | None,
435
+ revision: str | None,
436
+ workspace_slug: str | None,
437
+ ) -> None:
438
+ """Point a slide at a live value, or update an existing binding.
439
+
440
+ A binding is what keeps a slide honest: the number on the slide is read
441
+ from the model rather than typed, so changing the model changes the deck.
442
+ Create and update are one command — passing an existing --binding-id
443
+ updates it in place.
444
+
445
+ \b
446
+ Which address flag the kind takes — the server rejects the other one:
447
+ series --refs (comma-separated, ordered)
448
+ document_stat no address; --metric instead
449
+ value / chart / reasoning / source
450
+ --ref
451
+ htmlblock --ref deepcell:doc/<docId>#<blockId>
452
+
453
+ An `htmlblock` shows a designed exhibit authored in a document's prose —
454
+ a margin bridge, a comparison card. It addresses a `doc` because that is
455
+ where a block lives; the block stays one thing in one place, so editing
456
+ the memo changes the slide.
457
+
458
+ `--field` selects which text an already-addressed thing renders
459
+ (`label` / `body` / `excerpt` on reasoning, `title` / `locator` on a
460
+ source). It is not an address.
461
+ """
462
+ op: dict[str, Any] = {
463
+ "deckId": deck_id,
464
+ "bindingId": binding_id,
465
+ "bindingKind": binding_kind,
466
+ }
467
+ for key, value in (
468
+ ("field", field),
469
+ ("ref", ref),
470
+ ("metric", metric),
471
+ ("numberFormat", number_format),
472
+ ("prefix", prefix),
473
+ ("suffix", suffix),
474
+ ):
475
+ if value is not None:
476
+ op[key] = value
477
+ if refs is not None:
478
+ # Verbatim, and deliberately so. Two things are true here and the
479
+ # first draft got both wrong.
480
+ #
481
+ # It is a STRING on the wire: `UpsertPresentationBindingOp.refs` is
482
+ # `Optional[str]` and its comment calls it "an ordered comma-separated
483
+ # list". Sending a split list 422'd on every `--refs` call, which a
484
+ # mocked test cannot see — hence the schema validation in
485
+ # `test_deck_op_shape.py`.
486
+ #
487
+ # And the splitting is not ours to do. A comma is legal *inside* an
488
+ # item id when the id is single-quoted
489
+ # (`deepcell:cell/'Cost,Net'[FY26]`), so the separator can only be
490
+ # found by a quote-aware scan — `core.refs.binding_ref.split_ref_list`,
491
+ # which the file validator and the render builder also call. Splitting
492
+ # and rejoining here strips the space out of `'Cost, Net'` and changes
493
+ # which item the reference names.
494
+ op["refs"] = refs
495
+ _apply(
496
+ ctx,
497
+ _slug(ctx, workspace_slug),
498
+ filename,
499
+ [{"kind": "upsert_presentation_binding", **op}],
500
+ revision=revision,
501
+ )
502
+
503
+
504
+ @deck.command("unbind")
505
+ @click.argument("filename")
506
+ @click.argument("binding_id")
507
+ @click.option("--deck", "deck_id", required=True, help="Deck the binding lives on.")
508
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
509
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
510
+ @pass_ctx
511
+ def unbind(
512
+ ctx: Ctx,
513
+ filename: str,
514
+ binding_id: str,
515
+ deck_id: str,
516
+ revision: str | None,
517
+ workspace_slug: str | None,
518
+ ) -> None:
519
+ """Remove a binding, leaving whatever the slide's HTML says in its place."""
520
+ _apply(
521
+ ctx,
522
+ _slug(ctx, workspace_slug),
523
+ filename,
524
+ [
525
+ {
526
+ "kind": "delete_presentation_binding",
527
+ "deckId": deck_id,
528
+ "bindingId": binding_id,
529
+ }
530
+ ],
531
+ revision=revision,
532
+ )
533
+
534
+
535
+ def _parse_role_assignments(pairs: tuple[str, ...]) -> dict[str, str]:
536
+ roles: dict[str, str] = {}
537
+ for pair in pairs:
538
+ role, sep, colour = pair.partition("=")
539
+ if not sep or not role.strip() or not colour.strip():
540
+ raise click.BadParameter(f"expected ROLE=#RRGGBB, got {pair!r}", param_hint="--set")
541
+ roles[role.strip()] = colour.strip()
542
+ return roles
543
+
544
+
545
+ @deck.command("brand")
546
+ @click.argument("filename")
547
+ @click.option("--deck", "deck_id", required=True, help="Deck to recolour.")
548
+ @click.option(
549
+ "--set",
550
+ "assignments",
551
+ multiple=True,
552
+ metavar="ROLE=#RRGGBB",
553
+ help="Colour-role override, repeatable: --set accent=#0A5C3B --set ink=#111111. "
554
+ "Roles are the pack's colour names — accent, ink, series-1 … (`deepcell ref deck-style/hooks`).",
555
+ )
556
+ @click.option("--clear", is_flag=True, help="Remove the brand; the pack's own palette shows again.")
557
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
558
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
559
+ @pass_ctx
560
+ def brand(
561
+ ctx: Ctx,
562
+ filename: str,
563
+ deck_id: str,
564
+ assignments: tuple[str, ...],
565
+ clear: bool,
566
+ revision: str | None,
567
+ workspace_slug: str | None,
568
+ ) -> None:
569
+ """Recolour a deck by ROLE without touching its stylesheet.
570
+
571
+ A style pack paints through colour roles (`accent`, `ink`, `series-1` …,
572
+ the `dc-` custom properties it declares). A brand is a set of overrides for those roles, written
573
+ as `<Brand accent="#0A5C3B"/>` and emitted after the pack's CSS by every
574
+ renderer, so `deck brand` and a later pack swap compose: the brand
575
+ survives the restyle. The whole brand is replaced on each call — resend
576
+ every role you want kept, or `--clear` to drop it.
577
+ """
578
+ if clear and assignments:
579
+ raise click.UsageError("--clear takes no --set; pass one or the other.")
580
+ if not clear and not assignments:
581
+ raise click.UsageError("Pass at least one --set ROLE=#RRGGBB, or --clear.")
582
+ _apply(
583
+ ctx,
584
+ _slug(ctx, workspace_slug),
585
+ filename,
586
+ [
587
+ {
588
+ "kind": "set_presentation_deck_brand",
589
+ "deckId": deck_id,
590
+ "roles": {} if clear else _parse_role_assignments(assignments),
591
+ }
592
+ ],
593
+ revision=revision,
594
+ )