diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..422e4766 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,302 @@ +# AI Agent Development Guide + +**Purpose**: Quick reference for AI coding agents working in the CodeMie Code repository. + +--- + +## 🚀 Quick Start + +### Build & Run Commands + +```bash +# Install dependencies +npm install + +# Build project +npm run build # Full build (tsc + tsc-alias + copy-plugin) +npm run dev # Watch mode for development + +# Lint & Format +npm run lint # Check code (requires 0 warnings) +npm run lint:fix # Auto-fix issues + +# Testing +npm test # Run all tests (Vitest) +npm run test:unit # Unit tests only (src/) +npm run test:integration # Integration tests (tests/integration/) + +# Run single test file +npm test src/utils/__tests__/security.test.ts +npm test -- security # Pattern matching +npm test -- -t "test name" # Filter by test name + +# Test modes +npm run test:watch # Watch mode +npm run test:ui # Interactive UI +npm run test:coverage # Coverage report + +# Quality & CI +npm run ci # Full CI pipeline (lint + build + tests) +npm run validate:secrets # Check for secrets (Gitleaks) +``` + +### Tech Stack + +- **Language**: TypeScript 5.3+ (strict mode, ES2022 target) +- **Runtime**: Node.js 20.0.0+ (no virtual environment needed) +- **Module System**: ES Modules only (NodeNext resolution) +- **Testing**: Vitest 4.0.10+ with parallel execution +- **Linting**: ESLint 9+ (flat config, zero warnings required) +- **Frameworks**: LangGraph 1.0.2+, LangChain 1.0.4+ +- **Package Manager**: npm (no yarn/pnpm) + +--- + +## 📝 Code Style Guidelines + +### Imports & Modules + +```typescript +// ✅ Always use .js extension (required for ES modules) +import { exec } from './exec.js'; +import { logger } from '@/utils/logger.js'; +import type { Config } from './types.js'; + +// ❌ Never omit .js extension +import { exec } from './exec'; // WILL FAIL + +// ✅ Path aliases (@/* maps to src/*) +import { sanitizeValue } from '@/utils/security.js'; + +// ✅ ES modules only +export async function fetchData(): Promise { } + +// ❌ No CommonJS +const module = require('./module'); // NEVER USE +``` + +### Type Safety + +```typescript +// ✅ Explicit return types on all exported functions +export async function processItem(id: string): Promise { + return { success: true }; +} + +// ✅ Prefer interface over type for objects +interface Config { + apiKey: string; + timeout: number; +} + +// ✅ Use unknown instead of any when type is truly unknown +function parseInput(input: unknown): ParsedData { + if (typeof input === 'string') { /* ... */ } +} + +// ✅ Intentional unused variables with underscore prefix +function handler(_event: Event, data: Data) { + return processData(data); +} +``` + +### Async/Await Patterns + +```typescript +// ✅ Always async/await (never callbacks or .then() chains) +export async function fetchData(): Promise { + try { + const data = await apiCall(); + return processData(data); + } catch (error) { + throw new CustomError('Failed to fetch', error); + } +} + +// ✅ Use Promise.all() for parallel operations +const [results1, results2] = await Promise.all([ + operation1(), + operation2() +]); + +// ❌ Never await in loops (sequential bottleneck) +for (const item of items) { + await processItem(item); // BAD - use Promise.all() +} +``` + +### Error Handling + +```typescript +// ✅ Use custom error classes with context +import { ConfigurationError, createErrorContext } from '@/utils/errors.js'; + +try { + await riskyOperation(); +} catch (error) { + const context = createErrorContext(error, { sessionId, agent: 'claude' }); + logger.error('Operation failed', context); + throw new ConfigurationError('Specific error message'); +} + +// Available error classes: +// - ConfigurationError +// - AgentNotFoundError +// - AgentInstallationError +// - ToolExecutionError +// - PathSecurityError +// - NpmError +// - CodeMieError (base class) +``` + +### Logging Patterns + +```typescript +import { logger } from '@/utils/logger.js'; +import { sanitizeLogArgs } from '@/utils/security.js'; + +// ✅ Set session context at agent startup +logger.setSessionId(sessionId); +logger.setAgentName('claude'); +logger.setProfileName('work'); + +// ✅ Use appropriate log levels +logger.debug('Internal details'); // File-only (controlled by CODEMIE_DEBUG) +logger.info('Non-console log'); // File-only +logger.success('User feedback'); // Console + file +logger.error('Error occurred', ctx); // Console + file + +// ✅ Always sanitize sensitive data before logging +logger.debug('Request data', ...sanitizeLogArgs(requestData)); + +// ❌ Never use console.log() for debug info +console.log('Debug info'); // BAD - use logger.debug() +``` + +### Security Requirements + +```typescript +import { sanitizeValue, CredentialStore } from '@/utils/security.js'; + +// ✅ No hardcoded credentials +const apiKey = process.env.ANTHROPIC_API_KEY; + +// ✅ Use CredentialStore for sensitive data +const store = CredentialStore.getInstance(); +await store.storeSSOCredentials(credentials, baseUrl); + +// ✅ Sanitize before logging +logger.debug('Data', ...sanitizeLogArgs({ apiKey, data })); + +// ✅ Validate all file paths +import { validatePath } from '@/utils/security.js'; +validatePath(userProvidedPath); +``` + +--- + +## 🏗️ Architecture Guidelines + +### 5-Layer Architecture (Never Skip Layers) + +**Flow**: `CLI → Registry → Plugin → Core → Utils` + +| Layer | Path | Responsibility | +|-------|------|----------------| +| **CLI** | `src/cli/commands/` | User interface (Commander.js commands) | +| **Registry** | `src/agents/registry.ts` | Plugin discovery and routing | +| **Plugin** | `src/agents/plugins/` | Concrete implementations (agents, providers) | +| **Core** | `src/agents/core/` | Base classes, interfaces, contracts | +| **Utils** | `src/utils/` | Shared utilities (errors, logging, security) | + +### Testing Patterns (Critical for exec-dependent modules) + +```typescript +// ✅ Use dynamic imports when testing modules that call exec() +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as exec from '../exec.js'; + +describe('module name', () => { + let execSpy: ReturnType; + + beforeEach(() => { + // Set up spy BEFORE dynamic import + execSpy = vi.spyOn(exec, 'exec'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should do something', async () => { + execSpy.mockResolvedValue({ code: 0, stdout: '', stderr: '' }); + + // Dynamic import AFTER spy setup (crucial!) + const { functionToTest } = await import('../module.js'); + await functionToTest(); + + expect(execSpy).toHaveBeenCalledWith('command', ['arg']); + }); +}); +``` + +**Why?** Static imports execute before spies are set up, causing real exec() calls. + +--- + +## 🚨 Critical Rules + +### 1. Testing Policy +- ❌ **Never proactively write or run tests** +- ✅ **Only when user explicitly requests**: "write tests", "run tests", "add test coverage" +- **Rationale**: Testing requires time and context; only implement when needed + +### 2. Git Operations Policy +- ❌ **Never proactively commit, push, or create branches/PRs** +- ✅ **Only when user explicitly requests**: "commit changes", "push to remote", "create PR" +- **Branch pattern**: `/` (e.g., `feat/add-gemini-support`) +- **Commit format**: Conventional Commits (`feat:`, `fix:`, `refactor:`, etc.) + +### 3. Environment Policy +- ✅ **No activation needed** (Node.js >=20.0.0 required) +- ❌ **No virtual environment or conda** +- **Verify**: `node --version` should show >=20.0.0 + +### 4. Shell Commands +- ✅ **Use bash/Linux commands only** (macOS, Linux, WSL on Windows) +- ❌ **No PowerShell or cmd.exe commands** + +--- + +## 🎯 Common Pitfalls to Avoid + +| ❌ Never Do This | ✅ Do This Instead | +|------------------|---------------------| +| Import without `.js` extension | Always use `.js`: `import x from './file.js'` | +| Use `require()` or `__dirname` | Use ES modules: `import` and `getDirname(import.meta.url)` | +| Use `console.log()` for debug | Use `logger.debug()` (file-only, controlled) | +| Log sensitive data (tokens, keys) | Use `sanitizeLogArgs()` before logging | +| Throw generic `Error` | Throw specific error classes (`ConfigurationError`, etc.) | +| Use `child_process.exec` directly | Use `exec()` from `@/utils/processes.js` | +| Hardcode `~/.codemie/` paths | Use `getCodemiePath()` from `@/utils/paths.js` | +| CLI directly calls plugin code | CLI → Registry → Plugin (never skip layers) | +| Await in loops | Use `Promise.all()` for parallel operations | +| Write tests unless requested | Only write tests when user explicitly asks | + +--- + +## 📚 Additional Resources + +- **CLAUDE.md**: Comprehensive AI execution guide (608 lines) +- **.codemie/guides/**: Detailed pattern documentation + - `architecture/architecture.md` - 5-layer architecture + - `development/development-practices.md` - Error handling, logging + - `testing/testing-patterns.md` - Vitest patterns + - `standards/code-quality.md` - TypeScript patterns + - `standards/git-workflow.md` - Branch/commit conventions + - `integration/external-integrations.md` - LangGraph, providers + - `security/security-practices.md` - Sanitization, credentials + +--- + +**Remember**: When in doubt, check guides first, then ask the user for clarification. Never assume or skip critical rules. diff --git a/bin/codemie-opencode.js b/bin/codemie-opencode.js index 8ccdab8b..d7faaa42 100755 --- a/bin/codemie-opencode.js +++ b/bin/codemie-opencode.js @@ -2,9 +2,9 @@ import { AgentCLI } from '../dist/agents/core/AgentCLI.js'; import { AgentRegistry } from '../dist/agents/registry.js'; -const agent = AgentRegistry.getAgent('opencode'); +const agent = AgentRegistry.getAgent('codemie-opencode'); if (!agent) { - console.error('OpenCode agent not found. Run: codemie doctor'); + console.error('CodeMie OpenCode agent not found. Run: codemie doctor'); process.exit(1); } const cli = new AgentCLI(agent); diff --git a/echo_loop.sh b/echo_loop.sh new file mode 100644 index 00000000..fb1257b2 --- /dev/null +++ b/echo_loop.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Script to echo "echo1" through "echo10" using a for loop + +for i in {1..10} +do + echo "echo$i" +done diff --git a/package-lock.json b/package-lock.json index 7cd49d01..1991f172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@aws-sdk/credential-providers": "^3.948.0", "@clack/core": "^0.5.0", "@clack/prompts": "^0.11.0", + "@codemieai/codemie-opencode": "0.0.41", "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", @@ -1011,6 +1012,172 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@codemieai/codemie-opencode": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode/-/codemie-opencode-0.0.41.tgz", + "integrity": "sha512-Vu+sdpusP7h4HiijijQts08Dun3gikH2tsRNN0Gu4ghyeU6S5xVlPUvxMbWAuUc0NnRHSZkJXSI8nqWgLD+DlA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "codemie": "bin/codemie" + }, + "optionalDependencies": { + "@codemieai/codemie-opencode-darwin-arm64": "0.0.41", + "@codemieai/codemie-opencode-darwin-x64": "0.0.41", + "@codemieai/codemie-opencode-darwin-x64-baseline": "0.0.41", + "@codemieai/codemie-opencode-linux-arm64": "0.0.41", + "@codemieai/codemie-opencode-linux-arm64-musl": "0.0.41", + "@codemieai/codemie-opencode-linux-x64": "0.0.41", + "@codemieai/codemie-opencode-linux-x64-baseline": "0.0.41", + "@codemieai/codemie-opencode-linux-x64-baseline-musl": "0.0.41", + "@codemieai/codemie-opencode-linux-x64-musl": "0.0.41", + "@codemieai/codemie-opencode-windows-x64": "0.0.41", + "@codemieai/codemie-opencode-windows-x64-baseline": "0.0.41" + } + }, + "node_modules/@codemieai/codemie-opencode-darwin-arm64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-arm64/-/codemie-opencode-darwin-arm64-0.0.41.tgz", + "integrity": "sha512-6AeHnSEC0beU4oaZhgOgVA4N2mi4ElzKzh5C6L4zia88ClqjKVRqwfJq8tv4Zjo1uRj50YlXKiuqmSfA1EOU0A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-darwin-x64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64/-/codemie-opencode-darwin-x64-0.0.41.tgz", + "integrity": "sha512-RC8/JGInxb9A0zLq23ylU+tv+g2i99+O7ZlPI0KQ6ZvgPnTfeNat9iidpe3KJbP/aIHN20SSmNeLt63VpDWTXQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-darwin-x64-baseline": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64-baseline/-/codemie-opencode-darwin-x64-baseline-0.0.41.tgz", + "integrity": "sha512-FFLGDxLK7CzT1/u9pV9ixcHS2qWzmhzIMQQHFNH/75yPAzl2FoBZpTA51UE6T3R8C9ApxVLIRh3pIf5f8WQ1Hw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-arm64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64/-/codemie-opencode-linux-arm64-0.0.41.tgz", + "integrity": "sha512-Ox8ZDce4rguOYrlCIawaZlHnT48iMakN3B32V/4L8f8vLE9n2PpntqRxEkawc6cT6/PX6ENF3J/1hv7YIbMJEQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-arm64-musl": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64-musl/-/codemie-opencode-linux-arm64-musl-0.0.41.tgz", + "integrity": "sha512-At7XlLktoyCssfVJyXynfn98z8f96aOGAk51MEIgfzg4/2a6p2GHbT58mAhEfPsOzXAtO7HFxIkUKI83RFP8cA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64/-/codemie-opencode-linux-x64-0.0.41.tgz", + "integrity": "sha512-Pn5VcATJGZcX6MAD6/PAMxwTWnBCrITujXtiyBygSbkkSoKrNuKDc5JvBInkhQJ/uHYUrE6qrZdOD1aazLKWSQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-baseline": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline/-/codemie-opencode-linux-x64-baseline-0.0.41.tgz", + "integrity": "sha512-tiYdJXXY7qs5Ht3aIN0qQh2NU3NkgeNc3RteL+IhNygWVfS+gt6pXhzbfC6VnPQ/v8pfca205uJdhzIGFflVRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-baseline-musl": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline-musl/-/codemie-opencode-linux-x64-baseline-musl-0.0.41.tgz", + "integrity": "sha512-8o4EWBKUDyihZFS71qxsCm3cI/LxnH9MPjk5d4EnLltjpRW6DXDTZ6vJ85YBlTg5C1F9JYVUdWPZV0wHxMyINw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-musl": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-musl/-/codemie-opencode-linux-x64-musl-0.0.41.tgz", + "integrity": "sha512-rOGSDXHrZ1ovTVW6aKKrHjb1vGAQ4NzEHYIsulyZ3ja2jUtd+o43ybkGfnXzNlCFZt2spSFUAOFhc2o5+Ru/+A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-windows-x64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64/-/codemie-opencode-windows-x64-0.0.41.tgz", + "integrity": "sha512-auMpuQHYI1FIUy6UGUaZkCGZWXfgmW/L1VkK4LM9umJOaUemUpyWdR92lv2sYEnU8YtD4k1pmxO+WLzU5ZVxqw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@codemieai/codemie-opencode-windows-x64-baseline": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64-baseline/-/codemie-opencode-windows-x64-baseline-0.0.41.tgz", + "integrity": "sha512-dJvx7C4xsmueiyqXKL4dOXBatFwYArOYwpdPIAms/udsddVYmJ6HCxyLVY6+z0X7MFFoF7rN53SANTVcaz15QQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index ccd9102d..e36a93d5 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "ora": "^7.0.1", "strip-ansi": "^7.1.2", "yaml": "^2.3.4", + "@codemieai/codemie-opencode": "0.0.41", "zod": "^4.1.12" }, "devDependencies": { diff --git a/src/agents/__tests__/registry.test.ts b/src/agents/__tests__/registry.test.ts index 072a05b9..9b1507d3 100644 --- a/src/agents/__tests__/registry.test.ts +++ b/src/agents/__tests__/registry.test.ts @@ -12,8 +12,8 @@ describe('AgentRegistry', () => { it('should register all default agents', () => { const agentNames = AgentRegistry.getAgentNames(); - // Should have all 5 default agents (codemie-code, claude, claude-acp, gemini, opencode) - expect(agentNames).toHaveLength(5); + // Should have all 6 default agents (codemie-code, claude, claude-acp, gemini, opencode, codemie-opencode) + expect(agentNames).toHaveLength(6); }); it('should register built-in agent', () => { @@ -62,7 +62,7 @@ describe('AgentRegistry', () => { it('should return all registered agents', () => { const agents = AgentRegistry.getAllAgents(); - expect(agents).toHaveLength(5); + expect(agents).toHaveLength(6); expect(agents.every((agent) => agent.name)).toBe(true); }); @@ -74,6 +74,7 @@ describe('AgentRegistry', () => { expect(names).toContain('claude-acp'); expect(names).toContain('gemini'); expect(names).toContain('opencode'); + expect(names).toContain('codemie-opencode'); }); }); @@ -114,11 +115,11 @@ describe('AgentRegistry', () => { } }); - it('should include built-in agent in installed agents', async () => { - const installedAgents = await AgentRegistry.getInstalledAgents(); + it('should include built-in agent in all agents', () => { + const allAgents = AgentRegistry.getAllAgents(); - // Built-in agent should always be "installed" - const builtInAgent = installedAgents.find( + // Built-in agent should always be registered + const builtInAgent = allAgents.find( (agent) => agent.name === BUILTIN_AGENT_NAME ); diff --git a/src/agents/codemie-code/skills/core/SkillDiscovery.ts b/src/agents/codemie-code/skills/core/SkillDiscovery.ts index deb0cd3e..a578cde8 100644 --- a/src/agents/codemie-code/skills/core/SkillDiscovery.ts +++ b/src/agents/codemie-code/skills/core/SkillDiscovery.ts @@ -2,6 +2,7 @@ import { readFile } from 'fs/promises'; import { join } from 'path'; import fg from 'fast-glob'; import { getCodemiePath } from '../../../../utils/paths.js'; +import { logger } from '../../../../utils/logger.js'; import { parseFrontmatter, FrontmatterParseError } from '../utils/frontmatter.js'; import { SkillMetadataSchema } from './types.js'; import type { @@ -17,9 +18,10 @@ import type { * Higher priority = loaded first, can override lower priority */ const SOURCE_PRIORITY: Record = { - project: 1000, // Highest priority + project: 1000, // Highest priority - project-specific skills + plugin: 750, // High priority - plugins can override global but not project 'mode-specific': 500, // Medium priority - global: 100, // Lowest priority + global: 100, // Lowest priority - user's global skills }; /** @@ -47,14 +49,15 @@ export class SkillDiscovery { } // Discover from all locations - const [projectSkills, modeSkills, globalSkills] = await Promise.all([ + const [projectSkills, pluginSkills, modeSkills, globalSkills] = await Promise.all([ this.discoverProjectSkills(cwd), + this.discoverPluginSkills(), this.discoverModeSkills(options.mode), this.discoverGlobalSkills(), ]); // Combine and deduplicate by name (higher priority wins) - const allSkills = [...projectSkills, ...modeSkills, ...globalSkills]; + const allSkills = [...projectSkills, ...pluginSkills, ...modeSkills, ...globalSkills]; const deduplicatedSkills = this.deduplicateSkills(allSkills); // Filter by agent if specified @@ -103,6 +106,44 @@ export class SkillDiscovery { return this.discoverFromDirectory(globalSkillsDir, 'global'); } + /** + * Discover skills from installed plugins + * Path: ~/.codemie/plugins/{name}/skills/ + */ + private async discoverPluginSkills(): Promise { + const skills: Skill[] = []; + + try { + // Lazy import to avoid circular dependency + const { PluginRegistry } = await import('../../../../plugins/index.js'); + const registry = PluginRegistry.getInstance(); + + // Get all loaded plugins + const plugins = await registry.getAllPlugins(); + + for (const plugin of plugins) { + // Discover skills from each plugin's skills directory + const pluginSkillsDir = join(plugin.path, 'skills'); + const pluginSkills = await this.discoverFromDirectory(pluginSkillsDir, 'plugin'); + + // Add plugin info to each skill + for (const skill of pluginSkills) { + skill.pluginInfo = { + pluginName: plugin.name, + fullSkillName: `${plugin.name}:${skill.metadata.name}`, + pluginVersion: plugin.manifest.version, + }; + } + + skills.push(...pluginSkills); + } + } catch (error) { + logger.debug(`Failed to discover plugin skills: ${error instanceof Error ? error.message : String(error)}`); + } + + return skills; + } + /** * Discover skills from a specific directory * @@ -137,8 +178,8 @@ export class SkillDiscovery { .map((result) => result.skill); return skills; - } catch { - // Directory doesn't exist or other error - return empty array + } catch (error) { + logger.debug(`Failed to discover skills from ${directory}: ${error instanceof Error ? error.message : String(error)}`); return []; } } diff --git a/src/agents/codemie-code/skills/core/types.ts b/src/agents/codemie-code/skills/core/types.ts index 59a7d185..15ca1eac 100644 --- a/src/agents/codemie-code/skills/core/types.ts +++ b/src/agents/codemie-code/skills/core/types.ts @@ -27,7 +27,21 @@ export type SkillMetadata = z.infer; /** * Source type for a skill */ -export type SkillSource = 'global' | 'project' | 'mode-specific'; +export type SkillSource = 'global' | 'project' | 'mode-specific' | 'plugin'; + +/** + * Plugin info attached to skills from plugins + */ +export interface PluginSkillInfo { + /** Plugin name */ + pluginName: string; + + /** Full namespaced skill name (plugin-name:skill-name) */ + fullSkillName: string; + + /** Plugin version */ + pluginVersion: string; +} /** * Complete skill with metadata, content, and location info @@ -47,6 +61,9 @@ export interface Skill { /** Computed priority (source-based + metadata priority) */ computedPriority: number; + + /** Plugin info (only present if source is 'plugin') */ + pluginInfo?: PluginSkillInfo; } /** diff --git a/src/agents/codemie-code/skills/index.ts b/src/agents/codemie-code/skills/index.ts index 827b471b..4fabe6fd 100644 --- a/src/agents/codemie-code/skills/index.ts +++ b/src/agents/codemie-code/skills/index.ts @@ -8,6 +8,10 @@ export { SkillManager } from './core/SkillManager.js'; export { SkillDiscovery } from './core/SkillDiscovery.js'; +// Sync exports +export { SkillSync } from './sync/SkillSync.js'; +export type { SyncOptions, SyncResult } from './sync/SkillSync.js'; + // Type exports export type { Skill, @@ -17,6 +21,7 @@ export type { SkillDiscoveryOptions, SkillValidationResult, SkillsConfig, + PluginSkillInfo, } from './core/types.js'; export { SkillMetadataSchema } from './core/types.js'; @@ -29,3 +34,12 @@ export { FrontmatterParseError, } from './utils/frontmatter.js'; export type { FrontmatterResult } from './utils/frontmatter.js'; + +// Pattern matcher exports +export { + extractSkillPatterns, + isValidSkillName, + isValidNamespacedSkillName, + parseNamespacedSkillName, +} from './utils/pattern-matcher.js'; +export type { SkillPattern, PatternMatchResult } from './utils/pattern-matcher.js'; diff --git a/src/agents/codemie-code/skills/sync/SkillSync.ts b/src/agents/codemie-code/skills/sync/SkillSync.ts new file mode 100644 index 00000000..92d72cab --- /dev/null +++ b/src/agents/codemie-code/skills/sync/SkillSync.ts @@ -0,0 +1,243 @@ +import { readdir, stat, mkdir, copyFile, readFile, writeFile, rm } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { logger } from '../../../../utils/logger.js'; +import { SkillManager } from '../core/SkillManager.js'; +import type { SkillSource } from '../core/types.js'; + +/** + * Options for skill sync operation + */ +export interface SyncOptions { + /** Project working directory */ + cwd?: string; + /** Remove orphaned synced skills */ + clean?: boolean; + /** Preview without writing */ + dryRun?: boolean; +} + +/** + * Result of a sync operation + */ +export interface SyncResult { + /** Skills copied/updated */ + synced: string[]; + /** Skills unchanged (up-to-date) */ + skipped: string[]; + /** Skills cleaned up (--clean) */ + removed: string[]; + /** Non-fatal errors */ + errors: string[]; +} + +/** + * Entry in the sync manifest for a single skill + */ +interface SyncManifestEntry { + /** Skill source type */ + source: SkillSource; + /** Absolute path to source SKILL.md */ + sourcePath: string; + /** ISO timestamp of last sync */ + syncedAt: string; + /** Source file mtime at sync time (ms) */ + mtimeMs: number; +} + +/** + * Sync manifest stored at .claude/skills/.codemie-sync.json + */ +interface SyncManifest { + /** ISO timestamp of last sync */ + lastSync: string; + /** Synced skills keyed by skill name */ + skills: Record; +} + +/** + * Sync CodeMie-managed skills into Claude Code's .claude/skills/ directory. + * + * Uses SkillManager to discover ALL skills from ALL sources (project, global, + * plugin, mode-specific) and copies entire skill directories (SKILL.md + + * reference files) to the target. Tracks synced state via a manifest file + * for incremental sync (mtime comparison). + */ +export class SkillSync { + private readonly MANIFEST_FILE = '.codemie-sync.json'; + + /** + * Sync all CodeMie skills to .claude/skills/ + */ + async syncToClaude(options: SyncOptions = {}): Promise { + const { cwd = process.cwd(), clean = false, dryRun = false } = options; + + const result: SyncResult = { + synced: [], + skipped: [], + removed: [], + errors: [], + }; + + const targetBase = join(cwd, '.claude', 'skills'); + + try { + // Discover all skills + const manager = SkillManager.getInstance(); + manager.reload(); // Force fresh discovery + const skills = await manager.listSkills({ cwd }); + + if (skills.length === 0) { + logger.debug('[SkillSync] No skills discovered, nothing to sync'); + return result; + } + + // Ensure target directory exists + if (!dryRun) { + await mkdir(targetBase, { recursive: true }); + } + + // Load existing manifest + const manifest = await this.loadManifest(targetBase); + + // Track which skill names we process (for clean-up) + const processedNames = new Set(); + + // Sync each skill + for (const skill of skills) { + // Use namespaced name for plugin skills, regular name otherwise + const skillName = skill.pluginInfo?.fullSkillName ?? skill.metadata.name; + processedNames.add(skillName); + + const sourceDir = dirname(skill.filePath); + const targetDir = join(targetBase, skillName); + + try { + // Check if sync is needed (mtime comparison) + const sourceStat = await stat(skill.filePath); + const manifestEntry = manifest.skills[skillName]; + + if ( + manifestEntry && + manifestEntry.sourcePath === skill.filePath && + manifestEntry.mtimeMs === sourceStat.mtimeMs + ) { + result.skipped.push(skillName); + continue; + } + + if (dryRun) { + result.synced.push(skillName); + continue; + } + + // Copy entire source directory to target + await this.copyDirectory(sourceDir, targetDir); + + // Update manifest entry + manifest.skills[skillName] = { + source: skill.source, + sourcePath: skill.filePath, + syncedAt: new Date().toISOString(), + mtimeMs: sourceStat.mtimeMs, + }; + + result.synced.push(skillName); + logger.debug(`[SkillSync] Synced: ${skillName} (${skill.source})`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + result.errors.push(`${skillName}: ${msg}`); + logger.debug(`[SkillSync] Error syncing ${skillName}: ${msg}`); + } + } + + // Clean orphaned skills (in manifest but no longer discovered) + if (clean) { + for (const name of Object.keys(manifest.skills)) { + if (!processedNames.has(name)) { + const orphanDir = join(targetBase, name); + + if (dryRun) { + result.removed.push(name); + continue; + } + + try { + if (existsSync(orphanDir)) { + await rm(orphanDir, { recursive: true, force: true }); + } + delete manifest.skills[name]; + result.removed.push(name); + logger.debug(`[SkillSync] Removed orphaned skill: ${name}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + result.errors.push(`clean ${name}: ${msg}`); + } + } + } + } + + // Write updated manifest + if (!dryRun) { + manifest.lastSync = new Date().toISOString(); + await this.saveManifest(targetBase, manifest); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + result.errors.push(`sync failed: ${msg}`); + logger.debug(`[SkillSync] Sync failed: ${msg}`); + } + + return result; + } + + /** + * Load sync manifest from target directory + */ + private async loadManifest(targetBase: string): Promise { + const manifestPath = join(targetBase, this.MANIFEST_FILE); + + try { + if (existsSync(manifestPath)) { + const content = await readFile(manifestPath, 'utf-8'); + return JSON.parse(content) as SyncManifest; + } + } catch (error) { + logger.debug(`[SkillSync] Could not load manifest, starting fresh: ${error}`); + } + + return { lastSync: '', skills: {} }; + } + + /** + * Save sync manifest to target directory + */ + private async saveManifest(targetBase: string, manifest: SyncManifest): Promise { + const manifestPath = join(targetBase, this.MANIFEST_FILE); + await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); + } + + /** + * Recursively copy a directory + */ + private async copyDirectory(src: string, dest: string): Promise { + await mkdir(dest, { recursive: true }); + + const entries = await readdir(src, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = join(src, entry.name); + const destPath = join(dest, entry.name); + + if (entry.isDirectory()) { + // Skip hidden dirs and node_modules + if (entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + await this.copyDirectory(srcPath, destPath); + } else { + await copyFile(srcPath, destPath); + } + } + } +} diff --git a/src/agents/codemie-code/skills/utils/pattern-matcher.test.ts b/src/agents/codemie-code/skills/utils/pattern-matcher.test.ts index a3324d02..9f2fe0dd 100644 --- a/src/agents/codemie-code/skills/utils/pattern-matcher.test.ts +++ b/src/agents/codemie-code/skills/utils/pattern-matcher.test.ts @@ -3,7 +3,12 @@ */ import { describe, it, expect } from 'vitest'; -import { extractSkillPatterns, isValidSkillName } from './pattern-matcher.js'; +import { + extractSkillPatterns, + isValidSkillName, + isValidNamespacedSkillName, + parseNamespacedSkillName, +} from './pattern-matcher.js'; describe('extractSkillPatterns', () => { it('should detect single pattern at start', () => { @@ -13,6 +18,8 @@ describe('extractSkillPatterns', () => { expect(result.patterns).toHaveLength(1); expect(result.patterns[0]).toEqual({ name: 'mr', + fullName: 'mr', + namespace: undefined, position: 0, args: undefined, raw: '/mr', @@ -26,6 +33,8 @@ describe('extractSkillPatterns', () => { expect(result.patterns).toHaveLength(1); expect(result.patterns[0]).toEqual({ name: 'commit', + fullName: 'commit', + namespace: undefined, position: 15, args: 'this', raw: '/commit this', @@ -38,7 +47,9 @@ describe('extractSkillPatterns', () => { expect(result.hasPatterns).toBe(true); expect(result.patterns).toHaveLength(2); expect(result.patterns[0].name).toBe('commit'); + expect(result.patterns[0].fullName).toBe('commit'); expect(result.patterns[1].name).toBe('mr'); + expect(result.patterns[1].fullName).toBe('mr'); }); it('should detect pattern with arguments', () => { @@ -135,6 +146,7 @@ describe('extractSkillPatterns', () => { expect(result.hasPatterns).toBe(true); expect(result.patterns[0].name).toBe('my-skill-name'); + expect(result.patterns[0].fullName).toBe('my-skill-name'); }); it('should detect skills with numbers', () => { @@ -169,6 +181,60 @@ describe('extractSkillPatterns', () => { expect(result.originalMessage).toBe(originalMessage); }); + + // Namespaced skill tests + describe('namespaced skills', () => { + it('should detect namespaced skill pattern', () => { + const result = extractSkillPatterns('/gitlab-tools:mr'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toEqual({ + name: 'mr', + fullName: 'gitlab-tools:mr', + namespace: 'gitlab-tools', + position: 0, + args: undefined, + raw: '/gitlab-tools:mr', + }); + }); + + it('should detect namespaced skill with arguments', () => { + const result = extractSkillPatterns('/plugin-name:skill-name arg1 arg2'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns[0].name).toBe('skill-name'); + expect(result.patterns[0].namespace).toBe('plugin-name'); + expect(result.patterns[0].fullName).toBe('plugin-name:skill-name'); + expect(result.patterns[0].args).toBe('arg1 arg2'); + }); + + it('should detect both simple and namespaced patterns', () => { + const result = extractSkillPatterns('/commit and /gitlab:mr'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns).toHaveLength(2); + expect(result.patterns[0].fullName).toBe('commit'); + expect(result.patterns[0].namespace).toBeUndefined(); + expect(result.patterns[1].fullName).toBe('gitlab:mr'); + expect(result.patterns[1].namespace).toBe('gitlab'); + }); + + it('should deduplicate by full name', () => { + const result = extractSkillPatterns('/gitlab:mr and /gitlab:mr again'); + + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0].fullName).toBe('gitlab:mr'); + }); + + it('should NOT exclude built-in commands when namespaced', () => { + const result = extractSkillPatterns('/my-plugin:help'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns[0].name).toBe('help'); + expect(result.patterns[0].namespace).toBe('my-plugin'); + }); + }); }); describe('isValidSkillName', () => { @@ -220,3 +286,60 @@ describe('isValidSkillName', () => { expect(isValidSkillName('-skill')).toBe(false); }); }); + +describe('isValidNamespacedSkillName', () => { + it('should accept simple skill name', () => { + expect(isValidNamespacedSkillName('commit')).toBe(true); + }); + + it('should accept namespaced skill name', () => { + expect(isValidNamespacedSkillName('gitlab:mr')).toBe(true); + }); + + it('should accept complex namespaced name', () => { + expect(isValidNamespacedSkillName('my-plugin-123:skill-name-456')).toBe(true); + }); + + it('should reject invalid namespace', () => { + expect(isValidNamespacedSkillName('Invalid:skill')).toBe(false); + }); + + it('should reject invalid skill in namespace', () => { + expect(isValidNamespacedSkillName('plugin:Invalid')).toBe(false); + }); + + it('should reject multiple colons', () => { + expect(isValidNamespacedSkillName('a:b:c')).toBe(false); + }); + + it('should reject empty parts', () => { + expect(isValidNamespacedSkillName(':skill')).toBe(false); + expect(isValidNamespacedSkillName('plugin:')).toBe(false); + }); +}); + +describe('parseNamespacedSkillName', () => { + it('should parse simple name', () => { + const result = parseNamespacedSkillName('commit'); + expect(result).toEqual({ + name: 'commit', + namespace: undefined, + }); + }); + + it('should parse namespaced name', () => { + const result = parseNamespacedSkillName('gitlab:mr'); + expect(result).toEqual({ + name: 'mr', + namespace: 'gitlab', + }); + }); + + it('should parse complex namespaced name', () => { + const result = parseNamespacedSkillName('my-plugin:my-skill'); + expect(result).toEqual({ + name: 'my-skill', + namespace: 'my-plugin', + }); + }); +}); diff --git a/src/agents/codemie-code/skills/utils/pattern-matcher.ts b/src/agents/codemie-code/skills/utils/pattern-matcher.ts index d8646b42..f7d30ef2 100644 --- a/src/agents/codemie-code/skills/utils/pattern-matcher.ts +++ b/src/agents/codemie-code/skills/utils/pattern-matcher.ts @@ -2,6 +2,7 @@ * Pattern matcher for skill invocation detection * * Detects /skill-name patterns in user messages and extracts skill names. + * Supports namespaced skills: /plugin-name:skill-name * Excludes URLs and built-in CLI commands. */ @@ -17,6 +18,10 @@ export interface SkillPattern { args?: string; /** Full matched pattern (e.g., '/mr', '/commit -m "fix"') */ raw: string; + /** Plugin namespace (e.g., 'gitlab-tools' in '/gitlab-tools:mr') */ + namespace?: string; + /** Full namespaced name (e.g., 'gitlab-tools:mr' or just 'mr' if no namespace) */ + fullName: string; } /** @@ -48,11 +53,23 @@ const BUILT_IN_COMMANDS = new Set([ /** * Regex pattern for skill invocation * - * Matches: /skill-name with optional arguments + * Matches: + * - /skill-name with optional arguments + * - /plugin-name:skill-name with optional arguments (namespaced) + * * Excludes: URLs (negative lookbehind for : or alphanumeric before /) - * Format: /[a-z][a-z0-9-]{0,49} (lowercase, alphanumeric + hyphens, 1-50 chars) + * + * Format: + * - Simple: /[a-z][a-z0-9-]{0,49} + * - Namespaced: /[a-z][a-z0-9-]{0,49}:[a-z][a-z0-9-]{0,49} + * + * Groups: + * 1. First part (plugin name or skill name) + * 2. Second part after colon (skill name if namespaced) + * 3. Arguments */ -const SKILL_PATTERN = /(?(); + const seenFullNames = new Set(); // Reset regex state SKILL_PATTERN.lastIndex = 0; let match: RegExpExecArray | null; while ((match = SKILL_PATTERN.exec(message)) !== null) { - const [fullMatch, skillName, args] = match; + const [fullMatch, firstPart, secondPart, args] = match; const position = match.index; // Skip if this is part of a URL @@ -91,19 +120,27 @@ export function extractSkillPatterns(message: string): PatternMatchResult { continue; } - // Skip built-in commands - if (BUILT_IN_COMMANDS.has(skillName)) { + // Determine if namespaced or simple + const isNamespaced = secondPart !== undefined; + const namespace = isNamespaced ? firstPart : undefined; + const name = isNamespaced ? secondPart : firstPart; + const fullName = isNamespaced ? `${firstPart}:${secondPart}` : firstPart; + + // Skip built-in commands (only if not namespaced) + if (!isNamespaced && BUILT_IN_COMMANDS.has(name)) { continue; } - // Deduplicate by skill name (keep first occurrence) - if (seenNames.has(skillName)) { + // Deduplicate by full name (keep first occurrence) + if (seenFullNames.has(fullName)) { continue; } - seenNames.add(skillName); + seenFullNames.add(fullName); patterns.push({ - name: skillName, + name, + namespace, + fullName, position, args: args?.trim(), raw: fullMatch, @@ -132,3 +169,41 @@ export function extractSkillPatterns(message: string): PatternMatchResult { export function isValidSkillName(name: string): boolean { return /^[a-z][a-z0-9-]{0,49}$/.test(name); } + +/** + * Validate a namespaced skill name (plugin:skill) + * + * @param fullName - Full skill name (can be 'skill' or 'plugin:skill') + * @returns True if valid, false otherwise + */ +export function isValidNamespacedSkillName(fullName: string): boolean { + // Check for namespaced format + if (fullName.includes(':')) { + const parts = fullName.split(':'); + if (parts.length !== 2) { + return false; + } + return isValidSkillName(parts[0]) && isValidSkillName(parts[1]); + } + + // Simple skill name + return isValidSkillName(fullName); +} + +/** + * Parse a namespaced skill name + * + * @param fullName - Full skill name (can be 'skill' or 'plugin:skill') + * @returns Parsed namespace and skill name + */ +export function parseNamespacedSkillName(fullName: string): { + namespace?: string; + name: string; +} { + if (fullName.includes(':')) { + const [namespace, name] = fullName.split(':'); + return { namespace, name }; + } + + return { name: fullName }; +} diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 8d695342..0e777c0d 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -79,19 +79,17 @@ export class AgentCLI { await this.handleHealthCheck(); }); - // Add init command for frameworks (skip for built-in agent) - if (this.adapter.name !== BUILTIN_AGENT_NAME) { - this.program - .command('init') - .description('Initialize development framework') - .argument('[framework]', 'Framework to initialize (speckit, bmad)') - .option('-l, --list', 'List available frameworks') - .option('--force', 'Force re-initialization') - .option('--project-name ', 'Project name for framework initialization') - .action(async (framework, options) => { - await this.handleInit(framework, options); - }); - } + // Add init command for frameworks + this.program + .command('init') + .description('Initialize development framework') + .argument('[framework]', 'Framework to initialize (speckit, bmad)') + .option('-l, --list', 'List available frameworks') + .option('--force', 'Force re-initialization') + .option('--project-name ', 'Project name for framework initialization') + .action(async (framework, options) => { + await this.handleInit(framework, options); + }); } /** diff --git a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts new file mode 100644 index 00000000..37f660e0 --- /dev/null +++ b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for CodeMieCodePlugin and CodeMieCodePluginMetadata + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock BaseAgentAdapter to avoid dependency tree +vi.mock('../../core/BaseAgentAdapter.js', () => ({ + BaseAgentAdapter: class { + metadata: any; + constructor(metadata: any) { + this.metadata = metadata; + } + }, +})); + +// Mock binary resolution +vi.mock('../codemie-opencode/codemie-opencode-binary.js', () => ({ + resolveCodemieOpenCodeBinary: vi.fn(() => '/mock/bin/codemie'), +})); + +// Mock logger +vi.mock('../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Mock installGlobal +vi.mock('../../../utils/processes.js', () => ({ + installGlobal: vi.fn(), +})); + +// Use vi.hoisted for mock functions referenced in vi.mock factories +const { mockDiscoverSessionsCC, mockProcessSessionCC } = vi.hoisted(() => ({ + mockDiscoverSessionsCC: vi.fn(), + mockProcessSessionCC: vi.fn(), +})); + +// Mock OpenCodeSessionAdapter - must use function (not arrow) to support `new` +vi.mock('../opencode/opencode.session.js', () => ({ + OpenCodeSessionAdapter: vi.fn(function () { + return { + discoverSessions: mockDiscoverSessionsCC, + processSession: mockProcessSessionCC, + }; + }), +})); + +// Mock getModelConfig +vi.mock('../opencode/opencode-model-configs.js', () => ({ + getModelConfig: vi.fn(() => ({ + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + })), +})); + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const { existsSync } = await import('fs'); +const { resolveCodemieOpenCodeBinary } = await import('../codemie-opencode/codemie-opencode-binary.js'); +const { installGlobal } = await import('../../../utils/processes.js'); +const { logger } = await import('../../../utils/logger.js'); +const { OpenCodeSessionAdapter } = await import('../opencode/opencode.session.js'); +const { CodeMieCodePlugin, CodeMieCodePluginMetadata, BUILTIN_AGENT_NAME } = await import('../codemie-code.plugin.js'); +const { CodemieOpenCodePluginMetadata } = await import('../codemie-opencode/codemie-opencode.plugin.js'); + +const mockExistsSync = vi.mocked(existsSync); +const mockResolve = vi.mocked(resolveCodemieOpenCodeBinary); +const mockInstallGlobal = vi.mocked(installGlobal); + +describe('CodeMieCodePluginMetadata', () => { + it('has name codemie-code', () => { + expect(CodeMieCodePluginMetadata.name).toBe('codemie-code'); + expect(BUILTIN_AGENT_NAME).toBe('codemie-code'); + }); + + it('reuses beforeRun from CodemieOpenCodePluginMetadata', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.beforeRun).toBe( + CodemieOpenCodePluginMetadata.lifecycle!.beforeRun + ); + }); + + it('reuses enrichArgs from CodemieOpenCodePluginMetadata', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.enrichArgs).toBe( + CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs + ); + }); + + it('has custom onSessionEnd', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.onSessionEnd).toBeDefined(); + expect(CodeMieCodePluginMetadata.lifecycle!.onSessionEnd).not.toBe( + CodemieOpenCodePluginMetadata.lifecycle!.onSessionEnd + ); + }); +}); + +describe('CodeMieCodePlugin', () => { + let plugin: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + plugin = new CodeMieCodePlugin(); + }); + + describe('isInstalled', () => { + it('returns true when binary resolved and exists', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + + const result = await plugin.isInstalled(); + expect(result).toBe(true); + }); + + it('returns false when resolveCodemieOpenCodeBinary returns null', async () => { + mockResolve.mockReturnValue(null); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + + it('returns false when path resolved but file missing', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(false); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + }); + + describe('install', () => { + it('calls installGlobal with the correct package name', async () => { + await plugin.install(); + expect(mockInstallGlobal).toHaveBeenCalledWith('@codemieai/codemie-opencode'); + }); + }); + + describe('getSessionAdapter', () => { + it('returns an OpenCodeSessionAdapter instance', () => { + const adapter = plugin.getSessionAdapter(); + expect(adapter).toBeDefined(); + expect(OpenCodeSessionAdapter).toHaveBeenCalled(); + }); + }); + + describe('getExtensionInstaller', () => { + it('returns undefined', () => { + const installer = plugin.getExtensionInstaller(); + expect(installer).toBeUndefined(); + }); + }); +}); + +describe('CodeMieCodePlugin onSessionEnd', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses clientType codemie-code in context', async () => { + mockDiscoverSessionsCC.mockResolvedValue([ + { sessionId: 'test-session', filePath: '/path/to/session' }, + ]); + mockProcessSessionCC.mockResolvedValue({ success: true, totalRecords: 5 }); + + const env = { + CODEMIE_SESSION_ID: 'test-123', + CODEMIE_BASE_URL: 'http://localhost:3000', + } as unknown as NodeJS.ProcessEnv; + + await CodeMieCodePluginMetadata.lifecycle!.onSessionEnd!(0, env); + + expect(mockProcessSessionCC).toHaveBeenCalledWith( + '/path/to/session', + 'test-123', + expect.objectContaining({ clientType: 'codemie-code' }) + ); + }); + + it('skips when no CODEMIE_SESSION_ID', async () => { + const env = {} as NodeJS.ProcessEnv; + + await CodeMieCodePluginMetadata.lifecycle!.onSessionEnd!(0, env); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('skipping') + ); + }); + + it('handles errors gracefully', async () => { + mockDiscoverSessionsCC.mockRejectedValue(new Error('adapter error')); + + const env = { + CODEMIE_SESSION_ID: 'test-123', + } as unknown as NodeJS.ProcessEnv; + + // Should not throw + await CodeMieCodePluginMetadata.lifecycle!.onSessionEnd!(0, env); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed') + ); + }); +}); diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index a8a79030..603631e6 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -1,201 +1,163 @@ -import { AgentMetadata, AgentAdapter } from '../core/types.js'; +import type { AgentMetadata } from '../core/types.js'; +import { existsSync } from 'fs'; import { logger } from '../../utils/logger.js'; -import { CodeMieCode } from '../codemie-code/index.js'; -import { loadCodeMieConfig } from '../codemie-code/config.js'; -import { join } from 'path'; -import { readFileSync } from 'fs'; -import { getDirname } from '../../utils/paths.js'; -import { getRandomWelcomeMessage, getRandomGoodbyeMessage } from '../../utils/goodbye-messages.js'; -import { renderProfileInfo } from '../../utils/profile.js'; -import chalk from 'chalk'; +import { BaseAgentAdapter } from '../core/BaseAgentAdapter.js'; +import type { SessionAdapter } from '../core/session/BaseSessionAdapter.js'; +import type { BaseExtensionInstaller } from '../core/extension/BaseExtensionInstaller.js'; +import { installGlobal } from '../../utils/processes.js'; +import { OpenCodeSessionAdapter } from './opencode/opencode.session.js'; +import { resolveCodemieOpenCodeBinary } from './codemie-opencode/codemie-opencode-binary.js'; +import { CodemieOpenCodePluginMetadata } from './codemie-opencode/codemie-opencode.plugin.js'; /** * Built-in agent name constant - single source of truth */ export const BUILTIN_AGENT_NAME = 'codemie-code'; +// Resolve binary at load time, fallback to 'codemie' +const resolvedBinary = resolveCodemieOpenCodeBinary(); + /** - * CodeMie-Code Plugin Metadata + * CodeMie Code Plugin Metadata + * + * Reuses lifecycle hooks from CodemieOpenCodePluginMetadata (beforeRun, enrichArgs) + * since both agents wrap the same OpenCode binary. + * Only onSessionEnd is customized to use clientType: 'codemie-code' for metrics. */ export const CodeMieCodePluginMetadata: AgentMetadata = { name: BUILTIN_AGENT_NAME, - displayName: 'CodeMie Native', - description: 'Built-in LangGraph-based coding assistant', + displayName: 'CodeMie Code', + description: 'CodeMie Code - AI coding assistant', - npmPackage: null, // Built-in - cliCommand: null, // No external CLI + npmPackage: '@codemieai/codemie-opencode', + cliCommand: resolvedBinary || 'codemie', - envMapping: {}, + dataPaths: { + home: '.opencode' + }, - supportedProviders: ['ollama', 'litellm', 'ai-run-sso'], - blockedModelPatterns: [], + envMapping: { + baseUrl: [], + apiKey: [], + model: [] + }, - // Built-in agent doesn't use proxy (handles auth internally) - ssoConfig: undefined, + supportedProviders: ['litellm', 'ai-run-sso'], - customOptions: [ - { flags: '--task ', description: 'Execute a single task and exit' }, - { flags: '--debug', description: 'Enable debug logging' }, - { flags: '--plan', description: 'Enable planning mode' }, - { flags: '--plan-only', description: 'Plan without execution' } - ], + ssoConfig: { enabled: true, clientType: 'codemie-code' }, - isBuiltIn: true, + lifecycle: { + beforeRun: CodemieOpenCodePluginMetadata.lifecycle!.beforeRun, + enrichArgs: CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs, - // Custom handler for built-in agent - customRunHandler: async (args, options) => { - try { - // Check if we have a valid configuration first - const workingDir = process.cwd(); + async onSessionEnd(exitCode: number, env: NodeJS.ProcessEnv) { + const sessionId = env.CODEMIE_SESSION_ID; - let config; - try { - config = await loadCodeMieConfig(workingDir); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error('Configuration loading failed:', errorMessage); - throw new Error(`CodeMie configuration required: ${errorMessage}. Please run: codemie setup`); + if (!sessionId) { + logger.debug('[codemie-code] No CODEMIE_SESSION_ID in environment, skipping metrics processing'); + return; } - // Show welcome message with session info - // Read from environment variables (same as BaseAgentAdapter) - const profileName = process.env.CODEMIE_PROFILE_NAME || config.name || 'default'; - const provider = process.env.CODEMIE_PROVIDER || config.displayProvider || config.provider; - const model = process.env.CODEMIE_MODEL || config.model; - const codeMieUrl = process.env.CODEMIE_URL || config.codeMieUrl; - const sessionId = process.env.CODEMIE_SESSION_ID || 'n/a'; - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - console.log( - renderProfileInfo({ - profile: profileName, - provider, - model, - codeMieUrl, - agent: BUILTIN_AGENT_NAME, - cliVersion, - sessionId - }) - ); - - // Show random welcome message - console.log(chalk.cyan.bold(getRandomWelcomeMessage())); - console.log(''); // Empty line for spacing - - const codeMie = new CodeMieCode(workingDir); - await codeMie.initialize({ debug: options.debug as boolean | undefined }); - try { - if (options.task) { - await codeMie.executeTaskWithUI(options.task as string, { - planMode: (options.plan || options.planOnly) as boolean | undefined, - planOnly: options.planOnly as boolean | undefined - }); - } else if (args.length > 0) { - await codeMie.executeTaskWithUI(args.join(' ')); - if (!options.planOnly) { - await codeMie.startInteractive(); - } - } else { - await codeMie.startInteractive(); + logger.info(`[codemie-code] Processing session metrics before SessionSyncer (code=${exitCode})`); + + const adapter = new OpenCodeSessionAdapter(CodeMieCodePluginMetadata); + + const sessions = await adapter.discoverSessions({ maxAgeDays: 1 }); + + if (sessions.length === 0) { + logger.warn('[codemie-code] No recent OpenCode sessions found for processing'); + return; } - } finally { - // Show goodbye message - console.log(''); // Empty line for spacing - console.log(chalk.cyan.bold(getRandomGoodbyeMessage())); - console.log(''); // Spacing before powered by - console.log(chalk.cyan('Powered by AI/Run CodeMie CLI')); - console.log(''); // Empty line for spacing - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to run CodeMie Native: ${errorMessage}`); - } - }, - customHealthCheck: async () => { - const result = await CodeMieCode.testConnection(process.cwd()); + const latestSession = sessions[0]; + logger.debug(`[codemie-code] Processing latest session: ${latestSession.sessionId}`); + logger.debug(`[codemie-code] OpenCode session ID: ${latestSession.sessionId}`); + logger.debug(`[codemie-code] CodeMie session ID: ${sessionId}`); + + const context = { + sessionId, + apiBaseUrl: env.CODEMIE_BASE_URL || '', + cookies: '', + clientType: 'codemie-code', + version: env.CODEMIE_CLI_VERSION || '1.0.0', + dryRun: false + }; + + const result = await adapter.processSession( + latestSession.filePath, + sessionId, + context + ); + + if (result.success) { + logger.info(`[codemie-code] Metrics processing complete: ${result.totalRecords} records processed`); + logger.info('[codemie-code] Metrics written to JSONL - SessionSyncer will sync to v1/metrics next'); + } else { + logger.warn(`[codemie-code] Metrics processing had failures: ${result.failedProcessors.join(', ')}`); + } - if (result.success) { - logger.success('CodeMie Native is healthy'); - console.log(`Provider: ${result.provider || 'unknown'}`); - console.log(`Model: ${result.model || 'unknown'}`); - return true; - } else { - logger.error('Health check failed:', result.error); - return false; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`[codemie-code] Failed to process session metrics automatically: ${errorMessage}`); + } } } }; /** - * CodeMie-Code Adapter - * Custom implementation for built-in agent + * CodeMie Code Plugin + * Wraps the @codemieai/codemie-opencode binary as the built-in agent */ -export class CodeMieCodePlugin implements AgentAdapter { - name = BUILTIN_AGENT_NAME; - displayName = 'CodeMie Native'; - description = 'CodeMie Native Agent - Built-in LangGraph-based coding assistant'; +export class CodeMieCodePlugin extends BaseAgentAdapter { + private sessionAdapter: SessionAdapter; - async install(): Promise { - logger.info('CodeMie Native is built-in and already available'); - } - - async uninstall(): Promise { - logger.info('CodeMie Native is built-in and cannot be uninstalled'); + constructor() { + super(CodeMieCodePluginMetadata); + this.sessionAdapter = new OpenCodeSessionAdapter(CodeMieCodePluginMetadata); } + /** + * Check if the whitelabel binary is available. + * Uses existsSync on the resolved binary path instead of PATH lookup. + */ async isInstalled(): Promise { - return true; - } + const binaryPath = resolveCodemieOpenCodeBinary(); - async run(args: string[], envOverrides?: Record): Promise { - // Set environment variables if provided - if (envOverrides) { - Object.assign(process.env, envOverrides); + if (!binaryPath) { + logger.debug('[codemie-code] Whitelabel binary not found in node_modules'); + logger.debug('[codemie-code] Install with: npm i -g @codemieai/codemie-opencode'); + return false; } - // Parse options from args - const options: Record = {}; - const filteredArgs: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--task' && args[i + 1]) { - options.task = args[i + 1]; - i++; // Skip next arg - } else if (arg === '--debug') { - options.debug = true; - } else if (arg === '--plan') { - options.plan = true; - } else if (arg === '--plan-only') { - options.planOnly = true; - } else { - filteredArgs.push(arg); - } - } + const installed = existsSync(binaryPath); - if (!options.debug && logger.isDebugMode()) { - options.debug = true; + if (!installed) { + logger.debug('[codemie-code] Binary path resolved but file not found'); + logger.debug('[codemie-code] Install with: codemie install codemie-code'); } - if (CodeMieCodePluginMetadata.customRunHandler) { - await CodeMieCodePluginMetadata.customRunHandler(filteredArgs, options, {}); - } + return installed; } - async getVersion(): Promise { - try { - const packageJsonPath = join(getDirname(import.meta.url), '../../../package.json'); - const packageJsonContent = readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(packageJsonContent) as { version: string }; - return `v${packageJson.version} (built-in)`; - } catch { - return 'unknown (built-in)'; - } + /** + * Install the whitelabel package globally. + */ + async install(): Promise { + await installGlobal('@codemieai/codemie-opencode'); + } + + /** + * Return session adapter for analytics. + */ + getSessionAdapter(): SessionAdapter { + return this.sessionAdapter; } - getMetricsConfig(): import('../core/types.js').AgentMetricsConfig | undefined { - // Built-in agent doesn't have specific metrics config + /** + * No extension installer needed. + */ + getExtensionInstaller(): BaseExtensionInstaller | undefined { return undefined; } } diff --git a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts new file mode 100644 index 00000000..af5c6316 --- /dev/null +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for resolveCodemieOpenCodeBinary() + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock logger +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +const { existsSync } = await import('fs'); +const { logger } = await import('../../../../utils/logger.js'); +const { resolveCodemieOpenCodeBinary } = await import('../codemie-opencode-binary.js'); + +const mockExistsSync = vi.mocked(existsSync); + +describe('resolveCodemieOpenCodeBinary', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + delete process.env.CODEMIE_OPENCODE_WL_BIN; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns env var path when CODEMIE_OPENCODE_WL_BIN is set and file exists', () => { + process.env.CODEMIE_OPENCODE_WL_BIN = '/custom/bin/codemie'; + mockExistsSync.mockImplementation((p) => p === '/custom/bin/codemie'); + + const result = resolveCodemieOpenCodeBinary(); + + expect(result).toBe('/custom/bin/codemie'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('CODEMIE_OPENCODE_WL_BIN') + ); + }); + + it('warns and continues resolution when CODEMIE_OPENCODE_WL_BIN set but file missing', () => { + process.env.CODEMIE_OPENCODE_WL_BIN = '/missing/bin/codemie'; + mockExistsSync.mockReturnValue(false); + + const result = resolveCodemieOpenCodeBinary(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('CODEMIE_OPENCODE_WL_BIN') + ); + // Falls through to null since no node_modules binaries exist either + expect(result).toBeNull(); + }); + + it('skips env check when CODEMIE_OPENCODE_WL_BIN not set', () => { + mockExistsSync.mockReturnValue(false); + + resolveCodemieOpenCodeBinary(); + + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('CODEMIE_OPENCODE_WL_BIN') + ); + }); + + it('returns platform binary when found in node_modules', () => { + mockExistsSync.mockImplementation((p) => { + const ps = String(p); + // Platform package dir exists and binary file exists + return ps.includes('node_modules/@codemieai/codemie-opencode-') && ps.includes('/bin/'); + }); + + const result = resolveCodemieOpenCodeBinary(); + + if (result) { + expect(result).toContain('bin'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('platform binary') + ); + } + // If platform package is not found (node_modules doesn't exist), result may be null + }); + + it('returns wrapper binary when platform package not available', () => { + mockExistsSync.mockImplementation((p) => { + const ps = String(p); + // Platform-specific package NOT found, but wrapper package found + if (ps.includes('codemie-opencode-darwin') || ps.includes('codemie-opencode-linux') || ps.includes('codemie-opencode-windows')) { + return false; + } + return ps.includes('node_modules/@codemieai/codemie-opencode') && ps.includes('/bin/'); + }); + + const result = resolveCodemieOpenCodeBinary(); + + if (result) { + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('wrapper binary') + ); + } + }); + + it('returns null when no binary found anywhere', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveCodemieOpenCodeBinary(); + + expect(result).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('not found') + ); + }); +}); diff --git a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts new file mode 100644 index 00000000..9528b6ae --- /dev/null +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts @@ -0,0 +1,476 @@ +/** + * Tests for CodemieOpenCodePluginMetadata lifecycle hooks + * (beforeRun, enrichArgs, onSessionEnd) + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock BaseAgentAdapter +vi.mock('../../../core/BaseAgentAdapter.js', () => ({ + BaseAgentAdapter: class { + metadata: any; + constructor(metadata: any) { + this.metadata = metadata; + } + }, +})); + +// Mock binary resolution +vi.mock('../codemie-opencode-binary.js', () => ({ + resolveCodemieOpenCodeBinary: vi.fn(() => '/mock/bin/codemie'), +})); + +// Mock logger +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Mock installGlobal +vi.mock('../../../../utils/processes.js', () => ({ + installGlobal: vi.fn(), + detectGitBranch: vi.fn(() => Promise.resolve('main')), +})); + +// Mock getModelConfig +vi.mock('../../opencode/opencode-model-configs.js', () => ({ + getModelConfig: vi.fn(() => ({ + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + displayName: 'GPT-5.2 (Dec 2025)', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + })), +})); + +// Use vi.hoisted() so mock functions are available in hoisted vi.mock() factories +const { mockDiscoverSessions, mockProcessSession } = vi.hoisted(() => ({ + mockDiscoverSessions: vi.fn().mockResolvedValue([]), + mockProcessSession: vi.fn().mockResolvedValue({ success: true, totalRecords: 0 }), +})); + +// Mock OpenCodeSessionAdapter - must use function (not arrow) to support `new` +vi.mock('../../opencode/opencode.session.js', () => ({ + OpenCodeSessionAdapter: vi.fn(function () { + return { + discoverSessions: mockDiscoverSessions, + processSession: mockProcessSession, + }; + }), +})); + +// Mock SessionStore (dynamic import in ensureSessionFile) +vi.mock('../../../core/session/SessionStore.js', () => ({ + SessionStore: vi.fn(() => ({ + loadSession: vi.fn(() => Promise.resolve(null)), + saveSession: vi.fn(() => Promise.resolve()), + })), +})); + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(() => true), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const { writeFileSync } = await import('fs'); +const { logger } = await import('../../../../utils/logger.js'); +const { getModelConfig } = await import('../../opencode/opencode-model-configs.js'); +const { SessionStore } = await import('../../../core/session/SessionStore.js'); +const { CodemieOpenCodePluginMetadata } = await import('../codemie-opencode.plugin.js'); + +const mockGetModelConfig = vi.mocked(getModelConfig); + +const DEFAULT_MODEL_CONFIG = { + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + displayName: 'GPT-5.2 (Dec 2025)', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, +}; + +type AgentConfig = { model?: string }; + +describe('CodemieOpenCodePluginMetadata lifecycle', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + // Reset mock return value to default (clearAllMocks doesn't reset implementations) + mockGetModelConfig.mockReturnValue(DEFAULT_MODEL_CONFIG as any); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('beforeRun', () => { + const beforeRun = CodemieOpenCodePluginMetadata.lifecycle!.beforeRun!; + + it('creates session file when CODEMIE_SESSION_ID present', async () => { + const env: any = { CODEMIE_SESSION_ID: 'sess-123' }; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + const SessionStoreCtor = vi.mocked(SessionStore); + expect(SessionStoreCtor).toHaveBeenCalled(); + }); + + it('skips session file when no CODEMIE_SESSION_ID', async () => { + const env: any = {}; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + const SessionStoreCtor = vi.mocked(SessionStore); + expect(SessionStoreCtor).not.toHaveBeenCalled(); + }); + + it('logs warning and continues when ensureSessionFile fails', async () => { + vi.mocked(SessionStore).mockImplementationOnce(() => { + throw new Error('session store error'); + }); + + const env: any = { CODEMIE_SESSION_ID: 'sess-123' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + // ensureSessionFile has its own try/catch that calls logger.warn + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to create session file'), + expect.anything() + ); + // Should still return env (not throw) + expect(result).toBeDefined(); + }); + + it('returns env unchanged when no CODEMIE_BASE_URL', async () => { + const env: any = {}; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result).toBe(env); + expect(result.OPENCODE_CONFIG_CONTENT).toBeUndefined(); + }); + + it('warns and returns env unchanged for invalid CODEMIE_BASE_URL', async () => { + const env: any = { CODEMIE_BASE_URL: 'ftp://invalid' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid CODEMIE_BASE_URL'), + expect.anything() + ); + expect(result.OPENCODE_CONFIG_CONTENT).toBeUndefined(); + }); + + it('sets OPENCODE_CONFIG_CONTENT for valid http:// URL', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result.OPENCODE_CONFIG_CONTENT).toBeDefined(); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + expect(parsed.enabled_providers).toEqual(['codemie-proxy']); + expect(parsed.provider['codemie-proxy']).toBeDefined(); + }); + + it('sets OPENCODE_CONFIG_CONTENT for valid https:// URL', async () => { + const env: any = { CODEMIE_BASE_URL: 'https://proxy.example.com' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result.OPENCODE_CONFIG_CONTENT).toBeDefined(); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + expect(parsed.provider['codemie-proxy'].options.baseURL).toBe('https://proxy.example.com/'); + }); + + it('uses CODEMIE_MODEL env var for model selection', async () => { + const env: any = { + CODEMIE_BASE_URL: 'http://localhost:8080', + CODEMIE_MODEL: 'claude-opus-4-20250514', + }; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + expect(mockGetModelConfig).toHaveBeenCalledWith('claude-opus-4-20250514'); + }); + + it('falls back to config.model when no CODEMIE_MODEL', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = { model: 'custom-model' }; + + await beforeRun(env, config as any); + + expect(mockGetModelConfig).toHaveBeenCalledWith('custom-model'); + }); + + it('falls back to default gpt-5-2-2025-12-11 when no model specified', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + expect(mockGetModelConfig).toHaveBeenCalledWith('gpt-5-2-2025-12-11'); + }); + + it('generates valid config JSON with required fields', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + + expect(parsed).toHaveProperty('enabled_providers'); + expect(parsed).toHaveProperty('provider.codemie-proxy'); + expect(parsed).toHaveProperty('defaults'); + expect(parsed.defaults.model).toContain('codemie-proxy/'); + }); + + it('writes temp file when config exceeds 32KB', async () => { + // Return config with large headers to exceed MAX_ENV_SIZE + mockGetModelConfig.mockReturnValue({ + id: 'big-model', + name: 'big-model', + displayName: 'Big Model', + family: 'big', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + providerOptions: { + headers: { 'X-Large': 'x'.repeat(40000) }, + }, + } as any); + + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result.OPENCODE_CONFIG).toBeDefined(); + expect(result.OPENCODE_CONFIG_CONTENT).toBeUndefined(); + expect(writeFileSync).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('exceeds env var limit'), + expect.anything() + ); + }); + + it('strips displayName and providerOptions from model config in output', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + const modelConfig = Object.values(parsed.provider['codemie-proxy'].models)[0] as any; + + expect(modelConfig.displayName).toBeUndefined(); + expect(modelConfig.providerOptions).toBeUndefined(); + }); + + it('uses CODEMIE_TIMEOUT when no providerOptions.timeout', async () => { + const env: any = { + CODEMIE_BASE_URL: 'http://localhost:8080', + CODEMIE_TIMEOUT: '300', + }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + + expect(parsed.provider['codemie-proxy'].options.timeout).toBe(300000); + }); + }); + + describe('enrichArgs', () => { + const enrichArgs = CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs!; + const config: AgentConfig = {}; + + it('passes through known subcommands', () => { + for (const sub of ['run', 'chat', 'config', 'init', 'help', 'version']) { + const result = enrichArgs([sub, '--flag'], config as any); + expect(result[0]).toBe(sub); + } + }); + + it('transforms --task "fix bug" to ["run", "fix bug"]', () => { + const result = enrichArgs(['--task', 'fix bug'], config as any); + expect(result).toEqual(['run', 'fix bug']); + }); + + it('strips -m/--message when --task present', () => { + const result = enrichArgs(['-m', 'hello', '--task', 'fix bug'], config as any); + expect(result).not.toContain('-m'); + expect(result).not.toContain('hello'); + expect(result).toContain('fix bug'); + }); + + it('returns empty array for empty args', () => { + const result = enrichArgs([], config as any); + expect(result).toEqual([]); + }); + + it('returns unchanged when --task is last arg (no value)', () => { + const result = enrichArgs(['--task'], config as any); + expect(result).toEqual(['--task']); + }); + + it('returns unchanged for unknown args without --task', () => { + const result = enrichArgs(['--verbose', '--debug'], config as any); + expect(result).toEqual(['--verbose', '--debug']); + }); + + it('preserves other args alongside --task transformation', () => { + const result = enrichArgs(['--verbose', '--task', 'fix bug'], config as any); + expect(result).toContain('run'); + expect(result).toContain('--verbose'); + expect(result).toContain('fix bug'); + }); + }); + + describe('onSessionEnd', () => { + const onSessionEnd = CodemieOpenCodePluginMetadata.lifecycle!.onSessionEnd!; + + it('skips when no CODEMIE_SESSION_ID', async () => { + const env: any = {}; + + await onSessionEnd(0, env); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('skipping') + ); + }); + + it('processes latest session and logs success with record count', async () => { + mockDiscoverSessions.mockResolvedValue([ + { sessionId: 'oc-session', filePath: '/sessions/file.jsonl' }, + ]); + mockProcessSession.mockResolvedValue({ + success: true, + totalRecords: 42, + }); + + const env: any = { + CODEMIE_SESSION_ID: 'test-sess-id', + CODEMIE_BASE_URL: 'http://localhost:3000', + }; + + await onSessionEnd(0, env); + + expect(mockDiscoverSessions).toHaveBeenCalledWith({ maxAgeDays: 1 }); + expect(mockProcessSession).toHaveBeenCalledWith( + '/sessions/file.jsonl', + 'test-sess-id', + expect.objectContaining({ sessionId: 'test-sess-id' }) + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('42 records') + ); + }); + + it('warns when no sessions discovered', async () => { + mockDiscoverSessions.mockResolvedValue([]); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + await onSessionEnd(0, env); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('No recent') + ); + }); + + it('warns on partial failures with failedProcessors list', async () => { + mockDiscoverSessions.mockResolvedValue([ + { sessionId: 's1', filePath: '/path' }, + ]); + mockProcessSession.mockResolvedValue({ + success: false, + failedProcessors: ['tokenizer', 'cost-calculator'], + }); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + await onSessionEnd(0, env); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('failures') + ); + }); + + it('logs error but does not throw on processing error', async () => { + mockDiscoverSessions.mockRejectedValue(new Error('discover failed')); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + // Should not throw + await onSessionEnd(0, env); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed') + ); + }); + + it('uses clientType codemie-opencode in context', async () => { + mockDiscoverSessions.mockResolvedValue([ + { sessionId: 's1', filePath: '/path' }, + ]); + mockProcessSession.mockResolvedValue({ success: true, totalRecords: 1 }); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + await onSessionEnd(0, env); + + expect(mockProcessSession).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ clientType: 'codemie-opencode' }) + ); + }); + }); +}); diff --git a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts new file mode 100644 index 00000000..51fefd0e --- /dev/null +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for CodemieOpenCodePlugin class + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock BaseAgentAdapter to avoid dependency tree +vi.mock('../../../core/BaseAgentAdapter.js', () => ({ + BaseAgentAdapter: class { + metadata: any; + constructor(metadata: any) { + this.metadata = metadata; + } + }, +})); + +// Mock binary resolution +vi.mock('../codemie-opencode-binary.js', () => ({ + resolveCodemieOpenCodeBinary: vi.fn(() => '/mock/bin/codemie'), +})); + +// Mock logger +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Mock installGlobal +vi.mock('../../../../utils/processes.js', () => ({ + installGlobal: vi.fn(), +})); + +// Mock OpenCodeSessionAdapter - must use function (not arrow) to support `new` +vi.mock('../../opencode/opencode.session.js', () => ({ + OpenCodeSessionAdapter: vi.fn(function () { + return { + discoverSessions: vi.fn(), + processSession: vi.fn(), + }; + }), +})); + +// Mock getModelConfig +vi.mock('../../opencode/opencode-model-configs.js', () => ({ + getModelConfig: vi.fn(() => ({ + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + })), +})); + +// Mock fs for existsSync +vi.mock('fs', () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const { existsSync } = await import('fs'); +const { resolveCodemieOpenCodeBinary } = await import('../codemie-opencode-binary.js'); +const { installGlobal } = await import('../../../../utils/processes.js'); +const { OpenCodeSessionAdapter } = await import('../../opencode/opencode.session.js'); +const { CodemieOpenCodePlugin } = await import('../codemie-opencode.plugin.js'); + +const mockExistsSync = vi.mocked(existsSync); +const mockResolve = vi.mocked(resolveCodemieOpenCodeBinary); +const mockInstallGlobal = vi.mocked(installGlobal); + +describe('CodemieOpenCodePlugin', () => { + let plugin: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + plugin = new CodemieOpenCodePlugin(); + }); + + describe('isInstalled', () => { + it('returns true when binary resolved and exists', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + + const result = await plugin.isInstalled(); + expect(result).toBe(true); + }); + + it('returns false when resolveCodemieOpenCodeBinary returns null', async () => { + mockResolve.mockReturnValue(null); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + + it('returns false when path resolved but file missing', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(false); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + }); + + describe('install', () => { + it('calls installGlobal with the correct package name', async () => { + await plugin.install(); + expect(mockInstallGlobal).toHaveBeenCalledWith('@codemieai/codemie-opencode'); + }); + }); + + describe('getSessionAdapter', () => { + it('returns an OpenCodeSessionAdapter instance', () => { + const adapter = plugin.getSessionAdapter(); + expect(adapter).toBeDefined(); + expect(OpenCodeSessionAdapter).toHaveBeenCalled(); + }); + }); + + describe('getExtensionInstaller', () => { + it('returns undefined', () => { + const installer = plugin.getExtensionInstaller(); + expect(installer).toBeUndefined(); + }); + }); +}); diff --git a/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts b/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts new file mode 100644 index 00000000..3d31782b --- /dev/null +++ b/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts @@ -0,0 +1,104 @@ +import { existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { logger } from '../../../utils/logger.js'; + +/** + * Platform-specific package name mapping for @codemieai/codemie-opencode. + * The wrapper package lists these as optionalDependencies; npm only downloads + * the one matching the current platform. + */ +function getPlatformPackage(): string | null { + const platform = process.platform; + const arch = process.arch; + + const platformMap: Record> = { + darwin: { + x64: '@codemieai/codemie-opencode-darwin-x64', + arm64: '@codemieai/codemie-opencode-darwin-arm64', + }, + linux: { + x64: '@codemieai/codemie-opencode-linux-x64', + arm64: '@codemieai/codemie-opencode-linux-arm64', + }, + win32: { + x64: '@codemieai/codemie-opencode-windows-x64', + arm64: '@codemieai/codemie-opencode-windows-arm64', + }, + }; + + return platformMap[platform]?.[arch] ?? null; +} + +/** + * Walk up from a starting directory looking for a node_modules directory + * that contains the given package. + */ +function findPackageInNodeModules(startDir: string, packageName: string): string | null { + let current = startDir; + + while (true) { + const candidate = join(current, 'node_modules', packageName); + if (existsSync(candidate)) { + return candidate; + } + + const parent = dirname(current); + if (parent === current) break; // reached filesystem root + current = parent; + } + + return null; +} + +/** + * Resolve the bundled @codemieai/codemie-opencode binary from node_modules. + * + * Resolution order: + * 1. CODEMIE_OPENCODE_WL_BIN env var override (escape hatch) + * 2. Platform-specific binary from node_modules/@codemieai/codemie-opencode-{platform}-{arch}/bin/codemie + * 3. Wrapper package binary from node_modules/@codemieai/codemie-opencode/bin/codemie + * 4. Fallback: null (binary not found) + */ +export function resolveCodemieOpenCodeBinary(): string | null { + // 1. Environment variable override + const envBin = process.env.CODEMIE_OPENCODE_WL_BIN; + if (envBin) { + if (existsSync(envBin)) { + logger.debug(`[codemie-opencode] Using binary from CODEMIE_OPENCODE_WL_BIN: ${envBin}`); + return envBin; + } + logger.warn(`[codemie-opencode] CODEMIE_OPENCODE_WL_BIN set but binary not found: ${envBin}`); + } + + // Start searching from this module's directory + const moduleDir = dirname(fileURLToPath(import.meta.url)); + const binName = process.platform === 'win32' ? 'codemie.exe' : 'codemie'; + + // 2. Try platform-specific package first (direct binary, no wrapper) + const platformPkg = getPlatformPackage(); + if (platformPkg) { + const platformDir = findPackageInNodeModules(moduleDir, platformPkg); + if (platformDir) { + const platformBin = join(platformDir, 'bin', binName); + if (existsSync(platformBin)) { + logger.debug(`[codemie-opencode] Resolved platform binary: ${platformBin}`); + return platformBin; + } + } + } + + // 3. Fall back to wrapper package binary + const wrapperDir = findPackageInNodeModules(moduleDir, '@codemieai/codemie-opencode'); + if (wrapperDir) { + const wrapperBin = join(wrapperDir, 'bin', binName); + if (existsSync(wrapperBin)) { + logger.debug(`[codemie-opencode] Resolved wrapper binary: ${wrapperBin}`); + return wrapperBin; + } + } + + // 4. Not found + logger.debug('[codemie-opencode] Binary not found in node_modules'); + return null; +} diff --git a/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts new file mode 100644 index 00000000..b462ded7 --- /dev/null +++ b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts @@ -0,0 +1,359 @@ +import type { AgentMetadata, AgentConfig } from '../../core/types.js'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { writeFileSync, unlinkSync, existsSync } from 'fs'; +import { logger } from '../../../utils/logger.js'; +import { getModelConfig } from '../opencode/opencode-model-configs.js'; +import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; +import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; +import { installGlobal } from '../../../utils/processes.js'; +import { OpenCodeSessionAdapter } from '../opencode/opencode.session.js'; +import { resolveCodemieOpenCodeBinary } from './codemie-opencode-binary.js'; + +const OPENCODE_SUBCOMMANDS = ['run', 'chat', 'config', 'init', 'help', 'version']; + +// Environment variable size limit (conservative - varies by platform) +// Linux: ~128KB per var, Windows: ~32KB total env block +const MAX_ENV_SIZE = 32 * 1024; + +// Track temp config files for cleanup on process exit +const tempConfigFiles: string[] = []; +let cleanupRegistered = false; + +/** + * Register process exit handler for temp file cleanup (best effort) + * Only registers once, even if beforeRun is called multiple times + */ +function registerCleanupHandler(): void { + if (cleanupRegistered) return; + cleanupRegistered = true; + + process.on('exit', () => { + for (const file of tempConfigFiles) { + try { + unlinkSync(file); + logger.debug(`[codemie-opencode] Cleaned up temp config: ${file}`); + } catch { + // Ignore cleanup errors - file may already be deleted + } + } + }); +} + +/** + * Write config to temp file as fallback when env var size exceeded + * Returns the temp file path + */ +function writeConfigToTempFile(configJson: string): string { + const configPath = join( + tmpdir(), + `codemie-opencode-wl-config-${process.pid}-${Date.now()}.json` + ); + writeFileSync(configPath, configJson, 'utf-8'); + tempConfigFiles.push(configPath); + registerCleanupHandler(); + return configPath; +} + +/** + * Ensure session metadata file exists for SessionSyncer + * Creates or updates the session file in ~/.codemie/sessions/ + */ +async function ensureSessionFile(sessionId: string, env: NodeJS.ProcessEnv): Promise { + try { + const { SessionStore } = await import('../../core/session/SessionStore.js'); + const sessionStore = new SessionStore(); + + const existing = await sessionStore.loadSession(sessionId); + if (existing) { + logger.debug('[codemie-opencode] Session file already exists'); + return; + } + + const agentName = env.CODEMIE_AGENT || 'codemie-opencode'; + const provider = env.CODEMIE_PROVIDER || 'unknown'; + const project = env.CODEMIE_PROJECT; + const workingDirectory = process.cwd(); + + let gitBranch: string | undefined; + try { + const { detectGitBranch } = await import('../../../utils/processes.js'); + gitBranch = await detectGitBranch(workingDirectory); + } catch { + // Git detection optional + } + + const estimatedStartTime = Date.now() - 2000; + + const session = { + sessionId, + agentName, + provider, + ...(project && { project }), + startTime: estimatedStartTime, + workingDirectory, + ...(gitBranch && { gitBranch }), + status: 'completed' as const, + activeDurationMs: 0, + correlation: { + status: 'matched' as const, + agentSessionId: 'unknown', + retryCount: 0 + } + }; + + await sessionStore.saveSession(session); + logger.debug('[codemie-opencode] Created session metadata file'); + + } catch (error) { + logger.warn('[codemie-opencode] Failed to create session file:', error); + } +} + +// Resolve binary at load time, fallback to 'codemie' +const resolvedBinary = resolveCodemieOpenCodeBinary(); + +/** + * Environment variable contract between the umbrella CLI and whitelabel binary. + * + * The umbrella CLI orchestrates everything (proxy, auth, metrics, session sync) + * and spawns the whitelabel binary as a child process. The whitelabel knows + * nothing about SSO, cookies, or metrics — it just sees an OpenAI-compatible + * endpoint at localhost. + * + * Flow: BaseAgentAdapter.run() → setupProxy() → beforeRun hook → spawn(binary) + * + * | Env Var | Set By | Consumed By | Purpose | + * |--------------------------|----------------------|----------------------|------------------------------------------------| + * | OPENCODE_CONFIG_CONTENT | beforeRun hook | Whitelabel config.ts | Full provider config JSON (proxy URL, models) | + * | OPENCODE_CONFIG | beforeRun (fallback) | Whitelabel config.ts | Temp file path when JSON exceeds env var limit | + * | CODEMIE_SESSION_ID | BaseAgentAdapter | onSessionEnd hook | Session ID for metrics correlation | + * | CODEMIE_AGENT | BaseAgentAdapter | Lifecycle helpers | Agent name ('codemie-opencode') | + * | CODEMIE_PROVIDER | Config loader | setupProxy() | Provider name (e.g., 'ai-run-sso') | + * | CODEMIE_BASE_URL | setupProxy() | beforeRun hook | Proxy URL (http://localhost:{port}) | + * | CODEMIE_MODEL | Config/CLI | beforeRun hook | Selected model ID | + * | CODEMIE_PROJECT | SSO exportEnvVars | Session metadata | CodeMie project name | + */ +export const CodemieOpenCodePluginMetadata: AgentMetadata = { + name: 'codemie-opencode', + displayName: 'CodeMie OpenCode', + description: 'CodeMie OpenCode - whitelabel AI coding assistant', + npmPackage: '@codemieai/codemie-opencode', + cliCommand: resolvedBinary || 'codemie', + dataPaths: { + home: '.opencode' + // Session storage follows XDG conventions, handled by opencode.paths.ts + }, + envMapping: { + baseUrl: [], + apiKey: [], + model: [] + }, + supportedProviders: ['litellm', 'ai-run-sso'], + ssoConfig: { enabled: true, clientType: 'codemie-opencode' }, + + lifecycle: { + async beforeRun(env: NodeJS.ProcessEnv, config: AgentConfig) { + const sessionId = env.CODEMIE_SESSION_ID; + if (sessionId) { + try { + logger.debug('[codemie-opencode] Creating session metadata file before startup'); + await ensureSessionFile(sessionId, env); + logger.debug('[codemie-opencode] Session metadata file ready for SessionSyncer'); + } catch (error) { + logger.error('[codemie-opencode] Failed to create session file in beforeRun', { error }); + } + } + + const proxyUrl = env.CODEMIE_BASE_URL; + + if (!proxyUrl) { + return env; + } + + if (!proxyUrl.startsWith('http://') && !proxyUrl.startsWith('https://')) { + logger.warn(`Invalid CODEMIE_BASE_URL format: ${proxyUrl}`, { agent: 'codemie-opencode' }); + return env; + } + + const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; + const modelConfig = getModelConfig(selectedModel); + + const { displayName: _displayName, providerOptions, ...opencodeModelConfig } = modelConfig; + + const openCodeConfig = { + enabled_providers: ['codemie-proxy'], + provider: { + 'codemie-proxy': { + npm: '@ai-sdk/openai-compatible', + name: 'CodeMie SSO', + options: { + baseURL: `${proxyUrl}/`, + apiKey: 'proxy-handled', + timeout: providerOptions?.timeout || + parseInt(env.CODEMIE_TIMEOUT || '600') * 1000, + ...(providerOptions?.headers && { + headers: providerOptions.headers + }) + }, + models: { + [modelConfig.id]: opencodeModelConfig + } + } + }, + defaults: { + model: `codemie-proxy/${modelConfig.id}` + } + }; + + const configJson = JSON.stringify(openCodeConfig); + + if (configJson.length > MAX_ENV_SIZE) { + logger.warn(`Config size (${configJson.length} bytes) exceeds env var limit (${MAX_ENV_SIZE}), using temp file fallback`, { + agent: 'codemie-opencode' + }); + + const configPath = writeConfigToTempFile(configJson); + logger.debug(`[codemie-opencode] Wrote config to temp file: ${configPath}`); + + env.OPENCODE_CONFIG = configPath; + return env; + } + + env.OPENCODE_CONFIG_CONTENT = configJson; + return env; + }, + + enrichArgs: (args: string[], _config: AgentConfig) => { + if (args.length > 0 && OPENCODE_SUBCOMMANDS.includes(args[0])) { + return args; + } + + const taskIndex = args.indexOf('--task'); + if (taskIndex !== -1 && taskIndex < args.length - 1) { + const taskValue = args[taskIndex + 1]; + const otherArgs = args.filter((arg, i, arr) => { + if (i === taskIndex || i === taskIndex + 1) return false; + if (arg === '-m' || arg === '--message') return false; + if (i > 0 && (arr[i - 1] === '-m' || arr[i - 1] === '--message')) return false; + return true; + }); + return ['run', ...otherArgs, taskValue]; + } + return args; + }, + + async onSessionEnd(exitCode: number, env: NodeJS.ProcessEnv) { + const sessionId = env.CODEMIE_SESSION_ID; + + if (!sessionId) { + logger.debug('[codemie-opencode] No CODEMIE_SESSION_ID in environment, skipping metrics processing'); + return; + } + + try { + logger.info(`[codemie-opencode] Processing session metrics before SessionSyncer (code=${exitCode})`); + + const adapter = new OpenCodeSessionAdapter(CodemieOpenCodePluginMetadata); + + const sessions = await adapter.discoverSessions({ maxAgeDays: 1 }); + + if (sessions.length === 0) { + logger.warn('[codemie-opencode] No recent OpenCode sessions found for processing'); + return; + } + + const latestSession = sessions[0]; + logger.debug(`[codemie-opencode] Processing latest session: ${latestSession.sessionId}`); + logger.debug(`[codemie-opencode] OpenCode session ID: ${latestSession.sessionId}`); + logger.debug(`[codemie-opencode] CodeMie session ID: ${sessionId}`); + + const context = { + sessionId, + apiBaseUrl: env.CODEMIE_BASE_URL || '', + cookies: '', + clientType: 'codemie-opencode', + version: env.CODEMIE_CLI_VERSION || '1.0.0', + dryRun: false + }; + + const result = await adapter.processSession( + latestSession.filePath, + sessionId, + context + ); + + if (result.success) { + logger.info(`[codemie-opencode] Metrics processing complete: ${result.totalRecords} records processed`); + logger.info('[codemie-opencode] Metrics written to JSONL - SessionSyncer will sync to v1/metrics next'); + } else { + logger.warn(`[codemie-opencode] Metrics processing had failures: ${result.failedProcessors.join(', ')}`); + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`[codemie-opencode] Failed to process session metrics automatically: ${errorMessage}`); + } + } + } +}; + +/** + * CodeMie OpenCode whitelabel agent plugin + * Wraps the @codemieai/codemie-opencode binary distributed via npm + */ +export class CodemieOpenCodePlugin extends BaseAgentAdapter { + private sessionAdapter: SessionAdapter; + + constructor() { + super(CodemieOpenCodePluginMetadata); + this.sessionAdapter = new OpenCodeSessionAdapter(CodemieOpenCodePluginMetadata); + } + + /** + * Check if the whitelabel binary is available. + * Uses existsSync on the resolved binary path instead of PATH lookup. + */ + async isInstalled(): Promise { + const binaryPath = resolveCodemieOpenCodeBinary(); + + if (!binaryPath) { + logger.debug('[codemie-opencode] Whitelabel binary not found in node_modules'); + logger.debug('[codemie-opencode] Install with: npm i -g @codemieai/codemie-opencode'); + return false; + } + + const installed = existsSync(binaryPath); + + if (!installed) { + logger.debug('[codemie-opencode] Binary path resolved but file not found'); + logger.debug('[codemie-opencode] Install with: codemie install codemie-opencode'); + } + + return installed; + } + + /** + * Install the whitelabel package globally. + * The package's postinstall.mjs handles platform binary resolution. + */ + async install(): Promise { + await installGlobal('@codemieai/codemie-opencode'); + } + + /** + * Return session adapter for analytics. + * Reuses OpenCodeSessionAdapter since storage paths are identical. + */ + getSessionAdapter(): SessionAdapter { + return this.sessionAdapter; + } + + /** + * No extension installer needed. + */ + getExtensionInstaller(): BaseExtensionInstaller | undefined { + return undefined; + } +} diff --git a/src/agents/plugins/codemie-opencode/index.ts b/src/agents/plugins/codemie-opencode/index.ts new file mode 100644 index 00000000..845ba89d --- /dev/null +++ b/src/agents/plugins/codemie-opencode/index.ts @@ -0,0 +1,2 @@ +export { CodemieOpenCodePlugin, CodemieOpenCodePluginMetadata } from './codemie-opencode.plugin.js'; +export { resolveCodemieOpenCodeBinary } from './codemie-opencode-binary.js'; diff --git a/src/agents/plugins/opencode/opencode-model-configs.ts b/src/agents/plugins/opencode/opencode-model-configs.ts index 59a5880c..e848daa4 100644 --- a/src/agents/plugins/opencode/opencode-model-configs.ts +++ b/src/agents/plugins/opencode/opencode-model-configs.ts @@ -189,13 +189,223 @@ export const OPENCODE_MODEL_CONFIGS: Record = { context: 128000, output: 16384 } + }, + + // ── Claude Models ────────────────────────────────────────────────── + 'claude-4-5-sonnet': { + id: 'claude-4-5-sonnet', + name: 'Claude 4.5 Sonnet', + displayName: 'Claude 4.5 Sonnet', + family: 'claude-4', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-04-01', + release_date: '2025-09-29', + last_updated: '2025-09-29', + open_weights: false, + cost: { + input: 3, + output: 15, + cache_read: 0.3 + }, + limit: { + context: 200000, + output: 16384 + } + }, + 'claude-sonnet-4-5-20250929': { + id: 'claude-sonnet-4-5-20250929', + name: 'Claude Sonnet 4.5 (Sep 2025)', + displayName: 'Claude Sonnet 4.5 (Sep 2025)', + family: 'claude-4', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-04-01', + release_date: '2025-09-29', + last_updated: '2025-09-29', + open_weights: false, + cost: { + input: 3, + output: 15, + cache_read: 0.3 + }, + limit: { + context: 200000, + output: 16384 + } + }, + 'claude-opus-4-6': { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + displayName: 'Claude Opus 4.6', + family: 'claude-4', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-05-01', + release_date: '2026-01-15', + last_updated: '2026-01-15', + open_weights: false, + cost: { + input: 15, + output: 75, + cache_read: 1.5 + }, + limit: { + context: 200000, + output: 32000 + } + }, + 'claude-haiku-4-5': { + id: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + displayName: 'Claude Haiku 4.5', + family: 'claude-4', + tool_call: true, + reasoning: false, + attachment: true, + temperature: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-04-01', + release_date: '2025-10-01', + last_updated: '2025-10-01', + open_weights: false, + cost: { + input: 0.8, + output: 4, + cache_read: 0.08 + }, + limit: { + context: 200000, + output: 8192 + } + }, + + // ── Gemini Models ────────────────────────────────────────────────── + 'gemini-2.5-pro': { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + displayName: 'Gemini 2.5 Pro', + family: 'gemini-2', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image', 'audio', 'video'], + output: ['text'] + }, + knowledge: '2025-03-01', + release_date: '2025-06-05', + last_updated: '2025-06-05', + open_weights: false, + cost: { + input: 1.25, + output: 10, + cache_read: 0.31 + }, + limit: { + context: 1048576, + output: 65536 + } + }, + 'gemini-2.5-flash': { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + displayName: 'Gemini 2.5 Flash', + family: 'gemini-2', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image', 'audio', 'video'], + output: ['text'] + }, + knowledge: '2025-03-01', + release_date: '2025-04-17', + last_updated: '2025-04-17', + open_weights: false, + cost: { + input: 0.15, + output: 0.6, + cache_read: 0.0375 + }, + limit: { + context: 1048576, + output: 65536 + } + } +}; + +/** + * Family-specific defaults for unknown model variants. + * Used by getModelConfig() when an exact match isn't found but + * the model ID prefix matches a known family. + */ +const MODEL_FAMILY_DEFAULTS: Record> = { + 'claude': { + family: 'claude-4', + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + limit: { context: 200000, output: 16384 } + }, + 'gemini': { + family: 'gemini-2', + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { input: ['text', 'image', 'audio', 'video'], output: ['text'] }, + limit: { context: 1048576, output: 65536 } + }, + 'gpt': { + family: 'gpt-5', + reasoning: true, + attachment: true, + temperature: false, + modalities: { input: ['text', 'image'], output: ['text'] }, + limit: { context: 400000, output: 128000 } } }; /** * Get model configuration with fallback for unknown models * - * @param modelId - Model identifier (e.g., 'gpt-5-2-2025-12-11') + * Resolution order: + * 1. Exact match in OPENCODE_MODEL_CONFIGS + * 2. Family-aware fallback using MODEL_FAMILY_DEFAULTS + * 3. Generic fallback with conservative defaults + * + * @param modelId - Model identifier (e.g., 'gpt-5-2-2025-12-11', 'claude-4-5-sonnet') * @returns Model configuration in OpenCode format * * Note: The returned config is used directly in OPENCODE_CONFIG_CONTENT @@ -207,34 +417,35 @@ export function getModelConfig(modelId: string): OpenCodeModelConfig { return config; } - // Fallback for unknown models - create minimal OpenCode-compatible config - // Extract family from model ID (e.g., "gpt-4o" -> "gpt-4") - const family = modelId.split('-').slice(0, 2).join('-') || modelId; + // Detect model family from prefix for smarter defaults + const familyPrefix = Object.keys(MODEL_FAMILY_DEFAULTS).find( + prefix => modelId.startsWith(prefix) + ); + const familyDefaults = familyPrefix ? MODEL_FAMILY_DEFAULTS[familyPrefix] : {}; + + // Extract family from model ID (e.g., "gpt-4o" -> "gpt-4", "claude-4-5-sonnet" -> "claude-4") + const family = familyDefaults.family + || modelId.split('-').slice(0, 2).join('-') + || modelId; + + const today = new Date().toISOString().split('T')[0]; return { id: modelId, name: modelId, displayName: modelId, family, - tool_call: true, // Assume tool support - reasoning: false, // Conservative default - attachment: false, - temperature: true, - modalities: { - input: ['text'], - output: ['text'] - }, - knowledge: new Date().toISOString().split('T')[0], // Use current date - release_date: new Date().toISOString().split('T')[0], - last_updated: new Date().toISOString().split('T')[0], + tool_call: true, + reasoning: familyDefaults.reasoning ?? false, + attachment: familyDefaults.attachment ?? false, + temperature: familyDefaults.temperature ?? true, + structured_output: familyDefaults.structured_output, + modalities: familyDefaults.modalities ?? { input: ['text'], output: ['text'] }, + knowledge: today, + release_date: today, + last_updated: today, open_weights: false, - cost: { - input: 0, - output: 0 - }, - limit: { - context: 128000, - output: 4096 - } + cost: { input: 0, output: 0 }, + limit: familyDefaults.limit ?? { context: 128000, output: 4096 } }; } diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 75fd82b8..84db2c65 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -3,6 +3,7 @@ import { ClaudeAcpPlugin } from './plugins/claude/claude-acp.plugin.js'; import { CodeMieCodePlugin } from './plugins/codemie-code.plugin.js'; import { GeminiPlugin } from './plugins/gemini/gemini.plugin.js'; import { OpenCodePlugin } from './plugins/opencode/index.js'; +import { CodemieOpenCodePlugin } from './plugins/codemie-opencode/index.js'; import { AgentAdapter, AgentAnalyticsAdapter } from './core/types.js'; // Re-export for backwards compatibility @@ -31,6 +32,7 @@ export class AgentRegistry { AgentRegistry.registerPlugin(new ClaudeAcpPlugin()); AgentRegistry.registerPlugin(new GeminiPlugin()); AgentRegistry.registerPlugin(new OpenCodePlugin()); + AgentRegistry.registerPlugin(new CodemieOpenCodePlugin()); AgentRegistry.initialized = true; } diff --git a/src/cli/commands/codemie-opencode-metrics.ts b/src/cli/commands/codemie-opencode-metrics.ts new file mode 100644 index 00000000..be8306ba --- /dev/null +++ b/src/cli/commands/codemie-opencode-metrics.ts @@ -0,0 +1,234 @@ +/** + * CodeMie OpenCode (Whitelabel) Metrics CLI Command + * + * Trigger CodeMie OpenCode session processing to extract metrics and write JSONL deltas. + * Mirrors opencode-metrics.ts but uses the whitelabel plugin metadata. + * + * Usage: + * codemie codemie-opencode-metrics --session # Process specific session + * codemie codemie-opencode-metrics --discover # Discover and process all unprocessed sessions + */ + +import { Command } from 'commander'; +import { join } from 'path'; +import { existsSync, readdirSync } from 'fs'; +import { logger } from '../../utils/logger.js'; +import chalk from 'chalk'; + +export function createCodemieOpencodeMetricsCommand(): Command { + const command = new Command('codemie-opencode-metrics'); + + command + .description('Process CodeMie OpenCode sessions and extract metrics to JSONL') + .option('-s, --session ', 'Process specific OpenCode session by ID') + .option('-d, --discover', 'Discover and process all unprocessed sessions') + .option('-v, --verbose', 'Show detailed processing output') + .action(async (options) => { + try { + const { getOpenCodeStoragePath } = await import('../../agents/plugins/opencode/opencode.paths.js'); + const storagePath = getOpenCodeStoragePath(); + + if (!storagePath) { + console.error(chalk.red('OpenCode storage not found.')); + console.log(chalk.dim('Expected location: ~/.local/share/opencode/storage/ (Linux)')); + console.log(chalk.dim(' ~/Library/Application Support/opencode/storage/ (macOS)')); + process.exit(1); + } + + if (options.verbose) { + console.log(chalk.dim(`Storage path: ${storagePath}`)); + } + + if (options.session) { + await processSpecificSession(storagePath, options.session, options.verbose); + } else if (options.discover) { + await discoverAndProcessSessions(storagePath, options.verbose); + } else { + console.log(chalk.yellow('Use --session or --discover to process CodeMie OpenCode sessions')); + console.log(''); + console.log(chalk.bold('Examples:')); + console.log(chalk.dim(' codemie codemie-opencode-metrics --session ses_abc123...')); + console.log(chalk.dim(' codemie codemie-opencode-metrics --discover')); + } + + } catch (error: unknown) { + logger.error('Failed to process CodeMie OpenCode metrics:', error); + console.error(chalk.red('Failed to process CodeMie OpenCode metrics')); + if (error instanceof Error) { + console.error(chalk.dim(error.message)); + } + process.exit(1); + } + }); + + return command; +} + +/** + * Process a specific OpenCode session by ID + */ +async function processSpecificSession( + storagePath: string, + sessionId: string, + verbose: boolean +): Promise { + const sessionDir = join(storagePath, 'session'); + + if (!existsSync(sessionDir)) { + console.error(chalk.red(`Session directory not found: ${sessionDir}`)); + process.exit(1); + } + + let projectDirs: string[]; + try { + projectDirs = readdirSync(sessionDir); + } catch { + console.error(chalk.red(`Failed to read session directory: ${sessionDir}`)); + process.exit(1); + } + + for (const projectId of projectDirs) { + const projectPath = join(sessionDir, projectId); + + try { + const { statSync } = await import('fs'); + if (!statSync(projectPath).isDirectory()) continue; + } catch { + continue; + } + + const sessionPath = join(projectPath, `${sessionId}.json`); + if (existsSync(sessionPath)) { + console.log(chalk.blue(`Found session: ${sessionId}`)); + if (verbose) { + console.log(chalk.dim(` Path: ${sessionPath}`)); + console.log(chalk.dim(` Project: ${projectId}`)); + } + + await processSession(storagePath, sessionPath, sessionId, verbose); + return; + } + } + + console.error(chalk.red(`Session ${sessionId} not found in OpenCode storage`)); + console.log(chalk.dim('Searched in: ' + sessionDir)); + process.exit(1); +} + +/** + * Discover and process all OpenCode sessions + */ +async function discoverAndProcessSessions( + storagePath: string, + verbose: boolean +): Promise { + const { OpenCodeSessionAdapter } = await import('../../agents/plugins/opencode/opencode.session.js'); + const { CodemieOpenCodePluginMetadata } = await import('../../agents/plugins/codemie-opencode/codemie-opencode.plugin.js'); + + const adapter = new OpenCodeSessionAdapter(CodemieOpenCodePluginMetadata); + + console.log(chalk.blue('Discovering CodeMie OpenCode sessions...')); + + const sessions = await adapter.discoverSessions({ maxAgeDays: 30 }); + + if (sessions.length === 0) { + console.log(chalk.yellow('No CodeMie OpenCode sessions found in the last 30 days')); + return; + } + + console.log(chalk.green(`Found ${sessions.length} session(s)`)); + console.log(''); + + let processedCount = 0; + let skippedCount = 0; + let errorCount = 0; + + for (const sessionDesc of sessions) { + if (verbose) { + console.log(chalk.dim(`Processing: ${sessionDesc.sessionId}`)); + } + + try { + const result = await processSession( + storagePath, + sessionDesc.filePath, + sessionDesc.sessionId, + verbose + ); + + if (result.skipped) { + skippedCount++; + } else { + processedCount++; + } + } catch (error) { + errorCount++; + if (verbose) { + console.error(chalk.red(` Error: ${error instanceof Error ? error.message : 'Unknown error'}`)); + } + } + } + + console.log(''); + console.log(chalk.bold('Summary:')); + console.log(` ${chalk.green('✓')} Processed: ${processedCount}`); + console.log(` ${chalk.yellow('○')} Skipped (recently processed): ${skippedCount}`); + if (errorCount > 0) { + console.log(` ${chalk.red('✗')} Errors: ${errorCount}`); + } +} + +/** + * Process a single session and write metrics + */ +async function processSession( + _storagePath: string, + sessionPath: string, + sessionId: string, + verbose: boolean +): Promise<{ skipped: boolean }> { + const { OpenCodeSessionAdapter } = await import('../../agents/plugins/opencode/opencode.session.js'); + const { CodemieOpenCodePluginMetadata } = await import('../../agents/plugins/codemie-opencode/codemie-opencode.plugin.js'); + + const adapter = new OpenCodeSessionAdapter(CodemieOpenCodePluginMetadata); + + const parsedSession = await adapter.parseSessionFile(sessionPath, sessionId); + + const { OpenCodeMetricsProcessor } = await import('../../agents/plugins/opencode/session/processors/opencode.metrics-processor.js'); + const processor = new OpenCodeMetricsProcessor(); + + const context = { + sessionId, + apiBaseUrl: '', + cookies: '', + clientType: 'codemie-opencode', + version: '1.0.0', + dryRun: false + }; + + const result = await processor.process(parsedSession, context); + + if (verbose) { + console.log(chalk.dim(` Result: ${result.message}`)); + if (result.metadata) { + console.log(chalk.dim(` Deltas written: ${result.metadata.deltasWritten || 0}`)); + if (result.metadata.deltasSkipped) { + console.log(chalk.dim(` Deltas skipped (dedup): ${result.metadata.deltasSkipped}`)); + } + } + } + + const skipped = result.metadata?.skippedReason === 'RECENTLY_PROCESSED'; + + if (!verbose) { + const status = skipped + ? chalk.yellow('○') + : (result.success ? chalk.green('✓') : chalk.red('✗')); + const deltasInfo = result.metadata?.deltasWritten + ? chalk.dim(` (${result.metadata.deltasWritten} deltas)`) + : ''; + console.log(`${status} ${sessionId}${deltasInfo}`); + } + + return { skipped }; +} diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 0b79d24f..06989607 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -169,6 +169,31 @@ async function handleSessionStart(event: SessionStartEvent, _rawInput: string, s await createSessionRecord(event, sessionId, config); // Send session start metrics (SSO provider only) await sendSessionStartMetrics(event, sessionId, event.session_id, config); + // Sync CodeMie skills to Claude Code (.claude/skills/) + await syncSkillsToClaude(event.cwd || process.cwd()); +} + +/** + * Sync CodeMie-managed skills to .claude/skills/ for Claude Code discovery. + * Non-blocking: errors are logged but do not affect session startup. + */ +async function syncSkillsToClaude(cwd: string): Promise { + try { + const { SkillSync } = await import( + '../../agents/codemie-code/skills/sync/SkillSync.js' + ); + const sync = new SkillSync(); + const result = await sync.syncToClaude({ cwd }); + if (result.synced.length > 0) { + logger.info(`[hook:SessionStart] Synced ${result.synced.length} skill(s) to .claude/skills/`); + } + if (result.errors.length > 0) { + logger.debug(`[hook:SessionStart] Skill sync errors: ${result.errors.join(', ')}`); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.debug(`[hook:SessionStart] Skill sync failed (non-blocking): ${msg}`); + } } diff --git a/src/cli/commands/plugin.ts b/src/cli/commands/plugin.ts new file mode 100644 index 00000000..487dfabc --- /dev/null +++ b/src/cli/commands/plugin.ts @@ -0,0 +1,529 @@ +import { Command } from 'commander'; +import Table from 'cli-table3'; +import chalk from 'chalk'; +import { logger } from '../../utils/logger.js'; +import { + PluginRegistry, + PluginInstaller, + MarketplaceClient, + MarketplaceRegistry, +} from '../../plugins/index.js'; +import type { LoadedPlugin } from '../../plugins/index.js'; + +/** + * Format plugin source with color + */ +function formatSource(plugin: LoadedPlugin): string { + if (plugin.isDevelopment) { + return chalk.yellow('dev'); + } + if (plugin.installedMeta?.source === 'marketplace') { + return chalk.green('marketplace'); + } + return chalk.blue('local'); +} + +/** + * Create plugin list command + */ +function createListCommand(): Command { + return new Command('list') + .description('List all installed plugins') + .option('--verbose', 'Show detailed information') + .action(async (options) => { + try { + const registry = PluginRegistry.getInstance(); + const plugins = await registry.getAllPlugins(); + + if (plugins.length === 0) { + console.log(chalk.yellow('\nNo plugins installed\n')); + console.log(chalk.white('Install plugins using:')); + console.log(` ${chalk.cyan('codemie plugin install ')}`); + console.log(''); + console.log(chalk.white('Search for plugins:')); + console.log(` ${chalk.cyan('codemie plugin search ')}`); + console.log(''); + return; + } + + // Create table + const table = new Table({ + head: [ + chalk.bold('Name'), + chalk.bold('Version'), + chalk.bold('Source'), + chalk.bold('Skills'), + chalk.bold('Description'), + ], + colWidths: [20, 12, 15, 8, 45], + wordWrap: true, + }); + + // Add rows + for (const plugin of plugins) { + table.push([ + chalk.bold(plugin.name), + plugin.manifest.version, + formatSource(plugin), + plugin.skillCount.toString(), + plugin.manifest.description, + ]); + } + + console.log(''); + console.log(chalk.bold(`Plugins (${plugins.length} installed)`)); + console.log(table.toString()); + + // Show verbose details + if (options.verbose) { + console.log(''); + for (const plugin of plugins) { + console.log(chalk.bold(`\n${plugin.name}:`)); + console.log(` Path: ${chalk.dim(plugin.path)}`); + if (plugin.skillNames.length > 0) { + console.log(` Skills: ${plugin.skillNames.join(', ')}`); + } + if (plugin.loadErrors.length > 0) { + console.log(chalk.red(` Errors: ${plugin.loadErrors.join(', ')}`)); + } + if (plugin.installedMeta) { + console.log(` Installed: ${plugin.installedMeta.installedAt}`); + if (plugin.installedMeta.repositoryUrl) { + console.log(` Repository: ${plugin.installedMeta.repositoryUrl}`); + } + } + } + } + + console.log(''); + } catch (error) { + logger.error('Failed to list plugins:', error); + process.exit(1); + } + }); +} + +/** + * Create plugin install command + */ +function createInstallCommand(): Command { + return new Command('install') + .description('Install a plugin from the marketplace') + .argument('', 'Plugin name to install') + .option('--dir ', 'Install from a local directory') + .option('--source ', 'Marketplace source ID') + .option('--force', 'Force reinstall if already installed') + .action(async (name, options) => { + try { + const installer = new PluginInstaller(); + + // Install from local directory + if (options.dir) { + console.log(chalk.white(`\nInstalling plugin from ${options.dir}...\n`)); + const result = await installer.installFromLocal(options.dir); + + if (result.success) { + console.log(chalk.green(`${result.message}`)); + console.log(chalk.dim(` Path: ${result.installedPath}`)); + } else { + console.log(chalk.red(`${result.message}`)); + process.exit(1); + } + console.log(''); + return; + } + + // Install from marketplace + console.log(chalk.white(`\nInstalling plugin '${name}' from marketplace...\n`)); + + const result = await installer.install(name, { + force: options.force, + sourceId: options.source, + }); + + if (result.success) { + console.log(chalk.green(`${result.message}`)); + console.log(chalk.dim(` Path: ${result.installedPath}`)); + + // Reload registry + await PluginRegistry.getInstance().reload(); + } else { + console.log(chalk.red(`${result.message}`)); + process.exit(1); + } + + console.log(''); + } catch (error) { + logger.error('Failed to install plugin:', error); + process.exit(1); + } + }); +} + +/** + * Create plugin uninstall command + */ +function createUninstallCommand(): Command { + return new Command('uninstall') + .description('Uninstall a plugin') + .argument('', 'Plugin name to uninstall') + .action(async (name) => { + try { + console.log(chalk.white(`\nUninstalling plugin '${name}'...\n`)); + + const installer = new PluginInstaller(); + const result = await installer.uninstall(name); + + if (result.success) { + console.log(chalk.green(`${result.message}`)); + + // Reload registry + await PluginRegistry.getInstance().reload(); + } else { + console.log(chalk.red(`${result.message}`)); + process.exit(1); + } + + console.log(''); + } catch (error) { + logger.error('Failed to uninstall plugin:', error); + process.exit(1); + } + }); +} + +/** + * Create plugin update command + */ +function createUpdateCommand(): Command { + return new Command('update') + .description('Update a plugin to the latest version') + .argument('[name]', 'Plugin name to update (updates all if not specified)') + .action(async (name) => { + try { + const installer = new PluginInstaller(); + + if (name) { + // Update specific plugin + console.log(chalk.white(`\nUpdating plugin '${name}'...\n`)); + const result = await installer.update(name); + + if (result.success) { + console.log(chalk.green(`${result.message}`)); + } else { + console.log(chalk.red(`${result.message}`)); + process.exit(1); + } + } else { + // Check for updates for all plugins + console.log(chalk.white('\nChecking for updates...\n')); + const updates = await installer.checkForUpdates(); + + const hasUpdates = updates.filter((u) => u.hasUpdate); + + if (hasUpdates.length === 0) { + console.log(chalk.green('All plugins are up to date')); + } else { + console.log(chalk.yellow(`Updates available for ${hasUpdates.length} plugin(s):\n`)); + + for (const update of hasUpdates) { + console.log( + ` ${chalk.bold(update.pluginName)}: ${update.currentVersion} -> ${chalk.green(update.latestVersion)}` + ); + } + + console.log(''); + console.log(chalk.dim('Run `codemie plugin update ` to update a specific plugin')); + } + } + + // Reload registry + await PluginRegistry.getInstance().reload(); + + console.log(''); + } catch (error) { + logger.error('Failed to update plugin:', error); + process.exit(1); + } + }); +} + +/** + * Create plugin search command + */ +function createSearchCommand(): Command { + return new Command('search') + .description('Search for plugins in the marketplace') + .argument('', 'Search query') + .action(async (query) => { + try { + console.log(chalk.white(`\nSearching for '${query}'...\n`)); + + const client = new MarketplaceClient(); + const registryClient = MarketplaceRegistry.getInstance(); + const sources = await registryClient.getEnabledSources(); + + const results = await client.search(query, sources); + + if (results.length === 0) { + console.log(chalk.yellow('No plugins found matching your query')); + console.log(''); + return; + } + + // Create table + const table = new Table({ + head: [ + chalk.bold('Name'), + chalk.bold('Version'), + chalk.bold('Source'), + chalk.bold('Description'), + ], + colWidths: [25, 12, 25, 40], + wordWrap: true, + }); + + // Add rows (limit to top 10) + const topResults = results.slice(0, 10); + for (const result of topResults) { + table.push([ + chalk.bold(result.plugin.name), + result.plugin.version, + chalk.dim(result.sourceName), + result.plugin.description, + ]); + } + + console.log(table.toString()); + + if (results.length > 10) { + console.log(chalk.dim(`\n ... and ${results.length - 10} more results`)); + } + + console.log(''); + console.log(chalk.dim('Install with: codemie plugin install ')); + console.log(''); + } catch (error) { + logger.error('Failed to search plugins:', error); + process.exit(1); + } + }); +} + +/** + * Create plugin info command + */ +function createInfoCommand(): Command { + return new Command('info') + .description('Show detailed information about a plugin') + .argument('', 'Plugin name') + .action(async (name) => { + try { + // First check if installed + const registry = PluginRegistry.getInstance(); + const installedPlugin = await registry.getPlugin(name); + + if (installedPlugin) { + console.log(''); + console.log(chalk.bold(`${installedPlugin.name} (installed)`)); + console.log(''); + console.log(` ${chalk.bold('Version:')} ${installedPlugin.manifest.version}`); + console.log(` ${chalk.bold('Description:')} ${installedPlugin.manifest.description}`); + + if (installedPlugin.manifest.author) { + console.log(` ${chalk.bold('Author:')} ${installedPlugin.manifest.author}`); + } + if (installedPlugin.manifest.license) { + console.log(` ${chalk.bold('License:')} ${installedPlugin.manifest.license}`); + } + if (installedPlugin.manifest.homepage) { + console.log(` ${chalk.bold('Homepage:')} ${installedPlugin.manifest.homepage}`); + } + + console.log(''); + console.log(` ${chalk.bold('Path:')} ${installedPlugin.path}`); + console.log(` ${chalk.bold('Skills:')} ${installedPlugin.skillCount}`); + + if (installedPlugin.skillNames.length > 0) { + console.log(` ${chalk.bold('Skill names:')} ${installedPlugin.skillNames.join(', ')}`); + } + + if (installedPlugin.installedMeta) { + console.log(''); + console.log(` ${chalk.bold('Installed:')} ${installedPlugin.installedMeta.installedAt}`); + console.log(` ${chalk.bold('Source:')} ${installedPlugin.installedMeta.source}`); + if (installedPlugin.installedMeta.repositoryUrl) { + console.log(` ${chalk.bold('Repository:')} ${installedPlugin.installedMeta.repositoryUrl}`); + } + } + + console.log(''); + return; + } + + // Search in marketplace + const client = new MarketplaceClient(); + const registryClient = MarketplaceRegistry.getInstance(); + const sources = await registryClient.getEnabledSources(); + + for (const source of sources) { + const plugin = await client.getPlugin(source, name); + if (plugin) { + console.log(''); + console.log(chalk.bold(`${plugin.name} (available)`)); + console.log(''); + console.log(` ${chalk.bold('Version:')} ${plugin.version}`); + console.log(` ${chalk.bold('Description:')} ${plugin.description}`); + + if (plugin.author) { + console.log(` ${chalk.bold('Author:')} ${plugin.author}`); + } + if (plugin.category) { + console.log(` ${chalk.bold('Category:')} ${plugin.category}`); + } + if (plugin.keywords && plugin.keywords.length > 0) { + console.log(` ${chalk.bold('Keywords:')} ${plugin.keywords.join(', ')}`); + } + + console.log(''); + console.log(chalk.dim(`Install with: codemie plugin install ${plugin.name}`)); + console.log(''); + return; + } + } + + console.log(chalk.yellow(`\nPlugin '${name}' not found\n`)); + process.exit(1); + } catch (error) { + logger.error('Failed to get plugin info:', error); + process.exit(1); + } + }); +} + +/** + * Create marketplace subcommand group + */ +function createMarketplaceCommand(): Command { + const marketplace = new Command('marketplace') + .description('Manage marketplace sources'); + + // marketplace list + marketplace.addCommand( + new Command('list') + .description('List configured marketplace sources') + .action(async () => { + try { + const registry = MarketplaceRegistry.getInstance(); + const sources = await registry.getSources(); + + if (sources.length === 0) { + console.log(chalk.yellow('\nNo marketplace sources configured\n')); + return; + } + + // Create table + const table = new Table({ + head: [ + chalk.bold('ID'), + chalk.bold('Name'), + chalk.bold('Repository'), + chalk.bold('Status'), + ], + colWidths: [25, 25, 35, 12], + }); + + for (const source of sources) { + table.push([ + source.isDefault ? chalk.bold(source.id) : source.id, + source.name, + source.repository, + source.enabled + ? chalk.green('enabled') + : chalk.dim('disabled'), + ]); + } + + console.log(''); + console.log(chalk.bold('Marketplace Sources')); + console.log(table.toString()); + console.log(''); + } catch (error) { + logger.error('Failed to list marketplace sources:', error); + process.exit(1); + } + }) + ); + + // marketplace add + marketplace.addCommand( + new Command('add') + .description('Add a new marketplace source') + .argument('', 'GitHub repository (owner/repo)') + .option('--id ', 'Custom source ID') + .option('--name ', 'Custom display name') + .option('--branch ', 'Branch to use (default: main)') + .action(async (repository, options) => { + try { + const registry = MarketplaceRegistry.getInstance(); + + // Generate ID from repository if not provided + const id = options.id || repository.replace('/', '-'); + const name = options.name || repository; + + await registry.addSource({ + id, + name, + type: 'github', + repository, + branch: options.branch, + enabled: true, + }); + + console.log(chalk.green(`\nAdded marketplace source '${id}'\n`)); + } catch (error) { + logger.error('Failed to add marketplace source:', error); + process.exit(1); + } + }) + ); + + // marketplace remove + marketplace.addCommand( + new Command('remove') + .description('Remove a marketplace source') + .argument('', 'Source ID to remove') + .action(async (id) => { + try { + const registry = MarketplaceRegistry.getInstance(); + await registry.removeSource(id); + + console.log(chalk.green(`\nRemoved marketplace source '${id}'\n`)); + } catch (error) { + logger.error('Failed to remove marketplace source:', error); + process.exit(1); + } + }) + ); + + return marketplace; +} + +/** + * Create main plugin command with subcommands + */ +export function createPluginCommand(): Command { + const plugin = new Command('plugin') + .description('Manage CodeMie plugins'); + + // Add subcommands + plugin.addCommand(createListCommand()); + plugin.addCommand(createInstallCommand()); + plugin.addCommand(createUninstallCommand()); + plugin.addCommand(createUpdateCommand()); + plugin.addCommand(createSearchCommand()); + plugin.addCommand(createInfoCommand()); + plugin.addCommand(createMarketplaceCommand()); + + return plugin; +} diff --git a/src/cli/commands/skill.ts b/src/cli/commands/skill.ts index 22b9d9ad..58eb019a 100644 --- a/src/cli/commands/skill.ts +++ b/src/cli/commands/skill.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import Table from 'cli-table3'; import chalk from 'chalk'; -import { SkillManager } from '../../agents/codemie-code/skills/index.js'; +import { SkillManager, SkillSync } from '../../agents/codemie-code/skills/index.js'; import { logger } from '../../utils/logger.js'; import type { Skill } from '../../agents/codemie-code/skills/index.js'; @@ -197,6 +197,94 @@ function createReloadCommand(): Command { }); } +/** + * Create skill sync command + */ +function createSyncCommand(): Command { + return new Command('sync') + .description('Sync CodeMie skills to a target agent (e.g., Claude Code)') + .option('--target ', 'Target agent to sync skills to', 'claude') + .option('--clean', 'Remove synced skills that no longer exist in CodeMie') + .option('--dry-run', 'Preview what would be synced without writing') + .option('--cwd ', 'Working directory for project skills', process.cwd()) + .action(async (options) => { + try { + if (options.target !== 'claude') { + console.error(chalk.red(`\nUnsupported target: ${options.target}. Only "claude" is currently supported.\n`)); + process.exit(1); + } + + const sync = new SkillSync(); + const result = await sync.syncToClaude({ + cwd: options.cwd, + clean: options.clean, + dryRun: options.dryRun, + }); + + const prefix = options.dryRun ? chalk.yellow('[dry-run] ') : ''; + + console.log(''); + console.log(chalk.bold(`${prefix}Skill Sync → .claude/skills/`)); + console.log(''); + + // Summary table + const table = new Table({ + head: [chalk.bold('Status'), chalk.bold('Count'), chalk.bold('Skills')], + colWidths: [15, 10, 60], + wordWrap: true, + }); + + if (result.synced.length > 0) { + table.push([ + chalk.green('Synced'), + result.synced.length.toString(), + result.synced.join(', '), + ]); + } + + if (result.skipped.length > 0) { + table.push([ + chalk.dim('Skipped'), + result.skipped.length.toString(), + result.skipped.join(', '), + ]); + } + + if (result.removed.length > 0) { + table.push([ + chalk.red('Removed'), + result.removed.length.toString(), + result.removed.join(', '), + ]); + } + + if (result.errors.length > 0) { + table.push([ + chalk.red('Errors'), + result.errors.length.toString(), + result.errors.join(', '), + ]); + } + + if (result.synced.length === 0 && result.skipped.length === 0 && + result.removed.length === 0 && result.errors.length === 0) { + console.log(chalk.yellow('No skills found to sync')); + } else { + console.log(table.toString()); + } + + console.log(''); + + if (result.errors.length > 0) { + process.exit(1); + } + } catch (error) { + logger.error('Failed to sync skills:', error); + process.exit(1); + } + }); +} + /** * Create main skill command with subcommands */ @@ -208,6 +296,7 @@ export function createSkillCommand(): Command { skill.addCommand(createListCommand()); skill.addCommand(createValidateCommand()); skill.addCommand(createReloadCommand()); + skill.addCommand(createSyncCommand()); return skill; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 061859f4..6a250f1c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,6 +27,7 @@ import { createHookCommand } from './commands/hook.js'; import { createSkillCommand } from './commands/skill.js'; import { createOpencodeMetricsCommand } from './commands/opencode-metrics.js'; import { createAssistantsCommand } from './commands/assistants/index.js'; +import { createPluginCommand } from './commands/plugin.js'; import { FirstTimeExperience } from './first-time.js'; import { getDirname } from '../utils/paths.js'; @@ -65,31 +66,23 @@ program.addCommand(createAnalyticsCommand()); program.addCommand(createLogCommand()); program.addCommand(createHookCommand()); program.addCommand(createSkillCommand()); +program.addCommand(createPluginCommand()); program.addCommand(createOpencodeMetricsCommand()); // Check for --task option before parsing commands const taskIndex = process.argv.indexOf('--task'); if (taskIndex !== -1 && taskIndex < process.argv.length - 1) { - // Extract task and run the built-in agent - const task = process.argv[taskIndex + 1]; - (async () => { try { - const { CodeMieCode } = await import('../agents/codemie-code/index.js'); - const { logger } = await import('../utils/logger.js'); - - const workingDir = process.cwd(); - const codeMie = new CodeMieCode(workingDir); - - try { - await codeMie.initialize(); - } catch { - logger.error('CodeMie configuration required. Please run: codemie setup'); + const { AgentRegistry } = await import('../agents/registry.js'); + const { AgentCLI } = await import('../agents/core/AgentCLI.js'); + const agent = AgentRegistry.getAgent('codemie-code'); + if (!agent) { + console.error('CodeMie Code agent not found. Run: codemie doctor'); process.exit(1); } - - // Execute task with UI - await codeMie.executeTaskWithUI(task); + const cli = new AgentCLI(agent); + await cli.run(process.argv); process.exit(0); } catch (error) { console.error('Failed to run task:', error); diff --git a/src/plugins/core/PluginDiscovery.ts b/src/plugins/core/PluginDiscovery.ts new file mode 100644 index 00000000..4289b78f --- /dev/null +++ b/src/plugins/core/PluginDiscovery.ts @@ -0,0 +1,287 @@ +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { getCodemiePath, isPathWithinDirectory } from '../../utils/paths.js'; +import { logger } from '../../utils/logger.js'; +import { PathSecurityError } from '../../utils/errors.js'; +import { PluginManifestParser } from './PluginManifestParser.js'; +import { InstalledPluginMetaSchema, validatePluginName } from './types.js'; +import type { + DiscoveredPlugin, + PluginDiscoveryOptions, + InstalledPluginMeta, +} from './types.js'; + +/** + * Default plugins directory within CodeMie home + */ +const PLUGINS_DIR = 'plugins'; + +/** + * Installed plugin metadata filename + */ +const INSTALLED_META_FILE = 'plugin.installed.json'; + +/** + * Plugin discovery engine + * + * Discovers plugins from: + * 1. ~/.codemie/plugins/ (installed plugins) + * 2. Custom directories via --plugin-dir (development mode) + */ +export class PluginDiscovery { + private cache: Map = new Map(); + + /** + * Discover all plugins + * + * @param options - Discovery options + * @returns Array of discovered plugins + */ + async discoverPlugins( + options: PluginDiscoveryOptions = {} + ): Promise { + const { pluginDirs = [], forceReload = false, pluginName } = options; + + // Check cache + const cacheKey = this.getCacheKey(options); + if (!forceReload && this.cache.has(cacheKey)) { + return this.cache.get(cacheKey)!; + } + + // Discover from all sources + const [installedPlugins, devPlugins] = await Promise.all([ + this.discoverInstalledPlugins(), + this.discoverDevPlugins(pluginDirs), + ]); + + // Combine and deduplicate (dev plugins take priority) + let allPlugins = this.deduplicatePlugins([...devPlugins, ...installedPlugins]); + + // Filter by plugin name if specified + if (pluginName) { + allPlugins = allPlugins.filter((p) => p.name === pluginName); + } + + // Cache result + this.cache.set(cacheKey, allPlugins); + + return allPlugins; + } + + /** + * Discover plugins from ~/.codemie/plugins/ + */ + private async discoverInstalledPlugins(): Promise { + const pluginsDir = getCodemiePath(PLUGINS_DIR); + return this.discoverFromDirectory(pluginsDir, false); + } + + /** + * Discover plugins from development directories + */ + private async discoverDevPlugins(pluginDirs: string[]): Promise { + const results = await Promise.all( + pluginDirs.map((dir) => this.discoverFromDirectory(dir, true)) + ); + return results.flat(); + } + + /** + * Discover plugins from a specific directory + * + * @param directory - Directory to scan + * @param isDevelopment - Whether this is a development directory + * @returns Array of discovered plugins + */ + private async discoverFromDirectory( + directory: string, + isDevelopment: boolean + ): Promise { + const plugins: DiscoveredPlugin[] = []; + + try { + // Check if directory exists + if (!existsSync(directory)) { + return plugins; + } + + // List subdirectories + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const pluginDir = join(directory, entry.name); + + // Check if it's a valid plugin (has manifest) + if (!PluginManifestParser.hasManifest(pluginDir)) { + // For development mode, also check if the directory itself is a plugin + if (isDevelopment && PluginManifestParser.hasManifest(directory)) { + // The directory passed is the plugin itself + const plugin = await this.loadPlugin(directory, isDevelopment); + if (plugin) { + plugins.push(plugin); + } + return plugins; + } + continue; + } + + // Load the plugin + const plugin = await this.loadPlugin(pluginDir, isDevelopment); + if (plugin) { + plugins.push(plugin); + } + } + } catch (error) { + logger.debug(`Failed to discover plugins from ${directory}: ${error instanceof Error ? error.message : String(error)}`); + } + + return plugins; + } + + /** + * Load a single plugin from a directory + * + * @param pluginDir - Plugin directory path + * @param isDevelopment - Whether this is a development plugin + * @returns Discovered plugin or undefined if invalid + */ + private async loadPlugin( + pluginDir: string, + isDevelopment: boolean + ): Promise { + // Parse manifest + const parseResult = await PluginManifestParser.parse(pluginDir); + + if (parseResult.error || !parseResult.manifest) { + return undefined; + } + + // Load installed metadata if available + let installedMeta: InstalledPluginMeta | undefined; + if (!isDevelopment) { + installedMeta = await this.loadInstalledMeta(pluginDir); + } + + return { + name: parseResult.manifest.name, + path: pluginDir, + manifest: parseResult.manifest, + installedMeta, + isDevelopment, + }; + } + + /** + * Load installed plugin metadata + * + * @param pluginDir - Plugin directory path + * @returns Installed metadata or undefined + */ + private async loadInstalledMeta( + pluginDir: string + ): Promise { + const metaPath = join(pluginDir, INSTALLED_META_FILE); + + try { + if (!existsSync(metaPath)) { + return undefined; + } + + const content = await readFile(metaPath, 'utf-8'); + const rawMeta = JSON.parse(content); + + const result = InstalledPluginMetaSchema.safeParse(rawMeta); + if (!result.success) { + return undefined; + } + + return result.data; + } catch (error) { + logger.debug(`Failed to load installed metadata from ${pluginDir}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + /** + * Deduplicate plugins by name (first occurrence wins) + * Since dev plugins are added first, they take priority + */ + private deduplicatePlugins(plugins: DiscoveredPlugin[]): DiscoveredPlugin[] { + const seen = new Map(); + + for (const plugin of plugins) { + if (!seen.has(plugin.name)) { + seen.set(plugin.name, plugin); + } + } + + return Array.from(seen.values()); + } + + /** + * Generate cache key from options + */ + private getCacheKey(options: PluginDiscoveryOptions): string { + const { pluginDirs = [], pluginName } = options; + return `${pluginDirs.sort().join(':')}::${pluginName || ''}`; + } + + /** + * Clear discovery cache + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Get cache statistics + */ + getCacheStats(): { size: number; keys: string[] } { + return { + size: this.cache.size, + keys: Array.from(this.cache.keys()), + }; + } + + /** + * Get the default plugins directory path + */ + static getPluginsDir(): string { + return getCodemiePath(PLUGINS_DIR); + } + + /** + * Check if a plugin is installed + * + * @param pluginName - Plugin name to check + * @returns true if installed + */ + async isPluginInstalled(pluginName: string): Promise { + const pluginDir = PluginDiscovery.getPluginDir(pluginName); + return existsSync(pluginDir) && PluginManifestParser.hasManifest(pluginDir); + } + + /** + * Get plugin directory path + * + * @param pluginName - Plugin name + * @returns Absolute path to plugin directory + */ + static getPluginDir(pluginName: string): string { + validatePluginName(pluginName); + const pluginsDir = PluginDiscovery.getPluginsDir(); + const resolved = join(pluginsDir, pluginName); + if (!isPathWithinDirectory(pluginsDir, resolved)) { + throw new PathSecurityError( + resolved, + `Plugin path escapes the plugins directory '${pluginsDir}'` + ); + } + return resolved; + } +} diff --git a/src/plugins/core/PluginLoader.ts b/src/plugins/core/PluginLoader.ts new file mode 100644 index 00000000..733373ab --- /dev/null +++ b/src/plugins/core/PluginLoader.ts @@ -0,0 +1,160 @@ +import { join } from 'path'; +import fg from 'fast-glob'; +import { logger } from '../../utils/logger.js'; +import type { DiscoveredPlugin, LoadedPlugin } from './types.js'; + +/** + * Plugin loader + * + * Loads plugin resources (skills, future: commands, hooks, MCP, LSP). + * Phase 1: Skills only + */ +export class PluginLoader { + private cache: Map = new Map(); + + /** + * Load a plugin's resources + * + * @param plugin - Discovered plugin to load + * @returns Loaded plugin with resources + */ + async loadPlugin(plugin: DiscoveredPlugin): Promise { + // Check cache + const cacheKey = `${plugin.name}::${plugin.path}`; + if (this.cache.has(cacheKey)) { + return this.cache.get(cacheKey)!; + } + + const loadErrors: string[] = []; + let skillNames: string[] = []; + + // Load skills if plugin has skills capability + const hasSkills = + plugin.manifest.capabilities?.skills !== false && + (await this.hasSkillsDirectory(plugin.path)); + + if (hasSkills) { + try { + skillNames = await this.discoverSkillNames(plugin.path); + } catch (error) { + loadErrors.push( + `Failed to load skills: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + const loaded: LoadedPlugin = { + ...plugin, + skillNames, + skillCount: skillNames.length, + loadedAt: new Date().toISOString(), + loadErrors, + }; + + // Cache result + this.cache.set(cacheKey, loaded); + + return loaded; + } + + /** + * Check if a plugin has a skills directory + * + * @param pluginPath - Plugin directory path + * @returns true if skills/ directory exists + */ + private async hasSkillsDirectory(pluginPath: string): Promise { + try { + const skillsDir = join(pluginPath, 'skills'); + const pattern = '**/SKILL.md'; + const files = await fg(pattern, { + cwd: skillsDir, + caseSensitiveMatch: false, + onlyFiles: true, + deep: 3, + }); + return files.length > 0; + } catch (error) { + logger.debug(`Failed to check skills directory for plugin at ${pluginPath}: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + + /** + * Discover skill names from a plugin + * + * Skills are located in plugin/skills/{skill-name}/SKILL.md + * + * @param pluginPath - Plugin directory path + * @returns Array of skill names + */ + private async discoverSkillNames(pluginPath: string): Promise { + const skillsDir = join(pluginPath, 'skills'); + + try { + const pattern = '**/SKILL.md'; + const files = await fg(pattern, { + cwd: skillsDir, + caseSensitiveMatch: false, + onlyFiles: true, + absolute: false, + deep: 3, + ignore: ['**/node_modules/**', '**/.git/**'], + }); + + // Extract skill names from paths (e.g., "my-skill/SKILL.md" -> "my-skill") + const skillNames = files.map((file) => { + const parts = file.split('/'); + // If SKILL.md is at root of skills/, use the directory name + if (parts.length === 1) { + return 'default'; + } + return parts[0]; + }); + + // Deduplicate and filter + return [...new Set(skillNames)].filter(Boolean); + } catch (error) { + logger.debug(`Failed to discover skill names at ${pluginPath}: ${error instanceof Error ? error.message : String(error)}`); + return []; + } + } + + /** + * Get the skills directory for a plugin + * + * @param pluginPath - Plugin directory path + * @returns Absolute path to skills directory + */ + static getSkillsDir(pluginPath: string): string { + return join(pluginPath, 'skills'); + } + + /** + * Get the path to a specific skill within a plugin + * + * @param pluginPath - Plugin directory path + * @param skillName - Skill name + * @returns Absolute path to skill directory + */ + static getSkillPath(pluginPath: string, skillName: string): string { + return join(pluginPath, 'skills', skillName); + } + + /** + * Clear loader cache + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Get cache statistics + */ + getCacheStats(): { size: number; keys: string[] } { + return { + size: this.cache.size, + keys: Array.from(this.cache.keys()), + }; + } +} diff --git a/src/plugins/core/PluginManifestParser.ts b/src/plugins/core/PluginManifestParser.ts new file mode 100644 index 00000000..c31dbf86 --- /dev/null +++ b/src/plugins/core/PluginManifestParser.ts @@ -0,0 +1,187 @@ +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { PluginManifestSchema } from './types.js'; +import type { PluginManifest, PluginManifestParseResult } from './types.js'; + +/** + * Standard plugin manifest file location + */ +const PLUGIN_MANIFEST_PATH = '.claude-plugin/plugin.json'; + +/** + * Plugin manifest parser + * + * Parses .claude-plugin/plugin.json files with Zod validation. + * Supports Claude-compatible manifest format with CodeMie extensions. + */ +export class PluginManifestParser { + /** + * Check if a directory contains a valid plugin manifest + * + * @param pluginDir - Absolute path to plugin directory + * @returns true if .claude-plugin/plugin.json exists + */ + static hasManifest(pluginDir: string): boolean { + const manifestPath = join(pluginDir, PLUGIN_MANIFEST_PATH); + return existsSync(manifestPath); + } + + /** + * Get the manifest file path for a plugin directory + * + * @param pluginDir - Absolute path to plugin directory + * @returns Absolute path to the manifest file + */ + static getManifestPath(pluginDir: string): string { + return join(pluginDir, PLUGIN_MANIFEST_PATH); + } + + /** + * Parse a plugin manifest from a directory + * + * @param pluginDir - Absolute path to plugin directory + * @returns Parse result with manifest or error + */ + static async parse(pluginDir: string): Promise { + const manifestPath = PluginManifestParser.getManifestPath(pluginDir); + + try { + // Check if manifest exists + if (!existsSync(manifestPath)) { + return { + error: { + path: manifestPath, + message: `Plugin manifest not found at ${PLUGIN_MANIFEST_PATH}`, + }, + }; + } + + // Read manifest file + const content = await readFile(manifestPath, 'utf-8'); + + // Parse JSON + let rawManifest: unknown; + try { + rawManifest = JSON.parse(content); + } catch (parseError) { + return { + error: { + path: manifestPath, + message: 'Invalid JSON in plugin manifest', + cause: parseError, + }, + }; + } + + // Validate with Zod schema + const result = PluginManifestSchema.safeParse(rawManifest); + + if (!result.success) { + const errors = result.error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', '); + + return { + error: { + path: manifestPath, + message: `Invalid plugin manifest: ${errors}`, + cause: result.error, + }, + }; + } + + return { manifest: result.data }; + } catch (error) { + return { + error: { + path: manifestPath, + message: error instanceof Error ? error.message : String(error), + cause: error, + }, + }; + } + } + + /** + * Parse a plugin manifest from raw content + * + * @param content - JSON string content + * @param sourcePath - Path for error reporting + * @returns Parse result with manifest or error + */ + static parseContent( + content: string, + sourcePath: string = 'unknown' + ): PluginManifestParseResult { + try { + // Parse JSON + let rawManifest: unknown; + try { + rawManifest = JSON.parse(content); + } catch (parseError) { + return { + error: { + path: sourcePath, + message: 'Invalid JSON in plugin manifest', + cause: parseError, + }, + }; + } + + // Validate with Zod schema + const result = PluginManifestSchema.safeParse(rawManifest); + + if (!result.success) { + const errors = result.error.issues + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', '); + + return { + error: { + path: sourcePath, + message: `Invalid plugin manifest: ${errors}`, + cause: result.error, + }, + }; + } + + return { manifest: result.data }; + } catch (error) { + return { + error: { + path: sourcePath, + message: error instanceof Error ? error.message : String(error), + cause: error, + }, + }; + } + } + + /** + * Validate a manifest object + * + * @param manifest - Manifest object to validate + * @returns true if valid, false otherwise + */ + static isValid(manifest: unknown): manifest is PluginManifest { + const result = PluginManifestSchema.safeParse(manifest); + return result.success; + } + + /** + * Get validation errors for a manifest + * + * @param manifest - Manifest object to validate + * @returns Array of error messages, empty if valid + */ + static getValidationErrors(manifest: unknown): string[] { + const result = PluginManifestSchema.safeParse(manifest); + + if (result.success) { + return []; + } + + return result.error.issues.map((e) => `${e.path.join('.')}: ${e.message}`); + } +} diff --git a/src/plugins/core/PluginRegistry.ts b/src/plugins/core/PluginRegistry.ts new file mode 100644 index 00000000..d0abb421 --- /dev/null +++ b/src/plugins/core/PluginRegistry.ts @@ -0,0 +1,253 @@ +import { PluginDiscovery } from './PluginDiscovery.js'; +import { PluginLoader } from './PluginLoader.js'; +import type { + DiscoveredPlugin, + LoadedPlugin, + PluginDiscoveryOptions, +} from './types.js'; + +/** + * Plugin registry singleton + * + * Central registry for all plugins. Handles: + * - Plugin discovery + * - Plugin loading + * - Plugin lookup + * - Cache management + * + * Similar pattern to AgentRegistry but for plugins. + */ +export class PluginRegistry { + private static instance: PluginRegistry; + private discovery: PluginDiscovery; + private loader: PluginLoader; + private loadedPlugins: Map = new Map(); + private discoveredPlugins: Map = new Map(); + private initialized = false; + private initializationPromise: Promise | null = null; + + /** Additional plugin directories for development mode */ + private devPluginDirs: string[] = []; + + /** + * Private constructor (singleton pattern) + */ + private constructor() { + this.discovery = new PluginDiscovery(); + this.loader = new PluginLoader(); + } + + /** + * Get singleton instance + */ + static getInstance(): PluginRegistry { + if (!PluginRegistry.instance) { + PluginRegistry.instance = new PluginRegistry(); + } + return PluginRegistry.instance; + } + + /** + * Add development plugin directory + * + * Call this before initialization to load plugins from custom directories + * + * @param dir - Absolute path to plugin directory + */ + addDevPluginDir(dir: string): void { + if (!this.devPluginDirs.includes(dir)) { + this.devPluginDirs.push(dir); + // Clear cache to force re-discovery + if (this.initialized) { + this.reset(); + } + } + } + + /** + * Initialize the registry (lazy initialization) + * + * Discovers and loads all plugins. Safe to call multiple times. + */ + async initialize(): Promise { + if (this.initialized) { + return; + } + + // Prevent concurrent initialization + if (this.initializationPromise) { + return this.initializationPromise; + } + + this.initializationPromise = this.doInitialize(); + + try { + await this.initializationPromise; + } finally { + this.initializationPromise = null; + } + } + + /** + * Perform actual initialization + */ + private async doInitialize(): Promise { + // Discover all plugins + const options: PluginDiscoveryOptions = { + pluginDirs: this.devPluginDirs, + forceReload: true, + }; + + const discovered = await this.discovery.discoverPlugins(options); + + // Store discovered plugins + this.discoveredPlugins.clear(); + for (const plugin of discovered) { + this.discoveredPlugins.set(plugin.name, plugin); + } + + // Load all plugins + this.loadedPlugins.clear(); + for (const plugin of discovered) { + const loaded = await this.loader.loadPlugin(plugin); + this.loadedPlugins.set(plugin.name, loaded); + } + + this.initialized = true; + } + + /** + * Get a plugin by name + * + * @param name - Plugin name + * @returns Loaded plugin or undefined + */ + async getPlugin(name: string): Promise { + await this.initialize(); + return this.loadedPlugins.get(name); + } + + /** + * Get all loaded plugins + * + * @returns Array of all loaded plugins + */ + async getAllPlugins(): Promise { + await this.initialize(); + return Array.from(this.loadedPlugins.values()); + } + + /** + * Get all discovered plugins (before loading) + * + * @returns Array of all discovered plugins + */ + async getDiscoveredPlugins(): Promise { + await this.initialize(); + return Array.from(this.discoveredPlugins.values()); + } + + /** + * Get plugin names + * + * @returns Array of plugin names + */ + async getPluginNames(): Promise { + await this.initialize(); + return Array.from(this.loadedPlugins.keys()); + } + + /** + * Check if a plugin is loaded + * + * @param name - Plugin name + * @returns true if loaded + */ + async hasPlugin(name: string): Promise { + await this.initialize(); + return this.loadedPlugins.has(name); + } + + /** + * Get skills from a specific plugin + * + * @param pluginName - Plugin name + * @returns Array of skill names from the plugin + */ + async getPluginSkills(pluginName: string): Promise { + const plugin = await this.getPlugin(pluginName); + return plugin?.skillNames || []; + } + + /** + * Get all skills from all plugins + * + * @returns Map of plugin name to skill names + */ + async getAllPluginSkills(): Promise> { + await this.initialize(); + + const result = new Map(); + for (const [name, plugin] of this.loadedPlugins) { + result.set(name, plugin.skillNames); + } + + return result; + } + + /** + * Reload all plugins + * + * Clears cache and re-discovers/re-loads all plugins + */ + async reload(): Promise { + this.reset(); + await this.initialize(); + } + + /** + * Reset the registry + * + * Clears all caches and loaded plugins + */ + reset(): void { + this.discovery.clearCache(); + this.loader.clearCache(); + this.loadedPlugins.clear(); + this.discoveredPlugins.clear(); + this.initialized = false; + this.initializationPromise = null; + } + + /** + * Get registry statistics + */ + getStats(): { + initialized: boolean; + pluginCount: number; + totalSkillCount: number; + pluginNames: string[]; + } { + let totalSkillCount = 0; + for (const plugin of this.loadedPlugins.values()) { + totalSkillCount += plugin.skillCount; + } + + return { + initialized: this.initialized, + pluginCount: this.loadedPlugins.size, + totalSkillCount, + pluginNames: Array.from(this.loadedPlugins.keys()), + }; + } + + /** + * Reset singleton instance (for testing) + */ + static resetInstance(): void { + if (PluginRegistry.instance) { + PluginRegistry.instance.reset(); + } + PluginRegistry.instance = undefined as unknown as PluginRegistry; + } +} diff --git a/src/plugins/core/__tests__/PluginRegistry.test.ts b/src/plugins/core/__tests__/PluginRegistry.test.ts new file mode 100644 index 00000000..41218490 --- /dev/null +++ b/src/plugins/core/__tests__/PluginRegistry.test.ts @@ -0,0 +1,314 @@ +/** + * Tests for PluginRegistry singleton + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DiscoveredPlugin, LoadedPlugin } from '../types.js'; + +// Hoist mock functions so they're available in vi.mock factories +const { + mockDiscoverPlugins, + mockDiscoveryClearCache, + mockLoadPlugin, + mockLoaderClearCache, +} = vi.hoisted(() => ({ + mockDiscoverPlugins: vi.fn(), + mockDiscoveryClearCache: vi.fn(), + mockLoadPlugin: vi.fn(), + mockLoaderClearCache: vi.fn(), +})); + +// Mock PluginDiscovery - use function (not arrow) for `new` support +vi.mock('../PluginDiscovery.js', () => ({ + PluginDiscovery: vi.fn(function () { + return { + discoverPlugins: mockDiscoverPlugins, + clearCache: mockDiscoveryClearCache, + }; + }), +})); + +// Mock PluginLoader - use function (not arrow) for `new` support +vi.mock('../PluginLoader.js', () => ({ + PluginLoader: vi.fn(function () { + return { + loadPlugin: mockLoadPlugin, + clearCache: mockLoaderClearCache, + }; + }), +})); + +const { PluginRegistry } = await import('../PluginRegistry.js'); + +function makeDiscovered(name: string): DiscoveredPlugin { + return { + name, + path: `/plugins/${name}`, + manifest: { name, description: `${name} plugin`, version: '1.0.0' }, + isDevelopment: false, + }; +} + +function makeLoaded(name: string, skillNames: string[] = []): LoadedPlugin { + return { + ...makeDiscovered(name), + skillNames, + skillCount: skillNames.length, + loadedAt: new Date().toISOString(), + loadErrors: [], + }; +} + +describe('PluginRegistry', () => { + beforeEach(() => { + PluginRegistry.resetInstance(); + vi.clearAllMocks(); + + // Default: no plugins + mockDiscoverPlugins.mockResolvedValue([]); + mockLoadPlugin.mockImplementation((p: DiscoveredPlugin) => Promise.resolve(makeLoaded(p.name))); + }); + + describe('singleton', () => { + it('returns the same instance on multiple getInstance() calls', () => { + const a = PluginRegistry.getInstance(); + const b = PluginRegistry.getInstance(); + expect(a).toBe(b); + }); + + it('returns a new instance after resetInstance()', () => { + const a = PluginRegistry.getInstance(); + PluginRegistry.resetInstance(); + const b = PluginRegistry.getInstance(); + expect(a).not.toBe(b); + }); + }); + + describe('initialization', () => { + it('discovers and loads plugins during initialize', async () => { + const discovered = [makeDiscovered('alpha'), makeDiscovered('beta')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + + const registry = PluginRegistry.getInstance(); + await registry.initialize(); + + expect(mockDiscoverPlugins).toHaveBeenCalled(); + expect(mockLoadPlugin).toHaveBeenCalledTimes(2); + }); + + it('is idempotent (second call is no-op)', async () => { + mockDiscoverPlugins.mockResolvedValue([]); + + const registry = PluginRegistry.getInstance(); + await registry.initialize(); + await registry.initialize(); + + expect(mockDiscoverPlugins).toHaveBeenCalledTimes(1); + }); + + it('serializes concurrent init calls via initializationPromise', async () => { + mockDiscoverPlugins.mockResolvedValue([]); + + const registry = PluginRegistry.getInstance(); + const [r1, r2] = await Promise.all([ + registry.initialize(), + registry.initialize(), + ]); + + expect(r1).toBeUndefined(); + expect(r2).toBeUndefined(); + expect(mockDiscoverPlugins).toHaveBeenCalledTimes(1); + }); + }); + + describe('getStats', () => { + it('returns defaults when uninitialized', () => { + const registry = PluginRegistry.getInstance(); + const stats = registry.getStats(); + + expect(stats.initialized).toBe(false); + expect(stats.pluginCount).toBe(0); + expect(stats.totalSkillCount).toBe(0); + expect(stats.pluginNames).toEqual([]); + }); + + it('returns populated stats after init', async () => { + const discovered = [makeDiscovered('foo')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + mockLoadPlugin.mockResolvedValue(makeLoaded('foo', ['skill-a', 'skill-b'])); + + const registry = PluginRegistry.getInstance(); + await registry.initialize(); + const stats = registry.getStats(); + + expect(stats.initialized).toBe(true); + expect(stats.pluginCount).toBe(1); + expect(stats.totalSkillCount).toBe(2); + expect(stats.pluginNames).toEqual(['foo']); + }); + }); + + describe('retrieval', () => { + it('retrieves a plugin by name', async () => { + const discovered = [makeDiscovered('myplugin')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + mockLoadPlugin.mockResolvedValue(makeLoaded('myplugin')); + + const registry = PluginRegistry.getInstance(); + const plugin = await registry.getPlugin('myplugin'); + + expect(plugin).toBeDefined(); + expect(plugin!.name).toBe('myplugin'); + }); + + it('returns undefined for unknown plugin', async () => { + mockDiscoverPlugins.mockResolvedValue([]); + + const registry = PluginRegistry.getInstance(); + const plugin = await registry.getPlugin('nonexistent'); + + expect(plugin).toBeUndefined(); + }); + + it('returns all plugin names', async () => { + const discovered = [makeDiscovered('a'), makeDiscovered('b')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + mockLoadPlugin.mockImplementation((p: DiscoveredPlugin) => + Promise.resolve(makeLoaded(p.name)) + ); + + const registry = PluginRegistry.getInstance(); + const names = await registry.getPluginNames(); + + expect(names).toEqual(['a', 'b']); + }); + + it('hasPlugin returns true for loaded plugin', async () => { + const discovered = [makeDiscovered('test')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + mockLoadPlugin.mockResolvedValue(makeLoaded('test')); + + const registry = PluginRegistry.getInstance(); + const has = await registry.hasPlugin('test'); + + expect(has).toBe(true); + }); + }); + + describe('skills', () => { + it('returns per-plugin skills', async () => { + const discovered = [makeDiscovered('myplugin')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + mockLoadPlugin.mockResolvedValue(makeLoaded('myplugin', ['commit', 'review'])); + + const registry = PluginRegistry.getInstance(); + const skills = await registry.getPluginSkills('myplugin'); + + expect(skills).toEqual(['commit', 'review']); + }); + + it('returns empty for plugin with no skills', async () => { + const discovered = [makeDiscovered('noskills')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + mockLoadPlugin.mockResolvedValue(makeLoaded('noskills')); + + const registry = PluginRegistry.getInstance(); + const skills = await registry.getPluginSkills('noskills'); + + expect(skills).toEqual([]); + }); + + it('aggregates skills from all plugins', async () => { + const discovered = [makeDiscovered('a'), makeDiscovered('b')]; + mockDiscoverPlugins.mockResolvedValue(discovered); + mockLoadPlugin + .mockResolvedValueOnce(makeLoaded('a', ['s1'])) + .mockResolvedValueOnce(makeLoaded('b', ['s2', 's3'])); + + const registry = PluginRegistry.getInstance(); + const allSkills = await registry.getAllPluginSkills(); + + expect(allSkills.get('a')).toEqual(['s1']); + expect(allSkills.get('b')).toEqual(['s2', 's3']); + }); + }); + + describe('reset', () => { + it('clears state and calls clearCache on discovery and loader', async () => { + mockDiscoverPlugins.mockResolvedValue([makeDiscovered('x')]); + mockLoadPlugin.mockResolvedValue(makeLoaded('x')); + + const registry = PluginRegistry.getInstance(); + await registry.initialize(); + + expect(registry.getStats().initialized).toBe(true); + + registry.reset(); + + expect(registry.getStats().initialized).toBe(false); + expect(registry.getStats().pluginCount).toBe(0); + expect(mockDiscoveryClearCache).toHaveBeenCalled(); + expect(mockLoaderClearCache).toHaveBeenCalled(); + }); + }); + + describe('reload', () => { + it('resets then re-initializes with fresh discovery', async () => { + mockDiscoverPlugins.mockResolvedValue([]); + + const registry = PluginRegistry.getInstance(); + await registry.initialize(); + + mockDiscoverPlugins.mockResolvedValue([makeDiscovered('new')]); + mockLoadPlugin.mockResolvedValue(makeLoaded('new')); + + await registry.reload(); + + const names = await registry.getPluginNames(); + expect(names).toContain('new'); + // discoverPlugins called at least twice: initial + reload + expect(mockDiscoverPlugins).toHaveBeenCalledTimes(2); + }); + }); + + describe('addDevPluginDir', () => { + it('adds directory to discovery options', async () => { + const registry = PluginRegistry.getInstance(); + registry.addDevPluginDir('/my/dev/plugins'); + + mockDiscoverPlugins.mockResolvedValue([]); + await registry.initialize(); + + expect(mockDiscoverPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + pluginDirs: expect.arrayContaining(['/my/dev/plugins']), + }) + ); + }); + + it('does not add duplicates', () => { + const registry = PluginRegistry.getInstance(); + registry.addDevPluginDir('/dev'); + registry.addDevPluginDir('/dev'); + + // No way to inspect devPluginDirs directly, but it shouldn't trigger multiple resets + // We can verify through behavior: init should only get one copy + }); + + it('triggers reset if already initialized', async () => { + mockDiscoverPlugins.mockResolvedValue([]); + + const registry = PluginRegistry.getInstance(); + await registry.initialize(); + + expect(registry.getStats().initialized).toBe(true); + + registry.addDevPluginDir('/new/dir'); + + // After addDevPluginDir on an initialized registry, it resets + expect(registry.getStats().initialized).toBe(false); + }); + }); +}); diff --git a/src/plugins/core/__tests__/plugin-validation.test.ts b/src/plugins/core/__tests__/plugin-validation.test.ts new file mode 100644 index 00000000..5e591502 --- /dev/null +++ b/src/plugins/core/__tests__/plugin-validation.test.ts @@ -0,0 +1,157 @@ +/** + * Tests for plugin name validation and plugin directory resolution + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Cross-platform mock for isPathWithinDirectory (Windows path.join uses backslashes) +const mockIsPathWithin = (base: string, resolved: string): boolean => { + const norm = (p: string) => p.replace(/\\/g, '/'); + return norm(resolved).startsWith(norm(base)) && !resolved.includes('..'); +}; + +// Mock dependencies for getPluginDir +vi.mock('../../../utils/paths.js', () => ({ + getCodemiePath: vi.fn(() => '/home/user/.codemie/plugins'), + isPathWithinDirectory: vi.fn(mockIsPathWithin), +})); + +const { getCodemiePath, isPathWithinDirectory } = await import('../../../utils/paths.js'); +const { validatePluginName } = await import('../types.js'); +const { PluginDiscovery } = await import('../PluginDiscovery.js'); + +describe('validatePluginName', () => { + describe('valid names', () => { + it('accepts a simple lowercase name', () => { + expect(() => validatePluginName('myplugin')).not.toThrow(); + }); + + it('accepts a single character name', () => { + expect(() => validatePluginName('a')).not.toThrow(); + }); + + it('accepts name with digits', () => { + expect(() => validatePluginName('plugin123')).not.toThrow(); + }); + + it('accepts name with hyphens', () => { + expect(() => validatePluginName('my-plugin')).not.toThrow(); + }); + + it('accepts max-length name (50 chars)', () => { + const name = 'a' + 'b'.repeat(49); + expect(() => validatePluginName(name)).not.toThrow(); + }); + }); + + describe('path traversal rejection', () => { + it('rejects ../evil', () => { + expect(() => validatePluginName('../evil')).toThrow(); + }); + + it('rejects ../../etc/passwd', () => { + expect(() => validatePluginName('../../etc/passwd')).toThrow(); + }); + + it('rejects bare ..', () => { + expect(() => validatePluginName('..')).toThrow(); + }); + }); + + describe('format violations', () => { + it('rejects uppercase letters', () => { + expect(() => validatePluginName('MyPlugin')).toThrow(); + }); + + it('rejects name starting with digit', () => { + expect(() => validatePluginName('1plugin')).toThrow(); + }); + + it('rejects name starting with hyphen', () => { + expect(() => validatePluginName('-plugin')).toThrow(); + }); + + it('rejects name exceeding 50 chars', () => { + const name = 'a' + 'b'.repeat(50); // 51 chars + expect(() => validatePluginName(name)).toThrow(); + }); + + it('rejects empty string', () => { + expect(() => validatePluginName('')).toThrow(); + }); + + it('rejects @ character', () => { + expect(() => validatePluginName('@scope/plugin')).toThrow(); + }); + + it('rejects space character', () => { + expect(() => validatePluginName('my plugin')).toThrow(); + }); + + it('rejects underscore character', () => { + expect(() => validatePluginName('my_plugin')).toThrow(); + }); + + it('rejects dot character', () => { + expect(() => validatePluginName('my.plugin')).toThrow(); + }); + + it('rejects slash character', () => { + expect(() => validatePluginName('my/plugin')).toThrow(); + }); + }); + + describe('error type and message', () => { + it('throws PathSecurityError', () => { + try { + validatePluginName('INVALID'); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.name).toBe('PathSecurityError'); + } + }); + + it('includes the invalid name in the error message', () => { + try { + validatePluginName('../evil'); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).toContain('../evil'); + } + }); + + it('includes the pattern description in the error message', () => { + try { + validatePluginName('INVALID'); + expect.fail('should have thrown'); + } catch (error: any) { + expect(error.message).toContain('lowercase'); + } + }); + }); +}); + +describe('PluginDiscovery.getPluginDir', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getCodemiePath).mockReturnValue('/home/user/.codemie/plugins'); + vi.mocked(isPathWithinDirectory).mockImplementation(mockIsPathWithin); + }); + + it('returns resolved path for valid name', () => { + const result = PluginDiscovery.getPluginDir('myplugin'); + expect(result).toContain('myplugin'); + expect(result).toMatch(/\.codemie[/\\]plugins/); + }); + + it('throws for invalid name', () => { + expect(() => PluginDiscovery.getPluginDir('../evil')).toThrow(); + }); + + it('throws when path escapes plugins directory', () => { + vi.mocked(isPathWithinDirectory).mockReturnValue(false); + expect(() => PluginDiscovery.getPluginDir('myplugin')).toThrow('escapes'); + }); +}); diff --git a/src/plugins/core/types.ts b/src/plugins/core/types.ts new file mode 100644 index 00000000..4898e892 --- /dev/null +++ b/src/plugins/core/types.ts @@ -0,0 +1,239 @@ +import { z } from 'zod'; +import { PathSecurityError } from '../../utils/errors.js'; + +/** + * Plugin source type + */ +export type PluginSource = 'marketplace' | 'local'; + +/** + * Zod schema for Claude-compatible plugin manifest + * Compatible with .claude-plugin/plugin.json format + */ +export const PluginManifestSchema = z.object({ + // Required fields + name: z.string().min(1, 'Plugin name is required'), + description: z.string().min(1, 'Plugin description is required'), + + // Optional version (Claude Code plugins may not include this) + version: z.string().optional().default('0.0.0'), + + // Optional standard fields - author can be string or object + author: z + .union([ + z.string(), + z.object({ + name: z.string(), + email: z.string().optional(), + url: z.string().optional(), + }), + ]) + .optional() + .transform((val) => { + if (typeof val === 'object' && val !== null) { + return val.name; + } + return val; + }), + license: z.string().optional(), + homepage: z.string().url().optional(), + repository: z.string().optional(), + keywords: z.array(z.string()).optional(), + + // Plugin capabilities (what the plugin provides) + capabilities: z + .object({ + skills: z.boolean().default(false), + commands: z.boolean().default(false), + hooks: z.boolean().default(false), + mcp: z.boolean().default(false), + lsp: z.boolean().default(false), + }) + .optional(), + + // Compatibility requirements + compatibility: z + .object({ + agents: z.array(z.string()).optional(), + minVersion: z.string().optional(), + }) + .optional(), + + // CodeMie-specific extensions + codemie: z + .object({ + priority: z.number().default(0), + tags: z.array(z.string()).optional(), + category: z.string().optional(), + }) + .optional(), +}); + +/** + * TypeScript interface for plugin manifest + */ +export type PluginManifest = z.infer; + +/** + * Plugin installation metadata + * Stored as plugin.installed.json in the plugin directory + */ +export interface InstalledPluginMeta { + /** Plugin name */ + name: string; + + /** Installed version */ + version: string; + + /** Source of installation */ + source: PluginSource; + + /** Marketplace ID if installed from marketplace */ + marketplaceId?: string; + + /** Repository URL if installed from marketplace */ + repositoryUrl?: string; + + /** Installation timestamp (ISO 8601) */ + installedAt: string; + + /** Last update timestamp (ISO 8601) */ + updatedAt: string; + + /** Hash of the installed version (for update detection) */ + commitHash?: string; +} + +/** + * Zod schema for installed plugin metadata + */ +export const InstalledPluginMetaSchema = z.object({ + name: z.string(), + version: z.string(), + source: z.enum(['marketplace', 'local']), + marketplaceId: z.string().optional(), + repositoryUrl: z.string().optional(), + installedAt: z.string(), + updatedAt: z.string(), + commitHash: z.string().optional(), +}); + +/** + * Discovered plugin (before loading) + */ +export interface DiscoveredPlugin { + /** Plugin name from manifest */ + name: string; + + /** Absolute path to plugin directory */ + path: string; + + /** Parsed manifest */ + manifest: PluginManifest; + + /** Installation metadata (if installed from marketplace) */ + installedMeta?: InstalledPluginMeta; + + /** Whether this is a development plugin (loaded via --plugin-dir) */ + isDevelopment: boolean; +} + +/** + * Loaded plugin with all resources + */ +export interface LoadedPlugin extends DiscoveredPlugin { + /** Loaded skill names from this plugin */ + skillNames: string[]; + + /** Number of skills loaded */ + skillCount: number; + + /** Load timestamp */ + loadedAt: string; + + /** Any errors during loading (non-fatal) */ + loadErrors: string[]; +} + +/** + * Plugin skill info attached to skills from plugins + */ +export interface PluginSkillInfo { + /** Plugin name */ + pluginName: string; + + /** Full namespaced skill name (plugin-name:skill-name) */ + fullSkillName: string; + + /** Plugin version */ + pluginVersion: string; + + /** Plugin source */ + pluginSource: PluginSource; +} + +/** + * Result of parsing a plugin manifest + */ +export interface PluginManifestParseResult { + manifest?: PluginManifest; + error?: { + path: string; + message: string; + cause?: unknown; + }; +} + +/** + * Options for plugin discovery + */ +export interface PluginDiscoveryOptions { + /** Additional directories to scan for plugins (development mode) */ + pluginDirs?: string[]; + + /** Force reload (ignore cache) */ + forceReload?: boolean; + + /** Filter by plugin name */ + pluginName?: string; +} + +/** + * Plugin validation result + */ +export interface PluginValidationResult { + valid: boolean; + pluginPath: string; + pluginName?: string; + errors: string[]; +} + +/** + * Plugin operation result + */ +export interface PluginOperationResult { + success: boolean; + pluginName: string; + message: string; + error?: Error; +} + +/** + * Valid plugin name pattern: lowercase letters, digits, hyphens; 1-50 chars; starts with letter + */ +const VALID_PLUGIN_NAME = /^[a-z][a-z0-9-]{0,49}$/; + +/** + * Validate a plugin name to prevent path traversal attacks. + * + * @param name - Plugin name to validate + * @throws PathSecurityError if the name contains invalid characters + */ +export function validatePluginName(name: string): void { + if (!VALID_PLUGIN_NAME.test(name)) { + throw new PathSecurityError( + name, + `Invalid plugin name. Names must match ${VALID_PLUGIN_NAME} (lowercase, digits, hyphens; starts with a letter; max 50 chars).` + ); + } +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts new file mode 100644 index 00000000..84143662 --- /dev/null +++ b/src/plugins/index.ts @@ -0,0 +1,52 @@ +/** + * Plugin System - Public API + * + * Provides plugin discovery, loading, and marketplace integration for CodeMie. + * Compatible with Claude Code's official plugin format (.claude-plugin/plugin.json). + */ + +// Core exports +export { PluginRegistry } from './core/PluginRegistry.js'; +export { PluginDiscovery } from './core/PluginDiscovery.js'; +export { PluginLoader } from './core/PluginLoader.js'; +export { PluginManifestParser } from './core/PluginManifestParser.js'; + +// Core type exports +export type { + PluginManifest, + PluginSource, + InstalledPluginMeta, + DiscoveredPlugin, + LoadedPlugin, + PluginSkillInfo, + PluginManifestParseResult, + PluginDiscoveryOptions, + PluginValidationResult, + PluginOperationResult, +} from './core/types.js'; +export { PluginManifestSchema, InstalledPluginMetaSchema, validatePluginName } from './core/types.js'; + +// Marketplace exports +export { MarketplaceClient } from './marketplace/MarketplaceClient.js'; +export { MarketplaceRegistry } from './marketplace/MarketplaceRegistry.js'; +export { PluginInstaller } from './marketplace/PluginInstaller.js'; + +// Marketplace type exports +export type { + MarketplaceSource, + MarketplaceSourceType, + MarketplaceConfig, + MarketplaceIndex, + MarketplacePluginEntry, + MarketplaceSearchResult, + PluginDownloadInfo, + PluginInstallOptions, + PluginInstallResult, + PluginUpdateInfo, +} from './marketplace/types.js'; +export { + MarketplaceSourceSchema, + MarketplaceConfigSchema, + MarketplaceIndexSchema, + MarketplacePluginEntrySchema, +} from './marketplace/types.js'; diff --git a/src/plugins/marketplace/MarketplaceClient.ts b/src/plugins/marketplace/MarketplaceClient.ts new file mode 100644 index 00000000..339a304d --- /dev/null +++ b/src/plugins/marketplace/MarketplaceClient.ts @@ -0,0 +1,459 @@ +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { dirname } from 'path'; +import { existsSync } from 'fs'; +import { getCodemiePath } from '../../utils/paths.js'; +import { logger } from '../../utils/logger.js'; +import { PluginManifestParser } from '../core/PluginManifestParser.js'; +import { MarketplaceIndexSchema, MarketplacePluginEntrySchema } from './types.js'; +import type { + MarketplaceSource, + MarketplaceIndex, + MarketplacePluginEntry, + MarketplaceSearchResult, + PluginDownloadInfo, +} from './types.js'; + +/** + * Cache directory within CodeMie home + */ +const CACHE_DIR = 'cache'; + +/** + * Cache TTL in milliseconds (1 hour) + */ +const CACHE_TTL_MS = 60 * 60 * 1000; + +/** + * GitHub API base URL + */ +const GITHUB_API_URL = 'https://api.github.com'; + +/** + * GitHub raw content base URL + */ +const GITHUB_RAW_URL = 'https://raw.githubusercontent.com'; + +/** + * Marketplace client + * + * Fetches plugin listings from GitHub-based marketplaces. + * Caches index locally for performance. + */ +export class MarketplaceClient { + private indexCache: Map = new Map(); + + /** + * Fetch marketplace index from a source + * + * @param source - Marketplace source + * @param forceRefresh - Force refresh from remote + * @returns Marketplace index + */ + async fetchIndex( + source: MarketplaceSource, + forceRefresh = false + ): Promise { + // Check memory cache + if (!forceRefresh && this.indexCache.has(source.id)) { + const cached = this.indexCache.get(source.id)!; + if (new Date(cached.expiresAt) > new Date()) { + return cached; + } + } + + // Check file cache + if (!forceRefresh) { + const fileCached = await this.loadCachedIndex(source.id); + if (fileCached && new Date(fileCached.expiresAt) > new Date()) { + this.indexCache.set(source.id, fileCached); + return fileCached; + } + } + + // Fetch from remote + const index = await this.fetchRemoteIndex(source); + + // Save to caches + this.indexCache.set(source.id, index); + await this.saveCachedIndex(source.id, index); + + return index; + } + + /** + * Fetch index from remote GitHub repository + */ + private async fetchRemoteIndex(source: MarketplaceSource): Promise { + const branch = source.branch || 'main'; + const plugins: MarketplacePluginEntry[] = []; + + try { + // Fetch plugins from 'plugins/' directory + const pluginsDirPlugins = await this.fetchPluginsFromDirectory( + source.repository, + branch, + 'plugins' + ); + plugins.push(...pluginsDirPlugins); + + // Fetch external plugin references from 'external_plugins/' directory + const externalPlugins = await this.fetchExternalPlugins( + source.repository, + branch + ); + plugins.push(...externalPlugins); + } catch (error) { + // If we can't fetch, return empty index + logger.error( + `Failed to fetch marketplace index from ${source.repository}: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + CACHE_TTL_MS); + + return { + sourceId: source.id, + plugins, + version: 1, + fetchedAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + }; + } + + /** + * Fetch plugins from a directory in the repository + */ + private async fetchPluginsFromDirectory( + repository: string, + branch: string, + directory: string + ): Promise { + const plugins: MarketplacePluginEntry[] = []; + + try { + // Fetch directory listing via GitHub API + const [owner, repo] = repository.split('/'); + const apiUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/contents/${directory}?ref=${branch}`; + + const response = await fetch(apiUrl, { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'codemie-code', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + // Directory doesn't exist, which is fine + return plugins; + } + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + const entries = (await response.json()) as Array<{ + name: string; + type: string; + path: string; + }>; + + // Process each plugin directory + for (const entry of entries) { + if (entry.type !== 'dir') continue; + + try { + // Fetch the plugin manifest + const manifestUrl = `${GITHUB_RAW_URL}/${repository}/${branch}/${entry.path}/.claude-plugin/plugin.json`; + const manifestResponse = await fetch(manifestUrl); + + if (!manifestResponse.ok) continue; + + const manifestContent = await manifestResponse.text(); + const parseResult = PluginManifestParser.parseContent( + manifestContent, + manifestUrl + ); + + if (parseResult.manifest) { + plugins.push({ + name: parseResult.manifest.name, + description: parseResult.manifest.description, + version: parseResult.manifest.version, + author: parseResult.manifest.author, + keywords: parseResult.manifest.keywords, + category: parseResult.manifest.codemie?.category, + path: entry.path, + isExternal: false, + }); + } + } catch (error) { + logger.debug(`Skipping plugin ${entry.name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } catch (error) { + // Log but don't throw - allow partial results + logger.error( + `Failed to fetch plugins from ${directory}: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return plugins; + } + + /** + * Fetch external plugin references + */ + private async fetchExternalPlugins( + repository: string, + branch: string + ): Promise { + const plugins: MarketplacePluginEntry[] = []; + + try { + // Fetch external_plugins directory + const [owner, repo] = repository.split('/'); + const apiUrl = `${GITHUB_API_URL}/repos/${owner}/${repo}/contents/external_plugins?ref=${branch}`; + + const response = await fetch(apiUrl, { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'codemie-code', + }, + }); + + if (!response.ok) { + if (response.status === 404) { + return plugins; + } + throw new Error(`GitHub API error: ${response.status}`); + } + + const entries = (await response.json()) as Array<{ + name: string; + type: string; + path: string; + }>; + + // Process each external plugin JSON file + for (const entry of entries) { + if (entry.type !== 'file' || !entry.name.endsWith('.json')) continue; + + try { + const jsonUrl = `${GITHUB_RAW_URL}/${repository}/${branch}/${entry.path}`; + const jsonResponse = await fetch(jsonUrl); + + if (!jsonResponse.ok) continue; + + const content = (await jsonResponse.json()) as Record; + + // Validate with schema + const result = MarketplacePluginEntrySchema.safeParse({ + ...content, + isExternal: true, + path: entry.path, + }); + + if (result.success) { + plugins.push(result.data); + } + } catch (error) { + logger.debug(`Skipping external plugin ${entry.name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } catch (error) { + logger.debug(`Failed to fetch external plugins from ${repository}: ${error instanceof Error ? error.message : String(error)}`); + } + + return plugins; + } + + /** + * Load cached index from file + */ + private async loadCachedIndex(sourceId: string): Promise { + const cachePath = this.getCachePath(sourceId); + + try { + if (!existsSync(cachePath)) { + return null; + } + + const content = await readFile(cachePath, 'utf-8'); + const data = JSON.parse(content); + + const result = MarketplaceIndexSchema.safeParse(data); + if (!result.success) { + return null; + } + + return result.data; + } catch (error) { + logger.debug(`Failed to load cached index for ${sourceId}: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + /** + * Save index to file cache + */ + private async saveCachedIndex(sourceId: string, index: MarketplaceIndex): Promise { + const cachePath = this.getCachePath(sourceId); + + try { + await mkdir(dirname(cachePath), { recursive: true }); + await writeFile(cachePath, JSON.stringify(index, null, 2), 'utf-8'); + } catch (error) { + logger.debug(`Failed to save cached index for ${sourceId}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get cache file path for a source + */ + private getCachePath(sourceId: string): string { + const safeId = sourceId.replace(/[^a-zA-Z0-9-_]/g, '_'); + return getCodemiePath(CACHE_DIR, `marketplace-${safeId}.json`); + } + + /** + * Search for plugins across all provided sources + */ + async search( + query: string, + sources: MarketplaceSource[] + ): Promise { + const results: MarketplaceSearchResult[] = []; + const queryLower = query.toLowerCase(); + + for (const source of sources) { + if (!source.enabled) continue; + + try { + const index = await this.fetchIndex(source); + + for (const plugin of index.plugins) { + const score = this.calculateSearchScore(plugin, queryLower); + if (score > 0) { + results.push({ + plugin, + sourceId: source.id, + sourceName: source.name, + score, + }); + } + } + } catch (error) { + logger.debug(`Search failed for source ${source.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + // Sort by score (descending) + return results.sort((a, b) => b.score - a.score); + } + + /** + * Calculate search relevance score + */ + private calculateSearchScore( + plugin: MarketplacePluginEntry, + query: string + ): number { + let score = 0; + + // Exact name match + if (plugin.name.toLowerCase() === query) { + score += 100; + } + // Name contains query + else if (plugin.name.toLowerCase().includes(query)) { + score += 50; + } + + // Description contains query + if (plugin.description.toLowerCase().includes(query)) { + score += 20; + } + + // Keywords contain query + if (plugin.keywords?.some((k) => k.toLowerCase().includes(query))) { + score += 30; + } + + // Category matches + if (plugin.category?.toLowerCase().includes(query)) { + score += 25; + } + + // Author matches + if (plugin.author?.toLowerCase().includes(query)) { + score += 10; + } + + return score; + } + + /** + * Get plugin download info + */ + async getPluginDownloadInfo( + source: MarketplaceSource, + pluginName: string + ): Promise { + const index = await this.fetchIndex(source); + const plugin = index.plugins.find((p) => p.name === pluginName); + + if (!plugin) { + return null; + } + + const branch = source.branch || 'main'; + + // Handle external plugins + if (plugin.isExternal && plugin.externalRepo) { + return { + name: plugin.name, + downloadUrl: `https://github.com/${plugin.externalRepo}/archive/refs/heads/main.zip`, + repository: plugin.externalRepo, + branch: 'main', + path: '', + version: plugin.version, + }; + } + + // Internal plugin - need to download from the marketplace repo + + return { + name: plugin.name, + downloadUrl: `https://github.com/${source.repository}/archive/refs/heads/${branch}.zip`, + repository: source.repository, + branch, + path: plugin.path, + version: plugin.version, + }; + } + + /** + * Get plugin by name from a source + */ + async getPlugin( + source: MarketplaceSource, + pluginName: string + ): Promise { + const index = await this.fetchIndex(source); + return index.plugins.find((p) => p.name === pluginName) || null; + } + + /** + * Clear all caches + */ + clearCache(): void { + this.indexCache.clear(); + } + + /** + * Get cache directory path + */ + static getCacheDir(): string { + return getCodemiePath(CACHE_DIR); + } +} diff --git a/src/plugins/marketplace/MarketplaceRegistry.ts b/src/plugins/marketplace/MarketplaceRegistry.ts new file mode 100644 index 00000000..75603e84 --- /dev/null +++ b/src/plugins/marketplace/MarketplaceRegistry.ts @@ -0,0 +1,290 @@ +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { dirname } from 'path'; +import { existsSync } from 'fs'; +import { getCodemiePath } from '../../utils/paths.js'; +import { logger } from '../../utils/logger.js'; +import { MarketplaceConfigSchema } from './types.js'; +import type { MarketplaceSource, MarketplaceConfig } from './types.js'; + +/** + * Config file path within CodeMie home + */ +const CONFIG_FILE = 'marketplaces.json'; + +/** + * Default marketplace: Anthropic's official Claude plugins + */ +const DEFAULT_MARKETPLACE: MarketplaceSource = { + id: 'claude-plugins-official', + name: 'Claude Plugins (Official)', + type: 'github', + repository: 'anthropics/claude-plugins-official', + branch: 'main', + isDefault: true, + enabled: true, +}; + +/** + * Marketplace registry + * + * Manages marketplace sources configuration. + * Provides built-in default marketplace and user-configured sources. + */ +export class MarketplaceRegistry { + private static instance: MarketplaceRegistry; + private sources: MarketplaceSource[] = []; + private loaded = false; + + /** + * Private constructor (singleton pattern) + */ + private constructor() {} + + /** + * Get singleton instance + */ + static getInstance(): MarketplaceRegistry { + if (!MarketplaceRegistry.instance) { + MarketplaceRegistry.instance = new MarketplaceRegistry(); + } + return MarketplaceRegistry.instance; + } + + /** + * Load marketplace configuration + */ + async load(): Promise { + if (this.loaded) { + return; + } + + // Start with default marketplace + this.sources = [DEFAULT_MARKETPLACE]; + + // Load user configuration + try { + const config = await this.loadConfig(); + if (config) { + // Merge user sources (don't duplicate default) + for (const source of config.sources) { + if (source.id !== DEFAULT_MARKETPLACE.id) { + this.sources.push(source); + } + } + } + } catch (error) { + logger.debug(`Failed to load marketplace config, using defaults: ${error instanceof Error ? error.message : String(error)}`); + } + + this.loaded = true; + } + + /** + * Get all marketplace sources + */ + async getSources(): Promise { + await this.load(); + return [...this.sources]; + } + + /** + * Get enabled marketplace sources + */ + async getEnabledSources(): Promise { + await this.load(); + return this.sources.filter((s) => s.enabled); + } + + /** + * Get default marketplace source + */ + async getDefaultSource(): Promise { + await this.load(); + return this.sources.find((s) => s.isDefault) || DEFAULT_MARKETPLACE; + } + + /** + * Get marketplace source by ID + */ + async getSource(id: string): Promise { + await this.load(); + return this.sources.find((s) => s.id === id); + } + + /** + * Add a new marketplace source + */ + async addSource(source: Omit): Promise { + await this.load(); + + // Check for duplicates + if (this.sources.some((s) => s.id === source.id)) { + throw new Error(`Marketplace source with ID '${source.id}' already exists`); + } + + // Validate repository format + if (!source.repository.includes('/')) { + throw new Error('Repository must be in format owner/repo'); + } + + // Add the source + this.sources.push({ + ...source, + isDefault: false, + }); + + // Save configuration + await this.saveConfig(); + } + + /** + * Remove a marketplace source + */ + async removeSource(id: string): Promise { + await this.load(); + + // Cannot remove default + const source = this.sources.find((s) => s.id === id); + if (!source) { + throw new Error(`Marketplace source '${id}' not found`); + } + + if (source.isDefault) { + throw new Error('Cannot remove the default marketplace source'); + } + + // Remove the source + this.sources = this.sources.filter((s) => s.id !== id); + + // Save configuration + await this.saveConfig(); + } + + /** + * Enable/disable a marketplace source + */ + async setSourceEnabled(id: string, enabled: boolean): Promise { + await this.load(); + + const source = this.sources.find((s) => s.id === id); + if (!source) { + throw new Error(`Marketplace source '${id}' not found`); + } + + source.enabled = enabled; + + // Save configuration + await this.saveConfig(); + } + + /** + * Update a marketplace source + */ + async updateSource( + id: string, + updates: Partial> + ): Promise { + await this.load(); + + const index = this.sources.findIndex((s) => s.id === id); + if (index === -1) { + throw new Error(`Marketplace source '${id}' not found`); + } + + // Apply updates + this.sources[index] = { + ...this.sources[index], + ...updates, + }; + + // Save configuration + await this.saveConfig(); + } + + /** + * Load configuration from file + */ + private async loadConfig(): Promise { + const configPath = this.getConfigPath(); + + if (!existsSync(configPath)) { + return null; + } + + try { + const content = await readFile(configPath, 'utf-8'); + const data = JSON.parse(content); + + const result = MarketplaceConfigSchema.safeParse(data); + if (!result.success) { + return null; + } + + return result.data; + } catch (error) { + logger.debug(`Failed to parse marketplace config file: ${error instanceof Error ? error.message : String(error)}`); + return null; + } + } + + /** + * Save configuration to file + */ + private async saveConfig(): Promise { + const configPath = this.getConfigPath(); + + // Filter out default marketplace (it's always available) + const userSources = this.sources.filter((s) => !s.isDefault); + + const config: MarketplaceConfig = { + version: 1, + sources: userSources, + lastUpdated: new Date().toISOString(), + }; + + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); + } + + /** + * Get configuration file path + */ + private getConfigPath(): string { + return getCodemiePath(CONFIG_FILE); + } + + /** + * Reload configuration from file + */ + async reload(): Promise { + this.loaded = false; + await this.load(); + } + + /** + * Get marketplace source IDs + */ + async getSourceIds(): Promise { + await this.load(); + return this.sources.map((s) => s.id); + } + + /** + * Check if a source exists + */ + async hasSource(id: string): Promise { + await this.load(); + return this.sources.some((s) => s.id === id); + } + + /** + * Reset singleton instance (for testing) + */ + static resetInstance(): void { + if (MarketplaceRegistry.instance) { + MarketplaceRegistry.instance.sources = []; + MarketplaceRegistry.instance.loaded = false; + } + MarketplaceRegistry.instance = undefined as unknown as MarketplaceRegistry; + } +} diff --git a/src/plugins/marketplace/PluginInstaller.ts b/src/plugins/marketplace/PluginInstaller.ts new file mode 100644 index 00000000..e248034e --- /dev/null +++ b/src/plugins/marketplace/PluginInstaller.ts @@ -0,0 +1,466 @@ +import { mkdir, writeFile, rm, rename, readdir, copyFile } from 'fs/promises'; +import { join, dirname, basename } from 'path'; +import { existsSync, createWriteStream } from 'fs'; +import { pipeline } from 'stream/promises'; +import { Readable } from 'stream'; +import { getCodemiePath } from '../../utils/paths.js'; +import { logger } from '../../utils/logger.js'; +import { exec } from '../../utils/processes.js'; +import { PluginDiscovery } from '../core/PluginDiscovery.js'; +import { PluginManifestParser } from '../core/PluginManifestParser.js'; +import { validatePluginName } from '../core/types.js'; +import { MarketplaceClient } from './MarketplaceClient.js'; +import { MarketplaceRegistry } from './MarketplaceRegistry.js'; +import type { + PluginInstallOptions, + PluginInstallResult, + PluginUpdateInfo, + PluginDownloadInfo, +} from './types.js'; +import type { InstalledPluginMeta } from '../core/types.js'; + +/** + * Temporary directory for downloads + */ +const TEMP_DIR = 'tmp'; + +/** + * Installed plugin metadata filename + */ +const INSTALLED_META_FILE = 'plugin.installed.json'; + +/** + * Plugin installer + * + * Downloads and installs plugins from GitHub-based marketplaces. + * Handles extraction, validation, and metadata tracking. + */ +export class PluginInstaller { + private client: MarketplaceClient; + private registry: MarketplaceRegistry; + + constructor() { + this.client = new MarketplaceClient(); + this.registry = MarketplaceRegistry.getInstance(); + } + + /** + * Install a plugin from the marketplace + * + * @param pluginName - Name of the plugin to install + * @param options - Installation options + * @returns Installation result + */ + async install( + pluginName: string, + options: PluginInstallOptions = {} + ): Promise { + const { force = false, sourceId } = options; + + try { + validatePluginName(pluginName); + + // Get marketplace source + const source = sourceId + ? await this.registry.getSource(sourceId) + : await this.registry.getDefaultSource(); + + if (!source) { + return { + success: false, + pluginName, + version: '', + installedPath: '', + message: `Marketplace source '${sourceId}' not found`, + }; + } + + // Get plugin download info + const downloadInfo = await this.client.getPluginDownloadInfo(source, pluginName); + + if (!downloadInfo) { + return { + success: false, + pluginName, + version: '', + installedPath: '', + message: `Plugin '${pluginName}' not found in marketplace '${source.name}'`, + }; + } + + // Check if already installed + const pluginDir = PluginDiscovery.getPluginDir(pluginName); + if (existsSync(pluginDir) && !force) { + return { + success: false, + pluginName, + version: downloadInfo.version, + installedPath: pluginDir, + message: `Plugin '${pluginName}' is already installed. Use --force to reinstall.`, + }; + } + + // Download and extract + await this.downloadAndExtract(downloadInfo, pluginDir); + + // Write installation metadata + const installedMeta: InstalledPluginMeta = { + name: pluginName, + version: downloadInfo.version, + source: 'marketplace', + marketplaceId: source.id, + repositoryUrl: `https://github.com/${downloadInfo.repository}`, + installedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + commitHash: downloadInfo.commitHash, + }; + + await writeFile( + join(pluginDir, INSTALLED_META_FILE), + JSON.stringify(installedMeta, null, 2), + 'utf-8' + ); + + return { + success: true, + pluginName, + version: downloadInfo.version, + installedPath: pluginDir, + message: `Successfully installed plugin '${pluginName}' v${downloadInfo.version}`, + }; + } catch (error) { + return { + success: false, + pluginName, + version: '', + installedPath: '', + message: `Failed to install plugin '${pluginName}': ${error instanceof Error ? error.message : String(error)}`, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** + * Install a plugin from a local directory + * + * @param sourcePath - Path to the plugin directory + * @returns Installation result + */ + async installFromLocal(sourcePath: string): Promise { + try { + // Verify it's a valid plugin + if (!PluginManifestParser.hasManifest(sourcePath)) { + return { + success: false, + pluginName: basename(sourcePath), + version: '', + installedPath: '', + message: `Directory '${sourcePath}' is not a valid plugin (missing .claude-plugin/plugin.json)`, + }; + } + + // Parse manifest to get plugin name + const parseResult = await PluginManifestParser.parse(sourcePath); + if (!parseResult.manifest) { + return { + success: false, + pluginName: basename(sourcePath), + version: '', + installedPath: '', + message: `Invalid plugin manifest: ${parseResult.error?.message}`, + }; + } + + const pluginName = parseResult.manifest.name; + validatePluginName(pluginName); + const pluginDir = PluginDiscovery.getPluginDir(pluginName); + + // Copy to plugins directory + await this.copyDirectory(sourcePath, pluginDir); + + // Write installation metadata + const installedMeta: InstalledPluginMeta = { + name: pluginName, + version: parseResult.manifest.version, + source: 'local', + installedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await writeFile( + join(pluginDir, INSTALLED_META_FILE), + JSON.stringify(installedMeta, null, 2), + 'utf-8' + ); + + return { + success: true, + pluginName, + version: parseResult.manifest.version, + installedPath: pluginDir, + message: `Successfully installed plugin '${pluginName}' v${parseResult.manifest.version} from local directory`, + }; + } catch (error) { + return { + success: false, + pluginName: basename(sourcePath), + version: '', + installedPath: '', + message: `Failed to install from local directory: ${error instanceof Error ? error.message : String(error)}`, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** + * Uninstall a plugin + * + * @param pluginName - Name of the plugin to uninstall + * @returns Success status + */ + async uninstall(pluginName: string): Promise { + try { + validatePluginName(pluginName); + } catch (error) { + return { + success: false, + pluginName, + version: '', + installedPath: '', + message: error instanceof Error ? error.message : String(error), + }; + } + + const pluginDir = PluginDiscovery.getPluginDir(pluginName); + + if (!existsSync(pluginDir)) { + return { + success: false, + pluginName, + version: '', + installedPath: pluginDir, + message: `Plugin '${pluginName}' is not installed`, + }; + } + + try { + // Get version before removing + const parseResult = await PluginManifestParser.parse(pluginDir); + const version = parseResult.manifest?.version || 'unknown'; + + // Remove plugin directory + await rm(pluginDir, { recursive: true, force: true }); + + return { + success: true, + pluginName, + version, + installedPath: pluginDir, + message: `Successfully uninstalled plugin '${pluginName}'`, + }; + } catch (error) { + return { + success: false, + pluginName, + version: '', + installedPath: pluginDir, + message: `Failed to uninstall plugin '${pluginName}': ${error instanceof Error ? error.message : String(error)}`, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + } + + /** + * Update a plugin to the latest version + * + * @param pluginName - Name of the plugin to update + * @returns Update result + */ + async update(pluginName: string): Promise { + // Force reinstall + return this.install(pluginName, { force: true }); + } + + /** + * Check for plugin updates + * + * @param pluginName - Plugin name (or all if not specified) + * @returns Update information + */ + async checkForUpdates(pluginName?: string): Promise { + const updates: PluginUpdateInfo[] = []; + const discovery = new PluginDiscovery(); + + // Get installed plugins + const plugins = await discovery.discoverPlugins({ + pluginName, + forceReload: true, + }); + + for (const plugin of plugins) { + if (!plugin.installedMeta || plugin.isDevelopment) { + continue; + } + + try { + // Get marketplace source + const sourceId = plugin.installedMeta.marketplaceId; + const source = sourceId + ? await this.registry.getSource(sourceId) + : await this.registry.getDefaultSource(); + + if (!source) continue; + + // Get latest version from marketplace + const marketplacePlugin = await this.client.getPlugin(source, plugin.name); + + if (marketplacePlugin) { + updates.push({ + pluginName: plugin.name, + currentVersion: plugin.manifest.version, + latestVersion: marketplacePlugin.version, + // Simple string comparison — sufficient for detecting changes without adding a semver dependency + hasUpdate: marketplacePlugin.version !== plugin.manifest.version, + }); + } + } catch (error) { + logger.debug(`Failed to check updates for ${plugin.name}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + return updates; + } + + /** + * Download and extract a plugin + */ + private async downloadAndExtract( + downloadInfo: PluginDownloadInfo, + targetDir: string + ): Promise { + const tempDir = getCodemiePath(TEMP_DIR); + const tempFile = join(tempDir, `${downloadInfo.name}-${Date.now()}.zip`); + + try { + // Create temp directory + await mkdir(tempDir, { recursive: true }); + + // Download the archive + const response = await fetch(downloadInfo.downloadUrl); + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + + // Save to temp file + const fileStream = createWriteStream(tempFile); + if (!response.body) { + throw new Error('Download response has no body'); + } + // Convert Web ReadableStream to Node.js Readable + const nodeReadable = Readable.fromWeb(response.body as import('stream/web').ReadableStream); + await pipeline(nodeReadable, fileStream); + + // Extract the archive + await this.extractZip(tempFile, targetDir, downloadInfo.path); + } finally { + // Cleanup temp file + try { + await rm(tempFile, { force: true }); + } catch (error) { + logger.debug(`Failed to clean up temp file ${tempFile}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + /** + * Extract a ZIP archive + * + * Note: Using a simple approach with decompress package would be better, + * but for now we use unzip command as a simple solution + */ + private async extractZip( + zipPath: string, + targetDir: string, + subPath: string + ): Promise { + const tempExtractDir = join(dirname(zipPath), `extract-${Date.now()}`); + + try { + // Create temp extract directory + await mkdir(tempExtractDir, { recursive: true }); + + // Extract using unzip command (available on most systems) + await exec('unzip', ['-q', '-o', zipPath, '-d', tempExtractDir]); + + // Find the extracted content (usually in a subdirectory like repo-branch/) + const entries = await readdir(tempExtractDir); + if (entries.length === 0) { + throw new Error('Archive is empty'); + } + + // Get the root directory of extracted content + const extractedRoot = join(tempExtractDir, entries[0]); + + // Determine source path (could be a subdirectory within the archive) + let sourcePath = extractedRoot; + if (subPath) { + sourcePath = join(extractedRoot, subPath); + if (!existsSync(sourcePath)) { + throw new Error(`Plugin path '${subPath}' not found in archive`); + } + } + + // Remove existing target if any + if (existsSync(targetDir)) { + await rm(targetDir, { recursive: true, force: true }); + } + + // Create target parent directory + await mkdir(dirname(targetDir), { recursive: true }); + + // Move to target + await rename(sourcePath, targetDir); + } finally { + // Cleanup temp extract directory + try { + await rm(tempExtractDir, { recursive: true, force: true }); + } catch (error) { + logger.debug(`Failed to clean up temp extract dir ${tempExtractDir}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + /** + * Copy a directory recursively + */ + private async copyDirectory(source: string, target: string): Promise { + // Remove existing target + if (existsSync(target)) { + await rm(target, { recursive: true, force: true }); + } + + // Create target directory + await mkdir(target, { recursive: true }); + + // Copy all files + const entries = await readdir(source, { withFileTypes: true }); + + for (const entry of entries) { + const srcPath = join(source, entry.name); + const destPath = join(target, entry.name); + + if (entry.isDirectory()) { + await this.copyDirectory(srcPath, destPath); + } else { + await copyFile(srcPath, destPath); + } + } + } + + /** + * Get the temp directory path + */ + static getTempDir(): string { + return getCodemiePath(TEMP_DIR); + } +} diff --git a/src/plugins/marketplace/types.ts b/src/plugins/marketplace/types.ts new file mode 100644 index 00000000..89b2d864 --- /dev/null +++ b/src/plugins/marketplace/types.ts @@ -0,0 +1,238 @@ +import { z } from 'zod'; + +/** + * Marketplace source type + */ +export type MarketplaceSourceType = 'github'; + +/** + * Marketplace source configuration + */ +export interface MarketplaceSource { + /** Unique identifier for this source */ + id: string; + + /** Display name */ + name: string; + + /** Source type (currently only github) */ + type: MarketplaceSourceType; + + /** GitHub repository owner/repo (e.g., "anthropics/claude-plugins-official") */ + repository: string; + + /** Branch to use (default: main) */ + branch?: string; + + /** Whether this is the default marketplace */ + isDefault?: boolean; + + /** Whether this source is enabled */ + enabled: boolean; +} + +/** + * Zod schema for marketplace source + */ +export const MarketplaceSourceSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + type: z.literal('github'), + repository: z.string().min(1), + branch: z.string().optional(), + isDefault: z.boolean().optional(), + enabled: z.boolean().default(true), +}); + +/** + * Marketplace configuration stored at ~/.codemie/marketplaces.json + */ +export interface MarketplaceConfig { + /** Version of the config schema */ + version: number; + + /** List of marketplace sources */ + sources: MarketplaceSource[]; + + /** Last updated timestamp */ + lastUpdated: string; +} + +/** + * Zod schema for marketplace config + */ +export const MarketplaceConfigSchema = z.object({ + version: z.number().default(1), + sources: z.array(MarketplaceSourceSchema).default([]), + lastUpdated: z.string(), +}); + +/** + * Plugin entry in a marketplace index + */ +export interface MarketplacePluginEntry { + /** Plugin name */ + name: string; + + /** Plugin description */ + description: string; + + /** Current version */ + version: string; + + /** Author name */ + author?: string; + + /** Plugin keywords/tags */ + keywords?: string[]; + + /** Plugin category */ + category?: string; + + /** Path within the marketplace repo (e.g., "plugins/gitlab-tools") */ + path: string; + + /** Whether this is an external plugin reference */ + isExternal?: boolean; + + /** External repository (if isExternal is true) */ + externalRepo?: string; + + /** Last update timestamp */ + updatedAt?: string; + + /** Download count (if available) */ + downloads?: number; + + /** Star count (if available) */ + stars?: number; +} + +/** + * Marketplace index (cached locally) + */ +export interface MarketplaceIndex { + /** Marketplace source ID */ + sourceId: string; + + /** List of available plugins */ + plugins: MarketplacePluginEntry[]; + + /** Index version */ + version: number; + + /** When the index was fetched */ + fetchedAt: string; + + /** Index expiry time */ + expiresAt: string; +} + +/** + * Zod schema for marketplace plugin entry + */ +export const MarketplacePluginEntrySchema = z.object({ + name: z.string().min(1), + description: z.string(), + version: z.string(), + author: z.string().optional(), + keywords: z.array(z.string()).optional(), + category: z.string().optional(), + path: z.string(), + isExternal: z.boolean().optional(), + externalRepo: z.string().optional(), + updatedAt: z.string().optional(), + downloads: z.number().optional(), + stars: z.number().optional(), +}); + +/** + * Zod schema for marketplace index + */ +export const MarketplaceIndexSchema = z.object({ + sourceId: z.string(), + plugins: z.array(MarketplacePluginEntrySchema), + version: z.number().default(1), + fetchedAt: z.string(), + expiresAt: z.string(), +}); + +/** + * Result of a marketplace search + */ +export interface MarketplaceSearchResult { + /** Plugin entry */ + plugin: MarketplacePluginEntry; + + /** Marketplace source ID */ + sourceId: string; + + /** Marketplace source name */ + sourceName: string; + + /** Search relevance score */ + score: number; +} + +/** + * Plugin download information + */ +export interface PluginDownloadInfo { + /** Plugin name */ + name: string; + + /** Download URL (GitHub archive) */ + downloadUrl: string; + + /** Repository (owner/repo) */ + repository: string; + + /** Branch */ + branch: string; + + /** Path within repository */ + path: string; + + /** Version */ + version: string; + + /** Commit hash (if available) */ + commitHash?: string; +} + +/** + * Installation options + */ +export interface PluginInstallOptions { + /** Force reinstall even if already installed */ + force?: boolean; + + /** Specific version to install */ + version?: string; + + /** Source marketplace ID (uses default if not specified) */ + sourceId?: string; +} + +/** + * Installation result + */ +export interface PluginInstallResult { + success: boolean; + pluginName: string; + version: string; + installedPath: string; + message: string; + error?: Error; +} + +/** + * Update check result + */ +export interface PluginUpdateInfo { + pluginName: string; + currentVersion: string; + latestVersion: string; + hasUpdate: boolean; + updateUrl?: string; +}