Inside `specify init`: From User Command to Scaffolded Project
Prerequisites
- ›Article 1: Architecture and Project Navigation
- ›Basic understanding of Python CLI frameworks (typer)
- ›Familiarity with terminal TUI concepts
Inside specify init: From User Command to Scaffolded Project
The specify init command is where everything comes together. It's the first thing a developer runs, and in under ten seconds it transforms an empty directory into a fully scaffolded SDD project — with agent-specific commands, shell scripts, document templates, a constitution file, and a Git extension. This article traces the complete execution path, from the 17 CLI parameters through the interactive TUI to the orchestration pipeline that wires up all the subsystems we mapped in Part 1.
The 17 CLI Parameters and Their Interactions
The init command signature is the widest in the codebase — __init__.py#L873-L892:
@app.command()
def init(
project_name: str = typer.Argument(None, ...),
ai_assistant: str = typer.Option(None, "--ai", ...),
ai_commands_dir: str = typer.Option(None, "--ai-commands-dir", ...),
script_type: str = typer.Option(None, "--script", ...),
ignore_agent_tools: bool = typer.Option(False, "--ignore-agent-tools", ...),
no_git: bool = typer.Option(False, "--no-git", ...),
here: bool = typer.Option(False, "--here", ...),
force: bool = typer.Option(False, "--force", ...),
# ... plus 9 more including deprecated/hidden options
integration: str = typer.Option(None, "--integration", ...),
integration_options: str = typer.Option(None, "--integration-options", ...),
):
Key parameter interactions:
| Parameter | Purpose | Constraint |
|---|---|---|
--ai / --integration |
Select the target agent | Mutually exclusive — cannot use both |
--here / project_name |
Init in current dir vs. new dir | Cannot combine --here with a name |
--force |
Skip confirmation for non-empty dirs | Only meaningful with --here or existing dir |
--script |
Choose sh or ps script variant |
Auto-detected from OS if omitted |
--preset |
Apply a preset during init | Resolved from local path, bundled, or catalog |
--no-git |
Skip git init + extension | Skips the entire git step |
--ai-commands-dir |
Custom command directory | Required only for --ai generic |
The --ai flag is the legacy path; --integration is the newer equivalent. Both resolve through the same INTEGRATION_REGISTRY. Note the alias system at __init__.py#L64-L66 that maps shortcuts like kiro to kiro-cli.
Integration Resolution and Alias Lookup
After parameter validation, the first substantive logic is resolving which integration to use — __init__.py#L950-L976:
flowchart TD
A["User passes --integration or --ai"] --> B{"Both provided?"}
B -->|"Yes"| C["Error: mutually exclusive"]
B -->|"No"| D{"--integration?"}
D -->|"Yes"| E["get_integration(key)"]
D -->|"No"| F["Apply alias: AI_ASSISTANT_ALIASES"]
F --> G["get_integration(aliased_key)"]
E --> H{"Found?"}
G --> H
H -->|"No"| I["Error: unknown integration"]
H -->|"Yes"| J["resolved_integration ready"]
The alias lookup is a simple dict — {"kiro": "kiro-cli"} at the time of writing. After resolution, the code emits deprecation notes for legacy flags like --ai-skills (no longer needed since skills-based integrations like Claude handle this automatically) and --ai-commands-dir (replaced by --integration generic --integration-options).
The Interactive TUI: select_with_arrows()
When no --ai or --integration flag is passed, the CLI presents an interactive selection menu. The implementation at __init__.py#L215-L288 is a custom widget built on two libraries:
- readchar for cross-platform single-keypress capture (including arrow keys and Ctrl+P/N)
- rich.Live for flicker-free re-rendering of the selection panel
sequenceDiagram
participant User
participant get_key
participant Live as rich.Live
participant Panel as Selection Panel
User->>get_key: Press ↓
get_key->>Live: Return 'down'
Live->>Panel: Increment selected_index
Panel->>Live: Re-render with new highlight
Live->>User: Updated display
User->>get_key: Press Enter
get_key->>Live: Return 'enter'
Live-->>User: Return selected_key
The get_key() helper at line 195 translates raw readchar constants into semantic strings ('up', 'down', 'enter', 'escape'), handling both arrow keys and Emacs-style Ctrl+P/Ctrl+N bindings. The selection panel is a rich.Table.grid wrapped in a rich.Panel, re-rendered on every keypress with transient=True so the menu disappears after selection.
Tip: The
select_with_arrowsfunction accepts adefault_keyparameter. When called for AI selection, it defaults to"copilot"—__init__.py#L1068-L1072. This is a small UX decision that reveals GitHub's intent: Copilot is the default starting point.
StepTracker: Real-Time Progress Display
Once the integration is resolved and script type selected, init switches from interactive mode to a progress-tracked orchestration. The StepTracker class provides a tree-style progress display inspired by Claude Code's output:
⟨Initialize Specify Project⟩
● Check required tools (ok)
● Select AI assistant (claude)
● Select script type (sh)
○ Install integration
○ Install shared infrastructure
○ Ensure scripts executable
○ Constitution setup
○ Install git extension
○ Finalize
Each step has one of five states: pending (○ dim), running (○ cyan), done (● green), error (● red), or skipped (○ yellow). The tracker uses a callback pattern — attach_refresh(cb) — that hooks into rich.Live.update() so the display refreshes automatically whenever any step changes state.
sequenceDiagram
participant Init as init() command
participant ST as StepTracker
participant Live as rich.Live
Init->>ST: add("integration", "Install integration")
Init->>ST: start("integration")
ST->>Live: _maybe_refresh() → live.update(render())
Note over Live: Shows ○ cyan "Install integration"
Init->>ST: complete("integration", "Claude Code")
ST->>Live: _maybe_refresh() → live.update(render())
Note over Live: Shows ● green "Install integration (Claude Code)"
The render() method builds a rich.Tree widget, mapping each step to a styled line. This is wrapped in a Live context manager with refresh_per_second=8 and transient=True so the final tree is replaced by the completion panel.
The 8-Step Init Orchestration
The core orchestration runs inside a rich.Live context starting at __init__.py#L1144-L1210. Here's the complete pipeline:
flowchart TD
S1["1. Precheck<br/>Verify tools installed"] --> S2["2. AI Selection<br/>Interactive or --ai flag"]
S2 --> S3["3. Script Selection<br/>sh/ps auto or --script"]
S3 --> S4["4. integration.setup()<br/>Create agent commands + manifest"]
S4 --> S5["5. _install_shared_infra()<br/>Copy scripts + templates to .specify/"]
S5 --> S6["6. ensure_constitution<br/>Copy constitution template"]
S6 --> S7["7. Git Extension<br/>init repo + install bundled extension"]
S7 --> S8["8. Preset + Finalize<br/>Apply preset, save options, done"]
Steps 1–3 happen before the Live context. Steps 4–8 run inside it with progress tracking. Let's look at the most important steps.
Step 4 — Integration Setup creates the IntegrationManifest, calls resolved_integration.setup(), and saves the manifest to disk. The setup call is where format-specific command files are actually created — we'll explore this in detail in Part 3.
Step 5 — Shared Infrastructure is handled by _install_shared_infra(). It copies scripts and templates into .specify/ using merge-without-overwrite semantics — if a file already exists, it's skipped. This is critical for --here workflows where users might re-init an existing project.
Step 7 — Git Extension is a two-part step at __init__.py#L1236-L1261. First it initializes a git repo if none exists, then it installs the bundled git extension via ExtensionManager.install_from_directory(). The extension provides 5 commands and 18 lifecycle hooks that integrate git operations into every workflow stage.
Shared Infrastructure and File Output
The _install_shared_infra() function deserves a closer look because it demonstrates the dual-path resolution pattern from Part 1. It calls _locate_core_pack() first, then falls back to repository-relative paths:
core = _locate_core_pack()
if core and (core / "scripts").is_dir():
scripts_src = core / "scripts"
else:
repo_root = Path(__file__).parent.parent.parent
scripts_src = repo_root / "scripts"
The merge-without-overwrite behavior iterates source files and checks for existing destinations:
for src_path in variant_src.rglob("*"):
if src_path.is_file():
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
if dst_path.exists():
skipped_files.append(str(dst_path.relative_to(project_path)))
else:
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dst_path)
After all steps complete, save_init_options() persists the CLI flags to .specify/init-options.json. This file is consumed by downstream tools — for example, the preset system reads it to determine which script variant is active, and the extension system uses it to decide between SKILL.md and markdown output formats.
After init completes, the scaffolded project looks like this:
my-project/
├── .specify/
│ ├── init-options.json # Persisted CLI flags
│ ├── integration.json # Active integration metadata
│ ├── integrations/
│ │ └── claude.manifest.json # SHA-256 tracked file manifest
│ ├── extensions/
│ │ ├── .registry # Installed extensions registry
│ │ └── git/ # Git extension files
│ ├── scripts/bash/ # Shell scripts for workflow
│ ├── templates/ # Document templates
│ └── extensions.yml # Merged hook configuration
├── .claude/skills/ # (or .github/agents/, .windsurf/workflows/, etc.)
│ ├── speckit-specify/SKILL.md
│ ├── speckit-plan/SKILL.md
│ └── ...
└── memory/constitution.md # Project constitution
Tip: The
.specify/directory is the project's SDD infrastructure root. It mirrors the split between runtime state (manifests, registries, options) and content (scripts, templates). If something goes wrong during init, deleting.specify/and re-running is always safe.
What's Next
The init command delegates the most interesting work to resolved_integration.setup() — a single method call that hides a sophisticated class hierarchy. In Part 3, we'll explore how Spec Kit's 4-tier integration system adapts one set of command templates to 25+ AI agents through the Template Method pattern, and how the 7-step process_template() pipeline transforms raw Markdown into agent-specific instructions.