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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ specify init my-project --integration copilot
cd my-project
```

For CI or AI agent harnesses (no keyboard, or a PTY that cannot send arrow keys), pass `--non-interactive` so init never hangs on a picker. Combine with `--force` when initializing into a non-empty directory:

```bash
specify init my-project --non-interactive --ignore-agent-tools
specify init --here --force --non-interactive --integration claude
```

To check for updates or upgrade the installed CLI, use the self-management commands. See the [Upgrade Guide](./docs/upgrade.md) for detailed scenarios and customization options.

```bash
Expand Down
2 changes: 1 addition & 1 deletion 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 are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs (no TTY, or `--non-interactive`) default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.

## 1. Clone and Switch Branches

Expand Down
4 changes: 2 additions & 2 deletions 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. Throughout, we illustrate each step with a running example: **Taskify**, a small team productivity platform.

> [!NOTE]
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs (no TTY, or `--non-interactive`) default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.

Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.

Expand Down Expand Up @@ -43,7 +43,7 @@ uv tool install specify-cli
specify init taskify # or: specify init . to use the current directory
```

`init` lets you pick your coding agent interactively, or pass it explicitly with `--integration` (e.g. `--integration copilot`).
`init` lets you pick your coding agent interactively, or pass it explicitly with `--integration` (e.g. `--integration copilot`). For CI and AI agent harnesses, add `--non-interactive` so unspecified choices use documented defaults instead of hanging on an arrow-key picker.

> [!NOTE]
> Prefer `pipx`, one-time `uvx` runs, a pinned release, or an offline/air-gapped setup? See the [Installation Guide](installation.md) for all supported methods.
Expand Down
19 changes: 19 additions & 0 deletions src/specify_cli/_console.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ def select_with_arrows(
options: dict[str, str],
prompt_text: str = "Select an option",
default_key: str | None = None,
*,
flag_hint: str | None = None,
) -> str:
"""
Interactive selection using arrow keys with Rich Live display.
Expand All @@ -159,13 +161,30 @@ def select_with_arrows(
options: Dict with keys as option keys and values as descriptions
prompt_text: Text to show above the options
default_key: Default option key to start with
flag_hint: CLI flag the caller can pass instead of answering this prompt.
Included in the error when stdin is not a TTY so the hang is replaced
by an actionable message.

Returns:
Selected option key
"""
if not options:
raise ValueError("select_with_arrows() requires at least one option.")

# readchar.readkey() blocks forever when stdin is not a TTY. Fail immediately
# instead of hanging CI jobs and agent harnesses with no keyboard.
if not sys.stdin.isatty():
console.print(
"[red]Error:[/red] Interactive selection requires a terminal "
"(stdin is not a TTY). Waiting for arrow keys would hang indefinitely."
)
if flag_hint:
console.print(
f"Re-run with [bold]{flag_hint}[/bold] to supply this choice "
"non-interactively."
)
raise typer.Exit(1)

option_keys = list(options.keys())
if default_key and default_key in option_keys:
selected_index = option_keys.index(default_key)
Expand Down
48 changes: 42 additions & 6 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ def _stdin_is_interactive() -> bool:
return sys.stdin.isatty()


def _prompts_allowed(non_interactive: bool) -> bool:
"""Return True when interactive pickers and confirmations may be shown.

``--non-interactive`` suppresses prompts even when stdin is a TTY. Agent
harnesses often allocate a PTY (so ``isatty()`` is True) but cannot send
arrow-key input, which previously hung in ``select_with_arrows``.
"""
return not non_interactive and _stdin_is_interactive()


def _ext_spec_is_url(ext_spec: str) -> bool:
"""Return True when *ext_spec* is an http(s) URL rather than a name/path."""
from urllib.parse import urlparse
Expand All @@ -44,7 +54,10 @@ def _ext_spec_is_url(ext_spec: str) -> bool:


def _confirm_extension_url_trust(
url_specs: list[str], *, trust_override: bool
url_specs: list[str],
*,
trust_override: bool,
allow_prompt: bool | None = None,
) -> dict[str, bool]:
"""Resolve trust for each URL-based extension before the Live display.

Expand All @@ -58,7 +71,7 @@ def _confirm_extension_url_trust(
from rich.panel import Panel

approvals: dict[str, bool] = {}
interactive = _stdin_is_interactive()
interactive = _stdin_is_interactive() if allow_prompt is None else allow_prompt
for spec in url_specs:
if trust_override:
approvals[spec] = True
Expand Down Expand Up @@ -264,6 +277,16 @@ def init(
"--force",
help="Force merge/overwrite when using --here (skip confirmation)",
),
non_interactive: bool = typer.Option(
False,
"--non-interactive",
help=(
"Never prompt. Use documented defaults for unspecified "
"selections and fail instead of hanging when a choice has no "
"safe default. Required for agent harnesses that allocate a "
"PTY but cannot send arrow-key input."
),
),
skip_tls: bool = typer.Option(
False,
"--skip-tls",
Expand Down Expand Up @@ -324,7 +347,7 @@ def init(
This command will:
1. Check that required tools are installed
2. Let you choose your coding agent integration, or default to Copilot
in non-interactive sessions
in non-interactive sessions (no TTY, or --non-interactive)
3. Install bundled Spec Kit templates, scripts, workflow, and shared
project infrastructure
4. Set up coding agent integration commands and optional presets
Expand All @@ -341,6 +364,8 @@ def init(
specify init --here --integration vibe # Initialize with Mistral Vibe support
specify init --here
specify init --here --force # Skip confirmation when current directory not empty
specify init my-project --non-interactive # CI/agent: defaults, no prompts
specify init --here --force --non-interactive --integration claude # Scripted init, no hang
specify init my-project --integration claude # Claude installs skills by default
specify init --here --integration gemini
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
Expand Down Expand Up @@ -416,6 +441,13 @@ def init(
console.print(
"[cyan]--force supplied: skipping confirmation and proceeding with merge[/cyan]"
)
elif non_interactive:
console.print(
"[red]Error:[/red] Current directory is not empty and "
"--non-interactive was set. Re-run with "
"[bold]--force[/bold] to merge into it."
)
raise typer.Exit(1)
else:
# Fold the merge risk into the confirmation prompt rather than
# printing it unconditionally first: on the EOF/no-input path
Expand Down Expand Up @@ -491,7 +523,7 @@ def init(
)
raise typer.Exit(1)
selected_ai = integration
elif not _stdin_is_interactive():
elif not _prompts_allowed(non_interactive):
default_integration = resolve_default_init_integration()
console.print(
f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. "
Expand All @@ -504,6 +536,7 @@ def init(
ai_choices,
"Choose your coding agent integration:",
resolve_default_init_integration(),
flag_hint="--integration <agent>",
)

if not integration:
Expand Down Expand Up @@ -567,11 +600,12 @@ def init(
else:
default_script = "ps" if os.name == "nt" else "sh"

if _stdin_is_interactive():
if _prompts_allowed(non_interactive):
selected_script = select_with_arrows(
SCRIPT_TYPE_CHOICES,
"Choose script type (or press Enter)",
default_script,
flag_hint="--script sh|ps|py",
)
else:
selected_script = default_script
Expand Down Expand Up @@ -615,7 +649,9 @@ def init(
url_specs = [e for e in extensions if _ext_spec_is_url(e)]
if url_specs:
extension_url_approvals = _confirm_extension_url_trust(
url_specs, trust_override=trust_extension_urls
url_specs,
trust_override=trust_extension_urls,
allow_prompt=_prompts_allowed(non_interactive),
Comment thread
mnriem marked this conversation as resolved.
)

# Disable transient mode on Windows: PowerShell 5.1's legacy console
Expand Down
Loading