Read OSS

Inside `specify init`: From User Command to Scaffolded Project

Intermediate

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_arrows function accepts a default_key parameter. 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.