UNPKG

opencode-agent-kit

Version:

Multi-stack OpenCode agent toolkit — 33+ specialized AI agents, 200+ skills, 46 commands, 8 MCP servers (Nuxt, React, Node.js, Laravel, CI3, Android, Flutter, DevOps, SEO, SonarQube, and more)

662 lines (590 loc) 23.3 kB
import { readFileSync, existsSync, copyFileSync, mkdirSync, writeFileSync, readdirSync, appendFileSync, symlinkSync, rmSync, } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { execSync } from 'child_process'; import { platform } from 'os'; import { getOpenCodeHome } from './global-path.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PKG_ROOT = join(__dirname, '..', '..'); const TEMPLATE_DIR = join(PKG_ROOT, 'template'); const PKG_NAME = 'opencode-agent-kit'; // ANSI colors const C = { reset: '\x1b[0m', dim: '\x1b[2m', green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m', bold: '\x1b[1m', }; function info(msg) { console.log(` ${C.cyan}${C.reset} ${msg}`); } function success(msg) { console.log(` ${C.green}${C.reset} ${msg}`); } function warn(msg) { console.log(` ${C.yellow}${C.reset} ${msg}`); } function error(msg) { console.log(` ${C.red}${C.reset} ${msg}`); } function copyRecursive(src, dest) { if (!existsSync(src)) return; const entries = readdirSync(src, { withFileTypes: true }); mkdirSync(dest, { recursive: true }); for (const entry of entries) { if (entry.name === '.DS_Store') continue; if (entry.name === 'node_modules') continue; const srcPath = join(src, entry.name); const destPath = join(dest, entry.name); if (entry.isDirectory()) { copyRecursive(srcPath, destPath); } else if (!existsSync(destPath)) { // Only copy if destination does NOT exist — preserves user modifications copyFileSync(srcPath, destPath); } } } function detectPackageManager(cwd) { if (existsSync(join(cwd, 'bun.lock'))) return 'bun'; if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; if (existsSync(join(cwd, 'yarn.lock'))) return 'yarn'; return 'npm'; } /** * Strip comments from JSONC text and parse as JSON. * Handles // single-line and /* block comments. */ function parseJsonc(text) { // Strip // comments let cleaned = text.replace(/\/\/.*$/gm, ''); // Strip /* */ comments cleaned = cleaned.replace(/\/\*[\s\S]*?\*\//g, ''); // Strip trailing commas cleaned = cleaned.replace(/,\s*([}\]])/g, '$1'); return JSON.parse(cleaned); } function mergeJson(target, source, strategy = {}) { const merged = JSON.parse(JSON.stringify(target)); for (const [key, value] of Object.entries(source)) { if (!(key in merged)) { merged[key] = JSON.parse(JSON.stringify(value)); continue; } const rule = strategy[key] || 'default'; if (rule === 'keep-target') continue; if (rule === 'source-wins') { merged[key] = JSON.parse(JSON.stringify(value)); continue; } if (rule === 'merge-agents') { merged[key] = merged[key] || {}; for (const [agentKey, agentVal] of Object.entries(value)) { if (!(agentKey in merged[key])) { merged[key][agentKey] = JSON.parse(JSON.stringify(agentVal)); } } continue; } if (rule === 'merge-mcp') { merged[key] = merged[key] || {}; for (const [mcpKey, mcpVal] of Object.entries(value)) { if (!(mcpKey in merged[key])) { merged[key][mcpKey] = JSON.parse(JSON.stringify(mcpVal)); } } continue; } if (rule === 'merge-instructions') { const existing = merged[key] || []; const srcArr = Array.isArray(value) ? value : [value]; for (const item of srcArr) { if (!existing.includes(item)) { existing.push(item); } } merged[key] = existing; continue; } if (rule === 'merge-permissions') { merged[key] = merged[key] || {}; for (const [permKey, permVal] of Object.entries(value)) { if (!(permKey in merged[key])) { merged[key][permKey] = JSON.parse(JSON.stringify(permVal)); } } continue; } // default: source wins for top-level, merge nested objects if ( typeof value === 'object' && value !== null && !Array.isArray(value) && typeof merged[key] === 'object' && merged[key] !== null && !Array.isArray(merged[key]) ) { merged[key] = { ...merged[key], ...value }; } else { merged[key] = value; } } return merged; } /** * Rewrite ALL .opencode/ paths in the config for global install. * * Handles these locations where .opencode/ appears: * - instructions[] → absolute path * - agent.*.prompt → rewrites path inside {file:...} wrapper * - mcp.*.command[] → rewrites any array element starting with .opencode/ * - plugin[] → absolute path * * @param {object} config - Parsed template opencode.json * @param {string} opencodeHome - Absolute path to ~/.config/opencode/ * @returns {object} Config with all paths rewritten to absolute */ function rewriteTemplatePathsForGlobal(config, opencodeHome) { const rewritten = JSON.parse(JSON.stringify(config)); function toGlobalPath(p) { if (p.startsWith('.opencode/')) { return join(opencodeHome, p.slice('.opencode/'.length)); } return p; } // 1. instructions array if (Array.isArray(rewritten.instructions)) { rewritten.instructions = rewritten.instructions.map((p) => toGlobalPath(p)); } // 2. agent entries — prompt uses {file:.opencode/prompts/agents/...} format if (rewritten.agent) { for (const [, agentConfig] of Object.entries(rewritten.agent)) { if (agentConfig.prompt && agentConfig.prompt.includes('{file:')) { agentConfig.prompt = agentConfig.prompt.replace( /\{file:(\.opencode\/[^}]+)\}/g, (_, filePath) => `{file:${toGlobalPath(filePath)}}`, ); } // Also handle plain 'file' field if present if (agentConfig.file) { agentConfig.file = toGlobalPath(agentConfig.file); } } } // 3. mcp entries — command arrays may contain .opencode/ paths if (rewritten.mcp) { for (const [, mcpConfig] of Object.entries(rewritten.mcp)) { if (Array.isArray(mcpConfig.command)) { mcpConfig.command = mcpConfig.command.map((c) => toGlobalPath(c)); } } } // 4. plugin array if (Array.isArray(rewritten.plugin)) { rewritten.plugin = rewritten.plugin.map((p) => toGlobalPath(p)); } return rewritten; } /** * Install the kit globally into the OpenCode config directory. * * On macOS/Linux: ~/.config/opencode/ * On Windows: %APPDATA%\opencode\ * * Files from template/.opencode/ are copied FLAT (no .opencode wrapper), * and config paths are adjusted accordingly. */ async function installGlobal(options) { const opencodeHome = getOpenCodeHome(); console.log(`\n ${C.bold}${PKG_NAME} init --global${C.reset}`); console.log(` ${C.dim}${'─'.repeat(50)}${C.reset}`); // 1. Validate template if (!existsSync(TEMPLATE_DIR)) { error(`Template directory not found at ${TEMPLATE_DIR}`); error('This is a bug. Please reinstall the package.'); process.exit(1); } // 2. Check if already installed const jsoncPath = join(opencodeHome, 'opencode.jsonc'); const jsonPath = join(opencodeHome, 'opencode.json'); if ((existsSync(jsoncPath) || existsSync(jsonPath)) && !options.quiet && !options.force) { warn(`OpenCode config already exists at ${opencodeHome}`); const rl = await import('readline/promises'); const readline = rl.createInterface({ input: process.stdin, output: process.stdout }); const answer = await readline.question(` ? Merge agent kit into existing config? [y/N] `); readline.close(); if (answer.toLowerCase() !== 'y') { error('Aborted.'); process.exit(0); } } // 3. Ensure global directory exists info(`OpenCode home: ${opencodeHome}`); mkdirSync(opencodeHome, { recursive: true }); // 4. Copy template/.opencode/ contents FLAT into opencodeHome // e.g. template/.opencode/skills/ → opencodeHome/skills/ info('Copying skills, prompts, commands, instructions...'); const templateOpencode = join(TEMPLATE_DIR, '.opencode'); const entries = readdirSync(templateOpencode, { withFileTypes: true }); for (const entry of entries) { if (entry.name === '.DS_Store') continue; if (entry.name === 'node_modules') continue; if (entry.name === 'package.json') continue; // preserve existing const srcPath = join(templateOpencode, entry.name); const destPath = join(opencodeHome, entry.name); if (entry.isDirectory()) { copyRecursive(srcPath, destPath); } else if (!existsSync(destPath)) { copyFileSync(srcPath, destPath); } } // 5. Copy template's package.json if user doesn't have one const tmplPkgJson = join(templateOpencode, 'package.json'); const userPkgJson = join(opencodeHome, 'package.json'); if (!existsSync(userPkgJson)) { copyFileSync(tmplPkgJson, userPkgJson); } // 6. Merge config into opencode.jsonc (or opencode.json) const templateConfigPath = join(TEMPLATE_DIR, 'opencode.json'); const templateConfig = JSON.parse(readFileSync(templateConfigPath, 'utf-8')); // Rewrite paths for flat structure const kitConfig = rewriteTemplatePathsForGlobal(templateConfig, opencodeHome); // Read existing user config (JSONC or JSON) let userConfig = {}; if (existsSync(jsoncPath)) { try { const raw = readFileSync(jsoncPath, 'utf-8'); userConfig = parseJsonc(raw); } catch (err) { warn(`Could not parse existing opencode.jsonc: ${err.message}`); warn('Will create fresh config entries.'); } } else if (existsSync(jsonPath)) { try { userConfig = JSON.parse(readFileSync(jsonPath, 'utf-8')); } catch { warn('Could not parse existing opencode.json.'); } } // Build merge strategy const strategy = { $schema: 'keep-target', formatter: 'keep-target', permission: 'keep-target', provider: 'keep-target', plugin: 'merge-instructions', instructions: 'merge-instructions', agent: options.force ? 'source-wins' : 'merge-agents', mcp: 'merge-mcp', }; const merged = mergeJson(userConfig, kitConfig, strategy); // Write as .jsonc (JSON without comments — OpenCode reads both) info('Merging configuration into opencode.jsonc...'); writeFileSync(jsoncPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8'); // 7. Copy opencode.example.json const exampleSrc = join(TEMPLATE_DIR, 'opencode.example.json'); const exampleDest = join(opencodeHome, 'opencode.example.json'); if (existsSync(exampleSrc) && !existsSync(exampleDest)) { copyFileSync(exampleSrc, exampleDest); } // 8. Install dependencies info('Installing OpenCode dependencies...'); let pm; try { pm = detectPackageManager(opencodeHome); execSync(`${pm} install`, { cwd: opencodeHome, stdio: 'pipe' }); success('Dependencies installed'); } catch (err) { warn(`Dependency install failed: ${err.message}`); warn(`You can run "${pm} install" manually in ${opencodeHome}`); } // 9. Install agentmemory globally info('Setting up agentmemory (persistent memory)...'); try { execSync('agentmemory --version', { stdio: 'pipe' }); success('agentmemory already installed'); } catch { try { execSync('npm install -g @agentmemory/agentmemory', { stdio: 'pipe', timeout: 60000 }); success('agentmemory installed globally'); } catch (err) { warn(`agentmemory global install failed: ${err.message}`); warn('Run "npm install -g @agentmemory/agentmemory" manually'); } } // 10. Install portless globally info('Setting up portless (dev server URLs)...'); try { execSync('portless --version', { stdio: 'pipe' }); success('portless already installed'); } catch { try { execSync('npm install -g portless', { stdio: 'pipe', timeout: 60000 }); success('portless installed globally'); } catch (err) { warn(`portless global install failed: ${err.message}`); warn('Run "npm install -g portless" manually'); } } // 11. Write version const pkgJson = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')); writeFileSync(join(opencodeHome, '.kit-version'), pkgJson.version + '\n', 'utf-8'); // 12. Summary console.log( `\n ${C.bold}${C.green}✅ opencode-agent-kit v${pkgJson.version} installed globally!${C.reset}\n`, ); console.log(` ${C.dim}Location:${C.reset} ${opencodeHome}`); console.log(` ${C.dim}${'─'.repeat(30)}${C.reset}`); console.log(` ${C.bold}Installed:${C.reset}`); console.log(` • ${C.cyan}skills/${C.reset} — 200+ skill playbooks`); console.log(` • ${C.cyan}prompts/agents/${C.reset} — 34 agent prompt files`); console.log(` • ${C.cyan}commands/${C.reset} — 47 slash commands`); console.log(` • ${C.cyan}instructions/${C.reset} — Core rules`); console.log(` • ${C.cyan}hooks/${C.reset} — Automation hooks`); console.log(` • ${C.cyan}rules/${C.reset} — Scoped coding rules`); console.log(` • ${C.cyan}opencode.jsonc${C.reset} — Merged configuration`); console.log(` • ${C.cyan}agentmemory${C.reset} (global) — Persistent memory`); console.log(` • ${C.cyan}portless${C.reset} (global) — Dev server URLs`); console.log(`\n ${C.bold}${C.green}${C.reset} OpenCode will automatically use this config.\n`); } /** * Link: on macOS/Linux, the global config at ~/.config/opencode/ is * automatically detected by OpenCode. No per-project setup needed. * * On other platforms, creates a minimal per-project config. */ export async function linkProject(targetDir, _opencodeHome, force) { // On macOS/Linux: OpenCode auto-detects ~/.config/opencode/ if (platform() !== 'win32') { console.log(`\n ${C.bold}${PKG_NAME} link${C.reset}`); console.log(` ${C.dim}${'─'.repeat(50)}${C.reset}`); console.log(` Target: ${targetDir}\n`); info('OpenCode on this platform auto-detects the global config.'); info(`Global config at: ${getOpenCodeHome()}`); success('No per-project setup needed — OpenCode reads global config automatically.'); console.log(`\n ${C.bold}${C.green}${C.reset} Run ${C.cyan}opencode${C.reset} to start.\n`); return; } // Windows: create a reference config console.log(`\n ${C.bold}${PKG_NAME} link${C.reset}`); console.log(` ${C.dim}${'─'.repeat(50)}${C.reset}`); console.log(` Target: ${targetDir}\n`); const opencodeHome = _opencodeHome || getOpenCodeHome(); const opencodeDir = join(targetDir, '.opencode'); if ((existsSync(opencodeDir) || existsSync(join(targetDir, 'opencode.json'))) && !force) { warn(`Configuration already exists in ${targetDir}`); const rl = await import('readline/promises'); const readline = rl.createInterface({ input: process.stdin, output: process.stdout }); const answer = await readline.question(` ? Overwrite? [y/N] `); readline.close(); if (answer.toLowerCase() !== 'y') { error('Aborted.'); process.exit(0); } } // Symlink .opencode/ → global opencodeHome if (existsSync(opencodeDir)) { rmSync(opencodeDir, { recursive: true, force: true }); } symlinkSync(opencodeHome, opencodeDir, 'junction'); // Minimal opencode.json that references global install const configPath = join(targetDir, 'opencode.json'); const refConfig = { $schema: 'https://opencode.ai/config.json', instructions: [join(opencodeHome, 'instructions', 'INSTRUCTIONS.md')], }; writeFileSync(configPath, JSON.stringify(refConfig, null, 2) + '\n', 'utf-8'); // .gitignore const gitignorePath = join(targetDir, '.gitignore'); const gitignoreEntries = ['.opencode/*', 'opencode.json']; if (!existsSync(gitignorePath)) { writeFileSync(gitignorePath, gitignoreEntries.join('\n') + '\n', 'utf-8'); } else { const gitignoreContent = readFileSync(gitignorePath, 'utf-8'); for (const entry of gitignoreEntries) { if (!gitignoreContent.includes(entry)) { appendFileSync(gitignorePath, entry + '\n', 'utf-8'); } } } success(`.opencode/ → junction to global install`); success('opencode.json created'); console.log(`\n ${C.bold}${C.green}✅ Project linked!\n`); } /** * Local init: copy template into project directory (traditional behaviour). */ async function initLocal(options) { const targetDir = options.dir; const force = options.force; const skipInstall = options.skipInstall; if (!existsSync(targetDir)) { error(`Target directory does not exist: ${targetDir}`); process.exit(1); } const opencodeDir = join(targetDir, '.opencode'); const userConfigPath = join(targetDir, 'opencode.json'); if (existsSync(opencodeDir) && !force) { warn(`.opencode/ already exists in ${targetDir}`); const rl = await import('readline/promises'); const readline = rl.createInterface({ input: process.stdin, output: process.stdout }); const answer = await readline.question(` ? Overwrite existing files? [y/N] `); readline.close(); if (answer.toLowerCase() !== 'y') { error('Aborted.'); process.exit(0); } } if (!existsSync(TEMPLATE_DIR)) { error(`Template directory not found at ${TEMPLATE_DIR}`); error('This is a bug. Please reinstall the package.'); process.exit(1); } info('Copying .opencode/ configuration...'); copyRecursive(join(TEMPLATE_DIR, '.opencode'), opencodeDir); const templateConfigPath = join(TEMPLATE_DIR, 'opencode.json'); if (existsSync(templateConfigPath)) { info('Merging opencode.json...'); const templateConfig = JSON.parse(readFileSync(templateConfigPath, 'utf-8')); const userConfig = existsSync(userConfigPath) ? JSON.parse(readFileSync(userConfigPath, 'utf-8')) : {}; const strategy = { $schema: 'source-wins', formatter: 'keep-target', permission: 'keep-target', instructions: 'merge-instructions', mcp: 'merge-mcp', agent: force ? 'source-wins' : 'merge-agents', plugin: 'merge-instructions', }; const merged = mergeJson(userConfig, templateConfig, strategy); writeFileSync(userConfigPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8'); } const exampleSrc = join(TEMPLATE_DIR, 'opencode.example.json'); const exampleDest = join(targetDir, 'opencode.example.json'); if (existsSync(exampleSrc)) { copyFileSync(exampleSrc, exampleDest); info('opencode.example.json copied'); } if (!skipInstall) { const pm = detectPackageManager(opencodeDir); info(`Installing .opencode/ dependencies with ${pm}...`); try { execSync(`${pm} install`, { cwd: opencodeDir, stdio: 'pipe' }); success('.opencode/ dependencies installed'); } catch (err) { warn(`Dependency install failed: ${err.message}`); warn(`You can run "${pm} install" manually in .opencode/`); } } if (!skipInstall) { info('Setting up agentmemory...'); try { execSync('agentmemory --version', { stdio: 'pipe' }); success('agentmemory already installed'); } catch { try { execSync('npm install -g @agentmemory/agentmemory', { stdio: 'pipe', timeout: 60000 }); success('agentmemory installed globally'); } catch (err) { warn(`agentmemory global install failed: ${err.message}`); warn('Run "npm install -g @agentmemory/agentmemory" manually'); } } } if (!skipInstall) { info('Setting up portless...'); try { execSync('portless --version', { stdio: 'pipe' }); success('portless already installed'); } catch { try { execSync('npm install -g portless', { stdio: 'pipe', timeout: 60000 }); success('portless installed globally'); } catch (err) { warn(`portless global install failed: ${err.message}`); warn('Run "npm install -g portless" manually'); } } } const gitignorePath = join(targetDir, '.gitignore'); const gitignoreEntries = ['.opencode/*', 'opencode.json', 'opencode.example.json', 'data/']; if (!existsSync(gitignorePath)) { writeFileSync(gitignorePath, gitignoreEntries.join('\n') + '\n', 'utf-8'); success('Created .gitignore'); } else { const gitignoreContent = readFileSync(gitignorePath, 'utf-8'); let appended = false; for (const entry of gitignoreEntries) { if (!gitignoreContent.includes(entry)) { appendFileSync(gitignorePath, entry + '\n', 'utf-8'); appended = true; } } if (appended) success('Updated .gitignore'); } const pkgJson = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')); writeFileSync(join(opencodeDir, '.kit-version'), pkgJson.version + '\n', 'utf-8'); if (existsSync(opencodeDir) && existsSync(join(opencodeDir, '.kit-version'))) { const { runMigrations } = await import('./migrate.mjs'); const existingConfig = existsSync(userConfigPath) ? JSON.parse(readFileSync(userConfigPath, 'utf-8')) : {}; const result = await runMigrations(targetDir, existingConfig, { verbose: false }); if (result.ran > 0) info(`Applied ${result.ran} migration(s).`); } console.log( `\n ${C.bold}${C.green}✅ opencode-agent-kit v${pkgJson.version} installed!${C.reset}\n`, ); console.log(` ${C.dim}Location:${C.reset} ${targetDir}`); console.log(` ${C.dim}${'─'.repeat(30)}${C.reset}`); console.log(` ${C.bold}What you got:${C.reset}`); console.log(` • ${C.cyan}opencode.json${C.reset} — 33 agents + 10 MCP servers`); console.log(` • ${C.cyan}.opencode/prompts/agents/${C.reset} — 34 agent prompt files`); console.log(` • ${C.cyan}.opencode/skills/${C.reset} — 200+ skill playbooks`); console.log(` • ${C.cyan}.opencode/commands/${C.reset} — 47 slash commands`); console.log(` • ${C.cyan}.opencode/rules/${C.reset} — Scoped coding rules`); console.log(` • ${C.cyan}.opencode/hooks/${C.reset} — Automation hooks`); console.log(` • ${C.cyan}.opencode/docs/${C.reset} — Agent documentation`); console.log(` • ${C.cyan}agentmemory${C.reset} (global) — Persistent memory`); console.log( ` • ${C.cyan}portless${C.reset} (global) — Named .localhost URLs for dev servers`, ); console.log( `\n ${C.bold}${C.green}${C.reset} Next: run ${C.cyan}opencode${C.reset} to start\n`, ); } /** * Main init entry point. * Dispatches to: * --global → installGlobal() * --local → initLocal() * default → check global, then choose initLocal() (per-project) */ export async function init(options) { // Map --yes to quiet (auto-confirm merge without overwriting) if (options.yes) options.quiet = true; if (options.global) return installGlobal(options); if (options.local) return initLocal(options); const targetDir = options.dir; if (!existsSync(targetDir)) { error(`Target directory does not exist: ${targetDir}`); process.exit(1); } // Default: local copy (global is auto-detected by OpenCode on macOS/Linux) return initLocal(options); }