828 lines
27 KiB
JavaScript
Executable File
828 lines
27 KiB
JavaScript
Executable File
#!/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.
|
|
|
|
<user-request>
|
|
$ARGUMENTS
|
|
</user-request>
|
|
`;
|
|
|
|
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.
|
|
|
|
<user-request>
|
|
$ARGUMENTS
|
|
</user-request>
|
|
`;
|
|
|
|
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);
|
|
});
|