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,804 @@
1
+ """Sync commands: clone, pull, push, status, commit.
2
+
3
+ Git-like sync between a local folder and a cloud DeepCell workspace.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ from deepcell_cli.context import Ctx, pass_ctx
13
+ from deepcell_cli.output import (
14
+ echo_error,
15
+ echo_info,
16
+ echo_success,
17
+ echo_validation_map,
18
+ echo_warning,
19
+ )
20
+ from deepcell_cli.sync_state import (
21
+ FileChecksum,
22
+ SyncState,
23
+ compute_local_hash,
24
+ find_sync_root,
25
+ load_sync_state,
26
+ save_sync_state,
27
+ scan_local_files,
28
+ )
29
+
30
+
31
+ # -- Helpers -------------------------------------------------------------------
32
+
33
+
34
+ def _require_sync_root(ctx: Ctx | None = None) -> tuple[Path, SyncState]:
35
+ """Locate sync root from CWD or raise, and reject a contradictory --workspace.
36
+
37
+ `--workspace` is a root option documented as overriding the active
38
+ workspace for the invocation. It cannot do that here: a clone directory is
39
+ bound to one workspace by its sync state, and honouring the flag would
40
+ pull one workspace's files into another's checkout, or push them there.
41
+
42
+ So the flag was ignored — silently, which is the worse of the two failures
43
+ it could have. A reader who passes it believes they are inspecting the
44
+ workspace they named while looking at the one on disk, and `status` gives
45
+ them a clean bill of health for a workspace it never contacted.
46
+
47
+ Naming the same workspace is not a contradiction and passes through: a
48
+ script that sets `DEEPCELL_WORKSPACE` globally and then cds into that
49
+ workspace's clone is doing nothing wrong.
50
+ """
51
+ root = find_sync_root()
52
+ if root is None:
53
+ raise click.ClickException(
54
+ "Not inside a synced workspace. Run `deepcell clone <slug>` first."
55
+ )
56
+ state = load_sync_state(root)
57
+ requested = getattr(ctx, "workspace", None) if ctx is not None else None
58
+ if requested and requested != state.workspace_slug:
59
+ raise click.UsageError(
60
+ f"--workspace {requested!r} does not apply to a sync command: "
61
+ f"{root}/ is a clone of workspace {state.workspace_slug!r} and "
62
+ f"every sync command reads that binding from .deepcell-sync. "
63
+ f"Run this from a clone of {requested!r}, or "
64
+ f"`deepcell clone {requested}` to make one."
65
+ )
66
+ return root, state
67
+
68
+
69
+ def _get_head_sha(client, slug: str, variant: str | None = None) -> str:
70
+ """Fetch the HEAD commit SHA for a workspace (or a variant branch)."""
71
+ if variant:
72
+ data = client.get(f"/workspaces/{slug}/variants/{variant}")
73
+ if isinstance(data, dict) and data.get("head_sha"):
74
+ return data["head_sha"]
75
+ return ""
76
+ # Try head_sha on workspace detail first (fast path)
77
+ ws = client.get(f"/workspaces/{slug}")
78
+ if isinstance(ws, dict) and ws.get("head_sha"):
79
+ return ws["head_sha"]
80
+ # Fallback: fetch latest version entry
81
+ versions = client.get(f"/workspaces/{slug}/versions", params={"max_count": 1})
82
+ if versions and isinstance(versions, list):
83
+ return versions[0].get("sha", "")
84
+ return ""
85
+
86
+
87
+ def _get_remote_files(
88
+ client, slug: str, variant: str | None = None, *, include_uploaded: bool = False
89
+ ) -> list[dict]:
90
+ """Fetch the remote file list with git_sha per file (variant-aware).
91
+
92
+ Uploaded attachments (`file_type == "uploaded"`) live in object storage,
93
+ not git: they carry no git_sha and the file-read route 404s on them. Every
94
+ sync path here downloads what it lists, so they are filtered out by
95
+ default — `clone` filtered them at its own call site but `pull` and
96
+ `variant checkout` did not, and a single attachment uploaded in the web UI
97
+ then broke every later pull permanently. Pass *include_uploaded* only to
98
+ report on them.
99
+ """
100
+ if variant:
101
+ files = client.get(f"/workspaces/{slug}/variants/{variant}/files")
102
+ else:
103
+ files = client.get(f"/workspaces/{slug}/files")
104
+ if include_uploaded:
105
+ return files
106
+ return [f for f in files if f.get("file_type") != "uploaded"]
107
+
108
+
109
+ def _download_file(client, slug: str, filename: str, *, revision: str | None = None) -> str:
110
+ """Download a single file's content from the workspace (main branch)."""
111
+ params = {"revision": revision} if revision else None
112
+ data = client.get(f"/workspaces/{slug}/files/{filename}", params=params)
113
+ if isinstance(data, dict) and "content" in data:
114
+ return data["content"]
115
+ return str(data)
116
+
117
+
118
+ def _download_branch_file(
119
+ client, slug: str, filename: str, *, variant: str | None = None, revision: str | None = None
120
+ ) -> str:
121
+ """Download a single file's content from a branch (variant-aware).
122
+
123
+ When *variant* is set, hits the variant read route; otherwise the main route.
124
+ """
125
+ if not variant:
126
+ return _download_file(client, slug, filename, revision=revision)
127
+ if revision:
128
+ # The variant read route declares no `revision` param, so FastAPI
129
+ # dropped it and answered with the branch HEAD — the caller silently
130
+ # got different bytes than it asked for. Commit SHAs are
131
+ # branch-independent, so read a pinned revision via the main route.
132
+ raise ValueError(
133
+ f"Cannot read revision {revision!r} from variant '{variant}': the "
134
+ f"variant file route only serves branch HEAD. Read the pinned "
135
+ f"revision through the main branch instead (commit SHAs are "
136
+ f"branch-independent)."
137
+ )
138
+ data = client.get(
139
+ f"/workspaces/{slug}/variants/{variant}/files/{filename}"
140
+ )
141
+ if isinstance(data, dict) and "content" in data:
142
+ return data["content"]
143
+ return str(data)
144
+
145
+
146
+ def _try_merge(client, base_xml: str, ours_xml: str, theirs_xml: str, filename: str) -> dict | None:
147
+ """Attempt a 3-way merge via the merge API. Returns response dict or None on failure."""
148
+ try:
149
+ return client.post("/merge/preview", json={
150
+ "base_xml": base_xml,
151
+ "ours_xml": ours_xml,
152
+ "theirs_xml": theirs_xml,
153
+ "filename": filename,
154
+ })
155
+ except Exception:
156
+ return None
157
+
158
+
159
+ # -- clone ---------------------------------------------------------------------
160
+
161
+
162
+ @click.command()
163
+ @click.argument("slug")
164
+ @click.argument("directory", required=False)
165
+ @pass_ctx
166
+ def clone(ctx: Ctx, slug: str, directory: str | None) -> None:
167
+ """Clone a workspace to a local folder.
168
+
169
+ \b
170
+ deepcell clone my-workspace # creates ./my-workspace/
171
+ deepcell clone my-workspace ./local-dir # creates ./local-dir/
172
+ """
173
+ client = ctx.client
174
+
175
+ # 1. Validate workspace access
176
+ ws = client.get(f"/workspaces/{slug}")
177
+ workspace_id = str(ws.get("id", ""))
178
+
179
+ # 2. Get remote file list
180
+ remote_files = _get_remote_files(client, slug, include_uploaded=True)
181
+
182
+ # 3. Get HEAD SHA
183
+ head_sha = _get_head_sha(client, slug)
184
+
185
+ # 4. Determine target directory
186
+ target = Path(directory) if directory else Path(slug)
187
+ pre_existing = target.exists()
188
+ if pre_existing and any(target.iterdir()):
189
+ raise click.ClickException(f"Directory '{target}' already exists and is not empty.")
190
+ target.mkdir(parents=True, exist_ok=True)
191
+
192
+ # 5. Download each file. Uploaded attachments live in object storage, not
193
+ # git: they carry file_type="uploaded" and no git_sha, and the git read
194
+ # route 404s on them. Downloading them aborted the clone mid-way and left
195
+ # a partial directory that blocked every retry.
196
+ git_files = [f for f in remote_files if f.get("file_type") != "uploaded"]
197
+ non_git = [f["filename"] for f in remote_files if f.get("file_type") == "uploaded"]
198
+
199
+ checksums: dict[str, FileChecksum] = {}
200
+ try:
201
+ for f in git_files:
202
+ fname = f["filename"]
203
+ git_sha = f.get("git_sha", "")
204
+ echo_info(f" Downloading {fname}")
205
+ content = _download_file(client, slug, fname)
206
+ (target / fname).write_text(content, encoding="utf-8")
207
+ checksums[fname] = FileChecksum(
208
+ git_sha=git_sha,
209
+ local_hash=compute_local_hash(content),
210
+ )
211
+ except Exception:
212
+ # Leave no half-written directory behind: a retry would fail on
213
+ # "already exists and is not empty" instead of re-cloning.
214
+ if not pre_existing:
215
+ import shutil
216
+
217
+ shutil.rmtree(target, ignore_errors=True)
218
+ raise
219
+
220
+ # 6. Write sync state
221
+ from deepcell_cli.config import get_api_url
222
+
223
+ state = SyncState(
224
+ workspace_slug=slug,
225
+ workspace_id=workspace_id,
226
+ api_url=get_api_url(),
227
+ last_sync_sha=head_sha,
228
+ file_checksums=checksums,
229
+ )
230
+ save_sync_state(target, state)
231
+
232
+ echo_success(
233
+ f"Cloned workspace '{slug}' to {target}/ "
234
+ f"({len(git_files)} file(s), HEAD {head_sha[:8]})"
235
+ )
236
+ if non_git:
237
+ echo_warning(
238
+ f"{len(non_git)} uploaded attachment(s) not cloned (they are not in "
239
+ f"git): {', '.join(non_git)} — open the workspace in the Workbench "
240
+ f"to download them. `deepcell download` reads the git route, which "
241
+ f"404s on these."
242
+ )
243
+
244
+
245
+ # -- status --------------------------------------------------------------------
246
+
247
+
248
+ @click.command("status")
249
+ @pass_ctx
250
+ def sync_status(ctx: Ctx) -> None:
251
+ """Show local changes vs last sync.
252
+
253
+ \b
254
+ A filename — added locally
255
+ M filename — modified locally
256
+ D filename — deleted locally
257
+
258
+ Which workspace this reports on comes from the clone's `.deepcell-sync`,
259
+ not from `--workspace` — a clone directory is bound to one workspace. The
260
+ root `--workspace` flag is rejected here when it names a different one
261
+ rather than being quietly ignored.
262
+ """
263
+ root, state = _require_sync_root(ctx)
264
+ active_variant = state.active_variant or None
265
+ local_files = scan_local_files(root, tracked=state.file_checksums)
266
+
267
+ added: list[str] = []
268
+ modified: list[str] = []
269
+ deleted: list[str] = []
270
+
271
+ # Check for modifications and deletions
272
+ for name, ck in state.file_checksums.items():
273
+ if name not in local_files:
274
+ deleted.append(name)
275
+ elif local_files[name] != ck.local_hash:
276
+ modified.append(name)
277
+
278
+ # Check for additions
279
+ for name in local_files:
280
+ if name not in state.file_checksums:
281
+ added.append(name)
282
+
283
+ if not added and not modified and not deleted:
284
+ echo_info("Clean — no local changes since last sync.")
285
+ else:
286
+ for name in sorted(added):
287
+ click.echo(click.style("A ", fg="green") + name)
288
+ for name in sorted(modified):
289
+ click.echo(click.style("M ", fg="yellow") + name)
290
+ for name in sorted(deleted):
291
+ click.echo(click.style("D ", fg="red") + name)
292
+
293
+ # Optionally check remote
294
+ try:
295
+ client = ctx.client
296
+ head_sha = _get_head_sha(client, state.workspace_slug, variant=active_variant)
297
+ if head_sha and state.last_sync_sha and head_sha != state.last_sync_sha:
298
+ echo_info(f"Remote has new commit(s) since last sync (HEAD {head_sha[:8]})")
299
+ except Exception:
300
+ pass # offline is fine
301
+
302
+
303
+ # -- pull ----------------------------------------------------------------------
304
+
305
+
306
+ @click.command()
307
+ @pass_ctx
308
+ def pull(ctx: Ctx) -> None:
309
+ """Fetch latest cloud changes to the local folder.
310
+
311
+ Downloads remote changes and detects conflicts when a file was modified
312
+ both locally and on the server.
313
+
314
+ Which workspace it pulls from comes from the clone's `.deepcell-sync`, not
315
+ from `--workspace`; passing a different one is a usage error, not a
316
+ redirect. Run it from a clone of the workspace you mean.
317
+ """
318
+ root, state = _require_sync_root(ctx)
319
+ client = ctx.client
320
+ slug = state.workspace_slug
321
+ active_variant = state.active_variant or None
322
+
323
+ # 1. Check remote HEAD (of the ACTIVE branch — variant or main)
324
+ head_sha = _get_head_sha(client, slug, variant=active_variant)
325
+ if head_sha == state.last_sync_sha:
326
+ echo_info("Already up to date.")
327
+ return
328
+
329
+ # 2. Get remote file list (of the active branch)
330
+ remote_files = _get_remote_files(client, slug, variant=active_variant)
331
+ remote_map: dict[str, dict] = {f["filename"]: f for f in remote_files}
332
+
333
+ # 3. Scan local state
334
+ local_files = scan_local_files(root, tracked=state.file_checksums)
335
+
336
+ # 4. Diff remote vs stored checksums
337
+ new_checksums: dict[str, FileChecksum] = {}
338
+ conflicts = 0
339
+
340
+ # Process remote files (new or modified)
341
+ for fname, finfo in remote_map.items():
342
+ remote_git_sha = finfo.get("git_sha", "")
343
+ stored = state.file_checksums.get(fname)
344
+
345
+ if stored and stored.git_sha == remote_git_sha:
346
+ # Remote unchanged — keep current local state
347
+ local_hash = local_files.get(fname, stored.local_hash)
348
+ new_checksums[fname] = FileChecksum(
349
+ git_sha=remote_git_sha,
350
+ local_hash=local_hash,
351
+ )
352
+ continue
353
+
354
+ # Remote changed (or new file)
355
+ local_modified = (
356
+ stored is not None
357
+ and fname in local_files
358
+ and local_files[fname] != stored.local_hash
359
+ )
360
+
361
+ if local_modified:
362
+ # Conflict: both sides changed
363
+ local_content = (root / fname).read_text(encoding="utf-8")
364
+ theirs_content = _download_branch_file(
365
+ client, slug, fname, variant=active_variant,
366
+ )
367
+
368
+ # Try semantic merge for .deepcell files
369
+ merged = False
370
+ if fname.endswith(".deepcell") and state.last_sync_sha:
371
+ try:
372
+ # The merge BASE is pinned to a commit SHA (last_sync_sha),
373
+ # which is branch-independent. The variant file route reads
374
+ # only at the variant branch HEAD and ignores any `revision`
375
+ # param, so read the base via the MAIN route by SHA.
376
+ base_content = _download_file(
377
+ client, slug, fname, revision=state.last_sync_sha,
378
+ )
379
+ result = _try_merge(
380
+ client, base_content, local_content, theirs_content, fname,
381
+ )
382
+ if result and result.get("success") and result.get("merged_xml"):
383
+ from deepcell_cli.commands.merge import echo_merge_data_loss
384
+
385
+ # `success` counts conflicts only — an apply-time drop
386
+ # still comes back clean, so check before discarding
387
+ # the pre-merge local copy.
388
+ lossy = echo_merge_data_loss(result)
389
+ (root / fname).write_text(result["merged_xml"], encoding="utf-8")
390
+ new_checksums[fname] = FileChecksum(
391
+ git_sha=remote_git_sha,
392
+ # Baseline is what the SERVER holds (theirs), not
393
+ # the merge result: the merged content exists only
394
+ # on disk and still has to be pushed. Recording the
395
+ # merged hash made `push` say "Nothing to push" and
396
+ # `status` say Clean, and the next pull took the
397
+ # git_sha fast path — the merge was stranded with
398
+ # no command left to reveal it.
399
+ local_hash=compute_local_hash(theirs_content),
400
+ )
401
+ auto_count = result.get("auto_resolved_count", 0)
402
+ if lossy:
403
+ # Keep the pre-merge local copy and count it as a
404
+ # conflict so the pull exits 1 instead of reading
405
+ # as a clean auto-merge.
406
+ (root / f"{fname}.local").write_text(
407
+ local_content, encoding="utf-8"
408
+ )
409
+ conflicts += 1
410
+ echo_error(
411
+ f"INCOMPLETE MERGE: {fname} (pre-merge local saved "
412
+ f"as {fname}.local)"
413
+ )
414
+ else:
415
+ echo_info(f" Auto-merged {fname} ({auto_count} change(s) resolved)")
416
+ merged = True
417
+ elif result and not result.get("success"):
418
+ # Merge has conflicts — fall back to .local but show details
419
+ conflict_list = result.get("conflicts", [])
420
+ for c in conflict_list[:5]:
421
+ item = c.get("item_ref", "?")
422
+ cref = c.get("context_ref", "")
423
+ ours_val = c.get("ours", "")
424
+ theirs_val = c.get("theirs", "")
425
+ echo_warning(
426
+ f" {item}[{cref}]: ours={ours_val} vs theirs={theirs_val}"
427
+ )
428
+ if len(conflict_list) > 5:
429
+ echo_warning(f" ... and {len(conflict_list) - 5} more conflict(s)")
430
+ except Exception:
431
+ pass # graceful degradation — fall back to .local
432
+
433
+ if not merged:
434
+ conflicts += 1
435
+ local_backup = root / f"{fname}.local"
436
+ local_backup.write_text(local_content, encoding="utf-8")
437
+ (root / fname).write_text(theirs_content, encoding="utf-8")
438
+ new_checksums[fname] = FileChecksum(
439
+ git_sha=remote_git_sha,
440
+ local_hash=compute_local_hash(theirs_content),
441
+ )
442
+ echo_error(f"CONFLICT: {fname} (local saved as {fname}.local)")
443
+ if fname.endswith(".deepcell"):
444
+ echo_info(f" Hint: run `deepcell merge resolve {fname}` to resolve")
445
+ continue
446
+
447
+ # Download remote version (no conflict)
448
+ echo_info(f" Updating {fname}")
449
+ content = _download_branch_file(client, slug, fname, variant=active_variant)
450
+ (root / fname).write_text(content, encoding="utf-8")
451
+ new_checksums[fname] = FileChecksum(
452
+ git_sha=remote_git_sha,
453
+ local_hash=compute_local_hash(content),
454
+ )
455
+
456
+ # Handle remote deletions (file in stored checksums but not in remote)
457
+ for fname, stored in state.file_checksums.items():
458
+ if fname in remote_map:
459
+ continue
460
+ local_modified = (
461
+ fname in local_files
462
+ and local_files[fname] != stored.local_hash
463
+ )
464
+ if local_modified:
465
+ # Remote deleted, local modified — keep local
466
+ echo_info(f" Keeping locally modified {fname} (deleted on remote)")
467
+ new_checksums[fname] = FileChecksum(
468
+ git_sha="",
469
+ local_hash=local_files[fname],
470
+ )
471
+ elif fname in local_files:
472
+ # Remote deleted, no local changes — delete locally
473
+ echo_info(f" Deleting {fname}")
474
+ (root / fname).unlink()
475
+
476
+ # Preserve locally added files not tracked by sync
477
+ for fname in local_files:
478
+ if fname not in new_checksums and fname not in state.file_checksums:
479
+ # Locally added file — not in remote, keep it
480
+ new_checksums[fname] = FileChecksum(
481
+ git_sha="",
482
+ local_hash=local_files[fname],
483
+ )
484
+
485
+ # 5. Update sync state. Never overwrite a known baseline with a blank HEAD
486
+ # (a malformed 200 response with no head_sha): that would silently disarm the
487
+ # optimistic-lock guard on the next push (symmetric to the push guard). Keep
488
+ # the prior baseline armed and warn instead.
489
+ if head_sha:
490
+ state.last_sync_sha = head_sha
491
+ elif state.last_sync_sha:
492
+ echo_info(
493
+ "Pulled, but the server did not report a HEAD SHA; keeping the "
494
+ "previous sync baseline so the next push stays guarded."
495
+ )
496
+ else:
497
+ state.last_sync_sha = head_sha
498
+ state.file_checksums = new_checksums
499
+ save_sync_state(root, state)
500
+
501
+ if conflicts:
502
+ echo_warning(
503
+ f"Pulled with {conflicts} unresolved conflict(s). "
504
+ "Resolve with `deepcell merge resolve <file>` or edit .local files manually."
505
+ )
506
+ # Sync state is already saved; exit nonzero so a scripted
507
+ # `deepcell pull && deepcell push` never proceeds on a conflicted tree.
508
+ raise click.exceptions.Exit(1)
509
+ echo_success(f"Pulled latest changes (HEAD {head_sha[:8]})")
510
+
511
+
512
+ # -- push ----------------------------------------------------------------------
513
+
514
+
515
+ @click.command()
516
+ @click.option("-m", "--message", default="", help="Commit message for the push.")
517
+ @pass_ctx
518
+ def push(ctx: Ctx, message: str) -> None:
519
+ """Upload local changes to the cloud workspace.
520
+
521
+ Rejects if the remote has changed since your last sync.
522
+ Run `deepcell pull` first to incorporate remote changes.
523
+ """
524
+ root, state = _require_sync_root(ctx)
525
+ client = ctx.client
526
+ slug = state.workspace_slug
527
+ active_variant = state.active_variant or None
528
+
529
+ # 1. Check remote HEAD matches our last sync (against the ACTIVE branch)
530
+ head_sha = _get_head_sha(client, slug, variant=active_variant)
531
+ if head_sha and state.last_sync_sha and head_sha != state.last_sync_sha:
532
+ raise click.ClickException(
533
+ "Remote has new changes. Run `deepcell pull` first."
534
+ )
535
+
536
+ # 2. Compute local changes
537
+ local_files = scan_local_files(root, tracked=state.file_checksums)
538
+ added: list[str] = []
539
+ modified: list[str] = []
540
+ deleted: list[str] = []
541
+
542
+ for name in local_files:
543
+ if name not in state.file_checksums:
544
+ added.append(name)
545
+ elif local_files[name] != state.file_checksums[name].local_hash:
546
+ modified.append(name)
547
+
548
+ for name in state.file_checksums:
549
+ if name not in local_files:
550
+ deleted.append(name)
551
+
552
+ if not added and not modified and not deleted:
553
+ echo_info("Nothing to push.")
554
+ return
555
+
556
+ # 3. Upload changes
557
+ commit_msg = message or "Sync from CLI"
558
+ if not message:
559
+ import sys
560
+ print(
561
+ "Warning: no commit message given (-m). Proceeding with 'Sync from CLI'. "
562
+ "Run `deepcell push -m '<message>'` to describe your changes.",
563
+ file=sys.stderr,
564
+ )
565
+
566
+ # Variant deletions can't be applied (no backend route), so they're skipped.
567
+ # Tracked separately so they don't inflate the pushed count and so the still-
568
+ # on-server files stay in the tracked checksums (else they resurface as new
569
+ # on the next pull).
570
+ skipped_deletes: list[str] = []
571
+
572
+ # Files the server committed but flagged with validation errors. The push
573
+ # is real either way (the server persists invalid .deepcell content on
574
+ # purpose), but it must not read as a clean success or exit 0.
575
+ invalid_files: list[str] = []
576
+
577
+ def _surface_validation(fname: str, validation: object) -> None:
578
+ if not isinstance(validation, dict):
579
+ return
580
+ for w in validation.get("warnings") or []:
581
+ echo_warning(f"{fname}: {w}")
582
+ errors = validation.get("errors") or []
583
+ for e in errors:
584
+ echo_error(f"{fname}: Error: {e}")
585
+ if errors:
586
+ invalid_files.append(fname)
587
+
588
+ # Set only by the main-branch batch path; the per-file variant path has no
589
+ # batch response to read committed hashes from.
590
+ batch_result: dict | None = None
591
+
592
+ if active_variant:
593
+ # Variant mode: commit each changed file individually via the variant file endpoint.
594
+ # (No batch variant endpoint exists; per-file is the v1 approach.)
595
+ # Deleted files on a variant have no backend route — warn and skip.
596
+ last_commit_sha = ""
597
+ for fname in added + modified:
598
+ content = (root / fname).read_text(encoding="utf-8")
599
+ echo_info(f" Pushing {fname} → variant '{active_variant}'")
600
+ result = client.post(
601
+ f"/workspaces/{slug}/variants/{active_variant}/files/{fname}",
602
+ json={"content": content, "commit_message": commit_msg},
603
+ )
604
+ if isinstance(result, dict) and result.get("commit_sha"):
605
+ last_commit_sha = result["commit_sha"]
606
+ if isinstance(result, dict):
607
+ _surface_validation(fname, result.get("validation"))
608
+
609
+ if deleted:
610
+ skipped_deletes = list(deleted)
611
+ for fname in deleted:
612
+ echo_warning(
613
+ f" Skipping deletion of {fname} on variant — "
614
+ "no backend route for variant file-deletes (deferred)"
615
+ )
616
+
617
+ new_head = last_commit_sha or _get_head_sha(client, slug, variant=active_variant)
618
+ else:
619
+ # Main mode: single atomic batch commit
620
+ files_payload: dict[str, str] = {}
621
+ for fname in added + modified:
622
+ content = (root / fname).read_text(encoding="utf-8")
623
+ echo_info(f" Staging {fname}")
624
+ files_payload[fname] = content
625
+
626
+ for fname in deleted:
627
+ echo_info(f" Deleting {fname}")
628
+
629
+ batch_result = client.post(
630
+ f"/workspaces/{slug}/files:batch",
631
+ json={
632
+ "files": files_payload,
633
+ "deletes": deleted,
634
+ "commit_message": commit_msg,
635
+ # Optimistic-lock guard: the server rejects with 409 if main HEAD
636
+ # advanced past the revision we synced against. None on first sync
637
+ # (no baseline yet) so the server skips the CAS.
638
+ "expected_parent": state.last_sync_sha or None,
639
+ },
640
+ )
641
+
642
+ new_head = (
643
+ batch_result.get("commit_sha")
644
+ if isinstance(batch_result, dict) and batch_result.get("commit_sha")
645
+ else _get_head_sha(client, slug)
646
+ )
647
+ if isinstance(batch_result, dict) and isinstance(
648
+ batch_result.get("validation"), dict
649
+ ):
650
+ for fname, v in batch_result["validation"].items():
651
+ _surface_validation(fname, v)
652
+ remote_files = _get_remote_files(client, slug, variant=active_variant)
653
+ remote_map = {f["filename"]: f for f in remote_files}
654
+
655
+ # The server normalizes and recomputes .deepcell content before
656
+ # committing, so what landed in git may differ from what we sent.
657
+ # Recording the on-disk hash as the baseline made `status` report "Clean"
658
+ # while the two differed, and `pull` (which compares git SHAs) never
659
+ # reconciled it. Pull back exactly the files whose committed hash differs.
660
+ committed_hashes = (
661
+ batch_result.get("content_hashes")
662
+ if isinstance(batch_result, dict) else None
663
+ ) or {}
664
+ rewritten: list[str] = []
665
+ # Files we could not reconcile: baseline them against the server's hash so
666
+ # the drift stays visible instead of being papered over as Clean.
667
+ baseline_overrides: dict[str, str] = {}
668
+ for fname, committed_hash in committed_hashes.items():
669
+ if fname not in local_files or local_files[fname] == committed_hash:
670
+ continue
671
+ try:
672
+ server_content = _download_branch_file(
673
+ client, slug, fname, variant=active_variant,
674
+ )
675
+ except Exception:
676
+ # Leave the local copy alone, but baseline it against what the
677
+ # server committed so `status` actually reports the drift. Keeping
678
+ # the on-disk hash here made the baseline match the file and
679
+ # `status` say Clean — reinstating the very bug this reconcile
680
+ # exists to fix, just on the error path.
681
+ baseline_overrides[fname] = committed_hash
682
+ continue
683
+ (root / fname).write_text(server_content, encoding="utf-8")
684
+ local_files[fname] = compute_local_hash(server_content)
685
+ rewritten.append(fname)
686
+
687
+ new_checksums: dict[str, FileChecksum] = {}
688
+ for fname in local_files:
689
+ remote_info = remote_map.get(fname, {})
690
+ new_checksums[fname] = FileChecksum(
691
+ git_sha=remote_info.get("git_sha", ""),
692
+ local_hash=baseline_overrides.get(fname, local_files[fname]),
693
+ )
694
+
695
+ # Skipped variant deletions are still on the server: keep them tracked (with
696
+ # their server git_sha) so the next pull sees them as unchanged rather than
697
+ # re-downloading them as "new". They remain locally deleted — status keeps
698
+ # showing D until a real variant delete route exists.
699
+ for fname in skipped_deletes:
700
+ remote_info = remote_map.get(fname)
701
+ if remote_info is None:
702
+ continue # genuinely gone from the server — let it drop from tracking
703
+ prev = state.file_checksums.get(fname)
704
+ new_checksums[fname] = FileChecksum(
705
+ git_sha=remote_info.get("git_sha", ""),
706
+ local_hash=prev.local_hash if prev else "",
707
+ )
708
+
709
+ # Never persist a blank baseline. An empty new_head (no commit_sha AND the
710
+ # HEAD fallback also returned "") would silently disable the optimistic-lock
711
+ # guard on the next push. The upload/commit already succeeded above, so fail
712
+ # loudly and tell the user to re-sync rather than corrupting the baseline.
713
+ if not new_head:
714
+ raise click.ClickException(
715
+ "Push completed but could not determine the new HEAD SHA. "
716
+ "Run `deepcell pull` to re-sync before pushing again."
717
+ )
718
+
719
+ state.last_sync_sha = new_head
720
+ state.file_checksums = new_checksums
721
+ save_sync_state(root, state)
722
+
723
+ # Skipped variant deletions weren't applied, so they don't count as pushed.
724
+ total = len(added) + len(modified) + (len(deleted) - len(skipped_deletes))
725
+ echo_success(
726
+ f"Pushed {total} change(s) to '{slug}' (HEAD {new_head[:8]})"
727
+ )
728
+ if rewritten:
729
+ echo_info(
730
+ f" {len(rewritten)} file(s) updated locally to match server "
731
+ f"normalization/recalculation: {', '.join(rewritten)}"
732
+ )
733
+ if skipped_deletes:
734
+ echo_warning(
735
+ f"{len(skipped_deletes)} deletion(s) skipped "
736
+ "(no variant delete route — files remain on the server)"
737
+ )
738
+ if invalid_files:
739
+ echo_warning(
740
+ f"Push committed, but {len(invalid_files)} file(s) have validation "
741
+ f"error(s): {', '.join(invalid_files)} — fix the errors above and "
742
+ "push again"
743
+ )
744
+ raise click.exceptions.Exit(1)
745
+
746
+
747
+ # -- commit (server-side pending) ---------------------------------------------
748
+
749
+
750
+ @click.command()
751
+ @click.option("-m", "--message", default=None, help="Commit message.")
752
+ @pass_ctx
753
+ def commit(ctx: Ctx, message: str | None) -> None:
754
+ """Commit changes staged on the server by something other than you.
755
+
756
+ You almost certainly do not need this. `defs`, `edit`, `write` and
757
+ `reasoning` each commit their own change with their own `-m` — there is no
758
+ staging area they queue into. This commits what a *different* actor left
759
+ pending on the server, such as files created by an agent session.
760
+
761
+ Nothing staged is a no-op, not an error.
762
+ """
763
+ root, state = _require_sync_root(ctx)
764
+ slug = state.workspace_slug
765
+
766
+ # `-m` is no longer `required=True`, so this reaches the server even when
767
+ # the caller gave no message. The server checks "is anything staged"
768
+ # first and answers the no-op; a commit that will actually write still
769
+ # 422s without a message, and that is re-raised below as a usage error.
770
+ #
771
+ # It used to be rejected by Click during parsing, so a worker whose defs
772
+ # ops had each already committed and who reasonably tried to finalize got
773
+ # "Missing option '-m' / '--message'" — an argument-syntax error for a
774
+ # command with nothing to do. That is what the 2026-08-02 CLI eval hit.
775
+ data = ctx.client.post(
776
+ f"/workspaces/{slug}/versions/commit",
777
+ json={"message": message or ""},
778
+ )
779
+
780
+ # A commit with nothing staged is a benign no-op (the endpoint answers
781
+ # 200 with committed=false, sha=null) — report it instead of slicing a
782
+ # null sha.
783
+ if isinstance(data, dict) and data.get("committed") is False:
784
+ echo_info(
785
+ "Nothing to commit — no pending changes. `defs`, `edit`, `write` "
786
+ "and `reasoning` each commit their own change, so there is "
787
+ "normally nothing here to finalize."
788
+ )
789
+ return
790
+
791
+ sha = (data.get("sha") or "")[:8] if isinstance(data, dict) else ""
792
+ # Staged content is committed either way (the server persists invalid
793
+ # .deepcell on purpose), so report the errors rather than letting a clean
794
+ # "Committed" line stand in for them — parity with write/push.
795
+ invalid = echo_validation_map(
796
+ data.get("validation") if isinstance(data, dict) else None
797
+ )
798
+ if invalid:
799
+ echo_warning(
800
+ f"Committed ({sha}), but {len(invalid)} file(s) have validation "
801
+ f"error(s): {', '.join(invalid)} — fix the errors above"
802
+ )
803
+ raise click.exceptions.Exit(1)
804
+ echo_success(f"Committed ({sha}): {message}")