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,254 @@
|
|
|
1
|
+
"""Planning phase for setup-agents: action generation without side effects."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from gitwise.i18n import t
|
|
6
|
+
from gitwise.setup_agents.plan_gitfiles import (
|
|
7
|
+
gitattributes_block_basic,
|
|
8
|
+
gitattributes_block_extended,
|
|
9
|
+
gitignore_block_basic,
|
|
10
|
+
gitignore_block_extended,
|
|
11
|
+
plan_managed_block,
|
|
12
|
+
)
|
|
13
|
+
from gitwise.setup_agents.plan_skills import (
|
|
14
|
+
_SKILLS,
|
|
15
|
+
_read_skill_template,
|
|
16
|
+
plan_skills,
|
|
17
|
+
)
|
|
18
|
+
from gitwise.setup_agents.providers import detect_global_skills
|
|
19
|
+
from gitwise.setup_agents.providers.claude import ADAPTER as CLAUDE_PROVIDER
|
|
20
|
+
from gitwise.setup_agents.state import (
|
|
21
|
+
_detect_state,
|
|
22
|
+
)
|
|
23
|
+
from gitwise.setup_agents.types import StateDict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _snapshot_file_for_state(*, has_agents_layout: bool) -> str:
|
|
27
|
+
if has_agents_layout:
|
|
28
|
+
return ".agents/git-snapshot.md"
|
|
29
|
+
return ".claude/git-snapshot.md"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_clean_repo_for_canonical_default(
|
|
33
|
+
*,
|
|
34
|
+
state: StateDict,
|
|
35
|
+
migrate_legacy_claude: bool,
|
|
36
|
+
force_claude_core: bool,
|
|
37
|
+
) -> bool:
|
|
38
|
+
if migrate_legacy_claude or force_claude_core:
|
|
39
|
+
return False
|
|
40
|
+
return state["a_state"] == "absent" and state["c_state"] == "absent"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _plan_canonical_skills(root: Path) -> list[dict]:
|
|
44
|
+
actions: list[dict] = []
|
|
45
|
+
for skill in _SKILLS:
|
|
46
|
+
skill_dir = root / ".agents" / "skills" / skill
|
|
47
|
+
skill_file = skill_dir / "SKILL.md"
|
|
48
|
+
if not skill_dir.exists():
|
|
49
|
+
actions.append({"file": f".agents/skills/{skill}", "action": "mkdir"})
|
|
50
|
+
if skill_file.exists():
|
|
51
|
+
actions.append(
|
|
52
|
+
{
|
|
53
|
+
"file": f".agents/skills/{skill}/SKILL.md",
|
|
54
|
+
"action": "skip",
|
|
55
|
+
"reason": t("already_exists"),
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
actions.append(
|
|
60
|
+
{
|
|
61
|
+
"file": f".agents/skills/{skill}/SKILL.md",
|
|
62
|
+
"action": "create",
|
|
63
|
+
"content": _read_skill_template(skill),
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
return actions
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _legacy_skill_warnings(root: Path) -> list[str]:
|
|
70
|
+
warnings: list[str] = []
|
|
71
|
+
legacy_dir = root / ".claude" / "commands"
|
|
72
|
+
for skill in _SKILLS:
|
|
73
|
+
if (legacy_dir / f"{skill}.md").exists():
|
|
74
|
+
warnings.append(t("legacy_commands", skill=skill))
|
|
75
|
+
return warnings
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _plan_settings_json(root: Path) -> tuple[list[dict], list[str]]:
|
|
79
|
+
return CLAUDE_PROVIDER.plan_settings(root)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _plan_rules(root: Path) -> tuple[list[dict], list[str]]:
|
|
83
|
+
return CLAUDE_PROVIDER.plan_rules(root)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _plan_actions_global(
|
|
87
|
+
home: Path,
|
|
88
|
+
no_skills: bool = False,
|
|
89
|
+
) -> tuple[list[dict], list[str], list[dict]]:
|
|
90
|
+
return CLAUDE_PROVIDER.plan_global(home, no_skills=no_skills)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _bucket1_no_agents(
|
|
94
|
+
state: StateDict,
|
|
95
|
+
root: Path,
|
|
96
|
+
template: str,
|
|
97
|
+
) -> tuple[int, list[dict], list[str]]:
|
|
98
|
+
return CLAUDE_PROVIDER.bucket1_no_agents(state, root, template)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _bucket2_agents_no_claude(
|
|
102
|
+
_state: StateDict,
|
|
103
|
+
root: Path,
|
|
104
|
+
supports_symlinks: bool,
|
|
105
|
+
template: str,
|
|
106
|
+
) -> tuple[int, list[dict], list[str]]:
|
|
107
|
+
return CLAUDE_PROVIDER.bucket2_agents_no_claude(root, supports_symlinks, template)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _bucket3(
|
|
111
|
+
state: StateDict,
|
|
112
|
+
claude_md: Path,
|
|
113
|
+
agents_md: Path,
|
|
114
|
+
agents_actions: list[dict],
|
|
115
|
+
) -> tuple[int, list[dict], list[str]]:
|
|
116
|
+
return CLAUDE_PROVIDER.bucket3(state, claude_md, agents_md, agents_actions)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _bucket4_default(
|
|
120
|
+
state: StateDict,
|
|
121
|
+
claude_md: Path,
|
|
122
|
+
agents_actions: list[dict],
|
|
123
|
+
) -> tuple[int, list[dict], list[str]]:
|
|
124
|
+
return CLAUDE_PROVIDER.bucket4_default(state, claude_md, agents_actions)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _bucket4_replace(
|
|
128
|
+
agents_actions: list[dict],
|
|
129
|
+
claude_md: Path,
|
|
130
|
+
) -> tuple[int, list[dict], list[str]]:
|
|
131
|
+
return CLAUDE_PROVIDER.bucket4_replace(agents_actions, claude_md)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_canonical_doc(
|
|
135
|
+
root: Path,
|
|
136
|
+
state: StateDict,
|
|
137
|
+
no_symlinks: bool = False,
|
|
138
|
+
replace_claude_with_symlink: bool = False,
|
|
139
|
+
migrate_legacy_claude: bool = False,
|
|
140
|
+
) -> tuple[int, list[dict], list[str]]:
|
|
141
|
+
return CLAUDE_PROVIDER.resolve_canonical_doc(
|
|
142
|
+
root,
|
|
143
|
+
state,
|
|
144
|
+
no_symlinks=no_symlinks,
|
|
145
|
+
replace_claude_with_symlink=replace_claude_with_symlink,
|
|
146
|
+
migrate_legacy_claude=migrate_legacy_claude,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _plan_actions(
|
|
151
|
+
root: Path,
|
|
152
|
+
no_symlinks: bool = False,
|
|
153
|
+
replace_claude_with_symlink: bool = False,
|
|
154
|
+
migrate_legacy_claude: bool = False,
|
|
155
|
+
force_claude_core: bool = False,
|
|
156
|
+
no_git_files: bool = False,
|
|
157
|
+
frozen_time: bool = False,
|
|
158
|
+
) -> tuple[list[dict], list[str], list[dict], int, StateDict]:
|
|
159
|
+
state = _detect_state(root)
|
|
160
|
+
if state["errors"]:
|
|
161
|
+
err_actions: list[dict] = [
|
|
162
|
+
{"file": "", "action": "error", "reason": r} for r in state["errors"]
|
|
163
|
+
]
|
|
164
|
+
return [], [], err_actions, 5, state
|
|
165
|
+
|
|
166
|
+
canonical_clean = _is_clean_repo_for_canonical_default(
|
|
167
|
+
state=state,
|
|
168
|
+
migrate_legacy_claude=migrate_legacy_claude,
|
|
169
|
+
force_claude_core=force_claude_core,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
global_skills = detect_global_skills()
|
|
173
|
+
|
|
174
|
+
if canonical_clean:
|
|
175
|
+
bucket = 1
|
|
176
|
+
doc_actions = [
|
|
177
|
+
{
|
|
178
|
+
"file": "AGENTS.md",
|
|
179
|
+
"action": "create",
|
|
180
|
+
"content": CLAUDE_PROVIDER.build_template(root),
|
|
181
|
+
}
|
|
182
|
+
]
|
|
183
|
+
doc_warnings: list[str] = []
|
|
184
|
+
settings_actions: list[dict] = []
|
|
185
|
+
settings_warnings: list[str] = []
|
|
186
|
+
skills_actions = _plan_canonical_skills(root)
|
|
187
|
+
skills_warnings = [t("skill_globally_available", skill=skill) for skill in global_skills]
|
|
188
|
+
skills_warnings += _legacy_skill_warnings(root)
|
|
189
|
+
rules_actions: list[dict] = []
|
|
190
|
+
rules_warnings: list[str] = []
|
|
191
|
+
has_agents_layout = True
|
|
192
|
+
else:
|
|
193
|
+
bucket, doc_actions, doc_warnings = _resolve_canonical_doc(
|
|
194
|
+
root,
|
|
195
|
+
state,
|
|
196
|
+
no_symlinks=no_symlinks,
|
|
197
|
+
replace_claude_with_symlink=replace_claude_with_symlink,
|
|
198
|
+
migrate_legacy_claude=migrate_legacy_claude,
|
|
199
|
+
)
|
|
200
|
+
settings_actions, settings_warnings = _plan_settings_json(root)
|
|
201
|
+
has_agents_layout = state["agents_dir"] or migrate_legacy_claude
|
|
202
|
+
skills_actions, skills_warnings = plan_skills(
|
|
203
|
+
root,
|
|
204
|
+
state,
|
|
205
|
+
global_skills=global_skills,
|
|
206
|
+
force_agents_layout=has_agents_layout,
|
|
207
|
+
)
|
|
208
|
+
rules_actions, rules_warnings = _plan_rules(root)
|
|
209
|
+
|
|
210
|
+
has_agents_md = state["a_state"] != "absent" or canonical_clean
|
|
211
|
+
has_agents_dir = has_agents_layout
|
|
212
|
+
|
|
213
|
+
git_file_actions: list[dict] = []
|
|
214
|
+
git_file_warnings: list[str] = []
|
|
215
|
+
if not no_git_files and bucket != 5:
|
|
216
|
+
if has_agents_md or has_agents_dir:
|
|
217
|
+
gi_block = gitignore_block_extended(has_agents_md, has_agents_dir=has_agents_dir)
|
|
218
|
+
ga_block = gitattributes_block_extended(has_agents_md, has_agents_dir)
|
|
219
|
+
else:
|
|
220
|
+
gi_block = gitignore_block_basic()
|
|
221
|
+
ga_block = gitattributes_block_basic()
|
|
222
|
+
|
|
223
|
+
gi_actions, gi_warnings = plan_managed_block(root / ".gitignore", gi_block, ".gitignore")
|
|
224
|
+
ga_actions, ga_warnings = plan_managed_block(
|
|
225
|
+
root / ".gitattributes", ga_block, ".gitattributes"
|
|
226
|
+
)
|
|
227
|
+
git_file_actions = gi_actions + ga_actions
|
|
228
|
+
git_file_warnings = gi_warnings + ga_warnings
|
|
229
|
+
|
|
230
|
+
actions = (
|
|
231
|
+
doc_actions
|
|
232
|
+
+ settings_actions
|
|
233
|
+
+ skills_actions
|
|
234
|
+
+ rules_actions
|
|
235
|
+
+ git_file_actions
|
|
236
|
+
+ [
|
|
237
|
+
{
|
|
238
|
+
"file": _snapshot_file_for_state(has_agents_layout=has_agents_layout),
|
|
239
|
+
"action": "generate",
|
|
240
|
+
"frozen_time": frozen_time,
|
|
241
|
+
}
|
|
242
|
+
]
|
|
243
|
+
)
|
|
244
|
+
warnings = (
|
|
245
|
+
doc_warnings
|
|
246
|
+
+ settings_warnings
|
|
247
|
+
+ skills_warnings
|
|
248
|
+
+ rules_warnings
|
|
249
|
+
+ git_file_warnings
|
|
250
|
+
+ state["rules_warnings"]
|
|
251
|
+
)
|
|
252
|
+
if migrate_legacy_claude:
|
|
253
|
+
warnings.append(t("legacy_migration_mode"))
|
|
254
|
+
return actions, warnings, [], bucket, state
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Managed gitignore/gitattributes blocks for setup-agents."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from gitwise.i18n import t
|
|
6
|
+
|
|
7
|
+
_MANAGED_MARKER_START = "# >>> gitwise managed (do not edit between markers) >>>"
|
|
8
|
+
_MANAGED_MARKER_END = "# <<< gitwise managed <<<"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _snapshot_path_for_block(*, has_agents_dir: bool) -> str:
|
|
12
|
+
if has_agents_dir:
|
|
13
|
+
return ".agents/git-snapshot.md"
|
|
14
|
+
return ".claude/git-snapshot.md"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def gitignore_block_basic() -> str:
|
|
18
|
+
lines = [
|
|
19
|
+
_MANAGED_MARKER_START,
|
|
20
|
+
"# Claude Code local/personal files (do not commit)",
|
|
21
|
+
".claude/settings.local.json",
|
|
22
|
+
".claude/.credentials.json",
|
|
23
|
+
"# Snapshot regenerated each gitwise run (timestamps change)",
|
|
24
|
+
_snapshot_path_for_block(has_agents_dir=False),
|
|
25
|
+
"# Backups from gitwise setup-agents",
|
|
26
|
+
"*.bak",
|
|
27
|
+
"CLAUDE.md.bak*",
|
|
28
|
+
_MANAGED_MARKER_END,
|
|
29
|
+
]
|
|
30
|
+
return "\n".join(lines) + "\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def gitignore_block_extended(has_agents_md: bool, has_agents_dir: bool = False) -> str:
|
|
34
|
+
lines = [
|
|
35
|
+
_MANAGED_MARKER_START,
|
|
36
|
+
"# Claude Code local/personal files (do not commit)",
|
|
37
|
+
".claude/settings.local.json",
|
|
38
|
+
".claude/.credentials.json",
|
|
39
|
+
"# Snapshot regenerated each gitwise run (timestamps change)",
|
|
40
|
+
_snapshot_path_for_block(has_agents_dir=has_agents_dir),
|
|
41
|
+
"# Backups from gitwise setup-agents",
|
|
42
|
+
"*.bak",
|
|
43
|
+
"CLAUDE.md.bak*",
|
|
44
|
+
]
|
|
45
|
+
if has_agents_md:
|
|
46
|
+
lines.append("AGENTS.md.bak*")
|
|
47
|
+
lines.append(_MANAGED_MARKER_END)
|
|
48
|
+
return "\n".join(lines) + "\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def gitattributes_block_basic() -> str:
|
|
52
|
+
lines = [
|
|
53
|
+
_MANAGED_MARKER_START,
|
|
54
|
+
"# Generated snapshot: use local version on merge",
|
|
55
|
+
f"{_snapshot_path_for_block(has_agents_dir=False)} merge=ours linguist-generated=true",
|
|
56
|
+
"# Convention files: force LF for cross-platform consistency",
|
|
57
|
+
"CLAUDE.md text=auto eol=lf",
|
|
58
|
+
".claude/skills/**/SKILL.md text=auto eol=lf",
|
|
59
|
+
_MANAGED_MARKER_END,
|
|
60
|
+
]
|
|
61
|
+
return "\n".join(lines) + "\n"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def gitattributes_block_extended(has_agents_md: bool, has_agents_dir: bool) -> str:
|
|
65
|
+
lines = [
|
|
66
|
+
_MANAGED_MARKER_START,
|
|
67
|
+
"# Generated snapshot: use local version on merge",
|
|
68
|
+
f"{_snapshot_path_for_block(has_agents_dir=has_agents_dir)} merge=ours linguist-generated=true",
|
|
69
|
+
"# Convention files: force LF for cross-platform consistency",
|
|
70
|
+
"CLAUDE.md text=auto eol=lf",
|
|
71
|
+
]
|
|
72
|
+
if has_agents_md:
|
|
73
|
+
lines.append("AGENTS.md text=auto eol=lf")
|
|
74
|
+
lines.append(".claude/skills/**/SKILL.md text=auto eol=lf")
|
|
75
|
+
if has_agents_dir:
|
|
76
|
+
lines.append(".agents/skills/**/SKILL.md text=auto eol=lf")
|
|
77
|
+
lines.append(_MANAGED_MARKER_END)
|
|
78
|
+
return "\n".join(lines) + "\n"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _gitattributes_conflicts(existing_text: str, desired_block: str) -> list[str]:
|
|
82
|
+
block_start = existing_text.find(_MANAGED_MARKER_START)
|
|
83
|
+
outside_text = existing_text[:block_start] if block_start != -1 else existing_text
|
|
84
|
+
|
|
85
|
+
def _parse(text: str) -> dict[str, str]:
|
|
86
|
+
result: dict[str, str] = {}
|
|
87
|
+
for line in text.splitlines():
|
|
88
|
+
s = line.strip()
|
|
89
|
+
if not s or s.startswith("#"):
|
|
90
|
+
continue
|
|
91
|
+
parts = s.split(None, 1)
|
|
92
|
+
if len(parts) > 1:
|
|
93
|
+
result[parts[0]] = s
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
outside = _parse(outside_text)
|
|
97
|
+
block = _parse(desired_block)
|
|
98
|
+
warnings: list[str] = []
|
|
99
|
+
for pattern, block_line in block.items():
|
|
100
|
+
if pattern in outside and outside[pattern] != block_line:
|
|
101
|
+
warnings.append(
|
|
102
|
+
t(
|
|
103
|
+
"gitattributes_conflict",
|
|
104
|
+
pattern=pattern,
|
|
105
|
+
existing=outside[pattern],
|
|
106
|
+
desired=block_line,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
return warnings
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def plan_managed_block(
|
|
113
|
+
path: Path, desired_block: str, file_key: str
|
|
114
|
+
) -> tuple[list[dict], list[str]]:
|
|
115
|
+
if not path.exists():
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
"file": file_key,
|
|
119
|
+
"action": "managed-block-create",
|
|
120
|
+
"content": desired_block,
|
|
121
|
+
"_path": str(path),
|
|
122
|
+
}
|
|
123
|
+
], []
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
current = path.read_text(encoding="utf-8")
|
|
127
|
+
except OSError:
|
|
128
|
+
return [], []
|
|
129
|
+
|
|
130
|
+
conflict_warnings = (
|
|
131
|
+
_gitattributes_conflicts(current, desired_block) if file_key == ".gitattributes" else []
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if _MANAGED_MARKER_START not in current:
|
|
135
|
+
return [
|
|
136
|
+
{
|
|
137
|
+
"file": file_key,
|
|
138
|
+
"action": "managed-block-create",
|
|
139
|
+
"content": desired_block,
|
|
140
|
+
"_path": str(path),
|
|
141
|
+
"_append": True,
|
|
142
|
+
}
|
|
143
|
+
], conflict_warnings
|
|
144
|
+
|
|
145
|
+
start_idx = current.index(_MANAGED_MARKER_START)
|
|
146
|
+
end_marker_idx = current.find(_MANAGED_MARKER_END, start_idx)
|
|
147
|
+
if end_marker_idx == -1:
|
|
148
|
+
return [], [t("managed_block_unclosed", file=file_key)] + conflict_warnings
|
|
149
|
+
|
|
150
|
+
end_idx = end_marker_idx + len(_MANAGED_MARKER_END)
|
|
151
|
+
existing_block = current[start_idx:end_idx]
|
|
152
|
+
|
|
153
|
+
if existing_block.rstrip() == desired_block.rstrip():
|
|
154
|
+
return [
|
|
155
|
+
{"file": file_key, "action": "managed-block-skip", "_path": str(path)}
|
|
156
|
+
], conflict_warnings
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
"file": file_key,
|
|
161
|
+
"action": "managed-block-replace",
|
|
162
|
+
"content": desired_block,
|
|
163
|
+
"_path": str(path),
|
|
164
|
+
"_start_idx": start_idx,
|
|
165
|
+
"_end_idx": end_idx,
|
|
166
|
+
}
|
|
167
|
+
], conflict_warnings
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Unified skills planning for setup-agents (local + global)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from gitwise._paths import share_dir as _share_dir
|
|
7
|
+
from gitwise.i18n import t
|
|
8
|
+
from gitwise.setup_agents.state import _classify_path
|
|
9
|
+
from gitwise.setup_agents.types import StateDict
|
|
10
|
+
|
|
11
|
+
_SHARE_CLAUDE_DIR = _share_dir() / "claude"
|
|
12
|
+
_SHARE_AGENTS_DIR = _share_dir() / "agents"
|
|
13
|
+
_SKILLS: tuple[str, ...] = ("git-audit", "git-clean", "git-optimize")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read_template(name: str) -> str:
|
|
17
|
+
path = _SHARE_CLAUDE_DIR / name
|
|
18
|
+
if not path.is_file():
|
|
19
|
+
raise FileNotFoundError(t("template_not_found", path=str(path)))
|
|
20
|
+
return path.read_text(encoding="utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_skill_template(skill: str) -> str:
|
|
24
|
+
candidates = (
|
|
25
|
+
_SHARE_AGENTS_DIR / "skills" / skill / "SKILL.md",
|
|
26
|
+
_SHARE_CLAUDE_DIR / "skills" / skill / "SKILL.md",
|
|
27
|
+
)
|
|
28
|
+
for path in candidates:
|
|
29
|
+
if path.is_file():
|
|
30
|
+
return path.read_text(encoding="utf-8")
|
|
31
|
+
raise FileNotFoundError(
|
|
32
|
+
t("template_not_found", path=str(_SHARE_AGENTS_DIR / "skills" / skill / "SKILL.md"))
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _plan_single_skill(
|
|
37
|
+
*,
|
|
38
|
+
skill: str,
|
|
39
|
+
root: Path,
|
|
40
|
+
has_agents_dir: bool,
|
|
41
|
+
global_skills: frozenset[str],
|
|
42
|
+
force_agents_layout: bool,
|
|
43
|
+
check_global_available: bool,
|
|
44
|
+
symlink_mismatch_key: str,
|
|
45
|
+
symlink_broken_key: str,
|
|
46
|
+
dir_regular_with_agents_key: str,
|
|
47
|
+
) -> tuple[list[dict], list[str]]:
|
|
48
|
+
actions: list[dict] = []
|
|
49
|
+
warnings: list[str] = []
|
|
50
|
+
|
|
51
|
+
agents_skills_dir = root / ".agents" / "skills"
|
|
52
|
+
claude_skills = root / ".claude" / "skills"
|
|
53
|
+
claude_skill = claude_skills / skill
|
|
54
|
+
skill_file = f".claude/skills/{skill}/SKILL.md"
|
|
55
|
+
c_skill_state = _classify_path(claude_skill)
|
|
56
|
+
|
|
57
|
+
if has_agents_dir or force_agents_layout:
|
|
58
|
+
agents_skill = agents_skills_dir / skill
|
|
59
|
+
target_rel = os.path.relpath(str(agents_skill), str(claude_skill.parent))
|
|
60
|
+
|
|
61
|
+
if c_skill_state == "absent":
|
|
62
|
+
if skill in global_skills:
|
|
63
|
+
if check_global_available:
|
|
64
|
+
warnings.append(t("skill_globally_available", skill=skill))
|
|
65
|
+
else:
|
|
66
|
+
if not agents_skill.exists():
|
|
67
|
+
actions.append({"file": f".agents/skills/{skill}", "action": "mkdir"})
|
|
68
|
+
actions.append(
|
|
69
|
+
{
|
|
70
|
+
"file": f".claude/skills/{skill}",
|
|
71
|
+
"action": "symlink-create",
|
|
72
|
+
"target_relative": target_rel,
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
actions.append(
|
|
76
|
+
{
|
|
77
|
+
"file": skill_file,
|
|
78
|
+
"action": "create",
|
|
79
|
+
"content": _read_skill_template(skill),
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
elif c_skill_state == "symlink_valid":
|
|
83
|
+
try:
|
|
84
|
+
existing_target = os.readlink(str(claude_skill))
|
|
85
|
+
except OSError:
|
|
86
|
+
existing_target = ""
|
|
87
|
+
if existing_target == target_rel:
|
|
88
|
+
if (claude_skill / "SKILL.md").exists():
|
|
89
|
+
actions.append(
|
|
90
|
+
{"file": skill_file, "action": "skip", "reason": t("already_exists")}
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
actions.append(
|
|
94
|
+
{
|
|
95
|
+
"file": skill_file,
|
|
96
|
+
"action": "create",
|
|
97
|
+
"content": _read_skill_template(skill),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
warnings.append(
|
|
102
|
+
t(
|
|
103
|
+
symlink_mismatch_key,
|
|
104
|
+
skill=skill,
|
|
105
|
+
existing=existing_target,
|
|
106
|
+
expected=target_rel,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
elif c_skill_state == "symlink_broken":
|
|
110
|
+
warnings.append(t(symlink_broken_key, skill=skill))
|
|
111
|
+
else:
|
|
112
|
+
if check_global_available and agents_skill.exists():
|
|
113
|
+
warnings.append(t("skill_conflict_dir_agents", skill=skill))
|
|
114
|
+
actions.append(
|
|
115
|
+
{"file": skill_file, "action": "skip", "reason": t("conflict_dir_agents")}
|
|
116
|
+
)
|
|
117
|
+
elif check_global_available and not agents_skill.exists():
|
|
118
|
+
actions.append(
|
|
119
|
+
{
|
|
120
|
+
"file": f".claude/skills/{skill}",
|
|
121
|
+
"action": "skill-migrate-to-agents",
|
|
122
|
+
"target_relative": target_rel,
|
|
123
|
+
"agents_skill": str(agents_skill),
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
warnings.append(t(dir_regular_with_agents_key, skill=skill))
|
|
128
|
+
if (claude_skill / "SKILL.md").exists():
|
|
129
|
+
actions.append(
|
|
130
|
+
{"file": skill_file, "action": "skip", "reason": t("already_exists")}
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
actions.append(
|
|
134
|
+
{
|
|
135
|
+
"file": skill_file,
|
|
136
|
+
"action": "create",
|
|
137
|
+
"content": _read_skill_template(skill),
|
|
138
|
+
}
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
if skill in global_skills and not (claude_skill / "SKILL.md").exists():
|
|
142
|
+
warnings.append(t("skill_globally_available", skill=skill))
|
|
143
|
+
actions.append(
|
|
144
|
+
{"file": skill_file, "action": "skip", "reason": t("installed_globally")}
|
|
145
|
+
)
|
|
146
|
+
elif (claude_skill / "SKILL.md").exists():
|
|
147
|
+
actions.append({"file": skill_file, "action": "skip", "reason": t("already_exists")})
|
|
148
|
+
else:
|
|
149
|
+
actions.append(
|
|
150
|
+
{
|
|
151
|
+
"file": skill_file,
|
|
152
|
+
"action": "create",
|
|
153
|
+
"content": _read_skill_template(skill),
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return actions, warnings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def plan_skills(
|
|
161
|
+
root: Path,
|
|
162
|
+
state: StateDict,
|
|
163
|
+
global_skills: frozenset[str] = frozenset(),
|
|
164
|
+
force_agents_layout: bool = False,
|
|
165
|
+
) -> tuple[list[dict], list[str]]:
|
|
166
|
+
actions: list[dict] = []
|
|
167
|
+
warnings: list[str] = []
|
|
168
|
+
|
|
169
|
+
claude_skills = root / ".claude" / "skills"
|
|
170
|
+
skills_state = state["skills_state"]
|
|
171
|
+
has_agents_dir = state["agents_dir"] or force_agents_layout
|
|
172
|
+
|
|
173
|
+
if skills_state == "symlink_valid":
|
|
174
|
+
for skill in _SKILLS:
|
|
175
|
+
skill_file = f".claude/skills/{skill}/SKILL.md"
|
|
176
|
+
if skill in global_skills:
|
|
177
|
+
actions.append(
|
|
178
|
+
{"file": skill_file, "action": "skip", "reason": t("installed_globally")}
|
|
179
|
+
)
|
|
180
|
+
elif (claude_skills / skill / "SKILL.md").exists():
|
|
181
|
+
actions.append(
|
|
182
|
+
{"file": skill_file, "action": "skip", "reason": t("already_exists")}
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
actions.append(
|
|
186
|
+
{
|
|
187
|
+
"file": skill_file,
|
|
188
|
+
"action": "create",
|
|
189
|
+
"content": _read_skill_template(skill),
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
for skill in _SKILLS:
|
|
194
|
+
sk_actions, sk_warnings = _plan_single_skill(
|
|
195
|
+
skill=skill,
|
|
196
|
+
root=root,
|
|
197
|
+
has_agents_dir=has_agents_dir,
|
|
198
|
+
global_skills=global_skills,
|
|
199
|
+
force_agents_layout=force_agents_layout,
|
|
200
|
+
check_global_available=False,
|
|
201
|
+
symlink_mismatch_key="skill_symlink_diferente",
|
|
202
|
+
symlink_broken_key="skill_symlink_broken",
|
|
203
|
+
dir_regular_with_agents_key="skill_dir_regular_with_agents",
|
|
204
|
+
)
|
|
205
|
+
actions += sk_actions
|
|
206
|
+
warnings += sk_warnings
|
|
207
|
+
|
|
208
|
+
legacy_dir = root / ".claude" / "commands"
|
|
209
|
+
for skill in _SKILLS:
|
|
210
|
+
if (legacy_dir / f"{skill}.md").exists():
|
|
211
|
+
warnings.append(t("legacy_commands", skill=skill))
|
|
212
|
+
|
|
213
|
+
return actions, warnings
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def plan_global_skills(home: Path) -> tuple[list[dict], list[str]]:
|
|
217
|
+
actions: list[dict] = []
|
|
218
|
+
warnings: list[str] = []
|
|
219
|
+
|
|
220
|
+
agents_dir = home / ".agents"
|
|
221
|
+
has_agents_dir = agents_dir.is_dir() and not agents_dir.is_symlink()
|
|
222
|
+
claude_skills = home / ".claude" / "skills"
|
|
223
|
+
claude_skills_state = _classify_path(claude_skills)
|
|
224
|
+
|
|
225
|
+
if claude_skills_state == "symlink_valid":
|
|
226
|
+
for skill in _SKILLS:
|
|
227
|
+
skill_file = f".claude/skills/{skill}/SKILL.md"
|
|
228
|
+
if (claude_skills / skill / "SKILL.md").exists():
|
|
229
|
+
actions.append(
|
|
230
|
+
{"file": skill_file, "action": "skip", "reason": t("already_exists")}
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
actions.append(
|
|
234
|
+
{
|
|
235
|
+
"file": skill_file,
|
|
236
|
+
"action": "create",
|
|
237
|
+
"content": _read_template(f"skills/{skill}/SKILL.md"),
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
for skill in _SKILLS:
|
|
242
|
+
sk_actions, sk_warnings = _plan_single_skill(
|
|
243
|
+
skill=skill,
|
|
244
|
+
root=home,
|
|
245
|
+
has_agents_dir=has_agents_dir,
|
|
246
|
+
global_skills=frozenset(),
|
|
247
|
+
force_agents_layout=False,
|
|
248
|
+
check_global_available=True,
|
|
249
|
+
symlink_mismatch_key="global_skill_symlink_diferente",
|
|
250
|
+
symlink_broken_key="global_skill_symlink_broken",
|
|
251
|
+
dir_regular_with_agents_key="skill_dir_regular_with_agents",
|
|
252
|
+
)
|
|
253
|
+
actions += sk_actions
|
|
254
|
+
warnings += sk_warnings
|
|
255
|
+
|
|
256
|
+
return actions, warnings
|