#!/usr/bin/env node import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, cpSync, writeFileSync, } from "node:fs"; import { execSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as p from "@clack/prompts"; import color from "picocolors"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SKILLS_DIR = join(__dirname, "..", "skills"); /** Create a clickable terminal hyperlink */ function link(text, url) { return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`; } /** @typedef {"opencode" | "claude" | "codex"} Agent */ /** * Agent configuration for skill/command paths */ const AGENT_CONFIG = { opencode: { skillDir: ".opencode/skill", commandDir: ".opencode/command", cli: "opencode", configFiles: [".opencode", "opencode.json"], }, claude: { skillDir: ".claude/skills", commandDir: ".claude/commands", cli: "claude", configFiles: [".claude", "claude.json", "CLAUDE.md"], }, codex: { skillDir: ".codex/skills", commandDir: ".codex/commands", cli: "codex", configFiles: [".codex", "codex.json", "AGENTS.md"], }, }; /** @typedef {"npm" | "bun" | "pnpm"} PackageManager */ // ───────────────────────────────────────────────────────────────────────────── // Detection // ───────────────────────────────────────────────────────────────────────────── /** Check if a CLI binary is available */ function hasBinary(name) { try { execSync(`which ${name}`, { stdio: "ignore" }); return true; } catch { return false; } } /** * Detect package manager from lockfiles * @returns {PackageManager | null} */ function detectPackageManager() { const cwd = process.cwd(); if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb"))) { return "bun"; } if (existsSync(join(cwd, "pnpm-lock.yaml"))) { return "pnpm"; } if (existsSync(join(cwd, "package-lock.json"))) { return "npm"; } return null; } /** * Detect harness from repo config files * @returns {Agent | null} */ function detectConfiguredAgent() { const cwd = process.cwd(); for (const [agent, config] of Object.entries(AGENT_CONFIG)) { for (const file of config.configFiles) { if (existsSync(join(cwd, file))) { return /** @type {Agent} */ (agent); } } } return null; } /** * Get all agents with binary available * @returns {Agent[]} */ function getAvailableAgents() { return /** @type {Agent[]} */ ( Object.keys(AGENT_CONFIG).filter((agent) => hasBinary(AGENT_CONFIG[/** @type {Agent} */ (agent)].cli), ) ); } // ───────────────────────────────────────────────────────────────────────────── // Skills // ───────────────────────────────────────────────────────────────────────────── /** Recursively find all SKILL.md files and return skill info */ function discoverSkills() { const skills = []; function walk(dir, prefix = "") { if (!existsSync(dir)) return; for (const entry of readdirSync(dir)) { const fullPath = join(dir, entry); const stat = statSync(fullPath); if (stat.isDirectory()) { const skillFile = join(fullPath, "SKILL.md"); if (existsSync(skillFile)) { const id = prefix ? `${prefix}/${entry}` : entry; skills.push({ id, path: fullPath, skillFile }); } else { walk(fullPath, prefix ? `${prefix}/${entry}` : entry); } } } } walk(SKILLS_DIR); return skills; } /** Parse YAML frontmatter from SKILL.md */ function parseSkillMeta(skillFile) { const content = readFileSync(skillFile, "utf-8"); const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return { name: "unknown", description: "" }; const yaml = match[1]; const name = yaml.match(/name:\s*(.+)/)?.[1]?.trim() || "unknown"; const description = yaml.match(/description:\s*(.+)/)?.[1]?.trim() || ""; return { name, description }; } // ───────────────────────────────────────────────────────────────────────────── // Installation // ───────────────────────────────────────────────────────────────────────────── /** Install skill to target agent */ function installSkill(skillPath, skillName, agent) { const cwd = process.cwd(); const config = AGENT_CONFIG[agent]; const targetSkillDir = join(cwd, config.skillDir, skillName); if (!existsSync(skillPath)) { return { success: false, message: `Skill source not found: ${skillPath}` }; } mkdirSync(dirname(targetSkillDir), { recursive: true }); if (existsSync(targetSkillDir)) { return { success: true, message: `${targetSkillDir}`, existed: true }; } cpSync(skillPath, targetSkillDir, { recursive: true }); return { success: true, message: `${targetSkillDir}`, existed: false }; } /** Install command wrapper for skill */ function installCommand(skillName, agent) { const cwd = process.cwd(); const config = AGENT_CONFIG[agent]; const commandDir = join(cwd, config.commandDir); const commandFile = join(commandDir, "adopt-better-result.md"); mkdirSync(commandDir, { recursive: true }); if (existsSync(commandFile)) { return { success: true, message: `${commandFile}`, existed: true }; } const commandContent = `--- description: Use ${skillName} skill for better-result migration/adoption --- Load and use the ${skillName} skill to help with this codebase. First, invoke the skill: \`\`\` skill({ name: '${skillName}' }) \`\`\` Then follow the skill instructions. $ARGUMENTS `; writeFileSync(commandFile, commandContent); return { success: true, message: `${commandFile}`, existed: false }; } // ───────────────────────────────────────────────────────────────────────────── // Launch // ───────────────────────────────────────────────────────────────────────────── /** * Launch agent CLI interactively * @param {Agent} agent */ function launchAgent(agent) { const config = AGENT_CONFIG[agent]; try { execSync(config.cli, { stdio: "inherit", cwd: process.cwd(), env: process.env, }); process.exit(0); } catch (err) { process.exit(err.status ?? 0); } } // ───────────────────────────────────────────────────────────────────────────── // Main // ───────────────────────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────────────────────── // Version Detection // ───────────────────────────────────────────────────────────────────────────── /** * @typedef {{ installed: false } | { installed: true; version: string; major: number }} VersionInfo */ /** * Detect installed better-result version from package.json * @returns {VersionInfo} */ function detectInstalledVersion() { const cwd = process.cwd(); const pkgPath = join(cwd, "package.json"); if (!existsSync(pkgPath)) { return { installed: false }; } try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; const versionSpec = deps["better-result"]; if (!versionSpec) { return { installed: false }; } // Try to get actual installed version from node_modules const installedPkgPath = join(cwd, "node_modules", "better-result", "package.json"); if (existsSync(installedPkgPath)) { const installedPkg = JSON.parse(readFileSync(installedPkgPath, "utf-8")); const version = installedPkg.version; const major = parseInt(version.split(".")[0], 10); return { installed: true, version, major }; } // Fallback: parse version from spec (strip ^, ~, etc.) const version = versionSpec.replace(/^[\^~>=<]+/, ""); const major = parseInt(version.split(".")[0], 10); return { installed: true, version, major }; } catch { return { installed: false }; } } // ───────────────────────────────────────────────────────────────────────────── // Help // ───────────────────────────────────────────────────────────────────────────── function printHelp() { console.log(` ${color.bold("better-result")} - Lightweight Result type for TypeScript ${color.dim("Usage:")} npx better-result init Interactive setup (new projects) npx better-result migrate Detect version & guide migration npx better-result --help Show this help `); } // ───────────────────────────────────────────────────────────────────────────── // Migrate Command // ───────────────────────────────────────────────────────────────────────────── async function runMigrate() { console.log(); p.intro(color.bgCyan(color.black(" better-result migrate "))); const versionInfo = detectInstalledVersion(); if (!versionInfo.installed) { p.log.warn("better-result is not installed in this project"); const shouldInit = await p.confirm({ message: "Would you like to set up better-result now?", initialValue: true, }); if (p.isCancel(shouldInit) || !shouldInit) { p.cancel("Migration cancelled."); process.exit(0); } // Run init flow return runInit(); } p.log.info(`Detected version: ${color.cyan(versionInfo.version)}`); if (versionInfo.major >= 2) { p.log.success("Already on v2 - no migration needed!"); p.outro(color.green("You're up to date.")); process.exit(0); } // v1 detected - guide to v2 migration p.log.step(`Migration available: v${versionInfo.major} → v2`); console.log(); console.log(color.dim(" Breaking changes in v2:")); console.log(color.dim(" • TaggedError: class-based → factory-based API")); console.log(color.dim(" • TaggedError.match → matchError (standalone fn)")); console.log(color.dim(" • TaggedError.matchPartial → matchErrorPartial")); console.log(color.dim(" • New: Panic for unrecoverable errors")); console.log(); const onCancel = () => { p.cancel("Migration cancelled."); process.exit(0); }; // Detect defaults const detectedPM = detectPackageManager(); const configuredAgent = detectConfiguredAgent(); const availableAgents = getAvailableAgents(); const detectedAgent = configuredAgent ?? (availableAgents.length > 0 ? availableAgents[0] : null); // Build package manager options const pmList = /** @type {PackageManager[]} */ (["npm", "bun", "pnpm"]); const pmOptions = pmList.map((pm) => ({ value: pm, label: pm === detectedPM ? `${pm} ${color.dim("(detected)")}` : pm, })); if (detectedPM) { const idx = pmOptions.findIndex((o) => o.value === detectedPM); if (idx > 0) { const [detected] = pmOptions.splice(idx, 1); pmOptions.unshift(detected); } } // Build agent options const agents = /** @type {Agent[]} */ (["opencode", "claude", "codex"]); const agentOptions = agents.map((agent) => ({ value: agent, label: agent === detectedAgent ? `${agent} ${color.dim("(detected)")}` : agent, })); if (detectedAgent) { const idx = agentOptions.findIndex((o) => o.value === detectedAgent); if (idx > 0) { const [detected] = agentOptions.splice(idx, 1); agentOptions.unshift(detected); } } const responses = await p.group( { pm: () => p.select({ message: "Package manager", options: pmOptions, initialValue: detectedPM ?? "npm", }), installSkill: () => p.confirm({ message: "Install AI migration skill? (recommended for large codebases)", initialValue: true, }), }, { onCancel }, ); const selectedPM = /** @type {PackageManager} */ (responses.pm); /** @type {Agent | null} */ let selectedAgent = null; let shouldLaunch = false; if (responses.installSkill) { const toolResponses = await p.group( { agent: () => p.select({ message: "AI coding agent", options: agentOptions, initialValue: detectedAgent ?? "opencode", }), launch: () => p.confirm({ message: "Launch agent after install?", initialValue: true, }), }, { onCancel }, ); selectedAgent = /** @type {Agent} */ (toolResponses.agent); shouldLaunch = Boolean(toolResponses.launch); } // Upgrade package const s = p.spinner(); s.start("Upgrading better-result to v2"); try { const installCmd = selectedPM === "npm" ? "npm install better-result@latest" : selectedPM === "bun" ? "bun add better-result@latest" : "pnpm add better-result@latest"; execSync(installCmd, { stdio: "ignore", cwd: process.cwd() }); s.stop("Upgraded to v2"); } catch { s.stop(color.yellow("Upgrade failed - try manually")); } // Install migration skill + command if (selectedAgent) { const skills = discoverSkills(); const migrateSkill = skills.find((s) => s.id === "migrations/v2"); if (migrateSkill) { const meta = parseSkillMeta(migrateSkill.skillFile); const s2 = p.spinner(); s2.start(`Installing migration skill for ${selectedAgent}`); const skillResult = installSkill(migrateSkill.path, meta.name, selectedAgent); installMigrateCommand(meta.name, selectedAgent); s2.stop("Migration skill installed"); p.log.success(`Skill: ${color.dim(skillResult.message)}`); } } /** Strip ANSI codes for length calculation */ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); /** Print a styled box with title and message */ function box(title, lines) { const titlePlain = stripAnsi(title); const maxLen = Math.max(...lines.map((l) => stripAnsi(l).length), titlePlain.length + 4); const padding = maxLen - titlePlain.length - 2; const top = `${color.dim("┌──")} ${title} ${color.dim("─".repeat(Math.max(0, padding)) + "┐")}`; const bot = color.dim(`└${"─".repeat(maxLen + 2)}┘`); console.log(top); for (const line of lines) { const plainLen = stripAnsi(line).length; console.log(`${color.dim("│")} ${line}${" ".repeat(maxLen - plainLen)} ${color.dim("│")}`); } console.log(bot); } const commandHintLines = [ `${color.white("Run")} ${color.bold(color.cyan("/migrate-better-result"))} ${color.white("to start migration.")}`, "", color.yellow("The AI will update TaggedError classes to v2 syntax."), color.dim("See MIGRATION.md for manual migration steps."), ]; // Launch or show next steps if (selectedAgent && shouldLaunch) { if (!hasBinary(AGENT_CONFIG[selectedAgent].cli)) { p.log.warn(`${selectedAgent} binary not found`); box(color.bold("Next steps"), commandHintLines); p.outro(`Install ${selectedAgent}, then run: ${color.cyan(AGENT_CONFIG[selectedAgent].cli)}`); process.exit(0); } console.log(); box(color.green("Once the agent opens"), commandHintLines); console.log(); const confirmLaunch = await p.text({ message: `Press ${color.bold(color.cyan("Enter"))} to launch ${color.bold(selectedAgent)}...`, placeholder: "", defaultValue: "", }); if (p.isCancel(confirmLaunch)) { p.cancel("Migration cancelled."); process.exit(0); } launchAgent(selectedAgent); } else if (selectedAgent) { const manualLines = [ `${color.white("Run:")} ${color.bold(color.cyan(AGENT_CONFIG[selectedAgent].cli))}`, "", ...commandHintLines, ]; console.log(); box(color.bold("Next steps"), manualLines); p.outro(color.green("Ready to migrate!")); process.exit(0); } else { console.log(); p.log.info(`See ${color.cyan("MIGRATION.md")} for manual migration steps.`); p.outro(color.green("Package upgraded to v2!")); process.exit(0); } } /** Install migrate command wrapper */ function installMigrateCommand(skillName, agent) { const cwd = process.cwd(); const config = AGENT_CONFIG[agent]; const commandDir = join(cwd, config.commandDir); const commandFile = join(commandDir, "migrate-better-result.md"); mkdirSync(commandDir, { recursive: true }); if (existsSync(commandFile)) { return { success: true, message: `${commandFile}`, existed: true }; } const commandContent = `--- description: Migrate TaggedError classes from better-result v1 to v2 --- Load and use the ${skillName} skill to migrate this codebase. First, invoke the skill: \`\`\` skill({ name: '${skillName}' }) \`\`\` Then follow the skill instructions to migrate TaggedError classes. $ARGUMENTS `; writeFileSync(commandFile, commandContent); return { success: true, message: `${commandFile}`, existed: false }; } async function main() { const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { printHelp(); process.exit(0); } const command = args[0]; if (command === "migrate") { return runMigrate(); } if (command !== "init") { printHelp(); process.exit(command ? 1 : 0); } return runInit(); } // ───────────────────────────────────────────────────────────────────────────── // Init Command // ───────────────────────────────────────────────────────────────────────────── async function runInit() { // ───────────────────────────────────────────────────────────────────────── // Interactive init flow // ───────────────────────────────────────────────────────────────────────── console.log(); p.intro(color.bgCyan(color.black(" better-result "))); const skills = discoverSkills(); const adoptSkill = skills.find((s) => s.id === "adopt"); if (!adoptSkill) { p.log.error("adopt skill not found in package"); process.exit(1); } const meta = parseSkillMeta(adoptSkill.skillFile); // Detect defaults const detectedPM = detectPackageManager(); const configuredAgent = detectConfiguredAgent(); const availableAgents = getAvailableAgents(); const detectedAgent = configuredAgent ?? (availableAgents.length > 0 ? availableAgents[0] : null); // Build package manager options const pmList = /** @type {PackageManager[]} */ (["npm", "bun", "pnpm"]); const pmOptions = pmList.map((pm) => ({ value: pm, label: pm === detectedPM ? `${pm} ${color.dim("(detected)")}` : pm, })); if (detectedPM) { const idx = pmOptions.findIndex((o) => o.value === detectedPM); if (idx > 0) { const [detected] = pmOptions.splice(idx, 1); pmOptions.unshift(detected); } } // Build agent options const agents = /** @type {Agent[]} */ (["opencode", "claude", "codex"]); const agentOptions = agents.map((agent) => ({ value: agent, label: agent === detectedAgent ? `${agent} ${color.dim("(detected)")}` : agent, })); if (detectedAgent) { const idx = agentOptions.findIndex((o) => o.value === detectedAgent); if (idx > 0) { const [detected] = agentOptions.splice(idx, 1); agentOptions.unshift(detected); } } const onCancel = () => { p.cancel("Setup cancelled."); process.exit(0); }; const responses = await p.group( { pm: () => p.select({ message: "Package manager", options: pmOptions, initialValue: detectedPM ?? "npm", }), installTools: () => p.confirm({ message: "Install AI agent skill + command?", initialValue: true, }), }, { onCancel }, ); const selectedPM = /** @type {PackageManager} */ (responses.pm); // Conditional: agent selection + opensrc + launch option /** @type {Agent | null} */ let selectedAgent = null; let shouldLaunch = false; let installOpensrc = false; if (responses.installTools) { const toolResponses = await p.group( { agent: () => p.select({ message: "AI coding agent", options: agentOptions, initialValue: detectedAgent ?? "opencode", }), opensrc: () => p.confirm({ message: `Add source code for better AI context? ${color.dim(`(${link("opensrc", "https://github.com/vercel-labs/opensrc")})`)}`, initialValue: true, }), launch: () => p.confirm({ message: "Launch agent after install?", initialValue: true, }), }, { onCancel }, ); selectedAgent = /** @type {Agent} */ (toolResponses.agent); installOpensrc = Boolean(toolResponses.opensrc); shouldLaunch = Boolean(toolResponses.launch); } // Install package const s = p.spinner(); s.start("Installing better-result package"); try { const installCmd = selectedPM === "npm" ? "npm install better-result" : selectedPM === "bun" ? "bun add better-result" : "pnpm add better-result"; execSync(installCmd, { stdio: "ignore", cwd: process.cwd() }); s.stop("Package installed"); } catch { s.stop("Package install failed (may already be installed)"); } // Install source code via opensrc for better AI context if (installOpensrc) { const s3 = p.spinner(); s3.start("Fetching source code for AI context"); try { // --modify to auto-update .gitignore, tsconfig, AGENTS.md execSync("npx -y opensrc better-result --modify", { stdio: "ignore", cwd: process.cwd() }); s3.stop("Source code added to opensrc/"); } catch (err) { s3.stop(color.yellow("Source fetch failed (opensrc may not be available)")); } } // Install skill + command (if selected) if (selectedAgent) { const s2 = p.spinner(); s2.start(`Installing skill + command for ${selectedAgent}`); const skillResult = installSkill(adoptSkill.path, meta.name, selectedAgent); const commandResult = installCommand(meta.name, selectedAgent); s2.stop("Skill + command installed"); p.log.success( `Skill: ${color.dim(skillResult.message)}${skillResult.existed ? color.dim(" (existed)") : ""}`, ); p.log.success( `Command: ${color.dim(commandResult.message)}${commandResult.existed ? color.dim(" (existed)") : ""}`, ); } /** Strip ANSI codes for length calculation */ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, ""); /** Print a styled box with title and message */ function box(title, lines) { const titlePlain = stripAnsi(title); const maxLen = Math.max(...lines.map((l) => stripAnsi(l).length), titlePlain.length + 4); const padding = maxLen - titlePlain.length - 2; const top = `${color.dim("┌──")} ${title} ${color.dim("─".repeat(Math.max(0, padding)) + "┐")}`; const bot = color.dim(`└${"─".repeat(maxLen + 2)}┘`); console.log(top); for (const line of lines) { const plainLen = stripAnsi(line).length; console.log(`${color.dim("│")} ${line}${" ".repeat(maxLen - plainLen)} ${color.dim("│")}`); } console.log(bot); } const commandHintLines = [ `${color.white("Run")} ${color.bold(color.cyan("/adopt-better-result"))} ${color.white("to start adoption.")}`, "", color.yellow("This will analyze your codebase and suggest changes."), color.dim("It may take a few minutes depending on project size."), ]; // Launch or show next steps if (selectedAgent && shouldLaunch) { if (!hasBinary(AGENT_CONFIG[selectedAgent].cli)) { p.log.warn(`${selectedAgent} binary not found`); box(color.bold("Next steps"), commandHintLines); p.outro(`Install ${selectedAgent}, then run: ${color.cyan(AGENT_CONFIG[selectedAgent].cli)}`); process.exit(0); } console.log(); box(color.green("Once the agent opens"), commandHintLines); console.log(); const confirmLaunch = await p.text({ message: `Press ${color.bold(color.cyan("Enter"))} to launch ${color.bold(selectedAgent)}...`, placeholder: "", defaultValue: "", }); if (p.isCancel(confirmLaunch)) { p.cancel("Setup cancelled."); process.exit(0); } launchAgent(selectedAgent); } else if (selectedAgent) { const manualLines = [ `${color.white("Run:")} ${color.bold(color.cyan(AGENT_CONFIG[selectedAgent].cli))}`, "", ...commandHintLines, ]; console.log(); box(color.bold("Next steps"), manualLines); p.outro(color.green("Done!")); process.exit(0); } else { p.outro(color.green("Done!")); process.exit(0); } } main().catch((err) => { p.log.error(err.message); process.exit(1); });