devflow-cli 1.0.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.
- devflow_cli/__init__.py +51 -0
- devflow_cli/cli.py +52 -0
- devflow_cli/commands/__init__.py +0 -0
- devflow_cli/commands/check.py +167 -0
- devflow_cli/commands/context.py +173 -0
- devflow_cli/commands/export_cmd.py +149 -0
- devflow_cli/commands/extension.py +137 -0
- devflow_cli/commands/feature.py +102 -0
- devflow_cli/commands/init_cmd.py +94 -0
- devflow_cli/commands/migrate_cmd.py +106 -0
- devflow_cli/commands/regen.py +93 -0
- devflow_cli/commands/status.py +118 -0
- devflow_cli/commands/upgrade_cmd.py +142 -0
- devflow_cli/core/__init__.py +0 -0
- devflow_cli/core/catalog.py +101 -0
- devflow_cli/core/git.py +68 -0
- devflow_cli/core/hooks.py +89 -0
- devflow_cli/core/installer.py +139 -0
- devflow_cli/core/manifest.py +167 -0
- devflow_cli/core/scanner.py +136 -0
- devflow_cli/core/staleness.py +135 -0
- devflow_cli/core/state.py +69 -0
- devflow_cli/utils/__init__.py +0 -0
- devflow_cli/utils/console.py +34 -0
- devflow_cli/utils/paths.py +141 -0
- devflow_cli-1.0.0.dist-info/METADATA +142 -0
- devflow_cli-1.0.0.dist-info/RECORD +30 -0
- devflow_cli-1.0.0.dist-info/WHEEL +4 -0
- devflow_cli-1.0.0.dist-info/entry_points.txt +2 -0
- devflow_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""devflow upgrade — Detection et mise a jour des templates obsoletes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from devflow_cli import VERSION
|
|
11
|
+
from devflow_cli.core.manifest import (
|
|
12
|
+
compute_source_manifest,
|
|
13
|
+
load_manifest,
|
|
14
|
+
save_manifest,
|
|
15
|
+
compare_manifests,
|
|
16
|
+
compute_file_hash,
|
|
17
|
+
)
|
|
18
|
+
from devflow_cli.utils.console import console, ok, info, warn, fail, header
|
|
19
|
+
from devflow_cli.utils.paths import get_devflow_root, get_claude_dir
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade(
|
|
23
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Affiche le rapport sans modifier"),
|
|
24
|
+
force: bool = typer.Option(False, "--force", help="Met a jour sans confirmation"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Detecte et met a jour les templates devflow obsoletes."""
|
|
27
|
+
claude_dir = get_claude_dir()
|
|
28
|
+
|
|
29
|
+
if not claude_dir.is_dir():
|
|
30
|
+
fail("Aucun template installe. Lancez `devflow init` d'abord.")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
templates_dir = claude_dir / "templates"
|
|
34
|
+
if not templates_dir.is_dir():
|
|
35
|
+
fail("Aucun template installe. Lancez `devflow init` d'abord.")
|
|
36
|
+
raise typer.Exit(1)
|
|
37
|
+
|
|
38
|
+
# Calculer le manifeste source
|
|
39
|
+
devflow_root = get_devflow_root()
|
|
40
|
+
source_manifest = compute_source_manifest(devflow_root)
|
|
41
|
+
|
|
42
|
+
# Charger le manifeste d'installation
|
|
43
|
+
installed_manifest = load_manifest(claude_dir)
|
|
44
|
+
if installed_manifest is None:
|
|
45
|
+
info("Pas de manifeste d'installation (installation legacy).")
|
|
46
|
+
info("Comparaison par checksums directs.")
|
|
47
|
+
|
|
48
|
+
# Comparer
|
|
49
|
+
statuses = compare_manifests(source_manifest, installed_manifest, claude_dir)
|
|
50
|
+
|
|
51
|
+
if not statuses:
|
|
52
|
+
info("Aucun template a verifier.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Afficher le rapport
|
|
56
|
+
header("devflow upgrade — Rapport de templates")
|
|
57
|
+
console.print()
|
|
58
|
+
|
|
59
|
+
from devflow_cli.utils.console import make_table
|
|
60
|
+
table = make_table("Template", "Statut", title="Templates")
|
|
61
|
+
|
|
62
|
+
status_icons = {
|
|
63
|
+
"current": "[green]a jour[/green]",
|
|
64
|
+
"outdated": "[yellow]obsolete[/yellow]",
|
|
65
|
+
"modified": "[red]modifie local[/red]",
|
|
66
|
+
"new": "[blue]nouveau[/blue]",
|
|
67
|
+
"unknown": "[dim]inconnu[/dim]",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
outdated = [s for s in statuses if s.status == "outdated"]
|
|
71
|
+
modified = [s for s in statuses if s.status == "modified"]
|
|
72
|
+
new_templates = [s for s in statuses if s.status == "new"]
|
|
73
|
+
current = [s for s in statuses if s.status == "current"]
|
|
74
|
+
unknown = [s for s in statuses if s.status == "unknown"]
|
|
75
|
+
|
|
76
|
+
for s in statuses:
|
|
77
|
+
table.add_row(s.path, status_icons.get(s.status, s.status))
|
|
78
|
+
|
|
79
|
+
console.print(table)
|
|
80
|
+
console.print()
|
|
81
|
+
console.print(
|
|
82
|
+
f"Resume : {len(current)} a jour, {len(outdated)} obsoletes, "
|
|
83
|
+
f"{len(modified)} modifies, {len(new_templates)} nouveaux"
|
|
84
|
+
+ (f", {len(unknown)} inconnus" if unknown else "")
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Version warning
|
|
88
|
+
if installed_manifest and installed_manifest.get("devflow_version", "") > VERSION:
|
|
89
|
+
warn(f"La version installee ({installed_manifest['devflow_version']}) est plus recente que la source ({VERSION}).")
|
|
90
|
+
|
|
91
|
+
# Rien a faire ?
|
|
92
|
+
to_update = outdated + new_templates
|
|
93
|
+
if not to_update and not modified:
|
|
94
|
+
console.print()
|
|
95
|
+
ok("Tous les templates sont a jour.")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if dry_run:
|
|
99
|
+
console.print()
|
|
100
|
+
console.print("[dim]Mode dry-run : aucun fichier modifie.[/dim]")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
# Mise a jour des templates obsoletes et nouveaux
|
|
104
|
+
if to_update:
|
|
105
|
+
if not force:
|
|
106
|
+
console.print()
|
|
107
|
+
if not typer.confirm(f"Mettre a jour {len(to_update)} template(s) ?", default=True):
|
|
108
|
+
console.print("Annule.")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
for s in to_update:
|
|
112
|
+
src = devflow_root / s.path
|
|
113
|
+
dest = claude_dir / s.path
|
|
114
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
shutil.copy2(src, dest)
|
|
116
|
+
ok(f" {s.path}")
|
|
117
|
+
|
|
118
|
+
# Templates modifies localement
|
|
119
|
+
if modified:
|
|
120
|
+
console.print()
|
|
121
|
+
warn(f"{len(modified)} template(s) modifie(s) localement :")
|
|
122
|
+
for s in modified:
|
|
123
|
+
console.print(f" [red]{s.path}[/red]")
|
|
124
|
+
|
|
125
|
+
if force or typer.confirm("Ecraser les templates modifies ? (sauvegarde .bak)", default=False):
|
|
126
|
+
for s in modified:
|
|
127
|
+
src = devflow_root / s.path
|
|
128
|
+
dest = claude_dir / s.path
|
|
129
|
+
bak = dest.with_suffix(dest.suffix + ".bak")
|
|
130
|
+
shutil.copy2(dest, bak)
|
|
131
|
+
shutil.copy2(src, dest)
|
|
132
|
+
ok(f" {s.path} (ancien -> {bak.name})")
|
|
133
|
+
else:
|
|
134
|
+
info("Templates modifies ignores.")
|
|
135
|
+
|
|
136
|
+
# Mettre a jour le manifeste
|
|
137
|
+
new_manifest = compute_source_manifest(devflow_root)
|
|
138
|
+
new_manifest["installed_at"] = new_manifest.pop("computed_at")
|
|
139
|
+
save_manifest(claude_dir, new_manifest)
|
|
140
|
+
|
|
141
|
+
console.print()
|
|
142
|
+
ok("Mise a jour terminee. Manifeste actualise.")
|
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Lecture multi-catalogue JSON pour les extensions devflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from devflow_cli.utils.paths import get_devflow_root, get_claude_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ExtensionInclude:
|
|
14
|
+
type: str
|
|
15
|
+
file: str
|
|
16
|
+
target: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Extension:
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
type: str
|
|
24
|
+
version: str
|
|
25
|
+
includes: list[ExtensionInclude]
|
|
26
|
+
source: str = "principal" # principal | communaute | distant
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Catalog:
|
|
31
|
+
version: str
|
|
32
|
+
extensions: list[Extension]
|
|
33
|
+
source: str = "principal"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_catalog(path: Path, source: str = "principal") -> Catalog | None:
|
|
37
|
+
"""Charge un fichier catalogue JSON."""
|
|
38
|
+
if not path.is_file():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
43
|
+
from devflow_cli.utils.console import warn
|
|
44
|
+
warn(f"Catalogue corrompu ou illisible : {path} ({type(e).__name__}: {e})")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
extensions = []
|
|
48
|
+
for ext_data in data.get("extensions", []):
|
|
49
|
+
includes = [
|
|
50
|
+
ExtensionInclude(**inc) for inc in ext_data.get("includes", [])
|
|
51
|
+
]
|
|
52
|
+
extensions.append(Extension(
|
|
53
|
+
name=ext_data["name"],
|
|
54
|
+
description=ext_data.get("description", ""),
|
|
55
|
+
type=ext_data.get("type", "bundle"),
|
|
56
|
+
version=ext_data.get("version", "0.0.0"),
|
|
57
|
+
includes=includes,
|
|
58
|
+
source=source,
|
|
59
|
+
))
|
|
60
|
+
return Catalog(version=data.get("version", "1.0.0"), extensions=extensions, source=source)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_all_catalogs() -> list[Catalog]:
|
|
64
|
+
"""Charge tous les catalogues disponibles (principal, communautaire, distant)."""
|
|
65
|
+
root = get_devflow_root()
|
|
66
|
+
catalogs: list[Catalog] = []
|
|
67
|
+
|
|
68
|
+
# Principal
|
|
69
|
+
principal = load_catalog(root / "extensions" / "catalog.json", "principal")
|
|
70
|
+
if principal:
|
|
71
|
+
catalogs.append(principal)
|
|
72
|
+
|
|
73
|
+
# Communautaire
|
|
74
|
+
community = load_catalog(root / "extensions" / "catalog.community.json", "communaute")
|
|
75
|
+
if community and community.extensions:
|
|
76
|
+
catalogs.append(community)
|
|
77
|
+
|
|
78
|
+
# Catalogue distant : non implémenté (voir future feature)
|
|
79
|
+
|
|
80
|
+
return catalogs
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_extension(name: str, catalogs: list[Catalog] | None = None) -> Extension | None:
|
|
84
|
+
"""Cherche une extension par nom dans tous les catalogues."""
|
|
85
|
+
if catalogs is None:
|
|
86
|
+
catalogs = load_all_catalogs()
|
|
87
|
+
for catalog in catalogs:
|
|
88
|
+
for ext in catalog.extensions:
|
|
89
|
+
if ext.name == name:
|
|
90
|
+
return ext
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def is_installed(ext: Extension) -> bool:
|
|
95
|
+
"""Verifie si tous les fichiers d'une extension sont installes."""
|
|
96
|
+
claude_dir = get_claude_dir()
|
|
97
|
+
for inc in ext.includes:
|
|
98
|
+
filename = Path(inc.file).name
|
|
99
|
+
if not (claude_dir / inc.target / filename).is_file():
|
|
100
|
+
return False
|
|
101
|
+
return True
|
devflow_cli/core/git.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Operations git pour devflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitNotFoundError(Exception):
|
|
10
|
+
"""Levee quand git n'est pas installe ou pas dans le PATH."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
super().__init__("git n'est pas installé ou n'est pas dans le PATH")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_git_checked = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ensure_git_available() -> None:
|
|
20
|
+
"""Verifie que git est accessible. Leve GitNotFoundError sinon."""
|
|
21
|
+
global _git_checked # noqa: PLW0603
|
|
22
|
+
if _git_checked:
|
|
23
|
+
return
|
|
24
|
+
try:
|
|
25
|
+
subprocess.run(
|
|
26
|
+
["git", "--version"],
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
timeout=5,
|
|
30
|
+
)
|
|
31
|
+
_git_checked = True
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
raise GitNotFoundError() from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _run(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
|
37
|
+
ensure_git_available()
|
|
38
|
+
return subprocess.run(args, capture_output=True, text=True, cwd=cwd)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_git_repo(path: Path | None = None) -> bool:
|
|
42
|
+
"""Verifie si le repertoire est un depot git."""
|
|
43
|
+
result = _run(["git", "rev-parse", "--is-inside-work-tree"], cwd=path)
|
|
44
|
+
return result.returncode == 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def branch_exists(branch: str, cwd: Path | None = None) -> bool:
|
|
48
|
+
"""Verifie si une branche locale existe."""
|
|
49
|
+
result = _run(["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=cwd)
|
|
50
|
+
return result.returncode == 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_branch(branch: str, cwd: Path | None = None) -> bool:
|
|
54
|
+
"""Cree et checkout une nouvelle branche."""
|
|
55
|
+
result = _run(["git", "checkout", "-b", branch], cwd=cwd)
|
|
56
|
+
return result.returncode == 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def checkout_branch(branch: str, cwd: Path | None = None) -> bool:
|
|
60
|
+
"""Checkout une branche existante."""
|
|
61
|
+
result = _run(["git", "checkout", branch], cwd=cwd)
|
|
62
|
+
return result.returncode == 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def current_branch(cwd: Path | None = None) -> str:
|
|
66
|
+
"""Retourne le nom de la branche courante."""
|
|
67
|
+
result = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
|
68
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Validation de fichiers devflow-hooks.yml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
VALID_HOOK_POINTS = {"after_tasks", "before_implement", "after_implement"}
|
|
12
|
+
VALID_HOOK_TYPES = {"shell", "claude", "agent"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class ValidationResult:
|
|
17
|
+
errors: list[str] = field(default_factory=list)
|
|
18
|
+
warnings: list[str] = field(default_factory=list)
|
|
19
|
+
ok_messages: list[str] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def is_valid(self) -> bool:
|
|
23
|
+
return len(self.errors) == 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_hooks_file(hooks_file: Path) -> ValidationResult:
|
|
27
|
+
"""Valide la syntaxe et les references d'un fichier devflow-hooks.yml."""
|
|
28
|
+
result = ValidationResult()
|
|
29
|
+
|
|
30
|
+
if not hooks_file.is_file():
|
|
31
|
+
result.errors.append(f"Fichier de hooks non trouve : {hooks_file}")
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
data = yaml.safe_load(hooks_file.read_text(encoding="utf-8"))
|
|
36
|
+
except yaml.YAMLError as e:
|
|
37
|
+
result.errors.append(f"Erreur de syntaxe YAML : {e}")
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
if not isinstance(data, dict) or "hooks" not in data:
|
|
41
|
+
result.errors.append("Le fichier doit contenir une cle 'hooks' a la racine")
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
hooks = data.get("hooks")
|
|
45
|
+
if hooks is None:
|
|
46
|
+
# hooks: sans contenu = valide (tout commente)
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
if not isinstance(hooks, dict):
|
|
50
|
+
result.errors.append("'hooks' doit etre un dictionnaire")
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
for hook_point, entries in hooks.items():
|
|
54
|
+
# Verifier le hook point
|
|
55
|
+
if hook_point not in VALID_HOOK_POINTS:
|
|
56
|
+
result.errors.append(
|
|
57
|
+
f"Hook point inconnu : '{hook_point}' (valides: {', '.join(sorted(VALID_HOOK_POINTS))})"
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
result.ok_messages.append(f"Hook point : {hook_point}")
|
|
61
|
+
|
|
62
|
+
if not isinstance(entries, list):
|
|
63
|
+
result.errors.append(f"'{hook_point}' doit etre une liste")
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
for entry in entries:
|
|
67
|
+
if not isinstance(entry, dict):
|
|
68
|
+
result.errors.append(f"Chaque hook doit etre un dictionnaire (dans {hook_point})")
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# Verifier le type
|
|
72
|
+
hook_type = entry.get("type", "")
|
|
73
|
+
if hook_type not in VALID_HOOK_TYPES:
|
|
74
|
+
result.errors.append(
|
|
75
|
+
f"Type de hook inconnu : '{hook_type}' (valides: {', '.join(sorted(VALID_HOOK_TYPES))})"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Verifier la presence de command
|
|
79
|
+
if "command" not in entry:
|
|
80
|
+
result.warnings.append(f"Hook sans champ 'command' dans {hook_point}")
|
|
81
|
+
|
|
82
|
+
# Verifier format commande claude
|
|
83
|
+
if hook_type == "claude":
|
|
84
|
+
cmd = entry.get("command", "")
|
|
85
|
+
first_word = cmd.split()[0] if cmd.split() else ""
|
|
86
|
+
if first_word and not first_word.startswith("/"):
|
|
87
|
+
result.warnings.append(f"Commande claude sans '/' : {cmd}")
|
|
88
|
+
|
|
89
|
+
return result
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Installation devflow et gestion des extensions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from devflow_cli.core.catalog import Extension
|
|
10
|
+
from devflow_cli.utils.paths import get_devflow_root, get_claude_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CopyResult:
|
|
15
|
+
copied: int = 0
|
|
16
|
+
skipped: int = 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _copy_files(src_dir: Path, dest_dir: Path, pattern: str, force: bool = False) -> CopyResult:
|
|
20
|
+
"""Copie les fichiers correspondant au pattern."""
|
|
21
|
+
result = CopyResult()
|
|
22
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
for f in sorted(src_dir.glob(pattern)):
|
|
24
|
+
if not f.is_file():
|
|
25
|
+
continue
|
|
26
|
+
dest = dest_dir / f.name
|
|
27
|
+
if force or not dest.exists():
|
|
28
|
+
shutil.copy2(f, dest)
|
|
29
|
+
result.copied += 1
|
|
30
|
+
else:
|
|
31
|
+
result.skipped += 1
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def install_devflow(force: bool = False, script_type: str | None = None) -> dict[str, CopyResult]:
|
|
36
|
+
"""Installe commandes, agents, templates et scripts dans ~/.claude/.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
force: Ecraser les fichiers existants.
|
|
40
|
+
script_type: Filtrer les scripts par type ("bash", "powershell", ou None pour tous).
|
|
41
|
+
"""
|
|
42
|
+
root = get_devflow_root()
|
|
43
|
+
claude = get_claude_dir()
|
|
44
|
+
results: dict[str, CopyResult] = {}
|
|
45
|
+
|
|
46
|
+
# Commandes
|
|
47
|
+
results["commands"] = _copy_files(root / "commands", claude / "commands", "devflow*.md", force)
|
|
48
|
+
|
|
49
|
+
# Agents
|
|
50
|
+
results["agents"] = _copy_files(root / "agents", claude / "agents", "devflow*.md", force)
|
|
51
|
+
|
|
52
|
+
# Templates (fichiers racine)
|
|
53
|
+
results["templates"] = _copy_files(root / "templates", claude / "templates", "*", force)
|
|
54
|
+
|
|
55
|
+
# Templates export
|
|
56
|
+
if (root / "templates" / "export").is_dir():
|
|
57
|
+
r = _copy_files(root / "templates" / "export", claude / "templates" / "export", "*", force)
|
|
58
|
+
results["templates"].copied += r.copied
|
|
59
|
+
results["templates"].skipped += r.skipped
|
|
60
|
+
|
|
61
|
+
# Templates checklists
|
|
62
|
+
if (root / "templates" / "checklists").is_dir():
|
|
63
|
+
r = _copy_files(root / "templates" / "checklists", claude / "templates" / "checklists", "*", force)
|
|
64
|
+
results["templates"].copied += r.copied
|
|
65
|
+
results["templates"].skipped += r.skipped
|
|
66
|
+
|
|
67
|
+
# Templates contracts
|
|
68
|
+
if (root / "templates" / "contracts").is_dir():
|
|
69
|
+
r = _copy_files(root / "templates" / "contracts", claude / "templates" / "contracts", "*", force)
|
|
70
|
+
results["templates"].copied += r.copied
|
|
71
|
+
results["templates"].skipped += r.skipped
|
|
72
|
+
|
|
73
|
+
# Scripts — filtrage par script_type
|
|
74
|
+
if script_type == "bash":
|
|
75
|
+
extensions = ("*.sh",)
|
|
76
|
+
elif script_type == "powershell":
|
|
77
|
+
extensions = ("*.ps1",)
|
|
78
|
+
else:
|
|
79
|
+
extensions = ("*.sh", "*.ps1")
|
|
80
|
+
|
|
81
|
+
scripts_result = CopyResult()
|
|
82
|
+
scripts_dir = root / "scripts"
|
|
83
|
+
dest_scripts = claude / "scripts"
|
|
84
|
+
dest_scripts.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
for ext in extensions:
|
|
86
|
+
for f in sorted(scripts_dir.glob(ext)):
|
|
87
|
+
if not f.is_file():
|
|
88
|
+
continue
|
|
89
|
+
dest = dest_scripts / f.name
|
|
90
|
+
if force or not dest.exists():
|
|
91
|
+
shutil.copy2(f, dest)
|
|
92
|
+
if f.suffix == ".sh":
|
|
93
|
+
dest.chmod(dest.stat().st_mode | 0o111)
|
|
94
|
+
scripts_result.copied += 1
|
|
95
|
+
else:
|
|
96
|
+
scripts_result.skipped += 1
|
|
97
|
+
results["scripts"] = scripts_result
|
|
98
|
+
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def install_extension(ext: Extension) -> CopyResult:
|
|
103
|
+
"""Installe une extension (copie ses fichiers vers ~/.claude/)."""
|
|
104
|
+
root = get_devflow_root()
|
|
105
|
+
claude = get_claude_dir()
|
|
106
|
+
result = CopyResult()
|
|
107
|
+
|
|
108
|
+
for inc in ext.includes:
|
|
109
|
+
src = root / inc.file
|
|
110
|
+
filename = Path(inc.file).name
|
|
111
|
+
dest_dir = claude / inc.target
|
|
112
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
dest = dest_dir / filename
|
|
114
|
+
|
|
115
|
+
if not src.is_file():
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if dest.exists():
|
|
119
|
+
result.skipped += 1
|
|
120
|
+
else:
|
|
121
|
+
shutil.copy2(src, dest)
|
|
122
|
+
result.copied += 1
|
|
123
|
+
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def uninstall_extension(ext: Extension) -> int:
|
|
128
|
+
"""Desinstalle une extension (supprime ses fichiers de ~/.claude/)."""
|
|
129
|
+
claude = get_claude_dir()
|
|
130
|
+
removed = 0
|
|
131
|
+
|
|
132
|
+
for inc in ext.includes:
|
|
133
|
+
filename = Path(inc.file).name
|
|
134
|
+
dest = claude / inc.target / filename
|
|
135
|
+
if dest.is_file():
|
|
136
|
+
dest.unlink()
|
|
137
|
+
removed += 1
|
|
138
|
+
|
|
139
|
+
return removed
|