diff --git a/.github/vstack.json b/.github/vstack.json index a3b683e..25339d6 100644 --- a/.github/vstack.json +++ b/.github/vstack.json @@ -1,193 +1,267 @@ { - "vstack_version": "1.3.0", - "installed_at": "2026-04-21T22:51:50.907279+00:00", + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.3.6", + "installed_at": "2026-04-25T23:30:58.320097+00:00", "artifacts": { "skills": [ { "name": "adr", "file": "skills/adr/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "e28cb928c5a73e740cd6c58bb3b261606a11d4cda9a33abcc5109c208cf98d0e", + "checksum_algorithm": "sha256" }, { "name": "analyse", "file": "skills/analyse/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "c55ffd1bf0601d703e07420e914172008ef5a07b4d76eccd6f070fc8c17785a2", + "checksum_algorithm": "sha256" }, { "name": "architecture", "file": "skills/architecture/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "db0c632cce9fa73fc52cd7e7f6ee78f6184220c62af0089083e8d4fcbce58740", + "checksum_algorithm": "sha256" }, { "name": "cicd", "file": "skills/cicd/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "a1860a0d3ca9dc1d1c14b90fce3887f51c536204df58c1fbcc5c972cea13a70a", + "checksum_algorithm": "sha256" }, { "name": "code-review", "file": "skills/code-review/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "23264675e7f1706d607f39918f4e524ce74d536e5ccd9ccf6b09f0af4f14f305", + "checksum_algorithm": "sha256" }, { "name": "concise", "file": "skills/concise/SKILL.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "03a342b8a07ec99da845f6213b8f2573b2cb94d3546366ef45a3c376fa56244c", + "checksum_algorithm": "sha256" }, { "name": "consult", "file": "skills/consult/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "a48d3d3df3acad63b9194a5941c37107e6303993898cf7188963a86f699e74d6", + "checksum_algorithm": "sha256" }, { "name": "container", "file": "skills/container/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "40ed0e2a7bdfc5872bfe59e9fd052e9530bebd2f750c676d043781c3e95f6397", + "checksum_algorithm": "sha256" }, { "name": "debug", "file": "skills/debug/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "88e8867e722f8024afb919f03dab48a91f3fbaf941b2b9c0878124d17c1887f1", + "checksum_algorithm": "sha256" }, { "name": "dependency", "file": "skills/dependency/SKILL.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "3247b971c399f065178d771d8a0cd0217f317d8223390d4b084385b07167554b", + "checksum_algorithm": "sha256" }, { "name": "design", "file": "skills/design/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "f1ce0992a415831a96aa0a60bae07fcf95bde0fb97fdaf42171860188b765be8", + "checksum_algorithm": "sha256" }, { "name": "docs", "file": "skills/docs/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "613175ca5d5085496275714c4ef1bbe5304f5a2da7471a147d4f1a0a01fd251d", + "checksum_algorithm": "sha256" }, { "name": "explore", "file": "skills/explore/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "a7616442f81eee433d495fcfd8d7ef6e449e6bd8d3970c2fd1b26cc079f888b6", + "checksum_algorithm": "sha256" }, { "name": "guardrails", "file": "skills/guardrails/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "eddc61632d5557d89c4287c8ce6012ee50061986f1c9073612838a4c0c1d0d24", + "checksum_algorithm": "sha256" }, { "name": "incident", "file": "skills/incident/SKILL.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "dca316d214b8bfe564b590d98f5f45cea520342741834cf35af904be34517db3", + "checksum_algorithm": "sha256" }, { "name": "inspect", "file": "skills/inspect/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "e8411fe7cc35625b842e3e3671b67fc3c0f63f7d57773e574a7c595d45247730", + "checksum_algorithm": "sha256" }, { "name": "migrate", "file": "skills/migrate/SKILL.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "f404b74f152dee1b9d03e2ea24e5e2e634269747444ca59c95e0c11103dccdbd", + "checksum_algorithm": "sha256" }, { "name": "onboard", "file": "skills/onboard/SKILL.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "b9c91b03063bc06f80f08215406ef9fb434e8ab7b355498da19fcd9960d986d7", + "checksum_algorithm": "sha256" }, { "name": "openapi", "file": "skills/openapi/SKILL.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "81b9ce9dec89be2b833367913533aa9986426ea6a7be060de4934f9e4ea6ee80", + "checksum_algorithm": "sha256" }, { "name": "performance", "file": "skills/performance/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "ee9d440e9957ff6ed95ebe2551f509084e25543e8a3d844c42c607fcdf8beb48", + "checksum_algorithm": "sha256" }, { "name": "pr", "file": "skills/pr/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "e869d692a12e0ce1a42a67795fdab2984fa6c03b83511f41f838043bd8a8a9b9", + "checksum_algorithm": "sha256" }, { "name": "refactor", "file": "skills/refactor/SKILL.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "cc07080525bac8a78c226b5ee03c40c3991c440405de7710abf390799c93ea64", + "checksum_algorithm": "sha256" }, { "name": "release-notes", "file": "skills/release-notes/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "4238f3babc26a282c7d1c53829f3fad7620b912e8ced319249bcf86dcfc5a1c9", + "checksum_algorithm": "sha256" }, { "name": "requirements", "file": "skills/requirements/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "3ad75d993742791bf1914202c5fb58ab8657af7711723e11978e2eb7746a0b72", + "checksum_algorithm": "sha256" }, { "name": "security", "file": "skills/security/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "63aca73f52bbf8384f79f99a403e1aa91fe4bb4b337d776c17d26c92e3945cc1", + "checksum_algorithm": "sha256" }, { "name": "verify", "file": "skills/verify/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "be0e4fed0240219fcffbc91fe84ff0f4e6585a5d522aa86377492c66933c4d7b", + "checksum_algorithm": "sha256" }, { "name": "vision", "file": "skills/vision/SKILL.md", - "version": "1.0.2" + "version": "1.0.2", + "checksum": "c22f27f14839a561b56d61c75515b2636b7d9842052065ca6b9c7b06bde797bb", + "checksum_algorithm": "sha256" } ], "agents": [ { "name": "architect", "file": "agents/architect.agent.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "85b1c6fbfe890aa2bc2f3bfde80c5e98db2a4380a9e1ea6b65c57051807de781", + "checksum_algorithm": "sha256" }, { "name": "designer", "file": "agents/designer.agent.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "e3f86d1811d2d668df9fb06cb0cbb61d56f5a1e669affbf53df898120ad02559", + "checksum_algorithm": "sha256" }, { "name": "engineer", "file": "agents/engineer.agent.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "6c5f60d6aa844c9bf6c61feae464e25dc1ba8decc5d69915f21ab0eb24553e1f", + "checksum_algorithm": "sha256" }, { "name": "product", "file": "agents/product.agent.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "8a25f81f2b8f2fbebf6a2dc5460b4ed67baace8ae56efae21870eba33e664732", + "checksum_algorithm": "sha256" }, { "name": "release", "file": "agents/release.agent.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "fbe8b50739ebc364cae4af55575ab08cb327f1ccef66c16ae7b8b3676bd606f2", + "checksum_algorithm": "sha256" }, { "name": "tester", "file": "agents/tester.agent.md", - "version": "1.0.1" + "version": "1.0.1", + "checksum": "68892b8b9f24ab509f567ccc47c8b8a1a4ff30c21ab5c73d074d509c538e5ca6", + "checksum_algorithm": "sha256" } ], "instructions": [ { "name": "git", "file": "instructions/git.instructions.md", - "version": "0.1.0" + "version": "0.1.0", + "checksum": "6bc1c23d9e7eb12f37a11e83e57bd40ccf1e5cc02c51c1e764809f813193eed8", + "checksum_algorithm": "sha256" }, { "name": "python", "file": "instructions/python.instructions.md", - "version": "0.1.1" + "version": "0.1.1", + "checksum": "d01fc76c80f736738e0eb0e38224b6e0e6bdb579dc9bafd74482573d3b39d913", + "checksum_algorithm": "sha256" } ], "prompts": [ { "name": "code-review", "file": "prompts/code-review.prompt.md", - "version": "0.1.0" + "version": "0.1.0", + "checksum": "36e267c1aaf6dbac0774fa9056291454fd66fbf11b5bf84e2def168f42227538", + "checksum_algorithm": "sha256" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index f410ea6..6a6a6fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## 2.0.0 - 2026-04-26 + +CLI architecture refactor and manifest package extraction. **BREAKING CHANGE** β€” import paths have changed. + +### Breaking changes in 2.0.0 + +- `vstack.cli.commands` removed. `CommandService` and command classes now live in dedicated modules (`vstack.cli.service`, `vstack.cli.install`, `vstack.cli.verify`, etc.). +- Manifest persistence classes moved to new `vstack.manifest` package (`vstack.manifest.store`). + +### Added in 2.0.0 + +- `vstack manifest upgrade --backfill`: retroactively compute and write checksums for tracked manifest entries with no checksum. +- New `vstack.manifest` package with schema-versioned manifest read/write and `content_hash` utility. +- 12 focused CLI modules replacing the monolithic `commands.py`. +- mypy type checking added as a quality gate (106 files, 0 errors). +- 4 new ADRs: manifest schema versioning (014), conservative install defaults (015), atomic manifest writes (016), checksum backfill (017). + +### Fixed in 2.0.0 + +- `InstallCommand._version_gt` no longer raises `TypeError` when `existing` version is `None` on first install. + +### Internal in 2.0.0 + +- Full test suite restructured: per-module test files, `TestClass` layout, catch-all files deleted. Test count: 288 β†’ 342. +- End-to-end integration tests consolidated into `tests/vstack/test_integration.py`. + ## 1.3.6 - 2026-04-22 README and PyPI README badge/layout alignment. diff --git a/README-pypi.md b/README-pypi.md index 88dcac7..9f665ae 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -62,7 +62,9 @@ Expected result: vstack --version vstack validate vstack install --target /path/to/your/project -vstack verify --target /path/to/your/project +vstack manifest verify --target /path/to/your/project +vstack manifest status --target /path/to/your/project +vstack manifest upgrade --target /path/to/your/project ``` ## Common usage patterns @@ -79,6 +81,8 @@ Profile-wide install (optional defaults for all projects): vstack install --global ``` +By default, `vstack install` preserves existing unmanaged files and local edits to tracked files by comparing the current file contents with the SHA-256 checksum recorded in `vstack.json`. Use `--adopt-name ` to start tracking one existing unmanaged file without overwriting it. `vstack uninstall` also preserves locally modified tracked files unless you explicitly pass `--force` or `--force-name `. Use `vstack manifest status --target ...` (or `vstack status --target ...`) to see what still matches the manifest. If a legacy manifest schema is detected, run `vstack manifest upgrade --target ...` first. + ## Fast troubleshooting - Command not found after install: ensure your `pipx` binary path is in `PATH` diff --git a/README.md b/README.md index 6e8f0ba..34a4dc0 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ What gets built is determined by the product vision. vstack fixes the delivery roles and boundaries: `product`, `architect`, `designer`, `engineer`, `tester`, and `release`. -vstack started as a rethink inspired by [gstack](https://github.com/observiq/gstack), +vstack started as a rethink inspired by [gstack](https://github.com/garrytan/gstack), but was rebuilt around a template-driven, VS Code-first workflow model. ______________________________________________________________________ @@ -382,7 +382,10 @@ You can also manually remove any leftover `.github/agents`, `.github/skills`, et ```bash vstack --version # Show vstack version -vstack validate # Validate current vstack install +vstack validate # Validate source templates only +vstack manifest status --target . # Check installed files against manifest checksums +vstack manifest verify --target . # Verify installed output against manifest ownership/checksums +vstack manifest upgrade --target . # Upgrade legacy vstack.json schema vstack install --target . # Install vstack artifacts into current project vstack install --global # Install vstack artifacts globally vstack uninstall --target . # Uninstall vstack artifacts from current project @@ -393,19 +396,31 @@ ______________________________________________________________________ ## πŸ“– All vstack CLI commands -| Command | Description | -| ------------------------------- | ---------------------------------------------------- | -| `vstack --version` | Show vstack version | -| `vstack validate` | Validate vstack install and check for issues | -| `vstack verify` | Verify source templates and/or installed output | -| `vstack verify --target DIR` | Verify installed artifacts in DIR/.github | -| `vstack verify --global` | Verify artifacts in your VS Code global profile | -| `vstack install --target DIR` | Install vstack artifacts into a project | -| `vstack install --global` | Install vstack artifacts into your VS Code profile | -| `vstack install --dry-run` | Preview install actions without writing files | -| `vstack uninstall --target DIR` | Uninstall vstack artifacts from a project | -| `vstack uninstall --global` | Uninstall vstack artifacts from your VS Code profile | -| `vstack uninstall` | Uninstall from the current directory default target | +| Command | Description | +| -------------------------------------- | ---------------------------------------------------------------------------------------- | +| `vstack --version` | Show vstack version | +| `vstack validate` | Validate source templates only | +| `vstack verify` | Verify source templates and installed output, including checksum drift | +| `vstack status --target DIR` | Report which installed artifacts still match `vstack.json` | +| `vstack manifest status --target DIR` | Manifest-scoped status for installed output and ownership | +| `vstack manifest verify --target DIR` | Manifest-scoped verify for installed output only | +| `vstack manifest upgrade --target DIR` | Upgrade a legacy `vstack.json` schema to current format | +| `vstack verify --target DIR` | Verify installed artifacts in DIR/.github | +| `vstack verify --global` | Verify artifacts in your VS Code global profile | +| `vstack install --target DIR` | Install vstack artifacts into a project, preserving existing unmanaged files | +| `vstack install --global` | Install vstack artifacts into your VS Code profile, preserving local edits unless forced | +| `vstack install --dry-run` | Preview install actions without writing files | +| `vstack uninstall --target DIR` | Uninstall tracked artifacts that still match the manifest | +| `vstack uninstall --global` | Uninstall vstack artifacts from your VS Code profile | +| `vstack uninstall` | Uninstall from the current directory default target | + +By default, `vstack install` is conservative: if a target file already exists but is not tracked by `vstack`, it is left in place. For tracked files, `--update` only rewrites artifacts whose on-disk content still matches the SHA-256 checksum of the last installed version recorded in `vstack.json`. Use `--force` to overwrite everything, `--force-name ` to overwrite one specific managed artifact, or `--adopt-name ` to start tracking one existing unmanaged file without overwriting it. + +`vstack uninstall` is conservative as well: it removes only tracked artifacts whose current checksum still matches the manifest. If a tracked file was edited locally, it is preserved unless you explicitly pass `--force` or `--force-name`. Use `vstack manifest status` (or `vstack status`) for a read-only overview of managed, modified, missing, and conflicting files. + +When a legacy manifest schema is detected, verification/status/install paths now fail fast with an upgrade hint. Run `vstack manifest upgrade --target ...` once, then retry your normal commands. + +For smaller terminals, `vstack manifest status` (and `vstack status`) defaults to a compact issues-focused text view with color markers. For tooling or exports, use `--format json` or `--format yaml`. Add `--verbose` to include managed entries, and `--no-color` when plain text is preferred. ______________________________________________________________________ diff --git a/docs/architecture/adr/012-flat-templates-install-time-generation.md b/docs/architecture/adr/012-flat-templates-install-time-generation.md index a6660a6..86ba3f8 100644 --- a/docs/architecture/adr/012-flat-templates-install-time-generation.md +++ b/docs/architecture/adr/012-flat-templates-install-time-generation.md @@ -16,8 +16,8 @@ This created three problems: 1. **Agents must stay role-based.** Agent files represent fixed roles, not skills. -1. **Install-time generation must be deterministic.** Regeneration should always overwrite - generated output from current templates. +1. **Install-time generation must be deterministic.** Fresh generation from templates must be repeatable, + while install/update in consumer repos must preserve unmanaged files and locally modified tracked files unless forced. ## decision @@ -50,6 +50,20 @@ installer generates artifacts into `.github/`, including: - `.github/prompts/*.prompt.md` - `.github/instructions/*.instructions.md` +The installer is conservative by default: + +- existing files that are not tracked in `vstack.json` are preserved +- tracked files are updated only when their content still matches the last installed SHA-256 checksum +- `--adopt-name ` allows explicitly adopting one unmanaged file into tracking without overwriting it +- `--force-name ` can override preservation for one artifact +- `--force` is the explicit opt-in for overwriting local changes + +The same manifest checksum is also used for safe uninstall and status reporting: + +- `vstack uninstall` preserves tracked files with local drift unless forced +- `vstack verify` fails when installed output no longer matches the manifest checksum +- `vstack status` reports which files still match the manifest and which no longer do + ### 4. role-first agents (generated from templates) Agent instructions are authored in `src/vstack/_templates/agents/` and generated as diff --git a/docs/architecture/adr/014-manifest-schema-versioning.md b/docs/architecture/adr/014-manifest-schema-versioning.md new file mode 100644 index 0000000..3b37b97 --- /dev/null +++ b/docs/architecture/adr/014-manifest-schema-versioning.md @@ -0,0 +1,96 @@ +# ADR-014: Manifest Schema Versioning and Explicit Upgrade Gate + +> Maintained by: **architect** role + +**date:** 2026-04-26\ +**status:** accepted + +## context + +`vstack.json` is a per-project manifest that grows over time as vstack adds +tracking capabilities. Early versions of the manifest stored artifacts with a flat +`content_hash` field and no explicit algorithm. Later versions introduced `checksum`, +`checksum_algorithm`, and `manifest_version` fields. + +Without explicit schema versioning, running a newer version of the vstack CLI +against an older manifest could silently produce incorrect behaviour: + +- Checksum comparisons might use the wrong algorithm. +- A missing `checksum_algorithm` field could be misinterpreted. +- `verify` and `status` could report false positives or false negatives. + +The question was: should the CLI auto-upgrade manifests transparently, require +an explicit upgrade step, or simply accept any schema version at any time? + +## decision + +The manifest carries a `manifest_version` integer field (current value: `2`). + +Operations that require current-schema semantics (`install`, `verify`, `status`, +`uninstall`) **fail fast** when a legacy schema is detected and print a diagnostic +pointing to `vstack manifest upgrade`. The upgrade is never automatic. + +`vstack manifest upgrade --target DIR` explicitly migrates the manifest to the +current schema and is the only path that changes the version field. + +## alternatives considered + +### Option A: Transparent auto-upgrade on every read + +**Pros:** Zero user friction; manifests are always current. + +**Cons:** Silently mutates on-disk state during read-only operations (`status`, +`verify`). Surprises users who inspect the file. Creates hidden side effects that +are difficult to audit or test. Breaks the principle of least surprise for a +read-only command. + +**Why rejected:** Read-only commands (`status`, `verify`) must not write files. + +### Option B: Accept any schema version at any time + +**Pros:** Maximum backwards compatibility; no user action required. + +**Cons:** The CLI would have to carry forward all legacy parsing paths indefinitely. +Mismatches between manifest fields and CLI expectations would produce subtle, +hard-to-diagnose bugs rather than a clear error message. + +**Why rejected:** Silent mismatches are harder to debug than an explicit upgrade +requirement, especially for a tool that manages shared `.github/` artifacts. + +### Option C: Explicit upgrade gate (chosen) + +**Pros:** Fail-fast with a clear message at the point of first mismatch. Upgrade +is intentional and auditable. Read-only commands remain side-effect-free. Test +coverage is straightforward β€” test both legacy-rejection and upgrade paths. + +**Cons:** One extra command for users upgrading from an old vstack version. + +## rationale + +Explicit over implicit. A clear error message pointing to `vstack manifest upgrade` +is faster to resolve than a silent behavioural mismatch. The upgrade step is a +one-time migration, not a recurring cost, and it keeps `status` and `verify` +side-effect-free (NFR alignment). + +The `allow_legacy=True` parameter on `ManifestFile.read` is the single code-level +bypass, used only by the upgrade command itself. + +## consequences + +### positive + +- `status` and `verify` are guaranteed side-effect-free. +- Upgrade path is explicit, auditable, and testable. +- Old manifests are never silently misinterpreted. + +### negative / tradeoffs + +- Users upgrading from vstack < 1.0 must run `vstack manifest upgrade` once before + other commands work again. +- The CLI must maintain `from_dict` legacy parsing for the upgrade command's + `allow_legacy=True` path. + +## impact on future orchestrated pipeline + +No direct impact. The manifest schema gate is a CLI-only concern and would not +change if a multi-role orchestration layer is introduced. diff --git a/docs/architecture/adr/015-conservative-install-by-default.md b/docs/architecture/adr/015-conservative-install-by-default.md new file mode 100644 index 0000000..55eb828 --- /dev/null +++ b/docs/architecture/adr/015-conservative-install-by-default.md @@ -0,0 +1,115 @@ +# ADR-015: Conservative Install-by-Default + +> Maintained by: **architect** role + +**date:** 2026-04-26\ +**status:** accepted + +## context + +vstack installs skill, agent, instruction, and prompt artifacts into a project's +`.github/` directory. Projects that use vstack frequently customize these installed +files β€” adjusting skill prompts, tightening instructions, or extending agent +behaviours to fit their specific conventions. + +A naive re-install that overwrites existing files would silently destroy local +customizations. The install command therefore needs a clear policy for what it +will and will not overwrite. + +There are two distinct cases: + +1. **Untracked files**: files present in the target directory that were not placed + there by `vstack install` (not in the manifest, or not matched by any installed + artifact entry). +1. **Tracked files**: files placed by a previous `vstack install` and present in + the manifest with a stored checksum. + +The question was: should install be destructive by default (always overwrite), opt-in +safe (overwrite unless told not to), or conservative by default (refuse to overwrite +unless explicitly permitted)? + +## decision + +`vstack install` is conservative by default: + +- **Untracked files** are never overwritten. The install skips them and reports the + conflict. +- **Tracked files with local modifications** (on-disk checksum differs from manifest + checksum) are not overwritten. The install skips them and reports the drift. +- **Tracked files in a clean state** (on-disk checksum matches manifest checksum) + are safe to overwrite and are replaced. + +Explicit escape hatches for intentional updates: + +| Flag | Behaviour | +| --------------------- | ------------------------------------------------------------------- | +| `--force` | Overwrite all target files, tracked or not, modified or clean | +| `--force-name ` | Same as `--force` but scoped to a single named artifact | +| `--adopt-name ` | Take ownership of an untracked file without overwriting its content | +| `--update` | Overwrite clean tracked files; skip files with local modifications | + +## alternatives considered + +### Option A: Always overwrite (destructive by default) + +**Pros:** Simple implementation; install is idempotent in the naive sense. + +**Cons:** Silently destroys local customizations. Forces users to diff and re-apply +customizations after every upgrade. Not acceptable for a tool whose primary value +is customizable role and skill files. + +**Why rejected:** User customizations are first-class vstack usage; destroying them +by default contradicts the product intent. + +### Option B: Interactive prompt per conflict + +**Pros:** User decides case-by-case; no silent destruction. + +**Cons:** Unworkable in CI/CD pipelines. Makes scripted or automated installs +impossible without a `--yes` flag. Adds significant complexity to the CLI contract. + +**Why rejected:** vstack must operate non-interactively in CI contexts (NFR-5). + +### Option C: Conservative by default with explicit escape hatches (chosen) + +**Pros:** No silent destruction. Safe to re-run in CI (exits non-zero on conflict, +does not silently modify). Explicit flags (`--force`, `--adopt-name`, `--update`) +give full control for intentional upgrades. + +**Cons:** First-time experience may confuse users who expect re-install to just work. +Conflict output must be clear enough that the correct flag is obvious. + +**Why chosen:** Conservative default matches the principle of least surprise for a +tool managing files users have customized. CI pipelines can use `--force` or `--update` +explicitly when automation requires it. + +## rationale + +The checksum guard makes the boundary between "clean" and "modified" precise and +testable. An untracked file is never silently replaced. A file the user has modified +is never silently overwritten. The flags give escape hatches for every intentional +upgrade scenario, so conservative-by-default imposes no long-term friction on users +who understand the model. + +This also simplifies the manifest invariant: if a file is tracked and its checksum +matches, the manifest is the authoritative source and the overwrite is safe. + +## consequences + +### positive + +- Local customizations are always preserved unless the user explicitly opts in. +- CI pipelines that do not pass `--force` will fail loudly rather than silently + mutate committed artifacts. +- Checksum-based tracking enables drift detection (`verify`, `status`). + +### negative / tradeoffs + +- First-time update workflows require learning one of the escape-hatch flags. +- `adopt-name` adds a non-obvious concept to the install surface. +- Skipped-file output must be well-formatted to be actionable in CI logs. + +## impact on future orchestrated pipeline + +No direct impact on the multi-role pipeline model. The conservative install policy +is a CLI contract concern and does not affect how roles pass artifacts between stages. diff --git a/docs/architecture/adr/016-atomic-manifest-writes.md b/docs/architecture/adr/016-atomic-manifest-writes.md new file mode 100644 index 0000000..094edb7 --- /dev/null +++ b/docs/architecture/adr/016-atomic-manifest-writes.md @@ -0,0 +1,129 @@ +# ADR-016: Atomic Manifest Writes + +> Maintained by: **architect** role + +**date:** 2026-04-26\ +**status:** accepted + +## context + +`vstack.json` is the manifest that records every file vstack has installed in a +project. It is read by `verify`, `status`, `uninstall`, and `install --update`. +If the manifest is corrupt or truncated, these commands cannot function correctly +and manual repair is required. + +Before this change, `ManifestFile.write()` called `Path.write_text()` directly: + +```python +path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") +``` + +`write_text` is not atomic. A crash, `KeyboardInterrupt` (Ctrl+C), `SIGTERM`, or +an OS-level flush failure between the open and the close could leave `vstack.json` +in a partially-written state β€” truncated, incomplete JSON, or an empty file. + +The question was: how should we eliminate this class of corruption without adding +a runtime dependency or significant implementation complexity? + +## decision + +`ManifestFile.write()` stages the serialized manifest to a sibling file +`vstack.json.tmp` and then atomically promotes it via `os.replace()`: + +```python +tmp = self.path.with_suffix(".tmp") +tmp.write_text(json.dumps(data, indent=2), encoding="utf-8") +os.replace(tmp, self.path) +``` + +`os.replace` is guaranteed atomic on POSIX when source and destination are on the +same filesystem (which they always are for a `.tmp` sibling), so `vstack.json` is +either the old content or the new content β€” never a partial write. + +A stale `.tmp` file from a previous crash is silently overwritten by the next +successful write. No cleanup step is required. + +## alternatives considered + +### Option A: Write-and-verify (read back after write) + +Write the file, then read it back and compare against the intended content. If they +differ, raise an error. + +**Pros:** Catches write failures. + +**Cons:** Does not prevent partial writes from appearing from the perspective of +another concurrent reader between the write and the verify. Does not help on a +crash that happens after write and before verify. Adds a read I/O on every write. + +**Why rejected:** Does not eliminate the atomic-write problem; only detects it +after the fact. + +### Option B: Backup before write + +Copy the current `vstack.json` to `vstack.json.bak` before writing. + +**Pros:** Gives users a recovery path after a crash. + +**Cons:** Requires users to know to look for and use `.bak`. Still leaves `vstack.json` +potentially corrupt after the crash. Does not eliminate the need for manual +intervention. + +**Why rejected:** Shifts recovery burden to users rather than eliminating the +failure mode. + +### Option C: Accept the risk of partial writes + +Trust that modern OS buffering and Python's flush-on-close behaviour make partial +writes rare in practice. + +**Cons:** A partial write during `vstack install` leaves the project in a state where +`vstack verify`, `vstack status`, and `vstack uninstall` all fail with a JSON parse +error and there is no automated recovery path. The failure is silent (no error from +the install command itself) and the root cause is non-obvious. + +**Why rejected:** The consequence of a partial write is severe and non-obvious. The +fix is a trivial code change. Accepting the risk would be negligent given a simple +remedy exists. + +### Option D: `os.replace` via sibling `.tmp` file (chosen) + +See the decision above. + +**Why chosen:** `os.replace` is specified as atomic in POSIX.1-2008. No external +library is required. The implementation adds three lines of code. Stale `.tmp` files +are self-cleaning. The approach is a well-established pattern in systems programming. + +## rationale + +Eliminating an entire class of manifest corruption with three lines and zero new +dependencies is the correct tradeoff. The `os.replace` POSIX guarantee is +well-documented and widely relied upon. The alternative approaches either shift +recovery burden to users or detect failures too late. + +NFR-3 explicitly requires atomic manifest writes; this ADR records the implementation +decision that satisfies it. + +## consequences + +### positive + +- `vstack.json` is never left in a partially-written state on POSIX systems. +- No new dependencies introduced. +- `.tmp` cleanup is implicit (next write overwrites any stale `.tmp`). + +### negative / tradeoffs + +- On Windows, `os.replace` is not guaranteed atomic (it is atomic on POSIX only). + vstack's primary target is POSIX (Linux, macOS) CI environments; Windows is + best-effort for now. +- A stale `.tmp` file may be visible in the directory listing if a write is + interrupted between `write_text` and `os.replace`. It will be cleaned up on the + next successful write. + +## impact on future orchestrated pipeline + +No direct impact. If a future multi-role pipeline introduces parallel artifact writes, +each manifest write must still go through `ManifestFile.write()` to preserve the +atomicity guarantee. Concurrent writes from multiple processes to the same +`vstack.json` would require an additional locking mechanism not covered here. diff --git a/docs/architecture/adr/017-checksum-backfill-on-upgrade.md b/docs/architecture/adr/017-checksum-backfill-on-upgrade.md new file mode 100644 index 0000000..2055885 --- /dev/null +++ b/docs/architecture/adr/017-checksum-backfill-on-upgrade.md @@ -0,0 +1,260 @@ +# ADR-017: Checksum Backfill During Manifest Upgrade + +> Maintained by: **architect** role + +**date:** 2026-04-26\ +**status:** accepted + +## context + +Manifest v2 (ADR-014) introduced per-artifact `checksum` and `checksum_algorithm` +fields. The `vstack manifest upgrade` command migrates legacy manifests to the v2 +schema: it promotes `manifest_version` to `2`, sets `hash_algorithm`, and infers +`checksum_algorithm` for entries that already carry a checksum but lack an explicit +algorithm field. It does not backfill checksums for entries that have no checksum +at all. + +Projects that installed artifacts before checksum tracking was introduced have a +manifest where every `ArtifactEntry` has `checksum: None`. After running +`manifest upgrade`, the schema version is current but all entries still lack +checksums. The CLI classifies these entries as `MANAGED_LEGACY`: + +```text +classify_state(): + if existing_entry.checksum is None: + return MANAGED_LEGACY, ": tracked legacy entry without checksum; treated as managed" +``` + +`MANAGED_LEGACY` has three material consequences: + +1. **`status`** β€” reports all tracked files as legacy warnings rather than clean. +1. **`verify --output`** β€” accepts legacy entries as pass but cannot detect drift. +1. **`install --update`** β€” cannot compute drift, so it cannot determine whether a + file is safe to overwrite under the conservative default (ADR-015). The conservative + update is blocked for every legacy entry. + +The question is: should `vstack manifest upgrade` optionally compute and store +checksums for tracked files that currently lack them, resolving these consequences? + +## decision + +`vstack manifest upgrade` accepts an optional `--backfill` flag. + +When `--backfill` is set: + +1. For each tracked artifact entry that has no checksum, locate the on-disk file at + the expected relative path under the install root. +1. If the file exists and contains a `VSTACK-META` footer comment, compute its + SHA-256 checksum and store it in the upgraded entry alongside + `checksum_algorithm: "sha256"`. +1. If the file exists but lacks a `VSTACK-META` footer (unverifiable identity), skip + backfill for that entry and emit a warning. The entry retains `MANAGED_LEGACY` + status after upgrade. +1. If the file does not exist on disk, skip silently β€” the entry is already `MISSING` + and no checksum is meaningful. +1. The manifest is written atomically after all entries are processed (ADR-016). + +When `--backfill` is not set, `manifest upgrade` behavior is unchanged: schema-only +upgrade. **The `--backfill` flag is explicitly opt-in. There is no default backfill.** + +### upgrade behaviour table + +| Condition | Without `--backfill` | With `--backfill` | +| --------------------------------------------- | -------------------- | --------------------------- | +| Entry has checksum, no algorithm | Infer algorithm | Infer algorithm (unchanged) | +| Entry has no checksum, file exists + footer | No change | Compute + store SHA-256 | +| Entry has no checksum, file exists, no footer | No change | Skip; emit warning | +| Entry has no checksum, file missing | No change | Skip silently | + +The `VSTACK-META` footer is the machine-readable comment appended to every +generated artifact: + +```text + +``` + +Its presence is the identity gate. Absence means the file is either user-created, +pre-footer vstack output, or so heavily modified that the footer was removed. + +## alternatives considered + +### Option A: Eager unconditional backfill (no flag, always backfill on upgrade) + +During `manifest upgrade`, unconditionally compute checksums for all tracked files +found on disk, regardless of identity check. + +**Pros:** Zero friction β€” a single command resolves all `MANAGED_LEGACY` warnings. + +**Cons:** If a tracked file has been locally modified after install, the backfill +stores the modified state as canonical. Future `install --update` then sees the file +as `MANAGED` (clean) and overwrites the modified content β€” a silent violation of +the conservative-install guarantee in ADR-015. + +**Why rejected:** Silently promoting a modified file to clean state is a correctness +violation. An upgrade command that accepts arbitrary on-disk state as authoritative +without any user acknowledgement is unsafe. + +### Option B: No backfill β€” leave MANAGED_LEGACY entries indefinitely + +Schema upgrade only. Users who want checksums must re-run `vstack install --force` +to reinstall artifacts from source and record fresh checksums. + +**Pros:** No risk of incorrectly canonicalising a modified file. + +**Cons:** `MANAGED_LEGACY` persists indefinitely after upgrade. All tracked files +remain blocked from `install --update`. Users who want a clean status report must +force-reinstall every artifact β€” which overwrites local customisations and itself +violates ADR-015. This is a chicken-and-egg situation: conservative install needs +checksums, but the only path to checksums requires destructive overwrite. + +**Why rejected:** Forces destructive re-install as the only exit from `MANAGED_LEGACY`. +Incompatible with the conservative-install contract for projects with local +customisations. + +### Option C: Separate `vstack manifest backfill` command + +Introduce a new sub-command `vstack manifest backfill` with an identity-check flag. +Upgrade remains schema-only; backfill is a distinct step. + +**Pros:** Clean command separation. Backfill intent is maximally explicit. + +**Cons:** Requires two commands to fully recover from legacy state. Most users want +both in sequence; splitting them into two commands adds no clarity beyond a flag on the +existing upgrade command. Adds a permanent command-catalogue entry for a one-time +migration operation. + +**Why rejected:** The marginal clarity benefit does not justify the additional +command surface. A flag on the existing upgrade command is equally explicit and +simpler to document. + +### Option D: Opt-in flag with identity check (chosen) + +`vstack manifest upgrade --backfill` with `VSTACK-META` footer verification. + +See the decision above. + +**Why chosen:** Explicit user intent (opt-in flag). The `VSTACK-META` footer is a +reliable identity signal β€” only vstack writes it, and user-customised files rarely +strip the footer while retaining the surrounding content. The flag is one extra +argument that users supply once, not a recurring cost. The identity check is a +meaningful guard without requiring a separate command or complex heuristics. + +## rationale + +Safe-by-default is the governing constraint (ADR-015). An opt-in flag preserves it: +the user explicitly chooses to canonicalise current on-disk state. The `VSTACK-META` +footer check is lightweight but meaningful β€” it screens out files that were never +vstack-generated and files whose footers were removed as part of heavy +customisation. + +The check is an identity guard, not a modification guard. A user-modified file that +still carries the original `VSTACK-META` footer will have its modified content +checksummed and stored. This is documented behaviour: `--backfill` canonicalises +current state. Users who have modified files and need accurate drift detection +should run `vstack install --force-name ` for those artifacts after backfill, +not use backfill as a substitute for a fresh install. + +The result after a successful `--backfill` run is a manifest where every tracked file +that exists on disk and passes identity verification is classified as `MANAGED`, not +`MANAGED_LEGACY`. Conservative install (`--update`) then operates correctly for those +entries. + +## consequences + +### positive + +- After `vstack manifest upgrade --backfill`, tracked files with `VSTACK-META` footers + are promoted from `MANAGED_LEGACY` to `MANAGED`. +- `status` no longer shows legacy warnings for those entries. +- `verify --output` can detect drift for those entries. +- `install --update` operates correctly for those entries. +- Entries without `VSTACK-META` footers continue to report `MANAGED_LEGACY`, which is + the correct conservative signal for files whose vstack origin is unverifiable. + +### negative / tradeoffs + +- `--backfill` canonicalises current on-disk state. Modified content that retains the + `VSTACK-META` footer is accepted as canonical β€” drift is no longer detectable for + that entry until a fresh install records a new checksum. +- The `VSTACK-META` footer check is a heuristic, not a cryptographic proof of origin. + A manually crafted file with a forged footer would pass the check. This is an + acceptable risk in a developer tooling context. +- Entries that lack the `VSTACK-META` footer remain `MANAGED_LEGACY` after backfill + and continue to block conservative `--update`. Users with such entries must use + `--force` or `--force-name` for those artifacts. + +## impact on future orchestrated pipeline + +No direct impact. Checksum backfill is a manifest-maintenance operation. It would not +change if a multi-role orchestration layer is introduced in Option B. + +______________________________________________________________________ + +## designer handoff + +The following sections of `docs/design/design.md` must be updated to reflect this +decision before implementation begins. + +### 1. Section 1.1 β€” artifact lifecycle states + +Add `managed-legacy` as an explicit named state in the state table: + +```text +managed-legacy β€” in manifest; checksum absent; file exists but drift cannot be determined +``` + +Extend the state machine diagram with backfill transitions: + +```text +managed-legacy --> managed : manifest upgrade --backfill (VSTACK-META footer present) +managed-legacy --> managed-legacy : manifest upgrade --backfill (no footer; entry unchanged) +managed-legacy --> clean : install --force or --force-name rewrites file + records checksum +``` + +### 2. Section 6 β€” `manifest upgrade` command contract + +Add `--backfill` to the command synopsis and flag table: + +```bash +vstack manifest upgrade [--target ] [--backfill] +``` + +| Flag | Description | +| ------------ | --------------------------------------------------------------------------------------------------------------- | +| `--backfill` | Compute and store SHA-256 checksums for tracked entries with no checksum, gated by `VSTACK-META` identity check | + +Extend the exit-code table: + +| Exit code | Meaning | +| --------- | -------------------------------------------------------------------------- | +| `0` | Manifest upgraded (and backfilled if `--backfill`; partial backfill is OK) | +| `1` | Manifest missing, unreadable, or parse error | + +Document that `--backfill` is a one-way operation: current on-disk state becomes the +canonical baseline for backfilled entries. Users should be informed which entries were +backfilled and which were skipped (missing footer or missing file). + +### 3. Section 2.1 β€” manifest domain interface + +Document the `with_backfilled_checksums` method on `Manifest`: + +```python +def with_backfilled_checksums( + self, + install_dir: Path, +) -> tuple["Manifest", list[str], list[str]]: + """Return an updated manifest with checksums backfilled where possible. + + For each entry with ``checksum=None``, reads the on-disk file at + ``install_dir / entry.file``. Computes SHA-256 and stores it only when + the file contains a ``VSTACK-META`` footer comment. + + Returns: + (updated_manifest, backfilled_names, skipped_names) + where ``skipped_names`` are entries whose files existed but lacked the footer. + """ +``` + +Document: `CommandService.manifest_upgrade` calls `upgraded()` first (schema +migration), then `with_backfilled_checksums(install_dir)` when `--backfill` is set, +and passes the result to `manifest_file.write()`. diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index cd99907..0327883 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -1,13 +1,17 @@ # vstack β€” architecture -> Maintained by: **agents** role\ -> Last updated: 2026-04-21 +> Maintained by: **architect** role\ +> Last updated: 2026-04-26 ## overview vstack is a VS Code–native AI engineering workflow system. It provides template-driven -skills for planning, reviewing, verifying, and releasing backend services, microservices, -and libraries via GitHub Copilot Agent Mode. +skills, agents, instructions, and prompts for planning, reviewing, verifying, and +releasing software via GitHub Copilot Agent Mode. + +**System style:** `platform` β€” a standalone CLI tool and SDK. vstack installs +structured role artifacts into a project's `.github/` directory; it does not itself +implement the software being built. ______________________________________________________________________ @@ -22,7 +26,8 @@ vstack/ β”‚ β”œβ”€β”€ agents/ ← AGENT_SCHEMA, AGENT_TYPE β”‚ β”œβ”€β”€ instructions/ ← instruction config and wrappers β”‚ β”œβ”€β”€ prompts/ ← prompt config and wrappers -β”‚ β”œβ”€β”€ cli/ ← commands, parser, constants +β”‚ β”œβ”€β”€ manifest/ ← Manifest, ManifestFile, ArtifactEntry, checksums +β”‚ β”œβ”€β”€ cli/ ← interface, registry, service, per-command handlers, helpers β”‚ └── _templates/ ← source templates for all artifact types β”œβ”€β”€ docs/ β”‚ β”œβ”€β”€ architecture/ ← architecture docs + ADRs @@ -92,8 +97,21 @@ See `docs/architecture/adr/009-role-model.md` for the decision record. ### 5. manifest (`vstack.json`) Generated at install time in the target directory. Tracks every artifact installed -by `vstack install` (skills, agents, instructions, and prompts) so that `vstack uninstall` can remove -exactly those files. Not committed to the vstack source repo. +by `vstack install` (skills, agents, instructions, and prompts), including a per-file +SHA-256 checksum, version, and algorithm so that: + +- `vstack uninstall` removes exactly the files it installed. +- `install --update` detects local modifications before rewriting. +- `verify` / `status` report checksum drift and ownership state. +- `manifest upgrade` migrates legacy schema to the current version. + +The manifest schema is versioned (`manifest_version` field). Operations that require +the current schema fail fast with an upgrade hint rather than silently misbehaving. +See ADR-014 and ADR-015. + +Writes are atomic: content is staged to a sibling `.tmp` file and promoted with +`os.replace` so a crash or `KeyboardInterrupt` cannot produce a partially-written +manifest. See ADR-016. ### 6. VS Code agent files (`.github/agents/.agent.md`) @@ -121,6 +139,37 @@ user-invocable: true Each agent body describes: responsibilities, workflow steps, artifact ownership, and which skills to invoke. +### 7. CLI layer (`src/vstack/cli/`) + +The CLI layer translates argparse input into domain operations through a small set of +focused components. See `docs/design/design.md` for the full component table and +dispatch flow. + +| Component | Responsibility | +| ------------------------ | ----------------------------------------------------------------------------------- | +| `CommandLineInterface` | Facade: parser construction, service creation, target/scope resolution, dispatch | +| `CommandService` | Shared coordinator: generators, path labelling, manifest access, artifact state | +| `build_command_registry` | Maps command names to `BaseCommand` instances | +| `BaseCommand` | ABC contract: all handlers implement `run(args, install_dir, only) β†’ int` | +| Per-command modules | `install`, `verify`, `status`, `uninstall`, `validate`, `manifest` β€” one class each | +| `helpers.py` | Shared install/uninstall utilities (name normalization, manifest preservation) | + +______________________________________________________________________ + +## non-functional requirements + +These bind architecture decisions. Full list in `docs/product/requirements.md`. + +| ID | Requirement | Architectural binding | +| ----- | --------------------------------------------------------------------------------- | ----------------------------------------- | +| NFR-1 | No runtime dependencies beyond the Python standard library | ADR-006, ADR-007 | +| NFR-2 | Python 3.11–3.14 compatibility | ADR-007 | +| NFR-3 | Manifest writes are atomic | ADR-016 | +| NFR-4 | All public behavior covered by automated tests; CI enforces test pass | `tests/` structure, `verify.yml` workflow | +| NFR-5 | CLI operates standalone; no VS Code process required for CLI operations | ADR-006, stdlib-only runtime | +| NFR-6 | Lint and type checking pass on every commit; CI gate enforces zero violations | `pyproject.toml` ruff + mypy config | +| NFR-7 | Generated output lives under `.github/` only; templates never modified at runtime | ADR-012 | + ______________________________________________________________________ ## execution model @@ -160,3 +209,22 @@ ______________________________________________________________________ All significant architectural decisions are recorded in `docs/architecture/adr/`. See individual files for context, decision, alternatives, and rationale. + +| ADR | Title | Status | +| --- | ---------------------------------------------------- | -------- | +| 001 | VS Code-native variant | accepted | +| 002 | Artifact naming and compatibility policy | accepted | +| 003 | Backend-first verify | accepted | +| 004 | Option A to B pipeline | accepted | +| 005 | VS Code prompt format | accepted | +| 006 | No runtime dependency on external binaries | accepted | +| 007 | Python runtime | accepted | +| 008 | Agents over prompts | accepted | +| 009 | 6-role agent model | accepted | +| 010 | Artifact flow | accepted | +| 011 | Skill restructure | accepted | +| 012 | Flat templates and install-time generation | accepted | +| 013 | Policy vs procedure boundary for instructions/skills | accepted | +| 014 | Manifest schema versioning and explicit upgrade gate | accepted | +| 015 | Conservative install-by-default | accepted | +| 016 | Atomic manifest writes | accepted | diff --git a/docs/design/design.md b/docs/design/design.md index 2339720..0bbf7ba 100644 --- a/docs/design/design.md +++ b/docs/design/design.md @@ -1,227 +1,686 @@ # vstack β€” design > Maintained by: **designer** role\ -> Last updated: 2026-04-21 +> Last updated: 2026-04-26 ## overview -This document describes the internal component design of vstack: how the generator -works, how frontmatter is structured, how the resolver pipeline operates, and how -output artifacts are built. +This document is the concrete design baseline for vstack β€” a `platform` artifact: a +standalone CLI tool and SDK that installs structured role artifacts into a project's +`.github/` directory for use with GitHub Copilot Agent Mode. + +It translates the architecture blueprint in `docs/architecture/architecture.md` into +implementable interfaces, data schemas, state models, validation rules, and error +contracts. Implementation follows this document; architecture decisions are recorded in +`docs/architecture/adr/`. ______________________________________________________________________ -## generator design (`src/vstack/artifacts/generator.py`) +## 1. domain model + +### 1.1 artifact lifecycle states -`GenericArtifactGenerator` renders, writes, and validates any family of prompt -artifacts (skills, agents). All type-specific behaviour β€” output filename pattern, -frontmatter injection, partial loading, auto-generated footer, required tokens β€” is -expressed through an `ArtifactTypeConfig` descriptor rather than subclass overrides. +An artifact (skill, agent, instruction, prompt) exists in one of these states relative +to the target `.github/` directory: -### execution flow +```text +absent β€” not in manifest AND not on disk +untracked β€” not in manifest, but a file exists at the expected path +clean β€” in manifest; on-disk checksum matches manifest checksum +modified β€” in manifest; on-disk checksum differs from manifest checksum +missing β€” in manifest; expected file does not exist on disk +managed-legacy β€” in manifest; checksum absent; file exists but drift cannot be determined +unknown β€” state cannot be determined (I/O error) +``` + +State machine: ```mermaid -flowchart TD - A[Discover template directories
skip underscore-prefixed dirs] --> B[Load per-template source] - B --> C[Validate frontmatter against schema] - C --> D[Load partials when configured] - D --> E[Resolve placeholder tokens] - E --> F[Inject frontmatter and footer] - F --> G{Command mode} - G -->|install| H[Write output files to target directory] - G -->|validate or verify| I[Validate templates and report failures] +stateDiagram-v2 + [*] --> absent : not installed + [*] --> untracked : file exists, not in manifest + + absent --> clean : install writes file + records checksum + absent --> managed-legacy : legacy entry promoted (no checksum recorded) + untracked --> clean : install --force or --adopt-name + clean --> modified : user edits file + clean --> missing : user deletes file + clean --> clean : install --update rewrites file (same or newer version) + modified --> clean : install --force rewrites file + missing --> clean : install (re)writes file + clean --> absent : uninstall removes file + modified --> modified : uninstall skips (checksum drift; no --force) + + managed-legacy --> clean : manifest upgrade --backfill (VSTACK-META footer present) + managed-legacy --> managed-legacy : manifest upgrade --backfill (no footer; entry unchanged) + managed-legacy --> clean : install --force or --force-name rewrites file + records checksum ``` -### frontmatter architecture (`src/vstack/frontmatter/`) +### 1.2 manifest JSON schema + +`vstack.json` β€” written by `install`, read by all other commands. + +```json +{ + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.3.0", + "installed_at": "2026-04-26T12:34:56.000000+00:00", + "artifacts": { + "skills": [ + { + "name": "architecture", + "file": "skills/architecture/SKILL.md", + "version": "1.0.1", + "checksum": "a1b2c3...", + "checksum_algorithm": "sha256" + } + ], + "agents": [], + "instructions": [], + "prompts": [] + } +} +``` + +Top-level field contracts: + +| Field | Type | Required | Notes | +| ------------------ | ------- | -------- | ------------------------------------------------------------- | +| `manifest_version` | integer | yes | Must equal `CURRENT_MANIFEST_VERSION` (2) for most operations | +| `hash_algorithm` | string | yes | Current default: `sha256` | +| `vstack_version` | string | yes | vstack version that last wrote the manifest | +| `installed_at` | string | yes | ISO-8601 timestamp of last write | +| `artifacts` | object | yes | Dict keyed by manifest type key (`skills`, `agents`, …) | -Frontmatter is handled by a dedicated package: +Per-artifact entry (`ArtifactEntry`) field contracts: -| Module | Responsibility | -| --------------- | ------------------------------------------------------------------------------------ | -| `schema.py` | `FrontmatterSchema` β€” ordered field specs, types, and constraints | -| `parser.py` | `FrontmatterParser` β€” parse YAML frontmatter from Markdown + standalone YAML | -| `serializer.py` | `FrontmatterSerializer` β€” serialize schema-validated fields back to YAML frontmatter | +| Field | Type | Required | Notes | +| -------------------- | ------ | -------- | ----------------------------------------------------------------------- | +| `name` | string | yes | Canonical artifact name | +| `file` | string | yes | Relative file path under install root (for example `skills/x/SKILL.md`) | +| `version` | string | no | May be absent on legacy entries | +| `checksum` | string | no | May be absent on legacy entries | +| `checksum_algorithm` | string | no | May be absent on legacy entries | -Supported field types: `string`, `list`, `bool`, `object-list`, `raw` +### 1.3 manifest version gate + +`manifest_version` is read before any operation that consumes manifest state. If the +value differs from `CURRENT_MANIFEST_VERSION`, the operation fails with: + +```text +ERROR: Legacy manifest schema detected in vstack.json. + Run: vstack manifest upgrade --target . +``` + +`ManifestFile.read(allow_legacy=True)` bypasses this gate β€” it is called only by +`ManifestCommand` when executing `manifest upgrade`. ______________________________________________________________________ -## skill design +## 2. Python interfaces -### template structure (`src/vstack/_templates/skills//`) +### 2.1 manifest domain (`src/vstack/manifest/`) -Each skill is a directory containing two files: +```python +CURRENT_MANIFEST_VERSION: int = 2 -- `config.yaml` β€” skill frontmatter fields (metadata) -- `template.md` β€” skill instructions body (no frontmatter) -```markdown -{{SKILL_CONTEXT}} +@dataclass +class ArtifactEntry: + name: str + file: str + version: str | None = None + checksum: str | None = None + checksum_algorithm: str | None = None -## Your skill instructions here -``` -```yaml -name: architecture -version: 1.0.1 -description: | - Engineering-lead plan review. Lock in the execution plan β€” service boundaries, - data models, API contracts, error handling, test strategy, ... -argument-hint: '[plan or system to review]' -``` +@dataclass +class Manifest: + vstack_version: str + installed_at: str + manifest_version: int = CURRENT_MANIFEST_VERSION + hash_algorithm: str = CURRENT_HASH_ALGORITHM + artifacts: dict[str, list[ArtifactEntry]] = field(default_factory=dict) -The `{{SKILL_CONTEXT}}` token is replaced with the shared `_partials/skill-context.md` -at generation time. + def entries_for(self, type_name: str) -> list[ArtifactEntry]: ... + def names_for(self, type_name: str) -> list[str]: ... + def files_for(self, type_name: str) -> list[str]: ... + def to_dict(self) -> dict: ... + def needs_upgrade(self) -> bool: ... -### skill frontmatter schema + @classmethod + def from_dict(cls, data: dict) -> "Manifest": ... -| Field | Type | Required | Notes | -| -------------------------- | ------ | -------- | ---------------------------------------------------------------------- | -| `name` | string | **yes** | Lowercase kebab-case. Must match the directory name. Max 64 chars. | -| `version` | string | **yes** | Semantic version for vstack change tracking. | -| `description` | string | **yes** | What the skill does and when to use it. Max 1024 chars (vstack limit). | -| `argument-hint` | string | no | Shown after `/skill-name` in the chat input. | -| `user-invocable` | bool | no | `true` (default) = appears in the `/` slash command menu. | -| `disable-model-invocation` | bool | no | `true` = Copilot will never auto-load this skill. | + def upgraded(self) -> "Manifest": ... + # Returns a new Manifest at CURRENT_MANIFEST_VERSION. + # Infers missing checksum_algorithm via _infer_algorithm_for_legacy_entry. -______________________________________________________________________ + def with_backfilled_checksums( + self, + install_dir: Path, + ) -> tuple["Manifest", list[str], list[str]]: ... + # Returns (updated_manifest, backfilled_names, skipped_names). + # For each entry with checksum=None: + # - Reads the on-disk file at install_dir / entry.file. + # - Computes SHA-256 and stores it only when the file contains a VSTACK-META footer. + # - If the file exists but lacks the footer: entry is unchanged; name added to skipped_names. + # - If the file does not exist: silently skipped (entry is already missing). + # The returned manifest is a new instance; self is not mutated. + + @staticmethod + def _infer_algorithm(*, entry: dict, fallback_algorithm: str, manifest_version: int) -> str | None: ... -## agent design + @staticmethod + def _infer_algorithm_for_legacy_entry(checksum: str, fallback_algorithm: str) -> str | None: ... -### template structure (`src/vstack/_templates/agents//`) -Each agent is a directory containing two files: +class ManifestFile: + def __init__(self, parent_dir: Path) -> None: ... + read_error: str | None -- `config.yaml` β€” frontmatter fields (the agent metadata) -- `template.md` β€” agent instructions body (no frontmatter in this file) + def read(self, *, allow_legacy: bool = False) -> Manifest | None: ... + # Returns None for missing/invalid/legacy (unless allow_legacy=True). + # Stores message in read_error for user-facing diagnostics. -The generator reads `config.yaml`, validates fields against `AGENT_SCHEMA`, and emits -them as YAML frontmatter in the output `.agent.md`. + def write(self, manifest: Manifest) -> None: ... + # Atomic write: stages to .tmp, then os.replace β†’ . + # Never leaves vstack.json in a partially-written state on POSIX. -### agent frontmatter schema + def exists(self) -> bool: ... +``` -| Field | Type | Required | Notes | -| -------------------------- | ----------- | -------- | ----------------------------------------------------------- | -| `name` | string | no | Overrides filename as picker label | -| `description` | string | no | Shown as placeholder text in chat input | -| `argument-hint` | string | no | Hint text shown after `@agent` in chat | -| `tools` | list | no | Tools available to this agent | -| `agents` | list | no | Subagents this agent may invoke; `["*"]` = all | -| `model` | string | no | Force a specific model; omit to use picker selection | -| `user-invocable` | bool | no | `true` = show in agents dropdown (default) | -| `disable-model-invocation` | bool | no | `true` = prevent other agents from calling this one | -| `target` | string | no | `vscode` (default) or `github-copilot` | -| `handoffs` | object-list | no | Sequential workflow handoffs (see `docs/design/agents.md`) | -| `mcp-servers` | raw | no | MCP server config (`github-copilot` target only) | -| `hooks` | raw | no | Chat hooks (Preview feature) | -| `metadata` | raw | no | String key/value annotations (`github-copilot` target only) | +### 2.2 generator domain (`src/vstack/artifacts/`) + +```python +@dataclass +class ArtifactTypeConfig: + type_name: str + templates_dir: str + output_subdir: str + output_pattern: str + add_frontmatter: bool + artifact_is_dir: bool = False + partials_subdir: str | None = "_partials" + template_filename: str = "template.md" + config_filename: str = "config.yaml" + auto_gen_footer: bool = False + placeholders: dict[str, str] = field(default_factory=dict) + fail_on_unresolved: bool = False + frontmatter_schema: FrontmatterSchema | None = None + preserve_multiline_frontmatter: bool = False + manifest_key: str = "" + + +@dataclass +class RenderedArtifact: + name: str + content: str + source_path: Path + frontmatter: dict | None = None + unresolved: list[str] = field(default_factory=list) + + +@dataclass +class ArtifactResult: + artifacts: list[RenderedArtifact] + unresolved_warnings: list[str] + verification: ValidationResult + + @property + def ok(self) -> bool: ... + + +class GenericArtifactGenerator: + def __init__(self, type_config: ArtifactTypeConfig, templates_root: Path) -> None: ... + + # Static helpers + @staticmethod + def resolve_placeholders(text: str, resolvers: dict[str, str]) -> str: ... + @staticmethod + def find_unresolved(text: str) -> list[str]: ... + @staticmethod + def parse_generation_metadata(text: str) -> dict[str, str] | None: ... + + # Discovery + loading + def load_partials(self) -> dict[str, str]: ... + def find_templates(self) -> list[Path]: ... + def find_extra_files(self, tmpl_dir: Path) -> list[Path]: ... + def load_artifact_config(self, tmpl_dir: Path) -> dict: ... + + # Rendering + output + def render(self, tmpl_dir: Path) -> RenderedArtifact: ... + def render_all(self) -> list[RenderedArtifact]: ... + def output_path(self, name: str) -> str: ... + def install_relative_path(self, name: str) -> str: ... + def generate(self, output_dir: Path) -> ArtifactResult: ... + + # Validation + def verify_input(self, expected_names: list[str] | None = None) -> ValidationResult: ... + def verify_output(self, output_dir: Path, expected_names: list[str] | None = None) -> ValidationResult: ... +``` -### agent-internal fields (not emitted) +### 2.3 CLI domain (`src/vstack/cli/`) + +```python +class CommandLineInterface: + def __init__( + self, + *, + parser_cls: type[CommandLineParser] = CommandLineParser, + service_cls: type[CommandService] = CommandService, + templates_root, + ) -> None: ... + @classmethod + def resolve_only_for_scope(cls, args: object) -> list[str] | None: ... + def run(self) -> int: ... + # Parses argv via argparse and dispatches selected command. + # Top-level ValueError handling is in src/vstack/main.py. + + +class CommandService: + def __init__(self, templates_root: Path) -> None: ... + + generators: list[GenericArtifactGenerator] + + def label(self, path: Path) -> str: ... + # Returns path relative to template root when possible. + + def manifest_for(self, install_dir: Path) -> ManifestFile: ... + # Returns ManifestFile handle for /vstack.json + + def artifact_control_state( + self, + *, + out_file: Path, + existing_entry, + ) -> tuple[str, str]: ... + # Returns (state, detail_message). + # state: "managed" | "managed-legacy" | "modified" | "missing" + # | "untracked" | "absent" | "unknown" + # On OSError: returns ("unknown", str(error)) β€” never raises. + + def gen_for(self, artifact_type: str) -> GenericArtifactGenerator | None: ... + + +class BaseCommand(ABC): + @abstractmethod + def run( + self, + *, + args: argparse.Namespace, + install_dir: Path | None, + only: list[str] | None, + ) -> int: ... + # Returns 0 on success, non-zero on errors. + + +def build_command_registry(service: CommandService) -> dict[str, BaseCommand]: ... +# Returns {"install": InstallCommand, "verify": VerifyCommand, ...} +``` -| Field | Notes | -| --------- | ------------------------------------------------------------------------------ | -| `version` | Semantic version for vstack change tracking β€” never reaches the generated file | +______________________________________________________________________ -Multiline frontmatter output for agents is controlled centrally by -`ArtifactTypeConfig.preserve_multiline_frontmatter`. +## 3. frontmatter schemas + +### 3.1 skill frontmatter (`config.yaml`) + +| Field | Type | Required | Constraints | +| -------------------------- | ------ | -------- | --------------------------------------------------------------------- | +| `name` | string | **yes** | Lowercase kebab-case; max 64 chars; must match directory name | +| `version` | string | **yes** | Semver | +| `description` | string | **yes** | Max 1024 chars; what the skill does and when to invoke it | +| `license` | string | no | SPDX identifier | +| `compatibility` | string | no | Free text compatibility note | +| `metadata.owner` | string | no | β€” | +| `metadata.maturity` | string | no | `"stable"` \| `"beta"` \| `"experimental"` | +| `argument-hint` | string | no | Shown after `/skill-name` in chat input | +| `user-invocable` | bool | no | Default `true`; `false` hides skill from slash-command menu | +| `disable-model-invocation` | bool | no | Default `false`; `true` prevents Copilot from auto-loading this skill | + +### 3.2 agent frontmatter (`config.yaml`) + +| Field | Type | Required | Notes | +| -------------------------- | ----------- | -------- | -------------------------------------------------------------- | +| `name` | string | no | Overrides filename as picker label | +| `description` | string | no | Placeholder text in chat input | +| `argument-hint` | string | no | Hint text shown after `@agent` in chat | +| `tools` | list | no | `read`, `search`, `edit`, `web`, `vscode`, `todo`, `agent` | +| `agents` | list | no | Subagents this agent may invoke; `["*"]` = all | +| `model` | string | no | Force a specific model; omit to allow user selection | +| `user-invocable` | bool | no | Default `true` | +| `disable-model-invocation` | bool | no | Default `false`; `true` prevents other agents calling this one | +| `target` | string | no | `"vscode"` (default) or `"github-copilot"` | +| `handoffs` | object-list | no | Sequential workflow handoff steps | +| `mcp-servers` | raw | no | MCP server config β€” `github-copilot` target only | +| `hooks` | raw | no | Chat hooks (preview feature) | +| `metadata` | raw | no | String key/value annotations β€” `github-copilot` target only | +| `version` | string | no | **Internal only β€” never emitted.** vstack change-tracking only | + +### 3.3 instruction frontmatter (`config.yaml`) + +| Field | Type | Required | Notes | +| ------------- | ------ | -------- | ----------------------------------------- | +| `name` | string | **yes** | Matches directory name; kebab-case | +| `description` | string | **yes** | When this instruction applies | +| `applyTo` | string | no | Glob pattern e.g. `"**/*.py"` or `"**/*"` | + +### 3.4 prompt frontmatter (`config.yaml`) + +| Field | Type | Required | Notes | +| ------------- | ------ | -------- | ----------------------------------- | +| `name` | string | **yes** | Matches directory name; kebab-case | +| `description` | string | **yes** | What this prompt does | +| `mode` | string | no | `"ask"` \| `"edit"` \| `"generate"` | ______________________________________________________________________ -## placeholder resolver +## 4. placeholder resolver -Placeholders use `{{TOKEN}}` syntax. Partials are loaded from -`src/vstack/_templates/skills/_partials/*.md`. Each file stem is converted from lowercase-kebab -to UPPER_SNAKE to form the token: `skill-context.md` β†’ `SKILL_CONTEXT`. +Placeholders use `{{TOKEN}}` syntax. Resolution is literal string substitution β€” no +logic, no loops. Unresolved tokens are treated as errors by `validate` and `install`. -Resolution is literal string substitution β€” no logic, no loops in templates. -Unresolved tokens are left unchanged and flagged as errors by `validate` and `install`. +Partials live in `src/vstack/_templates/skills/_partials/*.md`. File stem is converted +from lowercase-kebab to `UPPER_SNAKE` to form the token: +`skill-context.md` β†’ `{{SKILL_CONTEXT}}`. -Current partials: +| Token | Source file | Used by | +| ----------------------------- | ---------------------------- | --------------------------------- | +| `{{SKILL_CONTEXT}}` | `skill-context.md` | All skills β€” shared opening block | +| `{{BASE_BRANCH}}` | `base-branch.md` | Skills that reference git diff | +| `{{RUN_TESTS}}` | `run-tests.md` | Skills that run tests | +| `{{OBSERVABILITY_CHECKLIST}}` | `observability-checklist.md` | `verify`, `architecture` | +| `{{API_CONTRACT_CHECKLIST}}` | `api-contract-checklist.md` | `design`, `code-review` | -| Token | Source file | -| ----------------------------- | ------------------------------------------------------------------------------------- | -| `{{SKILL_CONTEXT}}` | `skill-context.md` β€” shared opening: context, completeness principle, question format | -| `{{BASE_BRANCH}}` | `base-branch.md` β€” shell snippet to detect git base branch | -| `{{RUN_TESTS}}` | `run-tests.md` β€” test framework detection + run command | -| `{{OBSERVABILITY_CHECKLIST}}` | `observability-checklist.md` β€” observability coverage checklist | +**Resolution rules:** + +1. Load all partial files for the artifact type (skills only). +1. For each partial, substitute all occurrences of `{{STEM_AS_UPPER_SNAKE}}` in the + template body. +1. After all substitutions, scan for any remaining `{{...}}` patterns. +1. Remaining unresolved tokens are attached to `RenderedArtifact.unresolved` and are + reported by `validate`, `install`, and `verify` flows. ______________________________________________________________________ -## auto-gen footer +## 5. auto-gen footer -Every generated file ends with a footer line (when `auto_gen_footer=True` on the type config): +Every generated artifact ends with this block when `auto_gen_footer=True`: ```text - + ``` -The human-readable footer text lives in `src/vstack/artifacts/constants.py` and is shared across all artifact types. -The machine-readable `VSTACK-META` footer is emitted by `GenericArtifactGenerator._build_footer`. -Currently skills, agents, instructions, and prompts all have `auto_gen_footer=True`. +- Human-readable line: constant in `src/vstack/artifacts/constants.py` +- Machine-readable `VSTACK-META` line: emitted by `GenericArtifactGenerator._build_footer` +- All 4 artifact types have `auto_gen_footer=True` ______________________________________________________________________ -## CLI pipeline (`src/vstack/cli/`) +## 6. CLI command contracts ```bash vstack [--version] [--help] [options] ``` +Exit codes across all commands: + +| Code | Meaning | +| ---- | --------------------------------------------------------------------------- | +| `0` | Success; all requested operations completed without conflict | +| `1` | Operational failure: validation error, conflict, schema mismatch, I/O error | +| `2` | Invalid argument syntax (raised by `argparse`, not vstack) | + +Error and warning output is command-specific: + +```text +stdout: status summaries, install/uninstall progress, verify checks +stderr: parser errors and selected command failures (for example legacy manifest read errors) +``` + +`--no-color` suppresses ANSI codes. + ### `validate` -Render all templates in memory and report unresolved tokens. No files written. Exits 1 on error. +Render all templates in memory; report unresolved tokens. No files written. + +```bash +vstack validate [--only ...] +``` | Flag | Description | | ------------------ | -------------------------------------------------------- | | `--only ...` | Restrict to specific artifact types, e.g. `--only skill` | +Output: one line per error. Exit 0 = all valid, exit 1 = one or more errors. + ### `verify` -Validate source templates and/or installed output (schema checks, required tokens, file presence). +Validate source templates and/or installed output (schema, tokens, presence, checksum +drift). + +```bash +vstack verify [--target ] [--global] [--no-source] [--no-output] +``` + +Checks in order: + +1. Source template schema validation (unless `--no-source`) +1. Source template token resolution (unless `--no-source`) +1. Output file presence against manifest (unless `--no-output`) +1. Checksum drift against manifest (unless `--no-output`) -| Flag | Description | -| ---------------- | ---------------------------------------------------------- | -| `--target ` | Check output in `/.github/` instead of the repo's own | -| `--global` | Check the VS Code user profile directory | -| `--no-source` | Skip source template checks | -| `--no-output` | Skip installed output checks | +### `status` + +Read-only artifact state report. No files written. + +```bash +vstack status [--target ] [--global] [--only ] + [--format text|json|yaml] [--verbose] [--no-color] +``` + +`text` output (default) contains per-type summary lines and optional entry lines: + +```text +skill:architecture clean +skill:debug modified (checksum drift) +agent:engineer missing +``` + +`json` output shape: + +```json +{ + "ok": true, + "install_dir": "/path/to/.github", + "types": [ + { + "type": "skill", + "counts": { + "managed": 27, + "managed_legacy": 0, + "modified": 0, + "missing": 0, + "unknown": 0, + "untracked": 0, + "absent": 0 + }, + "issues": 0, + "warnings": 0, + "entries": [] + } + ], + "summary": { + "issues": 0, + "warnings": 0, + "types_checked": 1 + } +} +``` + +Exit 0 = all artifacts clean; exit 1 = any artifact not clean. ### `install` -Install artifacts into the target `.github/` directory. Skips artifacts already at the current version by default. +Write artifact files to the target directory and record checksums in `vstack.json`. + +```bash +vstack install [--target ] [--global] [--only ...] + [--force] [--force-name ] [--adopt-name ] + [--update] [--dry-run] +``` + +Install decision matrix per artifact: + +| File state | default | `--force` | `--update` | `--force-name ` | `--adopt-name ` | +| ---------- | ------- | --------- | ---------- | ------------------ | ------------------ | +| absent | write βœ“ | write βœ“ | write βœ“ | write βœ“ | write βœ“ | +| untracked | skip | overwrite | skip | overwrite (named) | adopt (named) | +| clean | write βœ“ | write βœ“ | write βœ“ | overwrite (named) | β€” | +| modified | skip | overwrite | skip | overwrite (named) | β€” | +| missing | write βœ“ | write βœ“ | write βœ“ | write βœ“ | β€” | -| Flag | Description | -| ------------------ | --------------------------------------------------------------------------------------- | -| `--target ` | Install into `/.github/` instead of the repo's own | -| `--global` | Install into the VS Code user profile directory | -| `--only ...` | Restrict to specific artifact types, e.g. `--only skill agent` | -| `--force` | Overwrite all artifacts regardless of installed version | -| `--update` | Overwrite only when the template version is newer *(mutually exclusive with `--force`)* | -| `--dry-run` | Print what would change without writing any files | +`--force` and `--update` are mutually exclusive. +`--dry-run` prints what would change without writing any files; exit code reflects what +would have happened. ### `uninstall` -Remove all artifacts tracked in `vstack.json`. User-placed files are not touched. +Remove artifacts recorded in `vstack.json`. Modified files are preserved by default. + +```bash +vstack uninstall [--target ] [--global] [--only ...] + [--force] [--force-name ] +``` + +Removal rules: + +- `clean` β†’ deleted +- `modified` β†’ skipped (unless `--force` or `--force-name`) +- `missing` β†’ entry removed from manifest, no file operation + +### `manifest status` / `manifest verify` + +Same contracts as top-level `status` and `verify`, scoped to manifest-tracked artifacts. + +### `manifest upgrade` + +Migrate legacy `vstack.json` (version < 2) to `manifest_version: 2`. + +```bash +vstack manifest upgrade [--target ] [--backfill] +``` + +This is the only command that calls `ManifestFile.read(allow_legacy=True)`. + +| Flag | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `--backfill` | Compute and store SHA-256 checksums for tracked entries with no checksum, gated by `VSTACK-META` identity check. Opt-in; no default. | + +Without `--backfill`, `manifest upgrade` performs a schema-only migration: `manifest_version` is promoted to `2`, `hash_algorithm` is set, and missing `checksum_algorithm` values are inferred for entries that already carry a checksum. No checksums are computed or written. + +With `--backfill`, the call sequence after schema migration is: + +1. `manifest.upgraded()` β€” schema migration (always). +1. `manifest.with_backfilled_checksums(install_dir)` β€” checksum backfill (only when `--backfill` is set). +1. `manifest_file.write(result)` β€” atomic write of the final manifest (ADR-016). + +`--backfill` is a one-way operation: current on-disk content becomes the canonical checksum baseline for backfilled entries. Entries whose files contain the `VSTACK-META` footer are promoted from `managed-legacy` to `clean`. Entries whose files exist but lack the footer remain `managed-legacy`; a warning is emitted for each. Entries whose files are absent are silently skipped. + +| Exit code | Meaning | +| --------- | -------------------------------------------------------------------------- | +| `0` | Manifest upgraded (and backfilled if `--backfill`; partial backfill is OK) | +| `1` | Manifest missing, unreadable, or parse error | -| Flag | Description | -| ---------------- | --------------------------------------------------------- | -| `--target ` | Uninstall from `/.github/` instead of the repo's own | -| `--global` | Uninstall from the VS Code user profile directory | +______________________________________________________________________ + +## 7. error contract + +### 7.1 exception table + +| Condition | Runtime behavior | +| ----------------------------- | --------------------------------------------------------------------------- | +| Missing `vstack.json` | `ManifestFile.read()` returns `None`; `read_error` is `None` | +| Invalid `vstack.json` JSON | `ManifestFile.read()` returns `None`; `read_error` = invalid format message | +| Legacy schema on read | `ManifestFile.read()` returns `None`; `read_error` = upgrade guidance | +| Unknown artifact type lookup | `CommandService.gen_for()` returns `None` | +| File I/O during state checks | `artifact_control_state()` returns `("unknown", message)` | +| CLI scope validation failures | `ValueError` raised in scope resolver; handled in `src/vstack/main.py` | + +Top-level error mapping for CLI process exit lives in `src/vstack/main.py`. + +### 7.2 validation errors vs operational errors + +- **Validation results** are typically collected in `ValidationResult` / `CheckMessage` + and rendered at command level. +- **Operational failures** are reported either through return codes and stderr output or + via `read_error` diagnostics on manifest reads. + +______________________________________________________________________ + +## 8. generator execution flow + +```mermaid +flowchart TD + A[Discover template directories
skip underscore-prefixed dirs] --> B[Load per-template source] + B --> C[Validate frontmatter against schema] + C --> D[Load partials when configured] + D --> E[Resolve placeholder tokens] + E --> F{Unresolved tokens?} + F -->|yes| Z[Marked on RenderedArtifact.unresolved] + F -->|no| G[Inject frontmatter and auto-gen footer] + G --> H{Command mode} + H -->|install| I[Write output via ManifestFile.write β€” atomic] + H -->|validate or verify| J[Report errors; no files written] +``` + +______________________________________________________________________ + +## 9. CLI command dispatch flow + +```mermaid +flowchart TD + A[sys.argv] --> B[CommandLineParser.build] + B --> C[args = parser.parse_args] + C --> D[CommandService created with templates_root] + D --> E[build_command_registry β†’ nameβ†’BaseCommand map] + C --> F[resolve install_dir and only scope] + E --> G[command.run(args, install_dir, only)] + F --> G + G --> H[BaseCommand.execute classmethod] + H --> I[service.generators / service.label / service.manifest_for / service.artifact_control_state] +``` ______________________________________________________________________ -The manifest (`vstack.json`) tracks every artifact installed by `install` so that -`uninstall` can remove exactly those files without touching anything placed there -manually. +## 10. CLI component table + +| Module | Class / function | Responsibility | +| -------------- | -------------------------- | --------------------------------------------------------------------------------------- | +| `interface.py` | `CommandLineInterface` | Facade: parser construction, service creation, target/scope resolution, dispatch | +| `registry.py` | `build_command_registry` | Maps command names to `BaseCommand` instances | +| `service.py` | `CommandService` | Shared coordinator: generators, path labelling, manifest access, artifact state | +| `base.py` | `BaseCommand` | ABC: all handlers implement `run(args, install_dir, only) β†’ int` | +| `install.py` | `InstallCommand` | Install flow: per-artifact write, checksum recording, dry-run, force/adopt/update modes | +| `verify.py` | `VerifyCommand` | Source + output verification: schema, tokens, presence, checksum drift | +| `status.py` | `StatusCommand` | Read-only report across text, JSON, and YAML output formats | +| `uninstall.py` | `UninstallCommand` | Remove tracked artifacts; preserve modified files unless explicitly forced | +| `validate.py` | `ValidateCommand` | In-memory template render and token resolution check; no files written | +| `manifest.py` | `ManifestCommand` | Dispatch `manifest status / verify / upgrade` subcommands | +| `parser.py` | `CommandLineParser` | Build and configure the `argparse` argument tree | +| `helpers.py` | Module-level functions | Shared install/uninstall helpers: name normalization, manifest preservation utilities | +| `constants.py` | `Colors`, `KNOWN_TYPES`, … | ANSI codes, artifact-type registry, canonical name lists, expected-name guards | ______________________________________________________________________ -## design principles +## 11. design principles -1. **stdlib only at runtime.** No runtime dependencies beyond Python β‰₯ 3.11 stdlib β€” `pyyaml`, `jinja2`, etc. are intentionally absent. Dev dependencies (pytest, ruff, mypy) are install-time only. +1. **stdlib only at runtime.** No runtime dependencies beyond Python β‰₯ 3.11 stdlib β€” `pyyaml`, `jinja2`, etc. are intentionally absent. 1. **Templates are source of truth.** No generated files live in `src/vstack/_templates/`. -1. **Flat resolver.** No template inheritance, no conditionals β€” keep it simple. -1. **Install-time output.** Generated artifacts belong in `.github/skills/` and `.github/agents/`, - never committed to the vstack source repo. +1. **Flat resolver.** No template inheritance, no conditionals β€” literal substitution only. +1. **Install-time output.** Generated artifacts belong under `.github/`; never committed to the vstack source repo. 1. **Idempotent.** Running the generator twice with the same `--target` produces identical output. +1. **Conservative by default.** Untracked and modified files are never overwritten without an explicit escape-hatch flag. +1. **Atomic manifest writes.** `vstack.json` is never left in a partially-written state (staged via `.tmp` + `os.replace`). +1. **Explicit upgrade gate.** Legacy manifest schemas are rejected with an actionable hint; never silently migrated. diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..41dfc1f --- /dev/null +++ b/docs/performance-baseline.md @@ -0,0 +1,83 @@ +# Performance Baseline + +**Branch:** `feat/improved_cli` +**Date:** 2026-04-26 +**Scope:** CLI hot-path operations β€” parser build, target resolution, registry build; post-backfill feature addition +**Method:** `timeit.repeat` micro-benchmarks (Python 3.13.12, Linux) + +______________________________________________________________________ + +## Verdict + +> **No regressions detected.** All measured operations are sub-2ms. The CLI is a local dev tool with no throughput or latency SLAs β€” these baselines exist to catch accidental regressions from future changes. + +______________________________________________________________________ + +## Benchmark Results + +All times in **milliseconds (ms)** per single call. + +| Operation | Mean | Min | Max | Stdev | Iterations | +| ---------------------------------------------- | -------- | -------- | -------- | ----- | ---------- | +| `CommandLineParser().build()` + `parse_args()` | 1.852 ms | 1.783 ms | 2.018 ms | β€” | 5 Γ— 1000 | +| `parser.resolve_targets(args)` | 0.009 ms | 0.008 ms | 0.012 ms | β€” | 5 Γ— 1000 | +| `build_command_registry(svc)` | 0.002 ms | 0.002 ms | 0.002 ms | β€” | 5 Γ— 1000 | +| Cold `import vstack` | 69.6 ms | 66.0 ms | 75.9 ms | β€” | 5 Γ— 1 | + +______________________________________________________________________ + +## Notes + +### CLI parser build + parse (~1.85 ms) + +`CommandLineParser().build()` constructs the full argparse tree β€” 6 top-level commands and all manifest subcommands β€” followed by `parse_args()`. The `CommandLineParser()` constructor itself is essentially a no-op (\<0.001 ms); the build step is what dominates. At ~1.85 ms end-to-end, this remains well within acceptable startup overhead for an interactive CLI. + +No regression from the backfill feature addition: the new `--backfill` flag on the `manifest upgrade` subcommand adds one `add_argument` call, which is negligible at this scale. + +### Target resolution (\<0.01 ms) + +`resolve_targets` is essentially free β€” filesystem path manipulation with a single `Path.exists()` check in the `--global` path. No blocking I/O in the default (CWD) path. + +### Registry build (\<0.01 ms) + +`build_command_registry` constructs the command map from the catalog. The catalog-driven approach (dict comprehension over ~8 entries) is negligible. + +### Cold import (~70 ms) + +The first `import vstack` in a fresh Python process takes ~70 ms on average (59–80 ms observed across 5 runs). This includes: + +- `subprocess` call to `git tag --points-at HEAD` (in `constants.py`) for version detection +- Stdlib imports (`pathlib`, `argparse`, `importlib.metadata`, `re`, `subprocess`, `yaml`) + +This is the dominant startup cost. The git subprocess call is the likely bottleneck. For a CLI tool this is acceptable β€” it runs once at startup. + +______________________________________________________________________ + +## Thresholds + +These are soft baselines for regression detection, not hard SLAs. + +| Operation | Regression threshold | +| ----------------------------- | -------------------- | +| `CommandLineParser().build()` | > 5 ms | +| `resolve_targets` | > 1 ms | +| `build_command_registry` | > 1 ms | +| Cold import | > 500 ms | + +If a future change causes a measured value to exceed these thresholds, investigate before merging. + +______________________________________________________________________ + +## Test Suite Wall Time + +``` +342 tests passed in 4.24s (pytest, with coverage) +``` + +Acceptable. No slow test outliers observed. + +______________________________________________________________________ + +## Profiling Notes + +No `pytest-benchmark` is installed in this environment; measurements were taken with `timeit.repeat`. If regression detection becomes a CI requirement, add `pytest-benchmark` to dev dependencies and convert the `timeit` measurements to parametrised benchmark tests. diff --git a/docs/product/requirements.md b/docs/product/requirements.md index b6ecd08..7831834 100644 --- a/docs/product/requirements.md +++ b/docs/product/requirements.md @@ -1,12 +1,136 @@ # requirements -> Maintained by: **product** role -> Last updated: 2026-04-20 +> Maintained by: **product** role\ +> Last updated: 2026-04-26 -Status: draft. +______________________________________________________________________ -This document captures the functional requirements, non-functional requirements, -success criteria, and constraints for the current scope. +## context -It is owned by the product role and should be updated whenever scope, -acceptance criteria, or constraints change. +vstack is a VS Code-native AI engineering workflow system. It installs structured +agents, skills, instructions, and prompts into `.github/` so GitHub Copilot Agent +Mode has a clear operating model. vstack is distributed as a standalone Python CLI +tool (`pipx install vstack`) with no runtime dependencies beyond the Python standard +library. + +______________________________________________________________________ + +## functional requirements + +### FR-1 β€” template-driven artifact generation + +- Source templates live under `src/vstack/_templates///`. +- Supported artifact types: `skill` (`.github/skills//SKILL.md`), `agent` + (`.github/agents/.agent.md`), `instruction` + (`.github/instructions/.instructions.md`), `prompt` + (`.github/prompts/.prompt.md`). +- At generation time the generator resolves `{{TOKEN}}` placeholders from shared + partials and injects frontmatter validated against the artifact schema. +- Unresolved tokens are flagged as errors by `validate` and `install`. + +### FR-2 β€” install targets + +- `--target DIR` installs artifacts into `DIR/.github/`. +- `--global` installs artifacts into the VS Code user profile directory. +- Both targets are mutually exclusive per invocation. + +### FR-3 β€” manifest tracking + +- `vstack install` writes a `vstack.json` manifest to the install root tracking + every installed artifact (name, relative path, version, SHA-256 checksum, and + algorithm). +- The manifest schema is versioned; older schemas must be explicitly upgraded with + `vstack manifest upgrade`. + +### FR-4 β€” file protection and install modes + +- Untracked files in the target directory are never overwritten. +- Tracked artifacts are only rewritten when their on-disk checksum matches the + stored manifest checksum (i.e., the file has not been locally modified), unless + an explicit override flag is provided. +- `--force` overwrites all tracked artifacts regardless of local modifications. +- `--force-name ` overwrites one named artifact regardless of local modifications. +- `--adopt-name ` starts tracking an existing unmanaged file without rewriting + its content. +- `--update` rewrites only when a newer version is available and the tracked file is + still clean. Mutually exclusive with `--force`. +- `--dry-run` prints what would change without writing any files. + +### FR-5 β€” uninstall + +- `vstack uninstall` removes only tracked artifacts whose current checksum still + matches the stored manifest entry. +- Modified tracked files are preserved unless `--force` or `--force-name` is provided. + +### FR-6 β€” validation + +- `vstack validate` renders all source templates in memory and reports unresolved + tokens. No files are written. Exits 1 on any error. +- `--only ...` restricts validation to specific artifact types. + +### FR-7 β€” verification + +- `vstack verify` checks source templates (schema, required tokens, structure) and + installed output (file presence, checksum drift). +- `--target DIR` / `--global` selects the install root for output checks. +- `--no-source` skips source template checks. +- `--no-output` skips installed output checks. + +### FR-8 β€” status reporting + +- `vstack status` inspects installed artifacts against `vstack.json` ownership and + checksum state without writing files. +- Output formats: `text` (compact default with color markers), `json`, `yaml`. +- `--verbose` includes managed entries; `--no-color` disables ANSI colors. + +### FR-9 β€” manifest subcommands + +- `vstack manifest status` β€” manifest-scoped status for installed output. +- `vstack manifest verify` β€” manifest-scoped verify for installed output only. +- `vstack manifest upgrade` β€” upgrade legacy `vstack.json` schema to current format. + +### FR-10 β€” role model + +- 6 fixed agent roles: `product`, `architect`, `designer`, `engineer`, `tester`, + `release`. +- 27 backend-oriented skills with canonical names enforced at source-verify time. + +______________________________________________________________________ + +## non-functional requirements + +| ID | Requirement | +| ----- | ------------------------------------------------------------------------------------------------------ | +| NFR-1 | No runtime dependencies beyond the Python standard library. | +| NFR-2 | Python 3.11–3.14 compatibility. | +| NFR-3 | Manifest writes are atomic: write to a temporary file, then replace atomically. | +| NFR-4 | All public behavior exercised by automated tests (pytest). CI gate enforces test pass. | +| NFR-5 | CLI operates standalone; no VS Code process required for `install`, `verify`, or `validate`. | +| NFR-6 | Lint (ruff) and type checking pass on every commit. CI gate enforces zero violations. | +| NFR-7 | Generated output lives under `.github/` only; source templates under `_templates/` are never modified. | + +______________________________________________________________________ + +## success criteria + +1. `vstack install --target DIR` installs all artifacts and writes `vstack.json` with + correct checksums. +1. `vstack uninstall --target DIR` removes only tracked artifacts with matching + checksums; modified files are preserved. +1. `vstack verify --target DIR` reports zero errors on a clean install. +1. `vstack validate` exits 0 when all source templates resolve cleanly. +1. Locally modified tracked files are preserved on re-install by default (FR-4). +1. All 27 canonical skill names are present after a full install. +1. `vstack manifest upgrade` migrates a legacy manifest without data loss. + +______________________________________________________________________ + +## constraints + +1. No external runtime dependencies (stdlib-only at runtime). +1. Generated output lives under `.github/` only; source templates are never modified + at runtime. +1. Manifest schema is versioned; old manifests require explicit upgrade before further + use. +1. CLI behavior must be reproducible across Python 3.11–3.14. +1. No cloud, network, or VS Code process dependency for CLI operations. diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index bf72f0b..99a619c 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -13,7 +13,7 @@ ______________________________________________________________________ | backend-first verification | shipped | Verify/inspect focus on contracts, observability, security | | VS Code agent migration | shipped | Native agent output format implemented | | role model + doc restructure | shipped | 6-role model and docs baseline established | -| new skill scaffolding | shipped | 20-skill set with canonical naming | +| new skill scaffolding | shipped | 27-skill set with canonical naming | | agent skill wiring | shipped | Role-to-skill mapping and handoffs are present | | optional orchestrated role pipeline | candidate | Optional future model, only if coordination bottlenecks appear | | multi-IDE support (IntelliJ first) | candidate | Not planned before v1 stabilization | @@ -31,7 +31,7 @@ Legend: shipped = implemented and available; candidate = optional future feature ### foundation [shipped] - template-driven generation with source under `src/vstack/_templates/` and install output under `.github/` -- 19 backend-oriented skills +- 27 backend-oriented skills - generated install manifest (`.github/vstack.json`) tracking installed artifacts - VS Code prompt file (`.prompt.md`) support - global install workflow (`vstack install --global`) @@ -59,7 +59,7 @@ Legend: shipped = implemented and available; candidate = optional future feature ### new skill scaffolding [shipped] -20 skills across 6 roles. New additions: +27 skills across 6 roles. New additions: - `requirements`, `adr`, `analyse` (new) - Renames: `experience` β†’ `consult`, `design-consult` β†’ `design`, `docs-release` β†’ `docs`, `discovery` β†’ `explore` diff --git a/docs/releases/2026-04-26.md b/docs/releases/2026-04-26.md new file mode 100644 index 0000000..5e20b59 --- /dev/null +++ b/docs/releases/2026-04-26.md @@ -0,0 +1,62 @@ +# Release 2026-04-26 β€” v2.0.0 + +## Summary + +vstack 2.0.0 replaces the monolithic `commands.py` CLI module with a clean, +module-per-command architecture and extracts manifest persistence into its own +`vstack.manifest` package. Import paths for CLI classes have changed β€” this is a +breaking release. The `vstack manifest upgrade --backfill` command is now available +for retroactively adding checksums to legacy manifest entries. + +## Breaking changes + +- `vstack.cli.commands` is removed. `CommandService` and command classes now live in + dedicated modules under `vstack.cli` (`service`, `install`, `verify`, `validate`, + `status`, `uninstall`, `manifest`, `report`). +- Manifest persistence classes (`Manifest`, `ManifestFile`, `ArtifactEntry`, etc.) + have moved to the new `vstack.manifest` package (`vstack.manifest.store`). +- Any code that imported directly from `vstack.cli.commands` must update its imports. + +## What's new + +- `vstack manifest upgrade --backfill`: retroactively compute and write checksums for + tracked manifest entries that have no checksum (useful after migrating from legacy + manifests). +- CLI architecture split across 12 focused modules: `base`, `catalog`, `helpers`, + `registry`, `service`, `interface`, `install`, `verify`, `validate`, `status`, + `uninstall`, `report` β€” each independently testable and importable. +- New `vstack.manifest` package with `Manifest`, `ManifestFile`, `ArtifactEntry`, + `content_hash`, and schema-versioned read/write logic. + +## Fixed + +- `_version_gt` in `InstallCommand` no longer passes `None` when `existing` is absent; + falls back to `"0"` for comparison, eliminating a `TypeError` on first install. + +## Internal + +- Full test suite restructured: one test file per source module, `TestClass` per class, + `test_function` per standalone function. Catch-all files (`test_commands.py`, + `test_coverage_blockers.py`) deleted. Test count: 288 β†’ 342. +- mypy added to the CI quality gate β€” 106 files, 0 errors. +- End-to-end integration tests consolidated into `tests/vstack/test_integration.py`. +- 4 new ADRs documenting manifest schema versioning (014), conservative install + defaults (015), atomic manifest writes (016), and checksum backfill (017). + +## Artifacts reviewed + +| Artifact | Status | +| ----------------------------------- | ----------------------------------------- | +| `docs/product/requirements.md` | βœ“ | +| `docs/architecture/architecture.md` | βœ“ | +| `docs/design/design.md` | βœ“ | +| `docs/test-report.md` | βœ“ updated (342/342, 100%) | +| `docs/security-report.md` | βœ“ PASS with notes (no blockers) | +| `docs/performance-baseline.md` | βœ“ present (not in scope for this release) | + +## Sign-offs + +| Role | Sign-off | +| ------- | ----------------------------------------------------- | +| Tester | OK β€” 342/342 passed, 100% coverage, ruff + mypy clean | +| Product | OK β€” full CLI refactor + backfill feature delivered | diff --git a/docs/security-report.md b/docs/security-report.md new file mode 100644 index 0000000..66da230 --- /dev/null +++ b/docs/security-report.md @@ -0,0 +1,134 @@ +# Security Report + +**Branch:** `feat/improved_cli` +**Date:** 2026-04-26 +**Scope:** Full source tree β€” static analysis (bandit) + dependency audit (pip-audit); security fixes for S-001 (assert guards) and S-002 (subprocess nosec) +**Method:** OWASP Top 10 + STRIDE (static analysis on a local CLI tool; no network surface, no auth surface, no DB) + +______________________________________________________________________ + +## Verdict + +| Category | Findings | Blocking | +| ------------------------ | --------------------------- | ---------------------------------------- | +| Static analysis (bandit) | 1 LOW | No β€” informational; import advisory only | +| Dependency CVEs | 1 (pip, LOW) | No β€” dev/build tooling only | +| Secrets in source | None | β€” | +| Injection risk | None identified | β€” | +| Auth / access control | N/A (local CLI, no network) | β€” | + +> **Ship readiness: PASS with notes** β€” no blocking security findings. Advisory items documented below. + +______________________________________________________________________ + +## OWASP Top 10 Assessment + +This is a **local CLI tool** β€” no web server, no user sessions, no database, no network endpoints. Most OWASP categories are not applicable. Relevant categories are assessed below. + +| # | Category | Status | Notes | +| --- | ------------------------- | -------- | --------------------------------------------------------------------------- | +| A01 | Broken Access Control | N/A | Local filesystem operations only | +| A02 | Cryptographic Failures | PASS | No cryptographic operations in source | +| A03 | Injection | PASS | Subprocess uses fixed list args (no shell=True, no user-interpolated input) | +| A04 | Insecure Design | PASS | No privileged operations, no credential storage | +| A05 | Security Misconfiguration | PASS | No config files with secrets; no exposed ports | +| A06 | Vulnerable Components | ADVISORY | pip 26.0.1 has CVE-2026-3219 (dev tooling, not shipped) | +| A07 | Auth / Identity Failures | N/A | No authentication surface | +| A08 | Software/Data Integrity | PASS | Checksums used for artifact validation in install/uninstall | +| A09 | Logging Failures | PASS | No sensitive data logged | +| A10 | SSRF | N/A | No HTTP client usage | + +______________________________________________________________________ + +## Static Analysis Findings (bandit) + +``` +bandit -r src/ +Issues: 1 HIGH=0 MED=0 LOW=1 + [LOW] B404 vstack/constants.py:6 β€” Consider possible security implications associated with the subprocess module. +``` + +### S-001 β€” `assert` used for runtime validation (RESOLVED) + +**File:** `src/vstack/cli/report.py:224, 241, 243` (fixed) + +**Resolution:** Replaced all three `assert isinstance(...)` guards with explicit `if not isinstance(...): raise TypeError(...)` guards. These now execute correctly in optimised builds (`python -O`) and are no longer flagged by bandit. + +______________________________________________________________________ + +### S-002 β€” subprocess call flagged (RESOLVED) + +**File:** `src/vstack/constants.py:53` (fixed) + +**Resolution:** Added `# nosec B603 B607` comment at the `subprocess.check_output(...)` call to suppress the advisory and make the review decision explicit. The call uses a fixed argument list (`["git", "-C", str(repo_root), "tag", "--points-at", "HEAD"]`), no `shell=True`, and no user-controlled input. It remains safe. + +The remaining bandit finding (B404 at line 6) is an **import-level advisory** with no associated code risk. It cannot be suppressed without disabling B404 globally. + +______________________________________________________________________ + +### S-003 (residual) β€” B404 subprocess import (LOW, informational) + +**File:** `src/vstack/constants.py:6` + +```python +import subprocess +``` + +Bandit flags any file that imports `subprocess`. This is a blanket informational note, not a finding tied to unsafe usage. The actual call (line 53) is safe and suppressed via `# nosec B603 B607`. + +**Severity:** LOW β€” informational; not actionable. + +______________________________________________________________________ + +## Dependency Audit (pip-audit) + +``` +pip-audit result: + Name Version ID Fix Versions + pip 26.0.1 CVE-2026-3219 (none listed) +``` + +### CVE-2026-3219 β€” pip tar+ZIP dual-format handling + +pip processes concatenated tar+ZIP archives as ZIP regardless of filename, potentially leading to incorrect file installation from ambiguous archives. + +**Assessment:** This affects `pip` itself as a **build/dev tool**, not vstack's shipped package or any runtime dependency. vstack has no runtime dependencies beyond Python stdlib. Users installing vstack from PyPI are not exposed by this CVE in any production path. + +**Recommendation:** Upgrade pip in the development virtualenv (`pip install --upgrade pip`) once a fixed version is released. Track the CVE for a fix version. + +**Severity:** LOW β€” dev tooling only; not a shipping blocker. + +______________________________________________________________________ + +## STRIDE Assessment + +| Threat | Relevant surface | Finding | +| ---------------------- | ------------------------------------------------- | ------------------------------------- | +| Spoofing | No network/auth | N/A | +| Tampering | Artifact checksums verified on uninstall | PASS | +| Repudiation | No audit trail for local file ops | LOW β€” acceptable for a local dev tool | +| Information disclosure | No secrets stored, no network calls | PASS | +| Denial of service | No resource-intensive loops exposed to user input | PASS | +| Elevation of privilege | No sudo/elevated ops | PASS | + +______________________________________________________________________ + +## Secrets Scan + +``` +grep -r -E '(password|secret|api_key|token)\s*[=:]\s*["'\''][^"'\'']{8,}' β†’ No matches in src/ +``` + +No hardcoded secrets found. + +______________________________________________________________________ + +## Summary of Advisory Items + +| ID | Severity | File | Action | +| ----- | -------- | --------------------------- | ------------------------------------------------------------------ | +| S-001 | LOW | `cli/report.py:224,241,243` | Replace `assert` with explicit `TypeError` guards | +| S-002 | LOW | `constants.py:53` | Add `# nosec B603 B607` to suppress false-positive bandit advisory | +| S-003 | LOW | `pip 26.0.1` | Upgrade pip in dev venv when fix is available | + +None of these items block release. diff --git a/docs/test-report.md b/docs/test-report.md new file mode 100644 index 0000000..117946f --- /dev/null +++ b/docs/test-report.md @@ -0,0 +1,89 @@ +# Test Report + +**Branch:** `feat/improved_cli` +**Date:** 2026-04-26 +**Scope:** Full repository β€” CLI refactor (`catalog`, `report`, `registry`, `parser`, `interface`, `service`, `helpers`); manifest checksum backfill feature (`manifest/store.py`, `cli/service.py`, `cli/parser.py`, `cli/manifest.py`); security fixes (`cli/report.py` assert guards, `constants.py` nosec); **full test suite restructure** (per-module test files, TestClass layout, 342 tests) + +______________________________________________________________________ + +## Verdict + +| Dimension | Result | +| ------------- | ------------------------------------------- | +| Functional | **PASS** β€” 342/342 tests green | +| Lint / Style | **PASS** β€” ruff clean | +| Type checking | **PASS** β€” mypy clean (106 files, 0 errors) | +| Coverage | **PASS** β€” 100.00% (fail-under=100) | +| Security | See `docs/security-report.md` | +| Performance | See `docs/performance-baseline.md` | + +> **Ship readiness: READY** β€” all verification gates currently pass. + +______________________________________________________________________ + +## Test Execution + +``` +platform: linux, Python 3.13.12-final-0 +runner: pytest 9.0.3 + pytest-cov 7.1.0 +command: pytest -q +342 passed in 4.24s +``` + +All tests pass. No flaky, skipped, or xfail tests observed. + +______________________________________________________________________ + +## Coverage Summary + +Total: 100.00% β€” 0 missed statements across 1,828 measured + +`fail-under=100` is configured in `pyproject.toml`. This gate is **passing**. + +All modules are now at 100% statement coverage. + +### Resolution summary + +Blockage coverage findings were resolved by adding targeted unit tests for: + +- per-module test files replacing the `test_commands.py` / `test_coverage_blockers.py` catch-all (test count: 288 β†’ 342) +- manifest subcommand dispatch and missing-action path +- service wrappers and `manifest_upgrade` success/error branches +- parser config guard rails (`scope_help` / `only_help` validation) +- interface scope resolution edge cases +- report YAML/JSON/text rendering branches +- command `run()` wrappers and context forwarding +- manifest-store defensive parsing branches +- install/verify/uninstall error-path behavior +- `manifest upgrade --backfill` branches: missing file, unreadable file, no VSTACK-META footer, existing checksum, unknown algorithm fallback + +______________________________________________________________________ + +## Lint and Type Checking + +``` +ruff check src tests β†’ All checks passed! +python -m mypy src tests β†’ Success: no issues found in 106 source files +``` + +No lint or type findings. + +______________________________________________________________________ + +## Syntax + +``` +python -m py_compile β†’ All checked files compile cleanly +``` + +______________________________________________________________________ + +## Blocking Issues + +No blocking test findings remain. + +______________________________________________________________________ + +## Handoff + +Continue monitoring this area by extending tests whenever new command branches or parser flags are introduced, to preserve the 100% coverage gate. diff --git a/src/vstack/cli/base.py b/src/vstack/cli/base.py new file mode 100644 index 0000000..d877f4d --- /dev/null +++ b/src/vstack/cli/base.py @@ -0,0 +1,35 @@ +"""Base command contract for CLI command handlers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from argparse import Namespace +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class CommandContext: + """Runtime context passed to every CLI command handler.""" + + args: Namespace + install_dir: Path | None + only: list[str] | None + + def require_install_dir(self, command_name: str) -> Path: + """Return install_dir or raise when the command requires one.""" + if self.install_dir is None: + raise ValueError(f"{command_name} requires install_dir") + return self.install_dir + + +class BaseCommand(ABC): + """Abstract base class for top-level CLI command handlers.""" + + @abstractmethod + def run( + self, + *, + context: CommandContext, + ) -> int: + """Execute the command and return a process-style status code.""" diff --git a/src/vstack/cli/catalog.py b/src/vstack/cli/catalog.py new file mode 100644 index 0000000..65c0eec --- /dev/null +++ b/src/vstack/cli/catalog.py @@ -0,0 +1,146 @@ +"""Central command catalogs shared by parser, registry, and interface.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from vstack.cli.base import BaseCommand +from vstack.cli.install import InstallCommand +from vstack.cli.manifest import ManifestCommand +from vstack.cli.status import StatusCommand +from vstack.cli.uninstall import UninstallCommand +from vstack.cli.validate import ValidateCommand +from vstack.cli.verify import VerifyCommand + +if TYPE_CHECKING: + from vstack.cli.service import CommandService + + +@dataclass(frozen=True) +class TopLevelCommandConfig: + """Configuration for one top-level CLI command.""" + + command_factory: Callable[[CommandService], BaseCommand] + help_text: str + requires_install_dir: bool + resolve_only_for_scope: bool + include_scope_group: bool + include_only_option: bool + scope_help: str | None = None + only_help: str | None = None + include_status_output_options: bool = False + + +@dataclass(frozen=True) +class ManifestSubcommandConfig: + """Configuration for one ``vstack manifest `` subcommand.""" + + help_text: str + scope_help: str + include_only_option: bool + only_help: str | None = None + include_status_output_options: bool = False + + +TOP_LEVEL_COMMAND_ORDER: tuple[str, ...] = ( + "validate", + "verify", + "status", + "manifest", + "install", + "uninstall", +) + + +COMMAND_CATALOG: dict[str, TopLevelCommandConfig] = { + "validate": TopLevelCommandConfig( + command_factory=ValidateCommand, + help_text="Validate source templates only; does not inspect installed files", + requires_install_dir=False, + resolve_only_for_scope=False, + include_scope_group=False, + include_only_option=True, + only_help="Validate only these artifact types, e.g. --only skill agent", + ), + "verify": TopLevelCommandConfig( + command_factory=VerifyCommand, + help_text="Validate source templates and installed output against the manifest", + requires_install_dir=True, + resolve_only_for_scope=True, + include_scope_group=True, + include_only_option=True, + scope_help="Verify output in /.github/", + only_help="Verify only these artifact types, e.g. --only agent prompt", + ), + "status": TopLevelCommandConfig( + command_factory=StatusCommand, + help_text="Inspect installed artifacts against vstack.json checksums and manifest ownership", + requires_install_dir=True, + resolve_only_for_scope=True, + include_scope_group=True, + include_only_option=True, + scope_help="Inspect output in /.github/", + only_help="Inspect only these artifact types, e.g. --only skill agent", + include_status_output_options=True, + ), + "manifest": TopLevelCommandConfig( + command_factory=ManifestCommand, + help_text="Manage manifest lifecycle (upgrade, status, verify)", + requires_install_dir=True, + resolve_only_for_scope=True, + include_scope_group=False, + include_only_option=False, + ), + "install": TopLevelCommandConfig( + command_factory=InstallCommand, + help_text="Generate and install artifacts (--only to filter types)", + requires_install_dir=True, + resolve_only_for_scope=True, + include_scope_group=True, + include_only_option=True, + scope_help="Install into /.github/", + only_help="Install only these artifact types, e.g. --only skill agent", + ), + "uninstall": TopLevelCommandConfig( + command_factory=UninstallCommand, + help_text="Safely remove vstack-managed files tracked in the manifest", + requires_install_dir=True, + resolve_only_for_scope=True, + include_scope_group=True, + include_only_option=True, + scope_help="Uninstall from /.github/", + only_help="Uninstall only these artifact types, e.g. --only skill agent", + ), +} + + +MANIFEST_SUBCOMMAND_ORDER: tuple[str, ...] = ( + "upgrade", + "status", + "verify", +) + + +MANIFEST_SUBCOMMAND_CATALOG: dict[str, ManifestSubcommandConfig] = { + "upgrade": ManifestSubcommandConfig( + help_text="Upgrade legacy vstack.json schema to current version", + scope_help="Upgrade manifest in /.github/", + include_only_option=False, + only_help=None, + ), + "status": ManifestSubcommandConfig( + help_text="Inspect installed artifacts against vstack.json checksums and manifest ownership", + scope_help="Inspect output in /.github/", + include_only_option=True, + only_help="Inspect only these artifact types, e.g. --only skill agent", + include_status_output_options=True, + ), + "verify": ManifestSubcommandConfig( + help_text="Verify installed output against manifest ownership and checksums", + scope_help="Verify output in /.github/", + include_only_option=True, + only_help="Verify only these artifact types, e.g. --only agent prompt", + ), +} diff --git a/src/vstack/cli/commands.py b/src/vstack/cli/commands.py deleted file mode 100644 index b052855..0000000 --- a/src/vstack/cli/commands.py +++ /dev/null @@ -1,596 +0,0 @@ -"""All vstack CLI commands as instance methods on :class:`CommandLineInterface`. - -The CLI is entirely type-generic: it iterates over -:data:`~vstack.artifacts.type_config.KNOWN_TYPES` for validate, install, and -verify instead of hard-coding skill-specific and agent-specific logic. - -All commands that touch the install root accept a single ``install_dir`` -(either workspace ``.github/`` or the VS Code user profile directory) -rather than separate ``skills_dir`` / ``agents_dir`` parameters β€” per-type -sub-directories are derived from -:attr:`~vstack.artifacts.type_config.ArtifactTypeConfig.output_subdir`. -""" - -from __future__ import annotations - -import datetime -import shutil -import sys -from pathlib import Path - -from vstack.agents.config import AGENT_TYPE -from vstack.artifacts.generator import GenericArtifactGenerator -from vstack.cli.constants import EXPECTED_CANONICAL_NAMES -from vstack.cli.manifest import ArtifactEntry, Manifest, ManifestFile -from vstack.constants import VERSION -from vstack.instructions.config import INSTRUCTION_TYPE -from vstack.models import CheckMessage, ValidationResult -from vstack.prompts.config import PROMPT_TYPE -from vstack.skills.config import SKILL_TYPE - -KNOWN_TYPES = [SKILL_TYPE, AGENT_TYPE, INSTRUCTION_TYPE, PROMPT_TYPE] - -# Names that must exist for the "skill" artifact type (used in verify --source). -_EXPECTED_INPUT_NAMES: dict[str, list[str]] = { - "skill": EXPECTED_CANONICAL_NAMES, -} - - -class _Colors: - """ANSI colour codes used in install output.""" - - GREEN = "\033[32m" - YELLOW = "\033[33m" - CYAN = "\033[36m" - BOLD = "\033[1m" - DIM = "\033[2m" - RESET = "\033[0m" - - -def _version_gt(new: str, existing: str) -> bool: - """Return True when *new* semver string is strictly greater than *existing*.""" - - def _t(v: str) -> tuple[int, ...]: - """Convert a dotted version string to an integer tuple for comparison. - - Invalid or non-semver-like inputs collapse to ``(0,)`` so the caller - can treat them as older than any valid semantic version. - """ - try: - return tuple(int(x) for x in v.split(".")) - except (ValueError, AttributeError): - return (0,) - - return _t(new) > _t(existing) - - -class CommandLineInterface: - """Coordinate validation, installation, verification, and uninstall flows. - - The class wires artifact-type generators into user-facing CLI operations, - while keeping per-type behavior in ``ArtifactTypeConfig`` definitions. - """ - - def __init__(self, templates_root: Path) -> None: - """Create generators for all known artifact families. - - Args: - templates_root: Root directory containing the source templates. - """ - self.root = templates_root - self._generators: list[GenericArtifactGenerator] = [ - GenericArtifactGenerator(tc, templates_root) for tc in KNOWN_TYPES - ] - - # ── Internal helpers ────────────────────────────────────────────────────── - - def _label(self, path: Path) -> str: - """Return *path* relative to template root when possible. - - This keeps CLI output concise while still handling absolute paths that - are outside the template tree. - """ - try: - return str(path.relative_to(self.root)) - except ValueError: - return str(path) - - def _manifest(self, install_dir: Path) -> ManifestFile: - """Build the manifest accessor for a given install directory.""" - return ManifestFile(parent_dir=install_dir) - - def _gen_for(self, type_name: str) -> GenericArtifactGenerator | None: - """Return the generator for *type_name*, or ``None`` when unknown.""" - return next((g for g in self._generators if g.config.type_name == type_name), None) - - def _expected_output_names( - self, - gen: GenericArtifactGenerator, - manifest_data: Manifest | None, - ) -> list[str] | None: - """Resolve expected output names for verify output checks.""" - if manifest_data: - return manifest_data.names_for(gen.config.manifest_key) - return _EXPECTED_INPUT_NAMES.get(gen.config.type_name) - - def _expected_manifest_metadata( - self, - gen: GenericArtifactGenerator, - manifest_data: Manifest, - entry: ArtifactEntry, - ) -> dict[str, str]: - """Build expected metadata values for one manifest-tracked artifact.""" - expected_meta = { - "generator": "vstack", - "vstack_version": manifest_data.vstack_version, - "artifact_type": gen.config.type_name, - "artifact_name": entry.name, - } - if entry.version is not None: - expected_meta["artifact_version"] = entry.version - return expected_meta - - def _verify_manifest_metadata_entry( - self, - gen: GenericArtifactGenerator, - manifest_data: Manifest, - entry: ArtifactEntry, - artifact_path: Path, - ) -> ValidationResult: - """Verify manifest-linked metadata for a single artifact file.""" - result = ValidationResult() - content = artifact_path.read_text(encoding="utf-8") - metadata = GenericArtifactGenerator.parse_generation_metadata(content) - rel_path = self._label(artifact_path) - - if metadata is None: - if "AUTO-GENERATED" not in content: - result.messages.append( - CheckMessage( - "fail", - f"{rel_path}: missing VSTACK-META and missing AUTO-GENERATED footer", - ) - ) - return result - - result.messages.append( - CheckMessage( - "pass", - f"{rel_path}: missing VSTACK-META footer; " - "treating manifest entry as source of truth", - ) - ) - result.messages.append( - CheckMessage( - "pass", - f"{rel_path}: legacy artifact accepted from manifest tracking", - ) - ) - return result - - for key, expected_value in self._expected_manifest_metadata( - gen, manifest_data, entry - ).items(): - actual_value = metadata.get(key) - if actual_value == expected_value: - result.messages.append(CheckMessage("pass", f"{rel_path}: {key} matches manifest")) - else: - result.messages.append( - CheckMessage( - "fail", - f"{rel_path}: {key} mismatch " - f"(expected '{expected_value}', got '{actual_value}')", - ) - ) - - return result - - def _verify_manifest_metadata( - self, - gen: GenericArtifactGenerator, - manifest_data: Manifest, - install_dir: Path, - ) -> ValidationResult | None: - """Verify footer metadata for all manifest-tracked artifacts of one type.""" - manifest_entries = manifest_data.entries_for(gen.config.manifest_key) - if not manifest_entries: - return None - - result = ValidationResult() - for entry in manifest_entries: - artifact_path = install_dir / entry.file - if not artifact_path.exists(): - continue - entry_result = self._verify_manifest_metadata_entry( - gen, - manifest_data, - entry, - artifact_path, - ) - result.messages.extend(entry_result.messages) - - return result if result.messages else None - - def _existing_versions_for_install( - self, - gens: list[GenericArtifactGenerator], - existing_manifest: Manifest | None, - ) -> dict[str, str | None]: - """Build existing-version lookup for selected generators.""" - if existing_manifest is None: - return {} - - existing_versions: dict[str, str | None] = {} - for gen in gens: - for entry in existing_manifest.entries_for(gen.config.manifest_key): - key = f"{gen.config.type_name}/{entry.name}" - existing_versions[key] = entry.version - return existing_versions - - def _preserved_manifest_entries( - self, - existing_manifest: Manifest | None, - selected_manifest_keys: set[str], - ) -> dict[str, list[ArtifactEntry]]: - """Keep existing manifest entries for artifact families not in this run.""" - if existing_manifest is None: - return {} - - preserved: dict[str, list[ArtifactEntry]] = {} - for manifest_key, entries in existing_manifest.artifacts.items(): - if manifest_key not in selected_manifest_keys: - preserved[manifest_key] = list(entries) - return preserved - - def _install_action( - self, - *, - force: bool, - update: bool, - out_file: Path, - existing_version: str | None, - new_version: str, - ) -> str: - """Return install action: install, skip, or update.""" - if force: - return "install" - if out_file.exists() and existing_version is not None: - if update: - return "update" if _version_gt(new_version, existing_version) else "skip" - return "skip" - return "install" - - def _print_install_action( - self, - *, - colors: type[_Colors], - prefix: str, - rel: str, - action: str, - existing_version: str | None, - new_version: str, - out_file: Path, - force: bool, - ) -> None: - """Print install/update/skip line for one artifact.""" - if action == "skip": - print( - f" {colors.YELLOW}β†·{colors.RESET} {rel}" - f" {colors.DIM}skipped β€” already v{existing_version}{colors.RESET}" - ) - return - - if action == "update": - print( - f" {prefix}{colors.CYAN}↑{colors.RESET} " - f"{colors.BOLD}{rel}{colors.RESET}" - f" v{existing_version} β†’ {colors.GREEN}v{new_version}{colors.RESET}" - ) - return - - tag = "(forced) " if force and out_file.exists() else "" - print( - f" {prefix}{colors.GREEN}βœ“{colors.RESET} " - f"{colors.BOLD}{rel}{colors.RESET}" - f" {colors.DIM}{tag}{colors.RESET}{colors.GREEN}v{new_version}{colors.RESET}" - ) - - def _record_manifest_entry( - self, - *, - new_entries: dict[str, list[ArtifactEntry]], - gen: GenericArtifactGenerator, - artifact_name: str, - version: str, - ) -> None: - """Append one installed artifact entry to the in-memory manifest payload.""" - new_entries.setdefault(gen.config.manifest_key, []).append( - ArtifactEntry( - name=artifact_name, - file=gen.install_relative_path(artifact_name), - version=version, - ) - ) - - # ── validate ────────────────────────────────────────────────────────────── - - def validate(self, only: list[str] | None = None) -> int: - """Render all templates in memory; report unresolved tokens. No files written.""" - gens = [g for g in self._generators if only is None or g.config.type_name in only] - all_artifacts: dict[str, list] = {} - total_partials = 0 - for gen in gens: - artifacts = gen.render_all() - all_artifacts[gen.config.type_name] = artifacts - total_partials += len(gen.load_partials()) - - if not any(all_artifacts.values()): - print("ERROR: No templates found", file=sys.stderr) - return 1 - - errors: list = [] - for type_name, artifacts in all_artifacts.items(): - type_gen = self._gen_for(type_name) - if type_gen is None: - continue - print(f"\n{type_name.capitalize()} ({len(artifacts)}):") - for a in artifacts: - suffix = f" ⚠ unresolved: {a.unresolved}" if a.unresolved else "" - print(f" {type_gen.output_path(a.name)}{suffix}") - errors.extend(a for a in artifacts if a.unresolved) - - total = sum(len(v) for v in all_artifacts.values()) - if errors: - print( - f"\nERROR: {len(errors)} template(s) have unresolved placeholders", - file=sys.stderr, - ) - return 1 - print(f"\nOK: {total} artifact(s), {total_partials} partial(s)") - return 0 - - # ── install ─────────────────────────────────────────────────────────────── - - def install( - self, - install_dir: Path, - *, - only: list[str] | None = None, - force: bool = False, - update: bool = False, - dry_run: bool = False, - ) -> int: - """Generate and install artifacts into install_dir. - - Args: - install_dir: Workspace ``.github/`` or VS Code user profile install root. - only: Optional list of type names to restrict installation. - force: Overwrite existing artifacts unconditionally. - update: Install only when a newer version is available. - dry_run: Print what would happen without writing any files. - """ - _C = _Colors - - gens = [g for g in self._generators if only is None or g.config.type_name in only] - - # Read existing manifest once β€” used to look up installed versions. - mf = self._manifest(install_dir) - existing_manifest = mf.read() - selected_manifest_keys = {gen.config.manifest_key for gen in gens} - existing_versions = self._existing_versions_for_install(gens, existing_manifest) - - prefix = f"{_C.DIM}[dry-run]{_C.RESET} " if dry_run else "" - all_ok = True - new_entries = self._preserved_manifest_entries(existing_manifest, selected_manifest_keys) - - for gen in gens: - out_dir = install_dir / gen.config.output_subdir - artifacts = gen.render_all() - - for artifact in artifacts: - out_file = out_dir / gen.output_path(artifact.name) - new_version = (artifact.frontmatter or {}).get("version") or VERSION - key = f"{gen.config.type_name}/{artifact.name}" - existing_version = existing_versions.get(key) - rel = self._label(out_file) - - # Unresolved placeholder warnings always shown. - if artifact.unresolved: - print( - f" {_C.YELLOW}⚠{_C.RESET} {rel} unresolved: {artifact.unresolved}", - file=sys.stderr, - ) - - action = self._install_action( - force=force, - update=update, - out_file=out_file, - existing_version=existing_version, - new_version=new_version, - ) - - if action == "skip" and out_file.exists(): - existing_meta = GenericArtifactGenerator.parse_generation_metadata( - out_file.read_text(encoding="utf-8") - ) - if existing_meta is not None and existing_meta.get("vstack_version") != VERSION: - action = "install" - - self._print_install_action( - colors=_C, - prefix=prefix, - rel=rel, - action=action, - existing_version=existing_version, - new_version=new_version, - out_file=out_file, - force=force, - ) - - if not dry_run and action != "skip": - out_dir.mkdir(parents=True, exist_ok=True) - out_file.parent.mkdir(parents=True, exist_ok=True) - out_file.write_text(artifact.content, encoding="utf-8") - - self._record_manifest_entry( - new_entries=new_entries, - gen=gen, - artifact_name=artifact.name, - version=new_version, - ) - - # Verify source for unresolvable issues. - vr = gen.verify_input() - for msg in vr.messages: - if msg.level == "fail": - print(f" ERROR [{gen.config.type_name}]: {msg.message}", file=sys.stderr) - all_ok = False - - if not dry_run: - manifest = Manifest( - vstack_version=VERSION, - installed_at=datetime.datetime.now(datetime.UTC).isoformat(), - artifacts=new_entries, - ) - mf.write(manifest) - print(f" {_C.DIM}wrote {self._label(mf.path)}{_C.RESET}") - - return 0 if all_ok else 1 - - # ── verify ──────────────────────────────────────────────────────────────── - - def verify( - self, - install_dir: Path | None = None, - *, - source: bool = True, - output: bool = True, - only: list[str] | None = None, - ) -> int: - """Check source templates and/or installed output. - - Args: - install_dir: Workspace ``.github/`` or VS Code user profile install root. - source: Verify source templates (metadata, tokens, structure). - output: Verify installed output files against the manifest. - only: Optional list of type names to restrict verification. - """ - results: list[ValidationResult] = [] - section_count = sum([source, output]) - step = 0 - gens = [g for g in self._generators if only is None or g.config.type_name in only] - - def _header(label: str) -> None: - """Print a numbered section header for verify progress output.""" - nonlocal step - step += 1 - print(f"[{step}/{section_count}] {label}") - - def _print_result(result: ValidationResult) -> None: - """Render one validation result block and accumulate totals.""" - for msg in result.messages: - prefix = "βœ“" if msg.level == "pass" else "βœ—" - print(f" {prefix} {msg.message}") - if result.failures == 0: - print(f" βœ“ {result.passes} check(s) passed") - results.append(result) - - if source: - _header("checking source templates") - for gen in gens: - expected = _EXPECTED_INPUT_NAMES.get(gen.config.type_name) - vr = gen.verify_input(expected) - if vr.messages: - _print_result(vr) - else: - print(f" (no {gen.config.type_name} templates found, skipping)") - - if output: - if install_dir is None: - print("ERROR: install_dir required for output checks", file=sys.stderr) - return 1 - manifest_data = self._manifest(install_dir).read() - - for gen in gens: - out_dir = install_dir / gen.config.output_subdir - _header(f"checking installed {gen.config.type_name} ({self._label(out_dir)}/)") - if not out_dir.exists(): - _print_result( - ValidationResult( - messages=[ - CheckMessage( - level="fail", - message=f"{self._label(out_dir)}/ not found β€” run: vstack install", - ) - ] - ) - ) - else: - expected = self._expected_output_names(gen, manifest_data) - _print_result(gen.verify_output(out_dir, expected)) - - if manifest_data: - metadata_result = self._verify_manifest_metadata( - gen, manifest_data, install_dir - ) - if metadata_result: - _print_result(metadata_result) - - print() - total_failures = sum(r.failures for r in results) - if total_failures: - print(f"FAILED: {total_failures} check(s) failed") - return 1 - print("All checks passed.") - return 0 - - # ── uninstall ───────────────────────────────────────────────────────────── - - def uninstall(self, install_dir: Path) -> int: - """Remove only vstack-managed files. User files are not touched.""" - removed: list[str] = [] - manifest_data = self._manifest(install_dir).read() - - for gen in self._generators: - out_dir = install_dir / gen.config.output_subdir - entries = manifest_data.entries_for(gen.config.type_name) if manifest_data else [] - - if not entries: - # No manifest entries: fall back to known names from templates - fallback = _EXPECTED_INPUT_NAMES.get(gen.config.type_name) - if fallback: - entries = [ - ArtifactEntry( - name=n, - file=gen.config.output_subdir + "/" + gen.output_path(n), - ) - for n in fallback - ] - - for entry in entries: - full_path = install_dir / entry.file - if gen.config.artifact_is_dir: - artifact_dir = full_path.parent - if artifact_dir.exists() and artifact_dir != out_dir: - shutil.rmtree(artifact_dir) - removed.append(self._label(artifact_dir)) - else: - if full_path.exists(): - full_path.unlink() - removed.append(self._label(full_path)) - - if out_dir.exists() and not any(out_dir.iterdir()): - out_dir.rmdir() - removed.append(self._label(out_dir) + "/") - - mf = self._manifest(install_dir) - if mf.exists(): - mf.path.unlink() - removed.append(self._label(mf.path)) - - if removed: - for p in removed: - print(f" removed {p}") - else: - print("Nothing to remove.") - return 0 diff --git a/src/vstack/cli/constants.py b/src/vstack/cli/constants.py index ae10933..df225d8 100644 --- a/src/vstack/cli/constants.py +++ b/src/vstack/cli/constants.py @@ -2,6 +2,11 @@ from __future__ import annotations +from vstack.agents.config import AGENT_TYPE +from vstack.instructions.config import INSTRUCTION_TYPE +from vstack.prompts.config import PROMPT_TYPE +from vstack.skills.config import SKILL_TYPE + EXPECTED_CANONICAL_NAMES = [ "vision", "architecture", @@ -31,3 +36,47 @@ "dependency", "incident", ] + + +class Colors: + """ANSI colour codes used in CLI output.""" + + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + CYAN = "\033[36m" + BLUE = "\033[34m" + BOLD = "\033[1m" + DIM = "\033[2m" + RESET = "\033[0m" + + +KNOWN_TYPES = [SKILL_TYPE, AGENT_TYPE, INSTRUCTION_TYPE, PROMPT_TYPE] +KNOWN_TYPE_NAMES = [type_config.type_name for type_config in KNOWN_TYPES] +GLOBAL_SUPPORTED_TYPE_NAMES = [ + AGENT_TYPE.type_name, + INSTRUCTION_TYPE.type_name, + PROMPT_TYPE.type_name, + SKILL_TYPE.type_name, +] + + +class ArtifactState: + """Canonical artifact control states returned by CLI state checks.""" + + MANAGED = "managed" + MANAGED_LEGACY = "managed-legacy" + MODIFIED = "modified" + MISSING = "missing" + UNTRACKED = "untracked" + ABSENT = "absent" + UNKNOWN = "unknown" + + +# Names that must exist for each artifact type (used in verify --source). +EXPECTED_INPUT_NAMES: dict[str, list[str]] = { + "skill": EXPECTED_CANONICAL_NAMES, + "agent": ["architect", "designer", "engineer", "product", "release", "tester"], + "instruction": ["git", "python"], + "prompt": ["code-review"], +} diff --git a/src/vstack/cli/helpers.py b/src/vstack/cli/helpers.py new file mode 100644 index 0000000..87526d5 --- /dev/null +++ b/src/vstack/cli/helpers.py @@ -0,0 +1,8 @@ +"""Shared internal helpers for CLI command handlers.""" + +from __future__ import annotations + + +def normalize_targeted_names(names: list[str] | None) -> set[str]: + """Normalize targeted artifact names for force/adopt operations.""" + return {name.strip() for name in names or [] if name.strip()} diff --git a/src/vstack/cli/install.py b/src/vstack/cli/install.py new file mode 100644 index 0000000..88e9aa4 --- /dev/null +++ b/src/vstack/cli/install.py @@ -0,0 +1,437 @@ +"""Install command wrapper.""" + +from __future__ import annotations + +import datetime +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.constants import Colors +from vstack.cli.helpers import normalize_targeted_names +from vstack.constants import VERSION +from vstack.manifest import ( + CURRENT_HASH_ALGORITHM, + CURRENT_MANIFEST_VERSION, + Manifest, + content_hash, + hash_with_algorithm, + preserve_existing_entry, + preserved_manifest_entries, +) + +if TYPE_CHECKING: + from vstack.cli.service import CommandService + + +class InstallCommand(BaseCommand): + """Install artifacts into the selected install directory.""" + + def __init__(self, service: CommandService) -> None: + self._service = service + + @staticmethod + def _version_gt(new: str, existing: str | None) -> bool: + """Return True when *new* semver string is strictly greater than *existing*.""" + + def _tuple(v: str) -> tuple[int, ...]: + """Convert a dotted version string to an integer tuple for comparison.""" + try: + return tuple(int(x) for x in v.split(".")) + except (ValueError, AttributeError): + return (0,) + + return _tuple(new) > _tuple(existing or "0") + + @staticmethod + def _existing_entries_for_install(gens, existing_manifest: Manifest | None): + """Build an existing-entry lookup for selected generators.""" + if existing_manifest is None: + return {} + + existing_entries = {} + for gen in gens: + for entry in existing_manifest.entries_for(gen.config.manifest_key): + key = f"{gen.config.type_name}/{entry.name}" + existing_entries[key] = entry + return existing_entries + + @staticmethod + def _installed_content_matches(*, out_file: Path, existing_entry) -> bool | None: + """Return whether on-disk content still matches the manifest checksum.""" + if not out_file.exists() or existing_entry.checksum is None: + return None + checksum_algorithm = (existing_entry.checksum_algorithm or CURRENT_HASH_ALGORITHM).lower() + try: + return ( + hash_with_algorithm(out_file.read_text(encoding="utf-8"), checksum_algorithm) + == existing_entry.checksum + ) + except ValueError: + return None + + @staticmethod + def _install_decision( + *, + force: bool, + force_name: bool, + adopt_name: bool, + update: bool, + out_file: Path, + existing_entry, + new_version: str, + ) -> tuple[str, str | None]: + """Return install action and optional explanatory reason.""" + if force or force_name: + return "install", None + if not out_file.exists(): + return "install", None + if existing_entry is None: + if adopt_name: + return "adopt", "existing file adopted into vstack manifest" + return "preserve", "existing file is not tracked by vstack" + current_matches = InstallCommand._installed_content_matches( + out_file=out_file, + existing_entry=existing_entry, + ) + if current_matches is False: + return "preserve", "local changes detected" + if current_matches is None: + return "preserve", "tracked file has no stored checksum" + + if not update: + return "install", None + + if existing_entry.version is not None: + return ( + ("update", None) + if InstallCommand._version_gt(new_version, existing_entry.version) + else ("skip", None) + ) + return "preserve", "tracked file has no stored version" + + @staticmethod + def _print_install_action( + *, + colors, + prefix: str, + rel: str, + action: str, + existing_version: str | None, + new_version: str, + out_file: Path, + force: bool, + force_name: bool = False, + reason: str | None = None, + ) -> None: + """Print install/update/skip output line for one artifact.""" + if action == "adopt": + print( + f" {colors.CYAN}β‰ˆ{colors.RESET} {rel}" + f" {colors.DIM}adopted β€” {reason}{colors.RESET}" + ) + return + + if action == "preserve": + force_hint = " Use --force or --force-name for this artifact." + print( + f" {colors.YELLOW}β†·{colors.RESET} {rel}" + f" {colors.DIM}preserved β€” {reason}.{force_hint}{colors.RESET}" + ) + return + + if action == "skip": + print( + f" {colors.YELLOW}β†·{colors.RESET} {rel}" + f" {colors.DIM}skipped β€” already v{existing_version}{colors.RESET}" + ) + return + + if action == "update": + print( + f" {prefix}{colors.CYAN}↑{colors.RESET} " + f"{colors.BOLD}{rel}{colors.RESET}" + f" v{existing_version} β†’ {colors.GREEN}v{new_version}{colors.RESET}" + ) + return + + tag = "(forced) " if (force or force_name) and out_file.exists() else "" + print( + f" {prefix}{colors.GREEN}βœ“{colors.RESET} " + f"{colors.BOLD}{rel}{colors.RESET}" + f" {colors.DIM}{tag}{colors.RESET}{colors.GREEN}v{new_version}{colors.RESET}" + ) + + @staticmethod + def _record_manifest_entry( + *, + new_entries, + gen, + artifact_name: str, + version: str | None, + checksum: str, + checksum_algorithm: str, + ) -> None: + """Append one installed artifact entry to in-memory manifest data.""" + from vstack.manifest import ArtifactEntry + + new_entries.setdefault(gen.config.manifest_key, []).append( + ArtifactEntry( + name=artifact_name, + file=gen.install_relative_path(artifact_name), + version=version, + checksum=checksum, + checksum_algorithm=checksum_algorithm, + ) + ) + + @staticmethod + def _adopted_manifest_values( + *, + out_file: Path, + ) -> tuple[str | None, str] | None: + """Return adopted version/checksum from on-disk content. + + Version is read from ``VSTACK-META.artifact_version`` when present. + """ + from vstack.artifacts.generator import GenericArtifactGenerator + + try: + content = out_file.read_text(encoding="utf-8") + except OSError: + return None + + metadata = GenericArtifactGenerator.parse_generation_metadata(content) + adopted_version = metadata.get("artifact_version") if metadata else None + return adopted_version, content_hash(content) + + @staticmethod + def _load_existing_manifest( + *, + service: CommandService, + install_dir: Path, + gens, + ): + """Read and validate current manifest state for an install run.""" + manifest_file = service.manifest_for(install_dir) + existing_manifest = manifest_file.read() + if existing_manifest is None and manifest_file.read_error: + print(f"ERROR: {manifest_file.read_error}", file=sys.stderr) + return None, None, None, None + + selected_manifest_keys = {gen.config.manifest_key for gen in gens} + existing_entries = InstallCommand._existing_entries_for_install(gens, existing_manifest) + new_entries = preserved_manifest_entries( + existing_manifest, + selected_manifest_keys, + ) + return manifest_file, existing_manifest, existing_entries, new_entries + + @staticmethod + def _install_single_artifact( + *, + service: CommandService, + gen, + artifact, + out_dir: Path, + colors, + prefix: str, + force: bool, + update: bool, + dry_run: bool, + targeted_force_names: set[str], + targeted_adopt_names: set[str], + existing_entries, + new_entries, + checksum_algorithm: str, + ) -> None: + """Apply install decision flow for one rendered artifact.""" + out_file = out_dir / gen.output_path(artifact.name) + new_version = (artifact.frontmatter or {}).get("version") or VERSION + key = f"{gen.config.type_name}/{artifact.name}" + existing_entry = existing_entries.get(key) + existing_version = existing_entry.version if existing_entry is not None else None + rel = service.label(out_file) + force_name = artifact.name in targeted_force_names or rel in targeted_force_names + adopt_name = artifact.name in targeted_adopt_names or rel in targeted_adopt_names + adopted_values: tuple[str | None, str] | None = None + + if artifact.unresolved: + print( + f" {colors.YELLOW}⚠{colors.RESET} {rel} unresolved: {artifact.unresolved}", + file=sys.stderr, + ) + + action, reason = InstallCommand._install_decision( + force=force, + force_name=force_name, + adopt_name=adopt_name, + update=update, + out_file=out_file, + existing_entry=existing_entry, + new_version=new_version, + ) + + if action == "adopt" and out_file.exists(): + adopted_values = InstallCommand._adopted_manifest_values(out_file=out_file) + if adopted_values is None: + action = "preserve" + reason = "existing file is unreadable; could not adopt into vstack manifest" + + InstallCommand._print_install_action( + colors=colors, + prefix=prefix, + rel=rel, + action=action, + existing_version=existing_version, + new_version=new_version, + out_file=out_file, + force=force, + force_name=force_name, + reason=reason, + ) + + if not dry_run and action in {"install", "update"}: + out_dir.mkdir(parents=True, exist_ok=True) + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_text(artifact.content, encoding="utf-8") + + if action in {"install", "update"}: + InstallCommand._record_manifest_entry( + new_entries=new_entries, + gen=gen, + artifact_name=artifact.name, + version=new_version, + checksum=content_hash(artifact.content), + checksum_algorithm=checksum_algorithm, + ) + return + + if action == "adopt" and adopted_values is not None: + adopted_version, adopted_checksum = adopted_values + InstallCommand._record_manifest_entry( + new_entries=new_entries, + gen=gen, + artifact_name=artifact.name, + version=adopted_version, + checksum=adopted_checksum, + checksum_algorithm=checksum_algorithm, + ) + return + + if existing_entry is not None: + preserve_existing_entry( + new_entries=new_entries, + manifest_key=gen.config.manifest_key, + existing_entry=existing_entry, + ) + + @staticmethod + def _write_manifest( + *, + service: CommandService, + manifest_file, + new_entries, + checksum_algorithm: str, + colors, + ) -> None: + """Persist the manifest after a non-dry-run install.""" + manifest = Manifest( + manifest_version=CURRENT_MANIFEST_VERSION, + hash_algorithm=checksum_algorithm, + vstack_version=VERSION, + installed_at=datetime.datetime.now(datetime.UTC).isoformat(), + artifacts=new_entries, + ) + manifest_file.write(manifest) + print(f" {colors.DIM}wrote {service.label(manifest_file.path)}{colors.RESET}") + + @staticmethod + def execute( + service: CommandService, + install_dir: Path, + *, + only: list[str] | None = None, + force: bool = False, + force_names: list[str] | None = None, + adopt_names: list[str] | None = None, + update: bool = False, + dry_run: bool = False, + ) -> int: + """Generate and install artifacts into install_dir.""" + colors = Colors + checksum_algorithm = CURRENT_HASH_ALGORITHM + + gens = [g for g in service.generators if only is None or g.config.type_name in only] + targeted_force_names = normalize_targeted_names(force_names) + targeted_adopt_names = normalize_targeted_names(adopt_names) + + manifest_file, _, existing_entries, new_entries = InstallCommand._load_existing_manifest( + service=service, + install_dir=install_dir, + gens=gens, + ) + if manifest_file is None or existing_entries is None or new_entries is None: + return 1 + + prefix = f"{colors.DIM}[dry-run]{colors.RESET} " if dry_run else "" + all_ok = True + + for gen in gens: + out_dir = install_dir / gen.config.output_subdir + artifacts = gen.render_all() + + for artifact in artifacts: + InstallCommand._install_single_artifact( + service=service, + gen=gen, + artifact=artifact, + out_dir=out_dir, + colors=colors, + prefix=prefix, + force=force, + update=update, + dry_run=dry_run, + targeted_force_names=targeted_force_names, + targeted_adopt_names=targeted_adopt_names, + existing_entries=existing_entries, + new_entries=new_entries, + checksum_algorithm=checksum_algorithm, + ) + + # Verify source for unresolvable issues. + verify_result = gen.verify_input() + for msg in verify_result.messages: + if msg.level == "fail": + print(f" ERROR [{gen.config.type_name}]: {msg.message}", file=sys.stderr) + all_ok = False + + if not dry_run: + InstallCommand._write_manifest( + service=service, + manifest_file=manifest_file, + new_entries=new_entries, + checksum_algorithm=checksum_algorithm, + colors=colors, + ) + + return 0 if all_ok else 1 + + def run( + self, + *, + context: CommandContext, + ) -> int: + install_dir = context.require_install_dir("install") + + return InstallCommand.execute( + self._service, + install_dir, + only=context.only, + force=getattr(context.args, "force", False), + force_names=getattr(context.args, "force_names", None), + adopt_names=getattr(context.args, "adopt_name", None), + update=getattr(context.args, "update", False), + dry_run=getattr(context.args, "dry_run", False), + ) diff --git a/src/vstack/cli/interface.py b/src/vstack/cli/interface.py new file mode 100644 index 0000000..436d1b7 --- /dev/null +++ b/src/vstack/cli/interface.py @@ -0,0 +1,93 @@ +"""CLI orchestration facade for parsing and command dispatch.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from vstack.cli.base import CommandContext +from vstack.cli.catalog import COMMAND_CATALOG +from vstack.cli.constants import GLOBAL_SUPPORTED_TYPE_NAMES +from vstack.cli.parser import CommandLineParser +from vstack.cli.registry import build_command_registry +from vstack.cli.service import CommandService + + +class CommandLineInterface: + """Facade that coordinates parser, service construction, and dispatch.""" + + def __init__( + self, + *, + parser_cls: type[CommandLineParser] = CommandLineParser, + service_cls: type[CommandService] = CommandService, + templates_root, + ) -> None: + self._parser_cls = parser_cls + self._service_cls = service_cls + self._templates_root = templates_root + + @classmethod + def resolve_only_for_scope(cls, args: argparse.Namespace) -> list[str] | None: + """Resolve effective ``--only`` values for the active command scope.""" + requested_only = getattr(args, "only", None) + if not getattr(args, "use_global", False): + return requested_only + + if requested_only is None: + return list(GLOBAL_SUPPORTED_TYPE_NAMES) + + disallowed = [t for t in requested_only if t not in GLOBAL_SUPPORTED_TYPE_NAMES] + if disallowed: + allowed = ", ".join(GLOBAL_SUPPORTED_TYPE_NAMES) + raise ValueError( + f"--global supports only: {allowed}. Unsupported type(s): {', '.join(disallowed)}" + ) + + return requested_only + + def _resolve_install_dir( + self, + *, + cli_parser: CommandLineParser, + args: argparse.Namespace, + requires_install_dir: bool, + ) -> Path | None: + """Resolve the install directory only for commands that need one.""" + if not requires_install_dir: + return None + return cli_parser.resolve_targets(args, command_name=args.command) + + def _resolve_only_filter( + self, + *, + args: argparse.Namespace, + resolve_only_for_scope: bool, + ) -> list[str] | None: + """Resolve effective ``--only`` filter for the active command.""" + if resolve_only_for_scope: + return self.resolve_only_for_scope(args) + return getattr(args, "only", None) + + def run(self) -> int: + """Run one CLI invocation and return a process-style status code.""" + cli_parser = self._parser_cls() + parser = cli_parser.build() + args = parser.parse_args() + service = self._service_cls(templates_root=self._templates_root) + commands = build_command_registry(service) + command_config = COMMAND_CATALOG[args.command] + + resolved_install_dir = self._resolve_install_dir( + cli_parser=cli_parser, + args=args, + requires_install_dir=command_config.requires_install_dir, + ) + effective_only = self._resolve_only_filter( + args=args, + resolve_only_for_scope=command_config.resolve_only_for_scope, + ) + + command = commands[args.command] + context = CommandContext(args=args, install_dir=resolved_install_dir, only=effective_only) + return command.run(context=context) diff --git a/src/vstack/cli/manifest.py b/src/vstack/cli/manifest.py index 7368e32..b2bc581 100644 --- a/src/vstack/cli/manifest.py +++ b/src/vstack/cli/manifest.py @@ -1,166 +1,69 @@ -"""vstack manifest β€” reads and writes ``vstack.json``. +"""Manifest compatibility exports and subcommand dispatch helpers. -The manifest tracks every artifact installed by ``vstack install`` so that -``vstack uninstall`` can remove exactly those files without touching anything -the user placed there manually. - -Format:: - - { - "vstack_version": "…", - "installed_at": "…", - "artifacts": { - "skills": [{"name": "vision", "version": "1.0.1", "file": "skills/vision/SKILL.md"}], - "agents": [{"name": "engineer", "file": "agents/engineer.agent.md"}], - "instructions": [{"name": "python", "file": "instructions/python.instructions.md"}], - "prompts": [{"name": "code-review", "file": "prompts/code-review.prompt.md"}] - } - } +The manifest domain implementation lives in ``vstack.manifest``. +This module re-exports manifest domain symbols for backward compatibility and +hosts dispatch for ``vstack manifest `` subcommands. """ from __future__ import annotations -import json -from dataclasses import dataclass, field +from argparse import Namespace from pathlib import Path -from vstack.constants import MANIFEST_FILENAME - - -@dataclass -class ArtifactEntry: - """Represent a single installed artifact entry in ``vstack.json``.""" - - name: str - file: str - version: str | None = None - - -@dataclass -class Manifest: - """Represent the parsed install manifest stored in ``vstack.json``.""" - - vstack_version: str - installed_at: str - artifacts: dict[str, list[ArtifactEntry]] = field(default_factory=dict) - - # ── Accessors ───────────────────────────────────────────────────────────── - - def entries_for(self, type_name: str) -> list[ArtifactEntry]: - """Return manifest entries for a single artifact type key. - - Args: - type_name: Manifest artifact key such as ``"skills"``. - """ - return self.artifacts.get(type_name, []) - - def names_for(self, type_name: str) -> list[str]: - """Return artifact names for a single manifest type key. - - Args: - type_name: Manifest artifact key such as ``"skills"``. - """ - return [e.name for e in self.entries_for(type_name)] +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.service import CommandService +from vstack.cli.status import StatusCommand +from vstack.cli.verify import VerifyCommand - def files_for(self, type_name: str) -> list[str]: - """Return relative output file paths for a manifest type key. - Args: - type_name: Manifest artifact key such as ``"skills"``. - """ - return [e.file for e in self.entries_for(type_name)] +class ManifestCommand(BaseCommand): + """Dispatch ``vstack manifest `` subcommands.""" - # ── Serialisation ───────────────────────────────────────────────────────── + def __init__(self, service: CommandService) -> None: + self._service = service - def to_dict(self) -> dict: - """Serialize the manifest into JSON-compatible primitives. - - Returns: - A nested dictionary structure suitable for ``json.dumps``. - """ - return { - "vstack_version": self.vstack_version, - "installed_at": self.installed_at, - "artifacts": { - type_name: [ - { - "name": e.name, - "file": e.file, - **({} if e.version is None else {"version": e.version}), - } - for e in entries - ] - for type_name, entries in self.artifacts.items() - }, - } - - @classmethod - def from_dict(cls, data: dict) -> Manifest: - """Create a :class:`Manifest` from parsed JSON data. - - Args: - data: Parsed JSON object read from ``vstack.json``. - - Returns: - A normalized in-memory manifest representation. - """ - artifacts: dict[str, list[ArtifactEntry]] = {} - for type_name, entries in data.get("artifacts", {}).items(): - artifacts[type_name] = [ - ArtifactEntry( - name=e["name"], - file=e["file"], - version=e.get("version"), - ) - for e in entries - if isinstance(e, dict) and "name" in e - ] - return cls( - vstack_version=data.get("vstack_version", ""), - installed_at=data.get("installed_at", ""), - artifacts=artifacts, + def _run_upgrade(self, *, args: Namespace, install_dir: Path) -> int: + """Upgrade manifest schema for the active install directory.""" + return self._service.manifest_upgrade( + install_dir, + backfill=getattr(args, "backfill", False), ) + def _run_status(self, *, args: Namespace, install_dir: Path, only: list[str] | None) -> int: + """Render manifest status for the active install directory.""" + return StatusCommand.execute( + self._service, + install_dir=install_dir, + only=only, + output_format=getattr(args, "output_format", "text"), + verbose=getattr(args, "verbose", False), + no_color=getattr(args, "no_color", False), + ) -class ManifestFile: - """Read and write the ``vstack.json`` manifest inside an install root.""" - - def __init__(self, parent_dir: Path) -> None: - """Create a manifest accessor rooted at the provided install directory. - - Args: - parent_dir: Install root that contains or will contain - ``vstack.json``. - """ - self.path = parent_dir / MANIFEST_FILENAME - - def exists(self) -> bool: - """Return ``True`` when the manifest file exists on disk.""" - return self.path.exists() - - def read(self) -> Manifest | None: - """Parse the manifest file from disk. - - Returns: - The parsed manifest, or ``None`` when the file is missing or - cannot be decoded safely. - """ - if not self.path.exists(): - return None - try: - data = json.loads(self.path.read_text(encoding="utf-8")) - return Manifest.from_dict(data) - except (json.JSONDecodeError, KeyError): - return None - - def write(self, manifest: Manifest) -> None: - """Write a manifest to disk in stable, human-readable JSON format. - - Args: - manifest: Manifest data to persist. - """ - self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text( - json.dumps(manifest.to_dict(), indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", + def _run_verify(self, *, install_dir: Path, only: list[str] | None) -> int: + """Run manifest-scoped verification (output-only checks).""" + return VerifyCommand.execute( + self._service, + install_dir=install_dir, + source=False, + output=True, + only=only, ) + + def run( + self, + *, + context: CommandContext, + ) -> int: + install_dir = context.require_install_dir("manifest command") + args = context.args + only = context.only + + action = getattr(args, "manifest_action", None) + if action == "upgrade": + return self._run_upgrade(args=args, install_dir=install_dir) + if action == "status": + return self._run_status(args=args, install_dir=install_dir, only=only) + if action == "verify": + return self._run_verify(install_dir=install_dir, only=only) + raise ValueError("manifest action is required") diff --git a/src/vstack/cli/parser.py b/src/vstack/cli/parser.py index 8dc7739..474996d 100644 --- a/src/vstack/cli/parser.py +++ b/src/vstack/cli/parser.py @@ -3,15 +3,221 @@ from __future__ import annotations import argparse -import sys from pathlib import Path +from typing import Any, Protocol +from vstack.cli.catalog import ( + COMMAND_CATALOG, + MANIFEST_SUBCOMMAND_CATALOG, + MANIFEST_SUBCOMMAND_ORDER, + TOP_LEVEL_COMMAND_ORDER, + ManifestSubcommandConfig, + TopLevelCommandConfig, +) +from vstack.cli.constants import KNOWN_TYPE_NAMES from vstack.constants import VERSION +class SubparserFactory(Protocol): + """Protocol for parser objects that can register subcommands.""" + + def add_parser(self, name: str, **kwargs: Any) -> argparse.ArgumentParser: + """Register and return a parser for one subcommand.""" + ... + + class CommandLineParser: """Create the vstack command-line parser and resolve install targets.""" + @staticmethod + def _add_scope_group(parser: argparse.ArgumentParser, *, help_text: str) -> None: + """Add mutually-exclusive target scope flags to a parser.""" + group = parser.add_mutually_exclusive_group() + group.add_argument("--target", metavar="", help=help_text) + group.add_argument( + "--global", + dest="use_global", + action="store_true", + help="VS Code user profile (agents/prompts/instructions/skills)", + ) + + @staticmethod + def _add_only_option(parser: argparse.ArgumentParser, *, help_text: str) -> None: + """Add the shared ``--only`` option used by multi-type commands.""" + parser.add_argument( + "--only", + nargs="+", + choices=KNOWN_TYPE_NAMES, + metavar="", + help=help_text, + ) + + def _add_scoped_only_command( + self, + sub: SubparserFactory, + *, + command_name: str, + config: TopLevelCommandConfig | ManifestSubcommandConfig, + ) -> argparse.ArgumentParser: + """Register a command that supports scope flags and optional ``--only`` filtering.""" + parser = sub.add_parser(command_name, help=config.help_text) + scope_help = config.scope_help + if scope_help is None: + raise ValueError("scope_help is required for scoped commands") + self._add_scope_group(parser, help_text=scope_help) + if config.include_only_option: + if config.only_help is None: + raise ValueError("only_help is required when include_only_option is True") + self._add_only_option(parser, help_text=config.only_help) + if config.include_status_output_options: + self._add_status_output_options(parser) + return parser + + def _add_validate_command(self, sub: SubparserFactory) -> None: + """Register the ``validate`` subcommand.""" + command_config = COMMAND_CATALOG["validate"] + parser = sub.add_parser( + "validate", + help=command_config.help_text, + ) + self._add_only_option( + parser, + help_text=command_config.only_help or "", + ) + + def _add_verify_command(self, sub: SubparserFactory) -> None: + """Register the ``verify`` subcommand.""" + verify_config = COMMAND_CATALOG["verify"] + parser = self._add_scoped_only_command(sub, command_name="verify", config=verify_config) + parser.add_argument( + "--no-source", + dest="source", + action="store_false", + default=True, + help="Skip source template checks", + ) + parser.add_argument( + "--no-output", + dest="output", + action="store_false", + default=True, + help="Skip installed output checks", + ) + + def _add_status_output_options(self, parser: argparse.ArgumentParser) -> None: + """Add shared status formatting options.""" + parser.add_argument( + "--format", + dest="output_format", + choices=["text", "json", "yaml"], + default="text", + help="Output format: compact text (default), json, or yaml", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Show managed entries in text output (default shows issues-focused view)", + ) + parser.add_argument( + "--no-color", + action="store_true", + help="Disable ANSI colors in text output", + ) + + def _add_status_command(self, sub: SubparserFactory) -> None: + """Register the top-level ``status`` subcommand.""" + status_config = COMMAND_CATALOG["status"] + self._add_scoped_only_command(sub, command_name="status", config=status_config) + + def _add_manifest_command(self, sub: SubparserFactory) -> None: + """Register the ``manifest`` subcommand and nested actions.""" + manifest_config = COMMAND_CATALOG["manifest"] + manifest = sub.add_parser( + "manifest", + help=manifest_config.help_text, + ) + manifest_sub = manifest.add_subparsers(dest="manifest_action", metavar="") + manifest_sub.required = True + + for subcommand_name in MANIFEST_SUBCOMMAND_ORDER: + subcommand_config = MANIFEST_SUBCOMMAND_CATALOG[subcommand_name] + subcommand_parser = self._add_scoped_only_command( + manifest_sub, + command_name=subcommand_name, + config=subcommand_config, + ) + if subcommand_name == "upgrade": + subcommand_parser.add_argument( + "--backfill", + action="store_true", + help=( + "Backfill checksums for tracked legacy entries whose on-disk files " + "still contain a VSTACK-META footer" + ), + ) + + def _add_install_command(self, sub: SubparserFactory) -> None: + """Register the ``install`` subcommand.""" + install_config = COMMAND_CATALOG["install"] + parser = self._add_scoped_only_command(sub, command_name="install", config=install_config) + + mode = parser.add_mutually_exclusive_group() + mode.add_argument( + "--force", + action="store_true", + help="Overwrite existing artifacts unconditionally", + ) + mode.add_argument( + "--update", + action="store_true", + help="Install only when a newer version is available", + ) + + parser.add_argument( + "--force-name", + dest="force_names", + action="append", + metavar="", + help="Force install one named artifact without overwriting everything", + ) + parser.add_argument( + "--adopt-name", + action="append", + default=None, + metavar="NAME", + help=( + "Adopt only the named existing unmanaged artifact into the manifest without overwriting it. " + "Repeat this option to target multiple names." + ), + ) + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="Show what would be installed without writing files", + ) + + def _add_uninstall_command(self, sub: SubparserFactory) -> None: + """Register the ``uninstall`` subcommand.""" + uninstall_config = COMMAND_CATALOG["uninstall"] + parser = self._add_scoped_only_command( + sub, + command_name="uninstall", + config=uninstall_config, + ) + parser.add_argument( + "--force", + action="store_true", + help="Remove tracked artifacts even when checksum drift is detected", + ) + parser.add_argument( + "--force-name", + dest="force_names", + action="append", + metavar="", + help="Force uninstall one named artifact without removing every modified file", + ) + def vscode_user_dir(self) -> Path | None: """Return the first detected VS Code user data directory. @@ -27,7 +233,7 @@ def vscode_user_dir(self) -> Path | None: ] return next((p for p in candidates if p.exists()), None) - def resolve_targets(self, args: argparse.Namespace) -> Path: + def resolve_targets(self, args: argparse.Namespace, *, command_name: str = "command") -> Path: """Resolve the install root directory from parsed CLI arguments. Args: @@ -37,25 +243,22 @@ def resolve_targets(self, args: argparse.Namespace) -> Path: The effective install root directory. Raises: - SystemExit: If ``--global`` cannot be resolved or the explicit + ValueError: If ``--global`` cannot be resolved or the explicit ``--target`` directory does not exist. """ if getattr(args, "use_global", False): user_dir = self.vscode_user_dir() if user_dir is None: - print( - "ERROR: Could not detect VS Code user data directory.\n" - "Specify manually with: vstack install --target ~/.config/Code/User", - file=sys.stderr, + raise ValueError( + "Could not detect VS Code user data directory for --global. " + f"Specify manually with: vstack {command_name} --target ~/.config/Code/User" ) - sys.exit(1) return user_dir if getattr(args, "target", None): target = Path(args.target).expanduser().resolve() if not target.exists(): - print(f"ERROR: target directory does not exist: {target}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"target directory does not exist: {target}") return target / ".github" # default: current working directory @@ -75,82 +278,15 @@ def build(self) -> argparse.ArgumentParser: sub = parser.add_subparsers(dest="command", metavar="") sub.required = True - p_validate = sub.add_parser( - "validate", help="Render templates in memory, report unresolved tokens" - ) - p_validate.add_argument( - "--only", - nargs="+", - metavar="", - help="Validate only these artifact types, e.g. --only skill agent", - ) - - p = sub.add_parser("verify", help="Validate source templates and/or installed output") - group = p.add_mutually_exclusive_group() - group.add_argument("--target", metavar="", help="Install into /.github/") - group.add_argument( - "--global", - dest="use_global", - action="store_true", - help="VS Code user profile (agents/prompts/instructions/skills)", - ) - p.add_argument( - "--only", - nargs="+", - metavar="", - help="Verify only these artifact types, e.g. --only agent prompt", - ) - p.add_argument( - "--no-source", - dest="source", - action="store_false", - default=True, - help="Skip source template checks", - ) - p.add_argument( - "--no-output", - dest="output", - action="store_false", - default=True, - help="Skip installed output checks", - ) - - for cmd, help_text in [ - ("install", "Generate and install artifacts (--only to filter types)"), - ("uninstall", "Remove vstack-managed files"), - ]: - p = sub.add_parser(cmd, help=help_text) - group = p.add_mutually_exclusive_group() - group.add_argument("--target", metavar="", help="Install into /.github/") - group.add_argument( - "--global", - dest="use_global", - action="store_true", - help="VS Code user profile (agents/prompts/instructions/skills)", - ) - if cmd == "install": - p.add_argument( - "--only", - nargs="+", - metavar="", - help="Install only these artifact types, e.g. --only skill agent", - ) - mode = p.add_mutually_exclusive_group() - mode.add_argument( - "--force", - action="store_true", - help="Overwrite existing artifacts unconditionally", - ) - mode.add_argument( - "--update", - action="store_true", - help="Install only when a newer version is available", - ) - p.add_argument( - "--dry-run", - dest="dry_run", - action="store_true", - help="Show what would be installed without writing files", - ) + command_adders = { + "validate": self._add_validate_command, + "verify": self._add_verify_command, + "status": self._add_status_command, + "manifest": self._add_manifest_command, + "install": self._add_install_command, + "uninstall": self._add_uninstall_command, + } + for command_name in TOP_LEVEL_COMMAND_ORDER: + command_adders[command_name](sub) return parser diff --git a/src/vstack/cli/registry.py b/src/vstack/cli/registry.py new file mode 100644 index 0000000..a2e8070 --- /dev/null +++ b/src/vstack/cli/registry.py @@ -0,0 +1,15 @@ +"""Command registry construction for CLI dispatch.""" + +from __future__ import annotations + +from vstack.cli.base import BaseCommand +from vstack.cli.catalog import COMMAND_CATALOG +from vstack.cli.service import CommandService + + +def build_command_registry(service: CommandService) -> dict[str, BaseCommand]: + """Build the map of top-level command names to command handlers.""" + return { + command_name: config.command_factory(service) + for command_name, config in COMMAND_CATALOG.items() + } diff --git a/src/vstack/cli/report.py b/src/vstack/cli/report.py new file mode 100644 index 0000000..7c85be2 --- /dev/null +++ b/src/vstack/cli/report.py @@ -0,0 +1,271 @@ +"""Report builders and renderers for CLI commands.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +from vstack.cli.constants import ArtifactState, Colors + +if TYPE_CHECKING: + from vstack.artifacts.generator import GenericArtifactGenerator + from vstack.cli.service import CommandService + from vstack.manifest import ArtifactEntry, Manifest + + +class BaseReport: + """Shared serialization helpers for CLI report renderers.""" + + @staticmethod + def _yaml_scalar(value: object) -> str: + """Render a primitive value as a YAML-safe scalar.""" + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + text = str(value) + escaped = text.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + @classmethod + def to_yaml(cls, data: object, indent: int = 0) -> str: + """Serialize a JSON-like structure to simple YAML.""" + prefix = " " * indent + if isinstance(data, dict): + lines: list[str] = [] + for key, value in data.items(): + if isinstance(value, (dict, list)): + lines.append(f"{prefix}{key}:") + lines.append(cls.to_yaml(value, indent + 2)) + else: + lines.append(f"{prefix}{key}: {cls._yaml_scalar(value)}") + return "\n".join(lines) + if isinstance(data, list): + lines = [] + for item in data: + if isinstance(item, (dict, list)): + nested = cls.to_yaml(item, indent + 2) + nested_lines = nested.splitlines() + if nested_lines: + lines.append(f"{prefix}- {nested_lines[0].lstrip()}") + lines.extend(nested_lines[1:]) + else: + lines.append(f"{prefix}-") + else: + lines.append(f"{prefix}- {cls._yaml_scalar(item)}") + return "\n".join(lines) + return f"{prefix}{cls._yaml_scalar(data)}" + + +class StatusReport(BaseReport): + """Status report builders and renderers for status command output.""" + + @staticmethod + def _status_bucket(state: str) -> str: + """Map an internal artifact state to a status bucket key.""" + mapping = { + ArtifactState.MANAGED: "managed", + ArtifactState.MANAGED_LEGACY: "managed_legacy", + ArtifactState.MODIFIED: "modified", + ArtifactState.MISSING: "missing", + ArtifactState.UNKNOWN: "unknown", + ArtifactState.UNTRACKED: "untracked", + ArtifactState.ABSENT: "absent", + } + return mapping.get(state, "unknown") + + @classmethod + def build_type_report( + cls, + *, + service: CommandService, + gen: GenericArtifactGenerator, + manifest_data: Manifest, + install_dir: Path, + ) -> dict[str, object]: + """Build structured status data for one artifact type.""" + entries: list[dict[str, str]] = [] + counts: dict[str, int] = { + "managed": 0, + "managed_legacy": 0, + "modified": 0, + "missing": 0, + "unknown": 0, + "untracked": 0, + "absent": 0, + } + existing_entries: dict[str, ArtifactEntry] = {} + + for entry in manifest_data.entries_for(gen.config.manifest_key): + existing_entries[entry.name] = entry + state, message = service.artifact_control_state( + out_file=install_dir / entry.file, + existing_entry=entry, + ) + bucket = cls._status_bucket(state) + counts[bucket] += 1 + entries.append( + { + "artifact": entry.name, + "path": entry.file, + "state": state, + "message": message, + } + ) + + for artifact in gen.render_all(): + if artifact.name in existing_entries: + continue + out_file = install_dir / gen.config.output_subdir / gen.output_path(artifact.name) + state, message = service.artifact_control_state(out_file=out_file, existing_entry=None) + bucket = cls._status_bucket(state) + counts[bucket] += 1 + entries.append( + { + "artifact": artifact.name, + "path": f"{gen.config.output_subdir}/{gen.output_path(artifact.name)}", + "state": state, + "message": message, + } + ) + + warnings = counts["managed_legacy"] + issues = counts["modified"] + counts["missing"] + counts["unknown"] + counts["untracked"] + return { + "type": gen.config.type_name, + "output_dir": str(install_dir / gen.config.output_subdir), + "counts": counts, + "issues": issues, + "warnings": warnings, + "entries": entries, + } + + @staticmethod + def summarize(reports: list[dict[str, object]]) -> tuple[int, int]: + """Compute total issue and warning counts across all type reports.""" + total_issues = 0 + total_warnings = 0 + for report in reports: + issues_value = report.get("issues") + total_issues += issues_value if isinstance(issues_value, int) else 0 + warnings_value = report.get("warnings") + total_warnings += warnings_value if isinstance(warnings_value, int) else 0 + return total_issues, total_warnings + + @staticmethod + def build_payload( + *, + install_dir: Path, + reports: list[dict[str, object]], + total_issues: int, + total_warnings: int, + ) -> dict[str, object]: + """Build output payload shared by JSON and YAML renderers.""" + return { + "ok": total_issues == 0, + "install_dir": str(install_dir), + "types": reports, + "summary": { + "issues": total_issues, + "warnings": total_warnings, + "types_checked": len(reports), + }, + } + + @staticmethod + def _missing_manifest_payload(install_dir: Path, error: str) -> dict[str, object]: + """Build the structured error payload for missing or invalid manifest state.""" + return { + "ok": False, + "error": error, + "install_dir": str(install_dir), + "types": [], + "summary": {"issues": 0}, + } + + @classmethod + def render_missing_manifest( + cls, + *, + output_format: str, + install_dir: Path, + error: str, + color, + ) -> int: + """Render an error response when status cannot read a usable manifest.""" + payload = cls._missing_manifest_payload(install_dir, error) + if output_format == "json": + print(json.dumps(payload, indent=2)) + return 1 + if output_format == "yaml": + print(cls.to_yaml(payload)) + return 1 + + print(color(Colors.RED, "FAILED") + f": {error}") + return 1 + + @staticmethod + def render_text_output( + *, + service: CommandService, + reports: list[dict[str, object]], + install_dir: Path, + verbose: bool, + total_issues: int, + color, + ) -> int: + """Render text status output and return process-style exit code.""" + print(f"{color(Colors.BOLD, 'STATUS')} {color(Colors.DIM, service.label(install_dir))}") + for report in reports: + counts = report["counts"] + if not isinstance(counts, dict): + raise TypeError(f"expected dict for 'counts', got {type(counts).__name__}") + issues_value = report.get("issues") + issues = issues_value if isinstance(issues_value, int) else 0 + warnings_value = report.get("warnings") + warnings = warnings_value if isinstance(warnings_value, int) else 0 + state_label = color(Colors.GREEN, "OK") if issues == 0 else color(Colors.RED, "ISSUES") + summary_line = ( + f"[{report['type']}] {state_label} " + f"ok={counts['managed']} mod={counts['modified']} " + f"miss={counts['missing']} untracked={counts['untracked']} " + f"unknown={counts['unknown']} legacy={counts['managed_legacy']}" + ) + print(summary_line) + if warnings: + print(color(Colors.YELLOW, f" ! {warnings} legacy warning(s)")) + + entries = report["entries"] + if not isinstance(entries, list): + raise TypeError(f"expected list for 'entries', got {type(entries).__name__}") + for entry in entries: + if not isinstance(entry, dict): + raise TypeError(f"expected dict for entry, got {type(entry).__name__}") + state = str(entry["state"]) + if not verbose and state == ArtifactState.MANAGED: + continue + + if state == ArtifactState.MANAGED: + marker = color(Colors.GREEN, " βœ“") + elif state in { + ArtifactState.MODIFIED, + ArtifactState.MISSING, + ArtifactState.UNKNOWN, + ArtifactState.UNTRACKED, + ArtifactState.MANAGED_LEGACY, + }: + marker = color(Colors.YELLOW, " !") + else: + marker = color(Colors.DIM, " Β·") + + print(f"{marker} {entry['message']}") + + print() + if total_issues: + print(color(Colors.RED, "FAILED") + f": {total_issues} artifact status issue(s) found") + return 1 + print(color(Colors.GREEN, "OK") + ": all tracked artifacts match the manifest") + return 0 diff --git a/src/vstack/cli/service.py b/src/vstack/cli/service.py new file mode 100644 index 0000000..4d44f3d --- /dev/null +++ b/src/vstack/cli/service.py @@ -0,0 +1,264 @@ +"""All vstack CLI commands as instance methods on :class:`CommandService`. + +The CLI is entirely type-generic: it iterates over +:data:`~vstack.artifacts.type_config.KNOWN_TYPES` for validate, install, and +verify instead of hard-coding skill-specific and agent-specific logic. + +All commands that touch the install root accept a single ``install_dir`` +(either workspace ``.github/`` or the VS Code user profile directory) +rather than separate ``skills_dir`` / ``agents_dir`` parameters β€” per-type +sub-directories are derived from +:attr:`~vstack.artifacts.type_config.ArtifactTypeConfig.output_subdir`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from vstack.artifacts.generator import GenericArtifactGenerator +from vstack.cli.constants import KNOWN_TYPES, ArtifactState +from vstack.manifest import CURRENT_MANIFEST_VERSION, ManifestFile, hash_with_algorithm + + +class CommandService: + """Coordinate validation, installation, verification, and uninstall flows. + + The class wires artifact-type generators into user-facing CLI operations, + while keeping per-type behavior in ``ArtifactTypeConfig`` definitions. + """ + + def __init__(self, templates_root: Path) -> None: + """Create generators for all known artifact families. + + Args: + templates_root: Root directory containing the source templates. + """ + self.root = templates_root + self.generators: list[GenericArtifactGenerator] = [ + GenericArtifactGenerator(tc, templates_root) for tc in KNOWN_TYPES + ] + + # ── Shared helpers ─────────────────────────────────────────────────────── + + def label(self, path: Path) -> str: + """Return *path* relative to template root when possible.""" + try: + return str(path.relative_to(self.root)) + except ValueError: + return str(path) + + def manifest_for(self, install_dir: Path): + """Build the manifest accessor for a given install directory.""" + return ManifestFile(parent_dir=install_dir) + + def gen_for(self, type_name: str) -> GenericArtifactGenerator | None: + """Return the generator for *type_name*, or ``None`` when unknown.""" + return next((g for g in self.generators if g.config.type_name == type_name), None) + + def artifact_control_state( + self, + *, + out_file: Path, + existing_entry, + ) -> tuple[str, str]: + """Classify ownership and drift state for one installed artifact path.""" + rel_path = self.label(out_file) + + if existing_entry is None: + if out_file.exists(): + return ( + ArtifactState.UNTRACKED, + f"{rel_path}: file exists but is not tracked by vstack", + ) + return ArtifactState.ABSENT, f"{rel_path}: file is absent and not tracked by vstack" + + if not out_file.exists(): + return ArtifactState.MISSING, f"{rel_path}: tracked file missing from disk" + + if existing_entry.checksum is None: + return ( + ArtifactState.MANAGED_LEGACY, + f"{rel_path}: tracked legacy entry without checksum; treated as managed", + ) + + checksum_algorithm = (existing_entry.checksum_algorithm or "sha256").lower() + try: + current_checksum = hash_with_algorithm( + out_file.read_text(encoding="utf-8"), + checksum_algorithm, + ) + except ValueError: + return ( + ArtifactState.UNKNOWN, + f"{rel_path}: unsupported checksum algorithm '{checksum_algorithm}' in manifest", + ) + except OSError as exc: + return ( + ArtifactState.UNKNOWN, + f"{rel_path}: could not read file β€” {exc}", + ) + + if current_checksum == existing_entry.checksum: + return ArtifactState.MANAGED, f"{rel_path}: checksum matches manifest" + + return ArtifactState.MODIFIED, f"{rel_path}: checksum differs from manifest" + + # ── validate ────────────────────────────────────────────────────────────── + + def validate(self, only: list[str] | None = None) -> int: + """Render all templates in memory; report unresolved tokens. No files written.""" + from vstack.cli.validate import ValidateCommand + + return ValidateCommand.execute(self, only=only) + + # ── install ─────────────────────────────────────────────────────────────── + + def install( + self, + install_dir: Path, + *, + only: list[str] | None = None, + force: bool = False, + force_names: list[str] | None = None, + adopt_names: list[str] | None = None, + update: bool = False, + dry_run: bool = False, + ) -> int: + """Generate and install artifacts into install_dir. + + Args: + install_dir: Workspace ``.github/`` or VS Code user profile install root. + only: Optional list of type names to restrict installation. + force: Overwrite existing artifacts unconditionally. + force_names: Overwrite only the named artifacts, even when locally modified. + adopt_names: Adopt only the named unmanaged artifacts into manifest tracking. + update: Install only when a newer version is available. + dry_run: Print what would happen without writing any files. + """ + from vstack.cli.install import InstallCommand + + return InstallCommand.execute( + self, + install_dir, + only=only, + force=force, + force_names=force_names, + adopt_names=adopt_names, + update=update, + dry_run=dry_run, + ) + + # ── verify ──────────────────────────────────────────────────────────────── + + def verify( + self, + install_dir: Path | None = None, + *, + source: bool = True, + output: bool = True, + only: list[str] | None = None, + ) -> int: + """Check source templates and/or installed output. + + Args: + install_dir: Workspace ``.github/`` or VS Code user profile install root. + source: Verify source templates (metadata, tokens, structure). + output: Verify installed output files against the manifest. + only: Optional list of type names to restrict verification. + """ + from vstack.cli.verify import VerifyCommand + + return VerifyCommand.execute( + self, + install_dir=install_dir, + source=source, + output=output, + only=only, + ) + + def status( + self, + install_dir: Path, + *, + only: list[str] | None = None, + output_format: str = "text", + verbose: bool = False, + no_color: bool = False, + ) -> int: + """Report which installed artifacts still match the manifest and which do not.""" + from vstack.cli.status import StatusCommand + + return StatusCommand.execute( + self, + install_dir=install_dir, + only=only, + output_format=output_format, + verbose=verbose, + no_color=no_color, + ) + + def manifest_upgrade(self, install_dir: Path, *, backfill: bool = False) -> int: + """Upgrade a legacy manifest schema to the current schema version.""" + manifest_file = self.manifest_for(install_dir) + manifest_data = manifest_file.read(allow_legacy=True) + + if manifest_data is None: + if manifest_file.read_error: + print(f"ERROR: {manifest_file.read_error}", file=sys.stderr) + return 1 + print("No manifest found to upgrade.") + return 1 + + needs_upgrade = manifest_data.needs_upgrade() + if not needs_upgrade and not backfill: + print(f"Manifest already up to date at schema v{manifest_data.manifest_version}.") + return 0 + + upgraded = manifest_data.upgraded() if needs_upgrade else manifest_data + backfilled: list[str] = [] + skipped: list[str] = [] + if backfill: + upgraded, backfilled, skipped = upgraded.with_backfilled_checksums( + install_dir=install_dir, + ) + + manifest_file.write(upgraded) + + if needs_upgrade: + print( + f"Upgraded manifest to schema v{CURRENT_MANIFEST_VERSION}: " + f"{self.label(manifest_file.path)}" + ) + else: + print(f"Manifest schema already current at v{upgraded.manifest_version}.") + + if backfill: + print( + f"Backfilled checksums for {len(backfilled)} tracked artifact(s); " + f"skipped {len(skipped)}." + ) + for item in skipped: + print(f" ! {item}") + return 0 + + # ── uninstall ───────────────────────────────────────────────────────────── + + def uninstall( + self, + install_dir: Path, + *, + only: list[str] | None = None, + force: bool = False, + force_names: list[str] | None = None, + ) -> int: + """Remove tracked files whose ownership still matches the manifest.""" + from vstack.cli.uninstall import UninstallCommand + + return UninstallCommand.execute( + self, + install_dir, + only=only, + force=force, + force_names=force_names, + ) diff --git a/src/vstack/cli/status.py b/src/vstack/cli/status.py new file mode 100644 index 0000000..cddc4a6 --- /dev/null +++ b/src/vstack/cli/status.py @@ -0,0 +1,110 @@ +"""Status command wrapper.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.constants import Colors +from vstack.cli.report import StatusReport + +if TYPE_CHECKING: + from vstack.cli.service import CommandService + + +class StatusCommand(BaseCommand): + """Report manifest ownership and checksum state.""" + + def __init__(self, service: CommandService) -> None: + self._service = service + + @staticmethod + def execute( + service: CommandService, + *, + install_dir: Path, + only: list[str] | None = None, + output_format: str = "text", + verbose: bool = False, + no_color: bool = False, + ) -> int: + """Report which installed artifacts still match the manifest and which do not.""" + manifest_file = service.manifest_for(install_dir) + manifest_data = manifest_file.read() + gens = [g for g in service.generators if only is None or g.config.type_name in only] + reports: list[dict[str, object]] = [] + + def color(code: str, text: str) -> str: + """Apply ANSI color to text when enabled.""" + use_color = output_format == "text" and not no_color and sys.stdout.isatty() + return f"{code}{text}{Colors.RESET}" if use_color else text + + if manifest_data is None: + if manifest_file.read_error: + return StatusReport.render_missing_manifest( + output_format=output_format, + install_dir=install_dir, + error=manifest_file.read_error, + color=color, + ) + + return StatusReport.render_missing_manifest( + output_format=output_format, + install_dir=install_dir, + error="vstack.json not found; run vstack install before using status", + color=color, + ) + + for gen in gens: + reports.append( + StatusReport.build_type_report( + service=service, + gen=gen, + manifest_data=manifest_data, + install_dir=install_dir, + ) + ) + + total_issues, total_warnings = StatusReport.summarize(reports) + payload = StatusReport.build_payload( + install_dir=install_dir, + reports=reports, + total_issues=total_issues, + total_warnings=total_warnings, + ) + + if output_format == "json": + print(json.dumps(payload, indent=2)) + return 0 if total_issues == 0 else 1 + + if output_format == "yaml": + print(StatusReport.to_yaml(payload)) + return 0 if total_issues == 0 else 1 + + return StatusReport.render_text_output( + service=service, + reports=reports, + install_dir=install_dir, + verbose=verbose, + total_issues=total_issues, + color=color, + ) + + def run( + self, + *, + context: CommandContext, + ) -> int: + install_dir = context.require_install_dir("status") + + return StatusCommand.execute( + self._service, + install_dir=install_dir, + only=context.only, + output_format=getattr(context.args, "output_format", "text"), + verbose=getattr(context.args, "verbose", False), + no_color=getattr(context.args, "no_color", False), + ) diff --git a/src/vstack/cli/uninstall.py b/src/vstack/cli/uninstall.py new file mode 100644 index 0000000..55db8a3 --- /dev/null +++ b/src/vstack/cli/uninstall.py @@ -0,0 +1,188 @@ +"""Uninstall command wrapper.""" + +from __future__ import annotations + +import datetime +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.constants import ArtifactState +from vstack.cli.helpers import normalize_targeted_names +from vstack.constants import VERSION +from vstack.manifest import Manifest, preserve_existing_entry, preserved_manifest_entries + +if TYPE_CHECKING: + from vstack.cli.service import CommandService + + +class UninstallCommand(BaseCommand): + """Uninstall tracked artifacts from the selected install directory.""" + + def __init__(self, service: CommandService) -> None: + self._service = service + + @staticmethod + def _remove_or_preserve_entry( + *, + service: CommandService, + gen, + out_dir: Path, + install_dir: Path, + entry, + force: bool, + targeted_force_names: set[str], + removed: list[str], + preserved: list[str], + new_entries: dict, + ) -> None: + """Handle uninstall decision for one manifest-tracked artifact.""" + full_path = install_dir / entry.file + force_name = ( + entry.name in targeted_force_names or service.label(full_path) in targeted_force_names + ) + state, message = service.artifact_control_state( + out_file=full_path, + existing_entry=entry, + ) + + if ( + state in {ArtifactState.MANAGED, ArtifactState.MANAGED_LEGACY, ArtifactState.MISSING} + or force + or force_name + ): + if full_path.exists(): + full_path.unlink() + removed.append(service.label(full_path)) + if gen.config.artifact_is_dir: + artifact_dir = full_path.parent + if ( + artifact_dir.exists() + and artifact_dir != out_dir + and not any(artifact_dir.iterdir()) + ): + artifact_dir.rmdir() + removed.append(service.label(artifact_dir)) + return + + preserved.append(message) + preserve_existing_entry( + new_entries=new_entries, + manifest_key=gen.config.manifest_key, + existing_entry=entry, + ) + + @staticmethod + def _write_updated_manifest( + *, + service: CommandService, + manifest_file, + manifest_data, + new_entries: dict[str, list], + removed: list[str], + ) -> None: + """Persist updated manifest data after uninstall decisions are applied.""" + if new_entries: + manifest = Manifest( + manifest_version=manifest_data.manifest_version, + hash_algorithm=manifest_data.hash_algorithm, + vstack_version=VERSION, + installed_at=datetime.datetime.now(datetime.UTC).isoformat(), + artifacts=new_entries, + ) + manifest_file.write(manifest) + return + + if manifest_file.exists(): + manifest_file.path.unlink() + removed.append(service.label(manifest_file.path)) + + @staticmethod + def _print_summary(*, removed: list[str], preserved: list[str]) -> None: + """Render uninstall summary output.""" + if removed: + for path in removed: + print(f" removed {path}") + if preserved: + for message in preserved: + print(f" preserved {message}. Use --force or --force-name to remove it anyway.") + if not removed and not preserved: + print("Nothing to remove.") + + @staticmethod + def execute( + service: CommandService, + install_dir: Path, + *, + only: list[str] | None = None, + force: bool = False, + force_names: list[str] | None = None, + ) -> int: + """Remove tracked files whose ownership still matches the manifest.""" + removed: list[str] = [] + preserved: list[str] = [] + manifest_file = service.manifest_for(install_dir) + manifest_data = manifest_file.read() + gens = [g for g in service.generators if only is None or g.config.type_name in only] + targeted_force_names = normalize_targeted_names(force_names) + + if manifest_data is None: + if manifest_file.read_error: + print(f"ERROR: {manifest_file.read_error}", file=sys.stderr) + return 1 + print("Nothing to remove.") + return 0 + + selected_manifest_keys = {gen.config.manifest_key for gen in gens} + new_entries = preserved_manifest_entries( + manifest_data, + selected_manifest_keys, + ) + + for gen in gens: + out_dir = install_dir / gen.config.output_subdir + entries = manifest_data.entries_for(gen.config.manifest_key) + + for entry in entries: + UninstallCommand._remove_or_preserve_entry( + service=service, + gen=gen, + out_dir=out_dir, + install_dir=install_dir, + entry=entry, + force=force, + targeted_force_names=targeted_force_names, + removed=removed, + preserved=preserved, + new_entries=new_entries, + ) + + if out_dir.exists() and not any(out_dir.iterdir()): + out_dir.rmdir() + removed.append(service.label(out_dir) + "/") + + UninstallCommand._write_updated_manifest( + service=service, + manifest_file=manifest_file, + manifest_data=manifest_data, + new_entries=new_entries, + removed=removed, + ) + UninstallCommand._print_summary(removed=removed, preserved=preserved) + return 0 + + def run( + self, + *, + context: CommandContext, + ) -> int: + install_dir = context.require_install_dir("uninstall") + + return UninstallCommand.execute( + self._service, + install_dir, + only=context.only, + force=getattr(context.args, "force", False), + force_names=getattr(context.args, "force_names", None), + ) diff --git a/src/vstack/cli/validate.py b/src/vstack/cli/validate.py new file mode 100644 index 0000000..9040e9e --- /dev/null +++ b/src/vstack/cli/validate.py @@ -0,0 +1,62 @@ +"""Validate command wrapper.""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +from vstack.artifacts.models import RenderedArtifact +from vstack.cli.base import BaseCommand, CommandContext + +if TYPE_CHECKING: + from vstack.cli.service import CommandService + + +class ValidateCommand(BaseCommand): + """Run source template validation.""" + + def __init__(self, service: CommandService) -> None: + self._service = service + + @staticmethod + def execute(service: CommandService, only: list[str] | None = None) -> int: + """Render templates in memory and report unresolved placeholders.""" + gens = [g for g in service.generators if only is None or g.config.type_name in only] + all_artifacts: dict[str, list[RenderedArtifact]] = {} + total_partials = 0 + for gen in gens: + artifacts = gen.render_all() + all_artifacts[gen.config.type_name] = artifacts + total_partials += len(gen.load_partials()) + + if not any(all_artifacts.values()): + print("ERROR: No templates found", file=sys.stderr) + return 1 + + errors: list[RenderedArtifact] = [] + for type_name, artifacts in all_artifacts.items(): + type_gen = service.gen_for(type_name) + if type_gen is None: + continue + print(f"\n{type_name.capitalize()} ({len(artifacts)}):") + for artifact in artifacts: + suffix = f" ⚠ unresolved: {artifact.unresolved}" if artifact.unresolved else "" + print(f" {type_gen.output_path(artifact.name)}{suffix}") + errors.extend(artifact for artifact in artifacts if artifact.unresolved) + + total = sum(len(v) for v in all_artifacts.values()) + if errors: + print( + f"\nERROR: {len(errors)} template(s) have unresolved placeholders", + file=sys.stderr, + ) + return 1 + print(f"\nOK: {total} artifact(s), {total_partials} partial(s)") + return 0 + + def run( + self, + *, + context: CommandContext, + ) -> int: + return ValidateCommand.execute(self._service, only=context.only) diff --git a/src/vstack/cli/verify.py b/src/vstack/cli/verify.py new file mode 100644 index 0000000..f13df0f --- /dev/null +++ b/src/vstack/cli/verify.py @@ -0,0 +1,292 @@ +"""Verify command wrapper.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.constants import EXPECTED_INPUT_NAMES, ArtifactState +from vstack.models import CheckMessage, ValidationResult + +if TYPE_CHECKING: + from vstack.cli.service import CommandService + + +class VerifyCommand(BaseCommand): + """Run verify checks for source templates and/or installed output.""" + + def __init__(self, service: CommandService) -> None: + self._service = service + + @staticmethod + def _print_result(result: ValidationResult, results: list[ValidationResult]) -> None: + """Render one validation result block and accumulate totals.""" + for msg in result.messages: + prefix = "βœ“" if msg.level == "pass" else "βœ—" + print(f" {prefix} {msg.message}") + if result.failures == 0: + print(f" βœ“ {result.passes} check(s) passed") + results.append(result) + + @staticmethod + def _verify_source( + *, + gens, + results: list[ValidationResult], + ) -> None: + """Run source template verification for selected generators.""" + for gen in gens: + expected = EXPECTED_INPUT_NAMES.get(gen.config.type_name) + verify_result = gen.verify_input(expected) + if verify_result.messages: + VerifyCommand._print_result(verify_result, results) + else: + print(f" (no {gen.config.type_name} templates found, skipping)") + + @staticmethod + def _expected_output_names(gen, manifest_data) -> list[str] | None: + """Resolve expected output names for verify output checks.""" + if manifest_data: + return manifest_data.names_for(gen.config.manifest_key) + return EXPECTED_INPUT_NAMES.get(gen.config.type_name) + + @staticmethod + def _expected_manifest_metadata( + service: CommandService, gen, manifest_data, entry + ) -> dict[str, str]: + """Build expected metadata values for one manifest-tracked artifact.""" + expected_meta = { + "generator": "vstack", + "vstack_version": manifest_data.vstack_version, + "artifact_type": gen.config.type_name, + "artifact_name": entry.name, + } + if entry.version is not None: + expected_meta["artifact_version"] = entry.version + return expected_meta + + @staticmethod + def _verify_manifest_metadata_entry( + service: CommandService, gen, manifest_data, entry, artifact_path: Path + ) -> ValidationResult: + """Verify manifest-linked metadata for a single artifact file.""" + from vstack.artifacts.generator import GenericArtifactGenerator + + result = ValidationResult() + rel_path = service.label(artifact_path) + try: + content = artifact_path.read_text(encoding="utf-8") + except OSError as exc: + result.messages.append( + CheckMessage( + "fail", + f"{rel_path}: could not read file during metadata verify β€” {exc}", + ) + ) + return result + + metadata = GenericArtifactGenerator.parse_generation_metadata(content) + + if metadata is None: + if "AUTO-GENERATED" not in content: + result.messages.append( + CheckMessage( + "fail", + f"{rel_path}: missing VSTACK-META and missing AUTO-GENERATED footer", + ) + ) + return result + + result.messages.append( + CheckMessage( + "pass", + f"{rel_path}: missing VSTACK-META footer; " + "treating manifest entry as source of truth", + ) + ) + result.messages.append( + CheckMessage( + "pass", + f"{rel_path}: legacy artifact accepted from manifest tracking", + ) + ) + return result + + for key, expected_value in VerifyCommand._expected_manifest_metadata( + service, gen, manifest_data, entry + ).items(): + actual_value = metadata.get(key) + if actual_value == expected_value: + result.messages.append(CheckMessage("pass", f"{rel_path}: {key} matches manifest")) + else: + result.messages.append( + CheckMessage( + "fail", + f"{rel_path}: {key} mismatch " + f"(expected '{expected_value}', got '{actual_value}')", + ) + ) + + return result + + @staticmethod + def _verify_manifest_metadata( + service: CommandService, gen, manifest_data, install_dir: Path + ) -> ValidationResult | None: + """Verify footer metadata for all manifest-tracked artifacts of one type.""" + manifest_entries = manifest_data.entries_for(gen.config.manifest_key) + if not manifest_entries: + return None + + result = ValidationResult() + for entry in manifest_entries: + artifact_path = install_dir / entry.file + if not artifact_path.exists(): + continue + entry_result = VerifyCommand._verify_manifest_metadata_entry( + service, + gen, + manifest_data, + entry, + artifact_path, + ) + result.messages.extend(entry_result.messages) + + return result if result.messages else None + + @staticmethod + def _verify_manifest_checksums( + service: CommandService, gen, manifest_data, install_dir: Path + ) -> ValidationResult | None: + """Verify checksum ownership and drift for manifest-tracked artifacts.""" + manifest_entries = manifest_data.entries_for(gen.config.manifest_key) + if not manifest_entries: + return None + + result = ValidationResult() + for entry in manifest_entries: + state, message = service.artifact_control_state( + out_file=install_dir / entry.file, + existing_entry=entry, + ) + level = ( + "pass" if state in {ArtifactState.MANAGED, ArtifactState.MANAGED_LEGACY} else "fail" + ) + result.messages.append(CheckMessage(level, message)) + + return result if result.messages else None + + @staticmethod + def _verify_output( + *, + service: CommandService, + install_dir: Path, + gens, + header, + results: list[ValidationResult], + ) -> int: + """Run installed-output verification for selected generators.""" + manifest_file = service.manifest_for(install_dir) + manifest_data = manifest_file.read() + if manifest_data is None and manifest_file.read_error: + print(f"ERROR: {manifest_file.read_error}", file=sys.stderr) + return 1 + + for gen in gens: + out_dir = install_dir / gen.config.output_subdir + header(f"checking installed {gen.config.type_name} ({service.label(out_dir)}/)") + if not out_dir.exists(): + VerifyCommand._print_result( + ValidationResult( + messages=[ + CheckMessage( + level="fail", + message=f"{service.label(out_dir)}/ not found β€” run: vstack install", + ) + ] + ), + results, + ) + continue + + expected = VerifyCommand._expected_output_names(gen, manifest_data) + VerifyCommand._print_result(gen.verify_output(out_dir, expected), results) + + if not manifest_data: + continue + + checksum_result = VerifyCommand._verify_manifest_checksums( + service, gen, manifest_data, install_dir + ) + if checksum_result: + VerifyCommand._print_result(checksum_result, results) + + metadata_result = VerifyCommand._verify_manifest_metadata( + service, gen, manifest_data, install_dir + ) + if metadata_result: + VerifyCommand._print_result(metadata_result, results) + + return 0 + + @staticmethod + def execute( + service: CommandService, + *, + install_dir: Path | None = None, + source: bool = True, + output: bool = True, + only: list[str] | None = None, + ) -> int: + """Check source templates and/or installed output.""" + results: list[ValidationResult] = [] + section_count = sum([source, output]) + step = 0 + gens = [g for g in service.generators if only is None or g.config.type_name in only] + + def _header(label: str) -> None: + """Print a numbered section header for verify progress output.""" + nonlocal step + step += 1 + print(f"[{step}/{section_count}] {label}") + + if source: + _header("checking source templates") + VerifyCommand._verify_source(gens=gens, results=results) + + if output: + if install_dir is None: + print("ERROR: install_dir required for output checks", file=sys.stderr) + return 1 + output_exit = VerifyCommand._verify_output( + service=service, + install_dir=install_dir, + gens=gens, + header=_header, + results=results, + ) + if output_exit: + return output_exit + + print() + total_failures = sum(r.failures for r in results) + if total_failures: + print(f"FAILED: {total_failures} check(s) failed") + return 1 + print("All checks passed.") + return 0 + + def run( + self, + *, + context: CommandContext, + ) -> int: + return VerifyCommand.execute( + self._service, + install_dir=context.install_dir, + source=getattr(context.args, "source", True), + output=getattr(context.args, "output", True), + only=context.only, + ) diff --git a/src/vstack/constants.py b/src/vstack/constants.py index 192a2e4..ef96d01 100644 --- a/src/vstack/constants.py +++ b/src/vstack/constants.py @@ -50,7 +50,7 @@ def _head_semver_tag() -> str | None: if repo_root is None: return None try: - out = subprocess.check_output( + out = subprocess.check_output( # nosec B603 B607 ["git", "-C", str(repo_root), "tag", "--points-at", "HEAD"], stderr=subprocess.DEVNULL, text=True, diff --git a/src/vstack/main.py b/src/vstack/main.py index 48dfb14..eacbe28 100644 --- a/src/vstack/main.py +++ b/src/vstack/main.py @@ -4,68 +4,21 @@ import sys -from vstack.cli.commands import CommandLineInterface +from vstack.cli.interface import CommandLineInterface from vstack.cli.parser import CommandLineParser +from vstack.cli.service import CommandService from vstack.constants import TEMPLATES_ROOT -_GLOBAL_SUPPORTED_TYPES = ["agent", "instruction", "prompt", "skill"] - - -def _resolve_only_for_scope(args: object) -> list[str] | None: - """Resolve the active artifact-type filter for the parsed CLI arguments. - - Args: - args: Parsed CLI arguments object, typically from ``argparse``. - - Returns: - The explicit ``--only`` filter for the active scope, the default - global-profile artifact set, or ``None`` when no filter applies. - """ - requested_only = getattr(args, "only", None) - if not getattr(args, "use_global", False): - return requested_only - - if requested_only is None: - return list(_GLOBAL_SUPPORTED_TYPES) - - disallowed = [t for t in requested_only if t not in _GLOBAL_SUPPORTED_TYPES] - if disallowed: - print( - "ERROR: --global supports only agents, prompts, and instructions. " - f"Unsupported type(s): {', '.join(disallowed)}", - file=sys.stderr, - ) - sys.exit(1) - - return requested_only - def main() -> None: """Parse CLI arguments and dispatch the selected top-level command.""" - cli_parser = CommandLineParser() - parser = cli_parser.build() - args = parser.parse_args() - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - - if args.command == "validate": - sys.exit(cli.validate(only=getattr(args, "only", None))) - - install_dir = cli_parser.resolve_targets(args) - only = _resolve_only_for_scope(args) if args.command in {"install", "verify"} else None - dispatch = { - "verify": lambda: cli.verify( - install_dir=install_dir, - source=getattr(args, "source", True), - output=getattr(args, "output", True), - only=only, - ), - "install": lambda: cli.install( - install_dir, - only=only, - force=getattr(args, "force", False), - update=getattr(args, "update", False), - dry_run=getattr(args, "dry_run", False), - ), - "uninstall": lambda: cli.uninstall(install_dir), - } - sys.exit(dispatch[args.command]()) + try: + interface = CommandLineInterface( + parser_cls=CommandLineParser, + service_cls=CommandService, + templates_root=TEMPLATES_ROOT, + ) + sys.exit(interface.run()) + except ValueError as error: + print(f"ERROR: {error}", file=sys.stderr) + sys.exit(1) diff --git a/src/vstack/manifest/__init__.py b/src/vstack/manifest/__init__.py new file mode 100644 index 0000000..c46b11c --- /dev/null +++ b/src/vstack/manifest/__init__.py @@ -0,0 +1,25 @@ +"""Manifest domain models and persistence helpers.""" + +from vstack.manifest.store import ( + CURRENT_HASH_ALGORITHM, + CURRENT_MANIFEST_VERSION, + ArtifactEntry, + Manifest, + ManifestFile, + content_hash, + hash_with_algorithm, + preserve_existing_entry, + preserved_manifest_entries, +) + +__all__ = [ + "CURRENT_HASH_ALGORITHM", + "CURRENT_MANIFEST_VERSION", + "ArtifactEntry", + "Manifest", + "ManifestFile", + "content_hash", + "hash_with_algorithm", + "preserve_existing_entry", + "preserved_manifest_entries", +] diff --git a/src/vstack/manifest/store.py b/src/vstack/manifest/store.py new file mode 100644 index 0000000..bb53f3b --- /dev/null +++ b/src/vstack/manifest/store.py @@ -0,0 +1,354 @@ +"""vstack manifest model and file persistence.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass, field +from pathlib import Path + +from vstack.constants import MANIFEST_FILENAME + +CURRENT_MANIFEST_VERSION = 2 +CURRENT_HASH_ALGORITHM = "sha256" +_META_COMMENT_RE = re.compile(r"") + + +def content_hash(content: str) -> str: + """Return a stable SHA-256 checksum for rendered artifact content.""" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def hash_with_algorithm(content: str, algorithm: str) -> str: + """Return a checksum for *content* using the requested algorithm.""" + normalized = algorithm.lower() + if normalized == "sha256": + return hashlib.sha256(content.encode("utf-8")).hexdigest() + if normalized == "md5": + return hashlib.md5(content.encode("utf-8"), usedforsecurity=False).hexdigest() + raise ValueError(f"Unsupported checksum algorithm: {algorithm}") + + +@dataclass +class ArtifactEntry: + """Represent a single installed artifact entry in ``vstack.json``.""" + + name: str + file: str + version: str | None = None + checksum: str | None = None + checksum_algorithm: str | None = None + + +@dataclass +class Manifest: + """Represent the parsed install manifest stored in ``vstack.json``.""" + + vstack_version: str + installed_at: str + manifest_version: int = CURRENT_MANIFEST_VERSION + hash_algorithm: str = CURRENT_HASH_ALGORITHM + artifacts: dict[str, list[ArtifactEntry]] = field(default_factory=lambda: {}) + + def entries_for(self, type_name: str) -> list[ArtifactEntry]: + """Return manifest entries for a single artifact type key.""" + return self.artifacts.get(type_name, []) + + def names_for(self, type_name: str) -> list[str]: + """Return artifact names for a single manifest type key.""" + return [e.name for e in self.entries_for(type_name)] + + def files_for(self, type_name: str) -> list[str]: + """Return relative output file paths for a manifest type key.""" + return [e.file for e in self.entries_for(type_name)] + + def to_dict(self) -> dict: + """Serialize the manifest into JSON-compatible primitives.""" + return { + "manifest_version": self.manifest_version, + "hash_algorithm": self.hash_algorithm, + "vstack_version": self.vstack_version, + "installed_at": self.installed_at, + "artifacts": { + type_name: [ + { + "name": e.name, + "file": e.file, + **({} if e.version is None else {"version": e.version}), + **({} if e.checksum is None else {"checksum": e.checksum}), + **( + {} + if e.checksum is None or e.checksum_algorithm is None + else {"checksum_algorithm": e.checksum_algorithm} + ), + } + for e in entries + ] + for type_name, entries in self.artifacts.items() + }, + } + + @staticmethod + def _infer_algorithm( + *, + entry: dict, + fallback_algorithm: str, + manifest_version: int, + ) -> str | None: + """Infer checksum algorithm for entries missing explicit metadata.""" + explicit = entry.get("checksum_algorithm") or entry.get("hash_algorithm") + if explicit: + return str(explicit) + + checksum = entry.get("checksum") or entry.get("content_hash") + if not checksum: + return None + + if isinstance(checksum, str): + if len(checksum) == 64: + return CURRENT_HASH_ALGORITHM + if ( + manifest_version >= CURRENT_MANIFEST_VERSION + and fallback_algorithm == CURRENT_HASH_ALGORITHM + ): + return CURRENT_HASH_ALGORITHM + return None + + def needs_upgrade(self) -> bool: + """Return ``True`` when this manifest should be normalized to current schema.""" + return self.manifest_version < CURRENT_MANIFEST_VERSION + + @staticmethod + def _infer_algorithm_for_legacy_entry(checksum: str, fallback_algorithm: str) -> str | None: + """Infer the checksum algorithm for a legacy entry that has no explicit algorithm field. + + Uses the same heuristics as :meth:`_infer_algorithm` but with ``manifest_version=0`` + to signal the pre-schema-v2 legacy path explicitly, avoiding a magic-constant call site. + """ + return Manifest._infer_algorithm( + entry={"checksum": checksum}, + fallback_algorithm=fallback_algorithm, + manifest_version=0, + ) + + def upgraded(self) -> Manifest: + """Return an upgraded copy of this manifest in current schema format.""" + upgraded_artifacts: dict[str, list[ArtifactEntry]] = {} + for type_name, entries in self.artifacts.items(): + upgraded_artifacts[type_name] = [] + for entry in entries: + checksum_algorithm = entry.checksum_algorithm + if entry.checksum and checksum_algorithm is None: + checksum_algorithm = self._infer_algorithm_for_legacy_entry( + checksum=entry.checksum, + fallback_algorithm=str(self.hash_algorithm).lower(), + ) + + upgraded_artifacts[type_name].append( + ArtifactEntry( + name=entry.name, + file=entry.file, + version=entry.version, + checksum=entry.checksum, + checksum_algorithm=checksum_algorithm, + ) + ) + + return Manifest( + vstack_version=self.vstack_version, + installed_at=self.installed_at, + manifest_version=CURRENT_MANIFEST_VERSION, + hash_algorithm=CURRENT_HASH_ALGORITHM, + artifacts=upgraded_artifacts, + ) + + @staticmethod + def _has_vstack_meta_footer(content: str) -> bool: + """Return ``True`` when content includes a VSTACK-META footer block.""" + return bool(_META_COMMENT_RE.search(content)) + + def with_backfilled_checksums( + self, + *, + install_dir: Path, + ) -> tuple[Manifest, list[str], list[str]]: + """Return a copy with checksums backfilled for footer-tagged legacy entries. + + Legacy entries are eligible only when: + - entry has no ``checksum`` yet + - file exists on disk under ``install_dir`` + - file still includes a ``VSTACK-META`` footer + """ + normalized_algorithm = str(self.hash_algorithm or CURRENT_HASH_ALGORITHM).lower() + if normalized_algorithm not in {"sha256", "md5"}: + normalized_algorithm = CURRENT_HASH_ALGORITHM + + backfilled: list[str] = [] + skipped: list[str] = [] + updated_artifacts: dict[str, list[ArtifactEntry]] = {} + + for type_name, entries in self.artifacts.items(): + updated_artifacts[type_name] = [] + for entry in entries: + if entry.checksum is not None: + updated_artifacts[type_name].append(entry) + continue + + out_file = install_dir / entry.file + display_name = f"{type_name}:{entry.name}" + + if not out_file.exists(): + skipped.append(f"{display_name} (missing file)") + updated_artifacts[type_name].append(entry) + continue + + try: + content = out_file.read_text(encoding="utf-8") + except OSError: + skipped.append(f"{display_name} (unreadable file)") + updated_artifacts[type_name].append(entry) + continue + + if not self._has_vstack_meta_footer(content): + skipped.append(f"{display_name} (missing VSTACK-META footer)") + updated_artifacts[type_name].append(entry) + continue + + checksum = hash_with_algorithm(content, normalized_algorithm) + updated_artifacts[type_name].append( + ArtifactEntry( + name=entry.name, + file=entry.file, + version=entry.version, + checksum=checksum, + checksum_algorithm=normalized_algorithm, + ) + ) + backfilled.append(display_name) + + return ( + Manifest( + vstack_version=self.vstack_version, + installed_at=self.installed_at, + manifest_version=self.manifest_version, + hash_algorithm=self.hash_algorithm, + artifacts=updated_artifacts, + ), + backfilled, + skipped, + ) + + @classmethod + def from_dict(cls, data: dict) -> Manifest: + """Create a :class:`Manifest` from parsed JSON data.""" + try: + manifest_version = int(data.get("manifest_version", 1)) + except (TypeError, ValueError) as error: + raise ValueError("Invalid manifest_version in vstack.json") from error + manifest_hash_algorithm = str(data.get("hash_algorithm") or CURRENT_HASH_ALGORITHM) + + artifacts: dict[str, list[ArtifactEntry]] = {} + raw_artifacts = data.get("artifacts", {}) + if not isinstance(raw_artifacts, dict): + raw_artifacts = {} + + for type_name, entries in raw_artifacts.items(): + if not isinstance(entries, list): + continue + artifacts[type_name] = [ + ArtifactEntry( + name=e["name"], + file=e["file"], + version=e.get("version"), + checksum=e.get("checksum") or e.get("content_hash"), + checksum_algorithm=cls._infer_algorithm( + entry=e, + fallback_algorithm=manifest_hash_algorithm, + manifest_version=manifest_version, + ), + ) + for e in entries + if isinstance(e, dict) and "name" in e and "file" in e + ] + return cls( + manifest_version=manifest_version, + hash_algorithm=manifest_hash_algorithm, + vstack_version=data.get("vstack_version", ""), + installed_at=data.get("installed_at", ""), + artifacts=artifacts, + ) + + +def preserved_manifest_entries( + existing_manifest: Manifest | None, + selected_manifest_keys: set[str], +) -> dict[str, list[ArtifactEntry]]: + """Preserve artifact families not selected for the current operation.""" + if existing_manifest is None: + return {} + + preserved: dict[str, list[ArtifactEntry]] = {} + for manifest_key, entries in existing_manifest.artifacts.items(): + if manifest_key not in selected_manifest_keys: + preserved[manifest_key] = list(entries) + return preserved + + +def preserve_existing_entry( + *, + new_entries: dict[str, list[ArtifactEntry]], + manifest_key: str, + existing_entry: ArtifactEntry, +) -> None: + """Carry forward one unchanged manifest entry for a manifest key.""" + new_entries.setdefault(manifest_key, []).append(existing_entry) + + +class ManifestFile: + """Read and write the ``vstack.json`` manifest inside an install root.""" + + def __init__(self, parent_dir: Path) -> None: + self.path = parent_dir / MANIFEST_FILENAME + self.read_error: str | None = None + + def exists(self) -> bool: + """Return ``True`` when the manifest file exists on disk.""" + return self.path.exists() + + def read(self, *, allow_legacy: bool = False) -> Manifest | None: + """Parse the manifest file from disk.""" + if not self.path.exists(): + self.read_error = None + return None + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + manifest = Manifest.from_dict(data) + if manifest.needs_upgrade() and not allow_legacy: + self.read_error = ( + "Legacy manifest schema detected in vstack.json. " + "Run: vstack manifest upgrade --target ." + ) + return None + + self.read_error = None + return manifest + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + self.read_error = "Invalid manifest format in vstack.json" + return None + + def write(self, manifest: Manifest) -> None: + """Write a manifest to disk in stable, human-readable JSON format. + + The write is atomic: content is first written to a sibling temporary file + and then replaced with an ``os.replace`` call so a crash or keyboard + interrupt cannot leave ``vstack.json`` in a partially-written state. + """ + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = self.path.with_name(self.path.name + ".tmp") + tmp_path.write_text( + json.dumps(manifest.to_dict(), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + os.replace(tmp_path, self.path) diff --git a/tests/vstack/cli/test_base.py b/tests/vstack/cli/test_base.py new file mode 100644 index 0000000..445caae --- /dev/null +++ b/tests/vstack/cli/test_base.py @@ -0,0 +1,45 @@ +"""Tests for CLI base types: CommandContext and BaseCommand.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path + +import pytest + +from vstack.cli.base import BaseCommand, CommandContext + + +class _FakeCommand(BaseCommand): + """Minimal concrete BaseCommand for testing.""" + + def __init__(self, exit_code: int = 0) -> None: + self.exit_code = exit_code + + def run(self, *, context: CommandContext) -> int: + del context + return self.exit_code + + +class TestCommandContext: + """Test cases for CommandContext.""" + + def test_require_install_dir_returns_value(self, tmp_path: Path) -> None: + """Returns install_dir when it is set.""" + context = CommandContext(args=Namespace(), install_dir=tmp_path, only=None) + assert context.require_install_dir("install") == tmp_path + + def test_require_install_dir_raises_when_missing(self) -> None: + """Raises ValueError when the command requires install_dir but none is set.""" + context = CommandContext(args=Namespace(), install_dir=None, only=None) + with pytest.raises(ValueError, match="requires install_dir"): + context.require_install_dir("status") + + +class TestBaseCommand: + """Test cases for BaseCommand.""" + + def test_concrete_subclass_run_returns_exit_code(self) -> None: + """Concrete subclass run() returns the configured exit code.""" + context = CommandContext(args=Namespace(), install_dir=None, only=None) + assert _FakeCommand(exit_code=3).run(context=context) == 3 diff --git a/tests/vstack/cli/test_catalog.py b/tests/vstack/cli/test_catalog.py new file mode 100644 index 0000000..0968f7e --- /dev/null +++ b/tests/vstack/cli/test_catalog.py @@ -0,0 +1,72 @@ +"""Tests for COMMAND_CATALOG and MANIFEST_SUBCOMMAND_CATALOG configuration.""" + +from __future__ import annotations + +import dataclasses +from typing import Any, cast + +import pytest + +from vstack.cli.catalog import ( + COMMAND_CATALOG, + MANIFEST_SUBCOMMAND_CATALOG, + TOP_LEVEL_COMMAND_ORDER, + ManifestSubcommandConfig, +) + + +class TestTopLevelCommandConfig: + """Test cases for TopLevelCommandConfig dataclass.""" + + def test_catalog_contains_expected_commands(self) -> None: + """COMMAND_CATALOG must contain all expected top-level commands.""" + assert set(COMMAND_CATALOG.keys()) == { + "validate", + "verify", + "status", + "manifest", + "install", + "uninstall", + } + + def test_command_order_matches_catalog_keys(self) -> None: + """TOP_LEVEL_COMMAND_ORDER must reference known catalog commands.""" + assert set(TOP_LEVEL_COMMAND_ORDER).issubset(set(COMMAND_CATALOG.keys())) + + def test_install_config_requires_install_dir(self) -> None: + """install command must require install_dir.""" + assert COMMAND_CATALOG["install"].requires_install_dir is True + + def test_validate_config_does_not_require_install_dir(self) -> None: + """validate command must not require install_dir.""" + assert COMMAND_CATALOG["validate"].requires_install_dir is False + + def test_frozen_dataclass_cannot_be_mutated(self) -> None: + """TopLevelCommandConfig is frozen; attribute assignment must raise.""" + with pytest.raises((TypeError, dataclasses.FrozenInstanceError)): + cast(Any, COMMAND_CATALOG["install"]).help_text = "changed" + + def test_all_commands_with_scope_have_scope_help(self) -> None: + """Every command with include_scope_group=True must supply scope_help.""" + for name, cfg in COMMAND_CATALOG.items(): + if cfg.include_scope_group: + assert cfg.scope_help, f"{name}: include_scope_group=True but scope_help is empty" + + +class TestManifestSubcommandConfig: + """Test cases for ManifestSubcommandConfig dataclass.""" + + def test_catalog_contains_expected_subcommands(self) -> None: + """MANIFEST_SUBCOMMAND_CATALOG must contain expected manifest subcommands.""" + assert set(MANIFEST_SUBCOMMAND_CATALOG.keys()) >= {"upgrade", "status", "verify"} + + def test_scope_help_present_for_all_subcommands(self) -> None: + """Every manifest subcommand must have a non-empty scope_help string.""" + for name, cfg in MANIFEST_SUBCOMMAND_CATALOG.items(): + assert cfg.scope_help, f"{name}: scope_help must not be empty" + + def test_frozen_dataclass_cannot_be_mutated(self) -> None: + """ManifestSubcommandConfig is frozen.""" + cfg = ManifestSubcommandConfig(help_text="h", scope_help="s", include_only_option=False) + with pytest.raises((TypeError, dataclasses.FrozenInstanceError)): + cast(Any, cfg).help_text = "changed" diff --git a/tests/vstack/cli/test_commands.py b/tests/vstack/cli/test_commands.py deleted file mode 100644 index e7a95ff..0000000 --- a/tests/vstack/cli/test_commands.py +++ /dev/null @@ -1,658 +0,0 @@ -"""Tests for CLI command handlers.""" - -from __future__ import annotations - -import json -import re -from pathlib import Path -from typing import Any, cast - -from tests.conftest import run_vstack -from vstack.cli.commands import CommandLineInterface, _version_gt -from vstack.cli.constants import EXPECTED_CANONICAL_NAMES -from vstack.cli.manifest import ArtifactEntry, Manifest -from vstack.constants import TEMPLATES_ROOT, VERSION -from vstack.models import CheckMessage, ValidationResult - - -class TestVersionGt: - """Test cases for VersionGt.""" - - def test_version_gt_true_for_higher(self) -> None: - """Test that version gt true for higher.""" - assert _version_gt("1.2.0", "1.1.9") - - def test_version_gt_false_for_equal(self) -> None: - """Test that version gt false for equal.""" - assert not _version_gt("1.2.0", "1.2.0") - - def test_version_gt_handles_invalid(self) -> None: - """Test that version gt handles invalid.""" - assert not _version_gt("abc", "1.0.0") - - -class TestCommandLineInterface: - """Test cases for CommandLineInterface.""" - - def test_validate_exits_zero(self) -> None: - """Test that validate exits zero.""" - result = run_vstack(["validate"]) - assert result.returncode == 0, f"vstack validate failed:\n{result.stderr}" - - def test_verify_exits_zero(self) -> None: - """Test that verify exits zero.""" - result = run_vstack(["verify", "--no-output"]) - assert result.returncode == 0, ( - f"vstack verify --no-output failed:\n{result.stdout}\n{result.stderr}" - ) - - def test_verify_output_exits_zero(self, installed_target: Path) -> None: - """Test that verify output exits zero.""" - result = run_vstack(["verify", "--no-source", "--target", str(installed_target)]) - assert result.returncode == 0, ( - f"vstack verify --no-source failed:\n{result.stdout}\n{result.stderr}" - ) - - def test_verify_only_filter_limits_checked_types(self, installed_target: Path) -> None: - """Test that verify --only checks only requested artifact families.""" - result = run_vstack( - ["verify", "--no-source", "--target", str(installed_target), "--only", "skill"] - ) - assert result.returncode == 0, ( - f"vstack verify --only skill failed:\n{result.stdout}\n{result.stderr}" - ) - - def test_install_and_verify_exits_zero(self, tmp_path: Path) -> None: - """Test that install and verify exits zero.""" - install = run_vstack(["install", "--target", str(tmp_path)]) - assert install.returncode == 0, ( - f"vstack install failed:\n{install.stdout}\n{install.stderr}" - ) - verify = run_vstack(["verify", "--target", str(tmp_path)]) - assert verify.returncode == 0, f"vstack verify failed:\n{verify.stdout}\n{verify.stderr}" - - def test_validate_with_empty_templates_returns_non_zero(self, tmp_path: Path) -> None: - """Test that validate with empty templates returns non zero.""" - cli = CommandLineInterface(templates_root=tmp_path) - assert cli.validate() == 1 - - def test_label_prefers_relative(self, tmp_path: Path) -> None: - """Test that label prefers relative.""" - cli = CommandLineInterface(templates_root=tmp_path) - path = tmp_path / "x" / "y" - assert cli._label(path) == "x/y" - - def test_label_falls_back_to_absolute(self, tmp_path: Path) -> None: - """Test that label falls back to absolute.""" - cli = CommandLineInterface(templates_root=tmp_path) - other = Path("/tmp/outside-path") - assert cli._label(other) == str(other) - - def test_cli_class_uses_known_types(self) -> None: - """Test that cli class uses known types.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - names = {g.config.type_name for g in cli._generators} - assert names == {"skill", "agent", "instruction", "prompt"} - - def test_install_only_skill_writes_skill_artifacts(self, tmp_path: Path) -> None: - """Test that install only skill writes skill artifacts.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.install(tmp_path / ".github", only=["skill"]) - assert rc == 0 - assert (tmp_path / ".github" / "skills").exists() - - def test_install_writes_expected_skill_count(self, tmp_path: Path) -> None: - """Test that install writes expected skill count.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.install(tmp_path / ".github", only=["skill"]) - assert rc == 0 - md_files = list((tmp_path / ".github" / "skills").glob("*/SKILL.md")) - assert len(md_files) == len(EXPECTED_CANONICAL_NAMES) - - def test_install_only_preserves_manifest_entries_for_other_types(self, tmp_path: Path) -> None: - """Test that --only install does not drop manifest entries from other artifact types.""" - install_dir = tmp_path / ".github" - install_dir.mkdir(parents=True) - manifest: dict[str, Any] = { - "vstack_version": "0.1.0", - "installed_at": "2026-01-01T00:00:00Z", - "artifacts": { - "agents": [ - { - "name": "engineer", - "file": "agents/engineer.agent.md", - "version": "0.1.0", - } - ], - "skills": [ - { - "name": "verify", - "file": "skills/verify/SKILL.md", - "version": "0.1.0", - } - ], - }, - } - (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") - - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.install(install_dir, only=["instruction"]) - assert rc == 0 - - updated: dict[str, Any] = json.loads( - (install_dir / "vstack.json").read_text(encoding="utf-8") - ) - assert "instructions" in updated["artifacts"] - assert updated["artifacts"]["agents"] == manifest["artifacts"]["agents"] - assert updated["artifacts"]["skills"] == manifest["artifacts"]["skills"] - - def test_install_update_skips_when_version_not_newer(self, tmp_path: Path) -> None: - """Test that install update skips when version not newer.""" - install_dir = tmp_path / ".github" - manifest = { - "vstack_version": "0.1.0", - "installed_at": "2026-01-01T00:00:00Z", - "artifacts": { - "skills": [ - { - "name": "vision", - "file": "skills/vision/SKILL.md", - "version": "999.0.0", - } - ] - }, - } - install_dir.mkdir(parents=True) - (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") - (install_dir / "skills" / "vision").mkdir(parents=True) - (install_dir / "skills" / "vision" / "SKILL.md").write_text("old", encoding="utf-8") - - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.install(install_dir, only=["skill"], update=True) - assert rc == 0 - assert (install_dir / "skills" / "vision" / "SKILL.md").read_text(encoding="utf-8") == "old" - - def test_install_dry_run_does_not_write_outputs(self, tmp_path: Path) -> None: - """Test that install dry run does not write outputs.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.install(tmp_path / ".github", only=["skill"], dry_run=True) - assert rc == 0 - assert not (tmp_path / ".github" / "skills").exists() - - def test_verify_output_requires_install_dir(self) -> None: - """Test that verify output requires install dir.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - assert cli.verify(install_dir=None, source=False, output=True) == 1 - - def test_verify_fails_when_output_missing(self, tmp_path: Path) -> None: - """Test that verify fails when output missing.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.verify(install_dir=tmp_path / ".github", source=False, output=True) - assert rc == 1 - - def test_uninstall_removes_manifest_and_outputs(self, tmp_path: Path) -> None: - """Test that uninstall removes manifest and outputs.""" - install_dir = tmp_path / ".github" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc_install = cli.install(install_dir, only=["skill", "agent"]) - assert rc_install == 0 - assert (install_dir / "vstack.json").exists() - rc_uninstall = cli.uninstall(install_dir) - assert rc_uninstall == 0 - assert not (install_dir / "vstack.json").exists() - - def test_uninstall_without_manifest_and_without_files(self, tmp_path: Path) -> None: - """Test that uninstall without manifest and without files.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.uninstall(tmp_path / ".github") - assert rc == 0 - - def test_gen_for_returns_none_for_unknown_type(self) -> None: - """Test that gen for returns none for unknown type.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - assert cli._gen_for("unknown") is None - - def test_validate_returns_non_zero_on_unresolved(self, tmp_path: Path) -> None: - """Test that validate returns non zero on unresolved.""" - - class _FakeArtifact: - """Test double for a rendered artifact.""" - - def __init__(self) -> None: - """Initialize instance state.""" - self.name = "a" - self.unresolved = ["MISSING"] - - class _Cfg: - """Minimal generator config for this test.""" - - type_name = "skill" - manifest_key = "skills" - output_subdir = "skills" - - class _FakeGen: - """Test double for an artifact generator.""" - - config = _Cfg() - - def render_all(self): - """Render all.""" - return [_FakeArtifact()] - - def load_partials(self): - """Load partials.""" - return {"X": "y"} - - def output_path(self, name: str) -> str: - """Output path.""" - return f"{name}/SKILL.md" - - cli = CommandLineInterface(templates_root=tmp_path) - cli._generators = cast(Any, [_FakeGen()]) - assert cli.validate() == 1 - - def test_install_force_and_verify_input_fail_path(self, tmp_path: Path) -> None: - """Test that install force and verify input fail path.""" - - class _FakeArtifact: - """Test double for a rendered artifact.""" - - def __init__(self) -> None: - """Initialize instance state.""" - self.name = "x" - self.unresolved = ["BROKEN"] - self.frontmatter = {"version": "1.0.0"} - self.content = "content" - - class _Cfg: - """Minimal generator config for this test.""" - - type_name = "skill" - manifest_key = "skills" - output_subdir = "skills" - artifact_is_dir = True - - class _FakeGen: - """Test double for an artifact generator.""" - - config = _Cfg() - - def render_all(self): - """Render all.""" - return [_FakeArtifact()] - - def output_path(self, name: str) -> str: - """Output path.""" - return f"{name}/SKILL.md" - - def install_relative_path(self, name: str) -> str: - """Install relative path.""" - return f"skills/{name}/SKILL.md" - - def verify_input(self): - """Verify input.""" - return ValidationResult(messages=[CheckMessage("fail", "bad")]) - - install_dir = tmp_path / ".github" - out_file = install_dir / "skills" / "x" / "SKILL.md" - out_file.parent.mkdir(parents=True) - out_file.write_text("old", encoding="utf-8") - - cli = CommandLineInterface(templates_root=tmp_path) - cli._generators = cast(Any, [_FakeGen()]) - rc = cli.install(install_dir, force=True) - assert rc == 1 - assert out_file.read_text(encoding="utf-8") == "content" - - def test_verify_source_with_no_messages_and_all_passed(self, tmp_path: Path) -> None: - """Test that verify source with no messages and all passed.""" - - class _Cfg: - """Minimal generator config for this test.""" - - type_name = "skill" - output_subdir = "skills" - - class _FakeGen: - """Test double for an artifact generator.""" - - config = _Cfg() - - def verify_input(self, expected=None): - """Verify input.""" - return ValidationResult(messages=[]) - - def verify_output(self, out_dir, expected=None): - """Verify output.""" - return ValidationResult(messages=[]) - - cli = CommandLineInterface(templates_root=tmp_path) - cli._generators = cast(Any, [_FakeGen()]) - rc = cli.verify(install_dir=tmp_path / ".github", source=True, output=False) - assert rc == 0 - - def test_verify_output_uses_manifest_names(self, tmp_path: Path) -> None: - """Test that verify output uses manifest names.""" - install_dir = tmp_path / ".github" - (install_dir / "skills" / "custom").mkdir(parents=True) - (install_dir / "skills" / "custom" / "SKILL.md").write_text( - "---\nname: custom\ndescription: 'd'\n---\nbody\n\n", - encoding="utf-8", - ) - manifest = { - "vstack_version": "0.1.0", - "installed_at": "2026-01-01T00:00:00Z", - "artifacts": {"skill": [{"name": "custom", "file": "skills/custom/SKILL.md"}]}, - } - install_dir.mkdir(parents=True, exist_ok=True) - (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") - - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.verify(install_dir=install_dir, source=False, output=True) - assert rc == 1 - - def test_uninstall_non_directory_artifact_removes_file(self, tmp_path: Path) -> None: - """Test that uninstall non directory artifact removes file.""" - install_dir = tmp_path / ".github" - file_path = install_dir / "agents" / "custom.agent.md" - file_path.parent.mkdir(parents=True) - file_path.write_text("x", encoding="utf-8") - manifest = { - "vstack_version": "0.1.0", - "installed_at": "2026-01-01T00:00:00Z", - "artifacts": {"agent": [{"name": "custom", "file": "agents/custom.agent.md"}]}, - } - (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") - - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.uninstall(install_dir) - assert rc == 0 - assert not file_path.exists() - - def test_validate_returns_zero_when_all_clean(self, tmp_path: Path) -> None: - """Test that validate returns zero when all clean.""" - - class _FakeArtifact: - """Test double for a rendered artifact.""" - - def __init__(self) -> None: - """Initialize instance state.""" - self.name = "a" - self.unresolved: list[str] = [] - - class _Cfg: - """Minimal generator config for this test.""" - - type_name = "skill" - manifest_key = "skills" - output_subdir = "skills" - - class _FakeGen: - """Test double for an artifact generator.""" - - config = _Cfg() - - def render_all(self): - """Render all.""" - return [_FakeArtifact()] - - def load_partials(self): - """Load partials.""" - return {"X": "y"} - - def output_path(self, name: str) -> str: - """Output path.""" - return f"{name}/SKILL.md" - - cli = CommandLineInterface(templates_root=tmp_path) - cli._generators = cast(Any, [_FakeGen()]) - assert cli.validate() == 0 - - def test_validate_handles_missing_generator_for_type(self, tmp_path: Path) -> None: - """Test that validate handles missing generator for type.""" - - class _FakeArtifact: - """Test double for a rendered artifact.""" - - def __init__(self) -> None: - """Initialize instance state.""" - self.name = "a" - self.unresolved: list[str] = [] - - class _Cfg: - """Minimal generator config for this test.""" - - type_name = "skill" - - class _FakeGen: - """Test double for an artifact generator.""" - - config = _Cfg() - - def render_all(self): - """Render all.""" - return [_FakeArtifact()] - - def load_partials(self): - """Load partials.""" - return {} - - def output_path(self, name: str) -> str: - """Output path.""" - return f"{name}/SKILL.md" - - cli = CommandLineInterface(templates_root=tmp_path) - cli._generators = cast(Any, [_FakeGen()]) - setattr(cli, "_gen_for", lambda _type_name: None) - assert cli.validate() == 0 - - def test_install_existing_without_update_skips(self, tmp_path: Path) -> None: - """Test that install existing without update skips.""" - install_dir = tmp_path / ".github" - manifest = { - "vstack_version": "0.1.0", - "installed_at": "2026-01-01T00:00:00Z", - "artifacts": { - "skills": [ - { - "name": "vision", - "file": "skills/vision/SKILL.md", - "version": "1.0.0", - } - ] - }, - } - install_dir.mkdir(parents=True) - (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") - (install_dir / "skills" / "vision").mkdir(parents=True) - (install_dir / "skills" / "vision" / "SKILL.md").write_text("old", encoding="utf-8") - - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.install(install_dir, only=["skill"], update=False) - assert rc == 0 - assert (install_dir / "skills" / "vision" / "SKILL.md").read_text(encoding="utf-8") == "old" - - def test_install_update_newer_version_writes_file(self, tmp_path: Path) -> None: - """Test that install update newer version writes file.""" - install_dir = tmp_path / ".github" - manifest = { - "vstack_version": "0.1.0", - "installed_at": "2026-01-01T00:00:00Z", - "artifacts": { - "skills": [ - { - "name": "vision", - "file": "skills/vision/SKILL.md", - "version": "0.0.1", - } - ] - }, - } - install_dir.mkdir(parents=True) - (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") - (install_dir / "skills" / "vision").mkdir(parents=True) - (install_dir / "skills" / "vision" / "SKILL.md").write_text("old", encoding="utf-8") - - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc = cli.install(install_dir, only=["skill"], update=True) - assert rc == 0 - new_content = (install_dir / "skills" / "vision" / "SKILL.md").read_text(encoding="utf-8") - assert new_content != "old" - assert "AUTO-GENERATED" in new_content - - def test_expected_output_names_falls_back_without_manifest(self) -> None: - """Test that expected output names fallback is used when manifest data is absent.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - gen = cli._gen_for("skill") - assert gen is not None - assert cli._expected_output_names(gen, None) == EXPECTED_CANONICAL_NAMES - - def test_verify_manifest_metadata_skips_missing_artifact_files(self, tmp_path: Path) -> None: - """Test that missing manifest-tracked files are ignored during metadata verification.""" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - gen = cli._gen_for("skill") - assert gen is not None - - manifest_data = Manifest( - vstack_version=VERSION, - installed_at="2026-01-01T00:00:00Z", - artifacts={ - "skills": [ - ArtifactEntry( - name="missing-skill", - file="skills/missing-skill/SKILL.md", - version="1.0.0", - ) - ] - }, - ) - - result = cli._verify_manifest_metadata(gen, manifest_data, tmp_path / ".github") - assert result is None - - def test_install_rewrites_skipped_artifact_when_footer_version_mismatches( - self, tmp_path: Path - ) -> None: - """Test that install rewrites a skipped artifact when footer vstack_version is stale.""" - install_dir = tmp_path / ".github" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - - rc_install = cli.install(install_dir, only=["skill"]) - assert rc_install == 0 - - artifact_path = install_dir / "skills" / "vision" / "SKILL.md" - original = artifact_path.read_text(encoding="utf-8") - tampered = re.sub( - r'"vstack_version":"[^"]+"', - '"vstack_version":"stale-version"', - original, - count=1, - ) - artifact_path.write_text(tampered, encoding="utf-8") - - rc_reinstall = cli.install(install_dir, only=["skill"], update=False) - assert rc_reinstall == 0 - - updated = artifact_path.read_text(encoding="utf-8") - assert '"vstack_version":"stale-version"' not in updated - assert f'"vstack_version":"{VERSION}"' in updated - - def test_verify_fails_on_vstack_meta_version_mismatch(self, tmp_path: Path) -> None: - """Test that verify fails when VSTACK-META footer differs from manifest values.""" - install_dir = tmp_path / ".github" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc_install = cli.install(install_dir, only=["instruction"]) - assert rc_install == 0 - - instruction_file = install_dir / "instructions" / "python.instructions.md" - content = instruction_file.read_text(encoding="utf-8") - tampered = re.sub( - r'"vstack_version":"[^"]+"', - '"vstack_version":"tampered-version"', - content, - count=1, - ) - instruction_file.write_text(tampered, encoding="utf-8") - - rc_verify = cli.verify( - install_dir=install_dir, - source=False, - output=True, - only=["instruction"], - ) - assert rc_verify == 1 - - def test_verify_accepts_legacy_artifact_without_vstack_meta(self, tmp_path: Path) -> None: - """Test that verify accepts old artifacts that do not include VSTACK-META.""" - install_dir = tmp_path / ".github" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc_install = cli.install(install_dir, only=["instruction"]) - assert rc_install == 0 - - instruction_file = install_dir / "instructions" / "python.instructions.md" - content = instruction_file.read_text(encoding="utf-8") - legacy_content = re.sub(r"\n\n", "\n", content, count=1) - instruction_file.write_text(legacy_content, encoding="utf-8") - - rc_verify = cli.verify( - install_dir=install_dir, - source=False, - output=True, - only=["instruction"], - ) - assert rc_verify == 0 - - def test_verify_rejects_legacy_artifact_without_vstack_meta_and_autogen( - self, tmp_path: Path - ) -> None: - """Test that verify rejects legacy fallback when AUTO-GENERATED marker is missing.""" - install_dir = tmp_path / ".github" - cli = CommandLineInterface(templates_root=TEMPLATES_ROOT) - rc_install = cli.install(install_dir, only=["instruction"]) - assert rc_install == 0 - - instruction_file = install_dir / "instructions" / "python.instructions.md" - content = instruction_file.read_text(encoding="utf-8") - no_meta = re.sub(r"\n\n", "\n", content, count=1) - no_autogen = re.sub( - r"\n\n", - "\n", - no_meta, - count=1, - ) - instruction_file.write_text(no_autogen, encoding="utf-8") - - rc_verify = cli.verify( - install_dir=install_dir, - source=False, - output=True, - only=["instruction"], - ) - assert rc_verify == 1 - - def test_verify_source_with_messages_path(self, tmp_path: Path) -> None: - """Test that verify source with messages path.""" - - class _Cfg: - """Minimal generator config for this test.""" - - type_name = "skill" - output_subdir = "skills" - - class _FakeGen: - """Test double for an artifact generator.""" - - config = _Cfg() - - def verify_input(self, expected=None): - """Verify input.""" - return ValidationResult(messages=[CheckMessage("pass", "ok")]) - - def verify_output(self, out_dir, expected=None): - """Verify output.""" - return ValidationResult(messages=[]) - - cli = CommandLineInterface(templates_root=tmp_path) - cli._generators = cast(Any, [_FakeGen()]) - rc = cli.verify(install_dir=tmp_path / ".github", source=True, output=False) - assert rc == 0 diff --git a/tests/vstack/cli/test_helpers.py b/tests/vstack/cli/test_helpers.py new file mode 100644 index 0000000..749ccfe --- /dev/null +++ b/tests/vstack/cli/test_helpers.py @@ -0,0 +1,35 @@ +"""Tests for CLI shared internal helpers.""" + +from __future__ import annotations + +from vstack.cli.helpers import normalize_targeted_names + + +class TestNormalizeTargetedNames: + """Test cases for normalize_targeted_names.""" + + def test_returns_empty_set_for_none(self) -> None: + """None input produces an empty set.""" + assert normalize_targeted_names(None) == set() + + def test_returns_empty_set_for_empty_list(self) -> None: + """Empty list produces an empty set.""" + assert normalize_targeted_names([]) == set() + + def test_strips_whitespace(self) -> None: + """Names with surrounding whitespace are stripped.""" + assert normalize_targeted_names([" vision ", " debug "]) == {"vision", "debug"} + + def test_deduplicates_names(self) -> None: + """Duplicate names collapse into a single entry.""" + assert normalize_targeted_names(["vision", "vision", "debug"]) == {"vision", "debug"} + + def test_filters_blank_strings(self) -> None: + """Blank strings (empty or whitespace-only) are excluded.""" + assert normalize_targeted_names([" ", "", "debug"]) == {"debug"} + + def test_returns_set_of_names(self) -> None: + """Result is always a set.""" + result = normalize_targeted_names(["a", "b"]) + assert isinstance(result, set) + assert result == {"a", "b"} diff --git a/tests/vstack/cli/test_install.py b/tests/vstack/cli/test_install.py new file mode 100644 index 0000000..da29bbd --- /dev/null +++ b/tests/vstack/cli/test_install.py @@ -0,0 +1,306 @@ +"""Tests for InstallCommand.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from vstack.cli.base import CommandContext +from vstack.cli.install import InstallCommand +from vstack.cli.service import CommandService +from vstack.manifest import content_hash + + +class TestInstallCommand: + """Test cases for InstallCommand.""" + + # ------------------------------------------------------------------ + # _version_gt + # ------------------------------------------------------------------ + + def test_version_gt_true_for_higher(self) -> None: + """Newer semver string is strictly greater.""" + assert InstallCommand._version_gt("1.2.0", "1.1.9") + + def test_version_gt_false_for_equal(self) -> None: + """Equal versions are not greater.""" + assert not InstallCommand._version_gt("1.2.0", "1.2.0") + + def test_version_gt_handles_invalid(self) -> None: + """Non-semver strings are treated as (0,) and not greater than a real version.""" + assert not InstallCommand._version_gt("abc", "1.0.0") + + def test_version_gt_handles_none_existing(self) -> None: + """None for existing falls back to (0,) so any real version is greater.""" + assert InstallCommand._version_gt("1.2.0", None) is True + + # ------------------------------------------------------------------ + # _installed_content_matches + # ------------------------------------------------------------------ + + def test_installed_content_matches_returns_none_for_unknown_algorithm( + self, tmp_path: Path + ) -> None: + """Unknown checksum algorithm is treated as indeterminate (None).""" + out_file = tmp_path / "artifact.txt" + out_file.write_text("content", encoding="utf-8") + entry = SimpleNamespace(checksum="x", checksum_algorithm="sha999") + assert ( + InstallCommand._installed_content_matches(out_file=out_file, existing_entry=entry) + is None + ) + + # ------------------------------------------------------------------ + # _install_decision + # ------------------------------------------------------------------ + + def test_decision_preserves_when_tracked_file_has_no_checksum(self, tmp_path: Path) -> None: + """Tracked file without stored checksum is always preserved.""" + out_file = tmp_path / "artifact.txt" + out_file.write_text("content", encoding="utf-8") + entry = SimpleNamespace(checksum=None, checksum_algorithm="sha256", version="1.0.0") + action, reason = InstallCommand._install_decision( + force=False, + force_name=False, + adopt_name=False, + update=False, + out_file=out_file, + existing_entry=entry, + new_version="1.0.1", + ) + assert action == "preserve" + assert reason == "tracked file has no stored checksum" + + def test_decision_preserves_when_version_unknown_under_update(self, tmp_path: Path) -> None: + """Update mode preserves tracked files that have no stored version metadata.""" + out_file = tmp_path / "artifact.txt" + out_file.write_text("content", encoding="utf-8") + entry = SimpleNamespace( + checksum=content_hash("content"), + checksum_algorithm="sha256", + version=None, + ) + action, reason = InstallCommand._install_decision( + force=False, + force_name=False, + adopt_name=False, + update=True, + out_file=out_file, + existing_entry=entry, + new_version="1.0.1", + ) + assert action == "preserve" + assert reason == "tracked file has no stored version" + + # ------------------------------------------------------------------ + # _load_existing_manifest + # ------------------------------------------------------------------ + + def test_load_existing_manifest_returns_none_tuple_on_read_error( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Returns a 4-tuple of None when the manifest file has a read error.""" + + class _ManifestFile: + read_error = "bad manifest" + + def read(self): + return None + + class _Service: + @staticmethod + def manifest_for(_install_dir: Path) -> _ManifestFile: + return _ManifestFile() + + result = InstallCommand._load_existing_manifest( + service=cast(CommandService, _Service()), + install_dir=Path("/tmp/install"), + gens=[], + ) + assert result == (None, None, None, None) + assert "ERROR: bad manifest" in capsys.readouterr().err + + # ------------------------------------------------------------------ + # execute + # ------------------------------------------------------------------ + + def test_execute_returns_nonzero_when_manifest_loading_fails( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """execute returns non-zero immediately when manifest loading returns None tuple.""" + monkeypatch.setattr( + "vstack.cli.install.InstallCommand._load_existing_manifest", + staticmethod(lambda **_kwargs: (None, None, None, None)), + ) + service = cast(CommandService, SimpleNamespace(generators=[])) + assert InstallCommand.execute(service, Path("/tmp/install")) == 1 + + def test_adopt_records_version_from_disk_metadata(self, tmp_path: Path) -> None: + """Adopted files should use on-disk artifact_version metadata, not new template version.""" + + class _Gen: + config = SimpleNamespace( + type_name="skill", + manifest_key="skills", + output_subdir="skills", + ) + + @staticmethod + def output_path(name: str) -> Path: + return Path(name) / "SKILL.md" + + @staticmethod + def install_relative_path(name: str) -> str: + return f"skills/{name}/SKILL.md" + + existing_content = ( + "# Skill\n" + "" + "\n" + ) + out_dir = tmp_path / "skills" + out_file = out_dir / "verify" / "SKILL.md" + out_file.parent.mkdir(parents=True) + out_file.write_text(existing_content, encoding="utf-8") + + new_entries: dict[str, list[Any]] = {} + InstallCommand._install_single_artifact( + service=cast(CommandService, SimpleNamespace(label=lambda path: str(path))), + gen=_Gen(), + artifact=SimpleNamespace( + name="verify", + frontmatter={"version": "9.9.9"}, + unresolved=[], + content="new content", + ), + out_dir=out_dir, + colors=SimpleNamespace( + CYAN="", + RESET="", + DIM="", + YELLOW="", + GREEN="", + BOLD="", + ), + prefix="", + force=False, + update=False, + dry_run=True, + targeted_force_names=set(), + targeted_adopt_names={"verify"}, + existing_entries={}, + new_entries=new_entries, + checksum_algorithm="sha256", + ) + + adopted_entry = cast(Any, new_entries["skills"][0]) + assert adopted_entry.version == "1.2.3" + assert adopted_entry.checksum == content_hash(existing_content) + + def test_adopt_unreadable_file_preserves_without_crashing( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Unreadable adopt targets should be preserved and not crash install.""" + + class _Gen: + config = SimpleNamespace( + type_name="skill", + manifest_key="skills", + output_subdir="skills", + ) + + @staticmethod + def output_path(name: str) -> Path: + return Path(name) / "SKILL.md" + + @staticmethod + def install_relative_path(name: str) -> str: + return f"skills/{name}/SKILL.md" + + out_dir = tmp_path / "skills" + out_file = out_dir / "verify" / "SKILL.md" + out_file.parent.mkdir(parents=True) + out_file.write_text("content", encoding="utf-8") + + def _raise_oserror(self: Path, encoding: str = "utf-8") -> str: + del self, encoding + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _raise_oserror) + + new_entries: dict[str, list[Any]] = {} + InstallCommand._install_single_artifact( + service=cast(CommandService, SimpleNamespace(label=lambda path: str(path))), + gen=_Gen(), + artifact=SimpleNamespace( + name="verify", + frontmatter={"version": "9.9.9"}, + unresolved=[], + content="new content", + ), + out_dir=out_dir, + colors=SimpleNamespace( + CYAN="", + RESET="", + DIM="", + YELLOW="", + GREEN="", + BOLD="", + ), + prefix="", + force=False, + update=False, + dry_run=True, + targeted_force_names=set(), + targeted_adopt_names={"verify"}, + existing_entries={}, + new_entries=new_entries, + checksum_algorithm="sha256", + ) + + out = capsys.readouterr().out + assert ( + "preserved β€” existing file is unreadable; could not adopt into vstack manifest" in out + ) + assert "skills" not in new_entries + + # ------------------------------------------------------------------ + # run (context forwarding) + # ------------------------------------------------------------------ + + def test_run_forwards_context_to_execute( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """run() unpacks CommandContext args and forwards them to execute().""" + captured: dict[str, Any] = {} + + def _fake_execute(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return 1 + + monkeypatch.setattr( + "vstack.cli.install.InstallCommand.execute", staticmethod(_fake_execute) + ) + + from argparse import Namespace + + context = CommandContext( + args=Namespace( + force=True, force_names=["a"], adopt_name=["b"], update=True, dry_run=True + ), + install_dir=tmp_path, + only=["skill"], + ) + assert InstallCommand(service=cast(CommandService, object())).run(context=context) == 1 + assert captured["kwargs"]["adopt_names"] == ["b"] + assert captured["kwargs"]["force"] is True diff --git a/tests/vstack/cli/test_integration_runtime.py b/tests/vstack/cli/test_integration_runtime.py deleted file mode 100644 index dc10715..0000000 --- a/tests/vstack/cli/test_integration_runtime.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Tests for CLI runtime integration behavior.""" - -from __future__ import annotations - -from tests.conftest import run_vstack - - -class TestRuntimeEntry: - """Test cases for RuntimeEntry.""" - - def test_help_command_exits_zero(self) -> None: - """Test that help command exits zero.""" - result = run_vstack(["--help"]) - assert result.returncode == 0 - assert "Manage vstack skill generation" in result.stdout diff --git a/tests/vstack/cli/test_interface.py b/tests/vstack/cli/test_interface.py new file mode 100644 index 0000000..411fae1 --- /dev/null +++ b/tests/vstack/cli/test_interface.py @@ -0,0 +1,152 @@ +"""Tests for CLI interface dispatch orchestration.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any, cast + +import pytest + +from vstack.cli.base import CommandContext +from vstack.cli.interface import CommandLineInterface + + +class _BuiltParser: + """Parser-like object exposing parse_args.""" + + def __init__(self, args: argparse.Namespace) -> None: + self._args = args + + def parse_args(self) -> argparse.Namespace: + """Return the configured parsed args.""" + return self._args + + +class _Parser: + """Parser factory test double with configurable args and targets.""" + + def __init__(self, args: argparse.Namespace, resolved_target=None) -> None: + self._args = args + self._resolved_target = resolved_target + + def build(self) -> _BuiltParser: + """Return a parser-like object exposing parse_args.""" + return _BuiltParser(self._args) + + def resolve_targets(self, _args, *, command_name: str = "command") -> object: + """Return the configured install target.""" + del command_name + return self._resolved_target + + +class _Command: + """Command test double that records invocation context.""" + + def __init__(self, exit_code: int = 0) -> None: + self.exit_code = exit_code + self.calls: list[CommandContext] = [] + + def run(self, *, context: CommandContext): + """Record the call and return the configured exit code.""" + self.calls.append(context) + return self.exit_code + + +class _Service: + """Service construction test double.""" + + def __init__(self, *, templates_root) -> None: + self.templates_root = templates_root + + +class TestCommandLineInterface: + """Test cases for interface-level parser and command dispatch.""" + + def test_run_dispatches_validate_without_resolving_target(self, monkeypatch, tmp_path) -> None: + """Test that validate passes through only-filter and no install_dir.""" + args = argparse.Namespace(command="validate", only=["skill"], use_global=False) + parser = _Parser(args=args) + command = _Command(exit_code=5) + + monkeypatch.setattr( + "vstack.cli.interface.build_command_registry", + lambda service: {"validate": command}, + ) + + interface = CommandLineInterface( + parser_cls=cast(Any, lambda: parser), + service_cls=cast(Any, _Service), + templates_root=tmp_path, + ) + + assert interface.run() == 5 + assert len(command.calls) == 1 + assert command.calls[0].args is args + assert command.calls[0].install_dir is None + assert command.calls[0].only == ["skill"] + + def test_run_dispatches_install_with_global_default_types(self, monkeypatch, tmp_path) -> None: + """Test that install resolves targets and applies default global type filters.""" + args = argparse.Namespace(command="install", only=None, use_global=True) + parser = _Parser(args=args, resolved_target=tmp_path / ".github") + command = _Command(exit_code=9) + + monkeypatch.setattr( + "vstack.cli.interface.build_command_registry", + lambda service: {"install": command}, + ) + + interface = CommandLineInterface( + parser_cls=cast(Any, lambda: parser), + service_cls=cast(Any, _Service), + templates_root=tmp_path, + ) + + assert interface.run() == 9 + assert len(command.calls) == 1 + assert command.calls[0].args is args + assert command.calls[0].install_dir == tmp_path / ".github" + assert command.calls[0].only == ["agent", "instruction", "prompt", "skill"] + + def test_resolve_only_for_scope_rejects_disallowed_global_types(self) -> None: + """Global mode rejects unsupported type filters with a helpful message.""" + args = argparse.Namespace(use_global=True, only=["skill", "workflow"]) + with pytest.raises(ValueError, match=r"Unsupported type\(s\): workflow"): + CommandLineInterface.resolve_only_for_scope(args) + + def test_resolve_install_dir_returns_none_when_not_required(self, tmp_path: Path) -> None: + """Commands that do not need an install dir skip target resolution.""" + + class _Parser: + def resolve_targets( + self, args: argparse.Namespace, *, command_name: str = "command" + ) -> Path: + del args, command_name + raise AssertionError("resolve_targets must not be called") + + cli = CommandLineInterface(templates_root=tmp_path) + resolved = cli._resolve_install_dir( + cli_parser=cast(Any, _Parser()), + args=argparse.Namespace(command="validate"), + requires_install_dir=False, + ) + assert resolved is None + + def test_resolve_only_filter_passthrough_when_scope_resolution_disabled( + self, tmp_path: Path + ) -> None: + """When scope resolution is off, interface passes through args.only as-is.""" + cli = CommandLineInterface(templates_root=tmp_path) + args = argparse.Namespace(only=["skill"]) + assert cli._resolve_only_filter(args=args, resolve_only_for_scope=False) == ["skill"] + + def test_resolve_only_for_scope_passthrough_when_not_global(self) -> None: + """Non-global calls return requested --only unchanged.""" + args = argparse.Namespace(use_global=False, only=["skill"]) + assert CommandLineInterface.resolve_only_for_scope(args) == ["skill"] + + def test_resolve_only_for_scope_accepts_allowed_global_types(self) -> None: + """Global mode accepts allowed types unchanged.""" + args = argparse.Namespace(use_global=True, only=["skill", "agent"]) + assert CommandLineInterface.resolve_only_for_scope(args) == ["skill", "agent"] diff --git a/tests/vstack/cli/test_manifest.py b/tests/vstack/cli/test_manifest.py index 4b37210..54b1e8a 100644 --- a/tests/vstack/cli/test_manifest.py +++ b/tests/vstack/cli/test_manifest.py @@ -1,96 +1,99 @@ -"""Tests for install manifest handling.""" +"""Tests for ManifestCommand dispatch logic.""" from __future__ import annotations -import json +from argparse import Namespace +from pathlib import Path +from typing import Any, cast -from vstack.cli.constants import EXPECTED_CANONICAL_NAMES -from vstack.cli.manifest import ArtifactEntry, Manifest, ManifestFile +import pytest +from vstack.cli.base import CommandContext +from vstack.cli.manifest import ManifestCommand +from vstack.cli.service import CommandService -class TestArtifactEntry: - """Test cases for ArtifactEntry.""" - def test_artifact_entry_fields(self) -> None: - """Test that artifact entry fields.""" - e = ArtifactEntry(name="vision", file="skills/vision/SKILL.md", version="1.0.0") - assert e.name == "vision" - assert e.file.endswith("SKILL.md") +class TestManifestCommand: + """Test cases for ManifestCommand.""" + def test_dispatches_upgrade(self) -> None: + """Routes upgrade action to service.manifest_upgrade with correct arguments.""" -class TestManifest: - """Test cases for Manifest.""" + class _Service: + def manifest_upgrade(self, install_dir: Path, *, backfill: bool = False) -> int: + assert install_dir == Path("/tmp/install") + assert backfill is True + return 17 - def test_entries_names_files_for(self) -> None: - """Test that entries names files for.""" - m = Manifest( - vstack_version="0.1.0", - installed_at="2026-01-01T00:00:00Z", - artifacts={"skills": [ArtifactEntry(name="vision", file="skills/vision/SKILL.md")]}, + command = ManifestCommand(cast(CommandService, _Service())) + context = CommandContext( + args=Namespace(manifest_action="upgrade", backfill=True), + install_dir=Path("/tmp/install"), + only=None, ) - assert len(m.entries_for("skills")) == 1 - assert m.names_for("skills") == ["vision"] - assert m.files_for("skills") == ["skills/vision/SKILL.md"] - - def test_to_dict_and_from_dict_roundtrip(self) -> None: - """Test that to dict and from dict roundtrip.""" - src = Manifest( - vstack_version="0.1.0", - installed_at="2026-01-01T00:00:00Z", - artifacts={"skills": [ArtifactEntry(name="vision", file="skills/vision/SKILL.md")]}, + assert command.run(context=context) == 17 + + def test_dispatches_status_with_flags(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Status action forwards output format and filter flags to StatusCommand.execute.""" + seen: dict[str, object] = {} + + def _fake_execute(service, **kwargs): + seen["service"] = service + seen.update(kwargs) + return 9 + + monkeypatch.setattr( + "vstack.cli.manifest.StatusCommand.execute", staticmethod(_fake_execute) ) - out = Manifest.from_dict(src.to_dict()) - assert out.vstack_version == "0.1.0" - assert out.names_for("skills") == ["vision"] - - -class TestManifestFile: - """Test cases for ManifestFile.""" - - def test_read_none_when_missing(self, tmp_path) -> None: - """Test that read none when missing.""" - mf = ManifestFile(parent_dir=tmp_path) - assert mf.read() is None - - def test_write_and_read_manifest(self, tmp_path) -> None: - """Test that write and read manifest.""" - mf = ManifestFile(parent_dir=tmp_path) - manifest = Manifest( - vstack_version="0.1.0", - installed_at="2026-01-01T00:00:00Z", - artifacts={"skills": [ArtifactEntry(name="vision", file="skills/vision/SKILL.md")]}, + + service: Any = object() + command = ManifestCommand(cast(CommandService, service)) + context = CommandContext( + args=Namespace( + manifest_action="status", output_format="yaml", verbose=True, no_color=True + ), + install_dir=Path("/tmp/install"), + only=["skill"], ) - mf.write(manifest) - loaded = mf.read() - assert loaded is not None - assert loaded.names_for("skills") == ["vision"] - - def test_manifest_contains_all_expected_canonical_names(self, installed_target) -> None: - """Test that manifest contains all expected canonical names.""" - data = json.loads( - (installed_target / ".github" / "vstack.json").read_text(encoding="utf-8") + + assert command.run(context=context) == 9 + assert seen["service"] is service + assert seen["install_dir"] == Path("/tmp/install") + assert seen["only"] == ["skill"] + assert seen["output_format"] == "yaml" + assert seen["verbose"] is True + assert seen["no_color"] is True + + def test_dispatches_verify(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Verify action forces output-only verify and forwards only filter.""" + seen: dict[str, object] = {} + + def _fake_execute(service, **kwargs): + seen["service"] = service + seen.update(kwargs) + return 3 + + monkeypatch.setattr( + "vstack.cli.manifest.VerifyCommand.execute", staticmethod(_fake_execute) ) - skill_names = [s["name"] for s in data["artifacts"]["skills"]] - for name in EXPECTED_CANONICAL_NAMES: - assert name in skill_names - - def test_read_none_when_manifest_is_invalid_json(self, tmp_path) -> None: - """Test that read none when manifest is invalid json.""" - mf = ManifestFile(parent_dir=tmp_path) - (tmp_path / "vstack.json").write_text("{broken", encoding="utf-8") - assert mf.read() is None - - def test_read_none_when_manifest_entry_missing_required_file_key(self, tmp_path) -> None: - """Test that read none when manifest entry missing required file key.""" - mf = ManifestFile(parent_dir=tmp_path) - (tmp_path / "vstack.json").write_text( - json.dumps( - { - "vstack_version": "0.1.0", - "installed_at": "2026-01-01T00:00:00Z", - "artifacts": {"skills": [{"name": "vision"}]}, - } - ), - encoding="utf-8", + + service2: Any = object() + command = ManifestCommand(cast(CommandService, service2)) + context = CommandContext( + args=Namespace(manifest_action="verify"), + install_dir=Path("/tmp/install"), + only=["agent"], ) - assert mf.read() is None + + assert command.run(context=context) == 3 + assert seen["service"] is service2 + assert seen["source"] is False + assert seen["output"] is True + assert seen["only"] == ["agent"] + + def test_requires_action(self) -> None: + """Raises ValueError when no manifest_action is present in args.""" + command = ManifestCommand(service=cast(CommandService, object())) + context = CommandContext(args=Namespace(), install_dir=Path("/tmp/install"), only=None) + with pytest.raises(ValueError, match="manifest action is required"): + command.run(context=context) diff --git a/tests/vstack/cli/test_parser.py b/tests/vstack/cli/test_parser.py index 5085353..8e30bc3 100644 --- a/tests/vstack/cli/test_parser.py +++ b/tests/vstack/cli/test_parser.py @@ -4,10 +4,13 @@ import argparse from pathlib import Path +from typing import Any, cast import pytest from vstack.cli import parser as parser_module +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.catalog import TopLevelCommandConfig class TestParserHelpers: @@ -39,20 +42,20 @@ def test_resolve_targets_target_existing(self, tmp_path: Path) -> None: args = argparse.Namespace(target=str(target), use_global=False) assert parser_module.CommandLineParser().resolve_targets(args) == target / ".github" - def test_resolve_targets_target_missing_exits(self, tmp_path: Path) -> None: - """Test that resolve targets target missing exits.""" + def test_resolve_targets_target_missing_raises_value_error(self, tmp_path: Path) -> None: + """Test that resolve targets target missing raises ValueError.""" args = argparse.Namespace(target=str(tmp_path / "missing"), use_global=False) - with pytest.raises(SystemExit) as exc: + with pytest.raises(ValueError, match="target directory does not exist"): parser_module.CommandLineParser().resolve_targets(args) - assert exc.value.code == 1 - def test_resolve_targets_global_missing_exits(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test that resolve targets global missing exits.""" + def test_resolve_targets_global_missing_raises_value_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that resolve targets global missing raises ValueError.""" monkeypatch.setattr(parser_module.CommandLineParser, "vscode_user_dir", lambda self: None) args = argparse.Namespace(target=None, use_global=True) - with pytest.raises(SystemExit) as exc: + with pytest.raises(ValueError, match="Could not detect VS Code user data directory"): parser_module.CommandLineParser().resolve_targets(args) - assert exc.value.code == 1 def test_resolve_targets_global_returns_user_dir( self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path @@ -73,9 +76,13 @@ class TestBuildParser: def test_build_parser_has_expected_commands(self) -> None: """Test that build parser has expected commands.""" parser = parser_module.CommandLineParser().build() - actions = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)] - subcommands = set(actions[0].choices.keys()) - assert subcommands == {"validate", "verify", "install", "uninstall"} + subcommands_action = next( + (action for action in parser._actions if hasattr(action, "choices") and action.choices), + None, + ) + assert subcommands_action is not None + subcommands = set(cast(Any, subcommands_action).choices.keys()) + assert subcommands == {"validate", "verify", "status", "manifest", "install", "uninstall"} def test_verify_accepts_only_filter(self) -> None: """Test that verify command supports --only type filters.""" @@ -84,3 +91,142 @@ def test_verify_accepts_only_filter(self) -> None: assert args.command == "verify" assert args.only == ["agent", "prompt"] assert args.source is False + + def test_install_accepts_force_name(self) -> None: + """Test that install supports targeted force names.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args(["install", "--force-name", "vision", "--target", "."]) + assert args.command == "install" + assert args.force_names == ["vision"] + + def test_install_accepts_adopt_name(self) -> None: + """Test that install supports targeted adopt names.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args(["install", "--adopt-name", "vision", "--target", "."]) + assert args.command == "install" + assert args.adopt_name == ["vision"] + + def test_status_accepts_only_filter(self) -> None: + """Test that status supports --only type filters.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args(["status", "--only", "skill", "--target", "."]) + assert args.command == "status" + assert args.only == ["skill"] + + def test_status_accepts_format_and_readability_flags(self) -> None: + """Test that status supports format and readability controls.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args( + ["status", "--format", "json", "--verbose", "--no-color", "--target", "."] + ) + assert args.command == "status" + assert args.output_format == "json" + assert args.verbose is True + assert args.no_color is True + + def test_uninstall_accepts_force_name(self) -> None: + """Test that uninstall supports targeted force names.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args(["uninstall", "--force-name", "vision", "--target", "."]) + assert args.command == "uninstall" + assert args.force_names == ["vision"] + + def test_manifest_upgrade_subcommand_parses(self) -> None: + """Test that manifest upgrade subcommand parses target arguments.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args(["manifest", "upgrade", "--target", "."]) + assert args.command == "manifest" + assert args.manifest_action == "upgrade" + + def test_manifest_upgrade_accepts_backfill_flag(self) -> None: + """Test that manifest upgrade supports optional checksum backfill.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args(["manifest", "upgrade", "--backfill", "--target", "."]) + assert args.command == "manifest" + assert args.manifest_action == "upgrade" + assert args.backfill is True + + def test_manifest_status_subcommand_parses(self) -> None: + """Test that manifest status subcommand parses status formatting flags.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args( + [ + "manifest", + "status", + "--format", + "json", + "--verbose", + "--no-color", + "--target", + ".", + ] + ) + assert args.command == "manifest" + assert args.manifest_action == "status" + assert args.output_format == "json" + assert args.verbose is True + assert args.no_color is True + + def test_manifest_verify_subcommand_parses(self) -> None: + """Test that manifest verify subcommand parses only filter.""" + parser = parser_module.CommandLineParser().build() + args = parser.parse_args(["manifest", "verify", "--only", "skill", "--target", "."]) + assert args.command == "manifest" + assert args.manifest_action == "verify" + assert args.only == ["skill"] + + def test_scoped_command_requires_scope_help(self) -> None: + """Parser fails fast when scoped command config has missing scope_help.""" + + class _FakeCmd(BaseCommand): + def run(self, *, context: CommandContext) -> int: + del context + return 0 + + def _dummy_factory(service: object) -> _FakeCmd: + del service + return _FakeCmd() + + cli_parser = parser_module.CommandLineParser() + sub = argparse.ArgumentParser().add_subparsers(dest="command") + config = TopLevelCommandConfig( + command_factory=_dummy_factory, + help_text="x", + requires_install_dir=False, + resolve_only_for_scope=False, + include_scope_group=True, + include_only_option=False, + scope_help=None, + only_help=None, + include_status_output_options=False, + ) + with pytest.raises(ValueError, match="scope_help is required"): + cli_parser._add_scoped_only_command(sub, command_name="x", config=config) + + def test_scoped_command_requires_only_help_when_only_enabled(self) -> None: + """Parser fails fast when include_only_option is set without only_help.""" + + class _FakeCmd(BaseCommand): + def run(self, *, context: CommandContext) -> int: + del context + return 0 + + def _dummy_factory(service: object) -> _FakeCmd: + del service + return _FakeCmd() + + cli_parser = parser_module.CommandLineParser() + sub = argparse.ArgumentParser().add_subparsers(dest="command") + config = TopLevelCommandConfig( + command_factory=_dummy_factory, + help_text="x", + requires_install_dir=False, + resolve_only_for_scope=False, + include_scope_group=True, + include_only_option=True, + scope_help="help", + only_help=None, + include_status_output_options=False, + ) + with pytest.raises(ValueError, match="only_help is required"): + cli_parser._add_scoped_only_command(sub, command_name="x", config=config) diff --git a/tests/vstack/cli/test_registry.py b/tests/vstack/cli/test_registry.py new file mode 100644 index 0000000..07fde25 --- /dev/null +++ b/tests/vstack/cli/test_registry.py @@ -0,0 +1,56 @@ +"""Tests for build_command_registry catalog factory dispatch.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import cast + +import pytest + +from vstack.cli.base import BaseCommand, CommandContext +from vstack.cli.registry import build_command_registry +from vstack.cli.service import CommandService + + +class _FakeCommand(BaseCommand): + """Minimal concrete command for registry tests.""" + + def run(self, *, context: CommandContext) -> int: + del context + return 0 + + +class TestBuildCommandRegistry: + """Test cases for build_command_registry.""" + + def test_uses_catalog_factories(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Registry construction delegates to command_factory for each catalog entry.""" + created: list[str] = [] + + def _factory(name: str): + def _make(_service: object) -> _FakeCommand: + created.append(name) + return _FakeCommand() + + return _make + + monkeypatch.setattr( + "vstack.cli.registry.COMMAND_CATALOG", + { + "alpha": SimpleNamespace(command_factory=_factory("alpha")), + "beta": SimpleNamespace(command_factory=_factory("beta")), + }, + ) + + registry = build_command_registry(service=cast(CommandService, object())) + assert set(registry.keys()) == {"alpha", "beta"} + assert created == ["alpha", "beta"] + + def test_returns_all_catalog_commands(self) -> None: + """Default registry contains one entry per COMMAND_CATALOG key.""" + from vstack.cli.catalog import COMMAND_CATALOG + from vstack.constants import TEMPLATES_ROOT + + svc = CommandService(templates_root=TEMPLATES_ROOT) + registry = build_command_registry(service=svc) + assert set(registry.keys()) == set(COMMAND_CATALOG.keys()) diff --git a/tests/vstack/cli/test_report.py b/tests/vstack/cli/test_report.py new file mode 100644 index 0000000..1e2f12b --- /dev/null +++ b/tests/vstack/cli/test_report.py @@ -0,0 +1,232 @@ +"""Tests for BaseReport and StatusReport rendering.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pytest + +from vstack.cli.report import BaseReport, StatusReport +from vstack.cli.service import CommandService + + +class TestBaseReport: + """Test cases for BaseReport YAML serialization.""" + + def test_yaml_handles_nested_empty_map_and_scalar_list(self) -> None: + """YAML serializer handles empty nested maps and mixed-scalar lists.""" + rendered = BaseReport.to_yaml([{}, "hello", 2, True, None]) + assert "-" in rendered + assert '"hello"' in rendered + assert "- 2" in rendered + assert "- true" in rendered + assert "- null" in rendered + + def test_yaml_scalar_fallback_for_plain_value(self) -> None: + """Scalar-only input returns a JSON-quoted string.""" + assert BaseReport.to_yaml("plain") == '"plain"' + + +class TestStatusReport: + """Test cases for StatusReport rendering.""" + + def test_render_missing_manifest_json_and_yaml( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Missing-manifest renderer supports json and yaml output formats.""" + install_dir = Path("/tmp/install") + + def color(_code: str, text: str) -> str: + return text + + assert ( + StatusReport.render_missing_manifest( + output_format="json", + install_dir=install_dir, + error="boom", + color=color, + ) + == 1 + ) + assert '"error": "boom"' in capsys.readouterr().out + + assert ( + StatusReport.render_missing_manifest( + output_format="yaml", + install_dir=install_dir, + error="boom", + color=color, + ) + == 1 + ) + assert "error:" in capsys.readouterr().out + + def test_render_text_output_handles_markers_and_exit_codes( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Text renderer prints per-entry markers and returns issue-based exit codes.""" + + class _Service: + @staticmethod + def label(path: Path) -> str: + return f"label:{path}" + + svc = cast(CommandService, _Service()) + + reports = [ + { + "type": "skill", + "counts": { + "managed": 1, + "managed_legacy": 1, + "modified": 1, + "missing": 0, + "unknown": 0, + "untracked": 0, + }, + "issues": 1, + "warnings": 1, + "entries": [ + {"state": "managed", "message": "managed entry"}, + {"state": "modified", "message": "modified entry"}, + {"state": "absent", "message": "absent entry"}, + ], + } + ] + + def color(_code: str, text: str) -> str: + return text + + exit_with_issues = StatusReport.render_text_output( + service=svc, + reports=reports, + install_dir=Path("/tmp/install"), + verbose=False, + total_issues=1, + color=color, + ) + out1 = capsys.readouterr().out + assert exit_with_issues == 1 + assert "legacy warning" in out1 + assert "modified entry" in out1 + assert "managed entry" not in out1 + + exit_clean = StatusReport.render_text_output( + service=svc, + reports=reports, + install_dir=Path("/tmp/install"), + verbose=True, + total_issues=0, + color=color, + ) + out2 = capsys.readouterr().out + assert exit_clean == 0 + assert "managed entry" in out2 + assert "all tracked artifacts match the manifest" in out2 + + def test_render_text_output_raises_on_bad_counts_type( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Raises TypeError when counts is not a dict.""" + + class _Service: + @staticmethod + def label(path: Path) -> str: + return str(path) + + svc = cast(CommandService, _Service()) + + def color(_code: str, text: str) -> str: + return text + + with pytest.raises(TypeError, match="expected dict for 'counts', got list"): + StatusReport.render_text_output( + service=svc, + reports=[{"type": "skill", "counts": [], "entries": []}], + install_dir=Path("/tmp/install"), + verbose=False, + total_issues=0, + color=color, + ) + + def test_render_text_output_raises_on_bad_entries_type( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Raises TypeError when entries is not a list.""" + + class _Service: + @staticmethod + def label(path: Path) -> str: + return str(path) + + svc = cast(CommandService, _Service()) + + def color(_code: str, text: str) -> str: + return text + + with pytest.raises(TypeError, match="expected list for 'entries', got dict"): + StatusReport.render_text_output( + service=svc, + reports=[ + { + "type": "skill", + "counts": { + "managed": 0, + "managed_legacy": 0, + "modified": 0, + "missing": 0, + "unknown": 0, + "untracked": 0, + }, + "entries": {}, + } + ], + install_dir=Path("/tmp/install"), + verbose=False, + total_issues=0, + color=color, + ) + + def test_render_text_output_raises_on_bad_entry_type( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Raises TypeError when an individual entry is not a dict.""" + + class _Service: + @staticmethod + def label(path: Path) -> str: + return str(path) + + svc = cast(CommandService, _Service()) + + def color(_code: str, text: str) -> str: + return text + + with pytest.raises(TypeError, match="expected dict for entry, got str"): + StatusReport.render_text_output( + service=svc, + reports=[ + { + "type": "skill", + "counts": { + "managed": 0, + "managed_legacy": 0, + "modified": 0, + "missing": 0, + "unknown": 0, + "untracked": 0, + }, + "entries": ["bad"], + } + ], + install_dir=Path("/tmp/install"), + verbose=False, + total_issues=0, + color=color, + ) diff --git a/tests/vstack/cli/test_service.py b/tests/vstack/cli/test_service.py new file mode 100644 index 0000000..de5341c --- /dev/null +++ b/tests/vstack/cli/test_service.py @@ -0,0 +1,1299 @@ +"""Tests for CommandService (the main CLI orchestration facade).""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from vstack.cli.constants import EXPECTED_CANONICAL_NAMES +from vstack.cli.service import CommandService +from vstack.cli.verify import VerifyCommand +from vstack.constants import TEMPLATES_ROOT, VERSION +from vstack.manifest import ArtifactEntry, Manifest, content_hash +from vstack.models import CheckMessage, ValidationResult + + +class TestCommandService: + """Test cases for CommandService.""" + + # ------------------------------------------------------------------ + # Utility helpers + # ------------------------------------------------------------------ + + def test_validate_exits_zero_against_real_templates(self) -> None: + """validate() returns 0 for the real templates root.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.validate() == 0 + + def test_validate_exits_nonzero_for_empty_templates(self, tmp_path: Path) -> None: + """validate() returns 1 when templates root is empty.""" + svc = CommandService(templates_root=tmp_path) + assert svc.validate() == 1 + + def test_label_prefers_relative(self, tmp_path: Path) -> None: + """label() returns a path relative to templates_root when possible.""" + svc = CommandService(templates_root=tmp_path) + path = tmp_path / "x" / "y" + assert svc.label(path) == "x/y" + + def test_label_falls_back_to_absolute(self, tmp_path: Path) -> None: + """label() returns the absolute string when path is outside templates_root.""" + svc = CommandService(templates_root=tmp_path) + other = Path("/tmp/outside-path") + assert svc.label(other) == str(other) + + def test_cli_class_uses_known_types(self) -> None: + """Service generators cover the four known artifact families.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + names = {g.config.type_name for g in svc.generators} + assert names == {"skill", "agent", "instruction", "prompt"} + + def test_gen_for_returns_none_for_unknown_type(self) -> None: + """gen_for() returns None for an unrecognized type name.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.gen_for("unknown") is None + + @pytest.mark.parametrize( + ("method_name", "module_path", "class_name", "kwargs", "expected"), + [ + ("validate", "vstack.cli.validate", "ValidateCommand", {"only": ["skill"]}, 10), + ( + "install", + "vstack.cli.install", + "InstallCommand", + { + "install_dir": Path("/tmp/install"), + "only": ["skill"], + "force": True, + "force_names": ["x"], + "adopt_names": ["y"], + "update": True, + "dry_run": True, + }, + 11, + ), + ( + "verify", + "vstack.cli.verify", + "VerifyCommand", + { + "install_dir": Path("/tmp/install"), + "source": False, + "output": True, + "only": ["skill"], + }, + 12, + ), + ( + "status", + "vstack.cli.status", + "StatusCommand", + { + "install_dir": Path("/tmp/install"), + "only": ["skill"], + "output_format": "json", + "verbose": True, + "no_color": True, + }, + 13, + ), + ( + "uninstall", + "vstack.cli.uninstall", + "UninstallCommand", + { + "install_dir": Path("/tmp/install"), + "only": ["skill"], + "force": True, + "force_names": ["x"], + }, + 14, + ), + ], + ) + def test_service_command_wrappers_forward_arguments( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + method_name: str, + module_path: str, + class_name: str, + kwargs: dict[str, object], + expected: int, + ) -> None: + """CommandService helper methods forward parameters to command execute methods.""" + captured: dict[str, Any] = {} + + def _fake_execute(*args, **call_kwargs): + captured["args"] = args + captured["kwargs"] = call_kwargs + return expected + + monkeypatch.setattr(f"{module_path}.{class_name}.execute", staticmethod(_fake_execute)) + + svc = CommandService(templates_root=tmp_path) + result = getattr(svc, method_name)(**kwargs) + + assert result == expected + assert captured["args"][0] is svc + + # ------------------------------------------------------------------ + # install + # ------------------------------------------------------------------ + + def test_install_only_skill_writes_skill_artifacts(self, tmp_path: Path) -> None: + """install() with only=['skill'] writes skills subdirectory.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(tmp_path / ".github", only=["skill"]) + assert rc == 0 + assert (tmp_path / ".github" / "skills").exists() + + def test_install_writes_expected_skill_count(self, tmp_path: Path) -> None: + """install() writes the full set of canonical skill files.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(tmp_path / ".github", only=["skill"]) + assert rc == 0 + md_files = list((tmp_path / ".github" / "skills").glob("*/SKILL.md")) + assert len(md_files) == len(EXPECTED_CANONICAL_NAMES) + + def test_install_only_preserves_manifest_entries_for_other_types(self, tmp_path: Path) -> None: + """install() with --only does not drop manifest entries from other artifact types.""" + install_dir = tmp_path / ".github" + install_dir.mkdir(parents=True) + manifest: dict[str, Any] = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "agents": [ + { + "name": "engineer", + "file": "agents/engineer.agent.md", + "version": "0.1.0", + } + ], + "skills": [ + { + "name": "verify", + "file": "skills/verify/SKILL.md", + "version": "0.1.0", + } + ], + }, + } + (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["instruction"]) + assert rc == 0 + + updated: dict[str, Any] = json.loads( + (install_dir / "vstack.json").read_text(encoding="utf-8") + ) + assert "instructions" in updated["artifacts"] + assert updated["artifacts"]["agents"] == manifest["artifacts"]["agents"] + assert updated["artifacts"]["skills"] == manifest["artifacts"]["skills"] + + def test_install_update_skips_when_version_not_newer(self, tmp_path: Path) -> None: + """install() update mode skips artifacts when the installed version is already higher.""" + install_dir = tmp_path / ".github" + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "version": "999.0.0", + "checksum": content_hash("old"), + } + ] + }, + } + install_dir.mkdir(parents=True) + (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") + (install_dir / "skills" / "vision").mkdir(parents=True) + (install_dir / "skills" / "vision" / "SKILL.md").write_text("old", encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["skill"], update=True) + assert rc == 0 + assert (install_dir / "skills" / "vision" / "SKILL.md").read_text(encoding="utf-8") == "old" + + def test_install_preserves_existing_unmanaged_file(self, tmp_path: Path) -> None: + """install() does not overwrite a pre-existing unmanaged file.""" + install_dir = tmp_path / ".github" + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text("user content", encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["skill"]) + + assert rc == 0 + assert artifact_path.read_text(encoding="utf-8") == "user content" + manifest = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + skill_entries = manifest["artifacts"]["skills"] + assert all(entry["name"] != "vision" for entry in skill_entries) + + def test_install_adopt_name_tracks_existing_unmanaged_file(self, tmp_path: Path) -> None: + """install() adopt_names tracks an unmanaged file without overwriting it.""" + install_dir = tmp_path / ".github" + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text("user content", encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["skill"], adopt_names=["vision"]) + + assert rc == 0 + assert artifact_path.read_text(encoding="utf-8") == "user content" + manifest = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + vision = next( + entry for entry in manifest["artifacts"]["skills"] if entry["name"] == "vision" + ) + assert vision["checksum"] == content_hash("user content") + assert vision["checksum_algorithm"] == "sha256" + + def test_install_force_name_overwrites_existing_unmanaged_file(self, tmp_path: Path) -> None: + """install() force_names overwrites one unmanaged artifact.""" + install_dir = tmp_path / ".github" + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text("user content", encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["skill"], force_names=["vision"]) + + assert rc == 0 + assert artifact_path.read_text(encoding="utf-8") != "user content" + manifest = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + vision = next( + entry for entry in manifest["artifacts"]["skills"] if entry["name"] == "vision" + ) + assert vision["checksum"] == content_hash(artifact_path.read_text(encoding="utf-8")) + + def test_install_update_preserves_locally_modified_tracked_file(self, tmp_path: Path) -> None: + """install() update preserves a tracked artifact with local modifications.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + + assert svc.install(install_dir, only=["skill"]) == 0 + + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + original = artifact_path.read_text(encoding="utf-8") + artifact_path.write_text(original + "\nlocal edit\n", encoding="utf-8") + + manifest_before = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + vision_before = next( + entry for entry in manifest_before["artifacts"]["skills"] if entry["name"] == "vision" + ) + + assert svc.install(install_dir, only=["skill"], update=True) == 0 + assert artifact_path.read_text(encoding="utf-8").endswith("local edit\n") + + manifest_after = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + vision_after = next( + entry for entry in manifest_after["artifacts"]["skills"] if entry["name"] == "vision" + ) + assert vision_after == vision_before + + def test_install_force_name_overwrites_locally_modified_tracked_file( + self, tmp_path: Path + ) -> None: + """install() force_names overwrites a modified tracked artifact.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + + assert svc.install(install_dir, only=["skill"]) == 0 + + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + original = artifact_path.read_text(encoding="utf-8") + artifact_path.write_text(original + "\nlocal edit\n", encoding="utf-8") + + assert svc.install(install_dir, only=["skill"], force_names=["vision"]) == 0 + updated = artifact_path.read_text(encoding="utf-8") + assert updated != original + "\nlocal edit\n" + assert updated == next( + a.content for a in cast(Any, svc.gen_for("skill")).render_all() if a.name == "vision" + ) + + def test_install_dry_run_does_not_write_outputs(self, tmp_path: Path) -> None: + """install() dry_run=True produces no file system changes.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(tmp_path / ".github", only=["skill"], dry_run=True) + assert rc == 0 + assert not (tmp_path / ".github" / "skills").exists() + + def test_install_force_and_verify_input_fail_path(self, tmp_path: Path) -> None: + """install() force=True still writes content but returns 1 when verify_input fails.""" + + class _FakeArtifact: + def __init__(self) -> None: + self.name = "x" + self.unresolved = ["BROKEN"] + self.frontmatter = {"version": "1.0.0"} + self.content = "content" + + class _Cfg: + type_name = "skill" + manifest_key = "skills" + output_subdir = "skills" + artifact_is_dir = True + + class _FakeGen: + config = _Cfg() + + def render_all(self): + return [_FakeArtifact()] + + def output_path(self, name: str) -> str: + return f"{name}/SKILL.md" + + def install_relative_path(self, name: str) -> str: + return f"skills/{name}/SKILL.md" + + def verify_input(self): + return ValidationResult(messages=[CheckMessage("fail", "bad")]) + + install_dir = tmp_path / ".github" + out_file = install_dir / "skills" / "x" / "SKILL.md" + out_file.parent.mkdir(parents=True) + out_file.write_text("old", encoding="utf-8") + + svc = CommandService(templates_root=tmp_path) + svc.generators = cast(Any, [_FakeGen()]) + rc = svc.install(install_dir, force=True) + assert rc == 1 + assert out_file.read_text(encoding="utf-8") == "content" + + def test_install_existing_without_update_rewrites_clean_tracked_file( + self, tmp_path: Path + ) -> None: + """Default install rewrites a tracked artifact when its checksum still matches.""" + install_dir = tmp_path / ".github" + old_content = "old" + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "version": "1.0.0", + "checksum": content_hash(old_content), + "checksum_algorithm": "sha256", + } + ] + }, + } + install_dir.mkdir(parents=True) + (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") + (install_dir / "skills" / "vision").mkdir(parents=True) + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + artifact_path.write_text(old_content, encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["skill"], update=False) + assert rc == 0 + assert artifact_path.read_text(encoding="utf-8") != old_content + + def test_install_existing_without_update_preserves_modified_tracked_file( + self, tmp_path: Path + ) -> None: + """Default install preserves tracked files with local checksum drift.""" + install_dir = tmp_path / ".github" + old_content = "old" + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "version": "1.0.0", + "checksum": content_hash(old_content), + "checksum_algorithm": "sha256", + } + ] + }, + } + install_dir.mkdir(parents=True) + (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") + (install_dir / "skills" / "vision").mkdir(parents=True) + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + artifact_path.write_text(old_content + "-local-edit", encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["skill"], update=False) + assert rc == 0 + assert artifact_path.read_text(encoding="utf-8") == old_content + "-local-edit" + + def test_install_update_newer_version_writes_file(self, tmp_path: Path) -> None: + """install() update mode writes new content when installed version is outdated.""" + install_dir = tmp_path / ".github" + old_content = "old" + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "version": "0.0.1", + "checksum": content_hash(old_content), + } + ] + }, + } + install_dir.mkdir(parents=True) + (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") + (install_dir / "skills" / "vision").mkdir(parents=True) + (install_dir / "skills" / "vision" / "SKILL.md").write_text(old_content, encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.install(install_dir, only=["skill"], update=True) + assert rc == 0 + new_content = (install_dir / "skills" / "vision" / "SKILL.md").read_text(encoding="utf-8") + assert new_content != old_content + assert "AUTO-GENERATED" in new_content + + def test_install_preserves_skipped_artifact_when_footer_version_mismatches( + self, tmp_path: Path + ) -> None: + """install() preserves a modified artifact that has a stale VSTACK-META footer.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + + assert svc.install(install_dir, only=["skill"]) == 0 + + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + original = artifact_path.read_text(encoding="utf-8") + tampered = re.sub( + r'"vstack_version":"[^"]+"', + '"vstack_version":"stale-version"', + original, + count=1, + ) + artifact_path.write_text(tampered, encoding="utf-8") + + assert svc.install(install_dir, only=["skill"], update=False) == 0 + + updated = artifact_path.read_text(encoding="utf-8") + assert '"vstack_version":"stale-version"' in updated + assert updated == tampered + + # ------------------------------------------------------------------ + # verify + # ------------------------------------------------------------------ + + def test_verify_output_requires_install_dir(self) -> None: + """verify() with output=True and no install_dir returns 1.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.verify(install_dir=None, source=False, output=True) == 1 + + def test_verify_fails_when_output_missing(self, tmp_path: Path) -> None: + """verify() returns 1 when the output directory does not exist.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.verify(install_dir=tmp_path / ".github", source=False, output=True) + assert rc == 1 + + def test_verify_source_with_no_messages_and_all_passed(self, tmp_path: Path) -> None: + """verify() source=True returns 0 when all checks pass with no messages.""" + + class _Cfg: + type_name = "skill" + output_subdir = "skills" + + class _FakeGen: + config = _Cfg() + + def verify_input(self, expected=None): + return ValidationResult(messages=[]) + + def verify_output(self, out_dir, expected=None): + return ValidationResult(messages=[]) + + svc = CommandService(templates_root=tmp_path) + svc.generators = cast(Any, [_FakeGen()]) + rc = svc.verify(install_dir=tmp_path / ".github", source=True, output=False) + assert rc == 0 + + def test_verify_source_with_messages_path(self, tmp_path: Path) -> None: + """verify() source=True returns 0 for pass-level CheckMessage results.""" + + class _Cfg: + type_name = "skill" + output_subdir = "skills" + + class _FakeGen: + config = _Cfg() + + def verify_input(self, expected=None): + return ValidationResult(messages=[CheckMessage("pass", "ok")]) + + def verify_output(self, out_dir, expected=None): + return ValidationResult(messages=[]) + + svc = CommandService(templates_root=tmp_path) + svc.generators = cast(Any, [_FakeGen()]) + rc = svc.verify(install_dir=tmp_path / ".github", source=True, output=False) + assert rc == 0 + + def test_verify_output_uses_manifest_names(self, tmp_path: Path) -> None: + """verify() uses manifest-tracked names when verifying output artifacts.""" + install_dir = tmp_path / ".github" + (install_dir / "skills" / "custom").mkdir(parents=True) + (install_dir / "skills" / "custom" / "SKILL.md").write_text( + "---\nname: custom\ndescription: 'd'\n---\nbody\n\n", + encoding="utf-8", + ) + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {"skill": [{"name": "custom", "file": "skills/custom/SKILL.md"}]}, + } + install_dir.mkdir(parents=True, exist_ok=True) + (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.verify(install_dir=install_dir, source=False, output=True) + assert rc == 1 + + def test_verify_fails_when_tracked_checksum_does_not_match(self, tmp_path: Path) -> None: + """verify() returns 1 when a tracked instruction file has checksum drift.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + instruction_file.write_text( + instruction_file.read_text(encoding="utf-8") + "\nlocal drift\n", + encoding="utf-8", + ) + + assert ( + svc.verify(install_dir=install_dir, source=False, output=True, only=["instruction"]) + == 1 + ) + + def test_verify_fails_on_vstack_meta_version_mismatch(self, tmp_path: Path) -> None: + """verify() returns 1 when VSTACK-META footer version disagrees with manifest.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + content = instruction_file.read_text(encoding="utf-8") + tampered = re.sub( + r'"vstack_version":"[^"]+"', + '"vstack_version":"tampered-version"', + content, + count=1, + ) + instruction_file.write_text(tampered, encoding="utf-8") + + assert ( + svc.verify(install_dir=install_dir, source=False, output=True, only=["instruction"]) + == 1 + ) + + def test_verify_fails_on_legacy_artifact_without_vstack_meta_when_checksum_changes( + self, tmp_path: Path + ) -> None: + """verify() returns 1 when a legacy artifact no longer matches the manifest checksum.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + content = instruction_file.read_text(encoding="utf-8") + legacy_content = re.sub(r"\n\n", "\n", content, count=1) + instruction_file.write_text(legacy_content, encoding="utf-8") + + assert ( + svc.verify(install_dir=install_dir, source=False, output=True, only=["instruction"]) + == 1 + ) + + def test_verify_rejects_legacy_artifact_without_vstack_meta_and_autogen( + self, tmp_path: Path + ) -> None: + """verify() rejects legacy fallback when AUTO-GENERATED marker is missing.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + content = instruction_file.read_text(encoding="utf-8") + no_meta = re.sub(r"\n\n", "\n", content, count=1) + no_autogen = re.sub( + r"\n\n", + "\n", + no_meta, + count=1, + ) + instruction_file.write_text(no_autogen, encoding="utf-8") + + assert ( + svc.verify(install_dir=install_dir, source=False, output=True, only=["instruction"]) + == 1 + ) + + # ------------------------------------------------------------------ + # status + # ------------------------------------------------------------------ + + def test_status_requires_manifest(self, tmp_path: Path) -> None: + """status() returns 1 when manifest is missing.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.status(tmp_path / ".github", only=["skill"]) == 1 + + def test_status_fails_when_manifest_schema_is_legacy(self, tmp_path: Path) -> None: + """status() returns 1 and prints guidance for a schema-v1 manifest.""" + install_dir = tmp_path / ".github" + install_dir.mkdir(parents=True) + (install_dir / "vstack.json").write_text( + json.dumps( + { + "vstack_version": "1.3.6", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {}, + } + ), + encoding="utf-8", + ) + + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.status(install_dir, only=["skill"]) == 1 + + def test_status_fails_when_untracked_collision_exists(self, tmp_path: Path) -> None: + """status() returns 1 when files at managed paths are not tracked in the manifest.""" + install_dir = tmp_path / ".github" + artifact_path = install_dir / "skills" / "vision" / "SKILL.md" + artifact_path.parent.mkdir(parents=True) + artifact_path.write_text("user content", encoding="utf-8") + (install_dir / "vstack.json").write_text( + json.dumps( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": VERSION, + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {}, + } + ), + encoding="utf-8", + ) + + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.status(install_dir, only=["skill"]) == 1 + + def test_status_json_output_contains_summary(self, tmp_path: Path, capsys: Any) -> None: + """status() json output contains a summary object with issues/warnings/types_checked.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + capsys.readouterr() + + rc = svc.status(install_dir, only=["instruction"], output_format="json") + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["summary"]["issues"] == 0 + assert payload["summary"]["warnings"] == 0 + assert payload["summary"]["types_checked"] == 1 + + def test_status_legacy_entry_without_checksum_is_warning_not_failure( + self, tmp_path: Path, capsys: Any + ) -> None: + """Legacy manifest entries without checksum produce warnings, not failures.""" + install_dir = tmp_path / ".github" + instruction_file = install_dir / "instructions" / "python.instructions.md" + instruction_file.parent.mkdir(parents=True) + instruction_file.write_text("legacy content\n", encoding="utf-8") + (install_dir / "vstack.json").write_text( + json.dumps( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": VERSION, + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "instructions": [ + { + "name": "python", + "file": "instructions/python.instructions.md", + "version": "0.1.0", + } + ] + }, + } + ), + encoding="utf-8", + ) + + svc = CommandService(templates_root=TEMPLATES_ROOT) + rc = svc.status(install_dir, only=["instruction"], output_format="json") + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["ok"] is True + assert payload["summary"]["issues"] == 0 + assert payload["summary"]["warnings"] == 1 + counts = payload["types"][0]["counts"] + assert counts["managed_legacy"] == 1 + + def test_status_yaml_output_contains_summary(self, tmp_path: Path, capsys: Any) -> None: + """status() yaml output contains summary keys.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + capsys.readouterr() + + rc = svc.status(install_dir, only=["instruction"], output_format="yaml") + assert rc == 0 + out = capsys.readouterr().out + assert "summary:" in out + assert "issues: 0" in out + assert "types_checked: 1" in out + + # ------------------------------------------------------------------ + # uninstall + # ------------------------------------------------------------------ + + def test_uninstall_removes_manifest_and_outputs(self, tmp_path: Path) -> None: + """uninstall() removes the manifest and all tracked artifacts.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["skill", "agent"]) == 0 + assert (install_dir / "vstack.json").exists() + assert svc.uninstall(install_dir) == 0 + assert not (install_dir / "vstack.json").exists() + + def test_uninstall_without_manifest_and_without_files(self, tmp_path: Path) -> None: + """uninstall() returns 0 when there is no manifest and no tracked files.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.uninstall(tmp_path / ".github") == 0 + + def test_uninstall_non_directory_artifact_removes_file(self, tmp_path: Path) -> None: + """uninstall() removes individual tracked files, not just directories.""" + install_dir = tmp_path / ".github" + file_path = install_dir / "agents" / "custom.agent.md" + file_path.parent.mkdir(parents=True) + file_path.write_text("x", encoding="utf-8") + manifest = { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "agents": [ + { + "name": "custom", + "file": "agents/custom.agent.md", + "checksum": content_hash("x"), + } + ] + }, + } + (install_dir / "vstack.json").write_text(json.dumps(manifest), encoding="utf-8") + + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.uninstall(install_dir) == 0 + assert not file_path.exists() + + def test_uninstall_preserves_locally_modified_tracked_file(self, tmp_path: Path) -> None: + """uninstall() preserves a modified tracked file by default.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + original = instruction_file.read_text(encoding="utf-8") + instruction_file.write_text(original + "\nlocal drift\n", encoding="utf-8") + + assert svc.uninstall(install_dir, only=["instruction"]) == 0 + assert instruction_file.exists() + manifest_after = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + assert manifest_after["artifacts"].get("instructions") + + def test_uninstall_force_name_removes_locally_modified_tracked_file( + self, tmp_path: Path + ) -> None: + """uninstall() force_names removes one modified tracked file.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + original = instruction_file.read_text(encoding="utf-8") + instruction_file.write_text(original + "\nlocal drift\n", encoding="utf-8") + + assert svc.uninstall(install_dir, only=["instruction"], force_names=["python"]) == 0 + assert not instruction_file.exists() + + # ------------------------------------------------------------------ + # validate + # ------------------------------------------------------------------ + + def test_validate_returns_non_zero_on_unresolved(self, tmp_path: Path) -> None: + """validate() returns 1 when a rendered artifact has unresolved placeholders.""" + + class _FakeArtifact: + def __init__(self) -> None: + self.name = "a" + self.unresolved = ["MISSING"] + + class _Cfg: + type_name = "skill" + manifest_key = "skills" + output_subdir = "skills" + + class _FakeGen: + config = _Cfg() + + def render_all(self): + return [_FakeArtifact()] + + def load_partials(self): + return {"X": "y"} + + def output_path(self, name: str) -> str: + return f"{name}/SKILL.md" + + svc = CommandService(templates_root=tmp_path) + svc.generators = cast(Any, [_FakeGen()]) + assert svc.validate() == 1 + + def test_validate_returns_zero_when_all_clean(self, tmp_path: Path) -> None: + """validate() returns 0 when all rendered artifacts have no unresolved placeholders.""" + + class _FakeArtifact: + def __init__(self) -> None: + self.name = "a" + self.unresolved: list[str] = [] + + class _Cfg: + type_name = "skill" + manifest_key = "skills" + output_subdir = "skills" + + class _FakeGen: + config = _Cfg() + + def render_all(self): + return [_FakeArtifact()] + + def load_partials(self): + return {"X": "y"} + + def output_path(self, name: str) -> str: + return f"{name}/SKILL.md" + + svc = CommandService(templates_root=tmp_path) + svc.generators = cast(Any, [_FakeGen()]) + assert svc.validate() == 0 + + def test_validate_handles_missing_generator_for_type(self, tmp_path: Path) -> None: + """validate() returns 0 when gen_for returns None for an artifact type.""" + + class _FakeArtifact: + def __init__(self) -> None: + self.name = "a" + self.unresolved: list[str] = [] + + class _Cfg: + type_name = "skill" + + class _FakeGen: + config = _Cfg() + + def render_all(self): + return [_FakeArtifact()] + + def load_partials(self): + return {} + + def output_path(self, name: str) -> str: + return f"{name}/SKILL.md" + + svc = CommandService(templates_root=tmp_path) + svc.generators = cast(Any, [_FakeGen()]) + setattr(svc, "gen_for", lambda _type_name: None) + assert svc.validate() == 0 + + # ------------------------------------------------------------------ + # manifest helpers + # ------------------------------------------------------------------ + + def test_manifest_upgrade_returns_error_when_read_has_error( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """manifest_upgrade prints and returns non-zero when manifest read fails.""" + + class _ManifestFile: + path = tmp_path / "vstack.json" + read_error = "invalid manifest" + + def read(self, *, allow_legacy: bool = False): + assert allow_legacy is True + return None + + svc = CommandService(templates_root=tmp_path) + monkeypatch.setattr(svc, "manifest_for", lambda _install_dir: _ManifestFile()) + + assert svc.manifest_upgrade(tmp_path) == 1 + assert "ERROR: invalid manifest" in capsys.readouterr().err + + def test_manifest_upgrade_returns_error_when_manifest_missing( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """manifest_upgrade returns non-zero when no manifest exists.""" + + class _ManifestFile: + path = tmp_path / "vstack.json" + read_error = None + + def read(self, *, allow_legacy: bool = False): + assert allow_legacy is True + return None + + svc = CommandService(templates_root=tmp_path) + monkeypatch.setattr(svc, "manifest_for", lambda _install_dir: _ManifestFile()) + + assert svc.manifest_upgrade(tmp_path) == 1 + assert "No manifest found to upgrade." in capsys.readouterr().out + + def test_manifest_upgrade_noop_when_current_schema( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """manifest_upgrade short-circuits when schema is already current.""" + + class _Manifest: + manifest_version = 2 + + def needs_upgrade(self) -> bool: + return False + + class _ManifestFile: + path = tmp_path / "vstack.json" + read_error = None + + def read(self, *, allow_legacy: bool = False): + assert allow_legacy is True + return _Manifest() + + svc = CommandService(templates_root=tmp_path) + monkeypatch.setattr(svc, "manifest_for", lambda _install_dir: _ManifestFile()) + + assert svc.manifest_upgrade(tmp_path) == 0 + assert "Manifest already up to date" in capsys.readouterr().out + + def test_manifest_upgrade_backfill_runs_on_current_schema_unit( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """manifest_upgrade backfills and writes even when schema is already current.""" + + class _Manifest: + manifest_version = 2 + + def needs_upgrade(self) -> bool: + return False + + def with_backfilled_checksums(self, *, install_dir: Path): + assert install_dir == tmp_path + return self, ["skills:vision"], ["skills:verify (missing file)"] + + class _ManifestFile: + path = tmp_path / "vstack.json" + read_error = None + written = None + + def read(self, *, allow_legacy: bool = False): + assert allow_legacy is True + return _Manifest() + + def write(self, manifest: object) -> None: + self.written = manifest + + mf = _ManifestFile() + svc = CommandService(templates_root=tmp_path) + monkeypatch.setattr(svc, "manifest_for", lambda _install_dir: mf) + + assert svc.manifest_upgrade(tmp_path, backfill=True) == 0 + out = capsys.readouterr().out + assert "schema already current" in out + assert "Backfilled checksums for 1 tracked artifact(s); skipped 1." in out + assert "skills:verify (missing file)" in out + assert mf.written is not None + + def test_manifest_upgrade_writes_upgraded_manifest_unit( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """manifest_upgrade persists the upgraded manifest and returns success.""" + + class _Manifest: + manifest_version = 1 + + def needs_upgrade(self) -> bool: + return True + + def upgraded(self) -> object: + return {"upgraded": True} + + class _ManifestFile: + path = tmp_path / "vstack.json" + read_error = None + written: object | None = None + + def read(self, *, allow_legacy: bool = False): + assert allow_legacy is True + return _Manifest() + + def write(self, manifest: object) -> None: + self.written = manifest + + mf = _ManifestFile() + svc = CommandService(templates_root=tmp_path) + monkeypatch.setattr(svc, "manifest_for", lambda _install_dir: mf) + + assert svc.manifest_upgrade(tmp_path) == 0 + assert mf.written == {"upgraded": True} + assert "Upgraded manifest to schema" in capsys.readouterr().out + + def test_manifest_upgrade_migrates_legacy_schema(self, tmp_path: Path) -> None: + """manifest_upgrade() migrates schema-v1 manifest to current schema.""" + install_dir = tmp_path / ".github" + install_dir.mkdir(parents=True) + (install_dir / "vstack.json").write_text( + json.dumps( + { + "vstack_version": "1.3.6", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {}, + } + ), + encoding="utf-8", + ) + + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.manifest_upgrade(install_dir) == 0 + + upgraded = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + assert upgraded["manifest_version"] == 2 + assert upgraded["hash_algorithm"] == "sha256" + + def test_manifest_upgrade_backfill_adds_checksum_for_footer_tagged_legacy_entry( + self, + tmp_path: Path, + ) -> None: + """manifest_upgrade(backfill=True) stores checksum for VSTACK-META-tagged entries.""" + install_dir = tmp_path / ".github" + install_dir.mkdir(parents=True) + + artifact_rel = "skills/vision/SKILL.md" + artifact_path = install_dir / artifact_rel + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text( + '# Vision\n\n\n', + encoding="utf-8", + ) + + (install_dir / "vstack.json").write_text( + json.dumps( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.3.6", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": artifact_rel, + "version": "1.0.0", + } + ] + }, + } + ), + encoding="utf-8", + ) + + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.manifest_upgrade(install_dir, backfill=True) == 0 + + upgraded = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + entry = upgraded["artifacts"]["skills"][0] + assert entry["checksum_algorithm"] == "sha256" + assert isinstance(entry["checksum"], str) + assert len(entry["checksum"]) == 64 + + # ------------------------------------------------------------------ + # artifact_control_state + # ------------------------------------------------------------------ + + def test_artifact_control_state_reports_missing_tracked_file(self, tmp_path: Path) -> None: + """Tracked entries missing on disk are reported as 'missing'.""" + svc = CommandService(templates_root=tmp_path) + entry = SimpleNamespace(checksum="abc", checksum_algorithm="sha256") + state, message = svc.artifact_control_state( + out_file=tmp_path / "missing.txt", + existing_entry=entry, + ) + assert state == "missing" + assert "tracked file missing from disk" in message + + def test_artifact_control_state_reports_unknown_algorithm(self, tmp_path: Path) -> None: + """Unsupported checksum algorithm returns 'unknown' state.""" + svc = CommandService(templates_root=tmp_path) + out_file = tmp_path / "artifact.txt" + out_file.write_text("data", encoding="utf-8") + entry = SimpleNamespace(checksum="abc", checksum_algorithm="sha999") + state, message = svc.artifact_control_state(out_file=out_file, existing_entry=entry) + assert state == "unknown" + assert "unsupported checksum algorithm" in message + + def test_artifact_control_state_reports_read_error( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """I/O read errors while hashing map to 'unknown' state.""" + svc = CommandService(templates_root=tmp_path) + out_file = tmp_path / "artifact.txt" + out_file.write_text("data", encoding="utf-8") + entry = SimpleNamespace(checksum="abc", checksum_algorithm="sha256") + + def _raise_oserror(self: Path, encoding: str = "utf-8") -> str: + del self, encoding + raise OSError("boom") + + monkeypatch.setattr(Path, "read_text", _raise_oserror) + state, message = svc.artifact_control_state(out_file=out_file, existing_entry=entry) + assert state == "unknown" + assert "could not read file" in message + + def test_artifact_control_state_managed_returns_managed(self, tmp_path: Path) -> None: + """artifact_control_state returns 'managed' for a clean tracked artifact.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + expected_hash = content_hash(instruction_file.read_text(encoding="utf-8")) + + entry = ArtifactEntry( + name="python", + file="instructions/python.instructions.md", + checksum=expected_hash, + checksum_algorithm="sha256", + ) + state, _message = svc.artifact_control_state( + out_file=instruction_file, existing_entry=entry + ) + assert state == "managed" + + def test_artifact_control_state_modified_returns_modified(self, tmp_path: Path) -> None: + """artifact_control_state returns 'modified' when file content drifts from checksum.""" + install_dir = tmp_path / ".github" + svc = CommandService(templates_root=TEMPLATES_ROOT) + assert svc.install(install_dir, only=["instruction"]) == 0 + + instruction_file = install_dir / "instructions" / "python.instructions.md" + entry = ArtifactEntry( + name="python", + file="instructions/python.instructions.md", + checksum=content_hash("different"), + checksum_algorithm="sha256", + ) + state, _message = svc.artifact_control_state( + out_file=instruction_file, existing_entry=entry + ) + assert state == "modified" + + def test_artifact_control_state_absent_returns_missing(self, tmp_path: Path) -> None: + """artifact_control_state returns 'missing' when a tracked file is not on disk.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + entry = ArtifactEntry( + name="ghost", + file="skills/ghost/SKILL.md", + checksum=content_hash("x"), + checksum_algorithm="sha256", + ) + state, _message = svc.artifact_control_state( + out_file=tmp_path / ".github" / "skills" / "ghost" / "SKILL.md", + existing_entry=entry, + ) + assert state == "missing" + + # ------------------------------------------------------------------ + # verify (VerifyCommand helpers) + # ------------------------------------------------------------------ + + def test_expected_output_names_falls_back_without_manifest(self) -> None: + """VerifyCommand._expected_output_names falls back to canonical list when manifest is absent.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + gen = svc.gen_for("skill") + assert gen is not None + assert VerifyCommand._expected_output_names(gen, None) == EXPECTED_CANONICAL_NAMES + + def test_verify_manifest_metadata_skips_missing_artifact_files(self, tmp_path: Path) -> None: + """VerifyCommand._verify_manifest_metadata returns None for manifest-only absent files.""" + svc = CommandService(templates_root=TEMPLATES_ROOT) + gen = svc.gen_for("skill") + assert gen is not None + + manifest_data = Manifest( + vstack_version=VERSION, + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="missing-skill", + file="skills/missing-skill/SKILL.md", + version="1.0.0", + ) + ] + }, + ) + + result = VerifyCommand._verify_manifest_metadata( + svc, gen, manifest_data, tmp_path / ".github" + ) + assert result is None diff --git a/tests/vstack/cli/test_status.py b/tests/vstack/cli/test_status.py new file mode 100644 index 0000000..bc7e687 --- /dev/null +++ b/tests/vstack/cli/test_status.py @@ -0,0 +1,41 @@ +"""Tests for StatusCommand.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from typing import Any, cast + +import pytest + +from vstack.cli.base import CommandContext +from vstack.cli.service import CommandService +from vstack.cli.status import StatusCommand + + +class TestStatusCommand: + """Test cases for StatusCommand.""" + + def test_run_forwards_context_to_execute( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """run() unpacks CommandContext args and forwards them to execute().""" + captured: dict[str, Any] = {} + + def _fake_execute(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return 3 + + monkeypatch.setattr("vstack.cli.status.StatusCommand.execute", staticmethod(_fake_execute)) + + context = CommandContext( + args=Namespace(output_format="json", verbose=True, no_color=True), + install_dir=tmp_path, + only=["prompt"], + ) + assert StatusCommand(service=cast(CommandService, object())).run(context=context) == 3 + assert captured["kwargs"]["output_format"] == "json" + assert captured["kwargs"]["verbose"] is True + assert captured["kwargs"]["no_color"] is True + assert captured["kwargs"]["install_dir"] == tmp_path diff --git a/tests/vstack/cli/test_uninstall.py b/tests/vstack/cli/test_uninstall.py new file mode 100644 index 0000000..b2c69ef --- /dev/null +++ b/tests/vstack/cli/test_uninstall.py @@ -0,0 +1,69 @@ +"""Tests for UninstallCommand.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from vstack.cli.base import CommandContext +from vstack.cli.service import CommandService +from vstack.cli.uninstall import UninstallCommand + + +class TestUninstallCommand: + """Test cases for UninstallCommand.""" + + def test_print_summary_handles_empty_lists(self, capsys: pytest.CaptureFixture[str]) -> None: + """Summary printer emits default text when nothing changed.""" + UninstallCommand._print_summary(removed=[], preserved=[]) + assert "Nothing to remove." in capsys.readouterr().out + + def test_execute_returns_error_when_manifest_read_fails( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """execute returns non-zero when manifest read has an error.""" + + class _ManifestFile: + read_error = "bad manifest" + + def read(self): + return None + + service = cast( + CommandService, + SimpleNamespace( + manifest_for=lambda _install_dir: _ManifestFile(), + generators=[], + ), + ) + assert UninstallCommand.execute(service, Path("/tmp/install")) == 1 + assert "ERROR: bad manifest" in capsys.readouterr().err + + def test_run_forwards_context_to_execute( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """run() unpacks CommandContext args and forwards them to execute().""" + captured: dict[str, Any] = {} + + def _fake_execute(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return 2 + + monkeypatch.setattr( + "vstack.cli.uninstall.UninstallCommand.execute", staticmethod(_fake_execute) + ) + + context = CommandContext( + args=Namespace(force=True, force_names=["x"]), + install_dir=tmp_path, + only=["agent"], + ) + assert UninstallCommand(service=cast(CommandService, object())).run(context=context) == 2 + assert captured["kwargs"]["force_names"] == ["x"] + # install_dir is passed as positional arg (second arg after service) + assert captured["args"][1] == tmp_path diff --git a/tests/vstack/cli/test_validate.py b/tests/vstack/cli/test_validate.py new file mode 100644 index 0000000..16bc39b --- /dev/null +++ b/tests/vstack/cli/test_validate.py @@ -0,0 +1,37 @@ +"""Tests for ValidateCommand.""" + +from __future__ import annotations + +from argparse import Namespace +from typing import Any, cast + +import pytest + +from vstack.cli.base import CommandContext +from vstack.cli.service import CommandService +from vstack.cli.validate import ValidateCommand + + +class TestValidateCommand: + """Test cases for ValidateCommand.""" + + def test_run_forwards_context_to_execute(self, monkeypatch: pytest.MonkeyPatch) -> None: + """run() unpacks CommandContext args and forwards them to execute().""" + captured: dict[str, Any] = {} + + def _fake_execute(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return 4 + + monkeypatch.setattr( + "vstack.cli.validate.ValidateCommand.execute", staticmethod(_fake_execute) + ) + + context = CommandContext( + args=Namespace(), + install_dir=None, + only=["skill"], + ) + assert ValidateCommand(service=cast(CommandService, object())).run(context=context) == 4 + assert captured["kwargs"]["only"] == ["skill"] diff --git a/tests/vstack/cli/test_verify.py b/tests/vstack/cli/test_verify.py new file mode 100644 index 0000000..73392bd --- /dev/null +++ b/tests/vstack/cli/test_verify.py @@ -0,0 +1,172 @@ +"""Tests for VerifyCommand.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from vstack.cli.base import CommandContext +from vstack.cli.service import CommandService +from vstack.cli.verify import VerifyCommand + + +class TestVerifyCommand: + """Test cases for VerifyCommand.""" + + def test_manifest_metadata_entry_returns_fail_on_unreadable_file( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """Unreadable artifact files should return a failing ValidationResult.""" + + artifact_path = tmp_path / "artifact.md" + artifact_path.write_text("content", encoding="utf-8") + + def _raise_oserror(self: Path, encoding: str = "utf-8") -> str: + del self, encoding + raise OSError("permission denied") + + monkeypatch.setattr(Path, "read_text", _raise_oserror) + + service = cast(CommandService, SimpleNamespace(label=lambda path: str(path))) + result = VerifyCommand._verify_manifest_metadata_entry( + service=service, + gen=SimpleNamespace(config=SimpleNamespace(type_name="skill")), + manifest_data=SimpleNamespace(vstack_version="2.0.0"), + entry=SimpleNamespace(name="verify", version="1.0.0"), + artifact_path=artifact_path, + ) + + assert result.failures == 1 + assert result.messages[0].level == "fail" + assert "could not read file during metadata verify" in result.messages[0].message + assert str(artifact_path) in result.messages[0].message + + def test_output_returns_error_when_manifest_read_fails( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """_verify_output fails early when manifest parse reports an error.""" + + class _ManifestFile: + read_error = "bad manifest" + + def read(self): + return None + + service = cast( + CommandService, + SimpleNamespace(manifest_for=lambda _install_dir: _ManifestFile()), + ) + result = VerifyCommand._verify_output( + service=service, + install_dir=Path("/tmp/install"), + gens=[], + header=lambda _label: None, + results=[], + ) + assert result == 1 + assert "ERROR: bad manifest" in capsys.readouterr().err + + def test_output_skips_manifest_checks_when_manifest_missing( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + ) -> None: + """When no manifest exists, verify runs output checks and returns 0.""" + + class _ManifestFile: + read_error = None + + def read(self): + return None + + class _Gen: + class config: + type_name = "skill" + output_subdir = "skills" + + @staticmethod + def verify_output(_out_dir: Path, _expected) -> object: + from vstack.models import ValidationResult + + return ValidationResult() + + monkeypatch.setattr( + "vstack.cli.verify.VerifyCommand._verify_manifest_checksums", + staticmethod( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not be called") + ) + ), + ) + monkeypatch.setattr( + "vstack.cli.verify.VerifyCommand._verify_manifest_metadata", + staticmethod( + lambda *_args, **_kwargs: (_ for _ in ()).throw( + AssertionError("must not be called") + ) + ), + ) + + install_dir = tmp_path + out_dir = install_dir / "skills" + out_dir.mkdir(parents=True) + + service = cast( + CommandService, + SimpleNamespace( + manifest_for=lambda _install_dir: _ManifestFile(), + label=lambda path: str(path), + ), + ) + result = VerifyCommand._verify_output( + service=service, + install_dir=install_dir, + gens=[_Gen()], + header=lambda _label: None, + results=[], + ) + assert result == 0 + + def test_execute_returns_output_exit_when_output_phase_fails( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """execute bubbles non-zero codes from the output verification phase.""" + monkeypatch.setattr( + "vstack.cli.verify.VerifyCommand._verify_output", + staticmethod(lambda **_kwargs: 7), + ) + service = cast(CommandService, SimpleNamespace(generators=[])) + assert ( + VerifyCommand.execute( + service, install_dir=Path("/tmp/install"), source=False, output=True + ) + == 7 + ) + + def test_run_forwards_context_to_execute( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """run() unpacks CommandContext args and forwards them to execute().""" + captured: dict[str, Any] = {} + + def _fake_execute(*args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + return 5 + + monkeypatch.setattr("vstack.cli.verify.VerifyCommand.execute", staticmethod(_fake_execute)) + + context = CommandContext( + args=Namespace(source=False, output=True), + install_dir=tmp_path, + only=["instruction"], + ) + assert VerifyCommand(service=cast(CommandService, object())).run(context=context) == 5 + assert captured["kwargs"]["source"] is False + assert captured["kwargs"]["output"] is True diff --git a/tests/vstack/instructions/test___init__.py b/tests/vstack/instructions/test___init__.py new file mode 100644 index 0000000..66b51dd --- /dev/null +++ b/tests/vstack/instructions/test___init__.py @@ -0,0 +1,19 @@ +"""Tests for vstack.instructions package public API.""" + +from __future__ import annotations + +from vstack.instructions import InstructionGenerator + + +class TestInstructionsInit: + """Test cases for vstack.instructions package exports.""" + + def test_exports_instruction_generator(self) -> None: + """Package exports InstructionGenerator at the top level.""" + assert InstructionGenerator is not None + + def test_all_contains_instruction_generator(self) -> None: + """__all__ declares InstructionGenerator.""" + import vstack.instructions as mod + + assert "InstructionGenerator" in mod.__all__ diff --git a/tests/vstack/instructions/test_config.py b/tests/vstack/instructions/test_config.py new file mode 100644 index 0000000..f8181fe --- /dev/null +++ b/tests/vstack/instructions/test_config.py @@ -0,0 +1,29 @@ +"""Tests for instruction artifact type configuration.""" + +from __future__ import annotations + +from vstack.instructions.config import INSTRUCTION_TYPE + + +class TestInstructionType: + """Test cases for INSTRUCTION_TYPE artifact configuration.""" + + def test_type_name_is_instruction(self) -> None: + """type_name must be 'instruction'.""" + assert INSTRUCTION_TYPE.type_name == "instruction" + + def test_output_subdir_is_instructions(self) -> None: + """Output artifacts go under the instructions subdirectory.""" + assert INSTRUCTION_TYPE.output_subdir == "instructions" + + def test_artifact_is_not_dir(self) -> None: + """Instructions produce single-file artifacts, not directories.""" + assert INSTRUCTION_TYPE.artifact_is_dir is False + + def test_add_frontmatter_is_true(self) -> None: + """Instruction artifacts include frontmatter.""" + assert INSTRUCTION_TYPE.add_frontmatter is True + + def test_auto_gen_footer_is_true(self) -> None: + """Instruction artifacts include the auto-gen footer.""" + assert INSTRUCTION_TYPE.auto_gen_footer is True diff --git a/tests/vstack/instructions/test_constants.py b/tests/vstack/instructions/test_constants.py new file mode 100644 index 0000000..85178c5 --- /dev/null +++ b/tests/vstack/instructions/test_constants.py @@ -0,0 +1,25 @@ +"""Tests for instruction module constants.""" + +from __future__ import annotations + +from vstack.instructions.constants import ( + INSTRUCTION_OUTPUT_SUBDIR, + INSTRUCTION_OUTPUT_SUFFIX, + INSTRUCTION_TEMPLATES_SUBDIR, +) + + +class TestInstructionConstants: + """Test cases for instruction constant values.""" + + def test_output_suffix_is_instructions_md(self) -> None: + """Output suffix must end with .instructions.md.""" + assert INSTRUCTION_OUTPUT_SUFFIX == ".instructions.md" + + def test_templates_subdir_is_instructions(self) -> None: + """Templates subdir must be 'instructions'.""" + assert INSTRUCTION_TEMPLATES_SUBDIR == "instructions" + + def test_output_subdir_is_instructions(self) -> None: + """Output subdir must be 'instructions'.""" + assert INSTRUCTION_OUTPUT_SUBDIR == "instructions" diff --git a/tests/vstack/manifest/test_store.py b/tests/vstack/manifest/test_store.py new file mode 100644 index 0000000..8c520b6 --- /dev/null +++ b/tests/vstack/manifest/test_store.py @@ -0,0 +1,599 @@ +"""Tests for manifest store model, serialization, and file operations.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from vstack.cli.constants import EXPECTED_CANONICAL_NAMES +from vstack.manifest import ( + CURRENT_HASH_ALGORITHM, + CURRENT_MANIFEST_VERSION, + ArtifactEntry, + Manifest, + ManifestFile, + hash_with_algorithm, +) + + +class TestArtifactEntry: + """Test cases for ArtifactEntry.""" + + def test_artifact_entry_fields(self) -> None: + """Test that artifact entry fields.""" + e = ArtifactEntry( + name="vision", + file="skills/vision/SKILL.md", + version="1.0.0", + checksum="abc123", + checksum_algorithm="sha256", + ) + assert e.name == "vision" + assert e.file.endswith("SKILL.md") + assert e.checksum == "abc123" + assert e.checksum_algorithm == "sha256" + + +class TestChecksumHelpers: + """Test cases for checksum helper functions.""" + + def test_hash_with_algorithm_supports_md5(self) -> None: + """Test that md5 remains supported for legacy manifest compatibility.""" + digest = hash_with_algorithm("hello", "md5") + assert len(digest) == 32 + + def test_hash_with_algorithm_rejects_unknown_algorithm(self) -> None: + """Test that unknown algorithms raise a clear ValueError.""" + with pytest.raises(ValueError, match="Unsupported checksum algorithm"): + hash_with_algorithm("hello", "sha999") + + +class TestManifest: + """Test cases for Manifest.""" + + def test_entries_names_files_for(self) -> None: + """Test that entries names files for.""" + m = Manifest( + vstack_version="0.1.0", + installed_at="2026-01-01T00:00:00Z", + artifacts={"skills": [ArtifactEntry(name="vision", file="skills/vision/SKILL.md")]}, + ) + assert len(m.entries_for("skills")) == 1 + assert m.names_for("skills") == ["vision"] + assert m.files_for("skills") == ["skills/vision/SKILL.md"] + + def test_to_dict_and_from_dict_roundtrip(self) -> None: + """Test that to dict and from dict roundtrip.""" + src = Manifest( + vstack_version="0.1.0", + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file="skills/vision/SKILL.md", + checksum="hash-1", + ) + ] + }, + ) + out = Manifest.from_dict(src.to_dict()) + assert out.vstack_version == "0.1.0" + assert out.manifest_version == 2 + assert out.hash_algorithm == "sha256" + assert out.names_for("skills") == ["vision"] + assert out.entries_for("skills")[0].checksum == "hash-1" + assert out.entries_for("skills")[0].checksum_algorithm == "sha256" + + def test_from_dict_accepts_legacy_content_hash_field(self) -> None: + """Test that legacy content_hash manifests are still readable.""" + manifest = Manifest.from_dict( + { + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "content_hash": "legacy-hash", + } + ] + }, + } + ) + assert manifest.entries_for("skills")[0].checksum == "legacy-hash" + assert manifest.entries_for("skills")[0].checksum_algorithm is None + + def test_from_dict_accepts_unknown_manifest_fields(self) -> None: + """Test that unknown manifest-level fields are ignored safely.""" + manifest = Manifest.from_dict( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "extra": {"future": True}, + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "unknown": "x", + } + ] + }, + } + ) + assert manifest.names_for("skills") == ["vision"] + + def test_needs_upgrade_true_for_legacy_schema(self) -> None: + """Test that schema v1 manifests are marked as upgrade candidates.""" + legacy = Manifest.from_dict( + { + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "content_hash": "a" * 32, + } + ] + }, + } + ) + assert legacy.needs_upgrade() is True + + def test_upgraded_normalizes_manifest_metadata(self) -> None: + """Test that upgraded converts legacy metadata to current schema values.""" + legacy = Manifest.from_dict( + { + "manifest_version": 1, + "hash_algorithm": "md5", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "checksum": "a" * 64, + } + ] + }, + } + ) + + upgraded = legacy.upgraded() + assert upgraded.manifest_version == CURRENT_MANIFEST_VERSION + assert upgraded.hash_algorithm == CURRENT_HASH_ALGORITHM + assert upgraded.entries_for("skills")[0].checksum_algorithm == "sha256" + + def test_upgraded_is_idempotent(self) -> None: + """Test that upgrading twice results in the same serialized manifest.""" + manifest = Manifest.from_dict( + { + "manifest_version": 1, + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": "skills/vision/SKILL.md", + "content_hash": "legacy-hash", + } + ] + }, + } + ) + + once = manifest.upgraded() + twice = once.upgraded() + assert once.to_dict() == twice.to_dict() + + def test_with_backfilled_checksums_updates_footer_tagged_legacy_entry( + self, + tmp_path, + ) -> None: + """Backfill should add checksum metadata when footer-tagged file exists.""" + install_dir = tmp_path / ".github" + artifact_rel = "skills/vision/SKILL.md" + artifact_path = install_dir / artifact_rel + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text( + '# Vision\n\n', + encoding="utf-8", + ) + + manifest = Manifest( + vstack_version="1.3.6", + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file=artifact_rel, + version="1.0.0", + ) + ] + }, + ) + + updated, backfilled, skipped = manifest.with_backfilled_checksums(install_dir=install_dir) + entry = updated.entries_for("skills")[0] + assert entry.checksum_algorithm == "sha256" + assert entry.checksum is not None + assert backfilled == ["skills:vision"] + assert skipped == [] + + def test_with_backfilled_checksums_skips_entry_without_footer(self, tmp_path) -> None: + """Backfill should skip legacy entries when VSTACK-META footer is missing.""" + install_dir = tmp_path / ".github" + artifact_rel = "skills/vision/SKILL.md" + artifact_path = install_dir / artifact_rel + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text("# user-managed content\n", encoding="utf-8") + + manifest = Manifest( + vstack_version="1.3.6", + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file=artifact_rel, + version="1.0.0", + ) + ] + }, + ) + + updated, backfilled, skipped = manifest.with_backfilled_checksums(install_dir=install_dir) + entry = updated.entries_for("skills")[0] + assert entry.checksum is None + assert backfilled == [] + assert skipped == ["skills:vision (missing VSTACK-META footer)"] + + def test_with_backfilled_checksums_keeps_entries_with_existing_checksum(self, tmp_path) -> None: + """Backfill should keep entries that already have checksum metadata untouched.""" + manifest = Manifest( + vstack_version="1.3.6", + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file="skills/vision/SKILL.md", + version="1.0.0", + checksum="abc", + checksum_algorithm="sha256", + ) + ] + }, + ) + + updated, backfilled, skipped = manifest.with_backfilled_checksums( + install_dir=tmp_path / ".github" + ) + entry = updated.entries_for("skills")[0] + assert entry.checksum == "abc" + assert entry.checksum_algorithm == "sha256" + assert backfilled == [] + assert skipped == [] + + def test_with_backfilled_checksums_skips_missing_files(self, tmp_path) -> None: + """Backfill should skip legacy entries when the tracked file is missing.""" + manifest = Manifest( + vstack_version="1.3.6", + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file="skills/vision/SKILL.md", + version="1.0.0", + ) + ] + }, + ) + + updated, backfilled, skipped = manifest.with_backfilled_checksums( + install_dir=tmp_path / ".github" + ) + assert updated.entries_for("skills")[0].checksum is None + assert backfilled == [] + assert skipped == ["skills:vision (missing file)"] + + def test_with_backfilled_checksums_skips_unreadable_files( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path, + ) -> None: + """Backfill should skip entries when file reads raise OSError.""" + install_dir = tmp_path / ".github" + artifact_rel = "skills/vision/SKILL.md" + artifact_path = install_dir / artifact_rel + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text("# Vision\n", encoding="utf-8") + + original_read_text = Path.read_text + + def _raise_for_target(self: Path, *args, **kwargs): + if self == artifact_path: + raise OSError("boom") + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", _raise_for_target) + + manifest = Manifest( + vstack_version="1.3.6", + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file=artifact_rel, + version="1.0.0", + ) + ] + }, + ) + + updated, backfilled, skipped = manifest.with_backfilled_checksums(install_dir=install_dir) + assert updated.entries_for("skills")[0].checksum is None + assert backfilled == [] + assert skipped == ["skills:vision (unreadable file)"] + + def test_with_backfilled_checksums_falls_back_to_sha256_for_unknown_algorithm( + self, + tmp_path, + ) -> None: + """Backfill should fall back to sha256 when manifest hash_algorithm is unsupported.""" + install_dir = tmp_path / ".github" + artifact_rel = "skills/vision/SKILL.md" + artifact_path = install_dir / artifact_rel + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text( + '# Vision\n\n', + encoding="utf-8", + ) + + manifest = Manifest( + vstack_version="1.3.6", + installed_at="2026-01-01T00:00:00Z", + hash_algorithm="sha999", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file=artifact_rel, + version="1.0.0", + ) + ] + }, + ) + + updated, backfilled, skipped = manifest.with_backfilled_checksums(install_dir=install_dir) + entry = updated.entries_for("skills")[0] + assert entry.checksum_algorithm == "sha256" + assert entry.checksum is not None + assert backfilled == ["skills:vision"] + assert skipped == [] + + def test_from_dict_parses_manifest_entries_with_checksum_algorithm(self) -> None: + """Test that from_dict builds ArtifactEntry fields for modern manifest entries.""" + manifest = Manifest.from_dict( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.0.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "verify", + "file": "skills/verify/SKILL.md", + "version": "1.0.0", + "checksum": "a" * 64, + "checksum_algorithm": "sha256", + } + ] + }, + } + ) + entries = manifest.entries_for("skills") + assert len(entries) == 1 + assert entries[0].name == "verify" + assert entries[0].file == "skills/verify/SKILL.md" + assert entries[0].checksum_algorithm == "sha256" + + def test_from_dict_handles_non_dict_artifacts_container(self) -> None: + """Test that non-dict artifacts payload is safely treated as empty.""" + manifest = Manifest.from_dict( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.0.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": ["not", "a", "dict"], + } + ) + assert manifest.artifacts == {} + + def test_from_dict_skips_non_list_entries_for_manifest_type(self) -> None: + """Test that per-type entries must be a list and invalid shapes are ignored.""" + manifest = Manifest.from_dict( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.0.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {"skills": "invalid"}, + } + ) + assert manifest.entries_for("skills") == [] + + def test_from_dict_skips_entries_missing_required_name_or_file(self) -> None: + """Entries missing required keys should be ignored, not fail manifest parsing.""" + manifest = Manifest.from_dict( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.0.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + {"name": "verify"}, + {"file": "skills/vision/SKILL.md"}, + {"name": "vision", "file": "skills/vision/SKILL.md"}, + ] + }, + } + ) + entries = manifest.entries_for("skills") + assert len(entries) == 1 + assert entries[0].name == "vision" + assert entries[0].file == "skills/vision/SKILL.md" + + +class TestManifestFile: + """Test cases for ManifestFile.""" + + def test_read_none_when_missing(self, tmp_path) -> None: + """Test that read none when missing.""" + mf = ManifestFile(parent_dir=tmp_path) + assert mf.read() is None + assert mf.read_error is None + + def test_write_and_read_manifest(self, tmp_path) -> None: + """Test that write and read manifest.""" + mf = ManifestFile(parent_dir=tmp_path) + manifest = Manifest( + vstack_version="0.1.0", + installed_at="2026-01-01T00:00:00Z", + artifacts={"skills": [ArtifactEntry(name="vision", file="skills/vision/SKILL.md")]}, + ) + mf.write(manifest) + loaded = mf.read() + assert loaded is not None + assert loaded.names_for("skills") == ["vision"] + + def test_manifest_contains_all_expected_canonical_names(self, installed_target) -> None: + """Test that manifest contains all expected canonical names.""" + data = json.loads( + (installed_target / ".github" / "vstack.json").read_text(encoding="utf-8") + ) + skill_names = [s["name"] for s in data["artifacts"]["skills"]] + for name in EXPECTED_CANONICAL_NAMES: + assert name in skill_names + + def test_read_none_when_manifest_is_invalid_json(self, tmp_path) -> None: + """Test that read none when manifest is invalid json.""" + mf = ManifestFile(parent_dir=tmp_path) + (tmp_path / "vstack.json").write_text("{broken", encoding="utf-8") + assert mf.read() is None + assert mf.read_error is not None + + def test_read_none_when_manifest_version_is_invalid(self, tmp_path) -> None: + """Test that read rejects non-numeric manifest_version values.""" + mf = ManifestFile(parent_dir=tmp_path) + (tmp_path / "vstack.json").write_text( + json.dumps( + { + "manifest_version": "abc", + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {}, + } + ), + encoding="utf-8", + ) + assert mf.read() is None + assert mf.read_error == "Invalid manifest format in vstack.json" + + def test_read_none_when_manifest_schema_is_legacy(self, tmp_path) -> None: + """Test that read blocks legacy schema manifests with upgrade guidance.""" + mf = ManifestFile(parent_dir=tmp_path) + (tmp_path / "vstack.json").write_text( + json.dumps( + { + "vstack_version": "1.3.6", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {}, + } + ), + encoding="utf-8", + ) + assert mf.read() is None + assert mf.read_error is not None + assert "vstack manifest upgrade --target" in mf.read_error + + def test_write_manifest_does_not_leave_tmp_file(self, tmp_path) -> None: + """Test that manifest write atomically replaces output without stale tmp file.""" + mf = ManifestFile(parent_dir=tmp_path) + manifest = Manifest( + vstack_version="0.1.0", + installed_at="2026-01-01T00:00:00Z", + artifacts={ + "skills": [ + ArtifactEntry( + name="vision", + file="skills/vision/SKILL.md", + checksum="abc123", + checksum_algorithm="sha256", + ) + ] + }, + ) + + mf.write(manifest) + + assert mf.path.exists() + assert not mf.path.with_name(mf.path.name + ".tmp").exists() + + def test_write_manifest_uses_explicit_json_tmp_filename( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path, + ) -> None: + """Atomic write should stage to vstack.json.tmp before os.replace.""" + mf = ManifestFile(parent_dir=tmp_path) + manifest = Manifest( + vstack_version="0.1.0", + installed_at="2026-01-01T00:00:00Z", + artifacts={}, + ) + captured: dict[str, Path] = {} + original_replace = os.replace + + def _capture_replace(src: Path, dst: Path) -> None: + captured["src"] = src + captured["dst"] = dst + original_replace(src, dst) + + monkeypatch.setattr("vstack.manifest.store.os.replace", _capture_replace) + mf.write(manifest) + + assert captured["src"].name == "vstack.json.tmp" + assert captured["dst"] == mf.path + + def test_read_none_when_manifest_entry_missing_required_file_key(self, tmp_path) -> None: + """Test that read none when manifest entry missing required file key.""" + mf = ManifestFile(parent_dir=tmp_path) + (tmp_path / "vstack.json").write_text( + json.dumps( + { + "vstack_version": "0.1.0", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {"skills": [{"name": "vision"}]}, + } + ), + encoding="utf-8", + ) + assert mf.read() is None diff --git a/tests/vstack/prompts/test___init__.py b/tests/vstack/prompts/test___init__.py new file mode 100644 index 0000000..65e3f94 --- /dev/null +++ b/tests/vstack/prompts/test___init__.py @@ -0,0 +1,19 @@ +"""Tests for vstack.prompts package public API.""" + +from __future__ import annotations + +from vstack.prompts import PromptGenerator + + +class TestPromptsInit: + """Test cases for vstack.prompts package exports.""" + + def test_exports_prompt_generator(self) -> None: + """Package exports PromptGenerator at the top level.""" + assert PromptGenerator is not None + + def test_all_contains_prompt_generator(self) -> None: + """__all__ declares PromptGenerator.""" + import vstack.prompts as mod + + assert "PromptGenerator" in mod.__all__ diff --git a/tests/vstack/prompts/test_config.py b/tests/vstack/prompts/test_config.py new file mode 100644 index 0000000..4331175 --- /dev/null +++ b/tests/vstack/prompts/test_config.py @@ -0,0 +1,29 @@ +"""Tests for prompt artifact type configuration.""" + +from __future__ import annotations + +from vstack.prompts.config import PROMPT_TYPE + + +class TestPromptType: + """Test cases for PROMPT_TYPE artifact configuration.""" + + def test_type_name_is_prompt(self) -> None: + """type_name must be 'prompt'.""" + assert PROMPT_TYPE.type_name == "prompt" + + def test_output_subdir_is_prompts(self) -> None: + """Output artifacts go under the prompts subdirectory.""" + assert PROMPT_TYPE.output_subdir == "prompts" + + def test_artifact_is_not_dir(self) -> None: + """Prompts produce single-file artifacts, not directories.""" + assert PROMPT_TYPE.artifact_is_dir is False + + def test_add_frontmatter_is_true(self) -> None: + """Prompt artifacts include frontmatter.""" + assert PROMPT_TYPE.add_frontmatter is True + + def test_auto_gen_footer_is_true(self) -> None: + """Prompt artifacts include the auto-gen footer.""" + assert PROMPT_TYPE.auto_gen_footer is True diff --git a/tests/vstack/prompts/test_constants.py b/tests/vstack/prompts/test_constants.py new file mode 100644 index 0000000..9086d22 --- /dev/null +++ b/tests/vstack/prompts/test_constants.py @@ -0,0 +1,25 @@ +"""Tests for prompt module constants.""" + +from __future__ import annotations + +from vstack.prompts.constants import ( + PROMPT_OUTPUT_SUBDIR, + PROMPT_OUTPUT_SUFFIX, + PROMPT_TEMPLATES_SUBDIR, +) + + +class TestPromptConstants: + """Test cases for prompt constant values.""" + + def test_output_suffix_is_prompt_md(self) -> None: + """Output suffix must end with .prompt.md.""" + assert PROMPT_OUTPUT_SUFFIX == ".prompt.md" + + def test_templates_subdir_is_prompts(self) -> None: + """Templates subdir must be 'prompts'.""" + assert PROMPT_TEMPLATES_SUBDIR == "prompts" + + def test_output_subdir_is_prompts(self) -> None: + """Output subdir must be 'prompts'.""" + assert PROMPT_OUTPUT_SUBDIR == "prompts" diff --git a/tests/vstack/test_integration.py b/tests/vstack/test_integration.py new file mode 100644 index 0000000..56422d5 --- /dev/null +++ b/tests/vstack/test_integration.py @@ -0,0 +1,140 @@ +"""End-to-end integration tests for the vstack CLI.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from tests.conftest import run_vstack + + +class TestRuntimeEntry: + """Test cases for RuntimeEntry.""" + + def test_help_command_exits_zero(self) -> None: + """Test that help command exits zero.""" + result = run_vstack(["--help"]) + assert result.returncode == 0 + assert "Manage vstack skill generation" in result.stdout + + +class TestIntegrationVstack: + """End-to-end subprocess tests for installed vstack CLI behavior.""" + + def test_verify_exits_zero(self) -> None: + """vstack verify --no-output exits zero against the real templates.""" + result = run_vstack(["verify", "--no-output"]) + assert result.returncode == 0, ( + f"vstack verify --no-output failed:\n{result.stdout}\n{result.stderr}" + ) + + def test_verify_output_exits_zero(self, installed_target: Path) -> None: + """vstack verify --no-source exits zero against a freshly installed target.""" + result = run_vstack(["verify", "--no-source", "--target", str(installed_target)]) + assert result.returncode == 0, ( + f"vstack verify --no-source failed:\n{result.stdout}\n{result.stderr}" + ) + + def test_manifest_verify_output_exits_zero(self, installed_target: Path) -> None: + """vstack manifest verify exits zero for a clean installed target.""" + result = run_vstack(["manifest", "verify", "--target", str(installed_target)]) + assert result.returncode == 0, ( + f"vstack manifest verify failed:\n{result.stdout}\n{result.stderr}" + ) + + def test_manifest_status_output_exits_zero(self, installed_target: Path) -> None: + """vstack manifest status exits zero for a clean installed target.""" + result = run_vstack(["manifest", "status", "--target", str(installed_target)]) + assert result.returncode == 0, ( + f"vstack manifest status failed:\n{result.stdout}\n{result.stderr}" + ) + + def test_verify_only_filter_limits_checked_types(self, installed_target: Path) -> None: + """vstack verify --only skill exits zero for a clean installed target.""" + result = run_vstack( + ["verify", "--no-source", "--target", str(installed_target), "--only", "skill"] + ) + assert result.returncode == 0, ( + f"vstack verify --only skill failed:\n{result.stdout}\n{result.stderr}" + ) + + def test_install_and_verify_exits_zero(self, tmp_path: Path) -> None: + """Fresh install followed by verify exits zero.""" + install = run_vstack(["install", "--target", str(tmp_path)]) + assert install.returncode == 0, ( + f"vstack install failed:\n{install.stdout}\n{install.stderr}" + ) + verify = run_vstack(["verify", "--target", str(tmp_path)]) + assert verify.returncode == 0, f"vstack verify failed:\n{verify.stdout}\n{verify.stderr}" + + def test_manifest_upgrade_migrates_legacy_schema(self, tmp_path: Path) -> None: + """vstack manifest upgrade migrates schema-v1 manifest to current schema.""" + install_dir = tmp_path / ".github" + install_dir.mkdir(parents=True) + (install_dir / "vstack.json").write_text( + json.dumps( + { + "vstack_version": "1.3.6", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": {}, + } + ), + encoding="utf-8", + ) + + upgrade = run_vstack(["manifest", "upgrade", "--target", str(tmp_path)]) + assert upgrade.returncode == 0, ( + f"vstack manifest upgrade failed:\n{upgrade.stdout}\n{upgrade.stderr}" + ) + + upgraded = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + assert upgraded["manifest_version"] == 2 + assert upgraded["hash_algorithm"] == "sha256" + + def test_manifest_upgrade_backfill_adds_checksum_for_footer_tagged_legacy_entry( + self, + tmp_path: Path, + ) -> None: + """vstack manifest upgrade --backfill stores checksum for VSTACK-META-tagged entries.""" + install_dir = tmp_path / ".github" + install_dir.mkdir(parents=True) + + artifact_rel = "skills/vision/SKILL.md" + artifact_path = install_dir / artifact_rel + artifact_path.parent.mkdir(parents=True, exist_ok=True) + artifact_path.write_text( + '# Vision\n\n\n', + encoding="utf-8", + ) + + (install_dir / "vstack.json").write_text( + json.dumps( + { + "manifest_version": 2, + "hash_algorithm": "sha256", + "vstack_version": "1.3.6", + "installed_at": "2026-01-01T00:00:00Z", + "artifacts": { + "skills": [ + { + "name": "vision", + "file": artifact_rel, + "version": "1.0.0", + } + ] + }, + } + ), + encoding="utf-8", + ) + + upgrade = run_vstack(["manifest", "upgrade", "--backfill", "--target", str(tmp_path)]) + assert upgrade.returncode == 0, ( + f"vstack manifest upgrade --backfill failed:\n{upgrade.stdout}\n{upgrade.stderr}" + ) + + upgraded = json.loads((install_dir / "vstack.json").read_text(encoding="utf-8")) + entry = upgraded["artifacts"]["skills"][0] + assert entry["checksum_algorithm"] == "sha256" + assert isinstance(entry["checksum"], str) + assert len(entry["checksum"]) == 64 diff --git a/tests/vstack/test_main.py b/tests/vstack/test_main.py index 3787bb8..6f291e8 100644 --- a/tests/vstack/test_main.py +++ b/tests/vstack/test_main.py @@ -1,4 +1,4 @@ -"""Tests for CLI entrypoint dispatch behavior.""" +"""Tests for CLI entrypoint behavior.""" from __future__ import annotations @@ -9,75 +9,28 @@ main_module = importlib.import_module("vstack.main") -class _Args: - """Minimal parsed-argument object for main() dispatch tests.""" +class _Interface: + """Simple command-line interface test double.""" - def __init__( - self, - command: str, - only: list[str] | None = None, - force: bool = False, - update: bool = False, - dry_run: bool = False, - use_global: bool = False, - source: bool = True, - output: bool = True, - ) -> None: - """Initialize instance state.""" - self.command = command - self.only = only - self.force = force - self.update = update - self.dry_run = dry_run - self.use_global = use_global - self.source = source - self.output = output - - -class _CLI: - """Test double for the CLI command handler.""" - - def __init__(self) -> None: - """Initialize instance state.""" - self.called: tuple[str, tuple, dict] | None = None + def __init__(self, exit_code: int = 0, error: Exception | None = None) -> None: + self.exit_code = exit_code + self.error = error - def validate(self, **kwargs): - """Validate.""" - self.called = ("validate", tuple(), kwargs) - return 7 - - def verify(self, *args, **kwargs): - """Verify.""" - self.called = ("verify", args, kwargs) - return 8 - - def install(self, *args, **kwargs): - """Install.""" - self.called = ("install", args, kwargs) - return 9 - - def uninstall(self, *args, **kwargs): - """Uninstall.""" - self.called = ("uninstall", args, kwargs) - return 10 + def run(self) -> int: + """Return the configured exit code or raise the configured error.""" + if self.error is not None: + raise self.error + return self.exit_code class TestMain: """Test cases for the CLI entrypoint.""" - def test_resolve_only_for_scope_returns_requested_only_for_non_global(self) -> None: - """Test that non-global commands keep the explicit type filter.""" - args = _Args("install", only=["skill"], use_global=False) - - assert main_module._resolve_only_for_scope(args) == ["skill"] + def test_main_exits_with_interface_code(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that main exits with the code returned by the interface.""" + interface = _Interface(exit_code=7) - def test_main_dispatch_validate(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Test that main dispatch validate.""" - cli = _CLI() - parser = type("P", (), {"parse_args": lambda self: _Args("validate")})() - - monkeypatch.setattr(main_module.CommandLineParser, "build", lambda self: parser) - monkeypatch.setattr(main_module, "CommandLineInterface", lambda templates_root: cli) + monkeypatch.setattr(main_module, "CommandLineInterface", lambda **kwargs: interface) monkeypatch.setattr( main_module.sys, "exit", lambda code: (_ for _ in ()).throw(SystemExit(code)) ) @@ -86,97 +39,14 @@ def test_main_dispatch_validate(self, monkeypatch: pytest.MonkeyPatch) -> None: main_module.main() assert exc.value.code == 7 - assert cli.called == ("validate", tuple(), {"only": None}) - - def test_main_dispatch_install(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Test that main dispatch install.""" - cli = _CLI() - seen: dict[str, object] = {} - - def _build_cli(templates_root): - """Internal helper to build cli.""" - seen["templates_root"] = templates_root - return cli - - args = _Args("install", only=["skill"], force=True, update=False, dry_run=True) - parser = type("P", (), {"parse_args": lambda self: args})() - - monkeypatch.setattr(main_module.CommandLineParser, "build", lambda self: parser) - monkeypatch.setattr( - main_module.CommandLineParser, "resolve_targets", lambda self, _args: tmp_path - ) - monkeypatch.setattr(main_module, "CommandLineInterface", _build_cli) - monkeypatch.setattr( - main_module.sys, "exit", lambda code: (_ for _ in ()).throw(SystemExit(code)) - ) - - with pytest.raises(SystemExit) as exc: - main_module.main() - - assert exc.value.code == 9 - assert seen["templates_root"] == main_module.TEMPLATES_ROOT - assert cli.called == ( - "install", - (tmp_path,), - { - "only": ["skill"], - "force": True, - "update": False, - "dry_run": True, - }, - ) - - def test_main_global_install_defaults_to_supported_types( - self, monkeypatch: pytest.MonkeyPatch, tmp_path - ) -> None: - """Test that global install defaults to non-skill profile artifacts.""" - cli = _CLI() - args = _Args("install", only=None, use_global=True) - parser = type("P", (), {"parse_args": lambda self: args})() - - monkeypatch.setattr(main_module.CommandLineParser, "build", lambda self: parser) - monkeypatch.setattr( - main_module.CommandLineParser, "resolve_targets", lambda self, _args: tmp_path - ) - monkeypatch.setattr(main_module, "CommandLineInterface", lambda templates_root: cli) - monkeypatch.setattr( - main_module.sys, "exit", lambda code: (_ for _ in ()).throw(SystemExit(code)) - ) - - with pytest.raises(SystemExit) as exc: - main_module.main() - - assert exc.value.code == 9 - assert cli.called == ( - "install", - (tmp_path,), - { - "only": ["agent", "instruction", "prompt", "skill"], - "force": False, - "update": False, - "dry_run": False, - }, - ) - def test_resolve_only_for_scope_returns_requested_only_for_global_allowed_types(self) -> None: - """Test that allowed global type filters are passed through unchanged.""" - args = _Args("install", only=["agent", "prompt"], use_global=True) - - assert main_module._resolve_only_for_scope(args) == ["agent", "prompt"] - - def test_main_global_install_rejects_unknown_type( - self, monkeypatch: pytest.MonkeyPatch, tmp_path + def test_main_prints_value_error_and_exits_non_zero( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """Test that global install rejects unsupported artifact types.""" - cli = _CLI() - args = _Args("install", only=["unknown"], use_global=True) - parser = type("P", (), {"parse_args": lambda self: args})() + """Test that main prints ValueError messages and exits with code 1.""" + interface = _Interface(error=ValueError("boom")) - monkeypatch.setattr(main_module.CommandLineParser, "build", lambda self: parser) - monkeypatch.setattr( - main_module.CommandLineParser, "resolve_targets", lambda self, _args: tmp_path - ) - monkeypatch.setattr(main_module, "CommandLineInterface", lambda templates_root: cli) + monkeypatch.setattr(main_module, "CommandLineInterface", lambda **kwargs: interface) monkeypatch.setattr( main_module.sys, "exit", lambda code: (_ for _ in ()).throw(SystemExit(code)) ) @@ -185,57 +55,4 @@ def test_main_global_install_rejects_unknown_type( main_module.main() assert exc.value.code == 1 - assert cli.called is None - - def test_main_dispatch_verify_includes_only_filter( - self, monkeypatch: pytest.MonkeyPatch, tmp_path - ) -> None: - """Test that verify receives source/output flags and scope type filter.""" - cli = _CLI() - args = _Args("verify", use_global=True, source=False, output=True) - parser = type("P", (), {"parse_args": lambda self: args})() - - monkeypatch.setattr(main_module.CommandLineParser, "build", lambda self: parser) - monkeypatch.setattr( - main_module.CommandLineParser, "resolve_targets", lambda self, _args: tmp_path - ) - monkeypatch.setattr(main_module, "CommandLineInterface", lambda templates_root: cli) - monkeypatch.setattr( - main_module.sys, "exit", lambda code: (_ for _ in ()).throw(SystemExit(code)) - ) - - with pytest.raises(SystemExit) as exc: - main_module.main() - - assert exc.value.code == 8 - assert cli.called == ( - "verify", - tuple(), - { - "install_dir": tmp_path, - "source": False, - "output": True, - "only": ["agent", "instruction", "prompt", "skill"], - }, - ) - - def test_main_dispatch_uninstall(self, monkeypatch: pytest.MonkeyPatch, tmp_path) -> None: - """Test that uninstall resolves the target directory and dispatches correctly.""" - cli = _CLI() - args = _Args("uninstall") - parser = type("P", (), {"parse_args": lambda self: args})() - - monkeypatch.setattr(main_module.CommandLineParser, "build", lambda self: parser) - monkeypatch.setattr( - main_module.CommandLineParser, "resolve_targets", lambda self, _args: tmp_path - ) - monkeypatch.setattr(main_module, "CommandLineInterface", lambda templates_root: cli) - monkeypatch.setattr( - main_module.sys, "exit", lambda code: (_ for _ in ()).throw(SystemExit(code)) - ) - - with pytest.raises(SystemExit) as exc: - main_module.main() - - assert exc.value.code == 10 - assert cli.called == ("uninstall", (tmp_path,), {}) + assert "ERROR: boom" in capsys.readouterr().err