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.
- devflow_cli/__init__.py +36 -1
- devflow_cli/adapters/__init__.py +19 -0
- devflow_cli/adapters/agents.py +148 -0
- devflow_cli/adapters/linear.py +79 -0
- devflow_cli/cli.py +21 -2
- devflow_cli/commands/check.py +4 -1
- devflow_cli/commands/docs_sync.py +33 -0
- devflow_cli/commands/extension.py +3 -1
- devflow_cli/commands/feature.py +8 -4
- devflow_cli/commands/hooks_cmd.py +193 -0
- devflow_cli/commands/init_cmd.py +5 -0
- devflow_cli/commands/linear.py +30 -0
- devflow_cli/commands/migrate_cmd.py +4 -0
- devflow_cli/commands/migrate_speckit.py +147 -22
- devflow_cli/commands/review.py +54 -0
- devflow_cli/commands/run.py +123 -0
- devflow_cli/commands/{regen.py → stale.py} +6 -6
- devflow_cli/commands/status.py +24 -29
- devflow_cli/commands/step.py +118 -0
- devflow_cli/commands/traceability.py +47 -0
- devflow_cli/commands/verify.py +78 -0
- devflow_cli/core/catalog.py +4 -0
- devflow_cli/core/docgen.py +93 -0
- devflow_cli/core/git.py +6 -0
- devflow_cli/core/hooks.py +148 -2
- devflow_cli/core/manifest.py +4 -0
- devflow_cli/core/orchestrator.py +170 -0
- devflow_cli/core/state.py +217 -7
- devflow_cli/core/state_store.py +45 -0
- devflow_cli/core/traceability.py +81 -0
- devflow_cli/core/validators.py +31 -5
- devflow_cli/core/verification.py +296 -0
- devflow_cli/schemas/state.schema.json +39 -0
- devflow_cli/utils/logging.py +62 -0
- devflow_cli/utils/paths.py +84 -10
- {devflow_cli-2.2.0.dist-info → devflow_cli-2.8.0.dist-info}/METADATA +15 -7
- devflow_cli-2.8.0.dist-info/RECORD +52 -0
- {devflow_cli-2.2.0.dist-info → devflow_cli-2.8.0.dist-info}/WHEEL +1 -1
- devflow_cli-2.2.0.dist-info/RECORD +0 -34
- {devflow_cli-2.2.0.dist-info → devflow_cli-2.8.0.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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()(
|
|
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__":
|
devflow_cli/commands/check.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
devflow_cli/commands/feature.py
CHANGED
|
@@ -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
|
|
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
|
|
102
|
-
total =
|
|
103
|
-
fast_count =
|
|
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)
|
devflow_cli/commands/init_cmd.py
CHANGED
|
@@ -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()
|