diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d16d822..e7b1866e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ 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. +--- + +## [0.42.5] - 2026-03-25 + +### 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). + +### Fixed + +- VS Code / Copilot: `chat.promptFilesRecommendations` lists only prompt sources actually exported by `init ide`; selective `--prompts` no longer leaves stale `.github/prompts/...` entries from unexported modules. +- Integration tests: restore module discovery / installer paths after the command-audit temp-home scenario so later unit tests do not observe leaked marketplace module roots. --- @@ -391,20 +405,6 @@ All notable changes to this project will be documented in this file. - Unit test and TDD evidence for CrossHair per-path timeout passthrough. - **Init module discovery alignment** (backlog-core-01): `specfact init` now uses the same module discovery roots as command registration (`discover_all_package_metadata()`), so `--list-modules`, `--enable-module`, and `--disable-module` operate on all discovered modules including workspace-level ones (e.g. `modules/backlog-core/`). Closes [#116](https://github.com/nold-ai/specfact-cli/issues/116) scope for init-module-discovery-alignment. - **Patch mode module** (patch-mode-01, [#177](https://github.com/nold-ai/specfact-cli/issues/177)): `specfact patch apply ` for local apply with preflight; `specfact patch apply --write --yes` for explicit upstream write orchestration and idempotency (`check_idempotent` / `mark_applied`). - -### Changed - -- `specfact init` module state and validation now build from `discover_all_package_metadata()` instead of `discover_package_metadata(get_modules_root())`, aligning enable/disable and list-modules with runtime command discovery. - -### Fixed - -- `specfact repro --crosshair-per-path-timeout 0` (or negative) now fails with a clear error instead of being silently ignored; CLI rejects non-positive CrossHair per-path timeout values. - ---- -## [Unreleased] - -### Added - - Architecture documentation remediation for OpenSpec change `arch-08-documentation-discrepancies-remediation`: - New architecture implementation status page: `docs/architecture/implementation-status.md`. - New ADR set with template and initial ADR: `docs/architecture/adr/`. @@ -412,6 +412,7 @@ All notable changes to this project will be documented in this file. ### Changed +- `specfact init` module state and validation now build from `discover_all_package_metadata()` instead of `discover_package_metadata(get_modules_root())`, aligning enable/disable and list-modules with runtime command discovery. - Reworked architecture references to align with implemented behavior: - `docs/reference/architecture.md` - `docs/architecture/README.md` @@ -428,6 +429,8 @@ All notable changes to this project will be documented in this file. ### Fixed +- `specfact repro --crosshair-per-path-timeout 0` (or negative) now fails with a clear error instead of being silently ignored; CLI rejects non-positive CrossHair per-path timeout values. + --- ## [0.33.0] - 2026-02-17 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/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 6cd3d2eb..647bfda5 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -149,7 +149,7 @@ Cross-repo dependency: `docs-07-core-handoff-conversion` depends on `specfact-cl | module-migration | 09 | backlog-module-ownership-cleanup | TBD | module-migration-06; backlog-core-07; cli-val-07 findings | | module-migration | 10 | module-migration-10-bundle-command-surface-alignment | [#385](https://github.com/nold-ai/specfact-cli/issues/385) | module-migration-02 ✅; module-migration-06/07 baseline; cli-val-07 findings | | module-migration | 11 | module-migration-11-project-codebase-ownership-realignment | [#408](https://github.com/nold-ai/specfact-cli/issues/408) | module-migration-06 baseline; backlog-module-ownership-cleanup precedent; blocks final canonical import-path decisions in module-migration-10 | -| init-ide | 01 | init-ide-prompt-source-selection | TBD | backlog-module-ownership-cleanup | +| init-ide | 01 | init-ide-prompt-source-selection | [#382](https://github.com/nold-ai/specfact-cli/issues/382) | backlog-module-ownership-cleanup; packaging-02-cross-platform-runtime-and-module-resources; modules-repo/packaging-01-bundle-resource-payloads (#101); module-migration-11 command-ownership alignment | | backlog-auth | 01 | backlog-auth-01-backlog-auth-commands | TBD | module-migration-03 (central auth interface in core; auth removed from core) | ### Cross-cutting foundations (no hard dependencies — implement early) diff --git a/openspec/changes/init-ide-prompt-source-selection/CHANGE_VALIDATION.md b/openspec/changes/init-ide-prompt-source-selection/CHANGE_VALIDATION.md new file mode 100644 index 00000000..ffc30e10 --- /dev/null +++ b/openspec/changes/init-ide-prompt-source-selection/CHANGE_VALIDATION.md @@ -0,0 +1,23 @@ +# Change Validation: init-ide-prompt-source-selection + +- Date: 2026-03-25 +- Command: `openspec validate init-ide-prompt-source-selection --strict` +- Result: `Change 'init-ide-prompt-source-selection' is valid` + +## Scope Review + +- The change was narrowed to `specfact-cli` orchestration only. +- Bundle-owned prompt/template payload migration remains in `specfact-cli-modules` change `packaging-01-bundle-resource-payloads` (`nold-ai/specfact-cli-modules#101`). +- Installed-resource discovery mechanics remain owned by `packaging-02-cross-platform-runtime-and-module-resources` in `specfact-cli`. + +## Dependency Review + +- `backlog-module-ownership-cleanup` remains a prerequisite so backlog prompt ownership is no longer split across core and bundle code. +- `specfact-cli-modules#101` is now treated as the paired payload provider for bundle-owned prompt/template resources. +- `module-migration-11-project-codebase-ownership-realignment` is treated as command-surface alignment context so exported prompts do not reassert obsolete ownership or grouped command paths. + +## Behavioral Boundaries Confirmed + +- `specfact init ide` is an anytime re-sync/export command. +- `specfact init ide` discovers installed resources from effective module roots; it does not download or install module archives. +- Install/bootstrap guidance continues to belong to `specfact module init` and `specfact module install` with user/project scope selection. 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/design.md b/openspec/changes/init-ide-prompt-source-selection/design.md index 16045fbc..3135d177 100644 --- a/openspec/changes/init-ide-prompt-source-selection/design.md +++ b/openspec/changes/init-ide-prompt-source-selection/design.md @@ -1,12 +1,27 @@ ## Context -Today `init ide` is effectively designed around core-owned prompt resources. That breaks down once backlog and other domain prompt assets are properly owned by modules. The export flow needs a source-aware model that works for both interactive and automation-friendly use. +`packaging-02-cross-platform-runtime-and-module-resources` moved `specfact-cli` toward installed-resource discovery, but it explicitly left the prompt-selection UX and source-targeting behavior for this follow-up change. + +That follow-up now has to cover more than a selector: + +- bundle-owned prompt/template payloads are being moved into `specfact-cli-modules` +- modules can be present in multiple roots depending on how the user bootstrapped or installed them +- `specfact init ide` can be run at any time and therefore must discover the currently effective installed roots instead of assuming a one-time bootstrap copy from core-owned resources +- `specfact init ide` must not start performing marketplace download/extract work, because installation remains owned by `specfact module init` and `specfact module install` + +The prompt/export flow therefore needs a source-aware and scope-aware orchestration model, not just a new option flag. ## Decision - Define prompt sources as: - `core` - installed/enabled module ids, for example `nold-ai/specfact-backlog` +- Resolve available sources from effective module roots in repository context: + - built-in core module root + - project module root at `/.specfact/modules` when present + - nearest workspace module root discovered from the repo path + - user module root at `~/.specfact/modules` + - optional custom roots from `SPECFACT_MODULES_ROOTS` - Default export behavior to `all` sources. - In interactive mode, show a multi-select picker over the available prompt sources. - In non-interactive mode, use a comma-separated `--prompts` selector that accepts: @@ -14,15 +29,31 @@ Today `init ide` is effectively designed around core-owned prompt resources. Tha - `core` - full installed module ids - mixed values such as `core,nold-ai/specfact-backlog` +- Keep installation responsibilities separate: + - `specfact module init --scope user|project` seeds bundled module artifacts/resources into the selected root + - `specfact module install --scope user|project --source ` downloads and extracts bundles into the selected root + - `specfact init ide` only discovers already-installed resources and copies/refreshes the IDE-facing output for the current repository ## Rules - unknown or not-installed module ids fail with actionable guidance - exported prompt resources remain namespaced by source to avoid collisions - `all` includes `core` plus all installed/enabled modules that contribute prompts +- root precedence and duplicate handling must remain deterministic across user/project/custom roots +- missing module-owned prompt/resources must report the owning module, the root that was inspected, and the install/bootstrap command that can satisfy the missing payload +- prompt ownership must respect active command-surface migration decisions and must not reassert obsolete import/project command paths in exported prompts or recommendations + +## Non-Goals + +- downloading marketplace artifacts during `specfact init ide` +- extracting archives directly from `specfact-cli-modules` source trees at runtime when the owning bundle is not installed +- reintroducing bundle-owned prompt/template payloads into the core package +- replacing `packaging-02` as the owner of low-level installed-resource discovery logic ## Validation - add tests for default `all` behavior +- add tests for root-aware source discovery across built-in, user, project, and custom roots - add tests for interactive picker source lists - add tests for non-interactive parsing and validation of `--prompts` +- add tests that `init ide` surfaces scope-aware install guidance instead of downloading/installing missing modules diff --git a/openspec/changes/init-ide-prompt-source-selection/proposal.md b/openspec/changes/init-ide-prompt-source-selection/proposal.md index ce536565..f0ac4092 100644 --- a/openspec/changes/init-ide-prompt-source-selection/proposal.md +++ b/openspec/changes/init-ide-prompt-source-selection/proposal.md @@ -2,32 +2,43 @@ ## Why +`specfact init ide` is now downstream of two separate realities: -`specfact init ide` needs a stable prompt-source model once backlog prompts/templates move into modules. Users should get all relevant prompts by default, and they need both interactive selection and non-interactive source targeting when they only want core prompts or a subset of installed module prompts. +- official bundle-owned prompts and templates are being moved into `specfact-cli-modules` +- users can install modules into different runtime roots (`~/.specfact/modules`, `/.specfact/modules`, built-in core package roots, and optional custom roots) -## What Changes +The original proposal treated this as only a simple prompt selector. That is no longer sufficient. The remaining core-side work is to make prompt and resource export aware of installation scope, ownership, and provenance without reintroducing payload ownership into `specfact-cli`. +## What Changes -- Redesign prompt export in `specfact init ide` around prompt sources rather than assuming prompts are core-owned. -- Default to exporting all prompt sources. -- Add interactive multi-select for installed prompt sources and non-interactive `--prompts` selection with `all`, `core`, and full module ids. -- Keep exported prompt resources namespaced by source so module prompt collisions remain deterministic and readable. +- MODIFY: Narrow this change to `specfact-cli` orchestration only. Prompt/template payload migration stays in `specfact-cli-modules` change `packaging-01-bundle-resource-payloads` (`nold-ai/specfact-cli-modules#101`). +- EXTEND: Build an installation-aware prompt/resource catalog for `specfact init ide` from core built-ins plus discovered installed modules across user, project, and configured custom module roots. +- ADD: Default `specfact init ide` behavior exports all discovered prompt sources, while interactive and non-interactive selection can target `core` and specific installed module ids. +- ADD: Exported prompt files remain attributable to their owning source so collisions are deterministic and later ownership changes do not silently overwrite unrelated prompts. +- ADD: `specfact init ide` remains an anytime re-sync command. It discovers and copies installed resources; it does not download, install, or extract module archives itself. +- ADD: When selected resources are missing, the command reports the missing owner/root and points users to the relevant core install/bootstrap flows such as `specfact module init --scope ` and `specfact module install --scope `. ## Capabilities ### New Capabilities - `init-ide-prompt-selection`: `specfact init ide` can export prompts from core and selected installed modules with consistent interactive and non-interactive behavior. +- `init-ide-installed-resource-orchestration`: `specfact init ide` can discover installed prompt/resource payloads from the effective module roots and direct users to the correct install/bootstrap command when those payloads are absent. ## Acceptance Criteria -- `specfact init ide` exports all available prompt sources by default. -- Interactive mode shows a multi-select picker containing `core` plus installed module ids. +- `specfact init ide` builds its prompt-source catalog from the effective installed module roots for the current repository context, including user scope, project scope, built-in core modules, and configured custom roots. +- Default execution exports all discovered prompt sources by default rather than only the first matching root. +- Interactive mode shows a source picker containing `core` plus installed module ids that actually contribute prompt resources. - Non-interactive mode accepts `--prompts all`, `--prompts core`, and comma-separated full module ids. -- The command fails clearly when a requested module id is not installed or does not expose prompt resources. -- Exported prompt files are grouped by source so prompt provenance remains visible. +- The command does not download or install module archives. Missing sources produce actionable guidance to run the correct core install/bootstrap command for the relevant scope. +- Exported prompt files are grouped or namespaced by source so prompt provenance remains visible and collisions stay deterministic. +- The scope respects canonical command ownership from active migration changes and must not reintroduce obsolete command paths into prompt export or recommendations. ## Dependencies - `backlog-module-ownership-cleanup` must land first so backlog prompt ownership is no longer split across core and module. -- Existing `init ide` resource-copy logic in `specfact-cli` provides the base export path that this change extends. +- `packaging-02-cross-platform-runtime-and-module-resources` provides the installed-resource discovery foundation in `specfact-cli` and must stay the owner of payload discovery mechanics. +- `specfact-cli-modules/packaging-01-bundle-resource-payloads` (`nold-ai/specfact-cli-modules#101`) must provide the bundle-owned prompt/template payloads that this change selects and exports. +- `module-migration-11-project-codebase-ownership-realignment` must be treated as command-surface alignment context so exported prompts do not preserve obsolete grouped command ownership. +- Existing `specfact module init` and `specfact module install` commands in `specfact-cli` remain the install/bootstrap path for user/project module roots; `init ide` extends only the post-install discovery/export path. --- @@ -39,4 +50,4 @@ - **Issue URL**: - **Last Synced Status**: proposed - **Sanitized**: false - \ No newline at end of file + diff --git a/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md b/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md index 149c5391..e1b00dc3 100644 --- a/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md +++ b/openspec/changes/init-ide-prompt-source-selection/specs/init-ide-prompt-source-selection/spec.md @@ -4,10 +4,30 @@ `specfact init ide` SHALL export all available prompt sources by default. -#### Scenario: Default export includes core and installed modules +#### Scenario: Default export includes core and installed modules across effective roots - **WHEN** a user runs `specfact init ide` without restricting prompt sources - **THEN** prompt export includes core prompts -- **AND** prompt export includes prompts from installed and enabled modules that provide prompt resources. +- **AND** prompt export includes prompts from installed and enabled modules that provide prompt resources +- **AND** the catalog is built from the effective built-in, project, user, and configured custom module roots for that repository context. + +### Requirement: Init IDE Must Discover Sources From Installed Module Roots Only + +`specfact init ide` SHALL discover prompt and related module-owned resources from installed module roots and packaged resource directories. It SHALL not fetch module archives or treat the modules source repository as a runtime extraction source. + +#### Scenario: Installed project-scope bundle contributes prompt resources +- **WHEN** a repository has an installed module under `/.specfact/modules` +- **THEN** `specfact init ide` can discover that module's packaged prompt resources for export in that repository. + +#### Scenario: Installed user-scope bundle contributes prompt resources +- **WHEN** a user has installed a module under `~/.specfact/modules` +- **AND** no overriding project-scope copy shadows it +- **THEN** `specfact init ide` can discover that module's packaged prompt resources for export. + +#### Scenario: Missing selected source does not trigger install work +- **WHEN** a selected prompt source is not installed or does not expose the required packaged resources +- **THEN** `specfact init ide` fails or warns with actionable guidance +- **AND** the guidance names the relevant scope and install/bootstrap command such as `specfact module init --scope project` or `specfact module install --scope user` +- **AND** the command does not download, install, or extract the module itself. ### Requirement: Init IDE Must Support Interactive Prompt Source Selection @@ -30,3 +50,12 @@ Non-interactive `specfact init ide` SHALL accept a comma-separated prompt source #### Scenario: Invalid or unavailable module source is rejected - **WHEN** a user passes a prompt source token that is not `all`, not `core`, and not an installed module id with prompt resources - **THEN** the command fails with actionable guidance describing the invalid token and the available prompt sources. + +### Requirement: Exported Prompt Files Must Preserve Source Provenance + +Exported prompt files SHALL preserve module/core provenance so collisions are deterministic and later command-surface migrations do not silently overwrite unrelated prompts. + +#### Scenario: Multiple sources expose similarly named prompts +- **WHEN** `core` and one or more installed modules expose prompt files with overlapping basenames or command affinity +- **THEN** the exported IDE-facing output preserves which source owns each prompt +- **AND** the collision outcome is deterministic and visible to the user. diff --git a/openspec/changes/init-ide-prompt-source-selection/tasks.md b/openspec/changes/init-ide-prompt-source-selection/tasks.md index 78d2eb7f..741d2aa2 100644 --- a/openspec/changes/init-ide-prompt-source-selection/tasks.md +++ b/openspec/changes/init-ide-prompt-source-selection/tasks.md @@ -1,23 +1,29 @@ ## 1. Spec And Dependency Setup -- [ ] 1.1 Add spec deltas for prompt-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`. +- [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 interactive multi-select over `core` plus installed module ids. -- [ ] 2.3 Add failing tests for non-interactive `--prompts` values including `all`, `core`, mixed selections, and invalid/non-installed module ids. -- [ ] 2.4 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 Implement prompt-source discovery for core and installed/enabled modules. -- [ ] 3.2 Update `specfact init ide` interactive flow to use a source picker. -- [ ] 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. +- [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 tests and record passing evidence in `TDD_EVIDENCE.md`. -- [ ] 4.2 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/pyproject.toml b/pyproject.toml index c3909a05..3f7a2a96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.42.4" +version = "0.42.5" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 2e7cb588..278e4fa8 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.42.4", + version="0.42.5", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 274f9d1e..9f78d9d9 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.42.4" +__version__ = "0.42.5" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index e76d2fd4..065da8ad 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -42,6 +42,6 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.42.4" +__version__ = "0.42.5" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index 91fe8b97..12d76121 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -653,6 +653,23 @@ def _grouped_command_order( app.add_typer(_make_lazy_typer(_name, _meta.help), name=_name, help=_meta.help) +@beartype +@require(lambda: isinstance(app, typer.Typer), "Root CLI app must be initialized") +@ensure(lambda result: result is None, "Must return None") +def rebuild_root_app_from_registry() -> None: + """Rebuild root Typer ``app`` from the current ``CommandRegistry``. + + Call after ``register_builtin_commands()`` when tests clear and re-register the registry. + Otherwise ``get_command(app)`` still reflects lazy groups from ``cli`` import time while + ``CommandRegistry`` lists only the newly registered commands (breaks CI core-only installs). + """ + app.registered_groups = [] + if hasattr(app, "registered_commands"): + app.registered_commands = [] + for _name, _meta in _grouped_command_order(CommandRegistry.list_commands_for_help()): + app.add_typer(_make_lazy_typer(_name, _meta.help), name=_name, help=_meta.help) + + _CLI_SKIP_OUTPUT_ARGS: frozenset[str] = frozenset( ("--help", "-h", "--version", "-v", "--show-completion", "--install-completion") ) diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 8e48f163..3a9f2719 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.17 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:589ec7f24e7d0129cf61ad972e9f505c744ae14bd84db32cc241bc5013834163 + signature: 9hiFgBYZZwCTMBRDBgly07v5JejhDHWXyX3/tTkTYOjjtkFT7xjZs2Pyea7l1mt2dhUZ/Tet4bdDRXyeSNcYCg== diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 5f8c8926..0fa41ea8 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -29,11 +29,18 @@ 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, + load_ide_prompt_export_source_ids, + write_ide_prompt_export_state, ) @@ -65,8 +72,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 +322,13 @@ 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) + prompt_subset = load_ide_prompt_export_source_ids(repo_path, detected_ide) + expected_paths = expected_ide_prompt_export_paths(repo_path, detected_ide, prompt_source_ids=prompt_subset) if not ide_dir.exists(): console.print( @@ -345,9 +337,12 @@ 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, prompt_source_ids=prompt_subset) + 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 +354,78 @@ 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))} + + q = cast(Any, questionary) + choices_with_default = [q.Choice(title=lab, checked=True) for lab in labels] + selected = q.checkbox( + "Select prompt sources:", + choices=choices_with_default, + 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 +628,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 +670,28 @@ 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 + ) + write_ide_prompt_export_state(repo_path, selected_ide, sorted(selected_catalog.keys())) _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..d4d7a6aa 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -9,6 +9,7 @@ import os import re +import shutil import site import sys from pathlib import Path @@ -118,6 +119,12 @@ }, } +# Canonical id for bundled `specfact_cli` prompt templates (not a module-package name). +PROMPT_SOURCE_CORE = "core" + +# Written by ``init ide`` so ``specfact init`` audit matches selective exports (``--prompts``). +IDE_PROMPT_EXPORT_STATE_FILE = "ide-prompt-export.yaml" + # Commands available in SpecFact # Workflow-ordered commands (Phase 3) SPECFACT_COMMANDS = [ @@ -142,6 +149,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 +164,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 +181,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 +354,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: @@ -302,8 +378,81 @@ def _output_filename_for_template(template_path: Path, format_type: str) -> str: return template_path.name +def _safe_resolved_segment_dir(repo_path: Path, ide: str, segment: str) -> Path | None: + """Return ``repo_path / ide_folder / segment`` resolved, or ``None`` if it escapes the IDE export root.""" + config = IDE_CONFIG[ide] + base = (repo_path / str(config["folder"])).resolve() + segment_dir = (base / segment).resolve() + try: + segment_dir.relative_to(base) + except ValueError: + return None + return segment_dir + + +def _prune_segment_exports_not_in_expected( + repo_path: Path, + ide: str, + segment: str, + template_paths: list[Path], +) -> None: + """Remove files under ``ide_folder/segment`` that are not part of this export (same filenames as copy).""" + if not template_paths: + return + config = IDE_CONFIG[ide] + format_type = str(config["format"]) + segment_dir = _safe_resolved_segment_dir(repo_path, ide, segment) + if segment_dir is None or not segment_dir.is_dir(): + return + expected_resolved: set[Path] = { + (segment_dir / _output_filename_for_template(tp, format_type)).resolve() for tp in template_paths + } + for p in list(segment_dir.iterdir()): + if not p.is_file(): + continue + if p.resolve() not in expected_resolved: + try: + p.unlink() + console.print(f"[dim]Removed stale prompt export:[/dim] {p}") + except OSError as exc: + console.print(f"[yellow]Could not remove stale export {p}:[/yellow] {exc}") + + +def _remove_unselected_prompt_export_segments( + repo_path: Path, + ide: str, + prompts_by_source: dict[str, list[Path]], +) -> None: + """Remove on-disk segment directories under the IDE export root that are not in this selective export.""" + config = IDE_CONFIG[ide] + base = (repo_path / str(config["folder"])).resolve() + selected_segments = {source_id_to_path_segment(sid) for sid in prompts_by_source} + if not base.is_dir(): + return + for child in list(base.iterdir()): + if not child.is_dir(): + continue + try: + child.resolve().relative_to(base) + except ValueError: + continue + if child.name in selected_segments: + continue + try: + shutil.rmtree(child) + console.print(f"[dim]Removed unselected export segment:[/dim] {child}") + except OSError as exc: + console.print(f"[yellow]Could not remove segment {child}:[/yellow] {exc}") + + 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 +463,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 +483,130 @@ 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") +@require( + lambda prompt_source_ids: ( + prompt_source_ids is None + or (isinstance(prompt_source_ids, frozenset) and all(isinstance(x, str) for x in prompt_source_ids)) + ), + "prompt_source_ids must be None or frozenset[str]", +) +@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, + *, + prompt_source_ids: frozenset[str] | None = None, +) -> list[Path]: + """Return expected on-disk paths for exported IDE prompts (source-namespaced layout). + + If ``prompt_source_ids`` is set (from ``.specfact/ide-prompt-export.yaml``), only those sources are + expected—matching a selective ``init ide --prompts`` run. Otherwise the full discovered catalog is used. + """ + config = IDE_CONFIG[ide] + format_type = str(config["format"]) + base = repo_path / str(config["folder"]) + catalog = discover_prompt_sources_catalog(repo_path) + if prompt_source_ids is not None: + catalog = {k: v for k, v in catalog.items() if k in prompt_source_ids} + 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") +@require( + lambda prompt_source_ids: ( + prompt_source_ids is None + or (isinstance(prompt_source_ids, frozenset) and all(isinstance(x, str) for x in prompt_source_ids)) + ), + "prompt_source_ids must be None or frozenset[str]", +) +@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, + *, + prompt_source_ids: frozenset[str] | None = None, +) -> 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) + if prompt_source_ids is not None: + catalog = {k: v for k, v in catalog.items() if k in prompt_source_ids} + 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] = [] + _remove_unselected_prompt_export_segments(repo_path, ide, prompts_by_source) + 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) + _prune_segment_exports_not_in_expected(repo_path, ide, segment, paths) + 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 +763,129 @@ 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 _is_specfact_github_prompt_path(path: str) -> bool: + """True for SpecFact-managed GitHub prompt recommendations (strip on selective export); keeps team paths.""" + normalized = path.replace("\\", "/").lstrip("./") + if not normalized.startswith("github/prompts/"): + return False + name = Path(normalized).name + return name.startswith("specfact") and name.endswith(".prompt.md") + + +def _strip_specfact_github_prompt_recommendations(paths: list[str]) -> list[str]: + """Remove prior SpecFact-managed ``.github/prompts/`` entries before merging a selective export; keep other paths.""" + return [p for p in paths if not _is_specfact_github_prompt_path(p)] + + @beartype @require(repo_path_exists, "Repo path must exist") @require(repo_path_is_dir, "Repo path must be a directory") +@require(lambda ide: isinstance(ide, str) and len(ide) > 0, "ide must be non-empty") +@require(lambda source_ids: isinstance(source_ids, list) and all(isinstance(s, str) for s in source_ids), "bad sources") +@ensure(lambda result: result is None, "Must return None") +def write_ide_prompt_export_state(repo_path: Path, ide: str, source_ids: list[str]) -> None: + """Persist last ``init ide`` source selection for audit/outdated checks on ``specfact init``.""" + specfact_dir = repo_path / ".specfact" + specfact_dir.mkdir(parents=True, exist_ok=True) + payload = { + "version": 1, + "ide": ide, + "prompt_sources": sorted(source_ids), + } + out = specfact_dir / IDE_PROMPT_EXPORT_STATE_FILE + out.write_text(yaml.safe_dump(payload, sort_keys=False, allow_unicode=False), encoding="utf-8") + + +@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: result is None or (isinstance(result, frozenset) and all(isinstance(x, str) for x in result)), + "Must return frozenset of str or None", +) +def load_ide_prompt_export_source_ids(repo_path: Path, ide: str) -> frozenset[str] | None: + """Return source ids from last ``init ide`` export for this IDE, or ``None`` if unset or IDE mismatches.""" + path = repo_path / ".specfact" / IDE_PROMPT_EXPORT_STATE_FILE + if not path.is_file(): + return None + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + return None + raw_dict: dict[str, Any] = cast(dict[str, Any], raw) + stored_ide = raw_dict.get("ide") + if stored_ide is None or str(stored_ide).strip() != ide: + return None + srcs = raw_dict.get("prompt_sources") + if not isinstance(srcs, list) or not srcs: + return None + out = frozenset(str(s).strip() for s in srcs if str(s).strip()) + return out if out else None + except (OSError, yaml.YAMLError, TypeError, ValueError): + return None + + +@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 **SpecFact-managed** ``.github/prompts/`` entries (paths whose + filename looks like ``specfact*.prompt.md``) are removed so selective ``--prompts`` runs do not + leave stale exports; other ``.github/prompts/`` entries and paths outside that folder are preserved. + 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 +901,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 +927,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..11d47d5a 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") @@ -174,43 +174,12 @@ def test_init_overwrites_with_force(self, tmp_path): assert "New content" in content or "Analyze" in content def test_init_handles_missing_templates(self, tmp_path, monkeypatch): - """Test init command handles missing templates directory gracefully.""" - # Mock importlib.util.find_spec to return None to simulate missing package - import importlib.util - - original_find_spec = importlib.util.find_spec - - def mock_find_spec(name): - if name == "specfact_cli": - return None # Simulate package not installed - return original_find_spec(name) - - monkeypatch.setattr(importlib.util, "find_spec", mock_find_spec) - - # Mock get_package_installation_locations to return empty list to avoid slow search - def mock_get_locations(package_name: str) -> list: - return [] # Return empty to simulate no package found - - monkeypatch.setattr( - "specfact_cli.utils.ide_setup.get_package_installation_locations", - mock_get_locations, - ) - - # Mock find_package_resources_path to return None to avoid slow search - def mock_find_resources(package_name: str, resource_subpath: str): - return None # Return None to simulate no resources found - + """Empty prompt catalog yields deterministic ``init ide`` failure messages.""" monkeypatch.setattr( - "specfact_cli.utils.ide_setup.find_package_resources_path", - mock_find_resources, - ) - # Also mock in the init command module where it's imported - monkeypatch.setattr( - "specfact_cli.modules.init.src.commands.find_package_resources_path", - mock_find_resources, + "specfact_cli.modules.init.src.commands.discover_prompt_sources_catalog", + lambda _repo_path, include_package_fallback=True: {}, ) - # Don't create templates directory old_cwd = os.getcwd() try: os.chdir(tmp_path) @@ -218,14 +187,10 @@ def mock_find_resources(package_name: str, resource_subpath: str): finally: os.chdir(old_cwd) - # May find templates from installed package or fail - both are valid - # 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 - else: - # If it succeeds, templates were found from installed package - assert result.exit_code == 0 + assert result.exit_code == 1 + out = result.stdout + assert "No prompt templates found" in out, out + assert "Seed or install modules first" in out, out def test_init_all_supported_ides(self, tmp_path): """Test init command works with all supported IDE types.""" @@ -281,8 +246,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 +279,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/integration/test_command_package_runtime_validation.py b/tests/integration/test_command_package_runtime_validation.py index 805b3830..deaf95c7 100644 --- a/tests/integration/test_command_package_runtime_validation.py +++ b/tests/integration/test_command_package_runtime_validation.py @@ -224,6 +224,22 @@ def _load_cli_app_for_home(home_dir: Path) -> typer.Typer: return cli_module.app +def _restore_specfact_module_root_paths_after_temp_home() -> None: + """Undo globals mutated by `_load_cli_app_for_home` so other tests see the real user home.""" + home = Path.home() + import specfact_cli.registry.bootstrap as bootstrap_module + import specfact_cli.registry.module_discovery as module_discovery + import specfact_cli.registry.module_installer as module_installer + + module_discovery.USER_MODULES_ROOT = home / ".specfact" / "modules" + module_discovery.MARKETPLACE_MODULES_ROOT = home / ".specfact" / "marketplace-modules" + module_discovery.CUSTOM_MODULES_ROOT = home / ".specfact" / "custom-modules" + module_installer.USER_MODULES_ROOT = module_discovery.USER_MODULES_ROOT + module_installer.MARKETPLACE_MODULES_ROOT = module_discovery.MARKETPLACE_MODULES_ROOT + module_installer.MODULE_DOWNLOAD_CACHE_ROOT = home / ".specfact" / "downloads" / "cache" + bootstrap_module._SPECFACT_CONFIG_PATH = home / ".specfact" / "config.yaml" + + def _run_help_case( app: typer.Typer, case: CommandAuditCase, @@ -262,25 +278,28 @@ def test_command_audit_help_cases_execute_cleanly_in_temp_home(tmp_path: Path, m env = _subprocess_env(home_dir) _seed_marketplace_modules(home_dir) - with monkeypatch.context() as context: - context.setenv("HOME", str(home_dir)) - context.setenv("SPECFACT_MODULES_REPO", str(MODULES_REPO.resolve())) - help_app = _load_cli_app_for_home(home_dir) - - failures: list[str] = [] - for case in build_command_audit_cases(): - if case.mode == "help-only": - return_code, merged_output = _run_help_case(help_app, case, home_dir, env, monkeypatch) - else: - result = _run_cli(env, *case.argv, cwd=home_dir) - return_code = result.returncode - merged_output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip() - if return_code != 0: - failures.append(f"{case.command_path}: rc={return_code}\nOUTPUT:\n{merged_output}") - continue - leaked = [marker for marker in FORBIDDEN_OUTPUT if marker in merged_output] - if leaked: - failures.append(f"{case.command_path}: leaked diagnostics {leaked}\nOUTPUT:\n{merged_output}") + failures: list[str] = [] + try: + with monkeypatch.context() as context: + context.setenv("HOME", str(home_dir)) + context.setenv("SPECFACT_MODULES_REPO", str(MODULES_REPO.resolve())) + help_app = _load_cli_app_for_home(home_dir) + + for case in build_command_audit_cases(): + if case.mode == "help-only": + return_code, merged_output = _run_help_case(help_app, case, home_dir, env, monkeypatch) + else: + result = _run_cli(env, *case.argv, cwd=home_dir) + return_code = result.returncode + merged_output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip() + if return_code != 0: + failures.append(f"{case.command_path}: rc={return_code}\nOUTPUT:\n{merged_output}") + continue + leaked = [marker for marker in FORBIDDEN_OUTPUT if marker in merged_output] + if leaked: + failures.append(f"{case.command_path}: leaked diagnostics {leaked}\nOUTPUT:\n{merged_output}") + finally: + _restore_specfact_module_root_paths_after_temp_home() assert not failures, "\n\n".join(failures) 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..4c4ff4fe --- /dev/null +++ b/tests/unit/modules/init/test_init_ide_prompt_selection.py @@ -0,0 +1,135 @@ +"""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_copy_prompts_by_source_to_ide_prunes_stale_in_selected_segment(tmp_path: Path) -> None: + """Re-exporting a subset of core templates removes outputs that are no longer expected.""" + 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") + f2 = prompts / "specfact.02-plan.md" + f2.write_text("---\ndescription: B\n---\n# B\n", encoding="utf-8") + + core_dir = tmp_path / ".cursor" / "commands" / "core" + copy_prompts_by_source_to_ide(tmp_path, "cursor", {PROMPT_SOURCE_CORE: [f1, f2]}, force=True) + assert (core_dir / "specfact.01-import.md").is_file() + assert (core_dir / "specfact.02-plan.md").is_file() + + copy_prompts_by_source_to_ide(tmp_path, "cursor", {PROMPT_SOURCE_CORE: [f1]}, force=True) + assert (core_dir / "specfact.01-import.md").is_file() + assert not (core_dir / "specfact.02-plan.md").exists() + + +def test_copy_prompts_by_source_to_ide_removes_unselected_catalog_segment(tmp_path: Path) -> None: + """Selective export removes IDE segment dirs for catalog sources not in this run.""" + 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") + + package_dir = tmp_path / ".specfact" / "modules" / "specfact-backlog" + prompt_dir = package_dir / "resources" / "prompts" + prompt_dir.mkdir(parents=True) + (package_dir / "module-package.yaml").write_text( + "name: nold-ai/specfact-backlog\nversion: '0.1.0'\ncommands: [backlog]\ncategory: backlog\n" + "bundle_group_command: backlog\n", + encoding="utf-8", + ) + f2 = prompt_dir / "specfact.backlog-add.md" + f2.write_text("---\ndescription: B\n---\n# B\n", encoding="utf-8") + + mod_seg = tmp_path / ".cursor" / "commands" / "nold-ai__specfact-backlog" + copy_prompts_by_source_to_ide( + tmp_path, + "cursor", + {PROMPT_SOURCE_CORE: [f1], "nold-ai/specfact-backlog": [f2]}, + force=True, + ) + assert (mod_seg / "specfact.backlog-add.md").is_file() + + copy_prompts_by_source_to_ide(tmp_path, "cursor", {PROMPT_SOURCE_CORE: [f1]}, force=True) + assert not mod_seg.exists() + assert (tmp_path / ".cursor" / "commands" / "core" / "specfact.01-import.md").is_file() + + +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 + out = result.stdout.lower() + assert "not available" in out + assert "nold-ai/not-installed" 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/registry/test_category_groups.py b/tests/unit/registry/test_category_groups.py index 9cdaf064..838b17dc 100644 --- a/tests/unit/registry/test_category_groups.py +++ b/tests/unit/registry/test_category_groups.py @@ -9,6 +9,7 @@ import pytest +from specfact_cli.cli import rebuild_root_app_from_registry from specfact_cli.registry import CommandRegistry from specfact_cli.registry.bootstrap import register_builtin_commands @@ -24,6 +25,7 @@ def test_bootstrap_with_category_grouping_enabled_registers_group_commands() -> """With category grouping enabled, root commands are limited to core + category groups (no flat shims).""" with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): register_builtin_commands() + rebuild_root_app_from_registry() names = [name for name, _ in CommandRegistry.list_commands_for_help()] allowed = {"init", "auth", "module", "upgrade", "code", "backlog", "project", "spec", "govern"} forbidden_flat = { @@ -53,6 +55,7 @@ def test_bootstrap_with_category_grouping_disabled_registers_flat_commands() -> """With category grouping disabled, grouped aliases are not mounted via category grouping.""" with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "false"}, clear=False): register_builtin_commands() + rebuild_root_app_from_registry() names = [name for name, _ in CommandRegistry.list_commands_for_help()] # Skip assertions if bundles aren't installed (e.g., in CI without modules) if "code" not in names: @@ -69,6 +72,7 @@ def test_code_analyze_routes_same_as_flat_analyze( """`code` group mounts only when codebase module is installed.""" with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): register_builtin_commands() + rebuild_root_app_from_registry() from typer.main import get_command from specfact_cli.cli import app @@ -91,6 +95,7 @@ def test_govern_help_when_not_installed_suggests_install( """specfact govern --help when govern bundle not installed produces install suggestion.""" with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): register_builtin_commands() + rebuild_root_app_from_registry() from click.testing import CliRunner from typer.main import get_command @@ -114,6 +119,7 @@ def test_flat_validate_is_not_found_in_copilot_mode( clear=False, ): register_builtin_commands() + rebuild_root_app_from_registry() from click.testing import CliRunner from typer.main import get_command @@ -134,6 +140,7 @@ def test_flat_validate_is_not_found_in_cicd_mode(tmp_path: Path) -> None: clear=False, ): register_builtin_commands() + rebuild_root_app_from_registry() from click.testing import CliRunner from typer.main import get_command @@ -150,6 +157,7 @@ def test_spec_api_validate_routes_correctly(tmp_path: Path) -> None: """The installed spec bundle exposes its native `spec validate` root path.""" with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): register_builtin_commands() + rebuild_root_app_from_registry() from click.testing import CliRunner from typer.main import get_command diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index acc65769..c6a7402e 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -2,17 +2,24 @@ from __future__ import annotations +import json from pathlib import Path import pytest from specfact_cli.utils.ide_setup import ( + PROMPT_SOURCE_CORE, SPECFACT_COMMANDS, + _is_specfact_github_prompt_path, copy_templates_to_ide, + create_vscode_settings, detect_ide, discover_prompt_template_files, + expected_ide_prompt_export_paths, + load_ide_prompt_export_source_ids, process_template, read_template, + write_ide_prompt_export_state, ) @@ -269,7 +276,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 +298,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,9 +310,90 @@ def test_discover_prompt_template_files_deduplicates_prompt_ids_by_filename( assert discovered == [first_prompt] +def test_is_specfact_github_prompt_path_only_specfact_named_prompts() -> None: + """Strip targets only ``specfact*.prompt.md`` under ``.github/prompts/`` (after path normalization).""" + assert _is_specfact_github_prompt_path(".github/prompts/core/specfact.01-import.prompt.md") + assert _is_specfact_github_prompt_path(".\\github\\prompts\\nold-ai__x\\specfact.extra.prompt.md") + assert not _is_specfact_github_prompt_path(".github/prompts/custom/team-owned.prompt.md") + assert not _is_specfact_github_prompt_path(".other/specfact.01-import.prompt.md") + assert not _is_specfact_github_prompt_path(".github/prompts/notes/readme.md") + + +def test_create_vscode_settings_selective_export_replaces_stale_github_prompt_paths(tmp_path: Path) -> None: + """When ``prompts_by_source`` is set, drop SpecFact-managed ``.github/prompts/`` entries; keep team 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", + ".github/prompts/custom/team-owned.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 ".github/prompts/custom/team-owned.prompt.md" 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 assert "specfact.backlog-daily" not in SPECFACT_COMMANDS assert "specfact.backlog-refine" not in SPECFACT_COMMANDS assert "specfact.sync-backlog" not in SPECFACT_COMMANDS + + +def test_write_and_load_ide_prompt_export_state_roundtrip(tmp_path: Path) -> None: + """Persisted source ids round-trip for init audit when IDE matches.""" + write_ide_prompt_export_state(tmp_path, "cursor", ["core", "nold-ai/specfact-backlog"]) + loaded = load_ide_prompt_export_source_ids(tmp_path, "cursor") + assert loaded == frozenset({"core", "nold-ai/specfact-backlog"}) + + +def test_load_ide_prompt_export_source_ids_mismatched_ide_returns_none(tmp_path: Path) -> None: + """State written for one IDE must not apply to audit for another IDE.""" + write_ide_prompt_export_state(tmp_path, "cursor", ["core"]) + assert load_ide_prompt_export_source_ids(tmp_path, "cursor") == frozenset({"core"}) + assert load_ide_prompt_export_source_ids(tmp_path, "vscode") is None + + +def test_expected_ide_prompt_export_paths_respects_prompt_source_subset( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Audit expectations follow last export subset, not the full discovered catalog.""" + p_core = tmp_path / "c.md" + p_mod = tmp_path / "m.md" + monkeypatch.setattr( + "specfact_cli.utils.ide_setup.discover_prompt_sources_catalog", + lambda _rp, include_package_fallback=True: { + PROMPT_SOURCE_CORE: [p_core], + "nold-ai/mod": [p_mod], + }, + ) + full_paths = expected_ide_prompt_export_paths(tmp_path, "cursor") + subset_paths = expected_ide_prompt_export_paths( + tmp_path, "cursor", prompt_source_ids=frozenset({PROMPT_SOURCE_CORE}) + ) + assert len(full_paths) == 2 + assert len(subset_paths) == 1 + assert "core" in subset_paths[0].parts