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
2 changes: 2 additions & 0 deletions src/specify_cli/integrations/amp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Amp CLI integration."""

from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any

from ..base import MarkdownIntegration
Expand Down Expand Up @@ -30,6 +31,7 @@ def build_exec_args(
output_json: bool = True,
integration_args: Sequence[str] | None = None,
integration_options: Mapping[str, Any] | None = None,
project_root: Path | None = None,
) -> list[str] | None:
self.validate_runtime_config(integration_args, integration_options)
args = [self._resolve_executable()]
Expand Down
1,136 changes: 51 additions & 1,085 deletions src/specify_cli/presets/_commands.py

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions src/specify_cli/presets/catalog/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Registration for the nested ``specify preset catalog`` command group.

Command handlers live in ``command_*.py`` modules.
"""

from __future__ import annotations

import typer

catalog_app = typer.Typer(
name="catalog",
help="Manage preset catalogs",
add_completion=False,
)


def register(app: typer.Typer) -> None:
"""Attach the catalog command group to the preset Typer app."""
# isort: off
from . import command_list # noqa: F401 — registers handler via decorator
from . import command_add # noqa: F401 — registers handler via decorator
from . import command_remove # noqa: F401 — registers handler via decorator
# isort: on

app.add_typer(catalog_app, name="catalog")
114 changes: 114 additions & 0 deletions src/specify_cli/presets/catalog/command_add.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Implementation of the ``specify preset catalog add`` command."""

from __future__ import annotations

import typer
import yaml
from rich.markup import escape as _escape_markup

from ..._console import console
from . import catalog_app


@catalog_app.command("add")
def preset_catalog_add(
url: str = typer.Argument(help="Catalog URL (must use HTTPS)"),
name: str = typer.Option(..., "--name", help="Catalog name"),
priority: int = typer.Option(
10, "--priority", help="Priority (lower = higher priority)"
),
install_allowed: bool = typer.Option(
False,
"--install-allowed/--no-install-allowed",
help="Allow presets from this catalog to be installed",
),
description: str = typer.Option(
"", "--description", help="Description of the catalog"
),
):
"""Add a catalog to .specify/preset-catalogs.yml."""
from ... import _display_project_path, _require_specify_project
from .. import PresetCatalog, PresetValidationError

project_root = _require_specify_project()
specify_dir = project_root / ".specify"

# Validate URL
tmp_catalog = PresetCatalog(project_root)
try:
tmp_catalog._validate_catalog_url(url)
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)

config_path = specify_dir / "preset-catalogs.yml"

# Load existing config
if config_path.exists():
try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001 - preserve CLI error boundary
config_label = _display_project_path(project_root, config_path)
console.print(
f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}"
)
raise typer.Exit(1)
if config is None:
config = {}
elif not isinstance(config, dict):
console.print(
"[red]Error:[/red] Invalid catalog config: expected a mapping."
)
raise typer.Exit(1)
else:
config = {}

catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print(
"[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list."
)
raise typer.Exit(1)

# Only rendering is escaped — the raw values are what get persisted and
# compared below, so a name containing markup still round-trips exactly.
safe_name = _escape_markup(str(name))
safe_url = _escape_markup(str(url))

# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
console.print(
f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists."
)
console.print(
"Use 'specify preset catalog remove' first, or choose a different name."
)
raise typer.Exit(1)

catalogs.append(
{
"name": name,
"url": url,
"priority": priority,
"install_allowed": install_allowed,
"description": description,
}
)

config["catalogs"] = catalogs
config_path.write_text(
yaml.safe_dump(
config, default_flow_style=False, sort_keys=False, allow_unicode=True
),
encoding="utf-8",
)

install_label = "install allowed" if install_allowed else "discovery only"
console.print(
f"\n[green]✓[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})"
)
console.print(f" URL: {safe_url}")
console.print(f" Priority: {priority}")
config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
console.print(f"\nConfig saved to {config_label}")
78 changes: 78 additions & 0 deletions src/specify_cli/presets/catalog/command_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Implementation of the ``specify preset catalog list`` command."""

from __future__ import annotations

import os
from pathlib import Path

import typer
from rich.markup import escape as _escape_markup

from ..._console import console
from . import catalog_app


@catalog_app.command("list")
def preset_catalog_list():
"""List all active preset catalogs."""
from ... import _display_project_path, _require_specify_project
from .. import PresetCatalog, PresetValidationError

project_root = _require_specify_project()
catalog = PresetCatalog(project_root)

try:
active_catalogs = catalog.get_active_catalogs()
except PresetValidationError as e:
console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)

console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
for entry in active_catalogs:
install_str = (
"[green]install allowed[/green]"
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
console.print(
f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})"
)
if entry.description:
console.print(f" {_escape_markup(str(entry.description))}")
console.print(f" URL: {_escape_markup(str(entry.url))}")
console.print(f" Install: {install_str}")
console.print()

config_path = project_root / ".specify" / "preset-catalogs.yml"
user_config_path = Path.home() / ".specify" / "preset-catalogs.yml"
if os.environ.get("SPECKIT_PRESET_CATALOG_URL"):
console.print(
"[dim]Catalog configured via SPECKIT_PRESET_CATALOG_URL environment variable.[/dim]"
)
else:
try:
proj_loaded = (
config_path.exists()
and catalog._load_catalog_config(config_path) is not None
)
except PresetValidationError:
proj_loaded = False
if proj_loaded:
console.print(
f"[dim]Config: {_display_project_path(project_root, config_path)}[/dim]"
)
else:
try:
user_loaded = (
user_config_path.exists()
and catalog._load_catalog_config(user_config_path) is not None
)
except PresetValidationError:
user_loaded = False
if user_loaded:
console.print("[dim]Config: ~/.specify/preset-catalogs.yml[/dim]")
else:
console.print("[dim]Using built-in default catalog stack.[/dim]")
console.print(
"[dim]Add .specify/preset-catalogs.yml to customize.[/dim]"
)
69 changes: 69 additions & 0 deletions src/specify_cli/presets/catalog/command_remove.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Implementation of the ``specify preset catalog remove`` command."""

from __future__ import annotations

import typer
import yaml
from rich.markup import escape as _escape_markup

from ..._console import console
from . import catalog_app


@catalog_app.command("remove")
def preset_catalog_remove(
name: str = typer.Argument(help="Catalog name to remove"),
):
"""Remove a catalog from .specify/preset-catalogs.yml."""
from ... import _require_specify_project

project_root = _require_specify_project()
specify_dir = project_root / ".specify"

config_path = specify_dir / "preset-catalogs.yml"
if not config_path.exists():
console.print(
"[red]Error:[/red] No preset catalog config found. Nothing to remove."
)
raise typer.Exit(1)

try:
config = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001 - preserve CLI error boundary
console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}")
raise typer.Exit(1)
if config is None:
config = {}
elif not isinstance(config, dict):
console.print("[red]Error:[/red] Invalid catalog config: expected a mapping.")
raise typer.Exit(1)

catalogs = config.get("catalogs", [])
if not isinstance(catalogs, list):
console.print(
"[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list."
)
raise typer.Exit(1)
# Rendering only — the raw name drives the comparison below.
safe_name = _escape_markup(str(name))

original_count = len(catalogs)
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]

if len(catalogs) == original_count:
console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.")
raise typer.Exit(1)

config["catalogs"] = catalogs
config_path.write_text(
yaml.safe_dump(
config, default_flow_style=False, sort_keys=False, allow_unicode=True
),
encoding="utf-8",
)

console.print(f"[green]✓[/green] Removed catalog '{safe_name}'")
if not catalogs:
console.print(
"\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]"
)
Loading
Loading