From be6bc00f21a0e8c5e85f8abf076c9ccda7534a85 Mon Sep 17 00:00:00 2001 From: Maksym Diabin Date: Thu, 19 Feb 2026 18:43:41 +0100 Subject: [PATCH 1/5] refactor(agents): replace codemie-code LangGraph agent with OpenCode binary wrapper - Replace built-in LangGraph agent with OpenCode binary execution - Add codemie-opencode plugin with binary management and lifecycle - Add SkillSync for synchronizing skills to agent plugins - Bump @codemieai/codemie-opencode to 0.0.41 - Add unit tests for OpenCode plugin system Generated with AI Co-Authored-By: codemie-ai --- bin/codemie-opencode.js | 4 +- package-lock.json | 167 ++++++ package.json | 1 + src/agents/__tests__/registry.test.ts | 15 +- src/agents/codemie-code/agent.ts | 8 +- src/agents/codemie-code/index.ts | 4 +- src/agents/codemie-code/prompts.ts | 2 +- .../skills/core/SkillDiscovery.ts | 288 +++++++++++ .../codemie-code/skills/core/SkillManager.ts | 186 +++++++ src/agents/codemie-code/skills/core/types.ts | 141 ++++++ src/agents/codemie-code/skills/index.ts | 42 ++ .../codemie-code/skills/sync/SkillSync.ts | 242 +++++++++ .../skills/utils/content-loader.test.ts | 184 +++++++ .../skills/utils/content-loader.ts | 212 ++++++++ .../skills/utils/frontmatter.test.ts | 372 ++++++++++++++ .../codemie-code/skills/utils/frontmatter.ts | 153 ++++++ .../skills/utils/pattern-matcher.test.ts | 222 ++++++++ .../skills/utils/pattern-matcher.ts | 134 +++++ src/agents/codemie-code/types.ts | 2 +- src/agents/core/AgentCLI.ts | 24 +- .../__tests__/codemie-code-plugin.test.ts | 228 +++++++++ src/agents/plugins/codemie-code.plugin.ts | 268 +++++----- .../__tests__/codemie-opencode-binary.test.ts | 125 +++++ .../codemie-opencode-lifecycle.test.ts | 476 ++++++++++++++++++ .../__tests__/codemie-opencode-plugin.test.ts | 143 ++++++ .../codemie-opencode-binary.ts | 104 ++++ .../codemie-opencode.plugin.ts | 359 +++++++++++++ src/agents/plugins/codemie-opencode/index.ts | 2 + .../opencode/opencode-model-configs.ts | 257 +++++++++- src/agents/registry.ts | 2 + src/cli/commands/codemie-opencode-metrics.ts | 234 +++++++++ src/cli/commands/hook.ts | 25 + src/cli/commands/skill.ts | 93 +++- src/cli/index.ts | 23 +- src/skills/index.ts | 6 +- 35 files changed, 4523 insertions(+), 225 deletions(-) create mode 100644 src/agents/codemie-code/skills/core/SkillDiscovery.ts create mode 100644 src/agents/codemie-code/skills/core/SkillManager.ts create mode 100644 src/agents/codemie-code/skills/core/types.ts create mode 100644 src/agents/codemie-code/skills/index.ts create mode 100644 src/agents/codemie-code/skills/sync/SkillSync.ts create mode 100644 src/agents/codemie-code/skills/utils/content-loader.test.ts create mode 100644 src/agents/codemie-code/skills/utils/content-loader.ts create mode 100644 src/agents/codemie-code/skills/utils/frontmatter.test.ts create mode 100644 src/agents/codemie-code/skills/utils/frontmatter.ts create mode 100644 src/agents/codemie-code/skills/utils/pattern-matcher.test.ts create mode 100644 src/agents/codemie-code/skills/utils/pattern-matcher.ts create mode 100644 src/agents/plugins/__tests__/codemie-code-plugin.test.ts create mode 100644 src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts create mode 100644 src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts create mode 100644 src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts create mode 100644 src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts create mode 100644 src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts create mode 100644 src/agents/plugins/codemie-opencode/index.ts create mode 100644 src/cli/commands/codemie-opencode-metrics.ts 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 b4d0c3d4..0a6643a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@aws-sdk/credential-providers": "^3.948.0", "@clack/core": "^0.5.0", "@clack/prompts": "^0.11.0", + "@codemieai/codemie-opencode": "0.0.41", "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", @@ -1011,6 +1012,172 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@codemieai/codemie-opencode": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode/-/codemie-opencode-0.0.41.tgz", + "integrity": "sha512-Vu+sdpusP7h4HiijijQts08Dun3gikH2tsRNN0Gu4ghyeU6S5xVlPUvxMbWAuUc0NnRHSZkJXSI8nqWgLD+DlA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "codemie": "bin/codemie" + }, + "optionalDependencies": { + "@codemieai/codemie-opencode-darwin-arm64": "0.0.41", + "@codemieai/codemie-opencode-darwin-x64": "0.0.41", + "@codemieai/codemie-opencode-darwin-x64-baseline": "0.0.41", + "@codemieai/codemie-opencode-linux-arm64": "0.0.41", + "@codemieai/codemie-opencode-linux-arm64-musl": "0.0.41", + "@codemieai/codemie-opencode-linux-x64": "0.0.41", + "@codemieai/codemie-opencode-linux-x64-baseline": "0.0.41", + "@codemieai/codemie-opencode-linux-x64-baseline-musl": "0.0.41", + "@codemieai/codemie-opencode-linux-x64-musl": "0.0.41", + "@codemieai/codemie-opencode-windows-x64": "0.0.41", + "@codemieai/codemie-opencode-windows-x64-baseline": "0.0.41" + } + }, + "node_modules/@codemieai/codemie-opencode-darwin-arm64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-arm64/-/codemie-opencode-darwin-arm64-0.0.41.tgz", + "integrity": "sha512-6AeHnSEC0beU4oaZhgOgVA4N2mi4ElzKzh5C6L4zia88ClqjKVRqwfJq8tv4Zjo1uRj50YlXKiuqmSfA1EOU0A==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-darwin-x64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64/-/codemie-opencode-darwin-x64-0.0.41.tgz", + "integrity": "sha512-RC8/JGInxb9A0zLq23ylU+tv+g2i99+O7ZlPI0KQ6ZvgPnTfeNat9iidpe3KJbP/aIHN20SSmNeLt63VpDWTXQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-darwin-x64-baseline": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64-baseline/-/codemie-opencode-darwin-x64-baseline-0.0.41.tgz", + "integrity": "sha512-FFLGDxLK7CzT1/u9pV9ixcHS2qWzmhzIMQQHFNH/75yPAzl2FoBZpTA51UE6T3R8C9ApxVLIRh3pIf5f8WQ1Hw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-arm64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64/-/codemie-opencode-linux-arm64-0.0.41.tgz", + "integrity": "sha512-Ox8ZDce4rguOYrlCIawaZlHnT48iMakN3B32V/4L8f8vLE9n2PpntqRxEkawc6cT6/PX6ENF3J/1hv7YIbMJEQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-arm64-musl": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64-musl/-/codemie-opencode-linux-arm64-musl-0.0.41.tgz", + "integrity": "sha512-At7XlLktoyCssfVJyXynfn98z8f96aOGAk51MEIgfzg4/2a6p2GHbT58mAhEfPsOzXAtO7HFxIkUKI83RFP8cA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64/-/codemie-opencode-linux-x64-0.0.41.tgz", + "integrity": "sha512-Pn5VcATJGZcX6MAD6/PAMxwTWnBCrITujXtiyBygSbkkSoKrNuKDc5JvBInkhQJ/uHYUrE6qrZdOD1aazLKWSQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-baseline": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline/-/codemie-opencode-linux-x64-baseline-0.0.41.tgz", + "integrity": "sha512-tiYdJXXY7qs5Ht3aIN0qQh2NU3NkgeNc3RteL+IhNygWVfS+gt6pXhzbfC6VnPQ/v8pfca205uJdhzIGFflVRg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-baseline-musl": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline-musl/-/codemie-opencode-linux-x64-baseline-musl-0.0.41.tgz", + "integrity": "sha512-8o4EWBKUDyihZFS71qxsCm3cI/LxnH9MPjk5d4EnLltjpRW6DXDTZ6vJ85YBlTg5C1F9JYVUdWPZV0wHxMyINw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-linux-x64-musl": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-musl/-/codemie-opencode-linux-x64-musl-0.0.41.tgz", + "integrity": "sha512-rOGSDXHrZ1ovTVW6aKKrHjb1vGAQ4NzEHYIsulyZ3ja2jUtd+o43ybkGfnXzNlCFZt2spSFUAOFhc2o5+Ru/+A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@codemieai/codemie-opencode-windows-x64": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64/-/codemie-opencode-windows-x64-0.0.41.tgz", + "integrity": "sha512-auMpuQHYI1FIUy6UGUaZkCGZWXfgmW/L1VkK4LM9umJOaUemUpyWdR92lv2sYEnU8YtD4k1pmxO+WLzU5ZVxqw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@codemieai/codemie-opencode-windows-x64-baseline": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64-baseline/-/codemie-opencode-windows-x64-baseline-0.0.41.tgz", + "integrity": "sha512-dJvx7C4xsmueiyqXKL4dOXBatFwYArOYwpdPIAms/udsddVYmJ6HCxyLVY6+z0X7MFFoF7rN53SANTVcaz15QQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", diff --git a/package.json b/package.json index 21a1f2a1..1586d245 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "ora": "^7.0.1", "strip-ansi": "^7.1.2", "yaml": "^2.3.4", + "@codemieai/codemie-opencode": "0.0.41", "zod": "^4.1.12" }, "devDependencies": { diff --git a/src/agents/__tests__/registry.test.ts b/src/agents/__tests__/registry.test.ts index 072a05b9..9b1507d3 100644 --- a/src/agents/__tests__/registry.test.ts +++ b/src/agents/__tests__/registry.test.ts @@ -12,8 +12,8 @@ describe('AgentRegistry', () => { it('should register all default agents', () => { const agentNames = AgentRegistry.getAgentNames(); - // Should have all 5 default agents (codemie-code, claude, claude-acp, gemini, opencode) - expect(agentNames).toHaveLength(5); + // Should have all 6 default agents (codemie-code, claude, claude-acp, gemini, opencode, codemie-opencode) + expect(agentNames).toHaveLength(6); }); it('should register built-in agent', () => { @@ -62,7 +62,7 @@ describe('AgentRegistry', () => { it('should return all registered agents', () => { const agents = AgentRegistry.getAllAgents(); - expect(agents).toHaveLength(5); + expect(agents).toHaveLength(6); expect(agents.every((agent) => agent.name)).toBe(true); }); @@ -74,6 +74,7 @@ describe('AgentRegistry', () => { expect(names).toContain('claude-acp'); expect(names).toContain('gemini'); expect(names).toContain('opencode'); + expect(names).toContain('codemie-opencode'); }); }); @@ -114,11 +115,11 @@ describe('AgentRegistry', () => { } }); - it('should include built-in agent in installed agents', async () => { - const installedAgents = await AgentRegistry.getInstalledAgents(); + it('should include built-in agent in all agents', () => { + const allAgents = AgentRegistry.getAllAgents(); - // Built-in agent should always be "installed" - const builtInAgent = installedAgents.find( + // Built-in agent should always be registered + const builtInAgent = allAgents.find( (agent) => agent.name === BUILTIN_AGENT_NAME ); diff --git a/src/agents/codemie-code/agent.ts b/src/agents/codemie-code/agent.ts index c1ba8412..38915b30 100644 --- a/src/agents/codemie-code/agent.ts +++ b/src/agents/codemie-code/agent.ts @@ -21,10 +21,10 @@ import { logger } from '@/utils/logger.js'; import { sanitizeCookies, sanitizeAuthToken } from '@/utils/security.js'; import { HookExecutor } from '../../hooks/executor.js'; import type { HookExecutionContext } from '../../hooks/types.js'; -import type { Skill } from '../../skills/index.js'; -import { extractSkillPatterns } from '../../skills/utils/pattern-matcher.js'; -import type { SkillPattern, SkillWithInventory } from '../../skills/core/types.js'; -import { SkillManager } from '../../skills/core/SkillManager.js'; +import type { Skill } from './skills/index.js'; +import { extractSkillPatterns } from './skills/utils/pattern-matcher.js'; +import type { SkillPattern, SkillWithInventory } from './skills/core/types.js'; +import { SkillManager } from './skills/core/SkillManager.js'; import { parseAtMentionCommand } from './ui/mentions.js'; import { loadRegisteredAssistants } from '@/utils/config.js'; diff --git a/src/agents/codemie-code/index.ts b/src/agents/codemie-code/index.ts index e4255e81..9465b25e 100644 --- a/src/agents/codemie-code/index.ts +++ b/src/agents/codemie-code/index.ts @@ -13,8 +13,8 @@ import { CodeMieAgentError } from './types.js'; import { hasClipboardImage, getClipboardImage } from '../../utils/clipboard.js'; import { logger } from '../../utils/logger.js'; import { sanitizeCookies } from '../../utils/security.js'; -import { SkillManager } from '../../skills/index.js'; -import type { Skill } from '../../skills/index.js'; +import { SkillManager } from './skills/index.js'; +import type { Skill } from './skills/index.js'; export class CodeMieCode { private agent: CodeMieAgent | null = null; diff --git a/src/agents/codemie-code/prompts.ts b/src/agents/codemie-code/prompts.ts index dba9e4f0..bbafe689 100644 --- a/src/agents/codemie-code/prompts.ts +++ b/src/agents/codemie-code/prompts.ts @@ -4,7 +4,7 @@ * Contains the system prompt and instructions for the LangGraph ReAct agent */ -import type { Skill } from '../../skills/index.js'; +import type { Skill } from './skills/index.js'; import type { CodemieAssistant } from '@/env/types.js'; export const SYSTEM_PROMPT = `You are CodeMie, an advanced AI coding assistant designed to help developers with various programming tasks. diff --git a/src/agents/codemie-code/skills/core/SkillDiscovery.ts b/src/agents/codemie-code/skills/core/SkillDiscovery.ts new file mode 100644 index 00000000..deb0cd3e --- /dev/null +++ b/src/agents/codemie-code/skills/core/SkillDiscovery.ts @@ -0,0 +1,288 @@ +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import fg from 'fast-glob'; +import { getCodemiePath } from '../../../../utils/paths.js'; +import { parseFrontmatter, FrontmatterParseError } from '../utils/frontmatter.js'; +import { SkillMetadataSchema } from './types.js'; +import type { + Skill, + SkillMetadata, + SkillSource, + SkillParseResult, + SkillDiscoveryOptions, +} from './types.js'; + +/** + * Priority multipliers for skill sources + * Higher priority = loaded first, can override lower priority + */ +const SOURCE_PRIORITY: Record = { + project: 1000, // Highest priority + 'mode-specific': 500, // Medium priority + global: 100, // Lowest priority +}; + +/** + * Skill discovery engine + * + * Discovers SKILL.md files from multiple locations, parses frontmatter, + * validates metadata, and applies priority sorting. + */ +export class SkillDiscovery { + private cache: Map = new Map(); + + /** + * Discover all skills matching the given options + * + * @param options - Discovery options (cwd, mode, agent, forceReload) + * @returns Array of discovered and validated skills, sorted by priority + */ + async discoverSkills(options: SkillDiscoveryOptions = {}): Promise { + const { cwd = process.cwd(), forceReload = false } = options; + + // Check cache first + const cacheKey = this.getCacheKey(options); + if (!forceReload && this.cache.has(cacheKey)) { + return this.cache.get(cacheKey)!; + } + + // Discover from all locations + const [projectSkills, modeSkills, globalSkills] = await Promise.all([ + this.discoverProjectSkills(cwd), + this.discoverModeSkills(options.mode), + this.discoverGlobalSkills(), + ]); + + // Combine and deduplicate by name (higher priority wins) + const allSkills = [...projectSkills, ...modeSkills, ...globalSkills]; + const deduplicatedSkills = this.deduplicateSkills(allSkills); + + // Filter by agent if specified + let filteredSkills = deduplicatedSkills; + if (options.agentName) { + filteredSkills = this.filterByAgent(deduplicatedSkills, options.agentName); + } + + // Filter by mode if specified + if (options.mode) { + filteredSkills = this.filterByMode(filteredSkills, options.mode); + } + + // Sort by computed priority (descending) + const sortedSkills = this.sortByPriority(filteredSkills); + + // Cache result + this.cache.set(cacheKey, sortedSkills); + + return sortedSkills; + } + + /** + * Discover skills from project directory (.codemie/skills/) + */ + private async discoverProjectSkills(cwd: string): Promise { + const projectSkillsDir = join(cwd, '.codemie', 'skills'); + return this.discoverFromDirectory(projectSkillsDir, 'project'); + } + + /** + * Discover skills from mode-specific directory (~/.codemie/skills-{mode}/) + */ + private async discoverModeSkills(mode?: string): Promise { + if (!mode) return []; + + const modeSkillsDir = getCodemiePath(`skills-${mode}`); + return this.discoverFromDirectory(modeSkillsDir, 'mode-specific'); + } + + /** + * Discover skills from global directory (~/.codemie/skills/) + */ + private async discoverGlobalSkills(): Promise { + const globalSkillsDir = getCodemiePath('skills'); + return this.discoverFromDirectory(globalSkillsDir, 'global'); + } + + /** + * Discover skills from a specific directory + * + * @param directory - Directory to search + * @param source - Source type (project, mode-specific, global) + * @returns Array of discovered skills + */ + private async discoverFromDirectory( + directory: string, + source: SkillSource + ): Promise { + try { + // Find all SKILL.md files (case-insensitive, up to 3 levels deep) + const pattern = '**/SKILL.md'; + const files = await fg(pattern, { + cwd: directory, + absolute: true, + caseSensitiveMatch: false, + deep: 3, + ignore: ['**/node_modules/**', '**/.git/**'], + onlyFiles: true, + }); + + // Parse all skill files + const parseResults = await Promise.all( + files.map((filePath) => this.parseSkillFile(filePath, source)) + ); + + // Filter out errors and return valid skills + const skills = parseResults + .filter((result): result is { skill: Skill } => result.skill !== undefined) + .map((result) => result.skill); + + return skills; + } catch { + // Directory doesn't exist or other error - return empty array + return []; + } + } + + /** + * Parse a single skill file + * + * @param filePath - Absolute path to SKILL.md + * @param source - Source type + * @returns Parse result with skill or error + */ + private async parseSkillFile( + filePath: string, + source: SkillSource + ): Promise { + try { + // Read file + const fileContent = await readFile(filePath, 'utf-8'); + + // Parse frontmatter + const { metadata, content } = parseFrontmatter(fileContent, filePath); + + // Validate metadata with Zod + const validatedMetadata = SkillMetadataSchema.parse(metadata); + + // Compute priority + const computedPriority = this.computePriority(validatedMetadata, source); + + // Create skill object + const skill: Skill = { + metadata: validatedMetadata, + content: content.trim(), + filePath, + source, + computedPriority, + }; + + return { skill }; + } catch (error) { + // Log error but don't throw - allow partial discovery + const errorMessage = + error instanceof FrontmatterParseError + ? error.message + : error instanceof Error + ? error.message + : String(error); + + return { + error: { + filePath, + message: errorMessage, + cause: error, + }, + }; + } + } + + /** + * Compute priority for a skill + * + * Priority = source base priority + metadata priority + */ + private computePriority(metadata: SkillMetadata, source: SkillSource): number { + const sourceBasePriority = SOURCE_PRIORITY[source]; + const metadataPriority = metadata.priority || 0; + return sourceBasePriority + metadataPriority; + } + + /** + * Deduplicate skills by name (higher priority wins) + */ + private deduplicateSkills(skills: Skill[]): Skill[] { + const skillMap = new Map(); + + for (const skill of skills) { + const existingSkill = skillMap.get(skill.metadata.name); + + if (!existingSkill || skill.computedPriority > existingSkill.computedPriority) { + skillMap.set(skill.metadata.name, skill); + } + } + + return Array.from(skillMap.values()); + } + + /** + * Filter skills by agent compatibility + */ + private filterByAgent(skills: Skill[], agentName: string): Skill[] { + return skills.filter((skill) => { + // If no compatibility specified, skill is compatible with all agents + if (!skill.metadata.compatibility?.agents) { + return true; + } + + // Check if agent is in compatibility list + return skill.metadata.compatibility.agents.includes(agentName); + }); + } + + /** + * Filter skills by mode + */ + private filterByMode(skills: Skill[], mode: string): Skill[] { + return skills.filter((skill) => { + // If no modes specified, skill is available in all modes + if (!skill.metadata.modes || skill.metadata.modes.length === 0) { + return true; + } + + // Check if mode is in modes list + return skill.metadata.modes.includes(mode); + }); + } + + /** + * Sort skills by computed priority (descending) + */ + private sortByPriority(skills: Skill[]): Skill[] { + return [...skills].sort((a, b) => b.computedPriority - a.computedPriority); + } + + /** + * Generate cache key from options + */ + private getCacheKey(options: SkillDiscoveryOptions): string { + const { cwd = process.cwd(), mode, agentName } = options; + return `${cwd}::${mode || ''}::${agentName || ''}`; + } + + /** + * Clear cache (force reload on next discovery) + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Get cache statistics (for debugging) + */ + getCacheStats(): { size: number; keys: string[] } { + return { + size: this.cache.size, + keys: Array.from(this.cache.keys()), + }; + } +} diff --git a/src/agents/codemie-code/skills/core/SkillManager.ts b/src/agents/codemie-code/skills/core/SkillManager.ts new file mode 100644 index 00000000..dc5a14bb --- /dev/null +++ b/src/agents/codemie-code/skills/core/SkillManager.ts @@ -0,0 +1,186 @@ +import { SkillDiscovery } from './SkillDiscovery.js'; +import type { + Skill, + SkillDiscoveryOptions, + SkillValidationResult, + SkillWithInventory, +} from './types.js'; +import { loadSkillWithInventory } from '../utils/content-loader.js'; +import { logger } from '../../../../utils/logger.js'; + +/** + * Skill manager singleton + * + * Provides high-level API for skill management: + * - Discovery and caching + * - Agent-aware filtering + * - Validation + * - Cache management + */ +export class SkillManager { + private static instance: SkillManager; + private discovery: SkillDiscovery; + + /** + * Private constructor (singleton pattern) + */ + private constructor() { + this.discovery = new SkillDiscovery(); + } + + /** + * Get singleton instance + */ + static getInstance(): SkillManager { + if (!SkillManager.instance) { + SkillManager.instance = new SkillManager(); + } + return SkillManager.instance; + } + + /** + * Get skills for a specific agent + * + * @param agentName - Name of the agent (e.g., 'codemie-code') + * @param options - Additional discovery options + * @returns Array of skills compatible with the agent + */ + async getSkillsForAgent( + agentName: string, + options: Omit = {} + ): Promise { + return this.discovery.discoverSkills({ + ...options, + agentName, + }); + } + + /** + * Get a specific skill by name + * + * @param name - Skill name + * @param options - Discovery options + * @returns Skill if found, undefined otherwise + */ + async getSkillByName( + name: string, + options: SkillDiscoveryOptions = {} + ): Promise { + const skills = await this.discovery.discoverSkills(options); + return skills.find((skill) => skill.metadata.name === name); + } + + /** + * List all discovered skills + * + * @param options - Discovery options + * @returns Array of all skills + */ + async listSkills(options: SkillDiscoveryOptions = {}): Promise { + return this.discovery.discoverSkills(options); + } + + /** + * Get multiple skills by names with file inventory + * + * Used for pattern-based invocation when /skill-name patterns are detected. + * Loads skill content and builds file inventory for each requested skill. + * + * @param names - Array of skill names to load + * @param options - Discovery options + * @returns Array of skills with inventory (only found skills) + */ + async getSkillsByNames( + names: string[], + options: SkillDiscoveryOptions = {} + ): Promise { + const results: SkillWithInventory[] = []; + + // Discover all skills (uses cache) + const allSkills = await this.discovery.discoverSkills(options); + + // Find and load each requested skill + for (const name of names) { + const skill = allSkills.find((s) => s.metadata.name === name); + + if (skill) { + try { + const withInventory = await loadSkillWithInventory(skill); + results.push(withInventory); + } catch (error) { + logger.warn(`Failed to load inventory for skill '${name}':`, error); + } + } else { + logger.debug(`Skill '${name}' not found during pattern invocation`); + } + } + + return results; + } + + /** + * Reload skills (clear cache and force re-discovery) + * + * Call this after adding/removing/modifying skill files + */ + reload(): void { + this.discovery.clearCache(); + } + + /** + * Validate all skill files + * + * Attempts to discover and parse all skills, returning validation results + * + * @param options - Discovery options + * @returns Validation results for all skills + */ + async validateAll( + options: SkillDiscoveryOptions = {} + ): Promise<{ valid: Skill[]; invalid: SkillValidationResult[] }> { + try { + // Force reload to ensure fresh validation + const skills = await this.discovery.discoverSkills({ + ...options, + forceReload: true, + }); + + // All discovered skills are valid (parsing errors are filtered out in discovery) + const valid = skills; + + // For now, we don't track invalid skills (they're silently filtered) + // Future enhancement: return parse errors from discovery + const invalid: SkillValidationResult[] = []; + + return { valid, invalid }; + } catch (error) { + // Discovery failed entirely + return { + valid: [], + invalid: [ + { + valid: false, + filePath: 'unknown', + errors: [ + error instanceof Error ? error.message : String(error), + ], + }, + ], + }; + } + } + + /** + * Get cache statistics (for debugging) + */ + getCacheStats(): { size: number; keys: string[] } { + return this.discovery.getCacheStats(); + } + + /** + * Reset singleton instance (for testing) + */ + static resetInstance(): void { + SkillManager.instance = undefined as unknown as SkillManager; + } +} diff --git a/src/agents/codemie-code/skills/core/types.ts b/src/agents/codemie-code/skills/core/types.ts new file mode 100644 index 00000000..59a7d185 --- /dev/null +++ b/src/agents/codemie-code/skills/core/types.ts @@ -0,0 +1,141 @@ +import { z } from 'zod'; + +/** + * Zod schema for skill metadata (frontmatter) + */ +export const SkillMetadataSchema = z.object({ + name: z.string().min(1, 'Skill name is required'), + description: z.string().min(1, 'Skill description is required'), + version: z.string().optional(), + author: z.string().optional(), + license: z.string().optional(), + modes: z.array(z.string()).optional(), + compatibility: z + .object({ + agents: z.array(z.string()).optional(), + minVersion: z.string().optional(), + }) + .optional(), + priority: z.number().default(0), +}); + +/** + * TypeScript interface for skill metadata + */ +export type SkillMetadata = z.infer; + +/** + * Source type for a skill + */ +export type SkillSource = 'global' | 'project' | 'mode-specific'; + +/** + * Complete skill with metadata, content, and location info + */ +export interface Skill { + /** Parsed and validated metadata from YAML frontmatter */ + metadata: SkillMetadata; + + /** Markdown content (body after frontmatter) */ + content: string; + + /** Absolute path to the SKILL.md file */ + filePath: string; + + /** Where this skill was discovered from */ + source: SkillSource; + + /** Computed priority (source-based + metadata priority) */ + computedPriority: number; +} + +/** + * Result of parsing a skill file + */ +export interface SkillParseResult { + skill?: Skill; + error?: { + filePath: string; + message: string; + cause?: unknown; + }; +} + +/** + * Options for skill discovery + */ +export interface SkillDiscoveryOptions { + /** Working directory (for project-level skills) */ + cwd?: string; + + /** Filter by mode (e.g., 'code', 'architect') */ + mode?: string; + + /** Filter by agent name */ + agentName?: string; + + /** Force cache reload */ + forceReload?: boolean; +} + +/** + * Validation result for a skill + */ +export interface SkillValidationResult { + valid: boolean; + filePath: string; + skillName?: string; + errors: string[]; +} + +/** + * Configuration for skills in agent config + */ +export interface SkillsConfig { + /** Enable/disable skill loading */ + enabled?: boolean; + + /** Mode for mode-specific skills */ + mode?: string; + + /** Auto-reload on file changes (future feature) */ + autoReload?: boolean; +} + +/** + * Skill pattern detected in a message + */ +export interface SkillPattern { + /** Skill name (e.g., 'mr', 'commit') */ + name: string; + /** Position in message where pattern starts */ + position: number; + /** Optional arguments after skill name */ + args?: string; + /** Full matched pattern (e.g., '/mr', '/commit -m "fix"') */ + raw: string; +} + +/** + * Result of pattern matching + */ +export interface PatternMatchResult { + /** Detected skill patterns */ + patterns: SkillPattern[]; + /** Original message */ + originalMessage: string; + /** Whether any patterns were found */ + hasPatterns: boolean; +} + +/** + * Skill with file inventory + */ +export interface SkillWithInventory { + /** Base skill metadata and content */ + skill: Skill; + /** Relative file paths (excluding SKILL.md) */ + files: string[]; + /** Formatted content ready for prompt injection */ + formattedContent: string; +} diff --git a/src/agents/codemie-code/skills/index.ts b/src/agents/codemie-code/skills/index.ts new file mode 100644 index 00000000..5ff0344b --- /dev/null +++ b/src/agents/codemie-code/skills/index.ts @@ -0,0 +1,42 @@ +/** + * Skills System - Public API + * + * Provides skill discovery, loading, and management for CodeMie agents. + */ + +// Core exports +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, + SkillMetadata, + SkillSource, + SkillParseResult, + SkillDiscoveryOptions, + SkillValidationResult, + SkillsConfig, +} from './core/types.js'; +export { SkillMetadataSchema } from './core/types.js'; + +// Utility exports +export { + parseFrontmatter, + hasFrontmatter, + extractMetadata, + extractContent, + 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/codemie-code/skills/utils/content-loader.test.ts b/src/agents/codemie-code/skills/utils/content-loader.test.ts new file mode 100644 index 00000000..b021c912 --- /dev/null +++ b/src/agents/codemie-code/skills/utils/content-loader.test.ts @@ -0,0 +1,184 @@ +/** + * Tests for skill content loader + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { loadSkillWithInventory } from './content-loader.js'; +import type { Skill } from '../core/types.js'; + +// Mock fs module +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: vi.fn(), + promises: { + readdir: vi.fn(), + }, + }; +}); + +describe('loadSkillWithInventory', () => { + const mockSkill: Skill = { + metadata: { + name: 'test-skill', + description: 'Test skill for unit tests', + priority: 0, + }, + content: 'This is the skill content from SKILL.md', + filePath: '/test/path/.codemie/skills/test-skill/SKILL.md', + source: 'project', + computedPriority: 100, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should load skill with file inventory', async () => { + // Mock directory exists + vi.mocked(fs.existsSync).mockReturnValue(true); + + // Mock file listing + vi.mocked(fs.promises.readdir).mockResolvedValueOnce([ + { name: 'README.md', isDirectory: () => false, isFile: () => true } as any, + { name: 'script.sh', isDirectory: () => false, isFile: () => true } as any, + { name: 'SKILL.md', isDirectory: () => false, isFile: () => true } as any, + ]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.skill).toBe(mockSkill); + expect(result.files).toEqual(['README.md', 'script.sh']); // SKILL.md excluded + expect(result.formattedContent).toContain('## Skill: test-skill'); + expect(result.formattedContent).toContain('This is the skill content'); + expect(result.formattedContent).toContain('### Available Files'); + expect(result.formattedContent).toContain('- README.md'); + expect(result.formattedContent).toContain('- script.sh'); + }); + + it('should handle missing directory gracefully', async () => { + // Mock directory does not exist + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.skill).toBe(mockSkill); + expect(result.files).toEqual([]); + expect(result.formattedContent).toContain('## Skill: test-skill'); + expect(result.formattedContent).not.toContain('### Available Files'); + }); + + it('should exclude hidden files', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + vi.mocked(fs.promises.readdir).mockResolvedValueOnce([ + { name: '.hidden', isDirectory: () => false, isFile: () => true } as any, + { name: 'visible.md', isDirectory: () => false, isFile: () => true } as any, + ]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.files).toEqual(['visible.md']); + expect(result.files).not.toContain('.hidden'); + }); + + it('should exclude node_modules directory', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + vi.mocked(fs.promises.readdir).mockResolvedValueOnce([ + { name: 'file.md', isDirectory: () => false, isFile: () => true } as any, + { name: 'node_modules', isDirectory: () => true, isFile: () => false } as any, + ]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.files).toEqual(['file.md']); + }); + + it('should only include supported file extensions', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + vi.mocked(fs.promises.readdir).mockResolvedValueOnce([ + { name: 'doc.md', isDirectory: () => false, isFile: () => true } as any, + { name: 'script.sh', isDirectory: () => false, isFile: () => true } as any, + { name: 'code.js', isDirectory: () => false, isFile: () => true } as any, + { name: 'binary.exe', isDirectory: () => false, isFile: () => true } as any, + { name: 'image.png', isDirectory: () => false, isFile: () => true } as any, + ]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.files).toEqual(['code.js', 'doc.md', 'script.sh']); // Sorted + expect(result.files).not.toContain('binary.exe'); + expect(result.files).not.toContain('image.png'); + }); + + it('should handle subdirectories', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + // First call: root directory + vi.mocked(fs.promises.readdir) + .mockResolvedValueOnce([ + { name: 'root.md', isDirectory: () => false, isFile: () => true } as any, + { name: 'subdir', isDirectory: () => true, isFile: () => false } as any, + ]) + // Second call: subdirectory + .mockResolvedValueOnce([ + { name: 'nested.md', isDirectory: () => false, isFile: () => true } as any, + ]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.files).toContain('root.md'); + expect(result.files).toContain(path.join('subdir', 'nested.md')); + }); + + it('should sort files alphabetically', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + vi.mocked(fs.promises.readdir).mockResolvedValueOnce([ + { name: 'zebra.md', isDirectory: () => false, isFile: () => true } as any, + { name: 'alpha.md', isDirectory: () => false, isFile: () => true } as any, + { name: 'beta.md', isDirectory: () => false, isFile: () => true } as any, + ]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.files).toEqual(['alpha.md', 'beta.md', 'zebra.md']); + }); + + it('should handle permission errors gracefully', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.promises.readdir).mockRejectedValueOnce( + new Error('Permission denied') + ); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.skill).toBe(mockSkill); + expect(result.files).toEqual([]); + }); + + it('should format content without files when inventory is empty', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.promises.readdir).mockResolvedValueOnce([]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.formattedContent).toContain('## Skill: test-skill'); + expect(result.formattedContent).toContain('### SKILL.md Content'); + expect(result.formattedContent).not.toContain('### Available Files'); + }); + + it('should include skill description in formatted content', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.promises.readdir).mockResolvedValueOnce([]); + + const result = await loadSkillWithInventory(mockSkill); + + expect(result.formattedContent).toContain('Test skill for unit tests'); + }); +}); diff --git a/src/agents/codemie-code/skills/utils/content-loader.ts b/src/agents/codemie-code/skills/utils/content-loader.ts new file mode 100644 index 00000000..b2fb67b6 --- /dev/null +++ b/src/agents/codemie-code/skills/utils/content-loader.ts @@ -0,0 +1,212 @@ +/** + * Content loader for skill files and inventory + * + * Loads skill content and builds file inventories for pattern-based invocation. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { Skill } from '../core/types.js'; +import { logger } from '../../../../utils/logger.js'; + +/** + * Skill with file inventory + */ +export interface SkillWithInventory { + /** Base skill metadata and content */ + skill: Skill; + /** Relative file paths (excluding SKILL.md) */ + files: string[]; + /** Formatted content ready for prompt injection */ + formattedContent: string; +} + +/** + * File extensions to include in inventory + */ +const INCLUDED_EXTENSIONS = new Set([ + '.md', + '.sh', + '.js', + '.ts', + '.py', + '.json', + '.yaml', + '.yml', + '.toml', + '.txt', +]); + +/** + * Directories to exclude from inventory + */ +const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build']); + +/** + * Maximum depth for file scanning (prevent infinite loops) + */ +const MAX_DEPTH = 5; + +/** + * Load a skill with its file inventory + * + * @param skill - Skill to load inventory for + * @returns Skill with file inventory and formatted content + */ +export async function loadSkillWithInventory( + skill: Skill +): Promise { + // Get skill directory from SKILL.md path + const skillDirectory = path.dirname(skill.filePath); + + // Build file inventory + const files = await buildFileInventory(skillDirectory); + + // Format content for injection + const formattedContent = formatSkillContent(skill, files); + + return { + skill, + files, + formattedContent, + }; +} + +/** + * Build file inventory for a skill directory + * + * @param skillDirectoryPath - Absolute path to skill directory + * @returns Array of relative file paths (sorted alphabetically) + */ +async function buildFileInventory( + skillDirectoryPath: string +): Promise { + const files: string[] = []; + + try { + // Check if directory exists + if (!fs.existsSync(skillDirectoryPath)) { + logger.warn(`Skill directory not found: ${skillDirectoryPath}`); + return []; + } + + // Scan directory recursively + await scanDirectory(skillDirectoryPath, skillDirectoryPath, files, 0); + + // Sort alphabetically for consistent output + files.sort(); + + return files; + } catch (error) { + logger.warn( + `Failed to build file inventory for ${skillDirectoryPath}:`, + error + ); + return []; + } +} + +/** + * Recursively scan a directory for files + * + * @param basePath - Base skill directory path + * @param currentPath - Current directory being scanned + * @param files - Accumulator for discovered files + * @param depth - Current recursion depth + */ +async function scanDirectory( + basePath: string, + currentPath: string, + files: string[], + depth: number +): Promise { + // Prevent infinite loops + if (depth >= MAX_DEPTH) { + return; + } + + try { + const entries = await fs.promises.readdir(currentPath, { + withFileTypes: true, + }); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + + // Skip hidden files/directories + if (entry.name.startsWith('.')) { + continue; + } + + if (entry.isDirectory()) { + // Skip excluded directories + if (EXCLUDED_DIRS.has(entry.name)) { + continue; + } + + // Recurse into subdirectory + await scanDirectory(basePath, fullPath, files, depth + 1); + } else if (entry.isFile()) { + // Skip SKILL.md (already loaded) + if (entry.name === 'SKILL.md') { + continue; + } + + // Check file extension + const ext = path.extname(entry.name); + if (INCLUDED_EXTENSIONS.has(ext)) { + // Store relative path + const relativePath = path.relative(basePath, fullPath); + files.push(relativePath); + } + } + // Skip symbolic links (avoid loops) + } + } catch (error) { + // Permission errors or other issues - log and continue + logger.debug( + `Failed to scan directory ${currentPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Format skill content for prompt injection + * + * @param skill - Skill to format + * @param files - File inventory + * @returns Formatted markdown content + */ +function formatSkillContent(skill: Skill, files: string[]): string { + const parts: string[] = []; + + // Header + parts.push(`## Skill: ${skill.metadata.name}`); + parts.push(''); + parts.push(skill.metadata.description); + parts.push(''); + + // Skill content + parts.push('### SKILL.md Content'); + parts.push(''); + parts.push(skill.content); + parts.push(''); + + // File inventory (if any) + if (files.length > 0) { + parts.push('### Available Files'); + parts.push(''); + parts.push( + 'The following files are available in this skill directory.' + ); + parts.push('Use the Read tool to access their content when needed:'); + parts.push(''); + + for (const file of files) { + parts.push(`- ${file}`); + } + parts.push(''); + } + + return parts.join('\n'); +} diff --git a/src/agents/codemie-code/skills/utils/frontmatter.test.ts b/src/agents/codemie-code/skills/utils/frontmatter.test.ts new file mode 100644 index 00000000..e75a44c9 --- /dev/null +++ b/src/agents/codemie-code/skills/utils/frontmatter.test.ts @@ -0,0 +1,372 @@ +/** + * Tests for frontmatter parsing utilities + */ + +import { describe, it, expect } from 'vitest'; +import { + parseFrontmatter, + hasFrontmatter, + extractMetadata, + extractContent, + FrontmatterParseError, +} from './frontmatter.js'; + +describe('parseFrontmatter', () => { + it('should parse valid frontmatter with content', () => { + const fileContent = `--- +name: test-skill +description: A test skill +version: 1.0.0 +--- +# Test Skill + +This is the skill content.`; + + const result = parseFrontmatter(fileContent); + + expect(result.metadata).toEqual({ + name: 'test-skill', + description: 'A test skill', + version: '1.0.0', + }); + expect(result.content).toBe('# Test Skill\n\nThis is the skill content.'); + }); + + it('should parse frontmatter with nested objects', () => { + const fileContent = `--- +name: advanced-skill +description: Advanced skill +compatibility: + agents: + - codemie-code + - claude + minVersion: 1.0.0 +modes: + - code + - architect +--- +Content here`; + + const result = parseFrontmatter(fileContent); + + expect(result.metadata).toEqual({ + name: 'advanced-skill', + description: 'Advanced skill', + compatibility: { + agents: ['codemie-code', 'claude'], + minVersion: '1.0.0', + }, + modes: ['code', 'architect'], + }); + expect(result.content).toBe('Content here'); + }); + + it('should parse frontmatter with empty content', () => { + const fileContent = `--- +name: minimal-skill +description: Minimal skill +---`; + + const result = parseFrontmatter(fileContent); + + expect(result.metadata).toEqual({ + name: 'minimal-skill', + description: 'Minimal skill', + }); + expect(result.content).toBe(''); + }); + + it('should trim leading/trailing whitespace', () => { + const fileContent = ` +--- +name: test-skill +description: Test +--- +Content + `; + + const result = parseFrontmatter(fileContent); + + expect(result.metadata.name).toBe('test-skill'); + expect(result.content).toBe('Content'); + }); + + it('should handle multiline YAML values', () => { + const fileContent = `--- +name: multiline-skill +description: | + This is a multiline + description with + multiple lines +--- +Content`; + + const result = parseFrontmatter(fileContent); + + expect(result.metadata.description).toBe( + 'This is a multiline\ndescription with\nmultiple lines\n' + ); + }); + + it('should throw error when missing opening delimiter', () => { + const fileContent = `name: test-skill +description: Test +--- +Content`; + + expect(() => parseFrontmatter(fileContent)).toThrow(FrontmatterParseError); + expect(() => parseFrontmatter(fileContent)).toThrow( + 'File must start with frontmatter delimiter (---)' + ); + }); + + it('should throw error when missing closing delimiter', () => { + const fileContent = `--- +name: test-skill +description: Test +Content without closing delimiter`; + + expect(() => parseFrontmatter(fileContent)).toThrow(FrontmatterParseError); + expect(() => parseFrontmatter(fileContent)).toThrow( + 'Missing closing frontmatter delimiter (---)' + ); + }); + + it('should throw error for invalid YAML', () => { + const fileContent = `--- +name: test-skill +description: [invalid: yaml: structure +--- +Content`; + + expect(() => parseFrontmatter(fileContent)).toThrow(FrontmatterParseError); + expect(() => parseFrontmatter(fileContent)).toThrow( + /Failed to parse YAML frontmatter/ + ); + }); + + it('should throw error when YAML is not an object', () => { + const fileContent = `--- +- item1 +- item2 +--- +Content`; + + expect(() => parseFrontmatter(fileContent)).toThrow(FrontmatterParseError); + expect(() => parseFrontmatter(fileContent)).toThrow( + 'Frontmatter must be a YAML object (key-value pairs)' + ); + }); + + it('should include file path in error messages', () => { + const fileContent = `Invalid content`; + const filePath = '/test/path/SKILL.md'; + + try { + parseFrontmatter(fileContent, filePath); + expect.fail('Should have thrown error'); + } catch (error) { + expect(error).toBeInstanceOf(FrontmatterParseError); + const parseError = error as FrontmatterParseError; + expect(parseError.filePath).toBe(filePath); + } + }); + + it('should handle YAML with special characters', () => { + const fileContent = `--- +name: "skill-with-dash" +description: 'Single quotes: OK' +author: "John O'Brien" +--- +Content`; + + const result = parseFrontmatter(fileContent); + + expect(result.metadata).toEqual({ + name: 'skill-with-dash', + description: 'Single quotes: OK', + author: "John O'Brien", + }); + }); + + it('should parse frontmatter with numeric and boolean values', () => { + const fileContent = `--- +name: test-skill +priority: 42 +enabled: true +experimental: false +version: 1.0 +--- +Content`; + + const result = parseFrontmatter(fileContent); + + expect(result.metadata).toEqual({ + name: 'test-skill', + priority: 42, + enabled: true, + experimental: false, + version: 1.0, + }); + }); +}); + +describe('hasFrontmatter', () => { + it('should return true for valid frontmatter', () => { + const fileContent = `--- +name: test-skill +--- +Content`; + + expect(hasFrontmatter(fileContent)).toBe(true); + }); + + it('should return false for invalid frontmatter', () => { + const fileContent = `No frontmatter here`; + + expect(hasFrontmatter(fileContent)).toBe(false); + }); + + it('should return false for missing closing delimiter', () => { + const fileContent = `--- +name: test-skill +Content`; + + expect(hasFrontmatter(fileContent)).toBe(false); + }); + + it('should return false for invalid YAML', () => { + const fileContent = `--- +[invalid: yaml +--- +Content`; + + expect(hasFrontmatter(fileContent)).toBe(false); + }); +}); + +describe('extractMetadata', () => { + it('should extract metadata without content', () => { + const fileContent = `--- +name: test-skill +description: Test skill +--- +This content is ignored`; + + const metadata = extractMetadata(fileContent); + + expect(metadata).toEqual({ + name: 'test-skill', + description: 'Test skill', + }); + }); + + it('should throw error for invalid frontmatter', () => { + const fileContent = `Invalid content`; + + expect(() => extractMetadata(fileContent)).toThrow(FrontmatterParseError); + }); + + it('should support generic type parameter', () => { + interface CustomMetadata { + name: string; + version: string; + } + + const fileContent = `--- +name: typed-skill +version: 2.0.0 +--- +Content`; + + const metadata = extractMetadata(fileContent); + + expect(metadata.name).toBe('typed-skill'); + expect(metadata.version).toBe('2.0.0'); + }); +}); + +describe('extractContent', () => { + it('should extract content without metadata', () => { + const fileContent = `--- +name: test-skill +description: This metadata is ignored +--- +# Important Content + +This is the actual content.`; + + const content = extractContent(fileContent); + + expect(content).toBe('# Important Content\n\nThis is the actual content.'); + }); + + it('should return empty string for no content', () => { + const fileContent = `--- +name: test-skill +---`; + + const content = extractContent(fileContent); + + expect(content).toBe(''); + }); + + it('should throw error for invalid frontmatter', () => { + const fileContent = `Invalid content`; + + expect(() => extractContent(fileContent)).toThrow(FrontmatterParseError); + }); + + it('should preserve markdown formatting in content', () => { + const fileContent = `--- +name: test-skill +--- +# Heading 1 +## Heading 2 + +- List item 1 +- List item 2 + +\`\`\`javascript +const code = 'preserved'; +\`\`\``; + + const content = extractContent(fileContent); + + expect(content).toContain('# Heading 1'); + expect(content).toContain('## Heading 2'); + expect(content).toContain('- List item 1'); + expect(content).toContain('```javascript'); + }); +}); + +describe('FrontmatterParseError', () => { + it('should create error with message and file path', () => { + const error = new FrontmatterParseError( + 'Test error', + '/test/path/file.md' + ); + + expect(error.message).toBe('Test error'); + expect(error.filePath).toBe('/test/path/file.md'); + expect(error.name).toBe('FrontmatterParseError'); + }); + + it('should create error with cause', () => { + const cause = new Error('Original error'); + const error = new FrontmatterParseError( + 'Test error', + '/test/path/file.md', + cause + ); + + expect(error.cause).toBe(cause); + }); + + it('should be instanceof Error', () => { + const error = new FrontmatterParseError('Test error'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(FrontmatterParseError); + }); +}); diff --git a/src/agents/codemie-code/skills/utils/frontmatter.ts b/src/agents/codemie-code/skills/utils/frontmatter.ts new file mode 100644 index 00000000..60544bce --- /dev/null +++ b/src/agents/codemie-code/skills/utils/frontmatter.ts @@ -0,0 +1,153 @@ +import { parse as parseYaml } from 'yaml'; + +/** + * Result of parsing frontmatter from a markdown file + */ +export interface FrontmatterResult> { + /** Parsed metadata from YAML frontmatter */ + metadata: T; + + /** Markdown content (body after frontmatter) */ + content: string; +} + +/** + * Error thrown when frontmatter parsing fails + */ +export class FrontmatterParseError extends Error { + constructor( + message: string, + public readonly filePath?: string, + public readonly cause?: unknown + ) { + super(message); + this.name = 'FrontmatterParseError'; + } +} + +/** + * Parse YAML frontmatter from a markdown file + * + * Expected format: + * ``` + * --- + * key: value + * --- + * Content here + * ``` + * + * @param fileContent - Raw file content + * @param filePath - Optional file path (for error messages) + * @returns Parsed frontmatter metadata and markdown content + * @throws FrontmatterParseError if parsing fails + */ +export function parseFrontmatter>( + fileContent: string, + filePath?: string +): FrontmatterResult { + // Trim leading/trailing whitespace + const trimmed = fileContent.trim(); + + // Check if file starts with frontmatter delimiter + if (!trimmed.startsWith('---')) { + throw new FrontmatterParseError( + 'File must start with frontmatter delimiter (---)', + filePath + ); + } + + // Find the closing delimiter + const lines = trimmed.split('\n'); + let closingDelimiterIndex = -1; + + // Start from line 1 (skip opening ---) + for (let i = 1; i < lines.length; i++) { + if (lines[i].trim() === '---') { + closingDelimiterIndex = i; + break; + } + } + + if (closingDelimiterIndex === -1) { + throw new FrontmatterParseError( + 'Missing closing frontmatter delimiter (---)', + filePath + ); + } + + // Extract YAML content (between delimiters) + const yamlLines = lines.slice(1, closingDelimiterIndex); + const yamlContent = yamlLines.join('\n'); + + // Extract markdown content (after closing delimiter) + const contentLines = lines.slice(closingDelimiterIndex + 1); + const content = contentLines.join('\n').trim(); + + // Parse YAML + let metadata: T; + try { + const parsed = parseYaml(yamlContent); + + // Ensure we got an object + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Frontmatter must be a YAML object (key-value pairs)'); + } + + metadata = parsed as T; + } catch (error) { + throw new FrontmatterParseError( + `Failed to parse YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`, + filePath, + error + ); + } + + return { + metadata, + content, + }; +} + +/** + * Check if a file has valid frontmatter format (non-throwing) + * + * @param fileContent - Raw file content + * @returns true if file has valid frontmatter structure + */ +export function hasFrontmatter(fileContent: string): boolean { + try { + parseFrontmatter(fileContent); + return true; + } catch { + return false; + } +} + +/** + * Extract just the metadata without validating content + * + * @param fileContent - Raw file content + * @param filePath - Optional file path (for error messages) + * @returns Parsed metadata + * @throws FrontmatterParseError if parsing fails + */ +export function extractMetadata>( + fileContent: string, + filePath?: string +): T { + const result = parseFrontmatter(fileContent, filePath); + return result.metadata; +} + +/** + * Extract just the content without validating metadata + * + * @param fileContent - Raw file content + * @param filePath - Optional file path (for error messages) + * @returns Markdown content + * @throws FrontmatterParseError if parsing fails + */ +export function extractContent(fileContent: string, filePath?: string): string { + const result = parseFrontmatter(fileContent, filePath); + return result.content; +} diff --git a/src/agents/codemie-code/skills/utils/pattern-matcher.test.ts b/src/agents/codemie-code/skills/utils/pattern-matcher.test.ts new file mode 100644 index 00000000..a3324d02 --- /dev/null +++ b/src/agents/codemie-code/skills/utils/pattern-matcher.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for skill pattern matcher + */ + +import { describe, it, expect } from 'vitest'; +import { extractSkillPatterns, isValidSkillName } from './pattern-matcher.js'; + +describe('extractSkillPatterns', () => { + it('should detect single pattern at start', () => { + const result = extractSkillPatterns('/mr'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toEqual({ + name: 'mr', + position: 0, + args: undefined, + raw: '/mr', + }); + }); + + it('should detect pattern mid-sentence', () => { + const result = extractSkillPatterns('ensure you can /commit this'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0]).toEqual({ + name: 'commit', + position: 15, + args: 'this', + raw: '/commit this', + }); + }); + + it('should detect multiple patterns', () => { + const result = extractSkillPatterns('/commit and /mr'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns).toHaveLength(2); + expect(result.patterns[0].name).toBe('commit'); + expect(result.patterns[1].name).toBe('mr'); + }); + + it('should detect pattern with arguments', () => { + const result = extractSkillPatterns('/commit -m "fix bug"'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns[0].args).toBe('-m "fix bug"'); + }); + + it('should handle URLs in context gracefully', () => { + // Note: Bare URLs like "https://github.com/repo" may match patterns in the protocol + // This is an acceptable edge case as users rarely paste bare URLs in CLI + // In realistic usage, URLs appear in context: "check out https://example.com" + const result = extractSkillPatterns('check out https://example.com for more info'); + + // The URL detection helps but isn't perfect for all edge cases + // Main use case (skill invocation) works correctly + expect(result).toBeDefined(); + }); + + it('should exclude built-in command: help', () => { + const result = extractSkillPatterns('/help'); + + expect(result.hasPatterns).toBe(false); + expect(result.patterns).toHaveLength(0); + }); + + it('should exclude built-in command: clear', () => { + const result = extractSkillPatterns('/clear'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should exclude built-in command: exit', () => { + const result = extractSkillPatterns('/exit'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should exclude built-in command: quit', () => { + const result = extractSkillPatterns('/quit'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should exclude built-in command: stats', () => { + const result = extractSkillPatterns('/stats'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should exclude built-in command: todos', () => { + const result = extractSkillPatterns('/todos'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should exclude built-in command: config', () => { + const result = extractSkillPatterns('/config'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should exclude built-in command: health', () => { + const result = extractSkillPatterns('/health'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should deduplicate skill names', () => { + const result = extractSkillPatterns('/mr and then /mr again'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns).toHaveLength(1); + expect(result.patterns[0].name).toBe('mr'); + }); + + it('should handle empty message', () => { + const result = extractSkillPatterns(''); + + expect(result.hasPatterns).toBe(false); + expect(result.patterns).toHaveLength(0); + }); + + it('should handle message without patterns', () => { + const result = extractSkillPatterns('Just a normal message'); + + expect(result.hasPatterns).toBe(false); + expect(result.patterns).toHaveLength(0); + }); + + it('should detect skills with hyphens', () => { + const result = extractSkillPatterns('/my-skill-name'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns[0].name).toBe('my-skill-name'); + }); + + it('should detect skills with numbers', () => { + const result = extractSkillPatterns('/skill123'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns[0].name).toBe('skill123'); + }); + + it('should not detect skills starting with number', () => { + const result = extractSkillPatterns('/123skill'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should not detect uppercase skills', () => { + const result = extractSkillPatterns('/MySkill'); + + expect(result.hasPatterns).toBe(false); + }); + + it('should handle multiline messages', () => { + const result = extractSkillPatterns('First line\n/commit\nLast line'); + + expect(result.hasPatterns).toBe(true); + expect(result.patterns[0].name).toBe('commit'); + }); + + it('should preserve original message', () => { + const originalMessage = 'test /mr message'; + const result = extractSkillPatterns(originalMessage); + + expect(result.originalMessage).toBe(originalMessage); + }); +}); + +describe('isValidSkillName', () => { + it('should accept valid lowercase name', () => { + expect(isValidSkillName('commit')).toBe(true); + }); + + it('should accept name with hyphens', () => { + expect(isValidSkillName('my-skill')).toBe(true); + }); + + it('should accept name with numbers', () => { + expect(isValidSkillName('skill123')).toBe(true); + }); + + it('should reject name starting with number', () => { + expect(isValidSkillName('123skill')).toBe(false); + }); + + it('should reject uppercase letters', () => { + expect(isValidSkillName('MySkill')).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValidSkillName('')).toBe(false); + }); + + it('should reject name over 50 characters', () => { + const longName = 'a'.repeat(51); + expect(isValidSkillName(longName)).toBe(false); + }); + + it('should accept name exactly 50 characters', () => { + const name = 'a'.repeat(50); + expect(isValidSkillName(name)).toBe(true); + }); + + it('should reject special characters', () => { + expect(isValidSkillName('skill_name')).toBe(false); + expect(isValidSkillName('skill.name')).toBe(false); + expect(isValidSkillName('skill@name')).toBe(false); + }); + + it('should accept single character', () => { + expect(isValidSkillName('a')).toBe(true); + }); + + it('should reject name starting with hyphen', () => { + expect(isValidSkillName('-skill')).toBe(false); + }); +}); diff --git a/src/agents/codemie-code/skills/utils/pattern-matcher.ts b/src/agents/codemie-code/skills/utils/pattern-matcher.ts new file mode 100644 index 00000000..d8646b42 --- /dev/null +++ b/src/agents/codemie-code/skills/utils/pattern-matcher.ts @@ -0,0 +1,134 @@ +/** + * Pattern matcher for skill invocation detection + * + * Detects /skill-name patterns in user messages and extracts skill names. + * Excludes URLs and built-in CLI commands. + */ + +/** + * Skill pattern detected in a message + */ +export interface SkillPattern { + /** Skill name (e.g., 'mr', 'commit') */ + name: string; + /** Position in message where pattern starts */ + position: number; + /** Optional arguments after skill name */ + args?: string; + /** Full matched pattern (e.g., '/mr', '/commit -m "fix"') */ + raw: string; +} + +/** + * Result of pattern matching + */ +export interface PatternMatchResult { + /** Detected skill patterns */ + patterns: SkillPattern[]; + /** Original message */ + originalMessage: string; + /** Whether any patterns were found */ + hasPatterns: boolean; +} + +/** + * Built-in CLI commands that should NOT be treated as skills + */ +const BUILT_IN_COMMANDS = new Set([ + 'help', + 'clear', + 'exit', + 'quit', + 'stats', + 'todos', + 'config', + 'health', +]); + +/** + * Regex pattern for skill invocation + * + * Matches: /skill-name with optional arguments + * Excludes: URLs (negative lookbehind for : or alphanumeric before /) + * Format: /[a-z][a-z0-9-]{0,49} (lowercase, alphanumeric + hyphens, 1-50 chars) + */ +const SKILL_PATTERN = /(?(); + + // Reset regex state + SKILL_PATTERN.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = SKILL_PATTERN.exec(message)) !== null) { + const [fullMatch, skillName, args] = match; + const position = match.index; + + // Skip if this is part of a URL + // Check if there's http:// or https:// within the last 100 chars before this position + const lookback = Math.min(100, position); + const beforeMatch = message.slice(position - lookback, position); + + // If we find a protocol and no whitespace between it and this slash, it's part of a URL + if (/https?:\/\/[^\s]*$/.test(beforeMatch)) { + continue; + } + + // Skip built-in commands + if (BUILT_IN_COMMANDS.has(skillName)) { + continue; + } + + // Deduplicate by skill name (keep first occurrence) + if (seenNames.has(skillName)) { + continue; + } + seenNames.add(skillName); + + patterns.push({ + name: skillName, + position, + args: args?.trim(), + raw: fullMatch, + }); + } + + return { + patterns, + originalMessage: message, + hasPatterns: patterns.length > 0, + }; +} + +/** + * Validate a skill name + * + * @param name - Skill name to validate + * @returns True if valid, false otherwise + * + * Rules: + * - Lowercase letters only + * - Can include digits and hyphens + * - Must start with a letter + * - 1-50 characters + */ +export function isValidSkillName(name: string): boolean { + return /^[a-z][a-z0-9-]{0,49}$/.test(name); +} diff --git a/src/agents/codemie-code/types.ts b/src/agents/codemie-code/types.ts index a42d5d04..1e34bac0 100644 --- a/src/agents/codemie-code/types.ts +++ b/src/agents/codemie-code/types.ts @@ -7,7 +7,7 @@ import type { FilterConfig } from './filters.js'; import type { HooksConfiguration } from '../../hooks/types.js'; -import type { SkillsConfig } from '../../skills/index.js'; +import type { SkillsConfig } from './skills/index.js'; /** * Configuration interface for the CodeMie agent diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 4cbded76..57f4efc2 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -78,19 +78,17 @@ export class AgentCLI { await this.handleHealthCheck(); }); - // Add init command for frameworks (skip for built-in agent) - if (this.adapter.name !== BUILTIN_AGENT_NAME) { - this.program - .command('init') - .description('Initialize development framework') - .argument('[framework]', 'Framework to initialize (speckit, bmad)') - .option('-l, --list', 'List available frameworks') - .option('--force', 'Force re-initialization') - .option('--project-name ', 'Project name for framework initialization') - .action(async (framework, options) => { - await this.handleInit(framework, options); - }); - } + // Add init command for frameworks + this.program + .command('init') + .description('Initialize development framework') + .argument('[framework]', 'Framework to initialize (speckit, bmad)') + .option('-l, --list', 'List available frameworks') + .option('--force', 'Force re-initialization') + .option('--project-name ', 'Project name for framework initialization') + .action(async (framework, options) => { + await this.handleInit(framework, options); + }); } /** diff --git a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts new file mode 100644 index 00000000..37f660e0 --- /dev/null +++ b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for CodeMieCodePlugin and CodeMieCodePluginMetadata + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock BaseAgentAdapter to avoid dependency tree +vi.mock('../../core/BaseAgentAdapter.js', () => ({ + BaseAgentAdapter: class { + metadata: any; + constructor(metadata: any) { + this.metadata = metadata; + } + }, +})); + +// Mock binary resolution +vi.mock('../codemie-opencode/codemie-opencode-binary.js', () => ({ + resolveCodemieOpenCodeBinary: vi.fn(() => '/mock/bin/codemie'), +})); + +// Mock logger +vi.mock('../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Mock installGlobal +vi.mock('../../../utils/processes.js', () => ({ + installGlobal: vi.fn(), +})); + +// Use vi.hoisted for mock functions referenced in vi.mock factories +const { mockDiscoverSessionsCC, mockProcessSessionCC } = vi.hoisted(() => ({ + mockDiscoverSessionsCC: vi.fn(), + mockProcessSessionCC: vi.fn(), +})); + +// Mock OpenCodeSessionAdapter - must use function (not arrow) to support `new` +vi.mock('../opencode/opencode.session.js', () => ({ + OpenCodeSessionAdapter: vi.fn(function () { + return { + discoverSessions: mockDiscoverSessionsCC, + processSession: mockProcessSessionCC, + }; + }), +})); + +// Mock getModelConfig +vi.mock('../opencode/opencode-model-configs.js', () => ({ + getModelConfig: vi.fn(() => ({ + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + })), +})); + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const { existsSync } = await import('fs'); +const { resolveCodemieOpenCodeBinary } = await import('../codemie-opencode/codemie-opencode-binary.js'); +const { installGlobal } = await import('../../../utils/processes.js'); +const { logger } = await import('../../../utils/logger.js'); +const { OpenCodeSessionAdapter } = await import('../opencode/opencode.session.js'); +const { CodeMieCodePlugin, CodeMieCodePluginMetadata, BUILTIN_AGENT_NAME } = await import('../codemie-code.plugin.js'); +const { CodemieOpenCodePluginMetadata } = await import('../codemie-opencode/codemie-opencode.plugin.js'); + +const mockExistsSync = vi.mocked(existsSync); +const mockResolve = vi.mocked(resolveCodemieOpenCodeBinary); +const mockInstallGlobal = vi.mocked(installGlobal); + +describe('CodeMieCodePluginMetadata', () => { + it('has name codemie-code', () => { + expect(CodeMieCodePluginMetadata.name).toBe('codemie-code'); + expect(BUILTIN_AGENT_NAME).toBe('codemie-code'); + }); + + it('reuses beforeRun from CodemieOpenCodePluginMetadata', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.beforeRun).toBe( + CodemieOpenCodePluginMetadata.lifecycle!.beforeRun + ); + }); + + it('reuses enrichArgs from CodemieOpenCodePluginMetadata', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.enrichArgs).toBe( + CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs + ); + }); + + it('has custom onSessionEnd', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.onSessionEnd).toBeDefined(); + expect(CodeMieCodePluginMetadata.lifecycle!.onSessionEnd).not.toBe( + CodemieOpenCodePluginMetadata.lifecycle!.onSessionEnd + ); + }); +}); + +describe('CodeMieCodePlugin', () => { + let plugin: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + plugin = new CodeMieCodePlugin(); + }); + + describe('isInstalled', () => { + it('returns true when binary resolved and exists', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + + const result = await plugin.isInstalled(); + expect(result).toBe(true); + }); + + it('returns false when resolveCodemieOpenCodeBinary returns null', async () => { + mockResolve.mockReturnValue(null); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + + it('returns false when path resolved but file missing', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(false); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + }); + + describe('install', () => { + it('calls installGlobal with the correct package name', async () => { + await plugin.install(); + expect(mockInstallGlobal).toHaveBeenCalledWith('@codemieai/codemie-opencode'); + }); + }); + + describe('getSessionAdapter', () => { + it('returns an OpenCodeSessionAdapter instance', () => { + const adapter = plugin.getSessionAdapter(); + expect(adapter).toBeDefined(); + expect(OpenCodeSessionAdapter).toHaveBeenCalled(); + }); + }); + + describe('getExtensionInstaller', () => { + it('returns undefined', () => { + const installer = plugin.getExtensionInstaller(); + expect(installer).toBeUndefined(); + }); + }); +}); + +describe('CodeMieCodePlugin onSessionEnd', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses clientType codemie-code in context', async () => { + mockDiscoverSessionsCC.mockResolvedValue([ + { sessionId: 'test-session', filePath: '/path/to/session' }, + ]); + mockProcessSessionCC.mockResolvedValue({ success: true, totalRecords: 5 }); + + const env = { + CODEMIE_SESSION_ID: 'test-123', + CODEMIE_BASE_URL: 'http://localhost:3000', + } as unknown as NodeJS.ProcessEnv; + + await CodeMieCodePluginMetadata.lifecycle!.onSessionEnd!(0, env); + + expect(mockProcessSessionCC).toHaveBeenCalledWith( + '/path/to/session', + 'test-123', + expect.objectContaining({ clientType: 'codemie-code' }) + ); + }); + + it('skips when no CODEMIE_SESSION_ID', async () => { + const env = {} as NodeJS.ProcessEnv; + + await CodeMieCodePluginMetadata.lifecycle!.onSessionEnd!(0, env); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('skipping') + ); + }); + + it('handles errors gracefully', async () => { + mockDiscoverSessionsCC.mockRejectedValue(new Error('adapter error')); + + const env = { + CODEMIE_SESSION_ID: 'test-123', + } as unknown as NodeJS.ProcessEnv; + + // Should not throw + await CodeMieCodePluginMetadata.lifecycle!.onSessionEnd!(0, env); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed') + ); + }); +}); diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index a8a79030..603631e6 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -1,201 +1,163 @@ -import { AgentMetadata, AgentAdapter } from '../core/types.js'; +import type { AgentMetadata } from '../core/types.js'; +import { existsSync } from 'fs'; import { logger } from '../../utils/logger.js'; -import { CodeMieCode } from '../codemie-code/index.js'; -import { loadCodeMieConfig } from '../codemie-code/config.js'; -import { join } from 'path'; -import { readFileSync } from 'fs'; -import { getDirname } from '../../utils/paths.js'; -import { getRandomWelcomeMessage, getRandomGoodbyeMessage } from '../../utils/goodbye-messages.js'; -import { renderProfileInfo } from '../../utils/profile.js'; -import chalk from 'chalk'; +import { BaseAgentAdapter } from '../core/BaseAgentAdapter.js'; +import type { SessionAdapter } from '../core/session/BaseSessionAdapter.js'; +import type { BaseExtensionInstaller } from '../core/extension/BaseExtensionInstaller.js'; +import { installGlobal } from '../../utils/processes.js'; +import { OpenCodeSessionAdapter } from './opencode/opencode.session.js'; +import { resolveCodemieOpenCodeBinary } from './codemie-opencode/codemie-opencode-binary.js'; +import { CodemieOpenCodePluginMetadata } from './codemie-opencode/codemie-opencode.plugin.js'; /** * Built-in agent name constant - single source of truth */ export const BUILTIN_AGENT_NAME = 'codemie-code'; +// Resolve binary at load time, fallback to 'codemie' +const resolvedBinary = resolveCodemieOpenCodeBinary(); + /** - * CodeMie-Code Plugin Metadata + * CodeMie Code Plugin Metadata + * + * Reuses lifecycle hooks from CodemieOpenCodePluginMetadata (beforeRun, enrichArgs) + * since both agents wrap the same OpenCode binary. + * Only onSessionEnd is customized to use clientType: 'codemie-code' for metrics. */ export const CodeMieCodePluginMetadata: AgentMetadata = { name: BUILTIN_AGENT_NAME, - displayName: 'CodeMie Native', - description: 'Built-in LangGraph-based coding assistant', + displayName: 'CodeMie Code', + description: 'CodeMie Code - AI coding assistant', - npmPackage: null, // Built-in - cliCommand: null, // No external CLI + npmPackage: '@codemieai/codemie-opencode', + cliCommand: resolvedBinary || 'codemie', - envMapping: {}, + dataPaths: { + home: '.opencode' + }, - supportedProviders: ['ollama', 'litellm', 'ai-run-sso'], - blockedModelPatterns: [], + envMapping: { + baseUrl: [], + apiKey: [], + model: [] + }, - // Built-in agent doesn't use proxy (handles auth internally) - ssoConfig: undefined, + supportedProviders: ['litellm', 'ai-run-sso'], - customOptions: [ - { flags: '--task ', description: 'Execute a single task and exit' }, - { flags: '--debug', description: 'Enable debug logging' }, - { flags: '--plan', description: 'Enable planning mode' }, - { flags: '--plan-only', description: 'Plan without execution' } - ], + ssoConfig: { enabled: true, clientType: 'codemie-code' }, - isBuiltIn: true, + lifecycle: { + beforeRun: CodemieOpenCodePluginMetadata.lifecycle!.beforeRun, + enrichArgs: CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs, - // Custom handler for built-in agent - customRunHandler: async (args, options) => { - try { - // Check if we have a valid configuration first - const workingDir = process.cwd(); + async onSessionEnd(exitCode: number, env: NodeJS.ProcessEnv) { + const sessionId = env.CODEMIE_SESSION_ID; - let config; - try { - config = await loadCodeMieConfig(workingDir); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - logger.error('Configuration loading failed:', errorMessage); - throw new Error(`CodeMie configuration required: ${errorMessage}. Please run: codemie setup`); + if (!sessionId) { + logger.debug('[codemie-code] No CODEMIE_SESSION_ID in environment, skipping metrics processing'); + return; } - // Show welcome message with session info - // Read from environment variables (same as BaseAgentAdapter) - const profileName = process.env.CODEMIE_PROFILE_NAME || config.name || 'default'; - const provider = process.env.CODEMIE_PROVIDER || config.displayProvider || config.provider; - const model = process.env.CODEMIE_MODEL || config.model; - const codeMieUrl = process.env.CODEMIE_URL || config.codeMieUrl; - const sessionId = process.env.CODEMIE_SESSION_ID || 'n/a'; - const cliVersion = process.env.CODEMIE_CLI_VERSION || 'unknown'; - console.log( - renderProfileInfo({ - profile: profileName, - provider, - model, - codeMieUrl, - agent: BUILTIN_AGENT_NAME, - cliVersion, - sessionId - }) - ); - - // Show random welcome message - console.log(chalk.cyan.bold(getRandomWelcomeMessage())); - console.log(''); // Empty line for spacing - - const codeMie = new CodeMieCode(workingDir); - await codeMie.initialize({ debug: options.debug as boolean | undefined }); - try { - if (options.task) { - await codeMie.executeTaskWithUI(options.task as string, { - planMode: (options.plan || options.planOnly) as boolean | undefined, - planOnly: options.planOnly as boolean | undefined - }); - } else if (args.length > 0) { - await codeMie.executeTaskWithUI(args.join(' ')); - if (!options.planOnly) { - await codeMie.startInteractive(); - } - } else { - await codeMie.startInteractive(); + logger.info(`[codemie-code] Processing session metrics before SessionSyncer (code=${exitCode})`); + + const adapter = new OpenCodeSessionAdapter(CodeMieCodePluginMetadata); + + const sessions = await adapter.discoverSessions({ maxAgeDays: 1 }); + + if (sessions.length === 0) { + logger.warn('[codemie-code] No recent OpenCode sessions found for processing'); + return; } - } finally { - // Show goodbye message - console.log(''); // Empty line for spacing - console.log(chalk.cyan.bold(getRandomGoodbyeMessage())); - console.log(''); // Spacing before powered by - console.log(chalk.cyan('Powered by AI/Run CodeMie CLI')); - console.log(''); // Empty line for spacing - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to run CodeMie Native: ${errorMessage}`); - } - }, - customHealthCheck: async () => { - const result = await CodeMieCode.testConnection(process.cwd()); + const latestSession = sessions[0]; + logger.debug(`[codemie-code] Processing latest session: ${latestSession.sessionId}`); + logger.debug(`[codemie-code] OpenCode session ID: ${latestSession.sessionId}`); + logger.debug(`[codemie-code] CodeMie session ID: ${sessionId}`); + + const context = { + sessionId, + apiBaseUrl: env.CODEMIE_BASE_URL || '', + cookies: '', + clientType: 'codemie-code', + version: env.CODEMIE_CLI_VERSION || '1.0.0', + dryRun: false + }; + + const result = await adapter.processSession( + latestSession.filePath, + sessionId, + context + ); + + if (result.success) { + logger.info(`[codemie-code] Metrics processing complete: ${result.totalRecords} records processed`); + logger.info('[codemie-code] Metrics written to JSONL - SessionSyncer will sync to v1/metrics next'); + } else { + logger.warn(`[codemie-code] Metrics processing had failures: ${result.failedProcessors.join(', ')}`); + } - if (result.success) { - logger.success('CodeMie Native is healthy'); - console.log(`Provider: ${result.provider || 'unknown'}`); - console.log(`Model: ${result.model || 'unknown'}`); - return true; - } else { - logger.error('Health check failed:', result.error); - return false; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`[codemie-code] Failed to process session metrics automatically: ${errorMessage}`); + } } } }; /** - * CodeMie-Code Adapter - * Custom implementation for built-in agent + * CodeMie Code Plugin + * Wraps the @codemieai/codemie-opencode binary as the built-in agent */ -export class CodeMieCodePlugin implements AgentAdapter { - name = BUILTIN_AGENT_NAME; - displayName = 'CodeMie Native'; - description = 'CodeMie Native Agent - Built-in LangGraph-based coding assistant'; +export class CodeMieCodePlugin extends BaseAgentAdapter { + private sessionAdapter: SessionAdapter; - async install(): Promise { - logger.info('CodeMie Native is built-in and already available'); - } - - async uninstall(): Promise { - logger.info('CodeMie Native is built-in and cannot be uninstalled'); + constructor() { + super(CodeMieCodePluginMetadata); + this.sessionAdapter = new OpenCodeSessionAdapter(CodeMieCodePluginMetadata); } + /** + * Check if the whitelabel binary is available. + * Uses existsSync on the resolved binary path instead of PATH lookup. + */ async isInstalled(): Promise { - return true; - } + const binaryPath = resolveCodemieOpenCodeBinary(); - async run(args: string[], envOverrides?: Record): Promise { - // Set environment variables if provided - if (envOverrides) { - Object.assign(process.env, envOverrides); + if (!binaryPath) { + logger.debug('[codemie-code] Whitelabel binary not found in node_modules'); + logger.debug('[codemie-code] Install with: npm i -g @codemieai/codemie-opencode'); + return false; } - // Parse options from args - const options: Record = {}; - const filteredArgs: string[] = []; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--task' && args[i + 1]) { - options.task = args[i + 1]; - i++; // Skip next arg - } else if (arg === '--debug') { - options.debug = true; - } else if (arg === '--plan') { - options.plan = true; - } else if (arg === '--plan-only') { - options.planOnly = true; - } else { - filteredArgs.push(arg); - } - } + const installed = existsSync(binaryPath); - if (!options.debug && logger.isDebugMode()) { - options.debug = true; + if (!installed) { + logger.debug('[codemie-code] Binary path resolved but file not found'); + logger.debug('[codemie-code] Install with: codemie install codemie-code'); } - if (CodeMieCodePluginMetadata.customRunHandler) { - await CodeMieCodePluginMetadata.customRunHandler(filteredArgs, options, {}); - } + return installed; } - async getVersion(): Promise { - try { - const packageJsonPath = join(getDirname(import.meta.url), '../../../package.json'); - const packageJsonContent = readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(packageJsonContent) as { version: string }; - return `v${packageJson.version} (built-in)`; - } catch { - return 'unknown (built-in)'; - } + /** + * Install the whitelabel package globally. + */ + async install(): Promise { + await installGlobal('@codemieai/codemie-opencode'); + } + + /** + * Return session adapter for analytics. + */ + getSessionAdapter(): SessionAdapter { + return this.sessionAdapter; } - getMetricsConfig(): import('../core/types.js').AgentMetricsConfig | undefined { - // Built-in agent doesn't have specific metrics config + /** + * No extension installer needed. + */ + getExtensionInstaller(): BaseExtensionInstaller | undefined { return undefined; } } diff --git a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts new file mode 100644 index 00000000..af5c6316 --- /dev/null +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts @@ -0,0 +1,125 @@ +/** + * Tests for resolveCodemieOpenCodeBinary() + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock logger +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }, +})); + +const { existsSync } = await import('fs'); +const { logger } = await import('../../../../utils/logger.js'); +const { resolveCodemieOpenCodeBinary } = await import('../codemie-opencode-binary.js'); + +const mockExistsSync = vi.mocked(existsSync); + +describe('resolveCodemieOpenCodeBinary', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + delete process.env.CODEMIE_OPENCODE_WL_BIN; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns env var path when CODEMIE_OPENCODE_WL_BIN is set and file exists', () => { + process.env.CODEMIE_OPENCODE_WL_BIN = '/custom/bin/codemie'; + mockExistsSync.mockImplementation((p) => p === '/custom/bin/codemie'); + + const result = resolveCodemieOpenCodeBinary(); + + expect(result).toBe('/custom/bin/codemie'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('CODEMIE_OPENCODE_WL_BIN') + ); + }); + + it('warns and continues resolution when CODEMIE_OPENCODE_WL_BIN set but file missing', () => { + process.env.CODEMIE_OPENCODE_WL_BIN = '/missing/bin/codemie'; + mockExistsSync.mockReturnValue(false); + + const result = resolveCodemieOpenCodeBinary(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('CODEMIE_OPENCODE_WL_BIN') + ); + // Falls through to null since no node_modules binaries exist either + expect(result).toBeNull(); + }); + + it('skips env check when CODEMIE_OPENCODE_WL_BIN not set', () => { + mockExistsSync.mockReturnValue(false); + + resolveCodemieOpenCodeBinary(); + + expect(logger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('CODEMIE_OPENCODE_WL_BIN') + ); + }); + + it('returns platform binary when found in node_modules', () => { + mockExistsSync.mockImplementation((p) => { + const ps = String(p); + // Platform package dir exists and binary file exists + return ps.includes('node_modules/@codemieai/codemie-opencode-') && ps.includes('/bin/'); + }); + + const result = resolveCodemieOpenCodeBinary(); + + if (result) { + expect(result).toContain('bin'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('platform binary') + ); + } + // If platform package is not found (node_modules doesn't exist), result may be null + }); + + it('returns wrapper binary when platform package not available', () => { + mockExistsSync.mockImplementation((p) => { + const ps = String(p); + // Platform-specific package NOT found, but wrapper package found + if (ps.includes('codemie-opencode-darwin') || ps.includes('codemie-opencode-linux') || ps.includes('codemie-opencode-windows')) { + return false; + } + return ps.includes('node_modules/@codemieai/codemie-opencode') && ps.includes('/bin/'); + }); + + const result = resolveCodemieOpenCodeBinary(); + + if (result) { + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('wrapper binary') + ); + } + }); + + it('returns null when no binary found anywhere', () => { + mockExistsSync.mockReturnValue(false); + + const result = resolveCodemieOpenCodeBinary(); + + expect(result).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('not found') + ); + }); +}); diff --git a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts new file mode 100644 index 00000000..9528b6ae --- /dev/null +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts @@ -0,0 +1,476 @@ +/** + * Tests for CodemieOpenCodePluginMetadata lifecycle hooks + * (beforeRun, enrichArgs, onSessionEnd) + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock BaseAgentAdapter +vi.mock('../../../core/BaseAgentAdapter.js', () => ({ + BaseAgentAdapter: class { + metadata: any; + constructor(metadata: any) { + this.metadata = metadata; + } + }, +})); + +// Mock binary resolution +vi.mock('../codemie-opencode-binary.js', () => ({ + resolveCodemieOpenCodeBinary: vi.fn(() => '/mock/bin/codemie'), +})); + +// Mock logger +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Mock installGlobal +vi.mock('../../../../utils/processes.js', () => ({ + installGlobal: vi.fn(), + detectGitBranch: vi.fn(() => Promise.resolve('main')), +})); + +// Mock getModelConfig +vi.mock('../../opencode/opencode-model-configs.js', () => ({ + getModelConfig: vi.fn(() => ({ + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + displayName: 'GPT-5.2 (Dec 2025)', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + })), +})); + +// Use vi.hoisted() so mock functions are available in hoisted vi.mock() factories +const { mockDiscoverSessions, mockProcessSession } = vi.hoisted(() => ({ + mockDiscoverSessions: vi.fn().mockResolvedValue([]), + mockProcessSession: vi.fn().mockResolvedValue({ success: true, totalRecords: 0 }), +})); + +// Mock OpenCodeSessionAdapter - must use function (not arrow) to support `new` +vi.mock('../../opencode/opencode.session.js', () => ({ + OpenCodeSessionAdapter: vi.fn(function () { + return { + discoverSessions: mockDiscoverSessions, + processSession: mockProcessSession, + }; + }), +})); + +// Mock SessionStore (dynamic import in ensureSessionFile) +vi.mock('../../../core/session/SessionStore.js', () => ({ + SessionStore: vi.fn(() => ({ + loadSession: vi.fn(() => Promise.resolve(null)), + saveSession: vi.fn(() => Promise.resolve()), + })), +})); + +// Mock fs +vi.mock('fs', () => ({ + existsSync: vi.fn(() => true), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const { writeFileSync } = await import('fs'); +const { logger } = await import('../../../../utils/logger.js'); +const { getModelConfig } = await import('../../opencode/opencode-model-configs.js'); +const { SessionStore } = await import('../../../core/session/SessionStore.js'); +const { CodemieOpenCodePluginMetadata } = await import('../codemie-opencode.plugin.js'); + +const mockGetModelConfig = vi.mocked(getModelConfig); + +const DEFAULT_MODEL_CONFIG = { + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + displayName: 'GPT-5.2 (Dec 2025)', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, +}; + +type AgentConfig = { model?: string }; + +describe('CodemieOpenCodePluginMetadata lifecycle', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + // Reset mock return value to default (clearAllMocks doesn't reset implementations) + mockGetModelConfig.mockReturnValue(DEFAULT_MODEL_CONFIG as any); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('beforeRun', () => { + const beforeRun = CodemieOpenCodePluginMetadata.lifecycle!.beforeRun!; + + it('creates session file when CODEMIE_SESSION_ID present', async () => { + const env: any = { CODEMIE_SESSION_ID: 'sess-123' }; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + const SessionStoreCtor = vi.mocked(SessionStore); + expect(SessionStoreCtor).toHaveBeenCalled(); + }); + + it('skips session file when no CODEMIE_SESSION_ID', async () => { + const env: any = {}; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + const SessionStoreCtor = vi.mocked(SessionStore); + expect(SessionStoreCtor).not.toHaveBeenCalled(); + }); + + it('logs warning and continues when ensureSessionFile fails', async () => { + vi.mocked(SessionStore).mockImplementationOnce(() => { + throw new Error('session store error'); + }); + + const env: any = { CODEMIE_SESSION_ID: 'sess-123' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + // ensureSessionFile has its own try/catch that calls logger.warn + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to create session file'), + expect.anything() + ); + // Should still return env (not throw) + expect(result).toBeDefined(); + }); + + it('returns env unchanged when no CODEMIE_BASE_URL', async () => { + const env: any = {}; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result).toBe(env); + expect(result.OPENCODE_CONFIG_CONTENT).toBeUndefined(); + }); + + it('warns and returns env unchanged for invalid CODEMIE_BASE_URL', async () => { + const env: any = { CODEMIE_BASE_URL: 'ftp://invalid' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid CODEMIE_BASE_URL'), + expect.anything() + ); + expect(result.OPENCODE_CONFIG_CONTENT).toBeUndefined(); + }); + + it('sets OPENCODE_CONFIG_CONTENT for valid http:// URL', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result.OPENCODE_CONFIG_CONTENT).toBeDefined(); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + expect(parsed.enabled_providers).toEqual(['codemie-proxy']); + expect(parsed.provider['codemie-proxy']).toBeDefined(); + }); + + it('sets OPENCODE_CONFIG_CONTENT for valid https:// URL', async () => { + const env: any = { CODEMIE_BASE_URL: 'https://proxy.example.com' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result.OPENCODE_CONFIG_CONTENT).toBeDefined(); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + expect(parsed.provider['codemie-proxy'].options.baseURL).toBe('https://proxy.example.com/'); + }); + + it('uses CODEMIE_MODEL env var for model selection', async () => { + const env: any = { + CODEMIE_BASE_URL: 'http://localhost:8080', + CODEMIE_MODEL: 'claude-opus-4-20250514', + }; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + expect(mockGetModelConfig).toHaveBeenCalledWith('claude-opus-4-20250514'); + }); + + it('falls back to config.model when no CODEMIE_MODEL', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = { model: 'custom-model' }; + + await beforeRun(env, config as any); + + expect(mockGetModelConfig).toHaveBeenCalledWith('custom-model'); + }); + + it('falls back to default gpt-5-2-2025-12-11 when no model specified', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + await beforeRun(env, config as any); + + expect(mockGetModelConfig).toHaveBeenCalledWith('gpt-5-2-2025-12-11'); + }); + + it('generates valid config JSON with required fields', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + + expect(parsed).toHaveProperty('enabled_providers'); + expect(parsed).toHaveProperty('provider.codemie-proxy'); + expect(parsed).toHaveProperty('defaults'); + expect(parsed.defaults.model).toContain('codemie-proxy/'); + }); + + it('writes temp file when config exceeds 32KB', async () => { + // Return config with large headers to exceed MAX_ENV_SIZE + mockGetModelConfig.mockReturnValue({ + id: 'big-model', + name: 'big-model', + displayName: 'Big Model', + family: 'big', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + providerOptions: { + headers: { 'X-Large': 'x'.repeat(40000) }, + }, + } as any); + + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + + expect(result.OPENCODE_CONFIG).toBeDefined(); + expect(result.OPENCODE_CONFIG_CONTENT).toBeUndefined(); + expect(writeFileSync).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('exceeds env var limit'), + expect.anything() + ); + }); + + it('strips displayName and providerOptions from model config in output', async () => { + const env: any = { CODEMIE_BASE_URL: 'http://localhost:8080' }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + const modelConfig = Object.values(parsed.provider['codemie-proxy'].models)[0] as any; + + expect(modelConfig.displayName).toBeUndefined(); + expect(modelConfig.providerOptions).toBeUndefined(); + }); + + it('uses CODEMIE_TIMEOUT when no providerOptions.timeout', async () => { + const env: any = { + CODEMIE_BASE_URL: 'http://localhost:8080', + CODEMIE_TIMEOUT: '300', + }; + const config: AgentConfig = {}; + + const result = await beforeRun(env, config as any); + const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); + + expect(parsed.provider['codemie-proxy'].options.timeout).toBe(300000); + }); + }); + + describe('enrichArgs', () => { + const enrichArgs = CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs!; + const config: AgentConfig = {}; + + it('passes through known subcommands', () => { + for (const sub of ['run', 'chat', 'config', 'init', 'help', 'version']) { + const result = enrichArgs([sub, '--flag'], config as any); + expect(result[0]).toBe(sub); + } + }); + + it('transforms --task "fix bug" to ["run", "fix bug"]', () => { + const result = enrichArgs(['--task', 'fix bug'], config as any); + expect(result).toEqual(['run', 'fix bug']); + }); + + it('strips -m/--message when --task present', () => { + const result = enrichArgs(['-m', 'hello', '--task', 'fix bug'], config as any); + expect(result).not.toContain('-m'); + expect(result).not.toContain('hello'); + expect(result).toContain('fix bug'); + }); + + it('returns empty array for empty args', () => { + const result = enrichArgs([], config as any); + expect(result).toEqual([]); + }); + + it('returns unchanged when --task is last arg (no value)', () => { + const result = enrichArgs(['--task'], config as any); + expect(result).toEqual(['--task']); + }); + + it('returns unchanged for unknown args without --task', () => { + const result = enrichArgs(['--verbose', '--debug'], config as any); + expect(result).toEqual(['--verbose', '--debug']); + }); + + it('preserves other args alongside --task transformation', () => { + const result = enrichArgs(['--verbose', '--task', 'fix bug'], config as any); + expect(result).toContain('run'); + expect(result).toContain('--verbose'); + expect(result).toContain('fix bug'); + }); + }); + + describe('onSessionEnd', () => { + const onSessionEnd = CodemieOpenCodePluginMetadata.lifecycle!.onSessionEnd!; + + it('skips when no CODEMIE_SESSION_ID', async () => { + const env: any = {}; + + await onSessionEnd(0, env); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('skipping') + ); + }); + + it('processes latest session and logs success with record count', async () => { + mockDiscoverSessions.mockResolvedValue([ + { sessionId: 'oc-session', filePath: '/sessions/file.jsonl' }, + ]); + mockProcessSession.mockResolvedValue({ + success: true, + totalRecords: 42, + }); + + const env: any = { + CODEMIE_SESSION_ID: 'test-sess-id', + CODEMIE_BASE_URL: 'http://localhost:3000', + }; + + await onSessionEnd(0, env); + + expect(mockDiscoverSessions).toHaveBeenCalledWith({ maxAgeDays: 1 }); + expect(mockProcessSession).toHaveBeenCalledWith( + '/sessions/file.jsonl', + 'test-sess-id', + expect.objectContaining({ sessionId: 'test-sess-id' }) + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('42 records') + ); + }); + + it('warns when no sessions discovered', async () => { + mockDiscoverSessions.mockResolvedValue([]); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + await onSessionEnd(0, env); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('No recent') + ); + }); + + it('warns on partial failures with failedProcessors list', async () => { + mockDiscoverSessions.mockResolvedValue([ + { sessionId: 's1', filePath: '/path' }, + ]); + mockProcessSession.mockResolvedValue({ + success: false, + failedProcessors: ['tokenizer', 'cost-calculator'], + }); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + await onSessionEnd(0, env); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('failures') + ); + }); + + it('logs error but does not throw on processing error', async () => { + mockDiscoverSessions.mockRejectedValue(new Error('discover failed')); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + // Should not throw + await onSessionEnd(0, env); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed') + ); + }); + + it('uses clientType codemie-opencode in context', async () => { + mockDiscoverSessions.mockResolvedValue([ + { sessionId: 's1', filePath: '/path' }, + ]); + mockProcessSession.mockResolvedValue({ success: true, totalRecords: 1 }); + + const env: any = { CODEMIE_SESSION_ID: 'test-123' }; + + await onSessionEnd(0, env); + + expect(mockProcessSession).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ clientType: 'codemie-opencode' }) + ); + }); + }); +}); diff --git a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts new file mode 100644 index 00000000..51fefd0e --- /dev/null +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for CodemieOpenCodePlugin class + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock BaseAgentAdapter to avoid dependency tree +vi.mock('../../../core/BaseAgentAdapter.js', () => ({ + BaseAgentAdapter: class { + metadata: any; + constructor(metadata: any) { + this.metadata = metadata; + } + }, +})); + +// Mock binary resolution +vi.mock('../codemie-opencode-binary.js', () => ({ + resolveCodemieOpenCodeBinary: vi.fn(() => '/mock/bin/codemie'), +})); + +// Mock logger +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Mock installGlobal +vi.mock('../../../../utils/processes.js', () => ({ + installGlobal: vi.fn(), +})); + +// Mock OpenCodeSessionAdapter - must use function (not arrow) to support `new` +vi.mock('../../opencode/opencode.session.js', () => ({ + OpenCodeSessionAdapter: vi.fn(function () { + return { + discoverSessions: vi.fn(), + processSession: vi.fn(), + }; + }), +})); + +// Mock getModelConfig +vi.mock('../../opencode/opencode-model-configs.js', () => ({ + getModelConfig: vi.fn(() => ({ + id: 'gpt-5-2-2025-12-11', + name: 'gpt-5-2-2025-12-11', + family: 'gpt-5', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + modalities: { input: ['text'], output: ['text'] }, + knowledge: '2025-06-01', + release_date: '2025-12-11', + last_updated: '2025-12-11', + open_weights: false, + cost: { input: 2.5, output: 10 }, + limit: { context: 1048576, output: 65536 }, + })), +})); + +// Mock fs for existsSync +vi.mock('fs', () => ({ + existsSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +const { existsSync } = await import('fs'); +const { resolveCodemieOpenCodeBinary } = await import('../codemie-opencode-binary.js'); +const { installGlobal } = await import('../../../../utils/processes.js'); +const { OpenCodeSessionAdapter } = await import('../../opencode/opencode.session.js'); +const { CodemieOpenCodePlugin } = await import('../codemie-opencode.plugin.js'); + +const mockExistsSync = vi.mocked(existsSync); +const mockResolve = vi.mocked(resolveCodemieOpenCodeBinary); +const mockInstallGlobal = vi.mocked(installGlobal); + +describe('CodemieOpenCodePlugin', () => { + let plugin: InstanceType; + + beforeEach(() => { + vi.clearAllMocks(); + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + plugin = new CodemieOpenCodePlugin(); + }); + + describe('isInstalled', () => { + it('returns true when binary resolved and exists', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(true); + + const result = await plugin.isInstalled(); + expect(result).toBe(true); + }); + + it('returns false when resolveCodemieOpenCodeBinary returns null', async () => { + mockResolve.mockReturnValue(null); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + + it('returns false when path resolved but file missing', async () => { + mockResolve.mockReturnValue('/mock/bin/codemie'); + mockExistsSync.mockReturnValue(false); + + const result = await plugin.isInstalled(); + expect(result).toBe(false); + }); + }); + + describe('install', () => { + it('calls installGlobal with the correct package name', async () => { + await plugin.install(); + expect(mockInstallGlobal).toHaveBeenCalledWith('@codemieai/codemie-opencode'); + }); + }); + + describe('getSessionAdapter', () => { + it('returns an OpenCodeSessionAdapter instance', () => { + const adapter = plugin.getSessionAdapter(); + expect(adapter).toBeDefined(); + expect(OpenCodeSessionAdapter).toHaveBeenCalled(); + }); + }); + + describe('getExtensionInstaller', () => { + it('returns undefined', () => { + const installer = plugin.getExtensionInstaller(); + expect(installer).toBeUndefined(); + }); + }); +}); diff --git a/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts b/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts new file mode 100644 index 00000000..3d31782b --- /dev/null +++ b/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts @@ -0,0 +1,104 @@ +import { existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { logger } from '../../../utils/logger.js'; + +/** + * Platform-specific package name mapping for @codemieai/codemie-opencode. + * The wrapper package lists these as optionalDependencies; npm only downloads + * the one matching the current platform. + */ +function getPlatformPackage(): string | null { + const platform = process.platform; + const arch = process.arch; + + const platformMap: Record> = { + darwin: { + x64: '@codemieai/codemie-opencode-darwin-x64', + arm64: '@codemieai/codemie-opencode-darwin-arm64', + }, + linux: { + x64: '@codemieai/codemie-opencode-linux-x64', + arm64: '@codemieai/codemie-opencode-linux-arm64', + }, + win32: { + x64: '@codemieai/codemie-opencode-windows-x64', + arm64: '@codemieai/codemie-opencode-windows-arm64', + }, + }; + + return platformMap[platform]?.[arch] ?? null; +} + +/** + * Walk up from a starting directory looking for a node_modules directory + * that contains the given package. + */ +function findPackageInNodeModules(startDir: string, packageName: string): string | null { + let current = startDir; + + while (true) { + const candidate = join(current, 'node_modules', packageName); + if (existsSync(candidate)) { + return candidate; + } + + const parent = dirname(current); + if (parent === current) break; // reached filesystem root + current = parent; + } + + return null; +} + +/** + * Resolve the bundled @codemieai/codemie-opencode binary from node_modules. + * + * Resolution order: + * 1. CODEMIE_OPENCODE_WL_BIN env var override (escape hatch) + * 2. Platform-specific binary from node_modules/@codemieai/codemie-opencode-{platform}-{arch}/bin/codemie + * 3. Wrapper package binary from node_modules/@codemieai/codemie-opencode/bin/codemie + * 4. Fallback: null (binary not found) + */ +export function resolveCodemieOpenCodeBinary(): string | null { + // 1. Environment variable override + const envBin = process.env.CODEMIE_OPENCODE_WL_BIN; + if (envBin) { + if (existsSync(envBin)) { + logger.debug(`[codemie-opencode] Using binary from CODEMIE_OPENCODE_WL_BIN: ${envBin}`); + return envBin; + } + logger.warn(`[codemie-opencode] CODEMIE_OPENCODE_WL_BIN set but binary not found: ${envBin}`); + } + + // Start searching from this module's directory + const moduleDir = dirname(fileURLToPath(import.meta.url)); + const binName = process.platform === 'win32' ? 'codemie.exe' : 'codemie'; + + // 2. Try platform-specific package first (direct binary, no wrapper) + const platformPkg = getPlatformPackage(); + if (platformPkg) { + const platformDir = findPackageInNodeModules(moduleDir, platformPkg); + if (platformDir) { + const platformBin = join(platformDir, 'bin', binName); + if (existsSync(platformBin)) { + logger.debug(`[codemie-opencode] Resolved platform binary: ${platformBin}`); + return platformBin; + } + } + } + + // 3. Fall back to wrapper package binary + const wrapperDir = findPackageInNodeModules(moduleDir, '@codemieai/codemie-opencode'); + if (wrapperDir) { + const wrapperBin = join(wrapperDir, 'bin', binName); + if (existsSync(wrapperBin)) { + logger.debug(`[codemie-opencode] Resolved wrapper binary: ${wrapperBin}`); + return wrapperBin; + } + } + + // 4. Not found + logger.debug('[codemie-opencode] Binary not found in node_modules'); + return null; +} diff --git a/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts new file mode 100644 index 00000000..b462ded7 --- /dev/null +++ b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts @@ -0,0 +1,359 @@ +import type { AgentMetadata, AgentConfig } from '../../core/types.js'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { writeFileSync, unlinkSync, existsSync } from 'fs'; +import { logger } from '../../../utils/logger.js'; +import { getModelConfig } from '../opencode/opencode-model-configs.js'; +import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; +import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; +import { installGlobal } from '../../../utils/processes.js'; +import { OpenCodeSessionAdapter } from '../opencode/opencode.session.js'; +import { resolveCodemieOpenCodeBinary } from './codemie-opencode-binary.js'; + +const OPENCODE_SUBCOMMANDS = ['run', 'chat', 'config', 'init', 'help', 'version']; + +// Environment variable size limit (conservative - varies by platform) +// Linux: ~128KB per var, Windows: ~32KB total env block +const MAX_ENV_SIZE = 32 * 1024; + +// Track temp config files for cleanup on process exit +const tempConfigFiles: string[] = []; +let cleanupRegistered = false; + +/** + * Register process exit handler for temp file cleanup (best effort) + * Only registers once, even if beforeRun is called multiple times + */ +function registerCleanupHandler(): void { + if (cleanupRegistered) return; + cleanupRegistered = true; + + process.on('exit', () => { + for (const file of tempConfigFiles) { + try { + unlinkSync(file); + logger.debug(`[codemie-opencode] Cleaned up temp config: ${file}`); + } catch { + // Ignore cleanup errors - file may already be deleted + } + } + }); +} + +/** + * Write config to temp file as fallback when env var size exceeded + * Returns the temp file path + */ +function writeConfigToTempFile(configJson: string): string { + const configPath = join( + tmpdir(), + `codemie-opencode-wl-config-${process.pid}-${Date.now()}.json` + ); + writeFileSync(configPath, configJson, 'utf-8'); + tempConfigFiles.push(configPath); + registerCleanupHandler(); + return configPath; +} + +/** + * Ensure session metadata file exists for SessionSyncer + * Creates or updates the session file in ~/.codemie/sessions/ + */ +async function ensureSessionFile(sessionId: string, env: NodeJS.ProcessEnv): Promise { + try { + const { SessionStore } = await import('../../core/session/SessionStore.js'); + const sessionStore = new SessionStore(); + + const existing = await sessionStore.loadSession(sessionId); + if (existing) { + logger.debug('[codemie-opencode] Session file already exists'); + return; + } + + const agentName = env.CODEMIE_AGENT || 'codemie-opencode'; + const provider = env.CODEMIE_PROVIDER || 'unknown'; + const project = env.CODEMIE_PROJECT; + const workingDirectory = process.cwd(); + + let gitBranch: string | undefined; + try { + const { detectGitBranch } = await import('../../../utils/processes.js'); + gitBranch = await detectGitBranch(workingDirectory); + } catch { + // Git detection optional + } + + const estimatedStartTime = Date.now() - 2000; + + const session = { + sessionId, + agentName, + provider, + ...(project && { project }), + startTime: estimatedStartTime, + workingDirectory, + ...(gitBranch && { gitBranch }), + status: 'completed' as const, + activeDurationMs: 0, + correlation: { + status: 'matched' as const, + agentSessionId: 'unknown', + retryCount: 0 + } + }; + + await sessionStore.saveSession(session); + logger.debug('[codemie-opencode] Created session metadata file'); + + } catch (error) { + logger.warn('[codemie-opencode] Failed to create session file:', error); + } +} + +// Resolve binary at load time, fallback to 'codemie' +const resolvedBinary = resolveCodemieOpenCodeBinary(); + +/** + * Environment variable contract between the umbrella CLI and whitelabel binary. + * + * The umbrella CLI orchestrates everything (proxy, auth, metrics, session sync) + * and spawns the whitelabel binary as a child process. The whitelabel knows + * nothing about SSO, cookies, or metrics — it just sees an OpenAI-compatible + * endpoint at localhost. + * + * Flow: BaseAgentAdapter.run() → setupProxy() → beforeRun hook → spawn(binary) + * + * | Env Var | Set By | Consumed By | Purpose | + * |--------------------------|----------------------|----------------------|------------------------------------------------| + * | OPENCODE_CONFIG_CONTENT | beforeRun hook | Whitelabel config.ts | Full provider config JSON (proxy URL, models) | + * | OPENCODE_CONFIG | beforeRun (fallback) | Whitelabel config.ts | Temp file path when JSON exceeds env var limit | + * | CODEMIE_SESSION_ID | BaseAgentAdapter | onSessionEnd hook | Session ID for metrics correlation | + * | CODEMIE_AGENT | BaseAgentAdapter | Lifecycle helpers | Agent name ('codemie-opencode') | + * | CODEMIE_PROVIDER | Config loader | setupProxy() | Provider name (e.g., 'ai-run-sso') | + * | CODEMIE_BASE_URL | setupProxy() | beforeRun hook | Proxy URL (http://localhost:{port}) | + * | CODEMIE_MODEL | Config/CLI | beforeRun hook | Selected model ID | + * | CODEMIE_PROJECT | SSO exportEnvVars | Session metadata | CodeMie project name | + */ +export const CodemieOpenCodePluginMetadata: AgentMetadata = { + name: 'codemie-opencode', + displayName: 'CodeMie OpenCode', + description: 'CodeMie OpenCode - whitelabel AI coding assistant', + npmPackage: '@codemieai/codemie-opencode', + cliCommand: resolvedBinary || 'codemie', + dataPaths: { + home: '.opencode' + // Session storage follows XDG conventions, handled by opencode.paths.ts + }, + envMapping: { + baseUrl: [], + apiKey: [], + model: [] + }, + supportedProviders: ['litellm', 'ai-run-sso'], + ssoConfig: { enabled: true, clientType: 'codemie-opencode' }, + + lifecycle: { + async beforeRun(env: NodeJS.ProcessEnv, config: AgentConfig) { + const sessionId = env.CODEMIE_SESSION_ID; + if (sessionId) { + try { + logger.debug('[codemie-opencode] Creating session metadata file before startup'); + await ensureSessionFile(sessionId, env); + logger.debug('[codemie-opencode] Session metadata file ready for SessionSyncer'); + } catch (error) { + logger.error('[codemie-opencode] Failed to create session file in beforeRun', { error }); + } + } + + const proxyUrl = env.CODEMIE_BASE_URL; + + if (!proxyUrl) { + return env; + } + + if (!proxyUrl.startsWith('http://') && !proxyUrl.startsWith('https://')) { + logger.warn(`Invalid CODEMIE_BASE_URL format: ${proxyUrl}`, { agent: 'codemie-opencode' }); + return env; + } + + const selectedModel = env.CODEMIE_MODEL || config?.model || 'gpt-5-2-2025-12-11'; + const modelConfig = getModelConfig(selectedModel); + + const { displayName: _displayName, providerOptions, ...opencodeModelConfig } = modelConfig; + + const openCodeConfig = { + enabled_providers: ['codemie-proxy'], + provider: { + 'codemie-proxy': { + npm: '@ai-sdk/openai-compatible', + name: 'CodeMie SSO', + options: { + baseURL: `${proxyUrl}/`, + apiKey: 'proxy-handled', + timeout: providerOptions?.timeout || + parseInt(env.CODEMIE_TIMEOUT || '600') * 1000, + ...(providerOptions?.headers && { + headers: providerOptions.headers + }) + }, + models: { + [modelConfig.id]: opencodeModelConfig + } + } + }, + defaults: { + model: `codemie-proxy/${modelConfig.id}` + } + }; + + const configJson = JSON.stringify(openCodeConfig); + + if (configJson.length > MAX_ENV_SIZE) { + logger.warn(`Config size (${configJson.length} bytes) exceeds env var limit (${MAX_ENV_SIZE}), using temp file fallback`, { + agent: 'codemie-opencode' + }); + + const configPath = writeConfigToTempFile(configJson); + logger.debug(`[codemie-opencode] Wrote config to temp file: ${configPath}`); + + env.OPENCODE_CONFIG = configPath; + return env; + } + + env.OPENCODE_CONFIG_CONTENT = configJson; + return env; + }, + + enrichArgs: (args: string[], _config: AgentConfig) => { + if (args.length > 0 && OPENCODE_SUBCOMMANDS.includes(args[0])) { + return args; + } + + const taskIndex = args.indexOf('--task'); + if (taskIndex !== -1 && taskIndex < args.length - 1) { + const taskValue = args[taskIndex + 1]; + const otherArgs = args.filter((arg, i, arr) => { + if (i === taskIndex || i === taskIndex + 1) return false; + if (arg === '-m' || arg === '--message') return false; + if (i > 0 && (arr[i - 1] === '-m' || arr[i - 1] === '--message')) return false; + return true; + }); + return ['run', ...otherArgs, taskValue]; + } + return args; + }, + + async onSessionEnd(exitCode: number, env: NodeJS.ProcessEnv) { + const sessionId = env.CODEMIE_SESSION_ID; + + if (!sessionId) { + logger.debug('[codemie-opencode] No CODEMIE_SESSION_ID in environment, skipping metrics processing'); + return; + } + + try { + logger.info(`[codemie-opencode] Processing session metrics before SessionSyncer (code=${exitCode})`); + + const adapter = new OpenCodeSessionAdapter(CodemieOpenCodePluginMetadata); + + const sessions = await adapter.discoverSessions({ maxAgeDays: 1 }); + + if (sessions.length === 0) { + logger.warn('[codemie-opencode] No recent OpenCode sessions found for processing'); + return; + } + + const latestSession = sessions[0]; + logger.debug(`[codemie-opencode] Processing latest session: ${latestSession.sessionId}`); + logger.debug(`[codemie-opencode] OpenCode session ID: ${latestSession.sessionId}`); + logger.debug(`[codemie-opencode] CodeMie session ID: ${sessionId}`); + + const context = { + sessionId, + apiBaseUrl: env.CODEMIE_BASE_URL || '', + cookies: '', + clientType: 'codemie-opencode', + version: env.CODEMIE_CLI_VERSION || '1.0.0', + dryRun: false + }; + + const result = await adapter.processSession( + latestSession.filePath, + sessionId, + context + ); + + if (result.success) { + logger.info(`[codemie-opencode] Metrics processing complete: ${result.totalRecords} records processed`); + logger.info('[codemie-opencode] Metrics written to JSONL - SessionSyncer will sync to v1/metrics next'); + } else { + logger.warn(`[codemie-opencode] Metrics processing had failures: ${result.failedProcessors.join(', ')}`); + } + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`[codemie-opencode] Failed to process session metrics automatically: ${errorMessage}`); + } + } + } +}; + +/** + * CodeMie OpenCode whitelabel agent plugin + * Wraps the @codemieai/codemie-opencode binary distributed via npm + */ +export class CodemieOpenCodePlugin extends BaseAgentAdapter { + private sessionAdapter: SessionAdapter; + + constructor() { + super(CodemieOpenCodePluginMetadata); + this.sessionAdapter = new OpenCodeSessionAdapter(CodemieOpenCodePluginMetadata); + } + + /** + * Check if the whitelabel binary is available. + * Uses existsSync on the resolved binary path instead of PATH lookup. + */ + async isInstalled(): Promise { + const binaryPath = resolveCodemieOpenCodeBinary(); + + if (!binaryPath) { + logger.debug('[codemie-opencode] Whitelabel binary not found in node_modules'); + logger.debug('[codemie-opencode] Install with: npm i -g @codemieai/codemie-opencode'); + return false; + } + + const installed = existsSync(binaryPath); + + if (!installed) { + logger.debug('[codemie-opencode] Binary path resolved but file not found'); + logger.debug('[codemie-opencode] Install with: codemie install codemie-opencode'); + } + + return installed; + } + + /** + * Install the whitelabel package globally. + * The package's postinstall.mjs handles platform binary resolution. + */ + async install(): Promise { + await installGlobal('@codemieai/codemie-opencode'); + } + + /** + * Return session adapter for analytics. + * Reuses OpenCodeSessionAdapter since storage paths are identical. + */ + getSessionAdapter(): SessionAdapter { + return this.sessionAdapter; + } + + /** + * No extension installer needed. + */ + getExtensionInstaller(): BaseExtensionInstaller | undefined { + return undefined; + } +} diff --git a/src/agents/plugins/codemie-opencode/index.ts b/src/agents/plugins/codemie-opencode/index.ts new file mode 100644 index 00000000..845ba89d --- /dev/null +++ b/src/agents/plugins/codemie-opencode/index.ts @@ -0,0 +1,2 @@ +export { CodemieOpenCodePlugin, CodemieOpenCodePluginMetadata } from './codemie-opencode.plugin.js'; +export { resolveCodemieOpenCodeBinary } from './codemie-opencode-binary.js'; diff --git a/src/agents/plugins/opencode/opencode-model-configs.ts b/src/agents/plugins/opencode/opencode-model-configs.ts index 59a5880c..e848daa4 100644 --- a/src/agents/plugins/opencode/opencode-model-configs.ts +++ b/src/agents/plugins/opencode/opencode-model-configs.ts @@ -189,13 +189,223 @@ export const OPENCODE_MODEL_CONFIGS: Record = { context: 128000, output: 16384 } + }, + + // ── Claude Models ────────────────────────────────────────────────── + 'claude-4-5-sonnet': { + id: 'claude-4-5-sonnet', + name: 'Claude 4.5 Sonnet', + displayName: 'Claude 4.5 Sonnet', + family: 'claude-4', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-04-01', + release_date: '2025-09-29', + last_updated: '2025-09-29', + open_weights: false, + cost: { + input: 3, + output: 15, + cache_read: 0.3 + }, + limit: { + context: 200000, + output: 16384 + } + }, + 'claude-sonnet-4-5-20250929': { + id: 'claude-sonnet-4-5-20250929', + name: 'Claude Sonnet 4.5 (Sep 2025)', + displayName: 'Claude Sonnet 4.5 (Sep 2025)', + family: 'claude-4', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-04-01', + release_date: '2025-09-29', + last_updated: '2025-09-29', + open_weights: false, + cost: { + input: 3, + output: 15, + cache_read: 0.3 + }, + limit: { + context: 200000, + output: 16384 + } + }, + 'claude-opus-4-6': { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + displayName: 'Claude Opus 4.6', + family: 'claude-4', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-05-01', + release_date: '2026-01-15', + last_updated: '2026-01-15', + open_weights: false, + cost: { + input: 15, + output: 75, + cache_read: 1.5 + }, + limit: { + context: 200000, + output: 32000 + } + }, + 'claude-haiku-4-5': { + id: 'claude-haiku-4-5', + name: 'Claude Haiku 4.5', + displayName: 'Claude Haiku 4.5', + family: 'claude-4', + tool_call: true, + reasoning: false, + attachment: true, + temperature: true, + modalities: { + input: ['text', 'image'], + output: ['text'] + }, + knowledge: '2025-04-01', + release_date: '2025-10-01', + last_updated: '2025-10-01', + open_weights: false, + cost: { + input: 0.8, + output: 4, + cache_read: 0.08 + }, + limit: { + context: 200000, + output: 8192 + } + }, + + // ── Gemini Models ────────────────────────────────────────────────── + 'gemini-2.5-pro': { + id: 'gemini-2.5-pro', + name: 'Gemini 2.5 Pro', + displayName: 'Gemini 2.5 Pro', + family: 'gemini-2', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image', 'audio', 'video'], + output: ['text'] + }, + knowledge: '2025-03-01', + release_date: '2025-06-05', + last_updated: '2025-06-05', + open_weights: false, + cost: { + input: 1.25, + output: 10, + cache_read: 0.31 + }, + limit: { + context: 1048576, + output: 65536 + } + }, + 'gemini-2.5-flash': { + id: 'gemini-2.5-flash', + name: 'Gemini 2.5 Flash', + displayName: 'Gemini 2.5 Flash', + family: 'gemini-2', + tool_call: true, + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { + input: ['text', 'image', 'audio', 'video'], + output: ['text'] + }, + knowledge: '2025-03-01', + release_date: '2025-04-17', + last_updated: '2025-04-17', + open_weights: false, + cost: { + input: 0.15, + output: 0.6, + cache_read: 0.0375 + }, + limit: { + context: 1048576, + output: 65536 + } + } +}; + +/** + * Family-specific defaults for unknown model variants. + * Used by getModelConfig() when an exact match isn't found but + * the model ID prefix matches a known family. + */ +const MODEL_FAMILY_DEFAULTS: Record> = { + 'claude': { + family: 'claude-4', + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + limit: { context: 200000, output: 16384 } + }, + 'gemini': { + family: 'gemini-2', + reasoning: true, + attachment: true, + temperature: true, + structured_output: true, + modalities: { input: ['text', 'image', 'audio', 'video'], output: ['text'] }, + limit: { context: 1048576, output: 65536 } + }, + 'gpt': { + family: 'gpt-5', + reasoning: true, + attachment: true, + temperature: false, + modalities: { input: ['text', 'image'], output: ['text'] }, + limit: { context: 400000, output: 128000 } } }; /** * Get model configuration with fallback for unknown models * - * @param modelId - Model identifier (e.g., 'gpt-5-2-2025-12-11') + * Resolution order: + * 1. Exact match in OPENCODE_MODEL_CONFIGS + * 2. Family-aware fallback using MODEL_FAMILY_DEFAULTS + * 3. Generic fallback with conservative defaults + * + * @param modelId - Model identifier (e.g., 'gpt-5-2-2025-12-11', 'claude-4-5-sonnet') * @returns Model configuration in OpenCode format * * Note: The returned config is used directly in OPENCODE_CONFIG_CONTENT @@ -207,34 +417,35 @@ export function getModelConfig(modelId: string): OpenCodeModelConfig { return config; } - // Fallback for unknown models - create minimal OpenCode-compatible config - // Extract family from model ID (e.g., "gpt-4o" -> "gpt-4") - const family = modelId.split('-').slice(0, 2).join('-') || modelId; + // Detect model family from prefix for smarter defaults + const familyPrefix = Object.keys(MODEL_FAMILY_DEFAULTS).find( + prefix => modelId.startsWith(prefix) + ); + const familyDefaults = familyPrefix ? MODEL_FAMILY_DEFAULTS[familyPrefix] : {}; + + // Extract family from model ID (e.g., "gpt-4o" -> "gpt-4", "claude-4-5-sonnet" -> "claude-4") + const family = familyDefaults.family + || modelId.split('-').slice(0, 2).join('-') + || modelId; + + const today = new Date().toISOString().split('T')[0]; return { id: modelId, name: modelId, displayName: modelId, family, - tool_call: true, // Assume tool support - reasoning: false, // Conservative default - attachment: false, - temperature: true, - modalities: { - input: ['text'], - output: ['text'] - }, - knowledge: new Date().toISOString().split('T')[0], // Use current date - release_date: new Date().toISOString().split('T')[0], - last_updated: new Date().toISOString().split('T')[0], + tool_call: true, + reasoning: familyDefaults.reasoning ?? false, + attachment: familyDefaults.attachment ?? false, + temperature: familyDefaults.temperature ?? true, + structured_output: familyDefaults.structured_output, + modalities: familyDefaults.modalities ?? { input: ['text'], output: ['text'] }, + knowledge: today, + release_date: today, + last_updated: today, open_weights: false, - cost: { - input: 0, - output: 0 - }, - limit: { - context: 128000, - output: 4096 - } + cost: { input: 0, output: 0 }, + limit: familyDefaults.limit ?? { context: 128000, output: 4096 } }; } diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 5345d634..0ddd7449 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 8c2f0795..dac96aac 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -108,6 +108,31 @@ async function handleSessionStart(event: SessionStartEvent, _rawInput: string, s await createSessionRecord(event, sessionId); // Send session start metrics (SSO provider only) await sendSessionStartMetrics(event, sessionId, event.session_id); + // 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 24b373e6..58eb019a 100644 --- a/src/cli/commands/skill.ts +++ b/src/cli/commands/skill.ts @@ -1,9 +1,9 @@ import { Command } from 'commander'; import Table from 'cli-table3'; import chalk from 'chalk'; -import { SkillManager } from '../../skills/index.js'; +import { SkillManager, SkillSync } from '../../agents/codemie-code/skills/index.js'; import { logger } from '../../utils/logger.js'; -import type { Skill } from '../../skills/index.js'; +import type { Skill } from '../../agents/codemie-code/skills/index.js'; /** * Format skill source with color @@ -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 bcf44812..62081468 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -68,26 +68,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); diff --git a/src/skills/index.ts b/src/skills/index.ts index 827b471b..e09f1f2d 100644 --- a/src/skills/index.ts +++ b/src/skills/index.ts @@ -1,13 +1,17 @@ /** * Skills System - Public API * - * Provides skill discovery, loading, and management for CodeMie agents. + * Re-exports from the relocated skills module at src/agents/codemie-code/skills/ */ // Core exports export { SkillManager } from './core/SkillManager.js'; export { SkillDiscovery } from './core/SkillDiscovery.js'; +// Sync exports (relocated to agents/codemie-code/skills/sync/) +export { SkillSync } from '../agents/codemie-code/skills/sync/SkillSync.js'; +export type { SyncOptions, SyncResult } from '../agents/codemie-code/skills/sync/SkillSync.js'; + // Type exports export type { Skill, From c72bb91d8dcbba2d3f03aa6b1eb56be468568153 Mon Sep 17 00:00:00 2001 From: Maksym Diabin Date: Mon, 23 Feb 2026 15:54:52 +0100 Subject: [PATCH 2/5] feat(agents): enable ollama and bedrock providers in whitelabel config - Refactor beforeRun to build multi-provider config (codemie-proxy, ollama, amazon-bedrock) - Inject all model configs into codemie-proxy provider block - Add ollama and bedrock to supportedProviders in both codemie-opencode and codemie-code metadata - Add getAllOpenCodeModelConfigs() helper for bulk model export - Update tests for new multi-provider config structure Generated with AI Co-Authored-By: codemie-ai --- package-lock.json | 96 +++++++++---------- package.json | 2 +- src/agents/plugins/codemie-code.plugin.ts | 2 +- .../codemie-opencode-lifecycle.test.ts | 72 ++++++++++++-- .../codemie-opencode.plugin.ts | 69 ++++++++----- .../opencode/opencode-model-configs.ts | 15 ++- .../plugins/opencode/opencode.plugin.ts | 69 ++++++++----- 7 files changed, 217 insertions(+), 108 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1991f172..a1020103 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@aws-sdk/credential-providers": "^3.948.0", "@clack/core": "^0.5.0", "@clack/prompts": "^0.11.0", - "@codemieai/codemie-opencode": "0.0.41", + "@codemieai/codemie-opencode": "0.0.43", "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", @@ -1013,32 +1013,32 @@ } }, "node_modules/@codemieai/codemie-opencode": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode/-/codemie-opencode-0.0.41.tgz", - "integrity": "sha512-Vu+sdpusP7h4HiijijQts08Dun3gikH2tsRNN0Gu4ghyeU6S5xVlPUvxMbWAuUc0NnRHSZkJXSI8nqWgLD+DlA==", + "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.41", - "@codemieai/codemie-opencode-darwin-x64": "0.0.41", - "@codemieai/codemie-opencode-darwin-x64-baseline": "0.0.41", - "@codemieai/codemie-opencode-linux-arm64": "0.0.41", - "@codemieai/codemie-opencode-linux-arm64-musl": "0.0.41", - "@codemieai/codemie-opencode-linux-x64": "0.0.41", - "@codemieai/codemie-opencode-linux-x64-baseline": "0.0.41", - "@codemieai/codemie-opencode-linux-x64-baseline-musl": "0.0.41", - "@codemieai/codemie-opencode-linux-x64-musl": "0.0.41", - "@codemieai/codemie-opencode-windows-x64": "0.0.41", - "@codemieai/codemie-opencode-windows-x64-baseline": "0.0.41" + "@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.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-arm64/-/codemie-opencode-darwin-arm64-0.0.41.tgz", - "integrity": "sha512-6AeHnSEC0beU4oaZhgOgVA4N2mi4ElzKzh5C6L4zia88ClqjKVRqwfJq8tv4Zjo1uRj50YlXKiuqmSfA1EOU0A==", + "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" ], @@ -1049,9 +1049,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-darwin-x64": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64/-/codemie-opencode-darwin-x64-0.0.41.tgz", - "integrity": "sha512-RC8/JGInxb9A0zLq23ylU+tv+g2i99+O7ZlPI0KQ6ZvgPnTfeNat9iidpe3KJbP/aIHN20SSmNeLt63VpDWTXQ==", + "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" ], @@ -1062,9 +1062,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-darwin-x64-baseline": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64-baseline/-/codemie-opencode-darwin-x64-baseline-0.0.41.tgz", - "integrity": "sha512-FFLGDxLK7CzT1/u9pV9ixcHS2qWzmhzIMQQHFNH/75yPAzl2FoBZpTA51UE6T3R8C9ApxVLIRh3pIf5f8WQ1Hw==", + "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" ], @@ -1075,9 +1075,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-linux-arm64": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64/-/codemie-opencode-linux-arm64-0.0.41.tgz", - "integrity": "sha512-Ox8ZDce4rguOYrlCIawaZlHnT48iMakN3B32V/4L8f8vLE9n2PpntqRxEkawc6cT6/PX6ENF3J/1hv7YIbMJEQ==", + "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" ], @@ -1088,9 +1088,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-linux-arm64-musl": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64-musl/-/codemie-opencode-linux-arm64-musl-0.0.41.tgz", - "integrity": "sha512-At7XlLktoyCssfVJyXynfn98z8f96aOGAk51MEIgfzg4/2a6p2GHbT58mAhEfPsOzXAtO7HFxIkUKI83RFP8cA==", + "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" ], @@ -1101,9 +1101,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-linux-x64": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64/-/codemie-opencode-linux-x64-0.0.41.tgz", - "integrity": "sha512-Pn5VcATJGZcX6MAD6/PAMxwTWnBCrITujXtiyBygSbkkSoKrNuKDc5JvBInkhQJ/uHYUrE6qrZdOD1aazLKWSQ==", + "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" ], @@ -1114,9 +1114,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-linux-x64-baseline": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline/-/codemie-opencode-linux-x64-baseline-0.0.41.tgz", - "integrity": "sha512-tiYdJXXY7qs5Ht3aIN0qQh2NU3NkgeNc3RteL+IhNygWVfS+gt6pXhzbfC6VnPQ/v8pfca205uJdhzIGFflVRg==", + "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" ], @@ -1127,9 +1127,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-linux-x64-baseline-musl": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline-musl/-/codemie-opencode-linux-x64-baseline-musl-0.0.41.tgz", - "integrity": "sha512-8o4EWBKUDyihZFS71qxsCm3cI/LxnH9MPjk5d4EnLltjpRW6DXDTZ6vJ85YBlTg5C1F9JYVUdWPZV0wHxMyINw==", + "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" ], @@ -1140,9 +1140,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-linux-x64-musl": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-musl/-/codemie-opencode-linux-x64-musl-0.0.41.tgz", - "integrity": "sha512-rOGSDXHrZ1ovTVW6aKKrHjb1vGAQ4NzEHYIsulyZ3ja2jUtd+o43ybkGfnXzNlCFZt2spSFUAOFhc2o5+Ru/+A==", + "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" ], @@ -1153,9 +1153,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-windows-x64": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64/-/codemie-opencode-windows-x64-0.0.41.tgz", - "integrity": "sha512-auMpuQHYI1FIUy6UGUaZkCGZWXfgmW/L1VkK4LM9umJOaUemUpyWdR92lv2sYEnU8YtD4k1pmxO+WLzU5ZVxqw==", + "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" ], @@ -1166,9 +1166,9 @@ ] }, "node_modules/@codemieai/codemie-opencode-windows-x64-baseline": { - "version": "0.0.41", - "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64-baseline/-/codemie-opencode-windows-x64-baseline-0.0.41.tgz", - "integrity": "sha512-dJvx7C4xsmueiyqXKL4dOXBatFwYArOYwpdPIAms/udsddVYmJ6HCxyLVY6+z0X7MFFoF7rN53SANTVcaz15QQ==", + "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" ], diff --git a/package.json b/package.json index e36a93d5..92e00e2f 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "ora": "^7.0.1", "strip-ansi": "^7.1.2", "yaml": "^2.3.4", - "@codemieai/codemie-opencode": "0.0.41", + "@codemieai/codemie-opencode": "0.0.43", "zod": "^4.1.12" }, "devDependencies": { diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index 603631e6..02a53cbd 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -42,7 +42,7 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { model: [] }, - supportedProviders: ['litellm', 'ai-run-sso'], + supportedProviders: ['litellm', 'ai-run-sso', 'ollama', 'bedrock'], ssoConfig: { enabled: true, clientType: 'codemie-code' }, 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 index 9528b6ae..44c5f985 100644 --- a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts +++ b/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts @@ -39,7 +39,7 @@ vi.mock('../../../../utils/processes.js', () => ({ detectGitBranch: vi.fn(() => Promise.resolve('main')), })); -// Mock getModelConfig +// Mock getModelConfig and getAllOpenCodeModelConfigs vi.mock('../../opencode/opencode-model-configs.js', () => ({ getModelConfig: vi.fn(() => ({ id: 'gpt-5-2-2025-12-11', @@ -58,6 +58,40 @@ vi.mock('../../opencode/opencode-model-configs.js', () => ({ 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 @@ -206,8 +240,9 @@ describe('CodemieOpenCodePluginMetadata lifecycle', () => { expect(result.OPENCODE_CONFIG_CONTENT).toBeDefined(); const parsed = JSON.parse(result.OPENCODE_CONFIG_CONTENT!); - expect(parsed.enabled_providers).toEqual(['codemie-proxy']); + 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 () => { @@ -260,8 +295,10 @@ describe('CodemieOpenCodePluginMetadata lifecycle', () => { expect(parsed).toHaveProperty('enabled_providers'); expect(parsed).toHaveProperty('provider.codemie-proxy'); - expect(parsed).toHaveProperty('defaults'); - expect(parsed.defaults.model).toContain('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 () => { @@ -301,16 +338,35 @@ describe('CodemieOpenCodePluginMetadata lifecycle', () => { ); }); - it('strips displayName and providerOptions from model config in output', async () => { + 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!); - const modelConfig = Object.values(parsed.provider['codemie-proxy'].models)[0] as any; - expect(modelConfig.displayName).toBeUndefined(); - expect(modelConfig.providerOptions).toBeUndefined(); + expect(parsed.provider.ollama).toBeDefined(); + expect(parsed.provider.ollama.models).toBeUndefined(); }); it('uses CODEMIE_TIMEOUT when no providerOptions.timeout', async () => { diff --git a/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts index b462ded7..06e2e61f 100644 --- a/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts +++ b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { writeFileSync, unlinkSync, existsSync } from 'fs'; import { logger } from '../../../utils/logger.js'; -import { getModelConfig } from '../opencode/opencode-model-configs.js'; +import { 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'; @@ -128,6 +128,7 @@ const resolvedBinary = resolveCodemieOpenCodeBinary(); * |--------------------------|----------------------|----------------------|------------------------------------------------| * | 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') | @@ -150,7 +151,7 @@ export const CodemieOpenCodePluginMetadata: AgentMetadata = { apiKey: [], model: [] }, - supportedProviders: ['litellm', 'ai-run-sso'], + supportedProviders: ['litellm', 'ai-run-sso', 'ollama', 'bedrock'], ssoConfig: { enabled: true, clientType: 'codemie-opencode' }, lifecycle: { @@ -166,47 +167,67 @@ export const CodemieOpenCodePluginMetadata: 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: 'codemie-opencode' }); + 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 { 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', 'amazon-bedrock'], + 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); if (configJson.length > MAX_ENV_SIZE) { diff --git a/src/agents/plugins/opencode/opencode-model-configs.ts b/src/agents/plugins/opencode/opencode-model-configs.ts index e848daa4..fa04ab8d 100644 --- a/src/agents/plugins/opencode/opencode-model-configs.ts +++ b/src/agents/plugins/opencode/opencode-model-configs.ts @@ -363,6 +363,19 @@ export const OPENCODE_MODEL_CONFIGS: Record = { } }; +/** + * 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 @@ -409,7 +422,7 @@ const MODEL_FAMILY_DEFAULTS: Record> = { * @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]; 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: From 429252607d69369ac6a8298dedc9e821130e2875 Mon Sep 17 00:00:00 2001 From: Maksym Diabin Date: Mon, 23 Feb 2026 17:22:24 +0100 Subject: [PATCH 3/5] feat(agents): add bedrock provider routing and bump codemie-opencode to 0.0.45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route bedrock provider through OpenCode's built-in amazon-bedrock provider instead of codemie-proxy. Add toBedrockModelId() to map short model IDs to Bedrock inference profile format (e.g. claude-sonnet-4-5-20250929 → us.anthropic.claude-sonnet-4-5-20250929-v1:0). Add bedrock to supportedProviders and amazon-bedrock to enabled_providers in opencode plugin. Generated with AI Co-Authored-By: codemie-ai --- package-lock.json | 96 +++++++++---------- package.json | 2 +- .../codemie-opencode.plugin.ts | 32 ++++++- .../plugins/opencode/opencode.plugin.ts | 36 +++++-- 4 files changed, 107 insertions(+), 59 deletions(-) diff --git a/package-lock.json b/package-lock.json index a1020103..5570680e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +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", + "@codemieai/codemie-opencode": "0.0.45", "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", @@ -1013,32 +1013,32 @@ } }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode/-/codemie-opencode-0.0.45.tgz", + "integrity": "sha512-RCGdSMMOZ00zwZKt1ACrHlVD8DcYANjJY2qtslF7aO77wVGhrmaIUh/0j6nf20apWOPHcFhOQDsAsc1iXHBcxg==", "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" + "@codemieai/codemie-opencode-darwin-arm64": "0.0.45", + "@codemieai/codemie-opencode-darwin-x64": "0.0.45", + "@codemieai/codemie-opencode-darwin-x64-baseline": "0.0.45", + "@codemieai/codemie-opencode-linux-arm64": "0.0.45", + "@codemieai/codemie-opencode-linux-arm64-musl": "0.0.45", + "@codemieai/codemie-opencode-linux-x64": "0.0.45", + "@codemieai/codemie-opencode-linux-x64-baseline": "0.0.45", + "@codemieai/codemie-opencode-linux-x64-baseline-musl": "0.0.45", + "@codemieai/codemie-opencode-linux-x64-musl": "0.0.45", + "@codemieai/codemie-opencode-windows-x64": "0.0.45", + "@codemieai/codemie-opencode-windows-x64-baseline": "0.0.45" } }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-arm64/-/codemie-opencode-darwin-arm64-0.0.45.tgz", + "integrity": "sha512-aivK2GIxdvLQYgfHfR422eu4IrGVrGCh6ChGQ/LDDRq+fxWdTMRK+tAkEct61UNzz9p9aeTOJlLvrg7gY1B0cw==", "cpu": [ "arm64" ], @@ -1049,9 +1049,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64/-/codemie-opencode-darwin-x64-0.0.45.tgz", + "integrity": "sha512-h88ysxXFLtdEaffE0EhUuX/g1k2S1okEPnwqaJXNR0fs2ngpuecqKhf1KVo2JTX8cV7nWeP8DDwQxpup+JXrCQ==", "cpu": [ "x64" ], @@ -1062,9 +1062,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-darwin-x64-baseline/-/codemie-opencode-darwin-x64-baseline-0.0.45.tgz", + "integrity": "sha512-hbQ/yYqxPsKhglUsidFKxq4jx+al1ITDMzrf1bbHB5VP1+956SfgMR574o6EyUDVMHYCmN4UMGWFkzc+Br6k1w==", "cpu": [ "x64" ], @@ -1075,9 +1075,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64/-/codemie-opencode-linux-arm64-0.0.45.tgz", + "integrity": "sha512-f0gRtwKtsRjoSfZ1FpEIZCuhGsaikBBmj45j/bQ7c9HOqJ36acA99Ir8ll00TH8EB2bL+81g7okUg5FLLBxqDQ==", "cpu": [ "arm64" ], @@ -1088,9 +1088,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-arm64-musl/-/codemie-opencode-linux-arm64-musl-0.0.45.tgz", + "integrity": "sha512-4oV0hhfw+tV4uGJ4IowhZz69NMSKlarLE992jWWPaEgBHHJlgyF9cYE/gUVwrXgpFL4YXsG4/IY0UUiVWAKH3Q==", "cpu": [ "arm64" ], @@ -1101,9 +1101,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64/-/codemie-opencode-linux-x64-0.0.45.tgz", + "integrity": "sha512-yEThiIHNsaVvfkrS/uvzTIyFpL35PCoRJGC0J8K/FCD/7/MGnlTJbd2jE7BCM+hXSiv18tNChDYf+oSY0i7Tcg==", "cpu": [ "x64" ], @@ -1114,9 +1114,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline/-/codemie-opencode-linux-x64-baseline-0.0.45.tgz", + "integrity": "sha512-CFy4lMsL57vQa9L9Io7VDl1zaycveambmw3JMIQnuxo6s2J252qxIZM1q5dCxqBud7xUldLZV9+ifnfYypvY8A==", "cpu": [ "x64" ], @@ -1127,9 +1127,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-baseline-musl/-/codemie-opencode-linux-x64-baseline-musl-0.0.45.tgz", + "integrity": "sha512-fERxWgvHCHv6j/pf6ek1NhObWTCu966HvNEKs5nBrHtKt1Q3Sr6CaVRuSLOdgv4wUMuxTYb4YjbgDlbNiUOFSw==", "cpu": [ "x64" ], @@ -1140,9 +1140,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-linux-x64-musl/-/codemie-opencode-linux-x64-musl-0.0.45.tgz", + "integrity": "sha512-NuAmbk57ZRg5sfn3+N7VF/PTC7Zw+vvuFvnWv5lKFVD/PeDHdhFUSSn4m/Sh+V1Ge8ByV+hJe6a0Nxy5+n2Hbw==", "cpu": [ "x64" ], @@ -1153,9 +1153,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64/-/codemie-opencode-windows-x64-0.0.45.tgz", + "integrity": "sha512-84RKed13aunhdsCFFrEMqZEbV3+rx3ZQaNOwx9SOX8yZSHUpMyV49tDStmbPMVvjpCAehjq6WFPIsWTK8qt+/w==", "cpu": [ "x64" ], @@ -1166,9 +1166,9 @@ ] }, "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==", + "version": "0.0.45", + "resolved": "https://registry.npmjs.org/@codemieai/codemie-opencode-windows-x64-baseline/-/codemie-opencode-windows-x64-baseline-0.0.45.tgz", + "integrity": "sha512-RimPSjWHu/KCy56v5AEpPwgYvg/QDDvG/V53fdC25DkErVZ8GRASasmzJGy56cHfWHpo4qiuRq6xO9LKK9ipTA==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 92e00e2f..80a3a425 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@aws-sdk/credential-providers": "^3.948.0", "@clack/core": "^0.5.0", "@clack/prompts": "^0.11.0", + "@codemieai/codemie-opencode": "0.0.45", "@langchain/core": "^1.0.4", "@langchain/langgraph": "^1.0.2", "@langchain/openai": "^1.1.0", @@ -128,7 +129,6 @@ "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/plugins/codemie-opencode/codemie-opencode.plugin.ts b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts index 06e2e61f..cf47f3e3 100644 --- a/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts +++ b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts @@ -13,6 +13,26 @@ import { resolveCodemieOpenCodeBinary } from './codemie-opencode-binary.js'; const OPENCODE_SUBCOMMANDS = ['run', 'chat', 'config', 'init', 'help', 'version']; +/** + * Convert a short model ID to Bedrock inference profile format. + * Bedrock requires region-prefixed ARN-style model IDs. + * + * Examples: + * claude-sonnet-4-5-20250929 → us.anthropic.claude-sonnet-4-5-20250929-v1:0 + * claude-opus-4-6 → us.anthropic.claude-opus-4-6-v1:0 + * + * If the model ID already contains 'anthropic.', it's returned as-is. + */ +function toBedrockModelId(modelId: string, region?: string): string { + if (modelId.includes('anthropic.')) return modelId; + + const regionPrefix = region?.startsWith('eu') ? 'eu' + : region?.startsWith('ap') ? 'ap' + : 'us'; + + return `${regionPrefix}.anthropic.${modelId}-v1:0`; +} + // Environment variable size limit (conservative - varies by platform) // Linux: ~128KB per var, Windows: ~32KB total env block const MAX_ENV_SIZE = 32 * 1024; @@ -187,14 +207,18 @@ export const CodemieOpenCodePluginMetadata: AgentMetadata = { // Build all models for codemie-proxy (stripped of CodeMie-specific fields) const allModels = getAllOpenCodeModelConfigs(); - // Determine URLs - const proxyBaseUrl = provider !== 'ollama' ? baseUrl : undefined; + // Determine URLs based on provider type + const isBedrock = provider === 'bedrock'; + const proxyBaseUrl = provider !== 'ollama' && !isBedrock ? 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'; + // - ollama: uses ollama provider directly + // - bedrock: uses OpenCode's built-in amazon-bedrock provider (AWS env vars set by provider hook) + // - all others: route through codemie-proxy (SSO/proxy) + const activeProvider = provider === 'ollama' ? 'ollama' : (isBedrock ? 'amazon-bedrock' : 'codemie-proxy'); const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; const openCodeConfig: Record = { @@ -224,7 +248,7 @@ export const CodemieOpenCodePluginMetadata: AgentMetadata = { } } }, - model: `${activeProvider}/${modelConfig.id}` + model: `${activeProvider}/${isBedrock ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : modelConfig.id}` }; env.OPENCODE_DISABLE_SHARE = 'true'; diff --git a/src/agents/plugins/opencode/opencode.plugin.ts b/src/agents/plugins/opencode/opencode.plugin.ts index d166b169..d9b5375f 100644 --- a/src/agents/plugins/opencode/opencode.plugin.ts +++ b/src/agents/plugins/opencode/opencode.plugin.ts @@ -12,6 +12,26 @@ import { OpenCodeSessionAdapter } from './opencode.session.js'; const OPENCODE_SUBCOMMANDS = ['run', 'chat', 'config', 'init', 'help', 'version']; +/** + * Convert a short model ID to Bedrock inference profile format. + * Bedrock requires region-prefixed ARN-style model IDs. + * + * Examples: + * claude-sonnet-4-5-20250929 → us.anthropic.claude-sonnet-4-5-20250929-v1:0 + * claude-opus-4-6 → us.anthropic.claude-opus-4-6-v1:0 + * + * If the model ID already contains 'anthropic.', it's returned as-is. + */ +function toBedrockModelId(modelId: string, region?: string): string { + if (modelId.includes('anthropic.')) return modelId; + + const regionPrefix = region?.startsWith('eu') ? 'eu' + : region?.startsWith('ap') ? 'ap' + : 'us'; + + return `${regionPrefix}.anthropic.${modelId}-v1:0`; +} + // Environment variable size limit (conservative - varies by platform) // Linux: ~128KB per var, Windows: ~32KB total env block const MAX_ENV_SIZE = 32 * 1024; @@ -136,7 +156,7 @@ export const OpenCodePluginMetadata: AgentMetadata = { apiKey: [], model: [] }, - supportedProviders: ['litellm', 'ai-run-sso', 'ollama'], + supportedProviders: ['litellm', 'ai-run-sso', 'ollama', 'bedrock'], ssoConfig: { enabled: true, clientType: 'codemie-opencode' }, lifecycle: { @@ -178,18 +198,22 @@ export const OpenCodePluginMetadata: AgentMetadata = { // Build all models for codemie-proxy (stripped of CodeMie-specific fields) const allModels = getAllOpenCodeModelConfigs(); - // Determine URLs - const proxyBaseUrl = provider !== 'ollama' ? baseUrl : undefined; + // Determine URLs based on provider type + const isBedrock = provider === 'bedrock'; + const proxyBaseUrl = provider !== 'ollama' && !isBedrock ? 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'; + // - ollama: uses ollama provider directly + // - bedrock: uses OpenCode's built-in amazon-bedrock provider (AWS env vars set by provider hook) + // - all others: route through codemie-proxy (SSO/proxy) + const activeProvider = provider === 'ollama' ? 'ollama' : (isBedrock ? 'amazon-bedrock' : 'codemie-proxy'); const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; const openCodeConfig: Record = { - enabled_providers: ['codemie-proxy', 'ollama'], + enabled_providers: ['codemie-proxy', 'ollama', 'amazon-bedrock'], share: 'disabled', provider: { ...(proxyBaseUrl && { @@ -215,7 +239,7 @@ export const OpenCodePluginMetadata: AgentMetadata = { } } }, - model: `${activeProvider}/${modelConfig.id}` + model: `${activeProvider}/${isBedrock ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : modelConfig.id}` }; env.OPENCODE_DISABLE_SHARE = 'true'; From 4a9c6ddbaa72c772a46c39a7c4d861bf6f30f3e7 Mon Sep 17 00:00:00 2001 From: Maksym Diabin Date: Mon, 23 Feb 2026 17:50:22 +0100 Subject: [PATCH 4/5] refactor(agents): remove codemie-opencode plugin, make codemie-code self-contained The codemie-code plugin now owns its binary resolver and lifecycle hooks (beforeRun, enrichArgs) directly instead of borrowing them from the codemie-opencode plugin. This removes the codemie-opencode plugin entirely: - Create codemie-code-binary.ts with binary resolver - Inline all lifecycle helpers into codemie-code.plugin.ts - Remove codemie-opencode from registry (5 agents instead of 6) - Remove bin/codemie-opencode.js entry point - Delete src/agents/plugins/codemie-opencode/ directory - Delete src/cli/commands/codemie-opencode-metrics.ts (dead code) - Update tests and documentation Generated with AI Co-Authored-By: codemie-ai --- .../integration/external-integrations.md | 2 +- README.md | 5 +- bin/codemie-opencode.js | 11 - docs/AGENTS.md | 11 - docs/COMMANDS.md | 5 - package.json | 3 +- src/agents/__tests__/registry.test.ts | 7 +- .../__tests__/codemie-code-plugin.test.ts | 28 +- ...ncode-binary.ts => codemie-code-binary.ts} | 12 +- src/agents/plugins/codemie-code.plugin.ts | 268 ++++++++- .../__tests__/codemie-opencode-binary.test.ts | 125 ---- .../codemie-opencode-lifecycle.test.ts | 532 ------------------ .../__tests__/codemie-opencode-plugin.test.ts | 143 ----- .../codemie-opencode.plugin.ts | 404 ------------- src/agents/plugins/codemie-opencode/index.ts | 2 - src/agents/registry.ts | 2 - src/cli/commands/codemie-opencode-metrics.ts | 234 -------- 17 files changed, 283 insertions(+), 1511 deletions(-) delete mode 100755 bin/codemie-opencode.js rename src/agents/plugins/{codemie-opencode/codemie-opencode-binary.ts => codemie-code-binary.ts} (86%) delete mode 100644 src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts delete mode 100644 src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts delete mode 100644 src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts delete mode 100644 src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts delete mode 100644 src/agents/plugins/codemie-opencode/index.ts delete mode 100644 src/cli/commands/codemie-opencode-metrics.ts diff --git a/.codemie/guides/integration/external-integrations.md b/.codemie/guides/integration/external-integrations.md index ad801a85..7e08ce9d 100644 --- a/.codemie/guides/integration/external-integrations.md +++ b/.codemie/guides/integration/external-integrations.md @@ -593,7 +593,7 @@ LITELLM_BASE_URL=http://localhost:4000 # OpenCode (via CodeMie proxy) CODEMIE_BASE_URL=https://proxy.codemie.ai CODEMIE_MODEL=gpt-5-2-2025-12-11 -CODEMIE_OPENCODE_BIN=opencode # Custom OpenCode binary path (optional) +CODEMIE_OPENCODE_WL_BIN=opencode # Custom OpenCode binary path (optional) # Enterprise SSO SSO_BASE_URL=https://api.company.com diff --git a/README.md b/README.md index 2711d32f..bc4dd9bc 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ You can also install and use external agents like Claude Code and Gemini. - **Claude Code** (`codemie-claude`) - Anthropic's official CLI with advanced code understanding - **Claude Code ACP** (`codemie-claude-acp`) - Claude Code for IDE integration via ACP protocol (Zed, JetBrains, Emacs) - **Gemini CLI** (`codemie-gemini`) - Google's Gemini for coding tasks -- **OpenCode** (`codemie-opencode`) - Open-source AI coding assistant with session analytics +- **OpenCode** (`codemie-code`) - Open-source AI coding assistant with session analytics ```bash # Install an agent (latest supported version) @@ -154,7 +154,6 @@ codemie-gemini "Implement a REST API" # Install OpenCode codemie install opencode -codemie-opencode "Generate unit tests for my service" # Install Claude Code ACP (for IDE integration) codemie install claude-acp @@ -239,7 +238,7 @@ These commands analyze your actual codebase to create tailored documentation and ### OpenCode Session Metrics -When using OpenCode (`codemie-opencode`), CodeMie automatically extracts and tracks session metrics: +When using OpenCode, CodeMie automatically extracts and tracks session metrics: **Manual Metrics Processing:** ```bash diff --git a/bin/codemie-opencode.js b/bin/codemie-opencode.js deleted file mode 100755 index d7faaa42..00000000 --- a/bin/codemie-opencode.js +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node -import { AgentCLI } from '../dist/agents/core/AgentCLI.js'; -import { AgentRegistry } from '../dist/agents/registry.js'; - -const agent = AgentRegistry.getAgent('codemie-opencode'); -if (!agent) { - console.error('CodeMie OpenCode agent not found. Run: codemie doctor'); - process.exit(1); -} -const cli = new AgentCLI(agent); -await cli.run(process.argv); diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 47a247ea..90d08c43 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -209,14 +209,6 @@ codemie opencode-metrics --discover codemie opencode-metrics --discover --verbose ``` -**Usage:** -```bash -codemie-opencode # Interactive mode -codemie-opencode "your prompt" # With initial message -codemie-opencode --model gpt-5-2-2025-12-11 "generate tests" # Specify model -codemie-opencode health # Health check -``` - **Session Storage:** OpenCode sessions are stored following XDG Base Directory Specification: - Linux: `~/.local/share/opencode/storage/` @@ -237,9 +229,6 @@ codemie install opencode # Configure provider (if not already done) codemie setup -# Use OpenCode -codemie-opencode "Review this API implementation" - # View metrics codemie analytics --agent opencode ``` diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 305a600d..5995a4a0 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -73,12 +73,10 @@ All external agents share the same command pattern: codemie-claude "message" # Claude Code agent codemie-claude-acp # Claude Code ACP (invoked by IDEs) codemie-gemini "message" # Gemini CLI agent -codemie-opencode "message" # OpenCode agent # Health checks codemie-claude health codemie-gemini health -codemie-opencode health # Note: codemie-claude-acp doesn't have interactive mode or health check # It's designed to be invoked by IDEs via ACP protocol @@ -86,12 +84,9 @@ codemie-opencode health # With configuration overrides codemie-claude --model claude-4-5-sonnet --api-key sk-... "review code" codemie-gemini -m gemini-2.5-flash --api-key key "optimize performance" -codemie-opencode --model gpt-5-2-2025-12-11 "generate unit tests" - # With profile selection codemie-claude --profile personal-openai "review PR" codemie-gemini --profile google-direct "analyze code" -codemie-opencode --profile work "refactor code" # Agent-specific options (pass-through to underlying CLI) codemie-claude --context large -p "review code" # -p = print mode (non-interactive) diff --git a/package.json b/package.json index 80a3a425..5a53a165 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,7 @@ "codemie-code": "./bin/agent-executor.js", "codemie-claude": "./bin/codemie-claude.js", "codemie-claude-acp": "./bin/codemie-claude-acp.js", - "codemie-gemini": "./bin/codemie-gemini.js", - "codemie-opencode": "./bin/codemie-opencode.js" + "codemie-gemini": "./bin/codemie-gemini.js" }, "files": [ "dist", diff --git a/src/agents/__tests__/registry.test.ts b/src/agents/__tests__/registry.test.ts index 9b1507d3..950e8502 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 6 default agents (codemie-code, claude, claude-acp, gemini, opencode, codemie-opencode) - expect(agentNames).toHaveLength(6); + // Should have all 5 default agents (codemie-code, claude, claude-acp, gemini, opencode) + expect(agentNames).toHaveLength(5); }); 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(6); + expect(agents).toHaveLength(5); expect(agents.every((agent) => agent.name)).toBe(true); }); @@ -74,7 +74,6 @@ describe('AgentRegistry', () => { expect(names).toContain('claude-acp'); expect(names).toContain('gemini'); expect(names).toContain('opencode'); - expect(names).toContain('codemie-opencode'); }); }); diff --git a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts index 37f660e0..2d416cfb 100644 --- a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts +++ b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts @@ -17,7 +17,7 @@ vi.mock('../../core/BaseAgentAdapter.js', () => ({ })); // Mock binary resolution -vi.mock('../codemie-opencode/codemie-opencode-binary.js', () => ({ +vi.mock('../codemie-code-binary.js', () => ({ resolveCodemieOpenCodeBinary: vi.fn(() => '/mock/bin/codemie'), })); @@ -53,7 +53,7 @@ vi.mock('../opencode/opencode.session.js', () => ({ }), })); -// Mock getModelConfig +// Mock getModelConfig and getAllOpenCodeModelConfigs vi.mock('../opencode/opencode-model-configs.js', () => ({ getModelConfig: vi.fn(() => ({ id: 'gpt-5-2-2025-12-11', @@ -71,6 +71,7 @@ vi.mock('../opencode/opencode-model-configs.js', () => ({ cost: { input: 2.5, output: 10 }, limit: { context: 1048576, output: 65536 }, })), + getAllOpenCodeModelConfigs: vi.fn(() => ({})), })); // Mock fs @@ -81,12 +82,11 @@ vi.mock('fs', () => ({ })); const { existsSync } = await import('fs'); -const { resolveCodemieOpenCodeBinary } = await import('../codemie-opencode/codemie-opencode-binary.js'); +const { resolveCodemieOpenCodeBinary } = await import('../codemie-code-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); @@ -98,23 +98,19 @@ describe('CodeMieCodePluginMetadata', () => { expect(BUILTIN_AGENT_NAME).toBe('codemie-code'); }); - it('reuses beforeRun from CodemieOpenCodePluginMetadata', () => { - expect(CodeMieCodePluginMetadata.lifecycle!.beforeRun).toBe( - CodemieOpenCodePluginMetadata.lifecycle!.beforeRun - ); + it('has beforeRun defined', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.beforeRun).toBeDefined(); + expect(typeof CodeMieCodePluginMetadata.lifecycle!.beforeRun).toBe('function'); }); - it('reuses enrichArgs from CodemieOpenCodePluginMetadata', () => { - expect(CodeMieCodePluginMetadata.lifecycle!.enrichArgs).toBe( - CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs - ); + it('has enrichArgs defined', () => { + expect(CodeMieCodePluginMetadata.lifecycle!.enrichArgs).toBeDefined(); + expect(typeof CodeMieCodePluginMetadata.lifecycle!.enrichArgs).toBe('function'); }); - it('has custom onSessionEnd', () => { + it('has onSessionEnd defined', () => { expect(CodeMieCodePluginMetadata.lifecycle!.onSessionEnd).toBeDefined(); - expect(CodeMieCodePluginMetadata.lifecycle!.onSessionEnd).not.toBe( - CodemieOpenCodePluginMetadata.lifecycle!.onSessionEnd - ); + expect(typeof CodeMieCodePluginMetadata.lifecycle!.onSessionEnd).toBe('function'); }); }); diff --git a/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts b/src/agents/plugins/codemie-code-binary.ts similarity index 86% rename from src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts rename to src/agents/plugins/codemie-code-binary.ts index 3d31782b..96d7621c 100644 --- a/src/agents/plugins/codemie-opencode/codemie-opencode-binary.ts +++ b/src/agents/plugins/codemie-code-binary.ts @@ -1,7 +1,7 @@ import { existsSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { logger } from '../../../utils/logger.js'; +import { logger } from '../../utils/logger.js'; /** * Platform-specific package name mapping for @codemieai/codemie-opencode. @@ -65,10 +65,10 @@ export function resolveCodemieOpenCodeBinary(): string | null { 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}`); + logger.debug(`[codemie-code] Using binary from CODEMIE_OPENCODE_WL_BIN: ${envBin}`); return envBin; } - logger.warn(`[codemie-opencode] CODEMIE_OPENCODE_WL_BIN set but binary not found: ${envBin}`); + logger.warn(`[codemie-code] CODEMIE_OPENCODE_WL_BIN set but binary not found: ${envBin}`); } // Start searching from this module's directory @@ -82,7 +82,7 @@ export function resolveCodemieOpenCodeBinary(): string | null { if (platformDir) { const platformBin = join(platformDir, 'bin', binName); if (existsSync(platformBin)) { - logger.debug(`[codemie-opencode] Resolved platform binary: ${platformBin}`); + logger.debug(`[codemie-code] Resolved platform binary: ${platformBin}`); return platformBin; } } @@ -93,12 +93,12 @@ export function resolveCodemieOpenCodeBinary(): string | null { if (wrapperDir) { const wrapperBin = join(wrapperDir, 'bin', binName); if (existsSync(wrapperBin)) { - logger.debug(`[codemie-opencode] Resolved wrapper binary: ${wrapperBin}`); + logger.debug(`[codemie-code] Resolved wrapper binary: ${wrapperBin}`); return wrapperBin; } } // 4. Not found - logger.debug('[codemie-opencode] Binary not found in node_modules'); + logger.debug('[codemie-code] Binary not found in node_modules'); return null; } diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index 02a53cbd..db69ebc4 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -1,28 +1,165 @@ -import type { AgentMetadata } from '../core/types.js'; -import { existsSync } from 'fs'; +import type { AgentMetadata, AgentConfig } from '../core/types.js'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { existsSync, writeFileSync, unlinkSync } 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/codemie-opencode-binary.js'; -import { CodemieOpenCodePluginMetadata } from './codemie-opencode/codemie-opencode.plugin.js'; +import { resolveCodemieOpenCodeBinary } from './codemie-code-binary.js'; /** * Built-in agent name constant - single source of truth */ export const BUILTIN_AGENT_NAME = 'codemie-code'; +const OPENCODE_SUBCOMMANDS = ['run', 'chat', 'config', 'init', 'help', 'version']; + +/** + * Convert a short model ID to Bedrock inference profile format. + * Bedrock requires region-prefixed ARN-style model IDs. + * + * Examples: + * claude-sonnet-4-5-20250929 → us.anthropic.claude-sonnet-4-5-20250929-v1:0 + * claude-opus-4-6 → us.anthropic.claude-opus-4-6-v1:0 + * + * If the model ID already contains 'anthropic.', it's returned as-is. + */ +function toBedrockModelId(modelId: string, region?: string): string { + if (modelId.includes('anthropic.')) return modelId; + + const regionPrefix = region?.startsWith('eu') ? 'eu' + : region?.startsWith('ap') ? 'ap' + : 'us'; + + return `${regionPrefix}.anthropic.${modelId}-v1:0`; +} + +// 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-code] 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-code-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-code] Session file already exists'); + return; + } + + const agentName = env.CODEMIE_AGENT || 'codemie-code'; + 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-code] Created session metadata file'); + + } catch (error) { + logger.warn('[codemie-code] Failed to create session file:', error); + } +} + // Resolve binary at load time, fallback to 'codemie' const resolvedBinary = resolveCodemieOpenCodeBinary(); /** - * CodeMie Code Plugin Metadata + * 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) * - * 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. + * | 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-code') | + * | 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 CodeMieCodePluginMetadata: AgentMetadata = { name: BUILTIN_AGENT_NAME, @@ -47,8 +184,119 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { ssoConfig: { enabled: true, clientType: 'codemie-code' }, lifecycle: { - beforeRun: CodemieOpenCodePluginMetadata.lifecycle!.beforeRun, - enrichArgs: CodemieOpenCodePluginMetadata.lifecycle!.enrichArgs, + async beforeRun(env: NodeJS.ProcessEnv, config: AgentConfig) { + const sessionId = env.CODEMIE_SESSION_ID; + if (sessionId) { + try { + logger.debug('[codemie-code] Creating session metadata file before startup'); + await ensureSessionFile(sessionId, env); + logger.debug('[codemie-code] Session metadata file ready for SessionSyncer'); + } catch (error) { + logger.error('[codemie-code] 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-code' }); + 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 based on provider type + const isBedrock = provider === 'bedrock'; + const proxyBaseUrl = provider !== 'ollama' && !isBedrock ? baseUrl : undefined; + const ollamaBaseUrl = provider === 'ollama' + ? (baseUrl.endsWith('/v1') || baseUrl.includes('/v1/') ? baseUrl : `${baseUrl.replace(/\/$/, '')}/v1`) + : 'http://localhost:11434/v1'; + + // Determine default model provider + // - ollama: uses ollama provider directly + // - bedrock: uses OpenCode's built-in amazon-bedrock provider (AWS env vars set by provider hook) + // - all others: route through codemie-proxy (SSO/proxy) + const activeProvider = provider === 'ollama' ? 'ollama' : (isBedrock ? 'amazon-bedrock' : '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}/${isBedrock ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : 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-code' + }); + + const configPath = writeConfigToTempFile(configJson); + logger.debug(`[codemie-code] 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; 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 deleted file mode 100644 index af5c6316..00000000 --- a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-binary.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * 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 deleted file mode 100644 index 44c5f985..00000000 --- a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-lifecycle.test.ts +++ /dev/null @@ -1,532 +0,0 @@ -/** - * 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 deleted file mode 100644 index 51fefd0e..00000000 --- a/src/agents/plugins/codemie-opencode/__tests__/codemie-opencode-plugin.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * 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.plugin.ts b/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts deleted file mode 100644 index cf47f3e3..00000000 --- a/src/agents/plugins/codemie-opencode/codemie-opencode.plugin.ts +++ /dev/null @@ -1,404 +0,0 @@ -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']; - -/** - * Convert a short model ID to Bedrock inference profile format. - * Bedrock requires region-prefixed ARN-style model IDs. - * - * Examples: - * claude-sonnet-4-5-20250929 → us.anthropic.claude-sonnet-4-5-20250929-v1:0 - * claude-opus-4-6 → us.anthropic.claude-opus-4-6-v1:0 - * - * If the model ID already contains 'anthropic.', it's returned as-is. - */ -function toBedrockModelId(modelId: string, region?: string): string { - if (modelId.includes('anthropic.')) return modelId; - - const regionPrefix = region?.startsWith('eu') ? 'eu' - : region?.startsWith('ap') ? 'ap' - : 'us'; - - return `${regionPrefix}.anthropic.${modelId}-v1:0`; -} - -// 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 based on provider type - const isBedrock = provider === 'bedrock'; - const proxyBaseUrl = provider !== 'ollama' && !isBedrock ? baseUrl : undefined; - const ollamaBaseUrl = provider === 'ollama' - ? (baseUrl.endsWith('/v1') || baseUrl.includes('/v1/') ? baseUrl : `${baseUrl.replace(/\/$/, '')}/v1`) - : 'http://localhost:11434/v1'; - - // Determine default model provider - // - ollama: uses ollama provider directly - // - bedrock: uses OpenCode's built-in amazon-bedrock provider (AWS env vars set by provider hook) - // - all others: route through codemie-proxy (SSO/proxy) - const activeProvider = provider === 'ollama' ? 'ollama' : (isBedrock ? 'amazon-bedrock' : '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}/${isBedrock ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : 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 deleted file mode 100644 index 845ba89d..00000000 --- a/src/agents/plugins/codemie-opencode/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { CodemieOpenCodePlugin, CodemieOpenCodePluginMetadata } from './codemie-opencode.plugin.js'; -export { resolveCodemieOpenCodeBinary } from './codemie-opencode-binary.js'; diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 84db2c65..75fd82b8 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -3,7 +3,6 @@ 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 @@ -32,7 +31,6 @@ 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 deleted file mode 100644 index be8306ba..00000000 --- a/src/cli/commands/codemie-opencode-metrics.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * 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 }; -} From 072f572ed8e706e7c1f3db98d6520b91c5d22b7d Mon Sep 17 00:00:00 2001 From: Maksym Diabin Date: Mon, 23 Feb 2026 18:05:16 +0100 Subject: [PATCH 5/5] refactor(agents): extract helpers from codemie-code beforeRun hook Extract determineActiveProvider(), resolveOllamaBaseUrl(), and buildOpenCodeConfig() from the 94-line beforeRun into focused helpers. Remove redundant try-catch around ensureSessionFile (it handles its own errors). Remove duplicate mock setup in isInstalled test. Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/codemie-code-plugin.test.ts | 3 - src/agents/plugins/codemie-code.plugin.ts | 121 +++++++++++------- 2 files changed, 72 insertions(+), 52 deletions(-) diff --git a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts index 2d416cfb..4d79a856 100644 --- a/src/agents/plugins/__tests__/codemie-code-plugin.test.ts +++ b/src/agents/plugins/__tests__/codemie-code-plugin.test.ts @@ -126,9 +126,6 @@ describe('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); }); diff --git a/src/agents/plugins/codemie-code.plugin.ts b/src/agents/plugins/codemie-code.plugin.ts index db69ebc4..38ea56f0 100644 --- a/src/agents/plugins/codemie-code.plugin.ts +++ b/src/agents/plugins/codemie-code.plugin.ts @@ -136,6 +136,68 @@ async function ensureSessionFile(sessionId: string, env: NodeJS.ProcessEnv): Pro } } +/** + * Map user-facing provider name to OpenCode's internal provider identifier. + */ +function determineActiveProvider(provider: string | undefined): string { + if (provider === 'ollama') return 'ollama'; + if (provider === 'bedrock') return 'amazon-bedrock'; + return 'codemie-proxy'; +} + +/** + * Normalize the Ollama base URL to include /v1 suffix. + * Non-ollama providers get the default localhost URL. + */ +function resolveOllamaBaseUrl(baseUrl: string, provider: string | undefined): string { + if (provider !== 'ollama') return 'http://localhost:11434/v1'; + if (baseUrl.endsWith('/v1') || baseUrl.includes('/v1/')) return baseUrl; + return `${baseUrl.replace(/\/$/, '')}/v1`; +} + +/** + * Build the OpenCode config object that gets passed to the whitelabel binary. + */ +function buildOpenCodeConfig(params: { + proxyBaseUrl: string | undefined; + ollamaBaseUrl: string; + activeProvider: string; + modelId: string; + timeout: number; + providerOptions?: any; + allModels: Record; +}): Record { + return { + enabled_providers: ['codemie-proxy', 'ollama', 'amazon-bedrock'], + share: 'disabled', + provider: { + ...(params.proxyBaseUrl && { + 'codemie-proxy': { + npm: '@ai-sdk/openai-compatible', + name: 'CodeMie SSO', + options: { + baseURL: `${params.proxyBaseUrl}/`, + apiKey: 'proxy-handled', + timeout: params.timeout, + ...(params.providerOptions?.headers && { headers: params.providerOptions.headers }) + }, + models: params.allModels + } + }), + ollama: { + npm: '@ai-sdk/openai-compatible', + name: 'Ollama', + options: { + baseURL: `${params.ollamaBaseUrl}/`, + apiKey: 'ollama', + timeout: params.timeout, + } + } + }, + model: `${params.activeProvider}/${params.modelId}` + }; +} + // Resolve binary at load time, fallback to 'codemie' const resolvedBinary = resolveCodemieOpenCodeBinary(); @@ -187,13 +249,8 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { async beforeRun(env: NodeJS.ProcessEnv, config: AgentConfig) { const sessionId = env.CODEMIE_SESSION_ID; if (sessionId) { - try { - logger.debug('[codemie-code] Creating session metadata file before startup'); - await ensureSessionFile(sessionId, env); - logger.debug('[codemie-code] Session metadata file ready for SessionSyncer'); - } catch (error) { - logger.error('[codemie-code] Failed to create session file in beforeRun', { error }); - } + // ensureSessionFile handles its own errors internally + await ensureSessionFile(sessionId, env); } const provider = env.CODEMIE_PROVIDER; @@ -210,55 +267,21 @@ export const CodeMieCodePluginMetadata: AgentMetadata = { 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 based on provider type const isBedrock = provider === 'bedrock'; const proxyBaseUrl = provider !== 'ollama' && !isBedrock ? baseUrl : undefined; - const ollamaBaseUrl = provider === 'ollama' - ? (baseUrl.endsWith('/v1') || baseUrl.includes('/v1/') ? baseUrl : `${baseUrl.replace(/\/$/, '')}/v1`) - : 'http://localhost:11434/v1'; - - // Determine default model provider - // - ollama: uses ollama provider directly - // - bedrock: uses OpenCode's built-in amazon-bedrock provider (AWS env vars set by provider hook) - // - all others: route through codemie-proxy (SSO/proxy) - const activeProvider = provider === 'ollama' ? 'ollama' : (isBedrock ? 'amazon-bedrock' : 'codemie-proxy'); + const ollamaBaseUrl = resolveOllamaBaseUrl(baseUrl, provider); + const activeProvider = determineActiveProvider(provider); const timeout = providerOptions?.timeout ?? parseInt(env.CODEMIE_TIMEOUT || '600') * 1000; + const modelId = isBedrock + ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) + : modelConfig.id; - 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}/${isBedrock ? toBedrockModelId(modelConfig.id, env.AWS_REGION || env.CODEMIE_AWS_REGION) : modelConfig.id}` - }; + const openCodeConfig = buildOpenCodeConfig({ + proxyBaseUrl, ollamaBaseUrl, activeProvider, modelId, timeout, providerOptions, allModels + }); env.OPENCODE_DISABLE_SHARE = 'true'; const configJson = JSON.stringify(openCodeConfig);