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,137 @@
|
|
|
1
|
+
"""devflow extension — Gestion des extensions devflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from devflow_cli.core.catalog import (
|
|
10
|
+
load_all_catalogs,
|
|
11
|
+
find_extension,
|
|
12
|
+
is_installed,
|
|
13
|
+
Extension,
|
|
14
|
+
)
|
|
15
|
+
from devflow_cli.core.installer import install_extension, uninstall_extension
|
|
16
|
+
from devflow_cli.utils.console import console, ok, warn, fail, make_table
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Gestion des extensions devflow")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command("list")
|
|
22
|
+
def list_extensions() -> None:
|
|
23
|
+
"""Liste les extensions avec statut d'installation."""
|
|
24
|
+
catalogs = load_all_catalogs()
|
|
25
|
+
|
|
26
|
+
if not catalogs:
|
|
27
|
+
fail("Aucun catalogue trouve.")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
table = make_table("Statut", "Source", "Nom", "Fichiers", "Description", title="Extensions devflow")
|
|
31
|
+
|
|
32
|
+
for catalog in catalogs:
|
|
33
|
+
for ext in catalog.extensions:
|
|
34
|
+
status = "[green]installe[/green]" if is_installed(ext) else "[dim]non installe[/dim]"
|
|
35
|
+
source = f"[yellow]{ext.source}[/yellow]" if ext.source != "principal" else ext.source
|
|
36
|
+
table.add_row(
|
|
37
|
+
status,
|
|
38
|
+
source,
|
|
39
|
+
ext.name,
|
|
40
|
+
str(len(ext.includes)),
|
|
41
|
+
ext.description,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
console.print(table)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def search(term: str = typer.Argument(..., help="Terme de recherche")) -> None:
|
|
49
|
+
"""Recherche dans le catalogue (principal + communautaire)."""
|
|
50
|
+
catalogs = load_all_catalogs()
|
|
51
|
+
term_lower = term.lower()
|
|
52
|
+
|
|
53
|
+
console.print(f"Recherche : '{term}'")
|
|
54
|
+
console.print()
|
|
55
|
+
|
|
56
|
+
found = False
|
|
57
|
+
for catalog in catalogs:
|
|
58
|
+
for ext in catalog.extensions:
|
|
59
|
+
if term_lower in ext.name.lower() or term_lower in ext.description.lower():
|
|
60
|
+
source_label = f" [yellow][{ext.source}][/yellow]" if ext.source != "principal" else ""
|
|
61
|
+
console.print(f" {ext.name}{source_label} — {ext.description}")
|
|
62
|
+
found = True
|
|
63
|
+
|
|
64
|
+
if not found:
|
|
65
|
+
console.print(" Aucun resultat.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command()
|
|
69
|
+
def add(name: str = typer.Argument(..., help="Nom de l'extension a installer")) -> None:
|
|
70
|
+
"""Installe une extension."""
|
|
71
|
+
catalogs = load_all_catalogs()
|
|
72
|
+
ext = find_extension(name, catalogs)
|
|
73
|
+
|
|
74
|
+
if ext is None:
|
|
75
|
+
fail(f"Extension '{name}' non trouvee dans aucun catalogue.")
|
|
76
|
+
console.print("Utilisez 'devflow extension catalog' pour voir les extensions disponibles.")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
source_label = f" [{ext.source}]" if ext.source != "principal" else ""
|
|
80
|
+
console.print(f"Installation de '{name}'{source_label}...")
|
|
81
|
+
console.print()
|
|
82
|
+
|
|
83
|
+
result = install_extension(ext)
|
|
84
|
+
|
|
85
|
+
for inc in ext.includes:
|
|
86
|
+
filename = inc.file.split("/")[-1]
|
|
87
|
+
console.print(f" [green][ok][/green] {inc.target}/{filename}")
|
|
88
|
+
|
|
89
|
+
console.print()
|
|
90
|
+
console.print(f"Termine : {result.copied} copies, {result.skipped} ignores")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def remove(name: str = typer.Argument(..., help="Nom de l'extension a desinstaller")) -> None:
|
|
95
|
+
"""Desinstalle une extension."""
|
|
96
|
+
catalogs = load_all_catalogs()
|
|
97
|
+
ext = find_extension(name, catalogs)
|
|
98
|
+
|
|
99
|
+
if ext is None:
|
|
100
|
+
fail(f"Extension '{name}' non trouvee dans le catalogue.")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
console.print(f"Suppression de '{name}'...")
|
|
104
|
+
console.print()
|
|
105
|
+
|
|
106
|
+
removed = uninstall_extension(ext)
|
|
107
|
+
|
|
108
|
+
for inc in ext.includes:
|
|
109
|
+
filename = inc.file.split("/")[-1]
|
|
110
|
+
console.print(f" [green][ok][/green] supprime {inc.target}/{filename}")
|
|
111
|
+
|
|
112
|
+
console.print()
|
|
113
|
+
console.print(f"Termine : {removed} fichiers supprimes")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def catalog() -> None:
|
|
118
|
+
"""Affiche le catalogue complet."""
|
|
119
|
+
catalogs = load_all_catalogs()
|
|
120
|
+
|
|
121
|
+
if not catalogs:
|
|
122
|
+
fail("Aucun catalogue trouve.")
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
|
|
125
|
+
for cat in catalogs:
|
|
126
|
+
label = cat.source.capitalize()
|
|
127
|
+
console.rule(f"[bold]Catalogue devflow — {label}[/bold]")
|
|
128
|
+
console.print()
|
|
129
|
+
|
|
130
|
+
for ext in cat.extensions:
|
|
131
|
+
console.print(f"Extension : [bold]{ext.name}[/bold] v{ext.version}")
|
|
132
|
+
console.print(f" Type : {ext.type}")
|
|
133
|
+
console.print(f" Desc : {ext.description}")
|
|
134
|
+
console.print(f" Fichiers :")
|
|
135
|
+
for inc in ext.includes:
|
|
136
|
+
console.print(f" - {inc.type}: {inc.file}")
|
|
137
|
+
console.print()
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""devflow feature — Initialise une feature (branche + state.json)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from devflow_cli.core.git import branch_exists, create_branch, checkout_branch, GitNotFoundError
|
|
12
|
+
from devflow_cli.core.state import create_state
|
|
13
|
+
from devflow_cli.utils.console import console, ok, info
|
|
14
|
+
from devflow_cli.utils.paths import get_specs_root, next_feature_number
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _clean_short_name(name: str) -> str:
|
|
18
|
+
"""Nettoie un short-name pour usage comme nom de repertoire."""
|
|
19
|
+
cleaned = name.lower()
|
|
20
|
+
cleaned = re.sub(r"[^a-z0-9]", "-", cleaned)
|
|
21
|
+
cleaned = re.sub(r"-+", "-", cleaned)
|
|
22
|
+
return cleaned.strip("-")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_issue_id(issue_id: str) -> None:
|
|
26
|
+
"""Valide que l'issue_id contient au moins un caractere alphanumerique."""
|
|
27
|
+
if not issue_id or not issue_id.strip() or not re.search(r"[a-zA-Z0-9]", issue_id):
|
|
28
|
+
console.print("[red]Erreur : identifiant de feature invalide (vide ou sans caractère alphanumérique).[/red]")
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ensure_git() -> None:
|
|
33
|
+
"""Verifie que git est disponible, sinon affiche erreur et quitte."""
|
|
34
|
+
try:
|
|
35
|
+
from devflow_cli.core.git import ensure_git_available
|
|
36
|
+
ensure_git_available()
|
|
37
|
+
except GitNotFoundError as e:
|
|
38
|
+
console.print(f"[red]Erreur : {e}[/red]")
|
|
39
|
+
raise typer.Exit(1)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _init_feature_files(feature_dir: Path, issue_id: str) -> None:
|
|
43
|
+
"""Cree state.json et review-log.md dans le dossier feature."""
|
|
44
|
+
state_file = feature_dir / "state.json"
|
|
45
|
+
if state_file.is_file():
|
|
46
|
+
console.print(" state.json existe deja, pas d'ecrasement.")
|
|
47
|
+
else:
|
|
48
|
+
create_state(state_file, issue_id)
|
|
49
|
+
ok("state.json initialise")
|
|
50
|
+
|
|
51
|
+
review_log = feature_dir / "review-log.md"
|
|
52
|
+
if review_log.is_file():
|
|
53
|
+
console.print(" review-log.md existe deja.")
|
|
54
|
+
else:
|
|
55
|
+
review_log.write_text(
|
|
56
|
+
f"# Review Log — {issue_id}\n\n"
|
|
57
|
+
f"> Journal des reviews de la feature {issue_id}\n\n---\n",
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
ok("review-log.md cree")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def feature(
|
|
64
|
+
issue_id: str = typer.Argument(..., help="Identifiant de l'issue (ex: KS-123)"),
|
|
65
|
+
short_name: Optional[str] = typer.Option(None, "--short-name", help="Nom court pour le repertoire (ex: user-auth)"),
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Initialise une feature : branche git + state.json + review-log.md."""
|
|
68
|
+
_validate_issue_id(issue_id)
|
|
69
|
+
_ensure_git()
|
|
70
|
+
|
|
71
|
+
name = _clean_short_name(short_name) if short_name else _clean_short_name(issue_id)
|
|
72
|
+
num = next_feature_number()
|
|
73
|
+
feature_name = f"{num:03d}-{name}"
|
|
74
|
+
branch = feature_name
|
|
75
|
+
feature_dir = get_specs_root() / feature_name
|
|
76
|
+
|
|
77
|
+
# Branche
|
|
78
|
+
if branch_exists(branch):
|
|
79
|
+
info(f"La branche {branch} existe deja, switch dessus.")
|
|
80
|
+
checkout_branch(branch)
|
|
81
|
+
else:
|
|
82
|
+
info(f"Creation de la branche {branch}...")
|
|
83
|
+
if not create_branch(branch):
|
|
84
|
+
console.print(f"[red]Erreur lors de la creation de la branche {branch}[/red]")
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
|
|
87
|
+
# Dossier feature
|
|
88
|
+
if feature_dir.is_dir():
|
|
89
|
+
console.print(f" Le dossier {feature_dir} existe deja.")
|
|
90
|
+
else:
|
|
91
|
+
feature_dir.mkdir(parents=True)
|
|
92
|
+
ok(f"Dossier {feature_dir} cree")
|
|
93
|
+
|
|
94
|
+
_init_feature_files(feature_dir, issue_id)
|
|
95
|
+
|
|
96
|
+
console.print()
|
|
97
|
+
console.print(f"Feature [bold]{issue_id}[/bold] initialisee :")
|
|
98
|
+
console.print(f" Branche : {branch}")
|
|
99
|
+
console.print(f" Dossier : {feature_dir}/")
|
|
100
|
+
console.print(f" Etat : spec (etape initiale)")
|
|
101
|
+
console.print()
|
|
102
|
+
console.print(f"Prochaine etape : /devflow {issue_id}")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""devflow init — Installe devflow dans ~/.claude/ et initialise le projet."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from devflow_cli import SUPPORTED_AGENTS
|
|
12
|
+
from devflow_cli.core.installer import install_devflow
|
|
13
|
+
from devflow_cli.core.git import is_git_repo
|
|
14
|
+
from devflow_cli.utils.console import console, ok, fail, warn, header
|
|
15
|
+
from devflow_cli.core.manifest import compute_source_manifest, save_manifest
|
|
16
|
+
from devflow_cli.utils.paths import get_devflow_root, get_claude_dir
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def init(
|
|
20
|
+
path: Optional[Path] = typer.Argument(None, help="Projet cible"),
|
|
21
|
+
force: bool = typer.Option(False, "--force", help="Ecraser les fichiers existants"),
|
|
22
|
+
with_constitution: bool = typer.Option(False, "--with-constitution", help="Creer une constitution starter"),
|
|
23
|
+
here: bool = typer.Option(False, "--here", help="Initialiser dans le repertoire courant"),
|
|
24
|
+
script: Optional[Literal["bash"]] = typer.Option(None, "--script", help="Type de scripts (bash)"),
|
|
25
|
+
ai: str = typer.Option("claude-code", "--ai", help="Agent IA cible"),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Installe devflow (commandes, agents, templates, scripts)."""
|
|
28
|
+
# Validation --here vs path
|
|
29
|
+
if here and path is not None:
|
|
30
|
+
fail("Les options --here et un chemin explicite sont mutuellement exclusifs.")
|
|
31
|
+
raise typer.Exit(1)
|
|
32
|
+
|
|
33
|
+
if here or path is None:
|
|
34
|
+
path = Path(".")
|
|
35
|
+
|
|
36
|
+
target = path.resolve()
|
|
37
|
+
|
|
38
|
+
if not is_git_repo(target):
|
|
39
|
+
fail(f"{target} n'est pas un depot git.")
|
|
40
|
+
raise typer.Exit(1)
|
|
41
|
+
|
|
42
|
+
# Validation --ai
|
|
43
|
+
if ai not in SUPPORTED_AGENTS:
|
|
44
|
+
warn(f"Agent '{ai}' non supporte. Agents reconnus : {', '.join(SUPPORTED_AGENTS)}. Installation avec defaults claude-code.")
|
|
45
|
+
|
|
46
|
+
header(f"devflow init — {target}")
|
|
47
|
+
console.print()
|
|
48
|
+
|
|
49
|
+
results = install_devflow(force=force, script_type=script)
|
|
50
|
+
|
|
51
|
+
for category, result in results.items():
|
|
52
|
+
label = category.ljust(10)
|
|
53
|
+
console.print(f" {label}: {result.copied} copies, {result.skipped} ignores")
|
|
54
|
+
|
|
55
|
+
# Resume des options
|
|
56
|
+
if script:
|
|
57
|
+
console.print(f" Scripts : {script} uniquement")
|
|
58
|
+
if ai != "claude-code":
|
|
59
|
+
console.print(f" Agent : {ai} (defaults claude-code appliques)")
|
|
60
|
+
|
|
61
|
+
# Structure projet
|
|
62
|
+
specs_dir = target / ".specify" / "specs"
|
|
63
|
+
if not specs_dir.exists():
|
|
64
|
+
specs_dir.mkdir(parents=True)
|
|
65
|
+
ok(".specify/specs/ cree")
|
|
66
|
+
else:
|
|
67
|
+
console.print(" Structure : .specify/specs/ existe deja")
|
|
68
|
+
|
|
69
|
+
# Constitution optionnelle
|
|
70
|
+
if with_constitution:
|
|
71
|
+
const_dest = target / ".specify" / "memory" / "constitution.md"
|
|
72
|
+
const_src = get_devflow_root() / "templates" / "constitution-template.md"
|
|
73
|
+
if force or not const_dest.exists():
|
|
74
|
+
if const_src.is_file():
|
|
75
|
+
const_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
shutil.copy2(const_src, const_dest)
|
|
77
|
+
ok("Constitution creee dans .specify/memory/constitution.md")
|
|
78
|
+
else:
|
|
79
|
+
fail("Template constitution non trouve")
|
|
80
|
+
else:
|
|
81
|
+
console.print(" Constitution : .specify/memory/constitution.md existe deja (--force pour ecraser)")
|
|
82
|
+
|
|
83
|
+
# Manifeste d'installation
|
|
84
|
+
try:
|
|
85
|
+
devflow_root = get_devflow_root()
|
|
86
|
+
manifest_data = compute_source_manifest(devflow_root)
|
|
87
|
+
manifest_data["installed_at"] = manifest_data.pop("computed_at")
|
|
88
|
+
save_manifest(get_claude_dir(), manifest_data)
|
|
89
|
+
ok("Manifeste d'installation cree")
|
|
90
|
+
except Exception:
|
|
91
|
+
warn("Impossible de creer le manifeste d'installation")
|
|
92
|
+
|
|
93
|
+
console.print()
|
|
94
|
+
console.print("[green]Installation terminee.[/green]")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""devflow migrate — Migration interactive des features legacy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from devflow_cli.core.state import read_state
|
|
11
|
+
from devflow_cli.utils.console import console, ok, info, warn, fail, header
|
|
12
|
+
from devflow_cli.utils.paths import get_specs_root, next_feature_number
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _clean_short_name(name: str) -> str:
|
|
16
|
+
"""Nettoie un short-name pour usage comme nom de repertoire."""
|
|
17
|
+
import re
|
|
18
|
+
cleaned = name.lower()
|
|
19
|
+
cleaned = re.sub(r"[^a-z0-9]", "-", cleaned)
|
|
20
|
+
cleaned = re.sub(r"-+", "-", cleaned)
|
|
21
|
+
return cleaned.strip("-")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def migrate(
|
|
25
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Affiche le mapping prevu sans deplacer"),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Migration interactive des features de docs/features/ vers .specify/specs/."""
|
|
28
|
+
legacy_dir = Path("docs/features")
|
|
29
|
+
|
|
30
|
+
if not legacy_dir.is_dir():
|
|
31
|
+
info("Rien a migrer : docs/features/ n'existe pas.")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
# Scanner les features legacy
|
|
35
|
+
features = []
|
|
36
|
+
for d in sorted(legacy_dir.iterdir()):
|
|
37
|
+
state_file = d / "state.json"
|
|
38
|
+
if d.is_dir() and state_file.is_file():
|
|
39
|
+
state = read_state(state_file)
|
|
40
|
+
features.append((d, state))
|
|
41
|
+
|
|
42
|
+
if not features:
|
|
43
|
+
info("Rien a migrer : aucune feature avec state.json dans docs/features/.")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
header(f"devflow migrate — {len(features)} feature(s) a migrer")
|
|
47
|
+
console.print()
|
|
48
|
+
|
|
49
|
+
specs_root = get_specs_root()
|
|
50
|
+
specs_root.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
|
|
52
|
+
migrated = 0
|
|
53
|
+
skipped = 0
|
|
54
|
+
conflicts = 0
|
|
55
|
+
|
|
56
|
+
for legacy_path, state in features:
|
|
57
|
+
issue_id = state.issueId
|
|
58
|
+
step = state.currentStep
|
|
59
|
+
artifact_count = sum(1 for f in legacy_path.iterdir() if f.is_file())
|
|
60
|
+
|
|
61
|
+
console.print(f" [bold]{issue_id}[/bold] — etape: {step}, {artifact_count} artefacts")
|
|
62
|
+
|
|
63
|
+
if dry_run:
|
|
64
|
+
console.print(f" [dim](dry-run) Serait migre vers .specify/specs/[/dim]")
|
|
65
|
+
migrated += 1
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
# Demander un short-name
|
|
69
|
+
try:
|
|
70
|
+
default_name = _clean_short_name(issue_id)
|
|
71
|
+
short_name = typer.prompt(
|
|
72
|
+
f" Short-name pour {issue_id}",
|
|
73
|
+
default=default_name,
|
|
74
|
+
)
|
|
75
|
+
except (KeyboardInterrupt, typer.Abort):
|
|
76
|
+
console.print(f" [yellow]Ignore (annule)[/yellow]")
|
|
77
|
+
skipped += 1
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
clean_name = _clean_short_name(short_name)
|
|
81
|
+
num = next_feature_number()
|
|
82
|
+
target_name = f"{num:03d}-{clean_name}"
|
|
83
|
+
target_path = specs_root / target_name
|
|
84
|
+
|
|
85
|
+
# Verifier les conflits
|
|
86
|
+
if target_path.exists():
|
|
87
|
+
warn(f" Conflit : {target_path} existe deja. Feature ignoree.")
|
|
88
|
+
conflicts += 1
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Deplacer
|
|
92
|
+
shutil.move(str(legacy_path), str(target_path))
|
|
93
|
+
ok(f" {issue_id} -> .specify/specs/{target_name}/")
|
|
94
|
+
migrated += 1
|
|
95
|
+
|
|
96
|
+
# Recapitulatif
|
|
97
|
+
console.print()
|
|
98
|
+
console.rule("Recapitulatif")
|
|
99
|
+
console.print(f" Migrees : {migrated}")
|
|
100
|
+
if skipped:
|
|
101
|
+
console.print(f" Ignorees : {skipped}")
|
|
102
|
+
if conflicts:
|
|
103
|
+
console.print(f" Conflits : {conflicts}")
|
|
104
|
+
if dry_run:
|
|
105
|
+
console.print()
|
|
106
|
+
console.print("[dim]Mode dry-run : aucun fichier deplace.[/dim]")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""devflow regen — Detection et regeneration en cascade des artefacts desuets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from devflow_cli.core.git import current_branch
|
|
12
|
+
from devflow_cli.core.staleness import get_staleness, get_stale_artifacts, REGEN_COMMANDS
|
|
13
|
+
from devflow_cli.utils.console import console, ok, info, header
|
|
14
|
+
from devflow_cli.utils.paths import resolve_feature_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def regen(
|
|
18
|
+
only: Optional[str] = typer.Option(None, "--only", help="Regenerer uniquement cet artefact (plan, tasks)"),
|
|
19
|
+
json_output: bool = typer.Option(False, "--json", help="Sortie JSON pour integration Claude Code"),
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Detecte et propose la regeneration des artefacts desuets."""
|
|
22
|
+
branch = current_branch()
|
|
23
|
+
feature_dir = resolve_feature_dir(branch) if branch else None
|
|
24
|
+
if feature_dir is None:
|
|
25
|
+
if json_output:
|
|
26
|
+
print(json.dumps({"error": "Aucune feature trouvee"}))
|
|
27
|
+
else:
|
|
28
|
+
console.print("Aucune feature trouvee dans specs/ ou docs/features/.")
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
|
|
31
|
+
constitution_path = Path(".specify/memory/constitution.md")
|
|
32
|
+
const_path = constitution_path if constitution_path.is_file() else None
|
|
33
|
+
|
|
34
|
+
staleness = get_staleness(feature_dir, const_path)
|
|
35
|
+
stale_list = get_stale_artifacts(feature_dir, const_path)
|
|
36
|
+
|
|
37
|
+
# Filtrage --only
|
|
38
|
+
if only:
|
|
39
|
+
target = f"{only}.md" if not only.endswith(".md") else only
|
|
40
|
+
stale_list = [s for s in stale_list if s.artifact == target]
|
|
41
|
+
|
|
42
|
+
if json_output:
|
|
43
|
+
output = {
|
|
44
|
+
"feature_dir": str(feature_dir),
|
|
45
|
+
"stale": [
|
|
46
|
+
{
|
|
47
|
+
"artifact": s.artifact,
|
|
48
|
+
"reason": s.reason,
|
|
49
|
+
"command": REGEN_COMMANDS.get(s.artifact, ""),
|
|
50
|
+
}
|
|
51
|
+
for s in stale_list
|
|
52
|
+
],
|
|
53
|
+
"fresh": [
|
|
54
|
+
name for name, info in staleness.items()
|
|
55
|
+
if info.state == "fresh"
|
|
56
|
+
],
|
|
57
|
+
"missing": [
|
|
58
|
+
name for name, info in staleness.items()
|
|
59
|
+
if info.state == "missing"
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
print(json.dumps(output, indent=2))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
header(f"devflow regen — {feature_dir.name}")
|
|
66
|
+
console.print()
|
|
67
|
+
|
|
68
|
+
if not stale_list:
|
|
69
|
+
ok("Tout est synchronise. Aucune regeneration necessaire.")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
console.print("Artefacts desuets detectes :")
|
|
73
|
+
for s in stale_list:
|
|
74
|
+
console.print(f" [yellow]{s.artifact}[/yellow] — {s.reason}")
|
|
75
|
+
|
|
76
|
+
console.print()
|
|
77
|
+
console.print("Commandes a executer :")
|
|
78
|
+
for i, s in enumerate(stale_list, 1):
|
|
79
|
+
cmd = REGEN_COMMANDS.get(s.artifact, "N/A")
|
|
80
|
+
console.print(f" {i}. {cmd}")
|
|
81
|
+
|
|
82
|
+
console.print()
|
|
83
|
+
if typer.confirm("Regenerer ?", default=False):
|
|
84
|
+
console.print()
|
|
85
|
+
info("Lancez les commandes suivantes dans l'ordre :")
|
|
86
|
+
for s in stale_list:
|
|
87
|
+
cmd = REGEN_COMMANDS.get(s.artifact)
|
|
88
|
+
if cmd:
|
|
89
|
+
console.print(f" → {cmd}")
|
|
90
|
+
console.print()
|
|
91
|
+
info("Ou utilisez /devflow.regen pour execution automatique.")
|
|
92
|
+
else:
|
|
93
|
+
console.print("Annule.")
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""devflow status — Affiche le statut des features."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from devflow_cli import STEPS, ARTIFACTS
|
|
11
|
+
from devflow_cli.core.state import read_state
|
|
12
|
+
from devflow_cli.core.staleness import get_staleness
|
|
13
|
+
from devflow_cli.utils.console import console, make_table
|
|
14
|
+
from devflow_cli.utils.paths import get_specs_root, resolve_feature_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def status(
|
|
18
|
+
issue_id: Optional[str] = typer.Argument(None, help="ID de la feature (optionnel)"),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Lecture rapide du statut d'une feature ou liste toutes les features."""
|
|
21
|
+
if issue_id is None:
|
|
22
|
+
_list_features()
|
|
23
|
+
else:
|
|
24
|
+
_show_feature(issue_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _list_features() -> None:
|
|
28
|
+
"""Liste toutes les features en scannant trois repertoires."""
|
|
29
|
+
scan_dirs = [
|
|
30
|
+
(Path(".specify/specs"), False),
|
|
31
|
+
(Path("specs"), True),
|
|
32
|
+
(Path("docs/features"), True),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
table = make_table("ID", "Etape", "Completees", "Location", title="Features")
|
|
36
|
+
|
|
37
|
+
found = False
|
|
38
|
+
has_legacy = False
|
|
39
|
+
|
|
40
|
+
for scan_root, is_legacy in scan_dirs:
|
|
41
|
+
if not scan_root.is_dir():
|
|
42
|
+
continue
|
|
43
|
+
for d in sorted(scan_root.iterdir()):
|
|
44
|
+
state_file = d / "state.json"
|
|
45
|
+
if not state_file.is_file():
|
|
46
|
+
continue
|
|
47
|
+
found = True
|
|
48
|
+
state = read_state(state_file)
|
|
49
|
+
location = "[yellow][legacy][/yellow]" if is_legacy else ""
|
|
50
|
+
if is_legacy:
|
|
51
|
+
has_legacy = True
|
|
52
|
+
table.add_row(
|
|
53
|
+
state.issueId,
|
|
54
|
+
state.currentStep,
|
|
55
|
+
f"{state.completed_count}/{state.total_steps}",
|
|
56
|
+
location,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if found:
|
|
60
|
+
console.print(table)
|
|
61
|
+
if has_legacy:
|
|
62
|
+
console.print(
|
|
63
|
+
"\n[yellow]Astuce: utilisez 'devflow migrate' pour deplacer "
|
|
64
|
+
"les features legacy vers .specify/specs/[/yellow]"
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
console.print("Aucune feature initialisee.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _show_feature(issue_id: str) -> None:
|
|
71
|
+
"""Affiche le detail d'une feature."""
|
|
72
|
+
feature_dir = resolve_feature_dir(issue_id)
|
|
73
|
+
|
|
74
|
+
if feature_dir is None:
|
|
75
|
+
console.print(f"Feature {issue_id} non initialisee.")
|
|
76
|
+
console.print(f"Lancer : devflow feature {issue_id}")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
state_file = feature_dir / "state.json"
|
|
80
|
+
|
|
81
|
+
if not state_file.is_file():
|
|
82
|
+
console.print(f"Feature {issue_id} non initialisee.")
|
|
83
|
+
console.print(f"Lancer : devflow feature {issue_id}")
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
|
|
86
|
+
state = read_state(state_file)
|
|
87
|
+
|
|
88
|
+
console.rule(f"[bold]devflow — {issue_id}[/bold]")
|
|
89
|
+
console.print()
|
|
90
|
+
console.print(f"Etape courante : [bold]{state.currentStep}[/bold]")
|
|
91
|
+
console.print(f"Cree le : {state.createdAt}")
|
|
92
|
+
console.print(f"Mis a jour : {state.updatedAt}")
|
|
93
|
+
console.print()
|
|
94
|
+
|
|
95
|
+
# Staleness detection
|
|
96
|
+
constitution_path = Path(".specify/memory/constitution.md")
|
|
97
|
+
staleness = get_staleness(feature_dir, constitution_path if constitution_path.is_file() else None)
|
|
98
|
+
|
|
99
|
+
console.print("Artefacts :")
|
|
100
|
+
for artifact in ARTIFACTS:
|
|
101
|
+
path = feature_dir / artifact
|
|
102
|
+
stale_info = staleness.get(artifact)
|
|
103
|
+
|
|
104
|
+
if artifact.endswith("/"):
|
|
105
|
+
# Directory artifact (e.g. contracts/)
|
|
106
|
+
if path.is_dir() and any(path.iterdir()):
|
|
107
|
+
console.print(f" [green][OK][/green] {artifact}")
|
|
108
|
+
elif (feature_dir / artifact.rstrip("/")).with_suffix(".md").is_file():
|
|
109
|
+
console.print(f" [green][OK][/green] {artifact.rstrip('/')}.md")
|
|
110
|
+
else:
|
|
111
|
+
console.print(f" [dim][ ][/dim] {artifact}")
|
|
112
|
+
elif path.is_file():
|
|
113
|
+
if stale_info and stale_info.state == "stale":
|
|
114
|
+
console.print(f" [yellow][STALE][/yellow] {artifact} — {stale_info.reason}")
|
|
115
|
+
else:
|
|
116
|
+
console.print(f" [green][OK][/green] {artifact}")
|
|
117
|
+
else:
|
|
118
|
+
console.print(f" [dim][ ][/dim] {artifact}")
|