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
4 changes: 2 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,8 @@ def _require_specify_project() -> Path:

# ===== Bundle Commands =====

# Bundler subcommand group (specify bundle ...) — see commands/bundle/.
from .commands.bundle import register as _register_bundle_cmds # noqa: E402
# Bundle subcommand group (specify bundle ...) — see bundles/_commands.py.
from .bundles._commands import register as _register_bundle_cmds # noqa: E402
_register_bundle_cmds(app)


Expand Down
5 changes: 5 additions & 0 deletions src/specify_cli/bundler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Compatibility import for the renamed :mod:`specify_cli.bundles` package."""

from .bundles import BundlerError

__all__ = ["BundlerError"]
2 changes: 0 additions & 2 deletions src/specify_cli/bundler/commands_impl/__init__.py

This file was deleted.

2 changes: 0 additions & 2 deletions src/specify_cli/bundler/lib/__init__.py

This file was deleted.

2 changes: 0 additions & 2 deletions src/specify_cli/bundler/models/__init__.py

This file was deleted.

2 changes: 0 additions & 2 deletions src/specify_cli/bundler/services/__init__.py

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
This package holds the models, services, and helpers behind the ``specify bundle``
subcommand. It is intentionally free of any Typer/CLI imports so the orchestration
logic can be unit-tested independently of the command surface (Constitution
Principle I). The CLI wiring lives in ``specify_cli.commands.bundle``.
Principle I). The CLI wiring lives in ``specify_cli.bundles._commands`` and adjacent
``command_*.py`` modules.
"""
from __future__ import annotations

Expand Down
163 changes: 163 additions & 0 deletions src/specify_cli/bundles/_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Shared infrastructure and registration for ``specify bundle`` commands.

Command handlers live in ``command_*.py`` modules. The nested ``catalog``
namespace registers through ``bundles.catalog``; domain behavior remains in
Typer-free modules in this package.
"""

from __future__ import annotations

from pathlib import Path

import typer
from rich.markup import escape as _escape_markup

from .._console import err_console
from . import BundlerError
from .project import active_integration
from .records import load_records

bundle_app = typer.Typer(
name="bundle",
help="Discover, install, and author Spec Kit bundles",
add_completion=False,
)


def _fail(message: str) -> None:
"""Print an actionable error to stderr and exit non-zero."""
# Use the stderr console so the error never lands on stdout, which under
# ``--json`` carries the machine-readable payload and must stay parseable.
# Escape the message: every caller passes ``str(exc)`` from a BundlerError
# that interpolates untrusted data (a CLI argument, a catalog url, a
# bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag
# -- silently swallowing the text, or raising MarkupError on an unbalanced
# closer and replacing the whole message with a traceback.
err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None)
raise typer.Exit(code=1)


def _user_config_dir() -> Path:
# User-scope Spec Kit config lives under ~/.specify (same convention as
# auth.json, extension/preset catalogs). Passing this through to the source
# stack is what makes the documented project > user > built-in precedence
# reachable from the CLI.
return Path.home() / ".specify"


def _build_stack(project_root: Path, *, offline: bool):
from .adapters import make_catalog_fetcher
from .catalog_stack import CatalogStack

fetcher = make_catalog_fetcher(allow_network=not offline)
return CatalogStack.load(project_root, fetcher, user_config_dir=_user_config_dir())


def _speckit_version() -> str:
from .._assets import get_speckit_version

return get_speckit_version()


def _trust_level(verified: bool) -> str:
"""Trust framing for a catalog entry (FR-010): org-curated vs community."""
return "verified" if verified else "community"


def _trust_badge(verified: bool) -> str:
return "[green]✔ verified[/green]" if verified else "[yellow]community[/yellow]"


def _default_script_type() -> str:
"""OS-appropriate default script flavor (FR-013)."""
import os

return "ps" if os.name == "nt" else "sh"


def _run_init(integration: str, *, script_type: str, offline: bool = False) -> None:
"""Idempotently scaffold a Spec Kit project here via the existing ``init`` machinery.

Reuses the real ``specify init`` command callback in-process (Principle I)
with ``--here --force`` so it is non-interactive and merges into the current
directory.
"""
from .. import app

init_cb = next(
c.callback
for c in app.registered_commands
if c.callback and c.callback.__name__ == "init"
)
try:
init_cb(
project_name=None,
script_type=script_type,
ignore_agent_tools=True,
here=True,
force=True,
skip_tls=False,
debug=False,
github_token=None,
offline=offline,
preset=None,
integration=integration,
integration_options=None,
extensions=None,
trust_extension_urls=False,
)
except typer.Exit as exc:
if exc.exit_code:
raise BundlerError(
f"Failed to initialize a Spec Kit project (integration '{integration}')."
) from exc


def _resolve_init_integration(override: str | None, manifest) -> str:
"""Precedence (FR-013): explicit override → bundle-declared → default."""
from .._agent_config import resolve_default_init_integration

if override:
return override
if manifest is not None and manifest.integration is not None:
return manifest.integration.id
return resolve_default_init_integration()


def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]:
"""Return informational overlaps between *manifest* and installed bundles."""
if manifest is None:
return []
try:
from .conflict import detect_conflicts

report = detect_conflicts(
manifest,
active_integration(project_root),
load_records(project_root),
)
return list(report.overlaps)
except BundlerError:
return []


def register(app: typer.Typer) -> None:
"""Attach the bundle command group to the root Typer app."""
from .catalog import register as register_catalog

register_catalog(bundle_app)

# isort: off
from . import command_search # noqa: F401 — registers handler via decorator
from . import command_info # noqa: F401 — registers handler via decorator
from . import command_list # noqa: F401 — registers handler via decorator
from . import command_install # noqa: F401 — registers handler via decorator
from . import command_add # noqa: F401 — registers handler via decorator
from . import command_update # noqa: F401 — registers handler via decorator
from . import command_remove # noqa: F401 — registers handler via decorator
from . import command_validate # noqa: F401 — registers handler via decorator
from . import command_build # noqa: F401 — registers handler via decorator
from . import command_init # noqa: F401 — registers handler via decorator
# isort: on

app.add_typer(bundle_app, name="bundle")
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@
from urllib.parse import ParseResult, urlparse
from urllib.request import url2pathname

from ..._assets import _locate_core_pack, _repo_root
from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
from .. import BundlerError
from ..lib.yamlio import load_json, loads_json
from ..models.catalog import CatalogSource
from ..models.manifest import ComponentRef
from .._assets import _locate_core_pack, _repo_root
from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
from . import BundlerError
from .yamlio import load_json, loads_json
from .catalogs import CatalogSource
from .manifest import ComponentRef

COMMUNITY_CATALOG_URL = (
"https://raw.githubusercontent.com/github/spec-kit/main/"
Expand Down Expand Up @@ -218,7 +218,7 @@ def _http_get_json(source_id: str, url: str) -> dict:
HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end
rather than only on the initial URL.
"""
from ...authentication.http import RedirectPolicyError, open_url
from ..authentication.http import RedirectPolicyError, open_url

def _validate_redirect(_old_url: str, new_url: str) -> None:
_validate_remote_url(source_id, new_url)
Expand Down
20 changes: 20 additions & 0 deletions src/specify_cli/bundles/catalog/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Registration for the nested ``specify bundle catalog`` command group."""

from __future__ import annotations

import typer

catalog_app = typer.Typer(
name="catalog",
help="Manage bundle catalog sources",
add_completion=False,
)


def register(app: typer.Typer) -> None:
"""Attach the catalog command group to the bundle Typer app."""
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

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

from __future__ import annotations

import typer
from rich.markup import escape as _escape_markup

from ..._console import console
from .. import BundlerError
from .._commands import _fail
from ..project import require_project_root
from . import catalog_app


@catalog_app.command("add")
def catalog_add(
url: str = typer.Argument(..., help="Catalog URL"),
policy: str = typer.Option(
"install-allowed", "--policy", help="install-allowed | discovery-only"
),
priority: int = typer.Option(
10, "--priority", help="Source priority (lower = higher)"
),
source_id: str = typer.Option(None, "--id", help="Explicit source id"),
) -> None:
"""Register a project-scoped catalog source and persist it."""
try:
project_root = require_project_root()
from ..catalog_config import add_source

source = add_source(
project_root, url, policy=policy, priority=priority, source_id=source_id
)
except BundlerError as exc:
_fail(str(exc))
return

console.print(
f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' "
f"(priority {source.priority}, {source.install_policy.value})."
)
38 changes: 38 additions & 0 deletions src/specify_cli/bundles/catalog/command_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Implementation of ``specify bundle catalog list``."""

from __future__ import annotations

from rich.markup import escape as _escape_markup

from ..._console import console
from .. import BundlerError
from .._commands import _fail, _user_config_dir
from ..project import require_project_root
from . import catalog_app


@catalog_app.command("list")
def catalog_list() -> None:
"""Print the active, priority-ordered catalog stack with scope and policy."""
try:
project_root = require_project_root()
from ..catalogs import Scope, load_source_stack

sources = load_source_stack(project_root, user_config_dir=_user_config_dir())
except BundlerError as exc:
_fail(str(exc))
return

console.print(
"\n[bold cyan]Catalog stack[/bold cyan] (highest precedence first):\n"
)
only_builtin = all(s.scope == Scope.BUILTIN for s in sources)
for source in sources:
console.print(
f" [bold]{_escape_markup(str(source.id))}[/bold] "
f"priority={source.priority} "
f"policy={source.install_policy.value} scope={source.scope.value}"
)
console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]")
if only_builtin:
console.print("\n[dim]Using the built-in default stack.[/dim]")
31 changes: 31 additions & 0 deletions src/specify_cli/bundles/catalog/command_remove.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Implementation of ``specify bundle catalog remove``."""

from __future__ import annotations

import typer
from rich.markup import escape as _escape_markup

from ..._console import console
from .. import BundlerError
from .._commands import _fail
from ..project import require_project_root
from . import catalog_app


@catalog_app.command("remove")
def catalog_remove(
id_or_url: str = typer.Argument(..., help="Source id or url to remove"),
) -> None:
"""Remove a project-scoped catalog source (built-in defaults can't be deleted)."""
try:
project_root = require_project_root()
from ..catalog_config import remove_source

removed = remove_source(project_root, id_or_url)
except BundlerError as exc:
_fail(str(exc))
return

console.print(
f"[green]✓[/green] Removed catalog source '{_escape_markup(str(removed))}'."
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
from urllib.parse import urlparse
import re

from .. import BundlerError
from ..lib.yamlio import dump_yaml, ensure_within, load_yaml
from ..models.catalog import (
from . import BundlerError
from .yamlio import dump_yaml, ensure_within, load_yaml
from .catalogs import (
CONFIG_FILENAME,
CONFIG_SCHEMA_VERSION,
BUILTIN_DEFAULT_STACK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
from pathlib import Path
from typing import Callable

from .. import BundlerError
from ..models.catalog import (
from . import BundlerError
from .catalogs import (
CatalogEntry,
CatalogSource,
load_catalog_payload,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
from pathlib import Path
from typing import Any

from .. import BundlerError
from ..lib.yamlio import ensure_within, load_yaml
from . import BundlerError
from .yamlio import ensure_within, load_yaml

CONFIG_FILENAME = "bundle-catalogs.yml"
# Supported bundle-catalogs.yml schema (major version). Both readers of the
Expand Down
Loading
Loading