devflow-cli 2.2.0__py3-none-any.whl → 2.8.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.
Files changed (41) hide show
  1. devflow_cli/__init__.py +36 -1
  2. devflow_cli/adapters/__init__.py +19 -0
  3. devflow_cli/adapters/agents.py +148 -0
  4. devflow_cli/adapters/linear.py +79 -0
  5. devflow_cli/cli.py +21 -2
  6. devflow_cli/commands/check.py +4 -1
  7. devflow_cli/commands/docs_sync.py +33 -0
  8. devflow_cli/commands/extension.py +3 -1
  9. devflow_cli/commands/feature.py +8 -4
  10. devflow_cli/commands/hooks_cmd.py +193 -0
  11. devflow_cli/commands/init_cmd.py +5 -0
  12. devflow_cli/commands/linear.py +30 -0
  13. devflow_cli/commands/migrate_cmd.py +4 -0
  14. devflow_cli/commands/migrate_speckit.py +147 -22
  15. devflow_cli/commands/review.py +54 -0
  16. devflow_cli/commands/run.py +123 -0
  17. devflow_cli/commands/{regen.py → stale.py} +6 -6
  18. devflow_cli/commands/status.py +24 -29
  19. devflow_cli/commands/step.py +118 -0
  20. devflow_cli/commands/traceability.py +47 -0
  21. devflow_cli/commands/verify.py +78 -0
  22. devflow_cli/core/catalog.py +4 -0
  23. devflow_cli/core/docgen.py +93 -0
  24. devflow_cli/core/git.py +6 -0
  25. devflow_cli/core/hooks.py +148 -2
  26. devflow_cli/core/manifest.py +4 -0
  27. devflow_cli/core/orchestrator.py +170 -0
  28. devflow_cli/core/state.py +217 -7
  29. devflow_cli/core/state_store.py +45 -0
  30. devflow_cli/core/traceability.py +81 -0
  31. devflow_cli/core/validators.py +31 -5
  32. devflow_cli/core/verification.py +296 -0
  33. devflow_cli/schemas/state.schema.json +39 -0
  34. devflow_cli/utils/logging.py +62 -0
  35. devflow_cli/utils/paths.py +84 -10
  36. {devflow_cli-2.2.0.dist-info → devflow_cli-2.8.0.dist-info}/METADATA +15 -7
  37. devflow_cli-2.8.0.dist-info/RECORD +52 -0
  38. {devflow_cli-2.2.0.dist-info → devflow_cli-2.8.0.dist-info}/WHEEL +1 -1
  39. devflow_cli-2.2.0.dist-info/RECORD +0 -34
  40. {devflow_cli-2.2.0.dist-info → devflow_cli-2.8.0.dist-info}/entry_points.txt +0 -0
  41. {devflow_cli-2.2.0.dist-info → devflow_cli-2.8.0.dist-info}/licenses/LICENSE +0 -0
devflow_cli/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """devflow CLI — Spec-Driven Development workflow."""
2
2
 
3
- VERSION = "2.2.0"
3
+ VERSION = "2.8.0"
4
+ STATE_SCHEMA_VERSION = 3
4
5
 
5
6
  STEPS = [
6
7
  "constitution", # Pré-requis projet (1/projet). Auto-complétée si .specify/memory/constitution.md existe.
@@ -20,6 +21,14 @@ STEPS = [
20
21
 
21
22
  OPTIONAL_STEPS = {"clarify", "research", "contracts", "docs"}
22
23
 
24
+ # Le mode fast conserve les gates et les bornes du pipeline. Il compte donc
25
+ # 9 etapes effectives, dont 4 etapes productives principales.
26
+ FAST_PRODUCTIVE_STEPS = ("spec", "plan", "tasks", "implement")
27
+ FAST_STEP_COUNT = len(STEPS) - len(OPTIONAL_STEPS)
28
+ FULL_STEP_COUNT = len(STEPS)
29
+
30
+ REVIEW_GATES = {"review-spec", "review-tasks", "review-impl"}
31
+
23
32
  STEP_ARTIFACTS = {
24
33
  "spec": "spec.md",
25
34
  "clarify": "clarify-log.md",
@@ -49,6 +58,20 @@ STEP_LINEAR_MAPPING = {
49
58
  "done": "Done",
50
59
  }
51
60
 
61
+ # Ordre de progression des statuts Linear (pour non-régression lors de la sync).
62
+ # Un statut ne doit jamais régresser : si l'index courant >= index cible, on ne met pas à jour.
63
+ STEP_LINEAR_ORDER = [
64
+ "Backlog",
65
+ "Spec",
66
+ "Research",
67
+ "Plan",
68
+ "Tasks",
69
+ "Review",
70
+ "In Progress",
71
+ "Doc",
72
+ "Done",
73
+ ]
74
+
52
75
  SUPPORTED_AGENTS = ["claude-code", "cursor"]
53
76
 
54
77
  # Mapping speckit → devflow pour la migration
@@ -76,3 +99,15 @@ ARTIFACTS = [
76
99
  "analysis.md",
77
100
  "checklist.md",
78
101
  ]
102
+
103
+ # --- Pipeline constants coherence checks ---
104
+ _steps_set = set(STEPS)
105
+ assert OPTIONAL_STEPS <= _steps_set, f"OPTIONAL_STEPS not subset of STEPS: {OPTIONAL_STEPS - _steps_set}"
106
+ assert REVIEW_GATES <= _steps_set, f"REVIEW_GATES not subset of STEPS: {REVIEW_GATES - _steps_set}"
107
+ assert set(STEP_ARTIFACTS.keys()) <= _steps_set, f"STEP_ARTIFACTS has unknown keys: {set(STEP_ARTIFACTS.keys()) - _steps_set}"
108
+ assert set(STEP_LINEAR_MAPPING.keys()) <= _steps_set, f"STEP_LINEAR_MAPPING has unknown keys: {set(STEP_LINEAR_MAPPING.keys()) - _steps_set}"
109
+ assert set(STEP_LINEAR_MAPPING.values()) <= set(STEP_LINEAR_ORDER), (
110
+ f"STEP_LINEAR_MAPPING values not in STEP_LINEAR_ORDER: "
111
+ f"{set(STEP_LINEAR_MAPPING.values()) - set(STEP_LINEAR_ORDER)}"
112
+ )
113
+ assert set(SPECKIT_STEP_MAP.values()) <= _steps_set, f"SPECKIT_STEP_MAP maps to unknown steps: {set(SPECKIT_STEP_MAP.values()) - _steps_set}"
@@ -0,0 +1,19 @@
1
+ """Adaptateurs des services externes utilisés par devflow."""
2
+
3
+ from devflow_cli.adapters.agents import (
4
+ AgentAdapter,
5
+ AgentExecution,
6
+ ClaudeCodeAdapter,
7
+ CodexAdapter,
8
+ CursorAdapter,
9
+ get_agent_adapter,
10
+ )
11
+
12
+ __all__ = [
13
+ "AgentAdapter",
14
+ "AgentExecution",
15
+ "ClaudeCodeAdapter",
16
+ "CodexAdapter",
17
+ "CursorAdapter",
18
+ "get_agent_adapter",
19
+ ]
@@ -0,0 +1,148 @@
1
+ """Adaptateurs d'exécution pour les agents IA en mode non interactif."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import re
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+
13
+ PermissionMode = Literal["auto", "acceptEdits", "manual"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AgentExecution:
18
+ returncode: int
19
+ stdout: str = ""
20
+ stderr: str = ""
21
+ timed_out: bool = False
22
+
23
+
24
+ class AgentAdapter(ABC):
25
+ """Contrat minimal d'un agent pilotable par le CLI."""
26
+
27
+ name: str
28
+ executable: str
29
+
30
+ @abstractmethod
31
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
32
+ """Construit une commande sans passer par un shell."""
33
+
34
+ def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
35
+ raise ValueError(f"L'adaptateur {self.name} ne supporte pas les agents nommés")
36
+
37
+ def execute(
38
+ self,
39
+ prompt: str,
40
+ cwd: Path,
41
+ timeout: int,
42
+ permission_mode: PermissionMode = "auto",
43
+ *,
44
+ capture_output: bool = False,
45
+ ) -> AgentExecution:
46
+ command = self.build_command(prompt, permission_mode)
47
+ try:
48
+ result = subprocess.run(
49
+ command,
50
+ cwd=cwd,
51
+ timeout=timeout,
52
+ text=True,
53
+ capture_output=capture_output,
54
+ shell=False,
55
+ )
56
+ return AgentExecution(
57
+ result.returncode,
58
+ result.stdout or "",
59
+ result.stderr or "",
60
+ )
61
+ except subprocess.TimeoutExpired as exc:
62
+ return AgentExecution(
63
+ 124,
64
+ _text(exc.stdout),
65
+ _text(exc.stderr),
66
+ timed_out=True,
67
+ )
68
+ except FileNotFoundError:
69
+ return AgentExecution(127, stderr=f"Executable '{self.executable}' introuvable")
70
+
71
+
72
+ class ClaudeCodeAdapter(AgentAdapter):
73
+ name = "claude-code"
74
+ executable = "claude"
75
+
76
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
77
+ return [
78
+ self.executable,
79
+ "--print",
80
+ "--permission-mode",
81
+ permission_mode,
82
+ prompt,
83
+ ]
84
+
85
+ def build_named_agent_command(self, agent_name: str, prompt: str) -> list[str]:
86
+ return [self.executable, "--print", "--agent", agent_name, prompt]
87
+
88
+
89
+ class CodexAdapter(AgentAdapter):
90
+ name = "codex"
91
+ executable = "codex"
92
+
93
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
94
+ command = [self.executable, "exec", "--sandbox", "workspace-write"]
95
+ if permission_mode in {"auto", "acceptEdits"}:
96
+ command.append("--approve-for-me")
97
+ command.append(_portable_prompt(prompt))
98
+ return command
99
+
100
+
101
+ class CursorAdapter(AgentAdapter):
102
+ name = "cursor"
103
+ executable = "cursor-agent"
104
+
105
+ def build_command(self, prompt: str, permission_mode: PermissionMode) -> list[str]:
106
+ command = [self.executable, "--print", "--output-format", "text"]
107
+ if permission_mode in {"auto", "acceptEdits"}:
108
+ command.append("--force")
109
+ command.append(_portable_prompt(prompt))
110
+ return command
111
+
112
+
113
+ _AGENT_ADAPTERS = {
114
+ "claude": ClaudeCodeAdapter,
115
+ "claude-code": ClaudeCodeAdapter,
116
+ "codex": CodexAdapter,
117
+ "cursor": CursorAdapter,
118
+ "cursor-agent": CursorAdapter,
119
+ }
120
+
121
+
122
+ def get_agent_adapter(name: str) -> AgentAdapter:
123
+ try:
124
+ adapter_type = _AGENT_ADAPTERS[name.casefold()]
125
+ except KeyError as exc:
126
+ supported = ", ".join(sorted({kind().name for kind in _AGENT_ADAPTERS.values()}))
127
+ raise ValueError(f"Agent inconnu '{name}'. Agents supportés: {supported}") from exc
128
+ return adapter_type()
129
+
130
+
131
+ def _portable_prompt(prompt: str) -> str:
132
+ """Traduit une slash-command Claude en instruction portable pour les autres agents."""
133
+ match = re.fullmatch(r"/devflow\.([a-z0-9-]+)(?:\s+(.*))?", prompt.strip())
134
+ if not match:
135
+ return prompt
136
+ command_name, arguments = match.groups()
137
+ arguments = arguments or ""
138
+ return (
139
+ f"Exécute strictement le workflow décrit dans "
140
+ f"commands/devflow.{command_name}.md avec $ARGUMENTS = {arguments!r}. "
141
+ "Travaille dans le dépôt courant et respecte toutes les validations de ce fichier."
142
+ )
143
+
144
+
145
+ def _text(value: str | bytes | None) -> str:
146
+ if value is None:
147
+ return ""
148
+ return value.decode(errors="replace") if isinstance(value, bytes) else value
@@ -0,0 +1,79 @@
1
+ """Politique de synchronisation Linear, indépendante du transport MCP/API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal, Protocol
7
+
8
+ from devflow_cli import STEP_LINEAR_MAPPING, STEP_LINEAR_ORDER
9
+
10
+
11
+ LinearAction = Literal["update", "keep", "skip"]
12
+
13
+
14
+ class LinearAdapter(Protocol):
15
+ """Transport minimal requis pour synchroniser le statut d'une issue."""
16
+
17
+ def get_status(self, issue_id: str) -> str | None: ...
18
+
19
+ def update_status(self, issue_id: str, status: str) -> None: ...
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LinearDecision:
24
+ action: LinearAction
25
+ step: str
26
+ current_status: str | None
27
+ target_status: str | None
28
+ reason: str
29
+
30
+ @property
31
+ def should_update(self) -> bool:
32
+ return self.action == "update"
33
+
34
+ def to_dict(self) -> dict[str, str | bool | None]:
35
+ return {
36
+ "action": self.action,
37
+ "step": self.step,
38
+ "currentStatus": self.current_status,
39
+ "targetStatus": self.target_status,
40
+ "shouldUpdate": self.should_update,
41
+ "reason": self.reason,
42
+ }
43
+
44
+
45
+ def decide_linear_transition(step: str, current_status: str | None) -> LinearDecision:
46
+ """Applique le mapping et la règle de non-régression canonique."""
47
+ target = STEP_LINEAR_MAPPING.get(step)
48
+ if target is None:
49
+ return LinearDecision("skip", step, current_status, None, "Étape sans statut Linear")
50
+ if current_status not in STEP_LINEAR_ORDER:
51
+ return LinearDecision(
52
+ "skip",
53
+ step,
54
+ current_status,
55
+ target,
56
+ "Statut Linear courant absent de l'ordre canonique",
57
+ )
58
+ current_index = STEP_LINEAR_ORDER.index(current_status)
59
+ target_index = STEP_LINEAR_ORDER.index(target)
60
+ if current_index >= target_index:
61
+ return LinearDecision(
62
+ "keep",
63
+ step,
64
+ current_status,
65
+ target,
66
+ "Statut courant identique ou plus avancé",
67
+ )
68
+ return LinearDecision("update", step, current_status, target, "Progression autorisée")
69
+
70
+
71
+ def sync_linear_status(
72
+ adapter: LinearAdapter, issue_id: str, step: str
73
+ ) -> LinearDecision:
74
+ """Synchronise via un transport injecté lorsque la politique l'autorise."""
75
+ current_status = adapter.get_status(issue_id)
76
+ decision = decide_linear_transition(step, current_status)
77
+ if decision.should_update and decision.target_status is not None:
78
+ adapter.update_status(issue_id, decision.target_status)
79
+ return decision
devflow_cli/cli.py CHANGED
@@ -5,18 +5,27 @@ from __future__ import annotations
5
5
  import typer
6
6
 
7
7
  from devflow_cli import VERSION
8
+ from devflow_cli.utils.logging import setup_logging
8
9
  from devflow_cli.commands.init_cmd import init
9
10
  from devflow_cli.commands.check import check
10
11
  from devflow_cli.commands.status import status
11
12
  from devflow_cli.commands.feature import feature
12
13
  from devflow_cli.commands.context import context
13
14
  from devflow_cli.commands.export_cmd import export
14
- from devflow_cli.commands.regen import regen
15
+ from devflow_cli.commands.stale import stale
15
16
  from devflow_cli.commands.migrate_cmd import migrate
16
17
  from devflow_cli.commands.upgrade_cmd import upgrade
17
18
  from devflow_cli.commands.rollback import rollback
18
19
  from devflow_cli.commands.migrate_speckit import migrate_speckit
19
20
  from devflow_cli.commands import extension
21
+ from devflow_cli.commands import hooks_cmd
22
+ from devflow_cli.commands import linear
23
+ from devflow_cli.commands import review
24
+ from devflow_cli.commands import step
25
+ from devflow_cli.commands.run import run
26
+ from devflow_cli.commands.docs_sync import docs_sync
27
+ from devflow_cli.commands.traceability import traceability
28
+ from devflow_cli.commands.verify import verify
20
29
 
21
30
  app = typer.Typer(
22
31
  name="devflow",
@@ -34,8 +43,10 @@ def version_callback(value: bool) -> None:
34
43
  @app.callback()
35
44
  def main(
36
45
  version: bool = typer.Option(False, "--version", callback=version_callback, is_eager=True, help="Affiche la version"),
46
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Active les logs dans stderr"),
37
47
  ) -> None:
38
48
  """devflow — Spec-Driven Development workflow CLI."""
49
+ setup_logging(verbose=verbose)
39
50
 
40
51
 
41
52
  app.command()(init)
@@ -44,12 +55,20 @@ app.command()(status)
44
55
  app.command()(feature)
45
56
  app.command()(context)
46
57
  app.command(name="export")(export)
47
- app.command()(regen)
58
+ app.command()(stale)
48
59
  app.command()(migrate)
49
60
  app.command()(upgrade)
50
61
  app.command()(rollback)
51
62
  app.command(name="migrate-speckit")(migrate_speckit)
52
63
  app.add_typer(extension.app, name="extension")
64
+ app.add_typer(hooks_cmd.app, name="hooks")
65
+ app.add_typer(linear.app, name="linear")
66
+ app.add_typer(step.app, name="step")
67
+ app.add_typer(review.app, name="review")
68
+ app.command()(run)
69
+ app.command(name="docs-sync")(docs_sync)
70
+ app.command()(traceability)
71
+ app.command()(verify)
53
72
 
54
73
 
55
74
  if __name__ == "__main__":
@@ -8,6 +8,9 @@ from pathlib import Path
8
8
 
9
9
  import typer
10
10
  from devflow_cli.utils.console import console, ok, warn, fail
11
+ from devflow_cli.utils.logging import get_logger
12
+
13
+ logger = get_logger(__name__)
11
14
  from devflow_cli.utils.paths import get_claude_dir
12
15
 
13
16
 
@@ -133,7 +136,7 @@ def check() -> None:
133
136
  linear_configured = True
134
137
  break
135
138
  except OSError:
136
- pass
139
+ logger.warning("Failed to read config %s", cfg_file, exc_info=True)
137
140
  if linear_configured:
138
141
  ok("Linear MCP -- configure")
139
142
  ok_count += 1
@@ -0,0 +1,33 @@
1
+ """devflow docs-sync — Synchronise la documentation dérivée des constantes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import typer
8
+
9
+ from devflow_cli.core.docgen import sync_pipeline_docs
10
+ from devflow_cli.utils.console import fail, ok
11
+
12
+
13
+ def docs_sync(
14
+ path: Path = typer.Argument(Path("."), help="Racine du depot devflow"),
15
+ check: bool = typer.Option(False, "--check", help="Vérifie sans modifier"),
16
+ ) -> None:
17
+ """Génère les références du pipeline depuis les constantes Python."""
18
+ root = path.resolve()
19
+ try:
20
+ stale = sync_pipeline_docs(root, check=check)
21
+ except (OSError, ValueError) as exc:
22
+ fail(str(exc))
23
+ raise typer.Exit(1) from exc
24
+
25
+ if check and stale:
26
+ for path in stale:
27
+ fail(f"Documentation obsolète: {path.relative_to(root)}")
28
+ raise typer.Exit(1)
29
+ if stale:
30
+ for path in stale:
31
+ ok(f"Synchronisé: {path.relative_to(root)}")
32
+ else:
33
+ ok("Documentation déjà synchronisée.")
@@ -76,7 +76,9 @@ def add(name: str = typer.Argument(..., help="Nom de l'extension a installer"))
76
76
  console.print("Utilisez 'devflow extension catalog' pour voir les extensions disponibles.")
77
77
  raise typer.Exit(1)
78
78
 
79
- source_label = f" [{ext.source}]" if ext.source != "principal" else ""
79
+ # Des crochets nus sont interpretes comme du markup Rich et rendent le
80
+ # libelle invisible (ex: "[communaute]").
81
+ source_label = f" ({ext.source})" if ext.source != "principal" else ""
80
82
  console.print(f"Installation de '{name}'{source_label}...")
81
83
  console.print()
82
84
 
@@ -9,6 +9,9 @@ from typing import Optional
9
9
  import typer
10
10
 
11
11
  from devflow_cli.core.git import branch_exists, create_branch, checkout_branch, GitNotFoundError
12
+ from devflow_cli.utils.logging import get_logger
13
+
14
+ logger = get_logger(__name__)
12
15
  from devflow_cli.core.state import create_state
13
16
  from devflow_cli.utils.console import console, ok, info
14
17
  from devflow_cli.utils.paths import get_specs_root, next_feature_number
@@ -28,6 +31,7 @@ def _ensure_git() -> None:
28
31
  from devflow_cli.core.git import ensure_git_available
29
32
  ensure_git_available()
30
33
  except GitNotFoundError as e:
34
+ logger.error("Git not found: %s", e, exc_info=True)
31
35
  console.print(f"[red]Erreur : {e}[/red]")
32
36
  raise typer.Exit(1)
33
37
 
@@ -61,7 +65,7 @@ def _init_feature_files(
61
65
  def feature(
62
66
  issue_id: str = typer.Argument(..., help="Identifiant de l'issue (ex: KS-123)"),
63
67
  short_name: Optional[str] = typer.Option(None, "--short-name", help="Nom court pour le repertoire (ex: user-auth)"),
64
- fast: bool = typer.Option(False, "--fast", help="Mode minimal (4 etapes : spec, plan, tasks, implement)"),
68
+ fast: bool = typer.Option(False, "--fast", help="Mode accelere (9 etapes, dont 4 productives)"),
65
69
  ) -> None:
66
70
  """Initialise une feature : branche git + state.json + review-log.md."""
67
71
  _validate_issue_id(issue_id)
@@ -98,9 +102,9 @@ def feature(
98
102
  console.print(f"Feature [bold]{issue_id}[/bold] initialisee :")
99
103
  console.print(f" Branche : {branch}")
100
104
  console.print(f" Dossier : {feature_dir}/")
101
- from devflow_cli import STEPS, OPTIONAL_STEPS
102
- total = len(STEPS)
103
- fast_count = total - len(OPTIONAL_STEPS)
105
+ from devflow_cli import FAST_STEP_COUNT, FULL_STEP_COUNT
106
+ total = FULL_STEP_COUNT
107
+ fast_count = FAST_STEP_COUNT
104
108
  console.print(f" Mode : {mode} ({fast_count} etapes)" if fast else f" Mode : {mode} ({total} etapes)")
105
109
  console.print(f" Etat : spec (etape initiale)")
106
110
  console.print()
@@ -0,0 +1,193 @@
1
+ """devflow hooks — Gestion et exécution contrôlée des hooks devflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import stat
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from devflow_cli.core.git import is_git_repo
12
+ from devflow_cli.core.hooks import (
13
+ DEFAULT_HOOK_TIMEOUT,
14
+ MAX_HOOK_TIMEOUT,
15
+ VALID_HOOK_POINTS,
16
+ run_hook_point,
17
+ )
18
+ from devflow_cli.utils.console import console, ok, fail, warn
19
+
20
+ app = typer.Typer(help="Gestion et execution des hooks devflow")
21
+
22
+ HOOKS_DIR_NAME = ".githooks"
23
+ HOOK_FILES = ("post-checkout", "post-merge")
24
+
25
+
26
+ def _configure_hooks_path(repo: Path) -> None:
27
+ subprocess.run(
28
+ ["git", "-C", str(repo), "config", "core.hooksPath", HOOKS_DIR_NAME],
29
+ check=True,
30
+ capture_output=True,
31
+ )
32
+
33
+
34
+ def _current_hooks_path(repo: Path) -> str | None:
35
+ result = subprocess.run(
36
+ ["git", "-C", str(repo), "config", "--get", "core.hooksPath"],
37
+ capture_output=True,
38
+ text=True,
39
+ )
40
+ if result.returncode != 0:
41
+ return None
42
+ return result.stdout.strip() or None
43
+
44
+
45
+ def _unset_hooks_path(repo: Path) -> None:
46
+ subprocess.run(
47
+ ["git", "-C", str(repo), "config", "--unset", "core.hooksPath"],
48
+ capture_output=True,
49
+ )
50
+
51
+
52
+ @app.command("install")
53
+ def install() -> None:
54
+ """Active les git hooks devflow (core.hooksPath + chmod +x)."""
55
+ repo = Path.cwd().resolve()
56
+
57
+ if not is_git_repo(repo):
58
+ fail(f"{repo} n'est pas un depot git.")
59
+ raise typer.Exit(1)
60
+
61
+ hooks_dir = repo / HOOKS_DIR_NAME
62
+ if not hooks_dir.is_dir():
63
+ fail(f"Dossier {HOOKS_DIR_NAME}/ introuvable a la racine du repo.")
64
+ raise typer.Exit(1)
65
+
66
+ missing = [h for h in HOOK_FILES if not (hooks_dir / h).is_file()]
67
+ if missing:
68
+ fail(f"Hooks manquants dans {HOOKS_DIR_NAME}/ : {', '.join(missing)}")
69
+ raise typer.Exit(1)
70
+
71
+ for name in HOOK_FILES:
72
+ path = hooks_dir / name
73
+ mode = path.stat().st_mode
74
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
75
+ ok(f"chmod +x {HOOKS_DIR_NAME}/{name}")
76
+
77
+ _configure_hooks_path(repo)
78
+ ok(f"git config core.hooksPath {HOOKS_DIR_NAME}")
79
+
80
+ console.print()
81
+ console.print("[green]Hooks devflow actifs.[/green] Un checkout ou merge sur develop lancera `devflow init --here --force`.")
82
+
83
+
84
+ @app.command("uninstall")
85
+ def uninstall() -> None:
86
+ """Desactive les git hooks devflow (supprime core.hooksPath)."""
87
+ repo = Path.cwd().resolve()
88
+
89
+ if not is_git_repo(repo):
90
+ fail(f"{repo} n'est pas un depot git.")
91
+ raise typer.Exit(1)
92
+
93
+ current = _current_hooks_path(repo)
94
+ if current is None:
95
+ warn("core.hooksPath n'est pas configure — rien a desactiver.")
96
+ return
97
+
98
+ if current != HOOKS_DIR_NAME:
99
+ warn(f"core.hooksPath pointe sur '{current}' (pas sur '{HOOKS_DIR_NAME}'). Abandon.")
100
+ raise typer.Exit(1)
101
+
102
+ _unset_hooks_path(repo)
103
+ ok(f"git config core.hooksPath supprime (etait: {current})")
104
+
105
+
106
+ @app.command("status")
107
+ def status() -> None:
108
+ """Affiche l'etat des hooks devflow pour ce repo."""
109
+ repo = Path.cwd().resolve()
110
+
111
+ if not is_git_repo(repo):
112
+ fail(f"{repo} n'est pas un depot git.")
113
+ raise typer.Exit(1)
114
+
115
+ hooks_path = _current_hooks_path(repo)
116
+ hooks_dir = repo / HOOKS_DIR_NAME
117
+
118
+ console.print(f"Repo : {repo}")
119
+ console.print(f"hooksPath : {hooks_path or '[dim](non configure)[/dim]'}")
120
+ console.print(f"{HOOKS_DIR_NAME}/ : {'present' if hooks_dir.is_dir() else '[dim]absent[/dim]'}")
121
+
122
+ if hooks_dir.is_dir():
123
+ for name in HOOK_FILES:
124
+ p = hooks_dir / name
125
+ if not p.is_file():
126
+ console.print(f" {name:<15} : [dim]absent[/dim]")
127
+ continue
128
+ executable = bool(p.stat().st_mode & stat.S_IXUSR)
129
+ console.print(f" {name:<15} : present ({'exec' if executable else 'non-exec'})")
130
+
131
+ active = hooks_path == HOOKS_DIR_NAME and hooks_dir.is_dir()
132
+ console.print()
133
+ console.print(f"Statut : {'[green]actif[/green]' if active else '[yellow]inactif[/yellow]'}")
134
+
135
+
136
+ @app.command("run")
137
+ def run(
138
+ hook_point: str = typer.Argument(..., help="Point de hook devflow"),
139
+ issue_id: str = typer.Argument(..., help="ID de l'issue courante"),
140
+ allow_shell: bool = typer.Option(
141
+ False,
142
+ "--allow-shell",
143
+ help="Autorise explicitement les hooks de type shell",
144
+ ),
145
+ timeout: int = typer.Option(
146
+ DEFAULT_HOOK_TIMEOUT,
147
+ min=1,
148
+ max=MAX_HOOK_TIMEOUT,
149
+ help="Timeout par defaut par hook",
150
+ ),
151
+ ) -> None:
152
+ """Execute un point de hook depuis devflow-hooks.yml."""
153
+ if hook_point not in VALID_HOOK_POINTS:
154
+ fail(f"Hook point inconnu: {hook_point}")
155
+ raise typer.Exit(1)
156
+
157
+ repo = Path.cwd().resolve()
158
+ hooks_file = repo / "devflow-hooks.yml"
159
+ if not hooks_file.is_file():
160
+ console.print("Aucun devflow-hooks.yml — rien a executer.")
161
+ return
162
+
163
+ try:
164
+ executions = run_hook_point(
165
+ hooks_file,
166
+ hook_point,
167
+ issue_id,
168
+ repo,
169
+ allow_shell=allow_shell,
170
+ default_timeout=timeout,
171
+ )
172
+ except (ValueError, PermissionError) as exc:
173
+ fail(str(exc))
174
+ raise typer.Exit(1) from exc
175
+
176
+ if not executions:
177
+ console.print(f"Aucun hook configure pour {hook_point}.")
178
+ return
179
+
180
+ failed = False
181
+ for execution in executions:
182
+ label = " ".join(execution.command)
183
+ if execution.succeeded:
184
+ ok(f"{execution.hook_type}: {label}")
185
+ else:
186
+ failed = True
187
+ reason = "timeout" if execution.timed_out else f"code {execution.returncode}"
188
+ fail(f"{execution.hook_type}: {label} ({reason})")
189
+ if execution.stderr.strip():
190
+ console.print(f" {execution.stderr.strip()}")
191
+
192
+ if failed:
193
+ raise typer.Exit(1)
@@ -8,6 +8,10 @@ from typing import Literal, Optional
8
8
 
9
9
  import typer
10
10
 
11
+ from devflow_cli.utils.logging import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
11
15
  from devflow_cli import SUPPORTED_AGENTS
12
16
  from devflow_cli.core.installer import install_devflow
13
17
  from devflow_cli.core.git import is_git_repo
@@ -110,6 +114,7 @@ def init(
110
114
  save_manifest(get_claude_dir(), manifest_data)
111
115
  ok("Manifeste d'installation cree")
112
116
  except Exception:
117
+ logger.error("Failed to create manifest", exc_info=True)
113
118
  warn("Impossible de creer le manifeste d'installation")
114
119
 
115
120
  console.print()