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.
- gitwise/__init__.py +11 -0
- gitwise/__main__.py +113 -0
- gitwise/_cli_completions.py +88 -0
- gitwise/_cli_dispatch.py +469 -0
- gitwise/_cli_introspection.py +275 -0
- gitwise/_cli_parser.py +345 -0
- gitwise/_cli_setup_agents.py +439 -0
- gitwise/_i18n_data.json +1934 -0
- gitwise/_paths.py +22 -0
- gitwise/_runtime_config.py +246 -0
- gitwise/audit.py +338 -0
- gitwise/branches.py +183 -0
- gitwise/clean.py +197 -0
- gitwise/commit.py +142 -0
- gitwise/conflicts.py +112 -0
- gitwise/context.py +163 -0
- gitwise/design.py +383 -0
- gitwise/diff.py +309 -0
- gitwise/doctor.py +116 -0
- gitwise/git.py +254 -0
- gitwise/health.py +345 -0
- gitwise/i18n.py +99 -0
- gitwise/log.py +329 -0
- gitwise/merge.py +193 -0
- gitwise/optimize.py +212 -0
- gitwise/output.py +652 -0
- gitwise/pick.py +102 -0
- gitwise/pr.py +543 -0
- gitwise/py.typed +0 -0
- gitwise/schema.py +49 -0
- gitwise/setup.py +551 -0
- gitwise/setup_agents/__init__.py +36 -0
- gitwise/setup_agents/adapters/__init__.py +17 -0
- gitwise/setup_agents/adapters/aider.py +5 -0
- gitwise/setup_agents/adapters/base.py +5 -0
- gitwise/setup_agents/adapters/codex.py +5 -0
- gitwise/setup_agents/adapters/continue_adapter.py +5 -0
- gitwise/setup_agents/adapters/cursor.py +5 -0
- gitwise/setup_agents/adapters/opencode.py +5 -0
- gitwise/setup_agents/adapters/pi.py +5 -0
- gitwise/setup_agents/exec.py +449 -0
- gitwise/setup_agents/format.py +164 -0
- gitwise/setup_agents/plan.py +254 -0
- gitwise/setup_agents/plan_gitfiles.py +167 -0
- gitwise/setup_agents/plan_skills.py +256 -0
- gitwise/setup_agents/providers/__init__.py +96 -0
- gitwise/setup_agents/providers/aider.py +11 -0
- gitwise/setup_agents/providers/base.py +79 -0
- gitwise/setup_agents/providers/claude.py +408 -0
- gitwise/setup_agents/providers/codex.py +11 -0
- gitwise/setup_agents/providers/continue_adapter.py +11 -0
- gitwise/setup_agents/providers/cursor.py +11 -0
- gitwise/setup_agents/providers/opencode.py +11 -0
- gitwise/setup_agents/providers/pi.py +11 -0
- gitwise/setup_agents/state.py +141 -0
- gitwise/setup_agents/types.py +48 -0
- gitwise/share/agents/skills/git-audit/SKILL.md +25 -0
- gitwise/share/agents/skills/git-clean/SKILL.md +22 -0
- gitwise/share/agents/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/aider/CONVENTIONS.md.template +8 -0
- gitwise/share/aider/aider.conf.yml.template +4 -0
- gitwise/share/claude/CLAUDE.md.template +9 -0
- gitwise/share/claude/rules/gitwise.md +16 -0
- gitwise/share/claude/settings.json.template +47 -0
- gitwise/share/claude/skills/git-audit/SKILL.md +25 -0
- gitwise/share/claude/skills/git-clean/SKILL.md +22 -0
- gitwise/share/claude/skills/git-optimize/SKILL.md +21 -0
- gitwise/share/codex/agents/gitwise.toml.template +18 -0
- gitwise/share/continue/rules/gitwise.md.template +14 -0
- gitwise/share/cursor/rules/gitwise.mdc.template +16 -0
- gitwise/share/git-config-modern.txt +48 -0
- gitwise/share/hooks/commit-msg +22 -0
- gitwise/share/hooks/pre-commit +19 -0
- gitwise/share/opencode/agents/gitwise.md.template +14 -0
- gitwise/share/pi/skills/gitwise.md.template +14 -0
- gitwise/share/schemas/v1/input/audit.json +40 -0
- gitwise/share/schemas/v1/input/branches.json +51 -0
- gitwise/share/schemas/v1/input/clean.json +52 -0
- gitwise/share/schemas/v1/input/commands.json +36 -0
- gitwise/share/schemas/v1/input/commit.json +63 -0
- gitwise/share/schemas/v1/input/completions.json +51 -0
- gitwise/share/schemas/v1/input/conflicts.json +46 -0
- gitwise/share/schemas/v1/input/context.json +36 -0
- gitwise/share/schemas/v1/input/diff.json +56 -0
- gitwise/share/schemas/v1/input/doctor.json +36 -0
- gitwise/share/schemas/v1/input/health.json +36 -0
- gitwise/share/schemas/v1/input/log.json +71 -0
- gitwise/share/schemas/v1/input/merge.json +63 -0
- gitwise/share/schemas/v1/input/optimize.json +44 -0
- gitwise/share/schemas/v1/input/pick.json +63 -0
- gitwise/share/schemas/v1/input/pr.json +51 -0
- gitwise/share/schemas/v1/input/schema.json +48 -0
- gitwise/share/schemas/v1/input/setup-agents.json +108 -0
- gitwise/share/schemas/v1/input/setup.json +55 -0
- gitwise/share/schemas/v1/input/show.json +46 -0
- gitwise/share/schemas/v1/input/snapshot.json +36 -0
- gitwise/share/schemas/v1/input/stash.json +68 -0
- gitwise/share/schemas/v1/input/status.json +36 -0
- gitwise/share/schemas/v1/input/suggest.json +36 -0
- gitwise/share/schemas/v1/input/summarize.json +44 -0
- gitwise/share/schemas/v1/input/sync.json +55 -0
- gitwise/share/schemas/v1/input/tag.json +73 -0
- gitwise/share/schemas/v1/input/undo.json +60 -0
- gitwise/share/schemas/v1/input/update.json +40 -0
- gitwise/share/schemas/v1/input/worktree.json +50 -0
- gitwise/show.py +118 -0
- gitwise/snapshot.py +110 -0
- gitwise/stash.py +188 -0
- gitwise/status.py +93 -0
- gitwise/suggest.py +148 -0
- gitwise/summarize.py +202 -0
- gitwise/sync.py +257 -0
- gitwise/tag.py +252 -0
- gitwise/undo.py +145 -0
- gitwise/update.py +42 -0
- gitwise/utils/__init__.py +1 -0
- gitwise/utils/git_output.py +51 -0
- gitwise/utils/json_envelope.py +58 -0
- gitwise/utils/parsing.py +34 -0
- gitwise/worktree.py +182 -0
- gitwise_cli-0.24.2.dist-info/METADATA +151 -0
- gitwise_cli-0.24.2.dist-info/RECORD +125 -0
- gitwise_cli-0.24.2.dist-info/WHEEL +4 -0
- gitwise_cli-0.24.2.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|