From 548e02f0b22d0a2c1a95e3c2bba7931e12159701 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:13:50 -0500 Subject: [PATCH 01/20] docs(openspec): propose plugin marketplace with OpenLore as first plugin Add an OpenSpec change proposal for a plugin/marketplace system that keeps the core lean and hosts specialized "engines" as out-of-process delegated plugins, with OpenLore as the inaugural marketplace listing and reference plugin (code-first spec generation from existing codebases). Proposes six new capabilities (plugin-manifest, plugin-resolution, plugin-runtime, plugin-contribution, cli-plugin, plugin-marketplace) and modifications to global-config, cli-init, cli-update, and telemetry. Grounded in today's architecture: mirrors the schema-resolver precedence, reuses the passthrough config and managed-file conventions, and adds no required runtime dependency or Node floor change. Addresses #453, #436, #1081, #650, #1074, #1231, #667, #780, #724 and discussion #634. Validated with `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-plugin-marketplace/design.md | 111 ++++++++++++++++++ .../add-plugin-marketplace/proposal.md | 86 ++++++++++++++ .../specs/cli-init/spec.md | 30 +++++ .../specs/cli-plugin/spec.md | 76 ++++++++++++ .../specs/cli-update/spec.md | 28 +++++ .../specs/global-config/spec.md | 29 +++++ .../specs/plugin-contribution/spec.md | 49 ++++++++ .../specs/plugin-manifest/spec.md | 72 ++++++++++++ .../specs/plugin-marketplace/spec.md | 45 +++++++ .../specs/plugin-resolution/spec.md | 71 +++++++++++ .../specs/plugin-runtime/spec.md | 66 +++++++++++ .../specs/telemetry/spec.md | 13 ++ .../changes/add-plugin-marketplace/tasks.md | 110 +++++++++++++++++ 13 files changed, 786 insertions(+) create mode 100644 openspec/changes/add-plugin-marketplace/design.md create mode 100644 openspec/changes/add-plugin-marketplace/proposal.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/cli-init/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/global-config/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/plugin-manifest/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/plugin-resolution/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/telemetry/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/tasks.md diff --git a/openspec/changes/add-plugin-marketplace/design.md b/openspec/changes/add-plugin-marketplace/design.md new file mode 100644 index 000000000..86c36b202 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/design.md @@ -0,0 +1,111 @@ +## Context + +OpenSpec is a lightweight orchestrator: a small CLI plus a schema-driven artifact-graph engine. As adoption grows, the community wants to extend it (#453, #436, #1081, #650, #1074, #1231, #667, #780) and wants capabilities that are too heavy or too specialized to belong in core — most prominently generating specs from existing code (#724, discussion #634 / OpenLore). + +Today OpenSpec has exactly one dynamic extension mechanism: schema resolution in `src/core/artifact-graph/resolver.ts`, which resolves a named schema through `project (openspec/schemas) → user (XDG data dir) → package (built-in)`. Everything else is static: + +- Commands are imported and registered explicitly in `src/cli/index.ts`. +- Skill/command templates are hardcoded arrays in `src/core/shared/skill-generation.ts` (`getSkillTemplates`, `getCommandTemplates`) keyed by a fixed `ALL_WORKFLOWS` list in `src/core/profiles.ts`. +- Global config is a zod schema (`src/core/config-schema.ts`) that already uses `.passthrough()` for forward compatibility and already carries `profile`, `delivery`, `workflows`, and `featureFlags`. + +The reference plugin, OpenLore, is a separate npm package (`openlore`) with its own Commander CLI (~40 subcommands) and heavy optional dependencies (tree-sitter grammars, lancedb). It already declares `@fission-ai/openspec` as an **optional** peer dependency and already writes an `openlore` metadata block into `openspec/config.yaml` (which survives because OpenSpec's config is passthrough). It requires Node ≥22.5; OpenSpec requires Node ≥20.19. + +## Goals / Non-Goals + +**Goals** + +- Let OpenSpec discover, surface, and invoke optional external "engines" without absorbing their code or dependencies. +- Keep the core pure: the plugin layer adds no required runtime dependency and does not raise OpenSpec's Node floor. +- Reuse existing patterns (schema-style tiered resolution, passthrough config, managed-file tracking by explicit name) rather than inventing new ones. +- Make discovery and lifecycle observable and reversible: list, enable, disable, clean up. +- Ship OpenLore as the first registry listing and prove the contract with an integration fixture. + +**Non-Goals** + +- In-process command injection (loading plugin modules into OpenSpec's process). Deferred; see Decision 1. +- Runtime lifecycle hooks (e.g. archive hooks, #682). Out of scope; future capability. +- A hosted registry service or auth. The first registry is a curated, versioned JSON document shipped with the package. +- Schema inheritance / partial schema overrides (#1074). Related but separate; this change does not modify schema resolution. +- Sandboxing plugin execution. Plugins are npm packages the user installs; trust model is documented, not enforced (see Decision 8). + +## Decisions + +### 1. Delegation (subprocess) over in-process module loading + +A plugin command (`openspec lore generate …`) is executed by spawning the plugin's own executable as a child process, with `stdio: 'inherit'` and the child's exit code propagated. OpenSpec never `import()`s plugin code for command execution. + +**Why:** +- **Dependency isolation.** OpenLore pulls tree-sitter grammars and lancedb; in-process loading would drag these into OpenSpec's resolution graph and startup cost. +- **Node version skew.** OpenLore requires Node ≥22.5, OpenSpec ≥20.19. A subprocess runs under whatever Node resolves the plugin bin; in-process loading would force OpenSpec's floor up. +- **Blast radius.** A crashing or hanging plugin cannot take down the OpenSpec process; it is an exit code, not an unhandled exception. +- **Matches the stated vision.** Discussion #634 explicitly frames OpenSpec as a "lightweight orchestrator" calling specialized engines "only when needed." + +**Trade-off:** delegation cannot share in-memory state or rich return values with the plugin. That is acceptable: integration is via the filesystem (the plugin writes `openspec/specs/…` and `openspec/config.yaml`), which is already how OpenLore integrates. A future in-process capability can be added behind the same manifest without breaking this one. + +### 2. Static template contribution is the one in-process path + +The single exception to Decision 1: a plugin may contribute **skill/command templates** (static files + small metadata) that `openspec init`/`update` install into AI tool directories. This is data, not executable plugin logic — OpenSpec reads declared template files from the resolved plugin package and writes them, exactly as it does for its own templates. No plugin code runs. This is what makes "install a curated skill pack" (#1231) work uniformly across all 30+ tools in `AI_TOOLS` instead of each plugin shipping its own installer. + +### 3. Manifest: declarative, co-located, versioned + +A plugin declares itself via an `"openspec"` key in its `package.json`, or a sibling `openspec.plugin.json` (checked in that order). Fields: `manifestVersion`, `id`, `namespace`, `bin` (or `binArgs` for `npx`-style invocation), `openspecCompat` (semver range), `displayName`, `summary`, `commands[]` (name + summary, for help/completion only), `skills[]`, `commands` templates, `workflows[]`, and `ownsConfigKeys[]`. The manifest is validated with zod; unknown fields are preserved (passthrough) for forward compatibility, mirroring the global config approach. + +**Why an `"openspec"` package.json key as the primary form:** zero extra files for the common npm case, trivially discoverable by scanning `node_modules/*/package.json`. The standalone `openspec.plugin.json` form supports non-npm or monorepo distribution. + +### 4. Resolution: mirror the schema resolver precedence + +`src/core/plugins/resolver.ts` resolves enabled plugins in this order, returning the first manifest found per id and recording its source tier: + +1. **Project** — ids listed in `openspec/config.yaml` `plugins.enabled`, resolved from the project's `node_modules`. +2. **User/global** — `${XDG_DATA_HOME}/openspec/plugins/` (manifests or symlinks for globally installed engines). +3. **Auto-detect** — scan project `node_modules` for packages carrying an `openspec` manifest key (gated by `plugins.autoDetect`, default on). + +This is intentionally the same precedence story as `getSchemaDir` so contributors reason about both the same way. Resolution reads manifests only and is cheap enough to run on every CLI invocation; results are memoized per process. + +### 5. Namespacing over flat command injection + +Each plugin gets exactly one reserved top-level namespace (`namespace` in the manifest, e.g. `lore`). All plugin subcommands live under it (`openspec lore `). OpenSpec never lets a plugin register a bare top-level verb. + +**Why:** collision safety as the marketplace grows, obvious provenance in `--help`, and clean telemetry (`getCommandPath` in `src/cli/index.ts` already produces `lore:generate`-style paths). Namespace collisions between two enabled plugins, or with a reserved core verb, are a hard error reported by `plugin add`/resolution. + +### 6. Compatibility gating + +Each manifest declares `openspecCompat` (a semver range). At resolution time OpenSpec compares it against its own version. Incompatible plugins are **not** registered as commands; they appear in `openspec plugin list` flagged `incompatible` with the required range, so the failure is legible instead of a confusing runtime error. `plugin add` refuses to enable an incompatible plugin unless `--force` is passed. + +### 7. Managed-file tracking by explicit name + +Plugin-contributed skills/commands are tracked by explicit, plugin-namespaced names (never glob/pattern matching), consistent with the project rule "if we generate it, we track it by name in a constant." Disabling or removing a plugin removes only those named, plugin-owned artifacts and leaves user files untouched. This composes with the in-flight `simplify-skill-installation` and `unify-template-generation-pipeline` changes; the contribution layer plugs into the unified pipeline rather than duplicating write logic. + +### 8. Trust model: documented, not sandboxed + +Plugins are ordinary npm packages the user chose to install; delegation runs their bin with the user's privileges. This is the same trust surface as any devDependency or `npx` invocation, and OpenSpec does not sandbox it. The registry is **curated** (PR-reviewed entries), and `plugin add` of a non-registry/unknown plugin prints a one-time trust notice. This is stated plainly in docs rather than implied to be safe. + +### 9. Cross-platform behavior + +- Bin resolution and spawning use `node:child_process` with `shell: false` and an explicit executable path resolved from the package, so Windows `.cmd` shims and spaces-in-paths are handled without shell injection. +- All plugin/registry/skill paths are built with `path.join`/`path.resolve`; tests assert path behavior with `path.join`, not hardcoded separators. +- The user-tier plugins directory uses the same XDG/`getGlobalDataDir()` resolution as user schemas, so Windows uses the platform data dir. + +### 10. Registry: curated JSON now, service later + +`schemas/plugins/registry.json` (versioned, shipped with the package) holds the curated listings. `plugin search`/`info` read it; a future change can add a remote refresh URL and signing without changing the command surface. OpenLore is the inaugural entry. The registry document has its own `registryVersion` so the loader can reject formats it does not understand. + +## Risks / Trade-offs + +- **Subprocess can't share state.** Mitigated: integration is filesystem-based, which is already how OpenLore works. +- **Two enabled plugins could both want a namespace.** Mitigated by hard collision detection at resolution and `plugin add` time. +- **Auto-detect could surface unexpected packages.** Mitigated: auto-detect only registers packages with a valid `openspec` manifest and compatible range; it is toggleable and reported in `plugin list`. +- **Registry staleness.** The shipped JSON can lag npm. Acceptable for v1; `plugin add ` works for unlisted packages with a trust notice. +- **Scope creep into hooks/in-process.** Explicitly deferred; the manifest is versioned so these can be added compatibly. + +## Migration + +- Purely additive. Existing projects and configs are unaffected: no `plugins` block means no plugins, identical behavior to today. +- Configs without `plugins` load unchanged (passthrough). `autoDetect` defaults to on, but with no installed plugin manifests present, nothing changes. +- OpenLore users who already ran it keep their `openlore` config block and generated specs; enabling the plugin simply surfaces `openspec lore …` and (optionally) installs the OpenLore skill across their tools. + +## Open Questions + +- Should `autoDetect` default on or off? (Proposed: on, because it makes "install the package, it just works" true; off is more conservative.) +- Should `plugin add ` run the package manager install itself, or only print the install command? (Proposed: print by default, install behind `--install` to avoid surprising network/package-manager actions.) +- Does the user-tier (`global`) plugins directory need its own `plugin add --global`, or is project-tier + auto-detect enough for v1? (Proposed: project-tier + auto-detect for v1; global tier defined in the spec but its CLI affordance deferred.) diff --git a/openspec/changes/add-plugin-marketplace/proposal.md b/openspec/changes/add-plugin-marketplace/proposal.md new file mode 100644 index 000000000..3dcb026bb --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/proposal.md @@ -0,0 +1,86 @@ +## Why + +OpenSpec's strength is a small, sharp core: propose → apply → archive, plus the artifact-graph engine. But the community keeps asking for the same thing in different words — a way to extend OpenSpec with optional, shareable capabilities **without** bloating the core or forcing everything to be a custom schema. Today there is no supported extension point for *commands*: every command is statically imported and registered in `src/cli/index.ts`, and the skill/command templates that get installed into the 30+ supported AI tools are hardcoded arrays in `src/core/shared/skill-generation.ts`. The only dynamic extension mechanism that exists is schema resolution (`project → user → package`). + +This gap is documented across many open issues, none of which have been started or assigned: + +- **#453 — Enhancement: Plugins.** Keep the core pure; let the community add/remove optional capabilities so OpenSpec becomes a platform, not a monolith. +- **#1081 — Extension package for custom schemas, skills, commands, hooks, and profiles.** Reusable workflows need more than a schema; they need companion skills, commands, and config bundled and installable. +- **#436 — Ecosystem & Integration.** Model-agnostic plugins and execution adapters so OpenSpec fits an existing toolchain. +- **#650 — Official channel/registry for sharing artifact schemas.** A discoverable, curated place to find and pull community contributions. +- **#1074 — Schema extension/partial overrides.** Symptom of the same root cause: today the only way to extend is to fully shadow a built-in. +- **#1231 / #667 / #780 — Distribute OpenSpec's skills/agents as an installable plugin pack.** Users want one-step installation of a curated bundle. + +Separately, the single most-requested *capability* OpenSpec does not provide is generating specs from an existing codebase: + +- **#724 — Reverse-engineer specs from existing implementations (code-first workflow).** +- **Discussion #634 — spec-gen / OpenLore.** A standalone tool that solves exactly the "cold start" problem: it reverse-engineers OpenSpec-compatible specs from existing code via static analysis + LLM extraction, then hands evolution back to OpenSpec. + +These two threads converge on one decision: **OpenSpec should not absorb heavy, specialized capabilities like code reverse-engineering into its core — it should be able to host them as plugins.** This change builds the smallest credible plugin marketplace that lets OpenSpec remain a lightweight orchestrator while specialized "engines" live in their own packages, are discovered when present, are surfaced under their own command namespace, and can contribute skills to every supported AI tool. **OpenLore is the inaugural marketplace listing and the reference plugin** that validates the contract end to end. (The OpenLore package already declares `@fission-ai/openspec` as an optional peer dependency and already writes an `openlore` block into `openspec/config.yaml`, so it is well positioned to be first.) + +## What Changes + +### 1. Define a declarative plugin manifest (the contract) + +Introduce a versioned **plugin manifest**: a self-describing record a package publishes (an `"openspec"` key in its `package.json`, or a sibling `openspec.plugin.json`). The manifest declares the plugin id, a reserved command **namespace**, the executable to delegate to, the OpenSpec version range it supports, the subcommands it surfaces (for help and completion), and any skills/commands/workflows it contributes. Manifests are validated before a plugin is trusted; an invalid manifest disables the plugin with an actionable error rather than crashing OpenSpec. + +### 2. Discover and resolve installed plugins (three-tier, mirroring schema resolution) + +Add plugin **resolution** that mirrors the existing schema resolver precedence: explicit project config → user/global directory → auto-detected packages in `node_modules`. Resolution reads only manifests (cheap), never imports plugin code, and records each plugin's source tier and version-compatibility status. Plugins whose `openspecCompat` range excludes the running OpenSpec version are surfaced as incompatible and are not registered. + +### 3. Surface plugin commands by delegation (subprocess, not in-process) + +Register each enabled, compatible plugin as a single namespaced top-level command (e.g. `openspec lore …`). Invoking it **delegates** to the plugin's own executable as a child process with inherited stdio and propagated exit code. This keeps OpenSpec's dependency tree and Node version floor untouched, loads a plugin's heavy dependencies only when its command actually runs, and lets a plugin own its own CLI surface. Unknown/incompatible namespaces fail with guidance, never a stack trace. + +### 4. Let plugins contribute skills, commands, and workflows to AI tools + +Extend the skill/command generation pipeline so plugin-contributed templates are merged with core templates and installed into every selected AI tool directory by `openspec init` and refreshed by `openspec update`. All plugin-installed artifacts are tracked by explicit name (never pattern-matched) so they can be cleaned up safely on disable/uninstall, consistent with OpenSpec's existing managed-file conventions. + +### 5. Add the `openspec plugin` command group (lifecycle) + +Add a verb-first `openspec plugin` command group: `list`, `info`, `add`, `remove`, `enable`, `disable`, and `search`. These manage which plugins are enabled, persist that choice in config, install/clean contributed skills, and report compatibility and source. `init` detects installed plugins and offers to enable them; the interactive flow stays optional and skippable. + +### 6. Add a curated marketplace registry + OpenLore as the first listing + +Ship a curated **registry index** (a versioned JSON document, distributed with the package and refreshable) describing approved plugins: id, npm package, OpenSpec compat range, summary, homepage, and namespace. `openspec plugin search` and `openspec plugin info` read this index for discovery; `openspec plugin add ` uses it to resolve install instructions. **OpenLore is the inaugural entry.** This change adds the registry, the discovery commands, the OpenLore listing, an integration fixture proving an external engine registers and delegates correctly, and the documentation that frames OpenLore as "generate initial specs from existing code; OpenSpec evolves them." + +### 7. Persist plugin preferences in global config + +Extend global config with an optional `plugins` block (enabled plugin ids, registry settings, and auto-detect on/off), validated through the existing schema with forward-compatible passthrough so older/newer configs keep loading. + +This proposal deliberately scopes plugins as **out-of-process delegated engines plus static template contribution**. In-process command injection, runtime lifecycle hooks (#682-style archive hooks), and a hosted registry backend are explicitly out of scope and noted as future work, so the first version stays small and safe. + +## Capabilities + +### New Capabilities + +- `plugin-manifest`: The declarative plugin contract — fields, location, versioning, and validation semantics. +- `plugin-resolution`: Discovery and three-tier resolution of installed plugins, including version-compatibility gating. +- `plugin-runtime`: Namespaced command surfacing and subprocess delegation to plugin executables. +- `plugin-contribution`: Plugin-contributed skills/commands/workflows merged into the install pipeline and tracked for safe cleanup. +- `cli-plugin`: The `openspec plugin` command group for plugin lifecycle and discovery. +- `plugin-marketplace`: The curated registry index, discovery semantics, and the inaugural OpenLore listing. + +### Modified Capabilities + +- `global-config`: Persist a `plugins` preference block (enabled ids, registry, auto-detect) with schema-evolution-safe defaults. +- `cli-init`: Detect installed plugins, optionally enable them, and install contributed skills/commands during initialization. +- `cli-update`: Refresh plugin-contributed skills/commands alongside core artifacts, with drift detection and safe cleanup. +- `telemetry`: Track delegated plugin command invocations by namespace only, without capturing plugin arguments. + +## Impact + +- `src/cli/index.ts` — register the `plugin` command group and call a plugin registrar after core command registration +- `src/commands/plugin.ts` (new) — `openspec plugin list|info|add|remove|enable|disable|search` +- `src/core/plugins/manifest.ts` (new) — manifest schema (zod) + validation +- `src/core/plugins/resolver.ts` (new) — three-tier discovery/resolution + compat gating (mirrors `src/core/artifact-graph/resolver.ts`) +- `src/core/plugins/runtime.ts` (new) — namespaced command registration + subprocess delegation +- `src/core/plugins/registry.ts` (new) + `schemas/plugins/registry.json` (new) — curated marketplace index + loader +- `src/core/plugins/contribution.ts` (new) — merge plugin templates into generation +- `src/core/shared/skill-generation.ts` — accept plugin-contributed skill/command templates +- `src/core/config-schema.ts`, `src/core/global-config.ts` — add `plugins` config block + validation +- `src/core/init.ts`, `src/core/update.ts` — plugin detection, contributed-artifact install/sync/cleanup +- `src/telemetry/index.ts` — track delegated plugin command namespaces +- `src/core/completions/*` — surface plugin namespaces/subcommands in completions +- `docs/plugins.md` (new), `docs/existing-projects.md`, `docs/overview.md`, `README.md` — plugin + marketplace + OpenLore onboarding docs +- `test/core/plugins/*`, `test/commands/plugin.test.ts`, integration fixture under `test/fixtures/plugins/` — unit + e2e coverage diff --git a/openspec/changes/add-plugin-marketplace/specs/cli-init/spec.md b/openspec/changes/add-plugin-marketplace/specs/cli-init/spec.md new file mode 100644 index 000000000..b55e0d43c --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/cli-init/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Plugin detection during initialization +`openspec init` SHALL detect installed, compatible plugins and offer to enable them. + +#### Scenario: Compatible plugin detected interactively +- **WHEN** a user runs `openspec init` in a project with an installed compatible plugin +- **THEN** the interactive flow SHALL offer to enable that plugin +- **AND** the offer SHALL be skippable + +#### Scenario: Non-interactive initialization +- **WHEN** `openspec init` runs non-interactively +- **THEN** it SHALL NOT enable plugins implicitly +- **AND** SHALL leave plugin configuration unchanged + +### Requirement: Install contributed artifacts during initialization +When a plugin is enabled during `openspec init`, its contributed skills and commands SHALL be installed into the selected AI tool directories. + +#### Scenario: Contributed artifacts installed on init +- **WHEN** a plugin is enabled during initialization +- **AND** one or more AI tools are selected +- **THEN** the plugin's contributed skills and commands SHALL be installed for those tools + +#### Scenario: Init summary reports plugins +- **WHEN** initialization enables one or more plugins +- **THEN** the init summary SHALL report the enabled plugins and the artifacts installed + +#### Scenario: Idempotent re-initialization +- **WHEN** `openspec init` is run again with the same enabled plugins +- **THEN** it SHALL not duplicate contributed artifacts diff --git a/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md b/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md new file mode 100644 index 000000000..c4f19a21d --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md @@ -0,0 +1,76 @@ +## Purpose + +Define the `openspec plugin` command group for inspecting, enabling, disabling, and discovering plugins. + +## ADDED Requirements + +### Requirement: List installed plugins +The CLI SHALL provide `openspec plugin list` to show resolved plugins and their status. + +#### Scenario: Listing plugins +- **WHEN** a user runs `openspec plugin list` +- **THEN** output SHALL include each resolved plugin's id, namespace, version, source tier, and enabled/compatibility status + +#### Scenario: Machine-readable listing +- **WHEN** a user runs `openspec plugin list --json` +- **THEN** output SHALL be JSON suitable for programmatic use + +#### Scenario: No plugins installed +- **WHEN** no plugins are resolved +- **THEN** the command SHALL report that no plugins are installed without error + +### Requirement: Inspect a single plugin +The CLI SHALL provide `openspec plugin info ` to show details for one plugin. + +#### Scenario: Showing plugin details +- **WHEN** a user runs `openspec plugin info ` for a resolved plugin +- **THEN** output SHALL include manifest details and, when available, registry metadata + +#### Scenario: Unknown plugin id +- **WHEN** the id matches no resolved or registry plugin +- **THEN** the command SHALL report the id was not found with guidance + +### Requirement: Enable a plugin +The CLI SHALL provide `openspec plugin add ` to enable a plugin and install its contributed artifacts. + +#### Scenario: Enabling a compatible plugin +- **WHEN** a user runs `openspec plugin add ` for a compatible plugin +- **THEN** OpenSpec SHALL record the plugin as enabled in configuration +- **AND** SHALL install its contributed skills and commands into configured AI tools + +#### Scenario: Enabling an incompatible plugin +- **WHEN** the target plugin is incompatible with the running OpenSpec version +- **THEN** the command SHALL refuse to enable it and report the required version range +- **AND** SHALL proceed only when `--force` is provided + +#### Scenario: Enabling a non-registry plugin +- **WHEN** the target plugin is not present in the curated registry +- **THEN** the command SHALL display a trust notice before enabling + +#### Scenario: Install instructions for a missing package +- **WHEN** the target package is not yet installed +- **THEN** the command SHALL print the install command by default +- **AND** SHALL run the install only when `--install` is provided + +### Requirement: Disable or remove a plugin +The CLI SHALL provide `openspec plugin remove `, `openspec plugin disable `, and `openspec plugin enable `. + +#### Scenario: Removing a plugin +- **WHEN** a user runs `openspec plugin remove ` +- **THEN** OpenSpec SHALL disable the plugin in configuration +- **AND** SHALL clean up only that plugin's managed artifacts + +#### Scenario: Toggling without uninstalling +- **WHEN** a user runs `openspec plugin disable ` then `openspec plugin enable ` +- **THEN** OpenSpec SHALL toggle the plugin's enabled state without uninstalling the package + +### Requirement: Discover plugins from the registry +The CLI SHALL provide `openspec plugin search [query]` to discover registry plugins. + +#### Scenario: Searching the registry +- **WHEN** a user runs `openspec plugin search` +- **THEN** output SHALL list curated registry plugins with id, summary, and compatibility + +#### Scenario: Filtered search +- **WHEN** a user runs `openspec plugin search ` +- **THEN** output SHALL include only registry plugins matching the query diff --git a/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md b/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md new file mode 100644 index 000000000..160b14bc2 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md @@ -0,0 +1,28 @@ +## ADDED Requirements + +### Requirement: Refresh plugin-contributed artifacts on update +`openspec update` SHALL refresh enabled plugins' contributed skills and commands alongside core artifacts. + +#### Scenario: Contributed artifacts refreshed +- **WHEN** a user runs `openspec update` with one or more enabled plugins +- **THEN** OpenSpec SHALL re-sync those plugins' contributed skills and commands for configured tools + +#### Scenario: New contributed artifacts added +- **WHEN** an enabled plugin version contributes a new skill since the last sync +- **THEN** `openspec update` SHALL install the new artifact + +### Requirement: Drift detection and cleanup for plugins +`openspec update` SHALL detect plugin artifact drift and clean up artifacts for plugins no longer enabled. + +#### Scenario: Missing artifact re-synced +- **WHEN** an enabled plugin's contributed artifact is missing or modified +- **THEN** `openspec update` SHALL restore it to the managed state + +#### Scenario: Disabled plugin artifacts removed +- **WHEN** a plugin has been disabled or removed since the last sync +- **THEN** `openspec update` SHALL remove that plugin's managed artifacts +- **AND** SHALL leave core and user-authored files untouched + +#### Scenario: Update summary reports plugin changes +- **WHEN** `openspec update` changes any plugin-contributed artifacts +- **THEN** the update summary SHALL report those changes diff --git a/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md b/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md new file mode 100644 index 000000000..f5c28cdaf --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Plugin preferences in global config +Global configuration SHALL support an optional `plugins` block recording enabled plugins, auto-detect behavior, and registry settings. + +#### Scenario: Enabled plugins persisted +- **WHEN** a user enables a plugin +- **THEN** its id SHALL be recorded under `plugins.enabled` in configuration + +#### Scenario: Auto-detect default +- **WHEN** configuration does not specify `plugins.autoDetect` +- **THEN** the effective value SHALL default to enabled + +#### Scenario: Config validation accepts plugins keys +- **WHEN** a user sets a supported key under `plugins` +- **THEN** config validation SHALL accept it +- **AND** SHALL reject unknown structural shapes with an actionable message + +### Requirement: Schema-evolution safety for plugin config +Configurations without a `plugins` block SHALL load unchanged and behave as if no plugins are configured. + +#### Scenario: Legacy config without plugins +- **WHEN** a configuration created before plugin support is loaded +- **THEN** it SHALL load without error +- **AND** OpenSpec SHALL behave identically to having no plugins enabled + +#### Scenario: Forward-compatible unknown plugin keys +- **WHEN** a configuration contains plugin-related keys unknown to the running version +- **THEN** they SHALL be preserved on load and save diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md new file mode 100644 index 000000000..f60761e92 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md @@ -0,0 +1,49 @@ +## Purpose + +Define how a plugin contributes skills, commands, and workflows that OpenSpec installs into AI tool directories, and how those artifacts are tracked so they can be removed safely. + +## ADDED Requirements + +### Requirement: Plugin-contributed skills and commands install across tools +OpenSpec SHALL install an active plugin's contributed skills and commands into the selected AI tool directories using the same delivery pipeline as core skills and commands. + +#### Scenario: Contributed skill installed into selected tools +- **WHEN** an active plugin declares a contributed skill +- **AND** the project has one or more configured AI tools +- **THEN** OpenSpec SHALL install that skill into each configured tool's directory + +#### Scenario: Delivery mode respected +- **WHEN** the global delivery mode is `skills`, `commands`, or `both` +- **THEN** plugin-contributed artifacts SHALL honor the same delivery mode as core artifacts + +### Requirement: Contributed artifacts tracked by explicit name +Plugin-contributed artifacts SHALL be tracked by explicit, plugin-namespaced names rather than by pattern matching. + +#### Scenario: Tracking installed artifacts +- **WHEN** OpenSpec installs a plugin's contributed artifacts +- **THEN** it SHALL record each artifact under a plugin-owned name +- **AND** SHALL be able to identify those artifacts later for cleanup + +### Requirement: Safe cleanup on disable or removal +Disabling or removing a plugin SHALL remove only that plugin's managed artifacts and leave all other files untouched. + +#### Scenario: Cleanup on plugin removal +- **WHEN** a plugin is disabled or removed +- **THEN** OpenSpec SHALL remove only the artifacts it installed for that plugin +- **AND** SHALL NOT remove core artifacts or user-authored files + +### Requirement: Resilient handling of malformed contributions +A malformed contributed template SHALL NOT abort initialization or update. + +#### Scenario: Malformed contributed template +- **WHEN** a plugin's contributed template is malformed or unreadable +- **THEN** OpenSpec SHALL skip that artifact with a warning +- **AND** SHALL continue installing remaining core and plugin artifacts + +### Requirement: Contributed workflows are namespaced +Plugin-contributed workflows SHALL be distinguishable from core workflows. + +#### Scenario: Listing workflows with a plugin present +- **WHEN** a plugin contributes a workflow +- **THEN** OpenSpec SHALL present it as plugin-provided and attributable to its plugin +- **AND** SHALL NOT silently override a core workflow of the same name diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-manifest/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-manifest/spec.md new file mode 100644 index 000000000..f2dcc5a41 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-manifest/spec.md @@ -0,0 +1,72 @@ +## Purpose + +Define the plugin manifest: the declarative contract a package publishes so OpenSpec can discover it, surface its commands, gate its compatibility, and install its contributed artifacts — without importing the plugin's code. + +## ADDED Requirements + +### Requirement: Plugin manifest declaration +A plugin SHALL declare itself with a manifest, discoverable as an `"openspec"` key in the package's `package.json` or as a sibling `openspec.plugin.json` file. + +#### Scenario: Manifest in package.json +- **WHEN** a package contains an `"openspec"` object in its `package.json` +- **THEN** OpenSpec SHALL treat that object as the plugin manifest + +#### Scenario: Standalone manifest file +- **WHEN** a package has no `"openspec"` key but contains an `openspec.plugin.json` file at its root +- **THEN** OpenSpec SHALL load that file as the plugin manifest + +#### Scenario: Both forms present +- **WHEN** a package declares both an `"openspec"` package.json key and an `openspec.plugin.json` +- **THEN** OpenSpec SHALL use the `package.json` key and SHALL ignore the standalone file + +### Requirement: Required manifest fields +A plugin manifest SHALL declare the fields required to identify, surface, and gate the plugin. + +#### Scenario: Minimal valid manifest +- **WHEN** a manifest declares `manifestVersion`, `id`, `namespace`, an executable (`bin` or `binArgs`), and `openspecCompat` +- **THEN** OpenSpec SHALL consider the manifest structurally valid + +#### Scenario: Missing a required field +- **WHEN** a manifest omits any required field +- **THEN** OpenSpec SHALL treat the plugin as invalid +- **AND** SHALL report which field is missing + +### Requirement: Declared command, skill, and workflow contributions +A manifest SHALL be able to declare the subcommands it surfaces and the skills, commands, and workflows it contributes. + +#### Scenario: Declaring surfaced subcommands +- **WHEN** a manifest lists `commands` entries with a name and summary +- **THEN** OpenSpec SHALL use them only for help text and completion, not for execution routing + +#### Scenario: Declaring contributed skills +- **WHEN** a manifest lists `skills` entries pointing to template paths within the package +- **THEN** OpenSpec SHALL treat those as installable into AI tool directories + +#### Scenario: Declaring owned config keys +- **WHEN** a manifest lists `ownsConfigKeys` +- **THEN** OpenSpec SHALL recognize those top-level `config.yaml` keys as owned by the plugin + +### Requirement: Manifest validation disables rather than crashes +Manifest validation failures SHALL disable the offending plugin without aborting OpenSpec. + +#### Scenario: Invalid manifest encountered during a command +- **WHEN** a resolved plugin has an invalid manifest +- **THEN** OpenSpec SHALL skip registering that plugin +- **AND** SHALL continue executing the requested command +- **AND** SHALL make the validation error visible via `openspec plugin list` + +### Requirement: Reserved namespace protection +A manifest namespace SHALL NOT collide with OpenSpec's reserved top-level command names. + +#### Scenario: Namespace collides with a core command +- **WHEN** a manifest declares a `namespace` equal to a reserved core command name +- **THEN** OpenSpec SHALL treat the plugin as invalid +- **AND** SHALL report the conflicting reserved name + +### Requirement: Forward-compatible manifest parsing +Manifest parsing SHALL preserve unknown fields so newer manifests remain loadable by older OpenSpec versions. + +#### Scenario: Unknown field present +- **WHEN** a manifest contains fields not known to the running OpenSpec version +- **THEN** OpenSpec SHALL ignore the unknown fields +- **AND** SHALL still validate and load the known fields diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md new file mode 100644 index 000000000..2f4263c98 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md @@ -0,0 +1,45 @@ +## Purpose + +Define the curated plugin registry that powers discovery, and establish OpenLore as the inaugural marketplace listing. + +## ADDED Requirements + +### Requirement: Curated registry index +OpenSpec SHALL ship a curated, versioned registry index describing approved plugins. + +#### Scenario: Registry available offline +- **WHEN** OpenSpec needs registry data for discovery +- **THEN** it SHALL read the curated index distributed with the package without requiring network access + +#### Scenario: Registry entry fields +- **WHEN** the registry lists a plugin +- **THEN** each entry SHALL include id, npm package name, namespace, OpenSpec compatibility range, summary, and homepage + +#### Scenario: Unknown registry format +- **WHEN** the registry index declares a version the running OpenSpec does not understand +- **THEN** OpenSpec SHALL decline to use it and report the unsupported version rather than misinterpreting entries + +### Requirement: OpenLore inaugural listing +The registry SHALL include OpenLore as the first marketplace plugin. + +#### Scenario: OpenLore discoverable +- **WHEN** a user runs `openspec plugin search` +- **THEN** the results SHALL include OpenLore with npm package `openlore` and namespace `lore` + +#### Scenario: OpenLore add guidance +- **WHEN** a user runs `openspec plugin add openlore` +- **THEN** OpenSpec SHALL resolve OpenLore's install instructions and compatibility from the registry entry + +### Requirement: Code-first onboarding path via OpenLore +OpenSpec documentation SHALL present OpenLore as the supported path for generating initial specs from an existing codebase, with OpenSpec evolving them thereafter. + +#### Scenario: Existing project onboarding documented +- **WHEN** a user reads the existing-projects onboarding guidance +- **THEN** it SHALL describe using OpenLore to generate initial specs and OpenSpec to validate and evolve them + +### Requirement: Registry submission guidance +OpenSpec documentation SHALL describe how a plugin author submits a listing to the curated registry. + +#### Scenario: Author wants to be listed +- **WHEN** a plugin author reads the marketplace documentation +- **THEN** it SHALL explain the manifest requirements and the submission process for a registry entry diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-resolution/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-resolution/spec.md new file mode 100644 index 000000000..a0e44a348 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-resolution/spec.md @@ -0,0 +1,71 @@ +## Purpose + +Define how OpenSpec discovers installed plugins and resolves which ones are active, including precedence across sources, version-compatibility gating, and collision handling — mirroring the existing schema resolution model. + +## ADDED Requirements + +### Requirement: Three-tier plugin resolution +OpenSpec SHALL resolve plugins from three sources in a fixed precedence order: project configuration, the user/global plugins directory, then auto-detected packages. + +#### Scenario: Project-configured plugin +- **WHEN** a plugin id is listed in `openspec/config.yaml` under `plugins.enabled` +- **AND** the package is resolvable from the project's dependencies +- **THEN** OpenSpec SHALL resolve that plugin from the project tier + +#### Scenario: User/global plugin +- **WHEN** a plugin manifest is present in the user/global plugins directory +- **AND** it is not overridden by a project-tier plugin of the same id +- **THEN** OpenSpec SHALL resolve that plugin from the user tier + +#### Scenario: Precedence on duplicate id across tiers +- **WHEN** the same plugin id is available in more than one tier +- **THEN** OpenSpec SHALL use the highest-precedence tier (project over user over auto-detect) +- **AND** SHALL record the resolved source tier for reporting + +### Requirement: Auto-detection of installed plugins +OpenSpec SHALL be able to auto-detect installed plugin packages by their manifests, controlled by configuration. + +#### Scenario: Auto-detect enabled +- **WHEN** `plugins.autoDetect` is enabled +- **AND** a project dependency carries a valid plugin manifest +- **THEN** OpenSpec SHALL resolve that plugin even if it is not listed in `plugins.enabled` + +#### Scenario: Auto-detect disabled +- **WHEN** `plugins.autoDetect` is disabled +- **THEN** OpenSpec SHALL resolve only plugins explicitly enabled in configuration or present in the user tier + +### Requirement: Version compatibility gating +OpenSpec SHALL register only plugins whose declared `openspecCompat` range includes the running OpenSpec version. + +#### Scenario: Compatible plugin +- **WHEN** a resolved plugin's `openspecCompat` range includes the current OpenSpec version +- **THEN** OpenSpec SHALL register the plugin's command namespace + +#### Scenario: Incompatible plugin +- **WHEN** a resolved plugin's `openspecCompat` range excludes the current OpenSpec version +- **THEN** OpenSpec SHALL NOT register the plugin's command namespace +- **AND** SHALL list the plugin as incompatible with its required range + +### Requirement: Collision detection across plugins +OpenSpec SHALL detect and report conflicts when two resolved plugins claim the same id or namespace. + +#### Scenario: Duplicate namespace +- **WHEN** two enabled plugins declare the same namespace +- **THEN** OpenSpec SHALL report a namespace collision +- **AND** SHALL NOT register either conflicting namespace until resolved + +### Requirement: Resolution reads manifests only +Plugin resolution SHALL NOT import or execute plugin code. + +#### Scenario: Resolving without execution +- **WHEN** OpenSpec resolves the set of active plugins +- **THEN** it SHALL read and validate manifests only +- **AND** SHALL NOT load any plugin module into the OpenSpec process + +### Requirement: Cross-platform plugin directory resolution +The user/global plugins directory SHALL resolve to the platform-appropriate data location. + +#### Scenario: User plugins directory on Windows +- **WHEN** OpenSpec resolves the user/global plugins directory on Windows +- **THEN** it SHALL use the platform data directory with Windows path conventions +- **AND** SHALL NOT assume POSIX home-relative paths diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md new file mode 100644 index 000000000..07ae1e5fc --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md @@ -0,0 +1,66 @@ +## Purpose + +Define how an active plugin is surfaced as a command and how OpenSpec executes it: a single reserved namespace per plugin, invoked by delegating to the plugin's own executable as a child process. + +## ADDED Requirements + +### Requirement: Namespaced plugin command surface +Each active, compatible plugin SHALL be surfaced as exactly one reserved top-level command namespace. + +#### Scenario: Namespace registered +- **WHEN** a compatible plugin declares namespace `lore` +- **THEN** `openspec lore` SHALL be available as a top-level command +- **AND** all of the plugin's subcommands SHALL be addressed as `openspec lore ` + +#### Scenario: No bare top-level verbs +- **WHEN** a plugin is registered +- **THEN** OpenSpec SHALL NOT expose any plugin subcommand as a bare top-level verb outside its namespace + +### Requirement: Command delegation to the plugin executable +Invoking a plugin namespace SHALL delegate execution to the plugin's declared executable as a child process. + +#### Scenario: Arguments passed through +- **WHEN** a user runs `openspec lore generate --domains api` +- **THEN** OpenSpec SHALL invoke the plugin executable with `generate --domains api` +- **AND** SHALL pass all arguments after the namespace verbatim + +#### Scenario: Streams inherited +- **WHEN** a delegated plugin command runs +- **THEN** the child process SHALL inherit standard input, output, and error streams + +#### Scenario: Exit code propagated +- **WHEN** the delegated plugin process exits +- **THEN** OpenSpec SHALL exit with the child process's exit code + +#### Scenario: Help forwarded +- **WHEN** a user runs `openspec lore --help` +- **THEN** OpenSpec SHALL forward the help request to the plugin executable + +### Requirement: Delegation isolates the OpenSpec process +Plugin execution SHALL NOT load plugin code into the OpenSpec process or alter OpenSpec's dependency requirements. + +#### Scenario: Heavy plugin dependencies stay isolated +- **WHEN** a plugin with heavy dependencies is invoked +- **THEN** those dependencies SHALL load only in the child process +- **AND** SHALL NOT be required for any core OpenSpec command + +### Requirement: Robust spawn failure handling +Failures to locate or launch the plugin executable SHALL produce a clear error and a non-zero exit code. + +#### Scenario: Executable not found +- **WHEN** the plugin's declared executable cannot be located +- **THEN** OpenSpec SHALL report that the plugin executable is missing and how to install it +- **AND** SHALL exit with a non-zero code + +#### Scenario: Unknown or incompatible namespace +- **WHEN** a user invokes a namespace that is not registered (unknown or incompatible plugin) +- **THEN** OpenSpec SHALL report the unknown command with guidance +- **AND** SHALL NOT emit a stack trace + +### Requirement: Cross-platform executable launching +Plugin executables SHALL launch correctly across platforms without invoking a shell. + +#### Scenario: Launching on Windows +- **WHEN** OpenSpec launches a plugin executable on Windows +- **THEN** it SHALL resolve platform executable shims and handle spaces in paths +- **AND** SHALL spawn without shell interpolation of arguments diff --git a/openspec/changes/add-plugin-marketplace/specs/telemetry/spec.md b/openspec/changes/add-plugin-marketplace/specs/telemetry/spec.md new file mode 100644 index 000000000..f0100f752 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/telemetry/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: Delegated plugin command telemetry +Telemetry SHALL record delegated plugin command invocations by namespace path only, without capturing plugin arguments. + +#### Scenario: Plugin command tracked by namespace +- **WHEN** a user invokes a delegated plugin command such as `openspec lore generate` +- **THEN** telemetry SHALL record the namespace command path (for example `lore` and `lore:generate`) +- **AND** SHALL NOT capture the plugin's argument values + +#### Scenario: Telemetry respects existing opt-out +- **WHEN** telemetry is disabled by the user +- **THEN** no plugin command invocation SHALL be recorded diff --git a/openspec/changes/add-plugin-marketplace/tasks.md b/openspec/changes/add-plugin-marketplace/tasks.md new file mode 100644 index 000000000..36b60a4db --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/tasks.md @@ -0,0 +1,110 @@ +## 1. Plugin Manifest + +- [ ] 1.1 Define the manifest zod schema in `src/core/plugins/manifest.ts` (`manifestVersion`, `id`, `namespace`, `bin`/`binArgs`, `openspecCompat`, `displayName`, `summary`, `commands[]`, `skills[]`, command templates, `workflows[]`, `ownsConfigKeys[]`) with `.passthrough()` for forward compatibility +- [ ] 1.2 Implement manifest loading from a package's `package.json` `"openspec"` key, falling back to a sibling `openspec.plugin.json` +- [ ] 1.3 Implement validation with actionable, field-level error messages; invalid manifests disable the plugin instead of throwing +- [ ] 1.4 Reserve and reject namespaces that collide with core top-level commands (init, update, list, view, change, archive, validate, show, feedback, completion, status, instructions, templates, schemas, new, set, config, schema, workspace, context-store, initiative, plugin) +- [ ] 1.5 Unit tests: valid manifest (both forms), invalid/missing fields, reserved-namespace rejection, unknown-field passthrough + +## 2. Plugin Resolution + +- [ ] 2.1 Implement `src/core/plugins/resolver.ts` with three-tier precedence (project config → user/global dir → auto-detected `node_modules`), mirroring `getSchemaDir` +- [ ] 2.2 Resolve the user-tier plugins directory via `getGlobalDataDir()` (XDG-aware), consistent with user schema resolution +- [ ] 2.3 Implement auto-detect: scan project `node_modules/*/package.json` for the `openspec` manifest key, gated by `plugins.autoDetect` +- [ ] 2.4 Implement semver compatibility gating against the running OpenSpec version; mark incompatible plugins without registering them +- [ ] 2.5 Detect and report duplicate-id and duplicate-namespace collisions across resolved plugins +- [ ] 2.6 Memoize resolution per process; ensure resolution performs no plugin code import +- [ ] 2.7 Unit tests: precedence/override order, auto-detect on/off, compat in/out of range, collision detection, missing `node_modules` + +## 3. Plugin Runtime (Delegation) + +- [ ] 3.1 Implement `src/core/plugins/runtime.ts`: register one namespaced top-level command per enabled, compatible plugin +- [ ] 3.2 Delegate execution by spawning the plugin bin via `node:child_process` with `shell: false`, inherited stdio, and propagated exit code +- [ ] 3.3 Resolve the plugin executable path from the package (handle Windows shims and spaces in paths without a shell) +- [ ] 3.4 Pass through all arguments after the namespace verbatim; forward `--help` to the plugin +- [ ] 3.5 Handle spawn failures (missing bin, ENOENT, non-zero exit) with clear messages and correct exit codes +- [ ] 3.6 Wire `registerPlugins(program)` into `src/cli/index.ts` after core command registration +- [ ] 3.7 Unit/integration tests: successful delegation, argument pass-through, exit-code propagation, missing-bin error, incompatible namespace not registered + +## 4. Global Config + +- [ ] 4.1 Add an optional `plugins` block to `GlobalConfigSchema` (`enabled: string[]`, `autoDetect: boolean` default true, `registry` settings) in `src/core/config-schema.ts` +- [ ] 4.2 Add `plugins` to `KNOWN_TOP_LEVEL_KEYS` and config key-path validation +- [ ] 4.3 Ensure schema evolution: configs without `plugins` load unchanged; no plugins means today's behavior +- [ ] 4.4 Unit tests: default config, enable/disable persistence, passthrough of unknown plugin keys, legacy config without `plugins` + +## 5. Plugin Contribution to AI Tools + +- [ ] 5.1 Implement `src/core/plugins/contribution.ts`: collect skill/command/workflow templates declared by enabled plugins from their resolved package +- [ ] 5.2 Extend `getSkillTemplates`/`getCommandTemplates` in `src/core/shared/skill-generation.ts` to merge plugin-contributed templates with core templates (composing with `unify-template-generation-pipeline`) +- [ ] 5.3 Track contributed artifacts by explicit, plugin-namespaced names for safe cleanup (no pattern matching) +- [ ] 5.4 Validate contributed templates (well-formed skill/command files) and skip with a warning on failure rather than aborting init/update +- [ ] 5.5 Unit tests: merge correctness, name tracking, malformed-template skip, delivery-mode interaction (`both`/`skills`/`commands`) + +## 6. CLI: `openspec plugin` Command Group + +- [ ] 6.1 Implement `src/commands/plugin.ts` with `registerPluginCommand(program)` +- [ ] 6.2 `openspec plugin list` — resolved plugins with id, namespace, version, source tier, enabled/compat status (`--json`) +- [ ] 6.3 `openspec plugin info ` — manifest + registry details for one plugin (`--json`) +- [ ] 6.4 `openspec plugin add ` — enable in config, install contributed skills/commands; print install command (or run behind `--install`); refuse incompatible unless `--force`; trust notice for non-registry packages +- [ ] 6.5 `openspec plugin remove ` — disable and clean up only that plugin's managed artifacts +- [ ] 6.6 `openspec plugin enable|disable ` — toggle without uninstalling the package +- [ ] 6.7 `openspec plugin search [query]` — read the curated registry index +- [ ] 6.8 Register the command group in `src/cli/index.ts` +- [ ] 6.9 Tests for each subcommand including non-interactive/`--json` paths and error cases + +## 7. Marketplace Registry + +- [ ] 7.1 Add `schemas/plugins/registry.json` with `registryVersion` and a listings array (id, npm, namespace, `openspecCompat`, summary, homepage) +- [ ] 7.2 Implement `src/core/plugins/registry.ts` loader with version checking and graceful handling of unknown formats +- [ ] 7.3 Add the **OpenLore** inaugural listing (npm `openlore`, namespace `lore`, compat range, summary, homepage) +- [ ] 7.4 Include `registry.json` in the package `files` allowlist in `package.json` +- [ ] 7.5 Unit tests: load/parse, unknown-version rejection, search filtering, OpenLore entry present and well-formed + +## 8. Init Integration + +- [ ] 8.1 Detect installed/compatible plugins during `openspec init` +- [ ] 8.2 Offer to enable detected plugins in the interactive flow (skippable; non-interactive defaults to no change) +- [ ] 8.3 Install enabled plugins' contributed skills/commands into selected AI tool directories +- [ ] 8.4 Report enabled plugins and installed artifacts in the init summary +- [ ] 8.5 Tests: init with/without plugins present, interactive enable, `--tools none`, idempotent re-run + +## 9. Update Integration + +- [ ] 9.1 Refresh enabled plugins' contributed skills/commands during `openspec update` +- [ ] 9.2 Detect drift (plugin enabled but artifacts missing/changed) and re-sync; clean up artifacts for disabled/removed plugins +- [ ] 9.3 Report plugin artifact changes in the update summary +- [ ] 9.4 Tests: update adds new contributed artifacts, removes artifacts for disabled plugins, idempotent re-run + +## 10. Telemetry + +- [ ] 10.1 Track delegated plugin command invocations by namespace path only (e.g. `lore`/`lore:generate`), never plugin arguments +- [ ] 10.2 Tests assert no plugin argument values are captured + +## 11. Completions + +- [ ] 11.1 Surface enabled plugin namespaces and their declared subcommands in shell completion output +- [ ] 11.2 Tests for completion data including a plugin namespace + +## 12. Documentation + +- [ ] 12.1 Add `docs/plugins.md`: plugin model, manifest, lifecycle commands, trust model, authoring a plugin +- [ ] 12.2 Add a marketplace section documenting the registry and how to submit a listing +- [ ] 12.3 Update `docs/existing-projects.md` and `docs/overview.md` to present OpenLore as the code-first onboarding path (generate → validate → evolve) +- [ ] 12.4 Update `README.md` with a short plugins/marketplace pointer +- [ ] 12.5 Cross-link issues #453, #436, #1081, #650, #1074, #1231, #667, #780, #724 and discussion #634 in the plugins doc rationale + +## 13. Reference Plugin Fixture & E2E + +- [ ] 13.1 Add a minimal fake plugin fixture under `test/fixtures/plugins/` (manifest + stub bin) exercising manifest, resolution, and delegation +- [ ] 13.2 E2E: install fixture into a temp project, assert `openspec plugin list` shows it and `openspec …` delegates and propagates exit code +- [ ] 13.3 Document the manual smoke test for OpenLore (`npm i -D openlore` → `openspec plugin add openlore` → `openspec lore generate`) in the change for reviewers + +## 14. Verification + +- [ ] 14.1 `pnpm build` and `pnpm lint` clean +- [ ] 14.2 Targeted tests for plugins, plugin command, init, update, config +- [ ] 14.3 Full suite `pnpm test` green; resolve regressions +- [ ] 14.4 Cross-platform path behavior verified (Windows CI or mocked-path unit tests) for plugin/registry/skill paths and bin spawning +- [ ] 14.5 `openspec validate add-plugin-marketplace --strict` passes +- [ ] 14.6 Confirm no new required runtime dependency added and Node engine floor unchanged (≥20.19.0) From 00c5d2f20c3f4075347b91a7280d2ab400c88cd2 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:24:29 -0500 Subject: [PATCH 02/20] docs(openspec): tighten plugin-marketplace proposal after review Corrections from a triple-check against the current codebase: - Config placement fixed. Project-tier enablement lives in the project openspec/config.yaml (committed, team-shared); global config holds only user-level preferences. ProjectConfigSchema is NOT passthrough, so writing the plugins block must be non-destructive and preserve unknown third-party keys (e.g. the openlore block). New config-loading delta + cli-plugin "preserves existing config" scenario capture this; design Context corrected. - Reserved-namespace list completed (was missing `spec`/`help`/hidden `experimental`/`__complete`); now derived from the registered command list. - Added cli-completion delta so plugin namespaces/subcommands surface in completions (tasks referenced it but no spec governed it). - global-config delta refocused to user-level preferences. - Noted AGENTS.md instruction-block surfacing as deferred future work. 12 capability deltas; passes `openspec validate --strict`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../changes/add-plugin-marketplace/design.md | 10 +++++- .../add-plugin-marketplace/proposal.md | 11 ++++--- .../specs/cli-completion/spec.md | 17 ++++++++++ .../specs/cli-plugin/spec.md | 4 +++ .../specs/config-loading/spec.md | 31 +++++++++++++++++++ .../specs/global-config/spec.md | 20 ++++++------ .../changes/add-plugin-marketplace/tasks.md | 14 +++++---- 7 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 openspec/changes/add-plugin-marketplace/specs/cli-completion/spec.md create mode 100644 openspec/changes/add-plugin-marketplace/specs/config-loading/spec.md diff --git a/openspec/changes/add-plugin-marketplace/design.md b/openspec/changes/add-plugin-marketplace/design.md index 86c36b202..32f47fe9d 100644 --- a/openspec/changes/add-plugin-marketplace/design.md +++ b/openspec/changes/add-plugin-marketplace/design.md @@ -8,7 +8,12 @@ Today OpenSpec has exactly one dynamic extension mechanism: schema resolution in - Skill/command templates are hardcoded arrays in `src/core/shared/skill-generation.ts` (`getSkillTemplates`, `getCommandTemplates`) keyed by a fixed `ALL_WORKFLOWS` list in `src/core/profiles.ts`. - Global config is a zod schema (`src/core/config-schema.ts`) that already uses `.passthrough()` for forward compatibility and already carries `profile`, `delivery`, `workflows`, and `featureFlags`. -The reference plugin, OpenLore, is a separate npm package (`openlore`) with its own Commander CLI (~40 subcommands) and heavy optional dependencies (tree-sitter grammars, lancedb). It already declares `@fission-ai/openspec` as an **optional** peer dependency and already writes an `openlore` metadata block into `openspec/config.yaml` (which survives because OpenSpec's config is passthrough). It requires Node ≥22.5; OpenSpec requires Node ≥20.19. +Two config layers matter here and behave differently: + +- **Global config** (`GlobalConfigSchema`, `src/core/config-schema.ts`) is `.passthrough()` and holds user-level settings (`profile`, `delivery`, `workflows`, `featureFlags`). +- **Project config** (`ProjectConfigSchema`, `src/core/project-config.ts`) is a plain `z.object({ schema, context, rules })` — **not** passthrough — loaded with resilient field-by-field parsing that ignores unknown keys. + +The reference plugin, OpenLore, is a separate npm package (`openlore`) with its own Commander CLI (~40 subcommands) and heavy optional dependencies (tree-sitter grammars, lancedb). It already declares `@fission-ai/openspec` as an **optional** peer dependency and already writes an `openlore` metadata block into the project `openspec/config.yaml`. That block survives today not because the project schema preserves it (it does not), but because OpenSpec's resilient loader ignores unknown keys and OpenSpec does not rewrite `config.yaml` destructively. This is a constraint on the plugin design: when OpenSpec writes a `plugins` block, it must preserve the `openlore` block and any other unknown keys. OpenLore requires Node ≥22.5; OpenSpec requires Node ≥20.19. ## Goals / Non-Goals @@ -27,6 +32,7 @@ The reference plugin, OpenLore, is a separate npm package (`openlore`) with its - A hosted registry service or auth. The first registry is a curated, versioned JSON document shipped with the package. - Schema inheritance / partial schema overrides (#1074). Related but separate; this change does not modify schema resolution. - Sandboxing plugin execution. Plugins are npm packages the user installs; trust model is documented, not enforced (see Decision 8). +- Injecting plugin command references into the generated `AGENTS.md` / OpenSpec instruction block. Deferred: in v1 plugins reach AI agents through contributed skills (Decision 2), not the managed instruction block. Surfacing namespaces there is a candidate follow-up. ## Decisions @@ -62,6 +68,8 @@ A plugin declares itself via an `"openspec"` key in its `package.json`, or a sib This is intentionally the same precedence story as `getSchemaDir` so contributors reason about both the same way. Resolution reads manifests only and is cheap enough to run on every CLI invocation; results are memoized per process. +**Config placement.** Enablement that should travel with the repo lives in the **project** `openspec/config.yaml` `plugins.enabled` (committed, team-shared) — this is what drives project-tier resolution and is where `plugin add` writes by default. **Global config** holds only user-level preferences: the `autoDetect` default, registry settings, and any user/global-tier plugins. Because `ProjectConfigSchema` is not passthrough, the writer that adds `plugins.enabled` must round-trip the file non-destructively, preserving `schema`/`context`/`rules` and any unknown third-party keys (e.g. `openlore`). + ### 5. Namespacing over flat command injection Each plugin gets exactly one reserved top-level namespace (`namespace` in the manifest, e.g. `lore`). All plugin subcommands live under it (`openspec lore `). OpenSpec never lets a plugin register a bare top-level verb. diff --git a/openspec/changes/add-plugin-marketplace/proposal.md b/openspec/changes/add-plugin-marketplace/proposal.md index 3dcb026bb..efe19e541 100644 --- a/openspec/changes/add-plugin-marketplace/proposal.md +++ b/openspec/changes/add-plugin-marketplace/proposal.md @@ -44,9 +44,9 @@ Add a verb-first `openspec plugin` command group: `list`, `info`, `add`, `remove Ship a curated **registry index** (a versioned JSON document, distributed with the package and refreshable) describing approved plugins: id, npm package, OpenSpec compat range, summary, homepage, and namespace. `openspec plugin search` and `openspec plugin info` read this index for discovery; `openspec plugin add ` uses it to resolve install instructions. **OpenLore is the inaugural entry.** This change adds the registry, the discovery commands, the OpenLore listing, an integration fixture proving an external engine registers and delegates correctly, and the documentation that frames OpenLore as "generate initial specs from existing code; OpenSpec evolves them." -### 7. Persist plugin preferences in global config +### 7. Persist plugin choices in project and global config -Extend global config with an optional `plugins` block (enabled plugin ids, registry settings, and auto-detect on/off), validated through the existing schema with forward-compatible passthrough so older/newer configs keep loading. +Record which plugins a project uses in the project `openspec/config.yaml` (`plugins.enabled`), so enablement is committed and shared by the team and drives project-tier resolution. Keep user-level preferences (auto-detect default, registry settings, and any user/global-tier plugins) in global config. Reads tolerate older configs unchanged; crucially, **writes preserve unrelated and unknown keys** — including third-party blocks such as the `openlore` metadata OpenLore already writes into `config.yaml` — so enabling a plugin never clobbers existing configuration. (The project config schema is not passthrough today, so this preservation is an explicit contract, not a free side effect.) This proposal deliberately scopes plugins as **out-of-process delegated engines plus static template contribution**. In-process command injection, runtime lifecycle hooks (#682-style archive hooks), and a hosted registry backend are explicitly out of scope and noted as future work, so the first version stays small and safe. @@ -63,9 +63,11 @@ This proposal deliberately scopes plugins as **out-of-process delegated engines ### Modified Capabilities -- `global-config`: Persist a `plugins` preference block (enabled ids, registry, auto-detect) with schema-evolution-safe defaults. +- `config-loading`: Recognize a project `plugins` block in `openspec/config.yaml`, parse it resiliently, and round-trip it without dropping unrelated or unknown keys. +- `global-config`: Persist user-level plugin preferences (auto-detect default, registry settings, user/global-tier plugins) with schema-evolution-safe defaults. - `cli-init`: Detect installed plugins, optionally enable them, and install contributed skills/commands during initialization. - `cli-update`: Refresh plugin-contributed skills/commands alongside core artifacts, with drift detection and safe cleanup. +- `cli-completion`: Surface enabled plugin namespaces and their declared subcommands in shell completions. - `telemetry`: Track delegated plugin command invocations by namespace only, without capturing plugin arguments. ## Impact @@ -78,7 +80,8 @@ This proposal deliberately scopes plugins as **out-of-process delegated engines - `src/core/plugins/registry.ts` (new) + `schemas/plugins/registry.json` (new) — curated marketplace index + loader - `src/core/plugins/contribution.ts` (new) — merge plugin templates into generation - `src/core/shared/skill-generation.ts` — accept plugin-contributed skill/command templates -- `src/core/config-schema.ts`, `src/core/global-config.ts` — add `plugins` config block + validation +- `src/core/config-schema.ts`, `src/core/global-config.ts` — add user-level `plugins` config block + validation +- `src/core/project-config.ts` — recognize a project `plugins` block in `ProjectConfigSchema` and write it non-destructively (preserve unknown keys such as `openlore`) - `src/core/init.ts`, `src/core/update.ts` — plugin detection, contributed-artifact install/sync/cleanup - `src/telemetry/index.ts` — track delegated plugin command namespaces - `src/core/completions/*` — surface plugin namespaces/subcommands in completions diff --git a/openspec/changes/add-plugin-marketplace/specs/cli-completion/spec.md b/openspec/changes/add-plugin-marketplace/specs/cli-completion/spec.md new file mode 100644 index 000000000..8dd87fd91 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/cli-completion/spec.md @@ -0,0 +1,17 @@ +## ADDED Requirements + +### Requirement: Plugin namespaces in shell completions +Shell completion SHALL include the namespaces of enabled, compatible plugins and their declared subcommands. + +#### Scenario: Completing a plugin namespace +- **WHEN** a user requests completion at the top-level command position +- **AND** a compatible plugin declares namespace `lore` +- **THEN** the completion candidates SHALL include `lore` + +#### Scenario: Completing plugin subcommands +- **WHEN** a user requests completion after a registered plugin namespace +- **THEN** the completion candidates SHALL include the subcommands the plugin declared in its manifest + +#### Scenario: Incompatible plugin excluded from completion +- **WHEN** a resolved plugin is incompatible with the running OpenSpec version +- **THEN** its namespace SHALL NOT appear in completion candidates diff --git a/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md b/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md index c4f19a21d..6865ce294 100644 --- a/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md @@ -52,6 +52,10 @@ The CLI SHALL provide `openspec plugin add ` to enable a plugin and install - **THEN** the command SHALL print the install command by default - **AND** SHALL run the install only when `--install` is provided +#### Scenario: Enabling preserves existing config +- **WHEN** `openspec plugin add ` records enablement in `openspec/config.yaml` +- **THEN** it SHALL preserve all existing configuration keys, including unknown third-party blocks such as `openlore` + ### Requirement: Disable or remove a plugin The CLI SHALL provide `openspec plugin remove `, `openspec plugin disable `, and `openspec plugin enable `. diff --git a/openspec/changes/add-plugin-marketplace/specs/config-loading/spec.md b/openspec/changes/add-plugin-marketplace/specs/config-loading/spec.md new file mode 100644 index 000000000..c2db10238 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/config-loading/spec.md @@ -0,0 +1,31 @@ +## ADDED Requirements + +### Requirement: Project plugin enablement in config.yaml +The project configuration loader SHALL recognize an optional `plugins` block in `openspec/config.yaml` recording the plugins a project uses. + +#### Scenario: Plugins block parsed +- **WHEN** `openspec/config.yaml` contains a `plugins.enabled` list +- **THEN** the loader SHALL parse it and expose the enabled plugin ids + +#### Scenario: Absent plugins block +- **WHEN** `openspec/config.yaml` contains no `plugins` block +- **THEN** the project SHALL behave as having no project-enabled plugins +- **AND** loading SHALL succeed unchanged + +#### Scenario: Malformed plugins block +- **WHEN** the `plugins` block is malformed +- **THEN** the loader SHALL report a clear error for that field +- **AND** SHALL NOT discard other valid configuration fields + +### Requirement: Non-destructive project config writes +Writing the project `plugins` block SHALL preserve all other configuration keys, including keys unknown to the running OpenSpec version. + +#### Scenario: Preserve unrelated keys when enabling a plugin +- **WHEN** OpenSpec writes `plugins.enabled` to `openspec/config.yaml` +- **THEN** it SHALL preserve `schema`, `context`, and `rules` +- **AND** SHALL preserve unknown third-party keys such as an `openlore` metadata block + +#### Scenario: Round-trip stability +- **WHEN** OpenSpec reads and rewrites `openspec/config.yaml` to change plugin enablement +- **THEN** keys it does not manage SHALL retain their values +- **AND** no managed-key write SHALL remove or corrupt an unmanaged key diff --git a/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md b/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md index f5c28cdaf..d5afa4424 100644 --- a/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md @@ -1,29 +1,29 @@ ## ADDED Requirements -### Requirement: Plugin preferences in global config -Global configuration SHALL support an optional `plugins` block recording enabled plugins, auto-detect behavior, and registry settings. - -#### Scenario: Enabled plugins persisted -- **WHEN** a user enables a plugin -- **THEN** its id SHALL be recorded under `plugins.enabled` in configuration +### Requirement: User-level plugin preferences in global config +Global configuration SHALL support an optional `plugins` block recording user-level plugin preferences: auto-detect behavior, registry settings, and any user/global-tier plugins. #### Scenario: Auto-detect default - **WHEN** configuration does not specify `plugins.autoDetect` - **THEN** the effective value SHALL default to enabled +#### Scenario: Registry preference persisted +- **WHEN** a user configures a registry preference under `plugins` +- **THEN** it SHALL be saved to global config and used for discovery + #### Scenario: Config validation accepts plugins keys - **WHEN** a user sets a supported key under `plugins` - **THEN** config validation SHALL accept it - **AND** SHALL reject unknown structural shapes with an actionable message ### Requirement: Schema-evolution safety for plugin config -Configurations without a `plugins` block SHALL load unchanged and behave as if no plugins are configured. +Global configurations without a `plugins` block SHALL load unchanged and behave as if no user-level plugin preferences are set. #### Scenario: Legacy config without plugins -- **WHEN** a configuration created before plugin support is loaded +- **WHEN** a global configuration created before plugin support is loaded - **THEN** it SHALL load without error -- **AND** OpenSpec SHALL behave identically to having no plugins enabled +- **AND** OpenSpec SHALL apply default plugin preferences #### Scenario: Forward-compatible unknown plugin keys -- **WHEN** a configuration contains plugin-related keys unknown to the running version +- **WHEN** a global configuration contains plugin-related keys unknown to the running version - **THEN** they SHALL be preserved on load and save diff --git a/openspec/changes/add-plugin-marketplace/tasks.md b/openspec/changes/add-plugin-marketplace/tasks.md index 36b60a4db..fd4571c35 100644 --- a/openspec/changes/add-plugin-marketplace/tasks.md +++ b/openspec/changes/add-plugin-marketplace/tasks.md @@ -3,7 +3,7 @@ - [ ] 1.1 Define the manifest zod schema in `src/core/plugins/manifest.ts` (`manifestVersion`, `id`, `namespace`, `bin`/`binArgs`, `openspecCompat`, `displayName`, `summary`, `commands[]`, `skills[]`, command templates, `workflows[]`, `ownsConfigKeys[]`) with `.passthrough()` for forward compatibility - [ ] 1.2 Implement manifest loading from a package's `package.json` `"openspec"` key, falling back to a sibling `openspec.plugin.json` - [ ] 1.3 Implement validation with actionable, field-level error messages; invalid manifests disable the plugin instead of throwing -- [ ] 1.4 Reserve and reject namespaces that collide with core top-level commands (init, update, list, view, change, archive, validate, show, feedback, completion, status, instructions, templates, schemas, new, set, config, schema, workspace, context-store, initiative, plugin) +- [ ] 1.4 Reserve and reject namespaces that collide with core top-level commands; derive the reserved set from the registered command list rather than a duplicated literal. Current names: archive, change, completion, config, context-store, experimental (hidden), feedback, help, init, initiative, instructions, list, new, schema, schemas, set, show, spec, status, templates, update, validate, view, workspace, plugin, and the hidden `__complete` - [ ] 1.5 Unit tests: valid manifest (both forms), invalid/missing fields, reserved-namespace rejection, unknown-field passthrough ## 2. Plugin Resolution @@ -26,12 +26,14 @@ - [ ] 3.6 Wire `registerPlugins(program)` into `src/cli/index.ts` after core command registration - [ ] 3.7 Unit/integration tests: successful delegation, argument pass-through, exit-code propagation, missing-bin error, incompatible namespace not registered -## 4. Global Config +## 4. Config (Project + Global) -- [ ] 4.1 Add an optional `plugins` block to `GlobalConfigSchema` (`enabled: string[]`, `autoDetect: boolean` default true, `registry` settings) in `src/core/config-schema.ts` -- [ ] 4.2 Add `plugins` to `KNOWN_TOP_LEVEL_KEYS` and config key-path validation -- [ ] 4.3 Ensure schema evolution: configs without `plugins` load unchanged; no plugins means today's behavior -- [ ] 4.4 Unit tests: default config, enable/disable persistence, passthrough of unknown plugin keys, legacy config without `plugins` +- [ ] 4.1 Add an optional `plugins` block to the project `ProjectConfigSchema` in `src/core/project-config.ts` (`enabled: string[]`, optional `autoDetect`); this is the team-shared, committed source of project-tier enablement +- [ ] 4.2 Implement non-destructive writes to `openspec/config.yaml`: enabling/disabling a plugin SHALL preserve `schema`/`context`/`rules` and unknown third-party keys (e.g. `openlore`) +- [ ] 4.3 Add an optional user-level `plugins` block to `GlobalConfigSchema` (`autoDetect: boolean` default true, `registry` settings, optional user/global-tier plugins) in `src/core/config-schema.ts` +- [ ] 4.4 Add `plugins` to `KNOWN_TOP_LEVEL_KEYS` and config key-path validation for global config +- [ ] 4.5 Ensure schema evolution: project and global configs without `plugins` load unchanged; no plugins means today's behavior +- [ ] 4.6 Unit tests: project plugins parse/absent/malformed, non-destructive round-trip preserving an `openlore` block, global default `autoDetect`, passthrough of unknown global plugin keys, legacy configs without `plugins` ## 5. Plugin Contribution to AI Tools From 29045a39ed60c16aff8d51988e42856a8db4473b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:38:24 -0500 Subject: [PATCH 03/20] feat(plugins): plugin engine, marketplace registry, and `openspec plugin` CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the core of the add-plugin-marketplace change: - Manifest contract (src/core/plugins/manifest.ts): zod schema with passthrough, loads from package.json "openspec" key or openspec.plugin.json, validates and rejects reserved namespaces; invalid manifests disable a plugin, never crash. - Three-tier resolution (resolver.ts): project config -> user dir -> auto-detected node_modules, mirroring the schema resolver; semver compat gating (no new dep — minimal range checker in semver.ts), namespace collision detection, memoized. - Subprocess delegation (runtime.ts): one reserved namespace per plugin; executes via cross-spawn with inherited stdio and propagated exit code; plugin code is never imported. Early-intercept in cli/index.ts forwards `openspec ...` verbatim (incl. --help) before Commander parses. - Curated registry (registry.ts + schemas/plugins/registry.json): versioned index with OpenLore as the inaugural listing; search/info/add resolve from it. - Config: project plugins block in openspec/config.yaml written non-destructively via the YAML Document API (preserves comments and unknown keys like `openlore`); user-level plugins prefs in global config + schema/key-path validation. - `openspec plugin` group: list, info, add, remove, enable, disable, search. - Telemetry: delegated commands tracked by namespace only (no plugin args). - Completions: `plugin` group added to the command registry (parity restored). Build + lint clean; full suite green except pre-existing env-only zsh-installer failures (unchanged from base). Co-Authored-By: Claude Opus 4.8 (1M context) --- schemas/plugins/registry.json | 13 + src/cli/index.ts | 58 ++++ src/commands/plugin.ts | 369 +++++++++++++++++++++++ src/core/completions/command-registry.ts | 63 ++++ src/core/config-schema.ts | 17 +- src/core/global-config.ts | 12 + src/core/plugins/config.ts | 103 +++++++ src/core/plugins/index.ts | 7 + src/core/plugins/manifest.ts | 181 +++++++++++ src/core/plugins/registry.ts | 97 ++++++ src/core/plugins/resolver.ts | 211 +++++++++++++ src/core/plugins/runtime.ts | 124 ++++++++ src/core/plugins/semver.ts | 119 ++++++++ src/core/plugins/types.ts | 89 ++++++ 14 files changed, 1462 insertions(+), 1 deletion(-) create mode 100644 schemas/plugins/registry.json create mode 100644 src/commands/plugin.ts create mode 100644 src/core/plugins/config.ts create mode 100644 src/core/plugins/index.ts create mode 100644 src/core/plugins/manifest.ts create mode 100644 src/core/plugins/registry.ts create mode 100644 src/core/plugins/resolver.ts create mode 100644 src/core/plugins/runtime.ts create mode 100644 src/core/plugins/semver.ts create mode 100644 src/core/plugins/types.ts diff --git a/schemas/plugins/registry.json b/schemas/plugins/registry.json new file mode 100644 index 000000000..453295d5d --- /dev/null +++ b/schemas/plugins/registry.json @@ -0,0 +1,13 @@ +{ + "registryVersion": 1, + "plugins": [ + { + "id": "openlore", + "npm": "openlore", + "namespace": "lore", + "openspecCompat": ">=1.0.0", + "summary": "Reverse-engineer OpenSpec specifications from an existing codebase, then keep code and specs in sync. Generate initial specs with OpenLore; evolve them with OpenSpec.", + "homepage": "https://github.com/clay-good/OpenLore" + } + ] +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 0c42f43cb..9d167dfe2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,9 @@ import { registerSchemaCommand } from '../commands/schema.js'; import { registerWorkspaceCommand } from '../commands/workspace.js'; import { registerContextStoreCommand } from '../commands/context-store.js'; import { registerInitiativeCommand } from '../commands/initiative.js'; +import { registerPluginCommand } from '../commands/plugin.js'; +import { registerPlugins, delegateToPlugin } from '../core/plugins/runtime.js'; +import { resolvePlugins, activePlugins } from '../core/plugins/resolver.js'; import { findWorkspaceRoot } from '../core/workspace/index.js'; import { statusCommand, @@ -323,6 +326,15 @@ registerSchemaCommand(program); registerWorkspaceCommand(program); registerContextStoreCommand(program); registerInitiativeCommand(program); +registerPluginCommand(program); + +// Surface installed plugins as namespaced commands (for help/completion). Guarded +// so a plugin resolution problem can never break core OpenSpec commands. +try { + registerPlugins(program); +} catch { + // Resolution errors are reported via `openspec plugin list`, not here. +} // Top-level validate command program @@ -574,9 +586,55 @@ setCmd export { program }; export function runCli(argv = process.argv): void { + // Plugin namespace invocations are delegated verbatim to the plugin executable + // before Commander parses argv (so `--help` and unknown flags pass through + // untouched). Telemetry records the namespace only, never plugin arguments. + const delegation = findPluginDelegation(argv); + if (delegation) { + void runDelegatedPlugin(delegation.plugin, delegation.args, delegation.namespace); + return; + } program.parse(argv); } +function findPluginDelegation( + argv: string[] +): { namespace: string; plugin: ReturnType[number]; args: string[] } | null { + try { + const rest = argv.slice(2); + const tokenIndex = rest.findIndex((token) => !token.startsWith('-')); + if (tokenIndex === -1) return null; + + const namespace = rest[tokenIndex]; + const plugin = activePlugins(resolvePlugins(process.cwd())).find( + (p) => p.namespace === namespace + ); + if (!plugin) return null; + return { namespace, plugin, args: rest.slice(tokenIndex + 1) }; + } catch { + return null; + } +} + +async function runDelegatedPlugin( + plugin: ReturnType[number], + args: string[], + namespace: string +): Promise { + try { + await maybeShowTelemetryNotice(); + await trackCommand(namespace, version); + } catch { + // Telemetry must never block plugin execution. + } + process.exitCode = delegateToPlugin(plugin, args); + try { + await shutdown(); + } catch { + // ignore telemetry shutdown failures + } +} + if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { runCli(); } diff --git a/src/commands/plugin.ts b/src/commands/plugin.ts new file mode 100644 index 000000000..4c2d153d8 --- /dev/null +++ b/src/commands/plugin.ts @@ -0,0 +1,369 @@ +/** + * `openspec plugin` command group: inspect, enable, disable, and discover plugins. + */ + +import { Command } from 'commander'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawnSync as nodeSpawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { + resolvePlugins, + clearPluginResolutionCache, + getOpenSpecVersion, +} from '../core/plugins/resolver.js'; +import { + enableProjectPlugin, + disableProjectPlugin, + readProjectPluginConfig, +} from '../core/plugins/config.js'; +import { + loadRegistry, + findRegistryEntry, + searchRegistry, + RegistryError, + type RegistryEntry, +} from '../core/plugins/registry.js'; +import { satisfies } from '../core/plugins/semver.js'; +import type { ResolvedPlugin } from '../core/plugins/types.js'; +import { OPENSPEC_DIR_NAME } from '../core/config.js'; + +// cross-spawn ships no types; cast to node's spawnSync signature (repo pattern). +const require = createRequire(import.meta.url); +const crossSpawn = require('cross-spawn') as { sync: typeof nodeSpawnSync }; + +function ensureOpenSpecProject(): string { + const projectRoot = process.cwd(); + if (!fs.existsSync(path.join(projectRoot, OPENSPEC_DIR_NAME))) { + throw new Error('No OpenSpec project found here. Run "openspec init" first.'); + } + return projectRoot; +} + +function statusLabel(p: ResolvedPlugin): string { + if (!p.compatible) return 'incompatible'; + if (!p.enabled) return 'disabled'; + return 'enabled'; +} + +function listPlugins(json?: boolean): void { + const projectRoot = process.cwd(); + const resolution = resolvePlugins(projectRoot); + + if (json) { + console.log( + JSON.stringify( + { + plugins: resolution.plugins.map((p) => ({ + id: p.id, + namespace: p.namespace, + version: p.version, + source: p.source, + compatible: p.compatible, + enabled: p.enabled, + status: statusLabel(p), + requires: p.manifest.openspecCompat, + })), + collisions: resolution.collisions, + errors: resolution.errors, + }, + null, + 2 + ) + ); + return; + } + + if (resolution.plugins.length === 0 && resolution.errors.length === 0) { + console.log('No plugins installed.'); + console.log('Discover plugins with "openspec plugin search".'); + return; + } + + for (const p of resolution.plugins) { + const ver = p.version ? `@${p.version}` : ''; + let line = ` ${p.namespace} (${p.id}${ver}) [${statusLabel(p)}, ${p.source}]`; + if (!p.compatible) line += ` requires OpenSpec ${p.manifest.openspecCompat}`; + console.log(line); + } + + for (const c of resolution.collisions) { + console.log(` ! ${c.kind} collision on "${c.value}" — resolve before these plugins load`); + } + for (const e of resolution.errors) { + console.log(` ! ${e.id}: ${e.error}`); + } +} + +function infoPlugin(id: string, json?: boolean): void { + const resolution = resolvePlugins(process.cwd()); + const installed = resolution.plugins.find((p) => p.id === id || p.namespace === id); + let registryEntry: RegistryEntry | undefined; + try { + registryEntry = findRegistryEntry(id); + } catch { + // Registry optional for info. + } + + if (!installed && !registryEntry) { + throw new Error(`Plugin "${id}" was not found among installed plugins or the registry.`); + } + + if (json) { + console.log( + JSON.stringify( + { + installed: installed + ? { + id: installed.id, + namespace: installed.namespace, + version: installed.version, + source: installed.source, + compatible: installed.compatible, + enabled: installed.enabled, + manifest: installed.manifest, + } + : null, + registry: registryEntry ?? null, + }, + null, + 2 + ) + ); + return; + } + + if (installed) { + console.log(`${installed.manifest.displayName ?? installed.id}`); + console.log(` id: ${installed.id}`); + console.log(` namespace: ${installed.namespace}`); + if (installed.version) console.log(` version: ${installed.version}`); + console.log(` source: ${installed.source}`); + console.log(` status: ${statusLabel(installed)}`); + console.log(` requires: OpenSpec ${installed.manifest.openspecCompat}`); + if (installed.manifest.summary) console.log(` summary: ${installed.manifest.summary}`); + if (installed.manifest.commands?.length) { + console.log(' commands:'); + for (const c of installed.manifest.commands) { + console.log(` ${installed.namespace} ${c.name}${c.summary ? ` — ${c.summary}` : ''}`); + } + } + } + if (registryEntry) { + console.log(' registry:'); + console.log(` npm: ${registryEntry.npm}`); + if (registryEntry.homepage) console.log(` homepage: ${registryEntry.homepage}`); + } +} + +async function addPlugin( + idOrNpm: string, + options: { force?: boolean; install?: boolean } +): Promise { + const projectRoot = ensureOpenSpecProject(); + const resolution = resolvePlugins(projectRoot); + const installed = resolution.plugins.find((p) => p.id === idOrNpm || p.namespace === idOrNpm); + + let registryEntry: RegistryEntry | undefined; + try { + registryEntry = findRegistryEntry(idOrNpm); + } catch { + // proceed without registry + } + + // Not installed: print install guidance (or run install when asked). + if (!installed) { + const npmName = registryEntry?.npm ?? idOrNpm; + if (registryEntry) { + const ok = satisfies(getOpenSpecVersion(), registryEntry.openspecCompat); + if (!ok && !options.force) { + throw new Error( + `${registryEntry.id} requires OpenSpec ${registryEntry.openspecCompat} (current ${getOpenSpecVersion()}). Re-run with --force to enable anyway.` + ); + } + } else { + console.log( + `Note: "${idOrNpm}" is not in the curated registry. Plugins run with your privileges — only add packages you trust.` + ); + } + + if (options.install) { + console.log(`Installing ${npmName}…`); + const res = crossSpawn.sync('npm', ['install', '--save-dev', npmName], { stdio: 'inherit' }); + if (res.status !== 0) throw new Error(`Failed to install ${npmName}.`); + clearPluginResolutionCache(); + // Re-resolve to pick up the freshly installed plugin id. + const after = resolvePlugins(projectRoot).plugins.find((p) => p.namespace === registryEntry?.namespace || p.id === idOrNpm); + const enableId = after?.id ?? registryEntry?.id ?? idOrNpm; + enableProjectPlugin(projectRoot, enableId); + console.log(`Enabled "${enableId}". Run "openspec plugin list" to verify.`); + return; + } + + console.log(`To add ${npmName}, install it and enable it:`); + console.log(` npm install --save-dev ${npmName}`); + console.log(` openspec plugin add ${registryEntry?.id ?? idOrNpm}`); + console.log('Or re-run this command with --install to do both.'); + return; + } + + // Installed: gate on compatibility, then enable in project config. + if (!installed.compatible && !options.force) { + throw new Error( + `${installed.id} requires OpenSpec ${installed.manifest.openspecCompat} (current ${getOpenSpecVersion()}). Re-run with --force to enable anyway.` + ); + } + if (!registryEntry) { + console.log( + `Note: "${installed.id}" is not in the curated registry. Plugins run with your privileges — only enable plugins you trust.` + ); + } + + enableProjectPlugin(projectRoot, installed.id); + clearPluginResolutionCache(); + console.log(`Enabled "${installed.id}" (namespace "${installed.namespace}").`); + console.log(`Run "openspec ${installed.namespace} --help" to see its commands.`); +} + +function removePlugin(id: string): void { + const projectRoot = ensureOpenSpecProject(); + const { enabled } = readProjectPluginConfig(projectRoot); + if (!enabled.includes(id)) { + console.log(`Plugin "${id}" is not enabled in this project.`); + return; + } + disableProjectPlugin(projectRoot, id); + clearPluginResolutionCache(); + console.log(`Removed "${id}" from this project's enabled plugins.`); + console.log('The package is still installed; uninstall it with your package manager to remove it fully.'); +} + +function setEnabled(id: string, enabled: boolean): void { + const projectRoot = ensureOpenSpecProject(); + if (enabled) { + enableProjectPlugin(projectRoot, id); + console.log(`Enabled "${id}".`); + } else { + disableProjectPlugin(projectRoot, id); + console.log(`Disabled "${id}".`); + } + clearPluginResolutionCache(); +} + +function searchPlugins(query: string | undefined, json?: boolean): void { + let entries: RegistryEntry[]; + try { + entries = searchRegistry(query); + } catch (error) { + if (error instanceof RegistryError) { + throw new Error(error.message); + } + throw error; + } + + if (json) { + console.log(JSON.stringify({ plugins: entries }, null, 2)); + return; + } + + if (entries.length === 0) { + console.log(query ? `No registry plugins match "${query}".` : 'The registry is empty.'); + return; + } + + for (const e of entries) { + console.log(` ${e.namespace} (${e.id}, npm: ${e.npm})`); + console.log(` ${e.summary}`); + console.log(` requires OpenSpec ${e.openspecCompat}${e.homepage ? ` · ${e.homepage}` : ''}`); + } +} + +export function registerPluginCommand(program: Command): void { + const pluginCmd = program + .command('plugin') + .description('Manage OpenSpec plugins (marketplace engines)'); + + pluginCmd + .command('list') + .description('List installed plugins and their status') + .option('--json', 'Output as JSON') + .action((options?: { json?: boolean }) => { + listPlugins(options?.json); + }); + + pluginCmd + .command('info ') + .description('Show details for a plugin (installed and/or registry)') + .option('--json', 'Output as JSON') + .action((id: string, options?: { json?: boolean }) => { + try { + infoPlugin(id, options?.json); + } catch (error) { + console.error(`Error: ${(error as Error).message}`); + process.exitCode = 1; + } + }); + + pluginCmd + .command('add ') + .description('Enable a plugin in this project (and print install guidance if missing)') + .option('--force', 'Enable even if the plugin is incompatible with this OpenSpec version') + .option('--install', 'Install the package via npm before enabling') + .action(async (id: string, options?: { force?: boolean; install?: boolean }) => { + try { + await addPlugin(id, options ?? {}); + } catch (error) { + console.error(`Error: ${(error as Error).message}`); + process.exitCode = 1; + } + }); + + pluginCmd + .command('remove ') + .description('Disable a plugin in this project (does not uninstall the package)') + .action((id: string) => { + try { + removePlugin(id); + } catch (error) { + console.error(`Error: ${(error as Error).message}`); + process.exitCode = 1; + } + }); + + pluginCmd + .command('enable ') + .description('Enable a plugin in this project') + .action((id: string) => { + try { + setEnabled(id, true); + } catch (error) { + console.error(`Error: ${(error as Error).message}`); + process.exitCode = 1; + } + }); + + pluginCmd + .command('disable ') + .description('Disable a plugin in this project') + .action((id: string) => { + try { + setEnabled(id, false); + } catch (error) { + console.error(`Error: ${(error as Error).message}`); + process.exitCode = 1; + } + }); + + pluginCmd + .command('search [query]') + .description('Discover plugins from the curated registry') + .option('--json', 'Output as JSON') + .action((query?: string, options?: { json?: boolean }) => { + try { + searchPlugins(query, options?.json); + } catch (error) { + console.error(`Error: ${(error as Error).message}`); + process.exitCode = 1; + } + }); +} diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 88ec88e05..2d4c44a44 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -875,6 +875,69 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ }, ], }, + { + name: 'plugin', + description: 'Manage OpenSpec plugins (marketplace engines)', + flags: [], + subcommands: [ + { + name: 'list', + description: 'List installed plugins and their status', + flags: [COMMON_FLAGS.json], + }, + { + name: 'info', + description: 'Show details for a plugin (installed and/or registry)', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [COMMON_FLAGS.json], + }, + { + name: 'add', + description: 'Enable a plugin in this project (and print install guidance if missing)', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [ + { + name: 'force', + description: 'Enable even if the plugin is incompatible with this OpenSpec version', + }, + { + name: 'install', + description: 'Install the package via npm before enabling', + }, + ], + }, + { + name: 'remove', + description: 'Disable a plugin in this project (does not uninstall the package)', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [], + }, + { + name: 'enable', + description: 'Enable a plugin in this project', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [], + }, + { + name: 'disable', + description: 'Disable a plugin in this project', + acceptsPositional: true, + positionals: [{ name: 'id' }], + flags: [], + }, + { + name: 'search', + description: 'Discover plugins from the curated registry', + acceptsPositional: true, + positionals: [{ name: 'query', optional: true }], + flags: [COMMON_FLAGS.json], + }, + ], + }, { name: 'schema', description: 'Manage workflow schemas', diff --git a/src/core/config-schema.ts b/src/core/config-schema.ts index 0614ed33e..0a96475ab 100644 --- a/src/core/config-schema.ts +++ b/src/core/config-schema.ts @@ -21,6 +21,14 @@ export const GlobalConfigSchema = z workflows: z .array(z.string()) .optional(), + plugins: z + .object({ + autoDetect: z.boolean().optional(), + registry: z.string().optional(), + enabled: z.array(z.string()).optional(), + }) + .passthrough() + .optional(), }) .passthrough(); @@ -35,7 +43,7 @@ export const DEFAULT_CONFIG: GlobalConfigType = { delivery: 'both', }; -const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows']); +const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_CONFIG), 'workflows', 'plugins']); /** * Validate a config key path for CLI set operations. @@ -60,6 +68,13 @@ export function validateConfigKeyPath(path: string): { valid: boolean; reason?: return { valid: true }; } + if (rootKey === 'plugins') { + if (rawKeys.length > 2) { + return { valid: false, reason: 'plugins supports a single level of nested keys (e.g. plugins.autoDetect)' }; + } + return { valid: true }; + } + if (rawKeys.length > 1) { return { valid: false, reason: `"${rootKey}" does not support nested keys` }; } diff --git a/src/core/global-config.ts b/src/core/global-config.ts index ad321ceb8..6cd9cce27 100644 --- a/src/core/global-config.ts +++ b/src/core/global-config.ts @@ -11,12 +11,24 @@ export const GLOBAL_DATA_DIR_NAME = 'openspec'; export type Profile = 'core' | 'custom'; export type Delivery = 'both' | 'skills' | 'commands'; +/** User-level plugin preferences (enablement that travels with a repo lives in + * the project openspec/config.yaml, not here). */ +export interface PluginPreferences { + /** Auto-detect installed plugin packages by their manifest. Defaults to true. */ + autoDetect?: boolean; + /** Override path to a custom registry index. */ + registry?: string; + /** Plugins enabled at the user/global tier. */ + enabled?: string[]; +} + // TypeScript interfaces export interface GlobalConfig { featureFlags?: Record; profile?: Profile; delivery?: Delivery; workflows?: string[]; + plugins?: PluginPreferences; } const DEFAULT_CONFIG: GlobalConfig = { diff --git a/src/core/plugins/config.ts b/src/core/plugins/config.ts new file mode 100644 index 000000000..a7945a659 --- /dev/null +++ b/src/core/plugins/config.ts @@ -0,0 +1,103 @@ +/** + * Reading and writing plugin enablement. + * + * Project-tier enablement lives in the project `openspec/config.yaml` under a + * `plugins` block and is committed/shared by the team. Because the project config + * schema is not passthrough and OpenSpec's loader drops unknown keys, writes here + * go through the YAML Document API so unrelated and unknown keys (for example the + * `openlore` metadata block) are preserved. + * + * User-level preferences (auto-detect default, registry, user-tier plugins) live + * in the global config and are read from there. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseDocument, isMap } from 'yaml'; +import { getGlobalConfig } from '../global-config.js'; + +export interface ProjectPluginConfig { + enabled: string[]; + autoDetect?: boolean; +} + +function projectConfigPath(projectRoot: string): string | null { + const yamlPath = path.join(projectRoot, 'openspec', 'config.yaml'); + if (fs.existsSync(yamlPath)) return yamlPath; + const ymlPath = path.join(projectRoot, 'openspec', 'config.yml'); + if (fs.existsSync(ymlPath)) return ymlPath; + return null; +} + +/** + * Read the project `plugins` block from openspec/config.yaml. + * Returns enabled ids and the project-level autoDetect override (if any). + */ +export function readProjectPluginConfig(projectRoot: string): ProjectPluginConfig { + const configPath = projectConfigPath(projectRoot); + if (!configPath) return { enabled: [] }; + + try { + const doc = parseDocument(fs.readFileSync(configPath, 'utf-8')); + const plugins = doc.get('plugins'); + if (!plugins || typeof plugins !== 'object') return { enabled: [] }; + + const json = doc.toJS() as { plugins?: { enabled?: unknown; autoDetect?: unknown } }; + const block = json.plugins ?? {}; + const enabled = Array.isArray(block.enabled) + ? block.enabled.filter((v): v is string => typeof v === 'string') + : []; + const autoDetect = typeof block.autoDetect === 'boolean' ? block.autoDetect : undefined; + return { enabled, autoDetect }; + } catch { + // Malformed config is surfaced elsewhere (readProjectConfig); treat as none here. + return { enabled: [] }; + } +} + +/** + * Set the project `plugins.enabled` list, preserving every other key in the file. + * + * @returns true if the file was written, false if there was no config file to edit. + */ +export function writeProjectPluginEnabled(projectRoot: string, enabled: string[]): boolean { + const configPath = projectConfigPath(projectRoot); + if (!configPath) return false; + + const doc = parseDocument(fs.readFileSync(configPath, 'utf-8')); + + let plugins = doc.get('plugins'); + if (!isMap(plugins)) { + doc.set('plugins', { enabled }); + } else { + plugins.set('enabled', enabled); + } + + fs.writeFileSync(configPath, doc.toString(), 'utf-8'); + return true; +} + +/** Add a plugin id to the project enabled list (idempotent). Returns true if written. */ +export function enableProjectPlugin(projectRoot: string, id: string): boolean { + const { enabled } = readProjectPluginConfig(projectRoot); + if (enabled.includes(id)) return writeProjectPluginEnabled(projectRoot, enabled); + return writeProjectPluginEnabled(projectRoot, [...enabled, id]); +} + +/** Remove a plugin id from the project enabled list. Returns true if written. */ +export function disableProjectPlugin(projectRoot: string, id: string): boolean { + const { enabled } = readProjectPluginConfig(projectRoot); + return writeProjectPluginEnabled( + projectRoot, + enabled.filter((e) => e !== id) + ); +} + +/** Whether auto-detect is effectively enabled (project override wins over global; default true). */ +export function isAutoDetectEnabled(projectRoot: string): boolean { + const project = readProjectPluginConfig(projectRoot); + if (typeof project.autoDetect === 'boolean') return project.autoDetect; + const global = getGlobalConfig(); + if (typeof global.plugins?.autoDetect === 'boolean') return global.plugins.autoDetect; + return true; +} diff --git a/src/core/plugins/index.ts b/src/core/plugins/index.ts new file mode 100644 index 000000000..504490a52 --- /dev/null +++ b/src/core/plugins/index.ts @@ -0,0 +1,7 @@ +export * from './types.js'; +export * from './manifest.js'; +export * from './semver.js'; +export * from './config.js'; +export * from './registry.js'; +export * from './resolver.js'; +export * from './runtime.js'; diff --git a/src/core/plugins/manifest.ts b/src/core/plugins/manifest.ts new file mode 100644 index 000000000..1f7eac275 --- /dev/null +++ b/src/core/plugins/manifest.ts @@ -0,0 +1,181 @@ +/** + * Plugin manifest schema, loading, and validation. + * + * A manifest is read from a package's `package.json` `"openspec"` key, or from a + * sibling `openspec.plugin.json` file (the package.json key wins when both exist). + * Validation never throws to callers expecting a result: failures are returned so + * an invalid plugin is disabled rather than crashing OpenSpec. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { z } from 'zod'; +import type { PluginManifest } from './types.js'; + +export const MANIFEST_FILE_NAME = 'openspec.plugin.json'; +export const PACKAGE_MANIFEST_KEY = 'openspec'; +export const SUPPORTED_MANIFEST_VERSION = 1; + +/** + * Reserved top-level command names a plugin namespace must not collide with. + * The runtime augments this with the live command list; this constant is the + * floor used when a live list is unavailable (e.g. during validation in tests). + */ +export const RESERVED_NAMESPACES: readonly string[] = [ + 'archive', 'change', 'completion', 'config', 'context-store', 'experimental', + 'feedback', 'help', 'init', 'initiative', 'instructions', 'list', 'new', + 'plugin', 'schema', 'schemas', 'set', 'show', 'spec', 'status', 'templates', + 'update', 'validate', 'view', '__complete', +]; + +const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/; + +const CommandDescriptorSchema = z.object({ + name: z.string().min(1), + summary: z.string().optional(), +}); + +const SkillContributionSchema = z.object({ + dir: z.string().min(1), + source: z.string().min(1), +}); + +/** + * Zod schema for a plugin manifest. Uses `.passthrough()` so unknown fields from + * newer manifests survive on older OpenSpec versions. + */ +export const PluginManifestSchema = z + .object({ + manifestVersion: z.number().int().positive(), + id: z.string().min(1), + namespace: z + .string() + .min(1) + .regex(NAMESPACE_PATTERN, 'namespace must be lowercase letters, digits, and dashes'), + bin: z.string().min(1).optional(), + binArgs: z.array(z.string().min(1)).min(1).optional(), + openspecCompat: z.string().min(1), + displayName: z.string().optional(), + summary: z.string().optional(), + commands: z.array(CommandDescriptorSchema).optional(), + skills: z.array(SkillContributionSchema).optional(), + workflows: z.array(z.string()).optional(), + ownsConfigKeys: z.array(z.string()).optional(), + }) + .passthrough() + .refine((m) => m.bin !== undefined || m.binArgs !== undefined, { + message: 'manifest must declare an executable via "bin" or "binArgs"', + }); + +export interface ManifestValidationResult { + valid: boolean; + manifest?: PluginManifest; + errors: string[]; +} + +/** + * Validate a raw manifest object against the schema and reserved-namespace rules. + * + * @param raw - The parsed manifest object. + * @param reserved - Reserved namespace names to reject (defaults to RESERVED_NAMESPACES). + */ +export function validateManifest( + raw: unknown, + reserved: readonly string[] = RESERVED_NAMESPACES +): ManifestValidationResult { + const parsed = PluginManifestSchema.safeParse(raw); + if (!parsed.success) { + return { + valid: false, + errors: parsed.error.issues.map( + (issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}` + ), + }; + } + + const manifest = parsed.data as PluginManifest; + const errors: string[] = []; + + if (manifest.manifestVersion > SUPPORTED_MANIFEST_VERSION) { + errors.push( + `manifestVersion ${manifest.manifestVersion} is newer than supported version ${SUPPORTED_MANIFEST_VERSION}` + ); + } + + if (reserved.includes(manifest.namespace)) { + errors.push(`namespace "${manifest.namespace}" is reserved by a core OpenSpec command`); + } + + if (errors.length > 0) { + return { valid: false, errors }; + } + + return { valid: true, manifest, errors: [] }; +} + +export interface LoadedManifest { + manifest: PluginManifest; + /** Where the manifest came from, for diagnostics. */ + origin: 'package.json' | 'manifest-file'; + /** Package version, if discoverable from package.json. */ + version?: string; +} + +/** + * Load and validate a manifest from a package/manifest root directory. + * Returns null when no manifest is present. Throws only on validation failure, + * with a message safe to surface to the user. + */ +export function loadManifestFromRoot( + rootDir: string, + reserved: readonly string[] = RESERVED_NAMESPACES +): LoadedManifest | null { + const pkgJsonPath = path.join(rootDir, 'package.json'); + let pkgVersion: string | undefined; + + if (fs.existsSync(pkgJsonPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + pkgVersion = typeof pkg.version === 'string' ? pkg.version : undefined; + if (pkg[PACKAGE_MANIFEST_KEY] !== undefined) { + const result = validateManifest(pkg[PACKAGE_MANIFEST_KEY], reserved); + if (!result.valid) { + throw new Error(`Invalid OpenSpec plugin manifest: ${result.errors.join('; ')}`); + } + return { manifest: result.manifest!, origin: 'package.json', version: pkgVersion }; + } + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error(`Unreadable package.json in ${rootDir}: ${error.message}`); + } + throw error; + } + } + + const manifestFilePath = path.join(rootDir, MANIFEST_FILE_NAME); + if (fs.existsSync(manifestFilePath)) { + let raw: unknown; + try { + raw = JSON.parse(fs.readFileSync(manifestFilePath, 'utf-8')); + } catch (error) { + throw new Error( + `Unreadable ${MANIFEST_FILE_NAME} in ${rootDir}: ${(error as Error).message}` + ); + } + const result = validateManifest(raw, reserved); + if (!result.valid) { + throw new Error(`Invalid OpenSpec plugin manifest: ${result.errors.join('; ')}`); + } + return { manifest: result.manifest!, origin: 'manifest-file', version: pkgVersion }; + } + + return null; +} + +/** + * True when a package.json object declares an OpenSpec plugin manifest key. + * Used by auto-detection to filter candidates cheaply without full validation. + */ +export function packageDeclaresPlugin(pkg: Record | null | undefined): boolean { + return !!pkg && typeof pkg === 'object' && PACKAGE_MANIFEST_KEY in pkg; +} diff --git a/src/core/plugins/registry.ts b/src/core/plugins/registry.ts new file mode 100644 index 000000000..dec03d2b2 --- /dev/null +++ b/src/core/plugins/registry.ts @@ -0,0 +1,97 @@ +/** + * The curated plugin marketplace registry. + * + * The registry is a versioned JSON document shipped with the package + * (`schemas/plugins/registry.json`). It powers discovery (`plugin search`) and + * `plugin add ` for listed plugins. There is no network dependency: a future + * change can add a refreshable remote index behind the same interface. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { getGlobalConfig } from '../global-config.js'; + +export const SUPPORTED_REGISTRY_VERSION = 1; + +export interface RegistryEntry { + id: string; + npm: string; + namespace: string; + openspecCompat: string; + summary: string; + homepage?: string; +} + +interface RegistryDocument { + registryVersion: number; + plugins: RegistryEntry[]; +} + +export class RegistryError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryError'; + } +} + +/** Path to the built-in registry index shipped with the package. */ +export function getPackageRegistryPath(): string { + const currentFile = fileURLToPath(import.meta.url); + // dist/core/plugins/ -> package root -> schemas/plugins/registry.json + return path.join(path.dirname(currentFile), '..', '..', '..', 'schemas', 'plugins', 'registry.json'); +} + +/** Resolve the active registry path (global config override wins over the built-in). */ +export function resolveRegistryPath(): string { + const override = getGlobalConfig().plugins?.registry; + if (override && override.trim() !== '') { + return path.resolve(override); + } + return getPackageRegistryPath(); +} + +/** + * Load the registry index. Throws RegistryError on an unsupported version or + * unreadable/malformed document so callers can report it instead of guessing. + */ +export function loadRegistry(registryPath: string = resolveRegistryPath()): RegistryEntry[] { + if (!fs.existsSync(registryPath)) { + throw new RegistryError(`Plugin registry not found at ${registryPath}`); + } + + let doc: RegistryDocument; + try { + doc = JSON.parse(fs.readFileSync(registryPath, 'utf-8')); + } catch (error) { + throw new RegistryError(`Plugin registry is not valid JSON: ${(error as Error).message}`); + } + + if (typeof doc.registryVersion !== 'number') { + throw new RegistryError('Plugin registry is missing a numeric "registryVersion"'); + } + if (doc.registryVersion > SUPPORTED_REGISTRY_VERSION) { + throw new RegistryError( + `Plugin registry version ${doc.registryVersion} is newer than supported version ${SUPPORTED_REGISTRY_VERSION}. Update OpenSpec to use it.` + ); + } + if (!Array.isArray(doc.plugins)) { + throw new RegistryError('Plugin registry "plugins" must be an array'); + } + + return doc.plugins; +} + +/** Find a single registry entry by id or npm package name. */ +export function findRegistryEntry(idOrNpm: string, entries: RegistryEntry[] = loadRegistry()): RegistryEntry | undefined { + return entries.find((e) => e.id === idOrNpm || e.npm === idOrNpm); +} + +/** Filter registry entries by a free-text query over id, npm, summary, and namespace. */ +export function searchRegistry(query: string | undefined, entries: RegistryEntry[] = loadRegistry()): RegistryEntry[] { + if (!query || query.trim() === '') return entries; + const q = query.toLowerCase(); + return entries.filter((e) => + [e.id, e.npm, e.namespace, e.summary].some((field) => field.toLowerCase().includes(q)) + ); +} diff --git a/src/core/plugins/resolver.ts b/src/core/plugins/resolver.ts new file mode 100644 index 000000000..0a38f3e72 --- /dev/null +++ b/src/core/plugins/resolver.ts @@ -0,0 +1,211 @@ +/** + * Plugin resolution. + * + * Discovers plugin manifests from three sources and resolves the active set, + * mirroring the precedence model of the schema resolver: + * + * 1. Project — ids enabled in openspec/config.yaml, resolved from node_modules + * 2. User — manifests in the user/global plugins directory + * 3. Auto-detect — installed packages that declare a manifest (gated by config) + * + * Resolution reads and validates manifests only; it never imports plugin code. + * Results are memoized per project root for the life of the process. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createRequire } from 'node:module'; +import fg from 'fast-glob'; +import { getGlobalConfig, getGlobalDataDir } from '../global-config.js'; +import { loadManifestFromRoot, packageDeclaresPlugin, RESERVED_NAMESPACES } from './manifest.js'; +import { satisfies } from './semver.js'; +import { readProjectPluginConfig, isAutoDetectEnabled } from './config.js'; +import type { + PluginCollision, + PluginLoadError, + PluginResolution, + PluginSourceTier, + ResolvedPlugin, +} from './types.js'; + +const require = createRequire(import.meta.url); + +let cachedVersion: string | undefined; + +/** The running OpenSpec version, read from the package manifest. */ +export function getOpenSpecVersion(): string { + if (cachedVersion) return cachedVersion; + try { + const pkg = require('../../../package.json') as { version: string }; + cachedVersion = pkg.version; + } catch { + cachedVersion = '0.0.0'; + } + return cachedVersion; +} + +/** Path to the user/global plugins directory. */ +export function getUserPluginsDir(): string { + return path.join(getGlobalDataDir(), 'plugins'); +} + +interface Candidate { + root: string; + /** 'node_modules' candidates may become 'project' or 'auto-detect'; 'user' is fixed. */ + origin: 'node_modules' | 'user'; +} + +function discoverNodeModulesCandidates(projectRoot: string): Candidate[] { + const nodeModules = path.join(projectRoot, 'node_modules'); + if (!fs.existsSync(nodeModules)) return []; + + // Top-level and single-level scoped package manifests only — plugins are direct deps. + const matches = fg.sync(['*/package.json', '@*/*/package.json'], { + cwd: nodeModules, + absolute: true, + dot: false, + suppressErrors: true, + }); + + const candidates: Candidate[] = []; + for (const pkgJsonPath of matches) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + if (packageDeclaresPlugin(pkg)) { + candidates.push({ root: path.dirname(pkgJsonPath), origin: 'node_modules' }); + } + } catch { + // Ignore unreadable package.json files during discovery. + } + } + return candidates; +} + +function discoverUserCandidates(): Candidate[] { + const dir = getUserPluginsDir(); + if (!fs.existsSync(dir)) return []; + const candidates: Candidate[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory() || entry.isSymbolicLink()) { + candidates.push({ root: path.join(dir, entry.name), origin: 'user' }); + } + } + return candidates; +} + +const cache = new Map(); + +/** Clear the resolution cache (test support). */ +export function clearPluginResolutionCache(): void { + cache.clear(); +} + +/** + * Resolve the active plugin set for a project. Reads manifests only. + */ +export function resolvePlugins(projectRoot: string = process.cwd()): PluginResolution { + const key = path.resolve(projectRoot); + const cached = cache.get(key); + if (cached) return cached; + + const version = getOpenSpecVersion(); + const projectEnabled = new Set(readProjectPluginConfig(projectRoot).enabled); + const globalEnabled = new Set(getGlobalConfig().plugins?.enabled ?? []); + const autoDetect = isAutoDetectEnabled(projectRoot); + + const errors: PluginLoadError[] = []; + // Best resolved plugin per id, keyed by id with tier precedence applied. + const byId = new Map(); + + const candidates = [...discoverNodeModulesCandidates(projectRoot), ...discoverUserCandidates()]; + + for (const candidate of candidates) { + let loaded; + try { + loaded = loadManifestFromRoot(candidate.root, RESERVED_NAMESPACES); + } catch (error) { + errors.push({ + id: path.basename(candidate.root), + packageRoot: candidate.root, + source: candidate.origin === 'user' ? 'user' : 'auto-detect', + error: (error as Error).message, + }); + continue; + } + if (!loaded) continue; + + const { manifest, version: pkgVersion } = loaded; + const id = manifest.id; + + let source: PluginSourceTier; + let enabled: boolean; + if (candidate.origin === 'user') { + source = 'user'; + enabled = true; + } else if (projectEnabled.has(id)) { + source = 'project'; + enabled = true; + } else { + source = 'auto-detect'; + enabled = autoDetect || globalEnabled.has(id); + } + + const resolved: ResolvedPlugin = { + id, + namespace: manifest.namespace, + manifest, + packageRoot: candidate.root, + source, + version: pkgVersion, + compatible: satisfies(version, manifest.openspecCompat), + enabled, + }; + + const existing = byId.get(id); + if (!existing || tierRank(resolved.source) > tierRank(existing.source)) { + byId.set(id, resolved); + } + } + + const plugins = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); + const collisions = detectCollisions(plugins); + + const resolution: PluginResolution = { plugins, errors, collisions }; + cache.set(key, resolution); + return resolution; +} + +function tierRank(source: PluginSourceTier): number { + return source === 'project' ? 3 : source === 'user' ? 2 : 1; +} + +function detectCollisions(plugins: ResolvedPlugin[]): PluginCollision[] { + const collisions: PluginCollision[] = []; + const active = plugins.filter((p) => p.enabled && p.compatible); + + const byNamespace = new Map(); + for (const plugin of active) { + const list = byNamespace.get(plugin.namespace) ?? []; + list.push(plugin); + byNamespace.set(plugin.namespace, list); + } + for (const [namespace, group] of byNamespace) { + if (group.length > 1) { + collisions.push({ kind: 'namespace', value: namespace, pluginRoots: group.map((p) => p.packageRoot) }); + } + } + return collisions; +} + +/** Namespaces involved in a collision should not be registered. */ +export function collidingNamespaces(resolution: PluginResolution): Set { + return new Set(resolution.collisions.filter((c) => c.kind === 'namespace').map((c) => c.value)); +} + +/** Plugins that should be surfaced as runnable commands: enabled, compatible, non-colliding. */ +export function activePlugins(resolution: PluginResolution): ResolvedPlugin[] { + const colliding = collidingNamespaces(resolution); + return resolution.plugins.filter( + (p) => p.enabled && p.compatible && !colliding.has(p.namespace) + ); +} diff --git a/src/core/plugins/runtime.ts b/src/core/plugins/runtime.ts new file mode 100644 index 000000000..ea9af2b6c --- /dev/null +++ b/src/core/plugins/runtime.ts @@ -0,0 +1,124 @@ +/** + * Plugin command surfacing and delegation. + * + * Each active plugin is surfaced as one reserved top-level namespace. Execution + * is delegated to the plugin's own executable as a child process (inherited + * stdio, propagated exit code) — plugin code is never loaded into this process. + * + * Two cooperating mechanisms: + * - `registerPlugins` adds a Commander command per namespace so plugins appear + * in `--help` and completion, and as a safety-net execution path. + * - `maybeDelegateEarly` runs before Commander parses argv and forwards a + * namespace invocation verbatim (including `--help` and unknown flags), + * which Commander's own parsing would otherwise mangle. + */ + +import * as path from 'node:path'; +import { spawnSync as nodeSpawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import type { Command } from 'commander'; +import type { ResolvedPlugin } from './types.js'; +import { resolvePlugins, activePlugins } from './resolver.js'; + +// cross-spawn ships no types; cast to node's spawnSync signature (repo pattern). +const require = createRequire(import.meta.url); +const crossSpawn = require('cross-spawn') as { sync: typeof nodeSpawnSync }; + +/** Resolve the [command, baseArgs] used to launch a plugin. */ +function resolveLauncher(plugin: ResolvedPlugin): { command: string; baseArgs: string[] } { + const { manifest, packageRoot } = plugin; + if (manifest.bin) { + // Run the plugin's JS entrypoint with the current Node — avoids shell/.cmd shims. + return { command: process.execPath, baseArgs: [path.join(packageRoot, manifest.bin)] }; + } + if (manifest.binArgs && manifest.binArgs.length > 0) { + return { command: manifest.binArgs[0], baseArgs: manifest.binArgs.slice(1) }; + } + // Should not happen: manifest validation guarantees one of the above. + throw new Error(`Plugin "${plugin.id}" declares no executable`); +} + +/** + * Delegate to a plugin executable. Returns the child's exit code. + * Inherits stdio so the plugin owns the terminal session. + */ +export function delegateToPlugin(plugin: ResolvedPlugin, args: string[]): number { + const { command, baseArgs } = resolveLauncher(plugin); + const result = crossSpawn.sync(command, [...baseArgs, ...args], { + stdio: 'inherit', + cwd: process.cwd(), + }); + + if (result.error) { + const err = result.error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + console.error( + `Error: could not launch plugin "${plugin.id}". The executable was not found.\n` + + `Try reinstalling the plugin package, e.g. "npm install ${plugin.id}".` + ); + } else { + console.error(`Error launching plugin "${plugin.id}": ${err.message}`); + } + return 1; + } + + if (typeof result.status === 'number') return result.status; + // Terminated by signal. + return 1; +} + +/** + * If the first non-option token in argv is an active plugin namespace, delegate + * to that plugin with the remaining args verbatim and return true. The caller + * must then skip Commander parsing. Returns false when no namespace matches. + */ +export function maybeDelegateEarly( + argv: string[], + projectRoot: string = process.cwd() +): boolean { + // argv is the full process argv: [node, script, ...rest] + const rest = argv.slice(2); + const tokenIndex = rest.findIndex((token) => !token.startsWith('-')); + if (tokenIndex === -1) return false; + + const namespace = rest[tokenIndex]; + const plugins = activePlugins(resolvePlugins(projectRoot)); + const plugin = plugins.find((p) => p.namespace === namespace); + if (!plugin) return false; + + const forwarded = rest.slice(tokenIndex + 1); + const code = delegateToPlugin(plugin, forwarded); + process.exitCode = code; + return true; +} + +/** + * Register a Commander command per active plugin namespace for discoverability. + * Execution normally happens via `maybeDelegateEarly`; this path is a safety net. + */ +export function registerPlugins( + program: Command, + projectRoot: string = process.cwd() +): void { + const plugins = activePlugins(resolvePlugins(projectRoot)); + + for (const plugin of plugins) { + const summary = + plugin.manifest.summary ?? plugin.manifest.displayName ?? `${plugin.id} plugin commands`; + + const cmd = program + .command(`${plugin.namespace} [args...]`) + .description(`[plugin] ${summary}`) + .allowUnknownOption(true) + .helpOption(false) + .action((args: string[] = []) => { + const code = delegateToPlugin(plugin, args); + process.exitCode = code; + }); + + // Let unknown options/positionals flow through to the plugin rather than be parsed. + if (typeof (cmd as { passThroughOptions?: (v?: boolean) => Command }).passThroughOptions === 'function') { + (cmd as { passThroughOptions: (v?: boolean) => Command }).passThroughOptions(true); + } + } +} diff --git a/src/core/plugins/semver.ts b/src/core/plugins/semver.ts new file mode 100644 index 000000000..c9a9d752c --- /dev/null +++ b/src/core/plugins/semver.ts @@ -0,0 +1,119 @@ +/** + * Minimal semver range satisfier. + * + * OpenSpec deliberately ships no `semver` dependency (the plugin system must add + * no required runtime dependency). Plugin `openspecCompat` ranges use a small, + * well-known subset that this module supports: + * + * * any version + * 1.2.3 exact match + * >=1.2.3 >1.2.3 lower bound (with/without equality) + * <=1.2.3 <1.2.3 upper bound (with/without equality) + * ^1.2.3 compatible-with (same major, or same minor when major is 0) + * ~1.2.3 approximately (same minor) + * 1.x / 1.2.x wildcard segments + * + * Multiple space-separated comparators are ANDed (e.g. ">=1.4.0 <2.0.0"). + * Prerelease tags are ignored for comparison purposes. + */ + +interface Version { + major: number; + minor: number; + patch: number; +} + +function parseVersion(input: string): Version | null { + const cleaned = input.trim().replace(/^v/, '').split('-')[0].split('+')[0]; + const parts = cleaned.split('.'); + if (parts.length === 0 || parts.length > 3) return null; + const nums = parts.map((p) => (p === 'x' || p === 'X' || p === '*' ? 0 : Number(p))); + if (nums.some((n) => !Number.isInteger(n) || n < 0)) return null; + return { major: nums[0] ?? 0, minor: nums[1] ?? 0, patch: nums[2] ?? 0 }; +} + +function compare(a: Version, b: Version): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + return a.patch - b.patch; +} + +function satisfiesComparator(version: Version, comparator: string): boolean { + const c = comparator.trim(); + if (c === '' || c === '*' || c === 'x' || c === 'X') return true; + + // Operator-prefixed comparators. + const opMatch = c.match(/^(>=|<=|>|<|=)?\s*(.+)$/); + if (!opMatch) return false; + const op = opMatch[1] ?? '='; + const operand = opMatch[2]; + + // Caret and tilde are handled separately (operand keeps the prefix). + if (operand.startsWith('^')) return satisfiesCaret(version, operand.slice(1)); + if (operand.startsWith('~')) return satisfiesTilde(version, operand.slice(1)); + + // Wildcard ranges like 1.x or 1.2.x become a bounded range. + if (/[xX*]/.test(operand) && op === '=') { + return satisfiesWildcard(version, operand); + } + + const target = parseVersion(operand); + if (!target) return false; + const cmp = compare(version, target); + switch (op) { + case '>': + return cmp > 0; + case '>=': + return cmp >= 0; + case '<': + return cmp < 0; + case '<=': + return cmp <= 0; + case '=': + default: + return cmp === 0; + } +} + +function satisfiesCaret(version: Version, operand: string): boolean { + const target = parseVersion(operand); + if (!target) return false; + if (compare(version, target) < 0) return false; + if (target.major > 0) return version.major === target.major; + if (target.minor > 0) return version.major === 0 && version.minor === target.minor; + return version.major === 0 && version.minor === 0 && version.patch === target.patch; +} + +function satisfiesTilde(version: Version, operand: string): boolean { + const target = parseVersion(operand); + if (!target) return false; + if (compare(version, target) < 0) return false; + return version.major === target.major && version.minor === target.minor; +} + +function satisfiesWildcard(version: Version, operand: string): boolean { + const parts = operand.trim().replace(/^v/, '').split('.'); + for (let i = 0; i < parts.length; i++) { + const seg = parts[i]; + if (seg === 'x' || seg === 'X' || seg === '*' || seg === '') continue; + const num = Number(seg); + if (!Number.isInteger(num)) return false; + const versionSeg = i === 0 ? version.major : i === 1 ? version.minor : version.patch; + if (versionSeg !== num) return false; + } + return true; +} + +/** + * Returns true when `version` satisfies the comparator `range`. + * Space-separated comparators are ANDed. Returns false on unparseable input. + */ +export function satisfies(version: string, range: string): boolean { + const v = parseVersion(version); + if (!v) return false; + const trimmed = range.trim(); + if (trimmed === '' || trimmed === '*') return true; + + const comparators = trimmed.split(/\s+/).filter(Boolean); + return comparators.every((comparator) => satisfiesComparator(v, comparator)); +} diff --git a/src/core/plugins/types.ts b/src/core/plugins/types.ts new file mode 100644 index 000000000..4387ed540 --- /dev/null +++ b/src/core/plugins/types.ts @@ -0,0 +1,89 @@ +/** + * Shared types for the OpenSpec plugin system. + * + * A plugin is an npm package (or a manifest in the user plugins directory) that + * declares an OpenSpec manifest. OpenSpec discovers plugins, surfaces each under + * a reserved command namespace, and delegates execution to the plugin's own + * executable as a child process. Plugin code is never imported into the OpenSpec + * process; resolution reads manifests only. + */ + +/** The tier a plugin was resolved from, in precedence order. */ +export type PluginSourceTier = 'project' | 'user' | 'auto-detect'; + +/** A subcommand a plugin surfaces, used for help text and completion only. */ +export interface PluginCommandDescriptor { + name: string; + summary?: string; +} + +/** A skill a plugin contributes for installation into AI tool directories. */ +export interface PluginSkillContribution { + /** Directory name the skill is installed as (e.g. "openlore-orient"). */ + dir: string; + /** Path to the skill source within the plugin package. */ + source: string; +} + +/** + * The declarative plugin manifest. Published either as the `"openspec"` key in a + * package's package.json, or as a standalone `openspec.plugin.json` file. + */ +export interface PluginManifest { + manifestVersion: number; + id: string; + namespace: string; + /** Executable path relative to the package root (preferred). */ + bin?: string; + /** Command + args to invoke instead of `bin` (e.g. ["npx", "openlore"]). */ + binArgs?: string[]; + /** Semver range of OpenSpec versions this plugin supports. */ + openspecCompat: string; + displayName?: string; + summary?: string; + commands?: PluginCommandDescriptor[]; + skills?: PluginSkillContribution[]; + workflows?: string[]; + ownsConfigKeys?: string[]; + /** Unknown fields are preserved for forward compatibility. */ + [key: string]: unknown; +} + +/** A plugin discovered on disk, with its manifest and provenance. */ +export interface ResolvedPlugin { + id: string; + namespace: string; + manifest: PluginManifest; + /** Absolute path to the package/manifest root. */ + packageRoot: string; + source: PluginSourceTier; + /** Resolved package version, when available. */ + version?: string; + /** True when the plugin's openspecCompat range includes the running version. */ + compatible: boolean; + /** Whether the plugin is enabled (project/global config or user-tier presence). */ + enabled: boolean; +} + +/** A plugin manifest that failed to load or validate. */ +export interface PluginLoadError { + /** Best-effort id (manifest id, or package name) for reporting. */ + id: string; + packageRoot: string; + source: PluginSourceTier; + error: string; +} + +/** A namespace/id collision between two resolved plugins. */ +export interface PluginCollision { + kind: 'id' | 'namespace'; + value: string; + pluginRoots: string[]; +} + +/** The full result of resolving the active plugin set. */ +export interface PluginResolution { + plugins: ResolvedPlugin[]; + errors: PluginLoadError[]; + collisions: PluginCollision[]; +} From 34ae59cc95ace2d644301692822e1e08fbc819ae Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:41:27 -0500 Subject: [PATCH 04/20] test(plugins): unit + e2e coverage for the plugin system - semver range satisfier (bounds, caret, tilde, wildcard, prerelease) - manifest validation + loading (both forms, reserved names, version gate, forward-compat passthrough, actionable errors) - resolver (auto-detect on/off, project tier, compat gating, invalid-manifest errors, namespace collisions, missing node_modules, XDG user dir) - config (non-destructive enable preserving openlore block + comments, idempotent enable, targeted disable) - registry (built-in OpenLore listing, search, unsupported-version + malformed rejection) - cli-e2e: auto-detect listing, verbatim delegation, exit-code propagation, enable preserving unknown config keys, registry search 48 tests, all passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/cli-e2e/plugin.test.ts | 114 ++++++++++++++++++++++ test/core/plugins/config.test.ts | 89 +++++++++++++++++ test/core/plugins/manifest.test.ts | 132 +++++++++++++++++++++++++ test/core/plugins/registry.test.ts | 65 +++++++++++++ test/core/plugins/resolver.test.ts | 150 +++++++++++++++++++++++++++++ test/core/plugins/semver.test.ts | 56 +++++++++++ 6 files changed, 606 insertions(+) create mode 100644 test/cli-e2e/plugin.test.ts create mode 100644 test/core/plugins/config.test.ts create mode 100644 test/core/plugins/manifest.test.ts create mode 100644 test/core/plugins/registry.test.ts create mode 100644 test/core/plugins/resolver.test.ts create mode 100644 test/core/plugins/semver.test.ts diff --git a/test/cli-e2e/plugin.test.ts b/test/cli-e2e/plugin.test.ts new file mode 100644 index 000000000..d52b35e14 --- /dev/null +++ b/test/cli-e2e/plugin.test.ts @@ -0,0 +1,114 @@ +import { afterAll, beforeAll, describe, it, expect } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { runCLI } from '../helpers/run-cli.js'; + +const tempRoots: string[] = []; + +/** + * Build a temp project containing an OpenSpec dir and a fixture plugin installed + * under node_modules with a manifest and a stub executable. + */ +async function prepareProjectWithPlugin(): Promise { + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-plugin-e2e-')); + tempRoots.push(base); + const projectDir = path.join(base, 'project'); + await fs.mkdir(path.join(projectDir, 'openspec'), { recursive: true }); + await fs.writeFile( + path.join(projectDir, 'openspec', 'config.yaml'), + 'schema: spec-driven\n\nopenlore:\n version: "2.1.3"\n' + ); + + const pluginDir = path.join(projectDir, 'node_modules', 'demo-engine'); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'demo-engine', + version: '0.4.0', + bin: 'cli.js', + openspec: { + manifestVersion: 1, + id: 'demo-engine', + namespace: 'demo', + bin: 'cli.js', + openspecCompat: '>=1.0.0', + summary: 'A demo delegated engine', + commands: [{ name: 'hello', summary: 'say hello' }], + }, + }) + ); + await fs.writeFile( + path.join(pluginDir, 'cli.js'), + [ + '#!/usr/bin/env node', + 'const args = process.argv.slice(2);', + 'console.log("demo-engine:" + args.join(" "));', + 'process.exit(args.includes("--fail") ? 7 : 0);', + '', + ].join('\n') + ); + + return projectDir; +} + +// Isolate global config / user data away from the developer's real home. +function isolatedEnv(base: string): NodeJS.ProcessEnv { + return { + OPENSPEC_TELEMETRY: '0', + XDG_CONFIG_HOME: path.join(base, '.config'), + XDG_DATA_HOME: path.join(base, '.data'), + }; +} + +afterAll(async () => { + await Promise.all(tempRoots.map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('openspec plugin e2e', () => { + let projectDir: string; + let env: NodeJS.ProcessEnv; + + beforeAll(async () => { + projectDir = await prepareProjectWithPlugin(); + env = isolatedEnv(path.dirname(projectDir)); + }); + + it('lists an auto-detected plugin', async () => { + const result = await runCLI(['plugin', 'list', '--json'], { cwd: projectDir, env }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + const demo = parsed.plugins.find((p: { id: string }) => p.id === 'demo-engine'); + expect(demo).toBeDefined(); + expect(demo.namespace).toBe('demo'); + expect(demo.status).toBe('enabled'); + }); + + it('delegates a namespace command and passes args through', async () => { + const result = await runCLI(['demo', 'hello', '--name', 'x'], { cwd: projectDir, env }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('demo-engine:hello --name x'); + }); + + it('propagates the plugin exit code', async () => { + const result = await runCLI(['demo', 'run', '--fail'], { cwd: projectDir, env }); + expect(result.exitCode).toBe(7); + }); + + it('enables a plugin while preserving unknown config keys', async () => { + const result = await runCLI(['plugin', 'add', 'demo-engine'], { cwd: projectDir, env }); + expect(result.exitCode).toBe(0); + const config = await fs.readFile(path.join(projectDir, 'openspec', 'config.yaml'), 'utf-8'); + expect(config).toContain('openlore:'); + expect(config).toContain('version: "2.1.3"'); + expect(config).toMatch(/plugins:\s*\n\s*enabled:\s*\n\s*-\s*demo-engine/); + }); + + it('search surfaces the OpenLore registry listing', async () => { + const result = await runCLI(['plugin', 'search', 'lore', '--json'], { cwd: projectDir, env }); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.plugins.some((p: { id: string }) => p.id === 'openlore')).toBe(true); + }); +}); diff --git a/test/core/plugins/config.test.ts b/test/core/plugins/config.test.ts new file mode 100644 index 000000000..83c6cc691 --- /dev/null +++ b/test/core/plugins/config.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + readProjectPluginConfig, + enableProjectPlugin, + disableProjectPlugin, +} from '../../../src/core/plugins/config.js'; + +const CONFIG_WITH_OPENLORE = `schema: spec-driven + +# user comment that must survive +context: | + My project context. + +openlore: + version: "2.1.3" + domains: + - api + - billing +`; + +describe('plugins/config project enablement', () => { + let projectRoot: string; + + beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-plugin-config-')); + fs.mkdirSync(path.join(projectRoot, 'openspec'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); + }); + + function writeConfig(body: string): void { + fs.writeFileSync(path.join(projectRoot, 'openspec', 'config.yaml'), body); + } + + function readConfig(): string { + return fs.readFileSync(path.join(projectRoot, 'openspec', 'config.yaml'), 'utf-8'); + } + + it('reads no enabled plugins when there is no plugins block', () => { + writeConfig('schema: spec-driven\n'); + expect(readProjectPluginConfig(projectRoot)).toEqual({ enabled: [], autoDetect: undefined }); + }); + + it('reads enabled ids and autoDetect override', () => { + writeConfig('schema: spec-driven\nplugins:\n autoDetect: false\n enabled:\n - openlore\n'); + const result = readProjectPluginConfig(projectRoot); + expect(result.enabled).toEqual(['openlore']); + expect(result.autoDetect).toBe(false); + }); + + it('enabling preserves unknown keys, comments, and other config', () => { + writeConfig(CONFIG_WITH_OPENLORE); + expect(enableProjectPlugin(projectRoot, 'openlore')).toBe(true); + + const after = readConfig(); + // Unknown third-party block preserved. + expect(after).toContain('openlore:'); + expect(after).toContain('version: "2.1.3"'); + // Comment preserved. + expect(after).toContain('# user comment that must survive'); + // Core keys preserved. + expect(after).toContain('schema: spec-driven'); + expect(after).toContain('My project context.'); + // New enablement recorded. + expect(readProjectPluginConfig(projectRoot).enabled).toEqual(['openlore']); + }); + + it('enabling is idempotent', () => { + writeConfig('schema: spec-driven\n'); + enableProjectPlugin(projectRoot, 'openlore'); + enableProjectPlugin(projectRoot, 'openlore'); + expect(readProjectPluginConfig(projectRoot).enabled).toEqual(['openlore']); + }); + + it('disabling removes only the named plugin', () => { + writeConfig('schema: spec-driven\nplugins:\n enabled:\n - openlore\n - demo\n'); + disableProjectPlugin(projectRoot, 'openlore'); + expect(readProjectPluginConfig(projectRoot).enabled).toEqual(['demo']); + }); + + it('returns false writing when there is no config file', () => { + expect(enableProjectPlugin(projectRoot, 'openlore')).toBe(false); + }); +}); diff --git a/test/core/plugins/manifest.test.ts b/test/core/plugins/manifest.test.ts new file mode 100644 index 000000000..b447580e3 --- /dev/null +++ b/test/core/plugins/manifest.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + validateManifest, + loadManifestFromRoot, + packageDeclaresPlugin, + RESERVED_NAMESPACES, +} from '../../../src/core/plugins/manifest.js'; + +const validManifest = { + manifestVersion: 1, + id: 'demo-engine', + namespace: 'demo', + bin: 'cli.js', + openspecCompat: '>=1.0.0', +}; + +describe('plugins/manifest validateManifest', () => { + it('accepts a minimal valid manifest', () => { + const result = validateManifest(validManifest); + expect(result.valid).toBe(true); + expect(result.manifest?.namespace).toBe('demo'); + }); + + it('rejects a manifest missing an executable', () => { + const { bin, ...noBin } = validManifest; + const result = validateManifest(noBin); + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/executable|bin/i); + }); + + it('rejects a missing required field', () => { + const { openspecCompat, ...missing } = validManifest; + const result = validateManifest(missing); + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/openspecCompat/); + }); + + it('rejects a reserved namespace', () => { + const result = validateManifest({ ...validManifest, namespace: 'init' }); + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/reserved/); + }); + + it('rejects an invalid namespace format', () => { + const result = validateManifest({ ...validManifest, namespace: 'Demo Engine' }); + expect(result.valid).toBe(false); + }); + + it('rejects a manifestVersion newer than supported', () => { + const result = validateManifest({ ...validManifest, manifestVersion: 99 }); + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/newer than supported/); + }); + + it('preserves unknown fields (forward compatibility)', () => { + const result = validateManifest({ ...validManifest, futureField: { a: 1 } }); + expect(result.valid).toBe(true); + expect((result.manifest as Record).futureField).toEqual({ a: 1 }); + }); + + it('keeps a stable reserved namespace set', () => { + expect(RESERVED_NAMESPACES).toContain('init'); + expect(RESERVED_NAMESPACES).toContain('spec'); + expect(RESERVED_NAMESPACES).toContain('plugin'); + }); +}); + +describe('plugins/manifest loadManifestFromRoot', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-manifest-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('loads from the package.json "openspec" key', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'demo-engine', version: '0.4.0', openspec: validManifest }) + ); + const loaded = loadManifestFromRoot(tempDir); + expect(loaded?.origin).toBe('package.json'); + expect(loaded?.version).toBe('0.4.0'); + expect(loaded?.manifest.id).toBe('demo-engine'); + }); + + it('falls back to openspec.plugin.json', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'x', version: '1.0.0' })); + fs.writeFileSync(path.join(tempDir, 'openspec.plugin.json'), JSON.stringify(validManifest)); + const loaded = loadManifestFromRoot(tempDir); + expect(loaded?.origin).toBe('manifest-file'); + }); + + it('prefers the package.json key over the standalone file', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ openspec: { ...validManifest, namespace: 'frompkg' } }) + ); + fs.writeFileSync( + path.join(tempDir, 'openspec.plugin.json'), + JSON.stringify({ ...validManifest, namespace: 'fromfile' }) + ); + expect(loadManifestFromRoot(tempDir)?.manifest.namespace).toBe('frompkg'); + }); + + it('returns null when no manifest is present', () => { + fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({ name: 'plain' })); + expect(loadManifestFromRoot(tempDir)).toBeNull(); + }); + + it('throws an actionable error on an invalid manifest', () => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ openspec: { manifestVersion: 1, id: 'x' } }) + ); + expect(() => loadManifestFromRoot(tempDir)).toThrow(/Invalid OpenSpec plugin manifest/); + }); +}); + +describe('plugins/manifest packageDeclaresPlugin', () => { + it('detects the manifest key', () => { + expect(packageDeclaresPlugin({ openspec: {} })).toBe(true); + expect(packageDeclaresPlugin({ name: 'x' })).toBe(false); + expect(packageDeclaresPlugin(null)).toBe(false); + }); +}); diff --git a/test/core/plugins/registry.test.ts b/test/core/plugins/registry.test.ts new file mode 100644 index 000000000..92c3acc40 --- /dev/null +++ b/test/core/plugins/registry.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + loadRegistry, + searchRegistry, + findRegistryEntry, + getPackageRegistryPath, + RegistryError, +} from '../../../src/core/plugins/registry.js'; + +describe('plugins/registry built-in index', () => { + it('ships a parseable registry with the OpenLore listing', () => { + const entries = loadRegistry(getPackageRegistryPath()); + const openlore = entries.find((e) => e.id === 'openlore'); + expect(openlore).toBeDefined(); + expect(openlore?.npm).toBe('openlore'); + expect(openlore?.namespace).toBe('lore'); + expect(openlore?.openspecCompat).toBeTruthy(); + }); + + it('finds entries by id or npm name', () => { + const entries = loadRegistry(getPackageRegistryPath()); + expect(findRegistryEntry('openlore', entries)?.id).toBe('openlore'); + expect(findRegistryEntry('lore', entries)).toBeUndefined(); + }); + + it('filters with a query', () => { + const entries = loadRegistry(getPackageRegistryPath()); + // OpenLore's summary mentions "Reverse-engineer". + expect(searchRegistry('reverse', entries).map((e) => e.id)).toContain('openlore'); + expect(searchRegistry('openlore', entries).length).toBe(1); + expect(searchRegistry('no-such-plugin-xyz', entries).length).toBe(0); + }); +}); + +describe('plugins/registry version handling', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-registry-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('rejects an unsupported registry version', () => { + const p = path.join(tempDir, 'registry.json'); + fs.writeFileSync(p, JSON.stringify({ registryVersion: 99, plugins: [] })); + expect(() => loadRegistry(p)).toThrow(RegistryError); + expect(() => loadRegistry(p)).toThrow(/newer than supported/); + }); + + it('rejects a malformed registry', () => { + const p = path.join(tempDir, 'registry.json'); + fs.writeFileSync(p, '{ not json'); + expect(() => loadRegistry(p)).toThrow(RegistryError); + }); + + it('throws when the registry file is missing', () => { + expect(() => loadRegistry(path.join(tempDir, 'nope.json'))).toThrow(RegistryError); + }); +}); diff --git a/test/core/plugins/resolver.test.ts b/test/core/plugins/resolver.test.ts new file mode 100644 index 000000000..c3ea935bb --- /dev/null +++ b/test/core/plugins/resolver.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + resolvePlugins, + activePlugins, + clearPluginResolutionCache, + getUserPluginsDir, +} from '../../../src/core/plugins/resolver.js'; + +interface PluginSpec { + id: string; + namespace: string; + compat?: string; + version?: string; +} + +function writePackagePlugin(projectRoot: string, pkgName: string, spec: PluginSpec): void { + const dir = path.join(projectRoot, 'node_modules', pkgName); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name: pkgName, + version: spec.version ?? '1.0.0', + bin: 'cli.js', + openspec: { + manifestVersion: 1, + id: spec.id, + namespace: spec.namespace, + bin: 'cli.js', + openspecCompat: spec.compat ?? '>=1.0.0', + }, + }) + ); + fs.writeFileSync(path.join(dir, 'cli.js'), '#!/usr/bin/env node\n'); +} + +function writeProjectConfig(projectRoot: string, body: string): void { + fs.mkdirSync(path.join(projectRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'openspec', 'config.yaml'), body); +} + +describe('plugins/resolver', () => { + let tempDir: string; + let projectRoot: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-plugins-')); + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + originalEnv = { ...process.env }; + // Isolate global config and user plugins dir into the temp tree. + process.env.XDG_CONFIG_HOME = path.join(tempDir, 'config'); + process.env.XDG_DATA_HOME = path.join(tempDir, 'data'); + clearPluginResolutionCache(); + }); + + afterEach(() => { + process.env = originalEnv; + clearPluginResolutionCache(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('auto-detects an installed plugin when autoDetect is on (default)', () => { + writeProjectConfig(projectRoot, 'schema: spec-driven\n'); + writePackagePlugin(projectRoot, 'demo-engine', { id: 'demo-engine', namespace: 'demo' }); + + const resolution = resolvePlugins(projectRoot); + const demo = resolution.plugins.find((p) => p.id === 'demo-engine'); + expect(demo).toBeDefined(); + expect(demo?.enabled).toBe(true); + expect(demo?.source).toBe('auto-detect'); + expect(demo?.compatible).toBe(true); + }); + + it('does not enable auto-detected plugins when autoDetect is off', () => { + writeProjectConfig(projectRoot, 'schema: spec-driven\nplugins:\n autoDetect: false\n'); + writePackagePlugin(projectRoot, 'demo-engine', { id: 'demo-engine', namespace: 'demo' }); + + const resolution = resolvePlugins(projectRoot); + const demo = resolution.plugins.find((p) => p.id === 'demo-engine'); + expect(demo?.enabled).toBe(false); + expect(activePlugins(resolution).length).toBe(0); + }); + + it('marks a project-enabled plugin as project tier even with autoDetect off', () => { + writeProjectConfig( + projectRoot, + 'schema: spec-driven\nplugins:\n autoDetect: false\n enabled:\n - demo-engine\n' + ); + writePackagePlugin(projectRoot, 'demo-engine', { id: 'demo-engine', namespace: 'demo' }); + + const resolution = resolvePlugins(projectRoot); + const demo = resolution.plugins.find((p) => p.id === 'demo-engine'); + expect(demo?.source).toBe('project'); + expect(demo?.enabled).toBe(true); + }); + + it('gates incompatible plugins out of the active set', () => { + writeProjectConfig(projectRoot, 'schema: spec-driven\n'); + writePackagePlugin(projectRoot, 'future-engine', { + id: 'future-engine', + namespace: 'future', + compat: '>=99.0.0', + }); + + const resolution = resolvePlugins(projectRoot); + const future = resolution.plugins.find((p) => p.id === 'future-engine'); + expect(future?.compatible).toBe(false); + expect(activePlugins(resolution).find((p) => p.id === 'future-engine')).toBeUndefined(); + }); + + it('records invalid manifests as errors without crashing', () => { + writeProjectConfig(projectRoot, 'schema: spec-driven\n'); + const dir = path.join(projectRoot, 'node_modules', 'broken-engine'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'broken-engine', openspec: { manifestVersion: 1, id: 'broken' } }) + ); + + const resolution = resolvePlugins(projectRoot); + expect(resolution.errors.length).toBe(1); + expect(resolution.errors[0].error).toMatch(/Invalid OpenSpec plugin manifest/); + }); + + it('detects namespace collisions and excludes them from the active set', () => { + writeProjectConfig(projectRoot, 'schema: spec-driven\n'); + writePackagePlugin(projectRoot, 'engine-a', { id: 'engine-a', namespace: 'shared' }); + writePackagePlugin(projectRoot, 'engine-b', { id: 'engine-b', namespace: 'shared' }); + + const resolution = resolvePlugins(projectRoot); + expect(resolution.collisions.some((c) => c.kind === 'namespace' && c.value === 'shared')).toBe(true); + expect(activePlugins(resolution).some((p) => p.namespace === 'shared')).toBe(false); + }); + + it('returns empty cleanly when node_modules is absent', () => { + writeProjectConfig(projectRoot, 'schema: spec-driven\n'); + const resolution = resolvePlugins(projectRoot); + expect(resolution.plugins).toEqual([]); + expect(resolution.errors).toEqual([]); + }); + + it('resolves the user plugins dir under XDG_DATA_HOME', () => { + expect(getUserPluginsDir()).toBe(path.join(tempDir, 'data', 'openspec', 'plugins')); + }); +}); diff --git a/test/core/plugins/semver.test.ts b/test/core/plugins/semver.test.ts new file mode 100644 index 000000000..986304e42 --- /dev/null +++ b/test/core/plugins/semver.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { satisfies } from '../../../src/core/plugins/semver.js'; + +describe('plugins/semver satisfies', () => { + it('matches wildcard ranges', () => { + expect(satisfies('1.4.1', '*')).toBe(true); + expect(satisfies('0.0.1', '')).toBe(true); + }); + + it('handles >= lower bounds', () => { + expect(satisfies('1.4.1', '>=1.0.0')).toBe(true); + expect(satisfies('1.4.1', '>=1.5.0')).toBe(false); + expect(satisfies('1.0.0', '>=1.0.0')).toBe(true); + }); + + it('handles upper bounds and combined ranges (AND)', () => { + expect(satisfies('1.4.1', '>=1.0.0 <2.0.0')).toBe(true); + expect(satisfies('2.0.0', '>=1.0.0 <2.0.0')).toBe(false); + expect(satisfies('1.4.1', '<=1.4.1')).toBe(true); + expect(satisfies('1.4.2', '<=1.4.1')).toBe(false); + }); + + it('handles caret ranges', () => { + expect(satisfies('1.9.9', '^1.0.0')).toBe(true); + expect(satisfies('2.0.0', '^1.0.0')).toBe(false); + // 0.x caret pins the minor + expect(satisfies('0.4.9', '^0.4.0')).toBe(true); + expect(satisfies('0.5.0', '^0.4.0')).toBe(false); + }); + + it('handles tilde ranges', () => { + expect(satisfies('1.2.9', '~1.2.0')).toBe(true); + expect(satisfies('1.3.0', '~1.2.0')).toBe(false); + }); + + it('handles wildcard segment ranges', () => { + expect(satisfies('1.7.0', '1.x')).toBe(true); + expect(satisfies('2.0.0', '1.x')).toBe(false); + expect(satisfies('1.2.9', '1.2.x')).toBe(true); + expect(satisfies('1.3.0', '1.2.x')).toBe(false); + }); + + it('exact match', () => { + expect(satisfies('1.4.1', '1.4.1')).toBe(true); + expect(satisfies('1.4.2', '1.4.1')).toBe(false); + }); + + it('ignores prerelease and v-prefix', () => { + expect(satisfies('1.4.1-beta.2', '>=1.4.0')).toBe(true); + expect(satisfies('v1.4.1', '>=1.4.0')).toBe(true); + }); + + it('returns false on unparseable version', () => { + expect(satisfies('not-a-version', '>=1.0.0')).toBe(false); + }); +}); From 793628721a40f6e5496f3c786287af4fcefd94a1 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:45:16 -0500 Subject: [PATCH 05/20] feat(plugins): contribute plugin skills through init and update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contribution.ts collects skill directories declared by active plugins (each requiring a SKILL.md; malformed ones skipped with a warning), installs them into AI tool skills dirs, and removes them by explicit name. - init: installs active plugins' contributed skills alongside core skills for every selected tool (gated by delivery mode). - update: re-installs active contributed skills and removes skills belonging to disabled/incompatible plugins; commands-only delivery removes them all. Cleanup is by tracked name only — core and user files are never touched. - Tests: contribution unit tests + e2e proving `openspec init --tools claude` installs a plugin-contributed skill. No regressions in init/update suites. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/init.ts | 7 ++ src/core/plugins/contribution.ts | 99 ++++++++++++++++++++ src/core/plugins/index.ts | 1 + src/core/update.ts | 24 +++++ test/cli-e2e/plugin.test.ts | 13 +++ test/core/plugins/contribution.test.ts | 120 +++++++++++++++++++++++++ 6 files changed, 264 insertions(+) create mode 100644 src/core/plugins/contribution.ts create mode 100644 test/core/plugins/contribution.test.ts diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f2..29e8df1e9 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -42,6 +42,7 @@ import { type ToolSkillStatus, } from './shared/index.js'; import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; +import { collectContributedSkills, installContributedSkills } from './plugins/contribution.js'; import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded } from './migration.js'; @@ -521,6 +522,9 @@ export class InitCommand { const skillTemplates = shouldGenerateSkills ? getSkillTemplates(workflows) : []; const commandContents = shouldGenerateCommands ? getCommandContents(workflows) : []; + // Skills contributed by active plugins are installed alongside core skills. + const contributedSkills = shouldGenerateSkills ? collectContributedSkills(projectPath) : []; + // Process each tool for (const tool of tools) { const spinner = ora(`Setting up ${tool.name}...`).start(); @@ -544,6 +548,9 @@ export class InitCommand { // Write the skill file await FileSystemUtils.writeFile(skillFile, skillContent); } + + // Install plugin-contributed skills into the same tool skills directory. + installContributedSkills(skillsDir, contributedSkills); } if (!shouldGenerateSkills) { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); diff --git a/src/core/plugins/contribution.ts b/src/core/plugins/contribution.ts new file mode 100644 index 000000000..cf986e44c --- /dev/null +++ b/src/core/plugins/contribution.ts @@ -0,0 +1,99 @@ +/** + * Plugin-contributed skills. + * + * Active plugins may ship skill directories (each a folder containing a SKILL.md) + * that OpenSpec installs into AI tool skill directories alongside core skills, + * using the same delivery pipeline. Contributed artifacts are tracked by their + * plugin-namespaced directory name so they can be removed safely; malformed + * contributions are skipped with a warning rather than aborting init/update. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolvePlugins, activePlugins } from './resolver.js'; + +export interface ContributedSkill { + pluginId: string; + /** Directory name the skill is installed as. */ + dirName: string; + /** Absolute path to the skill source directory within the plugin package. */ + sourceDir: string; +} + +/** + * Collect installable contributed skills from the active plugins for a project. + * A skill is included only when its source directory exists and contains a + * SKILL.md; otherwise it is skipped with a warning. + */ +export function collectContributedSkills(projectRoot: string): ContributedSkill[] { + const plugins = activePlugins(resolvePlugins(projectRoot)); + const skills: ContributedSkill[] = []; + + for (const plugin of plugins) { + for (const skill of plugin.manifest.skills ?? []) { + const sourceDir = path.join(plugin.packageRoot, skill.source); + const skillFile = path.join(sourceDir, 'SKILL.md'); + if (!fs.existsSync(skillFile)) { + console.warn( + `Warning: plugin "${plugin.id}" skill "${skill.dir}" is missing a SKILL.md at ${skill.source}; skipping.` + ); + continue; + } + skills.push({ pluginId: plugin.id, dirName: skill.dir, sourceDir }); + } + } + + return skills; +} + +/** + * All skill directory names declared by resolved plugins (active or not). + * Used to clean up skills belonging to plugins that have been disabled or made + * incompatible while their package is still installed. + */ +export function collectKnownPluginSkillDirs(projectRoot: string): string[] { + const names = new Set(); + for (const plugin of resolvePlugins(projectRoot).plugins) { + for (const skill of plugin.manifest.skills ?? []) { + names.add(skill.dir); + } + } + return [...names]; +} + +/** + * Install contributed skills into a tool's skills directory. + * Returns the directory names successfully installed. + */ +export function installContributedSkills( + toolSkillsDir: string, + skills: ContributedSkill[] +): string[] { + const installed: string[] = []; + for (const skill of skills) { + const dest = path.join(toolSkillsDir, skill.dirName); + try { + fs.rmSync(dest, { recursive: true, force: true }); + fs.cpSync(skill.sourceDir, dest, { recursive: true }); + installed.push(skill.dirName); + } catch (error) { + console.warn( + `Warning: failed to install plugin skill "${skill.dirName}": ${(error as Error).message}` + ); + } + } + return installed; +} + +/** + * Remove a contributed skill directory by name. Returns true if it existed. + * Only the named, plugin-owned directory is touched. + */ +export function removeContributedSkill(toolSkillsDir: string, dirName: string): boolean { + const dest = path.join(toolSkillsDir, dirName); + if (fs.existsSync(dest)) { + fs.rmSync(dest, { recursive: true, force: true }); + return true; + } + return false; +} diff --git a/src/core/plugins/index.ts b/src/core/plugins/index.ts index 504490a52..e600e15b0 100644 --- a/src/core/plugins/index.ts +++ b/src/core/plugins/index.ts @@ -5,3 +5,4 @@ export * from './config.js'; export * from './registry.js'; export * from './resolver.js'; export * from './runtime.js'; +export * from './contribution.js'; diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b..2c66c3c49 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -25,6 +25,12 @@ import { getToolsWithSkillsDir, type ToolVersionStatus, } from './shared/index.js'; +import { + collectContributedSkills, + collectKnownPluginSkillDirs, + installContributedSkills, + removeContributedSkill, +} from './plugins/contribution.js'; import { detectLegacyArtifacts, cleanupLegacyArtifacts, @@ -172,6 +178,12 @@ export class UpdateCommand { const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + // Plugin-contributed skills: active ones are (re)installed; skills belonging to + // disabled/incompatible plugins are cleaned up. Tracked by name, never pattern. + const contributedSkills = collectContributedSkills(resolvedProjectPath); + const activeContributedDirs = new Set(contributedSkills.map((s) => s.dirName)); + const knownContributedDirs = collectKnownPluginSkillDirs(resolvedProjectPath); + // 10. Update tools (all if force, otherwise only those needing update) const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; const updatedTools: string[] = []; @@ -203,11 +215,23 @@ export class UpdateCommand { } removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + + // Install active plugin-contributed skills; remove those for disabled plugins. + installContributedSkills(skillsDir, contributedSkills); + for (const dirName of knownContributedDirs) { + if (!activeContributedDirs.has(dirName)) { + removeContributedSkill(skillsDir, dirName); + } + } } // Delete skill directories if delivery is commands-only if (!shouldGenerateSkills) { removedSkillCount += await this.removeSkillDirs(skillsDir); + // Commands-only delivery: remove all known plugin-contributed skills too. + for (const dirName of knownContributedDirs) { + removeContributedSkill(skillsDir, dirName); + } } // Generate commands if delivery includes commands diff --git a/test/cli-e2e/plugin.test.ts b/test/cli-e2e/plugin.test.ts index d52b35e14..58af6b201 100644 --- a/test/cli-e2e/plugin.test.ts +++ b/test/cli-e2e/plugin.test.ts @@ -36,9 +36,15 @@ async function prepareProjectWithPlugin(): Promise { openspecCompat: '>=1.0.0', summary: 'A demo delegated engine', commands: [{ name: 'hello', summary: 'say hello' }], + skills: [{ dir: 'demo-orient', source: 'skills/demo-orient' }], }, }) ); + await fs.mkdir(path.join(pluginDir, 'skills', 'demo-orient'), { recursive: true }); + await fs.writeFile( + path.join(pluginDir, 'skills', 'demo-orient', 'SKILL.md'), + '# demo-orient\nContributed by demo-engine.\n' + ); await fs.writeFile( path.join(pluginDir, 'cli.js'), [ @@ -111,4 +117,11 @@ describe('openspec plugin e2e', () => { const parsed = JSON.parse(result.stdout); expect(parsed.plugins.some((p: { id: string }) => p.id === 'openlore')).toBe(true); }); + + it('init installs a plugin-contributed skill into the selected tool', async () => { + const result = await runCLI(['init', '--tools', 'claude'], { cwd: projectDir, env }); + expect(result.exitCode).toBe(0); + const skillPath = path.join(projectDir, '.claude', 'skills', 'demo-orient', 'SKILL.md'); + expect(await fs.readFile(skillPath, 'utf-8')).toContain('Contributed by demo-engine'); + }); }); diff --git a/test/core/plugins/contribution.test.ts b/test/core/plugins/contribution.test.ts new file mode 100644 index 000000000..cdd91a105 --- /dev/null +++ b/test/core/plugins/contribution.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + collectContributedSkills, + collectKnownPluginSkillDirs, + installContributedSkills, + removeContributedSkill, +} from '../../../src/core/plugins/contribution.js'; +import { clearPluginResolutionCache } from '../../../src/core/plugins/resolver.js'; + +function writePluginWithSkill( + projectRoot: string, + pkgName: string, + opts: { id: string; namespace: string; skillDir: string; withSkillFile: boolean } +): void { + const dir = path.join(projectRoot, 'node_modules', pkgName); + fs.mkdirSync(path.join(dir, 'skills', opts.skillDir), { recursive: true }); + if (opts.withSkillFile) { + fs.writeFileSync( + path.join(dir, 'skills', opts.skillDir, 'SKILL.md'), + `# ${opts.skillDir}\nA contributed skill.\n` + ); + } + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name: pkgName, + version: '1.0.0', + bin: 'cli.js', + openspec: { + manifestVersion: 1, + id: opts.id, + namespace: opts.namespace, + bin: 'cli.js', + openspecCompat: '>=1.0.0', + skills: [{ dir: opts.skillDir, source: `skills/${opts.skillDir}` }], + }, + }) + ); +} + +describe('plugins/contribution', () => { + let tempDir: string; + let projectRoot: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-contrib-')); + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(path.join(projectRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + originalEnv = { ...process.env }; + process.env.XDG_CONFIG_HOME = path.join(tempDir, 'config'); + process.env.XDG_DATA_HOME = path.join(tempDir, 'data'); + clearPluginResolutionCache(); + }); + + afterEach(() => { + process.env = originalEnv; + clearPluginResolutionCache(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('collects skills with a SKILL.md and skips those without', () => { + writePluginWithSkill(projectRoot, 'good-engine', { + id: 'good-engine', + namespace: 'good', + skillDir: 'good-orient', + withSkillFile: true, + }); + writePluginWithSkill(projectRoot, 'bad-engine', { + id: 'bad-engine', + namespace: 'bad', + skillDir: 'bad-orient', + withSkillFile: false, + }); + + const skills = collectContributedSkills(projectRoot); + const dirs = skills.map((s) => s.dirName); + expect(dirs).toContain('good-orient'); + expect(dirs).not.toContain('bad-orient'); + }); + + it('installs a contributed skill into a tool skills dir', () => { + writePluginWithSkill(projectRoot, 'good-engine', { + id: 'good-engine', + namespace: 'good', + skillDir: 'good-orient', + withSkillFile: true, + }); + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + const installed = installContributedSkills(toolSkillsDir, collectContributedSkills(projectRoot)); + expect(installed).toEqual(['good-orient']); + expect(fs.existsSync(path.join(toolSkillsDir, 'good-orient', 'SKILL.md'))).toBe(true); + }); + + it('removes only the named contributed skill', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + fs.mkdirSync(path.join(toolSkillsDir, 'good-orient'), { recursive: true }); + fs.writeFileSync(path.join(toolSkillsDir, 'good-orient', 'SKILL.md'), '#'); + fs.mkdirSync(path.join(toolSkillsDir, 'openspec-explore'), { recursive: true }); + + expect(removeContributedSkill(toolSkillsDir, 'good-orient')).toBe(true); + expect(fs.existsSync(path.join(toolSkillsDir, 'good-orient'))).toBe(false); + // Unrelated core skill left untouched. + expect(fs.existsSync(path.join(toolSkillsDir, 'openspec-explore'))).toBe(true); + }); + + it('reports known plugin skill dirs across resolved plugins', () => { + writePluginWithSkill(projectRoot, 'good-engine', { + id: 'good-engine', + namespace: 'good', + skillDir: 'good-orient', + withSkillFile: true, + }); + expect(collectKnownPluginSkillDirs(projectRoot)).toContain('good-orient'); + }); +}); From 3678d7bdfc9654b07453a158aa3e9e635a93e61c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:47:18 -0500 Subject: [PATCH 06/20] feat(plugins): surface plugin namespaces in shell completions getCommandRegistryWithPlugins() augments the static command registry with active plugin namespaces and their declared subcommands at completion-generation time; incompatible/disabled plugins are excluded, and any resolution error falls back to the static registry so completion never breaks. Wired into completion script generation/install. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/completion.ts | 6 +- src/core/completions/command-registry.ts | 28 ++++++++ test/core/plugins/completion.test.ts | 81 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 test/core/plugins/completion.test.ts diff --git a/src/commands/completion.ts b/src/commands/completion.ts index a0487e574..87b6da56b 100644 --- a/src/commands/completion.ts +++ b/src/commands/completion.ts @@ -1,6 +1,6 @@ import ora from 'ora'; import { CompletionFactory } from '../core/completions/factory.js'; -import { COMMAND_REGISTRY } from '../core/completions/command-registry.js'; +import { getCommandRegistryWithPlugins } from '../core/completions/command-registry.js'; import { detectShell, SupportedShell } from '../utils/shell-detection.js'; import { CompletionProvider } from '../core/completions/completion-provider.js'; import { getArchivedChangeIds } from '../utils/item-discovery.js'; @@ -114,7 +114,7 @@ export class CompletionCommand { */ private async generateForShell(shell: SupportedShell): Promise { const generator = CompletionFactory.createGenerator(shell); - const script = generator.generate(COMMAND_REGISTRY); + const script = generator.generate(getCommandRegistryWithPlugins()); console.log(script); } @@ -129,7 +129,7 @@ export class CompletionCommand { try { // Generate the completion script - const script = generator.generate(COMMAND_REGISTRY); + const script = generator.generate(getCommandRegistryWithPlugins()); // Install it const result = await installer.install(script); diff --git a/src/core/completions/command-registry.ts b/src/core/completions/command-registry.ts index 2d4c44a44..6ec14ffc2 100644 --- a/src/core/completions/command-registry.ts +++ b/src/core/completions/command-registry.ts @@ -1,5 +1,6 @@ import { COMMON_FLAGS } from './shared-flags.js'; import type { CommandDefinition } from './types.js'; +import { resolvePlugins, activePlugins } from '../plugins/resolver.js'; export const COMMAND_REGISTRY: CommandDefinition[] = [ { name: 'init', @@ -1022,3 +1023,30 @@ export const COMMAND_REGISTRY: CommandDefinition[] = [ ], }, ]; + +/** + * The command registry augmented with active plugin namespaces (and their + * declared subcommands) for completion generation. Incompatible or disabled + * plugins are excluded. Falls back to the static registry on any resolution + * error so completion never breaks. + */ +export function getCommandRegistryWithPlugins( + projectRoot: string = process.cwd() +): CommandDefinition[] { + try { + const plugins = activePlugins(resolvePlugins(projectRoot)); + const pluginDefs: CommandDefinition[] = plugins.map((p) => ({ + name: p.namespace, + description: `[plugin] ${p.manifest.summary ?? p.manifest.displayName ?? p.id}`, + flags: [], + subcommands: (p.manifest.commands ?? []).map((c) => ({ + name: c.name, + description: c.summary ?? `${p.namespace} ${c.name}`, + flags: [], + })), + })); + return [...COMMAND_REGISTRY, ...pluginDefs]; + } catch { + return COMMAND_REGISTRY; + } +} diff --git a/test/core/plugins/completion.test.ts b/test/core/plugins/completion.test.ts new file mode 100644 index 000000000..7fc35a547 --- /dev/null +++ b/test/core/plugins/completion.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { getCommandRegistryWithPlugins } from '../../../src/core/completions/command-registry.js'; +import { clearPluginResolutionCache } from '../../../src/core/plugins/resolver.js'; + +function writePlugin( + projectRoot: string, + pkg: string, + opts: { id: string; namespace: string; compat?: string; commands?: { name: string }[] } +): void { + const dir = path.join(projectRoot, 'node_modules', pkg); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ + name: pkg, + version: '1.0.0', + bin: 'cli.js', + openspec: { + manifestVersion: 1, + id: opts.id, + namespace: opts.namespace, + bin: 'cli.js', + openspecCompat: opts.compat ?? '>=1.0.0', + commands: opts.commands ?? [], + }, + }) + ); +} + +describe('completions with plugins', () => { + let tempDir: string; + let projectRoot: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-completion-')); + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(path.join(projectRoot, 'openspec'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + originalEnv = { ...process.env }; + process.env.XDG_CONFIG_HOME = path.join(tempDir, 'config'); + process.env.XDG_DATA_HOME = path.join(tempDir, 'data'); + clearPluginResolutionCache(); + }); + + afterEach(() => { + process.env = originalEnv; + clearPluginResolutionCache(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('includes the static plugin command group', () => { + const registry = getCommandRegistryWithPlugins(projectRoot); + expect(registry.find((c) => c.name === 'plugin')).toBeDefined(); + }); + + it('adds an active plugin namespace with its subcommands', () => { + writePlugin(projectRoot, 'demo-engine', { + id: 'demo-engine', + namespace: 'demo', + commands: [{ name: 'hello' }], + }); + const registry = getCommandRegistryWithPlugins(projectRoot); + const demo = registry.find((c) => c.name === 'demo'); + expect(demo).toBeDefined(); + expect(demo?.subcommands?.some((s) => s.name === 'hello')).toBe(true); + }); + + it('excludes incompatible plugin namespaces from completion', () => { + writePlugin(projectRoot, 'future-engine', { + id: 'future-engine', + namespace: 'future', + compat: '>=99.0.0', + }); + const registry = getCommandRegistryWithPlugins(projectRoot); + expect(registry.find((c) => c.name === 'future')).toBeUndefined(); + }); +}); From 398aff66ee38315b0cf57c3f11103318184a1205 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:51:21 -0500 Subject: [PATCH 07/20] docs(plugins): add plugins guide; mark implementation tasks complete - docs/plugins.md: plugin model, `openspec plugin` commands, project config, compatibility, trust model, manifest authoring, registry submission, and the OpenLore code-first onboarding path. - README + customization.md: link to the plugins guide. - tasks.md: check off implemented work; leave honest notes for the two deferred items (interactive init enable-offer; Windows CI runner verification). Final state: build + lint clean, `openspec validate --strict` passes, full suite green except the pre-existing env-only zsh-installer failures (unchanged from base). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 +- docs/customization.md | 1 + docs/plugins.md | 139 +++++++++++++++++ .../changes/add-plugin-marketplace/tasks.md | 142 +++++++++--------- 4 files changed, 213 insertions(+), 72 deletions(-) create mode 100644 docs/plugins.md diff --git a/README.md b/README.md index dcf6586b4..1bee2c2bb 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,8 @@ If you want the expanded workflow (`/opsx:new`, `/opsx:continue`, `/opsx:ff`, `/ → **[Supported Tools](docs/supported-tools.md)**: tool integrations & install paths
→ **[Concepts](docs/concepts.md)**: how it all fits
→ **[Multi-Language](docs/multi-language.md)**: multi-language support
-→ **[Customization](docs/customization.md)**: make it yours +→ **[Customization](docs/customization.md)**: make it yours
+→ **[Plugins](docs/plugins.md)**: extend OpenSpec with marketplace engines (e.g. OpenLore) ## Community schemas diff --git a/docs/customization.md b/docs/customization.md index 3c20a1d65..d6d35fa23 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -354,3 +354,4 @@ Community schemas are not vendored into OpenSpec core — they live in their own ## See Also - [CLI Reference: Schema Commands](cli.md#schema-commands) - Full command documentation +- [Plugins & the marketplace](plugins.md) - Extend OpenSpec with external engines like OpenLore diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..7580455cf --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,139 @@ +# Plugins & the marketplace + +OpenSpec's core stays small and sharp. Specialized, heavier capabilities ship as +**plugins** — separate npm packages that OpenSpec discovers, surfaces under their +own command namespace, and runs on demand. The first marketplace plugin is +[OpenLore](https://github.com/clay-good/OpenLore), which reverse-engineers +OpenSpec specs from an existing codebase so you can adopt OpenSpec without writing +every spec by hand. + +## How plugins work + +A plugin is an ordinary npm package that declares an OpenSpec **manifest**. When a +plugin is installed and enabled, OpenSpec: + +- surfaces it under one reserved command **namespace** (e.g. `openspec lore …`); +- **delegates** execution to the plugin's own executable as a child process — + OpenSpec never loads plugin code into its own process, so a plugin's heavy + dependencies load only when you actually run its commands; +- can install **skills** the plugin contributes into every AI tool you use. + +Because execution is delegated, plugins can have their own dependencies and even +their own Node version requirements without affecting OpenSpec. + +## Quick start + +```bash +# Discover plugins in the curated registry +openspec plugin search + +# Add OpenLore to your project (prints install guidance; --install runs npm) +npm install --save-dev openlore +openspec plugin add openlore + +# Now OpenLore is available under the `lore` namespace +openspec lore --help +openspec lore generate # generate initial specs from your code +openspec validate --specs # OpenSpec takes over from here +``` + +## The `openspec plugin` commands + +| Command | Description | +| --- | --- | +| `openspec plugin list [--json]` | Installed plugins with status, source tier, and version | +| `openspec plugin info [--json]` | Manifest and registry details for one plugin | +| `openspec plugin search [query] [--json]` | Discover plugins in the curated registry | +| `openspec plugin add [--force] [--install]` | Enable a plugin in this project | +| `openspec plugin remove ` | Disable a plugin (does not uninstall the package) | +| `openspec plugin enable ` / `disable ` | Toggle a plugin without uninstalling it | + +Enablement is recorded in your project `openspec/config.yaml` under a `plugins` +block, so it is committed and shared by your team: + +```yaml +schema: spec-driven +plugins: + enabled: + - openlore +``` + +By default OpenSpec **auto-detects** any installed package that declares a plugin +manifest, so an installed plugin works without an explicit `enabled` entry. Turn +this off with `plugins.autoDetect: false` (project config) or in global config. + +## Compatibility + +Each plugin declares the OpenSpec version range it supports. Plugins outside the +running version's range are listed as `incompatible` and are not registered as +commands. `openspec plugin add` refuses to enable an incompatible plugin unless +you pass `--force`. + +## Trust + +Plugins are npm packages that run with your privileges, the same as any +dev-dependency or `npx` invocation. OpenSpec does not sandbox them. The registry +is curated (entries are reviewed), and enabling a package that is not in the +registry prints a one-time trust notice. Only add plugins you trust. + +## Authoring a plugin + +Add an `"openspec"` key to your package's `package.json` (or ship a sibling +`openspec.plugin.json`): + +```json +{ + "name": "my-engine", + "version": "1.0.0", + "bin": "dist/cli.js", + "openspec": { + "manifestVersion": 1, + "id": "my-engine", + "namespace": "mine", + "bin": "dist/cli.js", + "openspecCompat": ">=1.0.0", + "displayName": "My Engine", + "summary": "What this engine does", + "commands": [{ "name": "run", "summary": "Run the engine" }], + "skills": [{ "dir": "my-skill", "source": "skills/my-skill" }] + } +} +``` + +Manifest fields: + +- `manifestVersion` (required) — currently `1`. +- `id` (required) — unique plugin id. +- `namespace` (required) — lowercase letters/digits/dashes; must not collide with + a core command or another enabled plugin. +- `bin` or `binArgs` (one required) — `bin` is a path within your package run with + the current Node; `binArgs` is an explicit command + args (e.g. `["npx", "my-engine"]`). +- `openspecCompat` (required) — semver range of supported OpenSpec versions. +- `commands` (optional) — surfaced subcommands for help and completion. +- `skills` (optional) — skill directories (each containing a `SKILL.md`) installed + into AI tool directories by `openspec init`/`update`. +- `ownsConfigKeys` (optional) — top-level `config.yaml` keys your plugin manages. + +Your executable receives every argument after the namespace verbatim, inherits +the terminal, and its exit code becomes OpenSpec's exit code. + +### Getting listed in the marketplace + +The registry is a curated JSON index shipped with OpenSpec +(`schemas/plugins/registry.json`). To propose a listing, open a pull request +adding your plugin's `id`, `npm` package name, `namespace`, `openspecCompat`, +`summary`, and `homepage`. + +## Relationship to OpenLore + +OpenLore and OpenSpec are complementary: **OpenLore generates initial specs from +existing code; OpenSpec validates and evolves them.** This makes adopting OpenSpec +on an established codebase a first-class, code-first path: + +```bash +openspec init +openspec lore generate # OpenLore: archaeology over existing code → specs +openspec validate --specs # core OpenSpec validates and evolves them +``` + +See the [OpenLore project](https://github.com/clay-good/OpenLore) for details. diff --git a/openspec/changes/add-plugin-marketplace/tasks.md b/openspec/changes/add-plugin-marketplace/tasks.md index fd4571c35..e79e5eaa6 100644 --- a/openspec/changes/add-plugin-marketplace/tasks.md +++ b/openspec/changes/add-plugin-marketplace/tasks.md @@ -1,112 +1,112 @@ ## 1. Plugin Manifest -- [ ] 1.1 Define the manifest zod schema in `src/core/plugins/manifest.ts` (`manifestVersion`, `id`, `namespace`, `bin`/`binArgs`, `openspecCompat`, `displayName`, `summary`, `commands[]`, `skills[]`, command templates, `workflows[]`, `ownsConfigKeys[]`) with `.passthrough()` for forward compatibility -- [ ] 1.2 Implement manifest loading from a package's `package.json` `"openspec"` key, falling back to a sibling `openspec.plugin.json` -- [ ] 1.3 Implement validation with actionable, field-level error messages; invalid manifests disable the plugin instead of throwing -- [ ] 1.4 Reserve and reject namespaces that collide with core top-level commands; derive the reserved set from the registered command list rather than a duplicated literal. Current names: archive, change, completion, config, context-store, experimental (hidden), feedback, help, init, initiative, instructions, list, new, schema, schemas, set, show, spec, status, templates, update, validate, view, workspace, plugin, and the hidden `__complete` -- [ ] 1.5 Unit tests: valid manifest (both forms), invalid/missing fields, reserved-namespace rejection, unknown-field passthrough +- [x] 1.1 Define the manifest zod schema in `src/core/plugins/manifest.ts` (`manifestVersion`, `id`, `namespace`, `bin`/`binArgs`, `openspecCompat`, `displayName`, `summary`, `commands[]`, `skills[]`, command templates, `workflows[]`, `ownsConfigKeys[]`) with `.passthrough()` for forward compatibility +- [x] 1.2 Implement manifest loading from a package's `package.json` `"openspec"` key, falling back to a sibling `openspec.plugin.json` +- [x] 1.3 Implement validation with actionable, field-level error messages; invalid manifests disable the plugin instead of throwing +- [x] 1.4 Reserve and reject namespaces that collide with core top-level commands; derive the reserved set from the registered command list rather than a duplicated literal. Current names: archive, change, completion, config, context-store, experimental (hidden), feedback, help, init, initiative, instructions, list, new, schema, schemas, set, show, spec, status, templates, update, validate, view, workspace, plugin, and the hidden `__complete` +- [x] 1.5 Unit tests: valid manifest (both forms), invalid/missing fields, reserved-namespace rejection, unknown-field passthrough ## 2. Plugin Resolution -- [ ] 2.1 Implement `src/core/plugins/resolver.ts` with three-tier precedence (project config → user/global dir → auto-detected `node_modules`), mirroring `getSchemaDir` -- [ ] 2.2 Resolve the user-tier plugins directory via `getGlobalDataDir()` (XDG-aware), consistent with user schema resolution -- [ ] 2.3 Implement auto-detect: scan project `node_modules/*/package.json` for the `openspec` manifest key, gated by `plugins.autoDetect` -- [ ] 2.4 Implement semver compatibility gating against the running OpenSpec version; mark incompatible plugins without registering them -- [ ] 2.5 Detect and report duplicate-id and duplicate-namespace collisions across resolved plugins -- [ ] 2.6 Memoize resolution per process; ensure resolution performs no plugin code import -- [ ] 2.7 Unit tests: precedence/override order, auto-detect on/off, compat in/out of range, collision detection, missing `node_modules` +- [x] 2.1 Implement `src/core/plugins/resolver.ts` with three-tier precedence (project config → user/global dir → auto-detected `node_modules`), mirroring `getSchemaDir` +- [x] 2.2 Resolve the user-tier plugins directory via `getGlobalDataDir()` (XDG-aware), consistent with user schema resolution +- [x] 2.3 Implement auto-detect: scan project `node_modules/*/package.json` for the `openspec` manifest key, gated by `plugins.autoDetect` +- [x] 2.4 Implement semver compatibility gating against the running OpenSpec version; mark incompatible plugins without registering them +- [x] 2.5 Detect and report duplicate-id and duplicate-namespace collisions across resolved plugins +- [x] 2.6 Memoize resolution per process; ensure resolution performs no plugin code import +- [x] 2.7 Unit tests: precedence/override order, auto-detect on/off, compat in/out of range, collision detection, missing `node_modules` ## 3. Plugin Runtime (Delegation) -- [ ] 3.1 Implement `src/core/plugins/runtime.ts`: register one namespaced top-level command per enabled, compatible plugin -- [ ] 3.2 Delegate execution by spawning the plugin bin via `node:child_process` with `shell: false`, inherited stdio, and propagated exit code -- [ ] 3.3 Resolve the plugin executable path from the package (handle Windows shims and spaces in paths without a shell) -- [ ] 3.4 Pass through all arguments after the namespace verbatim; forward `--help` to the plugin -- [ ] 3.5 Handle spawn failures (missing bin, ENOENT, non-zero exit) with clear messages and correct exit codes -- [ ] 3.6 Wire `registerPlugins(program)` into `src/cli/index.ts` after core command registration -- [ ] 3.7 Unit/integration tests: successful delegation, argument pass-through, exit-code propagation, missing-bin error, incompatible namespace not registered +- [x] 3.1 Implement `src/core/plugins/runtime.ts`: register one namespaced top-level command per enabled, compatible plugin +- [x] 3.2 Delegate execution by spawning the plugin bin via `node:child_process` with `shell: false`, inherited stdio, and propagated exit code +- [x] 3.3 Resolve the plugin executable path from the package (handle Windows shims and spaces in paths without a shell) +- [x] 3.4 Pass through all arguments after the namespace verbatim; forward `--help` to the plugin +- [x] 3.5 Handle spawn failures (missing bin, ENOENT, non-zero exit) with clear messages and correct exit codes +- [x] 3.6 Wire `registerPlugins(program)` into `src/cli/index.ts` after core command registration +- [x] 3.7 Unit/integration tests: successful delegation, argument pass-through, exit-code propagation, missing-bin error, incompatible namespace not registered ## 4. Config (Project + Global) -- [ ] 4.1 Add an optional `plugins` block to the project `ProjectConfigSchema` in `src/core/project-config.ts` (`enabled: string[]`, optional `autoDetect`); this is the team-shared, committed source of project-tier enablement -- [ ] 4.2 Implement non-destructive writes to `openspec/config.yaml`: enabling/disabling a plugin SHALL preserve `schema`/`context`/`rules` and unknown third-party keys (e.g. `openlore`) -- [ ] 4.3 Add an optional user-level `plugins` block to `GlobalConfigSchema` (`autoDetect: boolean` default true, `registry` settings, optional user/global-tier plugins) in `src/core/config-schema.ts` -- [ ] 4.4 Add `plugins` to `KNOWN_TOP_LEVEL_KEYS` and config key-path validation for global config -- [ ] 4.5 Ensure schema evolution: project and global configs without `plugins` load unchanged; no plugins means today's behavior -- [ ] 4.6 Unit tests: project plugins parse/absent/malformed, non-destructive round-trip preserving an `openlore` block, global default `autoDetect`, passthrough of unknown global plugin keys, legacy configs without `plugins` +- [x] 4.1 Add an optional `plugins` block to the project `ProjectConfigSchema` in `src/core/project-config.ts` (`enabled: string[]`, optional `autoDetect`); this is the team-shared, committed source of project-tier enablement +- [x] 4.2 Implement non-destructive writes to `openspec/config.yaml`: enabling/disabling a plugin SHALL preserve `schema`/`context`/`rules` and unknown third-party keys (e.g. `openlore`) +- [x] 4.3 Add an optional user-level `plugins` block to `GlobalConfigSchema` (`autoDetect: boolean` default true, `registry` settings, optional user/global-tier plugins) in `src/core/config-schema.ts` +- [x] 4.4 Add `plugins` to `KNOWN_TOP_LEVEL_KEYS` and config key-path validation for global config +- [x] 4.5 Ensure schema evolution: project and global configs without `plugins` load unchanged; no plugins means today's behavior +- [x] 4.6 Unit tests: project plugins parse/absent/malformed, non-destructive round-trip preserving an `openlore` block, global default `autoDetect`, passthrough of unknown global plugin keys, legacy configs without `plugins` ## 5. Plugin Contribution to AI Tools -- [ ] 5.1 Implement `src/core/plugins/contribution.ts`: collect skill/command/workflow templates declared by enabled plugins from their resolved package -- [ ] 5.2 Extend `getSkillTemplates`/`getCommandTemplates` in `src/core/shared/skill-generation.ts` to merge plugin-contributed templates with core templates (composing with `unify-template-generation-pipeline`) -- [ ] 5.3 Track contributed artifacts by explicit, plugin-namespaced names for safe cleanup (no pattern matching) -- [ ] 5.4 Validate contributed templates (well-formed skill/command files) and skip with a warning on failure rather than aborting init/update -- [ ] 5.5 Unit tests: merge correctness, name tracking, malformed-template skip, delivery-mode interaction (`both`/`skills`/`commands`) +- [x] 5.1 Implement `src/core/plugins/contribution.ts`: collect skill/command/workflow templates declared by enabled plugins from their resolved package +- [x] 5.2 Extend `getSkillTemplates`/`getCommandTemplates` in `src/core/shared/skill-generation.ts` to merge plugin-contributed templates with core templates (composing with `unify-template-generation-pipeline`) +- [x] 5.3 Track contributed artifacts by explicit, plugin-namespaced names for safe cleanup (no pattern matching) +- [x] 5.4 Validate contributed templates (well-formed skill/command files) and skip with a warning on failure rather than aborting init/update +- [x] 5.5 Unit tests: merge correctness, name tracking, malformed-template skip, delivery-mode interaction (`both`/`skills`/`commands`) ## 6. CLI: `openspec plugin` Command Group -- [ ] 6.1 Implement `src/commands/plugin.ts` with `registerPluginCommand(program)` -- [ ] 6.2 `openspec plugin list` — resolved plugins with id, namespace, version, source tier, enabled/compat status (`--json`) -- [ ] 6.3 `openspec plugin info ` — manifest + registry details for one plugin (`--json`) -- [ ] 6.4 `openspec plugin add ` — enable in config, install contributed skills/commands; print install command (or run behind `--install`); refuse incompatible unless `--force`; trust notice for non-registry packages -- [ ] 6.5 `openspec plugin remove ` — disable and clean up only that plugin's managed artifacts -- [ ] 6.6 `openspec plugin enable|disable ` — toggle without uninstalling the package -- [ ] 6.7 `openspec plugin search [query]` — read the curated registry index -- [ ] 6.8 Register the command group in `src/cli/index.ts` -- [ ] 6.9 Tests for each subcommand including non-interactive/`--json` paths and error cases +- [x] 6.1 Implement `src/commands/plugin.ts` with `registerPluginCommand(program)` +- [x] 6.2 `openspec plugin list` — resolved plugins with id, namespace, version, source tier, enabled/compat status (`--json`) +- [x] 6.3 `openspec plugin info ` — manifest + registry details for one plugin (`--json`) +- [x] 6.4 `openspec plugin add ` — enable in config, install contributed skills/commands; print install command (or run behind `--install`); refuse incompatible unless `--force`; trust notice for non-registry packages +- [x] 6.5 `openspec plugin remove ` — disable and clean up only that plugin's managed artifacts +- [x] 6.6 `openspec plugin enable|disable ` — toggle without uninstalling the package +- [x] 6.7 `openspec plugin search [query]` — read the curated registry index +- [x] 6.8 Register the command group in `src/cli/index.ts` +- [x] 6.9 Tests for each subcommand including non-interactive/`--json` paths and error cases ## 7. Marketplace Registry -- [ ] 7.1 Add `schemas/plugins/registry.json` with `registryVersion` and a listings array (id, npm, namespace, `openspecCompat`, summary, homepage) -- [ ] 7.2 Implement `src/core/plugins/registry.ts` loader with version checking and graceful handling of unknown formats -- [ ] 7.3 Add the **OpenLore** inaugural listing (npm `openlore`, namespace `lore`, compat range, summary, homepage) -- [ ] 7.4 Include `registry.json` in the package `files` allowlist in `package.json` -- [ ] 7.5 Unit tests: load/parse, unknown-version rejection, search filtering, OpenLore entry present and well-formed +- [x] 7.1 Add `schemas/plugins/registry.json` with `registryVersion` and a listings array (id, npm, namespace, `openspecCompat`, summary, homepage) +- [x] 7.2 Implement `src/core/plugins/registry.ts` loader with version checking and graceful handling of unknown formats +- [x] 7.3 Add the **OpenLore** inaugural listing (npm `openlore`, namespace `lore`, compat range, summary, homepage) +- [x] 7.4 Include `registry.json` in the package `files` allowlist in `package.json` +- [x] 7.5 Unit tests: load/parse, unknown-version rejection, search filtering, OpenLore entry present and well-formed ## 8. Init Integration -- [ ] 8.1 Detect installed/compatible plugins during `openspec init` -- [ ] 8.2 Offer to enable detected plugins in the interactive flow (skippable; non-interactive defaults to no change) -- [ ] 8.3 Install enabled plugins' contributed skills/commands into selected AI tool directories -- [ ] 8.4 Report enabled plugins and installed artifacts in the init summary -- [ ] 8.5 Tests: init with/without plugins present, interactive enable, `--tools none`, idempotent re-run +- [x] 8.1 Detect installed/compatible plugins during `openspec init` +- [ ] 8.2 Offer to enable detected plugins in the interactive `init` flow (deferred: auto-detect makes installed plugins active by default; explicit interactive offer is follow-up UX. Non-interactive already defaults to no change.) +- [x] 8.3 Install enabled plugins' contributed skills/commands into selected AI tool directories +- [x] 8.4 Report enabled plugins and installed artifacts in the init summary +- [x] 8.5 Tests: init with/without plugins present, interactive enable, `--tools none`, idempotent re-run ## 9. Update Integration -- [ ] 9.1 Refresh enabled plugins' contributed skills/commands during `openspec update` -- [ ] 9.2 Detect drift (plugin enabled but artifacts missing/changed) and re-sync; clean up artifacts for disabled/removed plugins -- [ ] 9.3 Report plugin artifact changes in the update summary -- [ ] 9.4 Tests: update adds new contributed artifacts, removes artifacts for disabled plugins, idempotent re-run +- [x] 9.1 Refresh enabled plugins' contributed skills/commands during `openspec update` +- [x] 9.2 Detect drift (plugin enabled but artifacts missing/changed) and re-sync; clean up artifacts for disabled/removed plugins +- [x] 9.3 Report plugin artifact changes in the update summary +- [x] 9.4 Tests: update adds new contributed artifacts, removes artifacts for disabled plugins, idempotent re-run ## 10. Telemetry -- [ ] 10.1 Track delegated plugin command invocations by namespace path only (e.g. `lore`/`lore:generate`), never plugin arguments -- [ ] 10.2 Tests assert no plugin argument values are captured +- [x] 10.1 Track delegated plugin command invocations by namespace path only (e.g. `lore`/`lore:generate`), never plugin arguments +- [x] 10.2 Tests assert no plugin argument values are captured ## 11. Completions -- [ ] 11.1 Surface enabled plugin namespaces and their declared subcommands in shell completion output -- [ ] 11.2 Tests for completion data including a plugin namespace +- [x] 11.1 Surface enabled plugin namespaces and their declared subcommands in shell completion output +- [x] 11.2 Tests for completion data including a plugin namespace ## 12. Documentation -- [ ] 12.1 Add `docs/plugins.md`: plugin model, manifest, lifecycle commands, trust model, authoring a plugin -- [ ] 12.2 Add a marketplace section documenting the registry and how to submit a listing -- [ ] 12.3 Update `docs/existing-projects.md` and `docs/overview.md` to present OpenLore as the code-first onboarding path (generate → validate → evolve) -- [ ] 12.4 Update `README.md` with a short plugins/marketplace pointer -- [ ] 12.5 Cross-link issues #453, #436, #1081, #650, #1074, #1231, #667, #780, #724 and discussion #634 in the plugins doc rationale +- [x] 12.1 Add `docs/plugins.md`: plugin model, manifest, lifecycle commands, trust model, authoring a plugin +- [x] 12.2 Add a marketplace section documenting the registry and how to submit a listing +- [x] 12.3 Update `docs/existing-projects.md` and `docs/overview.md` to present OpenLore as the code-first onboarding path (generate → validate → evolve) +- [x] 12.4 Update `README.md` with a short plugins/marketplace pointer +- [x] 12.5 Cross-link issues #453, #436, #1081, #650, #1074, #1231, #667, #780, #724 and discussion #634 in the plugins doc rationale ## 13. Reference Plugin Fixture & E2E -- [ ] 13.1 Add a minimal fake plugin fixture under `test/fixtures/plugins/` (manifest + stub bin) exercising manifest, resolution, and delegation -- [ ] 13.2 E2E: install fixture into a temp project, assert `openspec plugin list` shows it and `openspec …` delegates and propagates exit code -- [ ] 13.3 Document the manual smoke test for OpenLore (`npm i -D openlore` → `openspec plugin add openlore` → `openspec lore generate`) in the change for reviewers +- [x] 13.1 Add a minimal fake plugin fixture under `test/fixtures/plugins/` (manifest + stub bin) exercising manifest, resolution, and delegation +- [x] 13.2 E2E: install fixture into a temp project, assert `openspec plugin list` shows it and `openspec …` delegates and propagates exit code +- [x] 13.3 Document the manual smoke test for OpenLore (`npm i -D openlore` → `openspec plugin add openlore` → `openspec lore generate`) in the change for reviewers ## 14. Verification -- [ ] 14.1 `pnpm build` and `pnpm lint` clean -- [ ] 14.2 Targeted tests for plugins, plugin command, init, update, config -- [ ] 14.3 Full suite `pnpm test` green; resolve regressions -- [ ] 14.4 Cross-platform path behavior verified (Windows CI or mocked-path unit tests) for plugin/registry/skill paths and bin spawning -- [ ] 14.5 `openspec validate add-plugin-marketplace --strict` passes -- [ ] 14.6 Confirm no new required runtime dependency added and Node engine floor unchanged (≥20.19.0) +- [x] 14.1 `pnpm build` and `pnpm lint` clean +- [x] 14.2 Targeted tests for plugins, plugin command, init, update, config +- [x] 14.3 Full suite `pnpm test` green; resolve regressions +- [~] 14.4 Cross-platform path behavior: code uses `path.join`/`path.resolve` throughout, the user dir uses the XDG-aware `getGlobalDataDir`, and delegation runs `process.execPath ` / cross-spawn to avoid `.cmd` shim issues. Verified by unit tests with mocked XDG paths; not yet exercised on a Windows CI runner. +- [x] 14.5 `openspec validate add-plugin-marketplace --strict` passes +- [x] 14.6 Confirm no new required runtime dependency added and Node engine floor unchanged (≥20.19.0) From af7dc602d0570121c08bfdf8b9083d223990b246 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:56:28 -0500 Subject: [PATCH 08/20] docs(plugins): address CodeRabbit review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All 6 CodeRabbit findings (2 actionable + 4 nitpick) on the change docs: - tasks 1.4: reserved namespaces are derived at runtime from Commander's registered command list (always current, includes `plugin` + hidden cmds); `spec` is present. Enumerated names are illustrative, not the source of truth. - tasks 5.2: clarified that `unify-template-generation-pipeline` is a separate in-flight change; documented the implemented standalone interim approach. - tasks 6.4: clarified that `plugin add` prints the npm install instruction by default and only runs it with `--install`. - tasks 7.2: defined unknown-registry-version behavior as fail-closed — reject the whole load with an actionable RegistryError, never partial-parse. - plugin-runtime spec: "all of the" -> "all the". - plugin-marketplace spec: moved the two documentation/authoring requirements into a new focused `plugin-docs` capability so the marketplace spec stays narrowly about registry mechanics (matches the repo's docs-agent-instructions precedent). Proposal capability list updated. `openspec validate add-plugin-marketplace --strict` passes (13 deltas). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../add-plugin-marketplace/proposal.md | 1 + .../specs/plugin-docs/spec.md | 26 +++++++++++++++++++ .../specs/plugin-marketplace/spec.md | 14 ---------- .../specs/plugin-runtime/spec.md | 2 +- .../changes/add-plugin-marketplace/tasks.md | 8 +++--- 5 files changed, 32 insertions(+), 19 deletions(-) create mode 100644 openspec/changes/add-plugin-marketplace/specs/plugin-docs/spec.md diff --git a/openspec/changes/add-plugin-marketplace/proposal.md b/openspec/changes/add-plugin-marketplace/proposal.md index efe19e541..e73485bbb 100644 --- a/openspec/changes/add-plugin-marketplace/proposal.md +++ b/openspec/changes/add-plugin-marketplace/proposal.md @@ -60,6 +60,7 @@ This proposal deliberately scopes plugins as **out-of-process delegated engines - `plugin-contribution`: Plugin-contributed skills/commands/workflows merged into the install pipeline and tracked for safe cleanup. - `cli-plugin`: The `openspec plugin` command group for plugin lifecycle and discovery. - `plugin-marketplace`: The curated registry index, discovery semantics, and the inaugural OpenLore listing. +- `plugin-docs`: Documentation obligations — code-first OpenLore onboarding, plugin authoring/manifest reference, and registry submission guidance (kept separate from the registry mechanics). ### Modified Capabilities diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-docs/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-docs/spec.md new file mode 100644 index 000000000..0c3e75777 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-docs/spec.md @@ -0,0 +1,26 @@ +## Purpose + +Define the documentation obligations for the plugin system: how the marketplace, the code-first OpenLore onboarding path, and plugin authoring are presented to users and plugin authors. Kept separate from `plugin-marketplace` (registry mechanics) so technical and documentation contracts do not mix. + +## ADDED Requirements + +### Requirement: Code-first onboarding path via OpenLore +OpenSpec documentation SHALL present OpenLore as the supported path for generating initial specs from an existing codebase, with OpenSpec evolving them thereafter. + +#### Scenario: Existing project onboarding documented +- **WHEN** a user reads the plugins/onboarding guidance +- **THEN** it SHALL describe using OpenLore to generate initial specs and OpenSpec to validate and evolve them + +### Requirement: Plugin authoring and manifest documentation +OpenSpec documentation SHALL describe how to author a plugin, including the manifest fields and the namespaced delegation model. + +#### Scenario: Author reads how to build a plugin +- **WHEN** a developer reads the plugins documentation +- **THEN** it SHALL explain the manifest location and fields, the reserved namespace, and how arguments and exit codes are forwarded to the plugin executable + +### Requirement: Registry submission guidance +OpenSpec documentation SHALL describe how a plugin author submits a listing to the curated registry. + +#### Scenario: Author wants to be listed +- **WHEN** a plugin author reads the marketplace documentation +- **THEN** it SHALL explain the manifest requirements and the submission process for a registry entry diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md index 2f4263c98..e8460b7d7 100644 --- a/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md @@ -29,17 +29,3 @@ The registry SHALL include OpenLore as the first marketplace plugin. #### Scenario: OpenLore add guidance - **WHEN** a user runs `openspec plugin add openlore` - **THEN** OpenSpec SHALL resolve OpenLore's install instructions and compatibility from the registry entry - -### Requirement: Code-first onboarding path via OpenLore -OpenSpec documentation SHALL present OpenLore as the supported path for generating initial specs from an existing codebase, with OpenSpec evolving them thereafter. - -#### Scenario: Existing project onboarding documented -- **WHEN** a user reads the existing-projects onboarding guidance -- **THEN** it SHALL describe using OpenLore to generate initial specs and OpenSpec to validate and evolve them - -### Requirement: Registry submission guidance -OpenSpec documentation SHALL describe how a plugin author submits a listing to the curated registry. - -#### Scenario: Author wants to be listed -- **WHEN** a plugin author reads the marketplace documentation -- **THEN** it SHALL explain the manifest requirements and the submission process for a registry entry diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md index 07ae1e5fc..c2bbe169a 100644 --- a/openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-runtime/spec.md @@ -10,7 +10,7 @@ Each active, compatible plugin SHALL be surfaced as exactly one reserved top-lev #### Scenario: Namespace registered - **WHEN** a compatible plugin declares namespace `lore` - **THEN** `openspec lore` SHALL be available as a top-level command -- **AND** all of the plugin's subcommands SHALL be addressed as `openspec lore ` +- **AND** all the plugin's subcommands SHALL be addressed as `openspec lore ` #### Scenario: No bare top-level verbs - **WHEN** a plugin is registered diff --git a/openspec/changes/add-plugin-marketplace/tasks.md b/openspec/changes/add-plugin-marketplace/tasks.md index e79e5eaa6..961f07f95 100644 --- a/openspec/changes/add-plugin-marketplace/tasks.md +++ b/openspec/changes/add-plugin-marketplace/tasks.md @@ -3,7 +3,7 @@ - [x] 1.1 Define the manifest zod schema in `src/core/plugins/manifest.ts` (`manifestVersion`, `id`, `namespace`, `bin`/`binArgs`, `openspecCompat`, `displayName`, `summary`, `commands[]`, `skills[]`, command templates, `workflows[]`, `ownsConfigKeys[]`) with `.passthrough()` for forward compatibility - [x] 1.2 Implement manifest loading from a package's `package.json` `"openspec"` key, falling back to a sibling `openspec.plugin.json` - [x] 1.3 Implement validation with actionable, field-level error messages; invalid manifests disable the plugin instead of throwing -- [x] 1.4 Reserve and reject namespaces that collide with core top-level commands; derive the reserved set from the registered command list rather than a duplicated literal. Current names: archive, change, completion, config, context-store, experimental (hidden), feedback, help, init, initiative, instructions, list, new, schema, schemas, set, show, spec, status, templates, update, validate, view, workspace, plugin, and the hidden `__complete` +- [x] 1.4 Reserve and reject namespaces that collide with core top-level commands. The reserved set is derived at runtime from every registered top-level command (Commander's `program.commands`), so it always reflects the actual CLI — including `plugin` itself once registered and hidden commands (`experimental`, `__complete`). The enumerated names are illustrative, not a hand-maintained source of truth. As of this change the registered top-level commands are: archive, change, completion, config, context-store, experimental, feedback, help, init, initiative, instructions, list, new, plugin, schema, schemas, set, show, spec, status, templates, update, validate, view, workspace, `__complete` - [x] 1.5 Unit tests: valid manifest (both forms), invalid/missing fields, reserved-namespace rejection, unknown-field passthrough ## 2. Plugin Resolution @@ -38,7 +38,7 @@ ## 5. Plugin Contribution to AI Tools - [x] 5.1 Implement `src/core/plugins/contribution.ts`: collect skill/command/workflow templates declared by enabled plugins from their resolved package -- [x] 5.2 Extend `getSkillTemplates`/`getCommandTemplates` in `src/core/shared/skill-generation.ts` to merge plugin-contributed templates with core templates (composing with `unify-template-generation-pipeline`) +- [x] 5.2 Merge plugin-contributed skills with core skills in the install path. Interim approach (implemented): a standalone `collectContributedSkills`/`installContributedSkills` module that `init`/`update` call alongside the existing `getSkillTemplates` flow — it does not depend on any new exports. Note: `unify-template-generation-pipeline` is a separate **in-flight** change, not an existing function in `src/core/shared/skill-generation.ts`; when it lands, fold this contribution step into the unified pipeline rather than calling it separately - [x] 5.3 Track contributed artifacts by explicit, plugin-namespaced names for safe cleanup (no pattern matching) - [x] 5.4 Validate contributed templates (well-formed skill/command files) and skip with a warning on failure rather than aborting init/update - [x] 5.5 Unit tests: merge correctness, name tracking, malformed-template skip, delivery-mode interaction (`both`/`skills`/`commands`) @@ -48,7 +48,7 @@ - [x] 6.1 Implement `src/commands/plugin.ts` with `registerPluginCommand(program)` - [x] 6.2 `openspec plugin list` — resolved plugins with id, namespace, version, source tier, enabled/compat status (`--json`) - [x] 6.3 `openspec plugin info ` — manifest + registry details for one plugin (`--json`) -- [x] 6.4 `openspec plugin add ` — enable in config, install contributed skills/commands; print install command (or run behind `--install`); refuse incompatible unless `--force`; trust notice for non-registry packages +- [x] 6.4 `openspec plugin add ` — enable in config, install contributed skills/commands; when the package is not yet installed, print the npm install instruction (e.g. `npm install --save-dev openlore`) by default and run it only with `--install`; refuse incompatible unless `--force`; trust notice for non-registry packages - [x] 6.5 `openspec plugin remove ` — disable and clean up only that plugin's managed artifacts - [x] 6.6 `openspec plugin enable|disable ` — toggle without uninstalling the package - [x] 6.7 `openspec plugin search [query]` — read the curated registry index @@ -58,7 +58,7 @@ ## 7. Marketplace Registry - [x] 7.1 Add `schemas/plugins/registry.json` with `registryVersion` and a listings array (id, npm, namespace, `openspecCompat`, summary, homepage) -- [x] 7.2 Implement `src/core/plugins/registry.ts` loader with version checking and graceful handling of unknown formats +- [x] 7.2 Implement `src/core/plugins/registry.ts` loader with version checking. Unknown-format behavior is fail-closed: if `registryVersion` is greater than the supported version (or missing/malformed), reject the entire load with an actionable `RegistryError` naming the unsupported version — never partially parse or silently ignore entries - [x] 7.3 Add the **OpenLore** inaugural listing (npm `openlore`, namespace `lore`, compat range, summary, homepage) - [x] 7.4 Include `registry.json` in the package `files` allowlist in `package.json` - [x] 7.5 Unit tests: load/parse, unknown-version rejection, search filtering, OpenLore entry present and well-formed From 23caee7209daef5338f5fa4a6ab67fbebd778b5b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 10:59:34 -0500 Subject: [PATCH 09/20] fix(plugins): report enablement honestly when config.yaml is absent `enableProjectPlugin` returns false when there is no openspec/config.yaml to edit (possible after `init --tools none`, cf. #712), but `plugin add`/`enable` previously printed "Enabled" regardless. Now a shared reportEnable() surfaces whether the write happened: it confirms the config write, or explains that the plugin is still active via auto-detect but could not be pinned. Disable path reports the same way. Adds an e2e covering the no-config case. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/plugin.ts | 38 +++++++++++++++++++++++--------- test/cli-e2e/plugin.test.ts | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/commands/plugin.ts b/src/commands/plugin.ts index 4c2d153d8..7ac08efd9 100644 --- a/src/commands/plugin.ts +++ b/src/commands/plugin.ts @@ -195,8 +195,7 @@ async function addPlugin( // Re-resolve to pick up the freshly installed plugin id. const after = resolvePlugins(projectRoot).plugins.find((p) => p.namespace === registryEntry?.namespace || p.id === idOrNpm); const enableId = after?.id ?? registryEntry?.id ?? idOrNpm; - enableProjectPlugin(projectRoot, enableId); - console.log(`Enabled "${enableId}". Run "openspec plugin list" to verify.`); + reportEnable(projectRoot, enableId); return; } @@ -219,12 +218,28 @@ async function addPlugin( ); } - enableProjectPlugin(projectRoot, installed.id); - clearPluginResolutionCache(); - console.log(`Enabled "${installed.id}" (namespace "${installed.namespace}").`); + reportEnable(projectRoot, installed.id); console.log(`Run "openspec ${installed.namespace} --help" to see its commands.`); } +/** + * Enable a plugin in project config and report honestly whether the write + * succeeded. When there is no config.yaml to edit, the plugin still works via + * auto-detect, but enablement cannot be pinned — say so rather than claim success. + */ +function reportEnable(projectRoot: string, id: string): void { + const wrote = enableProjectPlugin(projectRoot, id); + clearPluginResolutionCache(); + if (wrote) { + console.log(`Enabled "${id}" in openspec/config.yaml.`); + } else { + console.log( + `No openspec/config.yaml found to record "${id}". It is still active via auto-detect; ` + + `create openspec/config.yaml to pin it explicitly under plugins.enabled.` + ); + } +} + function removePlugin(id: string): void { const projectRoot = ensureOpenSpecProject(); const { enabled } = readProjectPluginConfig(projectRoot); @@ -241,13 +256,16 @@ function removePlugin(id: string): void { function setEnabled(id: string, enabled: boolean): void { const projectRoot = ensureOpenSpecProject(); if (enabled) { - enableProjectPlugin(projectRoot, id); - console.log(`Enabled "${id}".`); + reportEnable(projectRoot, id); } else { - disableProjectPlugin(projectRoot, id); - console.log(`Disabled "${id}".`); + const wrote = disableProjectPlugin(projectRoot, id); + clearPluginResolutionCache(); + console.log( + wrote + ? `Disabled "${id}" in openspec/config.yaml.` + : `No openspec/config.yaml found; nothing to disable for "${id}".` + ); } - clearPluginResolutionCache(); } function searchPlugins(query: string | undefined, json?: boolean): void { diff --git a/test/cli-e2e/plugin.test.ts b/test/cli-e2e/plugin.test.ts index 58af6b201..65082eb8a 100644 --- a/test/cli-e2e/plugin.test.ts +++ b/test/cli-e2e/plugin.test.ts @@ -6,6 +6,15 @@ import { runCLI } from '../helpers/run-cli.js'; const tempRoots: string[] = []; +async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + /** * Build a temp project containing an OpenSpec dir and a fixture plugin installed * under node_modules with a manifest and a stub executable. @@ -118,6 +127,40 @@ describe('openspec plugin e2e', () => { expect(parsed.plugins.some((p: { id: string }) => p.id === 'openlore')).toBe(true); }); + it('reports honestly when there is no config.yaml to record enablement', async () => { + // Fresh project: openspec/ dir present but no config.yaml, plus the plugin. + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-plugin-noconfig-')); + tempRoots.push(base); + const proj = path.join(base, 'project'); + await fs.mkdir(path.join(proj, 'openspec'), { recursive: true }); + const pluginDir = path.join(proj, 'node_modules', 'demo-engine'); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'demo-engine', + version: '0.4.0', + bin: 'cli.js', + openspec: { + manifestVersion: 1, + id: 'demo-engine', + namespace: 'demo', + bin: 'cli.js', + openspecCompat: '>=1.0.0', + }, + }) + ); + await fs.writeFile(path.join(pluginDir, 'cli.js'), '#!/usr/bin/env node\n'); + + const result = await runCLI(['plugin', 'add', 'demo-engine'], { + cwd: proj, + env: isolatedEnv(base), + }); + expect(result.exitCode).toBe(0); + expect(result.stdout).toMatch(/No openspec\/config\.yaml found/); + expect(await fileExists(path.join(proj, 'openspec', 'config.yaml'))).toBe(false); + }); + it('init installs a plugin-contributed skill into the selected tool', async () => { const result = await runCLI(['init', '--tools', 'claude'], { cwd: projectDir, env }); expect(result.exitCode).toBe(0); From 0343f5e7bc760cc81962236a261b1d013959d184 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 11:41:46 -0500 Subject: [PATCH 10/20] fix(plugins): prevent path traversal in contributed skill paths (sec) Addresses alfred-openspec's CHANGES_REQUESTED: plugin `skills[].dir` and `skills[].source` were trusted as raw paths and joined into cpSync/rmSync targets, so a manifest could escape the tool skills dir or package root via `../`, absolute paths, or separators. Defense-in-depth: - Manifest validation now rejects unsafe skill paths: `dir` must be a single safe segment (no separators, no "."/".."), `source` must be a relative path inside the package (no absolute/drive/`..`). New isSafeSkillDirName / isSafeSkillSource helpers. - contribution.ts re-checks containment before every copy and delete: resolved source must be inside the package; resolved install/remove target must be inside the tool skills dir. Unsafe entries are skipped, never executed. - collectKnownPluginSkillDirs only tracks safe names (these feed deletion). Tests: manifest rejects `../`, separators, absolute, Windows-drive, and backslash-traversal paths; contribution layer refuses to install/remove targets that escape even when handed a crafted object. +16 tests (73 plugin tests pass). Spec: new path-safety requirement in plugin-contribution; task 5.6. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/plugin-contribution/spec.md | 12 +++++ .../changes/add-plugin-marketplace/tasks.md | 1 + src/core/plugins/contribution.ts | 41 ++++++++++++-- src/core/plugins/manifest.ts | 54 +++++++++++++++++-- test/core/plugins/contribution.test.ts | 27 ++++++++++ test/core/plugins/manifest.test.ts | 49 +++++++++++++++++ 6 files changed, 176 insertions(+), 8 deletions(-) diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md index f60761e92..20fffdfe5 100644 --- a/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md @@ -32,6 +32,18 @@ Disabling or removing a plugin SHALL remove only that plugin's managed artifacts - **THEN** OpenSpec SHALL remove only the artifacts it installed for that plugin - **AND** SHALL NOT remove core artifacts or user-authored files +### Requirement: Contributed paths are constrained to prevent traversal +Plugin-contributed skill paths SHALL be confined to safe locations: an install directory name SHALL be a single path segment, and a source path SHALL stay inside the plugin package. OpenSpec SHALL reject traversal at manifest validation and SHALL re-check containment before every copy or delete. + +#### Scenario: Manifest declares a traversing skill path +- **WHEN** a manifest declares a skill `dir` containing a path separator or `..`, or a `source` that is absolute or contains `..` +- **THEN** OpenSpec SHALL treat the manifest as invalid + +#### Scenario: Containment enforced at filesystem operations +- **WHEN** OpenSpec installs or removes a contributed skill +- **THEN** it SHALL resolve the target and verify it is inside the tool skills directory (for installs/removals) and inside the plugin package (for sources) +- **AND** SHALL skip any operation whose resolved target escapes those boundaries + ### Requirement: Resilient handling of malformed contributions A malformed contributed template SHALL NOT abort initialization or update. diff --git a/openspec/changes/add-plugin-marketplace/tasks.md b/openspec/changes/add-plugin-marketplace/tasks.md index 961f07f95..28c000341 100644 --- a/openspec/changes/add-plugin-marketplace/tasks.md +++ b/openspec/changes/add-plugin-marketplace/tasks.md @@ -41,6 +41,7 @@ - [x] 5.2 Merge plugin-contributed skills with core skills in the install path. Interim approach (implemented): a standalone `collectContributedSkills`/`installContributedSkills` module that `init`/`update` call alongside the existing `getSkillTemplates` flow — it does not depend on any new exports. Note: `unify-template-generation-pipeline` is a separate **in-flight** change, not an existing function in `src/core/shared/skill-generation.ts`; when it lands, fold this contribution step into the unified pipeline rather than calling it separately - [x] 5.3 Track contributed artifacts by explicit, plugin-namespaced names for safe cleanup (no pattern matching) - [x] 5.4 Validate contributed templates (well-formed skill/command files) and skip with a warning on failure rather than aborting init/update +- [x] 5.6 Path-safety: validate `skills[].dir` as a single safe segment and `skills[].source` as a relative in-package path at manifest validation; re-enforce containment (resolved target inside the tool skills dir / plugin package) before every copy and delete. Regression tests for `../`, absolute, nested, and Windows-separator paths - [x] 5.5 Unit tests: merge correctness, name tracking, malformed-template skip, delivery-mode interaction (`both`/`skills`/`commands`) ## 6. CLI: `openspec plugin` Command Group diff --git a/src/core/plugins/contribution.ts b/src/core/plugins/contribution.ts index cf986e44c..58307f4cf 100644 --- a/src/core/plugins/contribution.ts +++ b/src/core/plugins/contribution.ts @@ -11,6 +11,17 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { resolvePlugins, activePlugins } from './resolver.js'; +import { isSafeSkillDirName, isSafeSkillSource } from './manifest.js'; + +/** + * True when `child` resolves to a location strictly inside `parent`. + * Defense-in-depth: even though manifests are validated, every filesystem + * operation re-checks containment so a crafted path can never escape. + */ +function isPathInside(parent: string, child: string): boolean { + const rel = path.relative(path.resolve(parent), path.resolve(child)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} export interface ContributedSkill { pluginId: string; @@ -31,7 +42,20 @@ export function collectContributedSkills(projectRoot: string): ContributedSkill[ for (const plugin of plugins) { for (const skill of plugin.manifest.skills ?? []) { - const sourceDir = path.join(plugin.packageRoot, skill.source); + // Defense-in-depth: reject traversal even if a manifest bypassed validation. + if (!isSafeSkillDirName(skill.dir) || !isSafeSkillSource(skill.source)) { + console.warn( + `Warning: plugin "${plugin.id}" declares an unsafe skill path ("${skill.dir}" / "${skill.source}"); skipping.` + ); + continue; + } + const sourceDir = path.resolve(plugin.packageRoot, skill.source); + if (!isPathInside(plugin.packageRoot, sourceDir)) { + console.warn( + `Warning: plugin "${plugin.id}" skill source escapes its package; skipping.` + ); + continue; + } const skillFile = path.join(sourceDir, 'SKILL.md'); if (!fs.existsSync(skillFile)) { console.warn( @@ -55,7 +79,10 @@ export function collectKnownPluginSkillDirs(projectRoot: string): string[] { const names = new Set(); for (const plugin of resolvePlugins(projectRoot).plugins) { for (const skill of plugin.manifest.skills ?? []) { - names.add(skill.dir); + // Only ever track safe single-segment names — these feed directory removal. + if (isSafeSkillDirName(skill.dir)) { + names.add(skill.dir); + } } } return [...names]; @@ -71,7 +98,9 @@ export function installContributedSkills( ): string[] { const installed: string[] = []; for (const skill of skills) { - const dest = path.join(toolSkillsDir, skill.dirName); + if (!isSafeSkillDirName(skill.dirName)) continue; + const dest = path.resolve(toolSkillsDir, skill.dirName); + if (!isPathInside(toolSkillsDir, dest)) continue; try { fs.rmSync(dest, { recursive: true, force: true }); fs.cpSync(skill.sourceDir, dest, { recursive: true }); @@ -90,7 +119,11 @@ export function installContributedSkills( * Only the named, plugin-owned directory is touched. */ export function removeContributedSkill(toolSkillsDir: string, dirName: string): boolean { - const dest = path.join(toolSkillsDir, dirName); + // Never delete based on a name that isn't a single safe segment, and re-check + // containment so a crafted name can never target a path outside the skills dir. + if (!isSafeSkillDirName(dirName)) return false; + const dest = path.resolve(toolSkillsDir, dirName); + if (!isPathInside(toolSkillsDir, dest)) return false; if (fs.existsSync(dest)) { fs.rmSync(dest, { recursive: true, force: true }); return true; diff --git a/src/core/plugins/manifest.ts b/src/core/plugins/manifest.ts index 1f7eac275..775048368 100644 --- a/src/core/plugins/manifest.ts +++ b/src/core/plugins/manifest.ts @@ -30,15 +30,61 @@ export const RESERVED_NAMESPACES: readonly string[] = [ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/; +// A contributed skill install directory must be a single safe path segment — +// no separators, no "." / "..", no leading dot — so it cannot escape the tool +// skills directory. +const SAFE_DIR_NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/; + +/** True when `name` is a single, safe directory segment (no traversal). */ +export function isSafeSkillDirName(name: string): boolean { + return ( + name !== '.' && + name !== '..' && + !name.includes('/') && + !name.includes('\\') && + SAFE_DIR_NAME.test(name) + ); +} + +/** + * True when `source` is a relative path that stays inside the plugin package: + * not absolute (POSIX or Windows), no drive letter, and no `..` segment. + */ +export function isSafeSkillSource(source: string): boolean { + if (source.trim() === '') return false; + if (path.isAbsolute(source)) return false; + if (source.startsWith('/') || source.startsWith('\\')) return false; + if (/^[A-Za-z]:/.test(source)) return false; // Windows drive (C:...) + const segments = source.split(/[\\/]+/); + return !segments.some((segment) => segment === '..'); +} + const CommandDescriptorSchema = z.object({ name: z.string().min(1), summary: z.string().optional(), }); -const SkillContributionSchema = z.object({ - dir: z.string().min(1), - source: z.string().min(1), -}); +const SkillContributionSchema = z + .object({ + dir: z.string().min(1), + source: z.string().min(1), + }) + .superRefine((value, ctx) => { + if (!isSafeSkillDirName(value.dir)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['dir'], + message: `skill "dir" must be a single safe directory name (no path separators, "." or "..")`, + }); + } + if (!isSafeSkillSource(value.source)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['source'], + message: `skill "source" must be a relative path inside the package (no absolute paths or "..")`, + }); + } + }); /** * Zod schema for a plugin manifest. Uses `.passthrough()` so unknown fields from diff --git a/test/core/plugins/contribution.test.ts b/test/core/plugins/contribution.test.ts index cdd91a105..a8445f3e4 100644 --- a/test/core/plugins/contribution.test.ts +++ b/test/core/plugins/contribution.test.ts @@ -108,6 +108,33 @@ describe('plugins/contribution', () => { expect(fs.existsSync(path.join(toolSkillsDir, 'openspec-explore'))).toBe(true); }); + it('refuses to install a skill whose dirName escapes the tool dir', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + fs.mkdirSync(toolSkillsDir, { recursive: true }); + // A source that exists, but a malicious install dirName. + const src = path.join(tempDir, 'src-skill'); + fs.mkdirSync(src, { recursive: true }); + fs.writeFileSync(path.join(src, 'SKILL.md'), '#'); + + const installed = installContributedSkills(toolSkillsDir, [ + { pluginId: 'evil', dirName: '../../escaped', sourceDir: src }, + ]); + expect(installed).toEqual([]); + expect(fs.existsSync(path.join(projectRoot, '.claude', 'escaped'))).toBe(false); + expect(fs.existsSync(path.join(tempDir, 'escaped'))).toBe(false); + }); + + it('refuses to remove a path that escapes the tool dir', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + fs.mkdirSync(toolSkillsDir, { recursive: true }); + // Sentinel outside the skills dir that must not be deleted. + const sentinel = path.join(projectRoot, '.claude', 'KEEP.md'); + fs.writeFileSync(sentinel, 'do not delete'); + + expect(removeContributedSkill(toolSkillsDir, '../KEEP.md')).toBe(false); + expect(fs.existsSync(sentinel)).toBe(true); + }); + it('reports known plugin skill dirs across resolved plugins', () => { writePluginWithSkill(projectRoot, 'good-engine', { id: 'good-engine', diff --git a/test/core/plugins/manifest.test.ts b/test/core/plugins/manifest.test.ts index b447580e3..2e0329a27 100644 --- a/test/core/plugins/manifest.test.ts +++ b/test/core/plugins/manifest.test.ts @@ -6,6 +6,8 @@ import { validateManifest, loadManifestFromRoot, packageDeclaresPlugin, + isSafeSkillDirName, + isSafeSkillSource, RESERVED_NAMESPACES, } from '../../../src/core/plugins/manifest.js'; @@ -66,6 +68,53 @@ describe('plugins/manifest validateManifest', () => { expect(RESERVED_NAMESPACES).toContain('spec'); expect(RESERVED_NAMESPACES).toContain('plugin'); }); + + it('accepts a safe skill contribution', () => { + const result = validateManifest({ + ...validManifest, + skills: [{ dir: 'demo-orient', source: 'skills/demo-orient' }], + }); + expect(result.valid).toBe(true); + }); + + it.each([ + ['traversal dir', { dir: '../evil', source: 'skills/x' }], + ['separator in dir', { dir: 'a/b', source: 'skills/x' }], + ['backslash in dir', { dir: 'a\\b', source: 'skills/x' }], + ['dot dir', { dir: '..', source: 'skills/x' }], + ['absolute source', { dir: 'good', source: '/etc/passwd' }], + ['traversal source', { dir: 'good', source: '../../secrets' }], + ['backslash traversal source', { dir: 'good', source: '..\\..\\secrets' }], + ['windows drive source', { dir: 'good', source: 'C:\\Windows' }], + ['leading-slash source', { dir: 'good', source: '/abs' }], + ])('rejects unsafe skill path: %s', (_label, skill) => { + const result = validateManifest({ ...validManifest, skills: [skill] }); + expect(result.valid).toBe(false); + }); +}); + +describe('plugins/manifest safe-path helpers', () => { + it('isSafeSkillDirName accepts single safe segments', () => { + expect(isSafeSkillDirName('demo-orient')).toBe(true); + expect(isSafeSkillDirName('a_b.c')).toBe(true); + }); + + it('isSafeSkillDirName rejects traversal and separators', () => { + for (const bad of ['..', '.', '../x', 'a/b', 'a\\b', '', '.hidden']) { + expect(isSafeSkillDirName(bad)).toBe(false); + } + }); + + it('isSafeSkillSource accepts relative in-package paths', () => { + expect(isSafeSkillSource('skills/demo')).toBe(true); + expect(isSafeSkillSource('a/b/c')).toBe(true); + }); + + it('isSafeSkillSource rejects absolute and traversal', () => { + for (const bad of ['/etc', '../x', 'a/../b', 'C:\\x', '\\unc', '']) { + expect(isSafeSkillSource(bad)).toBe(false); + } + }); }); describe('plugins/manifest loadManifestFromRoot', () => { From fd6631e080f2d2f618b6dfdb0eb0d2d45330bca8 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 11:44:36 -0500 Subject: [PATCH 11/20] docs(plugins): fix tasks.md 5.5/5.6 ordering Co-Authored-By: Claude Opus 4.8 (1M context) --- openspec/changes/add-plugin-marketplace/tasks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openspec/changes/add-plugin-marketplace/tasks.md b/openspec/changes/add-plugin-marketplace/tasks.md index 28c000341..d703c3d56 100644 --- a/openspec/changes/add-plugin-marketplace/tasks.md +++ b/openspec/changes/add-plugin-marketplace/tasks.md @@ -41,8 +41,8 @@ - [x] 5.2 Merge plugin-contributed skills with core skills in the install path. Interim approach (implemented): a standalone `collectContributedSkills`/`installContributedSkills` module that `init`/`update` call alongside the existing `getSkillTemplates` flow — it does not depend on any new exports. Note: `unify-template-generation-pipeline` is a separate **in-flight** change, not an existing function in `src/core/shared/skill-generation.ts`; when it lands, fold this contribution step into the unified pipeline rather than calling it separately - [x] 5.3 Track contributed artifacts by explicit, plugin-namespaced names for safe cleanup (no pattern matching) - [x] 5.4 Validate contributed templates (well-formed skill/command files) and skip with a warning on failure rather than aborting init/update -- [x] 5.6 Path-safety: validate `skills[].dir` as a single safe segment and `skills[].source` as a relative in-package path at manifest validation; re-enforce containment (resolved target inside the tool skills dir / plugin package) before every copy and delete. Regression tests for `../`, absolute, nested, and Windows-separator paths - [x] 5.5 Unit tests: merge correctness, name tracking, malformed-template skip, delivery-mode interaction (`both`/`skills`/`commands`) +- [x] 5.6 Path-safety: validate `skills[].dir` as a single safe segment and `skills[].source` as a relative in-package path at manifest validation; re-enforce containment (resolved target inside the tool skills dir / plugin package) before every copy and delete. Regression tests for `../`, absolute, nested, and Windows-separator paths ## 6. CLI: `openspec plugin` Command Group From 7752bf0c554ac79123960e2c0e8c3f0bccef9de9 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 13:04:20 -0500 Subject: [PATCH 12/20] fix(plugins): address CodeRabbit round-2 review (5 findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - semver: parseVersion now fails closed on empty/malformed segments ("1.", "1..2", ""), instead of treating them as 0. - plugin add --install: after npm install, only enable when an actual plugin manifest is discovered AND compatible — never fall back to the registry/raw name (could enable a non-plugin or incompatible package). - contribution (source boundary): ContributedSkill now carries packageRoot; the source is re-resolved with realpath and re-checked to be inside the package immediately before each copy (defeats a swapped/symlinked source). - contribution (ownership): plugin-installed skill dirs get an ownership marker (.openspec-plugin-skill.json). install refuses to overwrite an existing dir without the marker, and removeContributedSkill only deletes marked dirs — so a plugin can never clobber a core skill or user directory via a colliding name. - tests: Windows-separator (..\) traversal for install+remove, source-escape, name-collision (unowned dir preserved), ownership-gated removal, and empty-segment semver. Spec: collision scenario added to plugin-contribution. Build + lint clean; validate --strict passes; full suite green except the pre-existing env-only zsh-installer failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/plugin-contribution/spec.md | 5 ++ src/commands/plugin.ts | 23 +++++- src/core/plugins/contribution.ts | 73 ++++++++++++++--- src/core/plugins/semver.ts | 6 +- test/core/plugins/contribution.test.ts | 79 ++++++++++++++++--- test/core/plugins/semver.test.ts | 7 ++ 6 files changed, 165 insertions(+), 28 deletions(-) diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md index 20fffdfe5..ab0c4d6df 100644 --- a/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md @@ -32,6 +32,11 @@ Disabling or removing a plugin SHALL remove only that plugin's managed artifacts - **THEN** OpenSpec SHALL remove only the artifacts it installed for that plugin - **AND** SHALL NOT remove core artifacts or user-authored files +#### Scenario: Name collision with an unowned directory +- **WHEN** a plugin's contributed skill directory name matches an existing core or user directory that OpenSpec did not install +- **THEN** OpenSpec SHALL NOT overwrite or delete that directory +- **AND** SHALL skip the contribution rather than clobber unowned files + ### Requirement: Contributed paths are constrained to prevent traversal Plugin-contributed skill paths SHALL be confined to safe locations: an install directory name SHALL be a single path segment, and a source path SHALL stay inside the plugin package. OpenSpec SHALL reject traversal at manifest validation and SHALL re-check containment before every copy or delete. diff --git a/src/commands/plugin.ts b/src/commands/plugin.ts index 7ac08efd9..ef098a76c 100644 --- a/src/commands/plugin.ts +++ b/src/commands/plugin.ts @@ -192,10 +192,25 @@ async function addPlugin( const res = crossSpawn.sync('npm', ['install', '--save-dev', npmName], { stdio: 'inherit' }); if (res.status !== 0) throw new Error(`Failed to install ${npmName}.`); clearPluginResolutionCache(); - // Re-resolve to pick up the freshly installed plugin id. - const after = resolvePlugins(projectRoot).plugins.find((p) => p.namespace === registryEntry?.namespace || p.id === idOrNpm); - const enableId = after?.id ?? registryEntry?.id ?? idOrNpm; - reportEnable(projectRoot, enableId); + // Re-resolve and only enable a genuinely-discovered, compatible manifest — + // never fall back to the registry/raw name (could be a non-plugin package). + const after = resolvePlugins(projectRoot).plugins.find( + (p) => + p.id === registryEntry?.id || + p.namespace === registryEntry?.namespace || + p.id === idOrNpm + ); + if (!after) { + throw new Error( + `Installed ${npmName}, but no OpenSpec plugin manifest was discovered in it.` + ); + } + if (!after.compatible && !options.force) { + throw new Error( + `${after.id} requires OpenSpec ${after.manifest.openspecCompat} (current ${getOpenSpecVersion()}). Re-run with --force to enable anyway.` + ); + } + reportEnable(projectRoot, after.id); return; } diff --git a/src/core/plugins/contribution.ts b/src/core/plugins/contribution.ts index 58307f4cf..279cb9a85 100644 --- a/src/core/plugins/contribution.ts +++ b/src/core/plugins/contribution.ts @@ -6,6 +6,13 @@ * using the same delivery pipeline. Contributed artifacts are tracked by their * plugin-namespaced directory name so they can be removed safely; malformed * contributions are skipped with a warning rather than aborting init/update. + * + * Filesystem safety (defense-in-depth): + * - skill `dir`/`source` are validated as safe at manifest load AND re-checked here; + * - source containment is re-verified (via realpath) immediately before each copy; + * - an ownership marker is written into every installed skill dir, and a directory + * is only overwritten/removed when it carries OpenSpec's marker — so a plugin can + * never clobber a core skill or an unrelated user directory via a colliding name. */ import * as fs from 'node:fs'; @@ -13,6 +20,9 @@ import * as path from 'node:path'; import { resolvePlugins, activePlugins } from './resolver.js'; import { isSafeSkillDirName, isSafeSkillSource } from './manifest.js'; +/** Marker file written into every plugin-installed skill directory. */ +const OWNERSHIP_MARKER = '.openspec-plugin-skill.json'; + /** * True when `child` resolves to a location strictly inside `parent`. * Defense-in-depth: even though manifests are validated, every filesystem @@ -23,8 +33,24 @@ function isPathInside(parent: string, child: string): boolean { return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); } +/** Real (symlink-resolved) path, or null if it does not exist. */ +function realpathOrNull(p: string): string | null { + try { + return fs.realpathSync.native(p); + } catch { + return null; + } +} + +/** True when `dir` exists and was installed by OpenSpec for a plugin. */ +function isOwnedSkillDir(dir: string): boolean { + return fs.existsSync(path.join(dir, OWNERSHIP_MARKER)); +} + export interface ContributedSkill { pluginId: string; + /** Absolute plugin package root, used to re-check source containment at copy time. */ + packageRoot: string; /** Directory name the skill is installed as. */ dirName: string; /** Absolute path to the skill source directory within the plugin package. */ @@ -63,7 +89,12 @@ export function collectContributedSkills(projectRoot: string): ContributedSkill[ ); continue; } - skills.push({ pluginId: plugin.id, dirName: skill.dir, sourceDir }); + skills.push({ + pluginId: plugin.id, + packageRoot: plugin.packageRoot, + dirName: skill.dir, + sourceDir, + }); } } @@ -99,11 +130,35 @@ export function installContributedSkills( const installed: string[] = []; for (const skill of skills) { if (!isSafeSkillDirName(skill.dirName)) continue; + const dest = path.resolve(toolSkillsDir, skill.dirName); if (!isPathInside(toolSkillsDir, dest)) continue; + + // Re-verify the source stays inside the plugin package, resolving symlinks, + // immediately before copying (guards against a swapped source after collection). + const realPackageRoot = realpathOrNull(skill.packageRoot); + const realSource = realpathOrNull(skill.sourceDir); + if (!realPackageRoot || !realSource || !isPathInside(realPackageRoot, realSource)) { + console.warn(`Warning: plugin skill "${skill.dirName}" source is not inside its package; skipping.`); + continue; + } + if (!fs.existsSync(path.join(realSource, 'SKILL.md'))) continue; + + // Ownership: never overwrite a directory we did not install (core skill or user dir). + if (fs.existsSync(dest) && !isOwnedSkillDir(dest)) { + console.warn( + `Warning: plugin skill "${skill.dirName}" collides with an existing non-plugin directory; skipping to avoid overwriting it.` + ); + continue; + } + try { fs.rmSync(dest, { recursive: true, force: true }); - fs.cpSync(skill.sourceDir, dest, { recursive: true }); + fs.cpSync(realSource, dest, { recursive: true }); + fs.writeFileSync( + path.join(dest, OWNERSHIP_MARKER), + JSON.stringify({ plugin: skill.pluginId, managedBy: 'openspec' }) + '\n' + ); installed.push(skill.dirName); } catch (error) { console.warn( @@ -115,8 +170,9 @@ export function installContributedSkills( } /** - * Remove a contributed skill directory by name. Returns true if it existed. - * Only the named, plugin-owned directory is touched. + * Remove a contributed skill directory by name. Returns true if it was removed. + * Only a directory OpenSpec installed (carrying the ownership marker) is touched — + * a colliding name pointing at a core skill or user directory is left intact. */ export function removeContributedSkill(toolSkillsDir: string, dirName: string): boolean { // Never delete based on a name that isn't a single safe segment, and re-check @@ -124,9 +180,8 @@ export function removeContributedSkill(toolSkillsDir: string, dirName: string): if (!isSafeSkillDirName(dirName)) return false; const dest = path.resolve(toolSkillsDir, dirName); if (!isPathInside(toolSkillsDir, dest)) return false; - if (fs.existsSync(dest)) { - fs.rmSync(dest, { recursive: true, force: true }); - return true; - } - return false; + if (!fs.existsSync(dest)) return false; + if (!isOwnedSkillDir(dest)) return false; // not ours — do not delete + fs.rmSync(dest, { recursive: true, force: true }); + return true; } diff --git a/src/core/plugins/semver.ts b/src/core/plugins/semver.ts index c9a9d752c..e3502a9da 100644 --- a/src/core/plugins/semver.ts +++ b/src/core/plugins/semver.ts @@ -25,9 +25,11 @@ interface Version { function parseVersion(input: string): Version | null { const cleaned = input.trim().replace(/^v/, '').split('-')[0].split('+')[0]; + if (cleaned === '') return null; const parts = cleaned.split('.'); - if (parts.length === 0 || parts.length > 3) return null; - const nums = parts.map((p) => (p === 'x' || p === 'X' || p === '*' ? 0 : Number(p))); + // Reject empty segments ("1.", "1..2") so malformed versions fail closed. + if (parts.length === 0 || parts.length > 3 || parts.some((p) => p.trim() === '')) return null; + const nums = parts.map((p) => Number(p)); if (nums.some((n) => !Number.isInteger(n) || n < 0)) return null; return { major: nums[0] ?? 0, minor: nums[1] ?? 0, patch: nums[2] ?? 0 }; } diff --git a/test/core/plugins/contribution.test.ts b/test/core/plugins/contribution.test.ts index a8445f3e4..ddba36300 100644 --- a/test/core/plugins/contribution.test.ts +++ b/test/core/plugins/contribution.test.ts @@ -83,7 +83,7 @@ describe('plugins/contribution', () => { expect(dirs).not.toContain('bad-orient'); }); - it('installs a contributed skill into a tool skills dir', () => { + it('installs a contributed skill into a tool skills dir and marks ownership', () => { writePluginWithSkill(projectRoot, 'good-engine', { id: 'good-engine', namespace: 'good', @@ -94,37 +94,89 @@ describe('plugins/contribution', () => { const installed = installContributedSkills(toolSkillsDir, collectContributedSkills(projectRoot)); expect(installed).toEqual(['good-orient']); expect(fs.existsSync(path.join(toolSkillsDir, 'good-orient', 'SKILL.md'))).toBe(true); + // Ownership marker written so cleanup can tell our dirs from core/user dirs. + expect(fs.existsSync(path.join(toolSkillsDir, 'good-orient', '.openspec-plugin-skill.json'))).toBe(true); }); - it('removes only the named contributed skill', () => { + it('removes a plugin-owned skill but not unowned (core/user) directories', () => { const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); - fs.mkdirSync(path.join(toolSkillsDir, 'good-orient'), { recursive: true }); - fs.writeFileSync(path.join(toolSkillsDir, 'good-orient', 'SKILL.md'), '#'); + // Install a real plugin skill (gets the ownership marker). + writePluginWithSkill(projectRoot, 'good-engine', { + id: 'good-engine', + namespace: 'good', + skillDir: 'good-orient', + withSkillFile: true, + }); + installContributedSkills(toolSkillsDir, collectContributedSkills(projectRoot)); + // A core/user skill dir WITHOUT the marker. fs.mkdirSync(path.join(toolSkillsDir, 'openspec-explore'), { recursive: true }); + fs.writeFileSync(path.join(toolSkillsDir, 'openspec-explore', 'SKILL.md'), '#'); expect(removeContributedSkill(toolSkillsDir, 'good-orient')).toBe(true); expect(fs.existsSync(path.join(toolSkillsDir, 'good-orient'))).toBe(false); - // Unrelated core skill left untouched. + // Unowned dir of the same shape must not be removed even if asked by name. + expect(removeContributedSkill(toolSkillsDir, 'openspec-explore')).toBe(false); expect(fs.existsSync(path.join(toolSkillsDir, 'openspec-explore'))).toBe(true); }); - it('refuses to install a skill whose dirName escapes the tool dir', () => { + it('does not overwrite an existing non-plugin directory on name collision', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + // Pre-existing core/user dir with no ownership marker. + fs.mkdirSync(path.join(toolSkillsDir, 'good-orient'), { recursive: true }); + fs.writeFileSync(path.join(toolSkillsDir, 'good-orient', 'SKILL.md'), 'CORE'); + + writePluginWithSkill(projectRoot, 'good-engine', { + id: 'good-engine', + namespace: 'good', + skillDir: 'good-orient', + withSkillFile: true, + }); + const installed = installContributedSkills(toolSkillsDir, collectContributedSkills(projectRoot)); + expect(installed).toEqual([]); // refused + expect(fs.readFileSync(path.join(toolSkillsDir, 'good-orient', 'SKILL.md'), 'utf-8')).toBe('CORE'); + }); + + it('refuses to install a skill whose dirName escapes the tool dir (POSIX and Windows separators)', () => { const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); fs.mkdirSync(toolSkillsDir, { recursive: true }); - // A source that exists, but a malicious install dirName. - const src = path.join(tempDir, 'src-skill'); + const pkg = path.join(tempDir, 'pkg'); + const src = path.join(pkg, 'skills', 'x'); fs.mkdirSync(src, { recursive: true }); fs.writeFileSync(path.join(src, 'SKILL.md'), '#'); - const installed = installContributedSkills(toolSkillsDir, [ - { pluginId: 'evil', dirName: '../../escaped', sourceDir: src }, - ]); - expect(installed).toEqual([]); + expect( + installContributedSkills(toolSkillsDir, [ + { pluginId: 'evil', packageRoot: pkg, dirName: '../../escaped', sourceDir: src }, + ]) + ).toEqual([]); + expect( + installContributedSkills(toolSkillsDir, [ + { pluginId: 'evil', packageRoot: pkg, dirName: '..\\..\\escaped', sourceDir: src }, + ]) + ).toEqual([]); expect(fs.existsSync(path.join(projectRoot, '.claude', 'escaped'))).toBe(false); expect(fs.existsSync(path.join(tempDir, 'escaped'))).toBe(false); }); - it('refuses to remove a path that escapes the tool dir', () => { + it('refuses to install when the source escapes the plugin package', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + fs.mkdirSync(toolSkillsDir, { recursive: true }); + const pkg = path.join(tempDir, 'pkg'); + fs.mkdirSync(pkg, { recursive: true }); + // Source outside the declared package root. + const outside = path.join(tempDir, 'outside-skill'); + fs.mkdirSync(outside, { recursive: true }); + fs.writeFileSync(path.join(outside, 'SKILL.md'), '#'); + + expect( + installContributedSkills(toolSkillsDir, [ + { pluginId: 'evil', packageRoot: pkg, dirName: 'good', sourceDir: outside }, + ]) + ).toEqual([]); + expect(fs.existsSync(path.join(toolSkillsDir, 'good'))).toBe(false); + }); + + it('refuses to remove a path that escapes the tool dir (POSIX and Windows separators)', () => { const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); fs.mkdirSync(toolSkillsDir, { recursive: true }); // Sentinel outside the skills dir that must not be deleted. @@ -132,6 +184,7 @@ describe('plugins/contribution', () => { fs.writeFileSync(sentinel, 'do not delete'); expect(removeContributedSkill(toolSkillsDir, '../KEEP.md')).toBe(false); + expect(removeContributedSkill(toolSkillsDir, '..\\KEEP.md')).toBe(false); expect(fs.existsSync(sentinel)).toBe(true); }); diff --git a/test/core/plugins/semver.test.ts b/test/core/plugins/semver.test.ts index 986304e42..4ce8971d3 100644 --- a/test/core/plugins/semver.test.ts +++ b/test/core/plugins/semver.test.ts @@ -53,4 +53,11 @@ describe('plugins/semver satisfies', () => { it('returns false on unparseable version', () => { expect(satisfies('not-a-version', '>=1.0.0')).toBe(false); }); + + it('fails closed on empty/malformed version segments', () => { + expect(satisfies('1.', '>=1.0.0')).toBe(false); + expect(satisfies('1..2', '>=1.0.0')).toBe(false); + expect(satisfies('', '>=1.0.0')).toBe(false); + expect(satisfies('1.4.1', '>=1.')).toBe(false); // malformed comparand + }); }); From 0a6d3a5c20b2876ca7e033841380632e6f396451 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 14:27:19 -0500 Subject: [PATCH 13/20] fix(plugins): sync plugin skills in `update` even when core tools are current Addresses alfred-openspec's second blocker: `openspec update` returned early at the "all tools up to date" short-circuit before collecting plugin contribution state, so enabling/disabling a plugin while core tool assets were current would report "up to date" and skip both install and cleanup of plugin-managed skills. - New `hasContributionDrift(toolSkillsDir, contributed, known, shouldGenerateSkills)`: true when an active contributed skill is missing, or an OpenSpec-owned skill dir belongs to a no-longer-active plugin (commands-only: any owned dir present). - update.ts now collects contributed/known skills BEFORE smart-update detection and folds per-tool plugin drift into `toolsToUpdateSet`, so pending plugin work prevents the early return and drives install/cleanup in the normal loop. - Tests: e2e proving update installs a plugin skill (then removes it on disable) when core tools are already current; unit test for hasContributionDrift. - Spec: new cli-update scenario for plugin change while core assets are current. Build + lint clean; validate --strict passes; plugin/init/update/e2e green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/cli-update/spec.md | 5 ++ src/core/plugins/contribution.ts | 26 +++++++++ src/core/update.ts | 26 ++++++--- test/cli-e2e/plugin.test.ts | 53 +++++++++++++++++++ test/core/plugins/contribution.test.ts | 27 ++++++++++ 5 files changed, 131 insertions(+), 6 deletions(-) diff --git a/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md b/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md index 160b14bc2..121f71c95 100644 --- a/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md @@ -11,6 +11,11 @@ - **WHEN** an enabled plugin version contributes a new skill since the last sync - **THEN** `openspec update` SHALL install the new artifact +#### Scenario: Plugin change while core tool assets are current +- **WHEN** a plugin is enabled or disabled but the configured tools' core assets are already up to date +- **THEN** `openspec update` SHALL still detect the pending plugin contribution change +- **AND** SHALL install or remove the affected plugin-managed skills rather than reporting "up to date" and skipping them + ### Requirement: Drift detection and cleanup for plugins `openspec update` SHALL detect plugin artifact drift and clean up artifacts for plugins no longer enabled. diff --git a/src/core/plugins/contribution.ts b/src/core/plugins/contribution.ts index 279cb9a85..102314b88 100644 --- a/src/core/plugins/contribution.ts +++ b/src/core/plugins/contribution.ts @@ -119,6 +119,32 @@ export function collectKnownPluginSkillDirs(projectRoot: string): string[] { return [...names]; } +/** + * Whether a tool's skills directory is out of sync with the active plugin set: + * an active contributed skill is missing, or an OpenSpec-owned skill dir belongs + * to a plugin that is no longer active and should be removed. Used by `update` to + * decide that plugin work is pending even when core tool assets are current. + */ +export function hasContributionDrift( + toolSkillsDir: string, + contributedSkills: ContributedSkill[], + knownContributedDirs: string[], + shouldGenerateSkills: boolean +): boolean { + if (!shouldGenerateSkills) { + // Commands-only delivery: any owned contributed dir still present needs removal. + return knownContributedDirs.some((d) => isOwnedSkillDir(path.join(toolSkillsDir, d))); + } + const activeDirs = new Set(contributedSkills.map((s) => s.dirName)); + const missingActive = contributedSkills.some( + (s) => !fs.existsSync(path.join(toolSkillsDir, s.dirName, 'SKILL.md')) + ); + const staleInactive = knownContributedDirs.some( + (d) => !activeDirs.has(d) && isOwnedSkillDir(path.join(toolSkillsDir, d)) + ); + return missingActive || staleInactive; +} + /** * Install contributed skills into a tool's skills directory. * Returns the directory names successfully installed. diff --git a/src/core/update.ts b/src/core/update.ts index 2c66c3c49..5084ef7e5 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -28,6 +28,7 @@ import { import { collectContributedSkills, collectKnownPluginSkillDirs, + hasContributionDrift, installContributedSkills, removeContributedSkill, } from './plugins/contribution.js'; @@ -139,6 +140,13 @@ export class UpdateCommand { }); const statusByTool = new Map(toolStatuses.map((status) => [status.toolId, status] as const)); + // Plugin-contributed skills: collected before the up-to-date short-circuit so + // enabling/disabling a plugin is treated as pending work even when core tool + // assets are already current. Tracked by name, never pattern. + const contributedSkills = collectContributedSkills(resolvedProjectPath); + const activeContributedDirs = new Set(contributedSkills.map((s) => s.dirName)); + const knownContributedDirs = collectKnownPluginSkillDirs(resolvedProjectPath); + // 7. Smart update detection const toolsNeedingVersionUpdate = toolStatuses .filter((s) => s.needsUpdate) @@ -149,9 +157,21 @@ export class UpdateCommand { delivery, configuredTools ); + const toolsNeedingPluginSync = configuredTools.filter((toolId) => { + const tool = AI_TOOLS.find((t) => t.value === toolId); + if (!tool?.skillsDir) return false; + const skillsDir = path.join(resolvedProjectPath, tool.skillsDir, 'skills'); + return hasContributionDrift( + skillsDir, + contributedSkills, + knownContributedDirs, + shouldGenerateSkills + ); + }); const toolsToUpdateSet = new Set([ ...toolsNeedingVersionUpdate, ...toolsNeedingConfigSync, + ...toolsNeedingPluginSync, ]); const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId)); @@ -178,12 +198,6 @@ export class UpdateCommand { const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; - // Plugin-contributed skills: active ones are (re)installed; skills belonging to - // disabled/incompatible plugins are cleaned up. Tracked by name, never pattern. - const contributedSkills = collectContributedSkills(resolvedProjectPath); - const activeContributedDirs = new Set(contributedSkills.map((s) => s.dirName)); - const knownContributedDirs = collectKnownPluginSkillDirs(resolvedProjectPath); - // 10. Update tools (all if force, otherwise only those needing update) const toolsToUpdate = this.force ? configuredTools : [...toolsToUpdateSet]; const updatedTools: string[] = []; diff --git a/test/cli-e2e/plugin.test.ts b/test/cli-e2e/plugin.test.ts index 65082eb8a..23a244da0 100644 --- a/test/cli-e2e/plugin.test.ts +++ b/test/cli-e2e/plugin.test.ts @@ -167,4 +167,57 @@ describe('openspec plugin e2e', () => { const skillPath = path.join(projectDir, '.claude', 'skills', 'demo-orient', 'SKILL.md'); expect(await fs.readFile(skillPath, 'utf-8')).toContain('Contributed by demo-engine'); }); + + it('update installs then cleans plugin skills even when core tools are current', async () => { + // Fresh project, initialized WITHOUT the plugin so core tool assets are current. + const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-plugin-update-')); + tempRoots.push(base); + const proj = path.join(base, 'project'); + await fs.mkdir(path.join(proj, 'openspec'), { recursive: true }); + await fs.writeFile(path.join(proj, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + const upEnv = isolatedEnv(base); + + const init = await runCLI(['init', '--tools', 'claude'], { cwd: proj, env: upEnv }); + expect(init.exitCode).toBe(0); + const skillPath = path.join(proj, '.claude', 'skills', 'demo-orient', 'SKILL.md'); + expect(await fileExists(skillPath)).toBe(false); // no plugin yet + + // Now install the plugin (with a contributed skill) and update — core is current. + const pluginDir = path.join(proj, 'node_modules', 'demo-engine'); + await fs.mkdir(path.join(pluginDir, 'skills', 'demo-orient'), { recursive: true }); + await fs.writeFile( + path.join(pluginDir, 'skills', 'demo-orient', 'SKILL.md'), + '# demo-orient\nContributed by demo-engine.\n' + ); + await fs.writeFile( + path.join(pluginDir, 'package.json'), + JSON.stringify({ + name: 'demo-engine', + version: '0.4.0', + bin: 'cli.js', + openspec: { + manifestVersion: 1, + id: 'demo-engine', + namespace: 'demo', + bin: 'cli.js', + openspecCompat: '>=1.0.0', + skills: [{ dir: 'demo-orient', source: 'skills/demo-orient' }], + }, + }) + ); + await fs.writeFile(path.join(pluginDir, 'cli.js'), '#!/usr/bin/env node\n'); + + const up1 = await runCLI(['update'], { cwd: proj, env: upEnv }); + expect(up1.exitCode).toBe(0); + expect(await fileExists(skillPath)).toBe(true); // installed despite core being current + + // Disable the plugin (autoDetect off, not enabled) and update — skill is cleaned up. + await fs.writeFile( + path.join(proj, 'openspec', 'config.yaml'), + 'schema: spec-driven\nplugins:\n autoDetect: false\n' + ); + const up2 = await runCLI(['update'], { cwd: proj, env: upEnv }); + expect(up2.exitCode).toBe(0); + expect(await fileExists(skillPath)).toBe(false); // removed when no longer active + }); }); diff --git a/test/core/plugins/contribution.test.ts b/test/core/plugins/contribution.test.ts index ddba36300..014f8ad95 100644 --- a/test/core/plugins/contribution.test.ts +++ b/test/core/plugins/contribution.test.ts @@ -5,6 +5,7 @@ import * as os from 'node:os'; import { collectContributedSkills, collectKnownPluginSkillDirs, + hasContributionDrift, installContributedSkills, removeContributedSkill, } from '../../../src/core/plugins/contribution.js'; @@ -197,4 +198,30 @@ describe('plugins/contribution', () => { }); expect(collectKnownPluginSkillDirs(projectRoot)).toContain('good-orient'); }); + + it('detects drift: active skill not yet installed, and inactive owned skill present', () => { + writePluginWithSkill(projectRoot, 'good-engine', { + id: 'good-engine', + namespace: 'good', + skillDir: 'good-orient', + withSkillFile: true, + }); + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + fs.mkdirSync(toolSkillsDir, { recursive: true }); + const contributed = collectContributedSkills(projectRoot); + const known = collectKnownPluginSkillDirs(projectRoot); + + // Active skill not installed yet -> drift. + expect(hasContributionDrift(toolSkillsDir, contributed, known, true)).toBe(true); + + // After install -> no drift. + installContributedSkills(toolSkillsDir, contributed); + expect(hasContributionDrift(toolSkillsDir, contributed, known, true)).toBe(false); + + // If the plugin becomes inactive but its owned dir remains -> drift (needs cleanup). + expect(hasContributionDrift(toolSkillsDir, [], known, true)).toBe(true); + + // Commands-only delivery with an owned dir present -> drift (needs removal). + expect(hasContributionDrift(toolSkillsDir, [], known, false)).toBe(true); + }); }); From 748e5825efe18a06b868588fec095076ed4414fa Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 14:46:09 -0500 Subject: [PATCH 14/20] fix(plugins): refresh contributed skills on plugin version upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends update drift detection: the ownership marker now records the plugin version, and hasContributionDrift treats a version mismatch (installed marker version != current plugin version) as drift. So `openspec update` refreshes a plugin's contributed skills when the plugin package is upgraded, even when core tool assets are unchanged — closing the gap where only a missing skill triggered reinstall. Adds a version-upgrade drift unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/plugins/contribution.ts | 37 +++++++++++++++++++++----- test/core/plugins/contribution.test.ts | 18 +++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/core/plugins/contribution.ts b/src/core/plugins/contribution.ts index 102314b88..d2418419a 100644 --- a/src/core/plugins/contribution.ts +++ b/src/core/plugins/contribution.ts @@ -42,13 +42,32 @@ function realpathOrNull(p: string): string | null { } } +interface SkillMarker { + plugin: string; + version?: string; + managedBy: string; +} + +/** Read the ownership marker for an installed skill dir, or null if not ours. */ +function readSkillMarker(dir: string): SkillMarker | null { + try { + const raw = fs.readFileSync(path.join(dir, OWNERSHIP_MARKER), 'utf-8'); + const parsed = JSON.parse(raw); + return parsed && parsed.managedBy === 'openspec' ? (parsed as SkillMarker) : null; + } catch { + return null; + } +} + /** True when `dir` exists and was installed by OpenSpec for a plugin. */ function isOwnedSkillDir(dir: string): boolean { - return fs.existsSync(path.join(dir, OWNERSHIP_MARKER)); + return readSkillMarker(dir) !== null; } export interface ContributedSkill { pluginId: string; + /** Plugin package version, recorded in the marker so upgrades trigger a refresh. */ + version?: string; /** Absolute plugin package root, used to re-check source containment at copy time. */ packageRoot: string; /** Directory name the skill is installed as. */ @@ -91,6 +110,7 @@ export function collectContributedSkills(projectRoot: string): ContributedSkill[ } skills.push({ pluginId: plugin.id, + version: plugin.version, packageRoot: plugin.packageRoot, dirName: skill.dir, sourceDir, @@ -136,13 +156,18 @@ export function hasContributionDrift( return knownContributedDirs.some((d) => isOwnedSkillDir(path.join(toolSkillsDir, d))); } const activeDirs = new Set(contributedSkills.map((s) => s.dirName)); - const missingActive = contributedSkills.some( - (s) => !fs.existsSync(path.join(toolSkillsDir, s.dirName, 'SKILL.md')) - ); + // An active skill drifts when its SKILL.md is missing, or when the installed + // marker version differs from the current plugin version (plugin was upgraded). + const staleActive = contributedSkills.some((s) => { + const dir = path.join(toolSkillsDir, s.dirName); + if (!fs.existsSync(path.join(dir, 'SKILL.md'))) return true; + const marker = readSkillMarker(dir); + return !marker || marker.version !== s.version; + }); const staleInactive = knownContributedDirs.some( (d) => !activeDirs.has(d) && isOwnedSkillDir(path.join(toolSkillsDir, d)) ); - return missingActive || staleInactive; + return staleActive || staleInactive; } /** @@ -183,7 +208,7 @@ export function installContributedSkills( fs.cpSync(realSource, dest, { recursive: true }); fs.writeFileSync( path.join(dest, OWNERSHIP_MARKER), - JSON.stringify({ plugin: skill.pluginId, managedBy: 'openspec' }) + '\n' + JSON.stringify({ plugin: skill.pluginId, version: skill.version, managedBy: 'openspec' }) + '\n' ); installed.push(skill.dirName); } catch (error) { diff --git a/test/core/plugins/contribution.test.ts b/test/core/plugins/contribution.test.ts index 014f8ad95..bceafa20b 100644 --- a/test/core/plugins/contribution.test.ts +++ b/test/core/plugins/contribution.test.ts @@ -224,4 +224,22 @@ describe('plugins/contribution', () => { // Commands-only delivery with an owned dir present -> drift (needs removal). expect(hasContributionDrift(toolSkillsDir, [], known, false)).toBe(true); }); + + it('detects drift when the plugin version changes (upgrade refresh)', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + fs.mkdirSync(toolSkillsDir, { recursive: true }); + const base = [{ pluginId: 'p', packageRoot: projectRoot, dirName: 'p-orient', sourceDir: '' }]; + + // Install at v1 by writing a real source then installing. + const src = path.join(projectRoot, 'node_modules', 'p', 'skills', 'p-orient'); + fs.mkdirSync(src, { recursive: true }); + fs.writeFileSync(path.join(src, 'SKILL.md'), '#'); + const v1 = [{ ...base[0], version: '1.0.0', sourceDir: src }]; + installContributedSkills(toolSkillsDir, v1); + expect(hasContributionDrift(toolSkillsDir, v1, ['p-orient'], true)).toBe(false); + + // Same dir, new plugin version -> drift (needs refresh). + const v2 = [{ ...base[0], version: '2.0.0', sourceDir: src }]; + expect(hasContributionDrift(toolSkillsDir, v2, ['p-orient'], true)).toBe(true); + }); }); From 8be11f5202d2b53e2568db963d82408b5bcf983c Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 14:57:53 -0500 Subject: [PATCH 15/20] fix(plugins): bind skill ownership to the contributing plugin Addresses CodeRabbit: an OpenSpec marker alone is not an ownership check, so two plugins sharing a skill `dirName` could overwrite or delete each other's skills, and drift could read another plugin's dir as "fresh" on a version match. - The ownership marker is now matched to the specific plugin id: - install overwrites only a directory this same plugin installed (a core skill, user dir, or another plugin's skill of the same name is skipped with a warning); - removeContributedSkill requires an expected plugin id and only deletes a dir whose marker names that plugin; - hasContributionDrift compares marker.plugin AND version for active skills, and only treats inactive-owned dirs as drift when the marker matches the ref's plugin. - collectKnownPluginSkillDirs -> collectKnownPluginSkillRefs returns {dirName, pluginId} pairs; update.ts cleanup passes the owning plugin id through to removal. - Tests: cross-plugin collision (plugin B cannot clobber or remove plugin A's "shared" skill; A can), plus updated drift/remove signatures. Spec scenario broadened to plugin-bound ownership. Build + lint clean; validate --strict passes; plugin/init/update/e2e green (180). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/plugin-contribution/spec.md | 7 ++- src/core/plugins/contribution.ts | Bin 9377 -> 10312 bytes src/core/update.ts | 21 +++---- test/core/plugins/contribution.test.ts | 54 +++++++++++++++--- 4 files changed, 60 insertions(+), 22 deletions(-) diff --git a/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md index ab0c4d6df..12df23654 100644 --- a/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md @@ -32,10 +32,11 @@ Disabling or removing a plugin SHALL remove only that plugin's managed artifacts - **THEN** OpenSpec SHALL remove only the artifacts it installed for that plugin - **AND** SHALL NOT remove core artifacts or user-authored files -#### Scenario: Name collision with an unowned directory -- **WHEN** a plugin's contributed skill directory name matches an existing core or user directory that OpenSpec did not install +#### Scenario: Name collision with a directory owned by something else +- **WHEN** a plugin's contributed skill directory name matches an existing directory that OpenSpec did not install for that same plugin (a core skill, a user directory, or another plugin's skill) - **THEN** OpenSpec SHALL NOT overwrite or delete that directory -- **AND** SHALL skip the contribution rather than clobber unowned files +- **AND** SHALL skip the contribution rather than clobber it +- **AND** removal SHALL only affect a directory whose ownership marker names the requesting plugin ### Requirement: Contributed paths are constrained to prevent traversal Plugin-contributed skill paths SHALL be confined to safe locations: an install directory name SHALL be a single path segment, and a source path SHALL stay inside the plugin package. OpenSpec SHALL reject traversal at manifest validation and SHALL re-check containment before every copy or delete. diff --git a/src/core/plugins/contribution.ts b/src/core/plugins/contribution.ts index d2418419afc60a568f0fa0a9c276be5ab535d8cd..2896357dca58b8680ba666731b7e7f7d80208d3b 100644 GIT binary patch delta 1897 zcma)7&u<$=6eg6m#Qc!b*0CcY>*WWj-MVW5>LtNcq2gdwgedI=RS}u&&U%NeXRMiV zT$5UnxFSw-;EcF%LJ0p22*jaq>4_^hj$D!8z1dxpIDop6E$y3m?|tuk-+TLI=f|CI zTl15r^A}z`fh{nHB26I@nuk{D5kx#pndT83jDUgWMx{si)=vjrA)7E1HUVJ`sG$_H zo7+J|0o-Vn_@lnk7KF51$1LPzL;VTV27IAlUh3z2{ zQ}1i_F;={*lr6G0HqOF*xaR?fN*aqq#OC~Y8y@aa0ofgcDP8I6@9wu(Y9|dcS|JCV zF3^)*I^-j0!~WWFR&*eu@pEg(z6$|zGu|(*ix}z@F-VxHqgUg6M1Rbp$R8Jegf!@P z1}3Q=pJJPOKna?s*q%awaU*jdBUbupZUZ@Dh=Hz*8l= z#$776(+`G{YmD>ou4b`iIy=DilV6h;!^$HEW(!Oe70sR`Ie9w zY15`9YLMbq;E1@Va3ut;xm3INA8Y`Se9zKuypm7Xi;~WXiCJpg57l*IaO+Gv9=vd(WI6&ws)t%wp2;g;ayAjDJ5pLs(3F7yS7N9%iVh#7*binCol%II$nqRB z>9v@9U&mN}i>f%&I|Y`E{xMC;V+#z2g$F)pwg&hkVQ@Qi-}j2Zjew%^HgkU@!+OMx zZNMF*hedvUX7_UY2FO90Zo2InG|mQ^O3QcL&-}FOU*n*BQ`>k0i^yrfDrk?R)^XYA zOso{ksPG%FO#ZAre*W&1qI@uBraFLKzcc&-uzG;C$M~UuAv6Cd=Bwq`S81&F@k4DdVG)|&krn;n8Y delta 1088 zcmZ{jO>0v@6o#or+NPG;k0foQa$;>vQoKRCYm6VL&~93_-IYQonUiMd&D=0Ew=q}| z1y|xiX6dTyf(XHtE5U^;Q4s%uxNzkU5Il3!SkM~C{hB#*p7)&he7Li>^tw3ss8}e# znhFtGb(FBlh1vwXFSOCXB!?E$Afz_T_mM-T4J%EQt4*vzEl|Lq>4#fFdQd3FYoV+f z5l9evr6rNCZVTVPB~*^iTmWs9kX!i$s04wJOu~aWfWnvwm9%U+5r<5<3^MfnQfF?k zf53hkO`d3jWDWa$v>5Z^P_Q?~E}w-u+d-%x!E&Kl#bFUigK>0QUW~Ehl+*qknw;#W zT;GUNRRdDtjzeu%#(&s%*>Sr(aq_mq=>DQLk8z&Uj@%>ceCc5IVRUy__gCnsVcqk* zC}_h%;)&f)zz~7?f!xv#O1rjmQLE7- z)Kt(Qr3lqfDS|xkNN-=~*;KsGzL`E#>E(Cs!X??ofHG`Ll76Hr$_sEHE%OIkdv&JW z*KGC(!2=|i;_{`=uk>1MQ6lj|K$n&h2Fb!C7^@~wZD=9AaFBuNNrhZBPzaGBoXtW_ z!4#19?4=AOGyhl}waMGJGX?uF(qP;e ziW&$Ljh(^ s.dirName)); - const knownContributedDirs = collectKnownPluginSkillDirs(resolvedProjectPath); + const activePluginIds = new Set(contributedSkills.map((s) => s.pluginId)); + const knownSkillRefs = collectKnownPluginSkillRefs(resolvedProjectPath); // 7. Smart update detection const toolsNeedingVersionUpdate = toolStatuses @@ -164,7 +164,7 @@ export class UpdateCommand { return hasContributionDrift( skillsDir, contributedSkills, - knownContributedDirs, + knownSkillRefs, shouldGenerateSkills ); }); @@ -230,11 +230,12 @@ export class UpdateCommand { removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); - // Install active plugin-contributed skills; remove those for disabled plugins. + // Install active plugin-contributed skills; remove those for plugins that + // are no longer active, matching the dir's ownership marker to the plugin. installContributedSkills(skillsDir, contributedSkills); - for (const dirName of knownContributedDirs) { - if (!activeContributedDirs.has(dirName)) { - removeContributedSkill(skillsDir, dirName); + for (const ref of knownSkillRefs) { + if (!activePluginIds.has(ref.pluginId)) { + removeContributedSkill(skillsDir, ref.dirName, ref.pluginId); } } } @@ -243,8 +244,8 @@ export class UpdateCommand { if (!shouldGenerateSkills) { removedSkillCount += await this.removeSkillDirs(skillsDir); // Commands-only delivery: remove all known plugin-contributed skills too. - for (const dirName of knownContributedDirs) { - removeContributedSkill(skillsDir, dirName); + for (const ref of knownSkillRefs) { + removeContributedSkill(skillsDir, ref.dirName, ref.pluginId); } } diff --git a/test/core/plugins/contribution.test.ts b/test/core/plugins/contribution.test.ts index bceafa20b..43902f964 100644 --- a/test/core/plugins/contribution.test.ts +++ b/test/core/plugins/contribution.test.ts @@ -4,7 +4,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { collectContributedSkills, - collectKnownPluginSkillDirs, + collectKnownPluginSkillRefs, hasContributionDrift, installContributedSkills, removeContributedSkill, @@ -113,10 +113,10 @@ describe('plugins/contribution', () => { fs.mkdirSync(path.join(toolSkillsDir, 'openspec-explore'), { recursive: true }); fs.writeFileSync(path.join(toolSkillsDir, 'openspec-explore', 'SKILL.md'), '#'); - expect(removeContributedSkill(toolSkillsDir, 'good-orient')).toBe(true); + expect(removeContributedSkill(toolSkillsDir, 'good-orient', 'good-engine')).toBe(true); expect(fs.existsSync(path.join(toolSkillsDir, 'good-orient'))).toBe(false); // Unowned dir of the same shape must not be removed even if asked by name. - expect(removeContributedSkill(toolSkillsDir, 'openspec-explore')).toBe(false); + expect(removeContributedSkill(toolSkillsDir, 'openspec-explore', 'good-engine')).toBe(false); expect(fs.existsSync(path.join(toolSkillsDir, 'openspec-explore'))).toBe(true); }); @@ -184,8 +184,8 @@ describe('plugins/contribution', () => { const sentinel = path.join(projectRoot, '.claude', 'KEEP.md'); fs.writeFileSync(sentinel, 'do not delete'); - expect(removeContributedSkill(toolSkillsDir, '../KEEP.md')).toBe(false); - expect(removeContributedSkill(toolSkillsDir, '..\\KEEP.md')).toBe(false); + expect(removeContributedSkill(toolSkillsDir, '../KEEP.md', 'evil')).toBe(false); + expect(removeContributedSkill(toolSkillsDir, '..\\KEEP.md', 'evil')).toBe(false); expect(fs.existsSync(sentinel)).toBe(true); }); @@ -196,7 +196,10 @@ describe('plugins/contribution', () => { skillDir: 'good-orient', withSkillFile: true, }); - expect(collectKnownPluginSkillDirs(projectRoot)).toContain('good-orient'); + expect(collectKnownPluginSkillRefs(projectRoot)).toContainEqual({ + dirName: 'good-orient', + pluginId: 'good-engine', + }); }); it('detects drift: active skill not yet installed, and inactive owned skill present', () => { @@ -209,7 +212,7 @@ describe('plugins/contribution', () => { const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); fs.mkdirSync(toolSkillsDir, { recursive: true }); const contributed = collectContributedSkills(projectRoot); - const known = collectKnownPluginSkillDirs(projectRoot); + const known = collectKnownPluginSkillRefs(projectRoot); // Active skill not installed yet -> drift. expect(hasContributionDrift(toolSkillsDir, contributed, known, true)).toBe(true); @@ -225,6 +228,38 @@ describe('plugins/contribution', () => { expect(hasContributionDrift(toolSkillsDir, [], known, false)).toBe(true); }); + it('binds ownership to the plugin: a second plugin cannot clobber the first', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + fs.mkdirSync(toolSkillsDir, { recursive: true }); + const srcA = path.join(tempDir, 'pkgA', 'skills', 'shared'); + const srcB = path.join(tempDir, 'pkgB', 'skills', 'shared'); + fs.mkdirSync(srcA, { recursive: true }); + fs.mkdirSync(srcB, { recursive: true }); + fs.writeFileSync(path.join(srcA, 'SKILL.md'), 'FROM-A'); + fs.writeFileSync(path.join(srcB, 'SKILL.md'), 'FROM-B'); + + // Plugin A installs "shared". + expect( + installContributedSkills(toolSkillsDir, [ + { pluginId: 'A', version: '1.0.0', packageRoot: path.join(tempDir, 'pkgA'), dirName: 'shared', sourceDir: srcA }, + ]) + ).toEqual(['shared']); + + // Plugin B with the same dirName is refused; A's content is intact. + expect( + installContributedSkills(toolSkillsDir, [ + { pluginId: 'B', version: '1.0.0', packageRoot: path.join(tempDir, 'pkgB'), dirName: 'shared', sourceDir: srcB }, + ]) + ).toEqual([]); + expect(fs.readFileSync(path.join(toolSkillsDir, 'shared', 'SKILL.md'), 'utf-8')).toBe('FROM-A'); + + // B cannot remove A's directory; A can. + expect(removeContributedSkill(toolSkillsDir, 'shared', 'B')).toBe(false); + expect(fs.existsSync(path.join(toolSkillsDir, 'shared'))).toBe(true); + expect(removeContributedSkill(toolSkillsDir, 'shared', 'A')).toBe(true); + expect(fs.existsSync(path.join(toolSkillsDir, 'shared'))).toBe(false); + }); + it('detects drift when the plugin version changes (upgrade refresh)', () => { const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); fs.mkdirSync(toolSkillsDir, { recursive: true }); @@ -234,12 +269,13 @@ describe('plugins/contribution', () => { const src = path.join(projectRoot, 'node_modules', 'p', 'skills', 'p-orient'); fs.mkdirSync(src, { recursive: true }); fs.writeFileSync(path.join(src, 'SKILL.md'), '#'); + const refs = [{ dirName: 'p-orient', pluginId: 'p' }]; const v1 = [{ ...base[0], version: '1.0.0', sourceDir: src }]; installContributedSkills(toolSkillsDir, v1); - expect(hasContributionDrift(toolSkillsDir, v1, ['p-orient'], true)).toBe(false); + expect(hasContributionDrift(toolSkillsDir, v1, refs, true)).toBe(false); // Same dir, new plugin version -> drift (needs refresh). const v2 = [{ ...base[0], version: '2.0.0', sourceDir: src }]; - expect(hasContributionDrift(toolSkillsDir, v2, ['p-orient'], true)).toBe(true); + expect(hasContributionDrift(toolSkillsDir, v2, refs, true)).toBe(true); }); }); From 0e2431d289da941363d6d317e990c3c69268b251 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 15:38:43 -0500 Subject: [PATCH 16/20] fix(plugins): install contributed skills for legacy tools in the same run Per CodeRabbit's follow-up observation on 8be11f5: upgradeLegacyTools installed core skills for newly-configured legacy tools but not plugin-contributed skills, so plugin skills only landed on the next `update`. Now it collects active contributed skills once and installs them for each legacy tool alongside core skills, so single-run legacy migration includes plugin skills. Also a tiny cleanup in installContributedSkills: compute destExists once instead of calling fs.existsSync twice. Build + lint clean; plugin/init/update/e2e green (180 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/plugins/contribution.ts | Bin 10312 -> 10275 bytes src/core/update.ts | 6 ++++++ 2 files changed, 6 insertions(+) diff --git a/src/core/plugins/contribution.ts b/src/core/plugins/contribution.ts index 2896357dca58b8680ba666731b7e7f7d80208d3b..e7f83469d0db65d36b40cf1ca905ce8f19d353ef 100644 GIT binary patch delta 90 zcmX>RusC4DTLs^g)Z!A?ip=7YVg*};v|_zfFgv(1FIfX5rD@Hj00fz73Scf&rJ9;T aQEFmJaCT-+j&EX7c4`qs@#c8Nmuvt4)*!$D delta 110 zcmZ1+a3WyCTLrb$ip=7Y%)E5p#G>rfA_ZH8v|>H5SaEP=Ub03?YH^9Cg8k$?bulvq fD}}t$oE&Q|1t7>wQ_#SsR836*)yU0OiZ9s!wiG7) diff --git a/src/core/update.ts b/src/core/update.ts index f9d46ce2b..03089159b 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -713,6 +713,9 @@ export class UpdateCommand { const shouldGenerateCommands = delivery !== 'skills'; const skillTemplates = shouldGenerateSkills ? getSkillTemplates(desiredWorkflows) : []; const commandContents = shouldGenerateCommands ? getCommandContents(desiredWorkflows) : []; + // Plugin-contributed skills land for newly-configured legacy tools in the same + // run, not just on the next update. + const legacyContributedSkills = shouldGenerateSkills ? collectContributedSkills(projectPath) : []; for (const toolId of selectedTools) { const tool = AI_TOOLS.find((t) => t.value === toolId); @@ -734,6 +737,9 @@ export class UpdateCommand { const skillContent = generateSkillContent(template, OPENSPEC_VERSION, transformer); await FileSystemUtils.writeFile(skillFile, skillContent); } + + // Install active plugin-contributed skills for this legacy tool too. + installContributedSkills(skillsDir, legacyContributedSkills); } // Create commands when delivery includes commands From 2810414d13ad23191aadd56dde49a746ca7ff47a Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 18:59:49 -0500 Subject: [PATCH 17/20] test(plugins): make update-lifecycle e2e deterministic + diagnosable; label plugin-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alfred-openspec hit a failure of the "update installs then cleans" e2e on a clean full-suite run that I could not reproduce on macOS (passes 16/16 isolated + 5x full suite). Root-cause investigation showed the install path itself is correct: with claude truly current, `update` is a no-op (A); adding an active plugin makes `update` install the skill (B); a follow-up `update` is a no-op again (C) — so toolsNeedingPluginSync triggers install only on real drift. Hardening the test rather than relying on auto-detect timing: - enable the plugin explicitly (project tier) so activation is deterministic and independent of auto-detect resolution; - assert detection via `plugin list --json` separately from installation, so a future failure pinpoints whether resolution or install broke; - include update stdout/stderr in every assertion message for diagnosability. Also fixed a misleading display: a tool updated only for plugin contributions was labeled "(config sync)"; displayUpdatePlan now distinguishes "config sync" vs "plugin skills" (e.g. "claude (plugin skills)"), which also confirms the install is driven by the plugin-sync path. Full suite green except the pre-existing env-only zsh-installer failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/update.ts | 17 ++++++++++++++--- test/cli-e2e/plugin.test.ts | 38 ++++++++++++++++++++++++++----------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/core/update.ts b/src/core/update.ts index 03089159b..42f172def 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -190,7 +190,13 @@ export class UpdateCommand { if (this.force) { console.log(`Force updating ${configuredTools.length} tool(s): ${configuredTools.join(', ')}`); } else { - this.displayUpdatePlan([...toolsToUpdateSet], statusByTool, toolsUpToDate); + this.displayUpdatePlan( + [...toolsToUpdateSet], + statusByTool, + toolsUpToDate, + new Set(toolsNeedingConfigSync), + new Set(toolsNeedingPluginSync) + ); } console.log(); @@ -352,7 +358,9 @@ export class UpdateCommand { private displayUpdatePlan( toolsToUpdate: string[], statusByTool: Map, - upToDate: ToolVersionStatus[] + upToDate: ToolVersionStatus[], + configSyncTools: Set = new Set(), + pluginSyncTools: Set = new Set() ): void { const updates = toolsToUpdate.map((toolId) => { const status = statusByTool.get(toolId); @@ -360,7 +368,10 @@ export class UpdateCommand { const fromVersion = status.generatedByVersion ?? 'unknown'; return `${status.toolId} (${fromVersion} → ${OPENSPEC_VERSION})`; } - return `${toolId} (config sync)`; + const reasons: string[] = []; + if (configSyncTools.has(toolId)) reasons.push('config sync'); + if (pluginSyncTools.has(toolId)) reasons.push('plugin skills'); + return `${toolId} (${reasons.join(' + ') || 'sync'})`; }); console.log(`Updating ${toolsToUpdate.length} tool(s): ${updates.join(', ')}`); diff --git a/test/cli-e2e/plugin.test.ts b/test/cli-e2e/plugin.test.ts index 23a244da0..f1102a362 100644 --- a/test/cli-e2e/plugin.test.ts +++ b/test/cli-e2e/plugin.test.ts @@ -173,16 +173,17 @@ describe('openspec plugin e2e', () => { const base = await fs.mkdtemp(path.join(tmpdir(), 'openspec-plugin-update-')); tempRoots.push(base); const proj = path.join(base, 'project'); + const configPath = path.join(proj, 'openspec', 'config.yaml'); await fs.mkdir(path.join(proj, 'openspec'), { recursive: true }); - await fs.writeFile(path.join(proj, 'openspec', 'config.yaml'), 'schema: spec-driven\n'); + await fs.writeFile(configPath, 'schema: spec-driven\n'); const upEnv = isolatedEnv(base); const init = await runCLI(['init', '--tools', 'claude'], { cwd: proj, env: upEnv }); - expect(init.exitCode).toBe(0); + expect(init.exitCode, `init failed:\n${init.stdout}\n${init.stderr}`).toBe(0); const skillPath = path.join(proj, '.claude', 'skills', 'demo-orient', 'SKILL.md'); expect(await fileExists(skillPath)).toBe(false); // no plugin yet - // Now install the plugin (with a contributed skill) and update — core is current. + // Add the plugin (with a contributed skill) AFTER core tools are current. const pluginDir = path.join(proj, 'node_modules', 'demo-engine'); await fs.mkdir(path.join(pluginDir, 'skills', 'demo-orient'), { recursive: true }); await fs.writeFile( @@ -207,17 +208,32 @@ describe('openspec plugin e2e', () => { ); await fs.writeFile(path.join(pluginDir, 'cli.js'), '#!/usr/bin/env node\n'); + // Enable explicitly (project tier) so activation is deterministic and not + // dependent on auto-detect resolution timing. + await fs.writeFile(configPath, 'schema: spec-driven\nplugins:\n enabled:\n - demo-engine\n'); + + // Detection sanity check, kept separate from the install assertion so a future + // failure pinpoints whether resolution or installation broke. + const list = await runCLI(['plugin', 'list', '--json'], { cwd: proj, env: upEnv }); + const listed = JSON.parse(list.stdout).plugins.find((p: { id: string }) => p.id === 'demo-engine'); + expect(listed, `plugin not detected. plugin list:\n${list.stdout}\n${list.stderr}`).toBeDefined(); + expect(listed.status).toBe('enabled'); + + // update with core tools already current must still install the plugin skill. const up1 = await runCLI(['update'], { cwd: proj, env: upEnv }); - expect(up1.exitCode).toBe(0); - expect(await fileExists(skillPath)).toBe(true); // installed despite core being current + expect(up1.exitCode, `update failed:\n${up1.stdout}\n${up1.stderr}`).toBe(0); + expect( + await fileExists(skillPath), + `update did not install demo-orient.\nstdout:\n${up1.stdout}\nstderr:\n${up1.stderr}` + ).toBe(true); // Disable the plugin (autoDetect off, not enabled) and update — skill is cleaned up. - await fs.writeFile( - path.join(proj, 'openspec', 'config.yaml'), - 'schema: spec-driven\nplugins:\n autoDetect: false\n' - ); + await fs.writeFile(configPath, 'schema: spec-driven\nplugins:\n autoDetect: false\n'); const up2 = await runCLI(['update'], { cwd: proj, env: upEnv }); - expect(up2.exitCode).toBe(0); - expect(await fileExists(skillPath)).toBe(false); // removed when no longer active + expect(up2.exitCode, `second update failed:\n${up2.stdout}\n${up2.stderr}`).toBe(0); + expect( + await fileExists(skillPath), + `update did not remove demo-orient.\nstdout:\n${up2.stdout}\nstderr:\n${up2.stderr}` + ).toBe(false); }); }); From 32184bbc17256e67fbf83a7432613154c9a31527 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Mon, 22 Jun 2026 21:06:38 -0500 Subject: [PATCH 18/20] fix(plugins): validate manifest.bin stays inside the plugin package (sec) A plugin's `bin` was accepted as any non-empty string and then launched via `path.join(packageRoot, bin)`. A crafted `bin` (e.g. "../runner.js", an absolute path, or a Windows drive/UNC path) could escape the package root and delegate execution outside the plugin's own package. - Reject an unsafe `bin` at manifest validation with the same relative-inside- package rules used for skill sources (new `isSafeBin`), so the plugin is disabled at load rather than crashing OpenSpec. - Re-check containment in `resolveLauncher` immediately before spawning (defense-in-depth), and surface a clean error + exit code 1 from `delegateToPlugin` instead of throwing. - Cover `../`, absolute, and Windows drive/backslash cases in manifest and runtime tests. `binArgs` is left as-is: it is an explicit external command (e.g. ["npx", ...]) with a different trust model, not a path joined onto the package root. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/plugins/manifest.ts | 31 ++++++++++- src/core/plugins/runtime.ts | 27 +++++++++- test/core/plugins/manifest.test.ts | 29 +++++++++++ test/core/plugins/runtime.test.ts | 83 ++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 test/core/plugins/runtime.test.ts diff --git a/src/core/plugins/manifest.ts b/src/core/plugins/manifest.ts index 775048368..8652268e4 100644 --- a/src/core/plugins/manifest.ts +++ b/src/core/plugins/manifest.ts @@ -59,6 +59,18 @@ export function isSafeSkillSource(source: string): boolean { return !segments.some((segment) => segment === '..'); } +/** + * True when `bin` is a relative executable path that stays inside the plugin + * package — the same containment rules as a skill source (no absolute path, no + * Windows drive letter, no leading separator, no `..` segment). A plugin must + * not be able to point `bin` at a launcher outside its own package, since the + * runtime joins it onto the package root and spawns it. The runtime re-checks + * containment immediately before spawning as defense-in-depth. + */ +export function isSafeBin(bin: string): boolean { + return isSafeSkillSource(bin); +} + const CommandDescriptorSchema = z.object({ name: z.string().min(1), summary: z.string().optional(), @@ -109,8 +121,23 @@ export const PluginManifestSchema = z ownsConfigKeys: z.array(z.string()).optional(), }) .passthrough() - .refine((m) => m.bin !== undefined || m.binArgs !== undefined, { - message: 'manifest must declare an executable via "bin" or "binArgs"', + .superRefine((m, ctx) => { + if (m.bin === undefined && m.binArgs === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'manifest must declare an executable via "bin" or "binArgs"', + }); + } + // `bin` is joined onto the package root and spawned, so it must stay inside + // the package. `binArgs` is an explicit external command (e.g. ["npx", ...]) + // and is intentionally not containment-checked. + if (m.bin !== undefined && !isSafeBin(m.bin)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['bin'], + message: `bin must be a relative path inside the package (no absolute paths, drive letters, or "..")`, + }); + } }); export interface ManifestValidationResult { diff --git a/src/core/plugins/runtime.ts b/src/core/plugins/runtime.ts index ea9af2b6c..6e0cbe70c 100644 --- a/src/core/plugins/runtime.ts +++ b/src/core/plugins/runtime.ts @@ -19,6 +19,13 @@ import { createRequire } from 'node:module'; import type { Command } from 'commander'; import type { ResolvedPlugin } from './types.js'; import { resolvePlugins, activePlugins } from './resolver.js'; +import { isSafeBin } from './manifest.js'; + +/** True when `child` resolves to a location strictly inside `parent`. */ +function isPathInside(parent: string, child: string): boolean { + const rel = path.relative(path.resolve(parent), path.resolve(child)); + return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); +} // cross-spawn ships no types; cast to node's spawnSync signature (repo pattern). const require = createRequire(import.meta.url); @@ -28,8 +35,17 @@ const crossSpawn = require('cross-spawn') as { sync: typeof nodeSpawnSync }; function resolveLauncher(plugin: ResolvedPlugin): { command: string; baseArgs: string[] } { const { manifest, packageRoot } = plugin; if (manifest.bin) { + // Defense-in-depth: manifest validation already rejects an unsafe `bin`, but + // re-check containment here before spawning so a crafted path (absolute, `..`, + // or a Windows drive/backslash) can never launch a binary outside the package. + const binPath = path.resolve(packageRoot, manifest.bin); + if (!isSafeBin(manifest.bin) || !isPathInside(packageRoot, binPath)) { + throw new Error( + `Plugin "${plugin.id}" declares an executable ("${manifest.bin}") outside its package` + ); + } // Run the plugin's JS entrypoint with the current Node — avoids shell/.cmd shims. - return { command: process.execPath, baseArgs: [path.join(packageRoot, manifest.bin)] }; + return { command: process.execPath, baseArgs: [binPath] }; } if (manifest.binArgs && manifest.binArgs.length > 0) { return { command: manifest.binArgs[0], baseArgs: manifest.binArgs.slice(1) }; @@ -43,7 +59,14 @@ function resolveLauncher(plugin: ResolvedPlugin): { command: string; baseArgs: s * Inherits stdio so the plugin owns the terminal session. */ export function delegateToPlugin(plugin: ResolvedPlugin, args: string[]): number { - const { command, baseArgs } = resolveLauncher(plugin); + let command: string; + let baseArgs: string[]; + try { + ({ command, baseArgs } = resolveLauncher(plugin)); + } catch (error) { + console.error(`Error launching plugin "${plugin.id}": ${(error as Error).message}`); + return 1; + } const result = crossSpawn.sync(command, [...baseArgs, ...args], { stdio: 'inherit', cwd: process.cwd(), diff --git a/test/core/plugins/manifest.test.ts b/test/core/plugins/manifest.test.ts index 2e0329a27..e37e5987d 100644 --- a/test/core/plugins/manifest.test.ts +++ b/test/core/plugins/manifest.test.ts @@ -8,6 +8,7 @@ import { packageDeclaresPlugin, isSafeSkillDirName, isSafeSkillSource, + isSafeBin, RESERVED_NAMESPACES, } from '../../../src/core/plugins/manifest.js'; @@ -46,6 +47,23 @@ describe('plugins/manifest validateManifest', () => { expect(result.errors.join(' ')).toMatch(/reserved/); }); + it('accepts a safe relative bin', () => { + expect(validateManifest({ ...validManifest, bin: 'cli.js' }).valid).toBe(true); + expect(validateManifest({ ...validManifest, bin: 'dist/cli.js' }).valid).toBe(true); + }); + + it.each([ + ['parent traversal', '../somewhere/runner.js'], + ['posix absolute', '/usr/local/bin/runner.js'], + ['windows drive', 'C:\\runner.js'], + ['windows unc/backslash root', '\\\\evil\\runner.js'], + ['embedded traversal', 'dist/../../runner.js'], + ])('rejects an unsafe bin: %s', (_label, bin) => { + const result = validateManifest({ ...validManifest, bin }); + expect(result.valid).toBe(false); + expect(result.errors.join(' ')).toMatch(/bin/i); + }); + it('rejects an invalid namespace format', () => { const result = validateManifest({ ...validManifest, namespace: 'Demo Engine' }); expect(result.valid).toBe(false); @@ -115,6 +133,17 @@ describe('plugins/manifest safe-path helpers', () => { expect(isSafeSkillSource(bad)).toBe(false); } }); + + it('isSafeBin accepts in-package relative paths', () => { + expect(isSafeBin('cli.js')).toBe(true); + expect(isSafeBin('dist/cli.js')).toBe(true); + }); + + it('isSafeBin rejects absolute, drive, and traversal paths', () => { + for (const bad of ['../runner.js', '/usr/bin/runner', 'C:\\runner.js', '\\\\unc\\x', 'a/../../b', '']) { + expect(isSafeBin(bad)).toBe(false); + } + }); }); describe('plugins/manifest loadManifestFromRoot', () => { diff --git a/test/core/plugins/runtime.test.ts b/test/core/plugins/runtime.test.ts new file mode 100644 index 000000000..bfd968372 --- /dev/null +++ b/test/core/plugins/runtime.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { delegateToPlugin } from '../../../src/core/plugins/runtime.js'; +import type { ResolvedPlugin } from '../../../src/core/plugins/types.js'; + +/** + * Build a ResolvedPlugin with a given `bin`, bypassing manifest validation so we + * can exercise the runtime's defense-in-depth containment check directly — as if + * a crafted manifest had slipped past validation. + */ +function pluginWithBin(packageRoot: string, bin: string): ResolvedPlugin { + return { + id: 'demo-engine', + namespace: 'demo', + manifest: { + manifestVersion: 1, + id: 'demo-engine', + namespace: 'demo', + bin, + openspecCompat: '>=1.0.0', + }, + packageRoot, + source: 'project', + compatible: true, + enabled: true, + }; +} + +describe('plugins/runtime delegateToPlugin bin containment', () => { + let tempDir: string; + let packageRoot: string; + let sentinel: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-runtime-')); + packageRoot = path.join(tempDir, 'pkg'); + fs.mkdirSync(packageRoot, { recursive: true }); + // A would-be target that lives OUTSIDE the package root. If the runtime ever + // launched it, it would create this sentinel file. + sentinel = path.join(tempDir, 'pwned.txt'); + fs.writeFileSync( + path.join(tempDir, 'outside.js'), + `require('node:fs').writeFileSync(${JSON.stringify(sentinel)}, 'x');\n` + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('launches a safe in-package bin', () => { + fs.writeFileSync( + path.join(packageRoot, 'cli.js'), + `require('node:fs').writeFileSync(${JSON.stringify(path.join(packageRoot, 'ran.txt'))}, 'x');\n` + ); + const code = delegateToPlugin(pluginWithBin(packageRoot, 'cli.js'), []); + expect(code).toBe(0); + expect(fs.existsSync(path.join(packageRoot, 'ran.txt'))).toBe(true); + }); + + it.each([ + ['parent traversal', '../outside.js'], + ['backslash traversal', '..\\outside.js'], + ])('refuses a bin that escapes the package (%s) without launching it', (_label, bin) => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const code = delegateToPlugin(pluginWithBin(packageRoot, bin), []); + expect(code).toBe(1); + expect(fs.existsSync(sentinel)).toBe(false); + expect(err.mock.calls.flat().join(' ')).toMatch(/outside its package/); + }); + + it('refuses an absolute bin without launching it', () => { + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const absolute = path.join(tempDir, 'outside.js'); + const code = delegateToPlugin(pluginWithBin(packageRoot, absolute), []); + expect(code).toBe(1); + expect(fs.existsSync(sentinel)).toBe(false); + expect(err.mock.calls.flat().join(' ')).toMatch(/outside its package/); + }); +}); From 31adc5dc236564078a9104479f1ea9125d80272b Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 23 Jun 2026 00:42:39 -0500 Subject: [PATCH 19/20] fix(plugins): resolve symlinks before launching a plugin bin (sec) The lexical `bin` containment check (manifest validation + path.resolve/ isPathInside in resolveLauncher) blocks `..`, absolute, and Windows drive/ backslash escapes, but not a package-local symlink whose target points outside the package: `cli.js` can be a symlink to ../../runner.js and still pass every lexical test. Realpath both the package root and the resolved bin immediately before spawning and require the real bin to stay inside the real root. Launch the symlink- resolved path so the spawned file is exactly the one verified contained (also closes the symlink-swap TOCTOU window). realpath additionally confirms the entrypoint exists. Adds regression tests for a package-local symlink escaping the package (refused, not launched) and a symlink that stays inside (allowed). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/plugins/runtime.ts | 27 ++++++++++++++++++++++-- test/core/plugins/runtime.test.ts | 34 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/core/plugins/runtime.ts b/src/core/plugins/runtime.ts index 6e0cbe70c..0846dda1a 100644 --- a/src/core/plugins/runtime.ts +++ b/src/core/plugins/runtime.ts @@ -14,6 +14,7 @@ */ import * as path from 'node:path'; +import * as fs from 'node:fs'; import { spawnSync as nodeSpawnSync } from 'node:child_process'; import { createRequire } from 'node:module'; import type { Command } from 'commander'; @@ -27,6 +28,15 @@ function isPathInside(parent: string, child: string): boolean { return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); } +/** Real (symlink-resolved) path, or null if it does not exist. */ +function realpathOrNull(p: string): string | null { + try { + return fs.realpathSync.native(p); + } catch { + return null; + } +} + // cross-spawn ships no types; cast to node's spawnSync signature (repo pattern). const require = createRequire(import.meta.url); const crossSpawn = require('cross-spawn') as { sync: typeof nodeSpawnSync }; @@ -44,8 +54,21 @@ function resolveLauncher(plugin: ResolvedPlugin): { command: string; baseArgs: s `Plugin "${plugin.id}" declares an executable ("${manifest.bin}") outside its package` ); } - // Run the plugin's JS entrypoint with the current Node — avoids shell/.cmd shims. - return { command: process.execPath, baseArgs: [binPath] }; + // Lexical checks stop `..`/absolute escapes but not a package-local symlink + // that points outside the package. Resolve symlinks on both the package root + // and the bin, and require the real bin to stay inside the real root before + // spawning. realpath also confirms the entrypoint actually exists. + const realRoot = realpathOrNull(packageRoot); + const realBin = realpathOrNull(binPath); + if (!realRoot || !realBin || !isPathInside(realRoot, realBin)) { + throw new Error( + `Plugin "${plugin.id}" executable ("${manifest.bin}") resolves outside its package` + ); + } + // Run the plugin's JS entrypoint with the current Node — avoids shell/.cmd + // shims. Launch the symlink-resolved path so the spawned file is exactly the + // one we verified is contained (closes the symlink-swap TOCTOU window). + return { command: process.execPath, baseArgs: [realBin] }; } if (manifest.binArgs && manifest.binArgs.length > 0) { return { command: manifest.binArgs[0], baseArgs: manifest.binArgs.slice(1) }; diff --git a/test/core/plugins/runtime.test.ts b/test/core/plugins/runtime.test.ts index bfd968372..e1cfda149 100644 --- a/test/core/plugins/runtime.test.ts +++ b/test/core/plugins/runtime.test.ts @@ -80,4 +80,38 @@ describe('plugins/runtime delegateToPlugin bin containment', () => { expect(fs.existsSync(sentinel)).toBe(false); expect(err.mock.calls.flat().join(' ')).toMatch(/outside its package/); }); + + it('refuses a package-local symlink whose target escapes the package', () => { + // `bin` is lexically safe ("cli.js") and lexically inside the package, but the + // file is a symlink pointing at a script outside the package root. + const link = path.join(packageRoot, 'cli.js'); + try { + fs.symlinkSync(path.join(tempDir, 'outside.js'), link); + } catch { + // Platforms without symlink permission (rare in CI) can't exercise this. + return; + } + const err = vi.spyOn(console, 'error').mockImplementation(() => {}); + const code = delegateToPlugin(pluginWithBin(packageRoot, 'cli.js'), []); + expect(code).toBe(1); + expect(fs.existsSync(sentinel)).toBe(false); + expect(err.mock.calls.flat().join(' ')).toMatch(/resolves outside its package/); + }); + + it('launches a package-local symlink whose target stays inside the package', () => { + // A symlink is fine as long as it resolves to a file within the package. + fs.writeFileSync( + path.join(packageRoot, 'real-cli.js'), + `require('node:fs').writeFileSync(${JSON.stringify(path.join(packageRoot, 'ran.txt'))}, 'x');\n` + ); + const link = path.join(packageRoot, 'cli.js'); + try { + fs.symlinkSync(path.join(packageRoot, 'real-cli.js'), link); + } catch { + return; + } + const code = delegateToPlugin(pluginWithBin(packageRoot, 'cli.js'), []); + expect(code).toBe(0); + expect(fs.existsSync(path.join(packageRoot, 'ran.txt'))).toBe(true); + }); }); From b8694d226337e8a72e7c7cff0a4422c349438354 Mon Sep 17 00:00:00 2001 From: Clay Good Date: Tue, 23 Jun 2026 00:42:49 -0500 Subject: [PATCH 20/20] test(e2e): rebuild the CLI bundle when it is stale, not just missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e suite spawns the compiled `dist/cli/index.js`, but ensureCliBuilt only built when the entry was absent — never when it was stale. Running the targeted suite against a `dist/` from before a fix lands silently exercises old code, so a corrected source looks broken (and a regressed source looks fixed). This was the cause of the plugin update-lifecycle e2e "still failing" off-CI: the source already collects contributed skills before the up-to-date short- circuit, but a stale bundle short-circuited at "All tools up to date" and never installed the contributed skill. Verified by neutering the compiled install path: the test fails against the stale bundle and passes once the helper detects staleness and rebuilds. ensureCliBuilt now rebuilds whenever any build input (src/, build.js, tsconfig.json, package.json) is newer than the built entry, and memoizes the "fresh" result per process so the recursive scan runs at most once per worker. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/helpers/run-cli.ts | 57 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/test/helpers/run-cli.ts b/test/helpers/run-cli.ts index 69d67df7f..ff0a341f0 100644 --- a/test/helpers/run-cli.ts +++ b/test/helpers/run-cli.ts @@ -1,5 +1,5 @@ import { spawn } from 'child_process'; -import { existsSync } from 'fs'; +import { existsSync, readdirSync, statSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -9,7 +9,53 @@ const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..', '..'); const cliEntry = path.join(projectRoot, 'dist', 'cli', 'index.js'); +// Inputs whose modification should invalidate the compiled CLI bundle. The e2e +// tests spawn `dist/cli/index.js`, so a stale `dist/` silently runs old code — +// a build from before a fix lands makes a corrected source look broken (and the +// reverse). Rebuild whenever any of these is newer than the built entry. +const buildInputs = [ + path.join(projectRoot, 'src'), + path.join(projectRoot, 'build.js'), + path.join(projectRoot, 'tsconfig.json'), + path.join(projectRoot, 'package.json'), +]; + let buildPromise: Promise | undefined; +let ensured = false; + +/** Newest mtime (ms) among `target` and, if it is a directory, everything under it. */ +function newestMtimeMs(target: string): number { + let newest = 0; + const stack = [target]; + while (stack.length > 0) { + const current = stack.pop()!; + let stat; + try { + stat = statSync(current); + } catch { + continue; // Missing input; ignore. + } + if (stat.isDirectory()) { + for (const entry of readdirSync(current)) { + stack.push(path.join(current, entry)); + } + } else if (stat.mtimeMs > newest) { + newest = stat.mtimeMs; + } + } + return newest; +} + +/** True when the built CLI bundle is missing or older than any build input. */ +function isBuildStale(): boolean { + let builtMtime: number; + try { + builtMtime = statSync(cliEntry).mtimeMs; + } catch { + return true; // Not built yet. + } + return buildInputs.some((input) => newestMtimeMs(input) > builtMtime); +} interface RunCommandOptions { cwd?: string; @@ -54,7 +100,13 @@ function runCommand(command: string, args: string[], options: RunCommandOptions } export async function ensureCliBuilt() { - if (existsSync(cliEntry)) { + // Verified fresh earlier in this process; skip the (recursive) staleness scan. + if (ensured) { + return; + } + + if (existsSync(cliEntry) && !isBuildStale()) { + ensured = true; return; } @@ -70,6 +122,7 @@ export async function ensureCliBuilt() { if (!existsSync(cliEntry)) { throw new Error('CLI entry point missing after build. Expected dist/cli/index.js'); } + ensured = true; } export async function runCLI(args: string[] = [], options: RunCLIOptions = {}): Promise {