Files
2026-05-27 23:29:53 +01:00

206 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OpenSpec Change: installer-port
- **Change ID**: installer-port
- **Packages**: `@suaveplan/installer` (new), `@suaveplan/install-agent-skills` (new)
- **Status**: PROPOSED
- **Date**: 2026-04-28
## Summary
Introduce `@suaveplan/installer`, a thin plugin host for project boilerplating and asset installation, and `@suaveplan/install-agent-skills`, the inaugural plugin/bundle that installs the full Suaveplan agent-skills set into a target project from npm — no repo clone required.
The installer is a generic, modular pipeline (`detect → prompts → plan → apply → postInstall`) built on top of three primitives that already exist on genesis: `@suaveplan/cli` (UX), `@suaveplan/templates` (rendering), `@suaveplan/validation` (Zod prompt schemas). Plugins are vertical (`install-agent-skills`, future `install-web-server`, `install-spa`, …) and each ships as its own npm package consumable via `npx @suaveplan/installer add <plugin>`.
## Why
Skills today require users to clone the repo to copy files out — a known pain point. We also want a unified path for boilerplating web servers, SPAs, desktop apps, mobile apps, etc., without each generator reinventing argv parsing, prompts, dry-run, conflict resolution, and rollback.
A plugin host solves both. The lifecycle (detect/prompts/plan/apply/postInstall) is identical for skills and for boilerplate; the rendering substrate (`@suaveplan/templates`) is identical; the UX substrate (`@suaveplan/cli`) is identical. Without a host, every generator duplicates ~600 lines of orchestration.
We ship the host together with one real plugin (`install-agent-skills`) so the contract is validated by a non-trivial consumer on day one — not a paper interface.
## Why a single bundle for skills
Earlier sketch proposed one npm package per skill. Rejected: ~90 skills × separate packages multiplies publish pipelines, changelogs, and resolution churn. Users almost always want "the suaveplan skill set" or a curated subset, not a cherry-pick. `install-agent-skills` ships **all skills as a single versioned bundle** with internal selectivity via `--only` / `--exclude` flags. If a future skill grows heavy assets (large datasets, models) that bloat the bundle for users who don't need them, that one peels out as `@suaveplan/skill-<heavy>` — bundle remains the default.
## Why `templates` is not a plugin
Plugins are vertical (skills, web-server, spa) — what the user chose to install. `@suaveplan/templates` is a rendering primitive every plugin uses internally. Modelling it as a plugin would invert the dependency: plugins would have to opt into rendering. Wrong shape. Templates stays an internal primitive of `installer` and of every plugin that needs interpolation.
## Architecture
### Layers
```
@suaveplan/cli ← argv, prompts, spinner, output (existing)
@suaveplan/templates ← interpolation (existing)
@suaveplan/validation ← Zod prompt schemas (existing)
@suaveplan/error ← structured errors (existing)
│
▼
@suaveplan/installer ← plugin host + lifecycle (this change)
│
├── @suaveplan/install-agent-skills (this change)
├── @suaveplan/install-web-server (future)
├── @suaveplan/install-spa (future)
└── @suaveplan/install-<vertical> (future)
```
### Plugin contract
The host's only public surface is the plugin contract:
```ts
export interface InstallerContext {
readonly cwd: string;
readonly logger: LoggerLike;
readonly fs: InstallerFs; // virtual filesystem (dry-run safe)
readonly templates: TemplateEngine; // shared template engine instance
readonly cli: InstallerCli; // prompts, spinner, output
readonly signal: AbortSignal;
}
export interface InstallerPlugin<TAnswers, THandle = void> {
readonly id: string;
readonly version: string;
detect?(ctx: InstallerContext): Promise<DetectResult>;
prompts?(ctx: InstallerContext): PromptSchema<TAnswers>;
plan(answers: TAnswers, ctx: InstallerContext): Promise<FileOp[]>;
apply(ops: FileOp[], ctx: InstallerContext): Promise<THandle>;
postInstall?(handle: THandle, ctx: InstallerContext): Promise<void>;
}
export type FileOp =
| { kind: "write"; path: string; contents: Uint8Array; mode?: number }
| { kind: "patch-json"; path: string; merge: JsonMerge }
| { kind: "patch-text"; path: string; edits: TextEdit[] }
| { kind: "mkdir"; path: string }
| { kind: "delete"; path: string }
| { kind: "rename"; from: string; to: string };
```
Generics (`TAnswers`, `THandle`) flow end-to-end so `prompts` returns typed answers and `apply` returns a typed handle the plugin can hand to `postInstall`. No `any`.
### Lifecycle
1. **detect** — plugin reports what is already present (e.g. existing `agent-skills/` directory, conflicting files).
2. **prompts** — Zod-validated answer schema; `cli` renders prompts in TTY, accepts `--answers <file>` non-interactively.
3. **plan** — pure function: produces `FileOp[]` against the virtual fs. No real writes yet.
4. **apply** — host materialises ops to disk inside a transaction (staged-tmp + atomic rename per file; rollback on failure). `--dry-run` prints the plan and exits before this step.
5. **postInstall** — plugin runs follow-up commands (e.g. `bun install`, registers entries, prints next-steps).
### Conflict resolution
Each `FileOp` declares an explicit conflict policy: `error | overwrite | skip-if-exists | merge | prompt`. The default is `error`. `--force` collapses to `overwrite`. `--no-interactive` rejects `prompt` ops.
### Manifest
Every plugin exports a `manifest.json` next to its entry. Schema (`PluginManifestSchema`) is owned by `@suaveplan/installer` and validated at load time. The manifest declares: `id`, `version`, `description`, `engines`, `peerInstallers`, `assets` (paths inside the plugin package), and the entry module.
## `@suaveplan/install-agent-skills`
The first vertical plugin. Ships every Suaveplan skill (`SKILL.md` + co-located assets) bundled inside the package under `assets/skills/<name>/`. The plugin's `plan` step:
1. Reads its bundled manifest (`assets/skills/manifest.json`).
2. Filters by `--only <names>` / `--exclude <names>` if supplied; defaults to **all** skills.
3. Detects target project's existing skill directory (`.suaveplan/skills/` by default; configurable).
4. Emits `FileOp[]`: `mkdir` for each skill folder, `write` for each file, `patch-json` for the project's skill index file (creates if missing).
5. `postInstall` prints the count installed and next-step guidance.
Bundle format (inside the published package):
```
assets/skills/
manifest.json # { skills: [{ id, version, dir, files }] }
crypto/
SKILL.md
examples/...
events/
SKILL.md
…
```
Versioning aligns with the monorepo: one bump per release, one changelog entry covering all skill changes.
## What changes
### 1. Two new packages
- `packages/platform/installer/` → `@suaveplan/installer@0.0.0`
- `packages/platform/install-agent-skills/` → `@suaveplan/install-agent-skills@0.0.0`
(`platform` plugin matches `@suaveplan/cli` and `@suaveplan/readline`, the other CLI-tier packages.)
### 2. Workspace deps
`@suaveplan/installer`:
- `@suaveplan/cli`, `@suaveplan/templates`, `@suaveplan/validation`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/logger`, `@opentelemetry/api`, `zod`. All `workspace:^` for internal, `catalog:` for external.
`@suaveplan/install-agent-skills`:
- `@suaveplan/installer`, `@suaveplan/error`, `@suaveplan/types`, `zod`. All `workspace:^`.
### 3. Subpath exports
`@suaveplan/installer`: `.`, `./types`, `./errors`, `./host`, `./fs`, `./manifest`, `./testing` (test doubles for `InstallerContext`).
`@suaveplan/install-agent-skills`: `.`, `./manifest`. The `./manifest` subpath exposes the bundled skills index for tooling that wants to enumerate without invoking the installer.
### 4. CLI bin
`@suaveplan/installer` ships a `suaveplan-install` bin (also exposed as `npx @suaveplan/installer …`):
```bash
npx @suaveplan/installer add agent-skills # default: all skills
npx @suaveplan/installer add agent-skills --only crypto,events
npx @suaveplan/installer add agent-skills --exclude legacy
npx @suaveplan/installer add agent-skills --dry-run
npx @suaveplan/installer add agent-skills --answers ./answers.json
npx @suaveplan/installer list # discovered plugins
```
Plugins are discovered by package-name convention (`@suaveplan/install-*` and `@<scope>/suaveplan-install-*` for third parties). Installer resolves them through the consumer's `node_modules` — same model as `eslint-plugin-*`.
### 5. Error codes
`INSTALLER` namespace via `defineCodes("INSTALLER", [...])`:
- `INSTALLER.PLUGIN_NOT_FOUND`
- `INSTALLER.PLUGIN_LOAD_FAILED`
- `INSTALLER.PLUGIN_INVALID_MANIFEST`
- `INSTALLER.PROMPT_VALIDATION_FAILED`
- `INSTALLER.PLAN_FAILED`
- `INSTALLER.APPLY_CONFLICT`
- `INSTALLER.APPLY_FAILED`
- `INSTALLER.ROLLBACK_FAILED`
- `INSTALLER.ABORTED`
`INSTALL_SKILLS` namespace for the skills plugin:
- `INSTALL_SKILLS.MANIFEST_INVALID`
- `INSTALL_SKILLS.UNKNOWN_SKILL`
- `INSTALL_SKILLS.TARGET_DIR_INVALID`
### 6. Observability
Every host stage (`detect`, `prompts`, `plan`, `apply`, `postInstall`) is wrapped in an OTel span (`installer.<stage>`) with attributes `plugin.id`, `plugin.version`, `dry_run`. `apply` records counters for files written / patched / skipped / conflicts.
### 7. Testing
- `@suaveplan/installer/testing` exports an in-memory `InstallerFs`, a no-TTY `InstallerCli` that scripts answers, and a `runPlugin(plugin, { answers })` harness for integration-style plugin tests with zero real disk I/O.
- `@suaveplan/install-agent-skills` uses the harness to verify the bundled manifest plans correctly under `--only` / `--exclude` filters.
## Out of scope (future changes)
- Future verticals (`install-web-server`, `install-spa`, `install-desktop-app`, `install-mobile-app`) ship as separate change-ids once the host is stable.
- Plugin upgrade flow (`upgrade <plugin>` to bring an existing install up to current bundle version) is a follow-up — initial scope is install + reinstall (idempotent re-run with conflict policy).
- A `create-suaveplan` thin wrapper bin that bundles installer + a curated default plugin set is a future packaging-only change; not introduced here.
## Acceptance criteria
- `bunx turbo lint typecheck test --filter=@suaveplan/installer` and `--filter=@suaveplan/install-agent-skills` green.
- `bunx turbo test:coverage` ≥ 100 % istanbul on touched modules in both packages.
- Sibling `.md` for every implementation `.ts` (`openspec/AGENTS.md` §4).
- Spec deltas folded into `openspec/specs/installer/spec.md` and `openspec/specs/install-agent-skills/spec.md` on archive.
- `INSTALLER-01`…`INSTALLER-09` and `INSTALL_SKILLS-01`…`INSTALL_SKILLS-05` have `**Verified by:**` lines pointing to `*.spec-conformance.test.ts`.
- End-to-end test: `runPlugin(agentSkillsPlugin, { answers: { skills: "all" } })` against an in-memory fs writes the full skill set and reports zero conflicts on a fresh target.