# 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 `. ## 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-` — 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- (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 { readonly id: string; readonly version: string; detect?(ctx: InstallerContext): Promise; prompts?(ctx: InstallerContext): PromptSchema; plan(answers: TAnswers, ctx: InstallerContext): Promise; apply(ops: FileOp[], ctx: InstallerContext): Promise; postInstall?(handle: THandle, ctx: InstallerContext): Promise; } 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 ` 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//`. The plugin's `plan` step: 1. Reads its bundled manifest (`assets/skills/manifest.json`). 2. Filters by `--only ` / `--exclude ` 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 `@/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.`) 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 ` 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.