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,110 @@
1
+ """``deepcell ingest`` — pull external filings into extractable data.
2
+
3
+ Thin pass-through to the Jingwei ``/ingest`` endpoints. Currently the ``cn``
4
+ group covers cninfo (巨潮资讯网) A-share annual / interim reports:
5
+
6
+ \b
7
+ deepcell ingest cn search "贵州茅台"
8
+ deepcell ingest cn filings 600519 --type annual
9
+ deepcell ingest cn statements <pdf_url>
10
+ deepcell ingest cn extract <pdf_url> --statement 合并利润表
11
+
12
+ The typical flow: search → filings (copy a PDF url) → statements (find the
13
+ page) → extract (rows + canonical item ids + a #page deep link). Feed the
14
+ result to a modeler / `deepcell write` per `deepcell guide cn-extraction`.
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 echo_warning, output, print_plain
23
+
24
+
25
+ @click.group()
26
+ def ingest() -> None:
27
+ """Pull external filings into extractable statement data."""
28
+
29
+
30
+ @ingest.group()
31
+ def cn() -> None:
32
+ """cninfo (A-share) filings — Shanghai / Shenzhen / Beijing exchanges."""
33
+
34
+
35
+ @cn.command("search")
36
+ @click.argument("keywords")
37
+ @pass_ctx
38
+ def cn_search(ctx: Ctx, keywords: str) -> None:
39
+ """Resolve a 6-digit code, company name (中文简称), or pinyin."""
40
+ data = ctx.client.get("/ingest/cn/search", params={"keywords": keywords})
41
+ output(data.get("matches", data), ctx.fmt)
42
+
43
+
44
+ @cn.command("filings")
45
+ @click.argument("code")
46
+ @click.option("--type", "filing_type", default="annual",
47
+ help="annual | interim | semiannual | q1 | q3")
48
+ @click.option("--count", default=5, type=click.IntRange(1, 30),
49
+ help="number of reports (1-30)")
50
+ @pass_ctx
51
+ def cn_filings(ctx: Ctx, code: str, filing_type: str, count: int) -> None:
52
+ """List annual / interim reports with PDF permalinks."""
53
+ data = ctx.client.get(
54
+ "/ingest/cn/filings",
55
+ params={"code": code, "filing_type": filing_type, "count": count},
56
+ )
57
+ output(data.get("filings", data), ctx.fmt)
58
+
59
+
60
+ @cn.command("statements")
61
+ @click.argument("pdf_url")
62
+ @pass_ctx
63
+ def cn_statements(ctx: Ctx, pdf_url: str) -> None:
64
+ """Locate the financial statements inside a report PDF (page + scale)."""
65
+ data = ctx.client.get("/ingest/cn/statements", params={"pdf_url": pdf_url})
66
+ output(data.get("statements", data), ctx.fmt)
67
+
68
+
69
+ @cn.command("extract")
70
+ @click.argument("pdf_url")
71
+ @click.option("--statement", default="合并利润表",
72
+ help="Chinese heading or income | balance | cash_flow")
73
+ @click.option(
74
+ "--persist/--no-persist",
75
+ "persist",
76
+ default=True,
77
+ help="Mirror the source PDF into DeepCell storage and return a durable "
78
+ "`source_page_url` to record as the <Source> <Locator>. --no-persist "
79
+ "skips the mirror (faster, but the only url you get back is the "
80
+ "volatile cninfo link).",
81
+ )
82
+ @pass_ctx
83
+ def cn_extract(ctx: Ctx, pdf_url: str, statement: str, persist: bool) -> None:
84
+ """Extract one statement's table — rows, canonical items, #page deep link."""
85
+ data = ctx.client.get(
86
+ "/ingest/cn/extract",
87
+ # Key matches the `persist` query param on GET /ingest/cn/extract.
88
+ params={"pdf_url": pdf_url, "statement": statement, "persist": persist},
89
+ )
90
+ if ctx.fmt == "json":
91
+ output(data, ctx.fmt)
92
+ return
93
+ # Human-readable: the markdown table plus the provenance lines. The mirror
94
+ # url (source_page_url) is the deepcell-hosted copy to record as <Url>.
95
+ mirror = data.get("source_page_url") or data.get("deep_link")
96
+ mirror_error = data.get("mirror_error")
97
+ if mirror_error:
98
+ # Without this the volatile cninfo link is printed as the durable
99
+ # `<Url>` to record, indistinguishable from a real mirror.
100
+ echo_warning(
101
+ f"{mirror_error} The 'Source <Url>' below is the ORIGINAL cninfo "
102
+ f"link, which may move — re-run to retry mirroring."
103
+ )
104
+ print_plain(
105
+ f"Statement: {data.get('statement')} (page {data.get('page')})\n"
106
+ f"Source <Url>: {mirror}\n"
107
+ f"Original (cninfo): {data.get('original_url') or data.get('deep_link')}\n"
108
+ f"Scale: 10^{data.get('scale')} Currency: {data.get('currency')}\n\n"
109
+ f"{data.get('markdown', '')}"
110
+ )
@@ -0,0 +1,399 @@
1
+ """Merge commands: preview, apply, and convenience resolve for .deepcell files.
2
+
3
+ Provides semantic three-way merge for .deepcell XML files using the backend
4
+ merge service. Works both standalone and as part of the pull conflict workflow.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import click
12
+
13
+ from deepcell_cli.context import Ctx, pass_ctx
14
+ from deepcell_cli.output import echo_error, echo_info, echo_success, echo_warning
15
+ from deepcell_cli.sync_state import (
16
+ FileChecksum,
17
+ compute_local_hash,
18
+ find_sync_root,
19
+ load_sync_state,
20
+ save_sync_state,
21
+ )
22
+
23
+
24
+ # -- Helpers -------------------------------------------------------------------
25
+
26
+
27
+ def _read_local(root: Path, filename: str) -> str:
28
+ """Read a file from the sync root."""
29
+ path = root / filename
30
+ if not path.exists():
31
+ raise click.ClickException(f"File not found: {filename}")
32
+ return path.read_text(encoding="utf-8")
33
+
34
+
35
+ def _download_file(client, slug: str, filename: str, *, revision: str | None = None) -> str:
36
+ """Download a file from the workspace, optionally at a specific revision."""
37
+ params = {"revision": revision} if revision else None
38
+ data = client.get(f"/workspaces/{slug}/files/{filename}", params=params)
39
+ if isinstance(data, dict) and "content" in data:
40
+ return data["content"]
41
+ return str(data)
42
+
43
+
44
+ def _show_conflicts(conflicts: list[dict]) -> None:
45
+ """Display conflict details to stderr."""
46
+ for c in conflicts:
47
+ item = c.get("item_ref", "?")
48
+ ctx = c.get("context_ref", "")
49
+ status = c.get("status_ref") or ""
50
+ ctype = c.get("type", "value")
51
+ ours = c.get("ours", "")
52
+ theirs = c.get("theirs", "")
53
+ base = c.get("base", "")
54
+ coords = ", ".join(p for p in (ctx, status) if p)
55
+ label = f"{item}[{coords}]" if coords else item
56
+ echo_warning(f" {ctype}: {label} base={base} ours={ours} theirs={theirs}")
57
+
58
+
59
+ def echo_merge_data_loss(result: dict) -> bool:
60
+ """Report items/values the merge could not carry over.
61
+
62
+ ``success`` is computed from the conflict count alone and cannot reflect
63
+ an apply-time drop, so a merge that silently lost an item still comes back
64
+ ``success=True``. Returns True when anything was dropped, so callers can
65
+ keep the ``.local`` backup and exit non-zero.
66
+ """
67
+ if not isinstance(result, dict):
68
+ return False
69
+ items = list(result.get("items_not_merged") or [])
70
+ values = list(result.get("values_not_merged") or [])
71
+ if not items and not values:
72
+ return False
73
+ if items:
74
+ echo_warning(
75
+ f"{len(items)} item(s) could NOT be merged and are missing from the "
76
+ f"result: {', '.join(items)}"
77
+ )
78
+ if values:
79
+ echo_warning(
80
+ f"{len(values)} value(s) could NOT be written to the merged "
81
+ f"document: {', '.join(values)}"
82
+ )
83
+ echo_warning(
84
+ "Re-apply them by hand — the merge is incomplete despite reporting success."
85
+ )
86
+ return True
87
+
88
+
89
+ def _update_checksum(root: Path, state, filename: str, server_content: str) -> None:
90
+ """Re-baseline a merged file against what the SERVER holds.
91
+
92
+ *server_content* is `theirs` — the content the merge was performed
93
+ against, which is still what is committed. The merge result exists only on
94
+ disk until it is pushed, so baselining it against the merged content made
95
+ `push` report "Nothing to push" and `status` report Clean, stranding the
96
+ resolved merge with no command left to reveal it.
97
+ """
98
+ if filename in state.file_checksums:
99
+ state.file_checksums[filename] = FileChecksum(
100
+ git_sha=state.file_checksums[filename].git_sha,
101
+ local_hash=compute_local_hash(server_content),
102
+ )
103
+ save_sync_state(root, state)
104
+
105
+
106
+ # -- merge group ---------------------------------------------------------------
107
+
108
+
109
+ @click.group()
110
+ def merge() -> None:
111
+ """Merge .deepcell files with semantic three-way merge.
112
+
113
+ \b
114
+ Resolve sync conflicts:
115
+ deepcell merge resolve model.deepcell # auto-merge, show conflicts
116
+ deepcell merge resolve model.deepcell --ours # keep all local values
117
+ deepcell merge resolve model.deepcell --theirs # take all remote values
118
+
119
+ \b
120
+ Power-user subcommands:
121
+ deepcell merge preview model.deepcell # preview merge result
122
+ deepcell merge apply <session-id> --resolve ours # resolve by session ID
123
+ """
124
+
125
+
126
+ # -- merge resolve (convenience) -----------------------------------------------
127
+
128
+
129
+ @merge.command()
130
+ @click.argument("filename")
131
+ @click.option("--ours", "strategy", flag_value="ours", help="Resolve all conflicts with local values.")
132
+ @click.option("--theirs", "strategy", flag_value="theirs", help="Resolve all conflicts with remote values.")
133
+ @pass_ctx
134
+ def resolve(ctx: Ctx, filename: str, strategy: str | None) -> None:
135
+ """Resolve a sync conflict for a .deepcell file.
136
+
137
+ After `deepcell pull` creates a conflict (file.deepcell.local), use this
138
+ command to merge or pick a side. --ours and --theirs are wholesale: they
139
+ take that side for *every* conflicting value, so prefer the bare
140
+ auto-merge and resolve what it reports.
141
+
142
+ \b
143
+ deepcell merge resolve model.deepcell # try auto-merge
144
+ deepcell merge resolve model.deepcell --ours # keep local values
145
+ deepcell merge resolve model.deepcell --theirs # take remote values
146
+ """
147
+ root = find_sync_root()
148
+ if root is None:
149
+ raise click.ClickException(
150
+ "Not inside a synced workspace. Run `deepcell clone <slug>` first."
151
+ )
152
+
153
+ state = load_sync_state(root)
154
+ client = ctx.client
155
+ slug = state.workspace_slug
156
+
157
+ local_backup = root / f"{filename}.local"
158
+ if not local_backup.exists():
159
+ raise click.ClickException(
160
+ f"No conflict found for {filename} (no {filename}.local file). "
161
+ "Conflicts are created by `deepcell pull` when both sides change a file."
162
+ )
163
+
164
+ # .local = ours (original local), main file = theirs (remote)
165
+ ours_xml = local_backup.read_text(encoding="utf-8")
166
+ theirs_xml = (root / filename).read_text(encoding="utf-8")
167
+
168
+ # Fetch base version from last sync point
169
+ if not state.last_sync_sha:
170
+ raise click.ClickException(
171
+ "Cannot determine merge base: no last_sync_sha in sync state."
172
+ )
173
+
174
+ try:
175
+ base_xml = _download_file(client, slug, filename, revision=state.last_sync_sha)
176
+ except Exception as exc:
177
+ raise click.ClickException(
178
+ f"Failed to fetch base version at {state.last_sync_sha[:8]}: {exc}"
179
+ ) from exc
180
+
181
+ # Call merge preview
182
+ try:
183
+ result = client.post("/merge/preview", json={
184
+ "base_xml": base_xml,
185
+ "ours_xml": ours_xml,
186
+ "theirs_xml": theirs_xml,
187
+ "filename": filename,
188
+ })
189
+ except Exception as exc:
190
+ raise click.ClickException(f"Merge API call failed: {exc}") from exc
191
+
192
+ if result.get("success") and result.get("merged_xml"):
193
+ # Clean merge — write result and remove .local
194
+ (root / filename).write_text(result["merged_xml"], encoding="utf-8")
195
+ _update_checksum(root, state, filename, theirs_xml)
196
+ auto_count = result.get("auto_resolved_count", 0)
197
+ if echo_merge_data_loss(result):
198
+ # Keep the .local backup: it is the only surviving copy of what
199
+ # the merge dropped.
200
+ echo_warning(
201
+ f"Merged {filename} ({auto_count} change(s) auto-resolved) but data "
202
+ f"was lost — '{filename}.local' kept so you can recover it"
203
+ )
204
+ raise click.exceptions.Exit(1)
205
+ local_backup.unlink()
206
+ echo_success(f"Merged {filename} ({auto_count} change(s) auto-resolved)")
207
+ return
208
+
209
+ # Has conflicts
210
+ conflicts = result.get("conflicts", [])
211
+ session_id = result.get("merge_session_id")
212
+
213
+ if not strategy:
214
+ echo_error(f"{len(conflicts)} conflict(s) in {filename}:")
215
+ _show_conflicts(conflicts)
216
+ echo_info("Resolve with --ours or --theirs, or use `deepcell merge apply`.")
217
+ if session_id:
218
+ echo_info(f"Session ID: {session_id}")
219
+ raise SystemExit(1)
220
+
221
+ # Resolve all conflicts with chosen strategy
222
+ if not session_id:
223
+ raise click.ClickException("No merge session ID returned. Try again.")
224
+
225
+ resolutions = {c["conflict_id"]: strategy for c in conflicts}
226
+ try:
227
+ resolved = client.post("/merge/resolve", json={
228
+ "merge_session_id": session_id,
229
+ "resolutions": resolutions,
230
+ })
231
+ except Exception as exc:
232
+ raise click.ClickException(f"Merge resolve failed: {exc}") from exc
233
+
234
+ if resolved.get("success") and resolved.get("merged_xml"):
235
+ (root / filename).write_text(resolved["merged_xml"], encoding="utf-8")
236
+ _update_checksum(root, state, filename, theirs_xml)
237
+ if echo_merge_data_loss(resolved):
238
+ echo_warning(
239
+ f"Resolved {len(conflicts)} conflict(s) with --{strategy} but data was "
240
+ f"lost — '{filename}.local' kept so you can recover it"
241
+ )
242
+ raise click.exceptions.Exit(1)
243
+ local_backup.unlink()
244
+ echo_success(f"Merged {filename} (resolved {len(conflicts)} conflict(s) with --{strategy})")
245
+ else:
246
+ remaining = resolved.get("remaining_conflicts", 0)
247
+ echo_error(f"{remaining} conflict(s) still unresolved in {filename}")
248
+ _show_conflicts(resolved.get("conflicts", []))
249
+ raise SystemExit(1)
250
+
251
+
252
+ # -- merge preview -------------------------------------------------------------
253
+
254
+
255
+ @merge.command()
256
+ @click.argument("filename")
257
+ @click.option("--base", "base_rev", default=None, help="Base revision SHA (defaults to last_sync_sha).")
258
+ @click.option("--theirs-rev", default=None, help="Revision for theirs (defaults to HEAD).")
259
+ @pass_ctx
260
+ def preview(ctx: Ctx, filename: str, base_rev: str | None, theirs_rev: str | None) -> None:
261
+ """Preview a three-way merge for a .deepcell file.
262
+
263
+ \b
264
+ deepcell merge preview model.deepcell
265
+ deepcell merge preview model.deepcell --base abc1234
266
+ """
267
+ root = find_sync_root()
268
+ if root is None:
269
+ raise click.ClickException("Not inside a synced workspace.")
270
+
271
+ state = load_sync_state(root)
272
+ client = ctx.client
273
+ slug = state.workspace_slug
274
+
275
+ # Ours = local file
276
+ ours_xml = _read_local(root, filename)
277
+
278
+ # Base = file at base_rev or last_sync_sha
279
+ base_sha = base_rev or state.last_sync_sha
280
+ if not base_sha:
281
+ raise click.ClickException("No base revision available. Specify --base <sha>.")
282
+
283
+ try:
284
+ base_xml = _download_file(client, slug, filename, revision=base_sha)
285
+ except Exception as exc:
286
+ raise click.ClickException(f"Failed to fetch base at {base_sha[:8]}: {exc}") from exc
287
+
288
+ # Theirs = file at theirs_rev or HEAD, on the branch you are actually on.
289
+ # Reading main's HEAD while a variant is checked out compares against the
290
+ # wrong branch: conflicts that don't exist on your branch, or a clean
291
+ # merge that isn't. (Base stays pinned to a SHA via the main route —
292
+ # commit SHAs are branch-independent and the variant route ignores
293
+ # `revision`.)
294
+ from deepcell_cli.commands.sync import _download_branch_file
295
+
296
+ try:
297
+ theirs_xml = _download_branch_file(
298
+ client, slug, filename,
299
+ variant=state.active_variant, revision=theirs_rev,
300
+ )
301
+ except Exception as exc:
302
+ raise click.ClickException(f"Failed to fetch theirs: {exc}") from exc
303
+
304
+ # Call merge preview
305
+ result = client.post("/merge/preview", json={
306
+ "base_xml": base_xml,
307
+ "ours_xml": ours_xml,
308
+ "theirs_xml": theirs_xml,
309
+ "filename": filename,
310
+ })
311
+
312
+ auto_count = result.get("auto_resolved_count", 0)
313
+ conflict_count = result.get("conflict_count", 0)
314
+ echo_info(f"Auto-resolved: {auto_count} Conflicts: {conflict_count}")
315
+
316
+ if result.get("success"):
317
+ lossy = echo_merge_data_loss(result)
318
+ echo_success("Clean merge — no conflicts.")
319
+ if result.get("merged_xml"):
320
+ click.echo(result["merged_xml"])
321
+ if lossy:
322
+ raise click.exceptions.Exit(1)
323
+ else:
324
+ conflicts = result.get("conflicts", [])
325
+ _show_conflicts(conflicts)
326
+ session_id = result.get("merge_session_id")
327
+ if session_id:
328
+ echo_info(f"Session ID: {session_id}")
329
+ echo_info("Resolve with: deepcell merge apply <session-id> --resolve ours|theirs")
330
+ # Exit nonzero so scripts can branch on "would this merge cleanly?"
331
+ # (consistent with `merge resolve`/`apply` and `describe --lint`).
332
+ raise click.exceptions.Exit(1)
333
+
334
+
335
+ # -- merge apply ---------------------------------------------------------------
336
+
337
+
338
+ @merge.command()
339
+ @click.argument("session_id")
340
+ @click.option(
341
+ "--resolve",
342
+ "strategy",
343
+ type=click.Choice(["ours", "theirs"]),
344
+ required=True,
345
+ help="Resolution strategy for all conflicts.",
346
+ )
347
+ @click.option("--file", "filename", default=None, help="Write merged result to this local file.")
348
+ @pass_ctx
349
+ def apply(ctx: Ctx, session_id: str, strategy: str, filename: str | None) -> None:
350
+ """Apply conflict resolutions to a pending merge session.
351
+
352
+ \b
353
+ deepcell merge apply <session-id> --resolve ours
354
+ deepcell merge apply <session-id> --resolve theirs --file model.deepcell
355
+ """
356
+ client = ctx.client
357
+
358
+ try:
359
+ result = client.post("/merge/resolve", json={
360
+ "merge_session_id": session_id,
361
+ "resolutions": {"*": strategy},
362
+ })
363
+ except Exception as exc:
364
+ raise click.ClickException(f"Merge resolve failed: {exc}") from exc
365
+
366
+ if result.get("success") and result.get("merged_xml"):
367
+ lossy = echo_merge_data_loss(result)
368
+ if filename:
369
+ root = find_sync_root()
370
+ if root:
371
+ target = root / filename
372
+ # Read before overwriting: `pull` left `theirs` in the tracked
373
+ # file, and that is what the new baseline must be.
374
+ server_content = (
375
+ target.read_text(encoding="utf-8") if target.exists() else ""
376
+ )
377
+ target.write_text(result["merged_xml"], encoding="utf-8")
378
+ state = load_sync_state(root)
379
+ _update_checksum(root, state, filename, server_content)
380
+ # Clean up .local — unless the merge dropped data, in which
381
+ # case the backup is the only copy of what was lost.
382
+ local_backup = root / f"{filename}.local"
383
+ if local_backup.exists() and not lossy:
384
+ local_backup.unlink()
385
+ echo_success(f"Merged and written to {filename}")
386
+ else:
387
+ Path(filename).write_text(result["merged_xml"], encoding="utf-8")
388
+ echo_success(f"Merged and written to {filename}")
389
+ else:
390
+ # Output merged XML to stdout
391
+ click.echo(result["merged_xml"])
392
+ echo_success("Merge resolved successfully")
393
+ if lossy:
394
+ raise click.exceptions.Exit(1)
395
+ else:
396
+ remaining = result.get("remaining_conflicts", 0)
397
+ echo_error(f"{remaining} conflict(s) still unresolved")
398
+ _show_conflicts(result.get("conflicts", []))
399
+ raise SystemExit(1)