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)

343 lines (304 loc) 10.9 kB
import { readFileSync, existsSync, copyFileSync, mkdirSync, writeFileSync, readdirSync, appendFileSync, } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { execSync } from 'child_process'; 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; const srcPath = join(src, entry.name); const destPath = join(dest, entry.name); if (entry.isDirectory()) { copyRecursive(srcPath, destPath); } else { 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'; } 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') { // Keep user's existing value 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)); } // else: keep user's existing agent (unless --force) } 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, but 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; } function mergeOencodeConfig(templateConfigPath, userConfigPath, force) { 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); return merged; } export async function init(options) { const targetDir = options.dir; const force = options.force; const skipInstall = options.skipInstall; console.log(`\n ${C.bold}${PKG_NAME} init${C.reset}`); console.log(` ${C.dim}${'─'.repeat(50)}${C.reset}`); // 1. Validate target if (!existsSync(targetDir)) { console.error(` ✗ Target directory does not exist: ${targetDir}`); error(`Target directory does not exist: ${targetDir}`); process.exit(1); } // 2. Check if .opencode already exists 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); } } // 3. Validate template exists if (!existsSync(TEMPLATE_DIR)) { error(`Template directory not found at ${TEMPLATE_DIR}`); error('This is a bug. Please reinstall the package.'); process.exit(1); } // 4. Copy .opencode/ from template info('Copying .opencode/ configuration...'); copyRecursive(join(TEMPLATE_DIR, '.opencode'), opencodeDir); // 5. Merge opencode.json const templateConfigPath = join(TEMPLATE_DIR, 'opencode.json'); if (existsSync(templateConfigPath)) { info('Merging opencode.json...'); const merged = mergeOencodeConfig(templateConfigPath, userConfigPath, force); writeFileSync(userConfigPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8'); } // 6. Copy opencode.example.json const exampleSrc = join(TEMPLATE_DIR, 'opencode.example.json'); const exampleDest = join(targetDir, 'opencode.example.json'); if (existsSync(exampleSrc)) { info('Copying opencode.example.json...'); copyFileSync(exampleSrc, exampleDest); } // 7. Install dependencies 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/`); } } // 8. Install agentmemory globally if (!skipInstall) { 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'); } } } // 9. Install portless globally (named .localhost URLs for dev servers) if (!skipInstall) { 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. Update .gitignore 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'); } // 12. Write .kit-version for migration tracking const pkgJson = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf-8')); const versionFile = join(opencodeDir, '.kit-version'); writeFileSync(versionFile, pkgJson.version + '\n', 'utf-8'); // 13. Run pending migrations (for upgrades over existing installs) 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).`); } } // 14. Done — summary 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/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 step: run ${C.cyan}opencode${C.reset} to start\n`, ); }