Skip to content
Merged
Show file tree
Hide file tree
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
50 changes: 50 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Test & Lint Python

permissions:
contents: read

on:
push:
branches: ["main"]
pull_request:

jobs:
ruff:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v6

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"

- name: Run ruff check
run: uvx ruff check src/

pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v6

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --extra test

- name: Run tests
run: uv run pytest
54 changes: 29 additions & 25 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
except ValueError as je:
raise RuntimeError(f"Failed to parse release JSON: {je}\nRaw (truncated 400): {response.text[:400]}")
except Exception as e:
console.print(f"[red]Error fetching release information[/red]")
console.print("[red]Error fetching release information[/red]")
console.print(Panel(str(e), title="Fetch Error", border_style="red"))
raise typer.Exit(1)

Expand Down Expand Up @@ -700,7 +700,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri

zip_path = download_dir / filename
if verbose:
console.print(f"[cyan]Downloading template...[/cyan]")
console.print("[cyan]Downloading template...[/cyan]")

try:
with client.stream(
Expand Down Expand Up @@ -739,7 +739,7 @@ def download_template_from_github(ai_assistant: str, download_dir: Path, *, scri
for chunk in response.iter_bytes(chunk_size=8192):
f.write(chunk)
except Exception as e:
console.print(f"[red]Error downloading template[/red]")
console.print("[red]Error downloading template[/red]")
detail = str(e)
if zip_path.exists():
zip_path.unlink()
Expand Down Expand Up @@ -823,7 +823,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
tracker.add("flatten", "Flatten nested directory")
tracker.complete("flatten")
elif verbose:
console.print(f"[cyan]Found nested directory structure[/cyan]")
console.print("[cyan]Found nested directory structure[/cyan]")

for item in source_dir.iterdir():
dest_path = project_path / item.name
Expand All @@ -848,7 +848,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
console.print(f"[yellow]Overwriting file:[/yellow] {item.name}")
shutil.copy2(item, dest_path)
if verbose and not tracker:
console.print(f"[cyan]Template files merged into current directory[/cyan]")
console.print("[cyan]Template files merged into current directory[/cyan]")
else:
zip_ref.extractall(project_path)

Expand All @@ -874,7 +874,7 @@ def download_and_extract_template(project_path: Path, ai_assistant: str, script_
tracker.add("flatten", "Flatten nested directory")
tracker.complete("flatten")
elif verbose:
console.print(f"[cyan]Flattened nested directory structure[/cyan]")
console.print("[cyan]Flattened nested directory structure[/cyan]")

except Exception as e:
if tracker:
Expand Down Expand Up @@ -924,13 +924,17 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None =
continue
except Exception:
continue
st = script.stat(); mode = st.st_mode
st = script.stat()
mode = st.st_mode
if mode & 0o111:
continue
new_mode = mode
if mode & 0o400: new_mode |= 0o100
if mode & 0o040: new_mode |= 0o010
if mode & 0o004: new_mode |= 0o001
if mode & 0o400:
new_mode |= 0o100
if mode & 0o040:
new_mode |= 0o010
if mode & 0o004:
new_mode |= 0o001
if not (new_mode & 0o100):
new_mode |= 0o100
os.chmod(script, new_mode)
Expand Down Expand Up @@ -976,7 +980,7 @@ def ensure_constitution_from_template(project_path: Path, tracker: StepTracker |
tracker.add("constitution", "Constitution setup")
tracker.complete("constitution", "copied from template")
else:
console.print(f"[cyan]Initialized constitution from template[/cyan]")
console.print("[cyan]Initialized constitution from template[/cyan]")
except Exception as e:
if tracker:
tracker.add("constitution", "Constitution setup")
Expand Down Expand Up @@ -1510,9 +1514,9 @@ def init(
enhancement_lines = [
"Optional commands that you can use for your specs [bright_black](improve quality & confidence)[/bright_black]",
"",
f"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
f"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
f"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
"○ [cyan]/speckit.clarify[/] [bright_black](optional)[/bright_black] - Ask structured questions to de-risk ambiguous areas before planning (run before [cyan]/speckit.plan[/] if used)",
"○ [cyan]/speckit.analyze[/] [bright_black](optional)[/bright_black] - Cross-artifact consistency & alignment report (after [cyan]/speckit.tasks[/], before [cyan]/speckit.implement[/])",
"○ [cyan]/speckit.checklist[/] [bright_black](optional)[/bright_black] - Generate quality checklists to validate requirements completeness, clarity, and consistency (after [cyan]/speckit.plan[/])"
]
enhancements_panel = Panel("\n".join(enhancement_lines), title="Enhancement Commands", border_style="cyan", padding=(1,2))
console.print()
Expand Down Expand Up @@ -1545,10 +1549,10 @@ def check():

# Check VS Code variants (not in agent config)
tracker.add("code", "Visual Studio Code")
code_ok = check_tool("code", tracker=tracker)
check_tool("code", tracker=tracker)

tracker.add("code-insiders", "Visual Studio Code Insiders")
code_insiders_ok = check_tool("code-insiders", tracker=tracker)
check_tool("code-insiders", tracker=tracker)

console.print(tracker.render())

Expand Down Expand Up @@ -1814,14 +1818,14 @@ def extension_add(
if zip_path.exists():
zip_path.unlink()

console.print(f"\n[green]✓[/green] Extension installed successfully!")
console.print("\n[green]✓[/green] Extension installed successfully!")
console.print(f"\n[bold]{manifest.name}[/bold] (v{manifest.version})")
console.print(f" {manifest.description}")
console.print(f"\n[bold cyan]Provided commands:[/bold cyan]")
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
console.print(f" • {cmd['name']} - {cmd.get('description', '')}")

console.print(f"\n[yellow]⚠[/yellow] Configuration may be required")
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
console.print(f" Check: .specify/extensions/{manifest.id}/")

except ValidationError as e:
Expand Down Expand Up @@ -1871,11 +1875,11 @@ def extension_remove(

# Confirm removal
if not force:
console.print(f"\n[yellow]⚠ This will remove:[/yellow]")
console.print("\n[yellow]⚠ This will remove:[/yellow]")
console.print(f" • {cmd_count} commands from AI agent")
console.print(f" • Extension directory: .specify/extensions/{extension}/")
if not keep_config:
console.print(f" • Config files (will be backed up)")
console.print(" • Config files (will be backed up)")
console.print()

confirm = typer.confirm("Continue?")
Expand All @@ -1894,7 +1898,7 @@ def extension_remove(
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{extension}/")
console.print(f"\nTo reinstall: specify extension add {extension}")
else:
console.print(f"[red]Error:[/red] Failed to remove extension")
console.print("[red]Error:[/red] Failed to remove extension")
raise typer.Exit(1)


Expand Down Expand Up @@ -2169,8 +2173,8 @@ def extension_update(
# TODO: Implement download and reinstall from URL
# For now, just show message
console.print(
f"[yellow]Note:[/yellow] Automatic update not yet implemented. "
f"Please update manually:"
"[yellow]Note:[/yellow] Automatic update not yet implemented. "
"Please update manually:"
)
console.print(f" specify extension remove {ext_id} --keep-config")
console.print(f" specify extension add {ext_id}")
Expand Down Expand Up @@ -2270,7 +2274,7 @@ def extension_disable(
hook_executor.save_project_config(config)

console.print(f"[green]✓[/green] Extension '{extension}' disabled")
console.print(f"\nCommands will no longer be available. Hooks will not execute.")
console.print("\nCommands will no longer be available. Hooks will not execute.")
console.print(f"To re-enable: specify extension enable {extension}")


Expand Down
6 changes: 4 additions & 2 deletions tests/test_ai_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- CLI validation: --ai-skills requires --ai
"""

import re
import pytest
import tempfile
import shutil
Expand Down Expand Up @@ -626,5 +627,6 @@ def test_ai_skills_flag_appears_in_help(self):
runner = CliRunner()
result = runner.invoke(app, ["init", "--help"])

assert "--ai-skills" in result.output
assert "agent skills" in result.output.lower()
plain = re.sub(r'\x1b\[[0-9;]*m', '', result.output)
assert "--ai-skills" in plain
assert "agent skills" in plain.lower()
Loading