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
deepcell_cli/config.py ADDED
@@ -0,0 +1,352 @@
1
+ """Configuration and credentials management.
2
+
3
+ Stores config in ``~/.deepcell/config.json`` and credentials in
4
+ ``~/.deepcell/credentials.json`` (mode 0600).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import urlparse
15
+
16
+ _DIR = Path.home() / ".deepcell"
17
+ _CONFIG_FILE = _DIR / "config.json"
18
+ _CREDS_FILE = _DIR / "credentials.json"
19
+
20
+ # ── Defaults ────────────────────────────────────────────────
21
+
22
+ DEFAULT_API_URL = "https://beta.deepcell.net/api/jingwei"
23
+ DEFAULT_FORMAT = "plain"
24
+
25
+ # ── Environment overrides ───────────────────────────────────
26
+
27
+ ENV_API_URL = "DEEPCELL_API_URL"
28
+ ENV_API_URL_FALLBACK = "JINGWEI_API_URL"
29
+ ENV_ACCESS_TOKEN = "DEEPCELL_ACCESS_TOKEN"
30
+ ENV_PROJECT = "DEEPCELL_PROJECT"
31
+ #: The pre-rename spelling. Still honored — an exported variable lives in
32
+ #: people's CI config and shell profiles, where a rename is a silent break.
33
+ ENV_WORKSPACE = "DEEPCELL_WORKSPACE"
34
+ ENV_CONFIG = "DEEPCELL_CONFIG"
35
+ #: Which surface is calling. Unset means the CLI itself; the MCP server sets it
36
+ #: to ``mcp`` because it runs commands through this same package in-process, so
37
+ #: without it an agent's tool call is indistinguishable from a human at a
38
+ #: terminal — two different products sharing one funnel.
39
+ ENV_CLIENT = "DEEPCELL_CLIENT"
40
+
41
+ # ── Caller identification ──────────────────────────────────
42
+
43
+ #: Mirrors ``HEADER`` in ``backend/jingwei_api/analytics/client_surface.py``,
44
+ #: which parses it and stamps it onto every audit row. The duplication is
45
+ #: unavoidable (separate packages, no shared runtime) and the failure mode is
46
+ #: silent — a rename on one side degrades every CLI row to NULL — so
47
+ #: ``backend/jingwei_api/tests/test_client_surface_taxonomy.py`` diffs the two.
48
+ CLIENT_HEADER = "X-DeepCell-Client"
49
+
50
+ #: The surfaces this package may legitimately claim to be. `browser` is
51
+ #: deliberately absent: the server would accept it, and a CLI that could
52
+ #: announce itself as a browser would quietly corrupt the one number this
53
+ #: header exists to produce.
54
+ CLIENT_SURFACES = ("cli", "mcp")
55
+
56
+ DEFAULT_CLIENT_SURFACE = "cli"
57
+
58
+ # ── TLS warning tracking ───────────────────────────────────
59
+
60
+ #: API URLs already warned about in this process. The MCP server runs many
61
+ #: commands in one process, so this is what keeps it to one warning there.
62
+ _insecure_warned: set[str] = set()
63
+
64
+ #: API URLs already warned about from this config, across processes. A shell
65
+ #: agent runs one process per command, so "once per process" was every
66
+ #: command — 55+ copies of the same line per task, each one prefixed to the
67
+ #: output the agent was actually reading. The marker lives beside the config
68
+ #: (see :func:`state_file`), so a session-scoped ``DEEPCELL_CONFIG`` warns
69
+ #: once per session and a changed ``api_url`` warns again.
70
+ INSECURE_WARNED_FILE = "insecure-http-warned.json"
71
+
72
+
73
+ def _ensure_dir() -> None:
74
+ _DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
75
+ # Fix permissions if directory was created by older code
76
+ try:
77
+ _DIR.chmod(0o700)
78
+ except OSError:
79
+ pass
80
+
81
+
82
+ def _write_private_file(path: Path, content: str) -> None:
83
+ """Write *content* to *path* with mode 0600.
84
+
85
+ The **permissions** are atomic, which is the property that matters for a
86
+ file holding a token: ``os.open`` with ``O_CREAT | O_WRONLY | O_TRUNC``
87
+ creates it 0600, so it never exists world-readable, not even briefly.
88
+
89
+ The **content** is not. ``O_TRUNC`` empties the file before the write, so
90
+ a crash mid-write leaves it truncated and the caller re-authenticates.
91
+ That is the accepted trade: a temp-file-and-rename would survive the crash
92
+ but writes through a symlink differently and changes the inode every save,
93
+ and nothing here is worth that. Do not describe this function as an atomic
94
+ write.
95
+ """
96
+ fd = os.open(
97
+ str(path),
98
+ os.O_WRONLY | os.O_CREAT | os.O_TRUNC,
99
+ 0o600,
100
+ )
101
+ # Only this narrow window owns the raw fd. Once `fdopen` takes it, the
102
+ # file object owns it and closing it here too would be a double close —
103
+ # on a recycled fd number that is somebody else's file.
104
+ try:
105
+ handle = os.fdopen(fd, "w")
106
+ except BaseException:
107
+ os.close(fd)
108
+ raise
109
+ with handle as f:
110
+ f.write(content)
111
+
112
+
113
+ def warn_if_insecure(api_url: str) -> None:
114
+ """Warn on stderr, once per API URL per config, when *api_url* uses
115
+ plain HTTP against a non-localhost host.
116
+
117
+ Loud the first time: the whole warning plus where the acknowledgement
118
+ is recorded. Silent after that — the marker file beside the config
119
+ remembers the URL, so the warning is not repeated on every command a
120
+ shell agent runs. If the marker cannot be written the warning stays
121
+ per-process, which is the old behaviour and still correct.
122
+ """
123
+ parsed = urlparse(api_url)
124
+ if parsed.scheme != "http":
125
+ return
126
+ host = parsed.hostname or ""
127
+ if host in ("localhost", "127.0.0.1", "::1"):
128
+ return
129
+ if api_url in _insecure_warned:
130
+ return
131
+ _insecure_warned.add(api_url)
132
+ if api_url in _load_insecure_warned():
133
+ return
134
+ marker = state_file(INSECURE_WARNED_FILE)
135
+ try:
136
+ _record_insecure_warned(api_url)
137
+ where = f"Shown once per API URL; recorded in {marker}."
138
+ except OSError:
139
+ where = f"Could not record this in {marker}, so it will repeat."
140
+ print(
141
+ f"WARNING: sending credentials over plain HTTP to {host}. "
142
+ "Use HTTPS for non-localhost servers.\n"
143
+ f" {where}",
144
+ file=sys.stderr,
145
+ )
146
+
147
+
148
+ def _load_insecure_warned() -> list[str]:
149
+ """API URLs the marker file says were already warned about."""
150
+ path = state_file(INSECURE_WARNED_FILE)
151
+ try:
152
+ data = json.loads(path.read_text())
153
+ except (OSError, ValueError):
154
+ return []
155
+ urls = data.get("api_urls") if isinstance(data, dict) else None
156
+ return [u for u in urls if isinstance(u, str)] if isinstance(urls, list) else []
157
+
158
+
159
+ def _record_insecure_warned(api_url: str) -> None:
160
+ urls = _load_insecure_warned()
161
+ if api_url not in urls:
162
+ urls.append(api_url)
163
+ save_state_file(INSECURE_WARNED_FILE, {"api_urls": urls})
164
+
165
+
166
+ # ── Config (api_url, active_workspace, default_format) ──────
167
+
168
+
169
+ def _config_file() -> Path:
170
+ """Resolve the config path: DEEPCELL_CONFIG env > ~/.deepcell/config.json.
171
+
172
+ DEEPCELL_CONFIG gives each job a session-scoped config so parallel
173
+ processes never race on the shared file (issue #1017). Credentials are
174
+ deliberately NOT redirected — login state stays shared.
175
+ """
176
+ env = os.environ.get(ENV_CONFIG)
177
+ if env:
178
+ return Path(env).expanduser()
179
+ return _CONFIG_FILE
180
+
181
+
182
+ def load_config() -> dict[str, Any]:
183
+ """Load the config file, returning defaults if missing."""
184
+ path = _config_file()
185
+ if path.exists():
186
+ return json.loads(path.read_text())
187
+ return {}
188
+
189
+
190
+ def save_config(data: dict[str, Any]) -> None:
191
+ """Write config to disk (mode 0600)."""
192
+ path = _config_file()
193
+ if path is _CONFIG_FILE:
194
+ _ensure_dir()
195
+ else:
196
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
197
+ _write_private_file(path, json.dumps(data, indent=2) + "\n")
198
+
199
+
200
+ def state_file(name: str) -> Path:
201
+ """Path to a small state file living beside the resolved config file.
202
+
203
+ Deliberately *beside the config*, not always in ``~/.deepcell``: a job
204
+ given a session-scoped ``DEEPCELL_CONFIG`` gets session-scoped state too,
205
+ so parallel processes never race on a shared cache and a test that
206
+ redirects the config redirects the cache with it.
207
+ """
208
+ return _config_file().parent / name
209
+
210
+
211
+ def save_state_file(name: str, data: dict[str, Any]) -> None:
212
+ """Write *data* as JSON to :func:`state_file` (mode 0600)."""
213
+ path = state_file(name)
214
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
215
+ _write_private_file(path, json.dumps(data, indent=2) + "\n")
216
+
217
+
218
+ def get_api_url() -> str:
219
+ """Resolve API URL: DEEPCELL_API_URL > JINGWEI_API_URL > config > default."""
220
+ env = os.environ.get(ENV_API_URL)
221
+ if env:
222
+ return env.rstrip("/")
223
+ env_fallback = os.environ.get(ENV_API_URL_FALLBACK)
224
+ if env_fallback:
225
+ return env_fallback.rstrip("/")
226
+ cfg = load_config()
227
+ return cfg.get("api_url", DEFAULT_API_URL).rstrip("/")
228
+
229
+
230
+ def get_client_surface() -> str:
231
+ """Which surface is calling: ``cli`` (default) or ``mcp``.
232
+
233
+ Read from the environment on every call rather than cached at import: the
234
+ MCP server invokes this package in-process through ``CliRunner``, setting
235
+ the variable per invocation, so a value captured at import time would be
236
+ whatever the first caller happened to be.
237
+
238
+ An unrecognised value falls back to ``cli`` rather than being forwarded.
239
+ This header is a self-declaration, so the server validates it anyway; the
240
+ point of validating here too is that a typo in someone's shell profile
241
+ should degrade to "a CLI called us" — which is true — instead of dropping
242
+ the surface entirely.
243
+ """
244
+ raw = (os.environ.get(ENV_CLIENT) or "").strip().lower()
245
+ return raw if raw in CLIENT_SURFACES else DEFAULT_CLIENT_SURFACE
246
+
247
+
248
+ def client_headers(surface: str | None = None) -> dict[str, str]:
249
+ """The ``X-DeepCell-Client`` header every request to the API carries.
250
+
251
+ Sent on every call, authenticated or not. The surface used to be visible
252
+ only on the anonymous-mint body, so a signed-in CLI session was entirely
253
+ unattributed — which is most of the sessions worth counting.
254
+
255
+ ``surface`` overrides the environment lookup, for callers that know what
256
+ they are without being told: the MCP server's own OAuth machinery runs in
257
+ the server process, not inside a ``CliRunner`` invocation, so
258
+ ``DEEPCELL_CLIENT`` is not set there and it would otherwise report ``cli``.
259
+ """
260
+ from . import __version__
261
+
262
+ resolved = surface if surface in CLIENT_SURFACES else get_client_surface()
263
+ return {CLIENT_HEADER: f"{resolved}/{__version__}"}
264
+
265
+
266
+ def get_active_workspace() -> str | None:
267
+ """Resolve the active project: env var > config file."""
268
+ env = os.environ.get(ENV_PROJECT) or os.environ.get(ENV_WORKSPACE)
269
+ if env:
270
+ return env
271
+ return load_config().get("active_workspace")
272
+
273
+
274
+ def set_active_workspace(slug: str) -> None:
275
+ """Persist active workspace in config."""
276
+ cfg = load_config()
277
+ cfg["active_workspace"] = slug
278
+ save_config(cfg)
279
+
280
+
281
+ def get_default_format() -> str:
282
+ return load_config().get("default_format", DEFAULT_FORMAT)
283
+
284
+
285
+ # ── Credentials (access_token, refresh_token, …) ───────────
286
+
287
+
288
+ def load_credentials() -> dict[str, Any]:
289
+ """Load ``~/.deepcell/credentials.json``."""
290
+ if _CREDS_FILE.exists():
291
+ return json.loads(_CREDS_FILE.read_text())
292
+ return {}
293
+
294
+
295
+ def save_credentials(data: dict[str, Any]) -> None:
296
+ """Write credentials to disk (mode 0600, atomic permissions)."""
297
+ _ensure_dir()
298
+ _write_private_file(_CREDS_FILE, json.dumps(data, indent=2) + "\n")
299
+
300
+
301
+ def clear_credentials() -> None:
302
+ """Remove credentials file."""
303
+ if _CREDS_FILE.exists():
304
+ _CREDS_FILE.unlink()
305
+
306
+
307
+ def get_access_token() -> str | None:
308
+ """Resolve access token: env var > credentials file."""
309
+ env = os.environ.get(ENV_ACCESS_TOKEN)
310
+ if env:
311
+ return env
312
+ return load_credentials().get("access_token")
313
+
314
+
315
+ def get_refresh_token() -> str | None:
316
+ return load_credentials().get("refresh_token")
317
+
318
+
319
+ def frontend_base_url() -> str:
320
+ """Web-app base URL for links the CLI prints (share/viewer URLs).
321
+
322
+ ``FRONTEND_URL`` / ``DEEPCELL_FRONTEND_URL`` env vars win; otherwise the
323
+ URL is derived from the API URL (scheme + host — deployments serve the
324
+ frontend at ``/`` and the API under ``/api/jingwei`` on the same host).
325
+ Local dev stacks run the frontend on its own port, so export
326
+ ``FRONTEND_URL=http://localhost:3000`` there.
327
+ """
328
+ from urllib.parse import urlparse
329
+
330
+ env = os.environ.get("FRONTEND_URL") or os.environ.get("DEEPCELL_FRONTEND_URL")
331
+ if env:
332
+ return env.rstrip("/")
333
+ api_url = get_api_url()
334
+ parsed = urlparse(api_url)
335
+ if not parsed.scheme or not parsed.hostname:
336
+ return api_url # malformed API URL — pass it through rather than guess
337
+ return f"{parsed.scheme}://{parsed.netloc}"
338
+
339
+
340
+ def is_anonymous_session() -> bool:
341
+ """True when the stored credentials are an anonymous demo session.
342
+
343
+ Anonymous sessions are minted by the client from ``POST /demo/session``
344
+ (see ``DeepCellClient``); they carry ``anonymous: true`` plus the
345
+ server-issued ``device_id`` identity secret instead of a refresh token.
346
+ """
347
+ return bool(load_credentials().get("anonymous"))
348
+
349
+
350
+ def get_anon_device_id() -> str | None:
351
+ """The server-issued anonymous identity secret, if any."""
352
+ return load_credentials().get("device_id")
@@ -0,0 +1,187 @@
1
+ """Shared Click context (``Ctx`` + ``pass_ctx``) for the ``deepcell`` CLI.
2
+
3
+ Lives in its own module so command sub-modules can import it without pulling
4
+ in ``deepcell_cli.main``, which transitively re-imports every command module
5
+ to register them on the root group. Importing a command module directly (e.g.
6
+ from a parity test) used to trip a circular import via ``main``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+
13
+ import click
14
+
15
+ from deepcell_cli.client import DeepCellClient
16
+
17
+
18
+ class Ctx:
19
+ """Object carried through Click context for shared state."""
20
+
21
+ def __init__(
22
+ self, *, verbose: bool = False, fmt: str = "plain", workspace: str | None = None
23
+ ) -> None:
24
+ self.verbose = verbose
25
+ self.fmt = fmt
26
+ self.workspace = workspace
27
+ self._client: DeepCellClient | None = None
28
+
29
+ @property
30
+ def client(self) -> DeepCellClient:
31
+ if self._client is None:
32
+ # Resolved here, not stored at the root callback: there it is only
33
+ # the group name. No `self.command or …` override — a settable
34
+ # field would let a caller reintroduce exactly that.
35
+ self._client = DeepCellClient(
36
+ verbose=self.verbose, command=self._command_path()
37
+ )
38
+ return self._client
39
+
40
+ @staticmethod
41
+ def _command_path() -> str | None:
42
+ """Full invoked command, e.g. ``defs add-item`` — not just the group.
43
+
44
+ The root group only knows ``invoked_subcommand`` (``defs``), which is
45
+ the least useful half of the answer when this lands in a commit trailer
46
+ and, from there, in the Changes feed. The client is built lazily inside
47
+ the leaf command, so by now Click's context stack knows the whole path.
48
+
49
+ Built by walking the context chain rather than splitting
50
+ ``command_path``: the program name is not always one token. Under
51
+ ``python -m deepcell_cli`` Click reports it as ``python -m
52
+ deepcell_cli``, so dropping a single token left ``-m deepcell_cli defs
53
+ add-item`` — which the server's header validator rejects outright for
54
+ its leading dash, silently losing the trailer that this exists to send.
55
+ """
56
+ try:
57
+ ctx = click.get_current_context(silent=True)
58
+ if ctx is None:
59
+ return None
60
+ names: list[str] = []
61
+ # The root context's `info_name` IS the program name; everything
62
+ # below it is a real command token.
63
+ while ctx.parent is not None:
64
+ if ctx.info_name:
65
+ names.append(ctx.info_name)
66
+ ctx = ctx.parent
67
+ return " ".join(reversed(names)) or None
68
+ except Exception:
69
+ return None
70
+
71
+ def require_workspace(self) -> str:
72
+ """Return the active workspace slug, auto-selecting if possible."""
73
+ from deepcell_cli.config import get_active_workspace, set_active_workspace
74
+ from deepcell_cli.errors import ConfigError
75
+
76
+ if self.workspace:
77
+ return self.workspace
78
+
79
+ from deepcell_cli.config import ENV_WORKSPACE
80
+
81
+ env_ws = os.environ.get(ENV_WORKSPACE)
82
+ if env_ws:
83
+ return env_ws
84
+
85
+ # Inside a cloned workspace folder, that clone wins over the global
86
+ # config. `clone` never sets the active workspace, so commands that
87
+ # resolve through here (log / diff / restore / variant …) used to
88
+ # target whatever slug happened to be active — `deepcell restore` in
89
+ # ws-a/ rolled back a *different* workspace and reported success.
90
+ sync_slug = self._sync_root_workspace()
91
+ if sync_slug:
92
+ return sync_slug
93
+
94
+ ws = get_active_workspace()
95
+ if ws:
96
+ return ws
97
+
98
+ try:
99
+ workspaces = self.client.get("/workspaces")
100
+ except click.ClickException:
101
+ # Auth / connection / API errors carry the actionable message
102
+ # (e.g. "Not authenticated. Run `deepcell login` first.") — never
103
+ # mask them behind a workspace-selection hint.
104
+ raise
105
+ except Exception:
106
+ raise ConfigError("No active project. Run `deepcell project use <slug>` first.")
107
+
108
+ if not workspaces or not isinstance(workspaces, list) or len(workspaces) == 0:
109
+ from deepcell_cli.config import is_anonymous_session
110
+
111
+ if is_anonymous_session():
112
+ return self._create_scratch_workspace()
113
+ raise ConfigError("No projects found. Create one with `deepcell project create <name>`.")
114
+
115
+ if len(workspaces) == 1:
116
+ slug = workspaces[0].get("slug", "")
117
+ set_active_workspace(slug)
118
+ click.echo(f"Using project '{slug}' (only project available)", err=True)
119
+ return slug
120
+
121
+ from deepcell_cli.config import is_anonymous_session
122
+
123
+ if is_anonymous_session():
124
+ # An anonymous session is ONE identity with no account behind it,
125
+ # so "which of your workspaces did you mean?" has no answer the
126
+ # caller could give — and the caller is usually an agent, for whom
127
+ # a selection prompt is a dead end mid-task. Pick, and say which.
128
+ #
129
+ # Prefer the demo-kind one, because that is what every anonymous
130
+ # surface on the server already resolves: the find-or-create in
131
+ # `ensure_workspace_for_anonymous` and the `prefer_demo` thread
132
+ # binding both filter `kind = 'demo'`, and the DB allows only one
133
+ # live per creator (`ux_one_live_demo_workspace_per_user`). Picking
134
+ # anything else here would put the CLI and the browser on different
135
+ # workspaces for the same identity.
136
+ chosen = next(
137
+ (w for w in workspaces if w.get("kind") == "demo"), workspaces[0]
138
+ )
139
+ slug = chosen.get("slug", "")
140
+ if slug:
141
+ set_active_workspace(slug)
142
+ click.echo(f"Using project '{slug}'", err=True)
143
+ return slug
144
+
145
+ slugs = [w.get("slug", "?") for w in workspaces]
146
+ listing = "\n".join(f" - {s}" for s in slugs)
147
+ raise ConfigError(
148
+ f"Multiple projects available:\n{listing}\n\nSelect one with `deepcell project use <slug>`."
149
+ )
150
+
151
+ @staticmethod
152
+ def _sync_root_workspace() -> str | None:
153
+ """Workspace slug recorded in an enclosing clone's ``.deepcell/sync.json``."""
154
+ try:
155
+ from deepcell_cli.sync_state import find_sync_root, load_sync_state
156
+
157
+ root = find_sync_root()
158
+ if root is None:
159
+ return None
160
+ state = load_sync_state(root)
161
+ return getattr(state, "workspace_slug", None) or None
162
+ except Exception:
163
+ # A malformed/unreadable sync file must not break commands that
164
+ # would otherwise resolve the workspace from config.
165
+ return None
166
+
167
+ def _create_scratch_workspace(self) -> str:
168
+ """Create and activate a scratch workspace for an anonymous session.
169
+
170
+ Anonymous users start with no workspace at all; instead of telling
171
+ them to run `deepcell project create` we provision one so the first
172
+ `write`/`query` just works. The server treats it like any other
173
+ workspace owned by the anon user, so a later `deepcell login` carries
174
+ it into the real account.
175
+ """
176
+ import secrets
177
+
178
+ from deepcell_cli.config import set_active_workspace
179
+
180
+ slug = f"scratch-{secrets.token_hex(4)}"
181
+ self.client.post("/workspaces", json={"name": "Scratch workspace", "slug": slug})
182
+ set_active_workspace(slug)
183
+ click.echo(f"Created workspace '{slug}'", err=True)
184
+ return slug
185
+
186
+
187
+ pass_ctx = click.make_pass_decorator(Ctx, ensure=True)
deepcell_cli/errors.py ADDED
@@ -0,0 +1,141 @@
1
+ """Error types for the DeepCell CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import click
8
+ import httpx
9
+
10
+
11
+ class APIError(click.ClickException):
12
+ """Raised when the API returns a non-2xx response.
13
+
14
+ ``payload`` carries the parsed JSON ``detail`` when it was structured
15
+ (dict/list) so commands can render rich errors (e.g. replace conflicts);
16
+ ``detail`` is always the flattened string form.
17
+
18
+ ``headers`` carries the response headers, because some of what a caller
19
+ needs to *act* on an error is only there and never in the body: the write
20
+ surfaces answer a 409 with ``X-Conflict-Reason`` and ``X-Current-Revision``
21
+ (see ``jingwei_api/conflict.py``), and /batch-edit + /apply-defs-ops send
22
+ the reason in the header alone. Without this the CLI could only tell those
23
+ conflicts apart by matching on the English of ``detail``, which stops
24
+ working the first time someone rewords the message.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ status_code: int,
30
+ detail: str,
31
+ payload: object | None = None,
32
+ headers: dict[str, str] | None = None,
33
+ ) -> None:
34
+ self.status_code = status_code
35
+ self.detail = detail
36
+ self.payload = payload
37
+ self.headers = headers or {}
38
+ super().__init__(f"[{status_code}] {detail}")
39
+
40
+ def format_message(self) -> str: # noqa: D102
41
+ # For user-facing errors (4xx), show the detail directly without "API error" wrapper
42
+ if 400 <= self.status_code < 500:
43
+ return self.detail
44
+ return f"API error ({self.status_code}): {self.detail}"
45
+
46
+
47
+ class EmailVerificationRequired(APIError):
48
+ """Raised when the API rejects a request because email is not verified."""
49
+
50
+ def __init__(self, detail: str = "Email verification required") -> None:
51
+ super().__init__(403, detail)
52
+
53
+ def format_message(self) -> str: # noqa: D102
54
+ return (
55
+ "Email verification required. "
56
+ "Run `deepcell verify-email` to verify your email address."
57
+ )
58
+
59
+
60
+ class AccountRequiredError(APIError):
61
+ """A 401/403 hit while running in an anonymous demo session.
62
+
63
+ The server-side detail is kept (it says *what* was denied); the appended
64
+ hint turns the dead end into the way forward — signing in.
65
+ """
66
+
67
+ def format_message(self) -> str: # noqa: D102
68
+ return (
69
+ f"{self.detail}\n"
70
+ "This needs an account. Run `deepcell login` to sign in."
71
+ )
72
+
73
+
74
+ class SessionExpiredError(APIError):
75
+ """A 401 that survived a refresh attempt on a signed-in session.
76
+
77
+ The credentials no longer work: the refresh token was missing, rejected, or
78
+ revoked. Without this, the bare 401 detail from the server reached the user
79
+ unexplained — the one failure in the CLI that looked like a permissions
80
+ problem and was really "sign in again". An agent reading that has no reason
81
+ to stop retrying, which is the worst response to it.
82
+
83
+ ``from_env`` picks the fix that actually works. When the token came from
84
+ ``DEEPCELL_ACCESS_TOKEN`` the environment wins over the credentials file
85
+ (see ``config.get_access_token``), so telling that caller to sign in would
86
+ send it in a circle: `deepcell login` writes a file the env var then
87
+ overrides, and the next command fails identically.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ status_code: int,
93
+ detail: str,
94
+ payload: object | None = None,
95
+ *,
96
+ from_env: bool = False,
97
+ ) -> None:
98
+ super().__init__(status_code, detail, payload=payload)
99
+ self.from_env = from_env
100
+
101
+ def format_message(self) -> str: # noqa: D102
102
+ fix = (
103
+ "The token in DEEPCELL_ACCESS_TOKEN is no longer valid. Replace it, "
104
+ "or unset it to use the credentials from `deepcell login`."
105
+ if self.from_env
106
+ else "Your session has expired. Run `deepcell login` to sign in again."
107
+ )
108
+ return f"{fix}\n(the server said: {self.detail})"
109
+
110
+
111
+ class AuthError(click.ClickException):
112
+ """Raised when authentication fails or is missing."""
113
+
114
+ def __init__(self, message: str = "Not authenticated. Run `deepcell login` first.") -> None:
115
+ super().__init__(message)
116
+
117
+
118
+ class ConnectionError(click.ClickException):
119
+ """Raised when the CLI cannot connect to the API server."""
120
+
121
+ def __init__(self, url: str, cause: Exception) -> None:
122
+ self.url = url
123
+ if "Connection refused" in str(cause):
124
+ hint = "Is the API server running?"
125
+ elif isinstance(cause, httpx.TimeoutException):
126
+ hint = "The request timed out."
127
+ else:
128
+ hint = str(cause)
129
+ super().__init__(f"Could not connect to {url} — {hint}")
130
+
131
+
132
+ class ConfigError(click.ClickException):
133
+ """Raised for configuration problems (missing workspace, etc.)."""
134
+
135
+ pass
136
+
137
+
138
+ def die(message: str, code: int = 1) -> None:
139
+ """Print an error to stderr and exit."""
140
+ click.echo(f"Error: {message}", err=True)
141
+ sys.exit(code)