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/package-lock.json b/package-lock.json index 7cd49d01..a1020103 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.43", "@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.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode/-/codemie-opencode-0.0.43.tgz", + "integrity": "sha512-Xx+LyjfM9o+yvKuDmqDs6ICeGpLJdM20Ov4qxSR1asU+y3XsM3kMcXZh2nwO+KwZO7XnN+tYrV//wtLQ1znzGQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "codemie": "bin/codemie" + }, + "optionalDependencies": { + "@codemieai/codemie-opencode-darwin-arm64": "0.0.43", + "@codemieai/codemie-opencode-darwin-x64": "0.0.43", + "@codemieai/codemie-opencode-darwin-x64-baseline": "0.0.43", + "@codemieai/codemie-opencode-linux-arm64": "0.0.43", + "@codemieai/codemie-opencode-linux-arm64-musl": "0.0.43", + "@codemieai/codemie-opencode-linux-x64": "0.0.43", + "@codemieai/codemie-opencode-linux-x64-baseline": "0.0.43", + "@codemieai/codemie-opencode-linux-x64-baseline-musl": "0.0.43", + "@codemieai/codemie-opencode-linux-x64-musl": "0.0.43", + "@codemieai/codemie-opencode-windows-x64": "0.0.43", + "@codemieai/codemie-opencode-windows-x64-baseline": "0.0.43" + } + }, + "node_modules/@codemieai/codemie-opencode-darwin-arm64": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-arm64/-/codemie-opencode-darwin-arm64-0.0.43.tgz", + "integrity": "sha512-hBNbGBcPDkTi1bnrBApTvTwDJ8NapFZmIJ+PiI+iqjyZm7ia1bDKWHFqHadTskByIzWHPtuoJ69cmDQsinJDxQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-darwin-x64": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64/-/codemie-opencode-darwin-x64-0.0.43.tgz", + "integrity": "sha512-o8mmFQkNyd3q5rRmhl2kLsDL2i108M7fP93J5IbvA+93hupzLHTSu3mL0EHtgkXj6HXezyLxYxqXmTRWTlTKcw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-darwin-x64-baseline": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64-baseline/-/codemie-opencode-darwin-x64-baseline-0.0.43.tgz", + "integrity": "sha512-vVRffBFDDR8qabBJHSgYfcUC5Ln6qCSKfFl5XXA6GprADTIWeoYa4NEe2hqmYwnMRJZaHn7vimRYT7LdMoyIPw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-arm64": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64/-/codemie-opencode-linux-arm64-0.0.43.tgz", + "integrity": "sha512-UP5mQmHcNRwAqxZLv8cZg5LRB0bLcyDYvCEE/Kzu+4Fm+xCtwgwGB0QvXA8NqDqX1cDfltr7Ro908xBXJiEuxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-arm64-musl": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64-musl/-/codemie-opencode-linux-arm64-musl-0.0.43.tgz", + "integrity": "sha512-axzhpE73AB7rWXclnnfRizHcfv5htCvWJDzAz9hx1wxlni7fcGFr3DSu94gWH/9t/xvoMp0qe8v/P99LtACEYw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64/-/codemie-opencode-linux-x64-0.0.43.tgz", + "integrity": "sha512-Vf5LdGufST7b5Hz0hFoTyFcnuwPbbVcjOYnlfBAmeZO/i9fHRHCXOJp0+slxNS4PAh88jQzWbNflA9c4AWs9EQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-baseline": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline/-/codemie-opencode-linux-x64-baseline-0.0.43.tgz", + "integrity": "sha512-kSSspYiowHcvXhcuyA6FhHuBHtNPUEehHmGZzbN1en3SqkPux5RZW5a0Y5Cvzvk1SbJCaLoi0i0yX9wK8yDq6A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-baseline-musl": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline-musl/-/codemie-opencode-linux-x64-baseline-musl-0.0.43.tgz", + "integrity": "sha512-Uj5fizw5UPfmdnQ3rcAN4uoDZvMHFne7ZMqmUQpLti0uYm1/JBtDSvUYZA5rXPf15SdZuJapXNFKY3IdAN2khQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-musl": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-musl/-/codemie-opencode-linux-x64-musl-0.0.43.tgz", + "integrity": "sha512-fwE6o3jBK6QayL/TbDFo310/Sfb+4RR+gduD56LbgK9urE4cmv6IRwvTehYYIUJ+l97c21H33IX9eQN2fU75kw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-windows-x64": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64/-/codemie-opencode-windows-x64-0.0.43.tgz", + "integrity": "sha512-Gl5fvNfB/CCuyt8tcKGvOWkXsYWk+NlttE+XSqfgDyL+1MjHDpJzg46SH3cGnM92WQfp8oI46BaWpuhJyIOLFQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@codemieai/codemie-opencode-windows-x64-baseline": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64-baseline/-/codemie-opencode-windows-x64-baseline-0.0.43.tgz", + "integrity": "sha512-4o0yFpGiwagsyBedtdVPunCe4mCxyI/WeKD64f3LEUVAKWgmhishbVkjSDtLYP7xHzF3KUklIOZ++t2dpn/B0g==", + "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..92e00e2f 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.43", "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/index.ts b/src/agents/codemie-code/skills/index.ts index 827b471b..572b808a 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, @@ -29,3 +33,11 @@ export { FrontmatterParseError, } from './utils/frontmatter.js'; export type { FrontmatterResult } from './utils/frontmatter.js'; + + +// Pattern matcher exports +export { + extractSkillPatterns, + isValidSkillName, +} 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..0d2a9436 --- /dev/null +++ b/src/agents/codemie-code/skills/sync/SkillSync.ts @@ -0,0 +1,242 @@ +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) { + const skillName = 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/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..02a53cbd 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', 'ollama', 'bedrock'], - 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..44c5f985 --- /dev/null +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts @@ -0,0 +1,532 @@ +/** + * 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 and getAllOpenCodeModelConfigs +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 }, + })), + getAllOpenCodeModelConfigs: vi.fn(() => ({ + 'gpt-5-2-2025-12-11': { + 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 }, + }, + 'claude-opus-4-6': { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + family: 'claude-4', + tool_call: true, + reasoning: true, + attachment: true, + temperature: 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 }, + }, + })), +})); + +// 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', 'ollama', 'amazon-bedrock']); + expect(parsed.provider['codemie-proxy']).toBeDefined(); + expect(parsed.provider['ollama']).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('provider.ollama'); + expect(parsed).toHaveProperty('model'); + expect(parsed).not.toHaveProperty('defaults'); + expect(parsed.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('includes all models in codemie-proxy without CodeMie-specific 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!); + const models = parsed.provider['codemie-proxy'].models; + + // Verify all models from getAllOpenCodeModelConfigs are present + expect(Object.keys(models)).toContain('gpt-5-2-2025-12-11'); + expect(Object.keys(models)).toContain('claude-opus-4-6'); + expect(Object.keys(models).length).toBe(2); // matches mock + + // Verify CodeMie-specific fields are stripped + for (const model of Object.values(models) as any[]) { + expect(model.displayName).toBeUndefined(); + expect(model.providerOptions).toBeUndefined(); + } + }); + + it('ollama provider has no models (auto-discovered)', 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.provider.ollama).toBeDefined(); + expect(parsed.provider.ollama.models).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..06e2e61f --- /dev/null +++ b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts @@ -0,0 +1,380 @@ +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, getAllOpenCodeModelConfigs } 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 | + * | OPENCODE_DISABLE_SHARE | beforeRun hook | Whitelabel | Disables share functionality | + * | 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', 'ollama', 'bedrock'], + 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 provider = env.CODEMIE_PROVIDER; + const baseUrl = env.CODEMIE_BASE_URL; + + if (!baseUrl) { + return env; + } + + if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { + logger.warn(`Invalid CODEMIE_BASE_URL format: ${baseUrl}`, { agent: 'codemie-opencode' }); + return env; + } + + const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; + const modelConfig = getModelConfig(selectedModel); + + const { providerOptions } = modelConfig; + + // Build all models for codemie-proxy (stripped of CodeMie-specific fields) + const allModels = getAllOpenCodeModelConfigs(); + + // Determine URLs + const proxyBaseUrl = provider !== 'ollama' ? baseUrl : undefined; + const ollamaBaseUrl = provider === 'ollama' + ? (baseUrl.endsWith('/v1') || baseUrl.includes('/v1/') ? baseUrl : `${baseUrl.replace(/\/$/, '')}/v1`) + : 'http://localhost:11434/v1'; + + // Determine default model provider + const activeProvider = provider === 'ollama' ? 'ollama' : 'codemie-proxy'; + const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; + + const openCodeConfig: Record = { + enabled_providers: ['codemie-proxy', 'ollama', 'amazon-bedrock'], + share: 'disabled', + provider: { + ...(proxyBaseUrl && { + 'codemie-proxy': { + npm: '@ai-sdk/openai-compatible', + name: 'CodeMie SSO', + options: { + baseURL: `${proxyBaseUrl}/`, + apiKey: 'proxy-handled', + timeout, + ...(providerOptions?.headers && { headers: providerOptions.headers }) + }, + models: allModels + } + }), + ollama: { + npm: '@ai-sdk/openai-compatible', + name: 'Ollama', + options: { + baseURL: `${ollamaBaseUrl}/`, + apiKey: 'ollama', + timeout, + } + } + }, + model: `${activeProvider}/${modelConfig.id}` + }; + + env.OPENCODE_DISABLE_SHARE = 'true'; + 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..fa04ab8d 100644 --- a/src/agents/plugins/opencode/opencode-model-configs.ts +++ b/src/agents/plugins/opencode/opencode-model-configs.ts @@ -189,17 +189,240 @@ 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 + } + } +}; + +/** + * Get all model configs stripped of CodeMie-specific fields (displayName, providerOptions). + * Used to populate all models in the OpenCode config so users can switch models during a session. + */ +export function getAllOpenCodeModelConfigs(): Record> { + const result: Record> = {}; + for (const [id, config] of Object.entries(OPENCODE_MODEL_CONFIGS)) { + const { displayName: _displayName, providerOptions: _providerOptions, ...opencodeConfig } = config; + result[id] = opencodeConfig; + } + return result; +} + +/** + * 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 - * defaults.model = "/" (e.g., "codemie-proxy/gpt-5-2-2025-12-11") + * model = "/" (e.g., "codemie-proxy/gpt-5-2-2025-12-11") */ export function getModelConfig(modelId: string): OpenCodeModelConfig { const config = OPENCODE_MODEL_CONFIGS[modelId]; @@ -207,34 +430,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/plugins/opencode/opencode.plugin.ts b/src/agents/plugins/opencode/opencode.plugin.ts index 9e991dc2..d166b169 100644 --- a/src/agents/plugins/opencode/opencode.plugin.ts +++ b/src/agents/plugins/opencode/opencode.plugin.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { writeFileSync, unlinkSync } from 'fs'; import { logger } from '../../../utils/logger.js'; -import { getModelConfig } from './opencode-model-configs.js'; +import { getModelConfig, getAllOpenCodeModelConfigs } from './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'; @@ -136,7 +136,7 @@ export const OpenCodePluginMetadata: AgentMetadata = { apiKey: [], model: [] }, - supportedProviders: ['litellm', 'ai-run-sso'], + supportedProviders: ['litellm', 'ai-run-sso', 'ollama'], ssoConfig: { enabled: true, clientType: 'codemie-opencode' }, lifecycle: { @@ -157,14 +157,15 @@ export const OpenCodePluginMetadata: AgentMetadata = { } } - const proxyUrl = env.CODEMIE_BASE_URL; + const provider = env.CODEMIE_PROVIDER; + const baseUrl = env.CODEMIE_BASE_URL; - if (!proxyUrl) { + if (!baseUrl) { return env; } - if (!proxyUrl.startsWith('http://') && !proxyUrl.startsWith('https://')) { - logger.warn(`Invalid CODEMIE_BASE_URL format: ${proxyUrl}`, { agent: 'opencode' }); + if (!baseUrl.startsWith('http://') && !baseUrl.startsWith('https://')) { + logger.warn(`Invalid CODEMIE_BASE_URL format: ${baseUrl}`, { agent: 'opencode' }); return env; } @@ -172,34 +173,52 @@ export const OpenCodePluginMetadata: AgentMetadata = { const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; const modelConfig = getModelConfig(selectedModel); - // Extract OpenCode-compatible model config (remove CodeMie-specific fields) - const { displayName: _displayName, providerOptions, ...opencodeModelConfig } = modelConfig; + const { providerOptions } = modelConfig; - const openCodeConfig = { - enabled_providers: ['codemie-proxy'], + // Build all models for codemie-proxy (stripped of CodeMie-specific fields) + const allModels = getAllOpenCodeModelConfigs(); + + // Determine URLs + const proxyBaseUrl = provider !== 'ollama' ? baseUrl : undefined; + const ollamaBaseUrl = provider === 'ollama' + ? (baseUrl.endsWith('/v1') || baseUrl.includes('/v1/') ? baseUrl : `${baseUrl.replace(/\/$/, '')}/v1`) + : 'http://localhost:11434/v1'; + + // Determine default model provider + const activeProvider = provider === 'ollama' ? 'ollama' : 'codemie-proxy'; + const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; + + const openCodeConfig: Record = { + enabled_providers: ['codemie-proxy', 'ollama'], + share: 'disabled', provider: { - 'codemie-proxy': { + ...(proxyBaseUrl && { + 'codemie-proxy': { + npm: '@ai-sdk/openai-compatible', + name: 'CodeMie SSO', + options: { + baseURL: `${proxyBaseUrl}/`, + apiKey: 'proxy-handled', + timeout, + ...(providerOptions?.headers && { headers: providerOptions.headers }) + }, + models: allModels + } + }), + ollama: { npm: '@ai-sdk/openai-compatible', - name: 'CodeMie SSO', + name: 'Ollama', options: { - baseURL: `${proxyUrl}/`, - apiKey: 'proxy-handled', - timeout: providerOptions?.timeout || - parseInt(env.CODEMIE_TIMEOUT || '600') * 1000, - ...(providerOptions?.headers && { - headers: providerOptions.headers - }) - }, - models: { - [modelConfig.id]: opencodeModelConfig + baseURL: `${ollamaBaseUrl}/`, + apiKey: 'ollama', + timeout, } } }, - defaults: { - model: `codemie-proxy/${modelConfig.id}` - } + model: `${activeProvider}/${modelConfig.id}` }; + env.OPENCODE_DISABLE_SHARE = 'true'; const configJson = JSON.stringify(openCodeConfig); // Config injection strategy: 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/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..b7bd9e3f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -70,26 +70,17 @@ 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);