diff --git a/README.md b/README.md index b61aeba..ba83a22 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,7 @@ compliance: | `gitagent validate [--compliance]` | Validate against spec and regulatory requirements | | `gitagent info` | Display agent summary | | `gitagent export --format ` | Export to other formats (see adapters below) | -| `gitagent import --from ` | Import (`claude`, `cursor`, `crewai`) | +| `gitagent import --from ` | Import (`claude`, `cursor`, `crewai`, `opencode`) | | `gitagent run --adapter ` | Run an agent from a git repo or local directory | | `gitagent install` | Resolve and install git-based dependencies | | `gitagent audit` | Generate compliance audit report | @@ -356,6 +356,7 @@ Adapters are used by both `export` and `run`. Available adapters: | `lyzr` | Lyzr Studio agent | | `github` | GitHub Actions agent | | `git` | Git-native execution (run only) | +| `opencode` | OpenCode instructions + config | | `openclaw` | OpenClaw format | | `nanobot` | Nanobot format | diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 487c132..62bc483 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -5,3 +5,4 @@ export { exportToCrewAI } from './crewai.js'; export { exportToOpenClawString, exportToOpenClaw } from './openclaw.js'; export { exportToNanobotString, exportToNanobot } from './nanobot.js'; export { exportToCopilotString, exportToCopilot } from './copilot.js'; +export { exportToOpenCodeString, exportToOpenCode } from './opencode.js'; diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts new file mode 100644 index 0000000..60a41bf --- /dev/null +++ b/src/adapters/opencode.ts @@ -0,0 +1,257 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import yaml from 'js-yaml'; +import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; +import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; + +/** + * Export a gitagent to OpenCode format. + * + * OpenCode (sst/opencode) uses: + * - .opencode/instructions.md (custom agent instructions) + * - opencode.json (project configuration) + * + * Returns structured output with all files that should be written. + */ +export interface OpenCodeExport { + instructions: string; + config: Record; +} + +export function exportToOpenCode(dir: string): OpenCodeExport { + const agentDir = resolve(dir); + const manifest = loadAgentManifest(agentDir); + + const instructions = buildInstructions(agentDir, manifest); + const config = buildConfig(manifest); + + return { instructions, config }; +} + +/** + * Export as a single string (for `gitagent export -f opencode`). + */ +export function exportToOpenCodeString(dir: string): string { + const exp = exportToOpenCode(dir); + const parts: string[] = []; + + parts.push('# === .opencode/instructions.md ==='); + parts.push(exp.instructions); + parts.push('\n# === opencode.json ==='); + parts.push(JSON.stringify(exp.config, null, 2)); + + return parts.join('\n'); +} + +function buildInstructions( + agentDir: string, + manifest: ReturnType, +): string { + const parts: string[] = []; + + // Agent identity + parts.push(`# ${manifest.name}`); + parts.push(`${manifest.description}`); + parts.push(''); + + // SOUL.md + const soul = loadFileIfExists(join(agentDir, 'SOUL.md')); + if (soul) { + parts.push(soul); + parts.push(''); + } + + // RULES.md + const rules = loadFileIfExists(join(agentDir, 'RULES.md')); + if (rules) { + parts.push(rules); + parts.push(''); + } + + // DUTIES.md + const duty = loadFileIfExists(join(agentDir, 'DUTIES.md')); + if (duty) { + parts.push(duty); + parts.push(''); + } + + // Skills + const skillsDir = join(agentDir, 'skills'); + const skills = loadAllSkills(skillsDir); + if (skills.length > 0) { + parts.push('## Skills'); + parts.push(''); + for (const skill of skills) { + const toolsList = getAllowedTools(skill.frontmatter); + const toolsNote = toolsList.length > 0 ? `\nAllowed tools: ${toolsList.join(', ')}` : ''; + parts.push(`### ${skill.frontmatter.name}`); + parts.push(`${skill.frontmatter.description}${toolsNote}`); + parts.push(''); + parts.push(skill.instructions); + parts.push(''); + } + } + + // Tools + const toolsDir = join(agentDir, 'tools'); + if (existsSync(toolsDir)) { + const toolFiles = readdirSync(toolsDir).filter(f => f.endsWith('.yaml')); + if (toolFiles.length > 0) { + parts.push('## Tools'); + parts.push(''); + for (const file of toolFiles) { + try { + const content = readFileSync(join(toolsDir, file), 'utf-8'); + const toolConfig = yaml.load(content) as { + name?: string; + description?: string; + input_schema?: Record; + }; + if (toolConfig?.name) { + parts.push(`### ${toolConfig.name}`); + if (toolConfig.description) { + parts.push(toolConfig.description); + } + if (toolConfig.input_schema) { + parts.push(''); + parts.push('```yaml'); + parts.push(yaml.dump(toolConfig.input_schema).trimEnd()); + parts.push('```'); + } + parts.push(''); + } + } catch { /* skip malformed tools */ } + } + } + } + + // Knowledge (always_load documents) + const knowledgeDir = join(agentDir, 'knowledge'); + const indexPath = join(knowledgeDir, 'index.yaml'); + if (existsSync(indexPath)) { + const index = yaml.load(readFileSync(indexPath, 'utf-8')) as { + documents?: Array<{ path: string; always_load?: boolean }>; + }; + + if (index.documents) { + const alwaysLoad = index.documents.filter(d => d.always_load); + if (alwaysLoad.length > 0) { + parts.push('## Knowledge'); + parts.push(''); + for (const doc of alwaysLoad) { + const content = loadFileIfExists(join(knowledgeDir, doc.path)); + if (content) { + parts.push(`### ${doc.path}`); + parts.push(content); + parts.push(''); + } + } + } + } + } + + // Compliance constraints + if (manifest.compliance) { + const constraints = buildComplianceSection(manifest.compliance); + if (constraints) { + parts.push(constraints); + parts.push(''); + } + } + + // Memory + const memory = loadFileIfExists(join(agentDir, 'memory', 'MEMORY.md')); + if (memory && memory.trim().split('\n').length > 2) { + parts.push('## Memory'); + parts.push(memory); + parts.push(''); + } + + return parts.join('\n').trimEnd() + '\n'; +} + +function buildConfig(manifest: ReturnType): Record { + const config: Record = {}; + + // Map model preference to OpenCode provider/model config + if (manifest.model?.preferred) { + const model = manifest.model.preferred; + const provider = inferProvider(model); + config.provider = provider; + config.model = model; + } + + return config; +} + +function inferProvider(model: string): string { + if (model.startsWith('claude') || model.includes('anthropic')) return 'anthropic'; + if (model.startsWith('gpt') || model.startsWith('o1') || model.startsWith('o3') || model.startsWith('o4')) return 'openai'; + if (model.startsWith('gemini')) return 'google'; + if (model.startsWith('deepseek')) return 'deepseek'; + if (model.startsWith('llama') || model.startsWith('mistral')) return 'ollama'; + return 'openai'; +} + +function buildComplianceSection(compliance: NonNullable['compliance']>): string { + const c = compliance; + const constraints: string[] = []; + + if (c.supervision?.human_in_the_loop === 'always') { + constraints.push('- All decisions require human approval before execution'); + } + if (c.supervision?.escalation_triggers) { + constraints.push('- Escalate to human supervisor when:'); + for (const trigger of c.supervision.escalation_triggers) { + for (const [key, value] of Object.entries(trigger)) { + constraints.push(` - ${key}: ${value}`); + } + } + } + if (c.communications?.fair_balanced) { + constraints.push('- All communications must be fair and balanced (FINRA 2210)'); + } + if (c.communications?.no_misleading) { + constraints.push('- Never make misleading, exaggerated, or promissory statements'); + } + if (c.data_governance?.pii_handling === 'redact') { + constraints.push('- Redact all PII from outputs'); + } + if (c.data_governance?.pii_handling === 'prohibit') { + constraints.push('- Do not process any personally identifiable information'); + } + + if (c.segregation_of_duties) { + const sod = c.segregation_of_duties; + constraints.push('- Segregation of duties is enforced:'); + if (sod.assignments) { + for (const [agentName, roles] of Object.entries(sod.assignments)) { + constraints.push(` - Agent "${agentName}" has role(s): ${roles.join(', ')}`); + } + } + if (sod.conflicts) { + constraints.push('- Duty separation rules (no single agent may hold both):'); + for (const [a, b] of sod.conflicts) { + constraints.push(` - ${a} and ${b}`); + } + } + if (sod.handoffs) { + constraints.push('- The following actions require multi-agent handoff:'); + for (const h of sod.handoffs) { + constraints.push(` - ${h.action}: must pass through roles ${h.required_roles.join(' → ')}${h.approval_required !== false ? ' (approval required)' : ''}`); + } + } + if (sod.isolation?.state === 'full') { + constraints.push('- Agent state/memory is fully isolated per role'); + } + if (sod.isolation?.credentials === 'separate') { + constraints.push('- Credentials are segregated per role'); + } + if (sod.enforcement === 'strict') { + constraints.push('- SOD enforcement is STRICT — violations will block execution'); + } + } + + if (constraints.length === 0) return ''; + return `## Compliance Constraints\n\n${constraints.join('\n')}`; +} diff --git a/src/commands/export.ts b/src/commands/export.ts index 465b040..4025840 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -9,6 +9,7 @@ import { exportToOpenClawString, exportToNanobotString, exportToCopilotString, + exportToOpenCodeString, } from '../adapters/index.js'; import { exportToLyzrString } from '../adapters/lyzr.js'; import { exportToGitHubString } from '../adapters/github.js'; @@ -21,7 +22,7 @@ interface ExportOptions { export const exportCommand = new Command('export') .description('Export agent to other formats') - .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot)') + .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode)') .option('-d, --dir ', 'Agent directory', '.') .option('-o, --output ', 'Output file path') .action(async (options: ExportOptions) => { @@ -61,9 +62,12 @@ export const exportCommand = new Command('export') case 'copilot': result = exportToCopilotString(dir); break; + case 'opencode': + result = exportToOpenCodeString(dir); + break; default: error(`Unknown format: ${options.format}`); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot'); + info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode'); process.exit(1); } diff --git a/src/commands/import.ts b/src/commands/import.ts index 8e5de9b..d596615 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -187,6 +187,76 @@ function importFromCrewAI(sourcePath: string, targetDir: string): void { } } +function importFromOpenCode(sourcePath: string, targetDir: string): void { + const sourceDir = resolve(sourcePath); + + // Look for .opencode/instructions.md or opencode.json + const instructionsPath = join(sourceDir, '.opencode', 'instructions.md'); + const configPath = join(sourceDir, 'opencode.json'); + + let instructions = ''; + let config: Record = {}; + + if (existsSync(instructionsPath)) { + instructions = readFileSync(instructionsPath, 'utf-8'); + info('Found .opencode/instructions.md'); + } else { + throw new Error('No .opencode/instructions.md found in source directory'); + } + + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + info('Found opencode.json'); + } catch { /* ignore malformed config */ } + } + + const dirName = basename(sourceDir); + + // Determine model from opencode.json + const model = (config.model as string) || undefined; + const agentYaml: Record = { + spec_version: '0.1.0', + name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), + version: '0.1.0', + description: `Imported from OpenCode project: ${dirName}`, + }; + if (model) { + agentYaml.model = { preferred: model }; + } + + writeFileSync(join(targetDir, 'agent.yaml'), yaml.dump(agentYaml), 'utf-8'); + success('Created agent.yaml'); + + // Convert instructions.md to SOUL.md + RULES.md + const sections = parseSections(instructions); + let soulContent = '# Soul\n\n'; + let rulesContent = '# Rules\n\n'; + let hasRules = false; + + for (const [title, content] of sections) { + const lower = title.toLowerCase(); + if (lower.includes('rule') || lower.includes('constraint') || lower.includes('never') || lower.includes('always') || lower.includes('must') || lower.includes('compliance')) { + rulesContent += `## ${title}\n${content}\n\n`; + hasRules = true; + } else { + soulContent += `## ${title}\n${content}\n\n`; + } + } + + if (sections.length === 0) { + soulContent += instructions; + } + + writeFileSync(join(targetDir, 'SOUL.md'), soulContent, 'utf-8'); + success('Created SOUL.md'); + + if (hasRules) { + writeFileSync(join(targetDir, 'RULES.md'), rulesContent, 'utf-8'); + success('Created RULES.md'); + } +} + function parseSections(markdown: string): [string, string][] { const sections: [string, string][] = []; const lines = markdown.split('\n'); @@ -215,7 +285,7 @@ function parseSections(markdown: string): [string, string][] { export const importCommand = new Command('import') .description('Import from other agent formats') - .requiredOption('--from ', 'Source format (claude, cursor, crewai)') + .requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode)') .argument('', 'Source file or directory path') .option('-d, --dir ', 'Target directory', '.') .action((sourcePath: string, options: ImportOptions) => { @@ -236,9 +306,12 @@ export const importCommand = new Command('import') case 'crewai': importFromCrewAI(sourcePath, targetDir); break; + case 'opencode': + importFromOpenCode(sourcePath, targetDir); + break; default: error(`Unknown format: ${options.from}`); - info('Supported formats: claude, cursor, crewai'); + info('Supported formats: claude, cursor, crewai, opencode'); process.exit(1); } diff --git a/src/commands/run.ts b/src/commands/run.ts index 4773dd5..cc8afb9 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -12,6 +12,7 @@ import { runWithNanobot } from '../runners/nanobot.js'; import { runWithLyzr } from '../runners/lyzr.js'; import { runWithGitHub } from '../runners/github.js'; import { runWithGit } from '../runners/git.js'; +import { runWithOpenCode } from '../runners/opencode.js'; interface RunOptions { repo?: string; @@ -26,7 +27,7 @@ interface RunOptions { export const runCommand = new Command('run') .description('Run an agent from a git repository or local directory') .option('-r, --repo ', 'Git repository URL') - .option('-a, --adapter ', 'Adapter: claude, openai, crewai, openclaw, nanobot, lyzr, github, git, prompt', 'claude') + .option('-a, --adapter ', 'Adapter: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, git, prompt', 'claude') .option('-b, --branch ', 'Git branch/tag to clone', 'main') .option('--refresh', 'Force re-clone (pull latest)', false) .option('--no-cache', 'Clone to temp dir, delete on exit') @@ -112,6 +113,9 @@ export const runCommand = new Command('run') case 'github': await runWithGitHub(agentDir, manifest, { prompt: options.prompt }); break; + case 'opencode': + runWithOpenCode(agentDir, manifest, { prompt: options.prompt }); + break; case 'git': if (!options.repo) { error('The git adapter requires --repo (-r)'); @@ -130,7 +134,7 @@ export const runCommand = new Command('run') break; default: error(`Unknown adapter: ${options.adapter}`); - info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, git, prompt'); + info('Supported adapters: claude, openai, crewai, openclaw, nanobot, lyzr, github, opencode, git, prompt'); process.exit(1); } } catch (e) { diff --git a/src/runners/git.ts b/src/runners/git.ts index 7e11a2d..668eef1 100644 --- a/src/runners/git.ts +++ b/src/runners/git.ts @@ -11,6 +11,7 @@ import { runWithOpenClaw } from './openclaw.js'; import { runWithNanobot } from './nanobot.js'; import { runWithLyzr } from './lyzr.js'; import { runWithGitHub } from './github.js'; +import { runWithOpenCode } from './opencode.js'; import { error, info, success, label, heading, divider, warn } from '../utils/format.js'; export interface GitRunOptions { @@ -103,6 +104,9 @@ export async function runWithGit( case 'nanobot': runWithNanobot(agentDir, manifest, { prompt: options.prompt }); break; + case 'opencode': + runWithOpenCode(agentDir, manifest, { prompt: options.prompt }); + break; case 'lyzr': await runWithLyzr(agentDir, manifest, { prompt: options.prompt }); break; @@ -178,6 +182,10 @@ function detectAdapter(agentDir: string, manifest: AgentManifest): string { info('Auto-detected adapter: github (from .github_models)'); return 'github'; } + if (existsSync(join(agentDir, '.opencode')) || existsSync(join(agentDir, 'opencode.json'))) { + info('Auto-detected adapter: opencode (from .opencode/ or opencode.json)'); + return 'opencode'; + } // 4. Default info('Using default adapter: claude'); diff --git a/src/runners/opencode.ts b/src/runners/opencode.ts new file mode 100644 index 0000000..2e83142 --- /dev/null +++ b/src/runners/opencode.ts @@ -0,0 +1,79 @@ +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { exportToOpenCode } from '../adapters/opencode.js'; +import { AgentManifest } from '../utils/loader.js'; +import { error, info } from '../utils/format.js'; + +export interface OpenCodeRunOptions { + prompt?: string; +} + +/** + * Run a gitagent agent using OpenCode (sst/opencode). + * + * Creates a temporary workspace with: + * - .opencode/instructions.md (agent instructions) + * - opencode.json (provider + model config) + * + * Then launches `opencode` in that workspace. OpenCode reads both files + * automatically on startup. + * + * Supports both interactive mode (no prompt) and single-shot mode (-p). + */ +export function runWithOpenCode(agentDir: string, manifest: AgentManifest, options: OpenCodeRunOptions = {}): void { + const exp = exportToOpenCode(agentDir); + + // Create a temporary workspace + const workspaceDir = join(tmpdir(), `gitagent-opencode-${randomBytes(4).toString('hex')}`); + mkdirSync(workspaceDir, { recursive: true }); + + // Write .opencode/instructions.md + const instructionsDir = join(workspaceDir, '.opencode'); + mkdirSync(instructionsDir, { recursive: true }); + writeFileSync(join(instructionsDir, 'instructions.md'), exp.instructions, 'utf-8'); + + // Write opencode.json + writeFileSync(join(workspaceDir, 'opencode.json'), JSON.stringify(exp.config, null, 2), 'utf-8'); + + info(`Workspace prepared at ${workspaceDir}`); + info(` .opencode/instructions.md, opencode.json`); + if (manifest.model?.preferred) { + info(` Model: ${manifest.model.preferred}`); + } + + // Build opencode CLI args + const args: string[] = []; + + // If a prompt is provided, pass it for single-shot mode + if (options.prompt) { + args.push('--prompt', options.prompt); + } + + info(`Launching OpenCode agent "${manifest.name}"...`); + if (!options.prompt) { + info('Starting interactive mode. Type your messages to chat.'); + } + + try { + const result = spawnSync('opencode', args, { + stdio: 'inherit', + cwd: workspaceDir, + env: { ...process.env }, + }); + + if (result.error) { + error(`Failed to launch OpenCode: ${result.error.message}`); + info('Make sure OpenCode is installed: npm install -g opencode'); + info('Or: brew install sst/tap/opencode'); + process.exit(1); + } + + process.exit(result.status ?? 0); + } finally { + // Cleanup temp workspace + try { rmSync(workspaceDir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}