diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c9fce2cd8d..64df171ee7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,7 +7,7 @@ - [ ] Tested locally with `uv run specify --help` -- [ ] Ran existing tests with `uv sync && uv run pytest` +- [ ] Ran existing tests with `uv sync --extra test && uv run pytest` - [ ] Tested with a sample project (if applicable) ## AI Disclosure diff --git a/AGENTS.md b/AGENTS.md index 9ef3d6c851..8f2c051a92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -540,6 +540,10 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag ## Common Pitfalls +For local Ruff checks, use the CI-pinned `uvx` command in +[Run Lint / Basic Checks](docs/local-development.md#8-run-lint--basic-checks), +even when Ruff is absent from `PATH` and `.venv`. + 1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. 2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files — including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts. 3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 96818dba35..f6c18c8a4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -226,6 +226,10 @@ job until a follow-up cleanup tightens the threshold. ### Manual testing +This section covers testing slash-command behavior through a coding agent and +reporting those results in a pull request. For post-initialization configuration, +run the automated verifier in the [local development guide](docs/local-development.md#4-verify-post-initialization-configuration). + #### Testing setup ```bash @@ -257,7 +261,7 @@ Any change that affects a slash command's behavior requires manually testing tha Paste this into your PR: -~~~markdown +```markdown ## Manual test results **Agent**: [e.g., GitHub Copilot in VS Code] | **OS/Shell**: [e.g., macOS/zsh] @@ -265,13 +269,13 @@ Paste this into your PR: | Command tested | Notes | |----------------|-------| | `/speckit.command` | | -~~~ +``` #### Determining which tests to run Copy this prompt into your agent. Include the agent's response (selected tests plus a brief explanation of the mapping) in your PR. -~~~text +```text Read CONTRIBUTING.md, then run `git diff --name-only main` to get my changed files. For each changed file, determine which slash commands it affects by reading the command templates in templates/commands/ to understand what each command @@ -303,7 +307,7 @@ Number each test sequentially (T1, T2, ...). List prerequisite tests first. - T1: /speckit.command — (reason) - T2: /speckit.command — (reason) -~~~ +``` ## AI contributions in Spec Kit diff --git a/docs/local-development.md b/docs/local-development.md index 34070451fc..d1f04d8f47 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -47,7 +47,25 @@ specify --help Re-running after code edits requires no reinstall because of editable mode. -## 4. Invoke with uvx Directly From Git (Current Branch) +## 4. Verify Post-Initialization Configuration + +Use the automated verifier to exercise the post-initialization configuration +workflow in a disposable Copilot project. After completing the editable install +in the previous section, run: + +```bash +scripts/dev/verify-post-initialization-configuration.sh --specify "$(pwd)/.venv/bin/specify" +``` + +The script verifies configuration reads, script upgrades, mutable settings, +persisted options, protected settings, and the bundled `git` extension +lifecycle. It removes the temporary project when it exits. Set `SPECIFY` to an +executable path instead of passing `--specify` if preferred. + +For manual slash-command testing and its pull-request reporting template, see +[Manual testing](../CONTRIBUTING.md#manual-testing). + +## 5. Invoke with uvx Directly From Git (Current Branch) `uvx` can run from a local path (or a Git ref) to simulate user flows: @@ -63,7 +81,7 @@ git push origin your-feature-branch uvx --from git+https://github.com/github/spec-kit.git@your-feature-branch specify init demo-branch-test --script ps ``` -### 4a. Absolute Path uvx (Run From Anywhere) +### 5a. Absolute Path uvx (Run From Anywhere) If you're in another directory, use an absolute path instead of `.`: @@ -87,7 +105,7 @@ specify-dev() { uvx --from /mnt/c/GitHub/spec-kit specify "$@"; } specify-dev --help ``` -## 5. Testing Script Permission Logic +## 6. Testing Script Permission Logic After running an `init`, check that shell scripts are executable on POSIX systems: @@ -98,7 +116,7 @@ ls -l scripts | grep .sh On Windows you will instead use the `.ps1` scripts (no chmod needed). -## 6. Scaffold a Built-In Integration +## 7. Scaffold a Built-In Integration Use the integration scaffold command to create the initial Python package and test skeleton for a new built-in integration: @@ -118,21 +136,28 @@ The scaffold does not register the integration automatically. Review the generated metadata, then add the import and `_register()` call in `src/specify_cli/integrations/__init__.py`. -## 7. Run Lint / Basic Checks +## 8. Run Lint / Basic Checks -CI enforces `ruff check src tests` (see `.github/workflows/test.yml`), so run it locally before pushing: +Run Ruff from the repository root through `uvx`, matching the version pinned in +`.github/workflows/test.yml`: ```bash -uvx ruff check src tests +uvx ruff@0.15.0 check src tests ``` +Ruff does not need to be on `PATH` or installed in `.venv`; `uvx` manages its +isolated tool environment. Do not report Ruff unavailable just because those +locations lack the executable. If the tool is already cached, use +`uvx --offline ruff@0.15.0 check src tests` to run without network access. +If sandbox permissions block the uv cache, request cache access and retry. + You can also quickly sanity check importability: ```bash python -c "import specify_cli; print('Import OK')" ``` -## 8. Build a Wheel Locally (Optional) +## 9. Build a Wheel Locally (Optional) Validate packaging before publishing: @@ -143,7 +168,7 @@ ls dist/ Install the built artifact into a fresh throwaway environment if needed. -## 9. Using a Temporary Workspace +## 10. Using a Temporary Workspace When testing `init --here` in a dirty directory, create a temp workspace: @@ -154,7 +179,7 @@ python -m src.specify_cli init --here --integration claude --ignore-agent-tools Or copy only the modified CLI portion if you want a lighter sandbox. -## 10. Debug Network / TLS Issues +## 11. Debug Network / TLS Issues > **Deprecated:** The `--skip-tls` flag is a no-op and has no effect. > It was previously used to bypass TLS validation during local testing. @@ -163,7 +188,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox. > > For example, set `SSL_CERT_FILE` or configure `HTTPS_PROXY` / `HTTP_PROXY`. -## 11. Rapid Edit Loop Summary +## 12. Rapid Edit Loop Summary | Action | Command | |--------|---------| @@ -174,7 +199,7 @@ Or copy only the modified CLI portion if you want a lighter sandbox. | Git branch uvx | `uvx --from git+URL@branch specify ...` | | Build wheel | `uv build` | -## 12. Cleaning Up +## 13. Cleaning Up Remove build artifacts / virtual env quickly: @@ -182,7 +207,7 @@ Remove build artifacts / virtual env quickly: rm -rf .venv dist build *.egg-info ``` -## 13. Common Issues +## 14. Common Issues | Symptom | Fix | |---------|-----| @@ -192,7 +217,7 @@ rm -rf .venv dist build *.egg-info | Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script py` explicitly | | TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | -## 14. Next Steps +## 15. Next Steps - Update docs and run through Quick Start using your modified CLI - Open a PR when satisfied diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 0000000000..4392cf56dc --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,72 @@ +# Project Configuration + +Use `specify config` to inspect and safely change supported settings recorded +when you initialized a project. Run these commands from the project root, or +set `SPECIFY_INIT_DIR` to the project root. + +## Inspect configuration + +```bash +specify config list +specify config list --json +specify config get script +``` + +`list` shows persisted initialization settings and a summary of installed +extensions. `--json` prints both as machine-readable JSON. + +## Change supported initialization settings + +```bash +specify config set feature-numbering timestamp +``` + +Supported values are: + +| Setting | Values | +| --- | --- | +| `feature-numbering` | `sequential`, `timestamp` | + +Legacy projects without `.specify/init-options.json` must first run +`specify integration install ` (or `specify integration use ` for an +already installed integration). Until then, `config set` refuses to create the +file because doing so without an active agent would disable legacy extension +and preset command registration. + +Script type, the active integration, and skills layout are owned by +`specify integration`. Changing a script type requires regenerating installed +agent files: + +```bash +specify integration upgrade --script py +``` + +Use the active integration key to update both its commands and the script +setting shown by `config get script`. Supported script types are `sh`, `ps`, +and `py`. Upgrade checks manifest hashes and refuses to overwrite modified +files without `--force`; review those changes before choosing to overwrite +them. + +Use `specify integration use ` to select an installed integration. +For layout changes, use `specify integration upgrade +--integration-options="..."` with that integration's supported options. For +example, Copilot supports `--integration-options="--commands"`. Layout options +vary by integration; `ai-skills` is not a universal toggle. + +`here` and `speckit-version` are read-only initialization metadata. Known +read-only settings and unknown keys produce distinct errors when set. + +## Manage extensions + +`specify config extension` exposes the existing extension lifecycle under the +configuration namespace. It has the same behavior as `specify extension`. + +```bash +specify config extension list +specify config extension add tdd +specify config extension disable tdd +specify config extension enable tdd +specify config extension remove tdd +``` + +Use `specify config extension --help` to see the full extension command set. diff --git a/docs/reference/overview.md b/docs/reference/overview.md index 077eeb1d31..9304e22a98 100644 --- a/docs/reference/overview.md +++ b/docs/reference/overview.md @@ -8,6 +8,14 @@ The foundational commands for creating and managing Spec Kit projects. Initializ [Core Commands reference →](core.md) +## Project Configuration + +Project configuration lets you inspect settings recorded during initialization, +change supported settings, and access extension lifecycle commands from the +same configuration namespace. + +[Project Configuration reference →](configuration.md) + ## Integrations Integrations connect Spec Kit to your AI coding agent. Each integration sets up the appropriate command files and directory structures for a specific agent. Only one integration is active per project at a time, and you can switch between them at any point. diff --git a/docs/toc.yml b/docs/toc.yml index c0a4264547..e0ee19b93d 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -35,6 +35,8 @@ href: reference/overview.md - name: Core Commands href: reference/core.md + - name: Project Configuration + href: reference/configuration.md - name: Integrations href: reference/integrations.md - name: Extensions diff --git a/scripts/dev/verify-post-initialization-configuration.sh b/scripts/dev/verify-post-initialization-configuration.sh new file mode 100755 index 0000000000..5027ccb26e --- /dev/null +++ b/scripts/dev/verify-post-initialization-configuration.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +# Developer-only verifier: kept outside the core script discovery paths. +# Verify the post-initialization configuration workflow in a disposable project. +set -euo pipefail + +SPECIFY_PATH="${SPECIFY:-}" +TEMP_DIR="" + +fail() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +cleanup() { + if [[ -n "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" || true + fi +} + +usage() { + cat <<'EOF' +Usage: verify-post-initialization-configuration.sh [--specify PATH] + +Verify post-initialization configuration using a disposable Copilot project. + +Options: + --specify PATH Path to the specify executable. Defaults to $SPECIFY or specify on PATH. + -h, --help Show this help message. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --specify) + [[ $# -ge 2 ]] || fail "--specify requires a path." + SPECIFY_PATH="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "Unknown option: $1" + ;; + esac +done + +if [[ -n "$SPECIFY_PATH" ]]; then + [[ -x "$SPECIFY_PATH" ]] || fail "Specify executable is not executable: $SPECIFY_PATH" +else + SPECIFY_PATH="$(command -v specify)" || fail "Specify executable not found. Pass --specify PATH." +fi +# Resolve before changing directories, including relative entries from PATH. +SPECIFY_PATH="$(cd -- "$(dirname -- "$SPECIFY_PATH")" && pwd)/$(basename -- "$SPECIFY_PATH")" +SPECIFY=("$SPECIFY_PATH") + +command -v python3 >/dev/null 2>&1 || fail "python3 is required to inspect init-options.json." + +TEMP_DIR="$(mktemp -d)" || fail "Could not create a temporary directory." +trap cleanup EXIT INT TERM +PROJECT_DIR="$TEMP_DIR/project" +# Project commands honor this override even after cd; confine them to this sandbox. +export SPECIFY_INIT_DIR="$PROJECT_DIR" + +run() { + "${SPECIFY[@]}" "$@" +} + +expect_value() { + local expected="$1" + shift + local actual + actual="$(run "$@")" + [[ "$actual" == "$expected" ]] || fail "Expected '$expected' from 'specify $*', got '$actual'." +} + +expect_failure() { + if run "$@" >/dev/null 2>&1; then + fail "Expected 'specify $*' to fail." + fi +} + +run init "$PROJECT_DIR" --integration copilot --ignore-agent-tools --script sh +cd "$PROJECT_DIR" + +run config list >/dev/null +run config list --json >/dev/null +expect_value sh config get script + +run integration upgrade copilot --script py +expect_value py config get script + +run config set feature-numbering timestamp +expect_value timestamp config get feature-numbering + +python3 - ".specify/init-options.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as file: + options = json.load(file) + +expected = {"script": "py", "feature_numbering": "timestamp"} +for key, value in expected.items(): + if options.get(key) != value: + raise SystemExit(f"Expected {key}={value!r} in init-options.json, got {options.get(key)!r}.") +PY + +expect_failure config set integration claude +expect_failure config set script sh +expect_failure config set ai-skills true +expect_failure config set here true +expect_value py config get script +expect_value timestamp config get feature-numbering + +run config extension add git >/dev/null +extension_list="$(run config extension list)" +[[ "$extension_list" == *"Git Branching Workflow"* ]] || fail "Git extension was not listed after installation." +run config extension disable git >/dev/null +extension_list="$(run config extension list)" +[[ "$extension_list" == *"Git Branching Workflow"* && "$extension_list" == *"Status: Disabled"* ]] || fail "Git extension was not listed as disabled." +run config extension enable git >/dev/null +extension_list="$(run config extension list)" +[[ "$extension_list" == *"Git Branching Workflow"* && "$extension_list" == *"Status: Enabled"* ]] || fail "Git extension was not listed as enabled." +run config extension remove git --force >/dev/null +extension_list="$(run config extension list)" +if [[ "$extension_list" == *"Git Branching Workflow"* ]]; then + fail "Git extension was still listed after removal." +fi + +printf 'Post-initialization configuration verified.\n' diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 93f10a1950..1d342383c6 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -521,6 +521,11 @@ def version( from .commands.event import register as _register_event_cmds # noqa: E402 _register_event_cmds(app) + +# ===== Project Configuration Commands ===== +from .commands.config import register as _register_config_cmds # noqa: E402 +_register_config_cmds(app) + # Re-export selected helpers to preserve the public import surface. from .integrations._helpers import ( # noqa: E402 _clear_init_options_for_integration as _clear_init_options_for_integration, diff --git a/src/specify_cli/_init_options.py b/src/specify_cli/_init_options.py index 9f509da256..e82bfddba3 100644 --- a/src/specify_cli/_init_options.py +++ b/src/specify_cli/_init_options.py @@ -29,15 +29,19 @@ def save_init_options(project_path: Path, options: dict[str, Any]) -> None: ) -def load_init_options(project_path: Path) -> dict[str, Any]: - """Load persisted init options, returning an empty dict when unavailable.""" +def load_init_options(project_path: Path, *, strict: bool = False) -> dict[str, Any]: + """Load init options; strict mode rejects unreadable or invalid existing files.""" path = project_path / INIT_OPTIONS_FILE - if not path.exists(): + if not path.exists() and not (strict and path.is_symlink()): return {} try: payload = json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError, UnicodeError): + if strict: + raise return {} + if strict and not isinstance(payload, dict): + raise ValueError("Initialization options must be a JSON object") return payload if isinstance(payload, dict) else {} diff --git a/src/specify_cli/commands/config.py b/src/specify_cli/commands/config.py new file mode 100644 index 0000000000..5aaa0b5a9a --- /dev/null +++ b/src/specify_cli/commands/config.py @@ -0,0 +1,171 @@ +"""Project configuration commands for settings persisted by ``specify init``.""" + +from __future__ import annotations + +import json +from typing import Any + +import typer +from rich.table import Table +from rich.text import Text + +from .._console import console +from .._init_options import INIT_OPTIONS_FILE, load_init_options, save_init_options +from ..extensions import ExtensionManager +from ..extensions._commands import extension_app + + +config_app = typer.Typer( + name="config", + help="View and manage project configuration", + add_completion=False, +) +config_app.add_typer(extension_app, name="extension") + + +_INIT_OPTION_KEYS = { + "ai": "ai", + "ai-skills": "ai_skills", + "feature-numbering": "feature_numbering", + "here": "here", + "integration": "integration", + "script": "script", + "speckit-version": "speckit_version", +} +_FEATURE_NUMBERING = {"sequential", "timestamp"} + + +def _require_specify_project(): + from .. import _require_specify_project as require_project + + return require_project() + + +def _canonical_key(key: str) -> str | None: + return _INIT_OPTION_KEYS.get(key.replace("_", "-").lower()) + + +def _display_value(value: Any) -> str: + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + +def _print_extensions(project_root) -> None: + installed = ExtensionManager(project_root).list_installed() + if not installed: + console.print("\nNo extensions installed.") + return + + table = Table(title="Extensions") + table.add_column("ID") + table.add_column("Status") + table.add_column("Priority", justify="right") + table.add_column("Config") + for extension in installed: + extension_id = extension["id"] + table.add_row( + extension_id, + "enabled" if extension["enabled"] else "disabled", + str(extension["priority"]), + f".specify/extensions/{extension_id}/", + ) + console.print() + console.print(table) + + +@config_app.command("list") +def config_list( + as_json: bool = typer.Option(False, "--json", help="Print machine-readable JSON"), +) -> None: + """List initialization settings and installed extensions.""" + project_root = _require_specify_project() + options = load_init_options(project_root) + extensions = ExtensionManager(project_root).list_installed() + + if as_json: + console.print_json( + json.dumps({"init": options, "extensions": extensions}, ensure_ascii=False) + ) + return + + table = Table(title="Initialization Settings") + table.add_column("Key") + table.add_column("Value") + for display_key, stored_key in _INIT_OPTION_KEYS.items(): + if stored_key in options: + table.add_row(display_key, Text(_display_value(options[stored_key]))) + console.print(table) + _print_extensions(project_root) + + +@config_app.command("get") +def config_get(key: str = typer.Argument(help="Configuration key")) -> None: + """Show one persisted initialization setting.""" + stored_key = _canonical_key(key) + if stored_key is None: + raise typer.BadParameter(f"Unknown configuration key: {key}") + + options = load_init_options(_require_specify_project()) + if stored_key not in options: + console.print(f"{key.replace('_', '-')} is not set") + raise typer.Exit(1) + console.print(_display_value(options[stored_key]), markup=False) + + +@config_app.command("set") +def config_set( + key: str = typer.Argument(help="Configuration key"), + value: str = typer.Argument(help="New value"), +) -> None: + """Change a supported initialization setting.""" + normalized_key = key.replace("_", "-").lower() + project_root = _require_specify_project() + # A failed read must not turn a partial update into replacement of all settings. + try: + options = load_init_options(project_root, strict=True) + except (OSError, ValueError) as exc: + raise typer.BadParameter( + "Cannot read .specify/init-options.json. Repair the file before changing settings." + ) from exc + + if normalized_key == "script": + raise typer.BadParameter( + "script is managed by specify integration upgrade --script " + ) + if normalized_key == "feature-numbering": + normalized_value = value.lower() + if normalized_value not in _FEATURE_NUMBERING: + raise typer.BadParameter( + "feature-numbering must be one of: sequential, timestamp" + ) + options["feature_numbering"] = normalized_value + elif normalized_key in {"ai", "integration"}: + raise typer.BadParameter( + f"{normalized_key} is managed by specify integration use {value}" + ) + elif normalized_key == "ai-skills": + raise typer.BadParameter( + "ai-skills is managed by specify integration upgrade --integration-options. " + "Options depend on the integration; see specify integration upgrade --help." + ) + elif normalized_key in {"here", "speckit-version"}: + raise typer.BadParameter(f"{normalized_key} is read-only") + else: + raise typer.BadParameter(f"Unknown configuration key: {key}") + + # An options file without an active agent disables legacy command registration. + if not (project_root / INIT_OPTIONS_FILE).exists(): + raise typer.BadParameter( + "This legacy project has no .specify/init-options.json. " + "Run specify integration install first " + "(or specify integration use if already installed), then retry." + ) + + save_init_options(project_root, options) + console.print(f"Updated {normalized_key}") + + +def register(app: typer.Typer) -> None: + """Attach the configuration command group to the root Typer app.""" + app.add_typer(config_app, name="config") diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index eaeecc6740..17e23ec63b 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2883,6 +2883,38 @@ def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path): class TestIntegrationUpgrade: + @pytest.mark.parametrize("modified", [False, True]) + def test_script_upgrade_regenerates_commands_or_preserves_customizations( + self, copilot_project, modified + ): + project = copilot_project + command = project / ".github" / "skills" / "speckit-plan" / "SKILL.md" + original = command.read_text(encoding="utf-8") + assert "scripts/bash/setup-plan.sh" in original + if modified: + command.write_text(original + "\nUser customization\n", encoding="utf-8") + before = command.read_bytes() + options_file = project / ".specify" / "init-options.json" + options_before = options_file.read_bytes() + + result = _run_in_project( + project, ["integration", "upgrade", "copilot", "--script", "py"] + ) + + if modified: + assert result.exit_code != 0, result.output + assert "modified" in result.output + assert command.read_bytes() == before + assert options_file.read_bytes() == options_before + else: + assert result.exit_code == 0, result.output + updated = command.read_text(encoding="utf-8") + assert "scripts/python/setup_plan.py" in updated + assert "scripts/bash/setup-plan.sh" not in updated + setting = _run_in_project(project, ["config", "get", "script"]) + assert setting.exit_code == 0, setting.output + assert setting.output.strip() == "py" + def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path): project = _init_project(tmp_path, "claude") _write_invalid_manifest(project, "claude") diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py new file mode 100644 index 0000000000..81042a0281 --- /dev/null +++ b/tests/test_config_cli.py @@ -0,0 +1,201 @@ +"""Behavior tests for post-initialization project configuration.""" + +from __future__ import annotations + +import json + +import pytest +from typer.testing import CliRunner + +from specify_cli import app, load_init_options, save_init_options + + +runner = CliRunner() + + +def _project(tmp_path): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + save_init_options( + project, + { + "ai": "codex", + "feature_numbering": "sequential", + "script": "sh", + "speckit_version": "0.0.0-test", + }, + ) + return project + + +def test_config_list_shows_initialization_settings(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["config", "list"]) + + assert result.exit_code == 0, result.output + assert "feature-numbering" in result.output + assert "sequential" in result.output + assert "script" in result.output + assert "sh" in result.output + + +def test_config_list_json_includes_options_and_extensions(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["config", "list", "--json"]) + + assert result.exit_code == 0, result.output + assert json.loads(result.output) == { + "extensions": [], + "init": load_init_options(project), + } + + +def test_config_get_reads_a_persisted_setting(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["config", "get", "feature-numbering"]) + + assert result.exit_code == 0, result.output + assert result.output.strip() == "sequential" + + +def test_config_set_feature_numbering_persists_a_supported_value(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["config", "set", "feature-numbering", "timestamp"]) + + assert result.exit_code == 0, result.output + assert load_init_options(project)["feature_numbering"] == "timestamp" + + +@pytest.mark.parametrize("value", ["", "daily", "sequentially"]) +def test_config_set_feature_numbering_rejects_unsupported_values( + tmp_path, monkeypatch, value +): + project = _project(tmp_path) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["config", "set", "feature-numbering", value]) + + assert result.exit_code != 0 + assert "sequential, timestamp" in result.output + assert load_init_options(project)["feature_numbering"] == "sequential" + + +@pytest.mark.parametrize( + ("key", "value", "guidance"), + [ + ("script", "py", "specify integration upgrade"), + ("ai", "claude", "specify integration use claude"), + ("ai-skills", "true", "--integration-options"), + ], +) +def test_config_routes_owned_settings_to_their_owning_command( + tmp_path, monkeypatch, key, value, guidance +): + project = _project(tmp_path) + monkeypatch.chdir(project) + before = load_init_options(project) + + result = runner.invoke(app, ["config", "set", key, value]) + + assert result.exit_code != 0 + assert guidance in result.output + assert load_init_options(project) == before + + +@pytest.mark.parametrize("key", ["here", "speckit-version"]) +def test_config_rejects_read_only_metadata(tmp_path, monkeypatch, key): + project = _project(tmp_path) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["config", "set", key, "anything"]) + + assert result.exit_code != 0 + assert "read-only" in result.output + + +def test_config_extension_reuses_extension_lifecycle_commands(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + + result = runner.invoke(app, ["config", "extension", "list"]) + + assert result.exit_code == 0, result.output + assert "No extensions installed" in result.output + + +@pytest.mark.parametrize("contents", [b'{"ai": "copilot",}', b'[]', b'null', b'\xff']) +def test_config_set_preserves_invalid_existing_configuration(tmp_path, monkeypatch, contents): + project = _project(tmp_path) + monkeypatch.chdir(project) + options_file = project / ".specify/init-options.json" + options_file.write_bytes(contents) + + result = runner.invoke(app, ["config", "set", "feature-numbering", "timestamp"]) + + assert result.exit_code != 0 + assert "init-options.json" in result.output + assert options_file.read_bytes() == contents + + +def test_config_set_preserves_other_settings(tmp_path, monkeypatch): + project = _project(tmp_path) + monkeypatch.chdir(project) + before = load_init_options(project) + + result = runner.invoke(app, ["config", "set", "feature-numbering", "timestamp"]) + + assert result.exit_code == 0, result.output + assert load_init_options(project) == {**before, "feature_numbering": "timestamp"} + + +@pytest.mark.parametrize( + "agents", + [(), (("gemini", "toml"),), (("gemini", "toml"), ("qwen", "md"))], +) +def test_config_set_preserves_legacy_registration(tmp_path, monkeypatch, agents): + (tmp_path / ".specify").mkdir() + for agent, _ in agents: + (tmp_path / f".{agent}/commands").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + result = runner.invoke(app, ["config", "set", "feature-numbering", "timestamp"]) + + assert result.exit_code != 0 + assert "specify integration install" in result.output + assert not (tmp_path / ".specify/init-options.json").exists() + + installed = runner.invoke(app, ["config", "extension", "add", "git"]) + + assert installed.exit_code == 0, installed.output + for agent, extension in agents: + command = tmp_path / f".{agent}/commands/speckit.git.feature.{extension}" + assert command.is_file() + assert "feature_numbering" in command.read_text(encoding="utf-8") + + +def test_config_set_works_after_legacy_integration_install(tmp_path, monkeypatch): + (tmp_path / ".specify").mkdir() + (tmp_path / ".gemini/commands").mkdir(parents=True) + monkeypatch.chdir(tmp_path) + + installed = runner.invoke(app, ["integration", "install", "gemini"]) + assert installed.exit_code == 0, installed.output + + result = runner.invoke(app, ["config", "set", "feature-numbering", "timestamp"]) + + assert result.exit_code == 0, result.output + options = load_init_options(tmp_path) + assert options["feature_numbering"] == "timestamp" + assert options["ai"] == "gemini" + + extension = runner.invoke(app, ["config", "extension", "add", "git"]) + assert extension.exit_code == 0, extension.output + assert (tmp_path / ".gemini/commands/speckit.git.feature.toml").is_file() diff --git a/tests/test_verify_post_initialization_configuration_script.py b/tests/test_verify_post_initialization_configuration_script.py new file mode 100644 index 0000000000..849712d680 --- /dev/null +++ b/tests/test_verify_post_initialization_configuration_script.py @@ -0,0 +1,63 @@ +"""End-to-end coverage for the local post-initialization verifier.""" + +from __future__ import annotations + +import os +import subprocess +import sysconfig +from pathlib import Path + +import pytest + +from tests.conftest import requires_bash + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = REPO_ROOT / "scripts" / "dev" / "verify-post-initialization-configuration.sh" +SPECIFY = Path(sysconfig.get_path("scripts")) / ("specify.exe" if os.name == "nt" else "specify") +pytestmark = requires_bash + + +@pytest.mark.parametrize("relative", [False, True], ids=["absolute-path", "relative-path"]) +def test_verifier_checks_post_initialization_configuration(relative) -> None: + """The helper validates the documented local configuration workflow.""" + executable = Path(os.path.relpath(SPECIFY, REPO_ROOT)) if relative else SPECIFY + result = subprocess.run( + ["bash", str(SCRIPT), "--specify", executable.as_posix()], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "Post-initialization configuration verified." in result.stdout + + +def test_verifier_preserves_project_from_inherited_override(tmp_path, monkeypatch): + project = tmp_path / "existing" + init = subprocess.run( + [str(SPECIFY), "init", str(project), "--integration", "copilot", "--ignore-agent-tools", "--script", "sh"], + capture_output=True, + text=True, + ) + assert init.returncode == 0, init.stdout + init.stderr + before = { + path.relative_to(project): path.read_bytes() + for path in project.rglob("*") if path.is_file() + } + monkeypatch.setenv("SPECIFY_INIT_DIR", str(project)) + + result = subprocess.run( + ["bash", str(SCRIPT), "--specify", SPECIFY.as_posix()], + cwd=REPO_ROOT, + capture_output=True, + text=True, + ) + + after = { + path.relative_to(project): path.read_bytes() + for path in project.rglob("*") if path.is_file() + } + assert after == before + assert result.returncode == 0, result.stdout + result.stderr + assert "Post-initialization configuration verified." in result.stdout