diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d16d822..e7879292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/__/`) 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 diff --git a/docs/core-cli/init.md b/docs/core-cli/init.md index 9dc02671..afe0480f 100644 --- a/docs/core-cli/init.md +++ b/docs/core-cli/init.md @@ -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 diff --git a/docs/core-cli/module.md b/docs/core-cli/module.md index 0d39c215..289a7ef1 100644 --- a/docs/core-cli/module.md +++ b/docs/core-cli/module.md @@ -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 diff --git a/openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md b/openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md new file mode 100644 index 00000000..af3baded --- /dev/null +++ b/openspec/changes/init-ide-prompt-source-selection/TDD_EVIDENCE.md @@ -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. diff --git a/openspec/changes/init-ide-prompt-source-selection/tasks.md b/openspec/changes/init-ide-prompt-source-selection/tasks.md index 3f8ec33a..741d2aa2 100644 --- a/openspec/changes/init-ide-prompt-source-selection/tasks.md +++ b/openspec/changes/init-ide-prompt-source-selection/tasks.md @@ -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`. diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 8e48f163..648f7ecc 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.13 +version: 0.1.14 commands: - init category: core @@ -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== diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 5f8c8926..9b72766c 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -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, ) @@ -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) @@ -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( @@ -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.") @@ -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: @@ -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: @@ -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() diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index ed732762..b1bf8df6 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -118,6 +118,10 @@ }, } +# Canonical id for bundled `specfact_cli` prompt templates (not a module-package name). +PROMPT_SOURCE_CORE = "core" + + # Commands available in SpecFact # Workflow-ordered commands (Phase 3) SPECFACT_COMMANDS = [ @@ -142,6 +146,7 @@ def _iter_prompt_template_files(templates_dir: Path) -> list[Path]: def _module_discovery_roots(repo_path: Path | None) -> list[tuple[Path, str]]: """Return module roots to inspect across builtin, repo-local, and configured locations.""" + from specfact_cli.registry.module_discovery import MARKETPLACE_MODULES_ROOT, USER_MODULES_ROOT from specfact_cli.registry.module_packages import get_modules_root, get_workspace_modules_root discovery_roots: list[tuple[Path, str]] = [] @@ -156,8 +161,10 @@ def _add_discovery_root(path: Path | None, source: str) -> None: _add_discovery_root(get_modules_root(), "builtin") if repo_path is not None: - _add_discovery_root((repo_path / ".specfact" / "modules").resolve(), "workspace") - _add_discovery_root(get_workspace_modules_root(repo_path), "workspace") + _add_discovery_root((repo_path / ".specfact" / "modules").resolve(), "project") + _add_discovery_root(get_workspace_modules_root(repo_path), "project") + _add_discovery_root(USER_MODULES_ROOT, "user") + _add_discovery_root(MARKETPLACE_MODULES_ROOT, "marketplace") extra_roots = os.environ.get("SPECFACT_MODULES_ROOTS", "") for raw_root in extra_roots.split(os.pathsep): @@ -171,6 +178,83 @@ def _add_discovery_root(path: Path | None, source: str) -> None: return discovery_roots +def _core_prompt_template_paths(repo_path: Path, include_package_fallback: bool) -> list[Path]: + repo_prompts = (repo_path / "resources" / "prompts").resolve() + if repo_prompts.is_dir(): + found = _iter_prompt_template_files(repo_prompts) + if found: + return found + if not include_package_fallback: + return [] + pkg_dir = find_package_resources_path("specfact_cli", "resources/prompts") + if pkg_dir is not None and pkg_dir.is_dir(): + return _iter_prompt_template_files(pkg_dir) + return [] + + +def _module_prompt_sources_catalog(repo_path: Path) -> dict[str, list[Path]]: + from specfact_cli.registry.module_packages import CORE_MODULE_ORDER, discover_package_metadata + + catalog: dict[str, list[Path]] = {} + for modules_root, source in _module_discovery_roots(repo_path): + if not modules_root.exists() or not modules_root.is_dir(): + continue + for package_dir, metadata in discover_package_metadata(modules_root, source=source): + if metadata.name in CORE_MODULE_ORDER: + continue + prompt_dir = (package_dir / "resources" / "prompts").resolve() + if not prompt_dir.is_dir(): + continue + files = _iter_prompt_template_files(prompt_dir) + if not files: + continue + module_id = str(metadata.name) + if module_id in catalog or module_id == PROMPT_SOURCE_CORE: + continue + catalog[module_id] = list(files) + return catalog + + +@beartype +@require(lambda source_id: isinstance(source_id, str) and source_id.strip() != "", "source_id must be non-empty") +@ensure(lambda result: isinstance(result, str) and len(result) > 0, "segment must be non-empty") +def source_id_to_path_segment(source_id: str) -> str: + """Map a prompt source id to a single directory segment under the IDE export folder.""" + cleaned = source_id.strip().replace("/", "__").replace("\\", "__") + if not cleaned or cleaned in {".", ".."}: + return "unknown" + return cleaned + + +@beartype +@require(repo_path_exists, "Repo path must exist") +@require(repo_path_is_dir, "Repo path must be a directory") +@ensure( + lambda result: ( + isinstance(result, dict) + and all(isinstance(k, str) for k in result) + and all(isinstance(v, list) and all(isinstance(p, Path) for p in v) for v in result.values()) + ), + "Catalog must map str source ids to lists of Paths", +) +def discover_prompt_sources_catalog( + repo_path: Path, + include_package_fallback: bool = True, +) -> dict[str, list[Path]]: + """ + Build prompt templates grouped by owning source: ``core`` or a module id (``module-package.yaml`` name). + + Core templates come from the repo checkout or the installed ``specfact_cli`` package. Module templates + are discovered from effective module roots (builtin, project, user, marketplace, custom). + """ + catalog: dict[str, list[Path]] = {} + core_files = _core_prompt_template_paths(repo_path, include_package_fallback) + if core_files: + catalog[PROMPT_SOURCE_CORE] = list(core_files) + catalog.update(_module_prompt_sources_catalog(repo_path)) + return catalog + + def _matches_requested_categories( resolved_package_dir: Path, candidate: Path, @@ -267,30 +351,19 @@ def _discover_module_resource_dirs( ) def discover_prompt_template_files(repo_path: Path, include_package_fallback: bool = True) -> list[Path]: """Return prompt templates from installed modules, then repo resources, then optional package fallback.""" - prompt_files: list[Path] = [] + catalog = discover_prompt_sources_catalog(repo_path, include_package_fallback=include_package_fallback) + merged: list[Path] = [] seen_names: set[str] = set() - - for resource_root in _discover_module_resource_dirs("resources/prompts", repo_path=repo_path): - for prompt_file in _iter_prompt_template_files(resource_root / "prompts"): + ordered_keys = [PROMPT_SOURCE_CORE, *sorted(k for k in catalog if k != PROMPT_SOURCE_CORE)] + for key in ordered_keys: + if key not in catalog: + continue + for prompt_file in catalog[key]: if prompt_file.name in seen_names: continue seen_names.add(prompt_file.name) - prompt_files.append(prompt_file) - - if prompt_files: - return prompt_files - - fallback_dirs: list[Path | None] = [(repo_path / "resources" / "prompts").resolve()] - if include_package_fallback: - fallback_dirs.append(find_package_resources_path("specfact_cli", "resources/prompts")) - - for fallback_dir in fallback_dirs: - if fallback_dir is None: - continue - fallback_files = _iter_prompt_template_files(fallback_dir) - if fallback_files: - return fallback_files - return [] + merged.append(prompt_file) + return merged def _output_filename_for_template(template_path: Path, format_type: str) -> str: @@ -303,7 +376,13 @@ def _output_filename_for_template(template_path: Path, format_type: str) -> str: def _copy_template_files_to_ide( - repo_path: Path, ide: str, template_files: list[Path], force: bool = False + repo_path: Path, + ide: str, + template_files: list[Path], + force: bool = False, + *, + source_segment: str | None = None, + write_settings: bool = True, ) -> tuple[list[Path], Path | None]: """Copy a concrete list of prompt template files to the IDE target location.""" config = IDE_CONFIG[ide] @@ -314,6 +393,8 @@ def _copy_template_files_to_ide( settings_file = None ide_dir = repo_path / ide_folder + if source_segment is not None: + ide_dir = ide_dir / source_segment ide_dir.mkdir(parents=True, exist_ok=True) copied_files: list[Path] = [] @@ -332,12 +413,96 @@ def _copy_template_files_to_ide( console.print(f"[green]Copied:[/green] {output_path}") settings_path = None - if settings_file and isinstance(settings_file, str): + if write_settings and settings_file and isinstance(settings_file, str): settings_path = create_vscode_settings(repo_path, settings_file) return copied_files, settings_path +@beartype +@require(repo_path_exists, "Repo path must exist") +@require(repo_path_is_dir, "Repo path must be a directory") +@require(lambda ide: ide in IDE_CONFIG, "IDE must be valid") +@ensure(lambda result: isinstance(result, list) and all(isinstance(p, Path) for p in result), "Must return Paths") +def expected_ide_prompt_export_paths(repo_path: Path, ide: str) -> list[Path]: + """Return expected on-disk paths for exported IDE prompts (source-namespaced layout).""" + config = IDE_CONFIG[ide] + format_type = str(config["format"]) + base = repo_path / str(config["folder"]) + catalog = discover_prompt_sources_catalog(repo_path) + paths: list[Path] = [] + for sid, templates in sorted(catalog.items(), key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0])): + segment = source_id_to_path_segment(sid) + for template_path in templates: + paths.append(base / segment / _output_filename_for_template(template_path, format_type)) + return paths + + +@beartype +@require(repo_path_exists, "Repo path must exist") +@require(repo_path_is_dir, "Repo path must be a directory") +@require(lambda ide: ide in IDE_CONFIG, "IDE must be valid") +@ensure(lambda result: isinstance(result, int) and result >= 0, "Count must be non-negative") +def count_outdated_ide_prompt_exports(repo_path: Path, ide: str) -> int: + """Count exported IDE prompt files that are older than their source templates.""" + config = IDE_CONFIG[ide] + format_type = str(config["format"]) + base = repo_path / str(config["folder"]) + catalog = discover_prompt_sources_catalog(repo_path) + outdated = 0 + for sid, paths in catalog.items(): + segment = source_id_to_path_segment(sid) + for src in paths: + dest = base / segment / _output_filename_for_template(src, format_type) + if src.exists() and dest.exists() and dest.stat().st_mtime < src.stat().st_mtime: + outdated += 1 + return outdated + + +@beartype +@require(repo_path_exists, "Repo path must exist") +@require(repo_path_is_dir, "Repo path must be a directory") +@require(lambda ide: ide in IDE_CONFIG, "IDE must be valid") +@require(lambda prompts_by_source: isinstance(prompts_by_source, dict), "prompts_by_source must be a dict") +@ensure( + lambda result: ( + isinstance(result, tuple) + and len(result) == 2 + and isinstance(result[0], list) + and (result[1] is None or isinstance(result[1], Path)) + ), + "Must return copied paths and optional settings path", +) +def copy_prompts_by_source_to_ide( + repo_path: Path, + ide: str, + prompts_by_source: dict[str, list[Path]], + force: bool = False, +) -> tuple[list[Path], Path | None]: + """Copy prompts grouped by source id into source-namespaced subfolders under the IDE export directory.""" + all_copied: list[Path] = [] + ordered = sorted( + prompts_by_source.items(), + key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0]), + ) + for source_id, paths in ordered: + if not paths: + continue + segment = source_id_to_path_segment(source_id) + copied, _settings = _copy_template_files_to_ide( + repo_path, ide, paths, force, source_segment=segment, write_settings=False + ) + all_copied.extend(copied) + + settings_path: Path | None = None + config = IDE_CONFIG[ide] + settings_file = config.get("settings_file") + if settings_file and isinstance(settings_file, str): + settings_path = create_vscode_settings(repo_path, settings_file, prompts_by_source=prompts_by_source) + + return all_copied, settings_path + + @beartype @require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'") def detect_ide(ide: str = "auto") -> str: @@ -494,17 +659,69 @@ def copy_templates_to_ide( return _copy_template_files_to_ide(repo_path, ide, _iter_prompt_template_files(templates_dir), force) +def _vscode_prompt_recommendation_paths_from_sources(prompts_by_source: dict[str, list[Path]]) -> list[str]: + """Build `.github/prompts/...` recommendation strings matching namespaced IDE export layout.""" + prompt_files: list[str] = [] + for source_id, paths in sorted( + prompts_by_source.items(), + key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0]), + ): + segment = source_id_to_path_segment(source_id) + for template_path in paths: + prompt_files.append(f".github/prompts/{segment}/{template_path.stem}.prompt.md") + return prompt_files + + +def _vscode_prompt_paths_from_full_catalog(repo_path: Path) -> list[str]: + """Recommendation paths for the full discovered prompt catalog (namespaced segments).""" + catalog = discover_prompt_sources_catalog(repo_path) + out: list[str] = [] + for source_id, paths in sorted(catalog.items(), key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0])): + segment = source_id_to_path_segment(source_id) + for template_path in paths: + out.append(f".github/prompts/{segment}/{template_path.stem}.prompt.md") + return out + + +def _finalize_vscode_prompt_recommendation_paths(repo_path: Path, prompt_files: list[str]) -> list[str]: + """Fall back to flat discovery or command list when namespaced paths are empty.""" + if not prompt_files: + discovered_flat = discover_prompt_template_files(repo_path) + prompt_files = [f".github/prompts/{template_path.stem}.prompt.md" for template_path in discovered_flat] + if not prompt_files: + return [f".github/prompts/{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS] + return prompt_files + + +def _strip_specfact_github_prompt_recommendations(paths: list[str]) -> list[str]: + """Remove prior SpecFact-managed prompt paths under `.github/prompts/` before re-adding an export.""" + return [p for p in paths if not p.startswith(".github/prompts/")] + + @beartype @require(repo_path_exists, "Repo path must exist") @require(repo_path_is_dir, "Repo path must be a directory") +@require( + lambda prompts_by_source: prompts_by_source is None or isinstance(prompts_by_source, dict), + "prompts_by_source must be None or a dict", +) @ensure(lambda result: vscode_settings_result_ok(result), "Settings file must exist if returned") -def create_vscode_settings(repo_path: Path, settings_file: str) -> Path | None: +def create_vscode_settings( + repo_path: Path, + settings_file: str, + *, + prompts_by_source: dict[str, list[Path]] | None = None, +) -> Path | None: """ Create or merge VS Code settings.json with prompt file recommendations. Args: repo_path: Repository root path settings_file: Settings file path (e.g., ".vscode/settings.json") + prompts_by_source: When set (e.g. from ``copy_prompts_by_source_to_ide``), recommendations list only + templates from that export; prior ``.github/prompts/`` entries are replaced so selective + ``--prompts`` runs do not leave stale module paths. When ``None``, recommendations follow the + full discovered catalog (or legacy flat fallbacks). Returns: Path to settings file, or None if not VS Code/Copilot @@ -520,11 +737,14 @@ def create_vscode_settings(repo_path: Path, settings_file: str) -> Path | None: settings_dir = settings_path.parent settings_dir.mkdir(parents=True, exist_ok=True) - # Generate prompt file recommendations - discovered_prompts = discover_prompt_template_files(repo_path) - prompt_files = [f".github/prompts/{template_path.stem}.prompt.md" for template_path in discovered_prompts] - if not prompt_files: - prompt_files = [f".github/prompts/{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS] + if prompts_by_source is not None: + prompt_files = _finalize_vscode_prompt_recommendation_paths( + repo_path, _vscode_prompt_recommendation_paths_from_sources(prompts_by_source) + ) + else: + prompt_files = _finalize_vscode_prompt_recommendation_paths( + repo_path, _vscode_prompt_paths_from_full_catalog(repo_path) + ) # Load existing settings or create new if settings_path.exists(): @@ -543,6 +763,10 @@ def create_vscode_settings(repo_path: Path, settings_file: str) -> Path | None: chat_block = existing_settings["chat"] chat_dict: dict[str, Any] = cast(dict[str, Any], chat_block) if isinstance(chat_block, dict) else {} existing_recommendations = chat_dict.get("promptFilesRecommendations", []) + if prompts_by_source is not None: + existing_recommendations = _strip_specfact_github_prompt_recommendations( + list(existing_recommendations) if isinstance(existing_recommendations, list) else [] + ) merged_recommendations = list(set(existing_recommendations + prompt_files)) chat_dict["promptFilesRecommendations"] = merged_recommendations existing_settings["chat"] = chat_dict diff --git a/src/specfact_cli/utils/startup_checks.py b/src/specfact_cli/utils/startup_checks.py index bba7dc3a..dfe8546c 100644 --- a/src/specfact_cli/utils/startup_checks.py +++ b/src/specfact_cli/utils/startup_checks.py @@ -119,6 +119,20 @@ def _expected_ide_template_filenames(format_type: str) -> list[str]: return expected_files +def _find_ide_exported_prompt_file(ide_dir: Path, basename: str) -> Path | None: + """Resolve an exported prompt filename under flat or source-namespaced layouts.""" + direct = ide_dir / basename + if direct.is_file(): + return direct + try: + for path in ide_dir.rglob(basename): + if path.is_file(): + return path + except OSError: + return None + return None + + def _scan_ide_template_drift( ide_dir: Path, templates_dir: Path, @@ -127,10 +141,10 @@ def _scan_ide_template_drift( missing_templates: list[str] = [] outdated_templates: list[str] = [] for expected_file in expected_files: - ide_file = ide_dir / expected_file + ide_file = _find_ide_exported_prompt_file(ide_dir, expected_file) source_template_name = expected_file.replace(".prompt.md", ".md").replace(".toml", ".md") source_file = templates_dir / source_template_name - if not ide_file.exists(): + if ide_file is None: missing_templates.append(expected_file) continue if not source_file.exists(): diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index c0ba5b46..b1b9aea9 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -40,8 +40,8 @@ def test_init_auto_detect_cursor(self, tmp_path, monkeypatch): assert "Cursor" in result.stdout assert ".cursor/commands/" in result.stdout - # Verify templates were copied - cursor_dir = tmp_path / ".cursor" / "commands" + # Verify templates were copied (namespaced by source: core/) + cursor_dir = tmp_path / ".cursor" / "commands" / "core" assert cursor_dir.exists() assert (cursor_dir / "specfact.01-import.md").exists() assert (cursor_dir / "specfact.02-plan.md").exists() @@ -64,8 +64,8 @@ def test_init_explicit_cursor(self, tmp_path): assert "Cursor" in result.stdout assert ".cursor/commands/" in result.stdout - # Verify template was copied - cursor_dir = tmp_path / ".cursor" / "commands" + # Verify template was copied (namespaced: core/) + cursor_dir = tmp_path / ".cursor" / "commands" / "core" assert cursor_dir.exists() assert (cursor_dir / "specfact.01-import.md").exists() @@ -87,8 +87,8 @@ def test_init_explicit_vscode(self, tmp_path): assert "VS Code" in result.stdout assert ".github/prompts/" in result.stdout - # Verify template was copied - prompts_dir = tmp_path / ".github" / "prompts" + # Verify template was copied (namespaced: core/) + prompts_dir = tmp_path / ".github" / "prompts" / "core" assert prompts_dir.exists() assert (prompts_dir / "specfact.01-import.prompt.md").exists() @@ -114,8 +114,8 @@ def test_init_explicit_copilot(self, tmp_path): assert "GitHub Copilot" in result.stdout assert ".github/prompts/" in result.stdout - # Verify template was copied - prompts_dir = tmp_path / ".github" / "prompts" + # Verify template was copied (namespaced: core/) + prompts_dir = tmp_path / ".github" / "prompts" / "core" assert prompts_dir.exists() assert (prompts_dir / "specfact.01-import.prompt.md").exists() @@ -127,8 +127,8 @@ def test_init_skips_existing_files_without_force(self, tmp_path): (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nContent") (templates_dir / "specfact.02-plan.md").write_text("---\ndescription: Plan Init\n---\nContent") - # Pre-create one file (but not all) - cursor_dir = tmp_path / ".cursor" / "commands" + # Pre-create one exported file (namespaced: core/) but not all + cursor_dir = tmp_path / ".cursor" / "commands" / "core" cursor_dir.mkdir(parents=True) (cursor_dir / "specfact.01-import.md").write_text("existing content") @@ -156,8 +156,8 @@ def test_init_overwrites_with_force(self, tmp_path): templates_dir.mkdir(parents=True) (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\nNew content") - # Pre-create one file - cursor_dir = tmp_path / ".cursor" / "commands" + # Pre-create one file under the namespaced core/ export path + cursor_dir = tmp_path / ".cursor" / "commands" / "core" cursor_dir.mkdir(parents=True) (cursor_dir / "specfact.01-import.md").write_text("existing content") @@ -222,7 +222,11 @@ def mock_find_resources(package_name: str, resource_subpath: str): # If templates are found from package, it succeeds (exit 0) # If templates are not found at all, it fails (exit 1) if result.exit_code == 1: - assert "Templates directory not found" in result.stdout or "Error" in result.stdout + assert ( + "No prompt templates found" in result.stdout + or "Templates directory not found" in result.stdout + or "Error" in result.stdout + ) else: # If it succeeds, templates were found from installed package assert result.exit_code == 0 @@ -281,8 +285,8 @@ def test_init_auto_detect_vscode(self, tmp_path, monkeypatch): assert "VS Code" in result.stdout or "vscode" in result.stdout.lower() assert ".github/prompts/" in result.stdout - # Verify templates were copied - prompts_dir = tmp_path / ".github" / "prompts" + # Verify templates were copied (namespaced: core/) + prompts_dir = tmp_path / ".github" / "prompts" / "core" assert prompts_dir.exists() assert (prompts_dir / "specfact.01-import.prompt.md").exists() @@ -314,8 +318,8 @@ def test_init_auto_detect_claude(self, tmp_path, monkeypatch): assert result.exit_code == 0 assert "Claude Code" in result.stdout or "claude" in result.stdout.lower() - # Verify templates were copied - claude_dir = tmp_path / ".claude" / "commands" + # Verify templates were copied (namespaced: core/) + claude_dir = tmp_path / ".claude" / "commands" / "core" assert claude_dir.exists() assert (claude_dir / "specfact.01-import.md").exists() diff --git a/tests/unit/modules/init/test_init_ide_prompt_selection.py b/tests/unit/modules/init/test_init_ide_prompt_selection.py new file mode 100644 index 00000000..820a12b2 --- /dev/null +++ b/tests/unit/modules/init/test_init_ide_prompt_selection.py @@ -0,0 +1,82 @@ +"""Tests for init ide prompt source catalog and --prompts parsing.""" + +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from specfact_cli.cli import app +from specfact_cli.modules.init.src import commands as init_commands +from specfact_cli.utils.ide_setup import ( + PROMPT_SOURCE_CORE, + copy_prompts_by_source_to_ide, + discover_prompt_sources_catalog, + source_id_to_path_segment, +) + + +def test_source_id_to_path_segment_sanitizes_slashes() -> None: + assert source_id_to_path_segment("nold-ai/specfact-backlog") == "nold-ai__specfact-backlog" + assert source_id_to_path_segment("core") == "core" + + +def test_discover_prompt_sources_catalog_includes_core_from_repo(tmp_path: Path) -> None: + prompts = tmp_path / "resources" / "prompts" + prompts.mkdir(parents=True) + p1 = prompts / "specfact.01-import.md" + p1.write_text("---\ndescription: A\n---\n# A\n", encoding="utf-8") + + catalog = discover_prompt_sources_catalog(tmp_path, include_package_fallback=False) + + assert PROMPT_SOURCE_CORE in catalog + assert p1 in catalog[PROMPT_SOURCE_CORE] + + +def test_copy_prompts_by_source_to_ide_namespaces_by_source(tmp_path: Path) -> None: + prompts = tmp_path / "resources" / "prompts" + prompts.mkdir(parents=True) + f1 = prompts / "specfact.01-import.md" + f1.write_text("---\ndescription: A\n---\n# A\n", encoding="utf-8") + + mod_dir = tmp_path / "mod" / "resources" / "prompts" + mod_dir.mkdir(parents=True) + f2 = mod_dir / "specfact.backlog-add.md" + f2.write_text("---\ndescription: B\n---\n# B\n", encoding="utf-8") + + by_source = {PROMPT_SOURCE_CORE: [f1], "nold-ai/specfact-backlog": [f2]} + copied, _settings = copy_prompts_by_source_to_ide(tmp_path, "cursor", by_source, force=True) + + assert (tmp_path / ".cursor" / "commands" / "core" / "specfact.01-import.md") in copied + assert (tmp_path / ".cursor" / "commands" / "nold-ai__specfact-backlog" / "specfact.backlog-add.md") in copied + + +def test_parse_prompts_option_all_expands_to_full_catalog() -> None: + fake_catalog = { + PROMPT_SOURCE_CORE: [], + "nold-ai/x": [], + } + out = init_commands._parse_prompts_option_to_catalog(fake_catalog, "all") + assert set(out.keys()) == {PROMPT_SOURCE_CORE, "nold-ai/x"} + + +def test_parse_prompts_option_core_token(tmp_path: Path) -> None: + p = tmp_path / "specfact.01-import.md" + p.write_text("---\n---\n", encoding="utf-8") + cat = {PROMPT_SOURCE_CORE: [p]} + out = init_commands._parse_prompts_option_to_catalog(cat, "core") + assert out == {PROMPT_SOURCE_CORE: [p]} + + +def test_init_ide_invalid_prompts_token_exits_nonzero(tmp_path: Path) -> None: + prompts = tmp_path / "resources" / "prompts" + prompts.mkdir(parents=True) + (prompts / "specfact.01-import.md").write_text("---\ndescription: A\n---\n# A\n", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + app, + ["init", "ide", "--repo", str(tmp_path), "--ide", "cursor", "--prompts", "nold-ai/not-installed", "--force"], + ) + assert result.exit_code == 1 + assert "not available" in result.stdout.lower() or "Error" in result.stdout diff --git a/tests/unit/modules/init/test_resource_resolution.py b/tests/unit/modules/init/test_resource_resolution.py index 0b9b0b1e..459ecb49 100644 --- a/tests/unit/modules/init/test_resource_resolution.py +++ b/tests/unit/modules/init/test_resource_resolution.py @@ -38,10 +38,10 @@ def test_discover_prompt_template_files_uses_installed_module_resources(monkeypa monkeypatch.setattr( ide_setup, - "_discover_module_resource_dirs", - lambda resource_subpath, repo_path=None, categories=None: ( - [prompt_dir.parent] if resource_subpath == "resources/prompts" else [] - ), + "discover_prompt_sources_catalog", + lambda repo_path, include_package_fallback=True: { + "nold-ai/example": [prompt_file], + }, ) discovered = ide_setup.discover_prompt_template_files(tmp_path) diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index acc65769..7178d8c5 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -2,13 +2,16 @@ from __future__ import annotations +import json from pathlib import Path import pytest from specfact_cli.utils.ide_setup import ( + PROMPT_SOURCE_CORE, SPECFACT_COMMANDS, copy_templates_to_ide, + create_vscode_settings, detect_ide, discover_prompt_template_files, process_template, @@ -269,7 +272,8 @@ def test_discover_prompt_template_files_prefers_target_repo_workspace_modules( discovered = discover_prompt_template_files(repo_path) - assert discovered == [prompt_file] + assert prompt_file in discovered + assert str(prompt_file).startswith(str(repo_path)) def test_discover_prompt_template_files_deduplicates_prompt_ids_by_filename( @@ -290,10 +294,11 @@ def test_discover_prompt_template_files_deduplicates_prompt_ids_by_filename( monkeypatch.setattr( ide_setup_module, - "_discover_module_resource_dirs", - lambda resource_subpath, repo_path=None, categories=None: ( - [first_dir.parent, second_dir.parent] if resource_subpath == "resources/prompts" else [] - ), + "discover_prompt_sources_catalog", + lambda repo_path, include_package_fallback=True: { + "nold-ai/mod-a": [first_prompt], + "nold-ai/mod-b": [second_prompt], + }, ) discovered = discover_prompt_template_files(tmp_path) @@ -301,6 +306,40 @@ def test_discover_prompt_template_files_deduplicates_prompt_ids_by_filename( assert discovered == [first_prompt] +def test_create_vscode_settings_selective_export_replaces_stale_github_prompt_paths(tmp_path: Path) -> None: + """When ``prompts_by_source`` is set, drop prior SpecFact ``.github/prompts/`` entries; keep other paths.""" + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + settings_path.write_text( + json.dumps( + { + "chat": { + "promptFilesRecommendations": [ + ".github/prompts/nold-ai__mod/specfact.extra.prompt.md", + ".other/custom.prompt.md", + ] + } + } + ), + encoding="utf-8", + ) + prompt = tmp_path / "specfact.01-import.md" + prompt.write_text("---\n---\n", encoding="utf-8") + + create_vscode_settings( + tmp_path, + ".vscode/settings.json", + prompts_by_source={PROMPT_SOURCE_CORE: [prompt]}, + ) + + data = json.loads(settings_path.read_text(encoding="utf-8")) + recs = list(data["chat"]["promptFilesRecommendations"]) + assert ".github/prompts/nold-ai__mod/specfact.extra.prompt.md" not in recs + assert ".other/custom.prompt.md" in recs + assert ".github/prompts/core/specfact.01-import.prompt.md" in recs + + def test_specfact_commands_excludes_backlog_prompt_ids() -> None: """Core IDE setup command list excludes backlog-owned prompt ids.""" assert "specfact.backlog-add" not in SPECFACT_COMMANDS