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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- 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")
|
deepcell_cli/context.py
ADDED
|
@@ -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)
|