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,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