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,279 @@
1
+ """``deepcell ref`` — generated enumerations, and the cross-reference resolver.
2
+
3
+ Where ``guide`` explains a procedure and ``rules`` states an invariant, ``ref``
4
+ answers "what values are legal here". Its content is derived from the
5
+ implementation — the functions from the formula constants, the op kinds from
6
+ the pydantic union the server validates against — so unlike a prose list it
7
+ cannot disagree with the code.
8
+
9
+ The second job is resolution: ``deepcell ref rule:R2`` and
10
+ ``deepcell ref guide:generate/calcs`` both work, so an agent holding a typed id
11
+ from a lint finding or a topic's ``see_also`` has exactly one command to reach
12
+ for and never has to work out which surface owns the name.
13
+
14
+ See ``docs/cli-agent-surface.md`` §6.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import click
20
+
21
+ from deepcell_cli.context import Ctx, pass_ctx
22
+ from deepcell_cli.output import output, print_plain
23
+
24
+
25
+ def _resolve_cmd_id(raw_id: str) -> dict:
26
+ """Resolve ``cmd:<invocation>`` against the live Click tree.
27
+
28
+ Raises rather than returning a stub for an unknown name: a resolver that
29
+ answers for every string is worse than none, because it would confirm a
30
+ citation to a command that does not exist — which is the failure the typed
31
+ id scheme is meant to make impossible.
32
+ """
33
+ from deepcell_cli.surface import build_commands
34
+
35
+ name = raw_id.partition(":")[2].strip()
36
+ commands = build_commands()
37
+ entry = commands.get(name)
38
+ if entry is None:
39
+ raise click.ClickException(
40
+ f"Cannot resolve id: {raw_id}\n"
41
+ f"No command named {name!r}. List them with: deepcell help"
42
+ )
43
+ return {
44
+ "id": f"cmd:{name}",
45
+ "kind": "cmd",
46
+ "name": name,
47
+ "summary": entry.get("summary", ""),
48
+ "command": f"deepcell {name} --help",
49
+ "example": entry.get("example"),
50
+ "see_also": entry.get("see_also") or [],
51
+ }
52
+
53
+
54
+ def _render_entry(entry: dict) -> str:
55
+ """Format one resolved entry for human reading."""
56
+ lines: list[str] = []
57
+ header = entry.get("id") or entry.get("name") or ""
58
+ if header:
59
+ lines += [header, "=" * len(header), ""]
60
+
61
+ if entry.get("summary"):
62
+ lines += [str(entry["summary"]), ""]
63
+
64
+ for label, key in (
65
+ ("Signature", "signature"),
66
+ ("Arity", "arity"),
67
+ ("Category", "category"),
68
+ ("Severity", "severity"),
69
+ ("Command", "command"),
70
+ ("Endpoint", "endpoint"),
71
+ ("Family", "family"),
72
+ ("Model", "model"),
73
+ # A deck style pack's metadata (`ref:deck-style/<pack>`): the five
74
+ # fields to choose a pack by, printed before the prose so a reader
75
+ # comparing packs sees the same shape on each.
76
+ ("Audience", "audience"),
77
+ ("Formality", "formality"),
78
+ ("Density", "density"),
79
+ ("Reading", "reading_mode"),
80
+ ("Export", "export_fidelity"),
81
+ ):
82
+ value = entry.get(key)
83
+ if value:
84
+ lines.append(f"{label + ':':<12}{value}")
85
+ roles = entry.get("roles")
86
+ if isinstance(roles, dict) and roles:
87
+ lines.append(
88
+ f"{'Roles:':<12}" + " ".join(f"{name}={value}" for name, value in roles.items())
89
+ )
90
+
91
+ if entry.get("aliases"):
92
+ lines.append(f"{'Aliases:':<12}{', '.join(entry['aliases'])}")
93
+ if entry.get("house_rule"):
94
+ lines.append(
95
+ f"{'House rule:':<12}rule:{entry['house_rule']} — {entry.get('house_rule_title', '')}"
96
+ )
97
+ if entry.get("resolved_from"):
98
+ lines.append(f"{'Resolved:':<12}{entry['resolved_from']} → {entry['name']}")
99
+
100
+ for label, key in (("Required", "required"), ("Optional", "optional")):
101
+ fields = entry.get(key)
102
+ if fields:
103
+ lines.append("")
104
+ lines.append(f"{label}:")
105
+ lines += [f" {f['name']}: {f['type']}" for f in fields]
106
+
107
+ if entry.get("fix"):
108
+ lines += ["", "Fix:", f" {entry['fix']}"]
109
+ if entry.get("detail"):
110
+ lines += ["", str(entry["detail"])]
111
+
112
+ # A deck style pack's starters (`ref:deck-style/<pack>`) are drop-in
113
+ # strings; fenced so a terminal reader gets exactly what the `-f json`
114
+ # field carries and can pass it through unchanged.
115
+ for label, key, lang in (
116
+ ("Deck CSS — pass through set_presentation_deck_style verbatim", "css", "css"),
117
+ ("Slide HTML starter", "slide_html", "html"),
118
+ # A deck layout (`ref:deck-layout/<name>`): the class-only slide body
119
+ # `deck add-slide --layout` inserts, fenced for the same reason.
120
+ ("Layout HTML — class-only, on the shared vocabulary", "html", "html"),
121
+ ):
122
+ value = entry.get(key)
123
+ if value:
124
+ lines += ["", f"{label}:", f"```{lang}", str(value).rstrip("\n"), "```"]
125
+
126
+ for sense in entry.get("senses") or []:
127
+ lines += [
128
+ "",
129
+ f" [{sense['id']}] {sense['meaning']}",
130
+ f" commands: {', '.join(sense['commands'])}",
131
+ ]
132
+
133
+ if entry.get("over_mcp"):
134
+ lines += ["", str(entry["over_mcp"])]
135
+ if entry.get("body"):
136
+ lines += ["", str(entry["body"]).rstrip()]
137
+
138
+ return "\n".join(lines).rstrip()
139
+
140
+
141
+ @click.command()
142
+ @click.argument("target", nargs=-1)
143
+ @click.option("--limit", default=40, show_default=True, help="Maximum search results.")
144
+ @pass_ctx
145
+ def ref(ctx: Ctx, target: tuple[str, ...], limit: int) -> None:
146
+ """Look up legal values, and resolve any typed id.
147
+
148
+ \b
149
+ List the namespaces: deepcell ref
150
+ List one namespace: deepcell ref function
151
+ Read one entry: deepcell ref function/NPV
152
+ Resolve a typed id: deepcell ref rule:R2
153
+ Search everything: deepcell ref search sensitivity
154
+
155
+ Search is a leading word rather than a subcommand: a Click group with an
156
+ optional positional cannot tell `ref search` (the verb) from `ref search`
157
+ (a namespace called "search"), and the ambiguity surfaces as a confusing
158
+ "No such command" on the *second* word.
159
+ """
160
+ if target and target[0] == "search":
161
+ _search(ctx, " ".join(target[1:]).strip(), limit)
162
+ return
163
+
164
+ if not target:
165
+ _list_namespaces(ctx)
166
+ return
167
+
168
+ what = target[0]
169
+
170
+ # A typed id (`rule:R2`, `ref:function/NPV`) resolves across every surface;
171
+ # a bare `function` or `function/NPV` addresses this surface directly.
172
+ #
173
+ # A colon alone does not make an id: `ref:selector/range` documents
174
+ # `Item[CURRENT-11:CURRENT]`, so a reader pasting what they typed asks for
175
+ # `selector/CURRENT-11:CURRENT`, which resolved to the nonexistent prefix
176
+ # `selector/CURRENT-11` and answered "cannot resolve" to a *legal*
177
+ # selector. A typed id's prefix is a fixed word and so never contains a
178
+ # `/`; a `/` before the first `:` means this is a namespace path whose name
179
+ # holds a colon. The authority is `src.core.ref.ids.looks_like_typed_id` —
180
+ # duplicated here because this package cannot import the backend, the same
181
+ # way the two `guide` index renderers agree by rule rather than by import.
182
+ if ":" in what and "/" not in what.partition(":")[0]:
183
+ raw_id = " ".join(target).strip()
184
+ if raw_id.startswith("cmd:"):
185
+ # Answered here, not by /ref/resolve. The command catalog is built
186
+ # from the live Click tree, which exists only in this package —
187
+ # backend/'s Docker build context excludes docs/cli-surface.json,
188
+ # so a server-side resolver would need a shipped copy of the tree
189
+ # and could drift from the commands that actually exist. This one
190
+ # cannot be wrong about that.
191
+ _print_entry(ctx, _resolve_cmd_id(raw_id))
192
+ return
193
+ _print_entry(ctx, ctx.client.get("/ref/resolve", params={"id": raw_id}))
194
+ return
195
+
196
+ namespace, _, name = what.partition("/")
197
+ if name:
198
+ _print_entry(ctx, ctx.client.get(f"/ref/{namespace}/{name}"))
199
+ return
200
+
201
+ _list_entries(ctx, namespace)
202
+
203
+
204
+ def _print_entry(ctx: Ctx, data: object) -> None:
205
+ if ctx.fmt == "json":
206
+ output(data, ctx.fmt)
207
+ return
208
+ print_plain(_render_entry(data) if isinstance(data, dict) else str(data))
209
+
210
+
211
+ def _list_namespaces(ctx: Ctx) -> None:
212
+ data = ctx.client.get("/ref")
213
+ namespaces = data.get("namespaces", []) if isinstance(data, dict) else data
214
+ if ctx.fmt == "json" or not isinstance(namespaces, list) or not namespaces:
215
+ output(data, ctx.fmt)
216
+ return
217
+ width = max(len(str(n.get("name", ""))) for n in namespaces)
218
+ lines = [
219
+ f"{str(n.get('name','')):<{width}} {str(n.get('count','')):>4} "
220
+ f"{n.get('description','')}"
221
+ for n in namespaces
222
+ ]
223
+ lines += [
224
+ "",
225
+ "List one: deepcell ref <namespace>",
226
+ "Read one: deepcell ref <namespace>/<name>",
227
+ "Resolve: deepcell ref <typed-id> e.g. rule:R2",
228
+ "Search: deepcell ref search <text>",
229
+ ]
230
+ print_plain("\n".join(lines))
231
+
232
+
233
+ def _list_entries(ctx: Ctx, namespace: str) -> None:
234
+ data = ctx.client.get(f"/ref/{namespace}")
235
+ entries = data.get("entries", []) if isinstance(data, dict) else data
236
+ if ctx.fmt == "json" or not isinstance(entries, list) or not entries:
237
+ output(data, ctx.fmt)
238
+ return
239
+ width = max(len(str(e.get("name", ""))) for e in entries)
240
+ lines = [
241
+ f"{str(e.get('name','')):<{width}} {e.get('summary','')}" for e in entries
242
+ ]
243
+ lines += ["", f"Read one: deepcell ref {namespace}/<name>"]
244
+ print_plain("\n".join(lines))
245
+
246
+
247
+ def _search(ctx: Ctx, text: str, limit: int) -> None:
248
+ """Search every surface at once and return typed ids.
249
+
250
+ Results say what *kind* of thing was found, so a search for "sensitivity"
251
+ that turns up an op kind, a guide topic and a rule can be told apart
252
+ without a second call to work out which command reads which.
253
+ """
254
+ if not text:
255
+ print_plain("Usage: deepcell ref search <text>")
256
+ return
257
+
258
+ data = ctx.client.get("/ref/search", params={"q": text, "limit": limit})
259
+ results = data.get("results", []) if isinstance(data, dict) else data
260
+
261
+ if ctx.fmt == "json":
262
+ output(data, ctx.fmt)
263
+ return
264
+ if not isinstance(results, list) or not results:
265
+ print_plain(f"No matches for '{text}'.")
266
+ return
267
+
268
+ width = max(len(str(r.get("id", ""))) for r in results)
269
+ lines: list[str] = []
270
+ for r in results:
271
+ lines.append(f"{str(r.get('id','')):<{width}} {r.get('summary','')}")
272
+ # A snippet means the summary above did NOT contain the search term —
273
+ # the match is deeper in the entry. Printing it is what stops such a
274
+ # row reading as a mismatch: without it the reader sees a summary with
275
+ # none of their words in it and concludes the search is broken.
276
+ if r.get("snippet"):
277
+ lines.append(f"{'':<{width}} ↳ {r['snippet']}")
278
+ lines += ["", "Read one: deepcell ref <id>"]
279
+ print_plain("\n".join(lines))
@@ -0,0 +1,326 @@
1
+ """Raw-XML string replacement in a workspace file.
2
+
3
+ Split out of ``edit`` because the two do not share an exit-1 sense. ``edit``
4
+ applies a batch of value changes and reports which rows landed, so a failure is
5
+ **partially applied**. ``replace`` writes the file *first* and validates the
6
+ result *after*, so a failure means the replacement is already on disk and the
7
+ document is invalid — ``written-but-invalid``. ``cli/src/deepcell_cli/
8
+ surface.py`` maps exit senses over command paths, so one command carrying two
9
+ senses was inexpressible; two commands is.
10
+
11
+ ``edit --replace`` stays as a deprecated alias (it calls :func:`do_replace`
12
+ directly) so existing scripts and agent transcripts keep working.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+
19
+ import click
20
+
21
+ from deepcell_cli.commands._batch_input import read_batch_payload
22
+ from deepcell_cli.commands._write_opts import WriteCommand, write_message
23
+ from deepcell_cli.context import Ctx, pass_ctx
24
+ from deepcell_cli.output import (
25
+ echo_success,
26
+ echo_validation,
27
+ echo_warning,
28
+ output_mutation,
29
+ )
30
+
31
+
32
+ @click.command("replace", cls=WriteCommand)
33
+ @click.argument("filename")
34
+ @click.argument("old_string", required=False)
35
+ @click.argument("new_string", required=False)
36
+ @click.option("--replace-all", is_flag=True, help="Replace every occurrence instead of requiring a unique match.")
37
+ @click.option(
38
+ "--batch",
39
+ "batch_file",
40
+ help='Replacement as a JSON object {"old_string": "...", "new_string": "..."} '
41
+ "— a file path, '-' for stdin, or inline JSON. Use this for multiline XML.",
42
+ )
43
+ @click.option("--revision", default=None, help="Expected revision SHA for optimistic locking.")
44
+ @click.option("--workspace", "workspace_slug", help="Override active workspace.")
45
+ @pass_ctx
46
+ def replace(
47
+ ctx: Ctx,
48
+ filename: str,
49
+ old_string: str | None,
50
+ new_string: str | None,
51
+ replace_all: bool,
52
+ batch_file: str | None,
53
+ revision: str | None,
54
+ workspace_slug: str | None,
55
+ ) -> None:
56
+ """Replace raw XML text in a file — the last-resort editor.
57
+
58
+ \b
59
+ Prefer a typed command where one exists:
60
+ Values `deepcell edit FILE ITEM CONTEXT VALUE`
61
+ Structure / formulas `deepcell defs add-calc / add-item / ...`
62
+ Raw XML (this command) sections with no typed op (Sources, ...)
63
+
64
+ \b
65
+ Exit 1 here does NOT mean nothing happened. The file is written first and
66
+ validated second, so exit 1 means the replacement IS already in the file
67
+ and the document is now invalid — read the errors and fix forward.
68
+
69
+ \b
70
+ Matching is tiered, server-side: exact → whitespace-tolerant →
71
+ canonicalization-aware (the server rewrites XML on every write, so an
72
+ old_string composed from what you previously *wrote* may no longer be the
73
+ stored spelling). A non-exact match is reported, because the bytes it
74
+ rewrote are not the bytes you passed.
75
+
76
+ \b
77
+ Examples:
78
+ deepcell replace model.deepcell "<Old>text</Old>" "<New>text</New>"
79
+ deepcell replace model.deepcell -m "fix tax rate" OLD NEW
80
+ deepcell replace model.deepcell --replace-all "old" "new"
81
+ echo '{"old_string":"...","new_string":"..."}' | deepcell replace model.deepcell --batch -
82
+
83
+ \b
84
+ A literal that starts with '-' must come after '--':
85
+ deepcell replace model.deepcell -- "-166667" "-200000"
86
+ """
87
+ slug = workspace_slug or ctx.require_workspace()
88
+ title, rationale = write_message()
89
+ do_replace(
90
+ ctx, slug, filename, old_string, new_string, batch_file, replace_all,
91
+ revision, title, rationale,
92
+ )
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Implementation — shared with the deprecated `edit --replace` alias
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def format_replace_conflict(payload: dict) -> str:
101
+ """Render the structured 409 detail from the :replace endpoint."""
102
+ reason = payload.get("reason", "conflict")
103
+ lines: list[str] = []
104
+ if reason == "not_found":
105
+ lines.append("old_string not found in file.")
106
+ nearest = payload.get("nearest_match")
107
+ if nearest:
108
+ nearest_lines = payload.get("nearest_lines") or []
109
+ where = (
110
+ f" (lines {nearest_lines[0]}-{nearest_lines[1]})"
111
+ if len(nearest_lines) == 2
112
+ else ""
113
+ )
114
+ lines.append(f"Nearest match in file{where}:")
115
+ lines.append(nearest)
116
+ elif reason == "ambiguous":
117
+ lines.append(f"old_string found {payload.get('count', '?')} times.")
118
+ spelling = payload.get("matched_spelling")
119
+ if spelling and spelling != payload.get("old_string"):
120
+ lines.append("Matched spelling in the file (what --replace-all would rewrite):")
121
+ lines.append(spelling)
122
+ elif reason == "stale_revision":
123
+ current = payload.get("current_revision")
124
+ lines.append(
125
+ f"File changed since your revision (current: {current})."
126
+ if current
127
+ else "File changed since your revision."
128
+ )
129
+ hint = payload.get("hint")
130
+ if hint:
131
+ lines.append(f"Hint: {hint}")
132
+ return "\n".join(lines)
133
+
134
+
135
+ def do_replace(
136
+ ctx: Ctx,
137
+ slug: str,
138
+ filename: str,
139
+ old_string_arg: str | None,
140
+ new_string_arg: str | None,
141
+ batch_file: str | None,
142
+ replace_all: bool,
143
+ revision: str | None,
144
+ title: str | None = None,
145
+ rationale: str | None = None,
146
+ *,
147
+ usage: str = "deepcell replace FILE OLD NEW",
148
+ ) -> None:
149
+ """Execute string-replacement editing on a workspace file (server-side).
150
+
151
+ Matching happens on the server (POST /files:replace, filename in the
152
+ body) so it can canonicalize old_string through the same normalization
153
+ pipeline the write path uses — a stale-but-semantically-current
154
+ old_string still matches. Against an older server without that route the
155
+ command falls back to the legacy client-side match-and-upload flow.
156
+
157
+ ``usage`` names the invocation form in argument errors, so the deprecated
158
+ ``edit --replace`` alias still teaches its own spelling.
159
+ """
160
+ from deepcell_cli.errors import APIError
161
+
162
+ # Parse old_string / new_string
163
+ if batch_file:
164
+ raw = read_batch_payload(batch_file)
165
+ try:
166
+ payload = json.loads(raw)
167
+ except json.JSONDecodeError as exc:
168
+ raise click.UsageError(f"Batch JSON does not parse — {exc}") from exc
169
+ if not isinstance(payload, dict) or "old_string" not in payload or "new_string" not in payload:
170
+ raise click.UsageError(
171
+ 'Replace --batch JSON must be {"old_string": "...", "new_string": "..."}.'
172
+ )
173
+ old_string: str = payload["old_string"]
174
+ new_string: str = payload["new_string"]
175
+ elif old_string_arg is not None and new_string_arg is not None:
176
+ old_string = old_string_arg
177
+ new_string = new_string_arg
178
+ else:
179
+ batch_form = usage.rsplit(" OLD NEW", 1)[0]
180
+ raise click.UsageError(
181
+ f"Replace requires two positional args: {usage}\n"
182
+ "or JSON via stdin: echo '{\"old_string\":\"...\",\"new_string\":\"...\"}' "
183
+ f"| {batch_form} --batch -"
184
+ )
185
+
186
+ body: dict = {
187
+ "filename": filename,
188
+ "old_string": old_string,
189
+ "new_string": new_string,
190
+ "replace_all": replace_all,
191
+ }
192
+ if revision:
193
+ body["expected_revision"] = revision
194
+ if title:
195
+ body["title"] = title
196
+ if rationale:
197
+ body["rationale"] = rationale
198
+
199
+ try:
200
+ result = ctx.client.post(f"/workspaces/{slug}/files:replace", json=body)
201
+ except APIError as exc:
202
+ if exc.status_code == 404 and exc.detail == "Not Found":
203
+ # The framework-default 404 means the files:replace route does not
204
+ # exist (older server) — a missing *file* gets a specific message.
205
+ echo_warning(
206
+ "Server predates server-side replace — falling back to "
207
+ "client-side matching (upgrade the server for "
208
+ "canonicalization-aware matching)."
209
+ )
210
+ do_replace_legacy(
211
+ ctx, slug, filename, old_string, new_string, replace_all,
212
+ title, rationale, revision,
213
+ )
214
+ return
215
+ if exc.status_code == 409 and isinstance(exc.payload, dict):
216
+ raise click.ClickException(format_replace_conflict(exc.payload)) from exc
217
+ raise
218
+
219
+ output_mutation(result, ctx.fmt, plain_key="commit_sha")
220
+
221
+ errors = echo_validation(result)
222
+
223
+ if isinstance(result, dict):
224
+ tier = result.get("match_tier")
225
+ if tier and tier != "exact":
226
+ echo_warning(
227
+ f"old_string matched via the {tier} tier — the bytes rewritten "
228
+ "in the file differ from the old_string you passed"
229
+ )
230
+
231
+ replaced = result.get("replacements", 1) if isinstance(result, dict) else 1
232
+ if errors:
233
+ echo_warning(
234
+ f"{replaced} replacement(s) applied but the document now has "
235
+ f"{len(errors)} validation error(s) — fix the errors above"
236
+ )
237
+ raise click.exceptions.Exit(1)
238
+ echo_success(f"{replaced} replacement(s) applied")
239
+
240
+
241
+ def do_replace_legacy(
242
+ ctx: Ctx,
243
+ slug: str,
244
+ filename: str,
245
+ old_string: str,
246
+ new_string: str,
247
+ replace_all: bool,
248
+ title: str | None,
249
+ rationale: str | None,
250
+ revision: str | None = None,
251
+ ) -> None:
252
+ """Client-side match-and-upload flow for servers without files:replace.
253
+
254
+ Exact + whitespace-tolerant matching only (no canonicalization tier —
255
+ that needs the server's normalization pipeline).
256
+
257
+ This is a read-modify-write across two round trips, so it carries the
258
+ lost-update window the server-side route does not: the revision of the
259
+ bytes it matched against rides back as ``expected_revision``, and the
260
+ caller's own ``--revision`` (if any) takes precedence over it.
261
+ """
262
+ from deepcell_cli.xml_replace import (
263
+ find_whitespace_tolerant_match,
264
+ normalize_new_string_indentation,
265
+ )
266
+
267
+ data, read_revision = ctx.client.get_with_revision(
268
+ f"/workspaces/{slug}/files/{filename}"
269
+ )
270
+ if isinstance(data, dict) and "content" in data:
271
+ content: str = data["content"]
272
+ else:
273
+ raise click.ClickException(f"Could not read file content for '{filename}'.")
274
+
275
+ count = content.count(old_string)
276
+ if count == 1:
277
+ new_content = content.replace(old_string, new_string, 1)
278
+ elif count > 1:
279
+ if replace_all:
280
+ new_content = content.replace(old_string, new_string)
281
+ else:
282
+ raise click.ClickException(
283
+ f"old_string found {count} times. Use --replace-all to replace all occurrences."
284
+ )
285
+ else:
286
+ actual_old = find_whitespace_tolerant_match(content, old_string)
287
+ if actual_old is None:
288
+ raise click.ClickException(
289
+ "old_string not found in file (exact and whitespace-tolerant search)."
290
+ )
291
+ adjusted_new = normalize_new_string_indentation(actual_old, old_string, new_string)
292
+ new_content = content.replace(actual_old, adjusted_new, 1)
293
+
294
+ from deepcell_cli.errors import APIError
295
+ from deepcell_cli.revision import raise_if_stale, write_body
296
+
297
+ # `commit_message` as well as the pair, on this path only. This runs when
298
+ # the server is old enough to lack `files:replace`, which means it is also
299
+ # old enough to lack `title`/`rationale` on the write body — and pydantic
300
+ # drops an unknown field silently, so the reason would vanish with no
301
+ # error. A server that understands both prefers the pair and keeps this as
302
+ # a `Summary` trailer, so sending it costs nothing there.
303
+ body = write_body(
304
+ new_content,
305
+ revision or read_revision,
306
+ title=title,
307
+ rationale=rationale,
308
+ commit_message=rationale or title,
309
+ )
310
+ try:
311
+ result = ctx.client.post(f"/workspaces/{slug}/files/{filename}", json=body)
312
+ except APIError as exc:
313
+ raise_if_stale(exc, filename=filename)
314
+ raise
315
+ output_mutation(result, ctx.fmt, plain_key="commit_sha")
316
+
317
+ errors = echo_validation(result)
318
+
319
+ replaced = count if (count > 1 and replace_all) else 1
320
+ if errors:
321
+ echo_warning(
322
+ f"{replaced} replacement(s) applied but the document now has "
323
+ f"{len(errors)} validation error(s) — fix the errors above"
324
+ )
325
+ raise click.exceptions.Exit(1)
326
+ echo_success(f"{replaced} replacement(s) applied")