Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
290 changes: 290 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1775,6 +1775,296 @@ def version():
console.print()


@app.command()
def status():
"""Show current project status and SDD workflow progress."""
show_banner()

project_root = Path.cwd()

# Check if we're in a spec-kit project
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run [cyan]specify init[/cyan] first to create a project, or cd into an existing one.")
raise typer.Exit(1)

project_name = project_root.name

# --- Detect AI agent(s) by scanning for known agent folders ---
detected_agents = []
for agent_key, agent_config in AGENT_CONFIG.items():
if agent_key == "generic":
continue
folder = agent_config.get("folder")
if folder and (project_root / folder.rstrip("/")).is_dir():
commands_subdir = agent_config.get("commands_subdir", "commands")
commands_dir = project_root / folder.rstrip("/") / commands_subdir
has_commands = commands_dir.is_dir() and any(commands_dir.iterdir())
detected_agents.append({
"key": agent_key,
"name": agent_config["name"],
"folder": folder,
"has_commands": has_commands,
})

# --- Detect script type ---
script_type = None
if (specify_dir / "scripts" / "bash").is_dir():
script_type = "sh"
if (specify_dir / "scripts" / "powershell").is_dir():
script_type = "ps" if script_type is None else "sh + ps"

# --- Detect current feature ---
current_branch = None
has_git = False

# 1. Check SPECIFY_FEATURE env var
env_feature = os.environ.get("SPECIFY_FEATURE", "").strip()
if env_feature:
current_branch = env_feature

# 2. Try git branch
if not current_branch:
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5,
cwd=str(project_root),
)
if result.returncode == 0:
has_git = True
current_branch = result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
pass

# 3. Fallback: scan specs/ for highest-numbered directory
if not current_branch or not current_branch[0:3].isdigit():
specs_dir = project_root / "specs"
if specs_dir.is_dir():
highest_num = 0
latest_feature = None
for d in specs_dir.iterdir():
if d.is_dir() and len(d.name) >= 4 and d.name[:3].isdigit() and d.name[3] == "-":
num = int(d.name[:3])
if num > highest_num:
highest_num = num
latest_feature = d.name
if latest_feature and (not current_branch or current_branch in ("main", "master")):
current_branch = latest_feature

# --- Resolve feature directory (prefix-based matching like common.sh) ---
feature_dir = None
if current_branch and len(current_branch) >= 4 and current_branch[:3].isdigit() and current_branch[3] == "-":
prefix = current_branch[:3]
specs_dir = project_root / "specs"
if specs_dir.is_dir():
matches = [d for d in specs_dir.iterdir() if d.is_dir() and d.name.startswith(f"{prefix}-")]
if len(matches) == 1:
feature_dir = matches[0]
elif len(matches) > 1:
# Try exact match first
exact = specs_dir / current_branch
if exact.is_dir():
feature_dir = exact
else:
feature_dir = matches[0] # Use first match

# --- Build output ---
info_table = Table(show_header=False, box=None, padding=(0, 2))
info_table.add_column("Key", style="cyan", justify="right", min_width=16)
info_table.add_column("Value", style="white")

info_table.add_row("Project", f"[bold]{project_name}[/bold]")

if detected_agents:
for i, agent in enumerate(detected_agents):
label = "AI Agent" if i == 0 else ""
cmd_status = "[green]commands present[/green]" if agent["has_commands"] else "[yellow]no commands[/yellow]"
info_table.add_row(label, f"{agent['name']} ({agent['folder']}) {cmd_status}")
else:
info_table.add_row("AI Agent", "[yellow]none detected[/yellow]")

if script_type:
info_table.add_row("Script Type", script_type)

if has_git:
info_table.add_row("Git Branch", current_branch or "[dim]unknown[/dim]")
else:
if env_feature:
info_table.add_row("Feature", f"{env_feature} [dim](from SPECIFY_FEATURE)[/dim]")
else:
info_table.add_row("Git", "[yellow]not available[/yellow]")

if feature_dir:
info_table.add_row("Feature Dir", f"specs/{feature_dir.name}/")
elif current_branch and current_branch not in ("main", "master"):
info_table.add_row("Feature Dir", f"[yellow]not found for {current_branch}[/yellow]")

panel = Panel(
info_table,
title="[bold cyan]Specify Project Status[/bold cyan]",
border_style="cyan",
padding=(1, 2),
)
console.print(panel)

# --- SDD Artifacts & Task Progress ---
if feature_dir and feature_dir.is_dir():
console.print(f"[bold cyan] Current Feature:[/bold cyan] {feature_dir.name}")
console.print(f" {'─' * 40}")

# Define all SDD artifacts to check
artifacts = [
("spec.md", feature_dir / "spec.md"),
("plan.md", feature_dir / "plan.md"),
("tasks.md", feature_dir / "tasks.md"),
("research.md", feature_dir / "research.md"),
("data-model.md", feature_dir / "data-model.md"),
("quickstart.md", feature_dir / "quickstart.md"),
]
# Directory-based artifacts
contracts_dir = feature_dir / "contracts"
checklists_dir = feature_dir / "checklists"

# Parse task progress from tasks.md if it exists
tasks_file = feature_dir / "tasks.md"
tasks_completed = 0
tasks_total = 0
if tasks_file.is_file():
try:
tasks_content = tasks_file.read_text(encoding="utf-8")
import re
for line in tasks_content.splitlines():
stripped = line.strip()
if re.match(r"^-\s+\[([ xX])\]", stripped):
tasks_total += 1
if re.match(r"^-\s+\[[xX]\]", stripped):
tasks_completed += 1
except OSError:
pass

# Parse checklist progress if checklists/ exists
checklist_count = 0
checklists_all_pass = True
if checklists_dir.is_dir():
for cl_file in checklists_dir.iterdir():
if cl_file.is_file() and cl_file.suffix == ".md":
checklist_count += 1
try:
cl_content = cl_file.read_text(encoding="utf-8")
for line in cl_content.splitlines():
stripped = line.strip()
if re.match(r"^-\s+\[ \]", stripped):
checklists_all_pass = False
break
except OSError:
pass

# Display artifacts with status
console.print()
console.print(" [bold]Artifacts:[/bold]")
for artifact_name, artifact_path in artifacts:
if artifact_path.is_file():
icon = "[green]✓[/green]"
extra = ""
# Add task progress info for tasks.md
if artifact_name == "tasks.md" and tasks_total > 0:
pct = int(tasks_completed / tasks_total * 100)
if tasks_completed == tasks_total:
extra = f" [green]{tasks_completed}/{tasks_total} completed (100%)[/green]"
else:
extra = f" [yellow]{tasks_completed}/{tasks_total} completed ({pct}%)[/yellow]"
console.print(f" {icon} {artifact_name}{extra}")
else:
console.print(f" [dim]✗ {artifact_name}[/dim]")

# Contracts directory
has_contracts = contracts_dir.is_dir() and any(contracts_dir.iterdir())
if has_contracts:
contract_files = len([f for f in contracts_dir.iterdir() if f.is_file()])
console.print(f" [green]✓[/green] contracts/ [dim]{contract_files} file(s)[/dim]")
else:
console.print(f" [dim]✗ contracts/[/dim]")

# Checklists directory
if checklist_count > 0:
if checklists_all_pass:
console.print(f" [green]✓[/green] checklists/ [green]{checklist_count} checklist(s), all passing[/green]")
else:
console.print(f" [yellow]✓[/yellow] checklists/ [yellow]{checklist_count} checklist(s), some incomplete[/yellow]")
else:
console.print(f" [dim]✗ checklists/[/dim]")

# --- Workflow Phase Detection ---
has_spec = (feature_dir / "spec.md").is_file()
has_plan = (feature_dir / "plan.md").is_file()
has_tasks = (feature_dir / "tasks.md").is_file()
all_tasks_done = has_tasks and tasks_total > 0 and tasks_completed == tasks_total

if all_tasks_done:
phase_label = "[bold green]Complete[/bold green]"
phase_hint = "All tasks done. Review your implementation."
elif has_tasks:
phase_label = "[bold yellow]Implement[/bold yellow]"
phase_hint = "Ready for [cyan]/speckit.implement[/cyan]"
elif has_plan:
phase_label = "[bold yellow]Tasks[/bold yellow]"
phase_hint = "Ready for [cyan]/speckit.tasks[/cyan]"
elif has_spec:
phase_label = "[bold yellow]Plan[/bold yellow]"
phase_hint = "Ready for [cyan]/speckit.clarify[/cyan] or [cyan]/speckit.plan[/cyan]"
else:
phase_label = "[bold red]Not Started[/bold red]"
phase_hint = "Run [cyan]/speckit.specify[/cyan] to create a spec"

console.print(f" [bold]Phase:[/bold] {phase_label}")
console.print(f" [dim]{phase_hint}[/dim]")
console.print()

elif current_branch and current_branch not in ("main", "master"):
console.print(f"\n [yellow]No feature directory found for branch '{current_branch}'[/yellow]")
console.print(f" [dim]Run /speckit.specify to create a feature.[/dim]\n")
else:
specs_dir = project_root / "specs"
if specs_dir.is_dir() and any(specs_dir.iterdir()):
feature_count = len([d for d in specs_dir.iterdir() if d.is_dir()])
console.print(f"\n [dim]{feature_count} feature(s) in specs/ — switch to a feature branch to see details.[/dim]\n")
else:
console.print(f"\n [dim]No features created yet. Run /speckit.specify to start.[/dim]\n")

# --- Extensions Summary ---
extensions_dir = specify_dir / "extensions"
installed_count = 0
if extensions_dir.is_dir():
try:
from .extensions import ExtensionManager
manager = ExtensionManager(project_root)
installed_count = len(manager.list_installed())
except Exception:
pass

available_count = 0
try:
from .extensions import ExtensionCatalog
catalog = ExtensionCatalog(project_root)
available_count = len(catalog.search())
except Exception:
pass

ext_parts = []
ext_parts.append(f"{installed_count} installed")
if available_count > 0:
ext_parts.append(f"{available_count} available in catalog")
else:
ext_parts.append("catalog unavailable")

console.print(f" [bold]Extensions:[/bold] {', '.join(ext_parts)}")
if installed_count == 0 and available_count > 0:
console.print(f" [dim]Run [cyan]specify extension search[/cyan] to browse available extensions.[/dim]")
console.print()


# ===== Extension Commands =====

extension_app = typer.Typer(
Expand Down