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/design.md b/openspec/changes/add-plugin-marketplace/design.md new file mode 100644 index 000000000..32f47fe9d --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/design.md @@ -0,0 +1,119 @@ +## 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`. + +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 + +**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). +- 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 + +### 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. + +**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. + +**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..e73485bbb --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/proposal.md @@ -0,0 +1,90 @@ +## 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 choices in project and global config + +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. + +## 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. +- `plugin-docs`: Documentation obligations — code-first OpenLore onboarding, plugin authoring/manifest reference, and registry submission guidance (kept separate from the registry mechanics). + +### Modified Capabilities + +- `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 + +- `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 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 +- `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-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-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..6865ce294 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/cli-plugin/spec.md @@ -0,0 +1,80 @@ +## 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 + +#### 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 `. + +#### 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..121f71c95 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/cli-update/spec.md @@ -0,0 +1,33 @@ +## 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 + +#### 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. + +#### 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/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 new file mode 100644 index 000000000..d5afa4424 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/global-config/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### 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 +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 global configuration created before plugin support is loaded +- **THEN** it SHALL load without error +- **AND** OpenSpec SHALL apply default plugin preferences + +#### Scenario: Forward-compatible unknown plugin keys +- **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/specs/plugin-contribution/spec.md b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md new file mode 100644 index 000000000..12df23654 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-contribution/spec.md @@ -0,0 +1,67 @@ +## 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 + +#### 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 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. + +#### 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. + +#### 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-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-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..e8460b7d7 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/specs/plugin-marketplace/spec.md @@ -0,0 +1,31 @@ +## 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 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..c2bbe169a --- /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 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..d703c3d56 --- /dev/null +++ b/openspec/changes/add-plugin-marketplace/tasks.md @@ -0,0 +1,113 @@ +## 1. Plugin Manifest + +- [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. 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 + +- [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) + +- [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) + +- [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 + +- [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 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`) +- [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 + +- [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; 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 +- [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 + +- [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. 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 + +## 8. Init Integration + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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 + +- [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) 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/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/commands/plugin.ts b/src/commands/plugin.ts new file mode 100644 index 000000000..ef098a76c --- /dev/null +++ b/src/commands/plugin.ts @@ -0,0 +1,402 @@ +/** + * `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 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; + } + + 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.` + ); + } + + 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); + 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) { + reportEnable(projectRoot, id); + } else { + 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}".` + ); + } +} + +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..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', @@ -875,6 +876,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', @@ -959,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/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/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/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/contribution.ts b/src/core/plugins/contribution.ts new file mode 100644 index 000000000..e7f83469d Binary files /dev/null and b/src/core/plugins/contribution.ts differ diff --git a/src/core/plugins/index.ts b/src/core/plugins/index.ts new file mode 100644 index 000000000..e600e15b0 --- /dev/null +++ b/src/core/plugins/index.ts @@ -0,0 +1,8 @@ +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'; +export * from './contribution.js'; diff --git a/src/core/plugins/manifest.ts b/src/core/plugins/manifest.ts new file mode 100644 index 000000000..8652268e4 --- /dev/null +++ b/src/core/plugins/manifest.ts @@ -0,0 +1,254 @@ +/** + * 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-]*$/; + +// 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 === '..'); +} + +/** + * 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(), +}); + +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 + * 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() + .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 { + 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..0846dda1a --- /dev/null +++ b/src/core/plugins/runtime.ts @@ -0,0 +1,170 @@ +/** + * 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 * as fs from 'node:fs'; +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'; +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); +} + +/** 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 }; + +/** Resolve the [command, baseArgs] used to launch a plugin. */ +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` + ); + } + // 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) }; + } + // 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 { + 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(), + }); + + 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..e3502a9da --- /dev/null +++ b/src/core/plugins/semver.ts @@ -0,0 +1,121 @@ +/** + * 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]; + if (cleaned === '') return null; + const parts = cleaned.split('.'); + // 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 }; +} + +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[]; +} diff --git a/src/core/update.ts b/src/core/update.ts index e1582cd5b..42f172def 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -25,6 +25,13 @@ import { getToolsWithSkillsDir, type ToolVersionStatus, } from './shared/index.js'; +import { + collectContributedSkills, + collectKnownPluginSkillRefs, + hasContributionDrift, + installContributedSkills, + removeContributedSkill, +} from './plugins/contribution.js'; import { detectLegacyArtifacts, cleanupLegacyArtifacts, @@ -133,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 activePluginIds = new Set(contributedSkills.map((s) => s.pluginId)); + const knownSkillRefs = collectKnownPluginSkillRefs(resolvedProjectPath); + // 7. Smart update detection const toolsNeedingVersionUpdate = toolStatuses .filter((s) => s.needsUpdate) @@ -143,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, + knownSkillRefs, + shouldGenerateSkills + ); + }); const toolsToUpdateSet = new Set([ ...toolsNeedingVersionUpdate, ...toolsNeedingConfigSync, + ...toolsNeedingPluginSync, ]); const toolsUpToDate = toolStatuses.filter((s) => !toolsToUpdateSet.has(s.toolId)); @@ -164,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(); @@ -203,11 +235,24 @@ export class UpdateCommand { } removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + + // 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 ref of knownSkillRefs) { + if (!activePluginIds.has(ref.pluginId)) { + removeContributedSkill(skillsDir, ref.dirName, ref.pluginId); + } + } } // 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 ref of knownSkillRefs) { + removeContributedSkill(skillsDir, ref.dirName, ref.pluginId); + } } // Generate commands if delivery includes commands @@ -313,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); @@ -321,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(', ')}`); @@ -674,6 +724,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); @@ -695,6 +748,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 diff --git a/test/cli-e2e/plugin.test.ts b/test/cli-e2e/plugin.test.ts new file mode 100644 index 000000000..f1102a362 --- /dev/null +++ b/test/cli-e2e/plugin.test.ts @@ -0,0 +1,239 @@ +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[] = []; + +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. + */ +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' }], + 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'), + [ + '#!/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); + }); + + 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); + 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'); + const configPath = path.join(proj, 'openspec', 'config.yaml'); + await fs.mkdir(path.join(proj, 'openspec'), { recursive: true }); + 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, `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 + + // 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( + 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'); + + // 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, `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(configPath, 'schema: spec-driven\nplugins:\n autoDetect: false\n'); + const up2 = await runCLI(['update'], { cwd: proj, env: upEnv }); + 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); + }); +}); 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(); + }); +}); 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/contribution.test.ts b/test/core/plugins/contribution.test.ts new file mode 100644 index 000000000..43902f964 --- /dev/null +++ b/test/core/plugins/contribution.test.ts @@ -0,0 +1,281 @@ +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, + collectKnownPluginSkillRefs, + hasContributionDrift, + 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 and marks ownership', () => { + 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); + // 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 a plugin-owned skill but not unowned (core/user) directories', () => { + const toolSkillsDir = path.join(projectRoot, '.claude', 'skills'); + // 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', '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', 'good-engine')).toBe(false); + expect(fs.existsSync(path.join(toolSkillsDir, 'openspec-explore'))).toBe(true); + }); + + 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 }); + 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'), '#'); + + 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 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. + const sentinel = path.join(projectRoot, '.claude', 'KEEP.md'); + fs.writeFileSync(sentinel, 'do not delete'); + + expect(removeContributedSkill(toolSkillsDir, '../KEEP.md', 'evil')).toBe(false); + expect(removeContributedSkill(toolSkillsDir, '..\\KEEP.md', 'evil')).toBe(false); + expect(fs.existsSync(sentinel)).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(collectKnownPluginSkillRefs(projectRoot)).toContainEqual({ + dirName: 'good-orient', + pluginId: 'good-engine', + }); + }); + + 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 = collectKnownPluginSkillRefs(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); + }); + + 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 }); + 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 refs = [{ dirName: 'p-orient', pluginId: 'p' }]; + const v1 = [{ ...base[0], version: '1.0.0', sourceDir: src }]; + installContributedSkills(toolSkillsDir, v1); + 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, refs, true)).toBe(true); + }); +}); diff --git a/test/core/plugins/manifest.test.ts b/test/core/plugins/manifest.test.ts new file mode 100644 index 000000000..e37e5987d --- /dev/null +++ b/test/core/plugins/manifest.test.ts @@ -0,0 +1,210 @@ +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, + isSafeSkillDirName, + isSafeSkillSource, + isSafeBin, + 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('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); + }); + + 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'); + }); + + 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); + } + }); + + 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', () => { + 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/runtime.test.ts b/test/core/plugins/runtime.test.ts new file mode 100644 index 000000000..e1cfda149 --- /dev/null +++ b/test/core/plugins/runtime.test.ts @@ -0,0 +1,117 @@ +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/); + }); + + 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); + }); +}); diff --git a/test/core/plugins/semver.test.ts b/test/core/plugins/semver.test.ts new file mode 100644 index 000000000..4ce8971d3 --- /dev/null +++ b/test/core/plugins/semver.test.ts @@ -0,0 +1,63 @@ +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); + }); + + 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 + }); +}); 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 {