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
|
@@ -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)
|