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

11 KiB
Raw Permalink Blame History

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

  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 …):

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.