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,445 @@
1
+ """Version control commands: log, diff, restore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from deepcell_cli.errors import APIError
8
+ from deepcell_cli.context import Ctx, pass_ctx
9
+ from deepcell_cli.output import (
10
+ echo_error, echo_info, echo_success, echo_warning, output, output_mutation, print_plain,
11
+ )
12
+
13
+
14
+ def _handle_invalid_revision(e: APIError) -> None:
15
+ """Format invalid-revision API errors with structured output."""
16
+ detail = e.detail
17
+ # Parse the structured error from the API
18
+ # Format: "Invalid revision 'REV'. ERROR. Recent revisions: r1, r2. Use 'deepcell log'..."
19
+ if "Invalid revision" in detail:
20
+ # Extract parts for structured display
21
+ parts = detail.split(". ")
22
+ echo_error(parts[0]) # "Invalid revision 'REV'"
23
+ if len(parts) > 1:
24
+ echo_info(f" {parts[1]}") # error detail
25
+ for part in parts[2:]:
26
+ if part.startswith("Recent revisions:"):
27
+ echo_info(f" {part}")
28
+ elif "deepcell log" in part:
29
+ echo_info(f" Hint: {part}")
30
+ raise SystemExit(1)
31
+ raise e
32
+
33
+
34
+ @click.command()
35
+ @click.argument("filename_pos", metavar="[FILENAME]", required=False)
36
+ @click.option("--file", "filename", default=None, help="Filter by filename.")
37
+ @click.option("-n", "--limit", default=20, help="Number of entries to show.")
38
+ @click.option(
39
+ "--after",
40
+ "after_sha",
41
+ default=None,
42
+ help="Pagination cursor: start AFTER this commit sha. Pass the last sha "
43
+ "of the previous page to read the next one.",
44
+ )
45
+ @pass_ctx
46
+ def log(
47
+ ctx: Ctx,
48
+ filename_pos: str | None,
49
+ filename: str | None,
50
+ limit: int,
51
+ after_sha: str | None,
52
+ ) -> None:
53
+ """Show version history for the workspace (like git log).
54
+
55
+ The filename filter can be positional (`log model.deepcell`, the form
56
+ every read command uses) or `--file` — flag-only was a measurable trap
57
+ (eval U5).
58
+
59
+ \b
60
+ Paging back through a long history:
61
+ deepcell log -n 20 # newest 20
62
+ deepcell log -n 20 --after <sha> # the 20 after that page's last sha
63
+ """
64
+ if filename_pos and filename:
65
+ raise click.UsageError("Pass FILENAME positionally or via --file, not both.")
66
+ filename = filename_pos or filename
67
+ slug = ctx.require_workspace()
68
+ params: dict = {"max_count": limit}
69
+ if filename:
70
+ params["filename"] = filename
71
+ # Key matches the `after_sha` query param on GET /workspaces/{slug}/versions.
72
+ if after_sha:
73
+ params["after_sha"] = after_sha
74
+ data = ctx.client.get(f"/workspaces/{slug}/versions", params=params)
75
+ output(data, ctx.fmt)
76
+
77
+
78
+ @click.command()
79
+ @click.argument("revision_a")
80
+ @click.argument("revision_b", required=False)
81
+ @click.option("--file", "filename", default=None, help="Filter diff by filename.")
82
+ @pass_ctx
83
+ def diff(ctx: Ctx, revision_a: str, revision_b: str | None, filename: str | None) -> None:
84
+ """Show diff between two revisions (like git diff).
85
+
86
+ \b
87
+ deepcell diff abc123 # compare revision to HEAD
88
+ deepcell diff abc123 def456 # compare two revisions
89
+ """
90
+ slug = ctx.require_workspace()
91
+ params: dict = {"from_rev": revision_a}
92
+ if revision_b:
93
+ params["to_rev"] = revision_b
94
+ if filename:
95
+ params["filename"] = filename
96
+
97
+ try:
98
+ data = ctx.client.get(f"/workspaces/{slug}/versions/diff", params=params)
99
+ except APIError as e:
100
+ if e.status_code == 400:
101
+ _handle_invalid_revision(e)
102
+ raise
103
+
104
+ # If the response is a string (unified diff), print as plain text
105
+ if isinstance(data, str):
106
+ print_plain(data)
107
+ elif isinstance(data, list) and ctx.fmt == "plain":
108
+ _print_diff_plain(data)
109
+ else:
110
+ output(data, ctx.fmt)
111
+
112
+
113
+ def _print_diff_plain(entries: list[dict]) -> None:
114
+ """Format semantic diff results for plain-text display."""
115
+ if not entries:
116
+ echo_info("No differences found.")
117
+ return
118
+
119
+ for entry in entries:
120
+ file_path = entry.get("file_path", "")
121
+ change_type = entry.get("change_type", "modified")
122
+ click.echo(click.style(f"--- {file_path} ({change_type})", bold=True), err=False)
123
+
124
+ sd = entry.get("semantic_diff")
125
+ if sd:
126
+ _print_semantic_diff(sd)
127
+ elif entry.get("patch"):
128
+ print_plain(entry["patch"])
129
+ click.echo("") # blank line between files
130
+
131
+
132
+ # Section keys emitted by the backend's compute_semantic_diff
133
+ # (backend/src/services/git_service.py). Contract: the shared fixture
134
+ # cli/tests/fixtures/semantic_diff_contract.json is generated from the real
135
+ # backend output and both suites test against it — see
136
+ # cli/tests/test_commands/test_version.py and
137
+ # backend/tests/test_semantic_diff_contract.py. If the backend adds a
138
+ # section, regenerate the fixture and add a renderer branch here.
139
+ SEMANTIC_DIFF_SECTIONS = (
140
+ "items_added",
141
+ "items_removed",
142
+ "contexts_added",
143
+ "contexts_removed",
144
+ "contexts_changed",
145
+ "value_changes",
146
+ "formula_changes",
147
+ "format_changes",
148
+ "presentation_changes",
149
+ "deck_changes",
150
+ "document_changes",
151
+ "reasoning_changes",
152
+ "source_changes",
153
+ "scenario_changes",
154
+ "status_changes",
155
+ "metadata_changes",
156
+ )
157
+
158
+
159
+ def _sd_label(entry: dict, id_key: str, name_key: str) -> str:
160
+ """Format `id (name)`, collapsing to `id` when the name adds nothing."""
161
+ eid = str(entry.get(id_key, "?"))
162
+ name = entry.get(name_key)
163
+ if name and str(name) != eid:
164
+ return f"{eid} ({name})"
165
+ return eid
166
+
167
+
168
+ def _print_added_removed(sd: dict, added_key: str, removed_key: str, noun: str, name_key: str) -> None:
169
+ """Render a pair of added/removed sections (items or contexts)."""
170
+ added = sd.get(added_key, [])
171
+ if added:
172
+ click.echo(f" {noun} Added:")
173
+ for e in added:
174
+ click.echo(click.style(f" + {_sd_label(e, 'id', name_key)}", fg="green"))
175
+ removed = sd.get(removed_key, [])
176
+ if removed:
177
+ click.echo(f" {noun} Removed:")
178
+ for e in removed:
179
+ click.echo(click.style(f" - {_sd_label(e, 'id', name_key)}", fg="red"))
180
+
181
+
182
+ def _echo_old_new(prefix: str, old, new) -> None:
183
+ """Render `prefix: old -> new` with the old value red and new green."""
184
+ click.echo(f"{prefix}: ", nl=False)
185
+ click.echo(click.style(str(old) if old is not None else "(none)", fg="red"), nl=False)
186
+ click.echo(" -> ", nl=False)
187
+ click.echo(click.style(str(new) if new is not None else "(none)", fg="green"))
188
+
189
+
190
+ def _print_semantic_diff(sd: dict) -> None:
191
+ """Format a semantic diff section with colored output.
192
+
193
+ Renders the sections emitted by the backend's compute_semantic_diff
194
+ (see SEMANTIC_DIFF_SECTIONS above for the shared-fixture contract).
195
+ """
196
+ _print_added_removed(sd, "items_added", "items_removed", "Items", "name")
197
+ _print_added_removed(sd, "contexts_added", "contexts_removed", "Contexts", "label")
198
+
199
+ ctx_changed = sd.get("contexts_changed", [])
200
+ if ctx_changed:
201
+ click.echo(" Contexts Changed:")
202
+ for cc in ctx_changed:
203
+ prefix = f" ~ {cc.get('id', '?')} {cc.get('field', '?')}"
204
+ _echo_old_new(prefix, cc.get("old"), cc.get("new"))
205
+
206
+ # Split the values section by cause. A run that edits three assumptions and
207
+ # recalculates fifty-seven cells printed sixty indistinguishable lines, and
208
+ # the three worth reading were somewhere in the middle. `derived` is the
209
+ # backend's answer (a CalcDef governs the cell); an entry without the key
210
+ # predates it and is shown as an edit, which is the safe way to be wrong —
211
+ # it over-reports what to read, never under-reports.
212
+ value_changes = sd.get("value_changes", [])
213
+ if value_changes:
214
+ edited = [cv for cv in value_changes if not cv.get("derived")]
215
+ recalculated = [cv for cv in value_changes if cv.get("derived")]
216
+
217
+ def _echo_cells(heading: str, entries: list) -> None:
218
+ if not entries:
219
+ return
220
+ click.echo(heading)
221
+ for cv in entries:
222
+ item = cv.get("item", "?")
223
+ ctx = cv.get("context", "?")
224
+ status = cv.get("status")
225
+ cell = f"{item}[{ctx}]" + (f" ({status})" if status else "")
226
+ _echo_old_new(f" {cell}", cv.get("old"), cv.get("new"))
227
+
228
+ _echo_cells(" Changed Values:", edited)
229
+ _echo_cells(" Recalculated:", recalculated)
230
+
231
+ formula_changes = sd.get("formula_changes", [])
232
+ if formula_changes:
233
+ click.echo(" Changed Formulas:")
234
+ for fc in formula_changes:
235
+ calc_id = fc.get("id", "?")
236
+ item = fc.get("item")
237
+ label = f"{calc_id} ({item})" if item and item != calc_id else str(calc_id)
238
+ click.echo(f" {label}:")
239
+ old_f = fc.get("old_formula")
240
+ new_f = fc.get("new_formula")
241
+ if old_f is not None:
242
+ click.echo(click.style(f" - {old_f}", fg="red"))
243
+ if new_f is not None:
244
+ click.echo(click.style(f" + {new_f}", fg="green"))
245
+
246
+ format_changes = sd.get("format_changes", [])
247
+ if format_changes:
248
+ click.echo(" Format Changes:")
249
+ for fc in format_changes:
250
+ details = fc.get("details")
251
+ line = f" ~ {fc.get('format_id', '?')}: {fc.get('change_type', '?')}"
252
+ if details:
253
+ line += f" — {details}"
254
+ click.echo(line)
255
+
256
+ pres_changes = sd.get("presentation_changes", [])
257
+ if pres_changes:
258
+ click.echo(" Presentation Changes:")
259
+ for pc in pres_changes:
260
+ change_type = pc.get("change_type", "?")
261
+ target = pc.get("block_id") or pc.get("sheet_id") or "?"
262
+ name = pc.get("name")
263
+ line = f" ~ {change_type}: {target}"
264
+ if name and name != target:
265
+ line += f" ({name})"
266
+ if pc.get("field"):
267
+ click.echo(line, nl=False)
268
+ _echo_old_new(f" {pc['field']}", pc.get("old"), pc.get("new"))
269
+ else:
270
+ click.echo(line)
271
+
272
+ for section_key, id_key, noun in (
273
+ ("scenario_changes", "scenario_id", "Scenario"),
274
+ ("status_changes", "status_id", "Status"),
275
+ ):
276
+ changes = sd.get(section_key, [])
277
+ if changes:
278
+ click.echo(f" {noun} Changes:")
279
+ for sc in changes:
280
+ sign = "+" if sc.get("change_type") == "added" else "-"
281
+ color = "green" if sign == "+" else "red"
282
+ click.echo(click.style(f" {sign} {_sd_label(sc, id_key, 'label')}", fg=color))
283
+
284
+ _print_deliverable_changes(sd)
285
+
286
+ meta_changes = sd.get("metadata_changes", [])
287
+ if meta_changes:
288
+ click.echo(" Metadata Changes:")
289
+ for mc in meta_changes:
290
+ _echo_old_new(f" ~ {mc.get('field', '?')}", mc.get("old"), mc.get("new"))
291
+
292
+
293
+ #: Which id a deliverable entry is addressed by, most specific first. A slide
294
+ #: entry carries both a deck id and a slide id; the slide is what changed.
295
+ _DELIVERABLE_ID_KEYS = (
296
+ "binding_id",
297
+ "slide_id",
298
+ "block_id",
299
+ "claim_id",
300
+ "assumption_id",
301
+ "evidence_id",
302
+ "argument_id",
303
+ "source_id",
304
+ "doc_id",
305
+ "deck_id",
306
+ )
307
+
308
+ #: Entries that REMOVE something, rendered red with a `-`. `binding_removed` is
309
+ #: on this list and is the one worth spotting: it is Detach, the single action
310
+ #: that costs a number its provenance.
311
+ _DELIVERABLE_REMOVALS = frozenset(
312
+ {
313
+ "deck_removed",
314
+ "slide_removed",
315
+ "document_removed",
316
+ "block_removed",
317
+ "claim_removed",
318
+ "binding_removed",
319
+ # The reasoning graph. `argument_removed` is on this list for the same
320
+ # reason `binding_removed` is: it is a retraction. An edge that stops
321
+ # existing is a conclusion that stops being held up by something, and
322
+ # nothing else in the diff says so.
323
+ "assumption_removed",
324
+ "evidence_removed",
325
+ "argument_removed",
326
+ "source_removed",
327
+ }
328
+ )
329
+
330
+ _DELIVERABLE_ADDITIONS = frozenset(
331
+ {
332
+ "deck_added",
333
+ "slide_added",
334
+ "document_added",
335
+ "block_added",
336
+ "claim_added",
337
+ "binding_added",
338
+ "assumption_added",
339
+ "evidence_added",
340
+ "argument_added",
341
+ "source_added",
342
+ }
343
+ )
344
+
345
+
346
+ def _print_deliverable_changes(sd: dict) -> None:
347
+ """Render the deck, document and reasoning sections.
348
+
349
+ One renderer for three sections because the entries share a shape: a
350
+ `change_type`, an id, and an optional `old`/`new` pair. Three near-identical
351
+ branches would drift the first time one of them gained a field.
352
+ """
353
+ for section_key, noun in (
354
+ ("deck_changes", "Deck"),
355
+ ("document_changes", "Document"),
356
+ ("reasoning_changes", "Reasoning"),
357
+ ("source_changes", "Source"),
358
+ ):
359
+ changes = sd.get(section_key, [])
360
+ if not changes:
361
+ continue
362
+ click.echo(f" {noun} Changes:")
363
+ for entry in changes:
364
+ change_type = entry.get("change_type", "?")
365
+ target = next(
366
+ (str(entry[key]) for key in _DELIVERABLE_ID_KEYS if entry.get(key)),
367
+ "?",
368
+ )
369
+ # `@id` on an <Argument> is optional, so an edge entry may carry no
370
+ # id at all. Its address is the (from, rel, to) triple, and printing
371
+ # "?" for the one element kind that routinely has no id would make
372
+ # every retracted edge unreadable.
373
+ if target == "?" and entry.get("rel"):
374
+ target = (
375
+ f"{entry.get('from', '?')} -{entry['rel']}-> {entry.get('to', '?')}"
376
+ )
377
+ if change_type in _DELIVERABLE_ADDITIONS:
378
+ sign, color = "+", "green"
379
+ elif change_type in _DELIVERABLE_REMOVALS:
380
+ sign, color = "-", "red"
381
+ else:
382
+ sign, color = "~", None
383
+ name = entry.get("name") or entry.get("label")
384
+ line = f" {sign} {change_type}: {target}"
385
+ if name and str(name) != target:
386
+ line += f" ({name})"
387
+ if entry.get("field"):
388
+ line += f" {entry['field']}"
389
+ styled = click.style(line, fg=color) if color else line
390
+ # `old`/`new` carry the whole content of a rebind, so print them
391
+ # where they exist rather than leaving "binding_modified: b3" as
392
+ # the entire account of a number that changed what it points at.
393
+ if entry.get("old") is not None or entry.get("new") is not None:
394
+ click.echo(styled, nl=False)
395
+ _echo_old_new("", entry.get("old"), entry.get("new"))
396
+ else:
397
+ click.echo(styled)
398
+
399
+
400
+ @click.command()
401
+ @click.argument("revision")
402
+ @click.option("--file", "filename", default=None, help="Restore specific file only.")
403
+ @click.option("-y", "--yes", is_flag=True, help="Skip confirmation prompt.")
404
+ @pass_ctx
405
+ def restore(ctx: Ctx, revision: str, filename: str | None, yes: bool) -> None:
406
+ """Restore the workspace to a previous revision (like git checkout).
407
+
408
+ Restores every file unless --file names one. A full restore rebuilds the
409
+ tree from the target revision alone, so **files added after it are
410
+ deleted** — the response lists any it removed. Restoring is itself a new
411
+ commit, so nothing is lost for good: restore a newer revision to undo it.
412
+
413
+ This moves the workspace on the server. Local folders are untouched until
414
+ you `deepcell pull`.
415
+ """
416
+ if not yes:
417
+ click.confirm("Are you sure you want to restore this revision?", abort=True)
418
+ slug = ctx.require_workspace()
419
+ body: dict = {"target_version": revision}
420
+ if filename:
421
+ body["filename"] = filename
422
+
423
+ try:
424
+ data = ctx.client.post(f"/workspaces/{slug}/versions/restore", json=body)
425
+ except APIError as e:
426
+ if e.status_code == 400:
427
+ _handle_invalid_revision(e)
428
+ raise
429
+
430
+ output_mutation(data, ctx.fmt, plain_key="sha")
431
+ echo_success(f"Restored to revision {revision[:8]}")
432
+
433
+ # A whole-workspace restore rebuilds the tree from the target alone, so
434
+ # every file added after it is deleted. Nothing said so — the command
435
+ # printed a sha and exited 0 while the files were gone.
436
+ deleted = (data or {}).get("files_deleted") or []
437
+ if deleted:
438
+ echo_warning(
439
+ f"{len(deleted)} file(s) added after {revision[:8]} were DELETED: "
440
+ + ", ".join(str(f) for f in deleted)
441
+ )
442
+ echo_warning(
443
+ "Recover them from a newer revision (`deepcell log`, then "
444
+ "`deepcell download <file> --revision <sha>`)."
445
+ )
@@ -0,0 +1,54 @@
1
+ """``deepcell viewer`` — print the browser URL to open a workspace file.
2
+
3
+ A local URL constructor (no API call of its own — resolving the active
4
+ workspace may still hit ``GET /workspaces``): signed-in sessions get the
5
+ authenticated workbench route (``/agent?assistantId=deepcell&workspaceSlug=…
6
+ &file=…``) where they operate with full auth — editing, agent chat, history.
7
+ Anonymous sessions can't sign into the browser as the CLI's device-keyed
8
+ identity, so the command points them at ``share create`` / ``login`` instead.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from urllib.parse import urlencode
14
+
15
+ import click
16
+
17
+ from deepcell_cli.config import frontend_base_url, is_anonymous_session
18
+ from deepcell_cli.context import Ctx, pass_ctx
19
+
20
+
21
+ def workbench_url(slug: str, filename: str) -> str:
22
+ """The authenticated workbench deep link for *filename* in *slug*."""
23
+ query = urlencode(
24
+ {"assistantId": "deepcell", "workspaceSlug": slug, "file": filename}
25
+ )
26
+ return f"{frontend_base_url()}/agent?{query}"
27
+
28
+
29
+ @click.command()
30
+ @click.argument("filename")
31
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
32
+ @click.option("--open", "launch", is_flag=True, help="Open the URL in the default browser.")
33
+ @pass_ctx
34
+ def viewer(ctx: Ctx, filename: str, workspace_slug: str | None, launch: bool) -> None:
35
+ """Print the browser URL to open FILENAME in the web workbench.
36
+
37
+ Requires a signed-in session — the workbench authenticates as your
38
+ account. Otherwise create a public view link with
39
+ `deepcell share create <file>` instead, or run `deepcell login` first.
40
+ """
41
+ # Check anonymity before resolving the workspace: require_workspace()
42
+ # would otherwise auto-provision a scratch workspace for an anon session
43
+ # only for this command to error out anyway.
44
+ if is_anonymous_session():
45
+ raise click.ClickException(
46
+ "The browser can't sign in as this CLI session.\n"
47
+ f"To view the file, create a view link: deepcell share create {filename}\n"
48
+ "For the full workbench (edit, agent chat), run `deepcell login` first."
49
+ )
50
+ slug = workspace_slug or ctx.require_workspace()
51
+ url = workbench_url(slug, filename)
52
+ click.echo(url)
53
+ if launch:
54
+ click.launch(url)
@@ -0,0 +1,101 @@
1
+ """Project commands: list, use, info, create.
2
+
3
+ The Click-visible name is ``project``; ``workspace`` stays as a hidden
4
+ alias registered in ``main``. The MODULE keeps its name so it still
5
+ mirrors ``backend/jingwei_api/routers/workspaces.py`` 1:1, and so do the
6
+ function names and the ``active_workspace`` config key — renaming that
7
+ key would silently drop every existing user's active selection.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import click
13
+
14
+ from deepcell_cli.config import get_active_workspace, set_active_workspace
15
+ from deepcell_cli.errors import EmailVerificationRequired
16
+ from deepcell_cli.context import Ctx, pass_ctx
17
+ from deepcell_cli.output import echo_error, echo_info, echo_success, output, output_mutation
18
+
19
+
20
+ @click.group("project")
21
+ def project() -> None:
22
+ """Manage projects.
23
+
24
+ A project is a container for .deepcell files, version history, and
25
+ variants. Most commands require an active project — set one with
26
+ `deepcell project use <slug>`.
27
+ """
28
+
29
+
30
+ @project.command("list")
31
+ @pass_ctx
32
+ def workspace_list(ctx: Ctx) -> None:
33
+ """List projects you belong to."""
34
+ data = ctx.client.get("/workspaces")
35
+ active = get_active_workspace()
36
+ for ws in data:
37
+ if isinstance(ws, dict):
38
+ ws["active"] = ws.get("slug") == active
39
+ output(data, ctx.fmt)
40
+
41
+
42
+ @project.command("use")
43
+ @click.argument("slug")
44
+ @pass_ctx
45
+ def workspace_use(ctx: Ctx, slug: str) -> None:
46
+ """Set the active project for subsequent commands."""
47
+ # Validate the project exists and user has access
48
+ ctx.client.get(f"/workspaces/{slug}")
49
+ set_active_workspace(slug)
50
+ echo_success(f"Active project set to '{slug}'")
51
+
52
+
53
+ @project.command("info")
54
+ @click.argument("slug", required=False)
55
+ @pass_ctx
56
+ def workspace_info(ctx: Ctx, slug: str | None) -> None:
57
+ """Show project details."""
58
+ slug = slug or ctx.require_workspace()
59
+ data = ctx.client.get(f"/workspaces/{slug}")
60
+ output(data, ctx.fmt)
61
+
62
+
63
+ @project.command("create")
64
+ @click.argument("name")
65
+ @click.option(
66
+ "--slug",
67
+ help=(
68
+ "URL-friendly slug. Derived from the name by the server, with a short "
69
+ "random suffix, if omitted — slugs are unique across all projects."
70
+ ),
71
+ )
72
+ @click.option("--description", default="", help="Project description.")
73
+ @pass_ctx
74
+ def workspace_create(ctx: Ctx, name: str, slug: str | None, description: str) -> None:
75
+ """Create a new project."""
76
+ # Omitted `--slug` is sent as nothing at all, and the server derives one.
77
+ # It is the only party that can: slugs are globally unique while
78
+ # `GET /workspaces` returns only your own, so anything derived here is a
79
+ # guess against a namespace this process cannot read. Deriving locally also
80
+ # could not name a CJK project at all — stripping non-ASCII left "", under
81
+ # the server's 3-character floor, so the command refused a name the server
82
+ # is perfectly able to slug.
83
+ body: dict = {"name": name, "description": description}
84
+ if slug:
85
+ body["slug"] = slug
86
+ try:
87
+ data = ctx.client.post("/workspaces", json=body)
88
+ except EmailVerificationRequired:
89
+ echo_info("Email verification is required to create projects.")
90
+ from deepcell_cli.commands.auth import _run_verification_flow
91
+
92
+ if _run_verification_flow(ctx):
93
+ # Retry after successful verification
94
+ data = ctx.client.post("/workspaces", json=body)
95
+ else:
96
+ echo_error("Email verification failed. Cannot create project.")
97
+ raise SystemExit(1)
98
+ output_mutation(data, ctx.fmt, plain_key="slug")
99
+ echo_success(f"Project '{data.get('slug', name)}' created")
100
+
101
+