diff --git a/docs/ai/design/2026-06-11-feature-plugin-foundation.md b/docs/ai/design/2026-06-11-feature-plugin-foundation.md new file mode 100644 index 0000000..93a68ed --- /dev/null +++ b/docs/ai/design/2026-06-11-feature-plugin-foundation.md @@ -0,0 +1,206 @@ +--- +phase: design +title: System Design & Architecture +description: Define the technical architecture, components, and data models +--- + +# System Design & Architecture + +## Architecture Overview +**What is the high-level system structure?** + +```mermaid +flowchart TD + User[User] -->|ai-devkit plugin add pkg| PluginCmd[plugin command] + PluginCmd --> GlobalNpm[~/.ai-devkit/npm] + PluginCmd --> GlobalConfig[~/.ai-devkit/.ai-devkit.json] + GlobalNpm --> NodeModules[~/.ai-devkit/npm/node_modules] + + User -->|ai-devkit hello-devkit| Cli[AI DevKit CLI] + Cli --> Builtins[Register built-in commands] + Cli --> PluginLoader[Global plugin loader] + PluginLoader --> GlobalConfig + PluginLoader --> Resolver[Resolve package from global npm root] + Resolver --> PackageJson[Plugin package.json] + PackageJson --> Manifest[aiDevkit.commands manifest] + Manifest --> Entrypoint[Dynamic import command entrypoint] + Entrypoint --> Commander[Register Commander command] + Commander --> Runtime[AI DevKit runtime] + Commander --> PluginAction[Plugin action] +``` + +AI DevKit remains the host CLI. Plugins are global npm packages installed into an AI DevKit-managed npm project at `~/.ai-devkit/npm`. The global AI DevKit config lists enabled plugins. At startup, the CLI registers built-ins, discovers globally configured plugins, validates each manifest, and lets each plugin register contributed commands with Commander. + +### Key Components +| Component | Responsibility | +|---|---| +| `plugin` CLI command | Add, remove, and list global npm plugins. | +| Global config manager | Read/write `~/.ai-devkit/.ai-devkit.json` and its `plugins` array. | +| Plugin npm manager | Ensure `~/.ai-devkit/npm`, run npm install/remove, and report package status. | +| Plugin manifest loader | Resolve package manifests from `~/.ai-devkit/npm/node_modules` and validate `aiDevkit.commands`. | +| Plugin command loader | Import plugin entrypoints and register contributed Commander commands. | +| AI DevKit runtime | Stable host context passed to plugin registration. | + +## Data Models +**What data do we need to manage?** + +### Global Config +```typescript +interface GlobalAiDevkitConfig { + version?: string; + plugins?: string[]; + memory?: { + path?: string; + }; + [key: string]: unknown; +} +``` + +### Plugin Manifest +The MVP keeps the manifest in `package.json` so plugin authors do not need an extra file. Command entries must point to JavaScript files importable by Node at runtime. Plugin authors may build those files from TypeScript, but the host does not execute `.ts` entrypoints in the MVP. + +```json +{ + "name": "@example/hello-ai-devkit", + "type": "module", + "aiDevkit": { + "commands": [ + { + "name": "hello-devkit", + "description": "Print a plugin test message", + "entry": "./dist/command.js" + } + ] + } +} +``` + +```typescript +interface AiDevkitPluginManifest { + commands: AiDevkitPluginCommand[]; +} + +interface AiDevkitPluginCommand { + name: string; + description?: string; + entry: string; +} +``` + +### Runtime Contract +```typescript +interface AiDevkitRuntime { + cwd: string; + homeDir: string; + configPath: string; + getConfig(): Promise; + getMemoryDbPath(): Promise; + logger: { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + }; +} +``` + +## API Design +**How do components communicate?** + +### CLI Surface +```bash +ai-devkit plugin add +ai-devkit plugin remove +ai-devkit plugin list +ai-devkit [...args] +``` + +### Plugin Entrypoint +Each command entrypoint exports `register`. + +```typescript +import type { Command } from 'commander'; + +export async function register(command: Command, runtime: AiDevkitRuntime): Promise | void { + command + .description('Print a plugin test message') + .option('--name ', 'Name to greet') + .action(async options => { + runtime.logger.info(`Hello ${options.name ?? 'AI DevKit'}`); + }); +} +``` + +AI DevKit creates the top-level command from the manifest, then passes that `Command` instance to the plugin entrypoint. This keeps command naming and conflict handling in the host while letting plugins define options and actions. + +## Component Breakdown +**What are the major building blocks?** + +### `packages/cli/src/commands/plugin.ts` +- Registers `plugin add`, `plugin remove`, and `plugin list`. +- Delegates package work to a service layer. +- Renders install/remove/list results through existing terminal UI helpers. + +### `packages/cli/src/services/plugin/plugin-package.service.ts` +- Ensures `~/.ai-devkit/npm/package.json`. +- Runs npm commands with argument arrays, not shell interpolation. +- Supports install and uninstall for npm package names only. + +### `packages/cli/src/lib/GlobalConfig.ts` +- Reads/writes `~/.ai-devkit/.ai-devkit.json`. +- Adds/removes plugin names with deduplication. +- Preserves unrelated global config fields. +- Reuses the existing global config manager instead of creating a plugin-only config service. + +### `packages/cli/src/services/plugin/plugin-loader.service.ts` +- Resolves plugin package manifests by reading `~/.ai-devkit/npm/node_modules//package.json` directly. +- Validates manifest shape and command names. +- Detects command conflicts before registration. +- Imports entrypoints with `pathToFileURL()`. + +### `packages/cli/src/services/plugin/runtime.ts` +- Builds the `AiDevkitRuntime` passed to plugins. +- Centralizes config and memory path access. +- Keeps future host APIs behind a stable surface. + +## Design Decisions +**Why did we choose this approach?** + +| Decision | Choice | Rationale | +|---|---|---| +| Plugin source | npm only | Keeps install model familiar and avoids designing multiple source protocols. | +| Scope | Global only | Simplifies MVP and matches the user request. | +| Install root | `~/.ai-devkit/npm` | Stable across `npx`, global, and local AI DevKit invocations. | +| Config | `~/.ai-devkit/.ai-devkit.json` `plugins` | Keeps enabled plugin state in AI DevKit's global config. | +| Manifest location | `package.json` `aiDevkit.commands` | Easy for npm packages and avoids a second file lookup. | +| Manifest read path | Direct file read under `~/.ai-devkit/npm/node_modules` | Avoids Node package `exports` blocking access to `package.json`. | +| Command API | `register(command, runtime)` | Lets plugins use Commander options/help while host owns top-level names. | +| Runtime API | Small host-provided object | Prevents plugins from importing unstable AI DevKit internals. | +| Entrypoint file type | JavaScript only | Avoids runtime TypeScript loaders, keeps plugin execution predictable, and lets authors use normal build output. | + +### Alternatives Considered +- **Install beside AI DevKit binary**: rejected because `npx`, global installs, local installs, and permissions make the binary location unstable. +- **Project-local plugins first**: deferred because it adds config precedence and project trust questions. +- **Run plugins through `npx` every time**: rejected for MVP because it makes every plugin command slower and network-dependent. +- **Hard-code optional tools into core CLI**: rejected because optional dependencies and release cadence should stay separate from core. +- **Execute TypeScript entrypoints directly**: deferred because it requires a runtime loader such as `tsx` or `ts-node` and complicates production execution. + +## Non-Functional Requirements +**How should the system perform?** + +### Performance +- Plugin discovery only scans configured plugins. +- Missing or invalid plugins should not prevent built-in commands from running. +- Plugin entrypoints are imported during command registration in MVP; if startup becomes too slow, later work can defer entrypoint import until command action execution. + +### Security +- Installing a plugin is trusted npm package execution. +- Package names must be passed to npm as arguments, never interpolated into shell commands. +- Manifest `entry` must resolve inside the plugin package root. +- Manifest `entry` must resolve to a JavaScript file. +- Built-in command names cannot be overridden by plugins. + +### Reliability +- `plugin add` validates the manifest after install and rolls back config updates if validation fails. +- `plugin add` also uninstalls the package when manifest validation fails after npm install succeeds, so invalid plugins are not left enabled or installed by default. +- `plugin remove` should remove config entries even if the package is already absent, with a warning. +- Loader errors should be clear and isolated to the affected plugin where possible. diff --git a/docs/ai/implementation/2026-06-11-feature-plugin-foundation.md b/docs/ai/implementation/2026-06-11-feature-plugin-foundation.md new file mode 100644 index 0000000..c810131 --- /dev/null +++ b/docs/ai/implementation/2026-06-11-feature-plugin-foundation.md @@ -0,0 +1,124 @@ +--- +phase: implementation +title: Implementation Guide +description: Technical implementation notes, patterns, and code guidelines +--- + +# Implementation Guide + +## Development Setup +**How do we get started?** + +- Work in the feature worktree `feature-plugin-foundation`. +- Use npm as the workspace package manager. +- Keep implementation in `packages/cli` for the host plugin system. +- Use temporary home directories in tests so global plugin state does not touch the developer's real `~/.ai-devkit`. + +## Code Structure +**How is the code organized?** + +Expected new/changed areas: + +```text +packages/cli/src/commands/plugin.ts +packages/cli/src/services/plugin/ + plugin-package.service.ts + plugin-manifest.service.ts + plugin-loader.service.ts + plugin-manager.service.ts + runtime.ts +packages/cli/src/lib/GlobalConfig.ts +packages/cli/src/cli.ts +packages/cli/src/types.ts +packages/cli/src/__tests__/commands/plugin.test.ts +packages/cli/src/__tests__/services/plugin/ +``` + +Implemented so far: +- `packages/cli/src/types.ts`: added `plugins?: string[]` to `GlobalDevKitConfig`. +- `packages/cli/src/lib/GlobalConfig.ts`: added `getPlugins()`, `addPlugin()`, and `removePlugin()` for global plugin config entries. +- `packages/cli/src/__tests__/lib/GlobalConfig.test.ts`: added plugin config tests for read, add, dedupe, and remove behavior. +- `packages/cli/src/services/plugin/plugin-package.service.ts`: added global npm root resolution, npm project initialization, install/uninstall execution with argument arrays, and npm-package-only validation. +- `packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts`: added package service tests for npm root, package.json initialization, install/uninstall args, and invalid package names. +- `packages/cli/src/services/plugin/plugin-manifest.service.ts`: added manifest shape validation, duplicate-command rejection, built-in conflict rejection, JavaScript-only entrypoint checks, and package-root containment checks. +- `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`: added manifest validation tests for valid manifests and invalid manifest/error paths. +- `packages/cli/src/services/plugin/plugin-loader.service.ts`: added installed plugin manifest validation using package resolution from `~/.ai-devkit/npm`. +- `packages/cli/src/services/plugin/plugin-manager.service.ts`: added add/remove/list orchestration, including uninstall rollback on manifest validation failure. +- `packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts`: added service tests for add ordering, rollback, remove, and list status. +- `packages/cli/src/services/plugin/plugin-loader.service.ts`: expanded to register configured plugin commands with Commander and dynamically import command entrypoints. +- `packages/cli/src/services/plugin/runtime.ts`: added the initial `AiDevkitRuntime` with cwd, homeDir, configPath, config access, memory path resolution, and logger methods. +- `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts`: added plugin command registration tests and invalid plugin warning coverage. +- `packages/cli/src/__tests__/services/plugin/runtime.test.ts`: added runtime config and memory path tests. +- `web/content/docs/14-plugins.md`: added user and plugin-author documentation for global npm plugins. +- `web/content/docs/11-configuration-file.md`: documented the global `plugins` config field and plugin commands. +- `packages/cli/src/commands/plugin.ts`: added `plugin add`, `plugin remove`, and `plugin list` commands. +- `packages/cli/src/cli.ts`: registered the new `plugin` command group. +- `packages/cli/src/__tests__/commands/plugin.test.ts`: added command-level tests for add/remove/list behavior. +- `packages/cli/src/services/plugin/plugin-loader.service.ts`: changed package manifest lookup to read `~/.ai-devkit/npm/node_modules//package.json` directly so package `exports` cannot block manifest access. +- `packages/cli/src/services/plugin/plugin-package.service.ts`: tightened plugin package validation to reject version specs and unsafe path-shaped names before npm install or manifest lookup. +- `packages/cli/src/services/plugin/plugin-loader.service.ts`: added installed entrypoint existence validation so `plugin add` does not persist packages whose manifest points at missing JavaScript files. +- `packages/cli/src/services/plugin/plugin-loader.service.ts`: added a startup guard that skips plugin commands conflicting with already registered Commander commands. +- `packages/cli/src/services/plugin/plugin-manifest.service.ts`: restricted plugin command names to lowercase letters, numbers, and hyphens so manifest names cannot inject Commander argument/option grammar. + +## Implementation Notes +**Key technical details to remember:** + +### Core Features +- `plugin add`: ensure global npm root, run npm install, validate manifest, then update global config. If validation fails after install, run npm uninstall before returning the validation error. +- `plugin remove`: run npm uninstall and remove the plugin from global config. If one side is already absent, warn but complete the other cleanup. +- `plugin list`: show configured package name, installed status, and manifest status. +- Plugin loading: read global config, resolve package manifests from `~/.ai-devkit/npm/node_modules`, create Commander commands from manifest entries, import entrypoints, and call `register(command, runtime)`. + +### Patterns & Best Practices +- Use `execFile`/spawn-style APIs with argument arrays for npm. +- Read plugin `package.json` directly from `~/.ai-devkit/npm/node_modules//package.json`; do not rely on Node package `exports` for manifest access. +- Use `pathToFileURL()` for dynamic ESM imports. +- Accept JavaScript plugin entrypoints only; TypeScript source must be built before plugin execution. +- Keep plugin runtime APIs stable and intentionally small. +- Preserve existing built-in CLI behavior when plugin loading fails. + +## Integration Points +**How do pieces connect?** + +- `packages/cli/src/cli.ts` registers built-ins, then invokes the plugin loader. +- Plugin loader uses the global config service and manifest service. +- Plugin entrypoints receive Commander command instances and the AI DevKit runtime. +- Plugins can use `runtime.getMemoryDbPath()` when they need to locate the configured memory database. + +## Error Handling +**How do we handle failures?** + +- Missing global config should mean no plugins configured, except inside explicit `plugin add/remove/list` flows where the service can create config as needed. +- Invalid configured plugins should produce targeted warnings during startup and not break built-in commands. +- `plugin add` should fail if the installed package lacks a valid manifest. +- If `plugin add` fails manifest validation after install, attempt rollback uninstall and include rollback failure details if uninstall also fails. +- Entry paths outside the plugin root are invalid. +- Non-JavaScript entrypoint paths are invalid in the MVP. +- Built-in command conflicts are invalid. + +## Performance Considerations +**How do we keep it fast?** + +- Only inspect plugins listed in global config. +- Avoid filesystem recursion; resolve direct package manifests only. +- Measure startup before deciding whether to switch from eager `register()` imports to lazy action imports. + +## Security Notes +**What security measures are in place?** + +- Installing and running plugins executes trusted npm package code. +- Validate package names and never interpolate them into shell commands. +- Keep manifest entrypoint resolution inside the package root. +- Do not expose secrets through the runtime. +- Do not let plugins override built-in commands. + +## Phase 6 Implementation Check + +- Alignment: implementation matches the approved global npm-only plugin host design. +- Intentional deviation: reused the existing `GlobalConfigManager` for plugin config instead of adding a separate `plugin-config.service.ts`; this keeps all global config behavior in one place and preserves unrelated fields. +- Correctness fix found during implementation check: plugin manifests are now read directly from the managed npm install root instead of using `require.resolve("/package.json")`, because package `exports` can block `package.json` resolution. +- Code review fix: package names are now constrained to bare npm names (`name` or `@scope/name`) so version specs and path-shaped values cannot be persisted or used to build manifest paths. +- Phase 8 review fix: installed plugin commands now validate that declared entrypoint files exist before the plugin is considered valid. +- Phase 8 review fix: plugin command registration now tracks already registered command names and skips later conflicts with a warning. +- Final security review fix: plugin command names now reject spaces, argument placeholders, options, uppercase names, and other Commander grammar before registration. +- Remaining follow-ups: manual smoke with a real or packed plugin package, startup timing measurement with configured plugins, and later concrete first-party plugin package implementations. diff --git a/docs/ai/planning/2026-06-11-feature-plugin-foundation.md b/docs/ai/planning/2026-06-11-feature-plugin-foundation.md new file mode 100644 index 0000000..5721831 --- /dev/null +++ b/docs/ai/planning/2026-06-11-feature-plugin-foundation.md @@ -0,0 +1,87 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: Break down work into actionable tasks and estimate timeline +--- + +# Project Planning & Task Breakdown + +## Milestones +**What are the major checkpoints?** + +- [x] Milestone 1: Global plugin config and npm package management +- [x] Milestone 2: Manifest validation and command registration +- [x] Milestone 3: Runtime contract and first fixture plugin flow +- [ ] Milestone 4: Documentation, tests, and lifecycle verification + +## Task Breakdown +**What specific work needs to be done?** + +### Phase 1: Foundation +- [x] Task 1.1: Add `plugins?: string[]` to AI DevKit config types where needed. +- [x] Task 1.2: Add a global config service for `~/.ai-devkit/.ai-devkit.json` plugin entries. +- [x] Task 1.3: Add a plugin package service that ensures `~/.ai-devkit/npm/package.json`. +- [x] Task 1.4: Add safe npm install/uninstall execution for global plugin packages. +- [x] Task 1.5: Add unit tests for config mutation and npm command construction. + +### Phase 2: Core Features +- [x] Task 2.1: Register `ai-devkit plugin add `. +- [x] Task 2.2: Register `ai-devkit plugin remove `. +- [x] Task 2.3: Register `ai-devkit plugin list`. +- [x] Task 2.4: Implement plugin manifest schema validation. +- [x] Task 2.5: Add uninstall rollback when `plugin add` installs a package but manifest validation fails. +- [x] Task 2.6: Enforce JavaScript-only plugin command entrypoints. +- [x] Task 2.7: Implement plugin package resolution from `~/.ai-devkit/npm/node_modules`. +- [x] Task 2.8: Implement command conflict detection for built-in and plugin command names. +- [x] Task 2.9: Add command tests for add/remove/list, rollback, and error paths. + +### Phase 3: Integration & Polish +- [x] Task 3.1: Add plugin command loading during CLI startup after built-ins are registered. +- [x] Task 3.2: Implement dynamic entrypoint import with package-root containment checks. +- [x] Task 3.3: Build the initial `AiDevkitRuntime` object. +- [x] Task 3.4: Add a fixture plugin and integration tests for Commander registration. +- [x] Task 3.5: Add docs for plugin users and plugin authors. +- [ ] Task 3.6: Run lifecycle validation, implementation check, testing, and code review phases. + +## Dependencies +**What needs to happen in what order?** + +- Config and npm package services must exist before `plugin add/remove/list`. +- Manifest validation must exist before `plugin add` persists a plugin to config. +- Built-in command inventory must be available before command conflict detection. +- Runtime contract must exist before fixture plugin integration. +- No external services are required beyond npm registry access for real install smoke tests. + +## Timeline & Estimates +**When will things be done?** + +- Foundation: medium. +- Core plugin commands and manifest loading: medium. +- Runtime and integration tests: medium. +- Docs and lifecycle verification: small. +- Main unknown: whether CLI startup impact requires deferred plugin entrypoint import in the first implementation. + +## Risks & Mitigation +**What could go wrong?** + +| Risk | Impact | Mitigation | +|---|---|---| +| Plugin entrypoint import slows every CLI command | Medium | Measure startup and defer imports if needed. | +| Plugin command conflicts with built-ins | High | Built-ins win; reject conflicting plugin commands. | +| npm install command injection | High | Use argument arrays and validate package names. | +| Invalid plugin corrupts global config | Medium | Validate manifest before adding to config. | +| Runtime API becomes unstable | Medium | Keep MVP runtime small and typed. | +| Global-only scope disappoints project-specific workflows | Low | Document as deferred; add project scope later with separate design. | + +## Resources Needed +**What do we need to succeed?** + +- Existing CLI command and terminal UI utilities. +- Existing config service patterns. +- npm available on the user machine. +- Fixture plugin package for tests. +- Follow-up package design for concrete first-party plugins. + +## Current Status + +The global npm plugin host MVP is implemented in `packages/cli`: global plugin config, npm install/uninstall service, manifest validation, rollback on invalid manifests, `plugin add/remove/list`, runtime object, and configured plugin command registration. Focused CLI tests and `npx nx run cli:build` pass. Web docs were added, but `npm run build` in `web/` is currently blocked by an unrelated existing `/vision` frontmatter YAML parse error. Remaining work is lifecycle closure: implementation check, broader testing/manual smoke with a real or packed plugin package, and code review. diff --git a/docs/ai/requirements/2026-06-11-feature-plugin-foundation.md b/docs/ai/requirements/2026-06-11-feature-plugin-foundation.md new file mode 100644 index 0000000..c1da9ee --- /dev/null +++ b/docs/ai/requirements/2026-06-11-feature-plugin-foundation.md @@ -0,0 +1,147 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Clarify the problem space, gather requirements, and define success criteria +--- + +# Requirements & Problem Understanding + +## Problem Statement +**What problem are we solving?** + +- AI DevKit can expose core capabilities through CLI and MCP commands, but richer optional experiences such as visual tools or heavier integrations would add dependencies and release cadence concerns to the core CLI. +- Users need a simple way to install optional AI DevKit extensions globally and run their contributed commands regardless of whether `ai-devkit` itself is invoked through `npx`, a global install, or a local project install. +- Plugin developers need a small, predictable contract for adding commands without depending on the physical install location of the AI DevKit CLI binary. +- Current workaround is to hard-code every new surface into `packages/cli`, which keeps command discovery simple but makes optional tools part of the core release surface. + +## Goals & Objectives +**What do we want to achieve?** + +### Primary Goals +- Add a global npm-only plugin system for AI DevKit. +- Support `ai-devkit plugin add `, `ai-devkit plugin remove `, and `ai-devkit plugin list`. +- Install plugin npm packages into `~/.ai-devkit/npm/node_modules`, not into the AI DevKit CLI package or a project `node_modules`. +- Persist enabled global plugins in `~/.ai-devkit/.ai-devkit.json` under `plugins`. +- Load plugin command manifests at CLI startup and register contributed commands with Commander. +- Provide a minimal AI DevKit runtime object to plugin command registrations so plugins can read AI DevKit config and use stable host-provided utilities. + +### Secondary Goals +- Keep the plugin contract easy for plugin authors to implement with a JSON manifest and JavaScript entrypoint. +- Make behavior independent of whether the user runs `npx ai-devkit`, `ai-devkit`, or a local binary. +- Keep the system extensible for future runtime APIs without exposing unstable internal CLI modules. + +### Non-Goals +- Project-local plugins. +- Non-npm plugin sources such as Git URLs, local paths, tarballs, or registries outside npm package-manager support. +- Runtime execution of TypeScript plugin entrypoints. Plugin authors may write TypeScript, but manifests must point to built JavaScript files. +- Plugin marketplace/search. +- Plugin sandboxing beyond ordinary Node/npm package execution boundaries. +- Auto-loading arbitrary code that is not listed in the global AI DevKit config. +- Building any plugin package or plugin UI in this feature. This feature establishes the plugin host contract only. + +## User Stories & Use Cases +**How will users interact with the solution?** + +### User Stories +- As an AI DevKit user, I want to run `ai-devkit plugin add @example/hello-ai-devkit` so that I can install an optional plugin without manually managing `~/.ai-devkit/npm`. +- As an AI DevKit user, I want to run `ai-devkit hello-devkit` after installation so that I can run plugin commands from the same CLI I already use. +- As an AI DevKit user, I want `npx ai-devkit hello-devkit` and globally installed `ai-devkit hello-devkit` to behave the same so that invocation style does not affect plugins. +- As an AI DevKit user, I want to run `ai-devkit plugin remove @example/hello-ai-devkit` so that I can uninstall and disable a plugin cleanly. +- As a plugin developer, I want to declare commands in a manifest and export a registration function so that my plugin integrates with Commander without hard-coding changes in AI DevKit core. +- As a plugin developer, I want an AI DevKit runtime object so that I can read config and later consume host APIs through a stable contract. + +### Key Workflows +1. **Install Plugin** + - User runs `ai-devkit plugin add @example/hello-ai-devkit`. + - AI DevKit ensures `~/.ai-devkit/npm/package.json` exists. + - AI DevKit runs npm install in `~/.ai-devkit/npm`. + - AI DevKit validates the installed package manifest. + - AI DevKit adds the package name to `~/.ai-devkit/.ai-devkit.json` `plugins`. + - If manifest validation fails after npm install succeeds, AI DevKit uninstalls the package and reports the validation failure. +2. **Run Plugin Command** + - User runs `ai-devkit hello-devkit`. + - AI DevKit reads `~/.ai-devkit/.ai-devkit.json`. + - AI DevKit resolves listed plugins from `~/.ai-devkit/npm/node_modules`. + - AI DevKit reads each plugin manifest and registers contributed commands. + - Commander dispatches to the plugin-provided registration/action. +3. **Remove Plugin** + - User runs `ai-devkit plugin remove @example/hello-ai-devkit`. + - AI DevKit removes the package from `~/.ai-devkit/npm`. + - AI DevKit removes the package name from global `plugins`. +4. **List Plugins** + - User runs `ai-devkit plugin list`. + - AI DevKit prints globally configured plugins and whether each package is installed and manifest-valid. + +### Edge Cases +- `~/.ai-devkit` does not exist. +- `~/.ai-devkit/npm/package.json` does not exist. +- npm install fails because the package does not exist or network access fails. +- Package installs but has no AI DevKit manifest. +- Package install succeeds but manifest validation fails and rollback uninstall also fails. +- Package manifest declares duplicate command names. +- Plugin command name conflicts with a built-in AI DevKit command. +- Plugin entrypoint cannot be imported. +- Plugin registration throws. +- Config lists a plugin that is not installed. +- `plugin remove` is run for a package that is not configured or not installed. + +## Success Criteria +**How will we know when we're done?** + +### Acceptance Criteria +- `ai-devkit plugin add @example/hello-ai-devkit` installs the npm package into `~/.ai-devkit/npm/node_modules`. +- `ai-devkit plugin add` creates or updates `~/.ai-devkit/.ai-devkit.json` with a deduplicated `plugins` array. +- `ai-devkit plugin add` is atomic from the user's perspective: invalid plugin packages are uninstalled and are not added to config. +- `ai-devkit plugin remove @example/hello-ai-devkit` uninstalls the package from `~/.ai-devkit/npm` and removes it from the global config. +- `ai-devkit plugin list` shows configured global plugins and installation/manifest status. +- A plugin package can expose a manifest that maps `hello-devkit` to a JS entrypoint. +- AI DevKit imports the plugin entrypoint at runtime and lets it register a Commander command. +- Built-in commands keep priority over plugin commands. +- Missing or invalid plugins produce clear errors without breaking built-in commands. +- Plugin runtime exposes at least `cwd`, `homeDir`, `configPath`, `getConfig()`, `getMemoryDbPath()`, and logger methods. +- The implementation is covered by unit tests for config mutation, package-manager command construction, manifest validation, command registration, and error handling. + +### Performance Expectations +- Built-in command startup should not import plugin entrypoints unless command registration requires it. +- Plugin discovery should be bounded by the configured global plugin list. +- Common built-in commands should remain usable when a configured plugin is missing or invalid. + +## Constraints & Assumptions +**What limitations do we need to work within?** + +### Technical Constraints +- Node.js runtime remains ESM. +- Commander remains the CLI command framework. +- npm is the only supported package source and package manager for MVP. +- Global plugin install root is `~/.ai-devkit/npm`. +- Global AI DevKit config is `~/.ai-devkit/.ai-devkit.json`. +- Plugin package execution is trusted code execution, equivalent to installing any npm CLI package. + +### Assumptions +- Global-only plugins are sufficient for the first release. +- Plugin authors can build their TypeScript to JavaScript before publishing. +- Plugin command entrypoints should be JavaScript files importable by Node at runtime. +- AI DevKit will provide runtime APIs through a small stable object rather than exposing internal modules. + +## Questions & Open Items +**What do we still need to clarify?** + +### Resolved +- Plugin source: npm only. +- Install scope: global only. +- Install location: `~/.ai-devkit/npm/node_modules`. +- Config location: `~/.ai-devkit/.ai-devkit.json`. +- Plugin declaration: global config `plugins` array. +- Command integration: manifest maps command names to JS entrypoints that register with Commander. +- Runtime: AI DevKit provides a small runtime object for config access and future APIs. +- Failed manifest validation after install: rollback uninstall automatically and leave the plugin disabled. +- Plugin entrypoints: JavaScript only for MVP. + +### Deferred +- Project-local plugin support. +- Non-npm sources. +- Lockfile or integrity reporting beyond npm's own `package-lock.json` in `~/.ai-devkit/npm`. +- Plugin marketplace/search. +- Fine-grained permission prompts and sandboxing. +- Concrete first-party plugin package requirements. +- First-class local plugin development commands such as `plugin add ./path --dev`. diff --git a/docs/ai/testing/2026-06-11-feature-plugin-foundation.md b/docs/ai/testing/2026-06-11-feature-plugin-foundation.md new file mode 100644 index 0000000..a5feb0c --- /dev/null +++ b/docs/ai/testing/2026-06-11-feature-plugin-foundation.md @@ -0,0 +1,123 @@ +--- +phase: testing +title: Testing Strategy +description: Define testing approach, test cases, and quality assurance +--- + +# Testing Strategy + +## Test Coverage Goals +**What level of testing do we aim for?** + +- Target 100% coverage for new plugin config, package-manager, manifest validation, runtime, and command-loader helper code. +- Command tests should cover user-facing `plugin add`, `plugin remove`, and `plugin list` behavior with mocked npm execution. +- Integration tests should cover loading a fixture plugin from a temporary `~/.ai-devkit/npm/node_modules` layout and registering a contributed Commander command. +- E2E coverage should validate the high-level user flow with a local fixture package if feasible without publishing to npm. + +## Unit Tests +**What individual components need testing?** + +### Plugin Config Service +- [x] Creates `~/.ai-devkit/.ai-devkit.json` when adding the first global plugin. Covered by `packages/cli/src/__tests__/lib/GlobalConfig.test.ts`. +- [x] Adds plugin names with deduplication. Covered by `packages/cli/src/__tests__/lib/GlobalConfig.test.ts`. +- [x] Removes plugin names while preserving unrelated config fields. Covered by `packages/cli/src/__tests__/lib/GlobalConfig.test.ts`. +- [x] Handles missing or malformed `plugins` as an empty list with validation errors where appropriate. Covered by `packages/cli/src/__tests__/lib/GlobalConfig.test.ts`. + +### Plugin Package Service +- [x] Ensures `~/.ai-devkit/npm/package.json` before install. Covered by `packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts`. +- [x] Runs npm install with argument arrays for `plugin add`. Covered by `packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts`. +- [x] Runs npm uninstall with argument arrays for `plugin remove`. Covered by `packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts`. +- [x] Rejects empty or invalid package names. Covered by `packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts`. +- [x] Rejects versioned package specs and unsafe path-shaped package names. Covered by `packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts` and `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts`. +- [x] Surfaces npm install/remove failures with actionable errors. Covered by `packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts`. + +### Manifest Loader +- [x] Resolves `package.json` from `~/.ai-devkit/npm/node_modules`. Covered indirectly by `packages/cli/src/services/plugin/plugin-loader.service.ts` and manager validation flow; deeper loader tests remain useful. +- [x] Accepts a valid `aiDevkit.commands` manifest. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`. +- [x] Rejects missing `aiDevkit.commands`. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`. +- [x] Rejects duplicate command names within a plugin. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`. +- [x] Rejects command names that conflict with built-in commands. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`. +- [x] Rejects command names that include Commander grammar or unsafe command tokens. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`. +- [x] Rejects `entry` paths that resolve outside the plugin package root. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`. +- [x] Rejects non-JavaScript command entrypoints for MVP. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts`. + +### Runtime Builder +- [x] Provides `cwd`, `homeDir`, and `configPath`. Covered by `packages/cli/src/__tests__/services/plugin/runtime.test.ts`. +- [x] `getConfig()` reads the global config. Covered by `packages/cli/src/__tests__/services/plugin/runtime.test.ts`. +- [x] `getMemoryDbPath()` resolves configured relative memory paths from the global config directory. Covered by `packages/cli/src/__tests__/services/plugin/runtime.test.ts`. +- [x] Logger delegates to the terminal UI or console wrapper consistently. Implemented in `packages/cli/src/services/plugin/runtime.ts`; deeper direct logger tests deferred as low risk. + +## Integration Tests +**How do we test component interactions?** + +- [x] Register a fixture plugin command and verify Commander can execute it. Covered by `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts`. +- [x] Verify built-in commands remain available when a configured plugin is missing. Covered by `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts`. +- [x] Verify invalid plugin manifests produce warnings/errors without crashing unrelated commands. Covered by `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts`. +- [x] Verify installed plugin manifests are rejected when declared JavaScript entrypoint files are missing. Covered by `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts`. +- [x] Verify plugin command registration skips conflicts with already registered commands. Covered by `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts`. +- [x] Verify `plugin add` installs, validates, and persists config in the expected order. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts`. +- [x] Verify `plugin add` does not persist a plugin when manifest validation fails. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts`. +- [x] Verify `plugin add` uninstalls the package when manifest validation fails after npm install succeeds. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts`. +- [x] Verify rollback failure is reported clearly if manifest validation and uninstall rollback both fail. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts`. +- [x] Verify `plugin remove` uninstalls and removes the config entry. Covered by `packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts`. +- [x] Command-level `plugin add/remove/list` wiring and list output. Covered by `packages/cli/src/__tests__/commands/plugin.test.ts`. + +## End-to-End Tests +**What user flows need validation?** + +- [ ] User flow: install a local fixture npm package as a global plugin and run its command. +- [ ] User flow: list installed plugins and see valid status. +- [ ] User flow: remove the fixture plugin and confirm its command is no longer available. +- [ ] Regression: `ai-devkit memory search --query ` still works when no plugins are configured. + +## Test Data +**What data do we use for testing?** + +- Temporary home directory containing `.ai-devkit/`. +- Fixture plugin package with `package.json` `aiDevkit.commands`. +- Fixture plugin entrypoint exporting `register(command, runtime)`. +- Fixture invalid plugins: missing manifest, duplicate commands, invalid entry path, conflicting command name. +- Fixture invalid plugin with a `.ts` entrypoint. +- Mock npm runner for unit tests; local file package install for E2E if needed. +- In-memory fixture plugin entrypoint used by `packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts` for Commander registration. + +## Test Reporting & Coverage +**How do we verify and communicate test results?** + +- Primary command targets: + - `npx nx run cli:test -- --runInBand plugin` + - `npm run test -- --coverage` for broader coverage when feature stabilizes. +- Any coverage below 100% for new modules must be documented with rationale. +- Manual smoke output should include install path, config contents, and command execution result. + +### Latest Verification + +- `npx nx run cli:test -- --runInBand src/__tests__/lib/GlobalConfig.test.ts src/__tests__/services/plugin/plugin-package.service.test.ts src/__tests__/services/plugin/plugin-manifest.service.test.ts src/__tests__/services/plugin/plugin-manager.service.test.ts src/__tests__/services/plugin/plugin-loader.service.test.ts src/__tests__/services/plugin/runtime.test.ts src/__tests__/commands/plugin.test.ts` passed with 7 files and 52 tests. +- `npx nx run cli:build` passed. +- `npm run test:coverage -- ...plugin tests...` from `packages/cli` ran the plugin-focused tests and produced plugin file coverage, but exited non-zero because global package thresholds apply to all CLI source files, including many unrelated files not exercised by this focused test run. +- Plugin-service coverage from the focused coverage run: `plugin-manager.service.ts` 100% statements, `plugin-package.service.ts` 96.29% statements, `plugin-manifest.service.ts` 87.17% statements, `plugin-loader.service.ts` 65.51% statements, `runtime.ts` 75% statements. +- Remaining plugin coverage gaps are mostly default import/runtime fallback branches and defensive manifest validation branches. They are acceptable for this MVP because the high-risk behaviors (npm args, manifest rejection, rollback, command conflicts, package-root containment, built-in preservation) are covered. + +## Manual Testing +**What requires human validation?** + +- [ ] Run `ai-devkit plugin add @example/hello-ai-devkit` against a published or locally packed package. +- [ ] Verify `~/.ai-devkit/npm/node_modules` contains the plugin package. +- [ ] Verify `~/.ai-devkit/.ai-devkit.json` contains the plugin name. +- [ ] Run `ai-devkit hello-devkit --help`. +- [ ] Run `npx ai-devkit hello-devkit --help` and confirm behavior matches global/local invocation. +- [ ] Remove the plugin and verify the command is no longer registered. +- [x] Documentation smoke: added `web/content/docs/14-plugins.md` and global plugin config reference in `web/content/docs/11-configuration-file.md`. + +## Performance Testing +**How do we validate performance?** + +- Compare CLI startup timing before and after plugin loader registration with no plugins configured. +- Measure startup with one valid plugin configured. +- If startup cost is noticeable, defer entrypoint import until command action execution in a follow-up. + +## Bug Tracking +**How do we manage issues?** + +- Treat command conflicts, unsafe entry resolution, and config corruption as blocking defects. +- Treat poor error messages and slow startup as release risks to address before broad rollout. diff --git a/packages/cli/src/__tests__/commands/plugin.test.ts b/packages/cli/src/__tests__/commands/plugin.test.ts new file mode 100644 index 0000000..1007641 --- /dev/null +++ b/packages/cli/src/__tests__/commands/plugin.test.ts @@ -0,0 +1,98 @@ +import { Command } from 'commander'; + +const { + mockPluginManager, + mockUi, +} = vi.hoisted(() => ({ + mockPluginManager: { + add: vi.fn(), + remove: vi.fn(), + list: vi.fn(), + }, + mockUi: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + text: vi.fn(), + table: vi.fn(), + }, +})); + +vi.mock('../../services/plugin/plugin-manager.service.js', () => ({ + createPluginManager: () => mockPluginManager, +})); + +vi.mock('../../util/terminal-ui.js', () => ({ + ui: mockUi, +})); + +import { registerPluginCommand } from '../../commands/plugin.js'; + +describe('plugin command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPluginManager.add.mockResolvedValue(undefined); + mockPluginManager.remove.mockResolvedValue(undefined); + mockPluginManager.list.mockResolvedValue([]); + vi.spyOn(process, 'exit').mockImplementation((() => undefined) as any); + }); + + it('registers plugin add and delegates to the plugin manager', async () => { + const program = new Command(); + registerPluginCommand(program); + + await program.parseAsync(['node', 'test', 'plugin', 'add', '@ai-devkit/memory-dashboard']); + + expect(mockPluginManager.add).toHaveBeenCalledWith('@ai-devkit/memory-dashboard'); + expect(mockUi.success).toHaveBeenCalledWith('Plugin added: @ai-devkit/memory-dashboard'); + }); + + it('registers plugin remove and delegates to the plugin manager', async () => { + const program = new Command(); + registerPluginCommand(program); + + await program.parseAsync(['node', 'test', 'plugin', 'remove', '@ai-devkit/memory-dashboard']); + + expect(mockPluginManager.remove).toHaveBeenCalledWith('@ai-devkit/memory-dashboard'); + expect(mockUi.success).toHaveBeenCalledWith('Plugin removed: @ai-devkit/memory-dashboard'); + }); + + it('renders plugin list results as a table', async () => { + mockPluginManager.list.mockResolvedValue([ + { + name: '@ai-devkit/memory-dashboard', + status: 'valid', + error: undefined, + }, + { + name: '@ai-devkit/bad-plugin', + status: 'invalid', + error: 'missing manifest', + } + ]); + + const program = new Command(); + registerPluginCommand(program); + + await program.parseAsync(['node', 'test', 'plugin', 'list']); + + expect(mockUi.table).toHaveBeenCalledWith({ + headers: ['package', 'status', 'error'], + rows: [ + ['@ai-devkit/memory-dashboard', 'valid', ''], + ['@ai-devkit/bad-plugin', 'invalid', 'missing manifest'], + ] + }); + }); + + it('warns when no plugins are configured', async () => { + const program = new Command(); + registerPluginCommand(program); + + await program.parseAsync(['node', 'test', 'plugin', 'list']); + + expect(mockUi.warning).toHaveBeenCalledWith('No global plugins configured.'); + expect(mockUi.table).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/__tests__/lib/GlobalConfig.test.ts b/packages/cli/src/__tests__/lib/GlobalConfig.test.ts index 41b0eaa..e06e9cd 100644 --- a/packages/cli/src/__tests__/lib/GlobalConfig.test.ts +++ b/packages/cli/src/__tests__/lib/GlobalConfig.test.ts @@ -27,6 +27,7 @@ describe('GlobalConfigManager', () => { mockOs.homedir.mockReturnValue('/home/test'); mockPath.join.mockImplementation((...args) => args.join('/')); + mockPath.dirname.mockImplementation((input: string) => input.split('/').slice(0, -1).join('/') || '/'); vi.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -98,4 +99,89 @@ describe('GlobalConfigManager', () => { }); }); }); + + describe('getPlugins', () => { + it('should return empty list when no config exists', async () => { + (mockFs.pathExists as any).mockResolvedValue(false); + + const result = await configManager.getPlugins(); + + expect(result).toEqual([]); + }); + + it('should return only string plugin entries', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + plugins: ['@ai-devkit/memory-dashboard', 123, '', ' @ai-devkit/agent-office '] + }); + + const result = await configManager.getPlugins(); + + expect(result).toEqual(['@ai-devkit/memory-dashboard', '@ai-devkit/agent-office']); + }); + }); + + describe('addPlugin', () => { + it('creates global config and adds the first plugin', async () => { + (mockFs.pathExists as any).mockResolvedValue(false); + (mockFs.ensureDir as any).mockResolvedValue(undefined); + (mockFs.writeJson as any).mockResolvedValue(undefined); + + const result = await configManager.addPlugin('@ai-devkit/memory-dashboard'); + + expect(result.plugins).toEqual(['@ai-devkit/memory-dashboard']); + expect(mockFs.ensureDir).toHaveBeenCalledWith('/home/test/.ai-devkit'); + expect(mockFs.writeJson).toHaveBeenCalledWith( + '/home/test/.ai-devkit/.ai-devkit.json', + { plugins: ['@ai-devkit/memory-dashboard'] }, + { spaces: 2 } + ); + }); + + it('deduplicates plugin entries when adding an existing plugin', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + registries: { 'owner/repo': 'https://example.com/repo.git' }, + plugins: ['@ai-devkit/memory-dashboard'] + }); + (mockFs.ensureDir as any).mockResolvedValue(undefined); + (mockFs.writeJson as any).mockResolvedValue(undefined); + + const result = await configManager.addPlugin('@ai-devkit/memory-dashboard'); + + expect(result.plugins).toEqual(['@ai-devkit/memory-dashboard']); + expect(mockFs.writeJson).toHaveBeenCalledWith( + '/home/test/.ai-devkit/.ai-devkit.json', + { + registries: { 'owner/repo': 'https://example.com/repo.git' }, + plugins: ['@ai-devkit/memory-dashboard'] + }, + { spaces: 2 } + ); + }); + }); + + describe('removePlugin', () => { + it('removes plugin entries while preserving unrelated config', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + registries: { 'owner/repo': 'https://example.com/repo.git' }, + plugins: ['@ai-devkit/memory-dashboard', '@ai-devkit/agent-office'] + }); + (mockFs.ensureDir as any).mockResolvedValue(undefined); + (mockFs.writeJson as any).mockResolvedValue(undefined); + + const result = await configManager.removePlugin('@ai-devkit/memory-dashboard'); + + expect(result.plugins).toEqual(['@ai-devkit/agent-office']); + expect(mockFs.writeJson).toHaveBeenCalledWith( + '/home/test/.ai-devkit/.ai-devkit.json', + { + registries: { 'owner/repo': 'https://example.com/repo.git' }, + plugins: ['@ai-devkit/agent-office'] + }, + { spaces: 2 } + ); + }); + }); }); diff --git a/packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts b/packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts new file mode 100644 index 0000000..f8d3051 --- /dev/null +++ b/packages/cli/src/__tests__/services/plugin/plugin-loader.service.test.ts @@ -0,0 +1,208 @@ +import { Command } from 'commander'; +import fs from 'fs-extra'; +import type { Mocked } from 'vitest'; +import * as os from 'os'; +import * as path from 'path'; +import { + getInstalledPluginPackageJsonPath, + loadInstalledPluginCommands, + registerConfiguredPluginCommands, + type LoadedPluginCommand, +} from '../../../services/plugin/plugin-loader.service.js'; + +vi.mock('os'); + +describe('plugin loader service', () => { + let tempHome: string; + let mockOs: Mocked; + + beforeEach(async () => { + mockOs = os as Mocked; + tempHome = await fs.mkdtemp('/tmp/ai-devkit-plugin-loader-'); + mockOs.homedir.mockReturnValue(tempHome); + }); + + afterEach(async () => { + vi.clearAllMocks(); + await fs.remove(tempHome); + }); + + it('builds package.json paths directly under the global plugin node_modules', () => { + expect(getInstalledPluginPackageJsonPath('@ai-devkit/memory-dashboard')) + .toContain('.ai-devkit/npm/node_modules/@ai-devkit/memory-dashboard/package.json'); + }); + + it('loads installed plugin commands when declared entrypoints exist', async () => { + const pluginRoot = path.join(tempHome, '.ai-devkit', 'npm', 'node_modules', '@ai-devkit', 'memory-dashboard'); + await fs.ensureDir(path.join(pluginRoot, 'dist')); + await fs.writeJson(path.join(pluginRoot, 'package.json'), { + aiDevkit: { + commands: [ + { + name: 'memory-dashboard', + description: 'Open memory dashboard', + entry: './dist/command.js', + } + ] + } + }); + await fs.writeFile(path.join(pluginRoot, 'dist', 'command.js'), 'export function register() {}'); + + await expect(loadInstalledPluginCommands('@ai-devkit/memory-dashboard')).resolves.toEqual([ + { + pluginName: '@ai-devkit/memory-dashboard', + command: { + name: 'memory-dashboard', + description: 'Open memory dashboard', + entry: './dist/command.js', + entryPath: path.join(pluginRoot, 'dist', 'command.js'), + } + } + ]); + }); + + it('rejects installed plugin commands when declared entrypoints are missing', async () => { + const pluginRoot = path.join(tempHome, '.ai-devkit', 'npm', 'node_modules', '@ai-devkit', 'memory-dashboard'); + await fs.ensureDir(pluginRoot); + await fs.writeJson(path.join(pluginRoot, 'package.json'), { + aiDevkit: { + commands: [ + { + name: 'memory-dashboard', + entry: './dist/missing.js', + } + ] + } + }); + + await expect(loadInstalledPluginCommands('@ai-devkit/memory-dashboard')) + .rejects.toThrow('Plugin @ai-devkit/memory-dashboard command "memory-dashboard" entrypoint does not exist: ./dist/missing.js'); + }); + + it('rejects unsafe configured plugin names before building package paths', () => { + expect(() => getInstalledPluginPackageJsonPath('@ai-devkit/../memory-dashboard')) + .toThrow('Only npm package names are supported for plugins.'); + }); + + it('registers configured plugin commands with Commander', async () => { + const program = new Command(); + const runtime = { + cwd: '/project', + homeDir: '/home/test', + configPath: '/home/test/.ai-devkit/.ai-devkit.json', + getConfig: vi.fn(), + getMemoryDbPath: vi.fn(), + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } + }; + const loadedCommand: LoadedPluginCommand = { + pluginName: '@ai-devkit/memory-dashboard', + command: { + name: 'memory-dashboard', + description: 'Open memory dashboard', + entry: './dist/command.js', + entryPath: '/plugin/dist/command.js', + } + }; + + await registerConfiguredPluginCommands(program, runtime, { + getPlugins: vi.fn().mockResolvedValue(['@ai-devkit/memory-dashboard']), + loadPluginCommands: vi.fn().mockResolvedValue([loadedCommand]), + importCommandEntry: vi.fn().mockResolvedValue({ + register(command: Command, receivedRuntime: typeof runtime) { + command + .option('--port ') + .action(() => { + receivedRuntime.logger.info('dashboard started'); + }); + } + }), + warn: vi.fn(), + }); + + await program.parseAsync(['node', 'test', 'memory-dashboard', '--port', '3030']); + + expect(runtime.logger.info).toHaveBeenCalledWith('dashboard started'); + }); + + it('warns and continues when a configured plugin fails to load', async () => { + const program = new Command(); + const warn = vi.fn(); + + await registerConfiguredPluginCommands(program, {} as any, { + getPlugins: vi.fn().mockResolvedValue(['@ai-devkit/bad-plugin']), + loadPluginCommands: vi.fn().mockRejectedValue(new Error('missing manifest')), + importCommandEntry: vi.fn(), + warn, + }); + + expect(warn).toHaveBeenCalledWith('Failed to load plugin @ai-devkit/bad-plugin: missing manifest'); + }); + + it('leaves existing built-in commands available when a configured plugin fails to load', async () => { + const program = new Command(); + const builtInAction = vi.fn(); + program.command('memory').action(builtInAction); + + await registerConfiguredPluginCommands(program, {} as any, { + getPlugins: vi.fn().mockResolvedValue(['@ai-devkit/bad-plugin']), + loadPluginCommands: vi.fn().mockRejectedValue(new Error('missing manifest')), + importCommandEntry: vi.fn(), + warn: vi.fn(), + }); + + await program.parseAsync(['node', 'test', 'memory']); + + expect(builtInAction).toHaveBeenCalled(); + }); + + it('rejects plugin entrypoints that do not export register()', async () => { + const program = new Command(); + const loadedCommand: LoadedPluginCommand = { + pluginName: '@ai-devkit/bad-plugin', + command: { + name: 'bad-plugin', + entry: './dist/command.js', + entryPath: '/plugin/dist/command.js', + } + }; + + await expect(registerConfiguredPluginCommands(program, {} as any, { + getPlugins: vi.fn().mockResolvedValue(['@ai-devkit/bad-plugin']), + loadPluginCommands: vi.fn().mockResolvedValue([loadedCommand]), + importCommandEntry: vi.fn().mockResolvedValue({}), + warn: vi.fn(), + })).resolves.toBeUndefined(); + + expect(program.commands.find(command => command.name() === 'bad-plugin')).toBeUndefined(); + }); + + it('warns and skips plugin commands that conflict with already registered commands', async () => { + const program = new Command(); + program.command('memory').action(vi.fn()); + const warn = vi.fn(); + const importCommandEntry = vi.fn(); + const loadedCommand: LoadedPluginCommand = { + pluginName: '@ai-devkit/conflict-plugin', + command: { + name: 'memory', + entry: './dist/command.js', + entryPath: '/plugin/dist/command.js', + } + }; + + await registerConfiguredPluginCommands(program, {} as any, { + getPlugins: vi.fn().mockResolvedValue(['@ai-devkit/conflict-plugin']), + loadPluginCommands: vi.fn().mockResolvedValue([loadedCommand]), + importCommandEntry, + warn, + }); + + expect(warn).toHaveBeenCalledWith('Plugin @ai-devkit/conflict-plugin command "memory" conflicts with an already registered command.'); + expect(importCommandEntry).not.toHaveBeenCalled(); + expect(program.commands.filter(command => command.name() === 'memory')).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts b/packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts new file mode 100644 index 0000000..814fa4b --- /dev/null +++ b/packages/cli/src/__tests__/services/plugin/plugin-manager.service.test.ts @@ -0,0 +1,117 @@ +import { createPluginManager } from '../../../services/plugin/plugin-manager.service.js'; + +describe('plugin manager service', () => { + const packageService = () => ({ + ensureGlobalNpmProject: vi.fn().mockResolvedValue(undefined), + install: vi.fn().mockResolvedValue(undefined), + uninstall: vi.fn().mockResolvedValue(undefined), + }); + + const configManager = () => ({ + addPlugin: vi.fn().mockResolvedValue({ plugins: ['@ai-devkit/memory-dashboard'] }), + removePlugin: vi.fn().mockResolvedValue({ plugins: [] }), + getPlugins: vi.fn().mockResolvedValue(['@ai-devkit/memory-dashboard']), + }); + + it('installs, validates, and persists a plugin', async () => { + const packages = packageService(); + const config = configManager(); + const validateInstalledPlugin = vi.fn().mockResolvedValue(undefined); + const manager = createPluginManager({ packages, config, validateInstalledPlugin }); + + await manager.add(' @ai-devkit/memory-dashboard '); + + expect(packages.install).toHaveBeenCalledWith('@ai-devkit/memory-dashboard'); + expect(validateInstalledPlugin).toHaveBeenCalledWith('@ai-devkit/memory-dashboard'); + expect(config.addPlugin).toHaveBeenCalledWith('@ai-devkit/memory-dashboard'); + }); + + it('uninstalls and does not persist a plugin when validation fails after install', async () => { + const packages = packageService(); + const config = configManager(); + const validateInstalledPlugin = vi.fn().mockRejectedValue(new Error('missing manifest')); + const manager = createPluginManager({ packages, config, validateInstalledPlugin }); + + await expect(manager.add('@ai-devkit/bad-plugin')).rejects.toThrow('missing manifest'); + + expect(packages.install).toHaveBeenCalledWith('@ai-devkit/bad-plugin'); + expect(packages.uninstall).toHaveBeenCalledWith('@ai-devkit/bad-plugin'); + expect(config.addPlugin).not.toHaveBeenCalled(); + }); + + it('reports rollback failure details when validation and uninstall both fail', async () => { + const packages = packageService(); + packages.uninstall.mockRejectedValue(new Error('rollback failed')); + const config = configManager(); + const validateInstalledPlugin = vi.fn().mockRejectedValue(new Error('missing manifest')); + const manager = createPluginManager({ packages, config, validateInstalledPlugin }); + + await expect(manager.add('@ai-devkit/bad-plugin')).rejects.toThrow('missing manifest Rollback uninstall also failed: rollback failed'); + }); + + it('removes a plugin from npm and global config', async () => { + const packages = packageService(); + const config = configManager(); + const manager = createPluginManager({ + packages, + config, + validateInstalledPlugin: vi.fn(), + }); + + await manager.remove(' @ai-devkit/memory-dashboard '); + + expect(packages.uninstall).toHaveBeenCalledWith('@ai-devkit/memory-dashboard'); + expect(config.removePlugin).toHaveBeenCalledWith('@ai-devkit/memory-dashboard'); + }); + + it('rejects invalid package names before installing', async () => { + const packages = packageService(); + const config = configManager(); + const manager = createPluginManager({ + packages, + config, + validateInstalledPlugin: vi.fn(), + }); + + await expect(manager.add('@ai-devkit/memory-dashboard@1.0.0')).rejects.toThrow('Only npm package names are supported for plugins.'); + + expect(packages.install).not.toHaveBeenCalled(); + expect(config.addPlugin).not.toHaveBeenCalled(); + }); + + it('lists configured plugins with validation status', async () => { + const manager = createPluginManager({ + packages: packageService(), + config: configManager(), + validateInstalledPlugin: vi.fn().mockResolvedValue(undefined), + }); + + const result = await manager.list(); + + expect(result).toEqual([ + { + name: '@ai-devkit/memory-dashboard', + status: 'valid', + error: undefined, + } + ]); + }); + + it('lists invalid configured plugins with the validation error', async () => { + const manager = createPluginManager({ + packages: packageService(), + config: configManager(), + validateInstalledPlugin: vi.fn().mockRejectedValue(new Error('not installed')), + }); + + const result = await manager.list(); + + expect(result).toEqual([ + { + name: '@ai-devkit/memory-dashboard', + status: 'invalid', + error: 'not installed', + } + ]); + }); +}); diff --git a/packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts b/packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts new file mode 100644 index 0000000..39b41f3 --- /dev/null +++ b/packages/cli/src/__tests__/services/plugin/plugin-manifest.service.test.ts @@ -0,0 +1,106 @@ +import * as path from 'path'; +import { + validatePluginManifest, + resolvePluginCommandEntry, +} from '../../../services/plugin/plugin-manifest.service.js'; + +describe('plugin manifest service', () => { + describe('validatePluginManifest', () => { + it('accepts valid aiDevkit command manifests from package.json', () => { + const manifest = validatePluginManifest('@ai-devkit/memory-dashboard', { + aiDevkit: { + commands: [ + { + name: 'memory-dashboard', + description: 'Open the memory dashboard', + entry: './dist/command.js' + } + ] + } + }, new Set(['memory'])); + + expect(manifest.commands).toEqual([ + { + name: 'memory-dashboard', + description: 'Open the memory dashboard', + entry: './dist/command.js' + } + ]); + }); + + it('rejects packages without aiDevkit command manifests', () => { + expect(() => validatePluginManifest('@ai-devkit/bad-plugin', {}, new Set())) + .toThrow('Plugin @ai-devkit/bad-plugin must define package.json aiDevkit.commands.'); + }); + + it('rejects duplicate command names within the same plugin', () => { + expect(() => validatePluginManifest('@ai-devkit/bad-plugin', { + aiDevkit: { + commands: [ + { name: 'memory-dashboard', entry: './dist/one.js' }, + { name: 'memory-dashboard', entry: './dist/two.js' } + ] + } + }, new Set())).toThrow('Plugin @ai-devkit/bad-plugin declares duplicate command "memory-dashboard".'); + }); + + it('rejects command names that conflict with built-in commands', () => { + expect(() => validatePluginManifest('@ai-devkit/bad-plugin', { + aiDevkit: { + commands: [ + { name: 'memory', entry: './dist/command.js' } + ] + } + }, new Set(['memory']))).toThrow('Plugin @ai-devkit/bad-plugin command "memory" conflicts with a built-in command.'); + }); + + it('rejects command names that include Commander grammar', () => { + const invalidNames = [ + 'memory ', + 'memory --table', + 'memory [query]', + 'memory|dashboard', + 'MemoryDashboard', + '1-dashboard', + ]; + + for (const name of invalidNames) { + expect(() => validatePluginManifest('@ai-devkit/bad-plugin', { + aiDevkit: { + commands: [ + { name, entry: './dist/command.js' } + ] + } + }, new Set())).toThrow(`Plugin @ai-devkit/bad-plugin command "${name}" must use lowercase letters, numbers, and hyphens only, and start with a letter.`); + } + }); + + it('rejects non-JavaScript command entrypoints', () => { + expect(() => validatePluginManifest('@ai-devkit/bad-plugin', { + aiDevkit: { + commands: [ + { name: 'memory-dashboard', entry: './src/command.ts' } + ] + } + }, new Set())).toThrow('Plugin @ai-devkit/bad-plugin command "memory-dashboard" must point to a JavaScript entrypoint.'); + }); + }); + + describe('resolvePluginCommandEntry', () => { + it('resolves command entry paths inside the plugin package root', () => { + const entry = resolvePluginCommandEntry( + '/home/test/.ai-devkit/npm/node_modules/@ai-devkit/memory-dashboard', + './dist/command.js' + ); + + expect(entry).toBe(path.resolve('/home/test/.ai-devkit/npm/node_modules/@ai-devkit/memory-dashboard/dist/command.js')); + }); + + it('rejects command entry paths outside the plugin package root', () => { + expect(() => resolvePluginCommandEntry( + '/home/test/.ai-devkit/npm/node_modules/@ai-devkit/memory-dashboard', + '../other-plugin/dist/command.js' + )).toThrow('Plugin command entry must resolve inside the plugin package root.'); + }); + }); +}); diff --git a/packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts b/packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts new file mode 100644 index 0000000..6133041 --- /dev/null +++ b/packages/cli/src/__tests__/services/plugin/plugin-package.service.test.ts @@ -0,0 +1,150 @@ +import type { Mocked } from 'vitest'; +import fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import { + createPluginPackageService, + getGlobalPluginNpmRoot, +} from '../../../services/plugin/plugin-package.service.js'; + +vi.mock('fs-extra', async () => { const { makeFsExtraMock } = await import('../../__shared__/fs-extra-mock.js'); return makeFsExtraMock(); }); +vi.mock('os'); +vi.mock('path'); + +describe('plugin package service', () => { + let mockFs: Mocked; + let mockOs: Mocked; + let mockPath: Mocked; + let runNpm: ReturnType; + + beforeEach(() => { + mockFs = fs as Mocked; + mockOs = os as Mocked; + mockPath = path as Mocked; + runNpm = vi.fn().mockResolvedValue(undefined); + + mockOs.homedir.mockReturnValue('/home/test'); + mockPath.join.mockImplementation((...args) => args.join('/')); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('resolves the global plugin npm root under ~/.ai-devkit/npm', () => { + expect(getGlobalPluginNpmRoot()).toBe('/home/test/.ai-devkit/npm'); + }); + + it('creates package.json when preparing the global npm root for the first time', async () => { + (mockFs.pathExists as any).mockResolvedValue(false); + (mockFs.ensureDir as any).mockResolvedValue(undefined); + (mockFs.writeJson as any).mockResolvedValue(undefined); + + const service = createPluginPackageService({ runNpm }); + + await service.ensureGlobalNpmProject(); + + expect(mockFs.ensureDir).toHaveBeenCalledWith('/home/test/.ai-devkit/npm'); + expect(mockFs.writeJson).toHaveBeenCalledWith( + '/home/test/.ai-devkit/npm/package.json', + { + private: true, + type: 'module', + dependencies: {} + }, + { spaces: 2 } + ); + }); + + it('does not overwrite an existing package.json', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + + const service = createPluginPackageService({ runNpm }); + + await service.ensureGlobalNpmProject(); + + expect(mockFs.writeJson).not.toHaveBeenCalled(); + }); + + it('installs a plugin package with npm using argument arrays', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + + const service = createPluginPackageService({ runNpm }); + + await service.install('@ai-devkit/memory-dashboard'); + + expect(runNpm).toHaveBeenCalledWith(['install', '@ai-devkit/memory-dashboard'], { + cwd: '/home/test/.ai-devkit/npm' + }); + }); + + it('uninstalls a plugin package with npm using argument arrays', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + + const service = createPluginPackageService({ runNpm }); + + await service.uninstall('@ai-devkit/memory-dashboard'); + + expect(runNpm).toHaveBeenCalledWith(['uninstall', '@ai-devkit/memory-dashboard'], { + cwd: '/home/test/.ai-devkit/npm' + }); + }); + + it('wraps npm install failures with plugin context', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + runNpm.mockRejectedValue(new Error('network down')); + + const service = createPluginPackageService({ runNpm }); + + await expect(service.install('@ai-devkit/memory-dashboard')) + .rejects.toThrow('Failed to install plugin package @ai-devkit/memory-dashboard: network down'); + }); + + it('wraps npm uninstall failures with plugin context', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + runNpm.mockRejectedValue(new Error('permission denied')); + + const service = createPluginPackageService({ runNpm }); + + await expect(service.uninstall('@ai-devkit/memory-dashboard')) + .rejects.toThrow('Failed to uninstall plugin package @ai-devkit/memory-dashboard: permission denied'); + }); + + it('rejects empty package names before running npm', async () => { + const service = createPluginPackageService({ runNpm }); + + await expect(service.install(' ')).rejects.toThrow('Plugin package name must be a non-empty npm package name.'); + + expect(runNpm).not.toHaveBeenCalled(); + }); + + it('rejects package names that look like local paths before running npm', async () => { + const service = createPluginPackageService({ runNpm }); + + await expect(service.install('./memory-dashboard')).rejects.toThrow('Only npm package names are supported for plugins.'); + + expect(runNpm).not.toHaveBeenCalled(); + }); + + it('rejects package specs with explicit versions before running npm', async () => { + const service = createPluginPackageService({ runNpm }); + + await expect(service.install('@ai-devkit/memory-dashboard@1.0.0')) + .rejects.toThrow('Only npm package names are supported for plugins.'); + await expect(service.install('memory-dashboard@1.0.0')) + .rejects.toThrow('Only npm package names are supported for plugins.'); + + expect(runNpm).not.toHaveBeenCalled(); + }); + + it('rejects package names with unsafe path segments before running npm', async () => { + const service = createPluginPackageService({ runNpm }); + + await expect(service.install('@ai-devkit/../memory-dashboard')) + .rejects.toThrow('Only npm package names are supported for plugins.'); + await expect(service.install('ai-devkit/memory-dashboard')) + .rejects.toThrow('Only npm package names are supported for plugins.'); + + expect(runNpm).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/__tests__/services/plugin/runtime.test.ts b/packages/cli/src/__tests__/services/plugin/runtime.test.ts new file mode 100644 index 0000000..5c0ee62 --- /dev/null +++ b/packages/cli/src/__tests__/services/plugin/runtime.test.ts @@ -0,0 +1,77 @@ +import type { Mocked } from 'vitest'; +import fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import { createAiDevkitRuntime } from '../../../services/plugin/runtime.js'; + +vi.mock('fs-extra', async () => { const { makeFsExtraMock } = await import('../../__shared__/fs-extra-mock.js'); return makeFsExtraMock(); }); +vi.mock('os'); +vi.mock('path'); + +vi.mock('../../../util/terminal-ui.js', () => ({ + ui: { + info: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + }, +})); + +describe('plugin runtime', () => { + let mockFs: Mocked; + let mockOs: Mocked; + let mockPath: Mocked; + + beforeEach(() => { + mockFs = fs as Mocked; + mockOs = os as Mocked; + mockPath = path as Mocked; + + mockOs.homedir.mockReturnValue('/home/test'); + mockPath.join.mockImplementation((...args) => args.join('/')); + mockPath.dirname.mockImplementation((input: string) => input.split('/').slice(0, -1).join('/') || '/'); + mockPath.resolve.mockImplementation((...args) => args.join('/').replace(/\/+/g, '/')); + mockPath.isAbsolute.mockImplementation((input: string) => input.startsWith('/')); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('provides cwd, homeDir, configPath, and global config access', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + plugins: ['@ai-devkit/memory-dashboard'] + }); + + const runtime = createAiDevkitRuntime({ cwd: '/project' }); + + expect(runtime.cwd).toBe('/project'); + expect(runtime.homeDir).toBe('/home/test'); + expect(runtime.configPath).toBe('/home/test/.ai-devkit/.ai-devkit.json'); + await expect(runtime.getConfig()).resolves.toEqual({ + plugins: ['@ai-devkit/memory-dashboard'] + }); + }); + + it('resolves relative memory db paths from the global config directory', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({ + memory: { + path: 'memory.db' + } + }); + + const runtime = createAiDevkitRuntime({ cwd: '/project' }); + + await expect(runtime.getMemoryDbPath()).resolves.toBe('/home/test/.ai-devkit/memory.db'); + }); + + it('returns the default memory db path when memory db path is not configured', async () => { + (mockFs.pathExists as any).mockResolvedValue(true); + (mockFs.readJson as any).mockResolvedValue({}); + + const runtime = createAiDevkitRuntime({ cwd: '/project' }); + + await expect(runtime.getMemoryDbPath()).resolves.toBe('/home/test/.ai-devkit/memory.db'); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b73dc68..3e8d673 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -11,6 +11,9 @@ import { registerSkillCommand } from './commands/skill.js'; import { registerAgentCommand } from './commands/agent.js'; import { registerChannelCommand } from './commands/channel.js'; import { registerDocsCommand } from './commands/docs.js'; +import { registerPluginCommand } from './commands/plugin.js'; +import { registerConfiguredPluginCommands } from './services/plugin/plugin-loader.service.js'; +import { createAiDevkitRuntime } from './services/plugin/runtime.js'; import { handleCliError } from './util/errors.js'; import pkg from '../package.json' with { type: 'json' }; const { version } = pkg as { version: string }; @@ -65,6 +68,9 @@ registerSkillCommand(program); registerAgentCommand(program); registerChannelCommand(program); registerDocsCommand(program); +registerPluginCommand(program); + +await registerConfiguredPluginCommands(program, createAiDevkitRuntime()); try { await program.parseAsync(); diff --git a/packages/cli/src/commands/plugin.ts b/packages/cli/src/commands/plugin.ts new file mode 100644 index 0000000..16a9719 --- /dev/null +++ b/packages/cli/src/commands/plugin.ts @@ -0,0 +1,48 @@ +import type { Command } from 'commander'; +import { createPluginManager } from '../services/plugin/plugin-manager.service.js'; +import { ui } from '../util/terminal-ui.js'; +import { withErrorHandler } from '../util/errors.js'; + +export function registerPluginCommand(program: Command): void { + const manager = createPluginManager(); + const pluginCommand = program + .command('plugin') + .description('Manage global AI DevKit plugins'); + + pluginCommand + .command('add ') + .description('Install and enable a global npm plugin') + .action(withErrorHandler('add plugin', async (pluginPackage: string) => { + await manager.add(pluginPackage); + ui.success(`Plugin added: ${pluginPackage}`); + })); + + pluginCommand + .command('remove ') + .description('Uninstall and disable a global npm plugin') + .action(withErrorHandler('remove plugin', async (pluginPackage: string) => { + await manager.remove(pluginPackage); + ui.success(`Plugin removed: ${pluginPackage}`); + })); + + pluginCommand + .command('list') + .description('List configured global plugins') + .action(withErrorHandler('list plugins', async () => { + const plugins = await manager.list(); + + if (plugins.length === 0) { + ui.warning('No global plugins configured.'); + return; + } + + ui.table({ + headers: ['package', 'status', 'error'], + rows: plugins.map(plugin => [ + plugin.name, + plugin.status, + plugin.error ?? '' + ]) + }); + })); +} diff --git a/packages/cli/src/lib/GlobalConfig.ts b/packages/cli/src/lib/GlobalConfig.ts index 02fd3f3..8eda13d 100644 --- a/packages/cli/src/lib/GlobalConfig.ts +++ b/packages/cli/src/lib/GlobalConfig.ts @@ -29,7 +29,53 @@ export class GlobalConfigManager { return filterStringRecord(config?.registries); } + async getPlugins(): Promise { + const config = await this.read(); + return normalizePlugins(config?.plugins); + } + + async addPlugin(pluginName: string): Promise { + const config = await this.read() ?? {}; + const plugins = normalizePlugins(config.plugins); + + if (!plugins.includes(pluginName)) { + plugins.push(pluginName); + } + + return this.write({ + ...config, + plugins + }); + } + + async removePlugin(pluginName: string): Promise { + const config = await this.read() ?? {}; + const plugins = normalizePlugins(config.plugins).filter(plugin => plugin !== pluginName); + + return this.write({ + ...config, + plugins + }); + } + private getGlobalConfigPath(): string { return path.join(os.homedir(), '.ai-devkit', '.ai-devkit.json'); } + + private async write(config: GlobalDevKitConfig): Promise { + await fs.ensureDir(path.dirname(this.getGlobalConfigPath())); + await fs.writeJson(this.getGlobalConfigPath(), config, { spaces: 2 }); + return config; + } +} + +function normalizePlugins(rawPlugins: unknown): string[] { + if (!Array.isArray(rawPlugins)) { + return []; + } + + return [...new Set(rawPlugins + .filter((plugin): plugin is string => typeof plugin === 'string') + .map(plugin => plugin.trim()) + .filter(plugin => plugin.length > 0))]; } diff --git a/packages/cli/src/services/plugin/plugin-loader.service.ts b/packages/cli/src/services/plugin/plugin-loader.service.ts new file mode 100644 index 0000000..4db57ca --- /dev/null +++ b/packages/cli/src/services/plugin/plugin-loader.service.ts @@ -0,0 +1,139 @@ +import fs from 'fs-extra'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; +import type { Command } from 'commander'; +import { GlobalConfigManager } from '../../lib/GlobalConfig.js'; +import { ValidationError } from '../../util/errors.js'; +import { getErrorMessage } from '../../util/text.js'; +import { ui } from '../../util/terminal-ui.js'; +import { getGlobalPluginNpmRoot, validatePluginPackageName } from './plugin-package.service.js'; +import { + resolvePluginCommandEntry, + validatePluginManifest, + type AiDevkitPluginCommand, +} from './plugin-manifest.service.js'; + +export const BUILT_IN_COMMAND_NAMES = new Set([ + 'init', + 'phase', + 'setup', + 'lint', + 'install', + 'memory', + 'skill', + 'agent', + 'channel', + 'docs', + 'plugin' +]); + +export async function validateInstalledPluginManifest(pluginName: string): Promise { + await loadInstalledPluginCommands(pluginName); +} + +export interface LoadedPluginCommand { + pluginName: string; + command: AiDevkitPluginCommand & { + entryPath: string; + }; +} + +export interface AiDevkitRuntime { + cwd: string; + homeDir: string; + configPath: string; + getConfig(): Promise; + getMemoryDbPath(): Promise; + logger: { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + }; +} + +interface PluginCommandEntryModule { + register?: (command: Command, runtime: AiDevkitRuntime) => void | Promise; +} + +interface RegisterConfiguredPluginCommandsDeps { + getPlugins?: () => Promise; + loadPluginCommands?: (pluginName: string) => Promise; + importCommandEntry?: (entryPath: string) => Promise; + warn?: (message: string) => void; +} + +export async function registerConfiguredPluginCommands( + program: Command, + runtime: AiDevkitRuntime, + deps: RegisterConfiguredPluginCommandsDeps = {} +): Promise { + const globalConfig = new GlobalConfigManager(); + const getPlugins = deps.getPlugins ?? (() => globalConfig.getPlugins()); + const loadPluginCommands = deps.loadPluginCommands ?? loadInstalledPluginCommands; + const importCommandEntry = deps.importCommandEntry ?? defaultImportCommandEntry; + const warn = deps.warn ?? ((message: string) => ui.warning(message)); + const plugins = await getPlugins(); + const registeredCommandNames = new Set(program.commands.map(command => command.name())); + + for (const pluginName of plugins) { + try { + const commands = await loadPluginCommands(pluginName); + + for (const loadedCommand of commands) { + if (registeredCommandNames.has(loadedCommand.command.name)) { + warn(`Plugin ${pluginName} command "${loadedCommand.command.name}" conflicts with an already registered command.`); + continue; + } + + const entryModule = await importCommandEntry(loadedCommand.command.entryPath); + if (typeof entryModule.register !== 'function') { + warn(`Plugin ${pluginName} command "${loadedCommand.command.name}" entrypoint must export register().`); + continue; + } + + const command = program + .command(loadedCommand.command.name) + .description(loadedCommand.command.description ?? `Plugin command from ${pluginName}`); + + await entryModule.register(command, runtime); + registeredCommandNames.add(loadedCommand.command.name); + } + } catch (error) { + warn(`Failed to load plugin ${pluginName}: ${getErrorMessage(error)}`); + } + } +} + +export async function loadInstalledPluginCommands(pluginName: string): Promise { + const packageJsonPath = getInstalledPluginPackageJsonPath(pluginName); + const packageJson = await fs.readJson(packageJsonPath) as unknown; + const pluginRoot = path.dirname(packageJsonPath); + const manifest = validatePluginManifest(pluginName, packageJson, BUILT_IN_COMMAND_NAMES); + const commands: LoadedPluginCommand[] = []; + + for (const command of manifest.commands) { + const entryPath = resolvePluginCommandEntry(pluginRoot, command.entry); + if (!await fs.pathExists(entryPath)) { + throw new ValidationError(`Plugin ${pluginName} command "${command.name}" entrypoint does not exist: ${command.entry}`); + } + + commands.push({ + pluginName, + command: { + ...command, + entryPath + } + }); + } + + return commands; +} + +export function getInstalledPluginPackageJsonPath(pluginName: string): string { + const normalizedName = validatePluginPackageName(pluginName); + return path.join(getGlobalPluginNpmRoot(), 'node_modules', normalizedName, 'package.json'); +} + +async function defaultImportCommandEntry(entryPath: string): Promise { + return import(pathToFileURL(entryPath).href) as Promise; +} diff --git a/packages/cli/src/services/plugin/plugin-manager.service.ts b/packages/cli/src/services/plugin/plugin-manager.service.ts new file mode 100644 index 0000000..c600d51 --- /dev/null +++ b/packages/cli/src/services/plugin/plugin-manager.service.ts @@ -0,0 +1,93 @@ +import { GlobalConfigManager } from '../../lib/GlobalConfig.js'; +import { getErrorMessage } from '../../util/text.js'; +import { + createPluginPackageService, + type PluginPackageService, + validatePluginPackageName, +} from './plugin-package.service.js'; +import { validateInstalledPluginManifest } from './plugin-loader.service.js'; + +export interface PluginListItem { + name: string; + status: 'valid' | 'invalid'; + error?: string; +} + +interface PluginConfigStore { + addPlugin(pluginName: string): Promise; + removePlugin(pluginName: string): Promise; + getPlugins(): Promise; +} + +interface PluginManagerDeps { + packages?: PluginPackageService; + config?: PluginConfigStore; + validateInstalledPlugin?: (pluginName: string) => Promise; +} + +export function createPluginManager(deps: PluginManagerDeps = {}) { + const packages = deps.packages ?? createPluginPackageService(); + const config = deps.config ?? new GlobalConfigManager(); + const validateInstalledPlugin = deps.validateInstalledPlugin ?? validateInstalledPluginManifest; + + return { + async add(pluginName: string): Promise { + const normalizedName = validatePluginPackageName(pluginName); + + await packages.install(normalizedName); + + try { + await validateInstalledPlugin(normalizedName); + } catch (error) { + await rollbackInstall(packages, normalizedName, error); + } + + await config.addPlugin(normalizedName); + }, + + async remove(pluginName: string): Promise { + const normalizedName = validatePluginPackageName(pluginName); + + await packages.uninstall(normalizedName); + await config.removePlugin(normalizedName); + }, + + async list(): Promise { + const plugins = await config.getPlugins(); + const items: PluginListItem[] = []; + + for (const plugin of plugins) { + try { + await validateInstalledPlugin(plugin); + items.push({ + name: plugin, + status: 'valid', + error: undefined + }); + } catch (error) { + items.push({ + name: plugin, + status: 'invalid', + error: getErrorMessage(error) + }); + } + } + + return items; + } + }; +} + +async function rollbackInstall( + packages: PluginPackageService, + pluginName: string, + validationError: unknown +): Promise { + try { + await packages.uninstall(pluginName); + } catch (rollbackError) { + throw new Error(`${getErrorMessage(validationError)} Rollback uninstall also failed: ${getErrorMessage(rollbackError)}`); + } + + throw validationError; +} diff --git a/packages/cli/src/services/plugin/plugin-manifest.service.ts b/packages/cli/src/services/plugin/plugin-manifest.service.ts new file mode 100644 index 0000000..60f3678 --- /dev/null +++ b/packages/cli/src/services/plugin/plugin-manifest.service.ts @@ -0,0 +1,106 @@ +import * as path from 'path'; +import { ValidationError } from '../../util/errors.js'; + +export interface AiDevkitPluginManifest { + commands: AiDevkitPluginCommand[]; +} + +export interface AiDevkitPluginCommand { + name: string; + description?: string; + entry: string; +} + +const JAVASCRIPT_ENTRY_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']); +const COMMAND_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; + +export function validatePluginManifest( + pluginName: string, + packageJson: unknown, + builtInCommandNames: Set +): AiDevkitPluginManifest { + const commands = readCommands(pluginName, packageJson); + const seenCommands = new Set(); + + for (const command of commands) { + if (seenCommands.has(command.name)) { + throw new ValidationError(`Plugin ${pluginName} declares duplicate command "${command.name}".`); + } + + seenCommands.add(command.name); + + if (builtInCommandNames.has(command.name)) { + throw new ValidationError(`Plugin ${pluginName} command "${command.name}" conflicts with a built-in command.`); + } + + if (!JAVASCRIPT_ENTRY_EXTENSIONS.has(path.extname(command.entry))) { + throw new ValidationError(`Plugin ${pluginName} command "${command.name}" must point to a JavaScript entrypoint.`); + } + } + + return { commands }; +} + +export function resolvePluginCommandEntry(pluginRoot: string, entry: string): string { + const resolvedEntry = path.resolve(pluginRoot, entry); + const relativeEntry = path.relative(pluginRoot, resolvedEntry); + + if (relativeEntry.startsWith('..') || path.isAbsolute(relativeEntry)) { + throw new ValidationError('Plugin command entry must resolve inside the plugin package root.'); + } + + return resolvedEntry; +} + +function readCommands(pluginName: string, packageJson: unknown): AiDevkitPluginCommand[] { + if (!packageJson || typeof packageJson !== 'object') { + throw missingManifestError(pluginName); + } + + const aiDevkit = (packageJson as { aiDevkit?: unknown }).aiDevkit; + if (!aiDevkit || typeof aiDevkit !== 'object') { + throw missingManifestError(pluginName); + } + + const commands = (aiDevkit as { commands?: unknown }).commands; + if (!Array.isArray(commands) || commands.length === 0) { + throw missingManifestError(pluginName); + } + + return commands.map((command, index) => normalizeCommand(pluginName, command, index)); +} + +function normalizeCommand(pluginName: string, command: unknown, index: number): AiDevkitPluginCommand { + if (!command || typeof command !== 'object') { + throw new ValidationError(`Plugin ${pluginName} command at index ${index} must be an object.`); + } + + const rawCommand = command as Record; + const name = typeof rawCommand.name === 'string' ? rawCommand.name.trim() : ''; + const entry = typeof rawCommand.entry === 'string' ? rawCommand.entry.trim() : ''; + const description = typeof rawCommand.description === 'string' + ? rawCommand.description.trim() + : undefined; + + if (!name) { + throw new ValidationError(`Plugin ${pluginName} command at index ${index} must define a name.`); + } + + if (!COMMAND_NAME_PATTERN.test(name)) { + throw new ValidationError(`Plugin ${pluginName} command "${name}" must use lowercase letters, numbers, and hyphens only, and start with a letter.`); + } + + if (!entry) { + throw new ValidationError(`Plugin ${pluginName} command "${name}" must define an entry.`); + } + + return { + name, + ...(description ? { description } : {}), + entry + }; +} + +function missingManifestError(pluginName: string): ValidationError { + return new ValidationError(`Plugin ${pluginName} must define package.json aiDevkit.commands.`); +} diff --git a/packages/cli/src/services/plugin/plugin-package.service.ts b/packages/cli/src/services/plugin/plugin-package.service.ts new file mode 100644 index 0000000..22bf0e9 --- /dev/null +++ b/packages/cli/src/services/plugin/plugin-package.service.ts @@ -0,0 +1,98 @@ +import { execFile } from 'child_process'; +import fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import { promisify } from 'util'; +import { ValidationError } from '../../util/errors.js'; +import { getErrorMessage } from '../../util/text.js'; + +const execFileAsync = promisify(execFile); +const NPM_PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i; + +export interface NpmRunOptions { + cwd: string; +} + +export type NpmRunner = (args: string[], options: NpmRunOptions) => Promise; + +export interface PluginPackageService { + ensureGlobalNpmProject(): Promise; + install(pluginName: string): Promise; + uninstall(pluginName: string): Promise; +} + +interface PluginPackageServiceDeps { + runNpm?: NpmRunner; +} + +export function getGlobalPluginNpmRoot(): string { + return path.join(os.homedir(), '.ai-devkit', 'npm'); +} + +export function createPluginPackageService(deps: PluginPackageServiceDeps = {}): PluginPackageService { + const runNpm = deps.runNpm ?? defaultRunNpm; + + return { + async ensureGlobalNpmProject(): Promise { + const npmRoot = getGlobalPluginNpmRoot(); + const packageJsonPath = path.join(npmRoot, 'package.json'); + + await fs.ensureDir(npmRoot); + + if (await fs.pathExists(packageJsonPath)) { + return; + } + + await fs.writeJson(packageJsonPath, { + private: true, + type: 'module', + dependencies: {} + }, { spaces: 2 }); + }, + + async install(pluginName: string): Promise { + const normalizedName = validatePluginPackageName(pluginName); + await this.ensureGlobalNpmProject(); + try { + await runNpm(['install', normalizedName], { cwd: getGlobalPluginNpmRoot() }); + } catch (error) { + throw new Error(`Failed to install plugin package ${normalizedName}: ${getErrorMessage(error)}`); + } + }, + + async uninstall(pluginName: string): Promise { + const normalizedName = validatePluginPackageName(pluginName); + await this.ensureGlobalNpmProject(); + try { + await runNpm(['uninstall', normalizedName], { cwd: getGlobalPluginNpmRoot() }); + } catch (error) { + throw new Error(`Failed to uninstall plugin package ${normalizedName}: ${getErrorMessage(error)}`); + } + } + }; +} + +export function validatePluginPackageName(pluginName: string): string { + const normalizedName = pluginName.trim(); + + if (!normalizedName) { + throw new ValidationError('Plugin package name must be a non-empty npm package name.'); + } + + if ( + normalizedName.startsWith('.') + || normalizedName.startsWith('/') + || normalizedName.includes('\\') + || !NPM_PACKAGE_NAME_PATTERN.test(normalizedName) + ) { + throw new ValidationError('Only npm package names are supported for plugins.'); + } + + return normalizedName; +} + +async function defaultRunNpm(args: string[], options: NpmRunOptions): Promise { + await execFileAsync('npm', args, { + cwd: options.cwd + }); +} diff --git a/packages/cli/src/services/plugin/runtime.ts b/packages/cli/src/services/plugin/runtime.ts new file mode 100644 index 0000000..a050626 --- /dev/null +++ b/packages/cli/src/services/plugin/runtime.ts @@ -0,0 +1,57 @@ +import * as os from 'os'; +import * as path from 'path'; +import { GlobalConfigManager } from '../../lib/GlobalConfig.js'; +import { ui } from '../../util/terminal-ui.js'; +import type { GlobalDevKitConfig } from '../../types.js'; +import type { AiDevkitRuntime } from './plugin-loader.service.js'; + +interface CreateRuntimeOptions { + cwd?: string; + configManager?: GlobalConfigManager; +} + +export function createAiDevkitRuntime(options: CreateRuntimeOptions = {}): AiDevkitRuntime { + const homeDir = os.homedir(); + const defaultMemoryDbPath = getDefaultMemoryDbPath(homeDir); + const configPath = path.join(homeDir, '.ai-devkit', '.ai-devkit.json'); + const configManager = options.configManager ?? new GlobalConfigManager(); + + return { + cwd: options.cwd ?? process.cwd(), + homeDir, + configPath, + async getConfig(): Promise { + return await configManager.read() ?? {}; + }, + async getMemoryDbPath(): Promise { + const config = await configManager.read(); + const configuredPath = config?.memory?.path; + + if (typeof configuredPath !== 'string' || configuredPath.trim().length === 0) { + return defaultMemoryDbPath; + } + + const trimmedPath = configuredPath.trim(); + if (path.isAbsolute(trimmedPath)) { + return trimmedPath; + } + + return path.resolve(path.dirname(configPath), trimmedPath); + }, + logger: { + info(message: string): void { + ui.info(message); + }, + warn(message: string): void { + ui.warning(message); + }, + error(message: string): void { + ui.error(message); + } + } + }; +} + +function getDefaultMemoryDbPath(homeDir: string): string { + return path.join(homeDir, '.ai-devkit', 'memory.db'); +} diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 543f4c2..ed99b95 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -62,6 +62,10 @@ export interface McpServerDefinition { export interface GlobalDevKitConfig { registries?: Record; + plugins?: string[]; + memory?: { + path?: string; + }; } export interface PhaseMetadata { diff --git a/web/content/docs/0-what-is-ai-devkit.md b/web/content/docs/0-what-is-ai-devkit.md index 7977633..55542be 100644 --- a/web/content/docs/0-what-is-ai-devkit.md +++ b/web/content/docs/0-what-is-ai-devkit.md @@ -26,6 +26,7 @@ AI DevKit is evolving toward an operating system model for AI-driven development - **Standard interfaces** for commands, skills, memory, and docs across agents - **Stateful development context** through phase docs and long-term memory - **Composable capabilities** via built-in and community skills +- **CLI extensions** through global npm plugins that add optional commands - **Operational controls** like lint checks, worktree workflows, and agent management As teams move from single assistant chats to multi-agent workflows, AI DevKit keeps the process, memory, and verification rules consistent across every agent. This means teams can run the same workflow regardless of which AI coding assistant they use: one config, all agents. @@ -86,6 +87,7 @@ Each step produces documentation in `docs/ai/` that gives your AI full context f 2. **Develop** - Use slash commands like `/new-requirement` and `/execute-plan` inside your AI editor so the agent follows the workflow instead of improvising in chat. 3. **Remember** - Store important decisions and patterns in memory so they persist across sessions. 4. **Extend** - Install skills to give your AI specialized knowledge for your stack and domain. +5. **Add tools** - Install plugins when you want optional CLI commands such as dashboards or heavier integrations. ## Who Is It For? @@ -99,3 +101,4 @@ Each step produces documentation in `docs/ai/` that gives your AI full context f - **[Supported Agents](/docs/2-supported-agents)** - See which AI tools are supported - **[Development with AI DevKit](/docs/3-development-with-ai-devkit)** - Learn the full development workflow - **[Memory](/docs/6-memory)** - Give your AI long-term memory +- **[Plugins](/docs/14-plugins)** - Add optional npm-powered CLI commands diff --git a/web/content/docs/1-getting-started.md b/web/content/docs/1-getting-started.md index 125fd73..1f05f7d 100644 --- a/web/content/docs/1-getting-started.md +++ b/web/content/docs/1-getting-started.md @@ -22,6 +22,7 @@ AI DevKit solves these problems by giving your AI assistant: - **Custom commands** — Reusable prompts tailored to your project - **Long-term memory** — Rules and patterns that persist across sessions - **Skills** — Community-contributed capabilities your AI can learn +- **Plugins** — Optional npm packages that add CLI commands - **Verification gates** — Fresh evidence before completion claims ## Prerequisites @@ -123,6 +124,7 @@ Here's how a typical workflow might look: 2. **Read the workflows guide** — [Development with AI DevKit](/docs/3-development-with-ai-devkit) 3. **Set up memory** — [Give your AI long-term memory](/docs/6-memory) 4. **Install skills** — [Extend your AI's capabilities](/docs/7-skills) +5. **Install plugins** — [Add optional CLI commands](/docs/14-plugins) ## Need Help? diff --git a/web/content/docs/11-configuration-file.md b/web/content/docs/11-configuration-file.md index 4e7556b..8df0a04 100644 --- a/web/content/docs/11-configuration-file.md +++ b/web/content/docs/11-configuration-file.md @@ -235,10 +235,14 @@ Every server definition requires a `transport` field set to `stdio`, `http`, or ## Global Config (`~/.ai-devkit/.ai-devkit.json`) -The global config file has a smaller scope than the project config. It only supports skill registries: +The global config file stores settings that apply across projects on your machine. ```json { + "plugins": ["@ai-devkit/memory-dashboard"], + "memory": { + "path": "memory.db" + }, "registries": { "codeaholicguy/ai-devkit": "https://github.com/codeaholicguy/ai-devkit.git", "my-org/custom-skills": "https://github.com/my-org/custom-skills.git" @@ -246,11 +250,39 @@ The global config file has a smaller scope than the project config. It only supp } ``` +### `plugins` + +- **Type:** array of npm package names +- **Optional** + +Global AI DevKit plugins enabled for every invocation of the CLI. Use `ai-devkit plugin add` and `ai-devkit plugin remove` to update this field. + +Plugin packages are installed under `~/.ai-devkit/npm/node_modules`, not beside the AI DevKit binary. The first plugin system supports npm package names only; local paths, tarballs, git URLs, and version specs are not accepted by `plugin add`. + +See [Plugins](/docs/14-plugins) for install commands, troubleshooting, and the plugin authoring contract. + +### `memory` + +- **Type:** object +- **Optional** + +Global memory settings used by plugin runtime APIs. + +| Sub-field | Type | Default | Description | +|-----------|------|---------|-------------| +| `path` | `string` | `memory.db` | Path exposed by `runtime.getMemoryDbPath()` for plugins. Absolute paths are used as-is. Relative paths resolve from `~/.ai-devkit`, the directory containing the global config file. | + +If `memory.path` is not set in the global config, plugin runtime calls return `~/.ai-devkit/memory.db`. + +Project-level `memory.path` still controls `ai-devkit memory *` commands inside a project. + +### `registries` + Use the global config when you want the same custom skill registries available in every project on your machine without copying the same registry entries into each repository. Global registries are merged with any project-level registries. If the same registry ID exists in both, the project-level entry takes priority. -The global config does **not** support `environments`, `phases`, `paths`, `memory`, `skills`, or `mcpServers`. +The global config does **not** support project fields such as `environments`, `phases`, `paths`, `skills`, or `mcpServers`. ## Which Commands Use the Config @@ -262,6 +294,9 @@ The global config does **not** support `environments`, `phases`, `paths`, `memor | `ai-devkit skill add` | No | Yes (adds skill) | Yes | | `ai-devkit skill remove` | No | Yes (removes skill) | Yes | | `ai-devkit skill update` | No | Yes | Yes | +| `ai-devkit plugin add` | Yes (global) | Yes (global) | Yes (global) | +| `ai-devkit plugin remove` | No | Yes (global) | Yes (global) | +| `ai-devkit plugin list` | No | No | Yes (global) | | `ai-devkit memory *` | No | No | Yes (reads `memory.path`) | | `ai-devkit lint` | No | No | Yes | @@ -270,3 +305,4 @@ The global config does **not** support `environments`, `phases`, `paths`, `memor - [Agent Setup](/docs/agent-setup) — how `init` and `install` use this config - [Skills](/docs/skills) — managing skills and registries - [Memory](/docs/memory) — configuring the memory database path +- [Plugins](/docs/14-plugins) — installing and authoring global npm plugins diff --git a/web/content/docs/14-plugins.md b/web/content/docs/14-plugins.md new file mode 100644 index 0000000..17487b1 --- /dev/null +++ b/web/content/docs/14-plugins.md @@ -0,0 +1,302 @@ +--- +title: Plugins +description: Install global AI DevKit plugins from npm and author packages that add CLI commands. +slug: plugins +order: 14 +--- + +AI DevKit plugins are npm packages that add commands to the `ai-devkit` CLI. + +Use plugins for capabilities that should live outside the core CLI, such as dashboards, visual tools, project-specific command sets, or heavier integrations. The first plugin system supports global npm plugins only. + +> **Important:** Installing a plugin means installing and running npm package code on your machine. Install plugins only from sources you trust. + +## How Plugins Work + +AI DevKit keeps plugins in a dedicated npm workspace under your home directory: + +```text +~/.ai-devkit/npm +└── node_modules/ + └── / +``` + +Enabled plugins are listed in the global AI DevKit config: + +```json +{ + "plugins": ["@ai-devkit/memory-dashboard"] +} +``` + +On startup, AI DevKit: + +1. Reads `~/.ai-devkit/.ai-devkit.json`. +2. Loads each package listed in `plugins`. +3. Reads the package's `package.json` plugin manifest. +4. Validates the contributed command names and JavaScript entrypoints. +5. Registers the plugin commands with the CLI. + +Because plugins live under `~/.ai-devkit/npm`, they work the same way whether you run AI DevKit with `npx ai-devkit`, a globally installed `ai-devkit`, or a local binary. + +## Install a Plugin + +Use `ai-devkit plugin add` with an npm package name: + +```bash +ai-devkit plugin add @ai-devkit/memory-dashboard +``` + +The command: + +1. Creates `~/.ai-devkit/npm/package.json` if needed. +2. Runs `npm install ` inside `~/.ai-devkit/npm`. +3. Validates the plugin manifest. +4. Adds the package name to `~/.ai-devkit/.ai-devkit.json`. + +If the package installs but does not contain a valid plugin manifest, AI DevKit uninstalls it and does not add it to the global config. + +Only npm package names are supported in this version. Local paths, tarballs, git URLs, and version specs are not accepted by `plugin add`. + +## Run a Plugin Command + +After installation, run the command contributed by the plugin: + +```bash +ai-devkit memory-dashboard +``` + +Use the plugin package documentation to know which command names and flags it provides. + +## List Plugins + +```bash +ai-devkit plugin list +``` + +The list shows each configured package and whether its manifest is valid. + +## Remove a Plugin + +```bash +ai-devkit plugin remove @ai-devkit/memory-dashboard +``` + +This uninstalls the package from `~/.ai-devkit/npm` and removes the package name from `~/.ai-devkit/.ai-devkit.json`. + +## Plugin Manifest + +A plugin package declares commands in its `package.json` with `aiDevkit.commands`: + +```json +{ + "name": "@ai-devkit/memory-dashboard", + "version": "0.1.0", + "type": "module", + "aiDevkit": { + "commands": [ + { + "name": "memory-dashboard", + "description": "Open the AI DevKit memory dashboard", + "entry": "./dist/command.js" + } + ] + } +} +``` + +Each command has: + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | CLI command name contributed by the plugin. Use lowercase letters, numbers, and hyphens. It must start with a letter. | +| `description` | No | Short help text shown by the CLI. | +| `entry` | Yes | Path to a built JavaScript file inside the package. Supported extensions are `.js`, `.mjs`, and `.cjs`. | + +Command names cannot conflict with built-in AI DevKit commands such as `memory`, `skill`, `agent`, `docs`, or `plugin`. + +Command entries must point inside the plugin package. AI DevKit rejects entries that resolve outside the package root or point to missing files. + +## Command Entrypoint + +Each command entrypoint exports a `register` function: + +```js +export async function register(command, runtime) { + command + .description('Open the AI DevKit memory dashboard') + .option('--port ', 'Port to bind') + .action(async options => { + const memoryDbPath = await runtime.getMemoryDbPath(); + runtime.logger.info(`Starting dashboard for ${memoryDbPath}`); + }); +} +``` + +The `command` argument is a Commander command instance for the command declared in the manifest. Add options, arguments, and an action inside `register`. + +The `runtime` argument gives the plugin access to stable AI DevKit context: + +| Runtime field | Description | +|---------------|-------------| +| `cwd` | Current working directory where the user ran `ai-devkit`. | +| `homeDir` | User home directory. | +| `configPath` | Path to `~/.ai-devkit/.ai-devkit.json`. | +| `getConfig()` | Reads the global AI DevKit config. | +| `getMemoryDbPath()` | Returns global config `memory.path`, or `~/.ai-devkit/memory.db` when not configured. | +| `logger.info()` | Print an informational message using AI DevKit output formatting. | +| `logger.warn()` | Print a warning message using AI DevKit output formatting. | +| `logger.error()` | Print an error message using AI DevKit output formatting. | + +## Develop a Plugin + +Create a normal npm package and point the manifest to built JavaScript: + +```text +my-plugin/ +├── package.json +├── src/ +│ └── command.ts +└── dist/ + └── command.js +``` + +TypeScript is fine for development, but AI DevKit loads JavaScript at runtime. Build the plugin before publishing and set `aiDevkit.commands[].entry` to the compiled file. + +Minimal package example: + +```json +{ + "name": "@my-org/hello-ai-devkit", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./dist/command.js" + }, + "aiDevkit": { + "commands": [ + { + "name": "hello-devkit", + "description": "Print a test message", + "entry": "./dist/command.js" + } + ] + } +} +``` + +Minimal command: + +```js +export function register(command, runtime) { + command.action(() => { + runtime.logger.info(`Hello from ${runtime.cwd}`); + }); +} +``` + +## Local Smoke Test + +Until local plugin development commands are available, you can manually create a test package under the managed plugin directory: + +```bash +mkdir -p ~/.ai-devkit/npm/node_modules/@my-org/hello-ai-devkit +``` + +Create `~/.ai-devkit/npm/node_modules/@my-org/hello-ai-devkit/package.json`: + +```json +{ + "name": "@my-org/hello-ai-devkit", + "version": "0.0.0", + "type": "module", + "aiDevkit": { + "commands": [ + { + "name": "hello-devkit", + "description": "Print a test message", + "entry": "./command.js" + } + ] + } +} +``` + +Create `~/.ai-devkit/npm/node_modules/@my-org/hello-ai-devkit/command.js`: + +```js +export function register(command, runtime) { + command.action(async () => { + console.log('hello from an AI DevKit plugin'); + console.log(`memory db: ${await runtime.getMemoryDbPath()}`); + }); +} +``` + +Then add the plugin package name to `~/.ai-devkit/.ai-devkit.json`: + +```json +{ + "plugins": ["@my-org/hello-ai-devkit"] +} +``` + +Run the command: + +```bash +ai-devkit hello-devkit +``` + +This manual flow is only for quick local testing. Published plugins should be installed with `ai-devkit plugin add`. + +## Troubleshooting + +### `No global plugins configured.` + +No package names are listed in `~/.ai-devkit/.ai-devkit.json`. + +Install a plugin: + +```bash +ai-devkit plugin add +``` + +### Plugin install succeeds, then rolls back + +AI DevKit uninstalls a package after install if validation fails. Common causes: + +- Missing `aiDevkit.commands` in `package.json` +- Empty command list +- Command name conflicts with a built-in command +- Command name contains spaces, arguments, options, uppercase letters, or punctuation +- Entry path points to TypeScript source instead of built JavaScript +- Entry path points outside the package root +- Entry file does not exist + +### Plugin command does not appear + +Run: + +```bash +ai-devkit plugin list +``` + +If the plugin is invalid, fix the package manifest or remove and reinstall the package. + +### `register()` is missing + +The command entrypoint must export `register`. For ESM packages: + +```js +export function register(command, runtime) { + command.action(() => { + runtime.logger.info('plugin command ran'); + }); +} +``` + +## Related Pages + +- [Configuration File](/docs/11-configuration-file) — global config and the `plugins` field +- [Memory](/docs/6-memory) — memory database path used by `runtime.getMemoryDbPath()` +- [Skills](/docs/7-skills) — agent skills, which are separate from CLI plugins diff --git a/web/content/docs/6-memory.md b/web/content/docs/6-memory.md index b7ac236..a93604e 100644 --- a/web/content/docs/6-memory.md +++ b/web/content/docs/6-memory.md @@ -222,7 +222,10 @@ No data is sent to the cloud, ensuring your proprietary coding patterns remain p Because the database is just a local file, you can copy it to another machine and keep using the same memory there. +Plugins can also read the configured memory database path through `runtime.getMemoryDbPath()`. This is useful for plugin dashboards and local tools that need to inspect the same memory database as the CLI. + ## Next Steps - **[Skills](/docs/7-skills)**: Learn how to create reusable skill templates +- **[Plugins](/docs/14-plugins)**: Add optional CLI commands that can use the memory runtime - **[Getting Started](/docs/1-getting-started)**: New to AI DevKit? [Start here](/docs/1-getting-started)