diff --git a/docs/installation.md b/docs/installation.md index 058303188f..fc65c73623 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -62,12 +62,14 @@ Auto behavior: - Windows default: `ps` - Other OS default: `sh` - Interactive mode: you'll be prompted unless you pass `--script` +- Cross-platform opt-in: pass `--script both` to install both variants Force a specific script type: ```bash specify init --script sh specify init --script ps +specify init --script both ``` ### Ignore Agent Tools Check @@ -94,10 +96,11 @@ After initialization, you should see the following commands available in your co - `/speckit.plan` - Generate implementation plans - `/speckit.tasks` - Break down into actionable tasks -Scripts are installed into a variant subdirectory matching the chosen script type: +Scripts are installed into variant subdirectories matching the chosen script type: - `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS) - `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows) +- `--script both` installs both directories while generated agent commands use the OS-default primary script ## Troubleshooting diff --git a/docs/local-development.md b/docs/local-development.md index 4776204d7d..becd885de1 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -2,7 +2,7 @@ This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first. -> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`. +> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps|both`. ## 1. Clone and Switch Branches @@ -163,7 +163,7 @@ rm -rf .venv dist build *.egg-info | `ModuleNotFoundError: typer` | Run `uv pip install -e .` | | Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` | | Git step skipped | You passed `--no-git` or Git not installed | -| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly | +| Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script both` explicitly | | TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. | ## 13. Next Steps diff --git a/docs/quickstart.md b/docs/quickstart.md index d278465d8d..cc76085f4a 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -3,7 +3,7 @@ This guide will help you get started with Spec-Driven Development using Spec Kit. > [!NOTE] -> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps`. +> All automation scripts now provide both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on OS unless you pass `--script sh|ps|both`. ## Recommended Workflow @@ -49,6 +49,7 @@ Pick script type explicitly (optional): ```bash uvx --from git+https://github.com/github/spec-kit.git specify init --script ps # Force PowerShell uvx --from git+https://github.com/github/spec-kit.git specify init --script sh # Force POSIX shell +uvx --from git+https://github.com/github/spec-kit.git specify init --script both # Install both variants ``` ### Step 2: Define Your Constitution diff --git a/docs/reference/core.md b/docs/reference/core.md index 70c711b1cc..fb35dbb059 100644 --- a/docs/reference/core.md +++ b/docs/reference/core.md @@ -12,7 +12,7 @@ specify init [] | ------------------------ | ------------------------------------------------------------------------ | | `--integration ` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys | | `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | -| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--script sh\|ps\|both` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `both` to install both variants | | `--here` | Initialize in the current directory instead of creating a new one | | `--force` | Force merge/overwrite when initializing in an existing directory | | `--no-git` | Skip git repository initialization | @@ -45,6 +45,9 @@ specify init --here --force --integration copilot # Use PowerShell scripts (Windows/cross-platform) specify init my-project --integration copilot --script ps +# Install both Bash and PowerShell scripts for mixed-OS teams +specify init my-project --integration copilot --script both + # Skip git initialization specify init my-project --integration copilot --no-git diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index 4e681bb660..aa96c719a3 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -57,7 +57,7 @@ specify integration install | Option | Description | | ------------------------ | ------------------------------------------------------------------------ | -| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--script sh\|ps\|both` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `both` to install both variants | | `--force` | Opt in to installing alongside integrations that are not declared multi-install safe | | `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) | @@ -93,7 +93,7 @@ specify integration switch | Option | Description | | ------------------------ | ------------------------------------------------------------------------ | -| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--script sh\|ps\|both` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `both` to install both variants | | `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default | | `--integration-options` | Options for the target integration when it is not already installed | @@ -120,7 +120,7 @@ specify integration upgrade [] | Option | Description | | ------------------------ | ------------------------------------------------------------------------ | | `--force` | Overwrite files even if they have been modified | -| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) | +| `--script sh\|ps\|both` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `both` to install both variants | | `--integration-options` | Options for the integration | Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration. diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d1f71e7e07..84369ca033 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -142,8 +142,9 @@ def _install_shared_infra( Copies ``.specify/scripts//`` and ``.specify/templates/`` from the bundled core_pack or source checkout, where ```` is - ``bash`` when *script_type* is ``"sh"`` and ``powershell`` when it is - ``"ps"``. Tracks all installed files in ``speckit.manifest.json``. + ``bash`` when *script_type* is ``"sh"``, ``powershell`` when it is + ``"ps"``, or both when it is ``"both"``. Tracks all installed files + in ``speckit.manifest.json``. Shared scripts and page templates are processed to resolve ``__SPECKIT_COMMAND___`` placeholders using *invoke_separator* diff --git a/src/specify_cli/_agent_config.py b/src/specify_cli/_agent_config.py index e95439a458..7c1913c34d 100644 --- a/src/specify_cli/_agent_config.py +++ b/src/specify_cli/_agent_config.py @@ -3,6 +3,8 @@ from typing import Any +from .script_types import SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES + def _build_agent_config() -> dict[str, dict[str, Any]]: from .integrations import INTEGRATION_REGISTRY @@ -41,5 +43,3 @@ def _build_ai_assistant_help() -> str: AI_ASSISTANT_HELP: str = _build_ai_assistant_help() - -SCRIPT_TYPE_CHOICES: dict[str, str] = {"sh": "POSIX Shell (bash/zsh)", "ps": "PowerShell"} diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 83578feaee..79bdccc940 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -1,7 +1,6 @@ """specify init command.""" from __future__ import annotations -import os import shlex import shutil import sys @@ -17,7 +16,6 @@ AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, DEFAULT_INIT_INTEGRATION, - SCRIPT_TYPE_CHOICES, ) from .._assets import ( _locate_bundled_extension, @@ -26,6 +24,12 @@ get_speckit_version, ) from .._console import StepTracker, console, select_with_arrows, show_banner +from ..script_types import ( + SCRIPT_TYPE_CHOICES, + default_script_type, + normalize_script_type, + script_render_type, +) from .._utils import check_tool, init_git_repo, is_git_repo def _build_integration_equivalent( @@ -99,7 +103,7 @@ def init( project_name: str = typer.Argument(None, help="Name for your new project directory (optional if using --here, or use '.' for current directory)"), ai_assistant: str = typer.Option(None, "--ai", help=AI_ASSISTANT_HELP), ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", help="Directory for agent command files (required with --ai generic, e.g. .myagent/commands/)"), - script_type: str = typer.Option(None, "--script", help="Script type to use: sh or ps"), + script_type: str = typer.Option(None, "--script", help="Script type to use: sh, ps, or both"), ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", help="Skip checks for coding agent tools like Claude Code"), no_git: bool = typer.Option(False, "--no-git", help="Skip git repository initialization"), here: bool = typer.Option(False, "--here", help="Initialize project in the current directory instead of creating a new one"), @@ -365,17 +369,19 @@ def init( raise typer.Exit(1) if script_type: - if script_type not in SCRIPT_TYPE_CHOICES: + try: + selected_script = normalize_script_type(script_type) + except ValueError: console.print(f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}") raise typer.Exit(1) - selected_script = script_type else: - default_script = "ps" if os.name == "nt" else "sh" + default_script = default_script_type() if _stdin_is_interactive(): selected_script = select_with_arrows(SCRIPT_TYPE_CHOICES, "Choose script type (or press Enter)", default_script) else: selected_script = default_script + render_script = script_render_type(selected_script) console.print(f"[cyan]Selected coding agent integration:[/cyan] {selected_ai}") console.print(f"[cyan]Selected script type:[/cyan] {selected_script}") @@ -426,7 +432,7 @@ def init( resolved_integration.setup( project_path, manifest, parsed_options=integration_parsed_options or None, - script_type=selected_script, + script_type=render_script, raw_options=integration_options, ) manifest.save() diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..7e264cfa7e 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -7,8 +7,8 @@ import typer -from .._agent_config import SCRIPT_TYPE_CHOICES from .._console import console +from ..script_types import SCRIPT_TYPE_CHOICES, normalize_script_type from ..integration_runtime import ( invoke_separator_for_integration as _invoke_separator_for_integration, resolve_integration_options as _resolve_integration_options_impl, @@ -161,9 +161,10 @@ class _SharedTemplateRefreshError(RuntimeError): def _normalize_script_type(script_type: str, source: str) -> str: """Normalize and validate a script type from CLI/config sources.""" - normalized = script_type.strip().lower() - if normalized in SCRIPT_TYPE_CHOICES: - return normalized + try: + return normalize_script_type(script_type) + except ValueError: + pass console.print( f"[red]Error:[/red] Invalid script type {script_type!r} from {source}. " f"Expected one of: {', '.join(sorted(SCRIPT_TYPE_CHOICES.keys()))}." diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 66fd2b2d26..47b84ce9e4 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -11,6 +11,7 @@ invoke_separator_for_integration as _invoke_separator_for_integration, with_integration_setting as _with_integration_setting, ) +from ..script_types import script_render_type from ..integration_state import ( dedupe_integration_keys as _dedupe_integration_keys, default_integration_key as _default_integration_key, @@ -38,7 +39,7 @@ @integration_app.command("install") def integration_install( key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or both (default: from init-options.json or platform default)"), force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"), integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'), ): @@ -102,6 +103,7 @@ def integration_install( raise typer.Exit(1) selected_script = _resolve_script_type(project_root, script) + render_script = script_render_type(selected_script) # Build parsed options from --integration-options so the integration # can determine its effective invoke separator before shared infra @@ -142,7 +144,7 @@ def integration_install( integration.setup( project_root, manifest, parsed_options=parsed_options, - script_type=selected_script, + script_type=render_script, raw_options=raw_options, ) manifest.save() diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 01cb51d687..654c005790 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -10,6 +10,7 @@ invoke_separator_for_integration as _invoke_separator_for_integration, with_integration_setting as _with_integration_setting, ) +from ..script_types import script_render_type from ..integration_state import ( dedupe_integration_keys as _dedupe_integration_keys, default_integration_key as _default_integration_key, @@ -40,7 +41,7 @@ @integration_app.command("switch") def integration_switch( target: str = typer.Argument(help="Integration key to switch to"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or both (default: from init-options.json or platform default)"), force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"), refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"), integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'), @@ -123,6 +124,7 @@ def integration_switch( raise typer.Exit(0) selected_script = _resolve_script_type(project_root, script) + render_script = script_render_type(selected_script) # Phase 1: Uninstall current integration (if any) if installed_key: @@ -254,7 +256,7 @@ def integration_switch( target_integration.setup( project_root, manifest, parsed_options=parsed_options, - script_type=selected_script, + script_type=render_script, raw_options=raw_options, ) manifest.save() @@ -340,7 +342,7 @@ def integration_switch( def integration_upgrade( key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"), force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"), - script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"), + script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or both (default: from init-options.json or platform default)"), integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"), ): """Upgrade an integration by reinstalling with diff-aware file handling. @@ -394,6 +396,7 @@ def integration_upgrade( raise typer.Exit(1) selected_script = _resolve_integration_script_type(project_root, current, key, script) + render_script = script_render_type(selected_script) # Build parsed options from --integration-options so the integration # can determine its effective invoke separator before shared infra @@ -435,7 +438,7 @@ def integration_upgrade( project_root, new_manifest, parsed_options=parsed_options, - script_type=selected_script, + script_type=render_script, raw_options=raw_options, ) settings = _with_integration_setting( diff --git a/src/specify_cli/script_types.py b/src/specify_cli/script_types.py new file mode 100644 index 0000000000..d590cf0e6c --- /dev/null +++ b/src/specify_cli/script_types.py @@ -0,0 +1,40 @@ +"""Script type helpers shared by init and integration commands.""" + +from __future__ import annotations + +import os + + +SCRIPT_TYPE_CHOICES: dict[str, str] = { + "sh": "POSIX Shell (bash/zsh)", + "ps": "PowerShell", + "both": "Both POSIX Shell and PowerShell", +} + +def normalize_script_type(script_type: str) -> str: + """Return a normalized script selection, or raise ``ValueError``.""" + normalized = script_type.strip().lower() + if normalized not in SCRIPT_TYPE_CHOICES: + raise ValueError(normalized) + return normalized + + +def default_script_type() -> str: + """Return the OS default primary script type.""" + return "ps" if os.name == "nt" else "sh" + + +def script_install_variants(script_type: str) -> tuple[str, ...]: + """Return script variants that should be copied for *script_type*.""" + normalized = normalize_script_type(script_type) + if normalized == "both": + return ("sh", "ps") + return (normalized,) + + +def script_render_type(script_type: str) -> str: + """Return the primary script type to embed in generated commands.""" + normalized = normalize_script_type(script_type) + if normalized == "both": + return default_script_type() + return normalized diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py index c5c2e4af62..d94d2d45ba 100644 --- a/src/specify_cli/shared_infra.py +++ b/src/specify_cli/shared_infra.py @@ -9,6 +9,7 @@ from .integrations.base import IntegrationBase from .integrations.manifest import IntegrationManifest +from .script_types import script_install_variants class SymlinkedSharedPathError(ValueError): @@ -262,6 +263,10 @@ def install_shared_infra( ) -> bool: """Install shared scripts and templates into *project_path*. + ``script_type`` may be ``"sh"``, ``"ps"``, or ``"both"``. The + ``"both"`` mode installs both script variant directories while still + processing templates once. + When ``refresh_managed`` is True, files whose on-disk hash still matches the previously recorded manifest hash are overwritten with the bundled version. Files whose hash diverges are treated as user customizations and @@ -345,57 +350,59 @@ def _ensure_or_bucket_dir(directory: Path) -> bool: if scripts_src.is_dir(): dest_scripts = project_path / ".specify" / "scripts" if _ensure_or_bucket_dir(dest_scripts): - variant_dir = "bash" if script_type == "sh" else "powershell" - variant_src = scripts_src / variant_dir - if variant_src.is_dir(): - dest_variant = dest_scripts / variant_dir - if _ensure_or_bucket_dir(dest_variant): - for src_path in variant_src.rglob("*"): - if not src_path.is_file(): - continue - - rel_path = src_path.relative_to(variant_src) - dst_path = dest_variant / rel_path - rel = dst_path.relative_to(project_path).as_posix() - if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False): - continue - write, bucket = _decide_overwrite(rel, dst_path) - if not write: - if bucket == "preserved": - preserved_user_files.append(rel) - else: - skipped_files.append(rel) - # Record the existing-on-disk file in the manifest so a - # fresh manifest run against an already-populated - # ``.specify/`` tree does not silently drop it (#2107). - # ``prior_hashes`` is the function-scope snapshot taken - # at entry, so this membership check is O(1) and avoids - # the repeated ``dict(self._files)`` copy that - # ``manifest.files`` performs on every access. - if dst_path.is_file() and rel not in prior_hashes: - try: - manifest.record_existing(rel, recovered=True) - except (OSError, ValueError) as exc: - # Tolerate races / permission issues / non-file - # collisions so one weird path does not abort - # the whole install. - console.print( - f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}" - ) - continue - - if not _ensure_or_bucket_dir(dst_path.parent): - continue - content = src_path.read_text(encoding="utf-8") - content = IntegrationBase.resolve_command_refs(content, invoke_separator) - planned_copies.append( - ( - dst_path, - rel, - content.encode("utf-8"), - src_path.stat().st_mode & 0o777, + variant_dirs = {"sh": "bash", "ps": "powershell"} + for variant in script_install_variants(script_type): + variant_dir = variant_dirs[variant] + variant_src = scripts_src / variant_dir + if variant_src.is_dir(): + dest_variant = dest_scripts / variant_dir + if _ensure_or_bucket_dir(dest_variant): + for src_path in variant_src.rglob("*"): + if not src_path.is_file(): + continue + + rel_path = src_path.relative_to(variant_src) + dst_path = dest_variant / rel_path + rel = dst_path.relative_to(project_path).as_posix() + if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False): + continue + write, bucket = _decide_overwrite(rel, dst_path) + if not write: + if bucket == "preserved": + preserved_user_files.append(rel) + else: + skipped_files.append(rel) + # Record the existing-on-disk file in the manifest so a + # fresh manifest run against an already-populated + # ``.specify/`` tree does not silently drop it (#2107). + # ``prior_hashes`` is the function-scope snapshot taken + # at entry, so this membership check is O(1) and avoids + # the repeated ``dict(self._files)`` copy that + # ``manifest.files`` performs on every access. + if dst_path.is_file() and rel not in prior_hashes: + try: + manifest.record_existing(rel, recovered=True) + except (OSError, ValueError) as exc: + # Tolerate races / permission issues / non-file + # collisions so one weird path does not abort + # the whole install. + console.print( + f"[yellow]⚠[/yellow] could not record {rel} in manifest: {exc}" + ) + continue + + if not _ensure_or_bucket_dir(dst_path.parent): + continue + content = src_path.read_text(encoding="utf-8") + content = IntegrationBase.resolve_command_refs(content, invoke_separator) + planned_copies.append( + ( + dst_path, + rel, + content.encode("utf-8"), + src_path.stat().st_mode & 0o777, + ) ) - ) templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root) if templates_src.is_dir(): diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 6198f294a4..18022d5f9a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -108,6 +108,57 @@ def test_integration_copilot_creates_files(self, tmp_path): shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() + def test_integration_copilot_script_both_installs_both_variants(self, tmp_path): + from typer.testing import CliRunner + from specify_cli import app + from specify_cli.script_types import script_render_type + + runner = CliRunner() + project = tmp_path / "both-init" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, [ + "init", "--here", "--integration", "copilot", "--script", "both", "--no-git", + ], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, f"init failed: {result.output}" + assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists() + assert (project / ".specify" / "scripts" / "powershell" / "common.ps1").exists() + + shared_manifest = json.loads( + (project / ".specify" / "integrations" / "speckit.manifest.json").read_text( + encoding="utf-8" + ) + ) + files = shared_manifest["files"] + assert ".specify/scripts/bash/common.sh" in files + assert ".specify/scripts/powershell/common.ps1" in files + + plan_agent = ( + project / ".github" / "agents" / "speckit.plan.agent.md" + ).read_text(encoding="utf-8") + primary = script_render_type("both") + if primary == "ps": + assert ".specify/scripts/powershell/setup-plan.ps1 -Json" in plan_agent + assert ".specify/scripts/bash/setup-plan.sh" not in plan_agent + else: + assert ".specify/scripts/bash/setup-plan.sh --json" in plan_agent + assert ".specify/scripts/powershell/setup-plan.ps1" not in plan_agent + + init_opts = json.loads( + (project / ".specify" / "init-options.json").read_text(encoding="utf-8") + ) + assert init_opts["script"] == "both" + + integration_state = json.loads( + (project / ".specify" / "integration.json").read_text(encoding="utf-8") + ) + assert integration_state["integration_settings"]["copilot"]["script"] == "both" + def test_noninteractive_init_defaults_to_copilot(self, tmp_path, monkeypatch): from typer.testing import CliRunner from specify_cli import app @@ -297,6 +348,45 @@ def test_shared_infra_skips_existing_files_without_force(self, tmp_path): assert (scripts_dir / "setup-plan.sh").exists() assert (templates_dir / "plan-template.md").exists() + def test_shared_infra_both_records_both_variants_without_overwrite(self, tmp_path): + """Both mode installs/records bash and PowerShell while preserving files.""" + from specify_cli import _install_shared_infra + + project = tmp_path / "both-shared-infra" + project.mkdir() + (project / ".specify").mkdir() + + bash_dir = project / ".specify" / "scripts" / "bash" + ps_dir = project / ".specify" / "scripts" / "powershell" + bash_dir.mkdir(parents=True) + ps_dir.mkdir(parents=True) + bash_custom = "# custom bash common\n" + ps_custom = "# custom ps common\n" + (bash_dir / "common.sh").write_text(bash_custom, encoding="utf-8") + (ps_dir / "common.ps1").write_text(ps_custom, encoding="utf-8") + + _install_shared_infra(project, "both", force=False) + + assert (bash_dir / "common.sh").read_text(encoding="utf-8") == bash_custom + assert (ps_dir / "common.ps1").read_text(encoding="utf-8") == ps_custom + assert (bash_dir / "setup-plan.sh").exists() + assert (ps_dir / "setup-plan.ps1").exists() + + manifest = json.loads( + (project / ".specify" / "integrations" / "speckit.manifest.json").read_text( + encoding="utf-8" + ) + ) + files = manifest["files"] + assert ".specify/scripts/bash/common.sh" in files + assert ".specify/scripts/bash/setup-plan.sh" in files + assert ".specify/scripts/powershell/common.ps1" in files + assert ".specify/scripts/powershell/setup-plan.ps1" in files + assert set(manifest["recovered_files"]) >= { + ".specify/scripts/bash/common.sh", + ".specify/scripts/powershell/common.ps1", + } + def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path): """Pre-existing shared files ARE overwritten when force=True.""" from specify_cli import _install_shared_infra diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 3a6bfe91ec..0a44b6485e 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1451,8 +1451,9 @@ def test_invalid_script_type_rejected(self, tmp_path): assert result.exit_code != 0 assert "Invalid script type" in result.output - def test_valid_script_types_accepted(self, tmp_path): - """Both 'sh' and 'ps' should be accepted.""" + @pytest.mark.parametrize("script_type", ["sh", "ps", "both"]) + def test_valid_script_types_accepted(self, tmp_path, script_type): + """All supported script types should be accepted.""" project = tmp_path / "proj" project.mkdir() (project / ".specify").mkdir() @@ -1461,7 +1462,7 @@ def test_valid_script_types_accepted(self, tmp_path): os.chdir(project) result = runner.invoke(app, [ "integration", "install", "claude", - "--script", "sh", + "--script", script_type, ], catch_exceptions=False) finally: os.chdir(old_cwd) diff --git a/tests/test_commands_package.py b/tests/test_commands_package.py index 2e51ff4974..8a3e6a9051 100644 --- a/tests/test_commands_package.py +++ b/tests/test_commands_package.py @@ -26,12 +26,14 @@ def test_agent_config_importable(): assert isinstance(AI_ASSISTANT_HELP, str) assert DEFAULT_INIT_INTEGRATION == "copilot" assert "sh" in SCRIPT_TYPE_CHOICES + assert "both" in SCRIPT_TYPE_CHOICES def test_agent_config_re_exported_from_init(): from specify_cli import AGENT_CONFIG, AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, SCRIPT_TYPE_CHOICES assert isinstance(AGENT_CONFIG, dict) assert "sh" in SCRIPT_TYPE_CHOICES + assert "both" in SCRIPT_TYPE_CHOICES def test_init_command_registered():