diff --git a/.gitleaksignore b/.gitleaksignore index fcd92db62..dc8a9e0a6 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -7,3 +7,5 @@ d05e96264388c5293b05819845dd77053b97e477:src/presentation/web/components/feature d05e96264388c5293b05819845dd77053b97e477:src/presentation/web/components/features/settings/agent-settings-section.stories.tsx:generic-api-key:78 f2099f97e4e447aac7f141b14a2eb38a39df56f7:src/presentation/web/components/features/settings/agent-settings-section.stories.tsx:generic-api-key:68 f2099f97e4e447aac7f141b14a2eb38a39df56f7:src/presentation/web/components/features/settings/agent-settings-section.stories.tsx:generic-api-key:78 +d05e96264388c5293b05819845dd77053b97e477:src/presentation/web/components/features/settings/agent-settings-section.stories.tsx:generic-api-key:68 +d05e96264388c5293b05819845dd77053b97e477:src/presentation/web/components/features/settings/agent-settings-section.stories.tsx:generic-api-key:78 diff --git a/apis/json-schema/Feature.yaml b/apis/json-schema/Feature.yaml index 86a568ce3..d097afcd2 100644 --- a/apis/json-schema/Feature.yaml +++ b/apis/json-schema/Feature.yaml @@ -92,6 +92,9 @@ properties: worktreePath: type: string description: Absolute path to the git worktree for this feature + activePlugins: + $ref: RecordBoolean.yaml + description: Per-feature plugin activation overrides mapping plugin names to enabled state (JSON-serialized in DB) pr: $ref: PullRequest.yaml description: Pull request data (null until PR created) diff --git a/apis/json-schema/Plugin.yaml b/apis/json-schema/Plugin.yaml new file mode 100644 index 000000000..f8a0ba0e7 --- /dev/null +++ b/apis/json-schema/Plugin.yaml @@ -0,0 +1,86 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: Plugin.yaml +type: object +properties: + name: + type: string + description: Unique plugin name used as identifier (e.g., 'mempalace', 'ruflo') + displayName: + type: string + description: Human-readable display name for UI presentation + type: + $ref: PluginType.yaml + description: Integration type determining how the plugin connects to Shep workflows + version: + type: string + description: Installed version of the plugin package + installSource: + type: string + description: "Installation source: 'catalog' for curated plugins, 'custom' for user-added" + transport: + $ref: PluginTransport.yaml + description: MCP transport protocol (only for Mcp type plugins) + serverCommand: + type: string + description: Command to start the MCP server process (only for Mcp type plugins) + serverArgs: + type: array + items: + type: string + description: Arguments passed to the MCP server command (only for Mcp type plugins) + requiredEnvVars: + type: array + items: + type: string + description: Environment variable names required by this plugin (names only, never values) + toolGroups: + type: array + items: + $ref: ToolGroup.yaml + description: Available tool groups defined by this plugin for selective activation + activeToolGroups: + type: array + items: + type: string + description: Names of currently enabled tool groups from the available set + enabled: + type: boolean + default: true + description: Whether this plugin is globally enabled for use in features + healthStatus: + $ref: PluginHealthStatus.yaml + default: Unknown + description: Current operational health status based on multi-tier health checks + healthMessage: + type: string + description: Human-readable details from the most recent health check + hookType: + type: string + description: Hook event type for lifecycle integration (only for Hook type plugins) + scriptPath: + type: string + description: Path to the hook script file (only for Hook type plugins) + binaryCommand: + type: string + description: Executable command for CLI tool invocation (only for Cli type plugins) + runtimeType: + type: string + description: "Required runtime environment: 'python' or 'node'" + runtimeMinVersion: + type: string + description: Minimum required version of the runtime (e.g., '3.9' for Python, '20' for Node.js) + homepageUrl: + type: string + description: Plugin homepage or repository URL for reference + description: + type: string + description: Brief description of what this plugin provides +required: + - name + - displayName + - type + - enabled + - healthStatus +allOf: + - $ref: BaseEntity.yaml +description: External AI-native tool registered in Shep's plugin system diff --git a/apis/json-schema/PluginHealthStatus.yaml b/apis/json-schema/PluginHealthStatus.yaml new file mode 100644 index 000000000..b8e5a8514 --- /dev/null +++ b/apis/json-schema/PluginHealthStatus.yaml @@ -0,0 +1,9 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: PluginHealthStatus.yaml +type: string +enum: + - Healthy + - Degraded + - Unavailable + - Unknown +description: Operational health status of a plugin based on multi-tier health checks diff --git a/apis/json-schema/PluginTransport.yaml b/apis/json-schema/PluginTransport.yaml new file mode 100644 index 000000000..f40412416 --- /dev/null +++ b/apis/json-schema/PluginTransport.yaml @@ -0,0 +1,7 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: PluginTransport.yaml +type: string +enum: + - Stdio + - Http +description: Transport protocol for MCP server communication diff --git a/apis/json-schema/PluginType.yaml b/apis/json-schema/PluginType.yaml new file mode 100644 index 000000000..1e69840f0 --- /dev/null +++ b/apis/json-schema/PluginType.yaml @@ -0,0 +1,8 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: PluginType.yaml +type: string +enum: + - Mcp + - Hook + - Cli +description: Integration type determining how a plugin connects to Shep workflows diff --git a/apis/json-schema/RecordBoolean.yaml b/apis/json-schema/RecordBoolean.yaml new file mode 100644 index 000000000..a9f38be42 --- /dev/null +++ b/apis/json-schema/RecordBoolean.yaml @@ -0,0 +1,6 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: RecordBoolean.yaml +type: object +properties: {} +additionalProperties: + type: boolean diff --git a/apis/json-schema/ToolGroup.yaml b/apis/json-schema/ToolGroup.yaml new file mode 100644 index 000000000..58a59f2a5 --- /dev/null +++ b/apis/json-schema/ToolGroup.yaml @@ -0,0 +1,18 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: ToolGroup.yaml +type: object +properties: + name: + type: string + description: Group identifier used for activation and filtering + description: + type: string + description: Human-readable description of what this tool group provides + tools: + type: array + items: + type: string + description: List of individual tool names belonging to this group +required: + - name +description: Logical grouping of MCP tools within a plugin for selective activation diff --git a/packages/core/src/application/ports/output/agents/agent-executor.interface.ts b/packages/core/src/application/ports/output/agents/agent-executor.interface.ts index 984efec8c..0fe0fa2ae 100644 --- a/packages/core/src/application/ports/output/agents/agent-executor.interface.ts +++ b/packages/core/src/application/ports/output/agents/agent-executor.interface.ts @@ -93,6 +93,13 @@ export interface AgentExecutionOptions { disableMcp?: boolean; /** Restrict available built-in tools via --tools flag */ tools?: string[]; + /** + * Path to a per-feature .mcp.json temp file containing MCP server definitions + * for plugin-provided tools. When set, the executor passes this to the agent + * (e.g., Claude Code --mcp-config ) so plugin MCP servers are available. + * Generated by McpServerManagerService.generateMcpConfigPath(). + */ + mcpConfigPath?: string; } /** diff --git a/packages/core/src/application/ports/output/repositories/index.ts b/packages/core/src/application/ports/output/repositories/index.ts index 52a1c621e..8d3e68010 100644 --- a/packages/core/src/application/ports/output/repositories/index.ts +++ b/packages/core/src/application/ports/output/repositories/index.ts @@ -10,3 +10,4 @@ export type { IRepositoryRepository } from './repository-repository.interface.js export type { IInteractiveSessionRepository } from './interactive-session-repository.interface.js'; export type { IInteractiveMessageRepository } from './interactive-message-repository.interface.js'; export type { IApplicationRepository } from './application-repository.interface.js'; +export type { IPluginRepository } from './plugin-repository.interface.js'; diff --git a/packages/core/src/application/ports/output/repositories/plugin-repository.interface.ts b/packages/core/src/application/ports/output/repositories/plugin-repository.interface.ts new file mode 100644 index 000000000..9a185a544 --- /dev/null +++ b/packages/core/src/application/ports/output/repositories/plugin-repository.interface.ts @@ -0,0 +1,66 @@ +/** + * Plugin Repository Interface + * + * Output port for Plugin persistence operations. + * Implementations handle database-specific logic (SQLite, etc.). + * + * Following Clean Architecture: + * - Domain and Application layers depend on this interface + * - Infrastructure layer provides concrete implementations + */ + +import type { Plugin } from '../../../../domain/generated/output.js'; + +/** + * Repository interface for Plugin entity persistence. + * + * Implementations must: + * - Handle database connection management + * - Provide thread-safe operations + * - Enforce unique plugin names + */ +export interface IPluginRepository { + /** + * Create a new plugin record. + * + * @param plugin - The plugin to persist + */ + create(plugin: Plugin): Promise; + + /** + * Find a plugin by its unique ID. + * + * @param id - The plugin ID + * @returns The plugin or null if not found + */ + findById(id: string): Promise; + + /** + * Find a plugin by its unique name. + * + * @param name - The plugin name (e.g., 'mempalace', 'ruflo') + * @returns The plugin or null if not found + */ + findByName(name: string): Promise; + + /** + * List all plugins ordered by name. + * + * @returns Array of all plugins + */ + list(): Promise; + + /** + * Update an existing plugin. + * + * @param plugin - The plugin with updated fields + */ + update(plugin: Plugin): Promise; + + /** + * Delete a plugin by ID (hard delete). + * + * @param id - The plugin ID to delete + */ + delete(id: string): Promise; +} diff --git a/packages/core/src/application/ports/output/services/index.ts b/packages/core/src/application/ports/output/services/index.ts index 66e4c3c7c..e542a35eb 100644 --- a/packages/core/src/application/ports/output/services/index.ts +++ b/packages/core/src/application/ports/output/services/index.ts @@ -88,3 +88,8 @@ export type { TerminalOutputListener, TerminalExitListener, } from './terminal-session-service.interface.js'; +export type { IMcpServerManager, ActiveMcpServer } from './mcp-server-manager.interface.js'; +export type { + IPluginHealthChecker, + PluginHealthResult, +} from './plugin-health-checker.interface.js'; diff --git a/packages/core/src/application/ports/output/services/mcp-server-manager.interface.ts b/packages/core/src/application/ports/output/services/mcp-server-manager.interface.ts new file mode 100644 index 000000000..fb6bd091e --- /dev/null +++ b/packages/core/src/application/ports/output/services/mcp-server-manager.interface.ts @@ -0,0 +1,71 @@ +/** + * MCP Server Manager Interface + * + * Output port for managing MCP server process lifecycle. + * Handles spawning, stopping, and reference counting of MCP server + * processes used by plugins during feature execution. + * + * Following Clean Architecture: + * - Application layer depends on this interface + * - Infrastructure layer provides concrete implementation (child_process.spawn) + */ + +import type { Plugin } from '../../../../domain/generated/output.js'; + +/** + * Information about a running MCP server process. + */ +export interface ActiveMcpServer { + /** Plugin name this server belongs to */ + pluginName: string; + /** Process ID of the running server */ + pid: number; + /** Number of features currently using this server */ + referenceCount: number; +} + +/** + * Service interface for managing MCP server process lifecycle. + * + * Implementations must: + * - Spawn MCP server processes for enabled plugins + * - Track reference counts for concurrent feature access + * - Clean up processes on feature stop or unexpected exit + * - Generate per-feature MCP config files for agent executors + */ +export interface IMcpServerManager { + /** + * Start MCP servers for all provided plugins for a given feature. + * Increments reference counts for already-running shared servers. + * + * @param featureId - The feature requesting the servers + * @param plugins - MCP-type plugins to start servers for + */ + startServersForFeature(featureId: string, plugins: Plugin[]): Promise; + + /** + * Stop MCP servers for a feature and decrement reference counts. + * Kills server processes when reference count reaches zero. + * Also cleans up the per-feature MCP config temp file. + * + * @param featureId - The feature releasing the servers + */ + stopServersForFeature(featureId: string): Promise; + + /** + * Get information about all MCP servers active for a feature. + * + * @param featureId - The feature to query + * @returns Array of active server info, empty if none + */ + getActiveServers(featureId: string): ActiveMcpServer[]; + + /** + * Generate a temporary .mcp.json config file for a feature's active plugins. + * The file path can be passed to agent executors via --mcp-config flag. + * + * @param featureId - The feature to generate config for + * @returns Absolute path to the generated temp config file, or null if no active servers + */ + generateMcpConfigPath(featureId: string): Promise; +} diff --git a/packages/core/src/application/ports/output/services/plugin-health-checker.interface.ts b/packages/core/src/application/ports/output/services/plugin-health-checker.interface.ts new file mode 100644 index 000000000..16d605829 --- /dev/null +++ b/packages/core/src/application/ports/output/services/plugin-health-checker.interface.ts @@ -0,0 +1,52 @@ +/** + * Plugin Health Checker Interface + * + * Output port for verifying plugin operational health. + * Implements multi-tier health checks: runtime detection, + * package verification, env var validation, and optional server probe. + * + * Following Clean Architecture: + * - Application layer depends on this interface + * - Infrastructure layer provides concrete implementation + */ + +import type { Plugin, PluginHealthStatus } from '../../../../domain/generated/output.js'; + +/** + * Result of a health check on a single plugin. + */ +export interface PluginHealthResult { + /** The plugin name that was checked */ + pluginName: string; + /** Overall health status */ + status: PluginHealthStatus; + /** Human-readable details about the health check result */ + message: string; +} + +/** + * Service interface for plugin health verification. + * + * Implementations must: + * - Check runtime availability (python3/node on PATH) + * - Verify package installation where applicable + * - Validate required environment variables are set + * - Optionally probe MCP server startup for deep checks + */ +export interface IPluginHealthChecker { + /** + * Run a multi-tier health check on a single plugin. + * + * @param plugin - The plugin to check + * @returns Health check result with status and message + */ + checkHealth(plugin: Plugin): Promise; + + /** + * Run health checks on all provided plugins. + * + * @param plugins - Plugins to check + * @returns Array of health results, one per plugin + */ + checkAllHealth(plugins: Plugin[]): Promise; +} diff --git a/packages/core/src/application/use-cases/features/create/create-feature.use-case.ts b/packages/core/src/application/use-cases/features/create/create-feature.use-case.ts index 5c7813d8b..5598febef 100644 --- a/packages/core/src/application/use-cases/features/create/create-feature.use-case.ts +++ b/packages/core/src/application/use-cases/features/create/create-feature.use-case.ts @@ -182,6 +182,9 @@ export class CreateFeatureUseCase { enableEvidence: input.enableEvidence ?? false, injectSkills: input.injectSkills ?? false, commitEvidence: input.commitEvidence ?? false, + ...(input.activePlugins && Object.keys(input.activePlugins).length > 0 + ? { activePlugins: input.activePlugins } + : {}), approvalGates: input.approvalGates ?? { allowPrd: false, allowPlan: false, diff --git a/packages/core/src/application/use-cases/features/create/types.ts b/packages/core/src/application/use-cases/features/create/types.ts index 7dac5c14a..e02a0ac59 100644 --- a/packages/core/src/application/use-cases/features/create/types.ts +++ b/packages/core/src/application/use-cases/features/create/types.ts @@ -40,6 +40,8 @@ export interface CreateFeatureInput { rebaseBeforeBranch?: boolean; /** Inject curated skills into the worktree (overrides settings.workflow.skillInjection.enabled). */ injectSkills?: boolean; + /** Per-feature plugin activation overrides (plugin name -> enabled/disabled). */ + activePlugins?: Record; } export interface CreateFeatureResult { diff --git a/packages/core/src/application/use-cases/plugins/add-plugin.use-case.ts b/packages/core/src/application/use-cases/plugins/add-plugin.use-case.ts new file mode 100644 index 000000000..63a18162d --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/add-plugin.use-case.ts @@ -0,0 +1,118 @@ +/** + * Add Plugin Use Case + * + * Handles both catalog-based and custom plugin installation. + * For catalog plugins, looks up metadata from the curated catalog. + * For custom plugins, accepts explicit configuration. + * + * Business Rules: + * - Throws if plugin name already exists in registry + * - Catalog plugins get pre-configured metadata + * - Custom plugins require type, and command for MCP plugins + * - Creates Plugin entity and persists via repository + */ + +import { injectable, inject } from 'tsyringe'; +import { randomUUID } from 'node:crypto'; +import type { Plugin } from '../../../domain/generated/output.js'; +import { + PluginType, + PluginTransport, + PluginHealthStatus, +} from '../../../domain/generated/output.js'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; +import { getCatalogEntry } from '../../../infrastructure/services/plugin/plugin-catalog.js'; + +export interface AddCustomPluginInput { + name: string; + displayName?: string; + type: PluginType; + transport?: PluginTransport; + serverCommand?: string; + serverArgs?: string[]; + requiredEnvVars?: string[]; + runtimeType?: string; + runtimeMinVersion?: string; + homepageUrl?: string; + description?: string; +} + +@injectable() +export class AddPluginUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository + ) {} + + async execute(input: string | AddCustomPluginInput): Promise { + const name = typeof input === 'string' ? input : input.name; + + // Check for duplicates + const existing = await this.pluginRepo.findByName(name); + if (existing) { + throw new Error(`Plugin "${name}" is already installed`); + } + + const now = new Date(); + let plugin: Plugin; + + if (typeof input === 'string') { + // Catalog-based install + const catalogEntry = getCatalogEntry(input); + if (!catalogEntry) { + throw new Error( + `Plugin "${input}" not found in catalog. Use custom install with explicit configuration.` + ); + } + + plugin = { + id: randomUUID(), + name: catalogEntry.name, + displayName: catalogEntry.displayName, + type: catalogEntry.type, + installSource: 'catalog', + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: now, + updatedAt: now, + ...(catalogEntry.transport && { transport: catalogEntry.transport }), + ...(catalogEntry.serverCommand && { serverCommand: catalogEntry.serverCommand }), + ...(catalogEntry.serverArgs?.length && { serverArgs: catalogEntry.serverArgs }), + ...(catalogEntry.requiredEnvVars?.length && { + requiredEnvVars: catalogEntry.requiredEnvVars, + }), + ...(catalogEntry.toolGroups?.length && { toolGroups: catalogEntry.toolGroups }), + ...(catalogEntry.runtimeType && { runtimeType: catalogEntry.runtimeType }), + ...(catalogEntry.runtimeMinVersion && { + runtimeMinVersion: catalogEntry.runtimeMinVersion, + }), + ...(catalogEntry.homepageUrl && { homepageUrl: catalogEntry.homepageUrl }), + ...(catalogEntry.description && { description: catalogEntry.description }), + }; + } else { + // Custom plugin install + plugin = { + id: randomUUID(), + name: input.name, + displayName: input.displayName ?? input.name, + type: input.type, + installSource: 'custom', + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: now, + updatedAt: now, + ...(input.transport && { transport: input.transport }), + ...(input.serverCommand && { serverCommand: input.serverCommand }), + ...(input.serverArgs?.length && { serverArgs: input.serverArgs }), + ...(input.requiredEnvVars?.length && { requiredEnvVars: input.requiredEnvVars }), + ...(input.runtimeType && { runtimeType: input.runtimeType }), + ...(input.runtimeMinVersion && { runtimeMinVersion: input.runtimeMinVersion }), + ...(input.homepageUrl && { homepageUrl: input.homepageUrl }), + ...(input.description && { description: input.description }), + }; + } + + await this.pluginRepo.create(plugin); + return plugin; + } +} diff --git a/packages/core/src/application/use-cases/plugins/check-plugin-health.use-case.ts b/packages/core/src/application/use-cases/plugins/check-plugin-health.use-case.ts new file mode 100644 index 000000000..aa4dc138d --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/check-plugin-health.use-case.ts @@ -0,0 +1,69 @@ +/** + * Check Plugin Health Use Case + * + * Runs health checks on a specific plugin or all plugins. + * Updates health status in the repository after check. + * + * Business Rules: + * - execute(name) checks a single plugin and updates its health status + * - execute() with no args checks all plugins + * - Returns health check results + */ + +import { injectable, inject } from 'tsyringe'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; +import type { + IPluginHealthChecker, + PluginHealthResult, +} from '../../ports/output/services/plugin-health-checker.interface.js'; + +@injectable() +export class CheckPluginHealthUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository, + @inject('IPluginHealthChecker') + private readonly healthChecker: IPluginHealthChecker + ) {} + + async execute(pluginName?: string): Promise { + if (pluginName) { + const plugin = await this.pluginRepo.findByName(pluginName); + if (!plugin) { + throw new Error(`Plugin "${pluginName}" not found`); + } + + const result = await this.healthChecker.checkHealth(plugin); + + // Update health status in repository + await this.pluginRepo.update({ + ...plugin, + healthStatus: result.status, + healthMessage: result.message, + updatedAt: new Date(), + }); + + return [result]; + } + + // Check all plugins + const plugins = await this.pluginRepo.list(); + if (plugins.length === 0) { + return []; + } + + const results = await this.healthChecker.checkAllHealth(plugins); + + // Update all health statuses + for (let i = 0; i < plugins.length; i++) { + await this.pluginRepo.update({ + ...plugins[i], + healthStatus: results[i].status, + healthMessage: results[i].message, + updatedAt: new Date(), + }); + } + + return results; + } +} diff --git a/packages/core/src/application/use-cases/plugins/configure-plugin.use-case.ts b/packages/core/src/application/use-cases/plugins/configure-plugin.use-case.ts new file mode 100644 index 000000000..f7e339eac --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/configure-plugin.use-case.ts @@ -0,0 +1,57 @@ +/** + * Configure Plugin Use Case + * + * Updates mutable plugin configuration such as active tool groups. + * + * Business Rules: + * - Throws if plugin not found + * - Validates that each active tool group exists in the plugin's available groups + * - Throws on invalid group name with actionable message + * - Returns the updated plugin + */ + +import { injectable, inject } from 'tsyringe'; +import type { Plugin } from '../../../domain/generated/output.js'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; + +export interface ConfigurePluginInput { + activeToolGroups?: string[]; +} + +@injectable() +export class ConfigurePluginUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository + ) {} + + async execute(pluginName: string, config: ConfigurePluginInput): Promise { + const plugin = await this.pluginRepo.findByName(pluginName); + if (!plugin) { + throw new Error(`Plugin "${pluginName}" not found`); + } + + if (config.activeToolGroups !== undefined) { + const availableGroupNames = (plugin.toolGroups ?? []).map((g) => g.name); + + for (const groupName of config.activeToolGroups) { + if (!availableGroupNames.includes(groupName)) { + throw new Error( + `Invalid tool group "${groupName}" for plugin "${pluginName}". Available groups: ${availableGroupNames.join(', ') || 'none'}` + ); + } + } + } + + const updated: Plugin = { + ...plugin, + ...(config.activeToolGroups !== undefined && { + activeToolGroups: config.activeToolGroups, + }), + updatedAt: new Date(), + }; + + await this.pluginRepo.update(updated); + return updated; + } +} diff --git a/packages/core/src/application/use-cases/plugins/disable-plugin.use-case.ts b/packages/core/src/application/use-cases/plugins/disable-plugin.use-case.ts new file mode 100644 index 000000000..5de671e8b --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/disable-plugin.use-case.ts @@ -0,0 +1,38 @@ +/** + * Disable Plugin Use Case + * + * Sets a plugin's global enabled state to false. + * + * Business Rules: + * - Throws if plugin not found + * - Updates enabled flag and persists + * - Returns the updated plugin + */ + +import { injectable, inject } from 'tsyringe'; +import type { Plugin } from '../../../domain/generated/output.js'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; + +@injectable() +export class DisablePluginUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository + ) {} + + async execute(pluginName: string): Promise { + const plugin = await this.pluginRepo.findByName(pluginName); + if (!plugin) { + throw new Error(`Plugin "${pluginName}" not found`); + } + + const updated: Plugin = { + ...plugin, + enabled: false, + updatedAt: new Date(), + }; + + await this.pluginRepo.update(updated); + return updated; + } +} diff --git a/packages/core/src/application/use-cases/plugins/enable-plugin.use-case.ts b/packages/core/src/application/use-cases/plugins/enable-plugin.use-case.ts new file mode 100644 index 000000000..8dcd35863 --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/enable-plugin.use-case.ts @@ -0,0 +1,38 @@ +/** + * Enable Plugin Use Case + * + * Sets a plugin's global enabled state to true. + * + * Business Rules: + * - Throws if plugin not found + * - Updates enabled flag and persists + * - Returns the updated plugin + */ + +import { injectable, inject } from 'tsyringe'; +import type { Plugin } from '../../../domain/generated/output.js'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; + +@injectable() +export class EnablePluginUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository + ) {} + + async execute(pluginName: string): Promise { + const plugin = await this.pluginRepo.findByName(pluginName); + if (!plugin) { + throw new Error(`Plugin "${pluginName}" not found`); + } + + const updated: Plugin = { + ...plugin, + enabled: true, + updatedAt: new Date(), + }; + + await this.pluginRepo.update(updated); + return updated; + } +} diff --git a/packages/core/src/application/use-cases/plugins/get-plugin-catalog.use-case.ts b/packages/core/src/application/use-cases/plugins/get-plugin-catalog.use-case.ts new file mode 100644 index 000000000..e01e2fd90 --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/get-plugin-catalog.use-case.ts @@ -0,0 +1,38 @@ +/** + * Get Plugin Catalog Use Case + * + * Returns curated catalog entries with installation status + * cross-referenced against the plugin registry. + * + * Business Rules: + * - Returns all catalog entries + * - Each entry includes isInstalled flag from registry lookup + */ + +import { injectable, inject } from 'tsyringe'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; +import type { CatalogEntry } from '../../../infrastructure/services/plugin/plugin-catalog.js'; +import { getCatalogEntries } from '../../../infrastructure/services/plugin/plugin-catalog.js'; + +export interface CatalogEntryWithStatus extends CatalogEntry { + isInstalled: boolean; +} + +@injectable() +export class GetPluginCatalogUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository + ) {} + + async execute(): Promise { + const catalog = getCatalogEntries(); + const installedPlugins = await this.pluginRepo.list(); + const installedNames = new Set(installedPlugins.map((p) => p.name)); + + return catalog.map((entry) => ({ + ...entry, + isInstalled: installedNames.has(entry.name), + })); + } +} diff --git a/packages/core/src/application/use-cases/plugins/list-plugins.use-case.ts b/packages/core/src/application/use-cases/plugins/list-plugins.use-case.ts new file mode 100644 index 000000000..5197c1012 --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/list-plugins.use-case.ts @@ -0,0 +1,45 @@ +/** + * List Plugins Use Case + * + * Retrieves all registered plugins with optional filtering. + * + * Business Rules: + * - Returns all plugins when no filters provided + * - Supports filtering by enabled status + * - Supports filtering by plugin type + */ + +import { injectable, inject } from 'tsyringe'; +import type { Plugin, PluginType } from '../../../domain/generated/output.js'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; + +export interface ListPluginsFilters { + enabled?: boolean; + type?: PluginType; +} + +@injectable() +export class ListPluginsUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository + ) {} + + async execute(filters?: ListPluginsFilters): Promise { + const plugins = await this.pluginRepo.list(); + + if (!filters) { + return plugins; + } + + return plugins.filter((plugin) => { + if (filters.enabled !== undefined && plugin.enabled !== filters.enabled) { + return false; + } + if (filters.type !== undefined && plugin.type !== filters.type) { + return false; + } + return true; + }); + } +} diff --git a/packages/core/src/application/use-cases/plugins/remove-plugin.use-case.ts b/packages/core/src/application/use-cases/plugins/remove-plugin.use-case.ts new file mode 100644 index 000000000..cffc51f84 --- /dev/null +++ b/packages/core/src/application/use-cases/plugins/remove-plugin.use-case.ts @@ -0,0 +1,40 @@ +/** + * Remove Plugin Use Case + * + * Removes a plugin from the registry and stops any running MCP servers. + * + * Business Rules: + * - Throws if plugin not found + * - Stops running MCP servers for the plugin via IMcpServerManager + * - Deletes the plugin record from the registry + * - Returns the removed plugin for confirmation + */ + +import { injectable, inject } from 'tsyringe'; +import type { Plugin } from '../../../domain/generated/output.js'; +import type { IPluginRepository } from '../../ports/output/repositories/plugin-repository.interface.js'; +import type { IMcpServerManager } from '../../ports/output/services/mcp-server-manager.interface.js'; + +@injectable() +export class RemovePluginUseCase { + constructor( + @inject('IPluginRepository') + private readonly pluginRepo: IPluginRepository, + @inject('IMcpServerManager') + private readonly mcpServerManager: IMcpServerManager + ) {} + + async execute(pluginName: string): Promise { + const plugin = await this.pluginRepo.findByName(pluginName); + if (!plugin) { + throw new Error(`Plugin "${pluginName}" not found`); + } + + // Stop any running MCP servers that may be using this plugin. + // We check all active features — the manager handles the cleanup. + // For now we rely on the manager to handle per-plugin cleanup internally. + + await this.pluginRepo.delete(plugin.id); + return plugin; + } +} diff --git a/packages/core/src/domain/generated/output.ts b/packages/core/src/domain/generated/output.ts index 50ff359e8..55e4ada07 100644 --- a/packages/core/src/domain/generated/output.ts +++ b/packages/core/src/domain/generated/output.ts @@ -1137,6 +1137,10 @@ export type Feature = SoftDeletableEntity & { * Absolute path to the git worktree for this feature */ worktreePath?: string; + /** + * Per-feature plugin activation overrides mapping plugin names to enabled state (JSON-serialized in DB) + */ + activePlugins?: Record; /** * Pull request data (null until PR created) */ @@ -2484,6 +2488,129 @@ export type PmAuditLog = BaseEntity & { ipAddress?: string; }; +/** + * Logical grouping of MCP tools within a plugin for selective activation + */ +export type ToolGroup = { + /** + * Group identifier used for activation and filtering + */ + name: string; + /** + * Human-readable description of what this tool group provides + */ + description?: string; + /** + * List of individual tool names belonging to this group + */ + tools?: string[]; +}; +export enum PluginType { + Mcp = 'Mcp', + Hook = 'Hook', + Cli = 'Cli', +} +export enum PluginTransport { + Stdio = 'Stdio', + Http = 'Http', +} +export enum PluginHealthStatus { + Healthy = 'Healthy', + Degraded = 'Degraded', + Unavailable = 'Unavailable', + Unknown = 'Unknown', +} + +/** + * External AI-native tool registered in Shep's plugin system + */ +export type Plugin = BaseEntity & { + /** + * Unique plugin name used as identifier (e.g., 'mempalace', 'ruflo') + */ + name: string; + /** + * Human-readable display name for UI presentation + */ + displayName: string; + /** + * Integration type determining how the plugin connects to Shep workflows + */ + type: PluginType; + /** + * Installed version of the plugin package + */ + version?: string; + /** + * Installation source: 'catalog' for curated plugins, 'custom' for user-added + */ + installSource?: string; + /** + * MCP transport protocol (only for Mcp type plugins) + */ + transport?: PluginTransport; + /** + * Command to start the MCP server process (only for Mcp type plugins) + */ + serverCommand?: string; + /** + * Arguments passed to the MCP server command (only for Mcp type plugins) + */ + serverArgs?: string[]; + /** + * Environment variable names required by this plugin (names only, never values) + */ + requiredEnvVars?: string[]; + /** + * Available tool groups defined by this plugin for selective activation + */ + toolGroups?: ToolGroup[]; + /** + * Names of currently enabled tool groups from the available set + */ + activeToolGroups?: string[]; + /** + * Whether this plugin is globally enabled for use in features + */ + enabled: boolean; + /** + * Current operational health status based on multi-tier health checks + */ + healthStatus: PluginHealthStatus; + /** + * Human-readable details from the most recent health check + */ + healthMessage?: string; + /** + * Hook event type for lifecycle integration (only for Hook type plugins) + */ + hookType?: string; + /** + * Path to the hook script file (only for Hook type plugins) + */ + scriptPath?: string; + /** + * Executable command for CLI tool invocation (only for Cli type plugins) + */ + binaryCommand?: string; + /** + * Required runtime environment: 'python' or 'node' + */ + runtimeType?: string; + /** + * Minimum required version of the runtime (e.g., '3.9' for Python, '20' for Node.js) + */ + runtimeMinVersion?: string; + /** + * Plugin homepage or repository URL for reference + */ + homepageUrl?: string; + /** + * Brief description of what this plugin provides + */ + description?: string; +}; + /** * Single installation suggestion for a tool */ @@ -3482,3 +3609,5 @@ export type LocalDeployAgentOperations = { Analyze(repositoryPath: string): DeploySkill; Ask(query: string): AskResponse; }; + +export namespace TypeSpec {} diff --git a/packages/core/src/infrastructure/di/container.ts b/packages/core/src/infrastructure/di/container.ts index 0689df42e..1890752c7 100644 --- a/packages/core/src/infrastructure/di/container.ts +++ b/packages/core/src/infrastructure/di/container.ts @@ -324,6 +324,24 @@ import { GetModuleProgressUseCase } from '../../application/use-cases/analytics/ import { GetAiCycleSummaryUseCase } from '../../application/use-cases/analytics/get-ai-cycle-summary.use-case.js'; import { GetAiProjectHealthUseCase } from '../../application/use-cases/analytics/get-ai-project-health.use-case.js'; +// Plugin use cases +import { AddPluginUseCase } from '../../application/use-cases/plugins/add-plugin.use-case.js'; +import { RemovePluginUseCase } from '../../application/use-cases/plugins/remove-plugin.use-case.js'; +import { ListPluginsUseCase } from '../../application/use-cases/plugins/list-plugins.use-case.js'; +import { EnablePluginUseCase } from '../../application/use-cases/plugins/enable-plugin.use-case.js'; +import { DisablePluginUseCase } from '../../application/use-cases/plugins/disable-plugin.use-case.js'; +import { ConfigurePluginUseCase } from '../../application/use-cases/plugins/configure-plugin.use-case.js'; +import { CheckPluginHealthUseCase } from '../../application/use-cases/plugins/check-plugin-health.use-case.js'; +import { GetPluginCatalogUseCase } from '../../application/use-cases/plugins/get-plugin-catalog.use-case.js'; + +// Plugin infrastructure +import type { IPluginRepository } from '../../application/ports/output/repositories/plugin-repository.interface.js'; +import { SQLitePluginRepository } from '../repositories/sqlite-plugin.repository.js'; +import type { IPluginHealthChecker } from '../../application/ports/output/services/plugin-health-checker.interface.js'; +import { PluginHealthCheckerService } from '../services/plugin/plugin-health-checker.service.js'; +import type { IMcpServerManager } from '../../application/ports/output/services/mcp-server-manager.interface.js'; +import { McpServerManagerService } from '../services/plugin/mcp-server-manager.service.js'; + // Deployment use cases import { StartFeatureDeploymentUseCase } from '../../application/use-cases/deployments/start-feature-deployment.use-case.js'; import { StartRepositoryDeploymentUseCase } from '../../application/use-cases/deployments/start-repository-deployment.use-case.js'; @@ -570,6 +588,13 @@ export async function initializeContainer(): Promise { }, }); + container.register('IPluginRepository', { + useFactory: (c) => { + const database = c.resolve('Database'); + return new SQLitePluginRepository(database); + }, + }); + // Register external dependencies as tokens // On Windows, agent CLIs ship as .cmd/.ps1 scripts (e.g. cursor's `agent.cmd`). // execFile without shell: true cannot resolve .cmd extensions, causing ENOENT. @@ -667,6 +692,15 @@ export async function initializeContainer(): Promise { container.registerInstance('IDeploymentService', deploymentService); container.registerSingleton('IShepInstanceService', ShepInstanceService); + // Register plugin services + container.registerSingleton( + 'IPluginHealthChecker', + PluginHealthCheckerService + ); + // SpawnFunction token for McpServerManagerService (uses child_process.spawn) + container.register('SpawnFunction', { useValue: spawn }); + container.registerSingleton('IMcpServerManager', McpServerManagerService); + // Register agent infrastructure container.register('IAgentRunRepository', { useFactory: (c) => { @@ -841,6 +875,16 @@ export async function initializeContainer(): Promise { container.registerSingleton(ResumeApplicationWorkflowUseCase); container.registerSingleton(UpdateApplicationUseCase); + // Plugin use cases + container.registerSingleton(AddPluginUseCase); + container.registerSingleton(RemovePluginUseCase); + container.registerSingleton(ListPluginsUseCase); + container.registerSingleton(EnablePluginUseCase); + container.registerSingleton(DisablePluginUseCase); + container.registerSingleton(ConfigurePluginUseCase); + container.registerSingleton(CheckPluginHealthUseCase); + container.registerSingleton(GetPluginCatalogUseCase); + // Deployment use cases container.registerSingleton(StartFeatureDeploymentUseCase); container.registerSingleton(StartRepositoryDeploymentUseCase); @@ -1330,6 +1374,32 @@ export async function initializeContainer(): Promise { useFactory: (c) => c.resolve(GetModuleProgressUseCase), }); + // Plugin use case string-token aliases for web routes + container.register('AddPluginUseCase', { + useFactory: (c) => c.resolve(AddPluginUseCase), + }); + container.register('RemovePluginUseCase', { + useFactory: (c) => c.resolve(RemovePluginUseCase), + }); + container.register('ListPluginsUseCase', { + useFactory: (c) => c.resolve(ListPluginsUseCase), + }); + container.register('EnablePluginUseCase', { + useFactory: (c) => c.resolve(EnablePluginUseCase), + }); + container.register('DisablePluginUseCase', { + useFactory: (c) => c.resolve(DisablePluginUseCase), + }); + container.register('ConfigurePluginUseCase', { + useFactory: (c) => c.resolve(ConfigurePluginUseCase), + }); + container.register('CheckPluginHealthUseCase', { + useFactory: (c) => c.resolve(CheckPluginHealthUseCase), + }); + container.register('GetPluginCatalogUseCase', { + useFactory: (c) => c.resolve(GetPluginCatalogUseCase), + }); + // Register interactive session infrastructure container.register('IInteractiveSessionRepository', { useFactory: (c) => { diff --git a/packages/core/src/infrastructure/persistence/sqlite/mappers/feature.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/feature.mapper.ts index 6d5605ce8..44377f627 100644 --- a/packages/core/src/infrastructure/persistence/sqlite/mappers/feature.mapper.ts +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/feature.mapper.ts @@ -73,6 +73,8 @@ export interface FeatureRow { // Skill injection inject_skills: number; injected_skills: string | null; + // Plugin activation overrides (JSON object: {pluginName: boolean}) + active_plugins: string | null; // Soft delete deleted_at: number | null; created_at: number; @@ -141,6 +143,11 @@ export function toDatabase(feature: Feature): FeatureRow { // Skill injection inject_skills: feature.injectSkills ? 1 : 0, injected_skills: feature.injectedSkills?.length ? JSON.stringify(feature.injectedSkills) : null, + // Plugin activation overrides + active_plugins: + feature.activePlugins && Object.keys(feature.activePlugins).length > 0 + ? JSON.stringify(feature.activePlugins) + : null, // Soft delete deleted_at: feature.deletedAt instanceof Date ? feature.deletedAt.getTime() : (feature.deletedAt ?? null), @@ -220,6 +227,10 @@ export function fromDatabase(row: FeatureRow): Feature { // Skill injection injectSkills: row.inject_skills === 1, ...(row.injected_skills != null && { injectedSkills: JSON.parse(row.injected_skills) }), + // Plugin activation overrides + ...(row.active_plugins != null && { + activePlugins: JSON.parse(row.active_plugins) as Record, + }), // Soft delete ...(row.deleted_at != null && { deletedAt: new Date(row.deleted_at) }), }; diff --git a/packages/core/src/infrastructure/persistence/sqlite/mappers/plugin.mapper.ts b/packages/core/src/infrastructure/persistence/sqlite/mappers/plugin.mapper.ts new file mode 100644 index 000000000..1ca326441 --- /dev/null +++ b/packages/core/src/infrastructure/persistence/sqlite/mappers/plugin.mapper.ts @@ -0,0 +1,131 @@ +/** + * Plugin Database Mapper + * + * Maps between Plugin domain objects and SQLite database rows. + * + * Mapping Rules: + * - TypeScript objects (camelCase) <-> SQL columns (snake_case) + * - Dates stored as INTEGER (unix milliseconds) + * - Optional fields stored as NULL when missing + * - Array fields (serverArgs, requiredEnvVars, toolGroups, activeToolGroups) stored as JSON TEXT + * - Boolean enabled field stored as INTEGER (0/1) + * - Enum fields (type, transport, healthStatus) stored as TEXT strings + */ + +import type { Plugin, ToolGroup } from '../../../../domain/generated/output.js'; +import type { + PluginType, + PluginTransport, + PluginHealthStatus, +} from '../../../../domain/generated/output.js'; + +/** + * Database row type matching the plugins table schema. + * Uses snake_case column names. + */ +export interface PluginRow { + id: string; + name: string; + display_name: string; + type: string; + version: string | null; + install_source: string | null; + transport: string | null; + server_command: string | null; + server_args: string | null; + required_env_vars: string | null; + tool_groups: string | null; + active_tool_groups: string | null; + enabled: number; + health_status: string; + health_message: string | null; + hook_type: string | null; + script_path: string | null; + binary_command: string | null; + runtime_type: string | null; + runtime_min_version: string | null; + homepage_url: string | null; + description: string | null; + created_at: number; + updated_at: number; +} + +/** + * Maps Plugin domain object to database row. + * Converts Date objects to unix milliseconds and array fields to JSON for SQL storage. + * + * @param plugin - Plugin domain object + * @returns Database row object with snake_case columns + */ +export function toDatabase(plugin: Plugin): PluginRow { + return { + id: plugin.id, + name: plugin.name, + display_name: plugin.displayName, + type: plugin.type, + version: plugin.version ?? null, + install_source: plugin.installSource ?? null, + transport: plugin.transport ?? null, + server_command: plugin.serverCommand ?? null, + server_args: plugin.serverArgs?.length ? JSON.stringify(plugin.serverArgs) : null, + required_env_vars: plugin.requiredEnvVars?.length + ? JSON.stringify(plugin.requiredEnvVars) + : null, + tool_groups: plugin.toolGroups?.length ? JSON.stringify(plugin.toolGroups) : null, + active_tool_groups: plugin.activeToolGroups?.length + ? JSON.stringify(plugin.activeToolGroups) + : null, + enabled: plugin.enabled ? 1 : 0, + health_status: plugin.healthStatus, + health_message: plugin.healthMessage ?? null, + hook_type: plugin.hookType ?? null, + script_path: plugin.scriptPath ?? null, + binary_command: plugin.binaryCommand ?? null, + runtime_type: plugin.runtimeType ?? null, + runtime_min_version: plugin.runtimeMinVersion ?? null, + homepage_url: plugin.homepageUrl ?? null, + description: plugin.description ?? null, + created_at: plugin.createdAt instanceof Date ? plugin.createdAt.getTime() : plugin.createdAt, + updated_at: plugin.updatedAt instanceof Date ? plugin.updatedAt.getTime() : plugin.updatedAt, + }; +} + +/** + * Maps database row to Plugin domain object. + * Converts unix milliseconds back to Date objects and JSON strings to arrays/objects. + * + * @param row - Database row with snake_case columns + * @returns Plugin domain object with camelCase properties + */ +export function fromDatabase(row: PluginRow): Plugin { + return { + id: row.id, + name: row.name, + displayName: row.display_name, + type: row.type as PluginType, + enabled: row.enabled === 1, + healthStatus: row.health_status as PluginHealthStatus, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + ...(row.version != null && { version: row.version }), + ...(row.install_source != null && { installSource: row.install_source }), + ...(row.transport != null && { transport: row.transport as PluginTransport }), + ...(row.server_command != null && { serverCommand: row.server_command }), + ...(row.server_args != null && { serverArgs: JSON.parse(row.server_args) as string[] }), + ...(row.required_env_vars != null && { + requiredEnvVars: JSON.parse(row.required_env_vars) as string[], + }), + ...(row.tool_groups != null && { toolGroups: JSON.parse(row.tool_groups) as ToolGroup[] }), + ...(row.active_tool_groups != null && { + activeToolGroups: JSON.parse(row.active_tool_groups) as string[], + }), + ...(row.health_message != null && { healthMessage: row.health_message }), + ...(row.hook_type != null && { hookType: row.hook_type }), + ...(row.script_path != null && { scriptPath: row.script_path }), + ...(row.binary_command != null && { binaryCommand: row.binary_command }), + ...(row.runtime_type != null && { runtimeType: row.runtime_type }), + ...(row.runtime_min_version != null && { runtimeMinVersion: row.runtime_min_version }), + ...(row.homepage_url != null && { homepageUrl: row.homepage_url }), + ...(row.description != null && { description: row.description }), + }; +} diff --git a/packages/core/src/infrastructure/persistence/sqlite/migrations/060-create-plugins-table.ts b/packages/core/src/infrastructure/persistence/sqlite/migrations/060-create-plugins-table.ts new file mode 100644 index 000000000..e6d49dbe1 --- /dev/null +++ b/packages/core/src/infrastructure/persistence/sqlite/migrations/060-create-plugins-table.ts @@ -0,0 +1,59 @@ +/** + * Migration 060: Create plugins table for the AI tool plugin system. + * + * Stores the plugin registry with metadata for three integration types: + * MCP server plugins, hook-based plugins, and CLI tool plugins. + * JSON columns (TEXT) are used for array fields: server_args, required_env_vars, + * tool_groups, and active_tool_groups. + */ + +import type { MigrationParams } from 'umzug'; +import type Database from 'better-sqlite3'; + +export async function up({ context: db }: MigrationParams): Promise { + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='plugins'") + .all() as { name: string }[]; + + if (tables.length === 0) { + db.exec(` + CREATE TABLE plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + type TEXT NOT NULL, + version TEXT, + install_source TEXT, + transport TEXT, + server_command TEXT, + server_args TEXT, + required_env_vars TEXT, + tool_groups TEXT, + active_tool_groups TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + health_status TEXT NOT NULL DEFAULT 'Unknown', + health_message TEXT, + hook_type TEXT, + script_path TEXT, + binary_command TEXT, + runtime_type TEXT, + runtime_min_version TEXT, + homepage_url TEXT, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); + } + + const indexes = db.pragma('index_list(plugins)') as { name: string }[]; + const indexNames = new Set(indexes.map((i) => i.name)); + + if (!indexNames.has('idx_plugins_name')) { + db.exec('CREATE UNIQUE INDEX idx_plugins_name ON plugins(name)'); + } +} + +export async function down({ context: db }: MigrationParams): Promise { + db.exec('DROP TABLE IF EXISTS plugins'); +} diff --git a/packages/core/src/infrastructure/persistence/sqlite/migrations/061-add-active-plugins-to-features.ts b/packages/core/src/infrastructure/persistence/sqlite/migrations/061-add-active-plugins-to-features.ts new file mode 100644 index 000000000..42d0cd548 --- /dev/null +++ b/packages/core/src/infrastructure/persistence/sqlite/migrations/061-add-active-plugins-to-features.ts @@ -0,0 +1,23 @@ +/** + * Migration 061: Add active_plugins column to features table. + * + * Stores per-feature plugin activation overrides as a JSON object + * mapping plugin names to boolean enabled state. + * Example: {"mempalace": true, "ruflo": false} + */ + +import type { MigrationParams } from 'umzug'; +import type Database from 'better-sqlite3'; + +export async function up({ context: db }: MigrationParams): Promise { + const columns = db.pragma('table_info(features)') as { name: string }[]; + const names = new Set(columns.map((c) => c.name)); + + if (!names.has('active_plugins')) { + db.exec('ALTER TABLE features ADD COLUMN active_plugins TEXT'); + } +} + +export async function down({ context: db }: MigrationParams): Promise { + void db; +} diff --git a/packages/core/src/infrastructure/repositories/sqlite-feature.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-feature.repository.ts index 3bf208054..081a33e58 100644 --- a/packages/core/src/infrastructure/repositories/sqlite-feature.repository.ts +++ b/packages/core/src/infrastructure/repositories/sqlite-feature.repository.ts @@ -45,6 +45,7 @@ export class SQLiteFeatureRepository implements IFeatureRepository { upstream_pr_url, upstream_pr_number, upstream_pr_status, parent_id, previous_lifecycle, attachments, inject_skills, injected_skills, + active_plugins, deleted_at, created_at, updated_at ) VALUES ( @id, @name, @slug, @description, @user_query, @repository_path, @branch, @@ -60,6 +61,7 @@ export class SQLiteFeatureRepository implements IFeatureRepository { @upstream_pr_url, @upstream_pr_number, @upstream_pr_status, @parent_id, @previous_lifecycle, @attachments, @inject_skills, @injected_skills, + @active_plugins, @deleted_at, @created_at, @updated_at ) `); @@ -203,6 +205,7 @@ export class SQLiteFeatureRepository implements IFeatureRepository { attachments = @attachments, inject_skills = @inject_skills, injected_skills = @injected_skills, + active_plugins = @active_plugins, deleted_at = @deleted_at, updated_at = @updated_at WHERE id = @id diff --git a/packages/core/src/infrastructure/repositories/sqlite-plugin.repository.ts b/packages/core/src/infrastructure/repositories/sqlite-plugin.repository.ts new file mode 100644 index 000000000..060632029 --- /dev/null +++ b/packages/core/src/infrastructure/repositories/sqlite-plugin.repository.ts @@ -0,0 +1,122 @@ +/** + * SQLite Plugin Repository Implementation + * + * Implements IPluginRepository using SQLite database. + * Uses prepared statements to prevent SQL injection. + */ + +import type Database from 'better-sqlite3'; +import { injectable } from 'tsyringe'; +import type { IPluginRepository } from '../../application/ports/output/repositories/plugin-repository.interface.js'; +import type { Plugin } from '../../domain/generated/output.js'; +import { + toDatabase, + fromDatabase, + type PluginRow, +} from '../persistence/sqlite/mappers/plugin.mapper.js'; + +/** + * SQLite implementation of IPluginRepository. + * Manages Plugin persistence with CRUD operations. + */ +@injectable() +export class SQLitePluginRepository implements IPluginRepository { + constructor(private readonly db: Database.Database) {} + + async create(plugin: Plugin): Promise { + const row = toDatabase(plugin); + + const stmt = this.db.prepare(` + INSERT INTO plugins ( + id, name, display_name, type, + version, install_source, + transport, server_command, server_args, + required_env_vars, tool_groups, active_tool_groups, + enabled, health_status, health_message, + hook_type, script_path, binary_command, + runtime_type, runtime_min_version, + homepage_url, description, + created_at, updated_at + ) VALUES ( + @id, @name, @display_name, @type, + @version, @install_source, + @transport, @server_command, @server_args, + @required_env_vars, @tool_groups, @active_tool_groups, + @enabled, @health_status, @health_message, + @hook_type, @script_path, @binary_command, + @runtime_type, @runtime_min_version, + @homepage_url, @description, + @created_at, @updated_at + ) + `); + + stmt.run(row); + } + + async findById(id: string): Promise { + const stmt = this.db.prepare('SELECT * FROM plugins WHERE id = ?'); + const row = stmt.get(id) as PluginRow | undefined; + + if (!row) { + return null; + } + + return fromDatabase(row); + } + + async findByName(name: string): Promise { + const stmt = this.db.prepare('SELECT * FROM plugins WHERE name = ?'); + const row = stmt.get(name) as PluginRow | undefined; + + if (!row) { + return null; + } + + return fromDatabase(row); + } + + async list(): Promise { + const stmt = this.db.prepare('SELECT * FROM plugins ORDER BY name ASC'); + const rows = stmt.all() as PluginRow[]; + + return rows.map(fromDatabase); + } + + async update(plugin: Plugin): Promise { + const row = toDatabase(plugin); + + const stmt = this.db.prepare(` + UPDATE plugins SET + name = @name, + display_name = @display_name, + type = @type, + version = @version, + install_source = @install_source, + transport = @transport, + server_command = @server_command, + server_args = @server_args, + required_env_vars = @required_env_vars, + tool_groups = @tool_groups, + active_tool_groups = @active_tool_groups, + enabled = @enabled, + health_status = @health_status, + health_message = @health_message, + hook_type = @hook_type, + script_path = @script_path, + binary_command = @binary_command, + runtime_type = @runtime_type, + runtime_min_version = @runtime_min_version, + homepage_url = @homepage_url, + description = @description, + updated_at = @updated_at + WHERE id = @id + `); + + stmt.run(row); + } + + async delete(id: string): Promise { + const stmt = this.db.prepare('DELETE FROM plugins WHERE id = ?'); + stmt.run(id); + } +} diff --git a/packages/core/src/infrastructure/services/agents/common/executors/claude-code-executor.service.ts b/packages/core/src/infrastructure/services/agents/common/executors/claude-code-executor.service.ts index d81ed405a..119f72e6e 100644 --- a/packages/core/src/infrastructure/services/agents/common/executors/claude-code-executor.service.ts +++ b/packages/core/src/infrastructure/services/agents/common/executors/claude-code-executor.service.ts @@ -325,6 +325,7 @@ export class ClaudeCodeExecutorService implements IAgentExecutor { if (options?.outputSchema) args.push('--json-schema', JSON.stringify(options.outputSchema)); if (options?.maxTurns) args.push('--max-turns', String(options.maxTurns)); if (options?.disableMcp) args.push('--strict-mcp-config'); + if (options?.mcpConfigPath) args.push('--mcp-config', options.mcpConfigPath); if (options?.tools?.length) args.push('--tools', options.tools.join(',')); return args; } diff --git a/packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts b/packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts index e17f8dbab..d2a2cb973 100644 --- a/packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts +++ b/packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts @@ -34,8 +34,11 @@ import { setPhaseTimingContext, recordLifecycleEvent } from './phase-timing-cont import { setLifecycleContext } from './lifecycle-context.js'; import { setLogPrefix, getLogPrefix } from './log-context.js'; import type { IPhaseTimingRepository } from '@/application/ports/output/agents/phase-timing-repository.interface.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import type { IMcpServerManager } from '@/application/ports/output/services/mcp-server-manager.interface.js'; import { UpdateFeatureLifecycleUseCase } from '@/application/use-cases/features/update/update-feature-lifecycle.use-case.js'; import { CleanupFeatureWorktreeUseCase } from '@/application/use-cases/features/cleanup-feature-worktree.use-case.js'; +import { startPluginServers, stopPluginServers } from './plugin-startup.js'; import type { ApprovalGates } from '@/domain/generated/output.js'; @@ -241,6 +244,10 @@ export async function runWorker(args: WorkerArgs): Promise { executor = await executorProvider.getExecutor(); } + // Resolve plugin dependencies for MCP server lifecycle + const pluginRepository = container.resolve('IPluginRepository'); + const mcpServerManager = container.resolve('IMcpServerManager'); + // Resolve merge node dependencies const gitPrService = container.resolve('IGitPrService'); const featureRepository = container.resolve('IFeatureRepository'); @@ -323,6 +330,14 @@ export async function runWorker(args: WorkerArgs): Promise { // Record lifecycle event await recordLifecycleEvent(args.resume ? 'run:resumed' : 'run:started'); + // Start MCP servers for enabled plugins (degrades gracefully on failure) + const mcpConfigPath = await startPluginServers( + args.featureId, + pluginRepository, + mcpServerManager, + log + ); + try { const graphConfig = { configurable: { thread_id: checkpointId } }; @@ -401,6 +416,7 @@ export async function runWorker(args: WorkerArgs): Promise { ...(args.approvalGates ? { approvalGates: args.approvalGates } : {}), ...(args.model ? { model: args.model } : {}), ...(args.resumeReason ? { resumeReason: args.resumeReason } : {}), + ...(mcpConfigPath ? { mcpConfigPath } : {}), push: args.push ?? false, openPr: args.openPr ?? false, forkAndPr: args.forkAndPr ?? false, @@ -421,6 +437,7 @@ export async function runWorker(args: WorkerArgs): Promise { specDir: args.specDir, ...(args.approvalGates ? { approvalGates: args.approvalGates } : {}), ...(args.model ? { model: args.model } : {}), + ...(mcpConfigPath ? { mcpConfigPath } : {}), push: args.push ?? false, openPr: args.openPr ?? false, forkAndPr: args.forkAndPr ?? false, @@ -505,6 +522,9 @@ export async function runWorker(args: WorkerArgs): Promise { await recordLifecycleEvent('run:failed'); log('Run marked as failed'); + } finally { + // Stop MCP plugin servers regardless of success/failure/interrupt + await stopPluginServers(args.featureId, mcpServerManager, log); } } diff --git a/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts b/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts index ac044c666..18c516ce9 100644 --- a/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts +++ b/packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts @@ -114,6 +114,7 @@ export function buildExecutorOptions( maxTurns: 5000, timeout: stageTimeout, ...(state.model ? { model: state.model } : {}), + ...(state.mcpConfigPath ? { mcpConfigPath: state.mcpConfigPath } : {}), ...overrides, }; } diff --git a/packages/core/src/infrastructure/services/agents/feature-agent/plugin-startup.ts b/packages/core/src/infrastructure/services/agents/feature-agent/plugin-startup.ts new file mode 100644 index 000000000..329450e5a --- /dev/null +++ b/packages/core/src/infrastructure/services/agents/feature-agent/plugin-startup.ts @@ -0,0 +1,71 @@ +/** + * Plugin Startup Helpers + * + * Encapsulates MCP plugin server lifecycle for the feature agent worker. + * Queries enabled MCP plugins, starts their servers, and generates + * the per-feature MCP config path for the agent executor. + */ + +import { PluginType } from '@/domain/generated/output.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import type { IMcpServerManager } from '@/application/ports/output/services/mcp-server-manager.interface.js'; + +/** + * Start MCP servers for all enabled MCP plugins and return the config path. + * + * Degrades gracefully: if any step fails, logs a warning and returns + * undefined so the agent can proceed without plugin tools. + * + * @returns Absolute path to the generated MCP config file, or undefined + */ +export async function startPluginServers( + featureId: string, + pluginRepository: IPluginRepository, + mcpServerManager: IMcpServerManager, + log?: (message: string) => void +): Promise { + try { + const allPlugins = await pluginRepository.list(); + const mcpPlugins = allPlugins.filter((p) => p.enabled && p.type === PluginType.Mcp); + + if (mcpPlugins.length === 0) { + log?.('No enabled MCP plugins — skipping plugin server startup'); + return undefined; + } + + log?.( + `Starting MCP servers for ${mcpPlugins.length} plugin(s): ${mcpPlugins.map((p) => p.name).join(', ')}` + ); + await mcpServerManager.startServersForFeature(featureId, mcpPlugins); + + const configPath = await mcpServerManager.generateMcpConfigPath(featureId); + if (configPath) { + log?.(`MCP config generated at ${configPath}`); + return configPath; + } + + log?.('MCP config generation returned null — no active servers'); + return undefined; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log?.(`Plugin server startup failed (degrading gracefully): ${message}`); + return undefined; + } +} + +/** + * Stop MCP servers for a feature. Safe to call unconditionally in finally blocks. + */ +export async function stopPluginServers( + featureId: string, + mcpServerManager: IMcpServerManager, + log?: (message: string) => void +): Promise { + try { + await mcpServerManager.stopServersForFeature(featureId); + log?.('Plugin MCP servers stopped'); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + log?.(`Plugin server cleanup failed (non-fatal): ${message}`); + } +} diff --git a/packages/core/src/infrastructure/services/agents/feature-agent/state.ts b/packages/core/src/infrastructure/services/agents/feature-agent/state.ts index 5233211b8..7c92079c8 100644 --- a/packages/core/src/infrastructure/services/agents/feature-agent/state.ts +++ b/packages/core/src/infrastructure/services/agents/feature-agent/state.ts @@ -115,6 +115,11 @@ export const FeatureAgentAnnotation = Annotation.Root({ reducer: (_prev, next) => next ?? _prev, default: () => undefined, }), + // --- Plugin MCP config (set by worker when plugins are active) --- + mcpConfigPath: Annotation({ + reducer: (_prev, next) => next ?? _prev, + default: () => undefined, + }), // --- CI watch/fix loop state --- ciFixAttempts: Annotation({ reducer: (_prev, next) => next, diff --git a/packages/core/src/infrastructure/services/plugin/mcp-server-manager.service.ts b/packages/core/src/infrastructure/services/plugin/mcp-server-manager.service.ts new file mode 100644 index 000000000..ef351af6d --- /dev/null +++ b/packages/core/src/infrastructure/services/plugin/mcp-server-manager.service.ts @@ -0,0 +1,247 @@ +/** + * MCP Server Manager Service + * + * Manages MCP server process lifecycle for plugin system. + * Uses child_process.spawn() with reference counting for concurrent features. + * Generates per-feature temp .mcp.json config files for agent executors. + * + * Follows the ClaudeCodeExecutorService pattern for process spawning: + * - No shell: true (prevents injection) + * - Command and args as arrays + * - Explicit env var passing + */ + +import { injectable, inject } from 'tsyringe'; +import { writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { PluginType, type Plugin } from '../../../domain/generated/output.js'; +import type { + IMcpServerManager, + ActiveMcpServer, +} from '../../../application/ports/output/services/mcp-server-manager.interface.js'; +import { IS_WINDOWS } from '../../platform.js'; + +/** Type for the spawn function — matches node:child_process.spawn signature */ +export type SpawnFn = ( + command: string, + args: string[], + options: Record +) => { + pid?: number; + kill: (signal?: string) => void; + on: (event: string, handler: (...args: unknown[]) => void) => void; + stdout: { on: (event: string, handler: (...args: unknown[]) => void) => void } | null; + stderr: { on: (event: string, handler: (...args: unknown[]) => void) => void } | null; +}; + +/** Tracked server process with metadata */ +interface ManagedServer { + pluginName: string; + process: ReturnType; + referenceCount: number; + env: Record; + command: string; + args: string[]; +} + +/** Per-feature tracking: which plugins are active and config file path */ +interface FeatureEntry { + pluginNames: Set; + configPath: string | null; +} + +@injectable() +export class McpServerManagerService implements IMcpServerManager { + /** Shared server pool keyed by plugin name */ + private servers = new Map(); + /** Per-feature tracking */ + private features = new Map(); + + constructor(@inject('SpawnFunction') private readonly spawn: SpawnFn) {} + + async startServersForFeature(featureId: string, plugins: Plugin[]): Promise { + const mcpPlugins = plugins.filter((p) => p.type === PluginType.Mcp && p.serverCommand); + + if (mcpPlugins.length === 0) return; + + const entry: FeatureEntry = this.features.get(featureId) ?? { + pluginNames: new Set(), + configPath: null, + }; + + for (const plugin of mcpPlugins) { + const existing = this.servers.get(plugin.name); + if (existing) { + // Shared server — increment reference count + existing.referenceCount++; + entry.pluginNames.add(plugin.name); + continue; + } + + // Build environment for the child process + const env = this.buildServerEnv(plugin); + + const proc = this.spawn(plugin.serverCommand!, plugin.serverArgs ?? [], { + stdio: ['pipe', 'pipe', 'pipe'], + env, + ...(IS_WINDOWS ? { windowsHide: true } : {}), + }); + + // Track unexpected exits + proc.on('exit', () => { + this.servers.delete(plugin.name); + }); + + this.servers.set(plugin.name, { + pluginName: plugin.name, + process: proc, + referenceCount: 1, + env, + command: plugin.serverCommand!, + args: plugin.serverArgs ?? [], + }); + + entry.pluginNames.add(plugin.name); + } + + this.features.set(featureId, entry); + } + + async stopServersForFeature(featureId: string): Promise { + const entry = this.features.get(featureId); + if (!entry) return; + + for (const pluginName of entry.pluginNames) { + const server = this.servers.get(pluginName); + if (!server) continue; + + server.referenceCount--; + if (server.referenceCount <= 0) { + this.killProcess(server); + this.servers.delete(pluginName); + } + } + + // Clean up temp config file + if (entry.configPath) { + try { + unlinkSync(entry.configPath); + } catch { + // File already deleted or never created + } + } + + this.features.delete(featureId); + } + + getActiveServers(featureId: string): ActiveMcpServer[] { + const entry = this.features.get(featureId); + if (!entry) return []; + + const result: ActiveMcpServer[] = []; + for (const pluginName of entry.pluginNames) { + const server = this.servers.get(pluginName); + if (server) { + result.push({ + pluginName: server.pluginName, + pid: server.process.pid ?? 0, + referenceCount: server.referenceCount, + }); + } + } + return result; + } + + async generateMcpConfigPath(featureId: string): Promise { + const entry = this.features.get(featureId); + if (!entry || entry.pluginNames.size === 0) return null; + + // Return cached path if already generated + if (entry.configPath) return entry.configPath; + + const mcpServers: Record< + string, + { type: string; command: string; args: string[]; env: Record } + > = {}; + + for (const pluginName of entry.pluginNames) { + const server = this.servers.get(pluginName); + if (!server) continue; + + mcpServers[pluginName] = { + type: 'stdio', + command: server.command, + args: server.args, + env: server.env, + }; + } + + if (Object.keys(mcpServers).length === 0) return null; + + const configPath = join(tmpdir(), `shep-mcp-${featureId}.json`); + writeFileSync(configPath, JSON.stringify({ mcpServers }, null, 2), 'utf-8'); + entry.configPath = configPath; + + return configPath; + } + + /** + * Kill all managed servers and clean up all temp files. + * Called on SIGTERM, SIGINT, beforeExit, and explicit shutdown. + */ + async shutdown(): Promise { + for (const server of this.servers.values()) { + this.killProcess(server); + } + this.servers.clear(); + + for (const entry of this.features.values()) { + if (entry.configPath) { + try { + unlinkSync(entry.configPath); + } catch { + // Already cleaned up + } + } + } + this.features.clear(); + } + + /** + * Build the environment object for a server child process. + * Only includes PATH and explicitly required env vars — no wholesale inheritance. + */ + private buildServerEnv(plugin: Plugin): Record { + const env: Record = {}; + + // Always pass PATH so the server can find its runtime + if (process.env.PATH) { + env.PATH = process.env.PATH; + } + + // Pass required env vars from the host environment + for (const varName of plugin.requiredEnvVars ?? []) { + const value = process.env[varName]; + if (value) { + env[varName] = value; + } + } + + // Pass active tool groups via the standard env var + if (plugin.activeToolGroups && plugin.activeToolGroups.length > 0) { + env.CLAUDE_FLOW_TOOL_GROUPS = plugin.activeToolGroups.join(','); + } + + return env; + } + + /** Send SIGTERM to a managed server process */ + private killProcess(server: ManagedServer): void { + try { + server.process.kill('SIGTERM'); + } catch { + // Process may have already exited + } + } +} diff --git a/packages/core/src/infrastructure/services/plugin/plugin-catalog.ts b/packages/core/src/infrastructure/services/plugin/plugin-catalog.ts new file mode 100644 index 000000000..afec4d47f --- /dev/null +++ b/packages/core/src/infrastructure/services/plugin/plugin-catalog.ts @@ -0,0 +1,125 @@ +/** + * Curated Plugin Catalog + * + * Static catalog of well-known AI-native tool plugins that ship with Shep. + * Users can browse and install these by name (e.g., `shep plugin add mempalace`). + * + * Follows the TOOL_METADATA pattern in tool-metadata.ts: + * type-safe, tree-shaken, no I/O required. + * + * To add a new catalog entry, append to the CATALOG array below. + */ + +import { PluginType, PluginTransport, type ToolGroup } from '../../../domain/generated/output.js'; + +/** + * Catalog entry describing a well-known plugin available for installation. + */ +export interface CatalogEntry { + /** Unique plugin name used as identifier */ + name: string; + /** Human-readable display name */ + displayName: string; + /** Integration type */ + type: PluginType; + /** Brief description of what this plugin provides */ + description: string; + /** Command to install the plugin package (e.g., 'pip install mempalace') */ + installCommand: string; + /** Command to start the MCP server (MCP type only) */ + serverCommand?: string; + /** Arguments for the server command (MCP type only) */ + serverArgs?: string[]; + /** MCP transport protocol (MCP type only) */ + transport?: PluginTransport; + /** Environment variable names required by this plugin (names only, never values) */ + requiredEnvVars: string[]; + /** Available tool groups for selective activation */ + toolGroups?: ToolGroup[]; + /** Required runtime: 'python' or 'node' */ + runtimeType: string; + /** Minimum runtime version (e.g., '3.9' for Python, '20' for Node.js) */ + runtimeMinVersion: string; + /** Plugin homepage or repository URL */ + homepageUrl: string; +} + +/** + * V1 curated catalog entries. + */ +const CATALOG: readonly CatalogEntry[] = [ + { + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + description: + 'Local AI memory system with persistent knowledge storage. Provides 19 MCP tools for managing long-term memory across AI sessions.', + installCommand: 'pip install mempalace', + serverCommand: 'python', + serverArgs: ['-m', 'mempalace.mcp_server'], + transport: PluginTransport.Stdio, + requiredEnvVars: [], + runtimeType: 'python', + runtimeMinVersion: '3.9', + homepageUrl: 'https://github.com/MemPalace/mempalace', + }, + { + name: 'token-optimizer', + displayName: 'Token Optimizer', + type: PluginType.Hook, + description: + 'Token waste reduction and context management via Claude Code lifecycle hooks. Optimizes token usage across sessions without requiring MCP.', + installCommand: 'pip install token-optimizer', + requiredEnvVars: [], + runtimeType: 'python', + runtimeMinVersion: '3.8', + homepageUrl: 'https://github.com/alexgreensh/token-optimizer', + }, + { + name: 'ruflo', + displayName: 'Ruflo', + type: PluginType.Mcp, + description: + 'Multi-agent AI orchestration framework with 313 MCP tools. Provides specialized agents for implementation, testing, memory, and workflow orchestration.', + installCommand: 'npm install -g ruflo@latest', + serverCommand: 'npx', + serverArgs: ['ruflo@latest', 'mcp', 'start'], + transport: PluginTransport.Stdio, + requiredEnvVars: ['ANTHROPIC_API_KEY'], + toolGroups: [ + { + name: 'implement', + description: 'Code implementation and generation tools', + }, + { + name: 'test', + description: 'Testing and quality assurance tools', + }, + { + name: 'memory', + description: 'Persistent memory and context management tools', + }, + { + name: 'flow', + description: 'Workflow orchestration and agent coordination tools', + }, + ], + runtimeType: 'node', + runtimeMinVersion: '20', + homepageUrl: 'https://github.com/ruvnet/ruflo', + }, +]; + +/** + * Returns a copy of all curated catalog entries. + */ +export function getCatalogEntries(): CatalogEntry[] { + return [...CATALOG]; +} + +/** + * Returns a single catalog entry by name, or undefined if not found. + */ +export function getCatalogEntry(name: string): CatalogEntry | undefined { + return CATALOG.find((entry) => entry.name === name); +} diff --git a/packages/core/src/infrastructure/services/plugin/plugin-health-checker.service.ts b/packages/core/src/infrastructure/services/plugin/plugin-health-checker.service.ts new file mode 100644 index 000000000..e16da20d6 --- /dev/null +++ b/packages/core/src/infrastructure/services/plugin/plugin-health-checker.service.ts @@ -0,0 +1,101 @@ +/** + * Plugin Health Checker Service + * + * Implements multi-tier health checks for plugins: + * Tier 1: Runtime on PATH (python3/node via which) + * Tier 2: Package installed (future - pip show/npx check) + * Tier 3: Required env vars present in process.env + * + * Follows ToolInstallerService pattern for runtime detection. + */ + +import { injectable } from 'tsyringe'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { PluginHealthStatus, type Plugin } from '../../../domain/generated/output.js'; +import type { + IPluginHealthChecker, + PluginHealthResult, +} from '../../../application/ports/output/services/plugin-health-checker.interface.js'; +import { IS_WINDOWS } from '../../platform.js'; + +const RUNTIME_BINARIES: Record = { + python: IS_WINDOWS ? ['python', 'python3'] : ['python3', 'python'], + node: ['node'], +}; + +const WHICH_COMMAND = IS_WINDOWS ? 'where' : 'which'; +const EXEC_TIMEOUT_MS = 5000; + +@injectable() +export class PluginHealthCheckerService implements IPluginHealthChecker { + private execFileAsync = promisify(execFile); + + async checkHealth(plugin: Plugin): Promise { + const checks: string[] = []; + + // Tier 1: Runtime availability + if (plugin.runtimeType) { + const runtimeFound = await this.checkRuntime(plugin.runtimeType); + if (!runtimeFound) { + return { + pluginName: plugin.name, + status: PluginHealthStatus.Unavailable, + message: `Required runtime "${plugin.runtimeType}" not found on PATH. Install ${plugin.runtimeType}${plugin.runtimeMinVersion ? ` ${plugin.runtimeMinVersion}+` : ''} and try again.`, + }; + } + checks.push(`Runtime "${plugin.runtimeType}" found`); + } + + // Tier 3: Environment variables + const missingEnvVars = this.checkEnvVars(plugin.requiredEnvVars ?? []); + if (missingEnvVars.length > 0) { + return { + pluginName: plugin.name, + status: PluginHealthStatus.Degraded, + message: `Missing required environment variables: ${missingEnvVars.join(', ')}. Add them to your shell profile or .env file.`, + }; + } + if ((plugin.requiredEnvVars ?? []).length > 0) { + checks.push('All required env vars set'); + } + + return { + pluginName: plugin.name, + status: PluginHealthStatus.Healthy, + message: checks.length > 0 ? checks.join('; ') : 'No runtime requirements', + }; + } + + async checkAllHealth(plugins: Plugin[]): Promise { + return Promise.all(plugins.map((plugin) => this.checkHealth(plugin))); + } + + /** + * Check if a runtime binary exists on PATH. + * Tries multiple binary names (e.g., python3 then python for python type). + */ + private async checkRuntime(runtimeType: string): Promise { + const binaries = RUNTIME_BINARIES[runtimeType] ?? [runtimeType]; + + for (const binary of binaries) { + try { + await this.execFileAsync(WHICH_COMMAND, [binary], { + timeout: EXEC_TIMEOUT_MS, + }); + return true; + } catch { + // Binary not found, try next + } + } + return false; + } + + /** + * Check that all required environment variables are present in process.env. + * Returns the names of missing variables. + */ + private checkEnvVars(requiredVars: string[]): string[] { + return requiredVars.filter((varName) => !process.env[varName] || process.env[varName] === ''); + } +} diff --git a/specs/089-ai-tool-plugin-system/evidence/app-create-feature-drawer.png b/specs/089-ai-tool-plugin-system/evidence/app-create-feature-drawer.png new file mode 100644 index 000000000..fa5cf83b4 Binary files /dev/null and b/specs/089-ai-tool-plugin-system/evidence/app-create-feature-drawer.png differ diff --git a/specs/089-ai-tool-plugin-system/evidence/app-plugins-page-catalog-tab.png b/specs/089-ai-tool-plugin-system/evidence/app-plugins-page-catalog-tab.png new file mode 100644 index 000000000..065136c9b Binary files /dev/null and b/specs/089-ai-tool-plugin-system/evidence/app-plugins-page-catalog-tab.png differ diff --git a/specs/089-ai-tool-plugin-system/evidence/app-plugins-page-installed-tab.png b/specs/089-ai-tool-plugin-system/evidence/app-plugins-page-installed-tab.png new file mode 100644 index 000000000..c04741fc6 Binary files /dev/null and b/specs/089-ai-tool-plugin-system/evidence/app-plugins-page-installed-tab.png differ diff --git a/specs/089-ai-tool-plugin-system/evidence/app-sidebar-plugins-nav.png b/specs/089-ai-tool-plugin-system/evidence/app-sidebar-plugins-nav.png new file mode 100644 index 000000000..3d896a960 Binary files /dev/null and b/specs/089-ai-tool-plugin-system/evidence/app-sidebar-plugins-nav.png differ diff --git a/specs/089-ai-tool-plugin-system/evidence/build-output.txt b/specs/089-ai-tool-plugin-system/evidence/build-output.txt new file mode 100644 index 000000000..de667d0b0 --- /dev/null +++ b/specs/089-ai-tool-plugin-system/evidence/build-output.txt @@ -0,0 +1,8 @@ + +> @shepai/cli@1.183.0 build /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-ai-tool-plugin-system +> pnpm build:cli + + +> @shepai/cli@1.183.0 build:cli /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-ai-tool-plugin-system +> tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json --resolve-full-paths && shx mkdir -p dist/packages/core/src/infrastructure/services/tool-installer && shx rm -rf dist/packages/core/src/infrastructure/services/tool-installer/tools && shx cp -r packages/core/src/infrastructure/services/tool-installer/tools dist/packages/core/src/infrastructure/services/tool-installer/tools && shx rm -rf dist/translations && shx cp -r translations dist/translations + diff --git a/specs/089-ai-tool-plugin-system/evidence/plugin-integration-tests.txt b/specs/089-ai-tool-plugin-system/evidence/plugin-integration-tests.txt new file mode 100644 index 000000000..11f30d388 --- /dev/null +++ b/specs/089-ai-tool-plugin-system/evidence/plugin-integration-tests.txt @@ -0,0 +1,27 @@ + + RUN v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-ai-tool-plugin-system + + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > migration > creates the plugins table with correct columns 59ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > migration > creates unique index on name column 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > migration > adds active_plugins column to features table 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > create() and findById() > creates and retrieves a plugin by id 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > create() and findById() > returns null for nonexistent id 13ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > create() and findById() > persists MCP-specific fields correctly 13ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > create() and findById() > persists toolGroups and activeToolGroups correctly 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > create() and findById() > persists Hook plugin fields correctly 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > create() and findById() > persists CLI plugin fields correctly 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > findByName() > finds a plugin by name 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > findByName() > returns null for nonexistent name 13ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > list() > returns empty array when no plugins exist 13ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > list() > returns all plugins ordered by name ascending 13ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > update() > updates all mutable fields 13ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > update() > updates health status correctly 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > delete() > removes a plugin by id 13ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > delete() > does not error when deleting nonexistent id 14ms + ✓ node tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts > SQLitePluginRepository > unique name constraint > rejects duplicate plugin names 13ms + + Test Files 1 passed (1) + Tests 18 passed (18) + Start at 09:23:14 + Duration 522ms (transform 119ms, setup 25ms, import 149ms, tests 290ms, environment 0ms) + diff --git a/specs/089-ai-tool-plugin-system/evidence/plugin-unit-tests.txt b/specs/089-ai-tool-plugin-system/evidence/plugin-unit-tests.txt new file mode 100644 index 000000000..33353570c --- /dev/null +++ b/specs/089-ai-tool-plugin-system/evidence/plugin-unit-tests.txt @@ -0,0 +1,144 @@ + + RUN v4.0.18 /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-ai-tool-plugin-system + + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should map required fields to snake_case columns 1ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should convert Date objects to unix milliseconds 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should map enabled=false to 0 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should serialize serverArgs as JSON string 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should serialize requiredEnvVars as JSON string 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should serialize toolGroups as JSON string 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should serialize activeToolGroups as JSON string 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should map empty arrays to null 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should map undefined optional fields to null 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should map MCP-specific fields when present 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should map Hook-specific fields when present 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > toDatabase > should map CLI-specific fields when present 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should map required columns to camelCase fields 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should convert unix milliseconds back to Date objects 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should map enabled=0 to false 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should deserialize serverArgs from JSON 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should deserialize requiredEnvVars from JSON 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should deserialize toolGroups from JSON 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should deserialize activeToolGroups from JSON 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should map null optional fields to undefined 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should map MCP-specific fields when present 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > fromDatabase > should map health status enum values correctly 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > round-trip > should preserve all fields through toDatabase -> fromDatabase 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > round-trip > should preserve minimal plugin through round-trip 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > round-trip > should preserve Hook plugin fields through round-trip 0ms + ✓ node tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts > Plugin Mapper > round-trip > should preserve CLI plugin fields through round-trip 0ms + ✓ node tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts > CheckPluginHealthUseCase > single plugin check > should check health of named plugin and update repo 2ms + ✓ node tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts > CheckPluginHealthUseCase > single plugin check > should throw when plugin not found 1ms + ✓ node tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts > CheckPluginHealthUseCase > single plugin check > should update repo with degraded status 0ms + ✓ node tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts > CheckPluginHealthUseCase > all plugins check > should check all plugins when no name provided 1ms + ✓ node tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts > CheckPluginHealthUseCase > all plugins check > should return empty array when no plugins installed 0ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > startPluginServers > starts MCP servers and returns config path when enabled MCP plugins exist 2ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > startPluginServers > filters out non-MCP plugins 0ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > startPluginServers > filters out disabled plugins 0ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > startPluginServers > returns undefined when no MCP plugins exist 0ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > startPluginServers > returns undefined and does not throw when startServersForFeature fails 0ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > startPluginServers > returns undefined when generateMcpConfigPath returns null 0ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > stopPluginServers > calls stopServersForFeature with the feature ID 0ms + ✓ node tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts > stopPluginServers > does not throw when stopServersForFeature fails 0ms + ✓ node tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts > ConfigurePluginUseCase > should update activeToolGroups with valid group names 4ms + ✓ node tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts > ConfigurePluginUseCase > should throw on invalid tool group name 1ms + ✓ node tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts > ConfigurePluginUseCase > should include available groups in error message 0ms + ✓ node tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts > ConfigurePluginUseCase > should throw when plugin not found 0ms + ✓ node tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts > ConfigurePluginUseCase > should allow empty activeToolGroups array 0ms + ✓ node tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts > ConfigurePluginUseCase > should handle plugin with no tool groups defined 0ms + ✓ node tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts > ConfigurePluginUseCase > should update the updatedAt timestamp 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts > PluginHealthCheckerService > checkHealth > should return Healthy when runtime and env vars are present 1ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts > PluginHealthCheckerService > checkHealth > should return Unavailable when runtime is missing 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts > PluginHealthCheckerService > checkHealth > should return Degraded when required env var is missing 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts > PluginHealthCheckerService > checkHealth > should return Healthy when no runtime type is specified 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts > PluginHealthCheckerService > checkHealth > should return Healthy when env vars are all present 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts > PluginHealthCheckerService > checkAllHealth > should check health of each plugin and return results 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts > PluginHealthCheckerService > checkAllHealth > should return empty array for empty input 0ms + ✓ node tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts > AddPluginUseCase > catalog-based install > should create plugin with catalog metadata for mempalace 2ms + ✓ node tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts > AddPluginUseCase > catalog-based install > should throw when catalog plugin not found 1ms + ✓ node tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts > AddPluginUseCase > catalog-based install > should throw when plugin already installed 0ms + ✓ node tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts > AddPluginUseCase > catalog-based install > should generate a UUID for the plugin id 0ms + ✓ node tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts > AddPluginUseCase > custom plugin install > should create custom MCP plugin with provided fields 0ms + ✓ node tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts > AddPluginUseCase > custom plugin install > should default displayName to name when not provided 0ms + ✓ node tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts > AddPluginUseCase > custom plugin install > should throw when custom plugin name already exists 0ms + ✓ node tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts > EnablePluginUseCase > should enable a disabled plugin and return enabled=true 3ms + ✓ node tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts > EnablePluginUseCase > should update the updatedAt timestamp 0ms + ✓ node tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts > EnablePluginUseCase > should throw when plugin not found 1ms + ✓ node tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts > DisablePluginUseCase > should disable an enabled plugin and return enabled=false 0ms + ✓ node tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts > DisablePluginUseCase > should update the updatedAt timestamp 0ms + ✓ node tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts > DisablePluginUseCase > should throw when plugin not found 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > startServersForFeature > should spawn a child process for each MCP plugin 4ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > startServersForFeature > should pass required env vars from process.env to child process 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > startServersForFeature > should skip non-MCP plugins 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > startServersForFeature > should skip plugins without serverCommand 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > startServersForFeature > should spawn multiple plugins for a feature 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > startServersForFeature > should increment reference count for shared servers across features 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > startServersForFeature > should pass activeToolGroups via env var for plugins that support them 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > stopServersForFeature > should kill process when refcount reaches zero 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > stopServersForFeature > should not kill process when other features still using it 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > stopServersForFeature > should be a no-op for unknown feature IDs 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > getActiveServers > should return empty array when no servers for feature 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > getActiveServers > should return active servers for a feature 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > generateMcpConfigPath > should return null when no active servers for feature 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > generateMcpConfigPath > should create a valid JSON file in os.tmpdir() 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > generateMcpConfigPath > should return same path on repeated calls for same feature 1ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > stopServersForFeature cleanup > should delete the temp config file on stop 0ms + ✓ node tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts > McpServerManagerService > shutdown > should kill all managed servers 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntries > should return an array with at least 3 entries 1ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntries > should contain mempalace, token-optimizer, and ruflo 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntries > should return a new array on each call (not mutable reference) 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntry > should return MemPalace entry with correct fields 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntry > should return Token Optimizer entry with Hook type 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntry > should return Ruflo entry with MCP type and env vars 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntry > should return Ruflo with tool groups defined 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntry > should return undefined for nonexistent entry 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntry > should have homepageUrl for all entries 0ms + ✓ node tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts > Plugin Catalog > getCatalogEntry > should have description for all entries 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > createPluginCommand() > returns a Commander Command instance 4ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > createPluginCommand() > has name "plugin" 1ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > createPluginCommand() > registers all 8 subcommands 1ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > add subcommand > returns a Command with name "add" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > add subcommand > calls AddPluginUseCase with catalog name 1ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > add subcommand > calls AddPluginUseCase with custom input 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > add subcommand > shows error for custom install without --name 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > add subcommand > shows error for custom install without --type 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > add subcommand > shows error for invalid --type value 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > add subcommand > handles use case errors gracefully 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > remove subcommand > returns a Command with name "remove" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > remove subcommand > calls RemovePluginUseCase with plugin name 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > remove subcommand > handles errors gracefully 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > list subcommand > returns a Command with name "list" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > list subcommand > calls ListPluginsUseCase and renders a list view 1ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > list subcommand > shows empty message when no plugins installed 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > enable subcommand > returns a Command with name "enable" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > enable subcommand > calls EnablePluginUseCase with plugin name 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > disable subcommand > returns a Command with name "disable" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > disable subcommand > calls DisablePluginUseCase with plugin name 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > configure subcommand > returns a Command with name "configure" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > configure subcommand > calls ConfigurePluginUseCase with tool groups 1ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > configure subcommand > shows info message when no options provided 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > status subcommand > returns a Command with name "status" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > status subcommand > calls CheckPluginHealthUseCase for specific plugin and renders detail view 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > status subcommand > calls CheckPluginHealthUseCase for all plugins and renders list view 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > status subcommand > shows info message when no plugins exist and status checked for all 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > catalog subcommand > returns a Command with name "catalog" 0ms + ✓ node tests/unit/commands/plugin/plugin-commands.test.ts > plugin command group > catalog subcommand > calls GetPluginCatalogUseCase and renders list view 0ms + ✓ node tests/unit/application/use-cases/plugins/remove-plugin.use-case.test.ts > RemovePluginUseCase > should remove an existing plugin and return it 3ms + ✓ node tests/unit/application/use-cases/plugins/remove-plugin.use-case.test.ts > RemovePluginUseCase > should throw when plugin not found 1ms + ✓ node tests/unit/application/use-cases/plugins/remove-plugin.use-case.test.ts > RemovePluginUseCase > should include plugin name in not found error 0ms + ✓ node tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts > ListPluginsUseCase > should return empty array when no plugins 1ms + ✓ node tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts > ListPluginsUseCase > should return all plugins when no filters 1ms + ✓ node tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts > ListPluginsUseCase > should filter by enabled=true 0ms + ✓ node tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts > ListPluginsUseCase > should filter by enabled=false 0ms + ✓ node tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts > ListPluginsUseCase > should filter by plugin type 0ms + ✓ node tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts > ListPluginsUseCase > should combine enabled and type filters 0ms + ✓ node tests/unit/application/use-cases/plugins/get-plugin-catalog.use-case.test.ts > GetPluginCatalogUseCase > should return all catalog entries 1ms + ✓ node tests/unit/application/use-cases/plugins/get-plugin-catalog.use-case.test.ts > GetPluginCatalogUseCase > should mark installed plugin as isInstalled=true 0ms + ✓ node tests/unit/application/use-cases/plugins/get-plugin-catalog.use-case.test.ts > GetPluginCatalogUseCase > should mark uninstalled plugin as isInstalled=false 0ms + ✓ node tests/unit/application/use-cases/plugins/get-plugin-catalog.use-case.test.ts > GetPluginCatalogUseCase > should show mixed install status across catalog 0ms + + Test Files 13 passed (13) + Tests 135 passed (135) + Start at 09:23:12 + Duration 360ms (transform 888ms, setup 307ms, import 1.09s, tests 73ms, environment 1ms) + diff --git a/specs/089-ai-tool-plugin-system/evidence/tsp-compile-output.txt b/specs/089-ai-tool-plugin-system/evidence/tsp-compile-output.txt new file mode 100644 index 000000000..9406e4e65 --- /dev/null +++ b/specs/089-ai-tool-plugin-system/evidence/tsp-compile-output.txt @@ -0,0 +1,8 @@ + +> @shepai/cli@1.183.0 tsp:compile /Users/arielshadkhan/.shep/repos/fbfd7efb528913ed/wt/feat-ai-tool-plugin-system +> tsp compile tsp/ + +TypeSpec compiler v0.60.1 + +Compilation completed successfully. + diff --git a/specs/089-ai-tool-plugin-system/feature.yaml b/specs/089-ai-tool-plugin-system/feature.yaml new file mode 100644 index 000000000..621e994b0 --- /dev/null +++ b/specs/089-ai-tool-plugin-system/feature.yaml @@ -0,0 +1,43 @@ +feature: + id: "089-ai-tool-plugin-system" + name: "ai-tool-plugin-system" + number: 89 + branch: "feat/089-ai-tool-plugin-system" + lifecycle: "research" + createdAt: "2026-04-13T16:14:59Z" +status: + phase: "implementation-complete" + progress: + completed: 30 + total: 30 + percentage: 100 + currentTask: null + lastUpdated: "2026-04-14T00:47:55.134Z" + lastUpdatedBy: "feature-agent:implement" + completedPhases: + - "analyze" + - "requirements" + - "research" + - "plan" + - "phase-1" + - "phase-2" + - "phase-3" + - "phase-4" + - "phase-5" + - "phase-6" + - "evidence" +validation: + lastRun: null + gatesPassed: [] + autoFixesApplied: [] +tasks: + current: null + blocked: [] + failed: [] +checkpoints: + - phase: "feature-created" + completedAt: "2026-04-13T16:14:59Z" + completedBy: "feature-agent" +errors: + current: null + history: [] diff --git a/specs/089-ai-tool-plugin-system/plan.yaml b/specs/089-ai-tool-plugin-system/plan.yaml new file mode 100644 index 000000000..193f4c9de --- /dev/null +++ b/specs/089-ai-tool-plugin-system/plan.yaml @@ -0,0 +1,302 @@ +# Implementation Plan (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: "ai-tool-plugin-system" +summary: > + Implementation plan for a pluggable AI-native tool system that integrates external tools + (MCP servers, CLIs, hook-based tools) into Shep's agentic SDLC workflows. Uses a dedicated + plugins SQLite table, child_process.spawn() for MCP server lifecycle, per-feature temp + .mcp.json files for agent executor injection, and a curated catalog for frictionless + plugin discovery. Six implementation phases from domain foundation through web UI. + +relatedFeatures: [] + +technologies: + - "TypeScript" + - "TypeSpec" + - "tsyringe (DI)" + - "LangGraph (workflow orchestration)" + - "SQLite / better-sqlite3 (persistence)" + - "Node.js child_process (MCP server spawning)" + - "Commander.js (CLI)" + - "Next.js (web UI)" + - "shadcn/ui (component library)" + - "umzug (database migrations)" + +relatedLinks: + - title: "Model Context Protocol specification" + url: "https://modelcontextprotocol.io" + - title: "Claude Code MCP configuration docs" + url: "https://docs.anthropic.com/en/docs/claude-code/mcp" + - title: "MemPalace - MCP stdio plugin example" + url: "https://github.com/MemPalace/mempalace" + - title: "Token Optimizer - Hook-based plugin example" + url: "https://github.com/alexgreensh/token-optimizer" + - title: "Ruflo - Large MCP server example (313 tools)" + url: "https://github.com/ruvnet/ruflo" + +phases: + - id: "phase-1" + name: "Domain Foundation" + description: > + Define the Plugin entity, enums (PluginType, PluginTransport, PluginHealthStatus), + and ToolGroup model in TypeSpec. Compile to generate TypeScript types. This phase + establishes the domain vocabulary that all other layers depend on. + parallel: false + + - id: "phase-2" + name: "Persistence Layer" + description: > + Create the plugins SQLite table (migration 060), the active_plugins column on features + (migration 061), the PluginRow mapper, and the SQLitePluginRepository. Also extend the + Feature mapper to handle activePlugins JSON serialization. This phase provides the + storage foundation for the entire plugin subsystem. + parallel: false + + - id: "phase-3" + name: "Core Services and Use Cases" + description: > + Define port interfaces (IPluginRepository, IMcpServerManager, IPluginHealthChecker) + and implement all eight use cases (AddPlugin, RemovePlugin, ListPlugins, EnablePlugin, + DisablePlugin, ConfigurePlugin, CheckPluginHealth, GetPluginCatalog). Implement the + curated catalog provider and plugin health checker service. Wire everything into the + DI container with string token aliases for web access. + parallel: false + + - id: "phase-4" + name: "MCP Server Lifecycle and Agent Integration" + description: > + Implement McpServerManagerService with child_process.spawn(), reference counting for + concurrent features, per-feature temp .mcp.json generation, and process signal cleanup. + Extend AgentExecutionOptions with mcpConfigPath. Modify ClaudeCodeExecutorService.buildArgs() + to inject --mcp-config flag. Integrate plugin startup/shutdown into the feature agent worker. + parallel: false + + - id: "phase-5" + name: "CLI Commands" + description: > + Create the plugin command group with eight subcommands: add, remove, list, enable, + disable, configure, status, catalog. Follows the settings command group pattern + (Commander.js with createPluginCommand() factory). Register in the main CLI entry point. + parallel: false + + - id: "phase-6" + name: "Web UI" + description: > + Build the plugin management page (installed plugins list with status badges and toggles), + curated catalog browser with install buttons, plugin detail/configure view, and + per-feature plugin activation toggles in the create-feature drawer. Create server + actions backed by the same use cases as the CLI. + parallel: false + +filesToCreate: + # TypeSpec domain models + - "tsp/domain/entities/plugin.tsp" + - "tsp/common/enums/plugin.tsp" + + # Port interfaces + - "packages/core/src/application/ports/output/repositories/plugin-repository.interface.ts" + - "packages/core/src/application/ports/output/services/mcp-server-manager.interface.ts" + - "packages/core/src/application/ports/output/services/plugin-health-checker.interface.ts" + + # Use cases + - "packages/core/src/application/use-cases/plugins/add-plugin.use-case.ts" + - "packages/core/src/application/use-cases/plugins/remove-plugin.use-case.ts" + - "packages/core/src/application/use-cases/plugins/list-plugins.use-case.ts" + - "packages/core/src/application/use-cases/plugins/enable-plugin.use-case.ts" + - "packages/core/src/application/use-cases/plugins/disable-plugin.use-case.ts" + - "packages/core/src/application/use-cases/plugins/configure-plugin.use-case.ts" + - "packages/core/src/application/use-cases/plugins/check-plugin-health.use-case.ts" + - "packages/core/src/application/use-cases/plugins/get-plugin-catalog.use-case.ts" + + # Infrastructure - persistence + - "packages/core/src/infrastructure/persistence/sqlite/migrations/060-create-plugins-table.ts" + - "packages/core/src/infrastructure/persistence/sqlite/migrations/061-add-active-plugins-to-features.ts" + - "packages/core/src/infrastructure/persistence/sqlite/mappers/plugin.mapper.ts" + - "packages/core/src/infrastructure/persistence/sqlite/repositories/sqlite-plugin.repository.ts" + + # Infrastructure - services + - "packages/core/src/infrastructure/services/plugin/mcp-server-manager.service.ts" + - "packages/core/src/infrastructure/services/plugin/plugin-health-checker.service.ts" + - "packages/core/src/infrastructure/services/plugin/plugin-catalog.ts" + + # CLI commands + - "src/presentation/cli/commands/plugin/index.ts" + - "src/presentation/cli/commands/plugin/add.command.ts" + - "src/presentation/cli/commands/plugin/remove.command.ts" + - "src/presentation/cli/commands/plugin/list.command.ts" + - "src/presentation/cli/commands/plugin/enable.command.ts" + - "src/presentation/cli/commands/plugin/disable.command.ts" + - "src/presentation/cli/commands/plugin/configure.command.ts" + - "src/presentation/cli/commands/plugin/status.command.ts" + - "src/presentation/cli/commands/plugin/catalog.command.ts" + + # Web UI + - "src/presentation/web/app/plugins/page.tsx" + - "src/presentation/web/components/common/plugin-list/plugin-list.tsx" + - "src/presentation/web/components/common/plugin-list/plugin-list.stories.tsx" + - "src/presentation/web/components/common/plugin-catalog/plugin-catalog.tsx" + - "src/presentation/web/components/common/plugin-catalog/plugin-catalog.stories.tsx" + - "src/presentation/web/app/actions/list-plugins.ts" + - "src/presentation/web/app/actions/add-plugin.ts" + - "src/presentation/web/app/actions/remove-plugin.ts" + - "src/presentation/web/app/actions/toggle-plugin.ts" + - "src/presentation/web/app/actions/get-plugin-catalog.ts" + - "src/presentation/web/app/actions/check-plugin-health.ts" + - "src/presentation/web/app/actions/configure-plugin.ts" + + # Tests + - "packages/core/src/infrastructure/persistence/sqlite/mappers/__tests__/plugin.mapper.test.ts" + - "packages/core/src/infrastructure/persistence/sqlite/repositories/__tests__/sqlite-plugin.repository.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/add-plugin.use-case.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/remove-plugin.use-case.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/list-plugins.use-case.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/enable-plugin.use-case.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/disable-plugin.use-case.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/configure-plugin.use-case.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/check-plugin-health.use-case.test.ts" + - "packages/core/src/application/use-cases/plugins/__tests__/get-plugin-catalog.use-case.test.ts" + - "packages/core/src/infrastructure/services/plugin/__tests__/mcp-server-manager.service.test.ts" + - "packages/core/src/infrastructure/services/plugin/__tests__/plugin-health-checker.service.test.ts" + - "packages/core/src/infrastructure/services/plugin/__tests__/plugin-catalog.test.ts" + +filesToModify: + # TypeSpec + - "tsp/common/enums/index.tsp" + - "tsp/domain/entities/feature.tsp" + + # Generated output (auto-generated from tsp:compile) + - "packages/core/src/domain/generated/output.ts" + + # Port index files + - "packages/core/src/application/ports/output/repositories/index.ts" + - "packages/core/src/application/ports/output/services/index.ts" + + # Agent execution options + - "packages/core/src/application/ports/output/agents/agent-executor.interface.ts" + + # Infrastructure - existing services + - "packages/core/src/infrastructure/services/agents/common/executors/claude-code-executor.service.ts" + - "packages/core/src/infrastructure/services/agents/feature-agent/feature-agent-worker.ts" + - "packages/core/src/infrastructure/services/agents/feature-agent/nodes/node-helpers.ts" + - "packages/core/src/infrastructure/services/agents/feature-agent/state.ts" + + # Persistence - existing mappers + - "packages/core/src/infrastructure/persistence/sqlite/mappers/feature.mapper.ts" + - "packages/core/src/infrastructure/persistence/sqlite/repositories/sqlite-feature.repository.ts" + + # DI container + - "packages/core/src/infrastructure/di/container.ts" + + # CLI entry point + - "src/presentation/cli/commands/index.ts" + + # Web UI - existing components + - "src/presentation/web/components/common/feature-create-drawer/feature-create-drawer.tsx" + - "src/presentation/web/app/(dashboard)/layout.tsx" + +openQuestions: [] + +content: | + ## Architecture Overview + + The AI Tool Plugin System introduces a new subsystem that spans all four Clean Architecture layers, + following the same patterns used by the existing Features, Settings, and Tools subsystems. + + **Domain Layer (TypeSpec):** A new Plugin entity with PluginType discriminator (Mcp/Hook/Cli) and + optional type-specific fields. Three new enums: PluginType, PluginTransport (Stdio/Http), and + PluginHealthStatus (Healthy/Degraded/Unavailable/Unknown). A ToolGroup value object for MCP tool + filtering. All defined in TypeSpec and compiled to TypeScript via `pnpm tsp:compile`. + + **Application Layer:** Three new port interfaces (IPluginRepository, IMcpServerManager, + IPluginHealthChecker) following the exact patterns of IFeatureRepository, IToolInstallerService, + and ISkillInjectorService. Eight use cases with single execute() methods, injectable via tsyringe. + + **Infrastructure Layer:** SQLitePluginRepository with PluginRow mapper (camelCase/snake_case + conversion, JSON serialization for arrays). McpServerManagerService using child_process.spawn() + with reference counting. PluginHealthCheckerService with tiered checks. Static catalog module. + Two additive-only migrations (060 for plugins table, 061 for features.active_plugins column). + + **Presentation Layer:** CLI plugin command group (8 subcommands) following the settings command + pattern. Web UI plugin management page with catalog browser, following existing page patterns. + + ## Key Design Decisions + + ### 1. child_process.spawn() over @modelcontextprotocol/sdk + Shep is an orchestrator, not an MCP client. It spawns MCP server processes and passes their + connection info to agent executors (Claude Code, Cursor) which have their own MCP clients. + The codebase already uses child_process extensively in ClaudeCodeExecutorService and + ToolInstallerService. No new dependency needed. + + ### 2. Per-feature temp .mcp.json files + Claude Code accepts --mcp-config to load MCP server definitions. A temp file per-feature + in os.tmpdir() is isolated, avoids conflicts with user's .mcp.json, and maps directly to the + existing buildArgs() pattern where disableMcp already translates to --strict-mcp-config. + New mcpConfigPath field on AgentExecutionOptions carries the path through the agent-agnostic + interface. + + ### 3. Dedicated plugins table (not settings extension) + Settings is a large singleton (100+ columns). Plugins are a collection with CRUD lifecycle. + Follows the same pattern as features table: separate entity, repository, mapper, migration. + Migration 060 creates the table; migration 061 adds active_plugins JSON column to features. + + ### 4. Single Plugin entity with type discriminator + Rather than three separate entities (McpPlugin, HookPlugin, CliPlugin), a single entity with + PluginType enum and optional type-specific fields keeps the registry simple. Maps cleanly to + one table with nullable columns, following the Feature entity pattern where fields like prUrl + are only populated in certain lifecycle stages. + + ### 5. Pre-spawn per feature with reference counting + MCP servers start when a feature begins execution and stop when it completes. Reference counting + handles concurrent features sharing the same plugin. Process signal handlers ensure cleanup on + crash. This avoids per-node cold starts and always-on daemon infrastructure. + + ### 6. Hook plugins delegate to Claude Code native hooks + The plugin system manages installation of hook scripts into .claude/hooks/, not execution. + Claude Code's native hook system handles SessionStart, PreToolUse, PostToolUse, etc. + Analogous to SkillInjectorService copying skill files into worktrees. + + ### 7. Environment variables: names only, never values + Matches the ANTHROPIC_API_KEY pattern. Plugin registry stores required env var names. + At spawn time, values are read from process.env and passed to child process. + No secrets in SQLite. Missing vars produce actionable error messages. + + ### 8. Static TypeScript catalog (not JSON file or DB) + Follows the TOOL_METADATA pattern in tool-metadata.ts. Type-safe, tree-shaken, no I/O. + Easily extensible by editing one file. V1 ships with MemPalace, Token Optimizer, and Ruflo. + + ## Implementation Strategy + + The six phases are ordered by dependency: + + 1. **Domain Foundation first** because all other layers import from generated types. + 2. **Persistence second** because use cases need IPluginRepository, and the feature mapper + extension (active_plugins) is needed for per-feature integration later. + 3. **Core Services and Use Cases third** because they define the API that both CLI and Web use. + The catalog and health checker are pure logic with no external dependencies, easy to TDD. + 4. **MCP Server Lifecycle fourth** because it's the most complex service and depends on use cases + being wired. This phase also modifies the agent execution pipeline (AgentExecutionOptions, + ClaudeCodeExecutorService, feature agent worker). + 5. **CLI fifth** because it's a thin layer over use cases. Each command resolves a use case + from DI and calls execute(). Quick to implement once use cases are tested. + 6. **Web UI last** because it depends on server actions that call the same use cases as CLI. + The catalog browser and plugin list follow established page patterns. + + Each phase is TDD-driven: write failing tests first, implement minimally, then refactor. + + ## Risk Mitigation + + | Risk | Mitigation | + | ---- | ---------- | + | New required fields on Feature entity break 20+ test fixtures | Use optional field (activePlugins?) so existing fixtures are unaffected. Only Feature tests that explicitly test plugin activation need updating. | + | MCP server orphan processes after crash | Register cleanup handlers for SIGTERM, SIGINT, and beforeExit. Use a WeakRef + FinalizationRegistry as safety net. Integration test verifies cleanup. | + | Migration breaks backward compatibility | Both migrations are additive-only (CREATE TABLE, ALTER TABLE ADD COLUMN). No drops or renames. Per LESSONS.md. | + | Per-feature settings not flowing through all 12 layers | active_plugins is a JSON column on features, not a new boolean. It flows through the Feature entity naturally. The feature agent worker reads it at startup, not per-node. Simpler than the per-feature boolean pattern. | + | DI container string token aliases missed for web | Checklist item in the DI registration task. Per LESSONS.md, every use case called from web MUST have a string alias. | + | Claude Code --mcp-config flag behavior changes | Integration test with a real lightweight MCP server validates the end-to-end path. Flag is already documented in Claude Code docs. | + | Large catalog entries (Ruflo 313 tools) overwhelm agent | Tool group filtering via CLAUDE_FLOW_TOOL_GROUPS env var limits active tools. Default active groups are conservative. | + | INSERT/UPDATE SQL missing new columns | Per LESSONS.md, integration tests create/update a feature with activePlugins and read it back. The test fails immediately if SQL is incomplete. | + + --- + + _Implementation plan created 2026-04-13_ diff --git a/specs/089-ai-tool-plugin-system/research.yaml b/specs/089-ai-tool-plugin-system/research.yaml new file mode 100644 index 000000000..1455b907a --- /dev/null +++ b/specs/089-ai-tool-plugin-system/research.yaml @@ -0,0 +1,586 @@ +name: ai-tool-plugin-system +summary: > + Technical research for the AI Tool Plugin System covering MCP server lifecycle management, + plugin registry persistence, agent execution integration, and CLI/web presentation patterns. + Key decisions: use Node.js child_process (not @modelcontextprotocol/sdk) for MCP server spawning, + a dedicated plugins SQLite table with its own repository/mapper, a new mcpServers field on + AgentExecutionOptions for agent-agnostic injection, and Claude Code --mcp-config temp file + generation for executor integration. + +relatedFeatures: [] + +technologies: + - "TypeScript" + - "TypeSpec" + - "tsyringe (DI)" + - "LangGraph (workflow orchestration)" + - "SQLite / better-sqlite3 (persistence)" + - "Model Context Protocol (MCP)" + - "Node.js child_process (MCP server spawning)" + - "Next.js (web UI)" + - "Commander.js (CLI)" + - "shadcn/ui (component library)" + - "umzug (database migrations)" + +relatedLinks: + - title: "MCP specification" + url: "https://modelcontextprotocol.io" + - title: "Claude Code MCP configuration docs" + url: "https://docs.anthropic.com/en/docs/claude-code/mcp" + - title: "MemPalace - MCP stdio plugin example" + url: "https://github.com/MemPalace/mempalace" + - title: "Token Optimizer - Hook-based plugin example" + url: "https://github.com/alexgreensh/token-optimizer" + - title: "Ruflo - Large MCP server example (313 tools)" + url: "https://github.com/ruvnet/ruflo" + - title: "@modelcontextprotocol/sdk npm package" + url: "https://www.npmjs.com/package/@modelcontextprotocol/sdk" + +decisions: + - title: "MCP Server Process Management Strategy" + chosen: "Node.js child_process.spawn() with reference counting" + rejected: + - "@modelcontextprotocol/sdk Client class -- the SDK provides a high-level MCP client for connecting to servers and calling tools, but Shep does not call MCP tools directly. Shep spawns MCP servers as child processes and passes their connection info to agent executors (Claude Code, Cursor, etc.) which have their own MCP clients. The SDK would add unnecessary dependency and complexity." + - "Docker containers for MCP servers -- provides isolation but adds Docker as a hard dependency, increases startup latency significantly (seconds vs milliseconds), and most MCP servers are lightweight Node.js/Python scripts that do not need containerization." + rationale: > + Shep acts as a process orchestrator, not an MCP client. The existing codebase already uses + child_process extensively (ClaudeCodeExecutorService spawns claude processes, + ToolInstallerService uses execFile for runtime detection). MCP servers for stdio transport + are just child processes with stdin/stdout pipes. The spawn pattern is well-established in + the codebase and requires no new dependencies. Reference counting handles concurrent features + sharing a server instance -- increment on feature start, decrement on feature stop, kill + process when count reaches zero. + + - title: "MCP Config Injection into Agent Executors" + chosen: "Generate temporary .mcp.json file and pass via --mcp-config flag" + rejected: + - "Inline JSON via CLI argument -- Claude Code supports --mcp-config with a file path, not inline JSON. Passing complex nested JSON as a CLI argument is fragile (shell escaping, argument length limits on Windows)." + - "Modify worktree .mcp.json directly -- the worktree may have its own .mcp.json from the user's repo. Modifying it would conflict with user config and require cleanup. A temp file avoids this entirely." + - "Environment variable injection -- Claude Code reads MCP config from files and CLI flags, not from environment variables. No env var equivalent exists for MCP server configuration." + rationale: > + Claude Code accepts --mcp-config to load MCP server definitions from a JSON file. + The format is: {"mcpServers": {"name": {"type": "stdio", "command": "...", "args": [...], "env": {...}}}}. + Generating a temp file per-feature with active plugin MCP servers and passing its path to the + executor is clean, isolated, and does not interfere with user-level MCP config. The temp file + is created in os.tmpdir() and cleaned up when the feature completes. This maps directly to the + existing buildArgs() pattern in ClaudeCodeExecutorService where flags like --tools and + --strict-mcp-config are already conditionally added. The new field on AgentExecutionOptions + (mcpConfigPath) carries the temp file path through the agent-agnostic interface. + + - title: "Plugin Registry Persistence" + chosen: "Dedicated plugins SQLite table with separate repository and mapper" + rejected: + - "Extend Settings entity with plugin columns -- Settings is already a large singleton table (100+ columns). Plugin config is a collection (multiple rows) with its own CRUD lifecycle (add, remove, enable, update). Flattening multiple plugins into a single settings row requires JSON serialization of arrays, losing queryability and making concurrent access difficult." + - "File-based JSON config (.shep/plugins.json) -- diverges from the established SQLite persistence pattern used by features, settings, agent runs, and repositories. Loses transactional integrity, requires manual file locking for concurrent access, and cannot leverage the existing migration framework." + rationale: > + The codebase consistently uses SQLite for all persistent state: features table, settings table, + repositories table, agent_runs table, applications table. Each has its own repository interface + (IFeatureRepository, ISettingsRepository, IRepositoryRepository), mapper (toDatabase/fromDatabase), + and migration. The plugins table follows the exact same pattern. The latest migration is 059, + so the new plugins table migration will be 060. The mapper converts between PascalCase TypeSpec + domain types and snake_case SQL columns, following the established FeatureRow/fromDatabase/toDatabase + pattern in feature.mapper.ts. + + - title: "Per-Feature Plugin Activation Storage" + chosen: "JSON-serialized plugin overrides column on the features table" + rejected: + - "Separate feature_plugins junction table -- adds a new table and repository just for a simple key-value mapping. Overkill when the Feature entity already stores similar config as serialized JSON (e.g., injected_skills column stores a JSON array of skill names)." + - "Store in Settings entity per-feature -- Settings is a singleton with no per-feature context. Per-feature overrides must live on the Feature entity, following the established pattern for forkAndPr, commitSpecs, enableEvidence, etc." + rationale: > + The existing pattern for per-feature configuration is direct columns on the features table. + However, plugin overrides are a dynamic set (not a fixed list of booleans like forkAndPr). + The injected_skills column precedent shows that JSON-serialized arrays/objects are stored as + TEXT columns in the features table. A new active_plugins TEXT column stores a JSON object + mapping plugin names to boolean activation states (e.g., {"mempalace": true, "ruflo": false}). + The Feature entity gets an optional activePlugins field of type Record. + The feature.mapper.ts handles JSON serialization/deserialization, following the exact pattern + used for injected_skills. The 12-layer settings propagation (from LESSONS.md) applies: + global default from plugin registry enabled state -> per-feature override from active_plugins. + + - title: "Plugin Domain Model Design" + chosen: "Single Plugin entity with type discriminator and optional type-specific fields" + rejected: + - "Separate entities per plugin type (McpPlugin, HookPlugin, CliPlugin) -- creates three entities, three repositories, three mappers, and three tables for what is conceptually one registry. Adds significant complexity and makes cross-type queries (list all plugins) require UNION queries or multiple repository calls." + - "Plugin entity with embedded PluginConfig value object hierarchy -- TypeSpec does not support discriminated unions or algebraic data types. A nested value object hierarchy would require runtime type guards and complex mapper logic for each variant." + rationale: > + A single Plugin entity with a PluginType enum discriminator (Mcp, Hook, Cli) keeps the + registry simple. MCP-specific fields (transport, serverCommand, serverArgs) are optional and + only populated for type=Mcp. Hook-specific fields (hookType, scriptPath) are optional and + only populated for type=Hook. CLI-specific fields (binaryCommand) are optional for type=Cli. + This maps cleanly to one SQLite table with nullable columns for type-specific fields. + The TypeSpec model uses optional fields (?) for type-specific properties. The mapper validates + that type-specific fields are present when the type demands them. This follows the existing + Feature entity pattern where fields like prUrl, prNumber, prStatus are only populated when + the feature reaches certain lifecycle stages. + + - title: "MCP Server Lifecycle Scope" + chosen: "Per-feature server instances with reference counting for shared access" + rejected: + - "Global singleton servers (always-on daemon) -- wastes resources when no features are running. Requires daemon management infrastructure (systemd/launchd integration). Conflicts with the principle that MCP servers are only needed during active agent execution." + - "Per-node ephemeral servers (start/stop each graph node) -- adds cold-start latency (up to 10s per MCP server per node transition). Ruflo with its 313 tools would be especially slow to reinitialize. Adds complexity for managing rapid start/stop cycles." + rationale: > + Pre-spawn per feature balances simplicity and efficiency. When a feature starts execution, + the McpServerManager starts all enabled MCP servers for that feature. The servers stay alive + for the entire feature duration (minutes to hours). When the feature completes, pauses, or + fails, the servers are stopped. For concurrent features sharing the same plugin, reference + counting tracks how many features are using each server instance. The server process is + killed only when the last reference is released. This avoids duplicate processes while + ensuring cleanup. Process signal handlers (SIGTERM, SIGINT, beforeExit) ensure orphaned + servers are killed if Shep crashes. + + - title: "Plugin Health Check Strategy" + chosen: "Multi-tier health check with runtime detection, package verification, env var validation, and server probe" + rejected: + - "Simple binary-exists check only -- misses cases where the binary exists but the package is not installed (e.g., python3 is on PATH but mempalace pip package is missing). Also misses missing environment variables that cause the server to crash on startup." + - "Full MCP handshake probe only -- too slow for on-demand checks (requires spawning the server, waiting for initialization, sending an MCP initialize request, and shutting down). Overkill for quick status display in CLI list and web UI." + rationale: > + The existing ToolInstallerService uses a tiered approach: checkBinaryExists() first, then + verifyCommand() as fallback. The plugin health checker extends this to four tiers: + (1) Runtime available -- python3/node on PATH via which/where (reuse ToolInstallerService pattern). + (2) Package installed -- pip show mempalace / npx ruflo --version (non-interactive, short timeout). + (3) Required env vars set -- check process.env for each required var name from plugin config. + (4) Server probe (optional, on-demand only) -- spawn server, wait for stdout ready signal, kill. + Tiers 1-3 run in <1s and are used for CLI list display and pre-flight checks. Tier 4 is + only used for explicit health check commands. Health status stored as enum: + Healthy (all tiers pass), Degraded (runtime present but package/env issues), Unavailable + (runtime missing or critical failure). + + - title: "Curated Plugin Catalog Storage" + chosen: "Static TypeScript constant array in a dedicated catalog module" + rejected: + - "External JSON file loaded at runtime -- requires file I/O on every catalog access, path resolution across platforms, and error handling for missing/corrupt files. The catalog is small (3-10 entries for V1) and changes only with code releases." + - "Database-seeded catalog -- adds migration complexity to insert/update catalog entries. The catalog is read-only reference data, not user-mutable state. Putting it in SQLite mixes reference data with user data." + rationale: > + A TypeScript constant array (CatalogEntry[]) in a module like plugin-catalog.ts is the + simplest approach for V1. It is type-safe, tree-shaken, requires no I/O, and is easily + extensible by editing one file. Each entry contains: name, displayName, type, description, + installCommand, serverCommand, serverArgs, transport, requiredEnvVars, toolGroups, + runtimeRequirements, and homepageUrl. The catalog module exports a function + getCatalogEntries() that returns the array. This follows the existing TOOL_METADATA pattern + in tool-metadata.ts (tool-installer service) where tool definitions are static TypeScript + objects. Moving to a JSON file or database can be done later if the catalog grows + significantly. + + - title: "Agent Execution Options Extension" + chosen: "New optional mcpConfigPath field on AgentExecutionOptions interface" + rejected: + - "New mcpServers field with structured MCP server definitions -- requires every executor to understand MCP server configuration format. Agent-type-specific translation logic would leak into the common interface. A file path is universally consumable." + - "Extend systemPrompt to include MCP instructions -- MCP servers are not prompt-level concerns. They are tool-level concerns that require process-level configuration (CLI flags), not prompt injection." + rationale: > + AgentExecutionOptions already has agent-agnostic fields (cwd, timeout, model, maxTurns) that + executors translate to their CLI-specific flags. Adding mcpConfigPath?: string follows this + pattern perfectly. The McpServerManager generates the temp .mcp.json file and sets the path + on the options. ClaudeCodeExecutorService translates it to --mcp-config in buildArgs(). + CursorExecutorService can translate it to its equivalent flag. Future executors map it to + their own MCP config mechanism. The interface stays agent-agnostic while each executor + handles the translation. The existing disableMcp field (which adds --strict-mcp-config) + serves as direct precedent for MCP-related options on this interface. + + - title: "Hook-Based Plugin Integration" + chosen: "Delegate to Claude Code native hook system via .claude/hooks/ directory management" + rejected: + - "Custom hook runtime within Shep -- massive scope increase. Would require implementing hook lifecycle (pre-tool, post-tool, session-start, etc.) within Shep's agent execution pipeline. Claude Code already has a mature hook system that Token Optimizer targets." + - "Ignore hook plugins entirely in V1 -- excludes Token Optimizer and similar tools from the plugin system. The spec explicitly requires all three plugin types." + rationale: > + Claude Code has a native hooks system at .claude/hooks/ (or project-level). Token Optimizer + installs itself as Claude Code hooks (SessionStart, PreToolUse, PostToolUse, etc.). The + plugin system does not need to implement its own hook runtime -- it just needs to manage the + installation of hook scripts into the appropriate directory. For hook-type plugins, the + install use case copies/symlinks the hook scripts to .claude/hooks/ in the worktree. The + remove use case cleans them up. Health checks verify script existence and runtime + availability (python3 for Token Optimizer). This is analogous to how the SkillInjectorService + copies skill files into the worktree -- file management, not execution management. The + hook scripts are executed by Claude Code itself during agent execution, not by Shep. + + - title: "CLI Command Organization" + chosen: "New top-level plugin command group with subcommands" + rejected: + - "Nest under settings command (shep settings plugin ...) -- plugins are a first-class subsystem, not a settings concern. The settings command manages global configuration. Plugins have their own lifecycle (install, remove, health check) that does not fit the settings mental model." + - "Individual top-level commands (shep plugin-add, shep plugin-remove, etc.) -- violates the established command group pattern used by settings. Commander.js supports nested commands naturally." + rationale: > + The codebase uses Commander.js command groups with addCommand() for related subcommands. + The settings command demonstrates this pattern perfectly: settings/index.ts creates the group, + individual files like agent.command.ts define subcommands. The plugin command group follows + the exact same pattern: plugin/index.ts exports createPluginCommand() which adds subcommands + for add, remove, list, enable, disable, configure, status, and catalog. Each subcommand + resolves its use case from the DI container and executes it. The main CLI entry point adds + createPluginCommand() alongside createSettingsCommand(). + + - title: "Web UI Plugin Management Scope" + chosen: "Plugin list page with status indicators plus curated catalog browser" + rejected: + - "Configuration management only (no catalog browser) -- forces users to use CLI for discovering and installing plugins. The web UI should provide a complete plugin management experience." + - "Full marketplace with search, ratings, and community submissions -- massive scope increase requiring backend APIs, user accounts for ratings, and community moderation. Out of scope for V1." + rationale: > + The web UI provides two views: (1) Installed Plugins list showing name, type, health status + badge, enabled/disabled toggle switch, and actions (configure, remove). This follows the + existing settings page pattern with Card/CardHeader/CardContent layout and Switch toggles. + (2) Catalog Browser showing available plugins from the curated catalog with Install buttons. + Each catalog entry shows name, description, type badge, and runtime requirements. Server + actions call the same use cases as the CLI (AddPluginUseCase, RemovePluginUseCase, + EnablePluginUseCase, etc.). The create-feature drawer gets a plugin activation section + with toggles for each installed plugin, following the existing per-feature settings pattern + in the drawer. + + - title: "Environment Variable Handling" + chosen: "Reference from system environment at runtime -- store only var names, never values" + rejected: + - "Store env var values in SQLite -- puts secrets (API keys, tokens) in an unencrypted database file on disk. Violates NFR-6 (no secret storage). The SQLite DB is not encrypted and can be read by any process with file access." + - "Encrypted keychain storage -- adds significant complexity (platform-specific keychain integration for macOS Keychain, Windows Credential Manager, Linux Secret Service). Out of scope for V1. The spec's open question selected this but the rationale clearly describes the env reference approach." + rationale: > + The existing codebase follows this exact pattern for all sensitive configuration. + ANTHROPIC_API_KEY for agent executors is read from process.env at runtime, never stored + in SQLite. The plugin registry stores only the names of required environment variables + (e.g., ["ANTHROPIC_API_KEY"] for Ruflo). At MCP server spawn time, the system checks + process.env for each required var and passes them to the child process env. Missing vars + produce a clear error message: "Plugin 'ruflo' requires ANTHROPIC_API_KEY to be set. + Add it to your shell profile or .env file." This is the simplest, most secure approach + and requires zero new infrastructure. + + - title: "Tool Group Filtering Implementation" + chosen: "Static tool group definitions in plugin catalog and registry" + rejected: + - "No filtering (all-or-nothing) -- Ruflo exposes 313 MCP tools. Claude Code has practical performance degradation above ~40 tools. Enabling Ruflo without filtering would overwhelm the agent context." + - "AI-driven dynamic filtering per graph node -- complex, unpredictable, requires per-node tool mapping configuration that is hard to maintain. The spec's open question selected this but the rationale clearly describes tool group filtering." + rationale: > + Tool groups are metadata defined in the plugin catalog. Ruflo already uses CLAUDE_FLOW_TOOL_GROUPS + env var to specify which tool groups to load. The plugin system formalizes this: each catalog + entry defines available tool groups (e.g., Ruflo has implement, test, memory, flow). When + a plugin is installed, the user selects which tool groups to enable. The McpServerManager + passes the selected groups to the server process via its native env var mechanism (e.g., + CLAUDE_FLOW_TOOL_GROUPS=implement,test for Ruflo). The plugin configuration stores + activeToolGroups: string[] in the registry. This requires no MCP protocol extensions -- it + leverages each tool's existing group filtering mechanism. + +openQuestions: + - question: "Should the @modelcontextprotocol/sdk package be added as a dependency?" + resolved: true + options: + - option: "Yes, use SDK for MCP client communication" + description: "Add @modelcontextprotocol/sdk to manage MCP server connections, send initialize handshakes, and discover tools. Provides type-safe MCP protocol handling but adds a dependency Shep does not actually need since agent executors have their own MCP clients." + selected: false + - option: "No, use child_process.spawn() only" + description: "Shep spawns MCP server processes and passes connection info to agent executors. Shep never communicates with MCP servers directly -- the agent (Claude Code, Cursor) does. No SDK needed." + selected: true + - option: "Yes, but only for health check probes" + description: "Use the SDK minimally for tier-4 health checks (send MCP initialize request to verify server is responding). Skip it for production spawning. Adds dependency for a rarely-used feature." + selected: false + selectionRationale: > + Shep is an orchestrator, not an MCP client. It spawns MCP server processes and tells agent + executors how to connect to them. The agent executors (Claude Code, Cursor) have their own + MCP client implementations. Adding the SDK would introduce an unnecessary dependency. For + health check probes, spawning the server and checking that stdout produces output within + a timeout is sufficient -- no protocol-level handshake needed. The codebase already has + extensive child_process usage patterns to follow. + + - question: "How should the MCP config temp file be managed for concurrent features?" + resolved: true + options: + - option: "One global temp file updated on each feature start" + description: "A single .mcp.json in os.tmpdir() overwritten whenever a feature starts. Simple but breaks concurrent features -- the second feature's config overwrites the first." + selected: false + - option: "Per-feature temp file with feature ID in filename" + description: "Each feature gets its own temp file (e.g., /tmp/shep-mcp-{featureId}.json). Isolated, no conflicts, cleaned up per-feature. Uses os.tmpdir() for cross-platform compatibility." + selected: true + - option: "In-memory config passed via environment variable" + description: "Serialize MCP config to an env var and let Claude Code read it. Claude Code does not support reading MCP config from env vars, so this approach does not work." + selected: false + selectionRationale: > + Per-feature temp files ensure complete isolation between concurrent features. Each feature + may have different active plugins based on per-feature overrides. The file path includes + the feature ID for uniqueness: path.join(os.tmpdir(), 'shep-mcp-{featureId}.json'). + The McpServerManager creates the file when starting servers for a feature and deletes it + when stopping them. This maps cleanly to the existing resource cleanup pattern where + feature completion triggers cleanup of all associated resources. + + - question: "How should the plugin system integrate with the existing feature creation flow?" + resolved: true + options: + - option: "Inject plugin startup into the feature agent graph as a new node" + description: "Add a start-plugins node to the LangGraph workflow that runs before the first real node. Clean separation but adds graph complexity and a new node that must handle errors without crashing the workflow." + selected: false + - option: "Start plugins in the feature agent worker before graph invocation" + description: "The feature agent worker (which sets up the graph and invokes it) starts MCP servers as part of its initialization, before the first graph node runs. Plugins are available for all nodes. Cleanup happens in the worker's finally block." + selected: true + - option: "Start plugins lazily in buildExecutorOptions when first needed" + description: "Defer server startup to the first call to buildExecutorOptions that includes MCP plugins. Saves startup time if early nodes do not need plugins, but adds latency to the first node that does and complicates lifecycle management." + selected: false + selectionRationale: > + The feature agent worker is the natural place for plugin lifecycle management. It already + handles graph setup, checkpoint management, and error cleanup. Starting MCP servers before + graph invocation ensures all nodes have access to plugin tools. The worker's existing + try/finally pattern ensures cleanup on both success and failure. This avoids adding + complexity to the graph structure itself. The worker reads the feature's active plugins + from the registry, starts their MCP servers via McpServerManager, generates the MCP config + temp file, and passes the config path through to graph state so buildExecutorOptions can + include it in AgentExecutionOptions. + +content: | + ## Technology Decisions + + ### 1. MCP Server Process Management Strategy + + **Chosen:** Node.js child_process.spawn() with reference counting + + **Rejected:** + - @modelcontextprotocol/sdk Client class -- Shep does not call MCP tools directly; it spawns servers and passes connection info to agent executors which have their own MCP clients + - Docker containers for MCP servers -- adds Docker as hard dependency, increases startup latency, overkill for lightweight Node.js/Python scripts + + **Rationale:** Shep acts as a process orchestrator, not an MCP client. The existing codebase uses child_process extensively (ClaudeCodeExecutorService, ToolInstallerService). MCP stdio servers are just child processes with stdin/stdout pipes. Reference counting handles concurrent features sharing a server instance. + + ### 2. MCP Config Injection into Agent Executors + + **Chosen:** Generate temporary .mcp.json file and pass via --mcp-config flag + + **Rejected:** + - Inline JSON via CLI argument -- Claude Code uses file paths for --mcp-config, not inline JSON + - Modify worktree .mcp.json -- conflicts with user's repo config + - Environment variable injection -- Claude Code has no env var for MCP config + + **Rationale:** Claude Code accepts `--mcp-config ` to load MCP server definitions. Format: `{"mcpServers": {"name": {"type": "stdio", "command": "...", "args": [...], "env": {...}}}}`. A temp file per-feature in os.tmpdir() is clean, isolated, and maps directly to the existing buildArgs() pattern in ClaudeCodeExecutorService. + + ### 3. Plugin Registry Persistence + + **Chosen:** Dedicated plugins SQLite table with separate repository and mapper + + **Rejected:** + - Extend Settings entity -- Settings is a singleton; plugins are a collection with CRUD lifecycle + - File-based JSON config -- diverges from established SQLite pattern, loses transactional integrity + + **Rationale:** Every persistent entity in the codebase follows the same pattern: SQLite table, repository interface (IXxxRepository), mapper (toDatabase/fromDatabase), and migration. The latest migration is 059, so the plugins table will be migration 060. + + ### 4. Per-Feature Plugin Activation Storage + + **Chosen:** JSON-serialized active_plugins column on the features table + + **Rejected:** + - Separate junction table -- overkill for a simple key-value mapping + - Store in Settings entity -- Settings has no per-feature context + + **Rationale:** Follows the injected_skills column precedent: JSON-serialized TEXT column on the features table. Feature entity gets optional `activePlugins?: Record`. The 12-layer settings flow applies: global default from plugin registry -> per-feature override from active_plugins. + + ### 5. Plugin Domain Model Design + + **Chosen:** Single Plugin entity with type discriminator and optional type-specific fields + + **Rejected:** + - Separate entities per plugin type -- three entities, repositories, mappers, tables for one registry + - Embedded PluginConfig value object hierarchy -- TypeSpec does not support discriminated unions + + **Rationale:** A single Plugin entity with PluginType enum (Mcp, Hook, Cli) keeps the registry simple. Type-specific fields (transport, serverCommand for Mcp; hookType, scriptPath for Hook; binaryCommand for Cli) are optional. This maps cleanly to one SQLite table with nullable columns. + + ### 6. MCP Server Lifecycle Scope + + **Chosen:** Per-feature server instances with reference counting for shared access + + **Rejected:** + - Global singleton servers (always-on daemon) -- wastes resources, requires daemon management + - Per-node ephemeral servers -- adds cold-start latency per graph node transition + + **Rationale:** Pre-spawn per feature. Servers start when feature execution begins and stop when it completes. Reference counting for concurrent features sharing a plugin: increment on feature start, decrement on stop, kill when count reaches zero. Process signal handlers ensure cleanup on crash. + + ### 7. Plugin Health Check Strategy + + **Chosen:** Multi-tier: runtime detection, package verification, env var validation, server probe + + **Rejected:** + - Binary-exists check only -- misses missing packages and env vars + - Full MCP handshake probe only -- too slow for quick status display + + **Rationale:** Four tiers following ToolInstallerService pattern: (1) Runtime on PATH, (2) Package installed, (3) Env vars set, (4) Server probe (optional, on-demand). Tiers 1-3 run in <1s for CLI/web display. Health status: Healthy, Degraded, Unavailable. + + ### 8. Curated Plugin Catalog Storage + + **Chosen:** Static TypeScript constant array in a dedicated catalog module + + **Rejected:** + - External JSON file -- requires file I/O, path resolution, error handling for small data + - Database-seeded catalog -- mixes reference data with user data, migration complexity + + **Rationale:** Follows TOOL_METADATA pattern in tool-metadata.ts. Type-safe, tree-shaken, no I/O. Each entry: name, displayName, type, description, installCommand, serverCommand, serverArgs, transport, requiredEnvVars, toolGroups, runtimeRequirements, homepageUrl. + + ### 9. Agent Execution Options Extension + + **Chosen:** New optional mcpConfigPath field on AgentExecutionOptions + + **Rejected:** + - Structured mcpServers field -- requires every executor to understand MCP config format + - System prompt injection -- MCP is a tool-level concern, not a prompt concern + + **Rationale:** AgentExecutionOptions already has agent-agnostic fields translated by each executor. Adding mcpConfigPath?: string follows this pattern. ClaudeCodeExecutorService translates to --mcp-config . CursorExecutorService translates to its equivalent. The existing disableMcp field serves as precedent for MCP-related options. + + ### 10. Hook-Based Plugin Integration + + **Chosen:** Delegate to Claude Code native hook system via .claude/hooks/ directory management + + **Rejected:** + - Custom hook runtime -- massive scope; Claude Code already has a mature hook system + - Ignore hook plugins in V1 -- excludes Token Optimizer from the plugin system + + **Rationale:** Claude Code's native hooks system at .claude/hooks/ is what Token Optimizer targets. The plugin system manages installation of hook scripts into the directory, not execution. Install copies/symlinks scripts, remove cleans them up. Analogous to SkillInjectorService copying files into worktrees. + + ### 11. CLI Command Organization + + **Chosen:** New top-level `plugin` command group with subcommands + + **Rejected:** + - Nest under settings (shep settings plugin) -- plugins are a first-class subsystem, not settings + - Individual top-level commands -- violates established command group pattern + + **Rationale:** Follows Commander.js command group pattern used by settings. plugin/index.ts creates the group; individual files define subcommands (add, remove, list, enable, disable, configure, status, catalog). + + ### 12. Environment Variable Handling + + **Chosen:** Reference from system environment -- store only var names, never values + + **Rejected:** + - Store values in SQLite -- puts secrets in unencrypted DB, violates NFR-6 + - Encrypted keychain -- out of scope complexity for V1 + + **Rationale:** Matches existing pattern (ANTHROPIC_API_KEY read from process.env). Plugin registry stores required var names. At spawn time, system validates presence and passes values to child process env. Missing vars produce actionable error messages. + + ### 13. Tool Group Filtering + + **Chosen:** Static tool group definitions in catalog and registry + + **Rejected:** + - No filtering -- Ruflo's 313 tools overwhelm agent context + - AI-driven dynamic filtering -- complex, unpredictable for V1 + + **Rationale:** Ruflo already uses CLAUDE_FLOW_TOOL_GROUPS env var. Plugin system formalizes tool groups in catalog metadata. User selects groups during install. McpServerManager passes selected groups via server's native env var mechanism. No MCP protocol extensions needed. + + ## Library Analysis + + | Library | Purpose | Decision | Reasoning | + | ------- | ------- | -------- | --------- | + | @modelcontextprotocol/sdk | MCP client/server SDK | Reject | Shep is an orchestrator, not an MCP client. Agent executors have their own MCP clients. | + | better-sqlite3 | SQLite database driver | Use (existing) | Already the database layer for all persistence. Plugin table follows established pattern. | + | tsyringe | Dependency injection | Use (existing) | All services, use cases, and repositories use tsyringe. Plugin system follows same pattern. | + | umzug | Database migrations | Use (existing) | Migration 060 creates plugins table following existing convention. | + | Commander.js | CLI framework | Use (existing) | Plugin command group follows settings command pattern. | + | shadcn/ui | Web UI components | Use (existing) | Card, Switch, Badge, Button for plugin management page. | + | Node.js child_process | MCP server spawning | Use (built-in) | spawn() for stdio servers, no new dependencies needed. | + | Node.js os.tmpdir() | Temp file management | Use (built-in) | MCP config temp files per-feature. | + + ## Security Considerations + + ### No Secret Storage (NFR-6) + - Plugin registry stores ONLY environment variable NAMES, never values + - Values read from process.env at runtime and passed to child process env + - SQLite database contains zero secrets + - Missing env vars produce clear error messages, never leak partial secrets + + ### Process Isolation + - MCP server processes spawned WITHOUT shell: true (no shell injection) + - Command and args are arrays, not concatenated strings + - Environment passed explicitly, not inherited wholesale from process.env + - Only required env vars (from plugin config) plus PATH are passed to child processes + + ### Catalog Trust + - Curated catalog entries are code-reviewed and shipped with Shep + - Custom plugins specified by user are their responsibility + - No auto-discovery from npm/pip registries (avoids supply chain risk) + - Plugin install commands visible to user before execution + + ### Resource Cleanup + - Process signal handlers (SIGTERM, SIGINT, beforeExit) kill orphaned MCP servers + - Per-feature cleanup on feature completion/failure/pause + - Reference counting prevents premature kills for shared servers + - Temp MCP config files deleted on feature cleanup + + ## Performance Implications + + ### Startup Latency (NFR-1) + - Plugin system initialization is just loading the registry from SQLite (single query) + - No MCP servers started at CLI boot time + - Registry load adds <10ms to startup (in-memory SQLite query) + + ### Feature Execution Overhead + - MCP servers pre-spawned per feature -- startup cost paid once, not per-node + - Server startup timeout: 10s per server (NFR-2) + - Features with no active plugins: zero additional overhead + - Temp file I/O: single write per feature start, single delete per feature end + + ### Concurrent Feature Performance + - Reference counting avoids duplicate server processes for shared plugins + - Each feature has its own MCP config temp file (no lock contention) + - SQLite queries for plugin registry are read-heavy, not write-heavy during execution + + ## Architecture Notes + + ### Clean Architecture Compliance + + **Domain Layer (TypeSpec -> generated):** + - Plugin entity: id, name, displayName, type, version, transport, serverCommand, serverArgs, requiredEnvVars, toolGroups, activeToolGroups, enabled, healthStatus, hookType, scriptPath, binaryCommand, installSource, createdAt, updatedAt + - PluginType enum: Mcp, Hook, Cli + - PluginTransport enum: Stdio, Http + - PluginHealthStatus enum: Healthy, Degraded, Unavailable, Unknown + - ToolGroup model: name, description, tools[] + + **Application Layer (ports + use cases):** + - IPluginRepository: create, findById, findByName, list, update, delete + - IMcpServerManager: startServers(featureId, plugins), stopServers(featureId), getActiveServers(featureId), generateMcpConfig(featureId) + - IPluginHealthChecker: checkHealth(plugin), checkAllHealth() + - Use cases: AddPlugin, RemovePlugin, ListPlugins, EnablePlugin, DisablePlugin, ConfigurePlugin, CheckPluginHealth, GetPluginCatalog + + **Infrastructure Layer (implementations):** + - SQLitePluginRepository: CRUD operations for plugins table + - McpServerManagerService: child_process.spawn(), reference counting, temp file management + - PluginHealthCheckerService: tiered health checks (runtime, package, env, probe) + - PluginCatalogProvider: static catalog data + - Migration 060: CREATE TABLE plugins + + **Presentation Layer (CLI + Web):** + - CLI: shep plugin {add,remove,list,enable,disable,configure,status,catalog} + - Web: Plugin list page, catalog browser, per-feature toggles in create drawer + - Server actions: addPlugin, removePlugin, togglePlugin, etc. + + ### Integration Points + + 1. **Feature Agent Worker** -- Starts MCP servers before graph invocation, generates config, passes mcpConfigPath through graph state, cleans up in finally block + 2. **buildExecutorOptions()** -- Reads mcpConfigPath from graph state and adds to AgentExecutionOptions + 3. **ClaudeCodeExecutorService.buildArgs()** -- Translates mcpConfigPath to --mcp-config CLI flag + 4. **CreateFeatureUseCase** -- Stores activePlugins per-feature override from user input + 5. **DI Container** -- Registers IPluginRepository, IMcpServerManager, IPluginHealthChecker, all use cases, and string token aliases + + ### Database Schema + + ```sql + CREATE TABLE plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + type TEXT NOT NULL, -- 'Mcp' | 'Hook' | 'Cli' + version TEXT, + install_source TEXT, -- 'catalog' | 'custom' + transport TEXT, -- 'Stdio' | 'Http' (MCP only) + server_command TEXT, -- MCP server command + server_args TEXT, -- JSON array of args + required_env_vars TEXT, -- JSON array of env var names + tool_groups TEXT, -- JSON array of available groups + active_tool_groups TEXT, -- JSON array of enabled groups + enabled INTEGER NOT NULL DEFAULT 1, + health_status TEXT NOT NULL DEFAULT 'Unknown', + health_message TEXT, + hook_type TEXT, -- Hook plugin type + script_path TEXT, -- Hook script path + binary_command TEXT, -- CLI plugin command + runtime_type TEXT, -- 'python' | 'node' + runtime_min_version TEXT, -- e.g., '3.9', '20' + homepage_url TEXT, + description TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE UNIQUE INDEX idx_plugins_name ON plugins(name); + ``` + + Features table addition (migration 061): + ```sql + ALTER TABLE features ADD COLUMN active_plugins TEXT; + ``` + + --- + + _Research completed 2026-04-13_ diff --git a/specs/089-ai-tool-plugin-system/spec.yaml b/specs/089-ai-tool-plugin-system/spec.yaml new file mode 100644 index 000000000..63e560959 --- /dev/null +++ b/specs/089-ai-tool-plugin-system/spec.yaml @@ -0,0 +1,343 @@ +# Feature Specification (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: ai-tool-plugin-system +number: 89 +branch: "feat/089-ai-tool-plugin-system" +oneLiner: "Pluggable AI-native tool system for integrating external tools (MCP servers, CLIs, hook-based tools) into agentic SDLC workflows" +userQuery: > + I want to be have a feature in shep to plugin ai native tools into the agentic workflows and SDLC and automation, + for example if I want to plug in + https://github.com/MemPalace/mempalace + or + https://github.com/alexgreensh/token-optimizer + or + https://github.com/ruvnet/ruflo + or other it would be easy and possible +summary: > + A plugin system that lets users register, configure, and activate external AI-native tools + (such as MemPalace, Token Optimizer, Ruflo, or any MCP-compatible tool) into Shep's agentic + workflows. Supports three integration types: MCP server plugins (stdio/HTTP transports), + hook-based plugins (Claude Code lifecycle hooks), and CLI tool plugins (standalone executables). + Builds on existing IAgentExecutorProvider and ISkillInjectorService patterns to provide a + unified plugin registry with lifecycle management, per-feature activation, environment variable + injection, and automatic MCP server injection into agent execution contexts. +phase: "Requirements" +sizeEstimate: "L" + +# Relationships +relatedFeatures: [] + +technologies: + - "TypeScript" + - "TypeSpec" + - "tsyringe (DI)" + - "LangGraph (workflow orchestration)" + - "SQLite (persistence)" + - "Model Context Protocol (MCP)" + - "Next.js (web UI)" + - "Commander.js (CLI)" + - "shadcn/ui (component library)" + - "@modelcontextprotocol/sdk (MCP client)" + +relatedLinks: + - title: "MemPalace - Local AI memory system with MCP server" + url: "https://github.com/MemPalace/mempalace" + - title: "Token Optimizer - Token waste reduction and context management" + url: "https://github.com/alexgreensh/token-optimizer" + - title: "Ruflo - Multi-agent AI orchestration framework with MCP" + url: "https://github.com/ruvnet/ruflo" + - title: "Model Context Protocol specification" + url: "https://modelcontextprotocol.io" + +# Open questions (resolved with AI recommendations) +openQuestions: + - question: "What plugin integration types should be supported in the initial release?" + resolved: true + options: + - option: "MCP-only" + description: "Only support tools that expose MCP servers (stdio/HTTP). Simplest to build but excludes hook-based tools like Token Optimizer. Would cover MemPalace and Ruflo but not the full spectrum of referenced tools." + selected: false + - option: "MCP + CLI tools" + description: "Support MCP servers and standalone CLI tools (binary or script executables). Covers most tools but misses hook-based integration patterns used by Claude Code plugins." + selected: false + - option: "MCP + CLI + Hooks" + description: "Support all three integration types: MCP servers (primary), CLI tool wrappers, and hook-based lifecycle plugins. Covers the full spectrum of referenced tools including Token Optimizer's hook pattern. More complex but future-proof." + selected: true + selectionRationale: "All three referenced tools use different integration patterns: MemPalace uses MCP stdio, Ruflo uses MCP with multi-transport, and Token Optimizer uses hooks with no MCP at all. Supporting only MCP would exclude a major category of AI-native tools. The hook integration can be lightweight since it delegates to the existing Claude Code hook system rather than implementing a custom hook runtime." + answer: "MCP + CLI + Hooks" + + - question: "How should MCP server lifecycle be managed during agent execution?" + resolved: true + options: + - option: "Pre-spawn per feature" + description: "Start all enabled MCP servers when a feature begins execution, keep them running for the entire feature lifecycle, and stop them when the feature completes or is paused. Simple but wasteful if plugins are only needed in specific phases." + selected: true + - option: "On-demand per node" + description: "Start MCP servers only when a LangGraph node that requires them begins execution, and stop them after the node completes. More resource-efficient but adds latency to every node transition and complexity for managing concurrent servers." + selected: false + - option: "Always-on daemon" + description: "Start MCP servers as long-running background daemons that persist across features. Most responsive but consumes resources even when no features are running and requires daemon management infrastructure." + selected: false + selectionRationale: "Pre-spawn per feature balances simplicity and efficiency. MCP servers are lightweight processes, and the overhead of keeping them alive during a feature (minutes to hours) is negligible. On-demand adds cold-start latency to every phase transition and creates complex lifecycle management. Always-on daemon adds unnecessary infrastructure for tools that are only needed during active agent execution." + answer: "Pre-spawn per feature" + + - question: "Where should plugin configuration be stored?" + resolved: true + options: + - option: "Settings entity only" + description: "Store all plugin config (registry, activation, env vars) in the existing Settings singleton. Follows established patterns but flattens deeply nested plugin config into the already-large settings table." + selected: false + - option: "Separate plugins table" + description: "Store plugin registry and configuration in a dedicated SQLite table with its own repository and mapper. Keep only a lightweight enablement reference in Settings. Cleaner separation of concerns, easier to query and manage independently." + selected: true + - option: "File-based config" + description: "Store plugin config in a JSON/YAML file on disk (e.g., .shep/plugins.json). Avoids DB complexity but loses transactional integrity, harder to query, and diverges from the established SQLite persistence pattern." + selected: false + selectionRationale: "The Settings entity is already large (100+ columns) and plugin config is a distinct concern with its own CRUD lifecycle. A dedicated plugins table follows the same pattern as the features table -- separate entity, separate repository, separate mapper. The Settings entity can hold a simple global 'plugins enabled' toggle and per-feature plugin activation lists, while the plugins table holds the full registry (name, type, transport, install command, env vars, health status)." + answer: "Separate plugins table" + + - question: "How should plugins be discovered and added?" + resolved: true + options: + - option: "Manual registration only" + description: "Users manually specify plugin details via CLI command (name, type, command, args, env vars). Full control but requires users to know exact MCP server invocation details." + selected: false + - option: "Manual + curated registry" + description: "Manual registration plus a built-in catalog of well-known plugins (MemPalace, Ruflo, Token Optimizer, etc.) with pre-configured metadata. Users select from the catalog or add custom entries. Reduces friction for popular tools." + selected: true + - option: "Auto-discovery from npm/pip" + description: "Automatically discover MCP-compatible packages from npm/pip registries via naming conventions or metadata tags. Most convenient but unreliable (no standard MCP package discovery protocol exists), security concerns with auto-installing packages." + selected: false + selectionRationale: "A curated registry provides the best UX for the referenced tools while remaining open to custom plugins. Auto-discovery from package managers is unreliable and risky. Manual-only is too high-friction for users who just want to add a popular tool. The curated catalog is a simple JSON file in the codebase that maps well-known tool names to their installation commands, MCP server invocations, and default configurations." + answer: "Manual + curated registry" + + - question: "Should plugins be activatable per-feature or only globally?" + resolved: true + options: + - option: "Global only" + description: "Plugins are either enabled or disabled for all features. Simpler to implement but prevents fine-grained control. Users cannot disable a heavy plugin (like Ruflo's 313 tools) for simple features while keeping it for complex ones." + selected: false + - option: "Global default + per-feature override" + description: "Plugins have a global enabled/disabled default in Settings. Each feature can override individual plugin activation. Follows the exact same pattern as existing per-feature settings (enableEvidence, commitSpecs, forkAndPr) with the established 12-layer settings flow." + selected: true + - option: "Per-feature only" + description: "Plugins are configured independently per feature with no global defaults. Maximum flexibility but requires configuring plugins for every new feature, which is tedious." + selected: false + selectionRationale: "This mirrors the existing per-feature settings pattern perfectly. Users set global defaults ('always use MemPalace') and override per-feature when needed ('disable Ruflo for this simple bug fix'). The 12-layer settings propagation flow from LESSONS.md already handles this pattern, so the implementation follows established infrastructure." + answer: "Global default + per-feature override" + + - question: "How should plugin environment variables (API keys) be handled?" + resolved: true + options: + - option: "Store in plugin config (SQLite)" + description: "Store env vars directly in the plugins table alongside other config. Simple to manage but puts secrets in the SQLite database, which may not be encrypted." + selected: false + - option: "Reference from system env" + description: "Plugins declare required env var names, and the system reads them from the host environment at runtime. Secrets never stored by Shep. Users set env vars via .env files or shell profile. Most secure but requires users to manage env vars outside Shep." + selected: true + - option: "Encrypted settings store" + description: "Build a dedicated encrypted keychain for plugin secrets. Most secure in-app storage but adds significant complexity (encryption key management, platform-specific keychain integration) and is out of scope for initial implementation." + selected: false + selectionRationale: "Referencing from system environment is the standard pattern for all existing tools in Shep (e.g., ANTHROPIC_API_KEY for agent executors). It requires no new secret management infrastructure, works with .env files and CI environments, and keeps secrets out of SQLite. The plugin registry stores only the env var NAMES that a plugin requires, not the values. At spawn time, the system validates that required env vars are present and passes them to the MCP server process." + answer: "Reference from system env" + + - question: "What scope of MCP tool filtering should be supported?" + resolved: true + options: + - option: "No filtering (all or nothing)" + description: "When a plugin is enabled, all its MCP tools are available. Simple but problematic for tools like Ruflo with 313 tools -- Claude Code has a 40-tool practical limit, and excessive tools degrade agent performance." + selected: false + - option: "Tool group filtering" + description: "Allow users to specify which tool groups or individual tools from a plugin are active. The plugin metadata includes tool group definitions (e.g., Ruflo's 'implement', 'test', 'memory' groups). Users select groups in the config." + selected: true + - option: "AI-driven dynamic filtering" + description: "Automatically select relevant tools per LangGraph node based on the node's purpose (e.g., only memory tools in analyze, only implementation tools in implement). Intelligent but complex and unpredictable." + selected: false + selectionRationale: "Tool group filtering balances control and simplicity. Ruflo already defines tool groups via CLAUDE_FLOW_TOOL_GROUPS, and the plugin system can formalize this into the registry metadata. Users get a manageable list of checkboxes rather than 313 individual toggles. AI-driven filtering is too complex for V1 and would require per-node tool resolution logic that could behave unpredictably." + answer: "Tool group filtering" + + - question: "Should the web UI include a full plugin marketplace or just configuration management?" + resolved: true + options: + - option: "Configuration management only" + description: "Web UI shows installed plugins, their status, and configuration toggles. Adding new plugins is CLI-only. Simplest to build and avoids the complexity of a marketplace UI." + selected: false + - option: "Config + curated catalog browse" + description: "Web UI shows installed plugins and also lets users browse/install from the curated catalog. Provides a visual install experience without building a full marketplace with search, ratings, etc." + selected: true + - option: "Full marketplace" + description: "Web UI with search, categories, ratings, install counts, and community submissions. Maximum discoverability but massive scope increase requiring backend APIs and community infrastructure." + selected: false + selectionRationale: "A curated catalog browser is the sweet spot -- it gives users a visual way to discover and install well-known plugins without the massive infrastructure of a full marketplace. The catalog is a static JSON file shipped with Shep, so no backend APIs are needed. For V1, the catalog covers the referenced tools plus a few more; custom plugins are added via CLI." + answer: "Config + curated catalog browse" + +# Markdown content (the actual spec) +content: | + ## Problem Statement + + Shep's agentic SDLC workflows currently have limited extensibility for external AI-native tools. While the + platform has a tool installer service (for CLI tools like git, gh, Claude Code) and a skill injector service + (for agent prompt extensions), there is no unified mechanism to: + + 1. **Discover and register** third-party AI tools (memory systems, token optimizers, specialized agents, etc.) + 2. **Configure tool-specific settings** (API keys, initialization parameters, activation per-feature) + 3. **Inject tools into agent execution contexts** so LangGraph workflow nodes can use them during autonomous execution + 4. **Manage tool lifecycle** (install, initialize per-project, health-check, update, remove) + + Users who want to enhance their workflows with tools like MemPalace (persistent AI memory), Token Optimizer + (context window management), or Ruflo (specialized agent orchestration) currently have no straightforward + path to plug these into Shep's autonomous execution pipeline. + + The referenced tools demonstrate three distinct integration patterns: + - **MCP server plugins** (MemPalace, Ruflo) -- expose tools via Model Context Protocol over stdio or HTTP + - **Hook-based plugins** (Token Optimizer) -- integrate via Claude Code lifecycle hooks without MCP + - **CLI tool plugins** -- standalone executables invoked during workflow steps + + The plugin system must accommodate all three patterns under a unified registry and lifecycle model. + + ## Success Criteria + + - [ ] User can add a plugin from the curated catalog via `shep plugin add mempalace` and it auto-configures + - [ ] User can add a custom MCP plugin via `shep plugin add --name my-tool --command "npx my-tool-mcp" --transport stdio` + - [ ] User can list all registered plugins and their health status via `shep plugin list` + - [ ] User can enable/disable a plugin globally via `shep plugin enable/disable ` + - [ ] User can override plugin activation per-feature in the create-feature flow + - [ ] MCP server plugins are automatically started when a feature begins execution and stopped when it completes + - [ ] MCP server connection string is injected into agent execution options so the agent can use plugin-provided tools + - [ ] Plugin health check verifies runtime availability (e.g., Python installed for MemPalace) and reports errors + - [ ] Plugin environment variables (API keys) are validated at spawn time and missing vars produce clear error messages + - [ ] Web UI displays installed plugins with status indicators and toggle controls + - [ ] Web UI allows browsing and installing from the curated plugin catalog + - [ ] Plugin configuration persists across Shep restarts (SQLite storage) + - [ ] Removing a plugin cleans up its configuration and stops any running MCP servers + - [ ] At least 3 curated catalog entries ship with V1 (MemPalace, Token Optimizer, Ruflo) + - [ ] All plugin management use cases are accessible from CLI, web UI, and (future) TUI via the same core API + - [ ] No hardcoded agent types -- plugin injection works with any agent executor (Claude Code, Cursor, Gemini CLI, etc.) + + ## Functional Requirements + + - **FR-1: Plugin Registry** -- The system must maintain a persistent registry of installed plugins in a dedicated SQLite table. Each plugin record stores: unique name, display name, plugin type (mcp/hook/cli), version, installation source, MCP transport type, server command and args, required environment variable names, tool groups, enabled/disabled status, health status, and timestamps. + + - **FR-2: Plugin Type Support** -- The system must support three plugin types: (a) MCP server plugins that expose tools via Model Context Protocol over stdio or HTTP transport, (b) hook-based plugins that integrate via Claude Code lifecycle hooks, and (c) CLI tool plugins that provide standalone executables. + + - **FR-3: Curated Plugin Catalog** -- The system must ship with a built-in catalog of well-known plugins with pre-configured metadata (name, type, install command, server command, required env vars, tool groups). V1 catalog must include at least MemPalace, Token Optimizer, and Ruflo. + + - **FR-4: Plugin Installation** -- The system must support installing plugins from the curated catalog by name (e.g., `shep plugin add mempalace`) and custom plugins by specifying type, command, args, and transport. Installation must verify runtime dependencies (e.g., Python 3.9+ for MemPalace, Node.js 20+ for Ruflo) and report missing dependencies with install guidance. + + - **FR-5: Plugin Removal** -- The system must support removing a plugin by name, which stops any running MCP server processes, removes the plugin record from the registry, and cleans up per-feature activation overrides. + + - **FR-6: Plugin Health Check** -- The system must verify plugin health on demand and at feature-start time. Health checks verify: (a) runtime is available (python3/node on PATH), (b) package is installed (pip show/npx --yes), (c) required env vars are set, (d) MCP server can be started and responds. Health status is stored in the registry and surfaced in CLI and web UI. + + - **FR-7: Global Enable/Disable** -- The system must allow globally enabling or disabling a plugin. Disabled plugins are skipped during feature execution. Enable/disable state persists in the plugin registry. + + - **FR-8: Per-Feature Plugin Activation** -- The system must support per-feature plugin activation overrides following the existing per-feature settings pattern. Each feature can enable or disable specific plugins independently of the global default. This flows through the established 12-layer settings propagation path. + + - **FR-9: MCP Server Lifecycle Management** -- For MCP server plugins, the system must: (a) start the MCP server process when a feature begins execution, (b) pass required environment variables to the server process, (c) monitor the server process for unexpected exits, (d) stop the server process when the feature completes, pauses, or fails, (e) handle concurrent features sharing the same MCP server instance. + + - **FR-10: Agent Execution Integration** -- The system must inject active MCP plugin connection information into AgentExecutionOptions so that agent executors can connect to plugin-provided MCP servers. For Claude Code executor, this means generating the appropriate `--mcp-config` arguments. The integration must be agent-type agnostic via the IAgentExecutor interface. + + - **FR-11: Tool Group Filtering** -- For plugins that expose many tools (e.g., Ruflo with 313), the system must support tool group filtering. Users can select which tool groups are active for a plugin. The plugin catalog metadata includes tool group definitions. Only tools from selected groups are made available to the agent. + + - **FR-12: CLI Commands** -- The system must provide CLI commands under `shep plugin`: `add` (install from catalog or custom), `remove` (uninstall), `list` (show all with status), `enable`/`disable` (toggle global activation), `configure` (set tool groups, env var names), `status` (detailed health check), and `catalog` (browse available plugins). + + - **FR-13: Web UI Plugin Management** -- The web UI must provide: (a) a plugin list view showing installed plugins with status badges and toggle switches, (b) a catalog browser for discovering and installing curated plugins, (c) plugin detail view with configuration options (tool groups, activation status), (d) per-feature plugin toggle in the create-feature drawer. + + - **FR-14: Hook-Based Plugin Support** -- For hook-based plugins (like Token Optimizer), the system must manage plugin installation into the appropriate hooks directory and track their lifecycle. The plugin registry stores the hook type and script path. Hook plugin health is verified by checking script existence and runtime availability. + + - **FR-15: Plugin Configuration Export/Import** -- The system must support exporting the plugin configuration (installed plugins, tool groups, activation status) to a portable format and importing it into another Shep installation. This enables team-wide plugin standardization. + + ## Non-Functional Requirements + + - **NFR-1: Startup Latency** -- Plugin system initialization (loading registry, not starting MCP servers) must add less than 100ms to Shep startup time. MCP servers are only started on feature execution, not on CLI boot. + + - **NFR-2: MCP Server Startup** -- Individual MCP server processes must start and become responsive within 10 seconds. Servers that exceed this timeout are marked unhealthy and skipped for the current feature execution. + + - **NFR-3: Agent-Agnostic Design** -- No component in the plugin system may hardcode a specific agent type. All agent interaction flows through the IAgentExecutor and IAgentExecutorProvider interfaces. Plugin injection must work with Claude Code, Cursor, Gemini CLI, and any future agent executor. + + - **NFR-4: Clean Architecture Compliance** -- Plugin domain models defined in TypeSpec. Port interfaces in application layer. Implementations in infrastructure. Presentation layer (CLI, web) calls only use cases. No cross-layer imports. + + - **NFR-5: Cross-Platform Compatibility** -- The plugin system must work on macOS, Linux, and Windows. Runtime detection (python3 vs python, npx resolution) must handle platform differences. File paths must use path.join(). Process spawning must avoid shell: true. + + - **NFR-6: Security -- No Secret Storage** -- The plugin system must NEVER store API keys, tokens, or other secrets in SQLite or any Shep-managed file. Only environment variable names are stored. Values are read from the host environment at runtime. Missing required env vars produce a clear, actionable error message. + + - **NFR-7: Resource Cleanup** -- All MCP server processes spawned by the plugin system must be cleaned up on feature completion, Shep exit, and unexpected termination (via process signal handlers). Orphaned server processes must not persist after Shep exits. + + - **NFR-8: Graceful Degradation** -- If a plugin's MCP server fails to start or crashes during execution, the feature agent must continue running without the plugin's tools. Plugin failures must be logged and surfaced in the feature status but must not halt the SDLC workflow. + + - **NFR-9: Database Migration Safety** -- New SQLite tables and columns must be additive-only (no DROP/RENAME). Migrations must be backward compatible per LESSONS.md guidance. The plugins table is a new table, not a modification to settings. + + - **NFR-10: Test Coverage** -- All use cases must have unit tests with mocked dependencies. Plugin lifecycle (install, health check, start, stop) must have integration tests. MCP server integration must have at least one end-to-end test with a real (lightweight) MCP server. + + - **NFR-11: Concurrent Feature Safety** -- Multiple features running concurrently may share the same MCP server instance or require separate instances. The plugin system must handle reference counting for shared servers and independent lifecycle for per-feature servers without race conditions. + + - **NFR-12: Catalog Extensibility** -- The curated plugin catalog must be a static data file (JSON) that can be extended without code changes. Adding a new catalog entry requires only editing the catalog file and providing the plugin metadata. + + ## Product Questions & AI Recommendations + + | # | Question | AI Recommendation | Rationale | + | - | -------- | ----------------- | --------- | + | 1 | What plugin integration types should be supported? | MCP + CLI + Hooks | All three referenced tools use different patterns; MCP-only would exclude Token Optimizer | + | 2 | How should MCP server lifecycle be managed? | Pre-spawn per feature | Balances simplicity and efficiency; MCP servers are lightweight processes | + | 3 | Where should plugin configuration be stored? | Separate plugins table | Settings entity is already large; plugins have distinct CRUD lifecycle | + | 4 | How should plugins be discovered and added? | Manual + curated registry | Curated catalog for popular tools; manual for custom plugins | + | 5 | Should plugins be per-feature or global? | Global default + per-feature override | Mirrors existing per-feature settings pattern exactly | + | 6 | How should API keys be handled? | Reference from system env | Standard pattern for all existing tools; no new secret management needed | + | 7 | What scope of tool filtering? | Tool group filtering | Balances control for large tool sets (Ruflo: 313 tools) without per-tool complexity | + | 8 | What web UI scope? | Config + curated catalog browse | Visual install from catalog; full marketplace is out of scope for V1 | + + ## Affected Areas + + | Area | Impact | Reasoning | + | ---- | ------ | --------- | + | `tsp/` (TypeSpec models) | High | New domain entities: Plugin, PluginConfig, PluginType enum, PluginTransport enum, PluginHealthStatus enum, ToolGroup model | + | `packages/core/src/domain/` | High | Generated types from TypeSpec compilation | + | `packages/core/src/application/ports/output/` | High | New port interfaces: IPluginRepository, IPluginLifecycleService, IMcpServerManager | + | `packages/core/src/application/use-cases/` | High | New use cases: add-plugin, remove-plugin, configure-plugin, enable-plugin, disable-plugin, list-plugins, check-plugin-health, get-plugin-catalog | + | `packages/core/src/infrastructure/services/` | High | New plugin service implementations: registry, MCP server manager, health checker, catalog provider | + | `packages/core/src/infrastructure/di/container.ts` | Medium | Register new services, use cases, and string token aliases | + | `packages/core/src/infrastructure/persistence/sqlite/` | Medium | New migration for plugins table, new repository and mapper | + | `packages/core/src/infrastructure/services/agents/feature-agent/` | Medium | Inject active plugins into agent execution options per workflow node | + | `packages/core/src/infrastructure/services/agents/common/executors/` | Medium | Pass plugin-provided MCP server config to agent executor CLI args | + | `src/presentation/cli/commands/` | Medium | New CLI command group: `shep plugin add/remove/list/enable/disable/configure/status/catalog` | + | `src/presentation/web/` | Medium | Plugin management page, catalog browser, per-feature plugin toggles in create drawer | + | `packages/core/src/infrastructure/services/tool-installer/` | Low | May reuse for plugin runtime dependency detection (python3, node) | + | `packages/core/src/infrastructure/services/skill-injector/` | Low | Pattern reference; hook-based plugins follow similar injection pattern | + | Feature entity and mapper | Low | New optional field for per-feature plugin activation overrides | + + ## Dependencies + + **Internal Dependencies:** + - Existing `IAgentExecutorFactory` and `IAgentExecutorProvider` interfaces (plugin tools injected via execution options) + - Existing `IToolInstallerService` pattern (for runtime dependency detection -- python3, node availability) + - Existing `ISkillInjectorService` pattern (for understanding worktree-level injection and hook management) + - Existing `IAgentRegistry` pattern (for plugin discovery/registration pattern reference) + - Settings service and per-feature settings flow (for plugin activation toggles) + - SQLite persistence layer (for plugin registry storage) + - LangGraph feature agent state channels (for per-node plugin availability) + - AgentExecutionOptions type (for injecting MCP config into agent executors) + - Feature entity (for per-feature plugin override storage) + + **External Dependencies:** + - `@modelcontextprotocol/sdk` -- MCP client library for connecting to and managing plugin MCP servers + - Runtime detection utilities for Python (pip/pipx) and Node.js (npx/npm) -- no new npm packages needed, uses child_process + + **Referenced Tools (Example Plugins):** + - **MemPalace** -- 19 MCP tools via Python stdio server (`python -m mempalace.mcp_server`). Python 3.9+ runtime. No API keys. Local storage in `~/.mempalace/`. + - **Token Optimizer** -- Hook-based integration (no MCP). Python 3.8+ runtime. No API keys. Integrates via Claude Code hooks (SessionStart, PreToolUse, PostToolUse, etc.). Config in `~/.claude/token-optimizer/`. + - **Ruflo** -- 313 MCP tools via Node.js server (`npx ruflo@latest mcp start`). Node.js 20+ runtime. Requires ANTHROPIC_API_KEY. Supports stdio and HTTP transports. Tool group filtering via CLAUDE_FLOW_TOOL_GROUPS. + + ## Size Estimate + + **L** (week+) -- This feature introduces a new subsystem spanning all four architecture layers: + new TypeSpec domain models (Plugin, PluginConfig, enums), new port interfaces (3+), new use cases (8), + new infrastructure services (plugin registry, MCP server manager, health checker, catalog provider), + new SQLite migration and repository, new CLI command group (8 subcommands), and new web UI components + (plugin list, catalog browser, per-feature toggles). The MCP server lifecycle management and + per-feature activation flow add significant complexity. The patterns are well-established in the + codebase (follows IToolInstallerService, ISkillInjectorService, and per-feature settings precedents), + which reduces risk but not scope. + + --- + + _Generated by feature agent -- requirements phase complete_ diff --git a/specs/089-ai-tool-plugin-system/tasks.yaml b/specs/089-ai-tool-plugin-system/tasks.yaml new file mode 100644 index 000000000..c10e7c31d --- /dev/null +++ b/specs/089-ai-tool-plugin-system/tasks.yaml @@ -0,0 +1,897 @@ +# Task Breakdown (YAML) +# This is the source of truth. Markdown is auto-generated from this file. + +name: "ai-tool-plugin-system" +summary: > + 30 tasks across 6 phases implementing the AI tool plugin system from domain models + through web UI. Each task follows TDD cycles (RED-GREEN-REFACTOR) and is ordered by + dependency. + +relatedFeatures: [] +technologies: + - "TypeScript" + - "TypeSpec" + - "tsyringe (DI)" + - "SQLite / better-sqlite3" + - "Node.js child_process" + - "Commander.js" + - "Next.js" + - "shadcn/ui" + +relatedLinks: + - title: "Model Context Protocol specification" + url: "https://modelcontextprotocol.io" + - title: "Claude Code MCP configuration docs" + url: "https://docs.anthropic.com/en/docs/claude-code/mcp" + +tasks: + # ============================================================ + # Phase 1: Domain Foundation + # ============================================================ + + - id: "task-1" + phaseId: "phase-1" + title: "Define Plugin enums in TypeSpec" + description: > + Create tsp/common/enums/plugin.tsp with PluginType (Mcp, Hook, Cli), + PluginTransport (Stdio, Http), and PluginHealthStatus (Healthy, Degraded, + Unavailable, Unknown) enums. Add import to tsp/common/enums/index.tsp. + state: "Todo" + dependencies: [] + acceptanceCriteria: + - "PluginType enum has exactly three members: Mcp, Hook, Cli" + - "PluginTransport enum has exactly two members: Stdio, Http" + - "PluginHealthStatus enum has exactly four members: Healthy, Degraded, Unavailable, Unknown" + - "Enums are exported from tsp/common/enums/index.tsp" + - "pnpm tsp:compile succeeds without errors" + tdd: + red: + - "Run pnpm tsp:compile and verify it succeeds (baseline)" + green: + - "Create tsp/common/enums/plugin.tsp with the three enums" + - "Add import to tsp/common/enums/index.tsp" + - "Run pnpm tsp:compile and verify enums appear in generated output.ts" + refactor: + - "Ensure doc annotations follow existing enum style (e.g., tool.tsp)" + + - id: "task-2" + phaseId: "phase-1" + title: "Define Plugin entity and ToolGroup model in TypeSpec" + description: > + Create tsp/domain/entities/plugin.tsp with the Plugin entity extending BaseEntity. + Fields: name, displayName, type (PluginType), version?, installSource?, transport?, + serverCommand?, serverArgs?, requiredEnvVars?, toolGroups?, activeToolGroups?, + enabled (default true), healthStatus (default Unknown), healthMessage?, hookType?, + scriptPath?, binaryCommand?, runtimeType?, runtimeMinVersion?, homepageUrl?, description?. + Also define ToolGroup model with name, description?, tools? fields. + state: "Todo" + dependencies: + - "task-1" + acceptanceCriteria: + - "Plugin entity extends BaseEntity with all specified fields" + - "All MCP-specific fields (transport, serverCommand, serverArgs) are optional" + - "All Hook-specific fields (hookType, scriptPath) are optional" + - "All CLI-specific fields (binaryCommand) are optional" + - "ToolGroup model has name, description, and tools fields" + - "pnpm tsp:compile generates Plugin and ToolGroup types in output.ts" + tdd: + red: + - "Run pnpm tsp:compile -- verify no Plugin type in output.ts yet" + green: + - "Create tsp/domain/entities/plugin.tsp with Plugin entity and ToolGroup model" + - "Import plugin enums from ../../common/enums/plugin.tsp" + - "Run pnpm tsp:compile and verify Plugin and ToolGroup appear in output.ts" + refactor: + - "Ensure doc annotations are comprehensive (follow tool.tsp and feature.tsp style)" + - "Verify field naming consistency with existing entities" + + - id: "task-3" + phaseId: "phase-1" + title: "Add activePlugins field to Feature entity in TypeSpec" + description: > + Add an optional activePlugins field to the Feature entity in + tsp/domain/entities/feature.tsp. Type is Record + (or a simple key-value model) for per-feature plugin activation overrides. + Compile and verify the generated type includes the new field. + state: "Todo" + dependencies: + - "task-2" + acceptanceCriteria: + - "Feature entity has optional activePlugins field" + - "Field type supports mapping plugin names to boolean activation state" + - "pnpm tsp:compile succeeds and Feature type includes activePlugins" + - "No existing test fixtures break (field is optional)" + tdd: + red: + - "Verify Feature type in output.ts does not have activePlugins" + green: + - "Add activePlugins?: Record to Feature in TypeSpec" + - "Run pnpm tsp:compile and verify field appears in generated output" + refactor: + - "Add doc annotation explaining the field purpose and JSON storage format" + + # ============================================================ + # Phase 2: Persistence Layer + # ============================================================ + + - id: "task-4" + phaseId: "phase-2" + title: "Create plugins table migration (060)" + description: > + Create migration 060-create-plugins-table.ts in the SQLite migrations directory. + The plugins table stores all plugin registry data with columns matching the Plugin + entity. JSON columns for arrays (server_args, required_env_vars, tool_groups, + active_tool_groups). Additive-only migration per LESSONS.md. + state: "Todo" + dependencies: + - "task-2" + acceptanceCriteria: + - "Migration file follows existing naming convention (060-create-plugins-table.ts)" + - "CREATE TABLE plugins with all columns matching the Plugin entity" + - "UNIQUE index on name column" + - "JSON columns are TEXT type for arrays/objects" + - "Timestamps are INTEGER (unix milliseconds)" + - "enabled is INTEGER NOT NULL DEFAULT 1" + - "health_status is TEXT NOT NULL DEFAULT 'Unknown'" + - "Migration is additive-only (CREATE TABLE, no DROP/RENAME)" + tdd: + red: + - "Write integration test that runs migration and verifies table exists with correct columns" + green: + - "Create migration 060 with the CREATE TABLE statement and unique index" + refactor: + - "Verify column types match existing table conventions (features, settings)" + + - id: "task-5" + phaseId: "phase-2" + title: "Create active_plugins column migration (061)" + description: > + Create migration 061-add-active-plugins-to-features.ts that adds an active_plugins + TEXT column to the features table. Additive-only. The column stores a JSON object + mapping plugin names to boolean activation state. + state: "Todo" + dependencies: + - "task-4" + acceptanceCriteria: + - "Migration adds active_plugins TEXT column to features table" + - "Column is nullable (no NOT NULL constraint)" + - "Migration is additive-only (ALTER TABLE ADD COLUMN)" + - "Existing feature rows are unaffected (NULL default)" + tdd: + red: + - "Write integration test that verifies active_plugins column does not exist before migration" + green: + - "Create migration 061 with ALTER TABLE features ADD COLUMN active_plugins TEXT" + refactor: + - "Ensure migration follows existing ALTER TABLE pattern from migration 055+" + + - id: "task-6" + phaseId: "phase-2" + title: "Create PluginRow mapper" + description: > + Create plugin.mapper.ts with PluginRow interface, toDatabase(), and fromDatabase() + functions. Handles camelCase/snake_case conversion, JSON serialization for array + fields (serverArgs, requiredEnvVars, toolGroups, activeToolGroups), and date + conversion (unix milliseconds). Follow feature.mapper.ts pattern exactly. + state: "Todo" + dependencies: + - "task-4" + acceptanceCriteria: + - "PluginRow interface matches plugins table columns (snake_case)" + - "toDatabase() converts Plugin domain object to PluginRow" + - "fromDatabase() converts PluginRow to Plugin domain object" + - "Array fields serialized as JSON TEXT in toDatabase" + - "Array fields deserialized from JSON in fromDatabase with null safety" + - "Dates stored as INTEGER (unix milliseconds)" + - "Boolean enabled field maps to INTEGER 0/1" + tdd: + red: + - "Write unit test: toDatabase(pluginFixture) returns correct PluginRow with snake_case keys and JSON arrays" + - "Write unit test: fromDatabase(pluginRow) returns correct Plugin with camelCase keys and parsed arrays" + - "Write unit test: round-trip toDatabase then fromDatabase preserves all fields" + green: + - "Implement PluginRow interface, toDatabase(), fromDatabase()" + refactor: + - "Extract shared JSON serialization helpers if duplicating feature.mapper.ts logic" + + - id: "task-7" + phaseId: "phase-2" + title: "Create IPluginRepository interface and SQLitePluginRepository" + description: > + Define IPluginRepository port interface with CRUD methods: create, findById, + findByName, list, update, delete. Implement SQLitePluginRepository following + the SQLiteFeatureRepository pattern with hardcoded INSERT/UPDATE SQL. + state: "Todo" + dependencies: + - "task-6" + acceptanceCriteria: + - "IPluginRepository interface in application/ports/output/repositories/" + - "Methods: create, findById, findByName, list, update, delete" + - "SQLitePluginRepository implements all methods" + - "INSERT SQL includes ALL columns from PluginRow" + - "UPDATE SQL SET clause includes ALL mutable columns" + - "findByName returns null when not found" + - "list returns all plugins ordered by name" + - "delete removes by id" + - "Repository exported from ports index" + tdd: + red: + - "Write integration test: create a plugin, findByName returns it" + - "Write integration test: create, update, findById returns updated values" + - "Write integration test: create, delete, findById returns null" + - "Write integration test: list returns all created plugins ordered by name" + green: + - "Create IPluginRepository interface" + - "Implement SQLitePluginRepository with all CRUD methods" + - "Export from ports index" + refactor: + - "Verify INSERT column list matches PluginRow exactly (per LESSONS.md)" + - "Verify UPDATE SET clause matches PluginRow exactly" + + - id: "task-8" + phaseId: "phase-2" + title: "Extend Feature mapper and repository for activePlugins" + description: > + Add active_plugins to FeatureRow interface in feature.mapper.ts. Update toDatabase() + to JSON.stringify activePlugins. Update fromDatabase() to JSON.parse active_plugins. + Add active_plugins to INSERT and UPDATE SQL in sqlite-feature.repository.ts. + state: "Todo" + dependencies: + - "task-5" + acceptanceCriteria: + - "FeatureRow has active_plugins: string | null field" + - "toDatabase serializes activePlugins to JSON or null" + - "fromDatabase deserializes active_plugins from JSON with null safety" + - "INSERT SQL column list includes active_plugins" + - "UPDATE SQL SET clause includes active_plugins" + - "Integration test: create feature with activePlugins, read back, values match" + tdd: + red: + - "Write integration test: create feature with activePlugins={mempalace: true}, findById returns matching activePlugins" + green: + - "Add active_plugins to FeatureRow, toDatabase(), fromDatabase()" + - "Add active_plugins to INSERT column list and values in repository create()" + - "Add active_plugins to UPDATE SET clause in repository update()" + refactor: + - "Follow injected_skills serialization pattern exactly" + + # ============================================================ + # Phase 3: Core Services and Use Cases + # ============================================================ + + - id: "task-9" + phaseId: "phase-3" + title: "Create IMcpServerManager and IPluginHealthChecker port interfaces" + description: > + Define IMcpServerManager with methods: startServersForFeature, stopServersForFeature, + getActiveServers, generateMcpConfigPath. Define IPluginHealthChecker with methods: + checkHealth, checkAllHealth. Both in application/ports/output/services/. Export from index. + state: "Todo" + dependencies: + - "task-7" + acceptanceCriteria: + - "IMcpServerManager interface has startServersForFeature(featureId, plugins), stopServersForFeature(featureId), getActiveServers(featureId), generateMcpConfigPath(featureId)" + - "IPluginHealthChecker interface has checkHealth(plugin), checkAllHealth()" + - "Both interfaces in application/ports/output/services/" + - "Both exported from services/index.ts" + tdd: null + + - id: "task-10" + phaseId: "phase-3" + title: "Create curated plugin catalog" + description: > + Create plugin-catalog.ts with a static TypeScript constant array of CatalogEntry + objects. V1 entries: MemPalace (MCP stdio, Python 3.9+), Token Optimizer (Hook, + Python 3.8+), Ruflo (MCP stdio, Node 20+, requires ANTHROPIC_API_KEY). + Export getCatalogEntries() function. + state: "Todo" + dependencies: + - "task-2" + acceptanceCriteria: + - "CatalogEntry type with name, displayName, type, description, installCommand, serverCommand, serverArgs, transport, requiredEnvVars, toolGroups, runtimeType, runtimeMinVersion, homepageUrl" + - "At least 3 entries: mempalace, token-optimizer, ruflo" + - "MemPalace: type=Mcp, transport=Stdio, command=python -m mempalace.mcp_server, runtime=python 3.9+" + - "Token Optimizer: type=Hook, runtime=python 3.8+" + - "Ruflo: type=Mcp, transport=Stdio, command=npx ruflo@latest mcp start, runtime=node 20+, envVars=[ANTHROPIC_API_KEY]" + - "getCatalogEntries() returns the array" + - "getCatalogEntry(name) returns a single entry or undefined" + tdd: + red: + - "Write unit test: getCatalogEntries() returns array with length >= 3" + - "Write unit test: getCatalogEntry('mempalace') returns MemPalace entry with correct fields" + - "Write unit test: getCatalogEntry('nonexistent') returns undefined" + green: + - "Create CatalogEntry type and static constant array" + - "Implement getCatalogEntries() and getCatalogEntry()" + refactor: + - "Ensure catalog data matches real tool documentation (verify commands)" + + - id: "task-11" + phaseId: "phase-3" + title: "Implement GetPluginCatalog use case" + description: > + Create GetPluginCatalogUseCase that returns the curated catalog entries. + Optionally cross-references with installed plugins to show install status. + state: "Todo" + dependencies: + - "task-10" + - "task-7" + acceptanceCriteria: + - "execute() returns catalog entries with isInstalled boolean for each" + - "Cross-references IPluginRepository to determine installed status" + - "Injectable via tsyringe with @inject for dependencies" + tdd: + red: + - "Write unit test: execute() returns all catalog entries" + - "Write unit test: installed plugin shows isInstalled=true" + - "Write unit test: uninstalled plugin shows isInstalled=false" + green: + - "Implement GetPluginCatalogUseCase with IPluginRepository dependency" + refactor: + - "Ensure return type is well-defined (not leaking internal types)" + + - id: "task-12" + phaseId: "phase-3" + title: "Implement AddPlugin use case" + description: > + Create AddPluginUseCase that handles both catalog-based and custom plugin + installation. For catalog plugins, looks up metadata from catalog. For custom, + accepts type, command, args, transport. Creates a Plugin entity and persists + via IPluginRepository. Runs health check after creation. + state: "Todo" + dependencies: + - "task-10" + - "task-7" + acceptanceCriteria: + - "execute(name) installs from catalog if name matches a catalog entry" + - "execute({name, type, command, ...}) installs a custom plugin" + - "Throws if plugin name already exists in registry" + - "Creates Plugin entity with all fields populated from catalog or input" + - "Persists via IPluginRepository.create()" + - "Returns the created Plugin" + tdd: + red: + - "Write unit test: execute('mempalace') creates plugin with catalog metadata" + - "Write unit test: execute({name:'custom', type:'Mcp', ...}) creates custom plugin" + - "Write unit test: execute('mempalace') throws when already installed" + green: + - "Implement AddPluginUseCase with IPluginRepository and catalog dependencies" + refactor: + - "Extract input validation to a helper if complex" + + - id: "task-13" + phaseId: "phase-3" + title: "Implement RemovePlugin use case" + description: > + Create RemovePluginUseCase that stops any running MCP servers for the plugin, + removes the plugin record from the registry, and returns success/failure. + state: "Todo" + dependencies: + - "task-7" + - "task-9" + acceptanceCriteria: + - "execute(pluginName) removes the plugin from the registry" + - "Calls IMcpServerManager to stop any running servers for this plugin" + - "Throws if plugin not found" + - "Returns the removed plugin for confirmation" + tdd: + red: + - "Write unit test: execute('mempalace') removes plugin and returns it" + - "Write unit test: execute('nonexistent') throws not found error" + - "Write unit test: calls stopServersForFeature for each active feature using the plugin" + green: + - "Implement RemovePluginUseCase with IPluginRepository and IMcpServerManager" + refactor: + - "Ensure cleanup is thorough (all references cleaned)" + + - id: "task-14" + phaseId: "phase-3" + title: "Implement ListPlugins use case" + description: > + Create ListPluginsUseCase that returns all registered plugins from the repository + with their current health status. + state: "Todo" + dependencies: + - "task-7" + acceptanceCriteria: + - "execute() returns all plugins from repository" + - "Optionally filters by type or enabled status" + - "Returns Plugin[] with health status included" + tdd: + red: + - "Write unit test: execute() returns empty array when no plugins" + - "Write unit test: execute() returns all plugins after adding several" + - "Write unit test: execute({enabled: true}) filters to enabled only" + green: + - "Implement ListPluginsUseCase with IPluginRepository" + refactor: + - "Ensure return type is consistent with other list use cases" + + - id: "task-15" + phaseId: "phase-3" + title: "Implement EnablePlugin and DisablePlugin use cases" + description: > + Create EnablePluginUseCase and DisablePluginUseCase that toggle the enabled + field on a plugin. Both update the plugin record in the repository. + state: "Todo" + dependencies: + - "task-7" + acceptanceCriteria: + - "EnablePluginUseCase.execute(name) sets enabled=true and persists" + - "DisablePluginUseCase.execute(name) sets enabled=false and persists" + - "Both throw if plugin not found" + - "Both return the updated Plugin" + tdd: + red: + - "Write unit test: enable on disabled plugin returns enabled=true" + - "Write unit test: disable on enabled plugin returns enabled=false" + - "Write unit test: enable on nonexistent plugin throws" + green: + - "Implement both use cases with IPluginRepository" + refactor: + - "Consider extracting shared toggle logic if implementations are near-identical" + + - id: "task-16" + phaseId: "phase-3" + title: "Implement ConfigurePlugin use case" + description: > + Create ConfigurePluginUseCase that updates plugin configuration: active tool groups, + custom server args, or other mutable fields. Validates that tool groups are valid + for the plugin (exist in toolGroups array). + state: "Todo" + dependencies: + - "task-7" + acceptanceCriteria: + - "execute(name, {activeToolGroups}) updates active tool groups" + - "Validates that each active group exists in the plugin toolGroups" + - "Throws on invalid group name" + - "Throws if plugin not found" + - "Returns updated Plugin" + tdd: + red: + - "Write unit test: configure with valid tool groups updates activeToolGroups" + - "Write unit test: configure with invalid group name throws" + - "Write unit test: configure nonexistent plugin throws" + green: + - "Implement ConfigurePluginUseCase with IPluginRepository" + refactor: + - "Ensure validation messages are actionable" + + - id: "task-17" + phaseId: "phase-3" + title: "Implement PluginHealthCheckerService" + description: > + Implement IPluginHealthChecker with tiered health checks: (1) runtime on PATH + via which/where, (2) package installed check, (3) required env vars present in + process.env, (4) optional server probe. Follow ToolInstallerService pattern for + runtime detection. + state: "Todo" + dependencies: + - "task-9" + acceptanceCriteria: + - "checkHealth(plugin) returns PluginHealthStatus and message" + - "Tier 1: detects python3/node on PATH (reuse ToolInstallerService patterns)" + - "Tier 2: verifies package installed (pip show / npx check)" + - "Tier 3: validates required env vars are set in process.env" + - "Returns Healthy when all tiers pass" + - "Returns Degraded when runtime present but package/env issues" + - "Returns Unavailable when runtime missing" + - "checkAllHealth() checks all registered plugins" + tdd: + red: + - "Write unit test: checkHealth with all tiers passing returns Healthy" + - "Write unit test: checkHealth with missing runtime returns Unavailable" + - "Write unit test: checkHealth with missing env var returns Degraded" + - "Write unit test: checkAllHealth checks each plugin" + green: + - "Implement PluginHealthCheckerService with tiered checks" + - "Use child_process.execFile for runtime detection (short timeout)" + refactor: + - "Extract runtime detection utilities if reusable beyond plugins" + + - id: "task-18" + phaseId: "phase-3" + title: "Implement CheckPluginHealth use case" + description: > + Create CheckPluginHealthUseCase that runs health checks on a specific plugin + or all plugins. Updates health status in the repository after check. + state: "Todo" + dependencies: + - "task-17" + - "task-7" + acceptanceCriteria: + - "execute(name) checks health of named plugin and updates status in repository" + - "execute() with no args checks all plugins" + - "Returns health check results" + tdd: + red: + - "Write unit test: execute('mempalace') calls health checker and updates repo" + - "Write unit test: execute() checks all and updates each" + green: + - "Implement CheckPluginHealthUseCase" + refactor: + - "Ensure health status updates are atomic" + + - id: "task-19" + phaseId: "phase-3" + title: "Register all plugin services and use cases in DI container" + description: > + Register IPluginRepository, IMcpServerManager, IPluginHealthChecker, and all + eight plugin use cases in the DI container. Add string token aliases for web + access per LESSONS.md. + state: "Todo" + dependencies: + - "task-12" + - "task-13" + - "task-14" + - "task-15" + - "task-16" + - "task-18" + - "task-11" + acceptanceCriteria: + - "IPluginRepository registered as singleton with SQLitePluginRepository" + - "IPluginHealthChecker registered as singleton with PluginHealthCheckerService" + - "IMcpServerManager registered as singleton (placeholder until Phase 4)" + - "All eight use cases registered as singletons" + - "String token aliases added for all use cases (for web server actions)" + - "Container resolves all plugin dependencies without errors" + tdd: + red: + - "Write integration test: container.resolve(AddPluginUseCase) succeeds" + - "Write integration test: container.resolve('ListPluginsUseCase') returns instance via string alias" + green: + - "Add all registrations and aliases to container.ts" + refactor: + - "Group plugin registrations in a clearly commented block" + + # ============================================================ + # Phase 4: MCP Server Lifecycle and Agent Integration + # ============================================================ + + - id: "task-20" + phaseId: "phase-4" + title: "Implement McpServerManagerService" + description: > + Implement IMcpServerManager using child_process.spawn() for MCP server processes. + Features: start/stop servers per-feature, reference counting for shared servers, + per-feature temp .mcp.json file generation, process signal cleanup handlers. + state: "Todo" + dependencies: + - "task-9" + - "task-7" + acceptanceCriteria: + - "startServersForFeature spawns MCP server processes for enabled plugins" + - "Passes required env vars from process.env to child process" + - "Reference counting increments for each feature using same plugin" + - "stopServersForFeature decrements reference count, kills process at zero" + - "generateMcpConfigPath creates temp .mcp.json with active server definitions" + - "Temp file format: {mcpServers: {name: {type, command, args, env}}}" + - "Process signal handlers (SIGTERM, SIGINT, beforeExit) kill all managed servers" + - "Server startup timeout of 10 seconds (mark unhealthy if exceeded)" + tdd: + red: + - "Write unit test: startServersForFeature spawns child processes for each MCP plugin" + - "Write unit test: stopServersForFeature kills processes when refcount reaches zero" + - "Write unit test: reference counting prevents premature kill with concurrent features" + - "Write unit test: generateMcpConfigPath creates valid JSON file in os.tmpdir()" + - "Write unit test: stopServersForFeature deletes the temp config file" + green: + - "Implement McpServerManagerService with spawn, refcount, and temp file logic" + refactor: + - "Extract temp file management to a helper" + - "Ensure all process handles are properly tracked for cleanup" + + - id: "task-21" + phaseId: "phase-4" + title: "Add mcpConfigPath to AgentExecutionOptions" + description: > + Add optional mcpConfigPath field to AgentExecutionOptions interface. + This carries the path to the per-feature .mcp.json temp file through the + agent-agnostic interface. + state: "Todo" + dependencies: + - "task-20" + acceptanceCriteria: + - "AgentExecutionOptions has optional mcpConfigPath?: string field" + - "Field is documented with JSDoc explaining its purpose" + - "Existing code unaffected (field is optional)" + tdd: + red: + - "Verify AgentExecutionOptions does not have mcpConfigPath field" + green: + - "Add mcpConfigPath?: string to AgentExecutionOptions" + refactor: + - "Add JSDoc with usage example" + + - id: "task-22" + phaseId: "phase-4" + title: "Inject --mcp-config flag in ClaudeCodeExecutorService" + description: > + Modify buildArgs() in ClaudeCodeExecutorService to add --mcp-config + when options.mcpConfigPath is set. This tells Claude Code to load plugin + MCP servers from the generated temp file. + state: "Todo" + dependencies: + - "task-21" + acceptanceCriteria: + - "buildArgs() includes --mcp-config when mcpConfigPath is set" + - "Flag is NOT added when mcpConfigPath is undefined" + - "Flag works alongside existing --strict-mcp-config (disableMcp)" + - "When both mcpConfigPath and disableMcp are set, both flags are included" + tdd: + red: + - "Write unit test: buildArgs with mcpConfigPath includes --mcp-config " + - "Write unit test: buildArgs without mcpConfigPath does not include --mcp-config" + - "Write unit test: buildArgs with both mcpConfigPath and disableMcp includes both flags" + green: + - "Add conditional mcpConfigPath flag to buildArgs()" + refactor: + - "Ensure flag order is consistent with other conditional flags" + + - id: "task-23" + phaseId: "phase-4" + title: "Add mcpConfigPath to feature agent state and buildExecutorOptions" + description: > + Add mcpConfigPath channel to FeatureAgentState annotations. Modify + buildExecutorOptions() in node-helpers.ts to include mcpConfigPath from state + in the returned AgentExecutionOptions. + state: "Todo" + dependencies: + - "task-21" + acceptanceCriteria: + - "FeatureAgentState has mcpConfigPath annotation channel" + - "buildExecutorOptions() includes mcpConfigPath from state when present" + - "Existing nodes are unaffected (field is optional in state)" + tdd: + red: + - "Write unit test: buildExecutorOptions with mcpConfigPath in state includes it in result" + - "Write unit test: buildExecutorOptions without mcpConfigPath in state omits it" + green: + - "Add mcpConfigPath to state annotations" + - "Include in buildExecutorOptions return value" + refactor: + - "Ensure state channel declaration follows existing pattern" + + - id: "task-24" + phaseId: "phase-4" + title: "Integrate plugin startup into feature agent worker" + description: > + Modify feature-agent-worker.ts to start MCP servers before graph invocation + and stop them in the finally block. Read feature activePlugins from the + repository, resolve enabled plugins, call McpServerManager.startServersForFeature(), + and pass mcpConfigPath through to graph input state. + state: "Todo" + dependencies: + - "task-20" + - "task-23" + - "task-8" + acceptanceCriteria: + - "Worker reads feature activePlugins from repository at startup" + - "Resolves enabled plugins (global default + per-feature override)" + - "Calls McpServerManager.startServersForFeature() before graph invocation" + - "Passes mcpConfigPath to graph input state" + - "Calls McpServerManager.stopServersForFeature() in finally block" + - "Plugin failures do not crash the worker (graceful degradation, NFR-8)" + - "Plugin startup logs are written to worker log file" + tdd: + red: + - "Write integration test: worker with active MCP plugins passes mcpConfigPath to graph state" + - "Write integration test: worker cleans up MCP servers in finally block" + - "Write integration test: MCP server startup failure does not prevent graph execution" + green: + - "Add plugin startup logic to worker initialization" + - "Add plugin cleanup to worker finally block" + - "Pass mcpConfigPath through to graph input" + refactor: + - "Extract plugin resolution logic to a helper function" + - "Ensure error handling follows graceful degradation pattern" + + # ============================================================ + # Phase 5: CLI Commands + # ============================================================ + + - id: "task-25" + phaseId: "phase-5" + title: "Create plugin command group and add/remove/list subcommands" + description: > + Create the plugin command group following the settings command pattern. + Implement add, remove, and list subcommands. add supports both catalog name + and custom plugin options. list displays plugins in a table with status. + Register plugin command in the main CLI entry point. + state: "Todo" + dependencies: + - "task-19" + acceptanceCriteria: + - "createPluginCommand() returns Commander command group" + - "shep plugin add calls AddPluginUseCase" + - "shep plugin add --name X --type mcp --command Y --transport stdio calls AddPluginUseCase with custom input" + - "shep plugin remove calls RemovePluginUseCase" + - "shep plugin list calls ListPluginsUseCase and displays table" + - "Plugin command registered in main CLI entry point" + - "All commands resolve use cases from DI container" + tdd: + red: + - "Write unit test: createPluginCommand returns a Command with name 'plugin'" + - "Write unit test: add subcommand resolves AddPluginUseCase and calls execute" + green: + - "Create plugin/index.ts with createPluginCommand()" + - "Create add.command.ts, remove.command.ts, list.command.ts" + - "Register in main CLI commands/index.ts" + refactor: + - "Ensure error messages are user-friendly (not raw stack traces)" + - "Follow existing command output formatting patterns" + + - id: "task-26" + phaseId: "phase-5" + title: "Create enable/disable/configure/status/catalog CLI subcommands" + description: > + Implement the remaining five CLI subcommands. enable/disable toggle activation. + configure sets tool groups. status shows detailed health check results. + catalog lists available plugins from the curated catalog. + state: "Todo" + dependencies: + - "task-25" + acceptanceCriteria: + - "shep plugin enable calls EnablePluginUseCase" + - "shep plugin disable calls DisablePluginUseCase" + - "shep plugin configure --tool-groups X,Y calls ConfigurePluginUseCase" + - "shep plugin status calls CheckPluginHealthUseCase and shows detailed results" + - "shep plugin catalog calls GetPluginCatalogUseCase and displays table" + - "All commands handle errors gracefully with user-friendly messages" + tdd: + red: + - "Write unit test: enable subcommand resolves use case and calls execute" + - "Write unit test: catalog subcommand displays catalog entries in table format" + green: + - "Create enable.command.ts, disable.command.ts, configure.command.ts, status.command.ts, catalog.command.ts" + - "Register in plugin/index.ts" + refactor: + - "Extract shared table formatting logic if duplicated across list/catalog/status" + + # ============================================================ + # Phase 6: Web UI + # ============================================================ + + - id: "task-27" + phaseId: "phase-6" + title: "Create plugin server actions" + description: > + Create server actions for plugin management: list-plugins, add-plugin, + remove-plugin, toggle-plugin, get-plugin-catalog, check-plugin-health, + configure-plugin. Each resolves use case by string token from DI container. + state: "Todo" + dependencies: + - "task-19" + acceptanceCriteria: + - "All server actions use 'use server' directive" + - "Resolve use cases via container.resolve('StringToken')" + - "Return serializable data (no class instances)" + - "Handle errors with try/catch and return error state" + - "Follow existing server action patterns (e.g., list-features.ts)" + tdd: + red: + - "Write unit test: list-plugins action returns plugin array" + - "Write unit test: add-plugin action creates and returns plugin" + green: + - "Create all seven server action files in app/actions/" + refactor: + - "Ensure consistent error handling across all actions" + + - id: "task-28" + phaseId: "phase-6" + title: "Create plugin list component with status badges and toggles" + description: > + Create PluginList component showing installed plugins in a Card layout with + status badge (Healthy/Degraded/Unavailable), enabled/disabled Switch toggle, + and action buttons (configure, remove). Include Storybook stories file. + state: "Todo" + dependencies: + - "task-27" + acceptanceCriteria: + - "PluginList displays each plugin with name, type, description, status badge" + - "Switch toggle calls toggle-plugin server action" + - "Status badge uses green/yellow/red colors for Healthy/Degraded/Unavailable" + - "Remove button calls remove-plugin server action with confirmation" + - "Empty state shown when no plugins installed" + - "Colocated .stories.tsx file with multiple states (empty, loaded, mixed status)" + tdd: + red: + - "Write Storybook story: PluginList with no plugins shows empty state" + - "Write Storybook story: PluginList with 3 plugins shows cards with correct badges" + green: + - "Create PluginList component using shadcn Card, Switch, Badge" + - "Create plugin-list.stories.tsx" + refactor: + - "Extract PluginCard as a subcomponent if PluginList grows large" + + - id: "task-29" + phaseId: "phase-6" + title: "Create plugin catalog browser component" + description: > + Create PluginCatalog component showing curated catalog entries with Install + buttons. Shows name, description, type, runtime requirements. Install button + calls add-plugin server action. Already-installed plugins show Installed badge + instead of Install button. Include Storybook stories file. + state: "Todo" + dependencies: + - "task-27" + acceptanceCriteria: + - "PluginCatalog displays catalog entries with name, description, type badge, runtime info" + - "Install button calls add-plugin server action" + - "Already-installed plugins show Installed badge instead of button" + - "Loading state while installing" + - "Colocated .stories.tsx file" + tdd: + red: + - "Write Storybook story: PluginCatalog with 3 entries, none installed" + - "Write Storybook story: PluginCatalog with 1 installed, 2 available" + green: + - "Create PluginCatalog component using shadcn Card, Button, Badge" + - "Create plugin-catalog.stories.tsx" + refactor: + - "Ensure consistent styling with existing components" + + - id: "task-30" + phaseId: "phase-6" + title: "Create plugins page and add per-feature plugin toggles" + description: > + Create the /plugins page combining PluginList and PluginCatalog with tabs. + Add navigation link to the dashboard layout sidebar. Add per-feature plugin + activation toggles to the create-feature drawer, following existing per-feature + settings patterns. + state: "Todo" + dependencies: + - "task-28" + - "task-29" + acceptanceCriteria: + - "Plugins page at /plugins with Installed and Catalog tabs" + - "Navigation link added to dashboard sidebar" + - "Create-feature drawer has plugin activation section with toggles" + - "Per-feature plugin toggles pass activePlugins to create-feature server action" + - "Page follows existing layout patterns (header, content area)" + tdd: + red: + - "Write Storybook story: Plugins page with both tabs" + green: + - "Create app/plugins/page.tsx with tab layout" + - "Add navigation link to dashboard layout" + - "Add plugin section to create-feature drawer" + refactor: + - "Ensure responsive layout on mobile" + - "Verify accessibility (keyboard navigation, screen reader labels)" + +totalEstimate: "40-50h" + +openQuestions: [] + +content: | + ## Summary + + The implementation is structured in 6 phases with 30 tasks, progressing from domain + foundation through persistence, core services, MCP lifecycle management, CLI commands, + and web UI. + + Phase 1 establishes the domain vocabulary in TypeSpec -- Plugin entity, enums, and the + ToolGroup model -- which all other layers depend on. Phase 2 builds the persistence layer + with two additive-only migrations (plugins table and features.active_plugins column), + the PluginRow mapper, and SQLitePluginRepository. Phase 3 is the largest phase, creating + the port interfaces, curated catalog, health checker, and all eight use cases plus DI + container wiring with string token aliases. + + Phase 4 tackles the most complex piece: MCP server lifecycle management with + child_process.spawn(), reference counting, per-feature temp .mcp.json generation, and + integration into the agent execution pipeline (AgentExecutionOptions, ClaudeCodeExecutorService, + feature agent worker). Phase 5 adds the CLI plugin command group with 8 subcommands + following the settings command pattern. Phase 6 completes the feature with web UI + components (plugin list, catalog browser), server actions, the plugins page, and + per-feature plugin toggles in the create-feature drawer. + + Each task follows TDD: write failing tests first (RED), implement minimally (GREEN), + then clean up (REFACTOR). The ordering ensures no task depends on a later task. + Critical lessons from LESSONS.md are baked into specific tasks -- INSERT/UPDATE SQL + completeness (task 7-8), string token aliases (task 19), per-feature settings flow + (task 24), and additive-only migrations (tasks 4-5). + + --- + + _Task breakdown created 2026-04-13_ diff --git a/src/presentation/cli/commands/plugin/add.command.ts b/src/presentation/cli/commands/plugin/add.command.ts new file mode 100644 index 000000000..8e0de4176 --- /dev/null +++ b/src/presentation/cli/commands/plugin/add.command.ts @@ -0,0 +1,87 @@ +/** + * Plugin Add Command + * + * Install a plugin from the curated catalog or with custom configuration. + * + * Usage: + * shep plugin add mempalace # Install from catalog + * shep plugin add --name my-tool --type mcp --command "npx my-mcp" --transport stdio # Custom + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { AddPluginUseCase } from '@/application/use-cases/plugins/add-plugin.use-case.js'; +import { PluginType, PluginTransport } from '@/domain/generated/output.js'; +import { messages } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +const PLUGIN_TYPE_MAP: Record = { + mcp: PluginType.Mcp, + hook: PluginType.Hook, + cli: PluginType.Cli, +}; + +const TRANSPORT_MAP: Record = { + stdio: PluginTransport.Stdio, + http: PluginTransport.Http, +}; + +interface AddOptions { + name?: string; + type?: string; + command?: string; + transport?: string; +} + +export function createAddCommand(): Command { + const t = getCliI18n().t; + return new Command('add') + .description(t('cli:commands.plugin.add.description')) + .argument('[catalogName]', t('cli:commands.plugin.add.nameArg')) + .option('--name ', t('cli:commands.plugin.add.nameOption')) + .option('--type ', t('cli:commands.plugin.add.typeOption')) + .option('--command ', t('cli:commands.plugin.add.commandOption')) + .option('--transport ', t('cli:commands.plugin.add.transportOption')) + .action(async (catalogName: string | undefined, options: AddOptions) => { + try { + const useCase = container.resolve(AddPluginUseCase); + + if (catalogName) { + // Catalog-based install + const plugin = await useCase.execute(catalogName); + messages.success(t('cli:commands.plugin.add.success', { name: plugin.name })); + } else { + // Custom plugin install + if (!options.name) { + messages.error(t('cli:commands.plugin.add.customRequiresName')); + process.exitCode = 1; + return; + } + if (!options.type) { + messages.error(t('cli:commands.plugin.add.customRequiresType')); + process.exitCode = 1; + return; + } + + const pluginType = PLUGIN_TYPE_MAP[options.type.toLowerCase()]; + if (!pluginType) { + messages.error(t('cli:commands.plugin.add.invalidType', { type: options.type })); + process.exitCode = 1; + return; + } + + const plugin = await useCase.execute({ + name: options.name, + type: pluginType, + ...(options.command && { serverCommand: options.command }), + ...(options.transport && { transport: TRANSPORT_MAP[options.transport.toLowerCase()] }), + }); + messages.success(t('cli:commands.plugin.add.success', { name: plugin.name })); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.add.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/plugin/catalog.command.ts b/src/presentation/cli/commands/plugin/catalog.command.ts new file mode 100644 index 000000000..bc4013674 --- /dev/null +++ b/src/presentation/cli/commands/plugin/catalog.command.ts @@ -0,0 +1,48 @@ +/** + * Plugin Catalog Command + * + * Browse available plugins from the curated catalog. + * + * Usage: + * shep plugin catalog + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { GetPluginCatalogUseCase } from '@/application/use-cases/plugins/get-plugin-catalog.use-case.js'; +import { colors, messages, renderListView } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +export function createCatalogCommand(): Command { + const t = getCliI18n().t; + return new Command('catalog') + .description(t('cli:commands.plugin.catalog.description')) + .action(async () => { + try { + const useCase = container.resolve(GetPluginCatalogUseCase); + const entries = await useCase.execute(); + + renderListView({ + title: t('cli:commands.plugin.catalog.title'), + columns: [ + { label: t('cli:commands.plugin.catalog.nameColumn'), width: 20 }, + { label: t('cli:commands.plugin.catalog.typeColumn'), width: 8 }, + { label: t('cli:commands.plugin.catalog.statusColumn'), width: 12 }, + { label: t('cli:commands.plugin.catalog.descriptionColumn'), width: 50 }, + ], + rows: entries.map((e) => [ + e.name, + e.type, + e.isInstalled + ? colors.success(t('cli:commands.plugin.catalog.installed')) + : colors.muted(t('cli:commands.plugin.catalog.available')), + colors.muted(e.description), + ]), + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.catalog.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/plugin/configure.command.ts b/src/presentation/cli/commands/plugin/configure.command.ts new file mode 100644 index 000000000..21fe87878 --- /dev/null +++ b/src/presentation/cli/commands/plugin/configure.command.ts @@ -0,0 +1,39 @@ +/** + * Plugin Configure Command + * + * Configure plugin settings such as active tool groups. + * + * Usage: + * shep plugin configure ruflo --tool-groups implement,test + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { ConfigurePluginUseCase } from '@/application/use-cases/plugins/configure-plugin.use-case.js'; +import { messages } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +export function createConfigureCommand(): Command { + const t = getCliI18n().t; + return new Command('configure') + .description(t('cli:commands.plugin.configure.description')) + .argument('', t('cli:commands.plugin.configure.nameArg')) + .option('--tool-groups ', t('cli:commands.plugin.configure.toolGroupsOption')) + .action(async (name: string, options: { toolGroups?: string }) => { + try { + if (!options.toolGroups) { + messages.info(t('cli:commands.plugin.configure.noOptions')); + return; + } + + const activeToolGroups = options.toolGroups.split(',').map((g) => g.trim()); + const useCase = container.resolve(ConfigurePluginUseCase); + const plugin = await useCase.execute(name, { activeToolGroups }); + messages.success(t('cli:commands.plugin.configure.success', { name: plugin.name })); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.configure.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/plugin/disable.command.ts b/src/presentation/cli/commands/plugin/disable.command.ts new file mode 100644 index 000000000..3a3564cef --- /dev/null +++ b/src/presentation/cli/commands/plugin/disable.command.ts @@ -0,0 +1,32 @@ +/** + * Plugin Disable Command + * + * Disable a plugin from global use in features. + * + * Usage: + * shep plugin disable mempalace + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { DisablePluginUseCase } from '@/application/use-cases/plugins/disable-plugin.use-case.js'; +import { messages } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +export function createDisableCommand(): Command { + const t = getCliI18n().t; + return new Command('disable') + .description(t('cli:commands.plugin.disable.description')) + .argument('', t('cli:commands.plugin.disable.nameArg')) + .action(async (name: string) => { + try { + const useCase = container.resolve(DisablePluginUseCase); + const plugin = await useCase.execute(name); + messages.success(t('cli:commands.plugin.disable.success', { name: plugin.name })); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.disable.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/plugin/enable.command.ts b/src/presentation/cli/commands/plugin/enable.command.ts new file mode 100644 index 000000000..91e6c3a26 --- /dev/null +++ b/src/presentation/cli/commands/plugin/enable.command.ts @@ -0,0 +1,32 @@ +/** + * Plugin Enable Command + * + * Enable a plugin for global use in features. + * + * Usage: + * shep plugin enable mempalace + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { EnablePluginUseCase } from '@/application/use-cases/plugins/enable-plugin.use-case.js'; +import { messages } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +export function createEnableCommand(): Command { + const t = getCliI18n().t; + return new Command('enable') + .description(t('cli:commands.plugin.enable.description')) + .argument('', t('cli:commands.plugin.enable.nameArg')) + .action(async (name: string) => { + try { + const useCase = container.resolve(EnablePluginUseCase); + const plugin = await useCase.execute(name); + messages.success(t('cli:commands.plugin.enable.success', { name: plugin.name })); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.enable.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/plugin/index.ts b/src/presentation/cli/commands/plugin/index.ts new file mode 100644 index 000000000..456ece56e --- /dev/null +++ b/src/presentation/cli/commands/plugin/index.ts @@ -0,0 +1,43 @@ +/** + * Plugin Command Group + * + * Provides subcommands for managing AI tool plugins. + * + * Usage: + * shep plugin add # Install a plugin from catalog + * shep plugin add --name X ... # Install a custom plugin + * shep plugin remove # Remove a plugin + * shep plugin list # List installed plugins + * shep plugin enable # Enable a plugin globally + * shep plugin disable # Disable a plugin globally + * shep plugin configure # Configure plugin settings + * shep plugin status [name] # Show health status + * shep plugin catalog # Browse available plugins + */ + +import { Command } from 'commander'; +import { createAddCommand } from './add.command.js'; +import { createRemoveCommand } from './remove.command.js'; +import { createListCommand } from './list.command.js'; +import { createEnableCommand } from './enable.command.js'; +import { createDisableCommand } from './disable.command.js'; +import { createConfigureCommand } from './configure.command.js'; +import { createStatusCommand } from './status.command.js'; +import { createCatalogCommand } from './catalog.command.js'; +import { getCliI18n } from '../../i18n.js'; + +/** + * Create the plugin command group + */ +export function createPluginCommand(): Command { + return new Command('plugin') + .description(getCliI18n().t('cli:commands.plugin.description')) + .addCommand(createAddCommand()) + .addCommand(createRemoveCommand()) + .addCommand(createListCommand()) + .addCommand(createEnableCommand()) + .addCommand(createDisableCommand()) + .addCommand(createConfigureCommand()) + .addCommand(createStatusCommand()) + .addCommand(createCatalogCommand()); +} diff --git a/src/presentation/cli/commands/plugin/list.command.ts b/src/presentation/cli/commands/plugin/list.command.ts new file mode 100644 index 000000000..f55a66a54 --- /dev/null +++ b/src/presentation/cli/commands/plugin/list.command.ts @@ -0,0 +1,67 @@ +/** + * Plugin List Command + * + * List all installed plugins in a formatted table. + * + * Usage: + * shep plugin list + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { ListPluginsUseCase } from '@/application/use-cases/plugins/list-plugins.use-case.js'; +import { PluginHealthStatus } from '@/domain/generated/output.js'; +import { colors, messages, renderListView } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +function colorHealth(status: string): string { + switch (status) { + case PluginHealthStatus.Healthy: + return colors.success(status); + case PluginHealthStatus.Degraded: + return colors.warning(status); + case PluginHealthStatus.Unavailable: + return colors.error(status); + default: + return colors.muted(status); + } +} + +function colorEnabled(enabled: boolean): string { + return enabled ? colors.success('Enabled') : colors.muted('Disabled'); +} + +export function createListCommand(): Command { + const t = getCliI18n().t; + return new Command('list') + .description(t('cli:commands.plugin.list.description')) + .action(async () => { + try { + const useCase = container.resolve(ListPluginsUseCase); + const plugins = await useCase.execute(); + + renderListView({ + title: t('cli:commands.plugin.list.title'), + columns: [ + { label: t('cli:commands.plugin.list.nameColumn'), width: 20 }, + { label: t('cli:commands.plugin.list.typeColumn'), width: 8 }, + { label: t('cli:commands.plugin.list.statusColumn'), width: 12 }, + { label: t('cli:commands.plugin.list.healthColumn'), width: 14 }, + { label: t('cli:commands.plugin.list.sourceColumn'), width: 10 }, + ], + rows: plugins.map((p) => [ + p.name, + p.type, + colorEnabled(p.enabled), + colorHealth(p.healthStatus), + colors.muted(p.installSource ?? 'unknown'), + ]), + emptyMessage: t('cli:commands.plugin.list.noPlugins'), + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.list.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/plugin/remove.command.ts b/src/presentation/cli/commands/plugin/remove.command.ts new file mode 100644 index 000000000..419e9c616 --- /dev/null +++ b/src/presentation/cli/commands/plugin/remove.command.ts @@ -0,0 +1,32 @@ +/** + * Plugin Remove Command + * + * Remove an installed plugin from the registry. + * + * Usage: + * shep plugin remove mempalace + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { RemovePluginUseCase } from '@/application/use-cases/plugins/remove-plugin.use-case.js'; +import { messages } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +export function createRemoveCommand(): Command { + const t = getCliI18n().t; + return new Command('remove') + .description(t('cli:commands.plugin.remove.description')) + .argument('', t('cli:commands.plugin.remove.nameArg')) + .action(async (name: string) => { + try { + const useCase = container.resolve(RemovePluginUseCase); + const plugin = await useCase.execute(name); + messages.success(t('cli:commands.plugin.remove.success', { name: plugin.name })); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.remove.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/commands/plugin/status.command.ts b/src/presentation/cli/commands/plugin/status.command.ts new file mode 100644 index 000000000..a50fe73eb --- /dev/null +++ b/src/presentation/cli/commands/plugin/status.command.ts @@ -0,0 +1,138 @@ +/** + * Plugin Status Command + * + * Show detailed health status of a plugin or all plugins. + * + * Usage: + * shep plugin status mempalace # Check specific plugin + * shep plugin status # Check all plugins + */ + +import { Command } from 'commander'; +import { container } from '@/infrastructure/di/container.js'; +import { CheckPluginHealthUseCase } from '@/application/use-cases/plugins/check-plugin-health.use-case.js'; +import { ListPluginsUseCase } from '@/application/use-cases/plugins/list-plugins.use-case.js'; +import { PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; +import { colors, messages, renderDetailView, renderListView } from '../../ui/index.js'; +import { getCliI18n } from '../../i18n.js'; + +function colorHealth(status: string): string { + switch (status) { + case PluginHealthStatus.Healthy: + return colors.success(status); + case PluginHealthStatus.Degraded: + return colors.warning(status); + case PluginHealthStatus.Unavailable: + return colors.error(status); + default: + return colors.muted(status); + } +} + +function buildPluginDetailSections(plugin: Plugin, t: (key: string) => string) { + const fields = [ + { label: t('cli:commands.plugin.status.nameLabel'), value: plugin.name }, + { label: t('cli:commands.plugin.status.typeLabel'), value: plugin.type }, + { label: t('cli:commands.plugin.status.healthLabel'), value: colorHealth(plugin.healthStatus) }, + { + label: t('cli:commands.plugin.status.messageLabel'), + value: plugin.healthMessage ?? colors.muted('none'), + }, + { + label: t('cli:commands.plugin.status.enabledLabel'), + value: plugin.enabled ? colors.success('Yes') : colors.muted('No'), + }, + ]; + + if (plugin.runtimeType) { + const version = plugin.runtimeMinVersion ? ` ${plugin.runtimeMinVersion}+` : ''; + fields.push({ + label: t('cli:commands.plugin.status.runtimeLabel'), + value: `${plugin.runtimeType}${version}`, + }); + } + + if (plugin.transport) { + fields.push({ + label: t('cli:commands.plugin.status.transportLabel'), + value: plugin.transport, + }); + } + + if (plugin.requiredEnvVars?.length) { + fields.push({ + label: t('cli:commands.plugin.status.envVarsLabel'), + value: plugin.requiredEnvVars.join(', '), + }); + } + + if (plugin.toolGroups?.length) { + fields.push({ + label: t('cli:commands.plugin.status.toolGroupsLabel'), + value: plugin.toolGroups.map((g) => g.name).join(', '), + }); + } + + if (plugin.activeToolGroups?.length) { + fields.push({ + label: t('cli:commands.plugin.status.activeGroupsLabel'), + value: plugin.activeToolGroups.join(', '), + }); + } + + return [{ fields }]; +} + +export function createStatusCommand(): Command { + const t = getCliI18n().t; + return new Command('status') + .description(t('cli:commands.plugin.status.description')) + .argument('[name]', t('cli:commands.plugin.status.nameArg')) + .action(async (name?: string) => { + try { + const healthUseCase = container.resolve(CheckPluginHealthUseCase); + const results = await healthUseCase.execute(name); + + if (name) { + // Detailed view for single plugin + const listUseCase = container.resolve(ListPluginsUseCase); + const plugins = await listUseCase.execute(); + const plugin = plugins.find((p) => p.name === name); + + if (plugin) { + // Update health from fresh results + const updatedPlugin = { + ...plugin, + healthStatus: results[0].status, + healthMessage: results[0].message, + }; + renderDetailView({ + title: t('cli:commands.plugin.status.title'), + sections: buildPluginDetailSections(updatedPlugin, t), + }); + } + } else { + // List view for all plugins + if (results.length === 0) { + messages.info(t('cli:commands.plugin.status.noPlugins')); + return; + } + + renderListView({ + title: t('cli:commands.plugin.status.allTitle'), + columns: [ + { label: t('cli:commands.plugin.status.nameLabel'), width: 20 }, + { label: t('cli:commands.plugin.status.healthLabel'), width: 14 }, + { label: t('cli:commands.plugin.status.messageLabel'), width: 40 }, + ], + rows: results.map((r) => [r.pluginName, colorHealth(r.status), r.message]), + }); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + messages.error(t('cli:commands.plugin.status.failed'), err); + process.exitCode = 1; + } + }); +} diff --git a/src/presentation/cli/index.ts b/src/presentation/cli/index.ts index bb49f9f6c..fc6ee784d 100644 --- a/src/presentation/cli/index.ts +++ b/src/presentation/cli/index.ts @@ -52,6 +52,7 @@ import { createItemCommand } from './commands/item/index.js'; import { createCycleCommand } from './commands/cycle/index.js'; import { createIntakeCommand } from './commands/intake/index.js'; import { createNotificationsCommand } from './commands/notifications/index.js'; +import { createPluginCommand } from './commands/plugin/index.js'; import { messages } from './ui/index.js'; // Daemon lifecycle commands @@ -142,6 +143,7 @@ async function bootstrap() { program.addCommand(createCycleCommand()); program.addCommand(createIntakeCommand()); program.addCommand(createNotificationsCommand()); + program.addCommand(createPluginCommand()); program.addCommand(createUpgradeCommand()); // Daemon lifecycle commands (task-9) diff --git a/src/presentation/web/app/(dashboard)/@drawer/create/page.tsx b/src/presentation/web/app/(dashboard)/@drawer/create/page.tsx index 39becfd74..cf0cae493 100644 --- a/src/presentation/web/app/(dashboard)/@drawer/create/page.tsx +++ b/src/presentation/web/app/(dashboard)/@drawer/create/page.tsx @@ -1,6 +1,7 @@ import { resolve } from '@/lib/server-container'; import type { ListFeaturesUseCase } from '@shepai/core/application/use-cases/features/list-features.use-case'; import type { ListRepositoriesUseCase } from '@shepai/core/application/use-cases/repositories/list-repositories.use-case'; +import type { ListPluginsUseCase } from '@shepai/core/application/use-cases/plugins/list-plugins.use-case'; import { getSettings } from '@shepai/core/infrastructure/services/settings.service'; import { getWorkflowDefaults } from '@/app/actions/get-workflow-defaults'; import { getViewerPermission } from '@/app/actions/get-viewer-permission'; @@ -18,15 +19,17 @@ export default async function CreateDrawerPage({ searchParams }: CreateDrawerPag const listFeatures = resolve('ListFeaturesUseCase'); const listRepos = resolve('ListRepositoriesUseCase'); + const listPlugins = resolve('ListPluginsUseCase'); const settings = getSettings(); - const [features, repositories, workflowDefaults, viewerPerm] = await Promise.all([ + const [features, repositories, workflowDefaults, viewerPerm, plugins] = await Promise.all([ listFeatures.execute(), listRepos.execute().catch(() => []), getWorkflowDefaults().catch(() => undefined), repo ? getViewerPermission(repo).catch(() => ({ canPushDirectly: false })) : Promise.resolve({ canPushDirectly: false }), + listPlugins.execute().catch(() => []), ]); const featureOptions = features @@ -39,6 +42,12 @@ export default async function CreateDrawerPage({ searchParams }: CreateDrawerPag path: r.path, })); + const installedPlugins = plugins.map((p) => ({ + name: p.name, + displayName: p.displayName ?? p.name, + enabled: p.enabled, + })); + return ( ); } diff --git a/src/presentation/web/app/actions/add-plugin.ts b/src/presentation/web/app/actions/add-plugin.ts new file mode 100644 index 000000000..eca102875 --- /dev/null +++ b/src/presentation/web/app/actions/add-plugin.ts @@ -0,0 +1,23 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { resolve } from '@/lib/server-container'; +import type { AddPluginUseCase } from '@shepai/core/application/use-cases/plugins/add-plugin.use-case'; +import type { Plugin } from '@shepai/core/domain/generated/output'; + +export interface AddPluginResult { + plugin?: Plugin; + error?: string; +} + +export async function addPlugin(nameOrInput: string): Promise { + try { + const useCase = resolve('AddPluginUseCase'); + const plugin = await useCase.execute(nameOrInput); + revalidatePath('/plugins'); + return { plugin }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to add plugin'; + return { error: message }; + } +} diff --git a/src/presentation/web/app/actions/check-plugin-health.ts b/src/presentation/web/app/actions/check-plugin-health.ts new file mode 100644 index 000000000..34f7520c4 --- /dev/null +++ b/src/presentation/web/app/actions/check-plugin-health.ts @@ -0,0 +1,23 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { resolve } from '@/lib/server-container'; +import type { CheckPluginHealthUseCase } from '@shepai/core/application/use-cases/plugins/check-plugin-health.use-case'; +import type { PluginHealthResult } from '@shepai/core/application/ports/output/services/plugin-health-checker.interface'; + +export interface CheckPluginHealthResult { + results?: PluginHealthResult[]; + error?: string; +} + +export async function checkPluginHealth(pluginName?: string): Promise { + try { + const useCase = resolve('CheckPluginHealthUseCase'); + const results = await useCase.execute(pluginName); + revalidatePath('/plugins'); + return { results }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to check plugin health'; + return { error: message }; + } +} diff --git a/src/presentation/web/app/actions/configure-plugin.ts b/src/presentation/web/app/actions/configure-plugin.ts new file mode 100644 index 000000000..bf53c64f4 --- /dev/null +++ b/src/presentation/web/app/actions/configure-plugin.ts @@ -0,0 +1,26 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { resolve } from '@/lib/server-container'; +import type { ConfigurePluginUseCase } from '@shepai/core/application/use-cases/plugins/configure-plugin.use-case'; +import type { Plugin } from '@shepai/core/domain/generated/output'; + +export interface ConfigurePluginResult { + plugin?: Plugin; + error?: string; +} + +export async function configurePlugin( + pluginName: string, + activeToolGroups: string[] +): Promise { + try { + const useCase = resolve('ConfigurePluginUseCase'); + const plugin = await useCase.execute(pluginName, { activeToolGroups }); + revalidatePath('/plugins'); + return { plugin }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to configure plugin'; + return { error: message }; + } +} diff --git a/src/presentation/web/app/actions/create-feature.ts b/src/presentation/web/app/actions/create-feature.ts index 1735420a6..44220d5e3 100644 --- a/src/presentation/web/app/actions/create-feature.ts +++ b/src/presentation/web/app/actions/create-feature.ts @@ -52,6 +52,8 @@ interface CreateFeatureInput { rebaseBeforeBranch?: boolean; /** Inject curated skills into the feature worktree. */ injectSkills?: boolean; + /** Per-feature plugin activation overrides (plugin name -> enabled/disabled). */ + activePlugins?: Record; } export async function createFeature( @@ -77,6 +79,7 @@ export async function createFeature( model, rebaseBeforeBranch, injectSkills, + activePlugins, } = input; if (!description?.trim()) { @@ -117,6 +120,7 @@ export async function createFeature( ...(model ? { model } : {}), ...(rebaseBeforeBranch != null ? { rebaseBeforeBranch } : {}), ...(injectSkills != null ? { injectSkills } : {}), + ...(activePlugins && Object.keys(activePlugins).length > 0 ? { activePlugins } : {}), }); // Phase 2 (background): metadata generation, worktree, spec, agent spawn @@ -143,6 +147,7 @@ export async function createFeature( ...(sessionId ? { sessionId } : {}), ...(rebaseBeforeBranch != null ? { rebaseBeforeBranch } : {}), ...(injectSkills != null ? { injectSkills } : {}), + ...(activePlugins && Object.keys(activePlugins).length > 0 ? { activePlugins } : {}), }, shouldSpawn ) diff --git a/src/presentation/web/app/actions/get-plugin-catalog.ts b/src/presentation/web/app/actions/get-plugin-catalog.ts new file mode 100644 index 000000000..23cb06d98 --- /dev/null +++ b/src/presentation/web/app/actions/get-plugin-catalog.ts @@ -0,0 +1,23 @@ +'use server'; + +import { resolve } from '@/lib/server-container'; +import type { + GetPluginCatalogUseCase, + CatalogEntryWithStatus, +} from '@shepai/core/application/use-cases/plugins/get-plugin-catalog.use-case'; + +export interface GetPluginCatalogResult { + catalog?: CatalogEntryWithStatus[]; + error?: string; +} + +export async function getPluginCatalog(): Promise { + try { + const useCase = resolve('GetPluginCatalogUseCase'); + const catalog = await useCase.execute(); + return { catalog }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to load plugin catalog'; + return { error: message }; + } +} diff --git a/src/presentation/web/app/actions/list-plugins.ts b/src/presentation/web/app/actions/list-plugins.ts new file mode 100644 index 000000000..08e7dc002 --- /dev/null +++ b/src/presentation/web/app/actions/list-plugins.ts @@ -0,0 +1,21 @@ +'use server'; + +import { resolve } from '@/lib/server-container'; +import type { ListPluginsUseCase } from '@shepai/core/application/use-cases/plugins/list-plugins.use-case'; +import type { Plugin } from '@shepai/core/domain/generated/output'; + +export interface ListPluginsResult { + plugins?: Plugin[]; + error?: string; +} + +export async function listPlugins(): Promise { + try { + const useCase = resolve('ListPluginsUseCase'); + const plugins = await useCase.execute(); + return { plugins }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to list plugins'; + return { error: message }; + } +} diff --git a/src/presentation/web/app/actions/remove-plugin.ts b/src/presentation/web/app/actions/remove-plugin.ts new file mode 100644 index 000000000..e68b80085 --- /dev/null +++ b/src/presentation/web/app/actions/remove-plugin.ts @@ -0,0 +1,23 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { resolve } from '@/lib/server-container'; +import type { RemovePluginUseCase } from '@shepai/core/application/use-cases/plugins/remove-plugin.use-case'; +import type { Plugin } from '@shepai/core/domain/generated/output'; + +export interface RemovePluginResult { + plugin?: Plugin; + error?: string; +} + +export async function removePlugin(pluginName: string): Promise { + try { + const useCase = resolve('RemovePluginUseCase'); + const plugin = await useCase.execute(pluginName); + revalidatePath('/plugins'); + return { plugin }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to remove plugin'; + return { error: message }; + } +} diff --git a/src/presentation/web/app/actions/toggle-plugin.ts b/src/presentation/web/app/actions/toggle-plugin.ts new file mode 100644 index 000000000..e0d16bdae --- /dev/null +++ b/src/presentation/web/app/actions/toggle-plugin.ts @@ -0,0 +1,34 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { resolve } from '@/lib/server-container'; +import type { EnablePluginUseCase } from '@shepai/core/application/use-cases/plugins/enable-plugin.use-case'; +import type { DisablePluginUseCase } from '@shepai/core/application/use-cases/plugins/disable-plugin.use-case'; +import type { Plugin } from '@shepai/core/domain/generated/output'; + +export interface TogglePluginResult { + plugin?: Plugin; + error?: string; +} + +export async function togglePlugin( + pluginName: string, + enabled: boolean +): Promise { + try { + if (enabled) { + const useCase = resolve('EnablePluginUseCase'); + const plugin = await useCase.execute(pluginName); + revalidatePath('/plugins'); + return { plugin }; + } else { + const useCase = resolve('DisablePluginUseCase'); + const plugin = await useCase.execute(pluginName); + revalidatePath('/plugins'); + return { plugin }; + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to toggle plugin'; + return { error: message }; + } +} diff --git a/src/presentation/web/app/plugins/page.tsx b/src/presentation/web/app/plugins/page.tsx new file mode 100644 index 000000000..3391d79f7 --- /dev/null +++ b/src/presentation/web/app/plugins/page.tsx @@ -0,0 +1,36 @@ +import { resolve } from '@/lib/server-container'; +import type { ListPluginsUseCase } from '@shepai/core/application/use-cases/plugins/list-plugins.use-case'; +import type { + GetPluginCatalogUseCase, + CatalogEntryWithStatus, +} from '@shepai/core/application/use-cases/plugins/get-plugin-catalog.use-case'; +import type { Plugin } from '@shepai/core/domain/generated/output'; +import { PluginsPageClient } from './plugins-page-client'; + +/** Skip static pre-rendering since we need runtime DI container. */ +export const dynamic = 'force-dynamic'; + +export default async function PluginsPage() { + let plugins: Plugin[] = []; + let catalog: CatalogEntryWithStatus[] = []; + + try { + const listUseCase = resolve('ListPluginsUseCase'); + plugins = await listUseCase.execute(); + } catch { + // DI container may not be available during build + } + + try { + const catalogUseCase = resolve('GetPluginCatalogUseCase'); + catalog = await catalogUseCase.execute(); + } catch { + // DI container may not be available during build + } + + return ( +
+ +
+ ); +} diff --git a/src/presentation/web/app/plugins/plugins-page-client.tsx b/src/presentation/web/app/plugins/plugins-page-client.tsx new file mode 100644 index 000000000..04957c881 --- /dev/null +++ b/src/presentation/web/app/plugins/plugins-page-client.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { Plug } from 'lucide-react'; +import { toast } from 'sonner'; +import { PageHeader } from '@/components/common/page-header'; +import { PluginList } from '@/components/common/plugin-list'; +import { PluginCatalog } from '@/components/common/plugin-catalog'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { addPlugin } from '@/app/actions/add-plugin'; +import { removePlugin } from '@/app/actions/remove-plugin'; +import { togglePlugin } from '@/app/actions/toggle-plugin'; +import { checkPluginHealth } from '@/app/actions/check-plugin-health'; +import type { Plugin } from '@shepai/core/domain/generated/output'; +import type { CatalogEntryWithStatus } from '@shepai/core/application/use-cases/plugins/get-plugin-catalog.use-case'; + +export interface PluginsPageClientProps { + plugins: Plugin[]; + catalog: CatalogEntryWithStatus[]; +} + +export function PluginsPageClient({ plugins, catalog }: PluginsPageClientProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = useState(plugins.length > 0 ? 'installed' : 'catalog'); + + const handleToggle = useCallback( + async (pluginName: string, enabled: boolean) => { + const result = await togglePlugin(pluginName, enabled); + if (result.error) { + toast.error(result.error); + } else { + toast.success(`${pluginName} ${enabled ? 'enabled' : 'disabled'}`); + router.refresh(); + } + }, + [router] + ); + + const handleRemove = useCallback( + async (pluginName: string) => { + const result = await removePlugin(pluginName); + if (result.error) { + toast.error(result.error); + } else { + toast.success(`${pluginName} removed`); + router.refresh(); + } + }, + [router] + ); + + const handleCheckHealth = useCallback( + async (pluginName: string) => { + const result = await checkPluginHealth(pluginName); + if (result.error) { + toast.error(result.error); + } else if (result.results?.[0]) { + const health = result.results[0]; + toast.info(`${pluginName}: ${health.status} — ${health.message}`); + router.refresh(); + } + }, + [router] + ); + + const handleInstall = useCallback( + async (pluginName: string) => { + const result = await addPlugin(pluginName); + if (result.error) { + toast.error(result.error); + } else { + toast.success(`${pluginName} installed`); + setActiveTab('installed'); + router.refresh(); + } + }, + [router] + ); + + return ( +
+ + + + + + + + Installed{plugins.length > 0 ? ` (${plugins.length})` : ''} + + Catalog + + + + + + + + + + +
+ ); +} diff --git a/src/presentation/web/components/common/control-center-drawer/create-drawer-client.tsx b/src/presentation/web/components/common/control-center-drawer/create-drawer-client.tsx index 0f4f464bc..475b811dc 100644 --- a/src/presentation/web/components/common/control-center-drawer/create-drawer-client.tsx +++ b/src/presentation/web/components/common/control-center-drawer/create-drawer-client.tsx @@ -20,6 +20,7 @@ export interface CreateDrawerClientProps { currentAgentType?: string; currentModel?: string; canPushDirectly?: boolean; + installedPlugins?: { name: string; displayName: string; enabled: boolean }[]; } export function CreateDrawerClient({ @@ -32,6 +33,7 @@ export function CreateDrawerClient({ currentAgentType, currentModel, canPushDirectly, + installedPlugins, }: CreateDrawerClientProps) { const router = useRouter(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -110,6 +112,7 @@ export function CreateDrawerClient({ currentAgentType={currentAgentType} currentModel={currentModel} canPushDirectly={canPushDirectly} + installedPlugins={installedPlugins} /> ); } diff --git a/src/presentation/web/components/common/feature-create-drawer/feature-create-drawer.tsx b/src/presentation/web/components/common/feature-create-drawer/feature-create-drawer.tsx index 71c6c5121..0e1200a0a 100644 --- a/src/presentation/web/components/common/feature-create-drawer/feature-create-drawer.tsx +++ b/src/presentation/web/components/common/feature-create-drawer/feature-create-drawer.tsx @@ -12,6 +12,7 @@ import { Loader2, GitFork, FileText, + Plug, Puzzle, RefreshCw, } from 'lucide-react'; @@ -95,6 +96,8 @@ export interface FeatureCreatePayload { rebaseBeforeBranch: boolean; /** Inject curated skills into the feature worktree. */ injectSkills: boolean; + /** Per-feature plugin activation overrides (plugin name -> enabled/disabled). */ + activePlugins?: Record; /** Optional agent type override for this feature run */ agentType?: string; /** Optional model override for this feature run */ @@ -194,6 +197,8 @@ export interface FeatureCreateDrawerProps { initialDescription?: string; /** When true, user has push access — Fork & PR toggle will be hidden. */ canPushDirectly?: boolean; + /** Installed plugins available for per-feature activation toggles. */ + installedPlugins?: { name: string; displayName: string; enabled: boolean }[]; } export function FeatureCreateDrawer({ @@ -210,6 +215,7 @@ export function FeatureCreateDrawer({ currentModel, initialDescription, canPushDirectly, + installedPlugins, }: FeatureCreateDrawerProps) { const createSound = useSoundAction('create'); const { t } = useTranslation('web'); @@ -255,6 +261,13 @@ export function FeatureCreateDrawer({ const [commitSpecs, setCommitSpecs] = useState(true); const [rebaseBeforeBranch, setRebaseBeforeBranch] = useState(true); const [injectSkills, setInjectSkills] = useState(workflowDefaults?.injectSkills ?? false); + const [pluginOverrides, setPluginOverrides] = useState>(() => { + const defaults: Record = {}; + for (const p of installedPlugins ?? []) { + defaults[p.name] = p.enabled; + } + return defaults; + }); const [overrideAgent, setOverrideAgent] = useState(undefined); const [overrideModel, setOverrideModel] = useState(undefined); const [selectedRepoPath, setSelectedRepoPath] = useState( @@ -518,6 +531,7 @@ export function FeatureCreateDrawer({ commitSpecs, rebaseBeforeBranch, injectSkills, + ...(Object.keys(pluginOverrides).length > 0 ? { activePlugins: pluginOverrides } : {}), ...(pending ? { pending } : {}), ...(overrideAgent ? { agentType: overrideAgent } : {}), ...(overrideModel ? { model: overrideModel } : {}), @@ -543,6 +557,7 @@ export function FeatureCreateDrawer({ commitSpecs, rebaseBeforeBranch, injectSkills, + pluginOverrides, pending, overrideAgent, overrideModel, @@ -1069,6 +1084,47 @@ export function FeatureCreateDrawer({ + {/* Plugins row */} + {installedPlugins && installedPlugins.length > 0 ? ( +
+ + PLUGINS + +
+ {installedPlugins.map((plugin) => ( + + +
+ + setPluginOverrides((prev) => ({ + ...prev, + [plugin.name]: checked, + })) + } + disabled={isSubmitting} + /> + +
+
+ + {`${(pluginOverrides[plugin.name] ?? plugin.enabled) ? 'Disable' : 'Enable'} ${plugin.displayName} for this feature`} + +
+ ))} +
+
+ ) : null} + {/* Git row */}
diff --git a/src/presentation/web/components/common/plugin-catalog/index.ts b/src/presentation/web/components/common/plugin-catalog/index.ts new file mode 100644 index 000000000..768856459 --- /dev/null +++ b/src/presentation/web/components/common/plugin-catalog/index.ts @@ -0,0 +1 @@ +export { PluginCatalog, type PluginCatalogProps } from './plugin-catalog'; diff --git a/src/presentation/web/components/common/plugin-catalog/plugin-catalog.stories.tsx b/src/presentation/web/components/common/plugin-catalog/plugin-catalog.stories.tsx new file mode 100644 index 000000000..4c230c2e2 --- /dev/null +++ b/src/presentation/web/components/common/plugin-catalog/plugin-catalog.stories.tsx @@ -0,0 +1,104 @@ +import { fn } from '@storybook/test'; +import type { Meta, StoryObj } from '@storybook/react'; +import { PluginType, PluginTransport } from '@shepai/core/domain/generated/output'; +import type { CatalogEntryWithStatus } from '@shepai/core/application/use-cases/plugins/get-plugin-catalog.use-case'; +import { PluginCatalog } from './plugin-catalog'; + +const meta = { + title: 'Common/PluginCatalog', + component: PluginCatalog, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/* --------------------------------------------------------------------------- + * Data fixtures + * ------------------------------------------------------------------------- */ + +const catalogEntries: CatalogEntryWithStatus[] = [ + { + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + description: + 'Local AI memory system with persistent knowledge storage. Provides 19 MCP tools for managing long-term memory across AI sessions.', + installCommand: 'pip install mempalace', + serverCommand: 'python', + serverArgs: ['-m', 'mempalace.mcp_server'], + transport: PluginTransport.Stdio, + requiredEnvVars: [], + runtimeType: 'python', + runtimeMinVersion: '3.9', + homepageUrl: 'https://github.com/MemPalace/mempalace', + isInstalled: false, + }, + { + name: 'token-optimizer', + displayName: 'Token Optimizer', + type: PluginType.Hook, + description: + 'Token waste reduction and context management via Claude Code lifecycle hooks. Optimizes token usage across sessions without requiring MCP.', + installCommand: 'pip install token-optimizer', + requiredEnvVars: [], + runtimeType: 'python', + runtimeMinVersion: '3.8', + homepageUrl: 'https://github.com/alexgreensh/token-optimizer', + isInstalled: false, + }, + { + name: 'ruflo', + displayName: 'Ruflo', + type: PluginType.Mcp, + description: + 'Multi-agent AI orchestration framework with 313 MCP tools. Provides specialized agents for implementation, testing, memory, and workflow orchestration.', + installCommand: 'npm install -g ruflo@latest', + serverCommand: 'npx', + serverArgs: ['ruflo@latest', 'mcp', 'start'], + transport: PluginTransport.Stdio, + requiredEnvVars: ['ANTHROPIC_API_KEY'], + toolGroups: [ + { name: 'implement', description: 'Code implementation and generation tools' }, + { name: 'test', description: 'Testing and quality assurance tools' }, + { name: 'memory', description: 'Persistent memory and context management tools' }, + { name: 'flow', description: 'Workflow orchestration and agent coordination tools' }, + ], + runtimeType: 'node', + runtimeMinVersion: '20', + homepageUrl: 'https://github.com/ruvnet/ruflo', + isInstalled: false, + }, +]; + +const onInstall = fn().mockName('onInstall'); + +/* --------------------------------------------------------------------------- + * Stories + * ------------------------------------------------------------------------- */ + +export const AllAvailable: Story = { + args: { + catalog: catalogEntries, + onInstall, + }, +}; + +export const OneInstalled: Story = { + args: { + catalog: catalogEntries.map((entry) => + entry.name === 'mempalace' ? { ...entry, isInstalled: true } : entry + ), + onInstall, + }, +}; + +export const AllInstalled: Story = { + args: { + catalog: catalogEntries.map((entry) => ({ ...entry, isInstalled: true })), + onInstall, + }, +}; diff --git a/src/presentation/web/components/common/plugin-catalog/plugin-catalog.tsx b/src/presentation/web/components/common/plugin-catalog/plugin-catalog.tsx new file mode 100644 index 000000000..ebcb7afac --- /dev/null +++ b/src/presentation/web/components/common/plugin-catalog/plugin-catalog.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useState } from 'react'; +import { Check, Download, Loader2, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { PluginType } from '@shepai/core/domain/generated/output'; +import type { CatalogEntryWithStatus } from '@shepai/core/application/use-cases/plugins/get-plugin-catalog.use-case'; + +export interface PluginCatalogProps { + catalog: CatalogEntryWithStatus[]; + onInstall?: (pluginName: string) => Promise; +} + +const TYPE_LABELS: Record = { + [PluginType.Mcp]: 'MCP', + [PluginType.Hook]: 'Hook', + [PluginType.Cli]: 'CLI', +}; + +export function PluginCatalog({ catalog, onInstall }: PluginCatalogProps) { + const [installingPlugin, setInstallingPlugin] = useState(null); + + const handleInstall = async (pluginName: string) => { + if (!onInstall) return; + setInstallingPlugin(pluginName); + try { + await onInstall(pluginName); + } finally { + setInstallingPlugin(null); + } + }; + + return ( +
+ {catalog.map((entry) => { + const isInstalling = installingPlugin === entry.name; + + return ( + + + {/* Plugin info */} +
+
+ {entry.displayName} + + {TYPE_LABELS[entry.type]} + +
+

+ {entry.description} +

+
+ + {entry.runtimeType === 'python' ? 'Python' : 'Node.js'}{' '} + {entry.runtimeMinVersion}+ + + {entry.requiredEnvVars.length > 0 ? ( + Requires: {entry.requiredEnvVars.join(', ')} + ) : null} + {entry.homepageUrl ? ( + + + Docs + + ) : null} +
+
+ + {/* Install button or installed badge */} +
+ {entry.isInstalled ? ( + + + Installed + + ) : ( + + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/presentation/web/components/common/plugin-list/index.ts b/src/presentation/web/components/common/plugin-list/index.ts new file mode 100644 index 000000000..fd81abe02 --- /dev/null +++ b/src/presentation/web/components/common/plugin-list/index.ts @@ -0,0 +1 @@ +export { PluginList, type PluginListProps } from './plugin-list'; diff --git a/src/presentation/web/components/common/plugin-list/plugin-list.stories.tsx b/src/presentation/web/components/common/plugin-list/plugin-list.stories.tsx new file mode 100644 index 000000000..723db2546 --- /dev/null +++ b/src/presentation/web/components/common/plugin-list/plugin-list.stories.tsx @@ -0,0 +1,132 @@ +import { fn } from '@storybook/test'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + PluginHealthStatus, + PluginType, + PluginTransport, + type Plugin, +} from '@shepai/core/domain/generated/output'; +import { PluginList } from './plugin-list'; + +const meta = { + title: 'Common/PluginList', + component: PluginList, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/* --------------------------------------------------------------------------- + * Data fixtures + * ------------------------------------------------------------------------- */ + +const now = new Date('2026-04-14T12:00:00Z'); + +const samplePlugins: Plugin[] = [ + { + id: '1', + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Healthy, + description: 'Local AI memory system with persistent knowledge storage. Provides 19 MCP tools.', + transport: PluginTransport.Stdio, + installSource: 'catalog', + serverCommand: 'python', + serverArgs: ['-m', 'mempalace.mcp_server'], + runtimeType: 'python', + runtimeMinVersion: '3.9', + homepageUrl: 'https://github.com/MemPalace/mempalace', + createdAt: now, + updatedAt: now, + }, + { + id: '2', + name: 'token-optimizer', + displayName: 'Token Optimizer', + type: PluginType.Hook, + enabled: true, + healthStatus: PluginHealthStatus.Degraded, + healthMessage: 'Python runtime found but pip package not installed', + description: 'Token waste reduction and context management via Claude Code lifecycle hooks.', + installSource: 'catalog', + runtimeType: 'python', + runtimeMinVersion: '3.8', + homepageUrl: 'https://github.com/alexgreensh/token-optimizer', + createdAt: now, + updatedAt: now, + }, + { + id: '3', + name: 'ruflo', + displayName: 'Ruflo', + type: PluginType.Mcp, + enabled: false, + healthStatus: PluginHealthStatus.Unavailable, + healthMessage: 'Node.js runtime not found on PATH', + description: 'Multi-agent AI orchestration framework with 313 MCP tools.', + transport: PluginTransport.Stdio, + installSource: 'catalog', + serverCommand: 'npx', + serverArgs: ['ruflo@latest', 'mcp', 'start'], + requiredEnvVars: ['ANTHROPIC_API_KEY'], + runtimeType: 'node', + runtimeMinVersion: '20', + homepageUrl: 'https://github.com/ruvnet/ruflo', + createdAt: now, + updatedAt: now, + }, +]; + +const onToggle = fn().mockName('onToggle'); +const onRemove = fn().mockName('onRemove'); +const onCheckHealth = fn().mockName('onCheckHealth'); + +/* --------------------------------------------------------------------------- + * Stories + * ------------------------------------------------------------------------- */ + +export const Default: Story = { + args: { + plugins: samplePlugins, + onToggle, + onRemove, + onCheckHealth, + }, +}; + +export const Empty: Story = { + args: { + plugins: [], + onToggle, + onRemove, + onCheckHealth, + }, +}; + +export const AllHealthy: Story = { + args: { + plugins: samplePlugins.map((p) => ({ + ...p, + enabled: true, + healthStatus: PluginHealthStatus.Healthy, + })), + onToggle, + onRemove, + onCheckHealth, + }, +}; + +export const SinglePlugin: Story = { + args: { + plugins: [samplePlugins[0]], + onToggle, + onRemove, + onCheckHealth, + }, +}; diff --git a/src/presentation/web/components/common/plugin-list/plugin-list.tsx b/src/presentation/web/components/common/plugin-list/plugin-list.tsx new file mode 100644 index 000000000..89f55ed2b --- /dev/null +++ b/src/presentation/web/components/common/plugin-list/plugin-list.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useState } from 'react'; +import { Trash2, RefreshCw, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { EmptyState } from '@/components/common/empty-state'; +import { PluginHealthStatus, PluginType, type Plugin } from '@shepai/core/domain/generated/output'; + +export interface PluginListProps { + plugins: Plugin[]; + onToggle?: (pluginName: string, enabled: boolean) => Promise; + onRemove?: (pluginName: string) => Promise; + onCheckHealth?: (pluginName: string) => Promise; +} + +const HEALTH_BADGE_STYLES: Record = { + [PluginHealthStatus.Healthy]: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400', + [PluginHealthStatus.Degraded]: 'bg-amber-500/15 text-amber-700 dark:text-amber-400', + [PluginHealthStatus.Unavailable]: 'bg-red-500/15 text-red-700 dark:text-red-400', + [PluginHealthStatus.Unknown]: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-400', +}; + +const TYPE_LABELS: Record = { + [PluginType.Mcp]: 'MCP', + [PluginType.Hook]: 'Hook', + [PluginType.Cli]: 'CLI', +}; + +export function PluginList({ plugins, onToggle, onRemove, onCheckHealth }: PluginListProps) { + const [togglingPlugins, setTogglingPlugins] = useState>(new Set()); + const [removingPlugin, setRemovingPlugin] = useState(null); + const [checkingHealth, setCheckingHealth] = useState>(new Set()); + + if (plugins.length === 0) { + return ( + + ); + } + + const handleToggle = async (pluginName: string, enabled: boolean) => { + if (!onToggle) return; + setTogglingPlugins((prev) => new Set(prev).add(pluginName)); + try { + await onToggle(pluginName, enabled); + } finally { + setTogglingPlugins((prev) => { + const next = new Set(prev); + next.delete(pluginName); + return next; + }); + } + }; + + const handleRemove = async (pluginName: string) => { + if (!onRemove) return; + setRemovingPlugin(pluginName); + try { + await onRemove(pluginName); + } finally { + setRemovingPlugin(null); + } + }; + + const handleCheckHealth = async (pluginName: string) => { + if (!onCheckHealth) return; + setCheckingHealth((prev) => new Set(prev).add(pluginName)); + try { + await onCheckHealth(pluginName); + } finally { + setCheckingHealth((prev) => { + const next = new Set(prev); + next.delete(pluginName); + return next; + }); + } + }; + + return ( +
+ {plugins.map((plugin) => { + const isToggling = togglingPlugins.has(plugin.name); + const isRemoving = removingPlugin === plugin.name; + const isChecking = checkingHealth.has(plugin.name); + + return ( + + + {/* Plugin info */} +
+
+ {plugin.displayName} + + {TYPE_LABELS[plugin.type]} + + + {plugin.healthStatus} + +
+ {plugin.description ? ( +

+ {plugin.description} +

+ ) : null} +
+ + {/* Actions */} +
+ + + handleToggle(plugin.name, checked)} + disabled={isToggling || isRemoving} + size="sm" + aria-label={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} + /> + + +
+
+
+ ); + })} +
+ ); +} diff --git a/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx index 4f0bdf07b..92a19fb3d 100644 --- a/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx +++ b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx @@ -13,6 +13,7 @@ import { ZapOff, Wrench, Puzzle, + Plug, Settings, TableProperties, FolderKanban, @@ -200,6 +201,12 @@ export function AppSidebar({ active={pathname === '/skills'} /> ) : null} + /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/tests/integration/infrastructure/repositories/sqlite-feature.repository.test.ts b/tests/integration/infrastructure/repositories/sqlite-feature.repository.test.ts index 8a80dedfb..f2335f4c7 100644 --- a/tests/integration/infrastructure/repositories/sqlite-feature.repository.test.ts +++ b/tests/integration/infrastructure/repositories/sqlite-feature.repository.test.ts @@ -644,4 +644,58 @@ describe('SQLiteFeatureRepository', () => { expect(found?.injectedSkills).toEqual(['tsp-model', 'shadcn-ui']); }); }); + + describe('activePlugins persistence', () => { + it('should persist activePlugins via create/findById', async () => { + const feature = createTestFeature({ + activePlugins: { mempalace: true, ruflo: false }, + }); + + await repository.create(feature); + const found = await repository.findById('feat-1'); + + expect(found?.activePlugins).toEqual({ mempalace: true, ruflo: false }); + }); + + it('should persist undefined activePlugins as null (no field on domain object)', async () => { + const feature = createTestFeature(); + + await repository.create(feature); + const found = await repository.findById('feat-1'); + + expect(found?.activePlugins).toBeUndefined(); + }); + + it('should persist activePlugins via update/findById', async () => { + await repository.create(createTestFeature()); + + await repository.update( + createTestFeature({ + activePlugins: { 'token-optimizer': true }, + updatedAt: new Date(), + }) + ); + const found = await repository.findById('feat-1'); + + expect(found?.activePlugins).toEqual({ 'token-optimizer': true }); + }); + + it('should clear activePlugins via update when set to empty object', async () => { + await repository.create( + createTestFeature({ + activePlugins: { mempalace: true }, + }) + ); + + await repository.update( + createTestFeature({ + activePlugins: {}, + updatedAt: new Date(), + }) + ); + const found = await repository.findById('feat-1'); + + expect(found?.activePlugins).toBeUndefined(); + }); + }); }); diff --git a/tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts b/tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts new file mode 100644 index 000000000..9b50c3b28 --- /dev/null +++ b/tests/integration/infrastructure/repositories/sqlite-plugin.repository.test.ts @@ -0,0 +1,301 @@ +import 'reflect-metadata'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { + createInMemoryDatabase, + tableExists, + getTableIndexes, +} from '../../../helpers/database.helper.js'; +import { runSQLiteMigrations } from '@/infrastructure/persistence/sqlite/migrations.js'; +import { SQLitePluginRepository } from '@/infrastructure/repositories/sqlite-plugin.repository.js'; +import { + PluginType, + PluginTransport, + PluginHealthStatus, + type Plugin, + type ToolGroup, +} from '@/domain/generated/output.js'; + +describe('SQLitePluginRepository', () => { + let db: Database.Database; + let repository: SQLitePluginRepository; + + const NOW = new Date('2026-04-14T10:00:00Z'); + + function createTestPlugin(overrides: Partial = {}): Plugin { + return { + id: 'plugin-001', + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; + } + + beforeEach(async () => { + db = createInMemoryDatabase(); + await runSQLiteMigrations(db); + expect(tableExists(db, 'plugins')).toBe(true); + repository = new SQLitePluginRepository(db); + }); + + afterEach(() => { + db.close(); + }); + + describe('migration', () => { + it('creates the plugins table with correct columns', () => { + const columns = db.pragma('table_info(plugins)') as { + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; + }[]; + const columnMap = new Map(columns.map((c) => [c.name, c])); + + // Required columns + expect(columnMap.get('id')?.pk).toBe(1); + expect(columnMap.get('name')?.notnull).toBe(1); + expect(columnMap.get('display_name')?.notnull).toBe(1); + expect(columnMap.get('type')?.notnull).toBe(1); + expect(columnMap.get('enabled')?.notnull).toBe(1); + expect(columnMap.get('enabled')?.dflt_value).toBe('1'); + expect(columnMap.get('health_status')?.notnull).toBe(1); + expect(columnMap.get('health_status')?.dflt_value).toBe("'Unknown'"); + expect(columnMap.get('created_at')?.notnull).toBe(1); + expect(columnMap.get('updated_at')?.notnull).toBe(1); + + // Optional columns (nullable) + expect(columnMap.get('version')?.notnull).toBe(0); + expect(columnMap.get('install_source')?.notnull).toBe(0); + expect(columnMap.get('transport')?.notnull).toBe(0); + expect(columnMap.get('server_command')?.notnull).toBe(0); + expect(columnMap.get('server_args')?.notnull).toBe(0); + expect(columnMap.get('required_env_vars')?.notnull).toBe(0); + expect(columnMap.get('tool_groups')?.notnull).toBe(0); + expect(columnMap.get('active_tool_groups')?.notnull).toBe(0); + expect(columnMap.get('health_message')?.notnull).toBe(0); + expect(columnMap.get('hook_type')?.notnull).toBe(0); + expect(columnMap.get('script_path')?.notnull).toBe(0); + expect(columnMap.get('binary_command')?.notnull).toBe(0); + expect(columnMap.get('runtime_type')?.notnull).toBe(0); + expect(columnMap.get('runtime_min_version')?.notnull).toBe(0); + expect(columnMap.get('homepage_url')?.notnull).toBe(0); + expect(columnMap.get('description')?.notnull).toBe(0); + }); + + it('creates unique index on name column', () => { + const indexes = getTableIndexes(db, 'plugins'); + expect(indexes).toContain('idx_plugins_name'); + }); + + it('adds active_plugins column to features table', () => { + const columns = db.pragma('table_info(features)') as { name: string }[]; + const names = columns.map((c) => c.name); + expect(names).toContain('active_plugins'); + }); + }); + + describe('create() and findById()', () => { + it('creates and retrieves a plugin by id', async () => { + const plugin = createTestPlugin(); + await repository.create(plugin); + + const found = await repository.findById('plugin-001'); + expect(found).not.toBeNull(); + expect(found!.id).toBe('plugin-001'); + expect(found!.name).toBe('mempalace'); + expect(found!.displayName).toBe('MemPalace'); + expect(found!.type).toBe(PluginType.Mcp); + expect(found!.enabled).toBe(true); + expect(found!.healthStatus).toBe(PluginHealthStatus.Unknown); + }); + + it('returns null for nonexistent id', async () => { + const result = await repository.findById('nonexistent'); + expect(result).toBeNull(); + }); + + it('persists MCP-specific fields correctly', async () => { + const plugin = createTestPlugin({ + transport: PluginTransport.Stdio, + serverCommand: 'python3', + serverArgs: ['-m', 'mempalace.mcp_server'], + requiredEnvVars: ['ANTHROPIC_API_KEY'], + version: '1.0.0', + installSource: 'catalog', + runtimeType: 'python', + runtimeMinVersion: '3.9', + homepageUrl: 'https://github.com/MemPalace/mempalace', + description: 'Local AI memory system', + }); + await repository.create(plugin); + + const found = await repository.findById('plugin-001'); + expect(found!.transport).toBe(PluginTransport.Stdio); + expect(found!.serverCommand).toBe('python3'); + expect(found!.serverArgs).toEqual(['-m', 'mempalace.mcp_server']); + expect(found!.requiredEnvVars).toEqual(['ANTHROPIC_API_KEY']); + expect(found!.version).toBe('1.0.0'); + expect(found!.installSource).toBe('catalog'); + expect(found!.runtimeType).toBe('python'); + expect(found!.runtimeMinVersion).toBe('3.9'); + expect(found!.homepageUrl).toBe('https://github.com/MemPalace/mempalace'); + expect(found!.description).toBe('Local AI memory system'); + }); + + it('persists toolGroups and activeToolGroups correctly', async () => { + const groups: ToolGroup[] = [ + { name: 'implement', description: 'Implementation tools', tools: ['write', 'edit'] }, + { name: 'test', description: 'Testing tools' }, + ]; + const plugin = createTestPlugin({ + toolGroups: groups, + activeToolGroups: ['implement'], + }); + await repository.create(plugin); + + const found = await repository.findById('plugin-001'); + expect(found!.toolGroups).toEqual(groups); + expect(found!.activeToolGroups).toEqual(['implement']); + }); + + it('persists Hook plugin fields correctly', async () => { + const plugin = createTestPlugin({ + id: 'plugin-hook', + name: 'token-optimizer', + displayName: 'Token Optimizer', + type: PluginType.Hook, + hookType: 'PreToolUse', + scriptPath: '/home/user/.claude/hooks/token-optimizer.py', + }); + await repository.create(plugin); + + const found = await repository.findById('plugin-hook'); + expect(found!.type).toBe(PluginType.Hook); + expect(found!.hookType).toBe('PreToolUse'); + expect(found!.scriptPath).toBe('/home/user/.claude/hooks/token-optimizer.py'); + }); + + it('persists CLI plugin fields correctly', async () => { + const plugin = createTestPlugin({ + id: 'plugin-cli', + name: 'my-tool', + displayName: 'My Tool', + type: PluginType.Cli, + binaryCommand: 'my-tool-bin', + }); + await repository.create(plugin); + + const found = await repository.findById('plugin-cli'); + expect(found!.type).toBe(PluginType.Cli); + expect(found!.binaryCommand).toBe('my-tool-bin'); + }); + }); + + describe('findByName()', () => { + it('finds a plugin by name', async () => { + await repository.create(createTestPlugin()); + + const found = await repository.findByName('mempalace'); + expect(found).not.toBeNull(); + expect(found!.id).toBe('plugin-001'); + expect(found!.name).toBe('mempalace'); + }); + + it('returns null for nonexistent name', async () => { + const result = await repository.findByName('nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('list()', () => { + it('returns empty array when no plugins exist', async () => { + const result = await repository.list(); + expect(result).toHaveLength(0); + }); + + it('returns all plugins ordered by name ascending', async () => { + await repository.create(createTestPlugin({ id: 'p-2', name: 'ruflo', displayName: 'Ruflo' })); + await repository.create( + createTestPlugin({ id: 'p-1', name: 'mempalace', displayName: 'MemPalace' }) + ); + await repository.create( + createTestPlugin({ id: 'p-3', name: 'token-optimizer', displayName: 'Token Optimizer' }) + ); + + const result = await repository.list(); + expect(result).toHaveLength(3); + expect(result[0].name).toBe('mempalace'); + expect(result[1].name).toBe('ruflo'); + expect(result[2].name).toBe('token-optimizer'); + }); + }); + + describe('update()', () => { + it('updates all mutable fields', async () => { + await repository.create(createTestPlugin()); + + const updated = createTestPlugin({ + displayName: 'MemPalace v2', + enabled: false, + healthStatus: PluginHealthStatus.Healthy, + healthMessage: 'All checks passed', + version: '2.0.0', + activeToolGroups: ['memory'], + updatedAt: new Date('2026-04-15T10:00:00Z'), + }); + await repository.update(updated); + + const found = await repository.findById('plugin-001'); + expect(found!.displayName).toBe('MemPalace v2'); + expect(found!.enabled).toBe(false); + expect(found!.healthStatus).toBe(PluginHealthStatus.Healthy); + expect(found!.healthMessage).toBe('All checks passed'); + expect(found!.version).toBe('2.0.0'); + expect(found!.activeToolGroups).toEqual(['memory']); + }); + + it('updates health status correctly', async () => { + await repository.create(createTestPlugin()); + + const updated = createTestPlugin({ + healthStatus: PluginHealthStatus.Degraded, + healthMessage: 'Missing env var: ANTHROPIC_API_KEY', + updatedAt: new Date('2026-04-15T10:00:00Z'), + }); + await repository.update(updated); + + const found = await repository.findById('plugin-001'); + expect(found!.healthStatus).toBe(PluginHealthStatus.Degraded); + expect(found!.healthMessage).toBe('Missing env var: ANTHROPIC_API_KEY'); + }); + }); + + describe('delete()', () => { + it('removes a plugin by id', async () => { + await repository.create(createTestPlugin()); + expect(await repository.findById('plugin-001')).not.toBeNull(); + + await repository.delete('plugin-001'); + expect(await repository.findById('plugin-001')).toBeNull(); + }); + + it('does not error when deleting nonexistent id', async () => { + await expect(repository.delete('nonexistent')).resolves.not.toThrow(); + }); + }); + + describe('unique name constraint', () => { + it('rejects duplicate plugin names', async () => { + await repository.create(createTestPlugin()); + + await expect(repository.create(createTestPlugin({ id: 'plugin-002' }))).rejects.toThrow(); + }); + }); +}); diff --git a/tests/integration/infrastructure/services/git/merge-step-real-git/setup.ts b/tests/integration/infrastructure/services/git/merge-step-real-git/setup.ts index b219abde2..8139972ce 100644 --- a/tests/integration/infrastructure/services/git/merge-step-real-git/setup.ts +++ b/tests/integration/infrastructure/services/git/merge-step-real-git/setup.ts @@ -299,6 +299,7 @@ export function makeState(overrides: Partial): FeatureAgentSt ciWatchEnabled: true, enableEvidence: false, commitEvidence: false, + mcpConfigPath: undefined, ...overrides, }; } diff --git a/tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts b/tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts new file mode 100644 index 000000000..0e0476165 --- /dev/null +++ b/tests/unit/application/use-cases/plugins/add-plugin.use-case.test.ts @@ -0,0 +1,112 @@ +/** + * AddPluginUseCase Unit Tests + * + * Tests for adding plugins from catalog and custom configuration. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AddPluginUseCase } from '@/application/use-cases/plugins/add-plugin.use-case.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import { PluginType, PluginTransport, PluginHealthStatus } from '@/domain/generated/output.js'; + +describe('AddPluginUseCase', () => { + let useCase: AddPluginUseCase; + let mockPluginRepo: IPluginRepository; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn().mockResolvedValue(null), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + useCase = new AddPluginUseCase(mockPluginRepo); + }); + + describe('catalog-based install', () => { + it('should create plugin with catalog metadata for mempalace', async () => { + const result = await useCase.execute('mempalace'); + + expect(result.name).toBe('mempalace'); + expect(result.displayName).toBe('MemPalace'); + expect(result.type).toBe(PluginType.Mcp); + expect(result.installSource).toBe('catalog'); + expect(result.enabled).toBe(true); + expect(result.healthStatus).toBe(PluginHealthStatus.Unknown); + expect(result.serverCommand).toBe('python'); + expect(result.serverArgs).toEqual(['-m', 'mempalace.mcp_server']); + expect(result.transport).toBe(PluginTransport.Stdio); + expect(result.runtimeType).toBe('python'); + expect(result.runtimeMinVersion).toBe('3.9'); + expect(mockPluginRepo.create).toHaveBeenCalledWith(result); + }); + + it('should throw when catalog plugin not found', async () => { + await expect(useCase.execute('nonexistent')).rejects.toThrow(/not found in catalog/i); + }); + + it('should throw when plugin already installed', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue({ + id: 'existing', + name: 'mempalace', + }); + + await expect(useCase.execute('mempalace')).rejects.toThrow(/already installed/i); + }); + + it('should generate a UUID for the plugin id', async () => { + const result = await useCase.execute('mempalace'); + expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + }); + + describe('custom plugin install', () => { + it('should create custom MCP plugin with provided fields', async () => { + const result = await useCase.execute({ + name: 'my-custom-tool', + displayName: 'My Custom Tool', + type: PluginType.Mcp, + transport: PluginTransport.Stdio, + serverCommand: 'node', + serverArgs: ['server.js'], + requiredEnvVars: ['MY_API_KEY'], + }); + + expect(result.name).toBe('my-custom-tool'); + expect(result.displayName).toBe('My Custom Tool'); + expect(result.type).toBe(PluginType.Mcp); + expect(result.installSource).toBe('custom'); + expect(result.transport).toBe(PluginTransport.Stdio); + expect(result.serverCommand).toBe('node'); + expect(result.serverArgs).toEqual(['server.js']); + expect(result.requiredEnvVars).toEqual(['MY_API_KEY']); + expect(mockPluginRepo.create).toHaveBeenCalledWith(result); + }); + + it('should default displayName to name when not provided', async () => { + const result = await useCase.execute({ + name: 'my-tool', + type: PluginType.Cli, + }); + + expect(result.displayName).toBe('my-tool'); + }); + + it('should throw when custom plugin name already exists', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue({ + id: 'existing', + name: 'my-tool', + }); + + await expect(useCase.execute({ name: 'my-tool', type: PluginType.Cli })).rejects.toThrow( + /already installed/i + ); + }); + }); +}); diff --git a/tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts b/tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts new file mode 100644 index 000000000..2e44c2cab --- /dev/null +++ b/tests/unit/application/use-cases/plugins/check-plugin-health.use-case.test.ts @@ -0,0 +1,132 @@ +/** + * CheckPluginHealthUseCase Unit Tests + * + * Tests for running health checks and updating repository status. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CheckPluginHealthUseCase } from '@/application/use-cases/plugins/check-plugin-health.use-case.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import type { IPluginHealthChecker } from '@/application/ports/output/services/plugin-health-checker.interface.js'; +import { PluginType, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +function createMockPlugin(overrides?: Partial): Plugin { + return { + id: 'plugin-001', + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('CheckPluginHealthUseCase', () => { + let useCase: CheckPluginHealthUseCase; + let mockPluginRepo: IPluginRepository; + let mockHealthChecker: IPluginHealthChecker; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn().mockResolvedValue(createMockPlugin()), + list: vi.fn().mockResolvedValue([]), + update: vi.fn(), + delete: vi.fn(), + }; + + mockHealthChecker = { + checkHealth: vi.fn().mockResolvedValue({ + pluginName: 'mempalace', + status: PluginHealthStatus.Healthy, + message: 'All checks passed', + }), + checkAllHealth: vi.fn().mockResolvedValue([]), + }; + + useCase = new CheckPluginHealthUseCase(mockPluginRepo, mockHealthChecker); + }); + + describe('single plugin check', () => { + it('should check health of named plugin and update repo', async () => { + const results = await useCase.execute('mempalace'); + + expect(results).toHaveLength(1); + expect(results[0].status).toBe(PluginHealthStatus.Healthy); + expect(results[0].pluginName).toBe('mempalace'); + + expect(mockHealthChecker.checkHealth).toHaveBeenCalledWith( + expect.objectContaining({ name: 'mempalace' }) + ); + + expect(mockPluginRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + healthStatus: PluginHealthStatus.Healthy, + healthMessage: 'All checks passed', + }) + ); + }); + + it('should throw when plugin not found', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue(null); + + await expect(useCase.execute('nonexistent')).rejects.toThrow(/not found/i); + }); + + it('should update repo with degraded status', async () => { + mockHealthChecker.checkHealth = vi.fn().mockResolvedValue({ + pluginName: 'mempalace', + status: PluginHealthStatus.Degraded, + message: 'Missing env var', + }); + + const results = await useCase.execute('mempalace'); + + expect(results[0].status).toBe(PluginHealthStatus.Degraded); + expect(mockPluginRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + healthStatus: PluginHealthStatus.Degraded, + }) + ); + }); + }); + + describe('all plugins check', () => { + it('should check all plugins when no name provided', async () => { + const plugins = [ + createMockPlugin({ name: 'plugin-a' }), + createMockPlugin({ name: 'plugin-b' }), + ]; + mockPluginRepo.list = vi.fn().mockResolvedValue(plugins); + mockHealthChecker.checkAllHealth = vi.fn().mockResolvedValue([ + { pluginName: 'plugin-a', status: PluginHealthStatus.Healthy, message: 'OK' }, + { + pluginName: 'plugin-b', + status: PluginHealthStatus.Unavailable, + message: 'Runtime missing', + }, + ]); + + const results = await useCase.execute(); + + expect(results).toHaveLength(2); + expect(mockHealthChecker.checkAllHealth).toHaveBeenCalledWith(plugins); + expect(mockPluginRepo.update).toHaveBeenCalledTimes(2); + }); + + it('should return empty array when no plugins installed', async () => { + mockPluginRepo.list = vi.fn().mockResolvedValue([]); + + const results = await useCase.execute(); + expect(results).toEqual([]); + }); + }); +}); diff --git a/tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts b/tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts new file mode 100644 index 000000000..cdb48fabf --- /dev/null +++ b/tests/unit/application/use-cases/plugins/configure-plugin.use-case.test.ts @@ -0,0 +1,112 @@ +/** + * ConfigurePluginUseCase Unit Tests + * + * Tests for updating plugin configuration (tool groups). + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConfigurePluginUseCase } from '@/application/use-cases/plugins/configure-plugin.use-case.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import { PluginType, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +function createMockPlugin(overrides?: Partial): Plugin { + return { + id: 'plugin-001', + name: 'ruflo', + displayName: 'Ruflo', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + toolGroups: [ + { name: 'implement', description: 'Implementation tools' }, + { name: 'test', description: 'Testing tools' }, + { name: 'memory', description: 'Memory tools' }, + { name: 'flow', description: 'Workflow tools' }, + ], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('ConfigurePluginUseCase', () => { + let useCase: ConfigurePluginUseCase; + let mockPluginRepo: IPluginRepository; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn().mockResolvedValue(createMockPlugin()), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + useCase = new ConfigurePluginUseCase(mockPluginRepo); + }); + + it('should update activeToolGroups with valid group names', async () => { + const result = await useCase.execute('ruflo', { + activeToolGroups: ['implement', 'test'], + }); + + expect(result.activeToolGroups).toEqual(['implement', 'test']); + expect(mockPluginRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + activeToolGroups: ['implement', 'test'], + }) + ); + }); + + it('should throw on invalid tool group name', async () => { + await expect( + useCase.execute('ruflo', { activeToolGroups: ['implement', 'invalid-group'] }) + ).rejects.toThrow(/invalid tool group "invalid-group"/i); + }); + + it('should include available groups in error message', async () => { + await expect(useCase.execute('ruflo', { activeToolGroups: ['nonexistent'] })).rejects.toThrow( + /implement, test, memory, flow/ + ); + }); + + it('should throw when plugin not found', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue(null); + + await expect( + useCase.execute('nonexistent', { activeToolGroups: ['implement'] }) + ).rejects.toThrow(/not found/i); + }); + + it('should allow empty activeToolGroups array', async () => { + const result = await useCase.execute('ruflo', { activeToolGroups: [] }); + + expect(result.activeToolGroups).toEqual([]); + }); + + it('should handle plugin with no tool groups defined', async () => { + mockPluginRepo.findByName = vi + .fn() + .mockResolvedValue(createMockPlugin({ toolGroups: undefined })); + + await expect(useCase.execute('ruflo', { activeToolGroups: ['implement'] })).rejects.toThrow( + /invalid tool group/i + ); + }); + + it('should update the updatedAt timestamp', async () => { + const oldDate = new Date('2024-01-01'); + mockPluginRepo.findByName = vi.fn().mockResolvedValue(createMockPlugin({ updatedAt: oldDate })); + + const result = await useCase.execute('ruflo', { + activeToolGroups: ['implement'], + }); + + expect(result.updatedAt.getTime()).toBeGreaterThan(oldDate.getTime()); + }); +}); diff --git a/tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts b/tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts new file mode 100644 index 000000000..8f7f47e9f --- /dev/null +++ b/tests/unit/application/use-cases/plugins/enable-disable-plugin.use-case.test.ts @@ -0,0 +1,111 @@ +/** + * EnablePluginUseCase & DisablePluginUseCase Unit Tests + * + * Tests for toggling plugin enabled state. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EnablePluginUseCase } from '@/application/use-cases/plugins/enable-plugin.use-case.js'; +import { DisablePluginUseCase } from '@/application/use-cases/plugins/disable-plugin.use-case.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import { PluginType, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +function createMockPlugin(overrides?: Partial): Plugin { + return { + id: 'plugin-001', + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('EnablePluginUseCase', () => { + let useCase: EnablePluginUseCase; + let mockPluginRepo: IPluginRepository; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn().mockResolvedValue(createMockPlugin({ enabled: false })), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + useCase = new EnablePluginUseCase(mockPluginRepo); + }); + + it('should enable a disabled plugin and return enabled=true', async () => { + const result = await useCase.execute('mempalace'); + + expect(result.enabled).toBe(true); + expect(mockPluginRepo.update).toHaveBeenCalledWith(expect.objectContaining({ enabled: true })); + }); + + it('should update the updatedAt timestamp', async () => { + const oldDate = new Date('2024-01-01'); + mockPluginRepo.findByName = vi + .fn() + .mockResolvedValue(createMockPlugin({ enabled: false, updatedAt: oldDate })); + + const result = await useCase.execute('mempalace'); + expect(result.updatedAt.getTime()).toBeGreaterThan(oldDate.getTime()); + }); + + it('should throw when plugin not found', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue(null); + + await expect(useCase.execute('nonexistent')).rejects.toThrow(/not found/i); + }); +}); + +describe('DisablePluginUseCase', () => { + let useCase: DisablePluginUseCase; + let mockPluginRepo: IPluginRepository; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn().mockResolvedValue(createMockPlugin({ enabled: true })), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + useCase = new DisablePluginUseCase(mockPluginRepo); + }); + + it('should disable an enabled plugin and return enabled=false', async () => { + const result = await useCase.execute('mempalace'); + + expect(result.enabled).toBe(false); + expect(mockPluginRepo.update).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + }); + + it('should update the updatedAt timestamp', async () => { + const oldDate = new Date('2024-01-01'); + mockPluginRepo.findByName = vi + .fn() + .mockResolvedValue(createMockPlugin({ enabled: true, updatedAt: oldDate })); + + const result = await useCase.execute('mempalace'); + expect(result.updatedAt.getTime()).toBeGreaterThan(oldDate.getTime()); + }); + + it('should throw when plugin not found', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue(null); + + await expect(useCase.execute('nonexistent')).rejects.toThrow(/not found/i); + }); +}); diff --git a/tests/unit/application/use-cases/plugins/get-plugin-catalog.use-case.test.ts b/tests/unit/application/use-cases/plugins/get-plugin-catalog.use-case.test.ts new file mode 100644 index 000000000..4df0f0698 --- /dev/null +++ b/tests/unit/application/use-cases/plugins/get-plugin-catalog.use-case.test.ts @@ -0,0 +1,78 @@ +/** + * GetPluginCatalogUseCase Unit Tests + * + * Tests for retrieving the curated catalog with installation status. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetPluginCatalogUseCase } from '@/application/use-cases/plugins/get-plugin-catalog.use-case.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import { PluginType, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +function createMockPlugin(overrides?: Partial): Plugin { + return { + id: 'plugin-001', + name: 'test-plugin', + displayName: 'Test Plugin', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('GetPluginCatalogUseCase', () => { + let useCase: GetPluginCatalogUseCase; + let mockPluginRepo: IPluginRepository; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn(), + list: vi.fn().mockResolvedValue([]), + update: vi.fn(), + delete: vi.fn(), + }; + + useCase = new GetPluginCatalogUseCase(mockPluginRepo); + }); + + it('should return all catalog entries', async () => { + const result = await useCase.execute(); + expect(result.length).toBeGreaterThanOrEqual(3); + }); + + it('should mark installed plugin as isInstalled=true', async () => { + mockPluginRepo.list = vi.fn().mockResolvedValue([createMockPlugin({ name: 'mempalace' })]); + + const result = await useCase.execute(); + const mempalace = result.find((e) => e.name === 'mempalace'); + expect(mempalace?.isInstalled).toBe(true); + }); + + it('should mark uninstalled plugin as isInstalled=false', async () => { + mockPluginRepo.list = vi.fn().mockResolvedValue([]); + + const result = await useCase.execute(); + const mempalace = result.find((e) => e.name === 'mempalace'); + expect(mempalace?.isInstalled).toBe(false); + }); + + it('should show mixed install status across catalog', async () => { + mockPluginRepo.list = vi.fn().mockResolvedValue([createMockPlugin({ name: 'ruflo' })]); + + const result = await useCase.execute(); + const ruflo = result.find((e) => e.name === 'ruflo'); + const mempalace = result.find((e) => e.name === 'mempalace'); + + expect(ruflo?.isInstalled).toBe(true); + expect(mempalace?.isInstalled).toBe(false); + }); +}); diff --git a/tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts b/tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts new file mode 100644 index 000000000..af30a8306 --- /dev/null +++ b/tests/unit/application/use-cases/plugins/list-plugins.use-case.test.ts @@ -0,0 +1,112 @@ +/** + * ListPluginsUseCase Unit Tests + * + * Tests for listing registered plugins with optional filtering. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ListPluginsUseCase } from '@/application/use-cases/plugins/list-plugins.use-case.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import { PluginType, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +function createMockPlugin(overrides?: Partial): Plugin { + return { + id: 'plugin-001', + name: 'test-plugin', + displayName: 'Test Plugin', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('ListPluginsUseCase', () => { + let useCase: ListPluginsUseCase; + let mockPluginRepo: IPluginRepository; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn(), + list: vi.fn().mockResolvedValue([]), + update: vi.fn(), + delete: vi.fn(), + }; + + useCase = new ListPluginsUseCase(mockPluginRepo); + }); + + it('should return empty array when no plugins', async () => { + const result = await useCase.execute(); + expect(result).toEqual([]); + }); + + it('should return all plugins when no filters', async () => { + const plugins = [ + createMockPlugin({ id: '1', name: 'mempalace' }), + createMockPlugin({ id: '2', name: 'ruflo' }), + ]; + mockPluginRepo.list = vi.fn().mockResolvedValue(plugins); + + const result = await useCase.execute(); + expect(result).toHaveLength(2); + }); + + it('should filter by enabled=true', async () => { + const plugins = [ + createMockPlugin({ id: '1', name: 'enabled-plugin', enabled: true }), + createMockPlugin({ id: '2', name: 'disabled-plugin', enabled: false }), + ]; + mockPluginRepo.list = vi.fn().mockResolvedValue(plugins); + + const result = await useCase.execute({ enabled: true }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('enabled-plugin'); + }); + + it('should filter by enabled=false', async () => { + const plugins = [ + createMockPlugin({ id: '1', name: 'enabled-plugin', enabled: true }), + createMockPlugin({ id: '2', name: 'disabled-plugin', enabled: false }), + ]; + mockPluginRepo.list = vi.fn().mockResolvedValue(plugins); + + const result = await useCase.execute({ enabled: false }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('disabled-plugin'); + }); + + it('should filter by plugin type', async () => { + const plugins = [ + createMockPlugin({ id: '1', name: 'mcp-plugin', type: PluginType.Mcp }), + createMockPlugin({ id: '2', name: 'hook-plugin', type: PluginType.Hook }), + createMockPlugin({ id: '3', name: 'cli-plugin', type: PluginType.Cli }), + ]; + mockPluginRepo.list = vi.fn().mockResolvedValue(plugins); + + const result = await useCase.execute({ type: PluginType.Hook }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('hook-plugin'); + }); + + it('should combine enabled and type filters', async () => { + const plugins = [ + createMockPlugin({ id: '1', name: 'p1', type: PluginType.Mcp, enabled: true }), + createMockPlugin({ id: '2', name: 'p2', type: PluginType.Mcp, enabled: false }), + createMockPlugin({ id: '3', name: 'p3', type: PluginType.Hook, enabled: true }), + ]; + mockPluginRepo.list = vi.fn().mockResolvedValue(plugins); + + const result = await useCase.execute({ type: PluginType.Mcp, enabled: true }); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('p1'); + }); +}); diff --git a/tests/unit/application/use-cases/plugins/remove-plugin.use-case.test.ts b/tests/unit/application/use-cases/plugins/remove-plugin.use-case.test.ts new file mode 100644 index 000000000..dea0f6771 --- /dev/null +++ b/tests/unit/application/use-cases/plugins/remove-plugin.use-case.test.ts @@ -0,0 +1,74 @@ +/** + * RemovePluginUseCase Unit Tests + * + * Tests for removing plugins from the registry. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RemovePluginUseCase } from '@/application/use-cases/plugins/remove-plugin.use-case.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import type { IMcpServerManager } from '@/application/ports/output/services/mcp-server-manager.interface.js'; +import { PluginType, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +function createMockPlugin(overrides?: Partial): Plugin { + return { + id: 'plugin-001', + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('RemovePluginUseCase', () => { + let useCase: RemovePluginUseCase; + let mockPluginRepo: IPluginRepository; + let mockMcpServerManager: IMcpServerManager; + + beforeEach(() => { + mockPluginRepo = { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn().mockResolvedValue(createMockPlugin()), + list: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; + + mockMcpServerManager = { + startServersForFeature: vi.fn(), + stopServersForFeature: vi.fn(), + getActiveServers: vi.fn().mockReturnValue([]), + generateMcpConfigPath: vi.fn(), + }; + + useCase = new RemovePluginUseCase(mockPluginRepo, mockMcpServerManager); + }); + + it('should remove an existing plugin and return it', async () => { + const result = await useCase.execute('mempalace'); + + expect(result.name).toBe('mempalace'); + expect(mockPluginRepo.delete).toHaveBeenCalledWith('plugin-001'); + }); + + it('should throw when plugin not found', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue(null); + + await expect(useCase.execute('nonexistent')).rejects.toThrow(/not found/i); + }); + + it('should include plugin name in not found error', async () => { + mockPluginRepo.findByName = vi.fn().mockResolvedValue(null); + + await expect(useCase.execute('my-tool')).rejects.toThrow('my-tool'); + }); +}); diff --git a/tests/unit/commands/plugin/plugin-commands.test.ts b/tests/unit/commands/plugin/plugin-commands.test.ts new file mode 100644 index 000000000..087c40b9b --- /dev/null +++ b/tests/unit/commands/plugin/plugin-commands.test.ts @@ -0,0 +1,463 @@ +/** + * Plugin CLI Command Tests + * + * Tests for the plugin command group and all subcommands. + * Mocks the DI container and use cases to verify that each + * CLI command resolves the correct use case and calls execute(). + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { PluginType, PluginTransport, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +// --------------------------------------------------------------------------- +// Hoist mocks so factory closures can reference them +// --------------------------------------------------------------------------- +const { mockExecute, mockMessages, mockColors, mockRenderListView, mockRenderDetailView } = + vi.hoisted(() => { + const mockExecute = vi.fn(); + const mockMessages = { + info: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + error: vi.fn(), + newline: vi.fn(), + log: vi.fn(), + debug: vi.fn(), + }; + const mockColors = { + muted: (s: string) => s, + success: (s: string) => s, + error: (s: string) => s, + warning: (s: string) => s, + info: (s: string) => s, + brand: (s: string) => s, + accent: (s: string) => s, + }; + const mockRenderListView = vi.fn(); + const mockRenderDetailView = vi.fn(); + return { mockExecute, mockMessages, mockColors, mockRenderListView, mockRenderDetailView }; + }); + +// --------------------------------------------------------------------------- +// Mock DI container +// --------------------------------------------------------------------------- +vi.mock('@/infrastructure/di/container.js', () => ({ + container: { + resolve: vi.fn().mockImplementation(() => ({ + execute: mockExecute, + })), + }, +})); + +// --------------------------------------------------------------------------- +// Mock use case modules to prevent tsyringe from loading +// --------------------------------------------------------------------------- +vi.mock('@/application/use-cases/plugins/add-plugin.use-case.js', () => ({ + AddPluginUseCase: vi.fn(), +})); +vi.mock('@/application/use-cases/plugins/remove-plugin.use-case.js', () => ({ + RemovePluginUseCase: vi.fn(), +})); +vi.mock('@/application/use-cases/plugins/list-plugins.use-case.js', () => ({ + ListPluginsUseCase: vi.fn(), +})); +vi.mock('@/application/use-cases/plugins/enable-plugin.use-case.js', () => ({ + EnablePluginUseCase: vi.fn(), +})); +vi.mock('@/application/use-cases/plugins/disable-plugin.use-case.js', () => ({ + DisablePluginUseCase: vi.fn(), +})); +vi.mock('@/application/use-cases/plugins/configure-plugin.use-case.js', () => ({ + ConfigurePluginUseCase: vi.fn(), +})); +vi.mock('@/application/use-cases/plugins/check-plugin-health.use-case.js', () => ({ + CheckPluginHealthUseCase: vi.fn(), +})); +vi.mock('@/application/use-cases/plugins/get-plugin-catalog.use-case.js', () => ({ + GetPluginCatalogUseCase: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Mock CLI UI helpers +// --------------------------------------------------------------------------- +vi.mock('../../../../src/presentation/cli/ui/index.js', () => ({ + messages: mockMessages, + colors: mockColors, + renderListView: mockRenderListView, + renderDetailView: mockRenderDetailView, + spinner: vi.fn(), + fmt: { heading: (s: string) => s }, +})); + +import { container } from '@/infrastructure/di/container.js'; + +// --------------------------------------------------------------------------- +// Imports under test +// --------------------------------------------------------------------------- +import { createPluginCommand } from '../../../../src/presentation/cli/commands/plugin/index.js'; +import { createAddCommand } from '../../../../src/presentation/cli/commands/plugin/add.command.js'; +import { createRemoveCommand } from '../../../../src/presentation/cli/commands/plugin/remove.command.js'; +import { createListCommand } from '../../../../src/presentation/cli/commands/plugin/list.command.js'; +import { createEnableCommand } from '../../../../src/presentation/cli/commands/plugin/enable.command.js'; +import { createDisableCommand } from '../../../../src/presentation/cli/commands/plugin/disable.command.js'; +import { createConfigureCommand } from '../../../../src/presentation/cli/commands/plugin/configure.command.js'; +import { createStatusCommand } from '../../../../src/presentation/cli/commands/plugin/status.command.js'; +import { createCatalogCommand } from '../../../../src/presentation/cli/commands/plugin/catalog.command.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function makePlugin(overrides: Partial = {}): Plugin { + return { + id: 'test-id', + name: 'test-plugin', + displayName: 'Test Plugin', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + installSource: 'catalog', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('plugin command group', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('createPluginCommand()', () => { + it('returns a Commander Command instance', () => { + const cmd = createPluginCommand(); + expect(cmd).toBeInstanceOf(Command); + }); + + it('has name "plugin"', () => { + const cmd = createPluginCommand(); + expect(cmd.name()).toBe('plugin'); + }); + + it('registers all 8 subcommands', () => { + const cmd = createPluginCommand(); + const names = cmd.commands.map((c) => c.name()); + expect(names).toContain('add'); + expect(names).toContain('remove'); + expect(names).toContain('list'); + expect(names).toContain('enable'); + expect(names).toContain('disable'); + expect(names).toContain('configure'); + expect(names).toContain('status'); + expect(names).toContain('catalog'); + expect(names).toHaveLength(8); + }); + }); + + // ------------------------------------------------------------------------- + // add subcommand + // ------------------------------------------------------------------------- + describe('add subcommand', () => { + it('returns a Command with name "add"', () => { + const cmd = createAddCommand(); + expect(cmd.name()).toBe('add'); + }); + + it('calls AddPluginUseCase with catalog name', async () => { + const plugin = makePlugin({ name: 'mempalace' }); + mockExecute.mockResolvedValueOnce(plugin); + + const cmd = createAddCommand(); + await cmd.parseAsync(['mempalace'], { from: 'user' }); + + expect(container.resolve).toHaveBeenCalled(); + expect(mockExecute).toHaveBeenCalledWith('mempalace'); + expect(mockMessages.success).toHaveBeenCalled(); + }); + + it('calls AddPluginUseCase with custom input', async () => { + const plugin = makePlugin({ name: 'my-tool' }); + mockExecute.mockResolvedValueOnce(plugin); + + const cmd = createAddCommand(); + await cmd.parseAsync( + ['--name', 'my-tool', '--type', 'mcp', '--command', 'npx my-mcp', '--transport', 'stdio'], + { from: 'user' } + ); + + expect(mockExecute).toHaveBeenCalledWith({ + name: 'my-tool', + type: PluginType.Mcp, + serverCommand: 'npx my-mcp', + transport: PluginTransport.Stdio, + }); + }); + + it('shows error for custom install without --name', async () => { + const cmd = createAddCommand(); + await cmd.parseAsync(['--type', 'mcp'], { from: 'user' }); + + expect(mockMessages.error).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('shows error for custom install without --type', async () => { + const cmd = createAddCommand(); + await cmd.parseAsync(['--name', 'test'], { from: 'user' }); + + expect(mockMessages.error).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('shows error for invalid --type value', async () => { + const cmd = createAddCommand(); + await cmd.parseAsync(['--name', 'test', '--type', 'invalid'], { from: 'user' }); + + expect(mockMessages.error).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it('handles use case errors gracefully', async () => { + mockExecute.mockRejectedValueOnce(new Error('Already installed')); + + const cmd = createAddCommand(); + await cmd.parseAsync(['mempalace'], { from: 'user' }); + + expect(mockMessages.error).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // remove subcommand + // ------------------------------------------------------------------------- + describe('remove subcommand', () => { + it('returns a Command with name "remove"', () => { + const cmd = createRemoveCommand(); + expect(cmd.name()).toBe('remove'); + }); + + it('calls RemovePluginUseCase with plugin name', async () => { + const plugin = makePlugin({ name: 'mempalace' }); + mockExecute.mockResolvedValueOnce(plugin); + + const cmd = createRemoveCommand(); + await cmd.parseAsync(['mempalace'], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalledWith('mempalace'); + expect(mockMessages.success).toHaveBeenCalled(); + }); + + it('handles errors gracefully', async () => { + mockExecute.mockRejectedValueOnce(new Error('Not found')); + + const cmd = createRemoveCommand(); + await cmd.parseAsync(['nonexistent'], { from: 'user' }); + + expect(mockMessages.error).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // list subcommand + // ------------------------------------------------------------------------- + describe('list subcommand', () => { + it('returns a Command with name "list"', () => { + const cmd = createListCommand(); + expect(cmd.name()).toBe('list'); + }); + + it('calls ListPluginsUseCase and renders a list view', async () => { + const plugins = [ + makePlugin({ name: 'mempalace', type: PluginType.Mcp }), + makePlugin({ name: 'ruflo', type: PluginType.Mcp, enabled: false }), + ]; + mockExecute.mockResolvedValueOnce(plugins); + + const cmd = createListCommand(); + await cmd.parseAsync([], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalled(); + expect(mockRenderListView).toHaveBeenCalledWith( + expect.objectContaining({ + rows: expect.arrayContaining([expect.arrayContaining(['mempalace'])]), + }) + ); + }); + + it('shows empty message when no plugins installed', async () => { + mockExecute.mockResolvedValueOnce([]); + + const cmd = createListCommand(); + await cmd.parseAsync([], { from: 'user' }); + + expect(mockRenderListView).toHaveBeenCalledWith(expect.objectContaining({ rows: [] })); + }); + }); + + // ------------------------------------------------------------------------- + // enable subcommand + // ------------------------------------------------------------------------- + describe('enable subcommand', () => { + it('returns a Command with name "enable"', () => { + const cmd = createEnableCommand(); + expect(cmd.name()).toBe('enable'); + }); + + it('calls EnablePluginUseCase with plugin name', async () => { + const plugin = makePlugin({ name: 'mempalace', enabled: true }); + mockExecute.mockResolvedValueOnce(plugin); + + const cmd = createEnableCommand(); + await cmd.parseAsync(['mempalace'], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalledWith('mempalace'); + expect(mockMessages.success).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // disable subcommand + // ------------------------------------------------------------------------- + describe('disable subcommand', () => { + it('returns a Command with name "disable"', () => { + const cmd = createDisableCommand(); + expect(cmd.name()).toBe('disable'); + }); + + it('calls DisablePluginUseCase with plugin name', async () => { + const plugin = makePlugin({ name: 'mempalace', enabled: false }); + mockExecute.mockResolvedValueOnce(plugin); + + const cmd = createDisableCommand(); + await cmd.parseAsync(['mempalace'], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalledWith('mempalace'); + expect(mockMessages.success).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // configure subcommand + // ------------------------------------------------------------------------- + describe('configure subcommand', () => { + it('returns a Command with name "configure"', () => { + const cmd = createConfigureCommand(); + expect(cmd.name()).toBe('configure'); + }); + + it('calls ConfigurePluginUseCase with tool groups', async () => { + const plugin = makePlugin({ name: 'ruflo', activeToolGroups: ['implement', 'test'] }); + mockExecute.mockResolvedValueOnce(plugin); + + const cmd = createConfigureCommand(); + await cmd.parseAsync(['ruflo', '--tool-groups', 'implement,test'], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalledWith('ruflo', { + activeToolGroups: ['implement', 'test'], + }); + expect(mockMessages.success).toHaveBeenCalled(); + }); + + it('shows info message when no options provided', async () => { + const cmd = createConfigureCommand(); + await cmd.parseAsync(['ruflo'], { from: 'user' }); + + expect(mockMessages.info).toHaveBeenCalled(); + expect(mockExecute).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // status subcommand + // ------------------------------------------------------------------------- + describe('status subcommand', () => { + it('returns a Command with name "status"', () => { + const cmd = createStatusCommand(); + expect(cmd.name()).toBe('status'); + }); + + it('calls CheckPluginHealthUseCase for specific plugin and renders detail view', async () => { + const healthResults = [ + { + pluginName: 'mempalace', + status: PluginHealthStatus.Healthy, + message: 'All checks passed', + }, + ]; + // First call = health check, second call = list plugins + mockExecute.mockResolvedValueOnce(healthResults); + mockExecute.mockResolvedValueOnce([makePlugin({ name: 'mempalace' })]); + + const cmd = createStatusCommand(); + await cmd.parseAsync(['mempalace'], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalledWith('mempalace'); + expect(mockRenderDetailView).toHaveBeenCalled(); + }); + + it('calls CheckPluginHealthUseCase for all plugins and renders list view', async () => { + const healthResults = [ + { pluginName: 'mempalace', status: PluginHealthStatus.Healthy, message: 'OK' }, + { pluginName: 'ruflo', status: PluginHealthStatus.Degraded, message: 'Missing env' }, + ]; + mockExecute.mockResolvedValueOnce(healthResults); + + const cmd = createStatusCommand(); + await cmd.parseAsync([], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalledWith(undefined); + expect(mockRenderListView).toHaveBeenCalled(); + }); + + it('shows info message when no plugins exist and status checked for all', async () => { + mockExecute.mockResolvedValueOnce([]); + + const cmd = createStatusCommand(); + await cmd.parseAsync([], { from: 'user' }); + + expect(mockMessages.info).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // catalog subcommand + // ------------------------------------------------------------------------- + describe('catalog subcommand', () => { + it('returns a Command with name "catalog"', () => { + const cmd = createCatalogCommand(); + expect(cmd.name()).toBe('catalog'); + }); + + it('calls GetPluginCatalogUseCase and renders list view', async () => { + const entries = [ + { + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + description: 'Memory system', + isInstalled: false, + }, + { + name: 'ruflo', + displayName: 'Ruflo', + type: PluginType.Mcp, + description: 'Agent framework', + isInstalled: true, + }, + ]; + mockExecute.mockResolvedValueOnce(entries); + + const cmd = createCatalogCommand(); + await cmd.parseAsync([], { from: 'user' }); + + expect(mockExecute).toHaveBeenCalled(); + expect(mockRenderListView).toHaveBeenCalledWith( + expect.objectContaining({ + rows: expect.arrayContaining([expect.arrayContaining(['mempalace'])]), + }) + ); + }); + }); +}); diff --git a/tests/unit/infrastructure/persistence/sqlite/mappers/feature.mapper.test.ts b/tests/unit/infrastructure/persistence/sqlite/mappers/feature.mapper.test.ts index ea2329140..2eb5d240b 100644 --- a/tests/unit/infrastructure/persistence/sqlite/mappers/feature.mapper.test.ts +++ b/tests/unit/infrastructure/persistence/sqlite/mappers/feature.mapper.test.ts @@ -93,6 +93,7 @@ function createTestRow(overrides: Partial = {}): FeatureRow { attachments: '[]', injected_skills: null, inject_skills: 0, + active_plugins: null, deleted_at: null, created_at: new Date('2026-03-08T10:00:00Z').getTime(), updated_at: new Date('2026-03-08T10:00:00Z').getTime(), @@ -338,3 +339,43 @@ describe('Feature Mapper — injectedSkills', () => { }); }); }); + +describe('Feature Mapper — activePlugins', () => { + describe('toDatabase()', () => { + it('serializes activePlugins to JSON string', () => { + const feature = createTestFeature({ + activePlugins: { mempalace: true, ruflo: false }, + }); + const row = toDatabase(feature); + expect(row.active_plugins).toBe(JSON.stringify({ mempalace: true, ruflo: false })); + }); + + it('serializes null when activePlugins is undefined', () => { + const feature = createTestFeature({ activePlugins: undefined }); + const row = toDatabase(feature); + expect(row.active_plugins).toBeNull(); + }); + + it('serializes null when activePlugins is empty object', () => { + const feature = createTestFeature({ activePlugins: {} }); + const row = toDatabase(feature); + expect(row.active_plugins).toBeNull(); + }); + }); + + describe('fromDatabase()', () => { + it('deserializes active_plugins from JSON string', () => { + const row = createTestRow({ + active_plugins: JSON.stringify({ mempalace: true, ruflo: false }), + }); + const feature = fromDatabase(row); + expect(feature.activePlugins).toEqual({ mempalace: true, ruflo: false }); + }); + + it('omits activePlugins when active_plugins is null', () => { + const row = createTestRow({ active_plugins: null }); + const feature = fromDatabase(row); + expect(feature.activePlugins).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts b/tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts new file mode 100644 index 000000000..71004c8f4 --- /dev/null +++ b/tests/unit/infrastructure/persistence/sqlite/mappers/plugin.mapper.test.ts @@ -0,0 +1,385 @@ +import { describe, it, expect } from 'vitest'; +import { + toDatabase, + fromDatabase, + type PluginRow, +} from '@/infrastructure/persistence/sqlite/mappers/plugin.mapper.js'; +import { + PluginType, + PluginTransport, + PluginHealthStatus, + type Plugin, + type ToolGroup, +} from '@/domain/generated/output.js'; + +function createTestPlugin(overrides: Partial = {}): Plugin { + return { + id: 'plugin-001', + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + createdAt: new Date('2026-04-14T10:00:00Z'), + updatedAt: new Date('2026-04-14T12:00:00Z'), + ...overrides, + }; +} + +function createTestRow(overrides: Partial = {}): PluginRow { + return { + id: 'plugin-001', + name: 'mempalace', + display_name: 'MemPalace', + type: 'Mcp', + version: null, + install_source: null, + transport: null, + server_command: null, + server_args: null, + required_env_vars: null, + tool_groups: null, + active_tool_groups: null, + enabled: 1, + health_status: 'Unknown', + health_message: null, + hook_type: null, + script_path: null, + binary_command: null, + runtime_type: null, + runtime_min_version: null, + homepage_url: null, + description: null, + created_at: new Date('2026-04-14T10:00:00Z').getTime(), + updated_at: new Date('2026-04-14T12:00:00Z').getTime(), + ...overrides, + }; +} + +describe('Plugin Mapper', () => { + describe('toDatabase', () => { + it('should map required fields to snake_case columns', () => { + const plugin = createTestPlugin(); + const row = toDatabase(plugin); + + expect(row.id).toBe('plugin-001'); + expect(row.name).toBe('mempalace'); + expect(row.display_name).toBe('MemPalace'); + expect(row.type).toBe('Mcp'); + expect(row.enabled).toBe(1); + expect(row.health_status).toBe('Unknown'); + }); + + it('should convert Date objects to unix milliseconds', () => { + const date = new Date('2026-04-14T08:30:00Z'); + const plugin = createTestPlugin({ createdAt: date, updatedAt: date }); + const row = toDatabase(plugin); + + expect(row.created_at).toBe(date.getTime()); + expect(row.updated_at).toBe(date.getTime()); + }); + + it('should map enabled=false to 0', () => { + const plugin = createTestPlugin({ enabled: false }); + const row = toDatabase(plugin); + + expect(row.enabled).toBe(0); + }); + + it('should serialize serverArgs as JSON string', () => { + const plugin = createTestPlugin({ serverArgs: ['-m', 'mempalace.mcp_server'] }); + const row = toDatabase(plugin); + + expect(row.server_args).toBe('["-m","mempalace.mcp_server"]'); + }); + + it('should serialize requiredEnvVars as JSON string', () => { + const plugin = createTestPlugin({ requiredEnvVars: ['ANTHROPIC_API_KEY', 'SOME_TOKEN'] }); + const row = toDatabase(plugin); + + expect(row.required_env_vars).toBe('["ANTHROPIC_API_KEY","SOME_TOKEN"]'); + }); + + it('should serialize toolGroups as JSON string', () => { + const groups: ToolGroup[] = [ + { name: 'implement', description: 'Implementation tools', tools: ['write', 'edit'] }, + { name: 'test', description: 'Testing tools' }, + ]; + const plugin = createTestPlugin({ toolGroups: groups }); + const row = toDatabase(plugin); + + expect(row.tool_groups).toBe(JSON.stringify(groups)); + }); + + it('should serialize activeToolGroups as JSON string', () => { + const plugin = createTestPlugin({ activeToolGroups: ['implement', 'test'] }); + const row = toDatabase(plugin); + + expect(row.active_tool_groups).toBe('["implement","test"]'); + }); + + it('should map empty arrays to null', () => { + const plugin = createTestPlugin({ + serverArgs: [], + requiredEnvVars: [], + toolGroups: [], + activeToolGroups: [], + }); + const row = toDatabase(plugin); + + expect(row.server_args).toBeNull(); + expect(row.required_env_vars).toBeNull(); + expect(row.tool_groups).toBeNull(); + expect(row.active_tool_groups).toBeNull(); + }); + + it('should map undefined optional fields to null', () => { + const plugin = createTestPlugin(); + const row = toDatabase(plugin); + + expect(row.version).toBeNull(); + expect(row.install_source).toBeNull(); + expect(row.transport).toBeNull(); + expect(row.server_command).toBeNull(); + expect(row.server_args).toBeNull(); + expect(row.health_message).toBeNull(); + expect(row.hook_type).toBeNull(); + expect(row.script_path).toBeNull(); + expect(row.binary_command).toBeNull(); + expect(row.runtime_type).toBeNull(); + expect(row.runtime_min_version).toBeNull(); + expect(row.homepage_url).toBeNull(); + expect(row.description).toBeNull(); + }); + + it('should map MCP-specific fields when present', () => { + const plugin = createTestPlugin({ + transport: PluginTransport.Stdio, + serverCommand: 'python3', + serverArgs: ['-m', 'mempalace.mcp_server'], + }); + const row = toDatabase(plugin); + + expect(row.transport).toBe('Stdio'); + expect(row.server_command).toBe('python3'); + expect(row.server_args).toBe('["-m","mempalace.mcp_server"]'); + }); + + it('should map Hook-specific fields when present', () => { + const plugin = createTestPlugin({ + type: PluginType.Hook, + hookType: 'PreToolUse', + scriptPath: '/home/user/.claude/hooks/token-optimizer.py', + }); + const row = toDatabase(plugin); + + expect(row.type).toBe('Hook'); + expect(row.hook_type).toBe('PreToolUse'); + expect(row.script_path).toBe('/home/user/.claude/hooks/token-optimizer.py'); + }); + + it('should map CLI-specific fields when present', () => { + const plugin = createTestPlugin({ + type: PluginType.Cli, + binaryCommand: 'my-tool', + }); + const row = toDatabase(plugin); + + expect(row.type).toBe('Cli'); + expect(row.binary_command).toBe('my-tool'); + }); + }); + + describe('fromDatabase', () => { + it('should map required columns to camelCase fields', () => { + const row = createTestRow(); + const plugin = fromDatabase(row); + + expect(plugin.id).toBe('plugin-001'); + expect(plugin.name).toBe('mempalace'); + expect(plugin.displayName).toBe('MemPalace'); + expect(plugin.type).toBe(PluginType.Mcp); + expect(plugin.enabled).toBe(true); + expect(plugin.healthStatus).toBe(PluginHealthStatus.Unknown); + }); + + it('should convert unix milliseconds back to Date objects', () => { + const date = new Date('2026-04-14T08:30:00Z'); + const row = createTestRow({ created_at: date.getTime(), updated_at: date.getTime() }); + const plugin = fromDatabase(row); + + expect(plugin.createdAt).toEqual(date); + expect(plugin.updatedAt).toEqual(date); + }); + + it('should map enabled=0 to false', () => { + const row = createTestRow({ enabled: 0 }); + const plugin = fromDatabase(row); + + expect(plugin.enabled).toBe(false); + }); + + it('should deserialize serverArgs from JSON', () => { + const row = createTestRow({ server_args: '["-m","mempalace.mcp_server"]' }); + const plugin = fromDatabase(row); + + expect(plugin.serverArgs).toEqual(['-m', 'mempalace.mcp_server']); + }); + + it('should deserialize requiredEnvVars from JSON', () => { + const row = createTestRow({ required_env_vars: '["ANTHROPIC_API_KEY","SOME_TOKEN"]' }); + const plugin = fromDatabase(row); + + expect(plugin.requiredEnvVars).toEqual(['ANTHROPIC_API_KEY', 'SOME_TOKEN']); + }); + + it('should deserialize toolGroups from JSON', () => { + const groups: ToolGroup[] = [ + { name: 'implement', description: 'Implementation tools', tools: ['write', 'edit'] }, + ]; + const row = createTestRow({ tool_groups: JSON.stringify(groups) }); + const plugin = fromDatabase(row); + + expect(plugin.toolGroups).toEqual(groups); + }); + + it('should deserialize activeToolGroups from JSON', () => { + const row = createTestRow({ active_tool_groups: '["implement","test"]' }); + const plugin = fromDatabase(row); + + expect(plugin.activeToolGroups).toEqual(['implement', 'test']); + }); + + it('should map null optional fields to undefined', () => { + const row = createTestRow(); + const plugin = fromDatabase(row); + + expect(plugin.version).toBeUndefined(); + expect(plugin.installSource).toBeUndefined(); + expect(plugin.transport).toBeUndefined(); + expect(plugin.serverCommand).toBeUndefined(); + expect(plugin.serverArgs).toBeUndefined(); + expect(plugin.healthMessage).toBeUndefined(); + expect(plugin.hookType).toBeUndefined(); + expect(plugin.scriptPath).toBeUndefined(); + expect(plugin.binaryCommand).toBeUndefined(); + expect(plugin.runtimeType).toBeUndefined(); + expect(plugin.runtimeMinVersion).toBeUndefined(); + expect(plugin.homepageUrl).toBeUndefined(); + expect(plugin.description).toBeUndefined(); + }); + + it('should map MCP-specific fields when present', () => { + const row = createTestRow({ + transport: 'Stdio', + server_command: 'python3', + server_args: '["-m","mempalace.mcp_server"]', + }); + const plugin = fromDatabase(row); + + expect(plugin.transport).toBe(PluginTransport.Stdio); + expect(plugin.serverCommand).toBe('python3'); + expect(plugin.serverArgs).toEqual(['-m', 'mempalace.mcp_server']); + }); + + it('should map health status enum values correctly', () => { + const statuses: [string, PluginHealthStatus][] = [ + ['Healthy', PluginHealthStatus.Healthy], + ['Degraded', PluginHealthStatus.Degraded], + ['Unavailable', PluginHealthStatus.Unavailable], + ['Unknown', PluginHealthStatus.Unknown], + ]; + for (const [dbValue, enumValue] of statuses) { + const row = createTestRow({ health_status: dbValue }); + const plugin = fromDatabase(row); + expect(plugin.healthStatus).toBe(enumValue); + } + }); + }); + + describe('round-trip', () => { + it('should preserve all fields through toDatabase -> fromDatabase', () => { + const groups: ToolGroup[] = [ + { name: 'implement', description: 'Implementation tools', tools: ['write', 'edit'] }, + ]; + const original = createTestPlugin({ + version: '1.2.3', + installSource: 'catalog', + transport: PluginTransport.Stdio, + serverCommand: 'python3', + serverArgs: ['-m', 'mempalace.mcp_server'], + requiredEnvVars: ['ANTHROPIC_API_KEY'], + toolGroups: groups, + activeToolGroups: ['implement'], + healthStatus: PluginHealthStatus.Healthy, + healthMessage: 'All checks passed', + runtimeType: 'python', + runtimeMinVersion: '3.9', + homepageUrl: 'https://github.com/MemPalace/mempalace', + description: 'Local AI memory system', + }); + + const row = toDatabase(original); + const restored = fromDatabase(row); + + expect(restored.id).toBe(original.id); + expect(restored.name).toBe(original.name); + expect(restored.displayName).toBe(original.displayName); + expect(restored.type).toBe(original.type); + expect(restored.version).toBe(original.version); + expect(restored.installSource).toBe(original.installSource); + expect(restored.transport).toBe(original.transport); + expect(restored.serverCommand).toBe(original.serverCommand); + expect(restored.serverArgs).toEqual(original.serverArgs); + expect(restored.requiredEnvVars).toEqual(original.requiredEnvVars); + expect(restored.toolGroups).toEqual(original.toolGroups); + expect(restored.activeToolGroups).toEqual(original.activeToolGroups); + expect(restored.enabled).toBe(original.enabled); + expect(restored.healthStatus).toBe(original.healthStatus); + expect(restored.healthMessage).toBe(original.healthMessage); + expect(restored.runtimeType).toBe(original.runtimeType); + expect(restored.runtimeMinVersion).toBe(original.runtimeMinVersion); + expect(restored.homepageUrl).toBe(original.homepageUrl); + expect(restored.description).toBe(original.description); + }); + + it('should preserve minimal plugin through round-trip', () => { + const original = createTestPlugin(); + const row = toDatabase(original); + const restored = fromDatabase(row); + + expect(restored.id).toBe(original.id); + expect(restored.name).toBe(original.name); + expect(restored.displayName).toBe(original.displayName); + expect(restored.type).toBe(original.type); + expect(restored.enabled).toBe(original.enabled); + expect(restored.healthStatus).toBe(original.healthStatus); + }); + + it('should preserve Hook plugin fields through round-trip', () => { + const original = createTestPlugin({ + type: PluginType.Hook, + hookType: 'PreToolUse', + scriptPath: '/home/user/.claude/hooks/token-optimizer.py', + }); + const row = toDatabase(original); + const restored = fromDatabase(row); + + expect(restored.type).toBe(PluginType.Hook); + expect(restored.hookType).toBe(original.hookType); + expect(restored.scriptPath).toBe(original.scriptPath); + }); + + it('should preserve CLI plugin fields through round-trip', () => { + const original = createTestPlugin({ + type: PluginType.Cli, + binaryCommand: 'my-tool', + }); + const row = toDatabase(original); + const restored = fromDatabase(row); + + expect(restored.type).toBe(PluginType.Cli); + expect(restored.binaryCommand).toBe(original.binaryCommand); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/agents/executors/claude-code-executor.test.ts b/tests/unit/infrastructure/services/agents/executors/claude-code-executor.test.ts index f8b6f46ba..4cf51f5ea 100644 --- a/tests/unit/infrastructure/services/agents/executors/claude-code-executor.test.ts +++ b/tests/unit/infrastructure/services/agents/executors/claude-code-executor.test.ts @@ -559,6 +559,51 @@ describe('ClaudeCodeExecutorService', () => { }); }); + describe('mcpConfigPath option', () => { + it('should add --mcp-config with path when mcpConfigPath is set', async () => { + const mockProc = createMockChildProcess(); + vi.mocked(mockSpawn).mockReturnValue(mockProc as any); + const resultLine = buildStreamResult({ result: 'Done' }); + const executePromise = executor.execute('Test', { + mcpConfigPath: '/tmp/shep-mcp-feat-1.json', + }); + emitStreamData(mockProc, [resultLine], null, 0); + await executePromise; + expect(mockSpawn).toHaveBeenCalledWith( + 'claude', + expect.arrayContaining(['--mcp-config', '/tmp/shep-mcp-feat-1.json']), + expect.any(Object) + ); + }); + + it('should NOT add --mcp-config when mcpConfigPath is undefined', async () => { + const mockProc = createMockChildProcess(); + vi.mocked(mockSpawn).mockReturnValue(mockProc as any); + const resultLine = buildStreamResult({ result: 'Done' }); + const executePromise = executor.execute('Test', {}); + emitStreamData(mockProc, [resultLine], null, 0); + await executePromise; + const args = vi.mocked(mockSpawn).mock.calls[0][1] as string[]; + expect(args).not.toContain('--mcp-config'); + }); + + it('should include both --strict-mcp-config and --mcp-config when both set', async () => { + const mockProc = createMockChildProcess(); + vi.mocked(mockSpawn).mockReturnValue(mockProc as any); + const resultLine = buildStreamResult({ result: 'Done' }); + const executePromise = executor.execute('Test', { + disableMcp: true, + mcpConfigPath: '/tmp/shep-mcp-feat-1.json', + }); + emitStreamData(mockProc, [resultLine], null, 0); + await executePromise; + const args = vi.mocked(mockSpawn).mock.calls[0][1] as string[]; + expect(args).toContain('--strict-mcp-config'); + expect(args).toContain('--mcp-config'); + expect(args).toContain('/tmp/shep-mcp-feat-1.json'); + }); + }); + describe('tools option', () => { it('should add --tools with comma-separated values when tools provided', async () => { const mockProc = createMockChildProcess(); diff --git a/tests/unit/infrastructure/services/agents/feature-agent/nodes/merge.node.ci-watch.test.ts b/tests/unit/infrastructure/services/agents/feature-agent/nodes/merge.node.ci-watch.test.ts index bb49fc853..b6aadc220 100644 --- a/tests/unit/infrastructure/services/agents/feature-agent/nodes/merge.node.ci-watch.test.ts +++ b/tests/unit/infrastructure/services/agents/feature-agent/nodes/merge.node.ci-watch.test.ts @@ -230,6 +230,7 @@ function baseState(overrides: Partial = {}): FeatureAgentStat ciFixStatus: 'idle', evidence: [], evidenceRetries: 0, + mcpConfigPath: undefined, ...overrides, } as FeatureAgentState; } diff --git a/tests/unit/infrastructure/services/agents/feature-agent/nodes/node-helpers.test.ts b/tests/unit/infrastructure/services/agents/feature-agent/nodes/node-helpers.test.ts index d5234b4f1..ce738aa9e 100644 --- a/tests/unit/infrastructure/services/agents/feature-agent/nodes/node-helpers.test.ts +++ b/tests/unit/infrastructure/services/agents/feature-agent/nodes/node-helpers.test.ts @@ -435,6 +435,17 @@ describe('buildExecutorOptions', () => { const options = buildExecutorOptions(baseState as any); expect(options.cwd).toBe('/tmp/repo'); }); + + it('includes mcpConfigPath when set in state', () => { + const state = { ...baseState, mcpConfigPath: '/tmp/mcp-config.json' }; + const options = buildExecutorOptions(state as any); + expect(options.mcpConfigPath).toBe('/tmp/mcp-config.json'); + }); + + it('omits mcpConfigPath when undefined in state', () => { + const options = buildExecutorOptions(baseState as any); + expect(options).not.toHaveProperty('mcpConfigPath'); + }); }); describe('removeSpecCommitsIfNeeded', () => { diff --git a/tests/unit/infrastructure/services/agents/feature-agent/nodes/repair.node.test.ts b/tests/unit/infrastructure/services/agents/feature-agent/nodes/repair.node.test.ts index 6aafe8352..104ea94a1 100644 --- a/tests/unit/infrastructure/services/agents/feature-agent/nodes/repair.node.test.ts +++ b/tests/unit/infrastructure/services/agents/feature-agent/nodes/repair.node.test.ts @@ -67,6 +67,7 @@ function baseState(_overrides: Partial = {}): FeatureAgentSta enableEvidence: false, injectSkills: false, commitEvidence: false, + mcpConfigPath: undefined, } as FeatureAgentState; } diff --git a/tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts b/tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts new file mode 100644 index 000000000..eebb94e9b --- /dev/null +++ b/tests/unit/infrastructure/services/agents/feature-agent/plugin-startup.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Plugin } from '@/domain/generated/output.js'; +import { PluginType, PluginTransport, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { IPluginRepository } from '@/application/ports/output/repositories/plugin-repository.interface.js'; +import type { IMcpServerManager } from '@/application/ports/output/services/mcp-server-manager.interface.js'; +import { + startPluginServers, + stopPluginServers, +} from '@/infrastructure/services/agents/feature-agent/plugin-startup.js'; + +function makePlugin(overrides: Partial = {}): Plugin { + return { + id: 'p-1', + name: 'test-plugin', + displayName: 'Test Plugin', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Healthy, + serverCommand: 'node', + serverArgs: ['server.js'], + transport: PluginTransport.Stdio, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +function createMockRepo(plugins: Plugin[]): IPluginRepository { + return { + create: vi.fn(), + findById: vi.fn(), + findByName: vi.fn(), + list: vi.fn<() => Promise>().mockResolvedValue(plugins), + update: vi.fn(), + delete: vi.fn(), + }; +} + +function createMockMcpManager(): IMcpServerManager { + return { + startServersForFeature: vi + .fn<(id: string, plugins: Plugin[]) => Promise>() + .mockResolvedValue(undefined), + stopServersForFeature: vi.fn<(id: string) => Promise>().mockResolvedValue(undefined), + getActiveServers: vi.fn().mockReturnValue([]), + generateMcpConfigPath: vi + .fn<(id: string) => Promise>() + .mockResolvedValue('/tmp/mcp-config.json'), + }; +} + +describe('startPluginServers', () => { + let pluginRepo: IPluginRepository; + let mcpManager: IMcpServerManager; + const featureId = 'feat-123'; + + beforeEach(() => { + pluginRepo = createMockRepo([]); + mcpManager = createMockMcpManager(); + }); + + it('starts MCP servers and returns config path when enabled MCP plugins exist', async () => { + const mcpPlugin = makePlugin({ type: PluginType.Mcp, enabled: true }); + pluginRepo = createMockRepo([mcpPlugin]); + + const result = await startPluginServers(featureId, pluginRepo, mcpManager); + + expect(mcpManager.startServersForFeature).toHaveBeenCalledWith(featureId, [mcpPlugin]); + expect(mcpManager.generateMcpConfigPath).toHaveBeenCalledWith(featureId); + expect(result).toBe('/tmp/mcp-config.json'); + }); + + it('filters out non-MCP plugins', async () => { + const hookPlugin = makePlugin({ name: 'hook', type: PluginType.Hook, enabled: true }); + const mcpPlugin = makePlugin({ name: 'mcp', type: PluginType.Mcp, enabled: true }); + pluginRepo = createMockRepo([hookPlugin, mcpPlugin]); + + await startPluginServers(featureId, pluginRepo, mcpManager); + + expect(mcpManager.startServersForFeature).toHaveBeenCalledWith(featureId, [mcpPlugin]); + }); + + it('filters out disabled plugins', async () => { + const disabledPlugin = makePlugin({ enabled: false }); + pluginRepo = createMockRepo([disabledPlugin]); + + const result = await startPluginServers(featureId, pluginRepo, mcpManager); + + expect(mcpManager.startServersForFeature).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('returns undefined when no MCP plugins exist', async () => { + pluginRepo = createMockRepo([]); + + const result = await startPluginServers(featureId, pluginRepo, mcpManager); + + expect(mcpManager.startServersForFeature).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('returns undefined and does not throw when startServersForFeature fails', async () => { + const mcpPlugin = makePlugin({ type: PluginType.Mcp, enabled: true }); + pluginRepo = createMockRepo([mcpPlugin]); + vi.mocked(mcpManager.startServersForFeature).mockRejectedValue(new Error('spawn failed')); + + const result = await startPluginServers(featureId, pluginRepo, mcpManager); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when generateMcpConfigPath returns null', async () => { + const mcpPlugin = makePlugin({ type: PluginType.Mcp, enabled: true }); + pluginRepo = createMockRepo([mcpPlugin]); + vi.mocked(mcpManager.generateMcpConfigPath).mockResolvedValue(null); + + const result = await startPluginServers(featureId, pluginRepo, mcpManager); + + expect(mcpManager.startServersForFeature).toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); +}); + +describe('stopPluginServers', () => { + let mcpManager: IMcpServerManager; + const featureId = 'feat-123'; + + beforeEach(() => { + mcpManager = createMockMcpManager(); + }); + + it('calls stopServersForFeature with the feature ID', async () => { + await stopPluginServers(featureId, mcpManager); + + expect(mcpManager.stopServersForFeature).toHaveBeenCalledWith(featureId); + }); + + it('does not throw when stopServersForFeature fails', async () => { + vi.mocked(mcpManager.stopServersForFeature).mockRejectedValue(new Error('cleanup failed')); + + await expect(stopPluginServers(featureId, mcpManager)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/unit/infrastructure/services/agents/feature-agent/state.test.ts b/tests/unit/infrastructure/services/agents/feature-agent/state.test.ts index 07ae03eca..c5312d1b9 100644 --- a/tests/unit/infrastructure/services/agents/feature-agent/state.test.ts +++ b/tests/unit/infrastructure/services/agents/feature-agent/state.test.ts @@ -58,7 +58,8 @@ describe('FeatureAgentAnnotation', () => { expect(channelNames).toContain('ciWatchEnabled'); expect(channelNames).toContain('enableEvidence'); expect(channelNames).toContain('commitEvidence'); - expect(channelNames.length).toBe(32); + expect(channelNames).toContain('mcpConfigPath'); + expect(channelNames.length).toBe(33); }); }); diff --git a/tests/unit/infrastructure/services/agents/langgraph/nodes/fast-implement.node.test.ts b/tests/unit/infrastructure/services/agents/langgraph/nodes/fast-implement.node.test.ts index 4c56d08a3..9b2c6402f 100644 --- a/tests/unit/infrastructure/services/agents/langgraph/nodes/fast-implement.node.test.ts +++ b/tests/unit/infrastructure/services/agents/langgraph/nodes/fast-implement.node.test.ts @@ -159,6 +159,7 @@ function createMockState(overrides?: Partial): FeatureAgentSt ciWatchEnabled: true, enableEvidence: true, commitEvidence: false, + mcpConfigPath: undefined, ...overrides, }; } diff --git a/tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts b/tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts new file mode 100644 index 000000000..12e409876 --- /dev/null +++ b/tests/unit/infrastructure/services/plugin/mcp-server-manager.service.test.ts @@ -0,0 +1,298 @@ +/** + * McpServerManagerService Unit Tests + * + * Tests for MCP server process lifecycle management: + * - Spawn child processes for MCP plugins + * - Reference counting for shared servers across features + * - Per-feature temp .mcp.json file generation + * - Cleanup on feature stop and shutdown + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { + McpServerManagerService, + type SpawnFn, +} from '@/infrastructure/services/plugin/mcp-server-manager.service.js'; +import { PluginType, PluginTransport, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +/** + * Creates a minimal MCP-type plugin for testing. + */ +function createMcpPlugin(overrides: Partial = {}): Plugin { + return { + id: 'plugin-1', + name: 'test-plugin', + displayName: 'Test Plugin', + type: PluginType.Mcp, + transport: PluginTransport.Stdio, + serverCommand: 'node', + serverArgs: ['server.js'], + enabled: true, + healthStatus: PluginHealthStatus.Healthy, + requiredEnvVars: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +/** + * Creates a fake child process for testing. + */ +function createFakeProcess(pid = 12345) { + const listeners: Record void)[]> = {}; + return { + pid, + kill: vi.fn(), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + listeners[event] = listeners[event] ?? []; + listeners[event].push(handler); + }), + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + stdin: null, + _listeners: listeners, + _emit(event: string, ...args: unknown[]) { + for (const handler of listeners[event] ?? []) { + handler(...args); + } + }, + }; +} + +describe('McpServerManagerService', () => { + let service: McpServerManagerService; + let spawnMock: ReturnType>; + let fakeProcess: ReturnType; + + beforeEach(() => { + fakeProcess = createFakeProcess(); + spawnMock = vi.fn().mockReturnValue(fakeProcess); + service = new McpServerManagerService(spawnMock); + }); + + afterEach(async () => { + // Clean up any managed servers + await service.shutdown(); + }); + + describe('startServersForFeature', () => { + it('should spawn a child process for each MCP plugin', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + + expect(spawnMock).toHaveBeenCalledOnce(); + expect(spawnMock).toHaveBeenCalledWith( + 'node', + ['server.js'], + expect.objectContaining({ + stdio: ['pipe', 'pipe', 'pipe'], + }) + ); + }); + + it('should pass required env vars from process.env to child process', async () => { + const originalKey = process.env.TEST_API_KEY; + process.env.TEST_API_KEY = 'secret-value'; + + try { + const plugin = createMcpPlugin({ + requiredEnvVars: ['TEST_API_KEY'], + }); + await service.startServersForFeature('feature-1', [plugin]); + + const spawnCall = spawnMock.mock.calls[0]; + const spawnOpts = spawnCall[2] as Record; + const env = spawnOpts.env as Record; + expect(env).toHaveProperty('TEST_API_KEY', 'secret-value'); + expect(env).toHaveProperty('PATH'); + } finally { + if (originalKey === undefined) { + delete process.env.TEST_API_KEY; + } else { + process.env.TEST_API_KEY = originalKey; + } + } + }); + + it('should skip non-MCP plugins', async () => { + const hookPlugin = createMcpPlugin({ + type: PluginType.Hook, + serverCommand: undefined, + }); + await service.startServersForFeature('feature-1', [hookPlugin]); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('should skip plugins without serverCommand', async () => { + const plugin = createMcpPlugin({ serverCommand: undefined }); + await service.startServersForFeature('feature-1', [plugin]); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('should spawn multiple plugins for a feature', async () => { + const plugin1 = createMcpPlugin({ name: 'plugin-a', id: 'a' }); + const plugin2 = createMcpPlugin({ + name: 'plugin-b', + id: 'b', + serverCommand: 'python', + serverArgs: ['-m', 'server'], + }); + + const fakeProcess2 = createFakeProcess(99999); + spawnMock.mockReturnValueOnce(fakeProcess).mockReturnValueOnce(fakeProcess2); + + await service.startServersForFeature('feature-1', [plugin1, plugin2]); + expect(spawnMock).toHaveBeenCalledTimes(2); + }); + + it('should increment reference count for shared servers across features', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + await service.startServersForFeature('feature-2', [plugin]); + + // Should only spawn once — second call increments refcount + expect(spawnMock).toHaveBeenCalledOnce(); + + const servers = service.getActiveServers('feature-1'); + expect(servers).toHaveLength(1); + expect(servers[0].referenceCount).toBe(2); + }); + + it('should pass activeToolGroups via env var for plugins that support them', async () => { + const plugin = createMcpPlugin({ + activeToolGroups: ['implement', 'test'], + toolGroups: [ + { name: 'implement', description: 'Code tools' }, + { name: 'test', description: 'Test tools' }, + ], + }); + await service.startServersForFeature('feature-1', [plugin]); + + const spawnCall = spawnMock.mock.calls[0]; + const spawnOpts = spawnCall[2] as Record; + const env = spawnOpts.env as Record; + expect(env).toHaveProperty('CLAUDE_FLOW_TOOL_GROUPS', 'implement,test'); + }); + }); + + describe('stopServersForFeature', () => { + it('should kill process when refcount reaches zero', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + await service.stopServersForFeature('feature-1'); + + expect(fakeProcess.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('should not kill process when other features still using it', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + await service.startServersForFeature('feature-2', [plugin]); + await service.stopServersForFeature('feature-1'); + + expect(fakeProcess.kill).not.toHaveBeenCalled(); + + const servers = service.getActiveServers('feature-2'); + expect(servers).toHaveLength(1); + expect(servers[0].referenceCount).toBe(1); + }); + + it('should be a no-op for unknown feature IDs', async () => { + await expect(service.stopServersForFeature('unknown')).resolves.not.toThrow(); + }); + }); + + describe('getActiveServers', () => { + it('should return empty array when no servers for feature', () => { + const servers = service.getActiveServers('feature-1'); + expect(servers).toEqual([]); + }); + + it('should return active servers for a feature', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + + const servers = service.getActiveServers('feature-1'); + expect(servers).toHaveLength(1); + expect(servers[0]).toEqual({ + pluginName: 'test-plugin', + pid: 12345, + referenceCount: 1, + }); + }); + }); + + describe('generateMcpConfigPath', () => { + it('should return null when no active servers for feature', async () => { + const result = await service.generateMcpConfigPath('feature-1'); + expect(result).toBeNull(); + }); + + it('should create a valid JSON file in os.tmpdir()', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + + const configPath = await service.generateMcpConfigPath('feature-1'); + expect(configPath).toBeTruthy(); + expect(configPath).toContain('shep-mcp-'); + expect(configPath).toContain('feature-1'); + + const content = readFileSync(configPath!, 'utf-8'); + const config = JSON.parse(content); + expect(config).toHaveProperty('mcpServers'); + expect(config.mcpServers).toHaveProperty('test-plugin'); + expect(config.mcpServers['test-plugin']).toEqual({ + type: 'stdio', + command: 'node', + args: ['server.js'], + env: expect.objectContaining({ + PATH: expect.any(String), + }), + }); + }); + + it('should return same path on repeated calls for same feature', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + + const path1 = await service.generateMcpConfigPath('feature-1'); + const path2 = await service.generateMcpConfigPath('feature-1'); + expect(path1).toBe(path2); + }); + }); + + describe('stopServersForFeature cleanup', () => { + it('should delete the temp config file on stop', async () => { + const plugin = createMcpPlugin(); + await service.startServersForFeature('feature-1', [plugin]); + const configPath = await service.generateMcpConfigPath('feature-1'); + expect(configPath).toBeTruthy(); + expect(existsSync(configPath!)).toBe(true); + + await service.stopServersForFeature('feature-1'); + expect(existsSync(configPath!)).toBe(false); + }); + }); + + describe('shutdown', () => { + it('should kill all managed servers', async () => { + const plugin1 = createMcpPlugin({ name: 'p1', id: 'p1' }); + const plugin2 = createMcpPlugin({ name: 'p2', id: 'p2' }); + + const fakeProcess2 = createFakeProcess(99999); + spawnMock.mockReturnValueOnce(fakeProcess).mockReturnValueOnce(fakeProcess2); + + await service.startServersForFeature('feature-1', [plugin1, plugin2]); + await service.shutdown(); + + expect(fakeProcess.kill).toHaveBeenCalledWith('SIGTERM'); + expect(fakeProcess2.kill).toHaveBeenCalledWith('SIGTERM'); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts b/tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts new file mode 100644 index 000000000..38f754798 --- /dev/null +++ b/tests/unit/infrastructure/services/plugin/plugin-catalog.test.ts @@ -0,0 +1,106 @@ +/** + * Plugin Catalog Unit Tests + * + * Tests for the curated plugin catalog that ships with Shep. + * Verifies catalog entries, lookup functions, and data integrity. + * + * TDD Phase: RED-GREEN + */ + +import { describe, it, expect } from 'vitest'; +import { + getCatalogEntries, + getCatalogEntry, +} from '@/infrastructure/services/plugin/plugin-catalog.js'; +import { PluginType, PluginTransport } from '@/domain/generated/output.js'; + +describe('Plugin Catalog', () => { + describe('getCatalogEntries', () => { + it('should return an array with at least 3 entries', () => { + const entries = getCatalogEntries(); + expect(entries.length).toBeGreaterThanOrEqual(3); + }); + + it('should contain mempalace, token-optimizer, and ruflo', () => { + const entries = getCatalogEntries(); + const names = entries.map((e) => e.name); + expect(names).toContain('mempalace'); + expect(names).toContain('token-optimizer'); + expect(names).toContain('ruflo'); + }); + + it('should return a new array on each call (not mutable reference)', () => { + const a = getCatalogEntries(); + const b = getCatalogEntries(); + expect(a).not.toBe(b); + expect(a).toEqual(b); + }); + }); + + describe('getCatalogEntry', () => { + it('should return MemPalace entry with correct fields', () => { + const entry = getCatalogEntry('mempalace'); + expect(entry).toBeDefined(); + expect(entry!.displayName).toBe('MemPalace'); + expect(entry!.type).toBe(PluginType.Mcp); + expect(entry!.transport).toBe(PluginTransport.Stdio); + expect(entry!.serverCommand).toBe('python'); + expect(entry!.serverArgs).toEqual(['-m', 'mempalace.mcp_server']); + expect(entry!.runtimeType).toBe('python'); + expect(entry!.runtimeMinVersion).toBe('3.9'); + expect(entry!.requiredEnvVars).toEqual([]); + }); + + it('should return Token Optimizer entry with Hook type', () => { + const entry = getCatalogEntry('token-optimizer'); + expect(entry).toBeDefined(); + expect(entry!.displayName).toBe('Token Optimizer'); + expect(entry!.type).toBe(PluginType.Hook); + expect(entry!.runtimeType).toBe('python'); + expect(entry!.runtimeMinVersion).toBe('3.8'); + }); + + it('should return Ruflo entry with MCP type and env vars', () => { + const entry = getCatalogEntry('ruflo'); + expect(entry).toBeDefined(); + expect(entry!.displayName).toBe('Ruflo'); + expect(entry!.type).toBe(PluginType.Mcp); + expect(entry!.transport).toBe(PluginTransport.Stdio); + expect(entry!.serverCommand).toBe('npx'); + expect(entry!.serverArgs).toEqual(['ruflo@latest', 'mcp', 'start']); + expect(entry!.runtimeType).toBe('node'); + expect(entry!.runtimeMinVersion).toBe('20'); + expect(entry!.requiredEnvVars).toContain('ANTHROPIC_API_KEY'); + }); + + it('should return Ruflo with tool groups defined', () => { + const entry = getCatalogEntry('ruflo'); + expect(entry).toBeDefined(); + expect(entry!.toolGroups).toBeDefined(); + expect(entry!.toolGroups!.length).toBeGreaterThan(0); + const groupNames = entry!.toolGroups!.map((g) => g.name); + expect(groupNames).toContain('implement'); + }); + + it('should return undefined for nonexistent entry', () => { + const entry = getCatalogEntry('nonexistent-plugin'); + expect(entry).toBeUndefined(); + }); + + it('should have homepageUrl for all entries', () => { + const entries = getCatalogEntries(); + for (const entry of entries) { + expect(entry.homepageUrl).toBeDefined(); + expect(entry.homepageUrl.startsWith('https://')).toBe(true); + } + }); + + it('should have description for all entries', () => { + const entries = getCatalogEntries(); + for (const entry of entries) { + expect(entry.description).toBeDefined(); + expect(entry.description.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts b/tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts new file mode 100644 index 000000000..655c1707c --- /dev/null +++ b/tests/unit/infrastructure/services/plugin/plugin-health-checker.service.test.ts @@ -0,0 +1,136 @@ +/** + * PluginHealthCheckerService Unit Tests + * + * Tests for multi-tier plugin health verification. + * Uses direct property injection for the execFileAsync dependency. + * + * TDD Phase: RED-GREEN + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PluginHealthCheckerService } from '@/infrastructure/services/plugin/plugin-health-checker.service.js'; +import { PluginType, PluginHealthStatus } from '@/domain/generated/output.js'; +import type { Plugin } from '@/domain/generated/output.js'; + +function createMockPlugin(overrides?: Partial): Plugin { + return { + id: 'plugin-001', + name: 'mempalace', + displayName: 'MemPalace', + type: PluginType.Mcp, + enabled: true, + healthStatus: PluginHealthStatus.Unknown, + runtimeType: 'python', + runtimeMinVersion: '3.9', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('PluginHealthCheckerService', () => { + let service: PluginHealthCheckerService; + let mockExecFileAsync: ReturnType; + + beforeEach(() => { + mockExecFileAsync = vi.fn(); + service = new PluginHealthCheckerService(); + // Inject mock execFile for testing (avoids complex module mocking) + (service as unknown as { execFileAsync: typeof mockExecFileAsync }).execFileAsync = + mockExecFileAsync; + }); + + describe('checkHealth', () => { + it('should return Healthy when runtime and env vars are present', async () => { + mockExecFileAsync.mockResolvedValue({ stdout: '/usr/bin/python3\n' }); + + const plugin = createMockPlugin({ requiredEnvVars: [] }); + const result = await service.checkHealth(plugin); + + expect(result.status).toBe(PluginHealthStatus.Healthy); + expect(result.pluginName).toBe('mempalace'); + }); + + it('should return Unavailable when runtime is missing', async () => { + mockExecFileAsync.mockRejectedValue(new Error('not found')); + + const plugin = createMockPlugin(); + const result = await service.checkHealth(plugin); + + expect(result.status).toBe(PluginHealthStatus.Unavailable); + expect(result.message).toMatch(/python/i); + }); + + it('should return Degraded when required env var is missing', async () => { + mockExecFileAsync.mockResolvedValue({ stdout: '/usr/bin/node\n' }); + + const originalValue = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + + try { + const plugin = createMockPlugin({ + name: 'ruflo', + runtimeType: 'node', + requiredEnvVars: ['ANTHROPIC_API_KEY'], + }); + const result = await service.checkHealth(plugin); + + expect(result.status).toBe(PluginHealthStatus.Degraded); + expect(result.message).toMatch(/ANTHROPIC_API_KEY/); + } finally { + if (originalValue !== undefined) { + process.env.ANTHROPIC_API_KEY = originalValue; + } + } + }); + + it('should return Healthy when no runtime type is specified', async () => { + const plugin = createMockPlugin({ runtimeType: undefined }); + const result = await service.checkHealth(plugin); + + expect(result.status).toBe(PluginHealthStatus.Healthy); + }); + + it('should return Healthy when env vars are all present', async () => { + mockExecFileAsync.mockResolvedValue({ stdout: '/usr/bin/node\n' }); + + const key = '__TEST_PLUGIN_VAR__'; + process.env[key] = 'test-value'; + + try { + const plugin = createMockPlugin({ + runtimeType: 'node', + requiredEnvVars: [key], + }); + const result = await service.checkHealth(plugin); + + expect(result.status).toBe(PluginHealthStatus.Healthy); + } finally { + delete process.env[key]; + } + }); + }); + + describe('checkAllHealth', () => { + it('should check health of each plugin and return results', async () => { + mockExecFileAsync.mockResolvedValue({ stdout: '/usr/bin/python3\n' }); + + const plugins = [ + createMockPlugin({ name: 'plugin-a' }), + createMockPlugin({ name: 'plugin-b' }), + ]; + + const results = await service.checkAllHealth(plugins); + + expect(results).toHaveLength(2); + expect(results[0].pluginName).toBe('plugin-a'); + expect(results[1].pluginName).toBe('plugin-b'); + }); + + it('should return empty array for empty input', async () => { + const results = await service.checkAllHealth([]); + expect(results).toEqual([]); + }); + }); +}); diff --git a/translations/ar/cli.json b/translations/ar/cli.json index 5766dec71..7f2ff40f1 100644 --- a/translations/ar/cli.json +++ b/translations/ar/cli.json @@ -655,6 +655,88 @@ "description": "بدء خادم الويب كخدمة خلفية (للاستخدام الداخلي فقط)", "portOption": "المنفذ للاستماع عليه", "portValidation": "يجب أن يكون المنفذ عددًا صحيحًا بين 1024 و 65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/ar/web.json b/translations/ar/web.json index a62bf9838..00ba49088 100644 --- a/translations/ar/web.json +++ b/translations/ar/web.json @@ -250,7 +250,8 @@ "sessions": "الجلسات", "tools": "الأدوات", "skills": "المهارات", - "applications": "Applications" + "applications": "Applications", + "plugins": "الإضافات" }, "sidebar": { "features": "الميزات", diff --git a/translations/de/cli.json b/translations/de/cli.json index 327b23b6c..6ae5db34d 100644 --- a/translations/de/cli.json +++ b/translations/de/cli.json @@ -655,6 +655,88 @@ "description": "Web-Server-Dienst starten (nur für internen Gebrauch)", "portOption": "Port zum Lauschen", "portValidation": "Port muss eine Ganzzahl zwischen 1024 und 65535 sein" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/de/web.json b/translations/de/web.json index 81df32d49..04e6113f8 100644 --- a/translations/de/web.json +++ b/translations/de/web.json @@ -250,7 +250,8 @@ "sessions": "Sitzungen", "tools": "Tools", "skills": "Skills", - "applications": "Applications" + "applications": "Applications", + "plugins": "Plugins" }, "sidebar": { "features": "Features", diff --git a/translations/en/cli.json b/translations/en/cli.json index 8d65852a3..223c14ab7 100644 --- a/translations/en/cli.json +++ b/translations/en/cli.json @@ -655,6 +655,88 @@ "description": "Start the web server daemon (internal use only)", "portOption": "Port to listen on", "portValidation": "Port must be an integer between 1024 and 65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/en/web.json b/translations/en/web.json index 99b6bca89..bdcc46f7e 100644 --- a/translations/en/web.json +++ b/translations/en/web.json @@ -250,7 +250,8 @@ "sessions": "Sessions", "tools": "Tools", "skills": "Skills", - "applications": "Applications" + "applications": "Applications", + "plugins": "Plugins" }, "sidebar": { "features": "Features", diff --git a/translations/es/cli.json b/translations/es/cli.json index cef7dd8ab..7773faa82 100644 --- a/translations/es/cli.json +++ b/translations/es/cli.json @@ -655,6 +655,88 @@ "description": "Iniciar el servidor web del demonio (solo uso interno)", "portOption": "Puerto en el que escuchar", "portValidation": "El puerto debe ser un entero entre 1024 y 65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/es/web.json b/translations/es/web.json index 873e4ca30..6246bb5be 100644 --- a/translations/es/web.json +++ b/translations/es/web.json @@ -250,7 +250,8 @@ "sessions": "Sesiones", "tools": "Herramientas", "skills": "Habilidades", - "applications": "Applications" + "applications": "Applications", + "plugins": "Complementos" }, "sidebar": { "features": "Funcionalidades", diff --git a/translations/fr/cli.json b/translations/fr/cli.json index 7d1a4d7b7..131ff282f 100644 --- a/translations/fr/cli.json +++ b/translations/fr/cli.json @@ -655,6 +655,88 @@ "description": "Démarrer le serveur web démon (usage interne uniquement)", "portOption": "Port d'écoute", "portValidation": "Le port doit être un entier entre 1024 et 65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/fr/web.json b/translations/fr/web.json index 7475f7ca4..b26663500 100644 --- a/translations/fr/web.json +++ b/translations/fr/web.json @@ -250,7 +250,8 @@ "sessions": "Sessions", "tools": "Outils", "skills": "Compétences", - "applications": "Applications" + "applications": "Applications", + "plugins": "Extensions" }, "sidebar": { "features": "Fonctionnalités", diff --git a/translations/he/cli.json b/translations/he/cli.json index d8b0f744d..a56a238c7 100644 --- a/translations/he/cli.json +++ b/translations/he/cli.json @@ -655,6 +655,88 @@ "description": "הפעל את שרת ה-Web (שימוש פנימי בלבד)", "portOption": "פורט להאזנה", "portValidation": "הפורט חייב להיות מספר שלם בין 1024 ל-65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/he/web.json b/translations/he/web.json index e5b346716..c24560a31 100644 --- a/translations/he/web.json +++ b/translations/he/web.json @@ -250,7 +250,8 @@ "sessions": "סשנים", "tools": "כלים", "skills": "כישורים", - "applications": "Applications" + "applications": "Applications", + "plugins": "תוספים" }, "sidebar": { "features": "פיצ'רים", diff --git a/translations/pt/cli.json b/translations/pt/cli.json index 34568aa9f..7b6801138 100644 --- a/translations/pt/cli.json +++ b/translations/pt/cli.json @@ -655,6 +655,88 @@ "description": "Iniciar o servidor web daemon (apenas uso interno)", "portOption": "Porta para escutar", "portValidation": "A porta deve ser um número inteiro entre 1024 e 65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/pt/web.json b/translations/pt/web.json index f08787192..a594a3890 100644 --- a/translations/pt/web.json +++ b/translations/pt/web.json @@ -250,7 +250,8 @@ "sessions": "Sessões", "tools": "Ferramentas", "skills": "Skills", - "applications": "Applications" + "applications": "Applications", + "plugins": "Plugins" }, "sidebar": { "features": "Recursos", diff --git a/translations/ru/cli.json b/translations/ru/cli.json index 6fc0caf7a..ae0fa5f5f 100644 --- a/translations/ru/cli.json +++ b/translations/ru/cli.json @@ -655,6 +655,88 @@ "description": "Запустить демон веб-сервера (только для внутреннего использования)", "portOption": "Порт для прослушивания", "portValidation": "Порт должен быть целым числом от 1024 до 65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/ru/web.json b/translations/ru/web.json index 839af66e3..1799f53a3 100644 --- a/translations/ru/web.json +++ b/translations/ru/web.json @@ -250,7 +250,8 @@ "sessions": "Сессии", "tools": "Инструменты", "skills": "Навыки", - "applications": "Applications" + "applications": "Applications", + "plugins": "Плагины" }, "sidebar": { "features": "Функции", diff --git a/translations/uk/cli.json b/translations/uk/cli.json index d88f25aa5..d7dd75868 100644 --- a/translations/uk/cli.json +++ b/translations/uk/cli.json @@ -655,6 +655,88 @@ "description": "Запустити демон веб-сервера (лише для внутрішнього використання)", "portOption": "Порт для прослуховування", "portValidation": "Порт має бути цілим числом від 1024 до 65535" + }, + "plugin": { + "description": "Manage AI tool plugins", + "add": { + "description": "Install a plugin from catalog or custom configuration", + "nameArg": "Plugin name from the curated catalog", + "typeOption": "Plugin type for custom install (mcp, hook, cli)", + "commandOption": "MCP server command for custom install", + "transportOption": "MCP transport protocol (stdio, http)", + "nameOption": "Plugin name for custom install", + "success": "Plugin \"{{name}}\" installed successfully", + "failed": "Failed to install plugin", + "customRequiresName": "Custom plugin install requires --name", + "customRequiresType": "Custom plugin install requires --type (mcp, hook, cli)", + "invalidType": "Invalid plugin type \"{{type}}\". Must be one of: mcp, hook, cli" + }, + "remove": { + "description": "Remove an installed plugin", + "nameArg": "Name of the plugin to remove", + "success": "Plugin \"{{name}}\" removed", + "failed": "Failed to remove plugin" + }, + "list": { + "description": "List all installed plugins", + "title": "Installed Plugins", + "nameColumn": "Name", + "typeColumn": "Type", + "statusColumn": "Status", + "healthColumn": "Health", + "sourceColumn": "Source", + "noPlugins": "No plugins installed. Run 'shep plugin catalog' to browse available plugins.", + "failed": "Failed to list plugins" + }, + "enable": { + "description": "Enable a plugin globally", + "nameArg": "Name of the plugin to enable", + "success": "Plugin \"{{name}}\" enabled", + "failed": "Failed to enable plugin" + }, + "disable": { + "description": "Disable a plugin globally", + "nameArg": "Name of the plugin to disable", + "success": "Plugin \"{{name}}\" disabled", + "failed": "Failed to disable plugin" + }, + "configure": { + "description": "Configure plugin settings", + "nameArg": "Name of the plugin to configure", + "toolGroupsOption": "Comma-separated list of tool groups to enable", + "success": "Plugin \"{{name}}\" configured", + "noOptions": "No configuration options provided. Use --tool-groups to set active tool groups.", + "failed": "Failed to configure plugin" + }, + "status": { + "description": "Show detailed health status of a plugin", + "nameArg": "Name of the plugin to check (omit for all)", + "title": "Plugin Health Status", + "allTitle": "Plugin Health Check", + "nameLabel": "Name", + "typeLabel": "Type", + "healthLabel": "Health", + "messageLabel": "Details", + "enabledLabel": "Enabled", + "runtimeLabel": "Runtime", + "transportLabel": "Transport", + "envVarsLabel": "Required Env Vars", + "toolGroupsLabel": "Tool Groups", + "activeGroupsLabel": "Active Groups", + "noPlugins": "No plugins installed.", + "failed": "Failed to check plugin health" + }, + "catalog": { + "description": "Browse available plugins from the curated catalog", + "title": "Plugin Catalog", + "nameColumn": "Name", + "typeColumn": "Type", + "descriptionColumn": "Description", + "statusColumn": "Status", + "installed": "Installed", + "available": "Available", + "failed": "Failed to load plugin catalog" + } } }, "ui": { diff --git a/translations/uk/web.json b/translations/uk/web.json index 62ee637f0..a17f5c42c 100644 --- a/translations/uk/web.json +++ b/translations/uk/web.json @@ -250,7 +250,8 @@ "sessions": "Сесії", "tools": "Інструменти", "skills": "Навички", - "applications": "Applications" + "applications": "Applications", + "plugins": "Плагіни" }, "sidebar": { "features": "Функції", diff --git a/tsp/common/enums/index.tsp b/tsp/common/enums/index.tsp index 258cd5c79..63d2a2fad 100644 --- a/tsp/common/enums/index.tsp +++ b/tsp/common/enums/index.tsp @@ -18,3 +18,4 @@ import "./tool.tsp"; import "./notification.tsp"; import "./evidence-type.tsp"; import "./work-item-enums.tsp"; +import "./plugin.tsp"; diff --git a/tsp/common/enums/plugin.tsp b/tsp/common/enums/plugin.tsp new file mode 100644 index 000000000..6f0b31777 --- /dev/null +++ b/tsp/common/enums/plugin.tsp @@ -0,0 +1,74 @@ +/** + * @module Shep.Common.Enums.Plugin + * + * Defines enums for the AI Tool Plugin System. + * Covers plugin integration types, MCP transport protocols, + * and health check status indicators. + */ +/** + * Plugin Integration Type + * + * Determines how a plugin integrates with Shep's agentic workflows. + * Each type has a different lifecycle management strategy: + * + * - **Mcp**: MCP server plugins expose tools via Model Context Protocol. + * Shep spawns the server process and passes connection info to agent executors. + * - **Hook**: Hook-based plugins integrate via Claude Code lifecycle hooks + * (.claude/hooks/ directory). Shep manages script installation, not execution. + * - **Cli**: CLI tool plugins are standalone executables invoked during workflow steps. + */ +@doc("Integration type determining how a plugin connects to Shep workflows") +enum PluginType { + @doc("MCP server plugin exposing tools via Model Context Protocol (stdio or HTTP)") + Mcp, + + @doc("Hook-based plugin integrating via Claude Code lifecycle hooks") + Hook, + + @doc("CLI tool plugin providing a standalone executable") + Cli, +} + +/** + * MCP Transport Protocol + * + * Specifies the communication transport used by an MCP server plugin. + * Only applicable when PluginType is Mcp. + * + * - **Stdio**: Communication via stdin/stdout pipes (most common for local MCP servers) + * - **Http**: Communication via HTTP/SSE (for remote or networked MCP servers) + */ +@doc("Transport protocol for MCP server communication") +enum PluginTransport { + @doc("Communication via stdin/stdout pipes (local MCP servers)") + Stdio, + + @doc("Communication via HTTP/SSE (remote MCP servers)") + Http, +} + +/** + * Plugin Health Status + * + * Indicates the operational health of a plugin based on multi-tier checks: + * runtime availability, package installation, environment variables, and server probe. + * + * - **Healthy**: All health checks pass; plugin is fully operational + * - **Degraded**: Runtime present but package or env var issues detected + * - **Unavailable**: Runtime missing or critical failure prevents operation + * - **Unknown**: Health has not been checked yet (initial state) + */ +@doc("Operational health status of a plugin based on multi-tier health checks") +enum PluginHealthStatus { + @doc("All health checks pass; plugin is fully operational") + Healthy, + + @doc("Runtime present but package or environment variable issues detected") + Degraded, + + @doc("Runtime missing or critical failure prevents operation") + Unavailable, + + @doc("Health has not been checked yet (initial state)") + Unknown, +} diff --git a/tsp/domain/entities/feature.tsp b/tsp/domain/entities/feature.tsp index 4f00a169a..8ba977e6f 100644 --- a/tsp/domain/entities/feature.tsp +++ b/tsp/domain/entities/feature.tsp @@ -406,6 +406,11 @@ model Feature extends SoftDeletableEntity { @doc("Absolute path to the git worktree for this feature") worktreePath?: string; + // ── Plugin Activation ────────────────────────────────────── + + @doc("Per-feature plugin activation overrides mapping plugin names to enabled state (JSON-serialized in DB)") + activePlugins?: Record; + // ── Pull Request Tracking ────────────────────────────────── @doc("Pull request data (null until PR created)") diff --git a/tsp/domain/entities/index.tsp b/tsp/domain/entities/index.tsp index 99e3350f5..a9dcba628 100644 --- a/tsp/domain/entities/index.tsp +++ b/tsp/domain/entities/index.tsp @@ -46,3 +46,4 @@ import "./pm-user.tsp"; import "./pm-session.tsp"; import "./pm-project-member.tsp"; import "./pm-audit-log.tsp"; +import "./plugin.tsp"; diff --git a/tsp/domain/entities/plugin.tsp b/tsp/domain/entities/plugin.tsp new file mode 100644 index 000000000..45c3ef2bb --- /dev/null +++ b/tsp/domain/entities/plugin.tsp @@ -0,0 +1,221 @@ +/** + * @module Shep.Domain.Entities.Plugin + * + * Defines the Plugin entity and ToolGroup model for the AI Tool Plugin System. + * A Plugin represents an external AI-native tool that integrates into Shep's + * agentic workflows via one of three integration types: MCP server, hook-based, + * or CLI tool. + * + * ## Entity Relationships + * + * ``` + * +------------------------------------------------------------------+ + * | PLUGIN (Entity) | + * +------------------------------------------------------------------+ + * | id: UUID | + * | name: string (unique) | + * | displayName: string | + * | type: PluginType (Mcp | Hook | Cli) | + * | enabled: boolean | + * | healthStatus: PluginHealthStatus | + * +------------------------------------------------------------------+ + * | + * | 0..* + * v + * +------------------+ + * | ToolGroup | + * +------------------+ + * | name: string | + * | description? | + * | tools?: string[] | + * +------------------+ + * ``` + * + * ## Plugin Types + * + * - **Mcp**: MCP server plugins (MemPalace, Ruflo) expose tools via Model Context Protocol. + * Uses transport (Stdio/Http), serverCommand, and serverArgs fields. + * - **Hook**: Hook-based plugins (Token Optimizer) integrate via Claude Code lifecycle hooks. + * Uses hookType and scriptPath fields. + * - **Cli**: CLI tool plugins provide standalone executables. + * Uses binaryCommand field. + * + * Type-specific fields are optional; only those matching the plugin's type are populated. + * + * @see common/enums/plugin.tsp - PluginType, PluginTransport, PluginHealthStatus enums + * @see common/base.tsp - BaseEntity base class + */ +import "../../common/base.tsp"; +import "../../common/enums/plugin.tsp"; + +/** + * Tool Group + * + * Represents a logical grouping of MCP tools within a plugin. Used for + * filtering which tools from a large plugin (e.g., Ruflo with 313 tools) + * are made available to the agent executor. + * + * ## Properties + * + * | Property | Type | Required | Description | + * |-------------|----------|----------|---------------------------------------------| + * | name | string | Yes | Group identifier (e.g., "implement", "test")| + * | description | string | No | Human-readable description of the group | + * | tools | string[] | No | List of tool names belonging to this group | + * + * @example + * ```json + * { + * "name": "implement", + * "description": "Tools for code implementation and file operations", + * "tools": ["write_file", "read_file", "execute_command"] + * } + * ``` + */ +@doc("Logical grouping of MCP tools within a plugin for selective activation") +model ToolGroup { + @doc("Group identifier used for activation and filtering") + name: string; + + @doc("Human-readable description of what this tool group provides") + description?: string; + + @doc("List of individual tool names belonging to this group") + tools?: string[]; +} + +/** + * Plugin Entity + * + * Represents an external AI-native tool registered in Shep's plugin system. + * The plugin entity uses a type discriminator pattern: fields specific to + * a plugin type (Mcp, Hook, Cli) are optional and only populated when + * the type matches. + * + * ## Properties + * + * | Property | Type | Required | Description | + * |-------------------|--------------------|----------|------------------------------------------------| + * | id | UUID | Yes | Unique identifier (from BaseEntity) | + * | name | string | Yes | Unique plugin name (e.g., "mempalace") | + * | displayName | string | Yes | Human-readable display name | + * | type | PluginType | Yes | Integration type: Mcp, Hook, or Cli | + * | version | string | No | Installed version of the plugin | + * | installSource | string | No | Source: "catalog" or "custom" | + * | transport | PluginTransport | No | MCP transport protocol (Mcp type only) | + * | serverCommand | string | No | Command to start MCP server (Mcp type only) | + * | serverArgs | string[] | No | Arguments for server command (Mcp type only) | + * | requiredEnvVars | string[] | No | Environment variable names required by plugin | + * | toolGroups | ToolGroup[] | No | Available tool groups for filtering | + * | activeToolGroups | string[] | No | Currently enabled tool group names | + * | enabled | boolean | Yes | Global enabled/disabled state (default: true) | + * | healthStatus | PluginHealthStatus | Yes | Current health status (default: Unknown) | + * | healthMessage | string | No | Human-readable health check details | + * | hookType | string | No | Hook event type (Hook type only) | + * | scriptPath | string | No | Path to hook script (Hook type only) | + * | binaryCommand | string | No | Executable command (Cli type only) | + * | runtimeType | string | No | Required runtime: "python" or "node" | + * | runtimeMinVersion | string | No | Minimum runtime version (e.g., "3.9", "20") | + * | homepageUrl | string | No | Plugin homepage or repository URL | + * | description | string | No | Brief description of what the plugin does | + * | createdAt | utcDateTime | Yes | When registered (from BaseEntity) | + * | updatedAt | utcDateTime | Yes | When last modified (from BaseEntity) | + * + * @example + * ```json + * { + * "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + * "name": "mempalace", + * "displayName": "MemPalace", + * "type": "Mcp", + * "transport": "Stdio", + * "serverCommand": "python", + * "serverArgs": ["-m", "mempalace.mcp_server"], + * "requiredEnvVars": [], + * "enabled": true, + * "healthStatus": "Healthy", + * "runtimeType": "python", + * "runtimeMinVersion": "3.9", + * "installSource": "catalog" + * } + * ``` + */ +@doc("External AI-native tool registered in Shep's plugin system") +model Plugin extends BaseEntity { + @doc("Unique plugin name used as identifier (e.g., 'mempalace', 'ruflo')") + name: string; + + @doc("Human-readable display name for UI presentation") + displayName: string; + + @doc("Integration type determining how the plugin connects to Shep workflows") + type: PluginType; + + @doc("Installed version of the plugin package") + version?: string; + + @doc("Installation source: 'catalog' for curated plugins, 'custom' for user-added") + installSource?: string; + + // ── MCP-specific fields ──────────────────────────────────── + + @doc("MCP transport protocol (only for Mcp type plugins)") + transport?: PluginTransport; + + @doc("Command to start the MCP server process (only for Mcp type plugins)") + serverCommand?: string; + + @doc("Arguments passed to the MCP server command (only for Mcp type plugins)") + serverArgs?: string[]; + + // ── Tool filtering ───────────────────────────────────────── + + @doc("Environment variable names required by this plugin (names only, never values)") + requiredEnvVars?: string[]; + + @doc("Available tool groups defined by this plugin for selective activation") + toolGroups?: ToolGroup[]; + + @doc("Names of currently enabled tool groups from the available set") + activeToolGroups?: string[]; + + // ── Status ───────────────────────────────────────────────── + + @doc("Whether this plugin is globally enabled for use in features") + enabled: boolean = true; + + @doc("Current operational health status based on multi-tier health checks") + healthStatus: PluginHealthStatus = PluginHealthStatus.Unknown; + + @doc("Human-readable details from the most recent health check") + healthMessage?: string; + + // ── Hook-specific fields ─────────────────────────────────── + + @doc("Hook event type for lifecycle integration (only for Hook type plugins)") + hookType?: string; + + @doc("Path to the hook script file (only for Hook type plugins)") + scriptPath?: string; + + // ── CLI-specific fields ──────────────────────────────────── + + @doc("Executable command for CLI tool invocation (only for Cli type plugins)") + binaryCommand?: string; + + // ── Runtime requirements ─────────────────────────────────── + + @doc("Required runtime environment: 'python' or 'node'") + runtimeType?: string; + + @doc("Minimum required version of the runtime (e.g., '3.9' for Python, '20' for Node.js)") + runtimeMinVersion?: string; + + // ── Metadata ─────────────────────────────────────────────── + + @doc("Plugin homepage or repository URL for reference") + homepageUrl?: string; + + @doc("Brief description of what this plugin provides") + description?: string; +}