agent-orchestrator/packages/cli/src/commands/init.ts

490 lines
16 KiB
TypeScript

import { createInterface } from "node:readline/promises";
import { writeFileSync, existsSync } from "node:fs";
import { resolve, basename } from "node:path";
import { cwd } from "node:process";
import { stringify as yamlStringify } from "yaml";
import chalk from "chalk";
import type { Command } from "commander";
import { git, gh, execSilent } from "../lib/shell.js";
import {
detectProjectType,
generateRulesFromTemplates,
formatProjectTypeForDisplay,
} from "../lib/project-detection.js";
async function prompt(
rl: ReturnType<typeof createInterface>,
question: string,
defaultValue?: string,
): Promise<string> {
const suffix = defaultValue ? ` ${chalk.dim(`(${defaultValue})`)}` : "";
const answer = await rl.question(`${question}${suffix}: `);
return answer.trim() || defaultValue || "";
}
interface EnvironmentInfo {
isGitRepo: boolean;
gitRemote: string | null;
ownerRepo: string | null;
currentBranch: string | null;
defaultBranch: string | null;
hasTmux: boolean;
hasGh: boolean;
ghAuthed: boolean;
hasLinearKey: boolean;
hasSlackWebhook: boolean;
}
async function detectDefaultBranch(
workingDir: string,
ownerRepo: string | null,
): Promise<string | null> {
// Method 1: Try to get from git symbolic-ref (most reliable)
const symbolicRef = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], workingDir);
if (symbolicRef) {
// Output: refs/remotes/origin/main
const match = symbolicRef.match(/refs\/remotes\/origin\/(.+)$/);
if (match) {
return match[1];
}
}
// Method 2: Try GitHub API via gh CLI
if (ownerRepo) {
const ghResult = await gh([
"repo",
"view",
ownerRepo,
"--json",
"defaultBranchRef",
"-q",
".defaultBranchRef.name",
]);
if (ghResult) {
return ghResult;
}
}
// Method 3: Check which common branch exists locally
const commonBranches = ["main", "master", "next", "develop"];
for (const branch of commonBranches) {
const exists = await git(["rev-parse", "--verify", `origin/${branch}`], workingDir);
if (exists) {
return branch;
}
}
// Fallback: return "main" as a reasonable default
return "main";
}
async function detectEnvironment(workingDir: string): Promise<EnvironmentInfo> {
// Check if in git repo
const isGitRepo = (await git(["rev-parse", "--git-dir"], workingDir)) !== null;
// Get git remote
let gitRemote: string | null = null;
let ownerRepo: string | null = null;
if (isGitRepo) {
gitRemote = await git(["remote", "get-url", "origin"], workingDir);
if (gitRemote) {
// Parse owner/repo from remote
// Examples:
// git@github.com:owner/repo.git
// https://github.com/owner/repo.git
const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
if (match) {
ownerRepo = match[1];
}
}
}
// Get current branch (for display only, NOT for defaultBranch)
const currentBranch = isGitRepo ? await git(["branch", "--show-current"], workingDir) : null;
// Detect the actual default branch (main/master/next)
const defaultBranch = isGitRepo ? await detectDefaultBranch(workingDir, ownerRepo) : null;
// Check for tmux (direct invocation more portable than 'which')
const hasTmux = (await execSilent("tmux", ["-V"])) !== null;
// Check for gh CLI (direct invocation more portable than 'which')
const hasGh = (await execSilent("gh", ["--version"])) !== null;
// Check gh auth status (rely on exit code, not output string)
let ghAuthed = false;
if (hasGh) {
const authStatus = await gh(["auth", "status"]);
// gh() returns null on error, non-null on success
ghAuthed = authStatus !== null;
}
// Check for API keys in environment
const hasLinearKey = !!process.env["LINEAR_API_KEY"];
const hasSlackWebhook = !!process.env["SLACK_WEBHOOK_URL"];
return {
isGitRepo,
gitRemote,
ownerRepo,
currentBranch,
defaultBranch,
hasTmux,
hasGh,
ghAuthed,
hasLinearKey,
hasSlackWebhook,
};
}
export function registerInit(program: Command): void {
program
.command("init")
.description("Interactive setup wizard — creates agent-orchestrator.yaml")
.option("-o, --output <path>", "Output file path", "agent-orchestrator.yaml")
.option("--auto", "Auto-generate config with sensible defaults (no prompts)")
.option(
"--smart",
"Analyze project and generate custom rules (requires --auto, uses AI if available)",
)
.action(async (opts: { output: string; auto?: boolean; smart?: boolean }) => {
const outputPath = resolve(opts.output);
if (existsSync(outputPath)) {
console.log(chalk.yellow(`Config already exists: ${outputPath}`));
console.log("Delete it first or specify a different path with --output.");
process.exit(1);
}
// Validate --smart requires --auto
if (opts.smart && !opts.auto) {
console.error(chalk.red("Error: --smart requires --auto"));
console.log(chalk.dim("Use: ao init --auto --smart"));
process.exit(1);
}
// Handle --auto mode
if (opts.auto) {
await handleAutoMode(outputPath, opts.smart || false);
return;
}
console.log(chalk.bold.cyan("\n Agent Orchestrator — Setup Wizard\n"));
console.log(chalk.dim(" Detecting environment...\n"));
const workingDir = cwd();
const env = await detectEnvironment(workingDir);
// Show detection results
if (env.isGitRepo) {
console.log(chalk.green(" ✓ Git repository detected"));
if (env.ownerRepo) {
console.log(chalk.dim(` Remote: ${env.ownerRepo}`));
}
if (env.currentBranch) {
console.log(chalk.dim(` Branch: ${env.currentBranch}`));
}
} else {
console.log(chalk.dim(" ○ Not in a git repository"));
}
if (env.hasTmux) {
console.log(chalk.green(" ✓ tmux available"));
} else {
console.log(chalk.yellow(" ⚠ tmux not found"));
console.log(chalk.dim(" Install with: brew install tmux"));
}
if (env.hasGh) {
if (env.ghAuthed) {
console.log(chalk.green(" ✓ GitHub CLI authenticated"));
} else {
console.log(chalk.yellow(" ⚠ GitHub CLI not authenticated"));
console.log(chalk.dim(" Run: gh auth login"));
}
} else {
console.log(chalk.yellow(" ⚠ GitHub CLI not found"));
console.log(chalk.dim(" Install with: brew install gh"));
}
if (env.hasLinearKey) {
console.log(chalk.green(" ✓ LINEAR_API_KEY detected"));
}
if (env.hasSlackWebhook) {
console.log(chalk.green(" ✓ SLACK_WEBHOOK_URL detected"));
}
console.log();
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
try {
// Basic config
console.log(chalk.bold(" Configuration\n"));
const dataDir = await prompt(
rl,
"Data directory (session metadata)",
"~/.agent-orchestrator",
);
const worktreeDir = await prompt(rl, "Worktree directory", "~/.worktrees");
const portStr = await prompt(rl, "Dashboard port", "3000");
const port = parseInt(portStr, 10);
if (isNaN(port) || port < 1 || port > 65535) {
console.error(chalk.red("\nInvalid port number. Must be 1-65535."));
rl.close();
process.exit(1);
}
// Default plugins
console.log(chalk.bold("\n Default Plugins\n"));
const runtime = await prompt(rl, "Runtime (tmux, process)", "tmux");
const agent = await prompt(rl, "Agent (claude-code, codex, aider)", "claude-code");
const workspace = await prompt(rl, "Workspace (worktree, clone)", "worktree");
const notifiersStr = await prompt(
rl,
"Notifiers (comma-separated: desktop, slack)",
"desktop",
);
const notifiers = notifiersStr.split(",").map((s) => s.trim());
// First project
console.log(chalk.bold("\n First Project\n"));
const defaultProjectId = env.isGitRepo ? basename(workingDir) : "";
const projectId = await prompt(
rl,
"Project ID (short name, e.g. my-app)",
defaultProjectId,
);
const config: Record<string, unknown> = {
dataDir,
worktreeDir,
port,
defaults: { runtime, agent, workspace, notifiers },
projects: {} as Record<string, unknown>,
};
let projectPath = "";
if (projectId) {
const repo = await prompt(rl, "GitHub repo (owner/repo)", env.ownerRepo || "");
projectPath = await prompt(
rl,
"Local path to repo",
env.isGitRepo ? workingDir : `~/${projectId}`,
);
const defaultBranch = await prompt(rl, "Default branch", env.defaultBranch || "main");
// Ask about tracker
console.log(chalk.bold("\n Issue Tracker\n"));
if (env.hasLinearKey) {
console.log(chalk.dim(" (LINEAR_API_KEY detected)\n"));
}
const tracker = await prompt(
rl,
"Tracker (github, linear, none)",
env.hasLinearKey ? "linear" : "github",
);
const projectConfig: Record<string, unknown> = {
repo,
path: projectPath,
defaultBranch,
};
if (tracker === "linear") {
if (!env.hasLinearKey) {
console.log(chalk.yellow("\nWarning: LINEAR_API_KEY not found in environment"));
console.log(chalk.dim("Set it in your shell profile or .env file"));
console.log(chalk.dim("Get your key at: https://linear.app/settings/api\n"));
}
const teamId = await prompt(rl, "Linear team ID (find at linear.app/settings/api)", "");
if (teamId) {
projectConfig.tracker = { plugin: "linear", teamId };
}
} else if (tracker === "none") {
// Don't add tracker config
} else {
// Default to github (no explicit config needed)
}
(config.projects as Record<string, unknown>)[projectId] = projectConfig;
}
const yamlContent = yamlStringify(config, { indent: 2 });
writeFileSync(outputPath, yamlContent);
// Validation checks
console.log(chalk.bold("\n Validating Setup...\n"));
const checks = [
{ name: "Git", pass: (await execSilent("git", ["--version"])) !== null },
{ name: "tmux", pass: env.hasTmux },
{ name: "GitHub CLI", pass: env.hasGh },
...(projectPath
? [
{
name: "Repo path exists",
pass: existsSync(projectPath.replace(/^~/, process.env.HOME || "")),
},
]
: []),
];
for (const { name, pass } of checks) {
if (pass) {
console.log(chalk.green(`${name}`));
} else {
console.log(chalk.yellow(`${name} not found`));
}
}
// Success message and next steps
console.log(chalk.green(`\n✓ Config written to ${outputPath}\n`));
console.log(chalk.bold("Next steps:\n"));
console.log(" 1. Review and edit the config:");
console.log(chalk.cyan(` nano ${outputPath}\n`));
if (projectId) {
console.log(" 2. Spawn your first agent:");
console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`));
} else {
console.log(" 2. Add a project to the config:");
console.log(chalk.cyan(` nano ${outputPath}\n`));
}
console.log(" 3. Monitor progress:");
console.log(chalk.cyan(" ao status\n"));
console.log(" 4. Open dashboard:");
console.log(chalk.cyan(" ao start\n"));
console.log(chalk.dim("See SETUP.md for detailed configuration options.\n"));
if (!env.hasTmux) {
console.log(chalk.yellow("Note: tmux is required for the default runtime."));
console.log(chalk.dim("Install with: brew install tmux\n"));
}
if (!env.ghAuthed && env.hasGh) {
console.log(chalk.yellow("Note: Authenticate GitHub CLI for full functionality."));
console.log(chalk.dim("Run: gh auth login\n"));
}
} finally {
rl.close();
}
});
}
async function handleAutoMode(outputPath: string, smart: boolean): Promise<void> {
const workingDir = cwd();
console.log(chalk.bold.cyan("\n Agent Orchestrator — Auto Setup\n"));
if (smart) {
console.log(chalk.dim(" 🤖 Analyzing your project...\n"));
} else {
console.log(chalk.dim(" 🚀 Auto-generating config with smart defaults...\n"));
}
// Detect environment
const env = await detectEnvironment(workingDir);
// Detect project type
const projectType = detectProjectType(workingDir);
// Show detection results
if (env.isGitRepo) {
console.log(chalk.green(" ✓ Git repository detected"));
if (env.ownerRepo) {
console.log(chalk.dim(` Remote: ${env.ownerRepo}`));
}
if (env.currentBranch) {
console.log(chalk.dim(` Branch: ${env.currentBranch}`));
}
}
if (projectType.languages.length > 0 || projectType.frameworks.length > 0) {
console.log(chalk.green(" ✓ Project type detected"));
const formattedType = formatProjectTypeForDisplay(projectType);
formattedType.split("\n").forEach((line) => {
console.log(chalk.dim(` ${line}`));
});
}
console.log();
// Generate agent rules
if (smart) {
// TODO: Implement AI-powered rule generation in future PR
console.log(chalk.yellow(" ⚠ AI-powered rule generation not yet implemented"));
console.log(chalk.dim(" Using template-based rules for now...\n"));
}
const agentRules = generateRulesFromTemplates(projectType);
// Build config with smart defaults
const projectId = env.isGitRepo ? basename(workingDir) : "my-project";
const repo = env.ownerRepo || "owner/repo";
const path = env.isGitRepo ? workingDir : `~/${projectId}`;
const defaultBranch = env.defaultBranch || "main";
const config: Record<string, unknown> = {
dataDir: "~/.agent-orchestrator",
worktreeDir: "~/.worktrees",
port: 3000,
defaults: {
runtime: "tmux",
agent: "claude-code",
workspace: "worktree",
notifiers: ["desktop"],
},
projects: {
[projectId]: {
repo,
path,
defaultBranch,
agentRules,
},
},
};
// Write config
const yamlContent = yamlStringify(config, { indent: 2 });
writeFileSync(outputPath, yamlContent);
// Show success message
console.log(chalk.green(`✓ Config written to ${outputPath}\n`));
// Show generated rules
if (agentRules) {
console.log(chalk.bold("Generated agent rules:\n"));
console.log(chalk.dim("---"));
agentRules.split("\n").forEach((line) => {
console.log(chalk.dim(`${line}`));
});
console.log(chalk.dim("---\n"));
}
// Show next steps
console.log(chalk.bold("Next steps:\n"));
console.log(" 1. Review and customize (optional):");
console.log(chalk.cyan(` nano ${outputPath}\n`));
console.log(" 2. Spawn your first agent:");
console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`));
console.log(" 3. Monitor progress:");
console.log(chalk.cyan(" ao status\n"));
// Show warnings
if (!env.hasTmux) {
console.log(chalk.yellow("⚠ tmux not found - install with: brew install tmux"));
}
if (!env.ghAuthed && env.hasGh) {
console.log(chalk.yellow("⚠ GitHub CLI not authenticated - run: gh auth login"));
}
console.log();
}