gitwise-cli 0.24.2__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 (125) hide show
  1. gitwise/__init__.py +11 -0
  2. gitwise/__main__.py +113 -0
  3. gitwise/_cli_completions.py +88 -0
  4. gitwise/_cli_dispatch.py +469 -0
  5. gitwise/_cli_introspection.py +275 -0
  6. gitwise/_cli_parser.py +345 -0
  7. gitwise/_cli_setup_agents.py +439 -0
  8. gitwise/_i18n_data.json +1934 -0
  9. gitwise/_paths.py +22 -0
  10. gitwise/_runtime_config.py +246 -0
  11. gitwise/audit.py +338 -0
  12. gitwise/branches.py +183 -0
  13. gitwise/clean.py +197 -0
  14. gitwise/commit.py +142 -0
  15. gitwise/conflicts.py +112 -0
  16. gitwise/context.py +163 -0
  17. gitwise/design.py +383 -0
  18. gitwise/diff.py +309 -0
  19. gitwise/doctor.py +116 -0
  20. gitwise/git.py +254 -0
  21. gitwise/health.py +345 -0
  22. gitwise/i18n.py +99 -0
  23. gitwise/log.py +329 -0
  24. gitwise/merge.py +193 -0
  25. gitwise/optimize.py +212 -0
  26. gitwise/output.py +652 -0
  27. gitwise/pick.py +102 -0
  28. gitwise/pr.py +543 -0
  29. gitwise/py.typed +0 -0
  30. gitwise/schema.py +49 -0
  31. gitwise/setup.py +551 -0
  32. gitwise/setup_agents/__init__.py +36 -0
  33. gitwise/setup_agents/adapters/__init__.py +17 -0
  34. gitwise/setup_agents/adapters/aider.py +5 -0
  35. gitwise/setup_agents/adapters/base.py +5 -0
  36. gitwise/setup_agents/adapters/codex.py +5 -0
  37. gitwise/setup_agents/adapters/continue_adapter.py +5 -0
  38. gitwise/setup_agents/adapters/cursor.py +5 -0
  39. gitwise/setup_agents/adapters/opencode.py +5 -0
  40. gitwise/setup_agents/adapters/pi.py +5 -0
  41. gitwise/setup_agents/exec.py +449 -0
  42. gitwise/setup_agents/format.py +164 -0
  43. gitwise/setup_agents/plan.py +254 -0
  44. gitwise/setup_agents/plan_gitfiles.py +167 -0
  45. gitwise/setup_agents/plan_skills.py +256 -0
  46. gitwise/setup_agents/providers/__init__.py +96 -0
  47. gitwise/setup_agents/providers/aider.py +11 -0
  48. gitwise/setup_agents/providers/base.py +79 -0
  49. gitwise/setup_agents/providers/claude.py +408 -0
  50. gitwise/setup_agents/providers/codex.py +11 -0
  51. gitwise/setup_agents/providers/continue_adapter.py +11 -0
  52. gitwise/setup_agents/providers/cursor.py +11 -0
  53. gitwise/setup_agents/providers/opencode.py +11 -0
  54. gitwise/setup_agents/providers/pi.py +11 -0
  55. gitwise/setup_agents/state.py +141 -0
  56. gitwise/setup_agents/types.py +48 -0
  57. gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
  58. gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
  59. gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
  60. gitwise/share/aider/CONVENTIONS.md.template +8 -0
  61. gitwise/share/aider/aider.conf.yml.template +4 -0
  62. gitwise/share/claude/CLAUDE.md.template +9 -0
  63. gitwise/share/claude/rules/gitwise.md +16 -0
  64. gitwise/share/claude/settings.json.template +47 -0
  65. gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
  66. gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
  67. gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
  68. gitwise/share/codex/agents/gitwise.toml.template +18 -0
  69. gitwise/share/continue/rules/gitwise.md.template +14 -0
  70. gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
  71. gitwise/share/git-config-modern.txt +48 -0
  72. gitwise/share/hooks/commit-msg +22 -0
  73. gitwise/share/hooks/pre-commit +19 -0
  74. gitwise/share/opencode/agents/gitwise.md.template +14 -0
  75. gitwise/share/pi/skills/gitwise.md.template +14 -0
  76. gitwise/share/schemas/v1/input/audit.json +40 -0
  77. gitwise/share/schemas/v1/input/branches.json +51 -0
  78. gitwise/share/schemas/v1/input/clean.json +52 -0
  79. gitwise/share/schemas/v1/input/commands.json +36 -0
  80. gitwise/share/schemas/v1/input/commit.json +63 -0
  81. gitwise/share/schemas/v1/input/completions.json +51 -0
  82. gitwise/share/schemas/v1/input/conflicts.json +46 -0
  83. gitwise/share/schemas/v1/input/context.json +36 -0
  84. gitwise/share/schemas/v1/input/diff.json +56 -0
  85. gitwise/share/schemas/v1/input/doctor.json +36 -0
  86. gitwise/share/schemas/v1/input/health.json +36 -0
  87. gitwise/share/schemas/v1/input/log.json +71 -0
  88. gitwise/share/schemas/v1/input/merge.json +63 -0
  89. gitwise/share/schemas/v1/input/optimize.json +44 -0
  90. gitwise/share/schemas/v1/input/pick.json +63 -0
  91. gitwise/share/schemas/v1/input/pr.json +51 -0
  92. gitwise/share/schemas/v1/input/schema.json +48 -0
  93. gitwise/share/schemas/v1/input/setup-agents.json +108 -0
  94. gitwise/share/schemas/v1/input/setup.json +55 -0
  95. gitwise/share/schemas/v1/input/show.json +46 -0
  96. gitwise/share/schemas/v1/input/snapshot.json +36 -0
  97. gitwise/share/schemas/v1/input/stash.json +68 -0
  98. gitwise/share/schemas/v1/input/status.json +36 -0
  99. gitwise/share/schemas/v1/input/suggest.json +36 -0
  100. gitwise/share/schemas/v1/input/summarize.json +44 -0
  101. gitwise/share/schemas/v1/input/sync.json +55 -0
  102. gitwise/share/schemas/v1/input/tag.json +73 -0
  103. gitwise/share/schemas/v1/input/undo.json +60 -0
  104. gitwise/share/schemas/v1/input/update.json +40 -0
  105. gitwise/share/schemas/v1/input/worktree.json +50 -0
  106. gitwise/show.py +118 -0
  107. gitwise/snapshot.py +110 -0
  108. gitwise/stash.py +188 -0
  109. gitwise/status.py +93 -0
  110. gitwise/suggest.py +148 -0
  111. gitwise/summarize.py +202 -0
  112. gitwise/sync.py +257 -0
  113. gitwise/tag.py +252 -0
  114. gitwise/undo.py +145 -0
  115. gitwise/update.py +42 -0
  116. gitwise/utils/__init__.py +1 -0
  117. gitwise/utils/git_output.py +51 -0
  118. gitwise/utils/json_envelope.py +58 -0
  119. gitwise/utils/parsing.py +34 -0
  120. gitwise/worktree.py +182 -0
  121. gitwise_cli-0.24.2.dist-info/METADATA +151 -0
  122. gitwise_cli-0.24.2.dist-info/RECORD +125 -0
  123. gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
  124. gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
  125. gitwise_cli-0.24.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,449 @@
1
+ """Execution phase for setup-agents: file writes, symlinks, rollback."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from gitwise.i18n import t
11
+ from gitwise.output import debug, ok, warn
12
+ from gitwise.setup_agents.state import _AGENTS_MD, _CLAUDE_MD
13
+
14
+
15
+ class SymlinkConflict(Exception):
16
+ pass
17
+
18
+
19
+ class PlanExecutionError(Exception):
20
+ pass
21
+
22
+
23
+ def _safe_create_symlink(link: Path, target_relative: str, root: Path) -> None:
24
+ """Creates a relative symlink safely: idempotency + sandbox + TOCTOU re-check.
25
+
26
+ Note: The sandbox check (os.path.realpath) and the os.symlink() call are not
27
+ atomic. In a concurrent scenario (two gitwise processes), a TOCTOU race exists.
28
+ This is acceptable because: (a) gitwise is a single-user CLI, (b) the idempotency
29
+ check at the top prevents double-creation, (c) symlink targets are always relative
30
+ paths within the repo root.
31
+ """
32
+ if link.is_symlink():
33
+ existing = os.readlink(link)
34
+ if existing == target_relative:
35
+ return
36
+ raise SymlinkConflict(
37
+ t(
38
+ "symlink_conflict_regular",
39
+ file=link.name,
40
+ existing=existing,
41
+ expected=target_relative,
42
+ )
43
+ )
44
+ if link.exists():
45
+ raise SymlinkConflict(t("symlink_conflict_file", file=link.name))
46
+
47
+ root_real = Path(os.path.realpath(str(root)))
48
+ target_real = Path(os.path.realpath(str(link.parent / target_relative)))
49
+ if not target_real.is_relative_to(root_real):
50
+ raise SymlinkConflict(t("symlink_escapes_root", target=target_relative))
51
+
52
+ link.parent.mkdir(parents=True, exist_ok=True)
53
+ os.symlink(target_relative, link)
54
+
55
+
56
+ def _undo_partial(actions_done: list[dict[str, Any]], root: Path) -> None:
57
+ """Transactional rollback: restore pre-action snapshots for all touched paths."""
58
+ snapshots_by_path: dict[str, dict[str, str | bytes | None]] = {}
59
+ for action in actions_done:
60
+ pre_state = action.get("_pre_state")
61
+ if isinstance(pre_state, list):
62
+ for snap in pre_state:
63
+ if isinstance(snap, dict):
64
+ path_key = str(snap.get("path", ""))
65
+ if path_key and path_key not in snapshots_by_path:
66
+ snapshots_by_path[path_key] = snap
67
+
68
+ if snapshots_by_path:
69
+ _restore_snapshots(list(snapshots_by_path.values()))
70
+ return
71
+
72
+ # Backward-compatibility fallback for legacy tests/actions without snapshots.
73
+ for action in reversed(actions_done):
74
+ act = action.get("action", "")
75
+ file_key = action.get("file", "")
76
+ path = root / file_key
77
+ try:
78
+ if act == "symlink-create" and path.is_symlink():
79
+ path.unlink()
80
+ debug(t("debug_rollback_symlink", file=file_key))
81
+ elif act == "claude-md-replace-with-symlink":
82
+ if path.is_symlink():
83
+ path.unlink()
84
+ bak = action.get("backup_path")
85
+ if bak and Path(bak).exists():
86
+ Path(bak).rename(path)
87
+ debug(t("debug_rollback_restored", file=file_key, backup=Path(bak).name))
88
+ elif act == "skill-migrate-to-agents":
89
+ moved_from = action.get("_moved_from")
90
+ agents_skill_str = action.get("agents_skill")
91
+ if path.is_symlink():
92
+ path.unlink()
93
+ if moved_from and agents_skill_str and Path(agents_skill_str).exists():
94
+ shutil.move(str(agents_skill_str), moved_from)
95
+ debug(
96
+ t("debug_rollback_restored_skill", file=file_key, skill=agents_skill_str)
97
+ )
98
+ elif act in ("create", "adapter-create") and action.get("_created") and path.exists():
99
+ path.unlink()
100
+ debug(t("debug_rollback_deleted", file=file_key))
101
+ except OSError as e:
102
+ warn(t("debug_rollback_failed", file=file_key, error=str(e)))
103
+
104
+
105
+ def _capture_snapshot(path: Path) -> dict[str, str | bytes | None]:
106
+ if path.is_symlink():
107
+ try:
108
+ target = os.readlink(path)
109
+ except OSError:
110
+ target = None
111
+ return {
112
+ "path": str(path),
113
+ "kind": "symlink",
114
+ "target": target,
115
+ "content": None,
116
+ }
117
+ if path.exists():
118
+ if path.is_dir():
119
+ return {
120
+ "path": str(path),
121
+ "kind": "dir",
122
+ "target": None,
123
+ "content": None,
124
+ }
125
+ try:
126
+ content = path.read_bytes()
127
+ except OSError:
128
+ content = b""
129
+ return {
130
+ "path": str(path),
131
+ "kind": "file",
132
+ "target": None,
133
+ "content": content,
134
+ }
135
+ return {
136
+ "path": str(path),
137
+ "kind": "absent",
138
+ "target": None,
139
+ "content": None,
140
+ }
141
+
142
+
143
+ def _remove_path(path: Path) -> None:
144
+ if path.is_symlink() or path.is_file():
145
+ path.unlink(missing_ok=True)
146
+ return
147
+ if path.is_dir():
148
+ shutil.rmtree(path, ignore_errors=True)
149
+
150
+
151
+ def _restore_snapshot(snapshot: dict[str, str | bytes | None]) -> None:
152
+ path = Path(str(snapshot["path"]))
153
+ kind = snapshot["kind"]
154
+
155
+ if kind == "absent":
156
+ _remove_path(path)
157
+ return
158
+
159
+ if kind == "dir":
160
+ if path.is_symlink() or path.is_file():
161
+ _remove_path(path)
162
+ path.mkdir(parents=True, exist_ok=True)
163
+ return
164
+
165
+ if kind == "symlink":
166
+ _remove_path(path)
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ target = snapshot.get("target")
169
+ if isinstance(target, str):
170
+ os.symlink(target, path)
171
+ return
172
+
173
+ if kind == "file":
174
+ _remove_path(path)
175
+ path.parent.mkdir(parents=True, exist_ok=True)
176
+ content = snapshot.get("content")
177
+ if isinstance(content, bytes):
178
+ path.write_bytes(content)
179
+ else:
180
+ path.write_text("", encoding="utf-8")
181
+
182
+
183
+ def _restore_snapshots(snapshots: list[dict[str, str | bytes | None]]) -> None:
184
+ seen: set[str] = set()
185
+ ordered = sorted(
186
+ snapshots,
187
+ key=lambda s: len(Path(str(s["path"])).parts),
188
+ reverse=True,
189
+ )
190
+ for snap in ordered:
191
+ key = str(snap["path"])
192
+ if key in seen:
193
+ continue
194
+ seen.add(key)
195
+ try:
196
+ _restore_snapshot(snap)
197
+ debug(t("debug_rollback_restored", file=Path(key).name, backup="snapshot"))
198
+ except OSError as e:
199
+ warn(t("debug_rollback_failed", file=Path(key).name, error=str(e)))
200
+
201
+
202
+ def _touched_paths(action: dict[str, Any], root: Path) -> list[Path]:
203
+ paths: list[Path] = []
204
+ act = action.get("action", "")
205
+ file_key = action.get("file", "")
206
+
207
+ if act in ("managed-block-create", "managed-block-replace"):
208
+ path_value = action.get("_path")
209
+ if isinstance(path_value, str):
210
+ paths.append(Path(path_value))
211
+ elif isinstance(file_key, str) and file_key:
212
+ paths.append(root / file_key)
213
+
214
+ backup_path = action.get("backup_path")
215
+ if isinstance(backup_path, str) and backup_path:
216
+ paths.append(Path(backup_path))
217
+
218
+ agents_skill = action.get("agents_skill")
219
+ if isinstance(agents_skill, str) and agents_skill:
220
+ paths.append(Path(agents_skill))
221
+
222
+ dedup: list[Path] = []
223
+ seen: set[str] = set()
224
+ for p in paths:
225
+ key = str(p)
226
+ if key in seen:
227
+ continue
228
+ seen.add(key)
229
+ dedup.append(p)
230
+ return dedup
231
+
232
+
233
+ def _apply_managed_block(action: dict[str, Any]) -> None:
234
+ path = Path(action["_path"])
235
+ desired = action["content"]
236
+ act = action["action"]
237
+
238
+ if act == "managed-block-create":
239
+ if not path.exists() or not action.get("_append"):
240
+ path.write_text(desired, encoding="utf-8")
241
+ else:
242
+ current = path.read_text(encoding="utf-8")
243
+ sep = "\n" if current.endswith("\n") else "\n\n"
244
+ path.write_text(current + sep + desired, encoding="utf-8")
245
+ elif act == "managed-block-replace":
246
+ current = path.read_text(encoding="utf-8")
247
+ start_idx = action["_start_idx"]
248
+ end_idx = action["_end_idx"]
249
+ new_content = current[:start_idx] + desired.rstrip("\n") + current[end_idx:]
250
+ path.write_text(new_content, encoding="utf-8")
251
+
252
+
253
+ def _exec_claude_md(action: dict[str, Any], root: Path) -> None:
254
+ path = root / _CLAUDE_MD
255
+ act = action["action"]
256
+ if act == "create":
257
+ path.write_text(action["content"], encoding="utf-8")
258
+ action["_created"] = True
259
+ ok(t("created", file=_CLAUDE_MD))
260
+ elif act == "append":
261
+ existing = path.read_text(encoding="utf-8")
262
+ sep = "\n" if existing.endswith("\n") else "\n\n"
263
+ path.write_text(existing + sep + action["content"], encoding="utf-8")
264
+ ok(t("updated_git_conventions", file=_CLAUDE_MD))
265
+ elif act == "symlink-create":
266
+ _safe_create_symlink(path, action["target_relative"], root)
267
+ ok(t("symlink_created_msg", file=_CLAUDE_MD, target=action["target_relative"]))
268
+ elif act == "claude-md-replace-with-symlink":
269
+ backup = Path(action["backup_path"])
270
+ path.rename(backup)
271
+ _safe_create_symlink(path, action["target_relative"], root)
272
+ ok(t("replaced", file=_CLAUDE_MD, backup=backup.name))
273
+
274
+
275
+ def _exec_agents_md(action: dict[str, Any], root: Path) -> None:
276
+ path = root / _AGENTS_MD
277
+ act = action.get("action", "append")
278
+ if act == "create":
279
+ path.write_text(action["content"], encoding="utf-8")
280
+ action["_created"] = True
281
+ ok(t("created", file=_AGENTS_MD))
282
+ return
283
+
284
+ existing = path.read_text(encoding="utf-8")
285
+ sep = "\n" if existing.endswith("\n") else "\n\n"
286
+ path.write_text(existing + sep + action["content"], encoding="utf-8")
287
+ ok(t("updated_git_conventions", file=_AGENTS_MD))
288
+
289
+
290
+ def _exec_settings_json(action: dict[str, Any], root: Path) -> None:
291
+ path = root / ".claude" / "settings.json"
292
+ path.parent.mkdir(parents=True, exist_ok=True)
293
+ path.write_text(
294
+ json.dumps(action["data"], ensure_ascii=False, indent=2) + "\n",
295
+ encoding="utf-8",
296
+ )
297
+ if action["action"] == "create":
298
+ ok(t("created", file=".claude/settings.json"))
299
+ else:
300
+ ok(t("settings_updated_merged"))
301
+
302
+
303
+ def _exec_skills_dir(action: dict[str, Any], root: Path) -> None:
304
+ target_dir = Path(os.path.normpath(root / ".claude" / action["target_relative"]))
305
+ target_dir.mkdir(parents=True, exist_ok=True)
306
+ _safe_create_symlink(root / ".claude" / "skills", action["target_relative"], root)
307
+ ok(
308
+ t(
309
+ "symlink_created_msg",
310
+ file=".claude/skills",
311
+ target=action["target_relative"],
312
+ )
313
+ )
314
+
315
+
316
+ def _exec_agents_skills_dir(action: dict[str, Any], root: Path) -> None:
317
+ (root / action["file"]).mkdir(parents=True, exist_ok=True)
318
+ debug(t("debug_mkdir", file=action["file"]))
319
+
320
+
321
+ def _exec_claude_skill(action: dict[str, Any], root: Path) -> None:
322
+ file_key = action["file"]
323
+ act = action["action"]
324
+ if act == "symlink-create":
325
+ skill_link = root / file_key
326
+ skill_link.parent.mkdir(parents=True, exist_ok=True)
327
+ _safe_create_symlink(skill_link, action["target_relative"], root)
328
+ ok(t("symlink_created_msg", file=file_key, target=action["target_relative"]))
329
+ elif act == "skill-migrate-to-agents":
330
+ skill_link = root / file_key
331
+ agents_skill = Path(action["agents_skill"])
332
+ agents_skill.parent.mkdir(parents=True, exist_ok=True)
333
+ shutil.move(str(skill_link), str(agents_skill))
334
+ action["_moved_from"] = str(skill_link)
335
+ _safe_create_symlink(skill_link, action["target_relative"], root)
336
+ ok(t("migrated_skill", file=file_key, target=action["target_relative"]))
337
+
338
+
339
+ def _exec_skill_md(action: dict[str, Any], root: Path) -> None:
340
+ file_key = action["file"]
341
+ rel = Path(file_key).relative_to(".claude")
342
+ target = root / ".claude" / rel
343
+ target.parent.mkdir(parents=True, exist_ok=True)
344
+ target.write_text(action["content"], encoding="utf-8")
345
+ ok(t("created", file=file_key))
346
+
347
+
348
+ def _exec_agents_skill_md(action: dict[str, Any], root: Path) -> None:
349
+ file_key = action["file"]
350
+ target = root / file_key
351
+ target.parent.mkdir(parents=True, exist_ok=True)
352
+ target.write_text(action["content"], encoding="utf-8")
353
+ action["_created"] = True
354
+ ok(t("created", file=file_key))
355
+
356
+
357
+ def _exec_rule(action: dict[str, Any], root: Path) -> None:
358
+ file_key = action["file"]
359
+ path = root / file_key
360
+ path.parent.mkdir(parents=True, exist_ok=True)
361
+ path.write_text(action["content"], encoding="utf-8")
362
+ action["_created"] = True
363
+ ok(t("created", file=file_key))
364
+
365
+
366
+ def _exec_adapter_create(action: dict[str, Any], root: Path) -> None:
367
+ file_key = action["file"]
368
+ path = root / file_key
369
+ path.parent.mkdir(parents=True, exist_ok=True)
370
+ path.write_text(action["content"], encoding="utf-8")
371
+ action["_created"] = True
372
+ ok(t("adapter_created", adapter=action.get("adapter", file_key), file=file_key))
373
+
374
+
375
+ def _exec_snapshot(action: dict[str, Any], root: Path) -> None:
376
+ from gitwise.snapshot import generate_snapshot as _gen_snapshot
377
+
378
+ snapshot_file = action.get("file", ".claude/git-snapshot.md")
379
+ _gen_snapshot(
380
+ root,
381
+ frozen_time=action.get("frozen_time", False),
382
+ relative_path=snapshot_file,
383
+ )
384
+ ok(t("snapshot_generated", path=snapshot_file))
385
+
386
+
387
+ def _exec_managed_block(action: dict[str, Any], root: Path) -> None:
388
+ _apply_managed_block(action)
389
+ act = action["action"]
390
+ msg_key = "managed_block_created" if act == "managed-block-create" else "managed_block_updated"
391
+ ok(t(msg_key, file=action["file"]))
392
+
393
+
394
+ def _match_file_key(file_key: str, act: str) -> Callable[[dict[str, Any], Path], None] | None:
395
+ if file_key == _CLAUDE_MD:
396
+ return _exec_claude_md
397
+ if file_key == _AGENTS_MD:
398
+ return _exec_agents_md
399
+ if file_key == ".claude/settings.json":
400
+ return _exec_settings_json
401
+ if file_key == ".claude/skills":
402
+ return _exec_skills_dir
403
+ if file_key.startswith(".agents/skills/") and file_key.count("/") == 2:
404
+ return _exec_agents_skills_dir
405
+ if file_key.startswith(".claude/skills/") and file_key.count("/") == 2:
406
+ return _exec_claude_skill
407
+ if file_key.startswith(".claude/skills/") and file_key.endswith("/SKILL.md"):
408
+ return _exec_skill_md
409
+ if file_key.startswith(".agents/skills/") and file_key.endswith("/SKILL.md"):
410
+ return _exec_agents_skill_md
411
+ if file_key.startswith(".claude/rules/") and file_key.endswith(".md"):
412
+ return _exec_rule
413
+ if file_key in (".claude/git-snapshot.md", ".agents/git-snapshot.md"):
414
+ return _exec_snapshot
415
+ if act in ("managed-block-create", "managed-block-replace"):
416
+ return _exec_managed_block
417
+ if act == "adapter-create":
418
+ return _exec_adapter_create
419
+ return None
420
+
421
+
422
+ def _execute_actions(root: Path, actions: list[dict[str, Any]]) -> None:
423
+ (root / ".claude").mkdir(parents=True, exist_ok=True)
424
+
425
+ actions_done: list[dict[str, Any]] = []
426
+ try:
427
+ for action in actions:
428
+ file_key: str = action["file"]
429
+ act: str = action["action"]
430
+
431
+ if act in ("skip", "symlink-skip", "managed-block-skip"):
432
+ debug(t("debug_skip", file=file_key))
433
+ actions_done.append(action)
434
+ continue
435
+
436
+ pre_state = [_capture_snapshot(p) for p in _touched_paths(action, root)]
437
+ action["_pre_state"] = pre_state
438
+ actions_done.append(action)
439
+
440
+ handler = _match_file_key(file_key, act)
441
+ if handler is not None:
442
+ handler(action, root)
443
+ else:
444
+ warn(t("unknown_action", action=act, file=file_key))
445
+
446
+ except (SymlinkConflict, OSError) as exc:
447
+ warn(t("action_failed", error=str(exc), count=str(len(actions_done))))
448
+ _undo_partial(actions_done, root)
449
+ raise PlanExecutionError(str(exc)) from exc
@@ -0,0 +1,164 @@
1
+ """JSON output formatting for setup-agents."""
2
+
3
+ from pathlib import Path
4
+
5
+ from gitwise.setup_agents.types import ActionDict, StateDict, build_action_summary
6
+
7
+ _SETUP_AGENTS_SCHEMA_VERSION = 3
8
+ _SETUP_AGENTS_SCHEMA_COMPAT = [1, 2, 3]
9
+
10
+
11
+ def _action_summaries(actions: list[ActionDict]) -> list[dict[str, str]]:
12
+ return [{"file": a["file"], "action": a["action"]} for a in actions]
13
+
14
+
15
+ def _canonical_layout_local(state: StateDict) -> str:
16
+ if state["agents_dir"]:
17
+ return "agents_dir"
18
+ if state["a_state"] != "absent":
19
+ return "agents_md"
20
+ return "claude_only"
21
+
22
+
23
+ def _canonical_layout_local_with_actions(
24
+ *,
25
+ state: StateDict,
26
+ actions: list[ActionDict],
27
+ migrate_legacy_claude: bool,
28
+ ) -> str:
29
+ if migrate_legacy_claude:
30
+ return "agents_dir"
31
+ if any(a.get("file") == "AGENTS.md" for a in actions):
32
+ return "agents_md"
33
+ if any(str(a.get("file", "")).startswith(".agents/") for a in actions):
34
+ return "agents_dir"
35
+ return _canonical_layout_local(state)
36
+
37
+
38
+ def _canonical_layout_global(*, has_agents_dir: bool) -> str:
39
+ if has_agents_dir:
40
+ return "agents_dir"
41
+ return "claude_only"
42
+
43
+
44
+ def format_json_output_global(
45
+ *,
46
+ home: Path,
47
+ actions: list[ActionDict],
48
+ warnings: list[str],
49
+ has_agents_dir: bool,
50
+ dry_run: bool = False,
51
+ ) -> dict[str, object]:
52
+ summary = build_action_summary(actions)
53
+ return {
54
+ "v": _SETUP_AGENTS_SCHEMA_VERSION,
55
+ "v_compat": _SETUP_AGENTS_SCHEMA_COMPAT,
56
+ "dry_run": dry_run,
57
+ "root": str(home / ".claude"),
58
+ "mode": "global",
59
+ "canonical_layout": _canonical_layout_global(has_agents_dir=has_agents_dir),
60
+ "actions": _action_summaries(actions),
61
+ "warnings": warnings,
62
+ "errors": [],
63
+ "summary": summary,
64
+ "ok": True,
65
+ }
66
+
67
+
68
+ def format_json_output_global_error(
69
+ *,
70
+ home: Path,
71
+ warnings: list[str],
72
+ errors: list[str],
73
+ has_agents_dir: bool,
74
+ dry_run: bool = False,
75
+ ) -> dict[str, object]:
76
+ return {
77
+ "v": _SETUP_AGENTS_SCHEMA_VERSION,
78
+ "v_compat": _SETUP_AGENTS_SCHEMA_COMPAT,
79
+ "dry_run": dry_run,
80
+ "root": str(home / ".claude"),
81
+ "mode": "global",
82
+ "canonical_layout": _canonical_layout_global(has_agents_dir=has_agents_dir),
83
+ "actions": [],
84
+ "warnings": warnings,
85
+ "errors": errors,
86
+ "summary": {
87
+ "created": 0,
88
+ "appended": 0,
89
+ "symlinked": 0,
90
+ "skipped": 0,
91
+ "errored": len(errors),
92
+ },
93
+ "ok": False,
94
+ }
95
+
96
+
97
+ def format_json_output_local_error(
98
+ *,
99
+ root: Path,
100
+ dry_run: bool = False,
101
+ plan_errors: list[dict[str, str]],
102
+ all_warnings: list[str],
103
+ migrate_legacy_claude: bool = False,
104
+ ) -> dict[str, object]:
105
+ return {
106
+ "v": _SETUP_AGENTS_SCHEMA_VERSION,
107
+ "v_compat": _SETUP_AGENTS_SCHEMA_COMPAT,
108
+ "dry_run": dry_run,
109
+ "root": str(root),
110
+ "mode": "local",
111
+ "canonical_layout": "agents_dir" if migrate_legacy_claude else "unknown",
112
+ "bucket": 5,
113
+ "agents_md_detected": False,
114
+ "agents_dir_detected": False,
115
+ "supports_symlinks": False,
116
+ "actions": [],
117
+ "warnings": all_warnings,
118
+ "rules_warnings": [],
119
+ "errors": [e["reason"] for e in plan_errors],
120
+ "summary": {
121
+ "created": 0,
122
+ "appended": 0,
123
+ "symlinked": 0,
124
+ "skipped": 0,
125
+ "errored": len(plan_errors),
126
+ },
127
+ "ok": False,
128
+ }
129
+
130
+
131
+ def format_json_output_local(
132
+ *,
133
+ root: Path,
134
+ dry_run: bool = False,
135
+ bucket: int,
136
+ actions: list[ActionDict],
137
+ all_warnings: list[str],
138
+ rules_warnings: list[str],
139
+ state: StateDict,
140
+ migrate_legacy_claude: bool = False,
141
+ ) -> dict[str, object]:
142
+ summary = build_action_summary(actions)
143
+ return {
144
+ "v": _SETUP_AGENTS_SCHEMA_VERSION,
145
+ "v_compat": _SETUP_AGENTS_SCHEMA_COMPAT,
146
+ "dry_run": dry_run,
147
+ "root": str(root),
148
+ "mode": "local",
149
+ "canonical_layout": _canonical_layout_local_with_actions(
150
+ state=state,
151
+ actions=actions,
152
+ migrate_legacy_claude=migrate_legacy_claude,
153
+ ),
154
+ "bucket": bucket,
155
+ "agents_md_detected": state["a_state"] != "absent",
156
+ "agents_dir_detected": state["agents_dir"],
157
+ "supports_symlinks": state["supports_symlinks"],
158
+ "actions": _action_summaries(actions),
159
+ "warnings": all_warnings,
160
+ "rules_warnings": rules_warnings,
161
+ "errors": [],
162
+ "summary": summary,
163
+ "ok": True,
164
+ }