cl-preset 0.1.0__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.
- cl_preset/__init__.py +19 -0
- cl_preset/cli.py +479 -0
- cl_preset/git_strategy.py +478 -0
- cl_preset/manager.py +199 -0
- cl_preset/models.py +60 -0
- cl_preset/py.typed +0 -0
- cl_preset/templates/git_strategy_template.yaml +60 -0
- cl_preset-0.1.0.dist-info/METADATA +394 -0
- cl_preset-0.1.0.dist-info/RECORD +12 -0
- cl_preset-0.1.0.dist-info/WHEEL +4 -0
- cl_preset-0.1.0.dist-info/entry_points.txt +2 -0
- cl_preset-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git strategy models and management for cl-preset package.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitStrategyType(str, Enum):
|
|
15
|
+
"""Types of git strategies."""
|
|
16
|
+
DUAL_REPO = "dual-repo"
|
|
17
|
+
GITHUB_FLOW = "github-flow"
|
|
18
|
+
GIT_FLOW = "git-flow"
|
|
19
|
+
CUSTOM = "custom"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BranchPattern(BaseModel):
|
|
23
|
+
"""Branch naming pattern."""
|
|
24
|
+
feature: str = Field(default="feature/*", description="Feature branch pattern")
|
|
25
|
+
bugfix: str = Field(default="bugfix/*", description="Bugfix branch pattern")
|
|
26
|
+
release: str = Field(default="release/*", description="Release branch pattern")
|
|
27
|
+
hotfix: str = Field(default="hotfix/*", description="Hotfix branch pattern")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MergeRule(BaseModel):
|
|
31
|
+
"""Merge rules for branches."""
|
|
32
|
+
require_pr: bool = Field(default=True, description="Require pull request")
|
|
33
|
+
require_approval: bool = Field(default=True, description="Require approval")
|
|
34
|
+
allow_squash: bool = Field(default=True, description="Allow squash merge")
|
|
35
|
+
allow_merge_commit: bool = Field(default=False, description="Allow merge commit")
|
|
36
|
+
allow_rebase: bool = Field(default=False, description="Allow rebase merge")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProtectionRule(BaseModel):
|
|
40
|
+
"""Branch protection rules."""
|
|
41
|
+
enabled: bool = Field(default=True, description="Enable protection")
|
|
42
|
+
require_status_checks: bool = Field(default=True, description="Require status checks")
|
|
43
|
+
require_branch_up_to_date: bool = Field(default=True, description="Require branch up to date")
|
|
44
|
+
required_check_names: list[str] = Field(
|
|
45
|
+
default_factory=list,
|
|
46
|
+
description="Required status check names"
|
|
47
|
+
)
|
|
48
|
+
allow_force_pushes: bool = Field(default=False, description="Allow force pushes")
|
|
49
|
+
allow_deletions: bool = Field(default=False, description="Allow branch deletions")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class GitStrategyConfig(BaseModel):
|
|
53
|
+
"""Git strategy configuration."""
|
|
54
|
+
name: str = Field(..., description="Strategy name")
|
|
55
|
+
strategy_type: GitStrategyType = Field(..., description="Strategy type")
|
|
56
|
+
description: str = Field(..., description="Strategy description")
|
|
57
|
+
|
|
58
|
+
# Branch configuration
|
|
59
|
+
main_branch: str = Field(default="main", description="Main branch name")
|
|
60
|
+
develop_branch: str = Field(default="develop", description="Develop branch name (for git-flow)")
|
|
61
|
+
branch_patterns: BranchPattern = Field(
|
|
62
|
+
default_factory=BranchPattern,
|
|
63
|
+
description="Branch naming patterns"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# Merge and protection rules
|
|
67
|
+
merge_rules: MergeRule = Field(
|
|
68
|
+
default_factory=MergeRule,
|
|
69
|
+
description="Merge rules"
|
|
70
|
+
)
|
|
71
|
+
protection_rules: dict[str, ProtectionRule] = Field(
|
|
72
|
+
default_factory=dict,
|
|
73
|
+
description="Protection rules per branch"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Dual-repo specific - use dict[str, Any] to allow flexible structures
|
|
77
|
+
dev_repository: dict[str, Any] | None = Field(
|
|
78
|
+
default=None,
|
|
79
|
+
description="Dev repository configuration (for dual-repo)"
|
|
80
|
+
)
|
|
81
|
+
release_repository: dict[str, Any] | None = Field(
|
|
82
|
+
default=None,
|
|
83
|
+
description="Release repository configuration (for dual-repo)"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
model_config = ConfigDict(use_enum_values=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GitStrategyManager:
|
|
90
|
+
"""Manager for git strategy operations."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, base_path: Path | None = None):
|
|
93
|
+
"""
|
|
94
|
+
Initialize the git strategy manager.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
base_path: Base path for strategy templates. Defaults to package templates
|
|
98
|
+
"""
|
|
99
|
+
self.console = Console()
|
|
100
|
+
self.base_path = base_path or (Path(__file__).parent / "templates" / "git_strategies")
|
|
101
|
+
self.strategies_path = Path.home() / ".cl-preset" / "git-strategies"
|
|
102
|
+
self.strategies_path.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
def get_builtin_strategies(self) -> dict[str, GitStrategyConfig]:
|
|
105
|
+
"""Get built-in git strategy definitions."""
|
|
106
|
+
return {
|
|
107
|
+
"dual-repo": self._get_dual_repo_strategy(),
|
|
108
|
+
"github-flow": self._get_github_flow_strategy(),
|
|
109
|
+
"git-flow": self._get_git_flow_strategy(),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def _get_dual_repo_strategy(self) -> GitStrategyConfig:
|
|
113
|
+
"""Get dual-repo strategy configuration."""
|
|
114
|
+
protection_rules = {
|
|
115
|
+
"main": ProtectionRule(
|
|
116
|
+
enabled=True,
|
|
117
|
+
require_status_checks=True,
|
|
118
|
+
require_branch_up_to_date=True,
|
|
119
|
+
required_check_names=["ci", "tests", "lint"],
|
|
120
|
+
allow_force_pushes=False,
|
|
121
|
+
allow_deletions=False,
|
|
122
|
+
),
|
|
123
|
+
"release/*": ProtectionRule(
|
|
124
|
+
enabled=True,
|
|
125
|
+
require_status_checks=True,
|
|
126
|
+
require_branch_up_to_date=True,
|
|
127
|
+
required_check_names=["ci", "tests"],
|
|
128
|
+
allow_force_pushes=False,
|
|
129
|
+
allow_deletions=True,
|
|
130
|
+
),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return GitStrategyConfig(
|
|
134
|
+
name="dual-repo",
|
|
135
|
+
strategy_type=GitStrategyType.DUAL_REPO,
|
|
136
|
+
description="Dev/Release repository separation workflow. Dev repo for active development, release repo for stable releases.",
|
|
137
|
+
main_branch="main",
|
|
138
|
+
develop_branch="develop",
|
|
139
|
+
branch_patterns=BranchPattern(
|
|
140
|
+
feature="feature/*",
|
|
141
|
+
bugfix="bugfix/*",
|
|
142
|
+
release="release/*",
|
|
143
|
+
hotfix="hotfix/*",
|
|
144
|
+
),
|
|
145
|
+
merge_rules=MergeRule(
|
|
146
|
+
require_pr=True,
|
|
147
|
+
require_approval=True,
|
|
148
|
+
allow_squash=True,
|
|
149
|
+
allow_merge_commit=False,
|
|
150
|
+
allow_rebase=False,
|
|
151
|
+
),
|
|
152
|
+
protection_rules=protection_rules,
|
|
153
|
+
dev_repository={
|
|
154
|
+
"remote": "origin",
|
|
155
|
+
"branch_pattern": "feature/*",
|
|
156
|
+
"description": "Active development, feature branches, experimental work",
|
|
157
|
+
},
|
|
158
|
+
release_repository={
|
|
159
|
+
"remote": "release",
|
|
160
|
+
"protected_branches": ["main"],
|
|
161
|
+
"description": "Production-ready code, stable releases, published documentation",
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _get_github_flow_strategy(self) -> GitStrategyConfig:
|
|
166
|
+
"""Get GitHub flow strategy configuration."""
|
|
167
|
+
protection_rules = {
|
|
168
|
+
"main": ProtectionRule(
|
|
169
|
+
enabled=True,
|
|
170
|
+
require_status_checks=True,
|
|
171
|
+
require_branch_up_to_date=True,
|
|
172
|
+
required_check_names=["ci"],
|
|
173
|
+
allow_force_pushes=False,
|
|
174
|
+
allow_deletions=False,
|
|
175
|
+
),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return GitStrategyConfig(
|
|
179
|
+
name="github-flow",
|
|
180
|
+
strategy_type=GitStrategyType.GITHUB_FLOW,
|
|
181
|
+
description="Simple branch-based workflow. Feature branches from main, PR for review, merge after approval.",
|
|
182
|
+
main_branch="main",
|
|
183
|
+
branch_patterns=BranchPattern(
|
|
184
|
+
feature="feature/*",
|
|
185
|
+
bugfix="bugfix/*",
|
|
186
|
+
release="",
|
|
187
|
+
hotfix="hotfix/*",
|
|
188
|
+
),
|
|
189
|
+
merge_rules=MergeRule(
|
|
190
|
+
require_pr=True,
|
|
191
|
+
require_approval=True,
|
|
192
|
+
allow_squash=True,
|
|
193
|
+
allow_merge_commit=True,
|
|
194
|
+
allow_rebase=True,
|
|
195
|
+
),
|
|
196
|
+
protection_rules=protection_rules,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _get_git_flow_strategy(self) -> GitStrategyConfig:
|
|
200
|
+
"""Get Git flow strategy configuration."""
|
|
201
|
+
protection_rules = {
|
|
202
|
+
"master": ProtectionRule(
|
|
203
|
+
enabled=True,
|
|
204
|
+
require_status_checks=True,
|
|
205
|
+
require_branch_up_to_date=True,
|
|
206
|
+
required_check_names=["ci", "tests"],
|
|
207
|
+
allow_force_pushes=False,
|
|
208
|
+
allow_deletions=False,
|
|
209
|
+
),
|
|
210
|
+
"develop": ProtectionRule(
|
|
211
|
+
enabled=True,
|
|
212
|
+
require_status_checks=True,
|
|
213
|
+
require_branch_up_to_date=False,
|
|
214
|
+
required_check_names=["ci"],
|
|
215
|
+
allow_force_pushes=False,
|
|
216
|
+
allow_deletions=False,
|
|
217
|
+
),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return GitStrategyConfig(
|
|
221
|
+
name="git-flow",
|
|
222
|
+
strategy_type=GitStrategyType.GIT_FLOW,
|
|
223
|
+
description="Feature/release/hotfix branching model. Master, develop, feature, release, hotfix branches with strict merge rules.",
|
|
224
|
+
main_branch="master",
|
|
225
|
+
develop_branch="develop",
|
|
226
|
+
branch_patterns=BranchPattern(
|
|
227
|
+
feature="feature/*",
|
|
228
|
+
bugfix="bugfix/*",
|
|
229
|
+
release="release/*",
|
|
230
|
+
hotfix="hotfix/*",
|
|
231
|
+
),
|
|
232
|
+
merge_rules=MergeRule(
|
|
233
|
+
require_pr=True,
|
|
234
|
+
require_approval=True,
|
|
235
|
+
allow_squash=True,
|
|
236
|
+
allow_merge_commit=True,
|
|
237
|
+
allow_rebase=False,
|
|
238
|
+
),
|
|
239
|
+
protection_rules=protection_rules,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def list_strategies(self, include_builtin: bool = True) -> list[dict]:
|
|
243
|
+
"""
|
|
244
|
+
List available strategies.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
include_builtin: Include built-in strategies
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of strategy information
|
|
251
|
+
"""
|
|
252
|
+
strategies = []
|
|
253
|
+
|
|
254
|
+
if include_builtin:
|
|
255
|
+
for name, config in self.get_builtin_strategies().items():
|
|
256
|
+
# strategy_type is already a string due to use_enum_values=True
|
|
257
|
+
strategy_type_value = (
|
|
258
|
+
config.strategy_type if isinstance(config.strategy_type, str)
|
|
259
|
+
else config.strategy_type.value
|
|
260
|
+
)
|
|
261
|
+
strategies.append({
|
|
262
|
+
"name": name,
|
|
263
|
+
"type": strategy_type_value,
|
|
264
|
+
"description": config.description,
|
|
265
|
+
"source": "builtin",
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
# List custom strategies
|
|
269
|
+
for strategy_file in self.strategies_path.glob("*.yaml"):
|
|
270
|
+
try:
|
|
271
|
+
import yaml
|
|
272
|
+
data = yaml.safe_load(strategy_file.read_text())
|
|
273
|
+
strategies.append({
|
|
274
|
+
"name": data.get("name", strategy_file.stem),
|
|
275
|
+
"type": data.get("strategy_type", "custom"),
|
|
276
|
+
"description": data.get("description", ""),
|
|
277
|
+
"source": "custom",
|
|
278
|
+
})
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
return strategies
|
|
283
|
+
|
|
284
|
+
def print_list(self, include_builtin: bool = True) -> None:
|
|
285
|
+
"""Print a formatted table of available strategies."""
|
|
286
|
+
strategies = self.list_strategies(include_builtin)
|
|
287
|
+
|
|
288
|
+
if not strategies:
|
|
289
|
+
self.console.print("[yellow]No strategies found.[/yellow]")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
table = Table(title="Available Git Strategies")
|
|
293
|
+
table.add_column("Name", style="cyan")
|
|
294
|
+
table.add_column("Type", style="green")
|
|
295
|
+
table.add_column("Description", style="white")
|
|
296
|
+
table.add_column("Source", style="yellow")
|
|
297
|
+
|
|
298
|
+
for strategy in strategies:
|
|
299
|
+
table.add_row(
|
|
300
|
+
strategy["name"],
|
|
301
|
+
strategy["type"],
|
|
302
|
+
strategy["description"][:60] + "..." if len(strategy["description"]) > 60 else strategy["description"],
|
|
303
|
+
strategy["source"],
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
self.console.print(table)
|
|
307
|
+
|
|
308
|
+
def get_strategy(self, name: str) -> GitStrategyConfig | None:
|
|
309
|
+
"""
|
|
310
|
+
Get a strategy by name.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
name: Strategy name
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
GitStrategyConfig if found, None otherwise
|
|
317
|
+
"""
|
|
318
|
+
builtin = self.get_builtin_strategies()
|
|
319
|
+
if name in builtin:
|
|
320
|
+
return builtin[name]
|
|
321
|
+
|
|
322
|
+
# Try to load custom strategy
|
|
323
|
+
strategy_file = self.strategies_path / f"{name}.yaml"
|
|
324
|
+
if strategy_file.exists():
|
|
325
|
+
try:
|
|
326
|
+
import yaml
|
|
327
|
+
data = yaml.safe_load(strategy_file.read_text())
|
|
328
|
+
return GitStrategyConfig(**data)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
self.console.print(f"[red]Error loading strategy '{name}': {e}[/red]")
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
def save_strategy(self, strategy: GitStrategyConfig, name: str) -> bool:
|
|
336
|
+
"""
|
|
337
|
+
Save a custom strategy.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
strategy: Strategy configuration to save
|
|
341
|
+
name: Name for the strategy
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
True if successful, False otherwise
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
import yaml
|
|
348
|
+
strategy_file = self.strategies_path / f"{name}.yaml"
|
|
349
|
+
strategy_file.write_text(
|
|
350
|
+
yaml.dump(strategy.model_dump(mode="json"), default_flow_style=False)
|
|
351
|
+
)
|
|
352
|
+
self.console.print(f"[green]Strategy '{name}' saved successfully.[/green]")
|
|
353
|
+
return True
|
|
354
|
+
except Exception as e:
|
|
355
|
+
self.console.print(f"[red]Error saving strategy: {e}[/red]")
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
def export_strategy_yaml(self, name: str, output_path: Path) -> bool:
|
|
359
|
+
"""
|
|
360
|
+
Export a strategy to YAML file.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
name: Strategy name
|
|
364
|
+
output_path: Output file path
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
True if successful, False otherwise
|
|
368
|
+
"""
|
|
369
|
+
strategy = self.get_strategy(name)
|
|
370
|
+
if strategy is None:
|
|
371
|
+
self.console.print(f"[red]Strategy '{name}' not found.[/red]")
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
import yaml
|
|
376
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
output_path.write_text(
|
|
378
|
+
yaml.dump(strategy.model_dump(mode="json"), default_flow_style=False, sort_keys=False)
|
|
379
|
+
)
|
|
380
|
+
self.console.print(f"[green]Strategy exported to {output_path}[/green]")
|
|
381
|
+
return True
|
|
382
|
+
except Exception as e:
|
|
383
|
+
self.console.print(f"[red]Error exporting strategy: {e}[/red]")
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
def validate_current_setup(self, expected_strategy: str) -> dict:
|
|
387
|
+
"""
|
|
388
|
+
Validate current git setup against a strategy.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
expected_strategy: Name of the expected strategy
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Validation results
|
|
395
|
+
"""
|
|
396
|
+
import subprocess
|
|
397
|
+
|
|
398
|
+
strategy = self.get_strategy(expected_strategy)
|
|
399
|
+
if strategy is None:
|
|
400
|
+
return {"valid": False, "errors": [f"Strategy '{expected_strategy}' not found"]}
|
|
401
|
+
|
|
402
|
+
errors = []
|
|
403
|
+
warnings = []
|
|
404
|
+
info = {}
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
# Check if we're in a git repository
|
|
408
|
+
result = subprocess.run(
|
|
409
|
+
["git", "rev-parse", "--git-dir"],
|
|
410
|
+
capture_output=True,
|
|
411
|
+
text=True,
|
|
412
|
+
cwd=Path.cwd()
|
|
413
|
+
)
|
|
414
|
+
if result.returncode != 0:
|
|
415
|
+
errors.append("Not in a git repository")
|
|
416
|
+
return {"valid": False, "errors": errors}
|
|
417
|
+
|
|
418
|
+
# Check current branch
|
|
419
|
+
result = subprocess.run(
|
|
420
|
+
["git", "branch", "--show-current"],
|
|
421
|
+
capture_output=True,
|
|
422
|
+
text=True,
|
|
423
|
+
cwd=Path.cwd()
|
|
424
|
+
)
|
|
425
|
+
current_branch = result.stdout.strip()
|
|
426
|
+
info["current_branch"] = current_branch
|
|
427
|
+
|
|
428
|
+
# Check remotes
|
|
429
|
+
result = subprocess.run(
|
|
430
|
+
["git", "remote", "-v"],
|
|
431
|
+
capture_output=True,
|
|
432
|
+
text=True,
|
|
433
|
+
cwd=Path.cwd()
|
|
434
|
+
)
|
|
435
|
+
remotes = result.stdout.strip()
|
|
436
|
+
info["remotes"] = remotes
|
|
437
|
+
|
|
438
|
+
# Check if main branch exists
|
|
439
|
+
result = subprocess.run(
|
|
440
|
+
["git", "branch", "-a", "--list", strategy.main_branch],
|
|
441
|
+
capture_output=True,
|
|
442
|
+
text=True,
|
|
443
|
+
cwd=Path.cwd()
|
|
444
|
+
)
|
|
445
|
+
if strategy.main_branch not in result.stdout:
|
|
446
|
+
warnings.append(f"Main branch '{strategy.main_branch}' not found")
|
|
447
|
+
|
|
448
|
+
# For git-flow, check develop branch
|
|
449
|
+
if strategy.strategy_type == GitStrategyType.GIT_FLOW:
|
|
450
|
+
result = subprocess.run(
|
|
451
|
+
["git", "branch", "-a", "--list", strategy.develop_branch],
|
|
452
|
+
capture_output=True,
|
|
453
|
+
text=True,
|
|
454
|
+
cwd=Path.cwd()
|
|
455
|
+
)
|
|
456
|
+
if strategy.develop_branch not in result.stdout:
|
|
457
|
+
warnings.append(f"Develop branch '{strategy.develop_branch}' not found")
|
|
458
|
+
|
|
459
|
+
# For dual-repo, check remotes
|
|
460
|
+
if strategy.strategy_type == GitStrategyType.DUAL_REPO:
|
|
461
|
+
if strategy.dev_repository:
|
|
462
|
+
dev_remote = strategy.dev_repository.get("remote", "origin")
|
|
463
|
+
if dev_remote not in remotes:
|
|
464
|
+
warnings.append(f"Dev remote '{dev_remote}' not configured")
|
|
465
|
+
if strategy.release_repository:
|
|
466
|
+
release_remote = strategy.release_repository.get("remote", "release")
|
|
467
|
+
if release_remote not in remotes:
|
|
468
|
+
warnings.append(f"Release remote '{release_remote}' not configured")
|
|
469
|
+
|
|
470
|
+
except Exception as e:
|
|
471
|
+
errors.append(f"Validation error: {e}")
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
"valid": len(errors) == 0,
|
|
475
|
+
"errors": errors,
|
|
476
|
+
"warnings": warnings,
|
|
477
|
+
"info": info,
|
|
478
|
+
}
|
cl_preset/manager.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Preset manager for handling installation, listing, and removal of presets.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from cl_preset.models import Preset, PresetConfig, PresetMetadata, PresetScope
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PresetManager:
|
|
16
|
+
"""Manager for preset operations."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base_path: Path | None = None):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the preset manager.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
base_path: Base path for global installations. Defaults to ~/.claude/presets
|
|
24
|
+
"""
|
|
25
|
+
self.console = Console()
|
|
26
|
+
self.base_path = base_path or (Path.home() / ".claude" / "presets")
|
|
27
|
+
self.registry_path = self.base_path / "registry.json"
|
|
28
|
+
self._ensure_registry()
|
|
29
|
+
|
|
30
|
+
def _ensure_registry(self) -> None:
|
|
31
|
+
"""Ensure the registry file exists."""
|
|
32
|
+
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
if not self.registry_path.exists():
|
|
34
|
+
self.registry_path.write_text(json.dumps({"installed": []}, indent=2))
|
|
35
|
+
|
|
36
|
+
def _load_registry(self) -> dict:
|
|
37
|
+
"""Load the installed presets registry."""
|
|
38
|
+
return json.loads(self.registry_path.read_text())
|
|
39
|
+
|
|
40
|
+
def _save_registry(self, registry: dict) -> None:
|
|
41
|
+
"""Save the registry to disk."""
|
|
42
|
+
self.registry_path.write_text(json.dumps(registry, indent=2))
|
|
43
|
+
|
|
44
|
+
def install(self, preset: Preset) -> bool:
|
|
45
|
+
"""
|
|
46
|
+
Install a preset.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
preset: The preset to install
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if successful, False otherwise
|
|
53
|
+
"""
|
|
54
|
+
install_path = preset.config.get_install_path(self.base_path)
|
|
55
|
+
|
|
56
|
+
# Check if already installed
|
|
57
|
+
registry = self._load_registry()
|
|
58
|
+
installed_names = [p["name"] for p in registry["installed"]]
|
|
59
|
+
|
|
60
|
+
if preset.config.metadata.name in installed_names:
|
|
61
|
+
self.console.print(f"[yellow]Preset '{preset.config.metadata.name}' is already installed.[/yellow]")
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Create installation directory
|
|
65
|
+
install_path.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
# Copy files
|
|
68
|
+
source_path = preset.config.source_path
|
|
69
|
+
if source_path.exists():
|
|
70
|
+
if source_path.is_dir():
|
|
71
|
+
shutil.copytree(source_path, install_path, dirs_exist_ok=True)
|
|
72
|
+
else:
|
|
73
|
+
shutil.copy2(source_path, install_path / source_path.name)
|
|
74
|
+
|
|
75
|
+
# Write metadata
|
|
76
|
+
metadata_path = install_path / "preset.json"
|
|
77
|
+
metadata_path.write_text(
|
|
78
|
+
json.dumps(preset.config.metadata.model_dump(mode="json"), indent=2)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Update registry
|
|
82
|
+
registry["installed"].append({
|
|
83
|
+
"name": preset.config.metadata.name,
|
|
84
|
+
"version": preset.config.metadata.version,
|
|
85
|
+
"scope": preset.config.scope.value,
|
|
86
|
+
"path": str(install_path),
|
|
87
|
+
})
|
|
88
|
+
self._save_registry(registry)
|
|
89
|
+
|
|
90
|
+
self.console.print(f"[green]Successfully installed '{preset.config.metadata.name}'[/green]")
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
def uninstall(self, name: str, scope: PresetScope | None = None) -> bool:
|
|
94
|
+
"""
|
|
95
|
+
Uninstall a preset.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name: Name of the preset to uninstall
|
|
99
|
+
scope: Optional scope to filter by
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if successful, False otherwise
|
|
103
|
+
"""
|
|
104
|
+
registry = self._load_registry()
|
|
105
|
+
|
|
106
|
+
# Find the preset
|
|
107
|
+
installed = registry.get("installed", [])
|
|
108
|
+
preset_entry = None
|
|
109
|
+
preset_index = -1
|
|
110
|
+
|
|
111
|
+
for i, preset in enumerate(installed):
|
|
112
|
+
if preset["name"] == name:
|
|
113
|
+
if scope is None or preset["scope"] == scope.value:
|
|
114
|
+
preset_entry = preset
|
|
115
|
+
preset_index = i
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
if preset_entry is None:
|
|
119
|
+
self.console.print(f"[red]Preset '{name}' not found.[/red]")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
# Remove files
|
|
123
|
+
preset_path = Path(preset_entry["path"])
|
|
124
|
+
if preset_path.exists():
|
|
125
|
+
shutil.rmtree(preset_path)
|
|
126
|
+
|
|
127
|
+
# Update registry
|
|
128
|
+
installed.pop(preset_index)
|
|
129
|
+
registry["installed"] = installed
|
|
130
|
+
self._save_registry(registry)
|
|
131
|
+
|
|
132
|
+
self.console.print(f"[green]Successfully uninstalled '{name}'[/green]")
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def list(self, scope: PresetScope | None = None) -> list[dict]:
|
|
136
|
+
"""
|
|
137
|
+
List installed presets.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
scope: Optional scope to filter by
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
List of installed preset information
|
|
144
|
+
"""
|
|
145
|
+
registry = self._load_registry()
|
|
146
|
+
installed = registry.get("installed", [])
|
|
147
|
+
|
|
148
|
+
if scope:
|
|
149
|
+
installed = [p for p in installed if p["scope"] == scope.value]
|
|
150
|
+
|
|
151
|
+
return installed
|
|
152
|
+
|
|
153
|
+
def print_list(self, scope: PresetScope | None = None) -> None:
|
|
154
|
+
"""Print a formatted table of installed presets."""
|
|
155
|
+
presets = self.list(scope)
|
|
156
|
+
|
|
157
|
+
if not presets:
|
|
158
|
+
self.console.print("[yellow]No presets installed.[/yellow]")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
table = Table(title="Installed Presets")
|
|
162
|
+
table.add_column("Name", style="cyan")
|
|
163
|
+
table.add_column("Version", style="green")
|
|
164
|
+
table.add_column("Scope", style="yellow")
|
|
165
|
+
table.add_column("Path", style="blue")
|
|
166
|
+
|
|
167
|
+
for preset in presets:
|
|
168
|
+
table.add_row(
|
|
169
|
+
preset["name"],
|
|
170
|
+
preset["version"],
|
|
171
|
+
preset["scope"],
|
|
172
|
+
preset["path"][:50] + "..." if len(preset["path"]) > 50 else preset["path"]
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
self.console.print(table)
|
|
176
|
+
|
|
177
|
+
def create_from_directory(
|
|
178
|
+
self,
|
|
179
|
+
source_path: Path,
|
|
180
|
+
metadata: PresetMetadata,
|
|
181
|
+
scope: PresetScope = PresetScope.USER
|
|
182
|
+
) -> Preset:
|
|
183
|
+
"""
|
|
184
|
+
Create a preset from a directory.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
source_path: Path to the source directory
|
|
188
|
+
metadata: Metadata for the preset
|
|
189
|
+
scope: Installation scope
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
A Preset object
|
|
193
|
+
"""
|
|
194
|
+
config = PresetConfig(
|
|
195
|
+
metadata=metadata,
|
|
196
|
+
source_path=source_path,
|
|
197
|
+
scope=scope
|
|
198
|
+
)
|
|
199
|
+
return Preset(config=config)
|