Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project_name> --script sh
specify init <project_name> --script ps
specify init <project_name> --script both
```

### Ignore Agent Tools Check
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions docs/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -49,6 +49,7 @@ Pick script type explicitly (optional):
```bash
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME> --script ps # Force PowerShell
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME> --script sh # Force POSIX shell
uvx --from git+https://github.com/github/spec-kit.git specify init <PROJECT_NAME> --script both # Install both variants
```

### Step 2: Define Your Constitution
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ specify init [<project_name>]
| ------------------------ | ------------------------------------------------------------------------ |
| `--integration <key>` | 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 |
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ specify integration install <key>

| 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"`) |

Expand Down Expand Up @@ -93,7 +93,7 @@ specify integration switch <key>

| 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 |

Expand All @@ -120,7 +120,7 @@ specify integration upgrade [<key>]
| 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.
Expand Down
5 changes: 3 additions & 2 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,9 @@ def _install_shared_infra(

Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
the bundled core_pack or source checkout, where ``<variant>`` 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_<NAME>__`` placeholders using *invoke_separator*
Expand Down
4 changes: 2 additions & 2 deletions src/specify_cli/_agent_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
20 changes: 13 additions & 7 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""specify init command."""
from __future__ import annotations

import os
import shlex
import shutil
import sys
Expand All @@ -17,7 +16,6 @@
AI_ASSISTANT_ALIASES,
AI_ASSISTANT_HELP,
DEFAULT_INIT_INTEGRATION,
SCRIPT_TYPE_CHOICES,
)
from .._assets import (
_locate_bundled_extension,
Expand All @@ -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(
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()))}."
Expand Down
6 changes: 4 additions & 2 deletions src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")'),
):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
11 changes: 7 additions & 4 deletions src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
40 changes: 40 additions & 0 deletions src/specify_cli/script_types.py
Original file line number Diff line number Diff line change
@@ -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
Loading