diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index de060ff..3614c00 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -133,6 +133,30 @@ For Python modules in this repository, treat code as the source of truth and kee - Avoid placeholder docstrings such as "Initialize instance state" or "Build parser". Describe intent and contract instead. - When a behavior, public API, or exception contract changes, update the corresponding docstring in the same change. +## Development Commands + +| Task | Command | +|------|---------| +| Fast tests (current interpreter only) | `make test-local` | +| Full quality gate (format-check + lint + typecheck + test) | `make check` | +| Lint | `make lint` | +| Type-check | `make typecheck` | +| Format Python + Markdown | `make format` | +| Regenerate `.github/` artifacts from templates | `python3 -m vstack install` | + +Test coverage is enforced at **100%** (`--cov-fail-under=100`). Every behavioral change must keep all checks green. See [CONTRIBUTING.md](../CONTRIBUTING.md) for full dev setup. + +## CLI Architecture + +CLI commands live in `src/vstack/cli/`, one file per command (e.g. `install.py`, `verify.py`, `validate.py`). + +- Each command subclasses `BaseCommand` and implements `run(*, context: CommandContext) -> int`. +- `CommandLineInterface` (`interface.py`) is the parsing/dispatch facade — no business logic. +- `CommandService` (`service.py`) constructs and dispatches commands. +- `COMMAND_CATALOG` (`catalog.py`) is the registration point for all commands. + +When adding a new CLI command: create the command class in a new file, then register it in `COMMAND_CATALOG`. + ## Work Style - Produce small, reviewable changes. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b1dda99..7e62612 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,6 +8,11 @@ on: types: - published +concurrency: + # Preserve release order on PyPI: publish runs execute one-by-one. + group: publish-pypi + cancel-in-progress: false + permissions: contents: read id-token: write @@ -24,6 +29,8 @@ jobs: if: github.event.release.prerelease == false runs-on: ubuntu-latest environment: pypi + env: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} steps: - name: Validate release tag format @@ -116,5 +123,22 @@ jobs: exit 1 fi - - name: Publish to PyPI + - name: Publish to PyPI (trusted publishing) + id: publish_trusted + continue-on-error: true + uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Publish to PyPI (API token fallback) + if: steps.publish_trusted.outcome == 'failure' && env.PYPI_API_TOKEN != '' uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ env.PYPI_API_TOKEN }} + + - name: Fail when trusted publishing fails and no fallback token exists + if: steps.publish_trusted.outcome == 'failure' && env.PYPI_API_TOKEN == '' + shell: bash + run: | + echo "ERROR: trusted publishing failed and secret PYPI_API_TOKEN is not configured." + echo "Either fix PyPI trusted publisher mapping or add PYPI_API_TOKEN as a fallback." + exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index cf633a8..107755c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,20 @@ ## [2.0.4](https://github.com/eschaar/vstack/compare/2.0.3...2.0.4) (2026-04-28) - ### Fixes -* **ci:** allow release manifest ahead of latest tag ([6e049b9](https://github.com/eschaar/vstack/commit/6e049b94f2dce202cc1805a1299e8c9df9c52d8b)) - +- **ci:** allow release manifest ahead of latest tag ([6e049b9](https://github.com/eschaar/vstack/commit/6e049b94f2dce202cc1805a1299e8c9df9c52d8b)) ### Documentation -* **release:** restore release-please style for 2.0.x ([98d8ee1](https://github.com/eschaar/vstack/commit/98d8ee1c3e6680ab16fa07b7781bbec8db71ae3c)) - +- **release:** restore release-please style for 2.0.x ([98d8ee1](https://github.com/eschaar/vstack/commit/98d8ee1c3e6680ab16fa07b7781bbec8db71ae3c)) ### Maintenance -* **ci:** bump automerge action dependencies ([db701d5](https://github.com/eschaar/vstack/commit/db701d5cf6ba3a370c2eb8333c49ed63176872bb)) -* **ci:** tune dependabot automerge policy ([ca6155c](https://github.com/eschaar/vstack/commit/ca6155c2f117c3e9a8ebf43d7f7a926e6dce264f)) -* **ci:** use app client id for release token generation ([4198797](https://github.com/eschaar/vstack/commit/41987971d521dbf8f257863b155de6c3503a6d65)) -* **deps:** tune dependabot cadence and PR limits ([c3fe474](https://github.com/eschaar/vstack/commit/c3fe47498b84ecfc44c3ccd39b9c81215f00e47c)) +- **ci:** bump automerge action dependencies ([db701d5](https://github.com/eschaar/vstack/commit/db701d5cf6ba3a370c2eb8333c49ed63176872bb)) +- **ci:** tune dependabot automerge policy ([ca6155c](https://github.com/eschaar/vstack/commit/ca6155c2f117c3e9a8ebf43d7f7a926e6dce264f)) +- **ci:** use app client id for release token generation ([4198797](https://github.com/eschaar/vstack/commit/41987971d521dbf8f257863b155de6c3503a6d65)) +- **deps:** tune dependabot cadence and PR limits ([c3fe474](https://github.com/eschaar/vstack/commit/c3fe47498b84ecfc44c3ccd39b9c81215f00e47c)) ## [2.0.3](https://github.com/eschaar/vstack/compare/2.0.2...2.0.3) (2026-04-28) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e41e03..a2811a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,18 +16,41 @@ pip install -U pip pip install -e ".[dev]" ``` +Run `make bootstrap` once after cloning to install all development tools. + ## Development Workflow 1. Create a branch from `main`. 1. Make focused, reviewable changes. -1. Run checks locally: +1. Run the fast test suite locally before pushing: + +```bash +make test-local +``` + +1. Alternatively, run the full quality gate (format-check + lint + typecheck + test): ```bash -make test +make check ``` 1. Open a pull request with clear context. +### Available make targets + +| Command | Purpose | +| ----------------- | ------------------------------------------------------- | +| `make test-local` | Run tests against the current interpreter with coverage | +| `make check` | Full quality gate: format-check, lint, typecheck, test | +| `make lint` | Lint with ruff | +| `make typecheck` | Type-check with mypy | +| `make format` | Auto-format Python and Markdown | + +### Coverage requirement + +Test coverage is enforced at **100%** (`--cov-fail-under=100`). Every behavioral +change must be accompanied by tests that keep all checks green. + ## Commit Message Guidance This repository uses a Conventional Commits baseline for release automation. diff --git a/README-pypi.md b/README-pypi.md index 9f665ae..74dcf4e 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -81,7 +81,15 @@ 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. +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. + +If you already have agents, skills, or other files in `.github/`, run a dry-run first to see what would be preserved before committing: + +```bash +vstack install --dry-run --target /path/to/your/project +``` + +The summary lists preserved files as `type/name` selectors (e.g. `agent/engineer`). Resolve each conflict with `--force-name type/name` to overwrite, `--adopt-name type/name` to take ownership without overwriting, or `--force` to overwrite everything. ## Fast troubleshooting diff --git a/README.md b/README.md index ef82764..18591d7 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ If you see the version and no errors, your install is working. Expected output (example): ```text -vstack 1.3.0 +vstack X.Y.Z Validation passed: no unresolved template tokens ``` @@ -414,7 +414,29 @@ ______________________________________________________________________ | `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. +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. + +If you already have agents, skills, or other files in `.github/`, run a dry-run first to see what would be preserved before committing: + +```bash +# Preview what install would do — no files are written +vstack install --dry-run --target /path/to/your/project +``` + +The summary shows every preserved file as a `type/name` selector (e.g. `agent/engineer`, `skill/verify`). You can then resolve each conflict selectively: + +```bash +# Overwrite a specific preserved artifact +vstack install --target . --force-name agent/engineer + +# Take ownership of an existing file without overwriting it +vstack install --target . --adopt-name agent/engineer + +# Overwrite everything +vstack install --target . --force +``` + +When multiple artifact types share the same name (e.g. an `agent` and a `skill` both named `engineer`), use the `type/name` form to target one precisely. `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. @@ -475,6 +497,8 @@ flowchart TD Action: Confirm templates exist under `src/vstack/_templates/agents/`, then run `Developer: Reload Window` in VS Code. - Issue: Agent does not execute actions Action: Make sure Copilot is in Agent Mode, not Ask or Edit mode. +- Issue: Files were preserved during install and vstack agents are not visible + Action: Run `vstack install --dry-run --target .` to see which files were preserved. Then use `--force-name type/name` to overwrite a specific file (e.g. `--force-name agent/engineer`), `--adopt-name type/name` to take ownership without overwriting, or `--force` to overwrite everything. ### CI parity and badges diff --git a/SECURITY.md b/SECURITY.md index 4a29fe1..ba9dff9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Supported | | ------- | --------- | -| 1.x | Yes | +| 2.x | Yes | +| 1.x | No | ## Reporting a Vulnerability diff --git a/docs/architecture/adr/001-vscode-native-variant.md b/docs/architecture/adr/001-vscode-native-variant.md index 7c532c5..358337c 100644 --- a/docs/architecture/adr/001-vscode-native-variant.md +++ b/docs/architecture/adr/001-vscode-native-variant.md @@ -1,6 +1,6 @@ # ADR-001: VS Code–Native Variant -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-27\ **status:** accepted diff --git a/docs/architecture/adr/002-artifact-naming-and-compatibility-policy.md b/docs/architecture/adr/002-artifact-naming-and-compatibility-policy.md index c78a622..2f71477 100644 --- a/docs/architecture/adr/002-artifact-naming-and-compatibility-policy.md +++ b/docs/architecture/adr/002-artifact-naming-and-compatibility-policy.md @@ -1,6 +1,6 @@ # ADR-002: Artifact Naming and Compatibility Policy -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-27\ **status:** accepted diff --git a/docs/architecture/adr/003-backend-first-verify.md b/docs/architecture/adr/003-backend-first-verify.md index 34b1ce7..ef10534 100644 --- a/docs/architecture/adr/003-backend-first-verify.md +++ b/docs/architecture/adr/003-backend-first-verify.md @@ -1,6 +1,6 @@ # ADR-003: Backend-First Verification (`verify` skill) -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-27\ **status:** accepted diff --git a/docs/architecture/adr/004-option-a-to-b-pipeline.md b/docs/architecture/adr/004-option-a-to-b-pipeline.md index eb95cd5..aa78cb3 100644 --- a/docs/architecture/adr/004-option-a-to-b-pipeline.md +++ b/docs/architecture/adr/004-option-a-to-b-pipeline.md @@ -1,6 +1,6 @@ # ADR-004: Single-Call Execution with Optional Future Orchestration -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-27\ **status:** accepted diff --git a/docs/architecture/adr/005-vscode-prompt-format.md b/docs/architecture/adr/005-vscode-prompt-format.md index 1b16280..5e526a9 100644 --- a/docs/architecture/adr/005-vscode-prompt-format.md +++ b/docs/architecture/adr/005-vscode-prompt-format.md @@ -1,6 +1,6 @@ # ADR-005: VS Code Prompt File Format -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-27\ **status:** superseded by ADR-009 diff --git a/docs/architecture/adr/006-no-runtime-dependency.md b/docs/architecture/adr/006-no-runtime-dependency.md index 40905d5..e8112e6 100644 --- a/docs/architecture/adr/006-no-runtime-dependency.md +++ b/docs/architecture/adr/006-no-runtime-dependency.md @@ -1,6 +1,6 @@ # ADR-006: No Runtime Dependency on External Binaries in Skill Content -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-27\ **status:** accepted diff --git a/docs/architecture/adr/007-python-runtime.md b/docs/architecture/adr/007-python-runtime.md index 62e42ee..8234162 100644 --- a/docs/architecture/adr/007-python-runtime.md +++ b/docs/architecture/adr/007-python-runtime.md @@ -1,6 +1,6 @@ # ADR-007: Python 3 as Canonical Runtime -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-27\ **status:** accepted (consolidates superseded ADR-007/ADR-008 from DECISIONS.md) @@ -15,18 +15,17 @@ The toolchain was initially prototyped in TypeScript/Bun. In practice: ## decision -**Python 3 is the sole canonical runtime** for vstack's toolchain: +**Python 3 is the sole canonical runtime** for vstack's toolchain. +The implementation lives in the `src/vstack/` package: -- `scripts/gen_skill_docs.py` — template generator -- `scripts/validate_skills.py` — skill validation -- `scripts/skill_check.py` — health dashboard -- `test/test_skills.py` — test suite (pytest) +- `src/vstack/cli/` — CLI command handlers +- `src/vstack/artifacts/` — generic template generator +- `src/vstack/manifest/` — manifest read/write and upgrade +- `src/vstack/frontmatter/` — YAML frontmatter parsing and validation +- `tests/vstack/` — pytest test suite (100% coverage enforced) -`package.json` is kept as a convenience wrapper (`npm run build` etc.) but no -`node_modules` are installed. - -Zero external Python dependencies. All scripts use stdlib only: -`re`, `json`, `pathlib`, `subprocess`, `sys`, `textwrap`. +Zero external Python dependencies at runtime. All runtime code uses stdlib only: +`re`, `json`, `pathlib`, `subprocess`, `sys`, `textwrap`, `hashlib`, `dataclasses`. ## alternatives considered @@ -36,11 +35,12 @@ Zero external Python dependencies. All scripts use stdlib only: ## rationale -Python 3.11+ is universally available on macOS, Linux, and CI systems. -The entire toolchain is under 1000 lines of Python 3 with zero external dependencies. +Python 3.11–3.14 is the supported range, matching CI and type-checking compatibility +requirements. The package is distributed via PyPI with no runtime dependencies beyond +the Python standard library. ## impact on future orchestrated pipeline -The future orchestrated pipeline runner (`scripts/runner.py`) will be implemented in Python. +The future orchestrated pipeline runner will be implemented in Python. `asyncio` + `subprocess` provide sufficient primitives for sequential and parallel stage execution. diff --git a/docs/architecture/adr/008-agents-over-prompts.md b/docs/architecture/adr/008-agents-over-prompts.md index 1e5a9e4..63eff3d 100644 --- a/docs/architecture/adr/008-agents-over-prompts.md +++ b/docs/architecture/adr/008-agents-over-prompts.md @@ -1,6 +1,6 @@ # ADR-008: VS Code Agent Files Over Prompt Files -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-28\ **status:** accepted @@ -18,29 +18,28 @@ provide two significant advantages: 1. **User-invocable flag**: `user-invocable: true` makes agents appear in the Copilot agent picker. -The `prompts/` directory had 19 files and was the only VS Code–exposed surface. +The `prompts/` directory was the only VS Code–exposed surface. ## decision Migrate from `prompts/*.prompt.md` → `.github/agents/*.agent.md`. - Delete `prompts/` directory entirely. -- Generator (`gen_skill_docs.py`) builds `.github/agents/*.agent.md` when called with `--agents`. +- Generator produces `.github/agents/*.agent.md` from templates under `src/vstack/_templates/agents/`. - Agent frontmatter: ```yaml --- - name: "" + name: "" description: "" - tools: [read_file, insert_edit_into_file, run_in_terminal, file_search] + tools: [read, search, edit, execute, web, vscode, todo, agent] user-invocable: true --- ``` -- `TOOL_MAP` in the generator maps template tool names to VS Code tool IDs. ## alternatives considered 1. Keep `.prompt.md` and add `.agent.md` in parallel — rejected as it duplicates - 19 files and creates synchronisation overhead. + files and creates synchronisation overhead. 1. Keep `.prompt.md` only — rejected because it lacks subagent capability. ## rationale @@ -50,6 +49,5 @@ calling each other. Migrating now keeps the groundwork low-cost. ## impact on future orchestrated pipeline -The `orchestrate` role (and eventually the pipeline runner) can invoke -`@architect`, `@tester`, etc. as named agents. This is the VS Code primitive -for multi-agent orchestration. +The pipeline can invoke `@architect`, `@tester`, etc. as named agents. +This is the VS Code primitive for multi-agent orchestration. diff --git a/docs/architecture/adr/009-role-model.md b/docs/architecture/adr/009-role-model.md index 8bd8e85..f77da43 100644 --- a/docs/architecture/adr/009-role-model.md +++ b/docs/architecture/adr/009-role-model.md @@ -1,6 +1,6 @@ # ADR-009: 6-Role Agent Model -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-28\ **status:** accepted diff --git a/docs/architecture/adr/010-artifact-flow.md b/docs/architecture/adr/010-artifact-flow.md index 5224131..3d0546d 100644 --- a/docs/architecture/adr/010-artifact-flow.md +++ b/docs/architecture/adr/010-artifact-flow.md @@ -1,6 +1,6 @@ # ADR-010: Artifact Hand-off Pipeline -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-28\ **status:** accepted @@ -37,13 +37,14 @@ and stops — it does not proceed with partial context. ### user gate moments -There are 3 explicit user gate moments: +There are 4 explicit user gate moments: | Gate | Trigger | Required | | ---------------------------- | ---------------------------------------------------- | ------------------------------------- | | **1. Requirements approval** | After `product` writes requirements.md | User approves before architect starts | | **2. Design approval** | After `architect` + `designer` write their artifacts | User approves before engineer starts | | **3. Pre-prod sign-off** | After `tester` reports are ready | User approves before release starts | +| **4. Merge approval** | Before `release` creates PR | User approves final merge | ## alternatives considered @@ -64,6 +65,6 @@ Files on disk: ## impact on future orchestrated pipeline -The pipeline runner (`scripts/runner.py`) reads artifact paths from a config and +The pipeline runner reads artifact paths from a config and checks for existence before invoking the next role. This makes the runner a thin orchestrator, not a data manager. diff --git a/docs/architecture/adr/011-skill-restructure.md b/docs/architecture/adr/011-skill-restructure.md index 4d30a9d..41717d5 100644 --- a/docs/architecture/adr/011-skill-restructure.md +++ b/docs/architecture/adr/011-skill-restructure.md @@ -1,9 +1,9 @@ # ADR-011: Skill Restructure (rename, new, retire) -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-28\ -**status:** accepted — implementation planned for v0.5.0 +**status:** accepted — implemented ## context @@ -38,12 +38,12 @@ or carry ambiguous scope (e.g., `design-consult`, `experience`, `discovery`). ### skills to retire (become documentation, not skills) -| skill | disposition | -| ------------- | ---------------------------------------------------- | -| `guardrails` | Inline note in tester/release workflow documentation | -| `freeze` | Inline note in engineer/architect agent file | -| `unfreeze` | Inline note in engineer/architect agent file | -| `orchestrate` | Responsibility moves to product + release roles | +| skill | disposition | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `guardrails` | Originally planned for retirement; **retained** as an active per-project installable safety skill (see `agent skill wiring` in roadmap) | +| `freeze` | Inline note in engineer/architect agent file | +| `unfreeze` | Inline note in engineer/architect agent file | +| `orchestrate` | Responsibility moves to product + release roles | ## alternatives considered 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 86ba3f8..66ab3a9 100644 --- a/docs/architecture/adr/012-flat-templates-install-time-generation.md +++ b/docs/architecture/adr/012-flat-templates-install-time-generation.md @@ -1,6 +1,6 @@ # ADR-012: Template Directories and Install-Time Generation -> Maintained by: **agents** role +> Maintained by: **architect** role **date:** 2026-03-28\ **status:** accepted — implemented diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 0327883..d092539 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -78,7 +78,6 @@ Key resolvers defined inline in the generator: | `{{BASE_BRANCH}}` | Shell snippet to detect git base branch | | `{{RUN_TESTS}}` | Detect test framework and run tests | | `{{OBSERVABILITY_CHECKLIST}}` | Observability coverage checklist | -| `{{API_CONTRACT_CHECKLIST}}` | API contract review checklist | ### 4. role model @@ -150,7 +149,7 @@ dispatch flow. | `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` | +| `BaseCommand` | ABC contract: all handlers implement `run(*, context: CommandContext) → int` | | Per-command modules | `install`, `verify`, `status`, `uninstall`, `validate`, `manifest` — one class each | | `helpers.py` | Shared install/uninstall utilities (name normalization, manifest preservation) | diff --git a/docs/design/design.md b/docs/design/design.md index 0bbf7ba..e03516f 100644 --- a/docs/design/design.md +++ b/docs/design/design.md @@ -308,11 +308,10 @@ class BaseCommand(ABC): def run( self, *, - args: argparse.Namespace, - install_dir: Path | None, - only: list[str] | None, + context: CommandContext, ) -> int: ... # Returns 0 on success, non-zero on errors. + # CommandContext carries args, install_dir, and only. def build_command_registry(service: CommandService) -> dict[str, BaseCommand]: ... @@ -325,37 +324,37 @@ ______________________________________________________________________ ### 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 | +| Field | Type | Required | Constraints | +| -------------------------- | ------ | -------- | --------------------------------------------------------------------------- | +| `name` | string | **yes** | Lowercase kebab-case; max 64 chars; must match directory name | +| `version` | string | **yes** | Semver; used for manifest tracking only — not emitted to generated SKILL.md | +| `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 | +| 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 or list | no | Force a specific model or list of models; 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`) @@ -390,7 +389,6 @@ from lowercase-kebab to `UPPER_SNAKE` to form the token: | `{{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` | **Resolution rules:** @@ -646,9 +644,9 @@ flowchart TD 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)] + E --> G[command.run(*, context=CommandContext(args, install_dir, only))] F --> G - G --> H[BaseCommand.execute classmethod] + G --> H[BaseCommand subclass executes] H --> I[service.generators / service.label / service.manifest_for / service.artifact_control_state] ``` @@ -661,7 +659,7 @@ ______________________________________________________________________ | `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` | +| `base.py` | `BaseCommand` | ABC: all handlers implement `run(*, context: CommandContext) → 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 | diff --git a/docs/design/skills.md b/docs/design/skills.md index 61f2e10..0ad2777 100644 --- a/docs/design/skills.md +++ b/docs/design/skills.md @@ -55,7 +55,7 @@ ______________________________________________________________________ | `consult` | DX triage and focused review. Routes to one path (API DX, CLI/tool DX, or developer workflow DX) and routes non-DX requests to specialized skills. | designer | focused DX report or routing recommendation | | `concise` | Runtime response-style controller. Switches response density (`normal`, `compact`, `ultra`) and reports active mode via `status` without reinstall. | all roles | session style state + status output | | `code-review` | Pre-landing code review. Finds bugs that pass CI but break in production — race conditions, security issues, performance landmines. | engineer | inline findings | -| `security` | OWASP Top 10 + STRIDE security audit. Finds auth bypasses, injection flaws, exposed secrets, broken access control. | engineer | security audit report | +| `security` | OWASP Top 10 + STRIDE security audit. Finds auth bypasses, injection flaws, exposed secrets, broken access control. | tester | security audit report | | `explore` | Repository and system discovery. Maps the architecture, identifies tech debt, produces an onboarding summary. | engineer | codebase map | | `analyse` | Cross-cutting technical analysis. Investigates impact, tradeoffs, root causes, or feasibility without implementing changes. | engineer, architect | analysis report | | `debug` | Systematic root-cause debugging. Follows scientific method: observe → hypothesise → test → conclude → fix → prevent. | engineer | root cause report + fix | diff --git a/docs/product/roadmap.md b/docs/product/roadmap.md index 99a619c..59670bb 100644 --- a/docs/product/roadmap.md +++ b/docs/product/roadmap.md @@ -1,26 +1,33 @@ # vstack — roadmap > Maintained by: **product** role\ -> Last updated: 2026-04-16 +> Last updated: 2026-05-02 ______________________________________________________________________ ## feature status table -| Feature | Status | Notes | -| ---------------------------------------- | ----------- | -------------------------------------------------------------- | -| foundation | shipped | Core template-driven install model is in place | -| 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 | 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 | -| heavy agent runtime framework | not planned | Keeps runtime lightweight and transparent | -| cloud control plane dependency | not planned | Keeps operation local/offline-capable | -| VS Code extension packaging | not planned | Not required for current install model | -| browser automation as default dependency | not planned | Backend/microservice-first remains default | +| Feature | Status | Notes | +| ---------------------------------------- | ----------- | --------------------------------------------------------------------------------- | +| foundation | shipped | Core template-driven install model is in place | +| 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 | 27-skill set with canonical naming | +| agent skill wiring | shipped | Role-to-skill mapping and handoffs are present | +| CLI modularisation (v2.0.0) | shipped | 12 focused CLI modules; BaseCommand + CommandContext contract | +| manifest package (v2.0.0) | shipped | Dedicated `manifest/` package; atomic writes (ADR-016) | +| mypy type checking (v2.0.0) | shipped | Full mypy coverage enforced in CI; 100% test coverage gate | +| manifest schema versioning (v2.0.0) | shipped | `manifest_version: 2`; upgrade path via `manifest upgrade` (ADR-014) | +| checksum backfill (v2.0.0) | shipped | `manifest upgrade --backfill` adds SHA-256 for VSTACK-META-tagged files (ADR-017) | +| conservative install (v2.0.0) | shipped | Untracked files never overwritten; checksum-gated update (ADR-015) | +| dry-run install | shipped | `vstack install --dry-run` previews actions; type/name selectors in summary | +| optional orchestrated role pipeline | candidate | Optional future model, only if coordination bottlenecks appear | +| multi-IDE support (IntelliJ first) | candidate | Not planned before v1 stabilization | +| heavy agent runtime framework | not planned | Keeps runtime lightweight and transparent | +| cloud control plane dependency | not planned | Keeps operation local/offline-capable | +| VS Code extension packaging | not planned | Not required for current install model | +| browser automation as default dependency | not planned | Backend/microservice-first remains default | ______________________________________________________________________ @@ -77,6 +84,31 @@ Role templates now reference the intended canonical skills: ______________________________________________________________________ +### CLI modularisation [shipped — v2.0.0] + +- 12 focused CLI modules under `src/vstack/cli/`; one `BaseCommand` subclass per command +- `BaseCommand` + `CommandContext` contract replaces ad hoc argument passing +- `CommandService` refactored to shared coordinator (generators, manifest, state) +- `COMMAND_CATALOG` as the single registration point for all commands + +### manifest package [shipped — v2.0.0] + +- Dedicated `src/vstack/manifest/` package extracted from CLI internals +- Atomic manifest writes via temp-file + `os.replace` (ADR-016) +- `ManifestFile` handles read/write/existence checks; `read_error` for diagnostics +- `manifest_version: 2` schema with `hash_algorithm` and per-entry `checksum_algorithm` +- Upgrade path: `vstack manifest upgrade [--backfill]` +- Checksum backfill for VSTACK-META-tagged files (ADR-017) + +### conservative install [shipped — v2.0.0] + +- Untracked files are never overwritten by default (ADR-015) +- Checksum-gated `--update` mode: only rewrites clean tracked files +- `--force-name` / `--adopt-name` accept `type/name` selectors (e.g. `agent/engineer`) +- `--dry-run` previews all actions with a summary and preserved-selectors list + +______________________________________________________________________ + ### optional orchestrated role pipeline [candidate] Possible future workflow with explicit orchestration (only if real coordination bottlenecks appear): diff --git a/poetry.lock b/poetry.lock index 3a9fc94..6640268 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,14 +2,14 @@ [[package]] name = "cachetools" -version = "7.0.6" +version = "7.1.0" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "cachetools-7.0.6-py3-none-any.whl", hash = "sha256:4e94956cfdd3086f12042cdd29318f5ced3893014f7d0d059bf3ead3f85b7f8b"}, - {file = "cachetools-7.0.6.tar.gz", hash = "sha256:e5d524d36d65703a87243a26ff08ad84f73352adbeafb1cde81e207b456aaf24"}, + {file = "cachetools-7.1.0-py3-none-any.whl", hash = "sha256:05afd1d309309e7c8971db462b4cf516d93fa8c9aea1b906e26e61b4399d0d82"}, + {file = "cachetools-7.1.0.tar.gz", hash = "sha256:ea5406e92956f9006b121f8032177c6b02cc5f9a488d1e53b2e4d9cb5aae15c6"}, ] [[package]] @@ -696,14 +696,14 @@ files = [ [[package]] name = "tox" -version = "4.53.0" +version = "4.53.1" description = "tox is a generic virtualenv management and test command line tool" optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "tox-4.53.0-py3-none-any.whl", hash = "sha256:cc4e716d18c4889aa179d785175c438fa60c35deef20ce689ec288d8fb656096"}, - {file = "tox-4.53.0.tar.gz", hash = "sha256:62c780e42f87d34ee60f2ea20342156253794fdcbd6885fd797d98ee05009f22"}, + {file = "tox-4.53.1-py3-none-any.whl", hash = "sha256:4a9948607e976a337c22d64a1b4fafd486125e82f00ab6ce32fa6cacc23f48b1"}, + {file = "tox-4.53.1.tar.gz", hash = "sha256:7be9805ed4a34242510c7acc9a7e3a01a35942e08f31f8bd69067c3a37130afc"}, ] [package.dependencies] diff --git a/src/vstack/artifacts/protocol.py b/src/vstack/artifacts/protocol.py index 6d73ad7..1185540 100644 --- a/src/vstack/artifacts/protocol.py +++ b/src/vstack/artifacts/protocol.py @@ -15,9 +15,12 @@ class ArtifactGenerator(Protocol): - """Structural protocol satisfied by :class:`~vstack.skills.generator.SkillGenerator` - and :class:`~vstack.agents.generator.AgentGenerator` (and future generators for - prompts, instructions, etc.). + """Structural protocol satisfied by all artifact generators. + + Implemented by :class:`~vstack.skills.generator.SkillGenerator`, + :class:`~vstack.agents.generator.AgentGenerator`, + :class:`~vstack.instructions.generator.InstructionGenerator`, and + :class:`~vstack.prompts.generator.PromptGenerator`. """ def generate(self, output_dir: Path) -> ArtifactResult: diff --git a/src/vstack/cli/install.py b/src/vstack/cli/install.py index 88e9aa4..433a62e 100644 --- a/src/vstack/cli/install.py +++ b/src/vstack/cli/install.py @@ -128,22 +128,21 @@ def _print_install_action( """Print install/update/skip output line for one artifact.""" if action == "adopt": print( - f" {colors.CYAN}≈{colors.RESET} {rel}" + f" {prefix}{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}" + f" {prefix}{colors.YELLOW}↷{colors.RESET} {rel}" + f" {colors.DIM}preserved — {reason}{colors.RESET}" ) return if action == "skip": print( - f" {colors.YELLOW}↷{colors.RESET} {rel}" + f" {prefix}{colors.YELLOW}↷{colors.RESET} {rel}" f" {colors.DIM}skipped — already v{existing_version}{colors.RESET}" ) return @@ -245,16 +244,24 @@ def _install_single_artifact( existing_entries, new_entries, checksum_algorithm: str, - ) -> None: - """Apply install decision flow for one rendered artifact.""" + ) -> str: + """Apply install decision flow for one rendered artifact and return the action taken.""" 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 + force_name = ( + artifact.name in targeted_force_names + or key in targeted_force_names + or rel in targeted_force_names + ) + adopt_name = ( + artifact.name in targeted_adopt_names + or key in targeted_adopt_names + or rel in targeted_adopt_names + ) adopted_values: tuple[str | None, str] | None = None if artifact.unresolved: @@ -306,7 +313,7 @@ def _install_single_artifact( checksum=content_hash(artifact.content), checksum_algorithm=checksum_algorithm, ) - return + return action if action == "adopt" and adopted_values is not None: adopted_version, adopted_checksum = adopted_values @@ -318,7 +325,7 @@ def _install_single_artifact( checksum=adopted_checksum, checksum_algorithm=checksum_algorithm, ) - return + return action if existing_entry is not None: preserve_existing_entry( @@ -326,6 +333,7 @@ def _install_single_artifact( manifest_key=gen.config.manifest_key, existing_entry=existing_entry, ) + return action @staticmethod def _write_manifest( @@ -347,6 +355,57 @@ def _write_manifest( manifest_file.write(manifest) print(f" {colors.DIM}wrote {service.label(manifest_file.path)}{colors.RESET}") + @staticmethod + def _print_summary( + *, + colors, + action_counts: dict[str, int], + preserved_selectors: list[str], + dry_run: bool, + ) -> None: + """Print a readable summary and conflict guidance after an install run.""" + installed = action_counts.get("install", 0) + updated = action_counts.get("update", 0) + preserved = action_counts.get("preserve", 0) + skipped = action_counts.get("skip", 0) + adopted = action_counts.get("adopt", 0) + + install_label = "installed" + summary_title = "Summary (dry-run)" if dry_run else "Summary" + total = installed + updated + preserved + skipped + adopted + + print() + print(f" {colors.BOLD}{summary_title}{colors.RESET}") + print(f" total processed : {colors.BOLD}{total}{colors.RESET}") + print(f" {install_label:<15}: {colors.BOLD}{installed}{colors.RESET}") + print(f" updated : {colors.BOLD}{updated}{colors.RESET}") + print(f" preserved : {colors.BOLD}{preserved}{colors.RESET}") + print(f" skipped : {colors.BOLD}{skipped}{colors.RESET}") + print(f" adopted : {colors.BOLD}{adopted}{colors.RESET}") + + if preserved: + noun = "file" if preserved == 1 else "files" + selectors_suffix = " Preserved selectors:" if preserved_selectors else "" + print() + print( + f" {colors.YELLOW}⚠{colors.RESET} " + f"{preserved} {noun} preserved — existing files were not overwritten." + f"{selectors_suffix}" + ) + if preserved_selectors: + for selector in sorted(preserved_selectors): + print(f" - {selector}") + print(" Next steps:") + print(f" {colors.DIM}--force{colors.RESET} overwrite all") + print( + f" {colors.DIM}--force-name {colors.RESET} " + "overwrite one artifact" + ) + print( + f" {colors.DIM}--adopt-name {colors.RESET} " + "take ownership without overwriting" + ) + @staticmethod def execute( service: CommandService, @@ -377,13 +436,15 @@ def execute( prefix = f"{colors.DIM}[dry-run]{colors.RESET} " if dry_run else "" all_ok = True + action_counts: dict[str, int] = {} + preserved_selectors: set[str] = set() for gen in gens: out_dir = install_dir / gen.config.output_subdir artifacts = gen.render_all() for artifact in artifacts: - InstallCommand._install_single_artifact( + artifact_action = InstallCommand._install_single_artifact( service=service, gen=gen, artifact=artifact, @@ -399,6 +460,9 @@ def execute( new_entries=new_entries, checksum_algorithm=checksum_algorithm, ) + action_counts[artifact_action] = action_counts.get(artifact_action, 0) + 1 + if artifact_action == "preserve": + preserved_selectors.add(f"{gen.config.type_name}/{artifact.name}") # Verify source for unresolvable issues. verify_result = gen.verify_input() @@ -407,6 +471,13 @@ def execute( print(f" ERROR [{gen.config.type_name}]: {msg.message}", file=sys.stderr) all_ok = False + InstallCommand._print_summary( + colors=colors, + action_counts=action_counts, + preserved_selectors=sorted(preserved_selectors), + dry_run=dry_run, + ) + if not dry_run: InstallCommand._write_manifest( service=service, diff --git a/src/vstack/cli/parser.py b/src/vstack/cli/parser.py index 474996d..3e2d411 100644 --- a/src/vstack/cli/parser.py +++ b/src/vstack/cli/parser.py @@ -177,17 +177,24 @@ def _add_install_command(self, sub: SubparserFactory) -> None: "--force-name", dest="force_names", action="append", - metavar="", - help="Force install one named artifact without overwriting everything", + metavar="", + help=( + "Force install one named artifact without overwriting everything. " + "Accepts a plain name (e.g. engineer) or a type/name selector " + "(e.g. agent/engineer) to disambiguate when multiple artifact types " + "share the same name. Repeat to target multiple artifacts." + ), ) parser.add_argument( "--adopt-name", action="append", default=None, - metavar="NAME", + metavar="", help=( "Adopt only the named existing unmanaged artifact into the manifest without overwriting it. " - "Repeat this option to target multiple names." + "Accepts a plain name (e.g. engineer) or a type/name selector " + "(e.g. agent/engineer) to disambiguate when multiple artifact types " + "share the same name. Repeat to target multiple artifacts." ), ) parser.add_argument( diff --git a/src/vstack/cli/service.py b/src/vstack/cli/service.py index 4d44f3d..2f8f212 100644 --- a/src/vstack/cli/service.py +++ b/src/vstack/cli/service.py @@ -1,13 +1,16 @@ -"""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 +"""Shared coordinator for vstack CLI command handlers. + +:class:`CommandService` wires artifact-type generators and manifest access +into the services that individual :class:`~vstack.cli.base.BaseCommand` +subclasses use when executing. Commands live in their own modules +(``install``, ``verify``, ``uninstall``, ``validate``, ``manifest``, +``status``) and receive a :class:`~vstack.cli.base.CommandContext` at +dispatch time. + +The service is type-generic: it iterates over +:data:`~vstack.artifacts.type_config.KNOWN_TYPES` rather than hard-coding +skill-specific or agent-specific logic. Per-type sub-directories are +derived from :attr:`~vstack.artifacts.type_config.ArtifactTypeConfig.output_subdir`. """ diff --git a/tests/vstack/cli/test_install.py b/tests/vstack/cli/test_install.py index da29bbd..9fab531 100644 --- a/tests/vstack/cli/test_install.py +++ b/tests/vstack/cli/test_install.py @@ -304,3 +304,209 @@ def _fake_execute(*args, **kwargs): assert InstallCommand(service=cast(CommandService, object())).run(context=context) == 1 assert captured["kwargs"]["adopt_names"] == ["b"] assert captured["kwargs"]["force"] is True + + # ------------------------------------------------------------------ + # _print_summary + # ------------------------------------------------------------------ + + def test_print_summary_no_conflicts_shows_installed_count( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Summary without preserves shows heading, fixed counters, and no guidance.""" + colors = SimpleNamespace(YELLOW="", RESET="", BOLD="", DIM="", GREEN="", CYAN="") + InstallCommand._print_summary( + colors=colors, + action_counts={"install": 7, "update": 1}, + preserved_selectors=[], + dry_run=False, + ) + out = capsys.readouterr().out + assert "Summary" in out + assert "total processed : 8" in out + assert "installed" in out and ": 7" in out + assert "updated" in out and ": 1" in out + assert "preserved" in out and ": 0" in out + assert "skipped" in out and ": 0" in out + assert "adopted" in out and ": 0" in out + assert "--force" not in out + + def test_print_summary_with_conflicts_shows_guidance( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Summary with preserved files shows count, warning, and flag guidance.""" + colors = SimpleNamespace(YELLOW="", RESET="", BOLD="", DIM="", GREEN="", CYAN="") + InstallCommand._print_summary( + colors=colors, + action_counts={"install": 3, "preserve": 2}, + preserved_selectors=["agent/engineer", "skill/verify"], + dry_run=False, + ) + out = capsys.readouterr().out + assert "Summary" in out + assert "⚠" in out + assert "preserved" in out and ": 2" in out + assert "2 files preserved" in out + assert "Preserved selectors:" in out + assert "Next steps:" in out + assert "--force" in out + assert "--force-name " in out + assert "--adopt-name " in out + assert "- agent/engineer" in out + assert "- skill/verify" in out + + def test_print_summary_single_preserve_uses_singular_noun( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """A single preserved file uses the singular 'file' noun.""" + colors = SimpleNamespace(YELLOW="", RESET="", BOLD="", DIM="", GREEN="", CYAN="") + InstallCommand._print_summary( + colors=colors, + action_counts={"install": 1, "preserve": 1}, + preserved_selectors=["agent/engineer"], + dry_run=False, + ) + out = capsys.readouterr().out + assert "1 file preserved" in out + + def test_print_summary_dry_run_marks_header_and_keeps_installed_label( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Dry-run mode marks the summary header and keeps action labels consistent.""" + colors = SimpleNamespace(YELLOW="", RESET="", BOLD="", DIM="", GREEN="", CYAN="") + InstallCommand._print_summary( + colors=colors, + action_counts={"install": 10}, + preserved_selectors=[], + dry_run=True, + ) + out = capsys.readouterr().out + assert "Summary (dry-run)" in out + assert "installed" in out and ": 10" in out + assert "would install" not in out + + def test_print_summary_shows_optional_counts_when_nonzero( + self, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Skipped and adopted counters are rendered with their non-zero values.""" + colors = SimpleNamespace(YELLOW="", RESET="", BOLD="", DIM="", GREEN="", CYAN="") + InstallCommand._print_summary( + colors=colors, + action_counts={"install": 2, "skip": 3, "adopt": 1}, + preserved_selectors=[], + dry_run=False, + ) + out = capsys.readouterr().out + assert "skipped" in out and ": 3" in out + assert "adopted" in out and ": 1" in out + + # ------------------------------------------------------------------ + # _install_single_artifact — return value + # ------------------------------------------------------------------ + + def test_install_single_artifact_returns_preserve_for_untracked_file( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Returns 'preserve' when an existing untracked file blocks install.""" + + class _Gen: + config = SimpleNamespace( + type_name="agent", + manifest_key="agents", + output_subdir="agents", + ) + + @staticmethod + def output_path(name: str) -> Path: + return Path(f"{name}.agent.md") + + @staticmethod + def install_relative_path(name: str) -> str: + return f"agents/{name}.agent.md" + + out_dir = tmp_path / "agents" + out_dir.mkdir() + (out_dir / "engineer.agent.md").write_text("existing", encoding="utf-8") + + colors = SimpleNamespace(CYAN="", RESET="", DIM="", YELLOW="", GREEN="", BOLD="") + result = InstallCommand._install_single_artifact( + service=cast(CommandService, SimpleNamespace(label=lambda p: str(p))), + gen=_Gen(), + artifact=SimpleNamespace( + name="engineer", + frontmatter={"version": "1.0.0"}, + unresolved=[], + content="new content", + ), + out_dir=out_dir, + colors=colors, + prefix="", + force=False, + update=False, + dry_run=True, + targeted_force_names=set(), + targeted_adopt_names=set(), + existing_entries={}, + new_entries={}, + checksum_algorithm="sha256", + ) + assert result == "preserve" + capsys.readouterr() + + def test_install_single_artifact_returns_adopt_when_adopting( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Returns 'adopt' when taking ownership of an existing untracked file.""" + + class _Gen: + config = SimpleNamespace( + type_name="agent", + manifest_key="agents", + output_subdir="agents", + ) + + @staticmethod + def output_path(name: str) -> Path: + return Path(f"{name}.agent.md") + + @staticmethod + def install_relative_path(name: str) -> str: + return f"agents/{name}.agent.md" + + out_dir = tmp_path / "agents" + out_dir.mkdir() + (out_dir / "engineer.agent.md").write_text("existing", encoding="utf-8") + + new_entries: dict[str, list[Any]] = {} + colors = SimpleNamespace(CYAN="", RESET="", DIM="", YELLOW="", GREEN="", BOLD="") + result = InstallCommand._install_single_artifact( + service=cast(CommandService, SimpleNamespace(label=lambda p: str(p))), + gen=_Gen(), + artifact=SimpleNamespace( + name="engineer", + frontmatter={"version": "1.0.0"}, + unresolved=[], + content="new content", + ), + out_dir=out_dir, + colors=colors, + prefix="", + force=False, + update=False, + dry_run=True, + targeted_force_names=set(), + targeted_adopt_names={"engineer"}, + existing_entries={}, + new_entries=new_entries, + checksum_algorithm="sha256", + ) + assert result == "adopt" + capsys.readouterr() diff --git a/tests/vstack/test_integration.py b/tests/vstack/test_integration.py index 56422d5..2e557bc 100644 --- a/tests/vstack/test_integration.py +++ b/tests/vstack/test_integration.py @@ -91,6 +91,22 @@ def test_manifest_upgrade_migrates_legacy_schema(self, tmp_path: Path) -> None: assert upgraded["manifest_version"] == 2 assert upgraded["hash_algorithm"] == "sha256" + def test_dry_run_preserved_selectors_include_type_prefix(self, tmp_path: Path) -> None: + """Preserved selectors include type/name prefix so artifacts can be targeted precisely.""" + github = tmp_path / ".github" + (github / "agents").mkdir(parents=True) + (github / "agents" / "engineer.agent.md").write_text("# custom engineer", encoding="utf-8") + (github / "agents" / "architect.agent.md").write_text( + "# custom architect", encoding="utf-8" + ) + + result = run_vstack(["install", "--dry-run", "--target", str(tmp_path)]) + assert result.returncode == 0, ( + f"vstack install --dry-run failed:\n{result.stdout}\n{result.stderr}" + ) + assert "agent/engineer" in result.stdout + assert "agent/architect" in result.stdout + def test_manifest_upgrade_backfill_adds_checksum_for_footer_tagged_legacy_entry( self, tmp_path: Path,