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.
@@ -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
@@ -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