From 47d3778bd556ed19bf71208a5c9d5812599a96fb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:43:55 -0500 Subject: [PATCH 01/19] feat(init): add preview JSON contract Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 154 +++ src/specify_cli/_command_init_json.py | 1105 +++++++++++++++++++ src/specify_cli/command_init.py | 29 +- tests/specify_cli/test_command_init_json.py | 647 +++++++++++ 4 files changed, 1934 insertions(+), 1 deletion(-) create mode 100644 src/specify_cli/_command_init_json.py create mode 100644 tests/specify_cli/test_command_init_json.py diff --git a/docs/reference/core.md b/docs/reference/core.md index afd53c46d4..2c99657537 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -15,8 +15,12 @@ specify init [] | `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) | | `--here` | Initialize in the current directory instead of creating a new one | | `--force` | Force merge/overwrite when initializing in an existing directory | +| `--non-interactive` | Never prompt; use safe documented defaults where available | +| `--json` | Emit the preview machine-readable init contract; implies non-interactive behavior | | `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools | | `--preset ` | Install a preset during initialization | +| `--extension ` | Install an optional bundled, local, catalog, or HTTPS extension; repeatable | +| `--trust-extension-urls` | Explicitly authorize HTTPS extension installs without a trust prompt | Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. @@ -44,8 +48,158 @@ specify init my-project --integration copilot --script ps # Install a preset during initialization specify init my-project --integration copilot --preset compliance + +# Initialize non-interactively with machine-readable output +specify init my-project --json +``` + +### Preview JSON contract + +`specify init --json` is the preview machine-readable contract for project +initialization. It always behaves non-interactively, even when stdin is a TTY: +it never renders the banner, Rich panels, progress/Live output, pickers, or +confirmation prompts, and it never waits for terminal input. + +JSON mode does **not** imply authorization. In particular, it does not imply +`--force`, `--trust-extension-urls`, `--ignore-agent-tools`, overwrite consent, +or any other destructive or trust decision. Supply those flags explicitly when +their documented behavior is intended. The only automatic choices are safe +defaults: + +- the default integration (`copilot`, or a valid + `SPECKIT_INTEGRATION_DEFAULT` value); +- `sh` scripts on POSIX systems or `ps` scripts on Windows. + +The success object reports `integration.defaulted` and `script.defaulted` so +callers can distinguish defaults from explicit selections. + +On success, stdout contains exactly one UTF-8 JSON object followed by a newline, +and stderr is empty. The object is command-specific; it does not use the +obsolete universal `schema_version` / `ok` / `result` wrapper. + +```json +{ + "project": { + "name": "my-project", + "path": "/work/my-project", + "operation": "created" + }, + "integration": { + "key": "copilot", + "defaulted": true, + "status": "installed" + }, + "script": { + "type": "sh", + "defaulted": true + }, + "components": { + "shared_infrastructure": { + "status": "installed", + "script_type": "sh" + }, + "workflow": { + "id": "speckit", + "status": "installed", + "version": "1.0.1" + }, + "constitution": { + "status": "created", + "path": "/work/my-project/.specify/memory/constitution.md", + "source": "copied" + }, + "script_permissions": { + "status": "completed", + "detail": "6 updated" + }, + "preset": null, + "extensions": [] + }, + "warnings": [], + "next_steps": [ + { + "action": "change_directory", + "path": "/work/my-project" + }, + { + "action": "start_agent", + "integration": "copilot", + "working_directory": "/work/my-project" + }, + { + "action": "run_spec_kit", + "commands": [ + "constitution", + "specify", + "plan", + "tasks", + "implement", + "converge" + ], + "working_directory": "/work/my-project" + } + ] +} +``` + +`project.operation` has precise target-state semantics: + +- `created`: the target directory did not exist before initialization; +- `merged`: initialization used a pre-existing directory that was not already a + Spec Kit project; +- `reinitialized`: the target already contained a `.specify/` directory. + +Optional preset, extension, workflow, constitution, permission, or +re-registration failures that do not prevent initialization are represented in +their component outcome and in the structured `warnings` array. They are never +printed as terminal warnings or silently discarded. `next_steps` uses +machine-usable actions and paths rather than shell-formatted prose. + +On failure, stdout is empty and stderr contains exactly one UTF-8 JSON error +object followed by a newline: + +```json +{"error":{"code":"target_not_empty","message":"The current directory is not empty; pass --force to merge into it.","details":{"item_count":3,"path":"/work/existing"}}} ``` +Stable init error codes are: + +| Code | Meaning | +| ---- | ------- | +| `target_required` | Neither a project name, `.` nor `--here` was supplied | +| `conflicting_target_options` | A project name and `--here` were supplied together | +| `target_exists` | A named target directory already exists without `--force` | +| `target_not_empty` | The `--here` target is non-empty without `--force` | +| `target_not_directory` | The target exists but is not a directory | +| `target_unavailable` | The target directory cannot be inspected | +| `invalid_arguments` | Command-line syntax or option parsing failed before initialization | +| `invalid_integration` | The requested integration is not registered | +| `invalid_integration_options` | Integration options are unknown, malformed, conflicting, or incomplete | +| `invalid_script_type` | `--script` is not one of `sh`, `ps`, or `py` | +| `missing_agent_tool` | A required agent tool is unavailable and `--ignore-agent-tools` was not supplied | +| `extension_url_trust_required` | An HTTPS extension was requested without `--trust-extension-urls` | +| `initialization_failed` | A known fatal initialization boundary failed | +| `internal_error` | An unexpected exception was sanitized | +| `rollback_failed` | Initialization failed and cleanup of a newly created target also failed | + +All target, option, required-tool, and URL-trust checks run before filesystem +mutation. URL extension trust is default-deny. If a later fatal error occurs +after a new target was created, init removes that target; a pre-existing target +is never deleted. A cleanup failure is returned as `rollback_failed` with the +original error nested in `details`. + +JSON mode has the same successful side effects as human mode: it installs the +selected integration, shared templates and scripts, bundled workflow, +constitution, optional preset, and requested extensions, and it writes the +normal `.specify/` state. `--force` retains its existing merge/overwrite +behavior. + +**Preview compatibility:** field names, operation values, error envelope, and +error codes above are the supported preview contract. New optional fields, +component statuses, warning codes, or next-step actions may be added during the +preview; consumers should ignore unknown fields. A future stabilization will be +documented before any incompatible contract change. + ### Environment Variables | Variable | Description | diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py new file mode 100644 index 0000000000..3bc0b89ca7 --- /dev/null +++ b/src/specify_cli/_command_init_json.py @@ -0,0 +1,1105 @@ +"""Private machine-readable execution path for ``specify init --json``.""" + +from __future__ import annotations + +import io +import json +import os +import shlex +import shutil +import sys +from contextlib import redirect_stderr, redirect_stdout +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, NoReturn + +import typer +from rich.console import Console +from typer.core import TyperCommand + +try: + from typer._click.exceptions import UsageError as _UsageError +except ModuleNotFoundError as error: + if error.name != "typer._click": + raise + from click import UsageError as _UsageError + +from ._agent_config import ( + AGENT_CONFIG, + DEFAULT_INIT_INTEGRATION, + DEFAULT_INIT_INTEGRATION_ENV_VAR, + SCRIPT_TYPE_CHOICES, +) +from ._assets import ( + _locate_bundled_preset, + _locate_bundled_workflow, + _locate_core_pack, + _repo_root, + get_speckit_version, +) +from ._console import StepTracker, console, err_console +from ._utils import check_tool + + +@dataclass +class InitJsonFailure(Exception): + """A stable command-specific failure for ``specify init --json``.""" + + code: str + message: str + details: dict[str, Any] = field(default_factory=dict) + + +class InitJsonCommand(TyperCommand): + """Keep JSON-mode parse failures on the init error contract.""" + + def make_context(self, info_name, args, parent=None, **extra): + json_output = "--json" in args + try: + return super().make_context(info_name, args, parent=parent, **extra) + except _UsageError as error: + if json_output: + _emit_failure( + InitJsonFailure( + "invalid_arguments", + _single_line(error), + ) + ) + raise + + +@dataclass +class _InitPlan: + project_name: str + project_path: Path + operation: str + dir_existed_before: bool + here: bool + force: bool + integration: Any + integration_defaulted: bool + integration_options: dict[str, Any] + raw_integration_options: str | None + script_type: str + script_defaulted: bool + ignore_agent_tools: bool + preset: str | None + extensions: list[str] + warnings: list[dict[str, Any]] + + +def _warning( + code: str, message: str, details: dict[str, Any] | None = None +) -> dict[str, Any]: + return {"code": code, "message": message, "details": details or {}} + + +def _single_line(value: object, *, limit: int = 500) -> str: + text = " ".join(str(value).split()) + return (text or value.__class__.__name__)[:limit] + + +def _emit(value: dict[str, Any], *, error: bool = False) -> None: + stream = sys.stderr if error else sys.stdout + stream.write( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ) + + +def _emit_failure(failure: InitJsonFailure) -> NoReturn: + _emit( + { + "error": { + "code": failure.code, + "message": failure.message, + "details": failure.details, + } + }, + error=True, + ) + raise typer.Exit(1) + + +def _invalid_integration_options(message: str, **details: Any) -> InitJsonFailure: + return InitJsonFailure( + "invalid_integration_options", + message, + details, + ) + + +def _parse_integration_options( + integration: Any, raw_options: str | None +) -> dict[str, Any]: + if not raw_options: + parsed: dict[str, Any] = {} + else: + try: + tokens = shlex.split(raw_options) + except ValueError as exc: + raise _invalid_integration_options( + "Could not parse integration options.", + reason=_single_line(exc), + ) from exc + + declared_options = list(integration.options()) + declared = {opt.name.lstrip("-"): opt for opt in declared_options} + allowed = sorted(opt.name for opt in declared_options) + parsed = {} + i = 0 + while i < len(tokens): + token = tokens[i] + if not token.startswith("-"): + raise _invalid_integration_options( + "Unexpected integration option value.", + value=token, + allowed=allowed, + ) + name = token.lstrip("-") + value: str | None = None + if "=" in name: + name, value = name.split("=", 1) + option = declared.get(name) + if option is None: + raise _invalid_integration_options( + "Unknown integration option.", + option=token, + allowed=allowed, + ) + key = name.replace("-", "_") + if option.is_flag: + if value is not None: + raise _invalid_integration_options( + "Integration flag does not accept a value.", + option=option.name, + ) + parsed[key] = True + i += 1 + elif value is not None: + parsed[key] = value + i += 1 + elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): + parsed[key] = tokens[i + 1] + i += 2 + else: + raise _invalid_integration_options( + "Integration option requires a value.", + option=option.name, + ) + + missing = [] + for option in integration.options(): + if not option.required: + continue + key = option.name.lstrip("-").replace("-", "_") + value = parsed.get(key) + if value is None or (isinstance(value, str) and not value.strip()): + missing.append(option.name) + if missing: + raise _invalid_integration_options( + "Required integration options were not supplied.", + missing=missing, + ) + + return parsed + + +def _resolve_default_integration( + warnings: list[dict[str, Any]], +) -> str: + override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip() + if not override: + return DEFAULT_INIT_INTEGRATION + if override in AGENT_CONFIG: + return override + warnings.append( + _warning( + "invalid_default_integration", + "The configured default integration was not recognized; the built-in default was used.", + { + "environment_variable": DEFAULT_INIT_INTEGRATION_ENV_VAR, + "value": override, + "default": DEFAULT_INIT_INTEGRATION, + }, + ) + ) + return DEFAULT_INIT_INTEGRATION + + +def _build_plan( + *, + project_name: str | None, + script_type: str | None, + ignore_agent_tools: bool, + here: bool, + force: bool, + preset: str | None, + integration_key: str | None, + integration_options: str | None, + extensions: list[str] | None, + trust_extension_urls: bool, +) -> _InitPlan: + from .integrations import get_integration + + warnings: list[dict[str, Any]] = [] + + if project_name == ".": + here = True + project_name = None + if here and project_name: + raise InitJsonFailure( + "conflicting_target_options", + "Cannot specify both a project name and --here.", + {"project_name": project_name}, + ) + if not here and not project_name: + raise InitJsonFailure( + "target_required", + "Specify a project name, use '.', or pass --here.", + ) + + if here: + project_path = Path.cwd().resolve() + resolved_name = project_path.name + dir_existed_before = True + else: + assert project_name is not None + project_path = Path(project_name).resolve() + resolved_name = project_path.name + dir_existed_before = project_path.exists() + + already_initialized = (project_path / ".specify").is_dir() + operation = ( + "reinitialized" + if already_initialized + else "merged" + if dir_existed_before + else "created" + ) + + if dir_existed_before: + if not project_path.is_dir(): + raise InitJsonFailure( + "target_not_directory", + "The target exists but is not a directory.", + {"path": str(project_path)}, + ) + try: + existing_items = list(project_path.iterdir()) + except OSError as exc: + raise InitJsonFailure( + "target_unavailable", + "The target directory could not be inspected.", + {"path": str(project_path), "reason": _single_line(exc)}, + ) from exc + if not force: + if here and existing_items: + raise InitJsonFailure( + "target_not_empty", + "The current directory is not empty; pass --force to merge into it.", + {"path": str(project_path), "item_count": len(existing_items)}, + ) + if not here: + raise InitJsonFailure( + "target_exists", + "The target directory already exists; pass --force to merge into it.", + {"path": str(project_path), "item_count": len(existing_items)}, + ) + + requested_extensions = list(extensions or []) + untrusted_urls = [ + spec + for spec in requested_extensions + if _extension_spec_is_url(spec) and not trust_extension_urls + ] + if untrusted_urls: + raise InitJsonFailure( + "extension_url_trust_required", + "External extension URLs require explicit trust before initialization.", + { + "extensions": untrusted_urls, + "required_flag": "--trust-extension-urls", + }, + ) + + integration_defaulted = integration_key is None + selected_integration = integration_key or _resolve_default_integration(warnings) + integration = get_integration(selected_integration) + if integration is None or selected_integration not in AGENT_CONFIG: + raise InitJsonFailure( + "invalid_integration", + "The requested integration is not registered.", + { + "integration": selected_integration, + "available": sorted(AGENT_CONFIG), + }, + ) + + parsed_options = _parse_integration_options(integration, integration_options) + try: + integration.is_skills_mode(parsed_options or None, project_root=project_path) + except ValueError as exc: + raise _invalid_integration_options( + "Integration options are invalid.", + integration=selected_integration, + reason=_single_line(exc), + ) from exc + + if not ignore_agent_tools: + agent_config = AGENT_CONFIG[selected_integration] + if agent_config.get("requires_cli") and not check_tool(selected_integration): + raise InitJsonFailure( + "missing_agent_tool", + "The selected integration requires an agent tool that was not found.", + { + "integration": selected_integration, + "install_url": agent_config.get("install_url"), + "override_flag": "--ignore-agent-tools", + }, + ) + + script_defaulted = script_type is None + selected_script = script_type or ("ps" if os.name == "nt" else "sh") + if selected_script not in SCRIPT_TYPE_CHOICES: + raise InitJsonFailure( + "invalid_script_type", + "The requested script type is not supported.", + { + "script_type": selected_script, + "available": sorted(SCRIPT_TYPE_CHOICES), + }, + ) + + return _InitPlan( + project_name=resolved_name, + project_path=project_path, + operation=operation, + dir_existed_before=dir_existed_before, + here=here, + force=force, + integration=integration, + integration_defaulted=integration_defaulted, + integration_options=parsed_options, + raw_integration_options=integration_options, + script_type=selected_script, + script_defaulted=script_defaulted, + ignore_agent_tools=ignore_agent_tools, + preset=preset, + extensions=requested_extensions, + warnings=warnings, + ) + + +def _extension_spec_is_url(value: str) -> bool: + from urllib.parse import urlparse + + try: + return urlparse(value).scheme in {"http", "https"} + except ValueError: + return False + + +def _record_suppressed_output( + warnings: list[dict[str, Any]], + *, + stdout: str, + stderr: str, +) -> None: + if stdout.strip(): + warnings.append( + _warning( + "suppressed_stdout", + "Initialization produced human-readable output that was suppressed in JSON mode.", + {"output": _single_line(stdout, limit=1000)}, + ) + ) + if stderr.strip(): + warnings.append( + _warning( + "suppressed_stderr", + "Initialization produced diagnostic output that was suppressed in JSON mode.", + {"output": _single_line(stderr, limit=1000)}, + ) + ) + + +def _re_register_existing_artifacts( + plan: _InitPlan, + warnings: list[dict[str, Any]], +) -> None: + if not plan.force: + return + try: + from .extensions import ExtensionManager + + ExtensionManager(plan.project_path).register_enabled_extensions_for_agent( + plan.integration.key, + force=True, + ) + except Exception as exc: # noqa: BLE001 - re-registration is best-effort + warnings.append( + _warning( + "extension_reregistration_failed", + "The integration was updated, but installed extension artifacts could not be re-registered.", + { + "integration": plan.integration.key, + "reason": _single_line(exc), + }, + ) + ) + try: + from .presets import PresetManager + + PresetManager(plan.project_path).register_enabled_presets_for_agent( + plan.integration.key + ) + except Exception as exc: # noqa: BLE001 - re-registration is best-effort + warnings.append( + _warning( + "preset_reregistration_failed", + "The integration was updated, but installed preset artifacts could not be re-registered.", + { + "integration": plan.integration.key, + "reason": _single_line(exc), + }, + ) + ) + + +def _install_shared_infrastructure( + plan: _InitPlan, + warnings: list[dict[str, Any]], +) -> dict[str, Any]: + from .integration_runtime import ( + invoke_prefix_for_integration, + ) + from .shared_infra import install_shared_infra + + captured = io.StringIO() + recording_console = Console( + file=captured, + force_terminal=False, + color_system=None, + width=240, + highlight=False, + ) + try: + install_shared_infra( + plan.project_path, + plan.script_type, + version=get_speckit_version(), + core_pack=_locate_core_pack(), + repo_root=_repo_root(), + console=recording_console, + force=plan.force, + invoke_separator=plan.integration.effective_invoke_separator( + plan.integration_options or None, + project_root=plan.project_path, + ), + invoke_prefix=invoke_prefix_for_integration( + plan.integration, + plan.integration.key, + plan.integration_options or None, + plan.project_path, + ), + ) + except (OSError, ValueError) as exc: + raise InitJsonFailure( + "initialization_failed", + "Failed to install shared infrastructure.", + { + "component": "shared_infrastructure", + "reason": _single_line(exc), + }, + ) from exc + + notice = _single_line(captured.getvalue(), limit=2000) + if captured.getvalue().strip(): + warnings.append( + _warning( + "shared_infrastructure_notice", + "Some shared infrastructure paths were preserved or skipped.", + {"notice": notice}, + ) + ) + return {"status": "installed", "script_type": plan.script_type} + + +def _install_bundled_workflow( + project_path: Path, + warnings: list[dict[str, Any]], +) -> dict[str, Any]: + try: + bundled_workflow = _locate_bundled_workflow("speckit") + if bundled_workflow is None: + outcome = { + "id": "speckit", + "status": "skipped", + "reason": "bundled_workflow_not_found", + } + warnings.append( + _warning( + "bundled_workflow_not_found", + "The bundled speckit workflow was not available.", + {"workflow": "speckit"}, + ) + ) + return outcome + + from .workflows.catalog import WorkflowRegistry + from .workflows.engine import WorkflowDefinition + + registry = WorkflowRegistry(project_path) + if registry.is_installed("speckit"): + return {"id": "speckit", "status": "already_installed"} + + destination = project_path / ".specify" / "workflows" / "speckit" + destination.mkdir(parents=True, exist_ok=True) + shutil.copy2( + bundled_workflow / "workflow.yml", + destination / "workflow.yml", + ) + definition = WorkflowDefinition.from_yaml(destination / "workflow.yml") + registry.add( + "speckit", + { + "name": definition.name, + "version": definition.version, + "description": definition.description, + "source": "bundled", + }, + ) + return { + "id": "speckit", + "status": "installed", + "version": definition.version, + } + except Exception as exc: # noqa: BLE001 - optional workflow failures continue + warnings.append( + _warning( + "workflow_install_failed", + "The project was initialized without the optional bundled workflow.", + {"workflow": "speckit", "reason": _single_line(exc)}, + ) + ) + return { + "id": "speckit", + "status": "failed", + "reason": _single_line(exc), + } + + +def _install_optional_preset( + project_path: Path, + preset: str | None, + warnings: list[dict[str, Any]], +) -> dict[str, Any] | None: + if preset is None: + return None + + try: + from .presets import PresetCatalog, PresetManager + + manager = PresetManager(project_path) + version = get_speckit_version() + local_path = Path(preset).resolve() + if local_path.is_dir() and (local_path / "preset.yml").exists(): + manifest = manager.install_from_directory(local_path, version) + return { + "requested": preset, + "id": manifest.id, + "status": "installed", + "source": "local", + } + + bundled_path = _locate_bundled_preset(preset) + if bundled_path is not None: + manifest = manager.install_from_directory(bundled_path, version) + return { + "requested": preset, + "id": manifest.id, + "status": "installed", + "source": "bundled", + } + + catalog = PresetCatalog(project_path) + info = catalog.get_pack_info(preset) + if not info: + warnings.append( + _warning( + "preset_not_found", + "The requested optional preset was not found and was skipped.", + {"preset": preset}, + ) + ) + return { + "requested": preset, + "status": "skipped", + "reason": "not_found", + } + if info.get("bundled") and not info.get("download_url"): + warnings.append( + _warning( + "bundled_preset_not_found", + "The requested bundled preset was missing from the installed package.", + {"preset": preset}, + ) + ) + return { + "requested": preset, + "status": "failed", + "reason": "bundled_preset_not_found", + } + + zip_path: Path | None = None + try: + zip_path = catalog.download_pack(preset) + manifest = manager.install_from_zip( + zip_path, + version, + catalog_name=info.get("_catalog_name"), + ) + return { + "requested": preset, + "id": manifest.id, + "status": "installed", + "source": "catalog", + } + finally: + if zip_path is not None: + try: + zip_path.unlink(missing_ok=True) + except OSError as exc: + warnings.append( + _warning( + "preset_download_cleanup_failed", + "The preset was processed, but its temporary download could not be removed.", + {"preset": preset, "reason": _single_line(exc)}, + ) + ) + except Exception as exc: # noqa: BLE001 - optional preset failures continue + warnings.append( + _warning( + "preset_install_failed", + "The project was initialized without the optional preset.", + {"preset": preset, "reason": _single_line(exc)}, + ) + ) + return { + "requested": preset, + "status": "failed", + "reason": _single_line(exc), + } + + +def _install_requested_extensions( + project_path: Path, + extensions: list[str], + warnings: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if not extensions: + return [] + + from .command_init import _install_extension_during_init + + outcomes: list[dict[str, Any]] = [] + installed_any = False + version = get_speckit_version() + for extension in extensions: + try: + message = _install_extension_during_init( + project_path, + extension, + version, + ) + status = "already_installed" if message == "already installed" else "installed" + outcomes.append( + { + "requested": extension, + "status": status, + "message": message, + } + ) + installed_any = True + except Exception as exc: # noqa: BLE001 - optional extension failures continue + reason = _single_line(exc) + warnings.append( + _warning( + "extension_install_failed", + "The project was initialized without a requested optional extension.", + {"extension": extension, "reason": reason}, + ) + ) + outcomes.append( + { + "requested": extension, + "status": "failed", + "reason": reason, + } + ) + + if installed_any: + try: + from .events import EventRefreshError, refresh_integration_events + + refresh_integration_events(project_path) + except EventRefreshError as exc: + warnings.append( + _warning( + "extension_event_refresh_failed", + "Extensions were installed, but one or more integration event configurations could not be refreshed.", + { + "failures": [ + {"integration": key, "reason": detail} + for key, detail in exc.failures + ] + }, + ) + ) + return outcomes + + +def _initialize_constitution( + project_path: Path, + warnings: list[dict[str, Any]], +) -> dict[str, Any]: + destination = project_path / ".specify" / "memory" / "constitution.md" + if destination.exists(): + return {"status": "preserved", "path": str(destination)} + try: + from .presets import _materialize_constitution_template + + materialization = _materialize_constitution_template( + project_path, + destination, + ) + if materialization is None: + warnings.append( + _warning( + "constitution_template_not_found", + "The constitution could not be initialized because no template was available.", + {"path": str(destination)}, + ) + ) + return { + "status": "failed", + "path": str(destination), + "reason": "template_not_found", + } + return { + "status": "created", + "path": str(destination), + "source": materialization, + } + except Exception as exc: # noqa: BLE001 - constitution failure is non-fatal + warnings.append( + _warning( + "constitution_initialization_failed", + "The project was initialized, but its constitution could not be created.", + {"path": str(destination), "reason": _single_line(exc)}, + ) + ) + return { + "status": "failed", + "path": str(destination), + "reason": _single_line(exc), + } + + +def _ensure_script_permissions( + project_path: Path, + warnings: list[dict[str, Any]], +) -> dict[str, Any]: + from . import ensure_executable_scripts + + tracker = StepTracker("init-json") + ensure_executable_scripts(project_path, tracker=tracker) + step = next((item for item in tracker.steps if item["key"] == "chmod"), None) + if step is None: + return {"status": "not_applicable"} + if step["status"] == "error": + warnings.append( + _warning( + "script_permission_update_failed", + "Some executable script permissions could not be updated.", + {"detail": step["detail"]}, + ) + ) + return {"status": "partial", "detail": step["detail"]} + return {"status": "completed", "detail": step["detail"]} + + +def _next_steps(plan: _InitPlan) -> list[dict[str, Any]]: + steps: list[dict[str, Any]] = [] + if not plan.here: + steps.append( + { + "action": "change_directory", + "path": str(plan.project_path), + } + ) + steps.extend( + [ + { + "action": "start_agent", + "integration": plan.integration.key, + "working_directory": str(plan.project_path), + }, + { + "action": "run_spec_kit", + "commands": [ + "constitution", + "specify", + "plan", + "tasks", + "implement", + "converge", + ], + "working_directory": str(plan.project_path), + }, + ] + ) + return steps + + +def _initialize_project(plan: _InitPlan) -> dict[str, Any]: + from . import save_init_options + from .events import resolve_events + from .integration_runtime import with_integration_setting + from .integration_state import write_integration_json + from .integrations.manifest import IntegrationManifest + + warnings = list(plan.warnings) + parsed_options = plan.integration_options or None + try: + manifest = IntegrationManifest( + plan.integration.key, + plan.project_path, + version=get_speckit_version(), + ) + events = resolve_events( + plan.integration.key, + plan.integration.config, + plan.project_path, + parsed_options, + ) + plan.integration.setup( + plan.project_path, + manifest, + parsed_options=parsed_options, + script_type=plan.script_type, + raw_options=plan.raw_integration_options, + events=events, + ) + manifest.save() + except (OSError, ValueError) as exc: + raise InitJsonFailure( + "initialization_failed", + "Failed to install the selected integration.", + { + "component": "integration", + "integration": plan.integration.key, + "reason": _single_line(exc), + }, + ) from exc + + try: + _re_register_existing_artifacts(plan, warnings) + + settings = with_integration_setting( + {}, + plan.integration.key, + plan.integration, + script_type=plan.script_type, + raw_options=plan.raw_integration_options, + parsed_options=parsed_options, + project_root=plan.project_path, + ) + write_integration_json( + plan.project_path, + version=get_speckit_version(), + integration_key=plan.integration.key, + installed_integrations=[plan.integration.key], + settings=settings, + ) + + shared_infrastructure = _install_shared_infrastructure(plan, warnings) + except (OSError, ValueError) as exc: + raise InitJsonFailure( + "initialization_failed", + "Failed to configure project infrastructure.", + { + "component": "project_infrastructure", + "reason": _single_line(exc), + }, + ) from exc + workflow = _install_bundled_workflow(plan.project_path, warnings) + + try: + init_options: dict[str, Any] = { + "ai": plan.integration.key, + "integration": plan.integration.key, + "here": plan.here, + "script": plan.script_type, + "feature_numbering": "sequential", + "speckit_version": get_speckit_version(), + } + if plan.integration.is_skills_mode( + parsed_options, + project_root=plan.project_path, + ): + init_options["ai_skills"] = True + save_init_options(plan.project_path, init_options) + except (OSError, ValueError) as exc: + raise InitJsonFailure( + "initialization_failed", + "Failed to save initialization state.", + { + "component": "init_options", + "reason": _single_line(exc), + }, + ) from exc + + permissions = _ensure_script_permissions(plan.project_path, warnings) + preset = _install_optional_preset(plan.project_path, plan.preset, warnings) + extensions = _install_requested_extensions( + plan.project_path, + plan.extensions, + warnings, + ) + constitution = _initialize_constitution(plan.project_path, warnings) + + return { + "project": { + "name": plan.project_name, + "path": str(plan.project_path), + "operation": plan.operation, + }, + "integration": { + "key": plan.integration.key, + "defaulted": plan.integration_defaulted, + "status": "installed", + }, + "script": { + "type": plan.script_type, + "defaulted": plan.script_defaulted, + }, + "components": { + "shared_infrastructure": shared_infrastructure, + "workflow": workflow, + "constitution": constitution, + "script_permissions": permissions, + "preset": preset, + "extensions": extensions, + }, + "warnings": warnings, + "next_steps": _next_steps(plan), + } + + +def _as_failure(exc: BaseException) -> InitJsonFailure: + if isinstance(exc, InitJsonFailure): + return exc + if isinstance(exc, (OSError, ValueError, typer.Exit, SystemExit)): + return InitJsonFailure( + "initialization_failed", + "Project initialization failed.", + {"reason": _single_line(exc)}, + ) + return InitJsonFailure( + "internal_error", + "Project initialization failed because of an unexpected internal error.", + {"exception_type": exc.__class__.__name__}, + ) + + +def _rollback_new_target( + plan: _InitPlan, + failure: InitJsonFailure, +) -> InitJsonFailure: + if plan.dir_existed_before or not plan.project_path.exists(): + return failure + try: + shutil.rmtree(plan.project_path) + except OSError as cleanup_error: + return InitJsonFailure( + "rollback_failed", + "Initialization failed and the newly created target could not be removed.", + { + "path": str(plan.project_path), + "original_error": { + "code": failure.code, + "message": failure.message, + "details": failure.details, + }, + "cleanup_error": { + "exception_type": cleanup_error.__class__.__name__, + "reason": _single_line(cleanup_error), + }, + }, + ) + failure.details = { + **failure.details, + "rollback": { + "status": "completed", + "path": str(plan.project_path), + }, + } + return failure + + +def run_init_json( + *, + project_name: str | None, + script_type: str | None, + ignore_agent_tools: bool, + here: bool, + force: bool, + preset: str | None, + integration: str | None, + integration_options: str | None, + extensions: list[str] | None, + trust_extension_urls: bool, +) -> None: + """Execute ``specify init --json`` and emit exactly one JSON object.""" + try: + plan = _build_plan( + project_name=project_name, + script_type=script_type, + ignore_agent_tools=ignore_agent_tools, + here=here, + force=force, + preset=preset, + integration_key=integration, + integration_options=integration_options, + extensions=extensions, + trust_extension_urls=trust_extension_urls, + ) + except Exception as exc: # noqa: BLE001 - JSON boundary sanitizes all failures + _emit_failure(_as_failure(exc)) + + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + try: + with ( + console.capture() as console_capture, + err_console.capture() as err_console_capture, + redirect_stdout(stdout_capture), + redirect_stderr(stderr_capture), + ): + result = _initialize_project(plan) + _record_suppressed_output( + result["warnings"], + stdout=stdout_capture.getvalue() + console_capture.get(), + stderr=stderr_capture.getvalue() + err_console_capture.get(), + ) + except Exception as exc: # noqa: BLE001 - JSON boundary sanitizes all failures + _emit_failure(_rollback_new_target(plan, _as_failure(exc))) + + _emit(result) diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index c2630f0ef8..b2ba4f7829 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -257,7 +257,9 @@ def ensure_constitution_from_template( def register(app: typer.Typer) -> None: - @app.command() + from ._command_init_json import InitJsonCommand + + @app.command(cls=InitJsonCommand) def init( project_name: str = typer.Argument( None, @@ -291,6 +293,14 @@ def init( "PTY but cannot send arrow-key input." ), ), + json_output: bool = typer.Option( + False, + "--json", + help=( + "Emit one machine-readable JSON result. Implies non-interactive " + "behavior but does not imply force or trust/permission flags." + ), + ), skip_tls: bool = typer.Option( False, "--skip-tls", @@ -395,6 +405,23 @@ def init( _write_integration_json, ) + if json_output: + from ._command_init_json import run_init_json + + run_init_json( + project_name=project_name, + script_type=script_type, + ignore_agent_tools=ignore_agent_tools, + here=here, + force=force, + preset=preset, + integration=integration, + integration_options=integration_options, + extensions=extensions, + trust_extension_urls=trust_extension_urls, + ) + return + show_banner() from .integrations import INTEGRATION_REGISTRY, get_integration diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py new file mode 100644 index 0000000000..75e84ab539 --- /dev/null +++ b/tests/specify_cli/test_command_init_json.py @@ -0,0 +1,647 @@ +"""Contract tests for ``specify init --json``.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import pytest +import typer +from typer.testing import CliRunner, Result + +from specify_cli import app + + +def _invoke(args: list[str], *, cwd: Path) -> Result: + previous = Path.cwd() + os.chdir(cwd) + try: + return CliRunner().invoke( + app, + ["init", *args], + catch_exceptions=False, + ) + finally: + os.chdir(previous) + + +def _success(result: Result) -> dict[str, Any]: + assert result.exit_code == 0, result.stderr or result.stdout + assert result.stderr == "" + assert result.stdout.endswith("\n") + assert result.stdout.count("\n") == 1 + assert "\x1b[" not in result.stdout + payload = json.loads(result.stdout) + assert isinstance(payload, dict) + assert "error" not in payload + return payload + + +def _failure(result: Result, code: str) -> dict[str, Any]: + assert result.exit_code == 1, result.stdout or result.stderr + assert result.stdout == "" + assert result.stderr.endswith("\n") + assert result.stderr.count("\n") == 1 + assert "\x1b[" not in result.stderr + payload = json.loads(result.stderr) + assert payload["error"]["code"] == code + assert set(payload["error"]) == {"code", "message", "details"} + return payload["error"] + + +def test_json_init_new_directory_uses_safe_defaults(tmp_path: Path): + project = tmp_path / "new-project" + + payload = _success( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + + assert payload["project"] == { + "name": "new-project", + "path": str(project.resolve()), + "operation": "created", + } + assert payload["integration"]["key"] == "copilot" + assert payload["integration"]["defaulted"] is True + assert payload["script"]["type"] == ("ps" if os.name == "nt" else "sh") + assert payload["script"]["defaulted"] is True + assert payload["components"]["shared_infrastructure"]["status"] == "installed" + assert payload["components"]["workflow"]["status"] in { + "installed", + "already_installed", + } + assert payload["components"]["constitution"]["status"] == "created" + assert payload["components"]["preset"] is None + assert payload["components"]["extensions"] == [] + assert payload["next_steps"][0] == { + "action": "change_directory", + "path": str(project.resolve()), + } + assert (project / ".specify" / "init-options.json").is_file() + + +def test_json_init_here_honors_explicit_integration_and_script(tmp_path: Path): + payload = _success( + _invoke( + [ + "--here", + "--json", + "--integration", + "copilot", + "--script", + "py", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + assert payload["project"]["operation"] == "merged" + assert payload["project"]["path"] == str(tmp_path.resolve()) + assert payload["integration"]["defaulted"] is False + assert payload["script"] == {"type": "py", "defaulted": False} + assert all(step["action"] != "change_directory" for step in payload["next_steps"]) + + +def test_json_init_never_prompts_even_when_stdin_is_a_tty( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli.command_init as init_command + + monkeypatch.setattr(init_command, "_stdin_is_interactive", lambda: True) + + def fail_prompt(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("JSON mode must not render or prompt") + + monkeypatch.setattr(init_command, "select_with_arrows", fail_prompt) + monkeypatch.setattr(typer, "confirm", fail_prompt) + monkeypatch.setattr(init_command, "show_banner", fail_prompt) + monkeypatch.setattr(init_command, "Live", fail_prompt) + + payload = _success( + _invoke( + ["tty-project", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + + assert payload["integration"]["defaulted"] is True + assert payload["script"]["defaulted"] is True + + +def test_json_init_rejects_nonempty_here_without_force_and_preserves_files( + tmp_path: Path, +): + marker = tmp_path / "keep.txt" + marker.write_text("keep", encoding="utf-8") + + error = _failure( + _invoke( + ["--here", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "target_not_empty", + ) + + assert error["details"]["item_count"] == 1 + assert marker.read_text(encoding="utf-8") == "keep" + assert not (tmp_path / ".specify").exists() + + +def test_json_init_force_merges_nonempty_target(tmp_path: Path): + project = tmp_path / "existing" + project.mkdir() + marker = project / "keep.txt" + marker.write_text("keep", encoding="utf-8") + + payload = _success( + _invoke( + [ + str(project), + "--json", + "--force", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + assert payload["project"]["operation"] == "merged" + assert marker.read_text(encoding="utf-8") == "keep" + assert (project / ".specify").is_dir() + + +def test_json_init_reports_reinitialization(tmp_path: Path): + project = tmp_path / "project" + _success( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + + payload = _success( + _invoke( + [ + str(project), + "--json", + "--force", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + assert payload["project"]["operation"] == "reinitialized" + + +@pytest.mark.parametrize( + ("args", "code"), + [ + (["--json"], "target_required"), + (["project", "--here", "--json"], "conflicting_target_options"), + ( + [ + "project", + "--json", + "--integration", + "not-registered", + ], + "invalid_integration", + ), + ( + [ + "project", + "--json", + "--integration", + "copilot", + "--integration-options=--not-an-option", + ], + "invalid_integration_options", + ), + ( + [ + "project", + "--json", + "--integration", + "generic", + ], + "invalid_integration_options", + ), + ( + [ + "project", + "--json", + "--script", + "fish", + ], + "invalid_script_type", + ), + ], +) +def test_json_init_validation_failures_do_not_create_target( + tmp_path: Path, + args: list[str], + code: str, +): + _failure(_invoke(args, cwd=tmp_path), code) + assert not (tmp_path / "project").exists() + + +@pytest.mark.parametrize( + "args", + [ + ["project", "--json", "--unknown-option"], + ["project", "--json", "--script"], + ], +) +def test_json_init_parser_failures_use_structured_error( + tmp_path: Path, + args: list[str], +): + _failure(_invoke(args, cwd=tmp_path), "invalid_arguments") + assert not (tmp_path / "project").exists() + + +def test_json_init_rejects_existing_named_target_without_force(tmp_path: Path): + project = tmp_path / "project" + project.mkdir() + + _failure( + _invoke([str(project), "--json"], cwd=tmp_path), + "target_exists", + ) + + assert project.is_dir() + assert not (project / ".specify").exists() + + +def test_json_init_reports_missing_required_agent_tool( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + monkeypatch.setattr(init_json, "check_tool", lambda _tool: False) + + error = _failure( + _invoke( + [ + "project", + "--json", + "--integration", + "claude", + ], + cwd=tmp_path, + ), + "missing_agent_tool", + ) + + assert error["details"]["integration"] == "claude" + assert error["details"]["override_flag"] == "--ignore-agent-tools" + assert not (tmp_path / "project").exists() + + +def test_json_init_rejects_untrusted_url_extension_before_mutation(tmp_path: Path): + project = tmp_path / "project" + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--extension", + "https://example.com/extension.zip", + ], + cwd=tmp_path, + ), + "extension_url_trust_required", + ) + + assert error["details"]["required_flag"] == "--trust-extension-urls" + assert not project.exists() + + +def test_json_init_explicit_url_trust_never_prompts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli.command_init as init_command + + def fail_prompt(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("trusted JSON URL install must not prompt") + + def fail_download(*_args: Any, **_kwargs: Any) -> str: + raise ValueError("download unavailable") + + monkeypatch.setattr(typer, "confirm", fail_prompt) + monkeypatch.setattr( + init_command, + "_install_extension_during_init", + fail_download, + ) + + payload = _success( + _invoke( + [ + "project", + "--json", + "--ignore-agent-tools", + "--extension", + "https://example.com/extension.zip", + "--trust-extension-urls", + ], + cwd=tmp_path, + ) + ) + + assert payload["components"]["extensions"][0]["status"] == "failed" + assert any( + warning["code"] == "extension_install_failed" + for warning in payload["warnings"] + ) + + +def test_json_init_invalid_default_integration_becomes_structured_warning( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "missing-default") + + payload = _success( + _invoke( + ["project", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + + assert payload["integration"]["key"] == "copilot" + assert payload["integration"]["defaulted"] is True + assert payload["warnings"][0]["code"] == "invalid_default_integration" + + +def test_json_init_exposes_optional_preset_and_extension_failures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli.command_init as init_command + from specify_cli.presets import PresetManager + + def fail_preset(*_args: Any, **_kwargs: Any) -> None: + raise OSError("preset install failed") + + def fail_extension(*_args: Any, **_kwargs: Any) -> str: + raise ValueError("extension install failed") + + monkeypatch.setattr(PresetManager, "install_from_directory", fail_preset) + monkeypatch.setattr( + init_command, + "_install_extension_during_init", + fail_extension, + ) + + payload = _success( + _invoke( + [ + "project", + "--json", + "--ignore-agent-tools", + "--preset", + "lean", + "--extension", + "git", + ], + cwd=tmp_path, + ) + ) + + assert payload["components"]["preset"]["status"] == "failed" + assert payload["components"]["extensions"] == [ + { + "requested": "git", + "status": "failed", + "reason": "extension install failed", + } + ] + warning_codes = {warning["code"] for warning in payload["warnings"]} + assert "preset_install_failed" in warning_codes + assert "extension_install_failed" in warning_codes + + +def test_json_init_exposes_skipped_bundled_workflow( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + monkeypatch.setattr(init_json, "_locate_bundled_workflow", lambda _id: None) + + payload = _success( + _invoke( + ["project", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + + assert payload["components"]["workflow"] == { + "id": "speckit", + "status": "skipped", + "reason": "bundled_workflow_not_found", + } + assert any( + warning["code"] == "bundled_workflow_not_found" + for warning in payload["warnings"] + ) + + +def test_json_init_rolls_back_new_target_after_fatal_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + def fail_shared(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise OSError("shared infrastructure failed") + + monkeypatch.setattr(init_json, "_install_shared_infrastructure", fail_shared) + project = tmp_path / "project" + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "initialization_failed", + ) + + assert error["details"]["rollback"] == { + "status": "completed", + "path": str(project.resolve()), + } + assert not project.exists() + + +def test_json_init_never_deletes_preexisting_target_after_fatal_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + project.mkdir() + marker = project / "keep.txt" + marker.write_text("keep", encoding="utf-8") + + def fail_shared(*_args: Any, **_kwargs: Any) -> dict[str, Any]: + raise OSError("shared infrastructure failed") + + monkeypatch.setattr(init_json, "_install_shared_infrastructure", fail_shared) + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--force", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ), + "initialization_failed", + ) + + assert "rollback" not in error["details"] + assert marker.read_text(encoding="utf-8") == "keep" + assert project.is_dir() + + +def test_json_init_rolls_back_when_integration_setup_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + from specify_cli.integrations import get_integration + + project = tmp_path / "project" + integration = get_integration("copilot") + assert integration is not None + + def fail_setup(*_args: Any, **_kwargs: Any) -> None: + project.mkdir(exist_ok=True) + raise OSError("integration setup failed") + + monkeypatch.setattr(integration, "setup", fail_setup) + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--integration", + "copilot", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ), + "initialization_failed", + ) + + assert error["details"]["component"] == "integration" + assert error["details"]["rollback"]["status"] == "completed" + assert not project.exists() + + +def test_json_init_rolls_back_when_manifest_construction_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli.integrations.manifest as manifest_module + + project = tmp_path / "project" + + class FailingManifest: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + project.mkdir(exist_ok=True) + raise OSError("manifest construction failed") + + monkeypatch.setattr(manifest_module, "IntegrationManifest", FailingManifest) + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "initialization_failed", + ) + + assert error["details"]["component"] == "integration" + assert error["details"]["rollback"]["status"] == "completed" + assert not project.exists() + + +def test_json_init_sanitizes_unexpected_exception_and_rolls_back( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + + def fail_unexpected(plan: Any) -> dict[str, Any]: + plan.project_path.mkdir() + raise RuntimeError("sensitive internal detail") + + monkeypatch.setattr(init_json, "_initialize_project", fail_unexpected) + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "internal_error", + ) + + assert error["details"]["exception_type"] == "RuntimeError" + assert "sensitive internal detail" not in json.dumps(error) + assert error["details"]["rollback"]["status"] == "completed" + assert not project.exists() + + +def test_json_init_reports_cleanup_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + + def fail_unexpected(plan: Any) -> dict[str, Any]: + plan.project_path.mkdir() + raise RuntimeError("initial failure") + + def fail_cleanup(_path: Path) -> None: + raise OSError("cleanup failed") + + monkeypatch.setattr(init_json, "_initialize_project", fail_unexpected) + monkeypatch.setattr(init_json.shutil, "rmtree", fail_cleanup) + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "rollback_failed", + ) + + assert error["details"]["original_error"]["code"] == "internal_error" + assert error["details"]["cleanup_error"]["exception_type"] == "OSError" + assert project.is_dir() + + +def test_noninteractive_human_output_remains_human_readable(tmp_path: Path): + result = _invoke( + [ + "human-project", + "--non-interactive", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + + assert result.exit_code == 0, result.output + assert "Project ready" in result.stdout + with pytest.raises(json.JSONDecodeError): + json.loads(result.stdout) From 2f7082cf4d758302eee3d7c78d7f543ac8e600ee Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:59:15 -0500 Subject: [PATCH 02/19] fix(init): keep JSON option conflicts structured Capture integration mode validation output before mutation so conflicting Copilot and Bob layout flags produce the documented invalid_integration_options error without leaking Rich output. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 48 +++++++++++++++++---- tests/specify_cli/test_command_init_json.py | 20 +++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 3bc0b89ca7..6111ffd452 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -210,6 +210,40 @@ def _parse_integration_options( return parsed +def _validate_integration_mode_options( + integration: Any, + parsed_options: dict[str, Any], + *, + integration_key: str, + project_root: Path, +) -> None: + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + try: + with ( + console.capture() as console_capture, + err_console.capture() as err_console_capture, + redirect_stdout(stdout_capture), + redirect_stderr(stderr_capture), + ): + integration.is_skills_mode( + parsed_options or None, + project_root=project_root, + ) + except (ValueError, typer.Exit) as exc: + captured = ( + stdout_capture.getvalue() + + console_capture.get() + + stderr_capture.getvalue() + + err_console_capture.get() + ) + raise _invalid_integration_options( + "Integration options are invalid.", + integration=integration_key, + reason=_single_line(captured or exc), + ) from exc + + def _resolve_default_integration( warnings: list[dict[str, Any]], ) -> str: @@ -342,14 +376,12 @@ def _build_plan( ) parsed_options = _parse_integration_options(integration, integration_options) - try: - integration.is_skills_mode(parsed_options or None, project_root=project_path) - except ValueError as exc: - raise _invalid_integration_options( - "Integration options are invalid.", - integration=selected_integration, - reason=_single_line(exc), - ) from exc + _validate_integration_mode_options( + integration, + parsed_options, + integration_key=selected_integration, + project_root=project_path, + ) if not ignore_agent_tools: agent_config = AGENT_CONFIG[selected_integration] diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 75e84ab539..3efbc31f8e 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -233,6 +233,26 @@ def test_json_init_reports_reinitialization(tmp_path: Path): ], "invalid_integration_options", ), + ( + [ + "project", + "--json", + "--integration", + "copilot", + "--integration-options=--skills --commands", + ], + "invalid_integration_options", + ), + ( + [ + "project", + "--json", + "--integration", + "bob", + "--integration-options=--skills --legacy-commands", + ], + "invalid_integration_options", + ), ( [ "project", From 135059aca4f36961909847ed8aafef629c0348f0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:19:02 -0500 Subject: [PATCH 03/19] fix(init): address JSON review findings Require an explicit boolean opt-in for JSON init callbacks, pass direct bundle callback defaults explicitly, and save the resolved active mode before extension and preset re-registration. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 50 ++++++------ src/specify_cli/bundles/_commands.py | 2 + src/specify_cli/command_init.py | 2 +- .../specify_cli/bundles/test_command_init.py | 14 ++++ tests/specify_cli/test_command_init_json.py | 81 +++++++++++++++++++ 5 files changed, 123 insertions(+), 26 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 6111ffd452..06035808d4 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -941,6 +941,31 @@ def _initialize_project(plan: _InitPlan) -> dict[str, Any]: }, ) from exc + try: + init_options: dict[str, Any] = { + "ai": plan.integration.key, + "integration": plan.integration.key, + "here": plan.here, + "script": plan.script_type, + "feature_numbering": "sequential", + "speckit_version": get_speckit_version(), + } + if plan.integration.is_skills_mode( + parsed_options, + project_root=plan.project_path, + ): + init_options["ai_skills"] = True + save_init_options(plan.project_path, init_options) + except (OSError, ValueError) as exc: + raise InitJsonFailure( + "initialization_failed", + "Failed to save initialization state.", + { + "component": "init_options", + "reason": _single_line(exc), + }, + ) from exc + try: _re_register_existing_artifacts(plan, warnings) @@ -973,31 +998,6 @@ def _initialize_project(plan: _InitPlan) -> dict[str, Any]: ) from exc workflow = _install_bundled_workflow(plan.project_path, warnings) - try: - init_options: dict[str, Any] = { - "ai": plan.integration.key, - "integration": plan.integration.key, - "here": plan.here, - "script": plan.script_type, - "feature_numbering": "sequential", - "speckit_version": get_speckit_version(), - } - if plan.integration.is_skills_mode( - parsed_options, - project_root=plan.project_path, - ): - init_options["ai_skills"] = True - save_init_options(plan.project_path, init_options) - except (OSError, ValueError) as exc: - raise InitJsonFailure( - "initialization_failed", - "Failed to save initialization state.", - { - "component": "init_options", - "reason": _single_line(exc), - }, - ) from exc - permissions = _ensure_script_permissions(plan.project_path, warnings) preset = _install_optional_preset(plan.project_path, plan.preset, warnings) extensions = _install_requested_extensions( diff --git a/src/specify_cli/bundles/_commands.py b/src/specify_cli/bundles/_commands.py index 5619ecc04b..df335be909 100644 --- a/src/specify_cli/bundles/_commands.py +++ b/src/specify_cli/bundles/_commands.py @@ -96,6 +96,8 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N ignore_agent_tools=True, here=True, force=True, + non_interactive=True, + json_output=False, skip_tls=False, debug=False, github_token=None, diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index b2ba4f7829..4a9157144e 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -405,7 +405,7 @@ def init( _write_integration_json, ) - if json_output: + if json_output is True: from ._command_init_json import run_init_json run_init_json( diff --git a/tests/specify_cli/bundles/test_command_init.py b/tests/specify_cli/bundles/test_command_init.py index adf60e0035..5c91b1eb0e 100644 --- a/tests/specify_cli/bundles/test_command_init.py +++ b/tests/specify_cli/bundles/test_command_init.py @@ -38,3 +38,17 @@ def test_override_symlinked_specify_errors_bundle_init_no_fallback( assert result.exit_code != 0 assert "symlinked .specify" in result.output assert not (elsewhere / ".specify").exists() + + +def test_bundle_init_direct_callback_keeps_human_output(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + result = runner.invoke( + app, + ["bundle", "init", "--integration", "copilot", "--offline"], + ) + + assert result.exit_code == 0, result.output + assert "Initializing a Spec Kit project" in result.output + assert "Spec Kit project ready" in result.output + assert '"project":{' not in result.output diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 3efbc31f8e..62042903a9 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -200,6 +200,87 @@ def test_json_init_reports_reinitialization(tmp_path: Path): assert payload["project"]["operation"] == "reinitialized" +def test_json_force_reinitialization_persists_new_mode_before_reregistration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + from specify_cli.extensions import ExtensionManager + from specify_cli.presets import PresetManager + + project = tmp_path / "project" + _success( + _invoke( + [ + str(project), + "--json", + "--integration", + "copilot", + "--integration-options=--commands", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + observed: list[tuple[str, str, dict[str, Any]]] = [] + + def record_extension_mode( + manager: ExtensionManager, + agent_name: str, + *, + force: bool = False, + ) -> None: + options = json.loads( + (manager.project_root / ".specify" / "init-options.json").read_text( + encoding="utf-8" + ) + ) + observed.append(("extension", agent_name, options)) + + def record_preset_mode(manager: PresetManager, agent_name: str) -> None: + options = json.loads( + (manager.project_root / ".specify" / "init-options.json").read_text( + encoding="utf-8" + ) + ) + observed.append(("preset", agent_name, options)) + + monkeypatch.setattr( + ExtensionManager, + "register_enabled_extensions_for_agent", + record_extension_mode, + ) + monkeypatch.setattr( + PresetManager, + "register_enabled_presets_for_agent", + record_preset_mode, + ) + + payload = _success( + _invoke( + [ + str(project), + "--json", + "--force", + "--integration", + "copilot", + "--integration-options=--skills", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + assert payload["project"]["operation"] == "reinitialized" + assert [(kind, agent) for kind, agent, _options in observed] == [ + ("extension", "copilot"), + ("preset", "copilot"), + ] + for _kind, _agent, options in observed: + assert options["ai"] == "copilot" + assert options["ai_skills"] is True + + @pytest.mark.parametrize( ("args", "code"), [ From 436560daba88481e64c8777b216d25ce23cd8b5e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:03:03 -0500 Subject: [PATCH 04/19] fix(init): claim JSON targets before mutation Atomically create new init targets after preflight and record ownership so rollback only removes directories claimed by the current invocation. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 39 +++++++++++++++++++-- tests/specify_cli/test_command_init_json.py | 36 +++++++++++++++++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 06035808d4..c1c4adfc0e 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -73,7 +73,7 @@ class _InitPlan: project_name: str project_path: Path operation: str - dir_existed_before: bool + target_owned: bool here: bool force: bool integration: Any @@ -412,7 +412,7 @@ def _build_plan( project_name=resolved_name, project_path=project_path, operation=operation, - dir_existed_before=dir_existed_before, + target_owned=False, here=here, force=force, integration=integration, @@ -428,6 +428,38 @@ def _build_plan( ) +def _claim_new_target(plan: _InitPlan) -> None: + if plan.operation != "created": + return + try: + plan.project_path.mkdir(parents=True, exist_ok=False) + except FileExistsError as exc: + if plan.project_path.is_dir(): + raise InitJsonFailure( + "target_exists", + "The target directory already exists; pass --force to merge into it.", + { + "path": str(plan.project_path), + "concurrent_creation": True, + }, + ) from exc + raise InitJsonFailure( + "target_not_directory", + "The target exists but is not a directory.", + { + "path": str(plan.project_path), + "concurrent_creation": True, + }, + ) from exc + except OSError as exc: + raise InitJsonFailure( + "target_unavailable", + "The target directory could not be created.", + {"path": str(plan.project_path), "reason": _single_line(exc)}, + ) from exc + plan.target_owned = True + + def _extension_spec_is_url(value: str) -> bool: from urllib.parse import urlparse @@ -1055,7 +1087,7 @@ def _rollback_new_target( plan: _InitPlan, failure: InitJsonFailure, ) -> InitJsonFailure: - if plan.dir_existed_before or not plan.project_path.exists(): + if not plan.target_owned or not plan.project_path.exists(): return failure try: shutil.rmtree(plan.project_path) @@ -1113,6 +1145,7 @@ def run_init_json( extensions=extensions, trust_extension_urls=trust_extension_urls, ) + _claim_new_target(plan) except Exception as exc: # noqa: BLE001 - JSON boundary sanitizes all failures _emit_failure(_as_failure(exc)) diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 62042903a9..c52478b8be 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -614,6 +614,40 @@ def fail_shared(*_args: Any, **_kwargs: Any) -> dict[str, Any]: assert project.is_dir() +def test_json_init_never_deletes_target_created_during_claim_race( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + marker = project / "keep.txt" + original_mkdir = Path.mkdir + + def racing_mkdir(path: Path, *args: Any, **kwargs: Any) -> None: + if path == project and kwargs.get("exist_ok") is False: + original_mkdir(path, parents=True) + marker.write_text("keep", encoding="utf-8") + original_mkdir(path, *args, **kwargs) + + def fail_initialize(_plan: Any) -> dict[str, Any]: + raise AssertionError("initialization must not start without target ownership") + + monkeypatch.setattr(Path, "mkdir", racing_mkdir) + monkeypatch.setattr(init_json, "_initialize_project", fail_initialize) + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "target_exists", + ) + + assert error["details"]["concurrent_creation"] is True + assert marker.read_text(encoding="utf-8") == "keep" + assert project.is_dir() + + def test_json_init_rolls_back_when_integration_setup_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): @@ -683,7 +717,6 @@ def test_json_init_sanitizes_unexpected_exception_and_rolls_back( project = tmp_path / "project" def fail_unexpected(plan: Any) -> dict[str, Any]: - plan.project_path.mkdir() raise RuntimeError("sensitive internal detail") monkeypatch.setattr(init_json, "_initialize_project", fail_unexpected) @@ -710,7 +743,6 @@ def test_json_init_reports_cleanup_failure( project = tmp_path / "project" def fail_unexpected(plan: Any) -> dict[str, Any]: - plan.project_path.mkdir() raise RuntimeError("initial failure") def fail_cleanup(_path: Path) -> None: From fd4d4550074133b67264cb4e1c3e799c0bb6cb05 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:30:14 -0500 Subject: [PATCH 05/19] fix(init): preflight generic command paths Resolve and validate generic integration command destinations before JSON init claims a target, while reusing the same containment check during setup. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 13 +++++++ .../integrations/generic/__init__.py | 34 +++++++++++++------ tests/specify_cli/test_command_init_json.py | 32 +++++++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index c1c4adfc0e..08d867cd8d 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -382,6 +382,19 @@ def _build_plan( integration_key=selected_integration, project_root=project_path, ) + if selected_integration == "generic": + try: + integration._resolve_commands_destination( + project_path, + parsed_options, + {"raw_options": integration_options}, + ) + except ValueError as exc: + raise _invalid_integration_options( + "Integration options are invalid.", + integration=selected_integration, + reason=_single_line(exc), + ) from exc if not ignore_agent_tools: agent_config = AGENT_CONFIG[selected_integration] diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 0dbe598ab7..078fd3fd5b 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -154,6 +154,25 @@ def _resolve_commands_dir( "--commands-dir is required for the generic integration" ) + @classmethod + def _resolve_commands_destination( + cls, + project_root: Path, + parsed_options: dict[str, Any] | None, + opts: dict[str, Any], + ) -> Path: + commands_dir = cls._resolve_commands_dir(parsed_options, opts) + project_root_resolved = project_root.resolve() + destination = (project_root / commands_dir).resolve() + try: + destination.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {destination} escapes " + f"project root {project_root_resolved}" + ) from exc + return destination + def _build_skill_content( self, src_file: Path, script_type: str, project_root: Path ) -> tuple[str, str]: @@ -235,8 +254,6 @@ def setup( **opts: Any, ) -> list[Path]: """Install commands to the user-provided commands directory.""" - commands_dir = self._resolve_commands_dir(parsed_options, opts) - templates = self.list_command_templates() if not templates: return [] @@ -248,14 +265,11 @@ def setup( f"project_root ({project_root_resolved})" ) - dest = (project_root / commands_dir).resolve() - try: - dest.relative_to(project_root_resolved) - except ValueError as exc: - raise ValueError( - f"Integration destination {dest} escapes " - f"project root {project_root_resolved}" - ) from exc + dest = self._resolve_commands_destination( + project_root, + parsed_options, + opts, + ) dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index c52478b8be..fc02c4d0ec 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -354,6 +354,38 @@ def test_json_init_validation_failures_do_not_create_target( assert not (tmp_path / "project").exists() +def test_json_init_rejects_escaping_generic_directory_before_target_claim( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + + def fail_claim(_plan: Any) -> None: + raise AssertionError("invalid integration options must fail before mutation") + + monkeypatch.setattr(init_json, "_claim_new_target", fail_claim) + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--integration", + "generic", + "--integration-options=--commands-dir ../outside", + ], + cwd=tmp_path, + ), + "invalid_integration_options", + ) + + assert "escapes project root" in error["details"]["reason"] + assert not project.exists() + assert not (tmp_path / "outside").exists() + + @pytest.mark.parametrize( "args", [ From 98dc78d99f8d7af5b0f37232969362d74cf0eb03 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:18:48 -0500 Subject: [PATCH 06/19] fix(init): reject unsafe layout reinitialization Reject command-to-skills layout changes through JSON init before mutation and direct callers to the integration upgrade path, preserving existing manifests and artifacts. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 7 +- src/specify_cli/_command_init_json.py | 71 +++++++++++++++++-- tests/specify_cli/test_command_init_json.py | 77 +++++++++++++++++++-- 3 files changed, 141 insertions(+), 14 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index 2c99657537..cb68ea8dfa 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -171,7 +171,7 @@ Stable init error codes are: | `target_exists` | A named target directory already exists without `--force` | | `target_not_empty` | The `--here` target is non-empty without `--force` | | `target_not_directory` | The target exists but is not a directory | -| `target_unavailable` | The target directory cannot be inspected | +| `target_unavailable` | The target directory cannot be inspected or created | | `invalid_arguments` | Command-line syntax or option parsing failed before initialization | | `invalid_integration` | The requested integration is not registered | | `invalid_integration_options` | Integration options are unknown, malformed, conflicting, or incomplete | @@ -192,7 +192,10 @@ JSON mode has the same successful side effects as human mode: it installs the selected integration, shared templates and scripts, bundled workflow, constitution, optional preset, and requested extensions, and it writes the normal `.specify/` state. `--force` retains its existing merge/overwrite -behavior. +behavior. Reinitialization does not perform command-to-skills or +skills-to-command layout migrations for an already installed dual-mode +integration; JSON mode rejects that change before mutation and reports a +machine-usable `integration_upgrade` recommendation. **Preview compatibility:** field names, operation values, error envelope, and error codes above are the supported preview contract. New optional fields, diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 08d867cd8d..09d44dfe5b 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -216,7 +216,7 @@ def _validate_integration_mode_options( *, integration_key: str, project_root: Path, -) -> None: +) -> bool: stdout_capture = io.StringIO() stderr_capture = io.StringIO() try: @@ -226,9 +226,11 @@ def _validate_integration_mode_options( redirect_stdout(stdout_capture), redirect_stderr(stderr_capture), ): - integration.is_skills_mode( - parsed_options or None, - project_root=project_root, + return bool( + integration.is_skills_mode( + parsed_options or None, + project_root=project_root, + ) ) except (ValueError, typer.Exit) as exc: captured = ( @@ -244,6 +246,59 @@ def _validate_integration_mode_options( ) from exc +def _validate_existing_integration_layout( + *, + integration_key: str, + project_root: Path, + requested_skills_mode: bool, + raw_integration_options: str | None, +) -> None: + if integration_key not in {"bob", "copilot", "generic"}: + return + + from .integrations._command_upgrade_layout import ( + _manifest_tracks_skill_layout, + ) + from .integrations.manifest import IntegrationManifest + + manifest_path = ( + project_root + / ".specify" + / "integrations" + / f"{integration_key}.manifest.json" + ) + if not manifest_path.is_file(): + return + try: + old_manifest = IntegrationManifest.load(integration_key, project_root) + except (OSError, ValueError) as exc: + raise InitJsonFailure( + "initialization_failed", + "The installed integration manifest could not be read.", + { + "component": "integration_manifest", + "integration": integration_key, + "reason": _single_line(exc), + }, + ) from exc + + current_skills_mode = _manifest_tracks_skill_layout(old_manifest) + if current_skills_mode == requested_skills_mode: + return + raise _invalid_integration_options( + "Changing an installed integration layout through init is not supported.", + integration=integration_key, + current_layout="skills" if current_skills_mode else "commands", + requested_layout="skills" if requested_skills_mode else "commands", + recommended_action={ + "command": "integration_upgrade", + "integration": integration_key, + "force": True, + "integration_options": raw_integration_options, + }, + ) + + def _resolve_default_integration( warnings: list[dict[str, Any]], ) -> str: @@ -376,7 +431,7 @@ def _build_plan( ) parsed_options = _parse_integration_options(integration, integration_options) - _validate_integration_mode_options( + selected_skills_mode = _validate_integration_mode_options( integration, parsed_options, integration_key=selected_integration, @@ -395,6 +450,12 @@ def _build_plan( integration=selected_integration, reason=_single_line(exc), ) from exc + _validate_existing_integration_layout( + integration_key=selected_integration, + project_root=project_path, + requested_skills_mode=selected_skills_mode, + raw_integration_options=integration_options, + ) if not ignore_agent_tools: agent_config = AGENT_CONFIG[selected_integration] diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index fc02c4d0ec..70b65c8f11 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -200,7 +200,7 @@ def test_json_init_reports_reinitialization(tmp_path: Path): assert payload["project"]["operation"] == "reinitialized" -def test_json_force_reinitialization_persists_new_mode_before_reregistration( +def test_json_force_reinitialization_persists_new_integration_before_reregistration( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): @@ -215,7 +215,6 @@ def test_json_force_reinitialization_persists_new_mode_before_reregistration( "--json", "--integration", "copilot", - "--integration-options=--commands", "--ignore-agent-tools", ], cwd=tmp_path, @@ -263,8 +262,7 @@ def record_preset_mode(manager: PresetManager, agent_name: str) -> None: "--json", "--force", "--integration", - "copilot", - "--integration-options=--skills", + "claude", "--ignore-agent-tools", ], cwd=tmp_path, @@ -273,14 +271,79 @@ def record_preset_mode(manager: PresetManager, agent_name: str) -> None: assert payload["project"]["operation"] == "reinitialized" assert [(kind, agent) for kind, agent, _options in observed] == [ - ("extension", "copilot"), - ("preset", "copilot"), + ("extension", "claude"), + ("preset", "claude"), ] for _kind, _agent, options in observed: - assert options["ai"] == "copilot" + assert options["ai"] == "claude" assert options["ai_skills"] is True +def test_json_force_reinitialization_rejects_layout_change_before_mutation( + tmp_path: Path, +): + project = tmp_path / "project" + _success( + _invoke( + [ + str(project), + "--json", + "--integration", + "copilot", + "--integration-options=--commands", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + manifest_path = ( + project / ".specify" / "integrations" / "copilot.manifest.json" + ) + init_options_path = project / ".specify" / "init-options.json" + old_manifest = manifest_path.read_bytes() + old_init_options = init_options_path.read_bytes() + old_command_files = sorted( + path.relative_to(project) + for root in (project / ".github" / "agents", project / ".github" / "prompts") + for path in root.glob("speckit.*") + ) + assert old_command_files + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--force", + "--integration", + "copilot", + "--integration-options=--skills", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ), + "invalid_integration_options", + ) + + assert error["details"]["current_layout"] == "commands" + assert error["details"]["requested_layout"] == "skills" + assert error["details"]["recommended_action"] == { + "command": "integration_upgrade", + "integration": "copilot", + "force": True, + "integration_options": "--skills", + } + assert manifest_path.read_bytes() == old_manifest + assert init_options_path.read_bytes() == old_init_options + assert sorted( + path.relative_to(project) + for root in (project / ".github" / "agents", project / ".github" / "prompts") + for path in root.glob("speckit.*") + ) == old_command_files + assert not (project / ".github" / "skills").exists() + + @pytest.mark.parametrize( ("args", "code"), [ From 742ff7b133085de6eca856661e6020f4dbc051be Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:38:48 -0500 Subject: [PATCH 07/19] fix(init): guard generic destination changes Reject generic command-directory migrations through JSON init before mutation while allowing same-destination reinitialization and recommending integration upgrade. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 3 +- src/specify_cli/_command_init_json.py | 49 +++++++++++- tests/specify_cli/test_command_init_json.py | 82 +++++++++++++++++++++ 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index cb68ea8dfa..cc06589e6c 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -194,7 +194,8 @@ constitution, optional preset, and requested extensions, and it writes the normal `.specify/` state. `--force` retains its existing merge/overwrite behavior. Reinitialization does not perform command-to-skills or skills-to-command layout migrations for an already installed dual-mode -integration; JSON mode rejects that change before mutation and reports a +integration, or move an installed generic integration to a different command +directory. JSON mode rejects those changes before mutation and reports a machine-usable `integration_upgrade` recommendation. **Preview compatibility:** field names, operation values, error envelope, and diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 09d44dfe5b..c1359afff3 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -251,6 +251,7 @@ def _validate_existing_integration_layout( integration_key: str, project_root: Path, requested_skills_mode: bool, + requested_commands_destination: Path | None, raw_integration_options: str | None, ) -> None: if integration_key not in {"bob", "copilot", "generic"}: @@ -284,12 +285,50 @@ def _validate_existing_integration_layout( current_skills_mode = _manifest_tracks_skill_layout(old_manifest) if current_skills_mode == requested_skills_mode: + if integration_key != "generic": + return + else: + raise _invalid_integration_options( + "Changing an installed integration layout through init is not supported.", + integration=integration_key, + current_layout="skills" if current_skills_mode else "commands", + requested_layout="skills" if requested_skills_mode else "commands", + recommended_action={ + "command": "integration_upgrade", + "integration": integration_key, + "force": True, + "integration_options": raw_integration_options, + }, + ) + + assert requested_commands_destination is not None + from .integrations.generic import registration_directory + + try: + current_commands_destination = registration_directory(project_root) + except (OSError, ValueError) as exc: + raise InitJsonFailure( + "initialization_failed", + "The installed generic integration settings could not be read.", + { + "component": "integration_state", + "integration": integration_key, + "reason": _single_line(exc), + }, + ) from exc + if current_commands_destination == requested_commands_destination: return + + project_root_resolved = project_root.resolve() raise _invalid_integration_options( - "Changing an installed integration layout through init is not supported.", + "Changing the generic command destination through init is not supported.", integration=integration_key, - current_layout="skills" if current_skills_mode else "commands", - requested_layout="skills" if requested_skills_mode else "commands", + current_commands_dir=current_commands_destination.relative_to( + project_root_resolved + ).as_posix(), + requested_commands_dir=requested_commands_destination.relative_to( + project_root_resolved + ).as_posix(), recommended_action={ "command": "integration_upgrade", "integration": integration_key, @@ -437,9 +476,10 @@ def _build_plan( integration_key=selected_integration, project_root=project_path, ) + requested_commands_destination: Path | None = None if selected_integration == "generic": try: - integration._resolve_commands_destination( + requested_commands_destination = integration._resolve_commands_destination( project_path, parsed_options, {"raw_options": integration_options}, @@ -454,6 +494,7 @@ def _build_plan( integration_key=selected_integration, project_root=project_path, requested_skills_mode=selected_skills_mode, + requested_commands_destination=requested_commands_destination, raw_integration_options=integration_options, ) diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 70b65c8f11..313b19147c 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -344,6 +344,88 @@ def test_json_force_reinitialization_rejects_layout_change_before_mutation( assert not (project / ".github" / "skills").exists() +def test_json_force_reinitialization_rejects_generic_destination_change( + tmp_path: Path, +): + project = tmp_path / "project" + _success( + _invoke( + [ + str(project), + "--json", + "--integration", + "generic", + "--integration-options=--commands-dir .old/commands", + ], + cwd=tmp_path, + ) + ) + + manifest_path = ( + project / ".specify" / "integrations" / "generic.manifest.json" + ) + init_options_path = project / ".specify" / "init-options.json" + old_manifest = manifest_path.read_bytes() + old_init_options = init_options_path.read_bytes() + old_commands = { + path.relative_to(project): path.read_bytes() + for path in (project / ".old" / "commands").glob("speckit.*") + } + assert old_commands + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--force", + "--integration", + "generic", + "--integration-options=--commands-dir .new/commands", + ], + cwd=tmp_path, + ), + "invalid_integration_options", + ) + + assert error["details"]["current_commands_dir"] == ".old/commands" + assert error["details"]["requested_commands_dir"] == ".new/commands" + assert error["details"]["recommended_action"] == { + "command": "integration_upgrade", + "integration": "generic", + "force": True, + "integration_options": "--commands-dir .new/commands", + } + assert manifest_path.read_bytes() == old_manifest + assert init_options_path.read_bytes() == old_init_options + assert { + path.relative_to(project): path.read_bytes() + for path in (project / ".old" / "commands").glob("speckit.*") + } == old_commands + assert not (project / ".new").exists() + + +def test_json_force_reinitialization_allows_same_generic_destination( + tmp_path: Path, +): + project = tmp_path / "project" + args = [ + str(project), + "--json", + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + ] + _success(_invoke(args, cwd=tmp_path)) + + payload = _success( + _invoke([*args, "--force"], cwd=tmp_path) + ) + + assert payload["project"]["operation"] == "reinitialized" + assert list((project / ".agent" / "commands").glob("speckit.*")) + + @pytest.mark.parametrize( ("args", "code"), [ From 417294dd22191373f34d75bab959c6212a8134ed Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:59:14 -0500 Subject: [PATCH 08/19] fix(init): align JSON default metadata Report empty integration and script selections as defaulted, add regression coverage, and document re-registration failures as warning-only outcomes. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 11 +-- src/specify_cli/_command_init_json.py | 4 +- tests/specify_cli/test_command_init_json.py | 78 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index cc06589e6c..9316a714c7 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -149,11 +149,12 @@ obsolete universal `schema_version` / `ok` / `result` wrapper. Spec Kit project; - `reinitialized`: the target already contained a `.specify/` directory. -Optional preset, extension, workflow, constitution, permission, or -re-registration failures that do not prevent initialization are represented in -their component outcome and in the structured `warnings` array. They are never -printed as terminal warnings or silently discarded. `next_steps` uses -machine-usable actions and paths rather than shell-formatted prose. +Optional preset, extension, workflow, constitution, or permission failures that +do not prevent initialization are represented in their component outcome and in +the structured `warnings` array. Best-effort extension and preset +re-registration failures are warning-only. No caught failure is printed as a +terminal warning or silently discarded. `next_steps` uses machine-usable +actions and paths rather than shell-formatted prose. On failure, stdout is empty and stderr contains exactly one UTF-8 JSON error object followed by a newline: diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index c1359afff3..03c804be2a 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -456,7 +456,7 @@ def _build_plan( }, ) - integration_defaulted = integration_key is None + integration_defaulted = not integration_key selected_integration = integration_key or _resolve_default_integration(warnings) integration = get_integration(selected_integration) if integration is None or selected_integration not in AGENT_CONFIG: @@ -511,7 +511,7 @@ def _build_plan( }, ) - script_defaulted = script_type is None + script_defaulted = not script_type selected_script = script_type or ("ps" if os.name == "nt" else "sh") if selected_script not in SCRIPT_TYPE_CHOICES: raise InitJsonFailure( diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 313b19147c..f548b0e9c9 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -108,6 +108,35 @@ def test_json_init_here_honors_explicit_integration_and_script(tmp_path: Path): assert all(step["action"] != "change_directory" for step in payload["next_steps"]) +def test_json_init_empty_selections_use_and_report_safe_defaults(tmp_path: Path): + project = tmp_path / "empty-selections" + + payload = _success( + _invoke( + [ + str(project), + "--json", + "--integration", + "", + "--script", + "", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + assert payload["integration"] == { + "key": "copilot", + "defaulted": True, + "status": "installed", + } + assert payload["script"] == { + "type": "ps" if os.name == "nt" else "sh", + "defaulted": True, + } + + def test_json_init_never_prompts_even_when_stdin_is_a_tty( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): @@ -426,6 +455,55 @@ def test_json_force_reinitialization_allows_same_generic_destination( assert list((project / ".agent" / "commands").glob("speckit.*")) +def test_json_reinitialization_reports_reregistration_failures_as_warnings( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + from specify_cli.extensions import ExtensionManager + from specify_cli.presets import PresetManager + + project = tmp_path / "project" + _success( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + + def fail_extensions(*_args: Any, **_kwargs: Any) -> None: + raise OSError("extension re-registration failed") + + def fail_presets(*_args: Any, **_kwargs: Any) -> None: + raise OSError("preset re-registration failed") + + monkeypatch.setattr( + ExtensionManager, + "register_enabled_extensions_for_agent", + fail_extensions, + ) + monkeypatch.setattr( + PresetManager, + "register_enabled_presets_for_agent", + fail_presets, + ) + + payload = _success( + _invoke( + [ + str(project), + "--json", + "--force", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + warning_codes = {warning["code"] for warning in payload["warnings"]} + assert "extension_reregistration_failed" in warning_codes + assert "preset_reregistration_failed" in warning_codes + + @pytest.mark.parametrize( ("args", "code"), [ From 198d013fc8094b942f04e6e655d9a5c9d029afb8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:19:31 -0500 Subject: [PATCH 09/19] fix(init): honor parser and generic state ordering Respect the end-of-options marker when detecting JSON parse errors and persist integration runtime settings before artifact re-registration. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 7 +-- tests/specify_cli/test_command_init_json.py | 50 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 03c804be2a..795e6ebfb0 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -54,7 +54,8 @@ class InitJsonCommand(TyperCommand): """Keep JSON-mode parse failures on the init error contract.""" def make_context(self, info_name, args, parent=None, **extra): - json_output = "--json" in args + end_of_options = args.index("--") if "--" in args else len(args) + json_output = "--json" in args[:end_of_options] try: return super().make_context(info_name, args, parent=parent, **extra) except _UsageError as error: @@ -1114,8 +1115,6 @@ def _initialize_project(plan: _InitPlan) -> dict[str, Any]: ) from exc try: - _re_register_existing_artifacts(plan, warnings) - settings = with_integration_setting( {}, plan.integration.key, @@ -1133,6 +1132,8 @@ def _initialize_project(plan: _InitPlan) -> dict[str, Any]: settings=settings, ) + _re_register_existing_artifacts(plan, warnings) + shared_infrastructure = _install_shared_infrastructure(plan, warnings) except (OSError, ValueError) as exc: raise InitJsonFailure( diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index f548b0e9c9..458f37425c 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -504,6 +504,43 @@ def fail_presets(*_args: Any, **_kwargs: Any) -> None: assert "preset_reregistration_failed" in warning_codes +def test_json_switch_to_generic_persists_settings_before_extension_reregistration( + tmp_path: Path, +): + project = tmp_path / "project" + initial = _success( + _invoke( + [ + str(project), + "--json", + "--ignore-agent-tools", + "--extension", + "git", + ], + cwd=tmp_path, + ) + ) + assert initial["components"]["extensions"][0]["status"] == "installed" + + payload = _success( + _invoke( + [ + str(project), + "--json", + "--force", + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + ], + cwd=tmp_path, + ) + ) + + warning_codes = {warning["code"] for warning in payload["warnings"]} + assert "extension_reregistration_failed" not in warning_codes + assert list((project / ".agent" / "commands").glob("speckit.git.*.md")) + + @pytest.mark.parametrize( ("args", "code"), [ @@ -624,6 +661,19 @@ def test_json_init_parser_failures_use_structured_error( assert not (tmp_path / "project").exists() +def test_end_of_options_json_project_name_keeps_human_parser_errors( + tmp_path: Path, +): + result = _invoke(["--", "--json", "extra"], cwd=tmp_path) + + assert result.exit_code == 2 + assert result.stdout == "" + assert "Usage:" in result.stderr + assert "unexpected extra argument" in result.stderr.lower() + with pytest.raises(json.JSONDecodeError): + json.loads(result.stderr) + + def test_json_init_rejects_existing_named_target_without_force(tmp_path: Path): project = tmp_path / "project" project.mkdir() From 4f1db66167f54433166efb98ca206aa799306f01 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:40:14 -0500 Subject: [PATCH 10/19] fix(init): harden JSON output and rollback Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 6 +- src/specify_cli/_command_init_json.py | 153 ++++++++++++++------ tests/specify_cli/test_command_init_json.py | 108 ++++++++++++++ 3 files changed, 221 insertions(+), 46 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index 9316a714c7..08155ae443 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -186,8 +186,10 @@ Stable init error codes are: All target, option, required-tool, and URL-trust checks run before filesystem mutation. URL extension trust is default-deny. If a later fatal error occurs after a new target was created, init removes that target; a pre-existing target -is never deleted. A cleanup failure is returned as `rollback_failed` with the -original error nested in `details`. +is never deleted. Rollback verifies that the target still identifies the +directory created by the current invocation before removing it; a concurrently +replaced target is preserved. A cleanup or identity-verification failure is +returned as `rollback_failed` with the original error nested in `details`. JSON mode has the same successful side effects as human mode: it installs the selected integration, shared templates and scripts, bundled workflow, diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 795e6ebfb0..44d579da9f 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -7,6 +7,7 @@ import os import shlex import shutil +import stat import sys from contextlib import redirect_stderr, redirect_stdout from dataclasses import dataclass, field @@ -74,7 +75,7 @@ class _InitPlan: project_name: str project_path: Path operation: str - target_owned: bool + target_identity: tuple[int, int] | None here: bool force: bool integration: Any @@ -102,7 +103,7 @@ def _single_line(value: object, *, limit: int = 500) -> str: def _emit(value: dict[str, Any], *, error: bool = False) -> None: stream = sys.stderr if error else sys.stdout - stream.write( + payload = ( json.dumps( value, ensure_ascii=False, @@ -110,6 +111,40 @@ def _emit(value: dict[str, Any], *, error: bool = False) -> None: separators=(",", ":"), ) + "\n" + ).encode("utf-8") + binary_stream = getattr(stream, "buffer", None) + if binary_stream is None: + stream.write(payload.decode("utf-8")) + stream.flush() + return + binary_stream.write(payload) + binary_stream.flush() + + +def _directory_identity(path: Path) -> tuple[int, int]: + state = path.stat(follow_symlinks=False) + if not stat.S_ISDIR(state.st_mode): + raise OSError(f"Target is no longer a directory: {path}") + return state.st_dev, state.st_ino + + +def _rollback_failure( + plan: _InitPlan, + failure: InitJsonFailure, + cleanup_error: dict[str, Any], +) -> InitJsonFailure: + return InitJsonFailure( + "rollback_failed", + "Initialization failed and the newly created target could not be removed.", + { + "path": str(plan.project_path), + "original_error": { + "code": failure.code, + "message": failure.message, + "details": failure.details, + }, + "cleanup_error": cleanup_error, + }, ) @@ -528,7 +563,7 @@ def _build_plan( project_name=resolved_name, project_path=project_path, operation=operation, - target_owned=False, + target_identity=None, here=here, force=force, integration=integration, @@ -573,7 +608,14 @@ def _claim_new_target(plan: _InitPlan) -> None: "The target directory could not be created.", {"path": str(plan.project_path), "reason": _single_line(exc)}, ) from exc - plan.target_owned = True + try: + plan.target_identity = _directory_identity(plan.project_path) + except OSError as exc: + raise InitJsonFailure( + "target_unavailable", + "The newly created target directory could not be verified.", + {"path": str(plan.project_path), "reason": _single_line(exc)}, + ) from exc def _extension_spec_is_url(value: str) -> bool: @@ -1203,25 +1245,42 @@ def _rollback_new_target( plan: _InitPlan, failure: InitJsonFailure, ) -> InitJsonFailure: - if not plan.target_owned or not plan.project_path.exists(): + if plan.target_identity is None: return failure + try: + current_identity = _directory_identity(plan.project_path) + except OSError as cleanup_error: + return _rollback_failure( + plan, + failure, + { + "code": "target_identity_unavailable", + "exception_type": cleanup_error.__class__.__name__, + "reason": _single_line(cleanup_error), + }, + ) + if current_identity != plan.target_identity: + return _rollback_failure( + plan, + failure, + { + "code": "target_identity_changed", + "reason": ( + "The target path no longer identifies the directory created " + "by this invocation." + ), + }, + ) try: shutil.rmtree(plan.project_path) except OSError as cleanup_error: - return InitJsonFailure( - "rollback_failed", - "Initialization failed and the newly created target could not be removed.", + return _rollback_failure( + plan, + failure, { - "path": str(plan.project_path), - "original_error": { - "code": failure.code, - "message": failure.message, - "details": failure.details, - }, - "cleanup_error": { - "exception_type": cleanup_error.__class__.__name__, - "reason": _single_line(cleanup_error), - }, + "code": "target_cleanup_failed", + "exception_type": cleanup_error.__class__.__name__, + "reason": _single_line(cleanup_error), }, ) failure.details = { @@ -1248,39 +1307,45 @@ def run_init_json( trust_extension_urls: bool, ) -> None: """Execute ``specify init --json`` and emit exactly one JSON object.""" - try: - plan = _build_plan( - project_name=project_name, - script_type=script_type, - ignore_agent_tools=ignore_agent_tools, - here=here, - force=force, - preset=preset, - integration_key=integration, - integration_options=integration_options, - extensions=extensions, - trust_extension_urls=trust_extension_urls, - ) - _claim_new_target(plan) - except Exception as exc: # noqa: BLE001 - JSON boundary sanitizes all failures - _emit_failure(_as_failure(exc)) - + plan: _InitPlan | None = None + result: dict[str, Any] | None = None + failure: InitJsonFailure | None = None stdout_capture = io.StringIO() stderr_capture = io.StringIO() try: with ( - console.capture() as console_capture, - err_console.capture() as err_console_capture, redirect_stdout(stdout_capture), redirect_stderr(stderr_capture), + console.capture() as console_capture, + err_console.capture() as err_console_capture, ): + plan = _build_plan( + project_name=project_name, + script_type=script_type, + ignore_agent_tools=ignore_agent_tools, + here=here, + force=force, + preset=preset, + integration_key=integration, + integration_options=integration_options, + extensions=extensions, + trust_extension_urls=trust_extension_urls, + ) + _claim_new_target(plan) result = _initialize_project(plan) - _record_suppressed_output( - result["warnings"], - stdout=stdout_capture.getvalue() + console_capture.get(), - stderr=stderr_capture.getvalue() + err_console_capture.get(), - ) except Exception as exc: # noqa: BLE001 - JSON boundary sanitizes all failures - _emit_failure(_rollback_new_target(plan, _as_failure(exc))) - + failure = _as_failure(exc) + if plan is not None: + failure = _rollback_new_target(plan, failure) + + if failure is not None: + _emit_failure(failure) + + assert plan is not None + assert result is not None + _record_suppressed_output( + result["warnings"], + stdout=stdout_capture.getvalue() + console_capture.get(), + stderr=stderr_capture.getvalue() + err_console_capture.get(), + ) _emit(result) diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 458f37425c..708f0ef666 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -2,8 +2,10 @@ from __future__ import annotations +import io import json import os +import sys from pathlib import Path from typing import Any @@ -163,6 +165,75 @@ def fail_prompt(*_args: Any, **_kwargs: Any) -> None: assert payload["script"]["defaulted"] is True +def test_json_init_emits_utf8_bytes_with_non_utf_text_encoding( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + project_name = "prøject" + raw_stdout = io.BytesIO() + encoded_stdout = io.TextIOWrapper(raw_stdout, encoding="utf-16") + monkeypatch.setattr(sys, "stdout", encoded_stdout) + previous = Path.cwd() + os.chdir(tmp_path) + try: + init_json.run_init_json( + project_name=project_name, + script_type=None, + ignore_agent_tools=True, + here=False, + force=False, + preset=None, + integration=None, + integration_options=None, + extensions=None, + trust_extension_urls=False, + ) + finally: + os.chdir(previous) + + output = raw_stdout.getvalue() + assert output.endswith(b"\n") + payload = json.loads(output.decode("utf-8")) + assert payload["project"]["name"] == project_name + assert (tmp_path / project_name / ".specify").is_dir() + + +def test_json_init_error_emits_utf8_bytes_with_non_utf_text_encoding( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + raw_stderr = io.BytesIO() + encoded_stderr = io.TextIOWrapper(raw_stderr, encoding="utf-16") + monkeypatch.setattr(sys, "stderr", encoded_stderr) + previous = Path.cwd() + os.chdir(tmp_path) + try: + with pytest.raises(typer.Exit): + init_json.run_init_json( + project_name="project", + script_type=None, + ignore_agent_tools=True, + here=False, + force=False, + preset=None, + integration="intégration", + integration_options=None, + extensions=None, + trust_extension_urls=False, + ) + finally: + os.chdir(previous) + + output = raw_stderr.getvalue() + assert output.endswith(b"\n") + payload = json.loads(output.decode("utf-8")) + assert payload["error"]["code"] == "invalid_integration" + assert payload["error"]["details"]["integration"] == "intégration" + assert not (tmp_path / "project").exists() + + def test_json_init_rejects_nonempty_here_without_force_and_preserves_files( tmp_path: Path, ): @@ -953,6 +1024,42 @@ def fail_initialize(_plan: Any) -> dict[str, Any]: assert project.is_dir() +def test_json_init_never_deletes_replacement_after_target_claim( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + moved_claim = tmp_path / "moved-claim" + marker = project / "keep.txt" + + def replace_target_and_fail(plan: Any) -> dict[str, Any]: + plan.project_path.rename(moved_claim) + plan.project_path.mkdir() + marker.write_text("keep", encoding="utf-8") + raise RuntimeError("initialization failed after target replacement") + + monkeypatch.setattr( + init_json, + "_initialize_project", + replace_target_and_fail, + ) + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "rollback_failed", + ) + + assert error["details"]["original_error"]["code"] == "internal_error" + assert error["details"]["cleanup_error"]["code"] == "target_identity_changed" + assert marker.read_text(encoding="utf-8") == "keep" + assert project.is_dir() + assert moved_claim.is_dir() + + def test_json_init_rolls_back_when_integration_setup_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): @@ -1065,6 +1172,7 @@ def fail_cleanup(_path: Path) -> None: ) assert error["details"]["original_error"]["code"] == "internal_error" + assert error["details"]["cleanup_error"]["code"] == "target_cleanup_failed" assert error["details"]["cleanup_error"]["exception_type"] == "OSError" assert project.is_dir() From 718969124891965023f5837d83ddc92ab88f86a6 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:11:18 -0500 Subject: [PATCH 11/19] fix(init): stabilize JSON component outcomes Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 4 +- src/specify_cli/_command_init_json.py | 32 +++-- src/specify_cli/command_init.py | 51 ++++++-- tests/integrations/test_cli.py | 3 +- tests/specify_cli/test_command_init_json.py | 125 ++++++++++++++++++++ 5 files changed, 191 insertions(+), 24 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index 08155ae443..c59fb539e8 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -154,7 +154,9 @@ do not prevent initialization are represented in their component outcome and in the structured `warnings` array. Best-effort extension and preset re-registration failures are warning-only. No caught failure is printed as a terminal warning or silently discarded. `next_steps` uses machine-usable -actions and paths rather than shell-formatted prose. +actions and paths rather than shell-formatted prose. When human-readable output +from an internal component is suppressed, the warning reports only its UTF-8 +byte count; the captured text is never included in the JSON payload. On failure, stdout is empty and stderr contains exactly one UTF-8 JSON error object followed by a newline: diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 44d579da9f..e81cf9f399 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -337,7 +337,11 @@ def _validate_existing_integration_layout( }, ) - assert requested_commands_destination is not None + if requested_commands_destination is None: + raise _invalid_integration_options( + "The generic integration command destination could not be resolved.", + integration=integration_key, + ) from .integrations.generic import registration_directory try: @@ -433,7 +437,11 @@ def _build_plan( resolved_name = project_path.name dir_existed_before = True else: - assert project_name is not None + if project_name is None: + raise InitJsonFailure( + "target_required", + "Specify a project name, use '.', or pass --here.", + ) project_path = Path(project_name).resolve() resolved_name = project_path.name dir_existed_before = project_path.exists() @@ -638,7 +646,7 @@ def _record_suppressed_output( _warning( "suppressed_stdout", "Initialization produced human-readable output that was suppressed in JSON mode.", - {"output": _single_line(stdout, limit=1000)}, + {"byte_count": len(stdout.encode("utf-8"))}, ) ) if stderr.strip(): @@ -646,7 +654,7 @@ def _record_suppressed_output( _warning( "suppressed_stderr", "Initialization produced diagnostic output that was suppressed in JSON mode.", - {"output": _single_line(stderr, limit=1000)}, + {"byte_count": len(stderr.encode("utf-8"))}, ) ) @@ -935,17 +943,16 @@ def _install_requested_extensions( version = get_speckit_version() for extension in extensions: try: - message = _install_extension_during_init( + extension_result = _install_extension_during_init( project_path, extension, version, ) - status = "already_installed" if message == "already installed" else "installed" outcomes.append( { "requested": extension, - "status": status, - "message": message, + "status": extension_result.status, + "message": extension_result.message, } ) installed_any = True @@ -1341,8 +1348,13 @@ def run_init_json( if failure is not None: _emit_failure(failure) - assert plan is not None - assert result is not None + if plan is None or result is None: + _emit_failure( + InitJsonFailure( + "internal_error", + "Project initialization did not produce a result.", + ) + ) _record_suppressed_output( result["warnings"], stdout=stdout_capture.getvalue() + console_capture.get(), diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index 4a9157144e..3adbdf5ada 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -8,7 +8,7 @@ import subprocess import sys from pathlib import Path -from typing import Any +from typing import Any, Literal, NamedTuple import typer from rich.live import Live @@ -29,6 +29,11 @@ from ._utils import check_tool +class _InitExtensionResult(NamedTuple): + status: Literal["installed", "already_installed"] + message: str + + def _stdin_is_interactive() -> bool: return sys.stdin.isatty() @@ -98,11 +103,15 @@ def _confirm_extension_url_trust( return approvals -def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str: +def _install_extension_during_init( + project_path: Path, + ext_spec: str, + speckit_version: str, +) -> _InitExtensionResult: """Install a single extension during ``specify init``. Handles bundled extension names, local directory paths, and HTTPS URLs. - Returns a short status message on success. + Returns a structured status and short human-readable message on success. Raises ``ValueError`` on failure so the caller can convert it to a tracker error without aborting the entire init. """ @@ -126,7 +135,10 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve ) except ExtensionError as exc: raise ValueError(str(exc)) from exc - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) # --- Local path --- if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute(): @@ -136,15 +148,21 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve if not (source_path / "extension.yml").exists(): raise ValueError(f"No extension.yml found in {source_path}") manifest = manager.install_from_directory(source_path, speckit_version) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) # --- Bundled extension name or catalog ID --- bundled_path = _locate_bundled_extension(ext_spec) if bundled_path is not None: if manager.registry.is_installed(ext_spec): - return "already installed" + return _InitExtensionResult("already_installed", "already installed") manifest = manager.install_from_directory(bundled_path, speckit_version) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) # Fall back to catalog catalog = ExtensionCatalog(project_path) @@ -159,9 +177,12 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve bundled_path = _locate_bundled_extension(resolved_id) if bundled_path is not None: if manager.registry.is_installed(resolved_id): - return "already installed" + return _InitExtensionResult("already_installed", "already installed") manifest = manager.install_from_directory(bundled_path, speckit_version) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) if ext_info.get("bundled") and not ext_info.get("download_url"): from .extensions import REINSTALL_COMMAND @@ -186,7 +207,10 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve ) finally: zip_path.unlink(missing_ok=True) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) def _shell_quote_arg(value: str) -> str: @@ -940,10 +964,13 @@ def init( ) continue try: - status_msg = _install_extension_during_init( + extension_result = _install_extension_during_init( project_path, ext_spec, speckit_ver ) - tracker.complete(f"extension-{i}", status_msg) + tracker.complete( + f"extension-{i}", + extension_result.message, + ) any_extension_installed = True except Exception as ext_err: sanitized_ext = str(ext_err).replace("\n", " ").strip() diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ed6864253b..221795e44d 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1872,7 +1872,8 @@ def fake_install_from_zip(self, _archive, _version, *, catalog_name=None): project, "catalog-extension", "1.0.0" ) - assert result == "Catalog Extension v1.0.0 installed" + assert result.status == "installed" + assert result.message == "Catalog Extension v1.0.0 installed" assert captured == {"catalog_name": "init-catalog"} def test_catalog_preset_init_forwards_catalog_name(self, tmp_path, monkeypatch): diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 708f0ef666..d6ad8ce965 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -505,6 +505,57 @@ def test_json_force_reinitialization_rejects_generic_destination_change( assert not (project / ".new").exists() +def test_json_force_reinitialization_rejects_missing_generic_destination( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + from specify_cli.integrations import get_integration + + project = tmp_path / "project" + _success( + _invoke( + [ + str(project), + "--json", + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + ], + cwd=tmp_path, + ) + ) + init_options_path = project / ".specify" / "init-options.json" + old_init_options = init_options_path.read_bytes() + integration = get_integration("generic") + assert integration is not None + monkeypatch.setattr( + integration, + "_resolve_commands_destination", + lambda *_args, **_kwargs: None, + ) + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--force", + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + ], + cwd=tmp_path, + ), + "invalid_integration_options", + ) + + assert error["message"] == ( + "The generic integration command destination could not be resolved." + ) + assert error["details"]["integration"] == "generic" + assert init_options_path.read_bytes() == old_init_options + + def test_json_force_reinitialization_allows_same_generic_destination( tmp_path: Path, ): @@ -612,6 +663,43 @@ def test_json_switch_to_generic_persists_settings_before_extension_reregistratio assert list((project / ".agent" / "commands").glob("speckit.git.*.md")) +def test_json_extension_status_does_not_depend_on_human_message( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli.command_init as init_command + + monkeypatch.setattr( + init_command, + "_install_extension_during_init", + lambda *_args, **_kwargs: init_command._InitExtensionResult( + "already_installed", + "present from an earlier installation", + ), + ) + + payload = _success( + _invoke( + [ + "project", + "--json", + "--ignore-agent-tools", + "--extension", + "git", + ], + cwd=tmp_path, + ) + ) + + assert payload["components"]["extensions"] == [ + { + "requested": "git", + "status": "already_installed", + "message": "present from an earlier installation", + } + ] + + @pytest.mark.parametrize( ("args", "code"), [ @@ -859,6 +947,43 @@ def test_json_init_invalid_default_integration_becomes_structured_warning( assert payload["warnings"][0]["code"] == "invalid_default_integration" +def test_json_init_redacts_suppressed_output( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli._command_init_json as init_json + + stdout_secret = "stdout-secret-token" + stderr_secret = "stderr-secret-token" + + def noisy_initialize(plan: Any) -> dict[str, Any]: + print(stdout_secret) + print(stderr_secret, file=sys.stderr) + return { + "project": {"path": str(plan.project_path)}, + "warnings": [], + } + + monkeypatch.setattr(init_json, "_initialize_project", noisy_initialize) + + result = _invoke( + ["project", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + payload = _success(result) + + serialized = result.stdout + assert stdout_secret not in serialized + assert stderr_secret not in serialized + warnings = {warning["code"]: warning for warning in payload["warnings"]} + assert warnings["suppressed_stdout"]["details"] == { + "byte_count": len(f"{stdout_secret}\n".encode()) + } + assert warnings["suppressed_stderr"]["details"] == { + "byte_count": len(f"{stderr_secret}\n".encode()) + } + + def test_json_init_exposes_optional_preset_and_extension_failures( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): From c9cddc73e65742573859dcb093b1ebe3ecaac6cb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:29:57 -0500 Subject: [PATCH 12/19] fix(init): report unverifiable target rollback Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 11 ++++++- tests/specify_cli/test_command_init_json.py | 33 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index e81cf9f399..cd1b0e1f4a 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -619,10 +619,19 @@ def _claim_new_target(plan: _InitPlan) -> None: try: plan.target_identity = _directory_identity(plan.project_path) except OSError as exc: - raise InitJsonFailure( + target_error = InitJsonFailure( "target_unavailable", "The newly created target directory could not be verified.", {"path": str(plan.project_path), "reason": _single_line(exc)}, + ) + raise _rollback_failure( + plan, + target_error, + { + "code": "target_identity_unavailable", + "exception_type": exc.__class__.__name__, + "reason": _single_line(exc), + }, ) from exc diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index d6ad8ce965..b1836a145b 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -1149,6 +1149,39 @@ def fail_initialize(_plan: Any) -> dict[str, Any]: assert project.is_dir() +def test_json_init_reports_rollback_failure_when_claim_identity_is_unavailable( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + + def fail_identity(_path: Path) -> tuple[int, int]: + raise OSError("identity lookup failed") + + def fail_unsafe_cleanup(_path: Path) -> None: + raise AssertionError("cleanup must not run without verified target identity") + + monkeypatch.setattr(init_json, "_directory_identity", fail_identity) + monkeypatch.setattr(init_json.shutil, "rmtree", fail_unsafe_cleanup) + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "rollback_failed", + ) + + assert error["details"]["original_error"]["code"] == "target_unavailable" + assert error["details"]["cleanup_error"]["code"] == ( + "target_identity_unavailable" + ) + assert error["details"]["cleanup_error"]["exception_type"] == "OSError" + assert project.is_dir() + + def test_json_init_never_deletes_replacement_after_target_claim( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): From 7fc6d80ad989d66cb6b10944a22142a166e8b6c8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:02:33 -0500 Subject: [PATCH 13/19] fix(init): tighten JSON extension preflight Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 3 ++ src/specify_cli/_command_init_json.py | 40 +++++++++++++-------- tests/specify_cli/test_command_init_json.py | 33 +++++++++++++++++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index c59fb539e8..eed548703e 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -70,6 +70,9 @@ defaults: `SPECKIT_INTEGRATION_DEFAULT` value); - `sh` scripts on POSIX systems or `ps` scripts on Windows. +JSON mode accepts only HTTPS extension URLs. An HTTP extension URL is rejected +before target creation even when `--trust-extension-urls` is supplied. + The success object reports `integration.defaulted` and `script.defaulted` so callers can distinguish defaults from explicit selections. diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index cd1b0e1f4a..9e7f6e1a1b 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -17,13 +17,7 @@ import typer from rich.console import Console from typer.core import TyperCommand - -try: - from typer._click.exceptions import UsageError as _UsageError -except ModuleNotFoundError as error: - if error.name != "typer._click": - raise - from click import UsageError as _UsageError +from typer.exceptions import TyperException from ._agent_config import ( AGENT_CONFIG, @@ -59,7 +53,7 @@ def make_context(self, info_name, args, parent=None, **extra): json_output = "--json" in args[:end_of_options] try: return super().make_context(info_name, args, parent=parent, **extra) - except _UsageError as error: + except TyperException as error: if json_output: _emit_failure( InitJsonFailure( @@ -67,7 +61,8 @@ def make_context(self, info_name, args, parent=None, **extra): _single_line(error), ) ) - raise + else: + raise @dataclass @@ -485,10 +480,25 @@ def _build_plan( ) requested_extensions = list(extensions or []) + extension_url_schemes = { + spec: _extension_url_scheme(spec) for spec in requested_extensions + } + insecure_urls = [ + spec for spec, scheme in extension_url_schemes.items() if scheme == "http" + ] + if insecure_urls: + raise InitJsonFailure( + "invalid_arguments", + "Extension URLs must use HTTPS in JSON mode.", + { + "extensions": insecure_urls, + "supported_scheme": "https", + }, + ) untrusted_urls = [ spec for spec in requested_extensions - if _extension_spec_is_url(spec) and not trust_extension_urls + if extension_url_schemes[spec] == "https" and not trust_extension_urls ] if untrusted_urls: raise InitJsonFailure( @@ -635,13 +645,14 @@ def _claim_new_target(plan: _InitPlan) -> None: ) from exc -def _extension_spec_is_url(value: str) -> bool: +def _extension_url_scheme(value: str) -> str | None: from urllib.parse import urlparse try: - return urlparse(value).scheme in {"http", "https"} + scheme = urlparse(value).scheme except ValueError: - return False + return None + return scheme if scheme in {"http", "https"} else None def _record_suppressed_output( @@ -964,7 +975,8 @@ def _install_requested_extensions( "message": extension_result.message, } ) - installed_any = True + if extension_result.status == "installed": + installed_any = True except Exception as exc: # noqa: BLE001 - optional extension failures continue reason = _single_line(exc) warnings.append( diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index b1836a145b..1ef03a87a7 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -668,6 +668,12 @@ def test_json_extension_status_does_not_depend_on_human_message( monkeypatch: pytest.MonkeyPatch, ): import specify_cli.command_init as init_command + import specify_cli.events as events + + def fail_refresh(_project_path: Path) -> None: + raise AssertionError( + "events must not refresh when no extension was newly installed" + ) monkeypatch.setattr( init_command, @@ -677,6 +683,7 @@ def test_json_extension_status_does_not_depend_on_human_message( "present from an earlier installation", ), ) + monkeypatch.setattr(events, "refresh_integration_events", fail_refresh) payload = _success( _invoke( @@ -891,6 +898,32 @@ def test_json_init_rejects_untrusted_url_extension_before_mutation(tmp_path: Pat assert not project.exists() +def test_json_init_rejects_http_extension_before_mutation(tmp_path: Path): + project = tmp_path / "project" + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--ignore-agent-tools", + "--extension", + "http://example.com/extension.zip", + "--trust-extension-urls", + ], + cwd=tmp_path, + ), + "invalid_arguments", + ) + + assert error["message"] == "Extension URLs must use HTTPS in JSON mode." + assert error["details"] == { + "extensions": ["http://example.com/extension.zip"], + "supported_scheme": "https", + } + assert not project.exists() + + def test_json_init_explicit_url_trust_never_prompts( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ): From 328b35050068cf1220c6538932822444465f0bd4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:47:30 -0500 Subject: [PATCH 14/19] refactor(init): share scaffolding with JSON output Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/core.md | 168 +- src/specify_cli/_command_init_json.py | 1305 ++------------ src/specify_cli/command_init.py | 648 ++++++- .../integrations/generic/__init__.py | 34 +- tests/integrations/test_cli.py | 3 +- tests/specify_cli/test_command_init_json.py | 1555 +++++++---------- 6 files changed, 1365 insertions(+), 2348 deletions(-) diff --git a/docs/reference/core.md b/docs/reference/core.md index eed548703e..ad017a17b5 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -16,11 +16,11 @@ specify init [] | `--here` | Initialize in the current directory instead of creating a new one | | `--force` | Force merge/overwrite when initializing in an existing directory | | `--non-interactive` | Never prompt; use safe documented defaults where available | -| `--json` | Emit the preview machine-readable init contract; implies non-interactive behavior | +| `--json` | Output machine-readable JSON; implies non-interactive behavior | | `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools | | `--preset ` | Install a preset during initialization | -| `--extension ` | Install an optional bundled, local, catalog, or HTTPS extension; repeatable | -| `--trust-extension-urls` | Explicitly authorize HTTPS extension installs without a trust prompt | +| `--extension ` | Install an optional bundled, local, catalog, or URL extension; repeatable | +| `--trust-extension-urls` | Explicitly authorize URL extension installs without a trust prompt | Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files. @@ -53,164 +53,12 @@ specify init my-project --integration copilot --preset compliance specify init my-project --json ``` -### Preview JSON contract - -`specify init --json` is the preview machine-readable contract for project -initialization. It always behaves non-interactively, even when stdin is a TTY: -it never renders the banner, Rich panels, progress/Live output, pickers, or -confirmation prompts, and it never waits for terminal input. - -JSON mode does **not** imply authorization. In particular, it does not imply -`--force`, `--trust-extension-urls`, `--ignore-agent-tools`, overwrite consent, -or any other destructive or trust decision. Supply those flags explicitly when -their documented behavior is intended. The only automatic choices are safe -defaults: - -- the default integration (`copilot`, or a valid - `SPECKIT_INTEGRATION_DEFAULT` value); -- `sh` scripts on POSIX systems or `ps` scripts on Windows. - -JSON mode accepts only HTTPS extension URLs. An HTTP extension URL is rejected -before target creation even when `--trust-extension-urls` is supplied. - -The success object reports `integration.defaulted` and `script.defaulted` so -callers can distinguish defaults from explicit selections. - -On success, stdout contains exactly one UTF-8 JSON object followed by a newline, -and stderr is empty. The object is command-specific; it does not use the -obsolete universal `schema_version` / `ok` / `result` wrapper. - -```json -{ - "project": { - "name": "my-project", - "path": "/work/my-project", - "operation": "created" - }, - "integration": { - "key": "copilot", - "defaulted": true, - "status": "installed" - }, - "script": { - "type": "sh", - "defaulted": true - }, - "components": { - "shared_infrastructure": { - "status": "installed", - "script_type": "sh" - }, - "workflow": { - "id": "speckit", - "status": "installed", - "version": "1.0.1" - }, - "constitution": { - "status": "created", - "path": "/work/my-project/.specify/memory/constitution.md", - "source": "copied" - }, - "script_permissions": { - "status": "completed", - "detail": "6 updated" - }, - "preset": null, - "extensions": [] - }, - "warnings": [], - "next_steps": [ - { - "action": "change_directory", - "path": "/work/my-project" - }, - { - "action": "start_agent", - "integration": "copilot", - "working_directory": "/work/my-project" - }, - { - "action": "run_spec_kit", - "commands": [ - "constitution", - "specify", - "plan", - "tasks", - "implement", - "converge" - ], - "working_directory": "/work/my-project" - } - ] -} -``` - -`project.operation` has precise target-state semantics: - -- `created`: the target directory did not exist before initialization; -- `merged`: initialization used a pre-existing directory that was not already a - Spec Kit project; -- `reinitialized`: the target already contained a `.specify/` directory. - -Optional preset, extension, workflow, constitution, or permission failures that -do not prevent initialization are represented in their component outcome and in -the structured `warnings` array. Best-effort extension and preset -re-registration failures are warning-only. No caught failure is printed as a -terminal warning or silently discarded. `next_steps` uses machine-usable -actions and paths rather than shell-formatted prose. When human-readable output -from an internal component is suppressed, the warning reports only its UTF-8 -byte count; the captured text is never included in the JSON payload. - -On failure, stdout is empty and stderr contains exactly one UTF-8 JSON error -object followed by a newline: - -```json -{"error":{"code":"target_not_empty","message":"The current directory is not empty; pass --force to merge into it.","details":{"item_count":3,"path":"/work/existing"}}} -``` +### JSON output -Stable init error codes are: - -| Code | Meaning | -| ---- | ------- | -| `target_required` | Neither a project name, `.` nor `--here` was supplied | -| `conflicting_target_options` | A project name and `--here` were supplied together | -| `target_exists` | A named target directory already exists without `--force` | -| `target_not_empty` | The `--here` target is non-empty without `--force` | -| `target_not_directory` | The target exists but is not a directory | -| `target_unavailable` | The target directory cannot be inspected or created | -| `invalid_arguments` | Command-line syntax or option parsing failed before initialization | -| `invalid_integration` | The requested integration is not registered | -| `invalid_integration_options` | Integration options are unknown, malformed, conflicting, or incomplete | -| `invalid_script_type` | `--script` is not one of `sh`, `ps`, or `py` | -| `missing_agent_tool` | A required agent tool is unavailable and `--ignore-agent-tools` was not supplied | -| `extension_url_trust_required` | An HTTPS extension was requested without `--trust-extension-urls` | -| `initialization_failed` | A known fatal initialization boundary failed | -| `internal_error` | An unexpected exception was sanitized | -| `rollback_failed` | Initialization failed and cleanup of a newly created target also failed | - -All target, option, required-tool, and URL-trust checks run before filesystem -mutation. URL extension trust is default-deny. If a later fatal error occurs -after a new target was created, init removes that target; a pre-existing target -is never deleted. Rollback verifies that the target still identifies the -directory created by the current invocation before removing it; a concurrently -replaced target is preserved. A cleanup or identity-verification failure is -returned as `rollback_failed` with the original error nested in `details`. - -JSON mode has the same successful side effects as human mode: it installs the -selected integration, shared templates and scripts, bundled workflow, -constitution, optional preset, and requested extensions, and it writes the -normal `.specify/` state. `--force` retains its existing merge/overwrite -behavior. Reinitialization does not perform command-to-skills or -skills-to-command layout migrations for an already installed dual-mode -integration, or move an installed generic integration to a different command -directory. JSON mode rejects those changes before mutation and reports a -machine-usable `integration_upgrade` recommendation. - -**Preview compatibility:** field names, operation values, error envelope, and -error codes above are the supported preview contract. New optional fields, -component statuses, warning codes, or next-step actions may be added during the -preview; consumers should ignore unknown fields. A future stabilization will be -documented before any incompatible contract change. +`--json` outputs machine-readable JSON and always runs non-interactively. It +uses the same initializer as human mode, so it changes output rather than the +generated project. It does not imply `--force`, `--trust-extension-urls`, +`--ignore-agent-tools`, or overwrite/trust consent. ### Environment Variables diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 9e7f6e1a1b..d1aeb17aeb 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -1,21 +1,17 @@ -"""Private machine-readable execution path for ``specify init --json``.""" +"""Machine-readable adapter for the existing ``specify init`` execution path.""" from __future__ import annotations import io import json import os -import shlex -import shutil -import stat import sys from contextlib import redirect_stderr, redirect_stdout from dataclasses import dataclass, field from pathlib import Path -from typing import Any, NoReturn +from typing import Any, Callable, NoReturn import typer -from rich.console import Console from typer.core import TyperCommand from typer.exceptions import TyperException @@ -25,70 +21,34 @@ DEFAULT_INIT_INTEGRATION_ENV_VAR, SCRIPT_TYPE_CHOICES, ) -from ._assets import ( - _locate_bundled_preset, - _locate_bundled_workflow, - _locate_core_pack, - _repo_root, - get_speckit_version, -) -from ._console import StepTracker, console, err_console +from ._console import console, err_console from ._utils import check_tool @dataclass class InitJsonFailure(Exception): - """A stable command-specific failure for ``specify init --json``.""" - code: str message: str details: dict[str, Any] = field(default_factory=dict) class InitJsonCommand(TyperCommand): - """Keep JSON-mode parse failures on the init error contract.""" + """Keep JSON-mode parser failures machine-readable.""" def make_context(self, info_name, args, parent=None, **extra): end_of_options = args.index("--") if "--" in args else len(args) json_output = "--json" in args[:end_of_options] try: return super().make_context(info_name, args, parent=parent, **extra) - except TyperException as error: + except TyperException as exc: if json_output: _emit_failure( InitJsonFailure( "invalid_arguments", - _single_line(error), + _single_line(exc), ) ) - else: - raise - - -@dataclass -class _InitPlan: - project_name: str - project_path: Path - operation: str - target_identity: tuple[int, int] | None - here: bool - force: bool - integration: Any - integration_defaulted: bool - integration_options: dict[str, Any] - raw_integration_options: str | None - script_type: str - script_defaulted: bool - ignore_agent_tools: bool - preset: str | None - extensions: list[str] - warnings: list[dict[str, Any]] - - -def _warning( - code: str, message: str, details: dict[str, Any] | None = None -) -> dict[str, Any]: - return {"code": code, "message": message, "details": details or {}} + raise def _single_line(value: object, *, limit: int = 500) -> str: @@ -116,33 +76,6 @@ def _emit(value: dict[str, Any], *, error: bool = False) -> None: binary_stream.flush() -def _directory_identity(path: Path) -> tuple[int, int]: - state = path.stat(follow_symlinks=False) - if not stat.S_ISDIR(state.st_mode): - raise OSError(f"Target is no longer a directory: {path}") - return state.st_dev, state.st_ino - - -def _rollback_failure( - plan: _InitPlan, - failure: InitJsonFailure, - cleanup_error: dict[str, Any], -) -> InitJsonFailure: - return InitJsonFailure( - "rollback_failed", - "Initialization failed and the newly created target could not be removed.", - { - "path": str(plan.project_path), - "original_error": { - "code": failure.code, - "message": failure.message, - "details": failure.details, - }, - "cleanup_error": cleanup_error, - }, - ) - - def _emit_failure(failure: InitJsonFailure) -> NoReturn: _emit( { @@ -157,260 +90,29 @@ def _emit_failure(failure: InitJsonFailure) -> NoReturn: raise typer.Exit(1) -def _invalid_integration_options(message: str, **details: Any) -> InitJsonFailure: - return InitJsonFailure( - "invalid_integration_options", - message, - details, - ) - - -def _parse_integration_options( - integration: Any, raw_options: str | None -) -> dict[str, Any]: - if not raw_options: - parsed: dict[str, Any] = {} - else: - try: - tokens = shlex.split(raw_options) - except ValueError as exc: - raise _invalid_integration_options( - "Could not parse integration options.", - reason=_single_line(exc), - ) from exc - - declared_options = list(integration.options()) - declared = {opt.name.lstrip("-"): opt for opt in declared_options} - allowed = sorted(opt.name for opt in declared_options) - parsed = {} - i = 0 - while i < len(tokens): - token = tokens[i] - if not token.startswith("-"): - raise _invalid_integration_options( - "Unexpected integration option value.", - value=token, - allowed=allowed, - ) - name = token.lstrip("-") - value: str | None = None - if "=" in name: - name, value = name.split("=", 1) - option = declared.get(name) - if option is None: - raise _invalid_integration_options( - "Unknown integration option.", - option=token, - allowed=allowed, - ) - key = name.replace("-", "_") - if option.is_flag: - if value is not None: - raise _invalid_integration_options( - "Integration flag does not accept a value.", - option=option.name, - ) - parsed[key] = True - i += 1 - elif value is not None: - parsed[key] = value - i += 1 - elif i + 1 < len(tokens) and not tokens[i + 1].startswith("-"): - parsed[key] = tokens[i + 1] - i += 2 - else: - raise _invalid_integration_options( - "Integration option requires a value.", - option=option.name, - ) - - missing = [] - for option in integration.options(): - if not option.required: - continue - key = option.name.lstrip("-").replace("-", "_") - value = parsed.get(key) - if value is None or (isinstance(value, str) and not value.strip()): - missing.append(option.name) - if missing: - raise _invalid_integration_options( - "Required integration options were not supplied.", - missing=missing, - ) - - return parsed - - -def _validate_integration_mode_options( - integration: Any, - parsed_options: dict[str, Any], - *, - integration_key: str, - project_root: Path, -) -> bool: - stdout_capture = io.StringIO() - stderr_capture = io.StringIO() - try: - with ( - console.capture() as console_capture, - err_console.capture() as err_console_capture, - redirect_stdout(stdout_capture), - redirect_stderr(stderr_capture), - ): - return bool( - integration.is_skills_mode( - parsed_options or None, - project_root=project_root, - ) - ) - except (ValueError, typer.Exit) as exc: - captured = ( - stdout_capture.getvalue() - + console_capture.get() - + stderr_capture.getvalue() - + err_console_capture.get() - ) - raise _invalid_integration_options( - "Integration options are invalid.", - integration=integration_key, - reason=_single_line(captured or exc), - ) from exc - - -def _validate_existing_integration_layout( - *, - integration_key: str, - project_root: Path, - requested_skills_mode: bool, - requested_commands_destination: Path | None, - raw_integration_options: str | None, -) -> None: - if integration_key not in {"bob", "copilot", "generic"}: - return - - from .integrations._command_upgrade_layout import ( - _manifest_tracks_skill_layout, - ) - from .integrations.manifest import IntegrationManifest - - manifest_path = ( - project_root - / ".specify" - / "integrations" - / f"{integration_key}.manifest.json" - ) - if not manifest_path.is_file(): - return - try: - old_manifest = IntegrationManifest.load(integration_key, project_root) - except (OSError, ValueError) as exc: - raise InitJsonFailure( - "initialization_failed", - "The installed integration manifest could not be read.", - { - "component": "integration_manifest", - "integration": integration_key, - "reason": _single_line(exc), - }, - ) from exc - - current_skills_mode = _manifest_tracks_skill_layout(old_manifest) - if current_skills_mode == requested_skills_mode: - if integration_key != "generic": - return - else: - raise _invalid_integration_options( - "Changing an installed integration layout through init is not supported.", - integration=integration_key, - current_layout="skills" if current_skills_mode else "commands", - requested_layout="skills" if requested_skills_mode else "commands", - recommended_action={ - "command": "integration_upgrade", - "integration": integration_key, - "force": True, - "integration_options": raw_integration_options, - }, - ) - - if requested_commands_destination is None: - raise _invalid_integration_options( - "The generic integration command destination could not be resolved.", - integration=integration_key, - ) - from .integrations.generic import registration_directory +def _extension_url(value: str) -> bool: + from urllib.parse import urlparse try: - current_commands_destination = registration_directory(project_root) - except (OSError, ValueError) as exc: - raise InitJsonFailure( - "initialization_failed", - "The installed generic integration settings could not be read.", - { - "component": "integration_state", - "integration": integration_key, - "reason": _single_line(exc), - }, - ) from exc - if current_commands_destination == requested_commands_destination: - return - - project_root_resolved = project_root.resolve() - raise _invalid_integration_options( - "Changing the generic command destination through init is not supported.", - integration=integration_key, - current_commands_dir=current_commands_destination.relative_to( - project_root_resolved - ).as_posix(), - requested_commands_dir=requested_commands_destination.relative_to( - project_root_resolved - ).as_posix(), - recommended_action={ - "command": "integration_upgrade", - "integration": integration_key, - "force": True, - "integration_options": raw_integration_options, - }, - ) - - -def _resolve_default_integration( - warnings: list[dict[str, Any]], -) -> str: - override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip() - if not override: - return DEFAULT_INIT_INTEGRATION - if override in AGENT_CONFIG: - return override - warnings.append( - _warning( - "invalid_default_integration", - "The configured default integration was not recognized; the built-in default was used.", - { - "environment_variable": DEFAULT_INIT_INTEGRATION_ENV_VAR, - "value": override, - "default": DEFAULT_INIT_INTEGRATION, - }, - ) - ) - return DEFAULT_INIT_INTEGRATION + return urlparse(value).scheme in {"http", "https"} + except ValueError: + return False -def _build_plan( +def _preflight( *, project_name: str | None, script_type: str | None, ignore_agent_tools: bool, here: bool, force: bool, - preset: str | None, integration_key: str | None, integration_options: str | None, extensions: list[str] | None, trust_extension_urls: bool, -) -> _InitPlan: +) -> dict[str, Any]: from .integrations import get_integration - - warnings: list[dict[str, Any]] = [] + from .integrations._commands import _parse_integration_options if project_name == ".": here = True @@ -427,30 +129,17 @@ def _build_plan( "Specify a project name, use '.', or pass --here.", ) - if here: - project_path = Path.cwd().resolve() - resolved_name = project_path.name - dir_existed_before = True - else: - if project_name is None: - raise InitJsonFailure( - "target_required", - "Specify a project name, use '.', or pass --here.", - ) - project_path = Path(project_name).resolve() - resolved_name = project_path.name - dir_existed_before = project_path.exists() - + project_path = Path.cwd() if here else Path(project_name or "").resolve() + existed_before = project_path.exists() already_initialized = (project_path / ".specify").is_dir() operation = ( "reinitialized" if already_initialized else "merged" - if dir_existed_before + if existed_before else "created" ) - - if dir_existed_before: + if existed_before: if not project_path.is_dir(): raise InitJsonFailure( "target_not_directory", @@ -480,25 +169,10 @@ def _build_plan( ) requested_extensions = list(extensions or []) - extension_url_schemes = { - spec: _extension_url_scheme(spec) for spec in requested_extensions - } - insecure_urls = [ - spec for spec, scheme in extension_url_schemes.items() if scheme == "http" - ] - if insecure_urls: - raise InitJsonFailure( - "invalid_arguments", - "Extension URLs must use HTTPS in JSON mode.", - { - "extensions": insecure_urls, - "supported_scheme": "https", - }, - ) untrusted_urls = [ - spec - for spec in requested_extensions - if extension_url_schemes[spec] == "https" and not trust_extension_urls + extension + for extension in requested_extensions + if _extension_url(extension) and not trust_extension_urls ] if untrusted_urls: raise InitJsonFailure( @@ -510,8 +184,31 @@ def _build_plan( }, ) + warnings: list[dict[str, Any]] = [] integration_defaulted = not integration_key - selected_integration = integration_key or _resolve_default_integration(warnings) + if integration_key: + selected_integration = integration_key + else: + override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip() + if override and override not in AGENT_CONFIG: + warnings.append( + { + "code": "invalid_default_integration", + "message": ( + "The configured default integration was not recognized; " + "the built-in default was used." + ), + "details": { + "environment_variable": DEFAULT_INIT_INTEGRATION_ENV_VAR, + "value": override, + "default": DEFAULT_INIT_INTEGRATION, + }, + } + ) + selected_integration = DEFAULT_INIT_INTEGRATION + else: + selected_integration = override or DEFAULT_INIT_INTEGRATION + integration = get_integration(selected_integration) if integration is None or selected_integration not in AGENT_CONFIG: raise InitJsonFailure( @@ -522,35 +219,32 @@ def _build_plan( "available": sorted(AGENT_CONFIG), }, ) + if selected_integration == "generic" and not integration_options: + raise InitJsonFailure( + "invalid_integration_options", + "The generic integration requires --commands-dir.", + {"integration": "generic"}, + ) - parsed_options = _parse_integration_options(integration, integration_options) - selected_skills_mode = _validate_integration_mode_options( - integration, - parsed_options, - integration_key=selected_integration, - project_root=project_path, - ) - requested_commands_destination: Path | None = None - if selected_integration == "generic": - try: - requested_commands_destination = integration._resolve_commands_destination( - project_path, - parsed_options, - {"raw_options": integration_options}, - ) - except ValueError as exc: - raise _invalid_integration_options( - "Integration options are invalid.", - integration=selected_integration, - reason=_single_line(exc), - ) from exc - _validate_existing_integration_layout( - integration_key=selected_integration, - project_root=project_path, - requested_skills_mode=selected_skills_mode, - requested_commands_destination=requested_commands_destination, - raw_integration_options=integration_options, - ) + try: + parsed_options = ( + _parse_integration_options(integration, integration_options) + if integration_options + else {} + ) + integration.is_skills_mode( + parsed_options or None, + project_root=project_path, + ) + except (ValueError, typer.Exit) as exc: + raise InitJsonFailure( + "invalid_integration_options", + "Integration options are invalid.", + { + "integration": selected_integration, + "reason": _single_line(exc), + }, + ) from exc if not ignore_agent_tools: agent_config = AGENT_CONFIG[selected_integration] @@ -577,808 +271,147 @@ def _build_plan( }, ) - return _InitPlan( - project_name=resolved_name, - project_path=project_path, - operation=operation, - target_identity=None, - here=here, - force=force, - integration=integration, - integration_defaulted=integration_defaulted, - integration_options=parsed_options, - raw_integration_options=integration_options, - script_type=selected_script, - script_defaulted=script_defaulted, - ignore_agent_tools=ignore_agent_tools, - preset=preset, - extensions=requested_extensions, - warnings=warnings, - ) - - -def _claim_new_target(plan: _InitPlan) -> None: - if plan.operation != "created": - return - try: - plan.project_path.mkdir(parents=True, exist_ok=False) - except FileExistsError as exc: - if plan.project_path.is_dir(): - raise InitJsonFailure( - "target_exists", - "The target directory already exists; pass --force to merge into it.", - { - "path": str(plan.project_path), - "concurrent_creation": True, - }, - ) from exc - raise InitJsonFailure( - "target_not_directory", - "The target exists but is not a directory.", - { - "path": str(plan.project_path), - "concurrent_creation": True, - }, - ) from exc - except OSError as exc: - raise InitJsonFailure( - "target_unavailable", - "The target directory could not be created.", - {"path": str(plan.project_path), "reason": _single_line(exc)}, - ) from exc - try: - plan.target_identity = _directory_identity(plan.project_path) - except OSError as exc: - target_error = InitJsonFailure( - "target_unavailable", - "The newly created target directory could not be verified.", - {"path": str(plan.project_path), "reason": _single_line(exc)}, - ) - raise _rollback_failure( - plan, - target_error, - { - "code": "target_identity_unavailable", - "exception_type": exc.__class__.__name__, - "reason": _single_line(exc), - }, - ) from exc - - -def _extension_url_scheme(value: str) -> str | None: - from urllib.parse import urlparse - - try: - scheme = urlparse(value).scheme - except ValueError: - return None - return scheme if scheme in {"http", "https"} else None - - -def _record_suppressed_output( - warnings: list[dict[str, Any]], - *, - stdout: str, - stderr: str, -) -> None: - if stdout.strip(): - warnings.append( - _warning( - "suppressed_stdout", - "Initialization produced human-readable output that was suppressed in JSON mode.", - {"byte_count": len(stdout.encode("utf-8"))}, - ) - ) - if stderr.strip(): - warnings.append( - _warning( - "suppressed_stderr", - "Initialization produced diagnostic output that was suppressed in JSON mode.", - {"byte_count": len(stderr.encode("utf-8"))}, - ) - ) - - -def _re_register_existing_artifacts( - plan: _InitPlan, - warnings: list[dict[str, Any]], -) -> None: - if not plan.force: - return - try: - from .extensions import ExtensionManager - - ExtensionManager(plan.project_path).register_enabled_extensions_for_agent( - plan.integration.key, - force=True, - ) - except Exception as exc: # noqa: BLE001 - re-registration is best-effort - warnings.append( - _warning( - "extension_reregistration_failed", - "The integration was updated, but installed extension artifacts could not be re-registered.", - { - "integration": plan.integration.key, - "reason": _single_line(exc), - }, - ) - ) - try: - from .presets import PresetManager - - PresetManager(plan.project_path).register_enabled_presets_for_agent( - plan.integration.key - ) - except Exception as exc: # noqa: BLE001 - re-registration is best-effort - warnings.append( - _warning( - "preset_reregistration_failed", - "The integration was updated, but installed preset artifacts could not be re-registered.", - { - "integration": plan.integration.key, - "reason": _single_line(exc), - }, - ) - ) - - -def _install_shared_infrastructure( - plan: _InitPlan, - warnings: list[dict[str, Any]], -) -> dict[str, Any]: - from .integration_runtime import ( - invoke_prefix_for_integration, - ) - from .shared_infra import install_shared_infra - - captured = io.StringIO() - recording_console = Console( - file=captured, - force_terminal=False, - color_system=None, - width=240, - highlight=False, - ) - try: - install_shared_infra( - plan.project_path, - plan.script_type, - version=get_speckit_version(), - core_pack=_locate_core_pack(), - repo_root=_repo_root(), - console=recording_console, - force=plan.force, - invoke_separator=plan.integration.effective_invoke_separator( - plan.integration_options or None, - project_root=plan.project_path, - ), - invoke_prefix=invoke_prefix_for_integration( - plan.integration, - plan.integration.key, - plan.integration_options or None, - plan.project_path, - ), - ) - except (OSError, ValueError) as exc: - raise InitJsonFailure( - "initialization_failed", - "Failed to install shared infrastructure.", - { - "component": "shared_infrastructure", - "reason": _single_line(exc), - }, - ) from exc - - notice = _single_line(captured.getvalue(), limit=2000) - if captured.getvalue().strip(): - warnings.append( - _warning( - "shared_infrastructure_notice", - "Some shared infrastructure paths were preserved or skipped.", - {"notice": notice}, - ) - ) - return {"status": "installed", "script_type": plan.script_type} - - -def _install_bundled_workflow( - project_path: Path, - warnings: list[dict[str, Any]], -) -> dict[str, Any]: - try: - bundled_workflow = _locate_bundled_workflow("speckit") - if bundled_workflow is None: - outcome = { - "id": "speckit", - "status": "skipped", - "reason": "bundled_workflow_not_found", - } - warnings.append( - _warning( - "bundled_workflow_not_found", - "The bundled speckit workflow was not available.", - {"workflow": "speckit"}, - ) - ) - return outcome - - from .workflows.catalog import WorkflowRegistry - from .workflows.engine import WorkflowDefinition - - registry = WorkflowRegistry(project_path) - if registry.is_installed("speckit"): - return {"id": "speckit", "status": "already_installed"} - - destination = project_path / ".specify" / "workflows" / "speckit" - destination.mkdir(parents=True, exist_ok=True) - shutil.copy2( - bundled_workflow / "workflow.yml", - destination / "workflow.yml", - ) - definition = WorkflowDefinition.from_yaml(destination / "workflow.yml") - registry.add( - "speckit", - { - "name": definition.name, - "version": definition.version, - "description": definition.description, - "source": "bundled", - }, - ) - return { - "id": "speckit", - "status": "installed", - "version": definition.version, - } - except Exception as exc: # noqa: BLE001 - optional workflow failures continue - warnings.append( - _warning( - "workflow_install_failed", - "The project was initialized without the optional bundled workflow.", - {"workflow": "speckit", "reason": _single_line(exc)}, - ) - ) - return { - "id": "speckit", - "status": "failed", - "reason": _single_line(exc), - } - - -def _install_optional_preset( - project_path: Path, - preset: str | None, - warnings: list[dict[str, Any]], -) -> dict[str, Any] | None: - if preset is None: - return None - - try: - from .presets import PresetCatalog, PresetManager - - manager = PresetManager(project_path) - version = get_speckit_version() - local_path = Path(preset).resolve() - if local_path.is_dir() and (local_path / "preset.yml").exists(): - manifest = manager.install_from_directory(local_path, version) - return { - "requested": preset, - "id": manifest.id, - "status": "installed", - "source": "local", - } - - bundled_path = _locate_bundled_preset(preset) - if bundled_path is not None: - manifest = manager.install_from_directory(bundled_path, version) - return { - "requested": preset, - "id": manifest.id, - "status": "installed", - "source": "bundled", - } - - catalog = PresetCatalog(project_path) - info = catalog.get_pack_info(preset) - if not info: - warnings.append( - _warning( - "preset_not_found", - "The requested optional preset was not found and was skipped.", - {"preset": preset}, - ) - ) - return { - "requested": preset, - "status": "skipped", - "reason": "not_found", - } - if info.get("bundled") and not info.get("download_url"): - warnings.append( - _warning( - "bundled_preset_not_found", - "The requested bundled preset was missing from the installed package.", - {"preset": preset}, - ) - ) - return { - "requested": preset, - "status": "failed", - "reason": "bundled_preset_not_found", - } - - zip_path: Path | None = None - try: - zip_path = catalog.download_pack(preset) - manifest = manager.install_from_zip( - zip_path, - version, - catalog_name=info.get("_catalog_name"), - ) - return { - "requested": preset, - "id": manifest.id, - "status": "installed", - "source": "catalog", - } - finally: - if zip_path is not None: - try: - zip_path.unlink(missing_ok=True) - except OSError as exc: - warnings.append( - _warning( - "preset_download_cleanup_failed", - "The preset was processed, but its temporary download could not be removed.", - {"preset": preset, "reason": _single_line(exc)}, - ) - ) - except Exception as exc: # noqa: BLE001 - optional preset failures continue - warnings.append( - _warning( - "preset_install_failed", - "The project was initialized without the optional preset.", - {"preset": preset, "reason": _single_line(exc)}, - ) - ) - return { - "requested": preset, - "status": "failed", - "reason": _single_line(exc), - } - - -def _install_requested_extensions( - project_path: Path, - extensions: list[str], - warnings: list[dict[str, Any]], -) -> list[dict[str, Any]]: - if not extensions: - return [] - - from .command_init import _install_extension_during_init - - outcomes: list[dict[str, Any]] = [] - installed_any = False - version = get_speckit_version() - for extension in extensions: - try: - extension_result = _install_extension_during_init( - project_path, - extension, - version, - ) - outcomes.append( - { - "requested": extension, - "status": extension_result.status, - "message": extension_result.message, - } - ) - if extension_result.status == "installed": - installed_any = True - except Exception as exc: # noqa: BLE001 - optional extension failures continue - reason = _single_line(exc) - warnings.append( - _warning( - "extension_install_failed", - "The project was initialized without a requested optional extension.", - {"extension": extension, "reason": reason}, - ) - ) - outcomes.append( - { - "requested": extension, - "status": "failed", - "reason": reason, - } - ) - - if installed_any: - try: - from .events import EventRefreshError, refresh_integration_events - - refresh_integration_events(project_path) - except EventRefreshError as exc: - warnings.append( - _warning( - "extension_event_refresh_failed", - "Extensions were installed, but one or more integration event configurations could not be refreshed.", - { - "failures": [ - {"integration": key, "reason": detail} - for key, detail in exc.failures - ] - }, - ) - ) - return outcomes - - -def _initialize_constitution( - project_path: Path, - warnings: list[dict[str, Any]], -) -> dict[str, Any]: - destination = project_path / ".specify" / "memory" / "constitution.md" - if destination.exists(): - return {"status": "preserved", "path": str(destination)} - try: - from .presets import _materialize_constitution_template - - materialization = _materialize_constitution_template( - project_path, - destination, - ) - if materialization is None: - warnings.append( - _warning( - "constitution_template_not_found", - "The constitution could not be initialized because no template was available.", - {"path": str(destination)}, - ) - ) - return { - "status": "failed", - "path": str(destination), - "reason": "template_not_found", - } - return { - "status": "created", - "path": str(destination), - "source": materialization, - } - except Exception as exc: # noqa: BLE001 - constitution failure is non-fatal - warnings.append( - _warning( - "constitution_initialization_failed", - "The project was initialized, but its constitution could not be created.", - {"path": str(destination), "reason": _single_line(exc)}, - ) - ) - return { - "status": "failed", - "path": str(destination), - "reason": _single_line(exc), - } - - -def _ensure_script_permissions( - project_path: Path, - warnings: list[dict[str, Any]], -) -> dict[str, Any]: - from . import ensure_executable_scripts - - tracker = StepTracker("init-json") - ensure_executable_scripts(project_path, tracker=tracker) - step = next((item for item in tracker.steps if item["key"] == "chmod"), None) - if step is None: - return {"status": "not_applicable"} - if step["status"] == "error": - warnings.append( - _warning( - "script_permission_update_failed", - "Some executable script permissions could not be updated.", - {"detail": step["detail"]}, - ) - ) - return {"status": "partial", "detail": step["detail"]} - return {"status": "completed", "detail": step["detail"]} - - -def _next_steps(plan: _InitPlan) -> list[dict[str, Any]]: - steps: list[dict[str, Any]] = [] - if not plan.here: - steps.append( - { - "action": "change_directory", - "path": str(plan.project_path), - } - ) - steps.extend( - [ - { - "action": "start_agent", - "integration": plan.integration.key, - "working_directory": str(plan.project_path), - }, - { - "action": "run_spec_kit", - "commands": [ - "constitution", - "specify", - "plan", - "tasks", - "implement", - "converge", - ], - "working_directory": str(plan.project_path), - }, - ] - ) - return steps - - -def _initialize_project(plan: _InitPlan) -> dict[str, Any]: - from . import save_init_options - from .events import resolve_events - from .integration_runtime import with_integration_setting - from .integration_state import write_integration_json - from .integrations.manifest import IntegrationManifest - - warnings = list(plan.warnings) - parsed_options = plan.integration_options or None - try: - manifest = IntegrationManifest( - plan.integration.key, - plan.project_path, - version=get_speckit_version(), - ) - events = resolve_events( - plan.integration.key, - plan.integration.config, - plan.project_path, - parsed_options, - ) - plan.integration.setup( - plan.project_path, - manifest, - parsed_options=parsed_options, - script_type=plan.script_type, - raw_options=plan.raw_integration_options, - events=events, - ) - manifest.save() - except (OSError, ValueError) as exc: - raise InitJsonFailure( - "initialization_failed", - "Failed to install the selected integration.", - { - "component": "integration", - "integration": plan.integration.key, - "reason": _single_line(exc), - }, - ) from exc - - try: - init_options: dict[str, Any] = { - "ai": plan.integration.key, - "integration": plan.integration.key, - "here": plan.here, - "script": plan.script_type, - "feature_numbering": "sequential", - "speckit_version": get_speckit_version(), - } - if plan.integration.is_skills_mode( - parsed_options, - project_root=plan.project_path, - ): - init_options["ai_skills"] = True - save_init_options(plan.project_path, init_options) - except (OSError, ValueError) as exc: - raise InitJsonFailure( - "initialization_failed", - "Failed to save initialization state.", - { - "component": "init_options", - "reason": _single_line(exc), - }, - ) from exc - - try: - settings = with_integration_setting( - {}, - plan.integration.key, - plan.integration, - script_type=plan.script_type, - raw_options=plan.raw_integration_options, - parsed_options=parsed_options, - project_root=plan.project_path, - ) - write_integration_json( - plan.project_path, - version=get_speckit_version(), - integration_key=plan.integration.key, - installed_integrations=[plan.integration.key], - settings=settings, - ) - - _re_register_existing_artifacts(plan, warnings) - - shared_infrastructure = _install_shared_infrastructure(plan, warnings) - except (OSError, ValueError) as exc: - raise InitJsonFailure( - "initialization_failed", - "Failed to configure project infrastructure.", - { - "component": "project_infrastructure", - "reason": _single_line(exc), - }, - ) from exc - workflow = _install_bundled_workflow(plan.project_path, warnings) - - permissions = _ensure_script_permissions(plan.project_path, warnings) - preset = _install_optional_preset(plan.project_path, plan.preset, warnings) - extensions = _install_requested_extensions( - plan.project_path, - plan.extensions, - warnings, - ) - constitution = _initialize_constitution(plan.project_path, warnings) - return { - "project": { - "name": plan.project_name, - "path": str(plan.project_path), - "operation": plan.operation, - }, - "integration": { - "key": plan.integration.key, - "defaulted": plan.integration_defaulted, - "status": "installed", - }, - "script": { - "type": plan.script_type, - "defaulted": plan.script_defaulted, - }, - "components": { - "shared_infrastructure": shared_infrastructure, - "workflow": workflow, - "constitution": constitution, - "script_permissions": permissions, - "preset": preset, - "extensions": extensions, - }, + "project_path": project_path, + "operation": operation, + "integration_defaulted": integration_defaulted, + "script_defaulted": script_defaulted, "warnings": warnings, - "next_steps": _next_steps(plan), } -def _as_failure(exc: BaseException) -> InitJsonFailure: - if isinstance(exc, InitJsonFailure): - return exc - if isinstance(exc, (OSError, ValueError, typer.Exit, SystemExit)): - return InitJsonFailure( - "initialization_failed", - "Project initialization failed.", - {"reason": _single_line(exc)}, - ) - return InitJsonFailure( - "internal_error", - "Project initialization failed because of an unexpected internal error.", - {"exception_type": exc.__class__.__name__}, - ) - - -def _rollback_new_target( - plan: _InitPlan, - failure: InitJsonFailure, -) -> InitJsonFailure: - if plan.target_identity is None: - return failure - try: - current_identity = _directory_identity(plan.project_path) - except OSError as cleanup_error: - return _rollback_failure( - plan, - failure, - { - "code": "target_identity_unavailable", - "exception_type": cleanup_error.__class__.__name__, - "reason": _single_line(cleanup_error), - }, - ) - if current_identity != plan.target_identity: - return _rollback_failure( - plan, - failure, - { - "code": "target_identity_changed", - "reason": ( - "The target path no longer identifies the directory created " - "by this invocation." - ), - }, - ) - try: - shutil.rmtree(plan.project_path) - except OSError as cleanup_error: - return _rollback_failure( - plan, - failure, - { - "code": "target_cleanup_failed", - "exception_type": cleanup_error.__class__.__name__, - "reason": _single_line(cleanup_error), - }, - ) - failure.details = { - **failure.details, - "rollback": { - "status": "completed", - "path": str(plan.project_path), - }, - } - return failure - - def run_init_json( *, + execute: Callable[[], dict[str, Any]], project_name: str | None, script_type: str | None, ignore_agent_tools: bool, here: bool, force: bool, - preset: str | None, integration: str | None, integration_options: str | None, extensions: list[str] | None, trust_extension_urls: bool, ) -> None: - """Execute ``specify init --json`` and emit exactly one JSON object.""" - plan: _InitPlan | None = None + """Invoke the regular initializer and replace only its output rendering.""" + from .command_init import ( + _InitRollbackError, + _InitTargetClaimError, + _init_failure_context, + _init_json_mode, + ) + result: dict[str, Any] | None = None failure: InitJsonFailure | None = None + context: dict[str, Any] | None = None stdout_capture = io.StringIO() stderr_capture = io.StringIO() + failure_token = _init_failure_context.set(None) + json_mode_token = _init_json_mode.set(True) try: with ( redirect_stdout(stdout_capture), redirect_stderr(stderr_capture), - console.capture() as console_capture, - err_console.capture() as err_console_capture, + console.capture(), + err_console.capture(), ): - plan = _build_plan( + context = _preflight( project_name=project_name, script_type=script_type, ignore_agent_tools=ignore_agent_tools, here=here, force=force, - preset=preset, integration_key=integration, integration_options=integration_options, extensions=extensions, trust_extension_urls=trust_extension_urls, ) - _claim_new_target(plan) - result = _initialize_project(plan) - except Exception as exc: # noqa: BLE001 - JSON boundary sanitizes all failures - failure = _as_failure(exc) - if plan is not None: - failure = _rollback_new_target(plan, failure) + result = execute() + except InitJsonFailure as exc: + failure = exc + except typer.Exit as exc: + internal_failure = _init_failure_context.get() + project_path = context["project_path"] if context else None + if isinstance(internal_failure, _InitTargetClaimError): + failure = InitJsonFailure( + internal_failure.code, + internal_failure.message, + internal_failure.details, + ) + elif isinstance(internal_failure, _InitRollbackError) or ( + context + and context["operation"] == "created" + and project_path is not None + and project_path.exists() + ): + cleanup_error = ( + internal_failure.details + if isinstance(internal_failure, _InitRollbackError) + else { + "code": "target_cleanup_failed", + "reason": "The newly created target still exists.", + } + ) + failure = InitJsonFailure( + "rollback_failed", + "Initialization failed and the newly created target could not be removed.", + { + "path": str(project_path), + "original_error": { + "code": "initialization_failed", + "message": "Project initialization failed.", + "details": {}, + }, + "cleanup_error": cleanup_error, + }, + ) + elif internal_failure is not None and not isinstance( + internal_failure, + (OSError, ValueError), + ): + failure = InitJsonFailure( + "internal_error", + "Project initialization failed because of an unexpected internal error.", + {"exception_type": internal_failure.__class__.__name__}, + ) + else: + details: dict[str, Any] = {} + if context and context["operation"] == "created": + details["rollback"] = { + "status": "completed", + "path": str(context["project_path"]), + } + failure = InitJsonFailure( + "initialization_failed", + "Project initialization failed.", + details, + ) + if exc.exit_code == 0: + failure = InitJsonFailure( + "initialization_failed", + "Project initialization did not complete.", + ) + except Exception as exc: # noqa: BLE001 - sanitize the JSON boundary + failure = InitJsonFailure( + "internal_error", + "Project initialization failed because of an unexpected internal error.", + {"exception_type": exc.__class__.__name__}, + ) + finally: + _init_failure_context.reset(failure_token) + _init_json_mode.reset(json_mode_token) if failure is not None: _emit_failure(failure) - - if plan is None or result is None: + if result is None or context is None: _emit_failure( InitJsonFailure( "internal_error", "Project initialization did not produce a result.", ) ) - _record_suppressed_output( - result["warnings"], - stdout=stdout_capture.getvalue() + console_capture.get(), - stderr=stderr_capture.getvalue() + err_console_capture.get(), - ) + + result["integration"]["defaulted"] = context["integration_defaulted"] + result["script"]["defaulted"] = context["script_defaulted"] + result["warnings"] = [*context["warnings"], *result["warnings"]] _emit(result) diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index 3adbdf5ada..0c18f83b68 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -5,10 +5,13 @@ import os import shlex import shutil +import stat import subprocess import sys +from contextlib import nullcontext +from contextvars import ContextVar from pathlib import Path -from typing import Any, Literal, NamedTuple +from typing import Any import typer from rich.live import Live @@ -29,9 +32,216 @@ from ._utils import check_tool -class _InitExtensionResult(NamedTuple): - status: Literal["installed", "already_installed"] - message: str +_init_failure_context: ContextVar[BaseException | None] = ContextVar( + "init_failure", + default=None, +) +_init_json_mode: ContextVar[bool] = ContextVar("init_json_mode", default=False) + + +class _InitRollbackError(Exception): + def __init__(self, details: dict[str, Any]): + super().__init__(details["code"]) + self.details = details + + +class _InitTargetClaimError(Exception): + def __init__(self, code: str, message: str, details: dict[str, Any]): + super().__init__(message) + self.code = code + self.message = message + self.details = details + + +def _directory_identity(path: Path) -> tuple[int, int]: + state = path.stat(follow_symlinks=False) + if not stat.S_ISDIR(state.st_mode): + raise OSError(f"Target is no longer a directory: {path}") + return state.st_dev, state.st_ino + + +def _claim_new_target( + project_path: Path, + *, + should_claim: bool, +) -> tuple[int, int] | None: + if not should_claim: + return None + try: + project_path.mkdir(parents=True, exist_ok=False) + except FileExistsError as exc: + is_directory = project_path.is_dir() + raise _InitTargetClaimError( + "target_exists" if is_directory else "target_not_directory", + ( + "The target directory was created concurrently." + if is_directory + else "The target was replaced by a non-directory." + ), + {"path": str(project_path), "concurrent_creation": True}, + ) from exc + except OSError as exc: + raise _InitTargetClaimError( + "target_unavailable", + "The target directory could not be created.", + {"path": str(project_path), "reason": str(exc)}, + ) from exc + try: + return _directory_identity(project_path) + except OSError as exc: + raise _InitRollbackError( + { + "code": "target_identity_unavailable", + "exception_type": exc.__class__.__name__, + "reason": str(exc), + } + ) from exc + + +def _rollback_new_target( + project_path: Path, + target_identity: tuple[int, int] | None, +) -> None: + if target_identity is None: + return + try: + current_identity = _directory_identity(project_path) + except OSError as exc: + raise _InitRollbackError( + { + "code": "target_identity_unavailable", + "exception_type": exc.__class__.__name__, + "reason": str(exc), + } + ) from exc + if current_identity != target_identity: + raise _InitRollbackError( + { + "code": "target_identity_changed", + "reason": ( + "The target path no longer identifies the directory created " + "by this invocation." + ), + } + ) + try: + shutil.rmtree(project_path) + except OSError as exc: + raise _InitRollbackError( + { + "code": "target_cleanup_failed", + "exception_type": exc.__class__.__name__, + "reason": str(exc), + } + ) from exc + + +def _warning( + code: str, + message: str, + **details: Any, +) -> dict[str, Any]: + return {"code": code, "message": message, "details": details} + + +def _build_init_result( + *, + project_path: Path, + operation: str, + integration_key: str, + integration_defaulted: bool, + script_type: str, + script_defaulted: bool, + here: bool, + workflow: dict[str, Any], + constitution: dict[str, Any], + preset: dict[str, Any] | None, + extensions: list[dict[str, Any]], + warnings: list[dict[str, Any]], + tracker: StepTracker, +) -> dict[str, Any]: + permission_step = next( + (step for step in tracker.steps if step["key"] == "chmod"), + None, + ) + if permission_step is None or permission_step["status"] == "pending": + permissions = {"status": "not_applicable"} + elif permission_step["status"] == "error": + permissions = { + "status": "partial", + "detail": permission_step["detail"], + } + warnings.append( + _warning( + "script_permission_update_failed", + "Some executable script permissions could not be updated.", + detail=permission_step["detail"], + ) + ) + else: + permissions = { + "status": "completed", + "detail": permission_step["detail"], + } + + next_steps: list[dict[str, Any]] = [] + if not here: + next_steps.append( + { + "action": "change_directory", + "path": str(project_path), + } + ) + next_steps.extend( + [ + { + "action": "start_agent", + "integration": integration_key, + "working_directory": str(project_path), + }, + { + "action": "run_spec_kit", + "commands": [ + "constitution", + "specify", + "plan", + "tasks", + "implement", + "converge", + ], + "working_directory": str(project_path), + }, + ] + ) + return { + "project": { + "name": project_path.name, + "path": str(project_path), + "operation": operation, + }, + "integration": { + "key": integration_key, + "defaulted": integration_defaulted, + "status": "installed", + }, + "script": { + "type": script_type, + "defaulted": script_defaulted, + }, + "components": { + "shared_infrastructure": { + "status": "installed", + "script_type": script_type, + }, + "workflow": workflow, + "constitution": constitution, + "script_permissions": permissions, + "preset": preset, + "extensions": extensions, + }, + "warnings": warnings, + "next_steps": next_steps, + } def _stdin_is_interactive() -> bool: @@ -107,11 +317,11 @@ def _install_extension_during_init( project_path: Path, ext_spec: str, speckit_version: str, -) -> _InitExtensionResult: +) -> str: """Install a single extension during ``specify init``. Handles bundled extension names, local directory paths, and HTTPS URLs. - Returns a structured status and short human-readable message on success. + Returns a short status message on success. Raises ``ValueError`` on failure so the caller can convert it to a tracker error without aborting the entire init. """ @@ -135,10 +345,7 @@ def _install_extension_during_init( ) except ExtensionError as exc: raise ValueError(str(exc)) from exc - return _InitExtensionResult( - "installed", - f"{manifest.name} v{manifest.version} installed", - ) + return f"{manifest.name} v{manifest.version} installed" # --- Local path --- if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute(): @@ -148,21 +355,15 @@ def _install_extension_during_init( if not (source_path / "extension.yml").exists(): raise ValueError(f"No extension.yml found in {source_path}") manifest = manager.install_from_directory(source_path, speckit_version) - return _InitExtensionResult( - "installed", - f"{manifest.name} v{manifest.version} installed", - ) + return f"{manifest.name} v{manifest.version} installed" # --- Bundled extension name or catalog ID --- bundled_path = _locate_bundled_extension(ext_spec) if bundled_path is not None: if manager.registry.is_installed(ext_spec): - return _InitExtensionResult("already_installed", "already installed") + return "already installed" manifest = manager.install_from_directory(bundled_path, speckit_version) - return _InitExtensionResult( - "installed", - f"{manifest.name} v{manifest.version} installed", - ) + return f"{manifest.name} v{manifest.version} installed" # Fall back to catalog catalog = ExtensionCatalog(project_path) @@ -177,12 +378,9 @@ def _install_extension_during_init( bundled_path = _locate_bundled_extension(resolved_id) if bundled_path is not None: if manager.registry.is_installed(resolved_id): - return _InitExtensionResult("already_installed", "already installed") + return "already installed" manifest = manager.install_from_directory(bundled_path, speckit_version) - return _InitExtensionResult( - "installed", - f"{manifest.name} v{manifest.version} installed", - ) + return f"{manifest.name} v{manifest.version} installed" if ext_info.get("bundled") and not ext_info.get("download_url"): from .extensions import REINSTALL_COMMAND @@ -207,10 +405,7 @@ def _install_extension_during_init( ) finally: zip_path.unlink(missing_ok=True) - return _InitExtensionResult( - "installed", - f"{manifest.name} v{manifest.version} installed", - ) + return f"{manifest.name} v{manifest.version} installed" def _shell_quote_arg(value: str) -> str: @@ -413,6 +608,39 @@ def init( specify init my-project --extension ./my-extensions/custom-ext # Local path extension specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive) """ + if json_output is True: + from ._command_init_json import run_init_json + + return run_init_json( + execute=lambda: init( + project_name=project_name, + script_type=script_type, + ignore_agent_tools=ignore_agent_tools, + here=here, + force=force, + non_interactive=True, + json_output=False, + skip_tls=skip_tls, + debug=debug, + github_token=github_token, + offline=offline, + preset=preset, + integration=integration, + integration_options=integration_options, + extensions=extensions, + trust_extension_urls=trust_extension_urls, + ), + project_name=project_name, + script_type=script_type, + ignore_agent_tools=ignore_agent_tools, + here=here, + force=force, + integration=integration, + integration_options=integration_options, + extensions=extensions, + trust_extension_urls=trust_extension_urls, + ) + # Lazy imports to avoid circular dependency — __init__.py imports this module from . import ( _install_shared_infra_or_exit, @@ -429,24 +657,8 @@ def init( _write_integration_json, ) - if json_output is True: - from ._command_init_json import run_init_json - - run_init_json( - project_name=project_name, - script_type=script_type, - ignore_agent_tools=ignore_agent_tools, - here=here, - force=force, - preset=preset, - integration=integration, - integration_options=integration_options, - extensions=extensions, - trust_extension_urls=trust_extension_urls, - ) - return - - show_banner() + if not _init_json_mode.get(): + show_banner() from .integrations import INTEGRATION_REGISTRY, get_integration @@ -571,6 +783,25 @@ def init( console.print(error_panel) raise typer.Exit(1) + operation = ( + "reinitialized" + if (project_path / ".specify").is_dir() + else "merged" + if dir_existed_before + else "created" + ) + init_warnings: list[dict[str, Any]] = [] + workflow_outcome: dict[str, Any] = { + "id": "speckit", + "status": "skipped", + "reason": "not_processed", + } + preset_outcome: dict[str, Any] | None = None + extension_outcomes: list[dict[str, Any]] = [] + constitution_existed = ( + project_path / ".specify" / "memory" / "constitution.md" + ).exists() + if integration: if integration not in AGENT_CONFIG: console.print( @@ -623,9 +854,10 @@ def init( f"{'Target Path':<15} [dim]{_escape_markup(str(project_path))}[/dim]" ) - console.print( - Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)) - ) + if not _init_json_mode.get(): + console.print( + Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2)) + ) if not ignore_agent_tools: agent_config = AGENT_CONFIG.get(selected_ai) @@ -665,8 +897,11 @@ def init( else: selected_script = default_script - console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}") - console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") + if not _init_json_mode.get(): + console.print( + f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}" + ) + console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") tracker = StepTracker("Initialize Specify Project") @@ -695,6 +930,19 @@ def init( tracker.add("final", "Finalize") + integration_parsed_options: dict[str, Any] = {} + if integration_options: + extra = _parse_integration_options( + resolved_integration, + integration_options, + ) + if extra: + integration_parsed_options.update(extra) + resolved_integration.is_skills_mode( + integration_parsed_options or None, + project_root=project_path, + ) + # Resolve trust for URL-based extensions BEFORE entering the Live # display: the confirmation prompt cannot be shown/answered underneath # the Rich Live spinner. URL installs are default-deny unless the user @@ -709,14 +957,33 @@ def init( allow_prompt=_prompts_allowed(non_interactive), ) + try: + target_identity = _claim_new_target( + project_path, + should_claim=not here and not dir_existed_before, + ) + except (_InitRollbackError, _InitTargetClaimError) as exc: + _init_failure_context.set(exc) + console.print(f"[red]Error:[/red] Could not create target: {exc}") + raise typer.Exit(1) from exc + # Disable transient mode on Windows: PowerShell 5.1's legacy console # hangs when Rich tries to restore cursor state via VT escape sequences. _transient = sys.platform != "win32" - with Live( - tracker.render(), console=console, refresh_per_second=8, transient=_transient - ) as live: - tracker.attach_refresh(lambda: live.update(tracker.render())) + live_context = ( + nullcontext() + if _init_json_mode.get() + else Live( + tracker.render(), + console=console, + refresh_per_second=8, + transient=_transient, + ) + ) + with live_context as live: + if live is not None: + tracker.attach_refresh(lambda: live.update(tracker.render())) try: from .integrations.manifest import IntegrationManifest @@ -727,14 +994,6 @@ def init( version=get_speckit_version(), ) - integration_parsed_options: dict[str, Any] = {} - if integration_options: - extra = _parse_integration_options( - resolved_integration, integration_options - ) - if extra: - integration_parsed_options.update(extra) - from .events import resolve_events events_map = resolve_events( resolved_integration.key, @@ -826,6 +1085,10 @@ def init( wf_registry = WorkflowRegistry(project_path) if wf_registry.is_installed("speckit"): tracker.complete("workflow", "already installed") + workflow_outcome = { + "id": "speckit", + "status": "already_installed", + } else: import shutil as _shutil @@ -850,11 +1113,41 @@ def init( }, ) tracker.complete("workflow", "speckit installed") + workflow_outcome = { + "id": "speckit", + "status": "installed", + "version": definition.version, + } else: tracker.skip("workflow", "bundled workflow not found") + workflow_outcome = { + "id": "speckit", + "status": "skipped", + "reason": "bundled_workflow_not_found", + } + init_warnings.append( + _warning( + "bundled_workflow_not_found", + "The bundled speckit workflow was not available.", + workflow="speckit", + ) + ) except Exception as wf_err: sanitized_wf = str(wf_err).replace("\n", " ").strip() tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") + workflow_outcome = { + "id": "speckit", + "status": "failed", + "reason": sanitized_wf, + } + init_warnings.append( + _warning( + "workflow_install_failed", + "The project was initialized without the optional bundled workflow.", + workflow="speckit", + reason=sanitized_wf, + ) + ) init_opts = { "ai": selected_ai, @@ -881,15 +1174,27 @@ def init( local_path = Path(preset).resolve() if local_path.is_dir() and (local_path / "preset.yml").exists(): - preset_manager.install_from_directory( + preset_manifest = preset_manager.install_from_directory( local_path, speckit_ver ) + preset_outcome = { + "requested": preset, + "id": preset_manifest.id, + "status": "installed", + "source": "local", + } else: bundled_path = _locate_bundled_preset(preset) if bundled_path: - preset_manager.install_from_directory( + preset_manifest = preset_manager.install_from_directory( bundled_path, speckit_ver ) + preset_outcome = { + "requested": preset, + "id": preset_manifest.id, + "status": "installed", + "source": "bundled", + } else: preset_catalog = PresetCatalog(project_path) pack_info = preset_catalog.get_pack_info(preset) @@ -897,6 +1202,18 @@ def init( console.print( f"[yellow]Warning:[/yellow] Preset '{preset}' not found in catalog. Skipping." ) + preset_outcome = { + "requested": preset, + "status": "skipped", + "reason": "not_found", + } + init_warnings.append( + _warning( + "preset_not_found", + "The requested optional preset was not found and was skipped.", + preset=preset, + ) + ) elif pack_info.get("bundled") and not pack_info.get( "download_url" ): @@ -912,15 +1229,33 @@ def init( console.print( f"Try reinstalling: {REINSTALL_COMMAND}" ) + preset_outcome = { + "requested": preset, + "status": "failed", + "reason": "bundled_preset_not_found", + } + init_warnings.append( + _warning( + "bundled_preset_not_found", + "The requested bundled preset was missing from the installed package.", + preset=preset, + ) + ) else: zip_path = None try: zip_path = preset_catalog.download_pack(preset) - preset_manager.install_from_zip( + preset_manifest = preset_manager.install_from_zip( zip_path, speckit_ver, catalog_name=pack_info.get("_catalog_name"), ) + preset_outcome = { + "requested": preset, + "id": preset_manifest.id, + "status": "installed", + "source": "catalog", + } except PresetError as preset_err: _print_cli_warning( "install", @@ -929,12 +1264,33 @@ def init( preset_err, continuing="Continuing without the optional preset.", ) + reason = str(preset_err).replace("\n", " ").strip() + preset_outcome = { + "requested": preset, + "status": "failed", + "reason": reason, + } + init_warnings.append( + _warning( + "preset_install_failed", + "The project was initialized without the optional preset.", + preset=preset, + reason=reason, + ) + ) finally: if zip_path is not None: try: zip_path.unlink(missing_ok=True) - except OSError: - pass + except OSError as cleanup_err: + init_warnings.append( + _warning( + "preset_download_cleanup_failed", + "The preset was processed, but its temporary download could not be removed.", + preset=preset, + reason=str(cleanup_err), + ) + ) except Exception as preset_err: _print_cli_warning( "install", @@ -943,10 +1299,24 @@ def init( preset_err, continuing="Continuing without the optional preset.", ) + reason = str(preset_err).replace("\n", " ").strip() + preset_outcome = { + "requested": preset, + "status": "failed", + "reason": reason, + } + init_warnings.append( + _warning( + "preset_install_failed", + "The project was initialized without the optional preset.", + preset=preset, + reason=reason, + ) + ) # Install extensions specified via --extension if extensions: - from .extensions._commands import _refresh_events_and_warn + from .events import EventRefreshError, refresh_integration_events speckit_ver = get_speckit_version() any_extension_installed = False @@ -962,38 +1332,153 @@ def init( "skipped: untrusted URL not confirmed " "(use --trust-extension-urls)", ) + extension_outcomes.append( + { + "requested": ext_spec, + "status": "skipped", + "reason": "extension_url_trust_required", + } + ) continue try: - extension_result = _install_extension_during_init( + status_message = _install_extension_during_init( project_path, ext_spec, speckit_ver ) tracker.complete( f"extension-{i}", - extension_result.message, + status_message, + ) + extension_status = ( + "already_installed" + if status_message == "already installed" + else "installed" + ) + extension_outcomes.append( + { + "requested": ext_spec, + "status": extension_status, + "message": status_message, + } ) - any_extension_installed = True + if extension_status == "installed": + any_extension_installed = True except Exception as ext_err: sanitized_ext = str(ext_err).replace("\n", " ").strip() tracker.error( f"extension-{i}", f"failed: {_escape_markup(sanitized_ext[:120])}", ) + extension_outcomes.append( + { + "requested": ext_spec, + "status": "failed", + "reason": sanitized_ext, + } + ) + init_warnings.append( + _warning( + "extension_install_failed", + "The project was initialized without a requested optional extension.", + extension=ext_spec, + reason=sanitized_ext, + ) + ) # Refresh native event configuration once after the batch so # that an extension declaring ``events:`` has its hooks # activated, mirroring the ``extension add`` path. if any_extension_installed: - _refresh_events_and_warn(project_path) + try: + refresh_integration_events(project_path) + except EventRefreshError as refresh_err: + console.print( + "[yellow]Warning:[/yellow] Extensions were installed, " + "but integration event configuration refresh failed." + ) + init_warnings.append( + _warning( + "extension_event_refresh_failed", + "Extensions were installed, but one or more integration event configurations could not be refreshed.", + failures=[ + { + "integration": key, + "reason": detail, + } + for key, detail in refresh_err.failures + ], + ) + ) # Seed the constitution AFTER preset installation so that a # preset-provided constitution-template (resolved via the # priority stack) wins over the core template. ensure_constitution_from_template(project_path, tracker=tracker) + constitution_path = ( + project_path / ".specify" / "memory" / "constitution.md" + ) + constitution_step = next( + step + for step in tracker.steps + if step["key"] == "constitution" + ) + if constitution_existed: + constitution_outcome = { + "status": "preserved", + "path": str(constitution_path), + } + elif constitution_path.exists(): + constitution_outcome = { + "status": "created", + "path": str(constitution_path), + "source": ( + "composed" + if "composed" in constitution_step["detail"] + else "copied" + ), + } + else: + constitution_outcome = { + "status": "failed", + "path": str(constitution_path), + "reason": constitution_step["detail"] or "template_not_found", + } + init_warnings.append( + _warning( + "constitution_initialization_failed", + "The project was initialized, but its constitution could not be created.", + path=str(constitution_path), + reason=constitution_outcome["reason"], + ) + ) tracker.complete("final", "project ready") + init_result = _build_init_result( + project_path=project_path, + operation=operation, + integration_key=resolved_integration.key, + integration_defaulted=not bool(integration), + script_type=selected_script, + script_defaulted=not bool(script_type), + here=here, + workflow=workflow_outcome, + constitution=constitution_outcome, + preset=preset_outcome, + extensions=extension_outcomes, + warnings=init_warnings, + tracker=tracker, + ) except (typer.Exit, SystemExit): + try: + _rollback_new_target(project_path, target_identity) + except _InitRollbackError as cleanup_err: + _init_failure_context.set(cleanup_err) + console.print( + "[red]Error:[/red] Initialization failed and the new " + f"target could not be removed: {cleanup_err.details['code']}" + ) raise except Exception as e: + _init_failure_context.set(e) tracker.error("final", str(e)) console.print( Panel( @@ -1020,13 +1505,22 @@ def init( border_style="magenta", ) ) - if not here and project_path.exists() and not dir_existed_before: - shutil.rmtree(project_path) + try: + _rollback_new_target(project_path, target_identity) + except _InitRollbackError as cleanup_err: + _init_failure_context.set(cleanup_err) + console.print( + "[red]Error:[/red] Initialization failed and the new " + f"target could not be removed: {cleanup_err.details['code']}" + ) raise typer.Exit(1) finally: pass - if _transient: + if _init_json_mode.get(): + return init_result + + if _transient and not _init_json_mode.get(): console.print(tracker.render()) console.print("\n[bold green]Project ready.[/bold green]") @@ -1232,3 +1726,5 @@ def _display_cmd(name: str) -> str: ) console.print() console.print(enhancements_panel) + + return init_result diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 078fd3fd5b..0dbe598ab7 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -154,25 +154,6 @@ def _resolve_commands_dir( "--commands-dir is required for the generic integration" ) - @classmethod - def _resolve_commands_destination( - cls, - project_root: Path, - parsed_options: dict[str, Any] | None, - opts: dict[str, Any], - ) -> Path: - commands_dir = cls._resolve_commands_dir(parsed_options, opts) - project_root_resolved = project_root.resolve() - destination = (project_root / commands_dir).resolve() - try: - destination.relative_to(project_root_resolved) - except ValueError as exc: - raise ValueError( - f"Integration destination {destination} escapes " - f"project root {project_root_resolved}" - ) from exc - return destination - def _build_skill_content( self, src_file: Path, script_type: str, project_root: Path ) -> tuple[str, str]: @@ -254,6 +235,8 @@ def setup( **opts: Any, ) -> list[Path]: """Install commands to the user-provided commands directory.""" + commands_dir = self._resolve_commands_dir(parsed_options, opts) + templates = self.list_command_templates() if not templates: return [] @@ -265,11 +248,14 @@ def setup( f"project_root ({project_root_resolved})" ) - dest = self._resolve_commands_destination( - project_root, - parsed_options, - opts, - ) + dest = (project_root / commands_dir).resolve() + try: + dest.relative_to(project_root_resolved) + except ValueError as exc: + raise ValueError( + f"Integration destination {dest} escapes " + f"project root {project_root_resolved}" + ) from exc dest.mkdir(parents=True, exist_ok=True) script_type = opts.get("script_type", "sh") diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 221795e44d..ed6864253b 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1872,8 +1872,7 @@ def fake_install_from_zip(self, _archive, _version, *, catalog_name=None): project, "catalog-extension", "1.0.0" ) - assert result.status == "installed" - assert result.message == "Catalog Extension v1.0.0 installed" + assert result == "Catalog Extension v1.0.0 installed" assert captured == {"catalog_name": "init-catalog"} def test_catalog_preset_init_forwards_catalog_name(self, tmp_path, monkeypatch): diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 1ef03a87a7..e99f787266 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -1,11 +1,10 @@ -"""Contract tests for ``specify init --json``.""" +"""JSON output and parity tests for ``specify init``.""" from __future__ import annotations -import io import json import os -import sys +import stat from pathlib import Path from typing import Any @@ -53,8 +52,55 @@ def _failure(result: Result, code: str) -> dict[str, Any]: return payload["error"] +def _normalize_value(value: Any, root: Path) -> Any: + if isinstance(value, dict): + return { + key: _normalize_value(item, root) + for key, item in value.items() + if key not in {"installed_at", "updated_at", "created_at"} + } + if isinstance(value, list): + return [_normalize_value(item, root) for item in value] + if isinstance(value, str): + return value.replace(str(root), "") + return value + + +def _project_snapshot(root: Path) -> dict[str, tuple[Any, ...]]: + snapshot: dict[str, tuple[Any, ...]] = {} + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + mode = stat.S_IMODE(path.lstat().st_mode) + if path.is_symlink(): + snapshot[relative] = ("symlink", mode, os.readlink(path)) + elif path.is_dir(): + snapshot[relative] = ("directory", mode) + else: + content = path.read_bytes() + try: + normalized = _normalize_value( + json.loads(content.decode("utf-8")), + root, + ) + content = json.dumps( + normalized, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + except (UnicodeDecodeError, json.JSONDecodeError): + try: + content = content.decode("utf-8").replace( + str(root), + "", + ).encode("utf-8") + except UnicodeDecodeError: + pass + snapshot[relative] = ("file", mode, content) + return snapshot + + def test_json_init_new_directory_uses_safe_defaults(tmp_path: Path): - project = tmp_path / "new-project" + project = tmp_path / "prøject" payload = _success( _invoke( @@ -64,26 +110,24 @@ def test_json_init_new_directory_uses_safe_defaults(tmp_path: Path): ) assert payload["project"] == { - "name": "new-project", + "name": "prøject", "path": str(project.resolve()), "operation": "created", } - assert payload["integration"]["key"] == "copilot" - assert payload["integration"]["defaulted"] is True - assert payload["script"]["type"] == ("ps" if os.name == "nt" else "sh") - assert payload["script"]["defaulted"] is True - assert payload["components"]["shared_infrastructure"]["status"] == "installed" - assert payload["components"]["workflow"]["status"] in { - "installed", - "already_installed", + assert payload["integration"] == { + "key": "copilot", + "defaulted": True, + "status": "installed", + } + assert payload["script"] == { + "type": "ps" if os.name == "nt" else "sh", + "defaulted": True, } + assert payload["components"]["shared_infrastructure"]["status"] == "installed" + assert payload["components"]["workflow"]["status"] == "installed" assert payload["components"]["constitution"]["status"] == "created" assert payload["components"]["preset"] is None assert payload["components"]["extensions"] == [] - assert payload["next_steps"][0] == { - "action": "change_directory", - "path": str(project.resolve()), - } assert (project / ".specify" / "init-options.json").is_file() @@ -110,53 +154,26 @@ def test_json_init_here_honors_explicit_integration_and_script(tmp_path: Path): assert all(step["action"] != "change_directory" for step in payload["next_steps"]) -def test_json_init_empty_selections_use_and_report_safe_defaults(tmp_path: Path): - project = tmp_path / "empty-selections" - - payload = _success( - _invoke( - [ - str(project), - "--json", - "--integration", - "", - "--script", - "", - "--ignore-agent-tools", - ], - cwd=tmp_path, - ) - ) - - assert payload["integration"] == { - "key": "copilot", - "defaulted": True, - "status": "installed", - } - assert payload["script"] == { - "type": "ps" if os.name == "nt" else "sh", - "defaulted": True, - } - - -def test_json_init_never_prompts_even_when_stdin_is_a_tty( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_json_init_never_uses_prompt_or_rich_ui( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): import specify_cli.command_init as init_command monkeypatch.setattr(init_command, "_stdin_is_interactive", lambda: True) - def fail_prompt(*_args: Any, **_kwargs: Any) -> None: - raise AssertionError("JSON mode must not render or prompt") + def fail_ui(*_args: Any, **_kwargs: Any) -> None: + raise AssertionError("JSON mode must not invoke interactive or Rich UI") - monkeypatch.setattr(init_command, "select_with_arrows", fail_prompt) - monkeypatch.setattr(typer, "confirm", fail_prompt) - monkeypatch.setattr(init_command, "show_banner", fail_prompt) - monkeypatch.setattr(init_command, "Live", fail_prompt) + monkeypatch.setattr(init_command, "select_with_arrows", fail_ui) + monkeypatch.setattr(init_command, "show_banner", fail_ui) + monkeypatch.setattr(init_command, "Live", fail_ui) + monkeypatch.setattr(init_command, "Panel", fail_ui) + monkeypatch.setattr(typer, "confirm", fail_ui) payload = _success( _invoke( - ["tty-project", "--json", "--ignore-agent-tools"], + ["project", "--json", "--ignore-agent-tools"], cwd=tmp_path, ) ) @@ -165,100 +182,19 @@ def fail_prompt(*_args: Any, **_kwargs: Any) -> None: assert payload["script"]["defaulted"] is True -def test_json_init_emits_utf8_bytes_with_non_utf_text_encoding( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli._command_init_json as init_json - - project_name = "prøject" - raw_stdout = io.BytesIO() - encoded_stdout = io.TextIOWrapper(raw_stdout, encoding="utf-16") - monkeypatch.setattr(sys, "stdout", encoded_stdout) - previous = Path.cwd() - os.chdir(tmp_path) - try: - init_json.run_init_json( - project_name=project_name, - script_type=None, - ignore_agent_tools=True, - here=False, - force=False, - preset=None, - integration=None, - integration_options=None, - extensions=None, - trust_extension_urls=False, - ) - finally: - os.chdir(previous) - - output = raw_stdout.getvalue() - assert output.endswith(b"\n") - payload = json.loads(output.decode("utf-8")) - assert payload["project"]["name"] == project_name - assert (tmp_path / project_name / ".specify").is_dir() - - -def test_json_init_error_emits_utf8_bytes_with_non_utf_text_encoding( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli._command_init_json as init_json - - raw_stderr = io.BytesIO() - encoded_stderr = io.TextIOWrapper(raw_stderr, encoding="utf-16") - monkeypatch.setattr(sys, "stderr", encoded_stderr) - previous = Path.cwd() - os.chdir(tmp_path) - try: - with pytest.raises(typer.Exit): - init_json.run_init_json( - project_name="project", - script_type=None, - ignore_agent_tools=True, - here=False, - force=False, - preset=None, - integration="intégration", - integration_options=None, - extensions=None, - trust_extension_urls=False, - ) - finally: - os.chdir(previous) - - output = raw_stderr.getvalue() - assert output.endswith(b"\n") - payload = json.loads(output.decode("utf-8")) - assert payload["error"]["code"] == "invalid_integration" - assert payload["error"]["details"]["integration"] == "intégration" - assert not (tmp_path / "project").exists() - - -def test_json_init_rejects_nonempty_here_without_force_and_preserves_files( - tmp_path: Path, -): - marker = tmp_path / "keep.txt" - marker.write_text("keep", encoding="utf-8") - - error = _failure( - _invoke( - ["--here", "--json", "--ignore-agent-tools"], - cwd=tmp_path, - ), - "target_not_empty", - ) - - assert error["details"]["item_count"] == 1 - assert marker.read_text(encoding="utf-8") == "keep" - assert not (tmp_path / ".specify").exists() - - def test_json_init_force_merges_nonempty_target(tmp_path: Path): project = tmp_path / "existing" project.mkdir() marker = project / "keep.txt" marker.write_text("keep", encoding="utf-8") + _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "target_exists", + ) payload = _success( _invoke( [ @@ -273,803 +209,606 @@ def test_json_init_force_merges_nonempty_target(tmp_path: Path): assert payload["project"]["operation"] == "merged" assert marker.read_text(encoding="utf-8") == "keep" - assert (project / ".specify").is_dir() - -def test_json_init_reports_reinitialization(tmp_path: Path): - project = tmp_path / "project" - _success( - _invoke( - [str(project), "--json", "--ignore-agent-tools"], - cwd=tmp_path, - ) - ) - payload = _success( - _invoke( +@pytest.mark.parametrize( + ("args", "code"), + [ + (["--json"], "target_required"), + (["project", "--here", "--json"], "conflicting_target_options"), + ( + ["project", "--json", "--integration", "missing"], + "invalid_integration", + ), + ( [ - str(project), + "project", "--json", - "--force", - "--ignore-agent-tools", + "--integration", + "generic", ], - cwd=tmp_path, - ) - ) - - assert payload["project"]["operation"] == "reinitialized" - - -def test_json_force_reinitialization_persists_new_integration_before_reregistration( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -): - from specify_cli.extensions import ExtensionManager - from specify_cli.presets import PresetManager - - project = tmp_path / "project" - _success( - _invoke( + "invalid_integration_options", + ), + ( [ - str(project), + "project", "--json", "--integration", "copilot", - "--ignore-agent-tools", + "--integration-options=--skills --commands", ], - cwd=tmp_path, - ) - ) - - observed: list[tuple[str, str, dict[str, Any]]] = [] - - def record_extension_mode( - manager: ExtensionManager, - agent_name: str, - *, - force: bool = False, - ) -> None: - options = json.loads( - (manager.project_root / ".specify" / "init-options.json").read_text( - encoding="utf-8" - ) - ) - observed.append(("extension", agent_name, options)) - - def record_preset_mode(manager: PresetManager, agent_name: str) -> None: - options = json.loads( - (manager.project_root / ".specify" / "init-options.json").read_text( - encoding="utf-8" - ) - ) - observed.append(("preset", agent_name, options)) - - monkeypatch.setattr( - ExtensionManager, - "register_enabled_extensions_for_agent", - record_extension_mode, - ) - monkeypatch.setattr( - PresetManager, - "register_enabled_presets_for_agent", - record_preset_mode, - ) - - payload = _success( - _invoke( + "invalid_integration_options", + ), + ( [ - str(project), + "project", "--json", - "--force", "--integration", - "claude", - "--ignore-agent-tools", + "copilot", + '--integration-options=--skills "', ], - cwd=tmp_path, - ) - ) - - assert payload["project"]["operation"] == "reinitialized" - assert [(kind, agent) for kind, agent, _options in observed] == [ - ("extension", "claude"), - ("preset", "claude"), - ] - for _kind, _agent, options in observed: - assert options["ai"] == "claude" - assert options["ai_skills"] is True - - -def test_json_force_reinitialization_rejects_layout_change_before_mutation( - tmp_path: Path, -): - project = tmp_path / "project" - _success( - _invoke( + "invalid_integration_options", + ), + ( [ - str(project), + "project", "--json", "--integration", "copilot", - "--integration-options=--commands", - "--ignore-agent-tools", + "--integration-options=--missing", ], - cwd=tmp_path, - ) - ) - - manifest_path = ( - project / ".specify" / "integrations" / "copilot.manifest.json" - ) - init_options_path = project / ".specify" / "init-options.json" - old_manifest = manifest_path.read_bytes() - old_init_options = init_options_path.read_bytes() - old_command_files = sorted( - path.relative_to(project) - for root in (project / ".github" / "agents", project / ".github" / "prompts") - for path in root.glob("speckit.*") - ) - assert old_command_files - - error = _failure( - _invoke( + "invalid_integration_options", + ), + ( [ - str(project), + "project", "--json", - "--force", "--integration", "copilot", - "--integration-options=--skills", - "--ignore-agent-tools", + "--integration-options=--skills=true", ], - cwd=tmp_path, + "invalid_integration_options", ), - "invalid_integration_options", - ) - - assert error["details"]["current_layout"] == "commands" - assert error["details"]["requested_layout"] == "skills" - assert error["details"]["recommended_action"] == { - "command": "integration_upgrade", - "integration": "copilot", - "force": True, - "integration_options": "--skills", - } - assert manifest_path.read_bytes() == old_manifest - assert init_options_path.read_bytes() == old_init_options - assert sorted( - path.relative_to(project) - for root in (project / ".github" / "agents", project / ".github" / "prompts") - for path in root.glob("speckit.*") - ) == old_command_files - assert not (project / ".github" / "skills").exists() - - -def test_json_force_reinitialization_rejects_generic_destination_change( - tmp_path: Path, -): - project = tmp_path / "project" - _success( - _invoke( + ( [ - str(project), + "project", "--json", "--integration", "generic", - "--integration-options=--commands-dir .old/commands", + "--integration-options=--commands-dir", ], - cwd=tmp_path, - ) - ) - - manifest_path = ( - project / ".specify" / "integrations" / "generic.manifest.json" - ) - init_options_path = project / ".specify" / "init-options.json" - old_manifest = manifest_path.read_bytes() - old_init_options = init_options_path.read_bytes() - old_commands = { - path.relative_to(project): path.read_bytes() - for path in (project / ".old" / "commands").glob("speckit.*") - } - assert old_commands - - error = _failure( - _invoke( + "invalid_integration_options", + ), + ( [ - str(project), + "project", "--json", - "--force", "--integration", - "generic", - "--integration-options=--commands-dir .new/commands", + "bob", + "--integration-options=--skills --legacy-commands", ], - cwd=tmp_path, + "invalid_integration_options", ), - "invalid_integration_options", - ) - - assert error["details"]["current_commands_dir"] == ".old/commands" - assert error["details"]["requested_commands_dir"] == ".new/commands" - assert error["details"]["recommended_action"] == { - "command": "integration_upgrade", - "integration": "generic", - "force": True, - "integration_options": "--commands-dir .new/commands", - } - assert manifest_path.read_bytes() == old_manifest - assert init_options_path.read_bytes() == old_init_options - assert { - path.relative_to(project): path.read_bytes() - for path in (project / ".old" / "commands").glob("speckit.*") - } == old_commands - assert not (project / ".new").exists() + (["project", "--json", "--script", "fish"], "invalid_script_type"), + ], +) +def test_json_validation_failures_do_not_create_target( + tmp_path: Path, + args: list[str], + code: str, +): + _failure(_invoke(args, cwd=tmp_path), code) + assert not (tmp_path / "project").exists() -def test_json_force_reinitialization_rejects_missing_generic_destination( +def test_json_init_reports_missing_required_agent_tool( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): - from specify_cli.integrations import get_integration + import specify_cli._command_init_json as init_json - project = tmp_path / "project" - _success( + monkeypatch.setattr(init_json, "check_tool", lambda _tool: False) + + error = _failure( _invoke( - [ - str(project), - "--json", - "--integration", - "generic", - "--integration-options=--commands-dir .agent/commands", - ], + ["project", "--json", "--integration", "claude"], cwd=tmp_path, - ) - ) - init_options_path = project / ".specify" / "init-options.json" - old_init_options = init_options_path.read_bytes() - integration = get_integration("generic") - assert integration is not None - monkeypatch.setattr( - integration, - "_resolve_commands_destination", - lambda *_args, **_kwargs: None, + ), + "missing_agent_tool", ) + assert error["details"]["override_flag"] == "--ignore-agent-tools" + assert not (tmp_path / "project").exists() + + +def test_json_init_rejects_untrusted_url_before_mutation(tmp_path: Path): error = _failure( _invoke( [ - str(project), + "project", "--json", - "--force", - "--integration", - "generic", - "--integration-options=--commands-dir .agent/commands", + "--extension", + "https://example.com/extension.zip", ], cwd=tmp_path, ), - "invalid_integration_options", - ) - - assert error["message"] == ( - "The generic integration command destination could not be resolved." - ) - assert error["details"]["integration"] == "generic" - assert init_options_path.read_bytes() == old_init_options - - -def test_json_force_reinitialization_allows_same_generic_destination( - tmp_path: Path, -): - project = tmp_path / "project" - args = [ - str(project), - "--json", - "--integration", - "generic", - "--integration-options=--commands-dir .agent/commands", - ] - _success(_invoke(args, cwd=tmp_path)) - - payload = _success( - _invoke([*args, "--force"], cwd=tmp_path) + "extension_url_trust_required", ) - assert payload["project"]["operation"] == "reinitialized" - assert list((project / ".agent" / "commands").glob("speckit.*")) + assert error["details"]["required_flag"] == "--trust-extension-urls" + assert not (tmp_path / "project").exists() -def test_json_reinitialization_reports_reregistration_failures_as_warnings( +def test_json_init_exposes_optional_failures( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): - from specify_cli.extensions import ExtensionManager + import specify_cli.command_init as init_command from specify_cli.presets import PresetManager - project = tmp_path / "project" - _success( - _invoke( - [str(project), "--json", "--ignore-agent-tools"], - cwd=tmp_path, - ) - ) - - def fail_extensions(*_args: Any, **_kwargs: Any) -> None: - raise OSError("extension re-registration failed") - - def fail_presets(*_args: Any, **_kwargs: Any) -> None: - raise OSError("preset re-registration failed") - monkeypatch.setattr( - ExtensionManager, - "register_enabled_extensions_for_agent", - fail_extensions, + PresetManager, + "install_from_directory", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + OSError("preset install failed") + ), ) monkeypatch.setattr( - PresetManager, - "register_enabled_presets_for_agent", - fail_presets, + init_command, + "_install_extension_during_init", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + ValueError("extension install failed") + ), ) payload = _success( _invoke( [ - str(project), + "project", "--json", - "--force", "--ignore-agent-tools", + "--preset", + "lean", + "--extension", + "git", ], cwd=tmp_path, ) ) - warning_codes = {warning["code"] for warning in payload["warnings"]} - assert "extension_reregistration_failed" in warning_codes - assert "preset_reregistration_failed" in warning_codes + assert payload["components"]["preset"]["status"] == "failed" + assert payload["components"]["extensions"][0]["status"] == "failed" + assert {warning["code"] for warning in payload["warnings"]} >= { + "preset_install_failed", + "extension_install_failed", + } -def test_json_switch_to_generic_persists_settings_before_extension_reregistration( +def test_json_init_rolls_back_new_target_after_fatal_failure( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): - project = tmp_path / "project" - initial = _success( - _invoke( - [ - str(project), - "--json", - "--ignore-agent-tools", - "--extension", - "git", - ], - cwd=tmp_path, - ) + import specify_cli + + monkeypatch.setattr( + specify_cli, + "_install_shared_infra_or_exit", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + typer.Exit(1) + ), ) - assert initial["components"]["extensions"][0]["status"] == "installed" + project = tmp_path / "project" - payload = _success( + error = _failure( _invoke( - [ - str(project), - "--json", - "--force", - "--integration", - "generic", - "--integration-options=--commands-dir .agent/commands", - ], + [str(project), "--json", "--ignore-agent-tools"], cwd=tmp_path, - ) + ), + "initialization_failed", ) - warning_codes = {warning["code"] for warning in payload["warnings"]} - assert "extension_reregistration_failed" not in warning_codes - assert list((project / ".agent" / "commands").glob("speckit.git.*.md")) + assert error["details"]["rollback"]["status"] == "completed" + assert not project.exists() -def test_json_extension_status_does_not_depend_on_human_message( +def test_json_init_preserves_preexisting_target_after_fatal_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): - import specify_cli.command_init as init_command - import specify_cli.events as events - - def fail_refresh(_project_path: Path) -> None: - raise AssertionError( - "events must not refresh when no extension was newly installed" - ) + import specify_cli + project = tmp_path / "project" + project.mkdir() + marker = project / "keep.txt" + marker.write_text("keep", encoding="utf-8") monkeypatch.setattr( - init_command, - "_install_extension_during_init", - lambda *_args, **_kwargs: init_command._InitExtensionResult( - "already_installed", - "present from an earlier installation", + specify_cli, + "_install_shared_infra_or_exit", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + typer.Exit(1) ), ) - monkeypatch.setattr(events, "refresh_integration_events", fail_refresh) - payload = _success( + error = _failure( _invoke( [ - "project", + str(project), "--json", + "--force", "--ignore-agent-tools", - "--extension", - "git", ], cwd=tmp_path, - ) + ), + "initialization_failed", ) - assert payload["components"]["extensions"] == [ - { - "requested": "git", - "status": "already_installed", - "message": "present from an earlier installation", - } - ] + assert "rollback" not in error["details"] + assert marker.read_text(encoding="utf-8") == "keep" -@pytest.mark.parametrize( - ("args", "code"), - [ - (["--json"], "target_required"), - (["project", "--here", "--json"], "conflicting_target_options"), - ( - [ - "project", - "--json", - "--integration", - "not-registered", - ], - "invalid_integration", - ), - ( - [ - "project", - "--json", - "--integration", - "copilot", - "--integration-options=--not-an-option", - ], - "invalid_integration_options", - ), - ( - [ - "project", - "--json", - "--integration", - "generic", - ], - "invalid_integration_options", - ), - ( - [ - "project", - "--json", - "--integration", - "copilot", - "--integration-options=--skills --commands", - ], - "invalid_integration_options", - ), - ( - [ - "project", - "--json", - "--integration", - "bob", - "--integration-options=--skills --legacy-commands", - ], - "invalid_integration_options", - ), - ( - [ - "project", - "--json", - "--script", - "fish", - ], - "invalid_script_type", - ), - ], -) -def test_json_init_validation_failures_do_not_create_target( +def test_json_init_sanitizes_unexpected_exception( tmp_path: Path, - args: list[str], - code: str, + monkeypatch: pytest.MonkeyPatch, ): - _failure(_invoke(args, cwd=tmp_path), code) + from specify_cli.integrations import get_integration + + integration = get_integration("copilot") + assert integration is not None + monkeypatch.setattr( + integration, + "setup", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("sensitive detail") + ), + ) + + error = _failure( + _invoke( + ["project", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "internal_error", + ) + + assert error["details"] == {"exception_type": "RuntimeError"} + assert "sensitive detail" not in json.dumps(error) assert not (tmp_path / "project").exists() -def test_json_init_rejects_escaping_generic_directory_before_target_claim( +def test_json_init_reports_cleanup_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): - import specify_cli._command_init_json as init_json - - project = tmp_path / "project" - - def fail_claim(_plan: Any) -> None: - raise AssertionError("invalid integration options must fail before mutation") + import specify_cli + import specify_cli.command_init as init_command - monkeypatch.setattr(init_json, "_claim_new_target", fail_claim) + monkeypatch.setattr( + specify_cli, + "_install_shared_infra_or_exit", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + typer.Exit(1) + ), + ) + monkeypatch.setattr( + init_command.shutil, + "rmtree", + lambda _path: (_ for _ in ()).throw(OSError("cleanup failed")), + ) error = _failure( _invoke( - [ - str(project), - "--json", - "--integration", - "generic", - "--integration-options=--commands-dir ../outside", - ], + ["project", "--json", "--ignore-agent-tools"], cwd=tmp_path, ), - "invalid_integration_options", + "rollback_failed", ) - assert "escapes project root" in error["details"]["reason"] - assert not project.exists() - assert not (tmp_path / "outside").exists() - - -@pytest.mark.parametrize( - "args", - [ - ["project", "--json", "--unknown-option"], - ["project", "--json", "--script"], - ], -) -def test_json_init_parser_failures_use_structured_error( - tmp_path: Path, - args: list[str], -): - _failure(_invoke(args, cwd=tmp_path), "invalid_arguments") - assert not (tmp_path / "project").exists() + assert error["details"]["cleanup_error"]["code"] == "target_cleanup_failed" + assert (tmp_path / "project").is_dir() -def test_end_of_options_json_project_name_keeps_human_parser_errors( +def test_json_init_preserves_target_created_during_claim_race( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): - result = _invoke(["--", "--json", "extra"], cwd=tmp_path) - - assert result.exit_code == 2 - assert result.stdout == "" - assert "Usage:" in result.stderr - assert "unexpected extra argument" in result.stderr.lower() - with pytest.raises(json.JSONDecodeError): - json.loads(result.stderr) + project = tmp_path / "project" + marker = project / "keep.txt" + original_mkdir = Path.mkdir + def racing_mkdir(path: Path, *args: Any, **kwargs: Any) -> None: + if path == project and kwargs.get("exist_ok") is False: + original_mkdir(path, parents=True) + marker.write_text("keep", encoding="utf-8") + original_mkdir(path, *args, **kwargs) -def test_json_init_rejects_existing_named_target_without_force(tmp_path: Path): - project = tmp_path / "project" - project.mkdir() + monkeypatch.setattr(Path, "mkdir", racing_mkdir) - _failure( - _invoke([str(project), "--json"], cwd=tmp_path), + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), "target_exists", ) - assert project.is_dir() - assert not (project / ".specify").exists() + assert error["details"]["concurrent_creation"] is True + assert marker.read_text(encoding="utf-8") == "keep" -def test_json_init_reports_missing_required_agent_tool( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_json_init_reports_unverifiable_claim_without_cleanup( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): - import specify_cli._command_init_json as init_json + import specify_cli.command_init as init_command - monkeypatch.setattr(init_json, "check_tool", lambda _tool: False) + project = tmp_path / "project" + monkeypatch.setattr( + init_command, + "_directory_identity", + lambda _path: (_ for _ in ()).throw(OSError("identity failed")), + ) + monkeypatch.setattr( + init_command.shutil, + "rmtree", + lambda _path: (_ for _ in ()).throw( + AssertionError("unverified target must not be removed") + ), + ) error = _failure( _invoke( - [ - "project", - "--json", - "--integration", - "claude", - ], + [str(project), "--json", "--ignore-agent-tools"], cwd=tmp_path, ), - "missing_agent_tool", + "rollback_failed", ) - assert error["details"]["integration"] == "claude" - assert error["details"]["override_flag"] == "--ignore-agent-tools" - assert not (tmp_path / "project").exists() + assert error["details"]["cleanup_error"]["code"] == ( + "target_identity_unavailable" + ) + assert project.is_dir() -def test_json_init_rejects_untrusted_url_extension_before_mutation(tmp_path: Path): +def test_json_init_preserves_replacement_target( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + from specify_cli.integrations import get_integration + project = tmp_path / "project" + claimed = tmp_path / "claimed" + marker = project / "keep.txt" + integration = get_integration("copilot") + assert integration is not None + + def replace_target(*_args: Any, **_kwargs: Any) -> None: + project.rename(claimed) + project.mkdir() + marker.write_text("keep", encoding="utf-8") + raise RuntimeError("initialization failed") + + monkeypatch.setattr(integration, "setup", replace_target) error = _failure( _invoke( - [ - str(project), - "--json", - "--extension", - "https://example.com/extension.zip", - ], + [str(project), "--json", "--ignore-agent-tools"], cwd=tmp_path, ), - "extension_url_trust_required", + "rollback_failed", ) - assert error["details"]["required_flag"] == "--trust-extension-urls" - assert not project.exists() + assert error["details"]["cleanup_error"]["code"] == "target_identity_changed" + assert marker.read_text(encoding="utf-8") == "keep" + assert claimed.is_dir() -def test_json_init_rejects_http_extension_before_mutation(tmp_path: Path): - project = tmp_path / "project" +@pytest.mark.parametrize( + "args", + [ + ["project", "--json", "--unknown-option"], + ["project", "--json", "--script"], + ["project", "--json", "--integration"], + ], +) +def test_json_parser_failures_are_structured( + tmp_path: Path, + args: list[str], +): + _failure(_invoke(args, cwd=tmp_path), "invalid_arguments") + assert not (tmp_path / "project").exists() - error = _failure( + +@pytest.mark.parametrize( + "args", + [ + ["--ignore-agent-tools"], + [ + "--integration", + "copilot", + "--script", + "py", + "--ignore-agent-tools", + ], + [ + "--integration", + "copilot", + "--preset", + "lean", + "--extension", + "git", + "--ignore-agent-tools", + ], + [ + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + ], + [ + "--integration", + "copilot", + "--integration-options=--commands", + "--ignore-agent-tools", + ], + [ + "--integration", + "copilot", + "--integration-options=--skills", + "--ignore-agent-tools", + ], + [ + "--integration", + "copilot", + "--script", + "sh", + "--ignore-agent-tools", + ], + ], +) +def test_json_and_human_init_create_identical_projects( + tmp_path: Path, + args: list[str], +): + human = tmp_path / "human" + machine = tmp_path / "machine" + + human_result = _invoke( + [str(human), "--non-interactive", *args], + cwd=tmp_path, + ) + assert human_result.exit_code == 0, human_result.output + _success( _invoke( - [ - str(project), - "--json", - "--ignore-agent-tools", - "--extension", - "http://example.com/extension.zip", - "--trust-extension-urls", - ], + [str(machine), "--json", *args], cwd=tmp_path, - ), - "invalid_arguments", + ) ) - assert error["message"] == "Extension URLs must use HTTPS in JSON mode." - assert error["details"] == { - "extensions": ["http://example.com/extension.zip"], - "supported_scheme": "https", - } - assert not project.exists() + assert _project_snapshot(human) == _project_snapshot(machine) -def test_json_init_explicit_url_trust_never_prompts( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_json_and_human_reinitialization_create_identical_projects( + tmp_path: Path, ): - import specify_cli.command_init as init_command + human = tmp_path / "human" + machine = tmp_path / "machine" + initial_args = [ + "--integration", + "copilot", + "--extension", + "git", + "--ignore-agent-tools", + ] + switch_args = [ + "--force", + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + "--ignore-agent-tools", + ] - def fail_prompt(*_args: Any, **_kwargs: Any) -> None: - raise AssertionError("trusted JSON URL install must not prompt") + assert _invoke( + [str(human), "--non-interactive", *initial_args], + cwd=tmp_path, + ).exit_code == 0 + _success(_invoke([str(machine), "--json", *initial_args], cwd=tmp_path)) + assert _invoke( + [str(human), "--non-interactive", *switch_args], + cwd=tmp_path, + ).exit_code == 0 + _success(_invoke([str(machine), "--json", *switch_args], cwd=tmp_path)) - def fail_download(*_args: Any, **_kwargs: Any) -> str: - raise ValueError("download unavailable") + assert _project_snapshot(human) == _project_snapshot(machine) - monkeypatch.setattr(typer, "confirm", fail_prompt) - monkeypatch.setattr( - init_command, - "_install_extension_during_init", - fail_download, - ) +def test_json_empty_selections_use_and_report_defaults(tmp_path: Path): payload = _success( _invoke( [ "project", "--json", + "--integration", + "", + "--script", + "", "--ignore-agent-tools", - "--extension", - "https://example.com/extension.zip", - "--trust-extension-urls", ], cwd=tmp_path, ) ) - assert payload["components"]["extensions"][0]["status"] == "failed" - assert any( - warning["code"] == "extension_install_failed" - for warning in payload["warnings"] - ) + assert payload["integration"]["defaulted"] is True + assert payload["script"]["defaulted"] is True -def test_json_init_invalid_default_integration_becomes_structured_warning( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "missing-default") +def test_json_here_rejects_nonempty_target_without_force(tmp_path: Path): + marker = tmp_path / "keep.txt" + marker.write_text("keep", encoding="utf-8") - payload = _success( + _failure( _invoke( - ["project", "--json", "--ignore-agent-tools"], + ["--here", "--json", "--ignore-agent-tools"], cwd=tmp_path, - ) + ), + "target_not_empty", ) - assert payload["integration"]["key"] == "copilot" - assert payload["integration"]["defaulted"] is True - assert payload["warnings"][0]["code"] == "invalid_default_integration" - - -def test_json_init_redacts_suppressed_output( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -): - import specify_cli._command_init_json as init_json - - stdout_secret = "stdout-secret-token" - stderr_secret = "stderr-secret-token" + assert marker.read_text(encoding="utf-8") == "keep" + assert not (tmp_path / ".specify").exists() - def noisy_initialize(plan: Any) -> dict[str, Any]: - print(stdout_secret) - print(stderr_secret, file=sys.stderr) - return { - "project": {"path": str(plan.project_path)}, - "warnings": [], - } - monkeypatch.setattr(init_json, "_initialize_project", noisy_initialize) +def test_json_rejects_target_file(tmp_path: Path): + target = tmp_path / "project" + target.write_text("keep", encoding="utf-8") - result = _invoke( - ["project", "--json", "--ignore-agent-tools"], - cwd=tmp_path, + _failure( + _invoke([str(target), "--json"], cwd=tmp_path), + "target_not_directory", ) - payload = _success(result) - - serialized = result.stdout - assert stdout_secret not in serialized - assert stderr_secret not in serialized - warnings = {warning["code"]: warning for warning in payload["warnings"]} - assert warnings["suppressed_stdout"]["details"] == { - "byte_count": len(f"{stdout_secret}\n".encode()) - } - assert warnings["suppressed_stderr"]["details"] == { - "byte_count": len(f"{stderr_secret}\n".encode()) - } + assert target.read_text(encoding="utf-8") == "keep" -def test_json_init_exposes_optional_preset_and_extension_failures( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli.command_init as init_command - from specify_cli.presets import PresetManager - def fail_preset(*_args: Any, **_kwargs: Any) -> None: - raise OSError("preset install failed") +def test_json_rejects_empty_named_target_without_force(tmp_path: Path): + target = tmp_path / "project" + target.mkdir() - def fail_extension(*_args: Any, **_kwargs: Any) -> str: - raise ValueError("extension install failed") + _failure( + _invoke([str(target), "--json"], cwd=tmp_path), + "target_exists", + ) - monkeypatch.setattr(PresetManager, "install_from_directory", fail_preset) - monkeypatch.setattr( - init_command, - "_install_extension_during_init", - fail_extension, + assert not (target / ".specify").exists() + + +def test_json_reports_reinitialization_operation(tmp_path: Path): + project = tmp_path / "project" + _success( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) ) payload = _success( _invoke( [ - "project", + str(project), "--json", + "--force", "--ignore-agent-tools", - "--preset", - "lean", - "--extension", - "git", ], cwd=tmp_path, ) ) - assert payload["components"]["preset"]["status"] == "failed" - assert payload["components"]["extensions"] == [ - { - "requested": "git", - "status": "failed", - "reason": "extension install failed", - } - ] - warning_codes = {warning["code"] for warning in payload["warnings"]} - assert "preset_install_failed" in warning_codes - assert "extension_install_failed" in warning_codes + assert payload["project"]["operation"] == "reinitialized" -def test_json_init_exposes_skipped_bundled_workflow( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_json_invalid_default_integration_is_a_warning( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): - import specify_cli._command_init_json as init_json - - monkeypatch.setattr(init_json, "_locate_bundled_workflow", lambda _id: None) + monkeypatch.setenv("SPECKIT_INTEGRATION_DEFAULT", "missing") payload = _success( _invoke( @@ -1078,294 +817,210 @@ def test_json_init_exposes_skipped_bundled_workflow( ) ) - assert payload["components"]["workflow"] == { - "id": "speckit", - "status": "skipped", - "reason": "bundled_workflow_not_found", - } - assert any( - warning["code"] == "bundled_workflow_not_found" - for warning in payload["warnings"] - ) + assert payload["integration"]["key"] == "copilot" + assert payload["warnings"][0]["code"] == "invalid_default_integration" -def test_json_init_rolls_back_new_target_after_fatal_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_json_reports_missing_bundled_workflow( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): - import specify_cli._command_init_json as init_json - - def fail_shared(*_args: Any, **_kwargs: Any) -> dict[str, Any]: - raise OSError("shared infrastructure failed") + import specify_cli.command_init as init_command - monkeypatch.setattr(init_json, "_install_shared_infrastructure", fail_shared) - project = tmp_path / "project" + monkeypatch.setattr(init_command, "_locate_bundled_workflow", lambda _id: None) - error = _failure( + payload = _success( _invoke( - [str(project), "--json", "--ignore-agent-tools"], + ["project", "--json", "--ignore-agent-tools"], cwd=tmp_path, - ), - "initialization_failed", + ) ) - assert error["details"]["rollback"] == { - "status": "completed", - "path": str(project.resolve()), - } - assert not project.exists() + assert payload["components"]["workflow"]["status"] == "skipped" + assert { + warning["code"] for warning in payload["warnings"] + } >= {"bundled_workflow_not_found"} -def test_json_init_never_deletes_preexisting_target_after_fatal_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch +def test_json_reports_missing_optional_preset( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ): - import specify_cli._command_init_json as init_json - - project = tmp_path / "project" - project.mkdir() - marker = project / "keep.txt" - marker.write_text("keep", encoding="utf-8") + from specify_cli.presets import PresetCatalog - def fail_shared(*_args: Any, **_kwargs: Any) -> dict[str, Any]: - raise OSError("shared infrastructure failed") - - monkeypatch.setattr(init_json, "_install_shared_infrastructure", fail_shared) + monkeypatch.setattr( + PresetCatalog, + "get_pack_info", + lambda *_args, **_kwargs: None, + ) - error = _failure( + payload = _success( _invoke( [ - str(project), + "project", "--json", - "--force", + "--preset", + "missing", "--ignore-agent-tools", ], cwd=tmp_path, - ), - "initialization_failed", + ) ) - assert "rollback" not in error["details"] - assert marker.read_text(encoding="utf-8") == "keep" - assert project.is_dir() - + assert payload["components"]["preset"]["status"] == "skipped" + assert {warning["code"] for warning in payload["warnings"]} >= { + "preset_not_found" + } -def test_json_init_never_deletes_target_created_during_claim_race( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli._command_init_json as init_json +def test_json_reports_already_installed_extension(tmp_path: Path): project = tmp_path / "project" - marker = project / "keep.txt" - original_mkdir = Path.mkdir + args = [ + str(project), + "--json", + "--extension", + "git", + "--ignore-agent-tools", + ] + _success(_invoke(args, cwd=tmp_path)) - def racing_mkdir(path: Path, *args: Any, **kwargs: Any) -> None: - if path == project and kwargs.get("exist_ok") is False: - original_mkdir(path, parents=True) - marker.write_text("keep", encoding="utf-8") - original_mkdir(path, *args, **kwargs) + payload = _success(_invoke([*args, "--force"], cwd=tmp_path)) - def fail_initialize(_plan: Any) -> dict[str, Any]: - raise AssertionError("initialization must not start without target ownership") + assert payload["components"]["extensions"][0]["status"] == "already_installed" - monkeypatch.setattr(Path, "mkdir", racing_mkdir) - monkeypatch.setattr(init_json, "_initialize_project", fail_initialize) - error = _failure( - _invoke( - [str(project), "--json", "--ignore-agent-tools"], - cwd=tmp_path, - ), - "target_exists", - ) +def test_json_preserves_existing_constitution(tmp_path: Path): + project = tmp_path / "project" + args = [str(project), "--json", "--ignore-agent-tools"] + _success(_invoke(args, cwd=tmp_path)) + constitution = project / ".specify" / "memory" / "constitution.md" + constitution.write_text("custom constitution", encoding="utf-8") - assert error["details"]["concurrent_creation"] is True - assert marker.read_text(encoding="utf-8") == "keep" - assert project.is_dir() + payload = _success(_invoke([*args, "--force"], cwd=tmp_path)) + + assert payload["components"]["constitution"]["status"] == "preserved" + assert constitution.read_text(encoding="utf-8") == "custom constitution" -def test_json_init_reports_rollback_failure_when_claim_identity_is_unavailable( +def test_json_explicit_url_trust_never_prompts( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ): - import specify_cli._command_init_json as init_json - - project = tmp_path / "project" - - def fail_identity(_path: Path) -> tuple[int, int]: - raise OSError("identity lookup failed") - - def fail_unsafe_cleanup(_path: Path) -> None: - raise AssertionError("cleanup must not run without verified target identity") - - monkeypatch.setattr(init_json, "_directory_identity", fail_identity) - monkeypatch.setattr(init_json.shutil, "rmtree", fail_unsafe_cleanup) + import specify_cli.command_init as init_command - error = _failure( - _invoke( - [str(project), "--json", "--ignore-agent-tools"], - cwd=tmp_path, + monkeypatch.setattr( + typer, + "confirm", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("trusted JSON URL must not prompt") ), - "rollback_failed", - ) - - assert error["details"]["original_error"]["code"] == "target_unavailable" - assert error["details"]["cleanup_error"]["code"] == ( - "target_identity_unavailable" ) - assert error["details"]["cleanup_error"]["exception_type"] == "OSError" - assert project.is_dir() - - -def test_json_init_never_deletes_replacement_after_target_claim( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli._command_init_json as init_json - - project = tmp_path / "project" - moved_claim = tmp_path / "moved-claim" - marker = project / "keep.txt" - - def replace_target_and_fail(plan: Any) -> dict[str, Any]: - plan.project_path.rename(moved_claim) - plan.project_path.mkdir() - marker.write_text("keep", encoding="utf-8") - raise RuntimeError("initialization failed after target replacement") - monkeypatch.setattr( - init_json, - "_initialize_project", - replace_target_and_fail, - ) - - error = _failure( - _invoke( - [str(project), "--json", "--ignore-agent-tools"], - cwd=tmp_path, + init_command, + "_install_extension_during_init", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + ValueError("download unavailable") ), - "rollback_failed", ) - assert error["details"]["original_error"]["code"] == "internal_error" - assert error["details"]["cleanup_error"]["code"] == "target_identity_changed" - assert marker.read_text(encoding="utf-8") == "keep" - assert project.is_dir() - assert moved_claim.is_dir() - - -def test_json_init_rolls_back_when_integration_setup_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - from specify_cli.integrations import get_integration - - project = tmp_path / "project" - integration = get_integration("copilot") - assert integration is not None - - def fail_setup(*_args: Any, **_kwargs: Any) -> None: - project.mkdir(exist_ok=True) - raise OSError("integration setup failed") - - monkeypatch.setattr(integration, "setup", fail_setup) - - error = _failure( + payload = _success( _invoke( [ - str(project), + "project", "--json", - "--integration", - "copilot", + "--extension", + "https://example.com/extension.zip", + "--trust-extension-urls", "--ignore-agent-tools", ], cwd=tmp_path, - ), - "initialization_failed", + ) ) - assert error["details"]["component"] == "integration" - assert error["details"]["rollback"]["status"] == "completed" - assert not project.exists() - - -def test_json_init_rolls_back_when_manifest_construction_fails( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli.integrations.manifest as manifest_module - - project = tmp_path / "project" - - class FailingManifest: - def __init__(self, *_args: Any, **_kwargs: Any) -> None: - project.mkdir(exist_ok=True) - raise OSError("manifest construction failed") + assert payload["components"]["extensions"][0]["status"] == "failed" - monkeypatch.setattr(manifest_module, "IntegrationManifest", FailingManifest) - error = _failure( +def test_json_http_url_uses_regular_extension_behavior(tmp_path: Path): + payload = _success( _invoke( - [str(project), "--json", "--ignore-agent-tools"], + [ + "project", + "--json", + "--extension", + "http://example.com/extension.zip", + "--trust-extension-urls", + "--ignore-agent-tools", + ], cwd=tmp_path, - ), - "initialization_failed", + ) ) - assert error["details"]["component"] == "integration" - assert error["details"]["rollback"]["status"] == "completed" - assert not project.exists() - - -def test_json_init_sanitizes_unexpected_exception_and_rolls_back( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli._command_init_json as init_json - - project = tmp_path / "project" + assert payload["components"]["extensions"][0]["status"] == "failed" + assert payload["warnings"][0]["code"] == "extension_install_failed" - def fail_unexpected(plan: Any) -> dict[str, Any]: - raise RuntimeError("sensitive internal detail") - monkeypatch.setattr(init_json, "_initialize_project", fail_unexpected) +def test_json_and_human_here_mode_create_identical_projects(tmp_path: Path): + human = tmp_path / "human" + machine = tmp_path / "machine" + human.mkdir() + machine.mkdir() + (human / "keep.txt").write_text("keep", encoding="utf-8") + (machine / "keep.txt").write_text("keep", encoding="utf-8") - error = _failure( + assert _invoke( + [ + "--here", + "--force", + "--non-interactive", + "--ignore-agent-tools", + ], + cwd=human, + ).exit_code == 0 + _success( _invoke( - [str(project), "--json", "--ignore-agent-tools"], - cwd=tmp_path, - ), - "internal_error", + [ + "--here", + "--force", + "--json", + "--ignore-agent-tools", + ], + cwd=machine, + ) ) - assert error["details"]["exception_type"] == "RuntimeError" - assert "sensitive internal detail" not in json.dumps(error) - assert error["details"]["rollback"]["status"] == "completed" - assert not project.exists() - - -def test_json_init_reports_cleanup_failure( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -): - import specify_cli._command_init_json as init_json - - project = tmp_path / "project" - - def fail_unexpected(plan: Any) -> dict[str, Any]: - raise RuntimeError("initial failure") + assert _project_snapshot(human) == _project_snapshot(machine) - def fail_cleanup(_path: Path) -> None: - raise OSError("cleanup failed") - monkeypatch.setattr(init_json, "_initialize_project", fail_unexpected) - monkeypatch.setattr(init_json.shutil, "rmtree", fail_cleanup) +def test_json_and_human_force_merge_create_identical_projects(tmp_path: Path): + human = tmp_path / "human" + machine = tmp_path / "machine" + human.mkdir() + machine.mkdir() + (human / "keep.txt").write_text("keep", encoding="utf-8") + (machine / "keep.txt").write_text("keep", encoding="utf-8") - error = _failure( + assert _invoke( + [ + str(human), + "--force", + "--non-interactive", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ).exit_code == 0 + _success( _invoke( - [str(project), "--json", "--ignore-agent-tools"], + [ + str(machine), + "--force", + "--json", + "--ignore-agent-tools", + ], cwd=tmp_path, - ), - "rollback_failed", + ) ) - assert error["details"]["original_error"]["code"] == "internal_error" - assert error["details"]["cleanup_error"]["code"] == "target_cleanup_failed" - assert error["details"]["cleanup_error"]["exception_type"] == "OSError" - assert project.is_dir() + assert _project_snapshot(human) == _project_snapshot(machine) def test_noninteractive_human_output_remains_human_readable(tmp_path: Path): From 45378e63f66510178bdac4bac28b69ff88bd9a63 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:22:05 -0500 Subject: [PATCH 15/19] fix(init): harden rollback and reinit preflight Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 49 +++- src/specify_cli/command_init.py | 307 +++++++++++++++++--- tests/integrations/test_cli.py | 9 +- tests/specify_cli/test_command_init_json.py | 269 +++++++++++++++-- 4 files changed, 558 insertions(+), 76 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index d1aeb17aeb..b95dbc8bc8 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -111,6 +111,11 @@ def _preflight( extensions: list[str] | None, trust_extension_urls: bool, ) -> dict[str, Any]: + from ._download_security import is_https_or_localhost_http + from .command_init import ( + _InitIntegrationOptionsError, + _validate_init_integration_options, + ) from .integrations import get_integration from .integrations._commands import _parse_integration_options @@ -169,6 +174,18 @@ def _preflight( ) requested_extensions = list(extensions or []) + unsupported_urls = [ + extension + for extension in requested_extensions + if _extension_url(extension) + and not is_https_or_localhost_http(extension) + ] + if unsupported_urls: + raise InitJsonFailure( + "invalid_extension_url", + "Extension URLs must use HTTPS; HTTP is allowed only for localhost.", + {"extensions": unsupported_urls}, + ) untrusted_urls = [ extension for extension in requested_extensions @@ -236,6 +253,21 @@ def _preflight( parsed_options or None, project_root=project_path, ) + _validate_init_integration_options( + project_path, + integration, + parsed_options or None, + integration_options, + ) + except _InitIntegrationOptionsError as exc: + raise InitJsonFailure( + "invalid_integration_options", + exc.message, + { + "integration": selected_integration, + **exc.details, + }, + ) from exc except (ValueError, typer.Exit) as exc: raise InitJsonFailure( "invalid_integration_options", @@ -338,20 +370,7 @@ def run_init_json( internal_failure.message, internal_failure.details, ) - elif isinstance(internal_failure, _InitRollbackError) or ( - context - and context["operation"] == "created" - and project_path is not None - and project_path.exists() - ): - cleanup_error = ( - internal_failure.details - if isinstance(internal_failure, _InitRollbackError) - else { - "code": "target_cleanup_failed", - "reason": "The newly created target still exists.", - } - ) + elif isinstance(internal_failure, _InitRollbackError): failure = InitJsonFailure( "rollback_failed", "Initialization failed and the newly created target could not be removed.", @@ -362,7 +381,7 @@ def run_init_json( "message": "Project initialization failed.", "details": {}, }, - "cleanup_error": cleanup_error, + "cleanup_error": internal_failure.details, }, ) elif internal_failure is not None and not isinstance( diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index 0c18f83b68..6000b6bc08 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -8,6 +8,7 @@ import stat import subprocess import sys +import tempfile from contextlib import nullcontext from contextvars import ContextVar from pathlib import Path @@ -53,6 +54,148 @@ def __init__(self, code: str, message: str, details: dict[str, Any]): self.details = details +class _InitIntegrationOptionsError(Exception): + def __init__(self, message: str, details: dict[str, Any]): + super().__init__(message) + self.message = message + self.details = details + + +def _validate_init_integration_options( + project_path: Path, + integration: Any, + parsed_options: dict[str, Any] | None, + raw_options: str | None, +) -> None: + """Reject unsafe integration destinations and in-place layout changes.""" + from .integration_state import ( + default_integration_key, + installed_integration_keys, + integration_setting, + try_read_integration_json, + ) + + project_root = project_path.resolve() + requested_destination = None + if integration.key == "generic": + try: + commands_dir = integration._resolve_commands_dir( + parsed_options, + {"raw_options": raw_options}, + ) + except ValueError as exc: + raise _InitIntegrationOptionsError( + "The generic integration options are invalid.", + {"reason": str(exc)}, + ) from exc + requested_destination = (project_path / commands_dir).resolve() + if not requested_destination.is_relative_to(project_root): + raise _InitIntegrationOptionsError( + "The generic integration command directory must be inside the project.", + { + "destination": str(requested_destination), + "project_path": str(project_root), + }, + ) + + state, state_error = try_read_integration_json(project_path) + if state_error is not None: + raise _InitIntegrationOptionsError( + "The existing integration state could not be validated.", + { + "reason": state_error.detail or state_error.kind, + "recommendation": "Repair .specify/integration.json before reinitializing.", + }, + ) + if state is None: + return + + installed = installed_integration_keys(state) + current_key = default_integration_key(state) + if current_key and current_key != integration.key: + raise _InitIntegrationOptionsError( + "Reinitialization cannot replace the active integration.", + { + "current_integration": current_key, + "requested_integration": integration.key, + "recommendation": ( + f"Run 'specify integration switch {integration.key}' instead." + ), + }, + ) + incompatible_installed = [key for key in installed if key != integration.key] + if incompatible_installed: + raise _InitIntegrationOptionsError( + "Reinitialization cannot replace the installed integration set.", + { + "installed_integrations": installed, + "requested_integration": integration.key, + "recommendation": ( + "Use 'specify integration switch' or the integration " + "install/uninstall commands instead." + ), + }, + ) + + stored = integration_setting(state, integration.key) + if not stored: + return + stored_parsed = stored.get("parsed_options") + if not isinstance(stored_parsed, dict): + stored_parsed = None + stored_mode = integration.is_skills_mode( + stored_parsed, + project_root=project_path, + ) + requested_mode = integration.is_skills_mode( + parsed_options, + project_root=project_path, + ) + if stored_mode != requested_mode: + raise _InitIntegrationOptionsError( + "Reinitialization cannot change the integration command layout.", + { + "integration": integration.key, + "current_layout": "skills" if stored_mode else "commands", + "requested_layout": "skills" if requested_mode else "commands", + "recommendation": ( + f"Run 'specify integration upgrade {integration.key} " + "--integration-options \"...\"' instead." + ), + }, + ) + + if requested_destination is not None: + try: + stored_commands_dir = integration._resolve_commands_dir( + stored_parsed, + {"raw_options": stored.get("raw_options")}, + ) + except ValueError as exc: + raise _InitIntegrationOptionsError( + "The existing generic integration destination is invalid.", + { + "reason": str(exc), + "recommendation": ( + "Repair .specify/integration.json before reinitializing." + ), + }, + ) from exc + stored_destination = (project_path / stored_commands_dir).resolve() + if stored_destination != requested_destination: + raise _InitIntegrationOptionsError( + "Reinitialization cannot change the generic integration destination.", + { + "current_destination": str(stored_destination), + "requested_destination": str(requested_destination), + "recommendation": ( + "Run 'specify integration upgrade generic " + "--integration-options \"--commands-dir ...\"' instead." + ), + }, + ) + + def _directory_identity(path: Path) -> tuple[int, int]: state = path.stat(follow_symlinks=False) if not stat.S_ISDIR(state.st_mode): @@ -105,33 +248,103 @@ def _rollback_new_target( if target_identity is None: return try: - current_identity = _directory_identity(project_path) + staging_root = Path( + tempfile.mkdtemp( + prefix=".specify-rollback-", + dir=project_path.parent, + ) + ) except OSError as exc: + raise _InitRollbackError( + { + "code": "target_staging_failed", + "exception_type": exc.__class__.__name__, + "reason": str(exc), + } + ) from exc + + staged_target = staging_root / "target" + try: + project_path.rename(staged_target) + except OSError as exc: + cleanup_error = None + try: + staging_root.rmdir() + except OSError as cleanup_exc: + cleanup_error = str(cleanup_exc) + details = { + "code": "target_staging_failed", + "exception_type": exc.__class__.__name__, + "reason": str(exc), + } + if cleanup_error is not None: + details["staging_cleanup_error"] = cleanup_error + raise _InitRollbackError( + details + ) from exc + + def preserve_staged_target() -> dict[str, Any]: + details: dict[str, Any] = {"preserved_path": str(staged_target)} + if project_path.exists(): + details["restore_error"] = "The target path is occupied." + return details + try: + staged_target.rename(project_path) + except OSError as exc: + details["restore_error"] = str(exc) + return details + details["preserved_path"] = str(project_path) + try: + staging_root.rmdir() + except OSError as exc: + details["staging_cleanup_error"] = str(exc) + return details + + try: + staged_identity = _directory_identity(staged_target) + except OSError as exc: + details = preserve_staged_target() raise _InitRollbackError( { "code": "target_identity_unavailable", "exception_type": exc.__class__.__name__, "reason": str(exc), + **details, } ) from exc - if current_identity != target_identity: + if staged_identity != target_identity: + details = preserve_staged_target() raise _InitRollbackError( { "code": "target_identity_changed", "reason": ( - "The target path no longer identifies the directory created " - "by this invocation." + "The target path no longer identified the directory created " + "by this invocation when rollback began." ), + **details, } ) try: - shutil.rmtree(project_path) + shutil.rmtree(staged_target) + except OSError as exc: + details = preserve_staged_target() + raise _InitRollbackError( + { + "code": "target_cleanup_failed", + "exception_type": exc.__class__.__name__, + "reason": str(exc), + **details, + } + ) from exc + try: + staging_root.rmdir() except OSError as exc: raise _InitRollbackError( { "code": "target_cleanup_failed", "exception_type": exc.__class__.__name__, "reason": str(exc), + "preserved_path": str(staging_root), } ) from exc @@ -938,6 +1151,19 @@ def init( ) if extra: integration_parsed_options.update(extra) + try: + _validate_init_integration_options( + project_path, + resolved_integration, + integration_parsed_options or None, + integration_options, + ) + except _InitIntegrationOptionsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + recommendation = exc.details.get("recommendation") + if recommendation: + console.print(f"[dim]{recommendation}[/dim]") + raise typer.Exit(1) from exc resolved_integration.is_skills_mode( integration_parsed_options or None, project_root=project_path, @@ -951,6 +1177,17 @@ def init( if extensions: url_specs = [e for e in extensions if _ext_spec_is_url(e)] if url_specs: + from ._download_security import is_https_or_localhost_http + + unsupported_urls = [ + spec for spec in url_specs if not is_https_or_localhost_http(spec) + ] + if unsupported_urls: + console.print( + "[red]Error:[/red] Extension URLs must use HTTPS; " + "HTTP is allowed only for localhost." + ) + raise typer.Exit(1) extension_url_approvals = _confirm_extension_url_trust( url_specs, trust_override=trust_extension_urls, @@ -1011,6 +1248,36 @@ def init( ) manifest.save() + init_opts = { + "ai": selected_ai, + "integration": resolved_integration.key, + "here": here, + "script": selected_script, + "feature_numbering": "sequential", + "speckit_version": get_speckit_version(), + } + if resolved_integration.is_skills_mode( + integration_parsed_options or None, project_root=project_path + ): + init_opts["ai_skills"] = True + save_init_options(project_path, init_opts) + + integration_settings = _with_integration_setting( + {}, + resolved_integration.key, + resolved_integration, + script_type=selected_script, + raw_options=integration_options, + parsed_options=integration_parsed_options or None, + project_root=project_path, + ) + _write_integration_json( + project_path, + resolved_integration.key, + [resolved_integration.key], + integration_settings, + ) + if force: from .integrations._helpers import ( _register_extensions_for_agent, @@ -1035,22 +1302,6 @@ def init( ), ) - integration_settings = _with_integration_setting( - {}, - resolved_integration.key, - resolved_integration, - script_type=selected_script, - raw_options=integration_options, - parsed_options=integration_parsed_options or None, - project_root=project_path, - ) - _write_integration_json( - project_path, - resolved_integration.key, - [resolved_integration.key], - integration_settings, - ) - tracker.complete( "integration", resolved_integration.config.get("name", resolved_integration.key), @@ -1149,20 +1400,6 @@ def init( ) ) - init_opts = { - "ai": selected_ai, - "integration": resolved_integration.key, - "here": here, - "script": selected_script, - "feature_numbering": "sequential", - "speckit_version": get_speckit_version(), - } - if resolved_integration.is_skills_mode( - integration_parsed_options or None, project_root=project_path - ): - init_opts["ai_skills"] = True - save_init_options(project_path, init_opts) - ensure_executable_scripts(project_path, tracker=tracker) if preset: diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index ed6864253b..542a26d104 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -1982,18 +1982,17 @@ def _zip_bytes_from_dir(source_dir): return buf.getvalue() def test_url_extension_rejects_non_https(self, tmp_path): - """A non-HTTPS URL is rejected before any download; init is not aborted.""" + """A non-HTTPS URL is rejected before target creation or download.""" project, result = self._run_init( tmp_path, ["--extension", "http://example.com/ext.zip", "--trust-extension-urls"], project_name="ext-http", ) - assert result.exit_code == 0, "init should not abort on a rejected URL" + assert result.exit_code == 1 normalized = _normalize_cli_output(result.output) - assert "failed" in normalized.lower() - # No extension directory should have been created for the bad URL. - assert not (project / ".specify" / "extensions" / "ext").exists() + assert "must use https" in normalized.lower() + assert not (project / ".specify").exists() def test_url_extension_skipped_without_trust(self, tmp_path): """Non-interactive URL install without --trust-extension-urls is denied.""" diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index e99f787266..227ecccb63 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -279,6 +279,16 @@ def test_json_init_force_merges_nonempty_target(tmp_path: Path): ], "invalid_integration_options", ), + ( + [ + "project", + "--json", + "--integration", + "generic", + "--integration-options=--commands-dir ../outside", + ], + "invalid_integration_options", + ), ( [ "project", @@ -289,6 +299,16 @@ def test_json_init_force_merges_nonempty_target(tmp_path: Path): ], "invalid_integration_options", ), + ( + [ + "project", + "--json", + "--extension", + "http://example.com/extension.zip", + "--trust-extension-urls", + ], + "invalid_extension_url", + ), (["project", "--json", "--script", "fish"], "invalid_script_type"), ], ) @@ -602,6 +622,50 @@ def replace_target(*_args: Any, **_kwargs: Any) -> None: assert claimed.is_dir() +def test_json_rollback_does_not_delete_public_path_replacement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli.command_init as init_command + from specify_cli.integrations import get_integration + + project = tmp_path / "project" + marker = project / "keep.txt" + integration = get_integration("copilot") + assert integration is not None + original_directory_identity = init_command._directory_identity + + def replace_public_path_after_staging(path: Path) -> tuple[int, int]: + identity = original_directory_identity(path) + if path.name == "target" and ".specify-rollback-" in path.parent.name: + project.mkdir() + marker.write_text("replacement", encoding="utf-8") + return identity + + monkeypatch.setattr( + init_command, + "_directory_identity", + replace_public_path_after_staging, + ) + monkeypatch.setattr( + integration, + "setup", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("initialization failed") + ), + ) + + _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "internal_error", + ) + + assert marker.read_text(encoding="utf-8") == "replacement" + + @pytest.mark.parametrize( "args", [ @@ -701,8 +765,7 @@ def test_json_and_human_reinitialization_create_identical_projects( switch_args = [ "--force", "--integration", - "generic", - "--integration-options=--commands-dir .agent/commands", + "copilot", "--ignore-agent-tools", ] @@ -720,6 +783,189 @@ def test_json_and_human_reinitialization_create_identical_projects( assert _project_snapshot(human) == _project_snapshot(machine) +@pytest.mark.parametrize( + "switch_args", + [ + [ + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + ], + [ + "--integration", + "copilot", + "--integration-options=--commands", + ], + ], +) +def test_json_rejects_incompatible_reinitialization_before_mutation( + tmp_path: Path, + switch_args: list[str], +): + project = tmp_path / "project" + _success( + _invoke( + [ + str(project), + "--json", + "--integration", + "copilot", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + before = _project_snapshot(project) + + error = _failure( + _invoke( + [ + str(project), + "--json", + "--force", + *switch_args, + "--ignore-agent-tools", + ], + cwd=tmp_path, + ), + "invalid_integration_options", + ) + + assert "integration" in error["details"] + assert _project_snapshot(project) == before + + +def test_json_rejects_generic_destination_change_before_mutation(tmp_path: Path): + project = tmp_path / "project" + _success( + _invoke( + [ + str(project), + "--json", + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + before = _project_snapshot(project) + + _failure( + _invoke( + [ + str(project), + "--json", + "--force", + "--integration", + "generic", + "--integration-options=--commands-dir .other/commands", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ), + "invalid_integration_options", + ) + + assert _project_snapshot(project) == before + + +def test_human_rejects_incompatible_reinitialization_before_mutation( + tmp_path: Path, +): + project = tmp_path / "project" + first = _invoke( + [ + str(project), + "--non-interactive", + "--integration", + "copilot", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + assert first.exit_code == 0, first.output + before = _project_snapshot(project) + + result = _invoke( + [ + str(project), + "--non-interactive", + "--force", + "--integration", + "generic", + "--integration-options=--commands-dir .agent/commands", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + + assert result.exit_code == 1 + assert "integration switch generic" in result.output + assert _project_snapshot(project) == before + + +def test_force_reinitialization_persists_state_before_registration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli + from specify_cli.integrations import _helpers + + project = tmp_path / "project" + assert ( + _invoke( + [ + str(project), + "--non-interactive", + "--integration", + "copilot", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ).exit_code + == 0 + ) + stale_options = specify_cli.load_init_options(project) + stale_options.update( + { + "ai": "generic", + "integration": "generic", + } + ) + stale_options.pop("ai_skills", None) + specify_cli.save_init_options(project, stale_options) + + observed: list[tuple[str, dict[str, Any]]] = [] + + def capture_state(project_root: Path, agent_key: str, **_kwargs: Any) -> None: + observed.append((agent_key, specify_cli.load_init_options(project_root))) + + monkeypatch.setattr(_helpers, "_register_extensions_for_agent", capture_state) + monkeypatch.setattr(_helpers, "_register_presets_for_agent", capture_state) + + result = _invoke( + [ + str(project), + "--non-interactive", + "--force", + "--integration", + "copilot", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + + assert result.exit_code == 0, result.output + assert len(observed) == 2 + for agent_key, options in observed: + assert agent_key == "copilot" + assert options["ai"] == "copilot" + assert options["integration"] == "copilot" + assert options["ai_skills"] is True + + def test_json_empty_selections_use_and_report_defaults(tmp_path: Path): payload = _success( _invoke( @@ -940,25 +1186,6 @@ def test_json_explicit_url_trust_never_prompts( assert payload["components"]["extensions"][0]["status"] == "failed" -def test_json_http_url_uses_regular_extension_behavior(tmp_path: Path): - payload = _success( - _invoke( - [ - "project", - "--json", - "--extension", - "http://example.com/extension.zip", - "--trust-extension-urls", - "--ignore-agent-tools", - ], - cwd=tmp_path, - ) - ) - - assert payload["components"]["extensions"][0]["status"] == "failed" - assert payload["warnings"][0]["code"] == "extension_install_failed" - - def test_json_and_human_here_mode_create_identical_projects(tmp_path: Path): human = tmp_path / "human" machine = tmp_path / "machine" From 4f22fb2e3143308335bafb494d1cc8d018754877 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:00:53 -0500 Subject: [PATCH 16/19] fix(init): complete structured JSON outcomes Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 31 ++-- src/specify_cli/command_init.py | 161 +++++++++++++++++--- src/specify_cli/integrations/_helpers.py | 25 +-- tests/specify_cli/test_command_init_json.py | 150 +++++++++++++++++- 4 files changed, 318 insertions(+), 49 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index b95dbc8bc8..475e521471 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -136,14 +136,6 @@ def _preflight( project_path = Path.cwd() if here else Path(project_name or "").resolve() existed_before = project_path.exists() - already_initialized = (project_path / ".specify").is_dir() - operation = ( - "reinitialized" - if already_initialized - else "merged" - if existed_before - else "created" - ) if existed_before: if not project_path.is_dir(): raise InitJsonFailure( @@ -305,7 +297,6 @@ def _preflight( return { "project_path": project_path, - "operation": operation, "integration_defaulted": integration_defaulted, "script_defaulted": script_defaulted, "warnings": warnings, @@ -331,6 +322,7 @@ def run_init_json( _InitTargetClaimError, _init_failure_context, _init_json_mode, + _init_rollback_context, ) result: dict[str, Any] | None = None @@ -339,6 +331,7 @@ def run_init_json( stdout_capture = io.StringIO() stderr_capture = io.StringIO() failure_token = _init_failure_context.set(None) + rollback_token = _init_rollback_context.set(None) json_mode_token = _init_json_mode.set(True) try: with ( @@ -361,8 +354,9 @@ def run_init_json( result = execute() except InitJsonFailure as exc: failure = exc - except typer.Exit as exc: + except (typer.Exit, SystemExit) as exc: internal_failure = _init_failure_context.get() + rollback = _init_rollback_context.get() project_path = context["project_path"] if context else None if isinstance(internal_failure, _InitTargetClaimError): failure = InitJsonFailure( @@ -395,17 +389,21 @@ def run_init_json( ) else: details: dict[str, Any] = {} - if context and context["operation"] == "created": - details["rollback"] = { - "status": "completed", - "path": str(context["project_path"]), - } + if rollback is not None and rollback.get("status") == "completed": + details["rollback"] = rollback failure = InitJsonFailure( "initialization_failed", "Project initialization failed.", details, ) - if exc.exit_code == 0: + exit_code = ( + exc.exit_code + if isinstance(exc, typer.Exit) + else exc.code + if isinstance(exc.code, int) + else 1 + ) + if exit_code == 0: failure = InitJsonFailure( "initialization_failed", "Project initialization did not complete.", @@ -418,6 +416,7 @@ def run_init_json( ) finally: _init_failure_context.reset(failure_token) + _init_rollback_context.reset(rollback_token) _init_json_mode.reset(json_mode_token) if failure is not None: diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index 6000b6bc08..89918dfb27 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -11,6 +11,7 @@ import tempfile from contextlib import nullcontext from contextvars import ContextVar +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -37,6 +38,10 @@ "init_failure", default=None, ) +_init_rollback_context: ContextVar[dict[str, Any] | None] = ContextVar( + "init_rollback", + default=None, +) _init_json_mode: ContextVar[bool] = ContextVar("init_json_mode", default=False) @@ -526,15 +531,21 @@ def _confirm_extension_url_trust( return approvals -def _install_extension_during_init( +@dataclass(frozen=True) +class _InitExtensionInstallResult: + status: str + message: str + + +def _install_extension_during_init_result( project_path: Path, ext_spec: str, speckit_version: str, -) -> str: +) -> _InitExtensionInstallResult: """Install a single extension during ``specify init``. Handles bundled extension names, local directory paths, and HTTPS URLs. - Returns a short status message on success. + Returns machine status separately from the human-facing message. Raises ``ValueError`` on failure so the caller can convert it to a tracker error without aborting the entire init. """ @@ -558,7 +569,10 @@ def _install_extension_during_init( ) except ExtensionError as exc: raise ValueError(str(exc)) from exc - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionInstallResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) # --- Local path --- if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute(): @@ -568,15 +582,24 @@ def _install_extension_during_init( if not (source_path / "extension.yml").exists(): raise ValueError(f"No extension.yml found in {source_path}") manifest = manager.install_from_directory(source_path, speckit_version) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionInstallResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) # --- Bundled extension name or catalog ID --- bundled_path = _locate_bundled_extension(ext_spec) if bundled_path is not None: if manager.registry.is_installed(ext_spec): - return "already installed" + return _InitExtensionInstallResult( + "already_installed", + "already installed", + ) manifest = manager.install_from_directory(bundled_path, speckit_version) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionInstallResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) # Fall back to catalog catalog = ExtensionCatalog(project_path) @@ -591,9 +614,15 @@ def _install_extension_during_init( bundled_path = _locate_bundled_extension(resolved_id) if bundled_path is not None: if manager.registry.is_installed(resolved_id): - return "already installed" + return _InitExtensionInstallResult( + "already_installed", + "already installed", + ) manifest = manager.install_from_directory(bundled_path, speckit_version) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionInstallResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) if ext_info.get("bundled") and not ext_info.get("download_url"): from .extensions import REINSTALL_COMMAND @@ -618,7 +647,23 @@ def _install_extension_during_init( ) finally: zip_path.unlink(missing_ok=True) - return f"{manifest.name} v{manifest.version} installed" + return _InitExtensionInstallResult( + "installed", + f"{manifest.name} v{manifest.version} installed", + ) + + +def _install_extension_during_init( + project_path: Path, + ext_spec: str, + speckit_version: str, +) -> str: + """Compatibility wrapper returning the existing human status message.""" + return _install_extension_during_init_result( + project_path, + ext_spec, + speckit_version, + ).message def _shell_quote_arg(value: str) -> str: @@ -967,6 +1012,17 @@ def init( if project_path.exists(): safe_name = _escape_markup(str(project_name)) if not project_path.is_dir(): + if _init_json_mode.get(): + _init_failure_context.set( + _InitTargetClaimError( + "target_not_directory", + "The target path exists but is not a directory.", + { + "path": str(project_path), + "concurrent_creation": True, + }, + ) + ) console.print( f"[red]Error:[/red] '{safe_name}' exists but is not a directory." ) @@ -984,6 +1040,17 @@ def init( f"[cyan]--force supplied: merging into existing directory '[cyan]{safe_name}[/cyan]'[/cyan]" ) else: + if _init_json_mode.get(): + _init_failure_context.set( + _InitTargetClaimError( + "target_exists", + "The target directory already exists.", + { + "path": str(project_path), + "concurrent_creation": True, + }, + ) + ) error_panel = Panel( f"Directory already exists: '[cyan]{safe_name}[/cyan]'\n" "Please choose a different project name or remove the existing directory.\n" @@ -1199,6 +1266,13 @@ def init( project_path, should_claim=not here and not dir_existed_before, ) + if target_identity is not None: + _init_rollback_context.set( + { + "status": "claimed", + "path": str(project_path), + } + ) except (_InitRollbackError, _InitTargetClaimError) as exc: _init_failure_context.set(exc) console.print(f"[red]Error:[/red] Could not create target: {exc}") @@ -1284,7 +1358,7 @@ def init( _register_presets_for_agent, ) - _register_extensions_for_agent( + extension_registration_error = _register_extensions_for_agent( project_path, resolved_integration.key, force=True, @@ -1293,7 +1367,18 @@ def init( " may need re-registration." ), ) - _register_presets_for_agent( + if extension_registration_error is not None: + init_warnings.append( + _warning( + "extension_reregistration_failed", + "The project was re-initialized, but installed extensions may need re-registration.", + integration=resolved_integration.key, + reason=extension_registration_error.replace( + "\n", " " + ).strip(), + ) + ) + preset_registration_error = _register_presets_for_agent( project_path, resolved_integration.key, continuing=( @@ -1301,6 +1386,17 @@ def init( " may need re-registration." ), ) + if preset_registration_error is not None: + init_warnings.append( + _warning( + "preset_reregistration_failed", + "The project was re-initialized, but installed presets may need re-registration.", + integration=resolved_integration.key, + reason=preset_registration_error.replace( + "\n", " " + ).strip(), + ) + ) tracker.complete( "integration", @@ -1578,26 +1674,21 @@ def init( ) continue try: - status_message = _install_extension_during_init( + install_result = _install_extension_during_init_result( project_path, ext_spec, speckit_ver ) tracker.complete( f"extension-{i}", - status_message, - ) - extension_status = ( - "already_installed" - if status_message == "already installed" - else "installed" + install_result.message, ) extension_outcomes.append( { "requested": ext_spec, - "status": extension_status, - "message": status_message, + "status": install_result.status, + "message": install_result.message, } ) - if extension_status == "installed": + if install_result.status == "installed": any_extension_installed = True except Exception as ext_err: sanitized_ext = str(ext_err).replace("\n", " ").strip() @@ -1707,7 +1798,20 @@ def init( except (typer.Exit, SystemExit): try: _rollback_new_target(project_path, target_identity) + if target_identity is not None: + _init_rollback_context.set( + { + "status": "completed", + "path": str(project_path), + } + ) except _InitRollbackError as cleanup_err: + _init_rollback_context.set( + { + "status": "failed", + "path": str(project_path), + } + ) _init_failure_context.set(cleanup_err) console.print( "[red]Error:[/red] Initialization failed and the new " @@ -1744,7 +1848,20 @@ def init( ) try: _rollback_new_target(project_path, target_identity) + if target_identity is not None: + _init_rollback_context.set( + { + "status": "completed", + "path": str(project_path), + } + ) except _InitRollbackError as cleanup_err: + _init_rollback_context.set( + { + "status": "failed", + "path": str(project_path), + } + ) _init_failure_context.set(cleanup_err) console.print( "[red]Error:[/red] Initialization failed and the new " diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 38e87294ec..2658a0cb5d 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -370,13 +370,14 @@ def _best_effort_extension_op( *, phase: str, continuing: str, -) -> None: +) -> str | None: """Run a best-effort ``ExtensionManager`` operation for ``agent_key``. ``op`` receives the ``ExtensionManager`` and ``agent_key``. Any failure is surfaced as a warning via ``_print_cli_warning`` and never aborts the surrounding integration operation. ``continuing`` describes what already - succeeded so the warning makes the partial outcome clear. + succeeded so the warning makes the partial outcome clear. Returns the + failure reason for callers that also expose structured outcomes. """ try: from ..extensions import ExtensionManager @@ -387,6 +388,8 @@ def _best_effort_extension_op( from .. import _print_cli_warning _print_cli_warning(phase, "integration", agent_key, ext_err, continuing=continuing) + return str(ext_err) + return None def _register_extensions_for_agent( @@ -395,7 +398,7 @@ def _register_extensions_for_agent( *, continuing: str, force: bool = False, -) -> None: +) -> str | None: """Register all enabled extensions' commands/skills for ``agent_key``. ``use`` / ``switch`` re-register enabled extensions for the agent they @@ -413,11 +416,12 @@ def _register_extensions_for_agent( extension content is layered on top of the core-template files that ``setup()`` just regenerated (fixes the skip-guard bug for skills mode). - Best-effort: never aborts the surrounding integration operation. Callers - invoke it *after* the use/upgrade/switch transaction has committed so a - failure here cannot trigger a rollback. + Best-effort: never aborts the surrounding integration operation and returns + the failure reason when registration fails. Callers invoke it *after* the + use/upgrade/switch transaction has committed so a failure here cannot + trigger a rollback. """ - _best_effort_extension_op( + return _best_effort_extension_op( project_root, agent_key, lambda mgr, key: mgr.register_enabled_extensions_for_agent(key, force=force), @@ -452,7 +456,7 @@ def _register_presets_for_agent( agent_key: str, *, continuing: str, -) -> None: +) -> str | None: """Register all enabled presets' command overrides/skills for ``agent_key``. Presets follow the same single-active rule as extensions (#2948): @@ -460,7 +464,8 @@ def _register_presets_for_agent( activate (rescaffold), so a preset installed while a different integration was active is not left targeting that inactive integration. - Best-effort: never aborts the surrounding integration operation. + Best-effort: never aborts the surrounding integration operation and returns + the failure reason when registration fails. """ try: from ..presets import PresetManager @@ -477,6 +482,8 @@ def _register_presets_for_agent( preset_err, continuing=continuing, ) + return str(preset_err) + return None def _resync_manifest_after_registration( diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 227ecccb63..909cb7d6c3 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -375,7 +375,7 @@ def test_json_init_exposes_optional_failures( ) monkeypatch.setattr( init_command, - "_install_extension_during_init", + "_install_extension_during_init_result", lambda *_args, **_kwargs: (_ for _ in ()).throw( ValueError("extension install failed") ), @@ -431,6 +431,34 @@ def test_json_init_rolls_back_new_target_after_fatal_failure( assert not project.exists() +def test_json_init_structures_system_exit_and_reports_actual_rollback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli + + monkeypatch.setattr( + specify_cli, + "_install_shared_infra_or_exit", + lambda *_args, **_kwargs: (_ for _ in ()).throw(SystemExit(7)), + ) + project = tmp_path / "project" + + error = _failure( + _invoke( + ["project", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "initialization_failed", + ) + + assert error["details"]["rollback"] == { + "status": "completed", + "path": str(project), + } + assert not project.exists() + + def test_json_init_preserves_preexisting_target_after_fatal_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -555,6 +583,36 @@ def racing_mkdir(path: Path, *args: Any, **kwargs: Any) -> None: assert marker.read_text(encoding="utf-8") == "keep" +def test_json_init_does_not_infer_rollback_from_stale_preflight( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli._command_init_json as init_json + + project = tmp_path / "project" + marker = project / "keep.txt" + original_preflight = init_json._preflight + + def racing_preflight(**kwargs: Any) -> dict[str, Any]: + context = original_preflight(**kwargs) + project.mkdir() + marker.write_text("keep", encoding="utf-8") + return context + + monkeypatch.setattr(init_json, "_preflight", racing_preflight) + + error = _failure( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ), + "target_exists", + ) + + assert "rollback" not in error["details"] + assert marker.read_text(encoding="utf-8") == "keep" + + def test_json_init_reports_unverifiable_claim_without_cleanup( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1135,6 +1193,94 @@ def test_json_reports_already_installed_extension(tmp_path: Path): assert payload["components"]["extensions"][0]["status"] == "already_installed" +def test_json_extension_status_does_not_depend_on_display_message( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli.command_init as init_command + import specify_cli.events as events + + monkeypatch.setattr( + init_command, + "_install_extension_during_init_result", + lambda *_args, **_kwargs: init_command._InitExtensionInstallResult( + status="already_installed", + message="present from an earlier installation", + ), + ) + monkeypatch.setattr( + events, + "refresh_integration_events", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("already-installed extension must not refresh events") + ), + ) + + payload = _success( + _invoke( + [ + "project", + "--json", + "--extension", + "git", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + assert payload["components"]["extensions"][0] == { + "requested": "git", + "status": "already_installed", + "message": "present from an earlier installation", + } + + +def test_json_exposes_force_reregistration_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + from specify_cli.integrations import _helpers + + project = tmp_path / "project" + _success( + _invoke( + [str(project), "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + monkeypatch.setattr( + _helpers, + "_register_extensions_for_agent", + lambda *_args, **_kwargs: "extension registration failed", + ) + monkeypatch.setattr( + _helpers, + "_register_presets_for_agent", + lambda *_args, **_kwargs: "preset registration failed", + ) + + payload = _success( + _invoke( + [ + str(project), + "--json", + "--force", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + warnings = {warning["code"]: warning for warning in payload["warnings"]} + assert warnings["extension_reregistration_failed"]["details"]["reason"] == ( + "extension registration failed" + ) + assert warnings["preset_reregistration_failed"]["details"]["reason"] == ( + "preset registration failed" + ) + + def test_json_preserves_existing_constitution(tmp_path: Path): project = tmp_path / "project" args = [str(project), "--json", "--ignore-agent-tools"] @@ -1163,7 +1309,7 @@ def test_json_explicit_url_trust_never_prompts( ) monkeypatch.setattr( init_command, - "_install_extension_during_init", + "_install_extension_during_init_result", lambda *_args, **_kwargs: (_ for _ in ()).throw( ValueError("download unavailable") ), From 5123942aac60fd60cac2998f504a4ef8384d8b2a Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:27:46 -0500 Subject: [PATCH 17/19] fix(init): preserve integration option diagnostics Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/_command_init_json.py | 14 ++++- src/specify_cli/integrations/_commands.py | 1 + src/specify_cli/integrations/_helpers.py | 33 +++++++++--- .../integrations/test_registration.py | 3 ++ tests/specify_cli/test_command_init_json.py | 54 +++++++++++++++++++ 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/_command_init_json.py b/src/specify_cli/_command_init_json.py index 475e521471..d0730f34b1 100644 --- a/src/specify_cli/_command_init_json.py +++ b/src/specify_cli/_command_init_json.py @@ -117,7 +117,10 @@ def _preflight( _validate_init_integration_options, ) from .integrations import get_integration - from .integrations._commands import _parse_integration_options + from .integrations._commands import ( + _IntegrationOptionsError, + _parse_integration_options, + ) if project_name == ".": here = True @@ -251,6 +254,15 @@ def _preflight( parsed_options or None, integration_options, ) + except _IntegrationOptionsError as exc: + raise InitJsonFailure( + "invalid_integration_options", + "Integration options are invalid.", + { + "integration": selected_integration, + "reason": exc.diagnostic, + }, + ) from exc except _InitIntegrationOptionsError as exc: raise InitJsonFailure( "invalid_integration_options", diff --git a/src/specify_cli/integrations/_commands.py b/src/specify_cli/integrations/_commands.py index ef90e576e9..f17d4a7175 100644 --- a/src/specify_cli/integrations/_commands.py +++ b/src/specify_cli/integrations/_commands.py @@ -12,6 +12,7 @@ # Re-export helpers used by command_init.py and tests from ._helpers import ( # noqa: F401 + _IntegrationOptionsError, _cli_error_detail, _cli_phase_label, _parse_integration_options, diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 2658a0cb5d..b4aa7c0f42 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -184,6 +184,14 @@ def _resolve_integration_script_type( # Integration options # --------------------------------------------------------------------------- +class _IntegrationOptionsError(typer.Exit): + """Exit carrying the parser diagnostic for machine-readable callers.""" + + def __init__(self, diagnostic: str): + super().__init__(1) + self.diagnostic = diagnostic + + def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, Any] | None: """Parse --integration-options string into a dict matching the integration's declared options. @@ -198,8 +206,9 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, # makes shlex raise "No closing quotation". Translate it into the same # clean exit-1 UX as every other bad-input path below rather than # letting a raw traceback escape. - console.print(f"[red]Error:[/red] Could not parse integration options: {exc}.") - raise typer.Exit(1) + diagnostic = f"Could not parse integration options: {exc}." + console.print(f"[red]Error:[/red] {diagnostic}") + raise _IntegrationOptionsError(diagnostic) declared_options = list(integration.options()) declared = {opt.name.lstrip("-"): opt for opt in declared_options} allowed = ", ".join(sorted(opt.name for opt in declared_options)) @@ -208,9 +217,11 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, token = tokens[i] if not token.startswith("-"): console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.") + diagnostic = f"Unexpected integration option value '{token}'." if allowed: console.print(f"Allowed options: {allowed}") - raise typer.Exit(1) + diagnostic += f" Allowed options: {allowed}." + raise _IntegrationOptionsError(diagnostic) name = token.lstrip("-") value: str | None = None # Handle --name=value syntax @@ -219,14 +230,19 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, opt = declared.get(name) if not opt: console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.") + diagnostic = f"Unknown integration option '{token}'." if allowed: console.print(f"Allowed options: {allowed}") - raise typer.Exit(1) + diagnostic += f" Allowed options: {allowed}." + raise _IntegrationOptionsError(diagnostic) key = name.replace("-", "_") if opt.is_flag: if value is not None: - console.print(f"[red]Error:[/red] Option '{opt.name}' is a flag and does not accept a value.") - raise typer.Exit(1) + diagnostic = ( + f"Option '{opt.name}' is a flag and does not accept a value." + ) + console.print(f"[red]Error:[/red] {diagnostic}") + raise _IntegrationOptionsError(diagnostic) parsed[key] = True i += 1 elif value is not None: @@ -236,8 +252,9 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str, parsed[key] = tokens[i + 1] i += 2 else: - console.print(f"[red]Error:[/red] Option '{opt.name}' requires a value.") - raise typer.Exit(1) + diagnostic = f"Option '{opt.name}' requires a value." + console.print(f"[red]Error:[/red] {diagnostic}") + raise _IntegrationOptionsError(diagnostic) return parsed or None diff --git a/tests/specify_cli/integrations/test_registration.py b/tests/specify_cli/integrations/test_registration.py index 6ef4256f74..fb95b771d5 100644 --- a/tests/specify_cli/integrations/test_registration.py +++ b/tests/specify_cli/integrations/test_registration.py @@ -106,6 +106,9 @@ def test_unbalanced_quote_exits_cleanly(self, capsys): with pytest.raises(typer.Exit) as excinfo: _parse_integration_options(integration, '--commands-dir "foo') assert excinfo.value.exit_code == 1 + assert excinfo.value.diagnostic == ( + "Could not parse integration options: No closing quotation." + ) assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out def test_bad_option_token_with_rich_markup_exits_cleanly(self): diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 909cb7d6c3..0191857c1d 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -321,6 +321,60 @@ def test_json_validation_failures_do_not_create_target( assert not (tmp_path / "project").exists() +@pytest.mark.parametrize( + ("integration", "options", "diagnostic"), + [ + ( + "copilot", + '--skills "', + "Could not parse integration options: No closing quotation.", + ), + ( + "copilot", + "--missing", + "Unknown integration option '--missing'.", + ), + ( + "copilot", + "--skills=true", + "Option '--skills' is a flag and does not accept a value.", + ), + ( + "generic", + "--commands-dir", + "Option '--commands-dir' requires a value.", + ), + ( + "copilot", + "--skills stray", + "Unexpected integration option value 'stray'.", + ), + ], +) +def test_json_integration_option_failures_preserve_parser_diagnostic( + tmp_path: Path, + integration: str, + options: str, + diagnostic: str, +): + error = _failure( + _invoke( + [ + "project", + "--json", + "--integration", + integration, + f"--integration-options={options}", + ], + cwd=tmp_path, + ), + "invalid_integration_options", + ) + + assert diagnostic in error["details"]["reason"] + assert not (tmp_path / "project").exists() + + def test_json_init_reports_missing_required_agent_tool( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 56aa42f946fb3f083136097459f6d80c47a9b745 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:00:03 -0500 Subject: [PATCH 18/19] test(init): cover event refresh JSON warning Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/specify_cli/test_command_init_json.py | 51 +++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index 0191857c1d..c7e7ef86ee 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -1290,6 +1290,57 @@ def test_json_extension_status_does_not_depend_on_display_message( } +def test_json_exposes_extension_event_refresh_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import specify_cli.events as events + from specify_cli.events import EventRefreshError + + monkeypatch.setattr( + events, + "refresh_integration_events", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + EventRefreshError( + [ + ("copilot", "hook write failed"), + ("claude", "settings unavailable"), + ] + ) + ), + ) + + payload = _success( + _invoke( + [ + "project", + "--json", + "--extension", + "git", + "--ignore-agent-tools", + ], + cwd=tmp_path, + ) + ) + + assert payload["components"]["extensions"][0]["status"] == "installed" + warning = next( + warning + for warning in payload["warnings"] + if warning["code"] == "extension_event_refresh_failed" + ) + assert warning["details"]["failures"] == [ + { + "integration": "copilot", + "reason": "hook write failed", + }, + { + "integration": "claude", + "reason": "settings unavailable", + }, + ] + + def test_json_exposes_force_reregistration_failures( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From e05b58e009daf77fe4472a539f9095b20236f336 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:18:10 -0500 Subject: [PATCH 19/19] fix(init): preserve layout conflict diagnostics Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/integrations/bob/__init__.py | 11 ++--- .../integrations/copilot/__init__.py | 11 ++--- tests/specify_cli/test_command_init_json.py | 47 +++++++++++++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 821f03dd66..38d10dc058 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -24,8 +24,6 @@ from pathlib import Path from typing import Any -import typer - from ..base import ( IntegrationBase, IntegrationOption, @@ -45,12 +43,13 @@ def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None: opts = parsed_options or {} if opts.get("skills") and opts.get("legacy_commands"): from ..._console import console + from .._helpers import _IntegrationOptionsError - console.print( - "[red]Error:[/red] --skills and --legacy-commands are mutually " - "exclusive; pass only one." + diagnostic = ( + "--skills and --legacy-commands are mutually exclusive; pass only one." ) - raise typer.Exit(1) + console.print(f"[red]Error:[/red] {diagnostic}") + raise _IntegrationOptionsError(diagnostic) def _warn_legacy_commands_deprecated() -> None: diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 0ea251d0af..864186f6ea 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -27,8 +27,6 @@ from pathlib import Path from typing import Any -import typer - from ..base import IntegrationBase, IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -96,12 +94,13 @@ def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None: opts = parsed_options or {} if opts.get("skills") and opts.get("commands"): from ..._console import console + from .._helpers import _IntegrationOptionsError - console.print( - "[red]Error:[/red] --skills and --commands are mutually exclusive; " - "pass only one." + diagnostic = ( + "--skills and --commands are mutually exclusive; pass only one." ) - raise typer.Exit(1) + console.print(f"[red]Error:[/red] {diagnostic}") + raise _IntegrationOptionsError(diagnostic) class _CopilotSkillsHelper(SkillsIntegration): diff --git a/tests/specify_cli/test_command_init_json.py b/tests/specify_cli/test_command_init_json.py index c7e7ef86ee..ca9251e496 100644 --- a/tests/specify_cli/test_command_init_json.py +++ b/tests/specify_cli/test_command_init_json.py @@ -349,6 +349,16 @@ def test_json_validation_failures_do_not_create_target( "--skills stray", "Unexpected integration option value 'stray'.", ), + ( + "copilot", + "--skills --commands", + "--skills and --commands are mutually exclusive; pass only one.", + ), + ( + "bob", + "--skills --legacy-commands", + "--skills and --legacy-commands are mutually exclusive; pass only one.", + ), ], ) def test_json_integration_option_failures_preserve_parser_diagnostic( @@ -1200,6 +1210,43 @@ def test_json_reports_missing_bundled_workflow( } >= {"bundled_workflow_not_found"} +def test_json_reports_bundled_workflow_install_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + from specify_cli.workflows.engine import WorkflowDefinition + + monkeypatch.setattr( + WorkflowDefinition, + "from_yaml", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + OSError("workflow parse failed") + ), + ) + + payload = _success( + _invoke( + ["project", "--json", "--ignore-agent-tools"], + cwd=tmp_path, + ) + ) + + assert payload["components"]["workflow"] == { + "id": "speckit", + "status": "failed", + "reason": "workflow parse failed", + } + warning = next( + warning + for warning in payload["warnings"] + if warning["code"] == "workflow_install_failed" + ) + assert warning["details"] == { + "workflow": "speckit", + "reason": "workflow parse failed", + } + + def test_json_reports_missing_optional_preset( tmp_path: Path, monkeypatch: pytest.MonkeyPatch,