11 KiB
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:
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
- detect — plugin reports what is already present (e.g. existing
agent-skills/directory, conflicting files). - prompts — Zod-validated answer schema;
clirenders prompts in TTY, accepts--answers <file>non-interactively. - plan — pure function: produces
FileOp[]against the virtual fs. No real writes yet. - apply — host materialises ops to disk inside a transaction (staged-tmp + atomic rename per file; rollback on failure).
--dry-runprints the plan and exits before this step. - 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:
- Reads its bundled manifest (
assets/skills/manifest.json). - Filters by
--only <names>/--exclude <names>if supplied; defaults to all skills. - Detects target project's existing skill directory (
.suaveplan/skills/by default; configurable). - Emits
FileOp[]:mkdirfor each skill folder,writefor each file,patch-jsonfor the project's skill index file (creates if missing). postInstallprints 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.0packages/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. Allworkspace:^for internal,catalog:for external.
@suaveplan/install-agent-skills:
@suaveplan/installer,@suaveplan/error,@suaveplan/types,zod. Allworkspace:^.
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 …):
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_FOUNDINSTALLER.PLUGIN_LOAD_FAILEDINSTALLER.PLUGIN_INVALID_MANIFESTINSTALLER.PROMPT_VALIDATION_FAILEDINSTALLER.PLAN_FAILEDINSTALLER.APPLY_CONFLICTINSTALLER.APPLY_FAILEDINSTALLER.ROLLBACK_FAILEDINSTALLER.ABORTED
INSTALL_SKILLS namespace for the skills plugin:
INSTALL_SKILLS.MANIFEST_INVALIDINSTALL_SKILLS.UNKNOWN_SKILLINSTALL_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/testingexports an in-memoryInstallerFs, a no-TTYInstallerClithat scripts answers, and arunPlugin(plugin, { answers })harness for integration-style plugin tests with zero real disk I/O.@suaveplan/install-agent-skillsuses the harness to verify the bundled manifest plans correctly under--only/--excludefilters.
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-suaveplanthin 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/installerand--filter=@suaveplan/install-agent-skillsgreen.bunx turbo test:coverage≥ 100 % istanbul on touched modules in both packages.- Sibling
.mdfor every implementation.ts(openspec/AGENTS.md§4). - Spec deltas folded into
openspec/specs/installer/spec.mdandopenspec/specs/install-agent-skills/spec.mdon archive. INSTALLER-01…INSTALLER-09andINSTALL_SKILLS-01…INSTALL_SKILLS-05have**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.