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,161 @@
1
+ """Log configuration for the ``deepcell-mcp`` server.
2
+
3
+ Why this duplicates ``backend/src/core/logging_config.py``
4
+ -----------------------------------------------------------
5
+ It cannot import it. ``deepcell-cli`` is a standalone published package, and
6
+ the ``mcp-server`` container is built from the ``cli/`` context alone
7
+ (``docker/docker-compose.yml``) — ``backend/`` is not in the image. So the wire
8
+ contract is shared, the code is not.
9
+
10
+ **The wire contract is the thing to keep in sync**, not this file's structure:
11
+ the key names below (``level`` lowercase, ``message``, ``logger``, ``service``,
12
+ ``logging.googleapis.com/sourceLocation``) are what
13
+ ``docker/ops-agent/config.yaml`` parses and ``docker/ops-agent/verify.sh``
14
+ asserts. ``level`` rather than ``severity`` is deliberate — see that config's
15
+ header for why a plain ``severity`` key does not set the LogEntry severity.
16
+
17
+ Everything goes to **stderr**, never stdout. Under the stdio transport, stdout
18
+ *is* the MCP protocol channel: a log line there corrupts the session. Docker's
19
+ json-file driver captures both streams, so the deployed stack loses nothing.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import os
27
+ import sys
28
+ from datetime import datetime, timezone
29
+
30
+ _LOG_FORMATS = ("text", "json")
31
+
32
+ _TEXT_FMT = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
33
+
34
+ _SOURCE_LOCATION_KEY = "logging.googleapis.com/sourceLocation"
35
+
36
+ # Attribute names a fresh LogRecord already owns; anything else on a record was
37
+ # put there by a caller's ``extra=``. Derived rather than hard-coded because the
38
+ # set moves between Python versions (``taskName`` arrived in 3.12).
39
+ _RESERVED_RECORD_ATTRS = frozenset(
40
+ logging.LogRecord("x", logging.INFO, "x", 0, "x", None, None).__dict__
41
+ ) | {"message", "asctime"}
42
+
43
+ # Top-level keys this formatter owns. An extra colliding with one is prefixed
44
+ # rather than allowed to overwrite it — losing the message because a caller
45
+ # passed ``extra={"message": ...}`` would be a bad trade.
46
+ _JSON_RESERVED_KEYS = frozenset(
47
+ {"time", "level", "message", "logger", "service", "stack_trace", _SOURCE_LOCATION_KEY}
48
+ )
49
+
50
+
51
+ class JsonFormatter(logging.Formatter):
52
+ """One Cloud Logging-shaped JSON object per record.
53
+
54
+ Per-record ``extra=`` fields become **top-level keys**, matching
55
+ ``backend/src/core/logging_config.py`` — a field named the same way must
56
+ land under the same key whichever service emitted it, since both feed the
57
+ same ``jsonPayload.<field>`` queries.
58
+
59
+ Never raises: ``default=str`` stringifies anything unserializable rather
60
+ than losing the line to a formatter traceback. ``ensure_ascii=False`` keeps
61
+ emoji as UTF-8.
62
+ """
63
+
64
+ def __init__(self, service: str | None = None) -> None:
65
+ super().__init__()
66
+ self._service = service if service is not None else _resolve_service()
67
+
68
+ def format(self, record: logging.LogRecord) -> str:
69
+ payload: dict[str, object] = {
70
+ "time": datetime.fromtimestamp(record.created, timezone.utc)
71
+ .isoformat()
72
+ .replace("+00:00", "Z"),
73
+ "level": record.levelname.lower(),
74
+ "message": record.getMessage(),
75
+ "logger": record.name,
76
+ _SOURCE_LOCATION_KEY: {
77
+ "file": record.filename,
78
+ "line": str(record.lineno),
79
+ "function": record.funcName,
80
+ },
81
+ }
82
+ if self._service:
83
+ payload["service"] = self._service
84
+ if record.exc_info:
85
+ payload["stack_trace"] = self.formatException(record.exc_info)
86
+ elif record.exc_text:
87
+ payload["stack_trace"] = record.exc_text
88
+
89
+ for key, value in record.__dict__.items():
90
+ if key in _RESERVED_RECORD_ATTRS:
91
+ continue
92
+ payload[f"ctx_{key}" if key in _JSON_RESERVED_KEYS else key] = value
93
+
94
+ return json.dumps(payload, ensure_ascii=False, default=str)
95
+
96
+
97
+ def _resolve_service() -> str | None:
98
+ """The `service` label, from LOG_SERVICE.
99
+
100
+ Docker's json-file path carries the container ID, not its name, so
101
+ ``resource.labels.container_name`` does not exist for a GCE VM tail. Each
102
+ service labels itself instead.
103
+ """
104
+ return os.getenv("LOG_SERVICE", "").strip() or None
105
+
106
+
107
+ def _resolve_format(default: str = "text") -> tuple[str, str | None]:
108
+ """Read LOG_FORMAT, falling back to `default` with a complaint if unusable."""
109
+ raw = os.getenv("LOG_FORMAT", "").strip().lower()
110
+ if not raw:
111
+ return default, None
112
+ if raw not in _LOG_FORMATS:
113
+ return default, (
114
+ f"Ignoring unrecognized LOG_FORMAT={raw!r}; expected one of "
115
+ f"{', '.join(_LOG_FORMATS)}. Using {default}."
116
+ )
117
+ return raw, None
118
+
119
+
120
+ def _resolve_level(default: int = logging.INFO) -> tuple[int, str | None]:
121
+ """Read LOG_LEVEL (name or number), falling back to `default` with a complaint."""
122
+ raw = os.getenv("LOG_LEVEL", "").strip()
123
+ if not raw:
124
+ return default, None
125
+ if raw.isdigit():
126
+ return int(raw), None
127
+ resolved = getattr(logging, raw.upper(), None)
128
+ # `getLevelName` round-trips only for registered levels; anything else comes
129
+ # back as the "Level %s" placeholder — so `LOG_LEVEL=raiseExceptions` is
130
+ # rejected rather than silently resolved to some unrelated module attribute.
131
+ if (
132
+ isinstance(resolved, int)
133
+ and not isinstance(resolved, bool)
134
+ and not logging.getLevelName(resolved).startswith("Level ")
135
+ ):
136
+ return resolved, None
137
+ return default, (
138
+ f"Ignoring unrecognized LOG_LEVEL={raw!r}; "
139
+ f"using {logging.getLevelName(default)}"
140
+ )
141
+
142
+
143
+ def setup_logging(level: int = logging.INFO) -> None:
144
+ """Install the MCP server's stderr log handler.
145
+
146
+ Honours ``LOG_LEVEL`` (name or number), ``LOG_FORMAT`` (``text``/``json``)
147
+ and ``LOG_SERVICE``. Safe to call more than once — ``force=True`` replaces
148
+ handlers rather than stacking them.
149
+ """
150
+ resolved_level, level_problem = _resolve_level(level)
151
+ fmt, fmt_problem = _resolve_format()
152
+
153
+ handler = logging.StreamHandler(sys.stderr)
154
+ handler.setFormatter(
155
+ JsonFormatter() if fmt == "json" else logging.Formatter(_TEXT_FMT)
156
+ )
157
+ logging.basicConfig(level=resolved_level, handlers=[handler], force=True)
158
+
159
+ for problem in (fmt_problem, level_problem):
160
+ if problem:
161
+ logging.getLogger(__name__).warning(problem)
deepcell_cli/main.py ADDED
@@ -0,0 +1,518 @@
1
+ """Root Click group and global options for the ``deepcell`` CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+
7
+ import click
8
+
9
+ from deepcell_cli import __version__
10
+ from deepcell_cli.config import DEFAULT_API_URL
11
+ from deepcell_cli.context import Ctx, pass_ctx
12
+
13
+
14
+ # ── Flexible group (global opts after subcommand) ─────────
15
+
16
+
17
+ class FlexibleGroup(click.Group):
18
+ """Click Group that allows global options after the subcommand name.
19
+
20
+ Supports: deepcell guide orient/start -f plain
21
+ In addition to: deepcell -f plain guide orient/start
22
+ """
23
+
24
+ # Global options that take a value
25
+ _GLOBAL_VALUE_OPTS = {"-f", "--format", "--project", "--workspace"}
26
+ # Global flags (no value)
27
+ _GLOBAL_FLAG_OPTS = {"-v", "--verbose"}
28
+
29
+ def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
30
+ """Render the command index staged, in the order the work happens.
31
+
32
+ The stage of each command is declared in ``deepcell_cli.stages`` and
33
+ nowhere else. There is deliberately no fallback bucket: a command with
34
+ no stage does not quietly appear under ``Other``, it fails
35
+ ``stages.assert_fully_staged`` in the test suite. The old safety net is
36
+ exactly how ``account`` and ``changes`` went uncategorized unnoticed.
37
+
38
+ Within a stage, only commands with a confusable sibling get a summary
39
+ line (``stages.DISAMBIGUATED``); the rest collapse to a name run. That
40
+ is what keeps this near ``deepcell guide``'s one-screen budget while
41
+ still answering the question an agent actually gets wrong — which of
42
+ ``describe`` / ``query`` / ``grep`` to reach for.
43
+ """
44
+ from deepcell_cli import stages
45
+
46
+ commands = {name: self.get_command(ctx, name) for name in self.list_commands(ctx)}
47
+ visible = {n: c for n, c in commands.items() if c and not c.hidden}
48
+
49
+ def _run(names: list[str]) -> str:
50
+ return " · ".join(names)
51
+
52
+ for tier in stages.STAGE_ORDER:
53
+ names = [n for n in stages.commands_in(tier) if n in visible]
54
+ if not names:
55
+ continue
56
+ heading = f"{tier.upper()} — {stages.STAGE_SUMMARY[tier]}"
57
+ with formatter.section(heading):
58
+ rows = [
59
+ (n, visible[n].get_short_help_str(limit=formatter.width))
60
+ for n in names
61
+ if n in stages.DISAMBIGUATED
62
+ ]
63
+ if rows:
64
+ formatter.write_dl(rows)
65
+ rest = [n for n in names if n not in stages.DISAMBIGUATED]
66
+ if rest:
67
+ formatter.write_text(_run(rest))
68
+
69
+ # The three tiers that are not stages: one collapsed line each.
70
+ tail = [
71
+ (tier, [n for n in stages.commands_in(tier) if n in visible])
72
+ for tier in stages.TAIL_ORDER
73
+ ]
74
+ tail = [(t, n) for t, n in tail if n]
75
+ if tail:
76
+ # Short dl terms on purpose. Folding the tier summary into the term
77
+ # ("Learn (the five reference surfaces)") overruns Click's term
78
+ # column and throws the whole name run onto its own deeply indented
79
+ # line — the summaries are not worth that, since these three tiers
80
+ # are self-describing in a way the stages are not.
81
+ with formatter.section("Also"):
82
+ formatter.write_dl(
83
+ [(tier.capitalize(), _run(names)) for tier, names in tail]
84
+ )
85
+
86
+ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
87
+ super().format_help(ctx, formatter)
88
+ status = _build_status_section()
89
+ if status:
90
+ formatter.write("\n")
91
+ formatter.write(status)
92
+
93
+ def resolve_command(self, ctx: click.Context, args: list[str]):
94
+ try:
95
+ return super().resolve_command(ctx, args)
96
+ except click.UsageError as exc:
97
+ if args and "No such command" in str(exc):
98
+ _track_unknown_command(args[0], args[1:])
99
+ raise
100
+
101
+ def _target_command_opts(
102
+ self, cmd_name: str, rest: list[str]
103
+ ) -> tuple[set[str], int]:
104
+ """Options accepted by the final command in the chain, and where it ends.
105
+
106
+ Walks nested groups (``defs add-item``) using the leading
107
+ positionals so hoisting can defer to an option the subcommand itself
108
+ defines (which may be repeatable or carry different semantics).
109
+
110
+ The second element is the index in *rest* just past the resolved
111
+ subcommand chain. Only options AFTER that point belong to the
112
+ subcommand: ``defs --workspace a add-item`` writes it at group
113
+ position, where the user means the global option.
114
+ """
115
+ cmd: click.Command | None = self.commands.get(cmd_name)
116
+ i = 0
117
+ while isinstance(cmd, click.Group) and i < len(rest):
118
+ nxt = rest[i]
119
+ if nxt.startswith("-"):
120
+ # Step OVER a leading global option instead of giving up on the
121
+ # walk. Bailing here resolves a nested command to its group,
122
+ # whose params may differ from the target leaf's options.
123
+ if nxt in self._GLOBAL_VALUE_OPTS and i + 1 < len(rest):
124
+ i += 2
125
+ elif nxt in self._GLOBAL_FLAG_OPTS or nxt.startswith(
126
+ tuple(f"{o}=" for o in self._GLOBAL_VALUE_OPTS)
127
+ ):
128
+ i += 1
129
+ else:
130
+ break
131
+ continue
132
+ sub = cmd.commands.get(nxt)
133
+ if sub is None:
134
+ break
135
+ cmd = sub
136
+ i += 1
137
+ opts: set[str] = set()
138
+ if cmd is not None:
139
+ for p in cmd.params:
140
+ opts.update(p.opts)
141
+ opts.update(p.secondary_opts)
142
+ return opts, i
143
+
144
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
145
+ # Find subcommand position. Skip the VALUE of a leading global option
146
+ # as well as the option itself — otherwise `--workspace query describe
147
+ # f.deepcell` treated the workspace name `query` as the subcommand.
148
+ # `mcp_server._command_index` already resolves this way; the two
149
+ # parsers disagreeing is what let blocked-command checks and the real
150
+ # dispatch see different commands.
151
+ cmd_idx = None
152
+ i = 0
153
+ while i < len(args):
154
+ arg = args[i]
155
+ if arg in self._GLOBAL_VALUE_OPTS:
156
+ i += 2
157
+ continue
158
+ if arg.startswith("-"):
159
+ i += 1
160
+ continue
161
+ if arg in self.commands:
162
+ cmd_idx = i
163
+ break
164
+ i += 1
165
+
166
+ if cmd_idx is not None:
167
+ before = list(args[:cmd_idx])
168
+ cmd_name = args[cmd_idx]
169
+ after = list(args[cmd_idx + 1 :])
170
+
171
+ # An option the target subcommand defines itself must stay with it —
172
+ # Hoisting an option that a nested leaf defines can silently change
173
+ # its meaning. The same option written BEFORE the subcommand name is
174
+ # not yet in the subcommand's scope, so Click would reject it on the
175
+ # group; move it across the subcommand instead of hoisting it to root.
176
+ local_opts, sub_end = self._target_command_opts(cmd_name, after)
177
+ hoist_value_opts = self._GLOBAL_VALUE_OPTS - local_opts
178
+ hoist_flag_opts = self._GLOBAL_FLAG_OPTS - local_opts
179
+
180
+ new_after: list[str] = []
181
+ relocated: list[str] = []
182
+ i = 0
183
+ while i < len(after):
184
+ a = after[i]
185
+ # Before the subcommand name, a local option is relocated after
186
+ # it; after the subcommand name it is simply left in place.
187
+ sink = new_after if i >= sub_end else relocated
188
+ if a in self._GLOBAL_VALUE_OPTS and i + 1 < len(after):
189
+ (before if a in hoist_value_opts else sink).extend(
190
+ [a, after[i + 1]]
191
+ )
192
+ i += 2
193
+ elif a.startswith(
194
+ tuple(f"{o}=" for o in self._GLOBAL_VALUE_OPTS)
195
+ ):
196
+ opt = a.split("=", 1)[0]
197
+ (before if opt in hoist_value_opts else sink).append(a)
198
+ i += 1
199
+ elif a in self._GLOBAL_FLAG_OPTS:
200
+ (before if a in hoist_flag_opts else sink).append(a)
201
+ i += 1
202
+ else:
203
+ new_after.append(a)
204
+ i += 1
205
+
206
+ args = before + [cmd_name] + new_after + relocated
207
+
208
+ return super().parse_args(ctx, args)
209
+
210
+
211
+ # Tracking daemons started by `_track_unknown_command`. In production this is
212
+ # fire-and-forget — daemons die with the process. In tests the list lets the
213
+ # conftest drain in-flight threads between tests so a stale POST doesn't leak
214
+ # into a successor test's `respx` mock context.
215
+ _TRACKING_THREADS: list = []
216
+
217
+
218
+ def _track_unknown_command(command: str, args: list[str]) -> None:
219
+ """Fire-and-forget POST of unknown command to backend."""
220
+ import threading
221
+
222
+ from deepcell_cli.config import client_headers as _client_headers
223
+
224
+ # Resolved HERE, on the calling thread, not inside `_send`. The surface
225
+ # comes from an environment variable, and under the MCP server this runs
226
+ # inside `CliRunner.invoke`, whose isolation restores os.environ the moment
227
+ # the command returns — usually before the daemon thread gets scheduled. A
228
+ # thread reading it later would report `cli` for an MCP caller.
229
+ surface_headers = dict(_client_headers())
230
+
231
+ def _send() -> None:
232
+ try:
233
+ import time
234
+
235
+ import httpx
236
+
237
+ from deepcell_cli.config import get_access_token, get_api_url
238
+
239
+ token = get_access_token()
240
+ headers = dict(surface_headers)
241
+ if token:
242
+ headers["Authorization"] = f"Bearer {token}"
243
+ httpx.Client(timeout=5.0).post(
244
+ f"{get_api_url()}/cli/unknown-commands",
245
+ json={
246
+ "command": command,
247
+ "args": args,
248
+ "cli_version": __version__,
249
+ "timestamp": time.time(),
250
+ },
251
+ headers=headers,
252
+ )
253
+ except Exception:
254
+ pass
255
+
256
+ t = threading.Thread(target=_send, daemon=True)
257
+ _TRACKING_THREADS.append(t)
258
+ t.start()
259
+
260
+
261
+ def _build_status_section() -> str:
262
+ """Build a dynamic Status section for --help output.
263
+
264
+ The facts come from the same collector `deepcell doctor` uses, so the two
265
+ surfaces cannot disagree about who you are or where your next command
266
+ lands. Only the rendering differs: this is a footnote on a help screen,
267
+ `doctor` is the command you run when the footnote says something is wrong.
268
+ """
269
+ from deepcell_cli.commands.doctor import _collect
270
+
271
+ try:
272
+ # No health probe: --help is typed constantly and does not report
273
+ # server state, so the round trip would be latency for nothing.
274
+ report = _collect(Ctx(), timeout=5.0, probe=False)
275
+ except Exception:
276
+ # A help screen must render even when the environment is broken.
277
+ return ""
278
+
279
+ lines = ["Status:"]
280
+ if report["identity"] == "none":
281
+ lines.append(" Not signed in. Run `deepcell login` to sign in.")
282
+ return "\n".join(lines) + "\n"
283
+
284
+ if report["identity"] == "anonymous":
285
+ lines.append(" Not signed in. Run `deepcell login` to sign in.")
286
+ else:
287
+ lines.append(f" Logged in as {report['email'] or 'unknown'}")
288
+
289
+ workspace = report["workspace"]
290
+ if not workspace:
291
+ lines.append(
292
+ ' No active project. Run `deepcell project create "My Project"` to get started.'
293
+ )
294
+ return "\n".join(lines) + "\n"
295
+
296
+ count = report["files"]
297
+ if count is None:
298
+ lines.append(f" Project: {workspace}")
299
+ else:
300
+ lines.append(f" Project: {workspace} ({count} file{'s' if count != 1 else ''})")
301
+
302
+ lines.append("\n `deepcell doctor` checks all of this, including the server.")
303
+ return "\n".join(lines) + "\n"
304
+
305
+
306
+ # ── Root group ──────────────────────────────────────────────
307
+
308
+ # f-string so the documented default can never drift from the real one in
309
+ # deepcell_cli.config (issue #998: the epilog used to claim localhost while
310
+ # the built-in default pointed at production).
311
+ _EPILOG = f"""\b
312
+ Getting started:
313
+ deepcell doctor # check this machine's setup
314
+ deepcell guide orient/start # build something (no account needed)
315
+ deepcell register # keep your work (or: deepcell login)
316
+
317
+ \b
318
+ Environment variables:
319
+ DEEPCELL_API_URL API endpoint (default: {DEFAULT_API_URL})
320
+ DEEPCELL_PROJECT Override active project (DEEPCELL_WORKSPACE still honored)
321
+ DEEPCELL_CONFIG Alternate config.json path (session-scoped config)
322
+ DEEPCELL_ACCESS_TOKEN Auth token for CI/headless use
323
+ DEEPCELL_NO_ANON Set to opt out of the anonymous session
324
+ DEEPCELL_NO_UPGRADE_CHECK Set to silence the upgrade-available notice
325
+ """
326
+
327
+
328
+ class _FormatChoice(click.Choice):
329
+ """``-f`` is ``--format``, but nearly every command here takes a FILE.
330
+
331
+ ``-f <file>`` is the natural guess, and the stock Choice error answers it
332
+ with "'model.deepcell' is not one of 'json', 'table', 'plain'" — accurate,
333
+ and silent about the one thing the caller needed to know: the filename is a
334
+ *positional* argument. In the 2026-08-09 cli-eval run a worker made this
335
+ exact call three times in a row (`revise_structure_rename`), re-reading the
336
+ same unhelpful error each time — the "error message that did not teach"
337
+ signature. Say the actual fix instead.
338
+ """
339
+
340
+ def convert(self, value, param, ctx): # type: ignore[no-untyped-def]
341
+ if isinstance(value, str) and value.endswith(".deepcell"):
342
+ # Deliberately NOT ctx.command_path: -f is a group-level option, so
343
+ # at this point the context is the root group and command_path is
344
+ # bare "deepcell" — rendering it into the example would suggest
345
+ # `deepcell model.deepcell ...`, which is not a command at all.
346
+ self.fail(
347
+ f"{value!r} is a filename, but -f/--format selects the output "
348
+ f"format ({', '.join(self.choices)}). Filenames are positional "
349
+ f"arguments — `deepcell <command> {value} ...`, not -f. "
350
+ f"Run `deepcell <command> --help` for the exact argument order.",
351
+ param,
352
+ ctx,
353
+ )
354
+ return super().convert(value, param, ctx)
355
+
356
+
357
+ # help_option_names inherits through Click's Context into every subcommand
358
+ # and group, so this single setting enables `-h` CLI-wide (eval U4: it
359
+ # previously worked nowhere, two exit-2s in one trace).
360
+ @click.group(
361
+ cls=FlexibleGroup,
362
+ epilog=_EPILOG,
363
+ context_settings={"help_option_names": ["-h", "--help"]},
364
+ )
365
+ @click.version_option(__version__, prog_name="deepcell")
366
+ @click.option("-v", "--verbose", is_flag=True, help="Print debug info to stderr.")
367
+ @click.option(
368
+ "-f",
369
+ "--format",
370
+ "fmt",
371
+ type=_FormatChoice(["json", "table", "plain"]),
372
+ default=None,
373
+ help="Output format (default: plain).",
374
+ )
375
+ @click.option(
376
+ "--project",
377
+ "--workspace",
378
+ "workspace",
379
+ default=None,
380
+ metavar="SLUG",
381
+ help="Target project for this invocation (overrides DEEPCELL_PROJECT and the active project).",
382
+ )
383
+ @click.pass_context
384
+ def cli(ctx: click.Context, verbose: bool, fmt: str | None, workspace: str | None) -> None:
385
+ """DeepCell CLI — put ideas, calculations, documents and slides in one file.
386
+
387
+ A .deepcell file keeps conclusions with the assumptions, evidence and
388
+ calculations behind them, and it remembers how the parts connect: change
389
+ one idea and you can see what it affects. Four selectable surfaces —
390
+ Reasoning (ideas), Spreadsheet (calculations), Document (prose), Deck
391
+ (slides). Qualitative work may need no Spreadsheet; another may use all four.
392
+
393
+ \b
394
+ Start here — the two entry points, and what each is for:
395
+ deepcell help [command] What to TYPE. Exact flags, arguments, exits.
396
+ deepcell guide [topic] What to DO next. The procedure, in order.
397
+
398
+ \b
399
+ Then, when a specific answer is needed:
400
+ deepcell rules [id] Invariants that must remain true.
401
+ deepcell ref [id] Legal names and values, generated from code.
402
+ deepcell example list Complete valid artifacts and transcripts.
403
+ """
404
+ from deepcell_cli.config import get_default_format
405
+ from deepcell_cli.upgrade_check import schedule as schedule_upgrade_check
406
+
407
+ ctx.ensure_object(Ctx)
408
+ ctx.obj.verbose = verbose
409
+ ctx.obj.fmt = fmt or get_default_format()
410
+ ctx.obj.workspace = workspace
411
+ # No `ctx.obj.command` here on purpose: `ctx.invoked_subcommand` is the
412
+ # group only (`defs`, never `defs add-item`). `Ctx.client` resolves the
413
+ # full path from Click's context stack once a leaf command is running.
414
+
415
+ # Reads a cached answer (no network) and defers the notice to context
416
+ # close, so it lands after the command's own output. Never raises.
417
+ schedule_upgrade_check(ctx)
418
+
419
+
420
+ # ── Register sub-commands ───────────────────────────────────
421
+
422
+ from deepcell_cli.commands.auth import login, register, logout, whoami, verify_email # noqa: E402
423
+ from deepcell_cli.commands.account import account # noqa: E402
424
+ from deepcell_cli.commands.workspace import project # noqa: E402
425
+ from deepcell_cli.commands.files import ls, cat, write, rm # noqa: E402
426
+ from deepcell_cli.commands.download import download # noqa: E402
427
+ from deepcell_cli.commands.describe import describe # noqa: E402
428
+ from deepcell_cli.commands.query import query, cell_meta, relationships # noqa: E402
429
+ from deepcell_cli.commands.edit import edit # noqa: E402
430
+ from deepcell_cli.commands.replace import replace # noqa: E402
431
+ from deepcell_cli.commands.defs import defs # noqa: E402
432
+ from deepcell_cli.commands.guide import guide # noqa: E402
433
+ from deepcell_cli.commands.rules import rules # noqa: E402
434
+ from deepcell_cli.commands.ref import ref # noqa: E402
435
+ from deepcell_cli.commands.help_cmd import help_cmd # noqa: E402
436
+ from deepcell_cli.commands.example import example # noqa: E402
437
+ from deepcell_cli.commands.doctor import doctor # noqa: E402
438
+ from deepcell_cli.commands.export import to_excel # noqa: E402
439
+ from deepcell_cli.commands.deck import deck # noqa: E402
440
+ from deepcell_cli.commands.doc import doc # noqa: E402
441
+ from deepcell_cli.commands.impact import impact # noqa: E402
442
+ from deepcell_cli.commands.export_docx import to_docx # noqa: E402
443
+ from deepcell_cli.commands.export_pdf import to_pdf # noqa: E402
444
+ from deepcell_cli.commands.export_pptx import to_pptx # noqa: E402
445
+ from deepcell_cli.commands.version import log, diff, restore # noqa: E402
446
+ from deepcell_cli.commands.changes import changes # noqa: E402
447
+ from deepcell_cli.commands.variant import variant # noqa: E402
448
+ from deepcell_cli.commands.grep import grep # noqa: E402
449
+ from deepcell_cli.commands.sync import clone, pull, push, sync_status, commit # noqa: E402
450
+ from deepcell_cli.commands.merge import merge # noqa: E402
451
+ from deepcell_cli.commands.import_cmd import import_cmd # noqa: E402
452
+ from deepcell_cli.commands.ingest import ingest # noqa: E402
453
+ from deepcell_cli.commands.reasoning import claim, assumption, reasoning, reasoning_diff # noqa: E402
454
+ from deepcell_cli.commands.share import share # noqa: E402
455
+ from deepcell_cli.commands.viewer import viewer # noqa: E402
456
+ from deepcell_cli.commands.upgrade import upgrade # noqa: E402
457
+
458
+ cli.add_command(login)
459
+ cli.add_command(register)
460
+ cli.add_command(logout)
461
+ cli.add_command(whoami)
462
+ cli.add_command(account)
463
+ cli.add_command(verify_email, "verify-email")
464
+ cli.add_command(project)
465
+ # ``workspace`` was the name until the concept was renamed. It stays
466
+ # registered so existing scripts and muscle memory keep working, but
467
+ # hidden: `surface.py` and `main`'s own help both skip hidden commands,
468
+ # so it produces no help row and no entry in any generated surface.
469
+ _workspace_alias = copy.copy(project)
470
+ _workspace_alias.name = "workspace"
471
+ _workspace_alias.hidden = True
472
+ cli.add_command(_workspace_alias, "workspace")
473
+ cli.add_command(ls)
474
+ cli.add_command(cat)
475
+ cli.add_command(write)
476
+ cli.add_command(rm)
477
+ cli.add_command(download)
478
+ cli.add_command(describe)
479
+ cli.add_command(query)
480
+ cli.add_command(cell_meta, "cell-meta")
481
+ cli.add_command(relationships)
482
+ cli.add_command(edit)
483
+ cli.add_command(replace)
484
+ cli.add_command(defs)
485
+ cli.add_command(guide)
486
+ cli.add_command(rules)
487
+ cli.add_command(ref)
488
+ cli.add_command(help_cmd)
489
+ cli.add_command(example)
490
+ cli.add_command(doctor)
491
+ cli.add_command(to_excel, "to-excel")
492
+ cli.add_command(to_pptx, "to-pptx")
493
+ cli.add_command(to_docx, "to-docx")
494
+ cli.add_command(to_pdf, "to-pdf")
495
+ cli.add_command(doc, "doc")
496
+ cli.add_command(impact, "impact")
497
+ cli.add_command(deck, "deck")
498
+ cli.add_command(log)
499
+ cli.add_command(diff)
500
+ cli.add_command(restore)
501
+ cli.add_command(changes)
502
+ cli.add_command(variant)
503
+ cli.add_command(grep)
504
+ cli.add_command(clone)
505
+ cli.add_command(pull)
506
+ cli.add_command(push)
507
+ cli.add_command(sync_status, "status")
508
+ cli.add_command(commit)
509
+ cli.add_command(merge)
510
+ cli.add_command(import_cmd, "import")
511
+ cli.add_command(ingest)
512
+ cli.add_command(claim)
513
+ cli.add_command(assumption)
514
+ cli.add_command(reasoning)
515
+ cli.add_command(reasoning_diff, "reasoning-diff")
516
+ cli.add_command(share)
517
+ cli.add_command(viewer)
518
+ cli.add_command(upgrade)