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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ All notable changes to this project will be documented in this file.
**Important:** Changes need to be documented below this block as this is the header section. Each section should be separated by a horizontal rule. Newer changelog entries need to be added on top of prior ones to keep the history chronological with most recent changes first.


---

## [Unreleased]

### Added

- `specfact init ide` builds a prompt-source catalog from **core** (bundled or repo `resources/prompts`) plus installed modules across builtin, project, user, and marketplace roots; defaults to exporting all sources; supports `--prompts` for non-interactive selection (`all`, `core`, comma-separated module ids) and an interactive multi-select when multiple sources exist.
- IDE prompt exports are written under per-source subfolders (for example `.cursor/commands/core/`, `.cursor/commands/<owner>__<module>/`) so filenames stay collision-safe.
- Startup IDE template drift checks resolve exports under the namespaced layout (flat or nested).

---

## [0.42.4] - 2026-03-24
Expand Down
20 changes: 15 additions & 5 deletions docs/core-cli/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,31 @@ specfact init --install backlog,code-review

## IDE Setup

The `init ide` subcommand generates IDE-specific prompt templates and settings:
The `init ide` subcommand discovers prompt templates from **core** (bundled `specfact_cli` resources or your repo checkout) and from **installed modules** under the effective module roots (builtin, project `.specfact/modules`, user `~/.specfact/modules`, marketplace, and `SPECFACT_MODULES_ROOTS`). It is a **re-sync** command: it only copies what is already installed; it does not download or extract modules. If prompts are missing, install or seed modules first (for example `specfact module init --scope project` or `specfact module install --scope user`).

```bash
# Initialize Cursor IDE integration
# Initialize Cursor IDE integration (interactive: pick IDE, then prompt sources)
specfact init ide --ide cursor

# Non-interactive: export all discovered sources (default)
specfact init ide --ide cursor --repo .

# Non-interactive: only core, or a comma-separated list of module ids
specfact init ide --ide cursor --prompts core
specfact init ide --ide cursor --prompts all
specfact init ide --ide cursor --prompts "core,nold-ai/specfact-backlog"

# Initialize with dependency installation
specfact init ide --install-deps
```

This creates:
Exported IDE files are placed under **per-source subfolders** (for example `.cursor/commands/core/`, `.cursor/commands/nold-ai__specfact-backlog/`) so names collide deterministically and provenance stays visible.

This creates or refreshes:

- `.specfact/` directory structure
- `.specfact/templates/backlog/field_mappings/` with default field mapping templates
- IDE-specific command files for your AI assistant
- `.specfact/templates/backlog/field_mappings/` with default field mapping templates when available
- IDE-specific command files under the IDE export directory, namespaced by prompt source

## Dependency Installation

Expand Down
2 changes: 2 additions & 0 deletions docs/core-cli/module.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ description: Reference for the specfact module command group - install, manage,

Manage marketplace modules: install, uninstall, search, upgrade, and configure registries.

Use `specfact module init --scope user|project` to seed bundled module trees and `specfact module install --scope user|project` to add or refresh modules. After modules are present, `specfact init ide` discovers their prompt resources from those roots and exports IDE-facing files; it does not download modules itself.

## Usage

```bash
Expand Down
12 changes: 12 additions & 0 deletions openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# TDD evidence: init-ide-prompt-source-selection

## Pre-implementation (failing tests)

- Command: `hatch run pytest tests/unit/modules/init/test_init_ide_prompt_selection.py -v`
- Timestamp: 2026-03-25 (session; tests added before implementation in worktree `feature/init-ide-prompt-source-selection`)
- Note: New tests were introduced to lock catalog export, `--prompts` parsing, and CLI failure on invalid tokens; implementation followed in the same change.

## Post-implementation (passing)

- Command: `hatch run pytest tests/unit/modules/init/test_init_ide_prompt_selection.py tests/unit/utils/test_ide_setup.py tests/e2e/test_init_command.py -q`
- Status: green after `ide_setup` catalog + namespaced copy, `init ide` wiring, startup_checks rglob, and e2e path updates.
36 changes: 18 additions & 18 deletions openspec/changes/init-ide-prompt-source-selection/tasks.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
## 1. Spec And Dependency Setup

- [ ] 1.1 Update spec deltas so this change owns only core-side orchestration: root-aware prompt/resource source discovery, default `all` export behavior, interactive source selection, and non-interactive `--prompts` parsing.
- [ ] 1.2 Confirm the final prompt ownership inputs from `backlog-module-ownership-cleanup`, `packaging-02-cross-platform-runtime-and-module-resources`, and `specfact-cli-modules/packaging-01-bundle-resource-payloads`.
- [ ] 1.3 Align exported prompt ownership and recommendations with the active command-surface decisions from `module-migration-11-project-codebase-ownership-realignment`.
- [x] 1.1 Update spec deltas so this change owns only core-side orchestration: root-aware prompt/resource source discovery, default `all` export behavior, interactive source selection, and non-interactive `--prompts` parsing.
- [x] 1.2 Confirm the final prompt ownership inputs from `backlog-module-ownership-cleanup`, `packaging-02-cross-platform-runtime-and-module-resources`, and `specfact-cli-modules/packaging-01-bundle-resource-payloads`.
- [x] 1.3 Align exported prompt ownership and recommendations with the active command-surface decisions from `module-migration-11-project-codebase-ownership-realignment`.

## 2. Test-First Prompt Source Selection

- [ ] 2.1 Add failing tests for default export of all available prompt sources.
- [ ] 2.2 Add failing tests for effective source discovery across built-in, user-scope, project-scope, and custom module roots.
- [ ] 2.3 Add failing tests for interactive multi-select over `core` plus installed module ids.
- [ ] 2.4 Add failing tests for non-interactive `--prompts` values including `all`, `core`, mixed selections, and invalid/non-installed module ids.
- [ ] 2.5 Add failing tests that missing prompt/resource payloads emit install/bootstrap guidance instead of downloading modules from `init ide`.
- [ ] 2.6 Record the failing evidence in `TDD_EVIDENCE.md`.
- [x] 2.1 Add failing tests for default export of all available prompt sources.
- [x] 2.2 Add failing tests for effective source discovery across built-in, user-scope, project-scope, and custom module roots.
- [x] 2.3 Add failing tests for interactive multi-select over `core` plus installed module ids.
- [x] 2.4 Add failing tests for non-interactive `--prompts` values including `all`, `core`, mixed selections, and invalid/non-installed module ids.
- [x] 2.5 Add failing tests that missing prompt/resource payloads emit install/bootstrap guidance instead of downloading modules from `init ide`.
- [x] 2.6 Record the failing evidence in `TDD_EVIDENCE.md`.

## 3. Implementation

- [ ] 3.1 Extend prompt-source discovery so `specfact init ide` sees the effective installed module roots for the current repo context, including user and project scope.
- [ ] 3.2 Update `specfact init ide` interactive flow to use a source picker over the discovered installed prompt sources.
- [ ] 3.3 Add non-interactive `--prompts` selection using comma-separated source tokens.
- [ ] 3.4 Ensure copied prompt resources are namespaced by source and collision-safe.
- [ ] 3.5 Add actionable scope-aware guidance that points users to `specfact module init` / `specfact module install` when selected resources are missing.
- [ ] 3.6 Keep `init ide` as an anytime re-sync command that copies discovered resources only and does not perform install/download/extract work itself.
- [x] 3.1 Extend prompt-source discovery so `specfact init ide` sees the effective installed module roots for the current repo context, including user and project scope.
- [x] 3.2 Update `specfact init ide` interactive flow to use a source picker over the discovered installed prompt sources.
- [x] 3.3 Add non-interactive `--prompts` selection using comma-separated source tokens.
- [x] 3.4 Ensure copied prompt resources are namespaced by source and collision-safe.
- [x] 3.5 Add actionable scope-aware guidance that points users to `specfact module init` / `specfact module install` when selected resources are missing.
- [x] 3.6 Keep `init ide` as an anytime re-sync command that copies discovered resources only and does not perform install/download/extract work itself.

## 4. Validation

- [ ] 4.1 Re-run the new prompt-selection and root-discovery tests and record passing evidence in `TDD_EVIDENCE.md`.
- [ ] 4.2 Update docs/help text for `specfact init ide`, `specfact module init`, and `specfact module install` so scope ownership and refresh behavior are explicit.
- [ ] 4.3 Run `openspec validate init-ide-prompt-source-selection --strict`.
- [x] 4.1 Re-run the new prompt-selection and root-discovery tests and record passing evidence in `TDD_EVIDENCE.md`.
- [x] 4.2 Update docs/help text for `specfact init ide`, `specfact module init`, and `specfact module install` so scope ownership and refresh behavior are explicit.
- [x] 4.3 Run `openspec validate init-ide-prompt-source-selection --strict`.
6 changes: 3 additions & 3 deletions src/specfact_cli/modules/init/module-package.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: init
version: 0.1.13
version: 0.1.14
commands:
- init
category: core
Expand All @@ -17,5 +17,5 @@ publisher:
description: Initialize SpecFact workspace and bootstrap local configuration.
license: Apache-2.0
integrity:
checksum: sha256:4f929d90edc481d21cf1aa9c2782f569a34baf560e25d3317067f1c5951509e9
signature: Sa60QWb6UaZRp40885CzkWMCGwT809/qQj1dnWIJcocwfLwT7Je+BR2STbGQv2RbTbTBBXmQ0Qwj/+AM3U0qAA==
checksum: sha256:45e5be8b419dace34597548d815a78aa5a5d2123cceb64fb8e4d07869b9953d6
signature: ckt1wRcZDCX8rjcQhZ30+nH+gNC55qUDNfx9ijB+3778Kcu1vetKavzNGNIaO31OR078EZg5LF4vCy2lhRCcBg==
158 changes: 121 additions & 37 deletions src/specfact_cli/modules/init/src/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,15 @@
from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo, build_tool_command, detect_env_manager
from specfact_cli.utils.ide_setup import (
IDE_CONFIG,
PROMPT_SOURCE_CORE,
_copy_template_files_to_ide,
_discover_module_resource_dirs,
copy_prompts_by_source_to_ide,
count_outdated_ide_prompt_exports,
detect_ide,
discover_prompt_sources_catalog,
discover_prompt_template_files,
expected_ide_prompt_export_paths,
find_package_resources_path,
)

Expand Down Expand Up @@ -65,8 +70,16 @@
),
"Must return copied files and optional settings path",
)
def copy_templates_to_ide(repo_path: Path, ide: str, force: bool = False) -> tuple[list[Path], Path | None]:
"""Compatibility wrapper that discovers prompt templates before copying them."""
def copy_templates_to_ide(
repo_path: Path,
ide: str,
force: bool = False,
*,
prompts_by_source: dict[str, list[Path]] | None = None,
) -> tuple[list[Path], Path | None]:
"""Discover prompt templates and copy them; use ``prompts_by_source`` for namespaced multi-source export."""
if prompts_by_source is not None:
return copy_prompts_by_source_to_ide(repo_path, ide, prompts_by_source, force)
return _copy_template_files_to_ide(repo_path, ide, discover_prompt_template_files(repo_path), force)


Expand Down Expand Up @@ -307,36 +320,12 @@ def _resolve_templates_dir(repo_path: Path) -> Path | None:
return find_package_resources_path("specfact_cli", "resources/prompts")


def _expected_ide_prompt_basenames(repo_path: Path, format_type: str) -> list[str]:
prompt_files = discover_prompt_template_files(repo_path)
if format_type == "prompt.md":
return [f"{path.stem}.prompt.md" for path in prompt_files]
if format_type == "toml":
return [f"{path.stem}.toml" for path in prompt_files]
return [path.name for path in prompt_files]


def _count_outdated_ide_prompts(ide_dir: Path, prompt_files: list[Path], format_type: str) -> int:
outdated = 0
for src in prompt_files:
if format_type == "prompt.md":
dest = ide_dir / f"{src.stem}.prompt.md"
elif format_type == "toml":
dest = ide_dir / f"{src.stem}.toml"
else:
dest = ide_dir / src.name
if src.exists() and dest.exists() and dest.stat().st_mtime < src.stat().st_mtime:
outdated += 1
return outdated


def _audit_prompt_installation(repo_path: Path) -> None:
"""Report prompt installation health and next steps without mutating files."""
detected_ide = detect_ide("auto")
config = IDE_CONFIG[detected_ide]
ide_dir = repo_path / str(config["folder"])
format_type = str(config["format"])
expected_files = _expected_ide_prompt_basenames(repo_path, format_type)
expected_paths = expected_ide_prompt_export_paths(repo_path, detected_ide)

if not ide_dir.exists():
console.print(
Expand All @@ -345,9 +334,8 @@ def _audit_prompt_installation(repo_path: Path) -> None:
)
return

missing = [name for name in expected_files if not (ide_dir / name).exists()]
prompt_files = discover_prompt_template_files(repo_path)
outdated = _count_outdated_ide_prompts(ide_dir, prompt_files, format_type) if prompt_files else 0
missing = [p for p in expected_paths if not p.exists()]
outdated = count_outdated_ide_prompt_exports(repo_path, detected_ide) if expected_paths else 0

if not missing and outdated == 0:
console.print(f"[green]Prompt status:[/green] {detected_ide} prompts are present and up to date.")
Expand All @@ -359,6 +347,81 @@ def _audit_prompt_installation(repo_path: Path) -> None:
console.print(f"[dim]Run: specfact init ide --ide {detected_ide}{' --force' if outdated > 0 else ''}[/dim]")


def _raise_missing_prompt_source(token: str, catalog: dict[str, list[Path]]) -> None:
avail = ", ".join(sorted(catalog.keys()))
console.print(f"[red]Error:[/red] Prompt source [bold]{token}[/bold] is not available or has no prompt resources.")
console.print(f"[dim]Available sources: {avail}[/dim]")
console.print(
"[dim]Install modules with [bold]specfact module install --scope user|project[/bold] "
"or seed bundled artifacts with [bold]specfact module init --scope user|project[/bold].[/dim]"
)
raise typer.Exit(1)


@beartype
def _parse_prompts_option_to_catalog(catalog: dict[str, list[Path]], prompts: str) -> dict[str, list[Path]]:
tokens = [t.strip() for t in prompts.split(",") if t.strip()]
if not tokens:
console.print("[red]Error:[/red] --prompts must list at least one source or `all`.")
raise typer.Exit(1)
if len(tokens) == 1 and tokens[0].lower() == "all":
return dict(catalog)
result: dict[str, list[Path]] = {}
for token in tokens:
key = PROMPT_SOURCE_CORE if token.lower() == "core" else token
if key not in catalog:
_raise_missing_prompt_source(token, catalog)
result[key] = catalog[key]
return result


def _select_prompt_sources_interactive(catalog: dict[str, list[Path]]) -> dict[str, list[Path]]:
keys = sorted(catalog.keys(), key=lambda k: (k != PROMPT_SOURCE_CORE, k))
if len(keys) <= 1:
return dict(catalog)
try:
import questionary # type: ignore[reportMissingImports]
except ImportError as e:
console.print(
"[red]Interactive prompt source selection requires 'questionary'. "
"Install with: pip install questionary[/red]"
)
raise typer.Exit(1) from e

console.print()
console.print(
Panel(
"[bold cyan]Prompt sources[/bold cyan]\n"
"Choose which prompt bundles to export (core and installed modules with prompt resources).",
border_style="cyan",
)
)
console.print("[dim]Controls: ↑↓ navigate β€’ Space toggle β€’ Enter confirm β€’ Type to filter β€’ Ctrl+C cancel[/dim]")

labels = [f"{k} ({len(catalog[k])} template(s))" for k in keys]
label_to_key = {labels[i]: keys[i] for i in range(len(keys))}

selected = (
cast(Any, questionary)
.checkbox(
"Select prompt sources:",
choices=labels,
default=labels,
style=_questionary_style(),
)
.ask()
)
if not selected:
console.print("[red]Error:[/red] Select at least one prompt source.")
raise typer.Exit(1)
chosen: dict[str, list[Path]] = {}
for label in selected:
sid = label_to_key.get(label)
if sid is not None:
chosen[sid] = catalog[sid]
return chosen


def _select_ide_interactive(default_ide: str) -> str:
"""Select IDE interactively with up/down controls."""
try:
Expand Down Expand Up @@ -561,8 +624,16 @@ def init_ide(
"--ide",
help="IDE type (cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q, auto)",
),
prompts: str | None = typer.Option(
None,
"--prompts",
help=(
"Comma-separated prompt sources: 'all', 'core', and/or module ids (e.g. nold-ai/specfact-backlog). "
"Default: all discovered sources. Omitted in interactive mode opens a multi-select."
),
),
) -> None:
"""Initialize IDE prompt templates and settings."""
"""Initialize IDE prompt templates and settings (exports core + installed module prompts; re-sync anytime)."""
repo_path = repo.resolve()
detected_default = detect_ide("auto")
if ide is not None:
Expand Down Expand Up @@ -595,14 +666,27 @@ def init_ide(
if install_deps:
_install_contract_enhancement_dependencies(repo_path, env_info)

prompt_files = discover_prompt_template_files(repo_path)
if not prompt_files:
console.print("[red]Error:[/red] Templates directory not found.")
catalog = discover_prompt_sources_catalog(repo_path)
if not catalog:
console.print("[red]Error:[/red] No prompt templates found.")
console.print(
"[dim]Seed or install modules first, e.g. [bold]specfact module init --scope project[/bold] "
"or [bold]specfact module install --scope user[/bold].[/dim]"
)
raise typer.Exit(1)

template_sources = ", ".join(sorted({str(path.parent) for path in prompt_files}))
console.print(f"[cyan]Templates:[/cyan] {template_sources}")
copied_files, settings_path = copy_templates_to_ide(repo_path, selected_ide, force)
if prompts is not None:
selected_catalog = _parse_prompts_option_to_catalog(catalog, prompts)
elif is_non_interactive():
selected_catalog = dict(catalog)
else:
selected_catalog = _select_prompt_sources_interactive(catalog)

source_summary = ", ".join(sorted(selected_catalog.keys()))
console.print(f"[cyan]Prompt sources:[/cyan] {source_summary}")
copied_files, settings_path = copy_templates_to_ide(
repo_path, selected_ide, force, prompts_by_source=selected_catalog
)
_copy_backlog_field_mapping_templates(repo_path, force, console)

console.print()
Expand Down
Loading
Loading