From f3f81dad847b2eba43ca3fc8a3d481c319474ef1 Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 17 Mar 2026 01:17:33 +0530 Subject: [PATCH] Simplify onboarding: absorb init + add-project into ao start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces onboarding to `npm install -g @composio/ao && ao start`. - Absorb init and add-project logic into `ao start` with auto-config creation, environment detection, and project type detection - Add single-instance tracking via running.json with already-running interactive menu (human) and info+exit (agent) - Add caller context detection (human/orchestrator/agent) via TTY and AO_CALLER_TYPE env var - Add plugin-based agent runtime detection — no hardcoded binary paths, each plugin exports detect() and displayName - Simplify `ao spawn` to just `ao spawn ` with auto-detected project (backward compat: `ao spawn ` still works) - Set AO_CALLER_TYPE, AO_PROJECT_ID, AO_CONFIG_PATH, AO_PORT env vars on all spawned sessions (worker, orchestrator, restore) - Add `ao config-help` subcommand for config schema reference - Deprecate `ao init` to thin wrapper, fully remove `ao add-project` - Register/unregister in running.json on start/stop Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/commands/add-project.ts | 148 ------ packages/cli/src/commands/init.ts | 483 +----------------- packages/cli/src/commands/spawn.ts | 97 +++- packages/cli/src/commands/start.ts | 426 ++++++++++++++- packages/cli/src/index.ts | 10 +- packages/cli/src/lib/caller-context.ts | 45 ++ packages/cli/src/lib/config-instruction.ts | 128 +++++ packages/cli/src/lib/detect-agent.ts | 92 ++++ packages/cli/src/lib/detect-env.ts | 69 +++ packages/cli/src/lib/running-state.ts | 87 ++++ packages/core/src/index.ts | 1 + packages/core/src/orchestrator-prompt.ts | 10 +- packages/core/src/session-manager.ts | 12 + packages/core/src/types.ts | 6 + packages/plugins/agent-aider/src/index.ts | 12 +- .../plugins/agent-claude-code/src/index.ts | 12 +- packages/plugins/agent-codex/src/index.ts | 12 +- packages/plugins/agent-opencode/src/index.ts | 12 +- 18 files changed, 1008 insertions(+), 654 deletions(-) delete mode 100644 packages/cli/src/commands/add-project.ts create mode 100644 packages/cli/src/lib/caller-context.ts create mode 100644 packages/cli/src/lib/config-instruction.ts create mode 100644 packages/cli/src/lib/detect-agent.ts create mode 100644 packages/cli/src/lib/detect-env.ts create mode 100644 packages/cli/src/lib/running-state.ts diff --git a/packages/cli/src/commands/add-project.ts b/packages/cli/src/commands/add-project.ts deleted file mode 100644 index 92ea77b6e..000000000 --- a/packages/cli/src/commands/add-project.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve, basename } from "node:path"; -import { parse as yamlParse, stringify as yamlStringify } from "yaml"; -import chalk from "chalk"; -import type { Command } from "commander"; -import { generateSessionPrefix, findConfigFile } from "@composio/ao-core"; -import { git, execSilent } from "../lib/shell.js"; -import { detectDefaultBranch } from "../lib/git-utils.js"; -import { - detectProjectType, - generateRulesFromTemplates, - formatProjectTypeForDisplay, -} from "../lib/project-detection.js"; - -export function registerAddProject(program: Command): void { - program - .command("add-project") - .description("Add a project to the existing agent-orchestrator.yaml config") - .argument("", "Path to the project repository") - .option("--no-rules", "Skip generating agent rules") - .option("-c, --config ", "Path to agent-orchestrator.yaml (auto-detected if omitted)") - .action(async (projectPath: string, opts: { rules: boolean; config?: string }) => { - // Resolve the project path - const resolvedPath = resolve(projectPath.replace(/^~/, process.env.HOME || "")); - - // Find existing config - const configPath = opts.config ? resolve(opts.config) : findConfigFile(); - if (!configPath) { - console.error(chalk.red("No agent-orchestrator.yaml found.")); - console.log(chalk.dim("Run `ao init` first to create a config, then add projects to it.")); - process.exit(1); - } - - console.log(chalk.bold.cyan("\n Agent Orchestrator — Add Project\n")); - - // Load and parse existing config - const rawYaml = readFileSync(configPath, "utf-8"); - const config = yamlParse(rawYaml); - - if (!config.projects) { - config.projects = {}; - } - - // Derive project ID from directory name - const projectId = basename(resolvedPath); - - // Check if project already exists - if (config.projects[projectId]) { - console.error(chalk.red(`Project "${projectId}" already exists in ${configPath}`)); - process.exit(1); - } - - // Detect git info - console.log(chalk.dim(" Detecting project info...\n")); - - const isGitRepo = (await git(["rev-parse", "--git-dir"], resolvedPath)) !== null; - if (!isGitRepo) { - console.error(chalk.red(`"${resolvedPath}" is not a git repository.`)); - process.exit(1); - } - - // Get remote - let ownerRepo: string | null = null; - const gitRemote = await git(["remote", "get-url", "origin"], resolvedPath); - if (gitRemote) { - const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/); - if (match) ownerRepo = match[1]; - } - - // Detect default branch - const defaultBranch = await detectDefaultBranch(resolvedPath, ownerRepo); - - // Generate session prefix and ensure uniqueness - let prefix = generateSessionPrefix(projectId); - const existingPrefixes = new Set( - Object.values(config.projects as Record>).map( - (p) => - (p.sessionPrefix as string) || generateSessionPrefix(basename(p.path as string)), - ), - ); - - if (existingPrefixes.has(prefix)) { - // Append a number to make it unique - let i = 2; - while (existingPrefixes.has(`${prefix}${i}`)) i++; - prefix = `${prefix}${i}`; - } - - // Detect project type and generate rules - const projectType = detectProjectType(resolvedPath); - - // Show what was detected - console.log(chalk.green(` ✓ Git repository`)); - if (ownerRepo) { - console.log(chalk.dim(` Remote: ${ownerRepo}`)); - } else { - console.log(chalk.yellow(` ⚠ Could not detect GitHub remote`)); - } - console.log(chalk.dim(` Default branch: ${defaultBranch}`)); - console.log(chalk.dim(` Session prefix: ${prefix}`)); - - 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(); - - // Build project config - const projectConfig: Record = { - repo: ownerRepo || "owner/repo", - path: resolvedPath, - defaultBranch, - sessionPrefix: prefix, - }; - - if (opts.rules) { - const agentRules = generateRulesFromTemplates(projectType); - if (agentRules) { - projectConfig.agentRules = agentRules; - } - } - - // Add to config and write - config.projects[projectId] = projectConfig; - const updatedYaml = yamlStringify(config, { indent: 2 }); - writeFileSync(configPath, updatedYaml); - - console.log(chalk.green(`✓ Added "${projectId}" to ${configPath}\n`)); - - if (!ownerRepo) { - console.log(chalk.yellow("⚠ Could not detect GitHub remote.")); - console.log(chalk.dim(` Update the 'repo' field in the config before spawning agents.\n`)); - } - - console.log(chalk.bold("Next steps:\n")); - console.log(chalk.dim(` Run these from the directory where ${configPath} lives:\n`)); - console.log(` 1. Start (or restart) the orchestrator:`); - console.log(chalk.cyan(` ao start\n`)); - console.log(` 2. Spawn an agent for this project:`); - console.log(chalk.cyan(` ao spawn ${projectId} \n`)); - console.log(` Want to add another project?`); - console.log(chalk.cyan(` ao add-project ~/path/to/repo\n`)); - }); -} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 798a7e663..d4ddf77dc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -1,481 +1,18 @@ -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 { generateSessionPrefix } from "@composio/ao-core"; -import { git, gh, execSilent } from "../lib/shell.js"; -import { detectDefaultBranch } from "../lib/git-utils.js"; -import { findFreePort, MAX_PORT_SCAN } from "../lib/web-dir.js"; -import { - detectProjectType, - generateRulesFromTemplates, - formatProjectTypeForDisplay, -} from "../lib/project-detection.js"; - -const DEFAULT_PORT = 3000; - -async function prompt( - rl: ReturnType, - question: string, - defaultValue?: string, -): Promise { - 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; -} - -// detectDefaultBranch is shared — see packages/cli/src/lib/git-utils.ts - -async function detectEnvironment(workingDir: string): Promise { - // 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("Auto-detect project and create agent-orchestrator.yaml") - .option("-o, --output ", "Output file path", "agent-orchestrator.yaml") - .option("-i, --interactive", "Run the full interactive setup wizard instead of auto-detection") - .action(async (opts: { output: string; interactive?: 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); - } - - // Default: auto mode. Use --interactive for the wizard. - if (!opts.interactive) { - await handleAutoMode(outputPath, 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 freePort = await findFreePort(DEFAULT_PORT); - if (freePort === null) { - console.log( - chalk.yellow( - `\n⚠ No free port found in range ${DEFAULT_PORT}–${DEFAULT_PORT + MAX_PORT_SCAN - 1}.`, - ), - ); - console.log(chalk.dim(" Please specify a port manually.\n")); - } else if (freePort !== DEFAULT_PORT) { - console.log( - chalk.yellow(`\n⚠ Port ${DEFAULT_PORT} is busy — suggesting ${freePort} instead.`), - ); - console.log(chalk.dim(" Press Enter to accept, or type a different port.\n")); - } - const portStr = await prompt(rl, "Dashboard port", String(freePort ?? DEFAULT_PORT)); - 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, opencode)", - "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 = { - dataDir, - worktreeDir, - port, - defaults: { runtime, agent, workspace, notifiers }, - projects: {} as Record, - }; - - 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 = { - name: projectId, - sessionPrefix: generateSessionPrefix(projectId), - 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)[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(chalk.dim(` Run the following commands from this directory (${workingDir}):\n`)); - console.log(" 1. Start orchestrator + dashboard:"); - console.log(chalk.cyan(" ao start\n")); - - if (projectId) { - console.log(" 2. Spawn agent sessions:"); - console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); - console.log(" Want to add more projects?"); - console.log(chalk.cyan(" ao add-project ~/path/to/another-repo\n")); - } else { - console.log(" 2. Add a project to the config:"); - console.log(chalk.cyan(` ao add-project ~/path/to/your-repo\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(); - } + .description("[deprecated] Use 'ao start' instead — it auto-creates config on first run") + .action(async () => { + console.log( + chalk.yellow( + "⚠ 'ao init' is deprecated. Use 'ao start' instead.\n" + + " 'ao start' auto-detects your project and creates the config.\n", + ), + ); + const { createConfigOnly } = await import("./start.js"); + await createConfigOnly(); }); } - -async function handleAutoMode(outputPath: string, _smart: boolean): Promise { - const workingDir = cwd(); - - console.log(chalk.bold.cyan("\n Agent Orchestrator — Auto Setup\n")); - console.log(chalk.dim(" Detecting project and generating config...\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(); - - const agentRules = generateRulesFromTemplates(projectType); - - // Build config with smart defaults - const projectId = env.isGitRepo ? basename(workingDir) : "my-project"; - const repo = env.ownerRepo || "owner/repo"; - const hasPlaceholderRepo = repo === "owner/repo"; - const path = env.isGitRepo ? workingDir : `~/${projectId}`; - const defaultBranch = env.defaultBranch || "main"; - - const port = await findFreePort(DEFAULT_PORT); - if (port === null) { - console.log( - chalk.yellow( - ` ⚠ No free port found in range ${DEFAULT_PORT}–${DEFAULT_PORT + MAX_PORT_SCAN - 1}.`, - ), - ); - console.log(chalk.dim(" Set the port manually in the config before running ao start.\n")); - } else if (port !== DEFAULT_PORT) { - console.log(chalk.yellow(` ⚠ Port ${DEFAULT_PORT} is busy — using ${port} instead.`)); - } - const config: Record = { - dataDir: "~/.agent-orchestrator", - worktreeDir: "~/.worktrees", - port: port ?? DEFAULT_PORT, - defaults: { - runtime: "tmux", - agent: "claude-code", - workspace: "worktree", - notifiers: ["desktop"], - }, - projects: { - [projectId]: { - name: projectId, - sessionPrefix: generateSessionPrefix(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`)); - - // Warn if placeholder repo value is used - if (hasPlaceholderRepo) { - console.log(chalk.yellow("⚠ Warning: Could not detect GitHub repository")); - console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents\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(chalk.dim(` Run the following commands from this directory (${workingDir}):\n`)); - - if (hasPlaceholderRepo) { - console.log(" 1. Edit config and update 'repo' field:"); - console.log(chalk.cyan(` nano ${outputPath}\n`)); - console.log(" 2. Start orchestrator + dashboard:"); - console.log(chalk.cyan(" ao start\n")); - console.log(" 3. Spawn agent sessions:"); - console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); - } else { - console.log(" 1. Start orchestrator + dashboard:"); - console.log(chalk.cyan(" ao start\n")); - console.log(" 2. Spawn agent sessions:"); - console.log(chalk.cyan(` ao spawn ${projectId} ISSUE-123\n`)); - console.log(" Want to add more projects?"); - console.log(chalk.cyan(" ao add-project ~/path/to/another-repo\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(); -} diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index a02d290eb..2e6436d70 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -1,6 +1,7 @@ import chalk from "chalk"; import ora from "ora"; import type { Command } from "commander"; +import { resolve } from "node:path"; import { loadConfig, decompose, @@ -18,6 +19,41 @@ import { getSessionManager } from "../lib/create-session-manager.js"; import { ensureLifecycleWorker } from "../lib/lifecycle-service.js"; import { preflight } from "../lib/preflight.js"; +/** + * Auto-detect the project ID from the config. + * - If only one project exists, use it. + * - If multiple projects exist, match cwd against project paths. + * - Falls back to AO_PROJECT_ID env var (set when called from an agent session). + */ +function autoDetectProject(config: OrchestratorConfig): string { + const projectIds = Object.keys(config.projects); + if (projectIds.length === 0) { + throw new Error("No projects configured. Run 'ao start' first."); + } + if (projectIds.length === 1) { + return projectIds[0]; + } + + // Try AO_PROJECT_ID env var (set by AO when spawning agent sessions) + const envProject = process.env.AO_PROJECT_ID; + if (envProject && config.projects[envProject]) { + return envProject; + } + + // Try matching cwd to a project path + const cwd = resolve(process.cwd()); + for (const [id, project] of Object.entries(config.projects)) { + if (project.path && resolve(project.path) === cwd) { + return id; + } + } + + throw new Error( + `Multiple projects configured. Specify one: ${projectIds.join(", ")}\n` + + `Or run from within a project directory.`, + ); +} + interface SpawnClaimOptions { claimPr?: string; assignOnGithub?: boolean; @@ -122,8 +158,8 @@ export function registerSpawn(program: Command): void { program .command("spawn") .description("Spawn a single agent session") - .argument("", "Project ID from config") - .argument("[issue]", "Issue identifier (e.g. INT-1234, #42) - must exist in tracker") + .argument("[first]", "Issue identifier, or project ID when two args given") + .argument("[second]", "Issue identifier when first arg is project ID") .option("--open", "Open session in terminal tab") .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-pr ", "Immediately claim an existing PR for the spawned session") @@ -132,8 +168,8 @@ export function registerSpawn(program: Command): void { .option("--max-depth ", "Max decomposition depth (default: 3)") .action( async ( - projectId: string, - issueId: string | undefined, + first: string | undefined, + second: string | undefined, opts: { open?: boolean; agent?: string; @@ -144,6 +180,36 @@ export function registerSpawn(program: Command): void { }, ) => { const config = loadConfig(); + let projectId: string; + let issueId: string | undefined; + + if (first && second) { + // Two args: ao spawn (backward compat) + projectId = first; + issueId = second; + } else if (first && config.projects[first]) { + // Single arg that matches a project ID: treat as project, no issue + projectId = first; + issueId = undefined; + } else if (first) { + // Single arg that's not a project ID: treat as issue, auto-detect project + issueId = first; + try { + projectId = autoDetectProject(config); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + } else { + // No args: auto-detect project, no issue + try { + projectId = autoDetectProject(config); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + } + if (!config.projects[projectId]) { console.error( chalk.red( @@ -232,11 +298,28 @@ export function registerBatchSpawn(program: Command): void { program .command("batch-spawn") .description("Spawn sessions for multiple issues with duplicate detection") - .argument("", "Project ID from config") - .argument("", "Issue identifiers") + .argument("", "Issue identifiers (optionally prefixed with project ID)") .option("--open", "Open sessions in terminal tabs") - .action(async (projectId: string, issues: string[], opts: { open?: boolean }) => { + .action(async (args: string[], opts: { open?: boolean }) => { const config = loadConfig(); + let projectId: string; + let issues: string[]; + + // If first arg matches a project ID, treat it as project + remaining as issues + // Otherwise all args are issues and project is auto-detected + if (args.length >= 2 && config.projects[args[0]]) { + projectId = args[0]; + issues = args.slice(1); + } else { + issues = args; + try { + projectId = autoDetectProject(config); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } + } + if (!config.projects[projectId]) { console.error( chalk.red( diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index ea98cbb87..38c010970 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -11,13 +11,16 @@ import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { resolve, basename } from "node:path"; +import { cwd } from "node:process"; import chalk from "chalk"; import ora from "ora"; import type { Command } from "commander"; import { loadConfig, generateOrchestratorPrompt, + generateSessionPrefix, + findConfigFile, isRepoUrl, parseRepoUrl, resolveCloneTarget, @@ -29,6 +32,7 @@ import { type ProjectConfig, type ParsedRepoUrl, } from "@composio/ao-core"; +import { stringify as yamlStringify } from "yaml"; import { exec, execSilent } from "../lib/shell.js"; import { getSessionManager } from "../lib/create-session-manager.js"; import { ensureLifecycleWorker, stopLifecycleWorker } from "../lib/lifecycle-service.js"; @@ -42,6 +46,15 @@ import { } from "../lib/web-dir.js"; import { cleanNextCache } from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; +import { register, unregister, isAlreadyRunning, getRunning } from "../lib/running-state.js"; +import { getCallerType, isHumanCaller } from "../lib/caller-context.js"; +import { detectEnvironment } from "../lib/detect-env.js"; +import { detectAgentRuntime } from "../lib/detect-agent.js"; +import { + detectProjectType, + generateRulesFromTemplates, + formatProjectTypeForDisplay, +} from "../lib/project-detection.js"; const DEFAULT_PORT = 3000; @@ -220,6 +233,213 @@ async function handleUrlStart( return { config: loadConfig(configPath), parsed, autoGenerated: true }; } +/** + * Auto-create agent-orchestrator.yaml when no config exists. + * Detects environment, project type, and generates config with smart defaults. + * Returns the loaded config. + */ +async function autoCreateConfig(workingDir: string): Promise { + console.log(chalk.bold.cyan("\n Agent Orchestrator — First Run Setup\n")); + console.log(chalk.dim(" Detecting project and generating config...\n")); + + const env = await detectEnvironment(workingDir); + 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(); + + 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"; + + // Detect available agent runtimes via plugin registry + const agent = await detectAgentRuntime(); + console.log(chalk.green(` ✓ Agent runtime: ${agent}`)); + + const port = await findFreePort(DEFAULT_PORT); + if (port !== null && port !== DEFAULT_PORT) { + console.log(chalk.yellow(` ⚠ Port ${DEFAULT_PORT} is busy — using ${port} instead.`)); + } + + const config: Record = { + port: port ?? DEFAULT_PORT, + defaults: { + runtime: "tmux", + agent, + workspace: "worktree", + notifiers: ["desktop"], + }, + projects: { + [projectId]: { + name: projectId, + sessionPrefix: generateSessionPrefix(projectId), + repo, + path, + defaultBranch, + ...(agentRules ? { agentRules } : {}), + }, + }, + }; + + const outputPath = resolve(workingDir, "agent-orchestrator.yaml"); + const yamlContent = yamlStringify(config, { indent: 2 }); + writeFileSync(outputPath, yamlContent); + + console.log(chalk.green(`✓ Config created: ${outputPath}\n`)); + + if (repo === "owner/repo") { + console.log(chalk.yellow("⚠ Could not detect GitHub remote.")); + console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents.\n")); + } + + 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")); + } + + return loadConfig(outputPath); +} + +/** + * Add a new project to an existing config. + * Detects git info, project type, generates rules, appends to config YAML. + * Returns the project ID that was added. + */ +async function addProjectToConfig( + config: OrchestratorConfig, + projectPath: string, +): Promise { + const { readFileSync } = await import("node:fs"); + const { parse: yamlParse } = await import("yaml"); + const { git } = await import("../lib/shell.js"); + const { detectDefaultBranch } = await import("../lib/git-utils.js"); + + const resolvedPath = resolve(projectPath.replace(/^~/, process.env["HOME"] || "")); + const projectId = basename(resolvedPath); + + console.log(chalk.dim(`\n Adding project "${projectId}"...\n`)); + + // Validate git repo + const isGitRepo = (await git(["rev-parse", "--git-dir"], resolvedPath)) !== null; + if (!isGitRepo) { + throw new Error(`"${resolvedPath}" is not a git repository.`); + } + + // Detect git remote + let ownerRepo: string | null = null; + const gitRemote = await git(["remote", "get-url", "origin"], resolvedPath); + if (gitRemote) { + const match = gitRemote.match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/); + if (match) ownerRepo = match[1]; + } + + const defaultBranch = await detectDefaultBranch(resolvedPath, ownerRepo); + + // Generate unique session prefix + let prefix = generateSessionPrefix(projectId); + const existingPrefixes = new Set( + Object.values(config.projects).map( + (p) => p.sessionPrefix || generateSessionPrefix(basename(p.path)), + ), + ); + if (existingPrefixes.has(prefix)) { + let i = 2; + while (existingPrefixes.has(`${prefix}${i}`)) i++; + prefix = `${prefix}${i}`; + } + + // Detect project type and generate rules + const projectType = detectProjectType(resolvedPath); + const agentRules = generateRulesFromTemplates(projectType); + + // Show what was detected + console.log(chalk.green(` ✓ Git repository`)); + if (ownerRepo) { + console.log(chalk.dim(` Remote: ${ownerRepo}`)); + } + console.log(chalk.dim(` Default branch: ${defaultBranch}`)); + console.log(chalk.dim(` Session prefix: ${prefix}`)); + + 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}`)); + }); + } + + // Load raw YAML, append project, rewrite + const rawYaml = readFileSync(config.configPath, "utf-8"); + const rawConfig = yamlParse(rawYaml); + if (!rawConfig.projects) rawConfig.projects = {}; + + rawConfig.projects[projectId] = { + name: projectId, + repo: ownerRepo || "owner/repo", + path: resolvedPath, + defaultBranch, + sessionPrefix: prefix, + ...(agentRules ? { agentRules } : {}), + }; + + writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 })); + console.log(chalk.green(`\n✓ Added "${projectId}" to ${config.configPath}\n`)); + + if (!ownerRepo) { + console.log(chalk.yellow("⚠ Could not detect GitHub remote.")); + console.log(chalk.dim(" Update the 'repo' field in the config before spawning agents.\n")); + } + + return projectId; +} + +/** + * Create config without starting dashboard/orchestrator. + * Used by deprecated `ao init` wrapper. + */ +export async function createConfigOnly(): Promise { + await autoCreateConfig(cwd()); +} + +/** + * Add a project without starting dashboard/orchestrator. + * Used by deprecated `ao add-project` wrapper. + */ +export async function addProjectOnly(projectPath: string): Promise { + const configPath = findConfigFile(); + if (!configPath) { + console.error(chalk.red("No agent-orchestrator.yaml found.")); + console.log(chalk.dim("Run `ao start` first to create a config.\n")); + process.exit(1); + } + const config = loadConfig(configPath); + await addProjectToConfig(config, projectPath); +} + /** * Start dashboard server in the background. * Returns the child process handle for cleanup. @@ -456,7 +676,7 @@ export function registerStart(program: Command): void { program .command("start [project]") .description( - "Start orchestrator agent and dashboard for a project (or pass a repo URL to onboard)", + "Start orchestrator agent and dashboard (auto-creates config on first run, adds projects by path/URL)", ) .option("--no-dashboard", "Skip starting the dashboard server") .option("--no-orchestrator", "Skip starting the orchestrator agent") @@ -474,27 +694,153 @@ export function registerStart(program: Command): void { let config: OrchestratorConfig; let projectId: string; let project: ProjectConfig; - // Detect URL argument — run onboarding flow + if (projectArg && isRepoUrl(projectArg)) { + // ── URL argument: clone + auto-config + start ── console.log(chalk.bold.cyan("\n Agent Orchestrator — Quick Start\n")); const result = await handleUrlStart(projectArg); config = result.config; ({ projectId, project } = resolveProjectByRepo(config, result.parsed)); + } else if (projectArg && isLocalPath(projectArg)) { + // ── Path argument: add project if new, then start ── + const resolvedPath = resolve(projectArg.replace(/^~/, process.env["HOME"] || "")); + + // Try to load existing config + let configPath: string | undefined; + try { + configPath = findConfigFile() ?? undefined; + } catch { + // No config found — create one first + } + + if (!configPath) { + // No config anywhere — auto-create in cwd, then add the path as project + config = await autoCreateConfig(cwd()); + // If the path is different from cwd, add it as a second project + if (resolve(cwd()) !== resolvedPath) { + const addedId = await addProjectToConfig(config, resolvedPath); + config = loadConfig(config.configPath); + projectId = addedId; + project = config.projects[projectId]; + } else { + ({ projectId, project } = resolveProject(config)); + } + } else { + config = loadConfig(configPath); + + // Check if project is already in config (match by path) + const existingEntry = Object.entries(config.projects).find( + ([, p]) => resolve(p.path.replace(/^~/, process.env["HOME"] || "")) === resolvedPath, + ); + + if (existingEntry) { + // Already in config — just start it + projectId = existingEntry[0]; + project = existingEntry[1]; + } else { + // New project — add it to config + const addedId = await addProjectToConfig(config, resolvedPath); + config = loadConfig(config.configPath); + projectId = addedId; + project = config.projects[projectId]; + } + } } else { - // Normal flow — load existing config - config = loadConfig(); + // ── No arg or project ID: load config or auto-create ── + let loadedConfig: OrchestratorConfig | null = null; + try { + loadedConfig = loadConfig(); + } catch (err) { + if (err instanceof Error && err.message.includes("No agent-orchestrator.yaml found")) { + // First run — auto-create config + loadedConfig = await autoCreateConfig(cwd()); + } else { + throw err; + } + } + config = loadedConfig; ({ projectId, project } = resolveProject(config, projectArg)); } + // ── Already-running detection (Step 9) ── + const running = isAlreadyRunning(); + if (running) { + if (isHumanCaller()) { + console.log(chalk.cyan(`\nℹ AO is already running.`)); + console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`); + console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`); + console.log(` Projects: ${running.projects.join(", ")}\n`); + + // Interactive menu + const { createInterface } = await import("node:readline/promises"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + console.log(" 1. Open dashboard (keep current)"); + console.log(" 2. Start new orchestrator on this project"); + console.log(" 3. Override — restart everything"); + console.log(" 4. Quit\n"); + const choice = await rl.question(" Choice [1-4]: "); + rl.close(); + + if (choice.trim() === "1") { + const url = `http://localhost:${running.port}`; + const [cmd, args]: [string, string[]] = + process.platform === "win32" + ? ["cmd.exe", ["/c", "start", "", url]] + : [process.platform === "linux" ? "xdg-open" : "open", [url]]; + spawn(cmd, args, { stdio: "ignore" }); + process.exit(0); + } else if (choice.trim() === "2") { + // Generate unique orchestrator: same project, new session + const suffix = Math.random().toString(36).slice(2, 6); + const newId = `${projectId}-${suffix}`; + const newPrefix = generateSessionPrefix(newId); + + const { readFileSync } = await import("node:fs"); + const { parse: yamlParse } = await import("yaml"); + const rawYaml = readFileSync(config.configPath, "utf-8"); + const rawConfig = yamlParse(rawYaml); + rawConfig.projects[newId] = { + ...rawConfig.projects[projectId], + sessionPrefix: newPrefix, + }; + writeFileSync(config.configPath, yamlStringify(rawConfig, { indent: 2 })); + console.log(chalk.green(`\n✓ New orchestrator "${newId}" added to config\n`)); + config = loadConfig(config.configPath); + projectId = newId; + project = config.projects[newId]; + // Continue to startup below + } else if (choice.trim() === "3") { + process.kill(running.pid, "SIGTERM"); + unregister(); + console.log(chalk.yellow("\n Stopped existing instance. Restarting...\n")); + // Continue to startup below + } else { + process.exit(0); + } + } else { + // Agent/non-TTY caller — print info and exit + console.log(`AO is already running.`); + console.log(`Dashboard: http://localhost:${running.port}`); + console.log(`PID: ${running.pid}`); + console.log(`Projects: ${running.projects.join(", ")}`); + console.log(`To restart: ao stop && ao start`); + process.exit(0); + } + } + await runStartup(config, projectId, project, opts); + + // ── Register in running.json (Step 10) ── + register({ + pid: process.pid, + configPath: config.configPath, + port: config.port ?? DEFAULT_PORT, + startedAt: new Date().toISOString(), + projects: Object.keys(config.projects), + }); } catch (err) { if (err instanceof Error) { - if (err.message.includes("No agent-orchestrator.yaml found")) { - console.error(chalk.red("\nNo config found. Run:")); - console.error(chalk.cyan(" ao init\n")); - } else { - console.error(chalk.red("\nError:"), err.message); - } + console.error(chalk.red("\nError:"), err.message); } else { console.error(chalk.red("\nError:"), String(err)); } @@ -504,15 +850,49 @@ export function registerStart(program: Command): void { ); } +/** + * Check if arg looks like a local path (not a project ID). + * Paths contain / or ~ or . at the start. + */ +function isLocalPath(arg: string): boolean { + return arg.startsWith("/") || arg.startsWith("~") || arg.startsWith("./") || arg.startsWith(".."); +} + export function registerStop(program: Command): void { program .command("stop [project]") - .description("Stop orchestrator agent and dashboard for a project") + .description("Stop orchestrator agent and dashboard") .option("--keep-session", "Keep mapped OpenCode session after stopping") .option("--purge-session", "Delete mapped OpenCode session when stopping") + .option("--all", "Stop all running AO instances") .action( - async (projectArg?: string, opts: { keepSession?: boolean; purgeSession?: boolean } = {}) => { + async ( + projectArg?: string, + opts: { keepSession?: boolean; purgeSession?: boolean; all?: boolean } = {}, + ) => { try { + // Check running.json first + const running = getRunning(); + + if (opts.all) { + // --all: kill via running.json if available, then fallback to config + if (running) { + try { + process.kill(running.pid, "SIGTERM"); + } catch { + // Already dead + } + unregister(); + console.log( + chalk.green(`\n✓ Stopped AO on port ${running.port}`), + ); + console.log(chalk.dim(` Projects: ${running.projects.join(", ")}\n`)); + } else { + console.log(chalk.yellow("No running AO instance found in running.json.")); + } + return; + } + const config = loadConfig(); const { projectId: _projectId, project } = resolveProject(config, projectArg); const sessionId = `${project.sessionPrefix}-orchestrator`; @@ -540,10 +920,26 @@ export function registerStop(program: Command): void { console.log(chalk.yellow("Lifecycle worker not running")); } - // Stop dashboard - await stopDashboard(port); + // Stop dashboard — use running.json PID if available, fallback to lsof + if (running) { + try { + process.kill(running.pid, "SIGTERM"); + } catch { + // Already dead + } + unregister(); + console.log(chalk.green("Dashboard stopped (via running.json)")); + } else { + await stopDashboard(port); + } console.log(chalk.bold.green("\n✓ Orchestrator stopped\n")); + console.log( + chalk.dim(` Uptime: since ${running?.startedAt ?? "unknown"}`), + ); + console.log( + chalk.dim(` Projects: ${Object.keys(config.projects).join(", ")}\n`), + ); } catch (err) { if (err instanceof Error) { console.error(chalk.red("\nError:"), err.message); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5de4d1b58..ece44d721 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -14,7 +14,7 @@ import { registerLifecycleWorker } from "./commands/lifecycle-worker.js"; import { registerVerify } from "./commands/verify.js"; import { registerDoctor } from "./commands/doctor.js"; import { registerUpdate } from "./commands/update.js"; -import { registerAddProject } from "./commands/add-project.js"; +import { getConfigInstruction } from "./lib/config-instruction.js"; const program = new Command(); @@ -38,6 +38,12 @@ registerLifecycleWorker(program); registerVerify(program); registerDoctor(program); registerUpdate(program); -registerAddProject(program); + +program + .command("config-help") + .description("Show config schema and guide for creating agent-orchestrator.yaml") + .action(() => { + console.log(getConfigInstruction()); + }); program.parse(); diff --git a/packages/cli/src/lib/caller-context.ts b/packages/cli/src/lib/caller-context.ts new file mode 100644 index 000000000..9821ade6c --- /dev/null +++ b/packages/cli/src/lib/caller-context.ts @@ -0,0 +1,45 @@ +export type CallerType = "human" | "orchestrator" | "agent"; + +export interface CallerContextOpts { + callerType: CallerType; + sessionId?: string; + projectId?: string; + configPath?: string; + port?: number; +} + +/** + * Detect who is calling the CLI. + * - If AO_CALLER_TYPE is set, trust it. + * - Otherwise, if stdout is a TTY, it's a human. + * - Non-TTY defaults to "agent". + */ +export function getCallerType(): CallerType { + const env = process.env["AO_CALLER_TYPE"]; + if (env === "orchestrator" || env === "agent" || env === "human") { + return env; + } + return process.stdout.isTTY ? "human" : "agent"; +} + +/** + * Returns true if the caller is a human (interactive terminal). + */ +export function isHumanCaller(): boolean { + return getCallerType() === "human"; +} + +/** + * Inject AO context environment variables into an env record. + * Used when spawning orchestrator/agent sessions so they know their context. + */ +export function setCallerContext( + env: Record, + opts: CallerContextOpts, +): void { + env["AO_CALLER_TYPE"] = opts.callerType; + if (opts.sessionId) env["AO_SESSION_ID"] = opts.sessionId; + if (opts.projectId) env["AO_PROJECT_ID"] = opts.projectId; + if (opts.configPath) env["AO_CONFIG_PATH"] = opts.configPath; + if (opts.port != null) env["AO_PORT"] = String(opts.port); +} diff --git a/packages/cli/src/lib/config-instruction.ts b/packages/cli/src/lib/config-instruction.ts new file mode 100644 index 000000000..60a1d60f2 --- /dev/null +++ b/packages/cli/src/lib/config-instruction.ts @@ -0,0 +1,128 @@ +/** + * Returns the complete AO config schema as formatted text. + * Used by `ao config-help` and injected into orchestrator system prompts. + */ +export function getConfigInstruction(): string { + return ` +# Agent Orchestrator Config Reference +# File: agent-orchestrator.yaml + +# ── Top-level settings ────────────────────────────────────────────── + +port: 3000 # Dashboard port (default: 3000, auto-finds free port if busy) +terminalPort: 3001 # Terminal WebSocket port (default: 3001) +directTerminalPort: 3003 # Direct terminal WebSocket port (default: 3003) +readyThresholdMs: 300000 # Ms before "ready" session becomes "idle" (default: 5 min) + +# ── Default plugins ───────────────────────────────────────────────── +# These apply to all projects unless overridden per-project. + +defaults: + runtime: tmux # tmux | process + agent: claude-code # claude-code | aider | codex | opencode + workspace: worktree # worktree | clone + notifiers: # List of active notifier plugins + - desktop # desktop | slack | webhook | composio | openclaw + orchestrator: + agent: claude-code # Agent for orchestrator sessions (optional override) + worker: + agent: claude-code # Agent for worker sessions (optional override) + +# ── Projects ──────────────────────────────────────────────────────── +# Each key is a project ID (typically the repo directory name). + +projects: + my-app: + name: My App # Display name + repo: owner/repo # GitHub "owner/repo" format + path: ~/code/my-app # Local path to the repo + defaultBranch: main # main | master | next | develop + sessionPrefix: myapp # Prefix for session names (e.g. myapp-1, myapp-2) + + # ── Per-project plugin overrides (optional) ─────────────────── + runtime: tmux # Override default runtime + agent: claude-code # Override default agent + workspace: worktree # Override default workspace + + # ── Agent configuration (optional) ──────────────────────────── + agentConfig: + permissions: auto # auto | manual — agent permission mode + model: claude-sonnet-4-20250514 + + # ── Agent rules (optional) ──────────────────────────────────── + agentRules: | # Inline rules passed to every agent prompt + Always run tests before committing. + Use conventional commits. + agentRulesFile: .ao-rules # Or point to a file (relative to project path) + orchestratorRules: | # Rules for the orchestrator agent + + # ── Orchestrator session strategy (optional) ────────────────── + # Controls what happens to the orchestrator session on restart. + orchestratorSessionStrategy: reuse + # Options: reuse | delete | ignore | delete-new | ignore-new | kill-previous + + # ── Workspace setup (optional) ──────────────────────────────── + symlinks: # Files/dirs to symlink into workspaces + - .env + - node_modules + postCreate: # Commands to run after workspace creation + - pnpm install + + # ── Issue tracker (optional) ────────────────────────────────── + tracker: + plugin: github # github | linear | gitlab + # Linear-specific: + # teamId: TEAM-123 + # projectId: PROJECT-456 + + # ── SCM configuration (optional, usually auto-detected) ─────── + scm: + plugin: github # github | gitlab + + # ── Task decomposition (optional) ───────────────────────────── + decomposer: + enabled: false # Auto-decompose backlog issues + maxDepth: 3 # Max recursion depth + model: claude-sonnet-4-20250514 + requireApproval: true # Require human approval before executing + + # ── Per-project reaction overrides (optional) ───────────────── + # reactions: + # ci-failure: + # enabled: true + +# ── Notification channels (optional) ──────────────────────────────── + +notifiers: + desktop: + plugin: desktop + slack: + plugin: slack + # Requires SLACK_WEBHOOK_URL env var + webhook: + plugin: webhook + # url: https://example.com/hook + +# ── Notification routing (optional) ───────────────────────────────── +# Route notifications by priority level. + +notificationRouting: + critical: + - desktop + - slack + high: + - desktop + low: + - desktop + +# ── Available plugins ─────────────────────────────────────────────── +# +# Agent: claude-code, aider, codex, opencode +# Runtime: tmux, process +# Workspace: worktree, clone +# SCM: github, gitlab +# Tracker: github, linear, gitlab +# Notifier: desktop, slack, webhook, composio, openclaw +# Terminal: iterm2, web +`.trim(); +} diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts new file mode 100644 index 000000000..a75cf55e9 --- /dev/null +++ b/packages/cli/src/lib/detect-agent.ts @@ -0,0 +1,92 @@ +/** + * Agent runtime detection — discovers available agent runtimes via plugin detect() methods. + * + * No hardcoded binary paths — relies entirely on each plugin's detect() export. + */ + +import type { PluginModule } from "@composio/ao-core"; +import { isHumanCaller } from "./caller-context.js"; + +export interface DetectedAgent { + name: string; + displayName: string; +} + +/** Known agent plugins — package name mapping. */ +const AGENT_PLUGINS: Array<{ name: string; pkg: string }> = [ + { name: "claude-code", pkg: "@composio/ao-plugin-agent-claude-code" }, + { name: "aider", pkg: "@composio/ao-plugin-agent-aider" }, + { name: "codex", pkg: "@composio/ao-plugin-agent-codex" }, + { name: "opencode", pkg: "@composio/ao-plugin-agent-opencode" }, +]; + +/** + * Discover which agent runtimes are available on this system. + * Imports each agent plugin and calls its detect() method. + */ +export async function detectAvailableAgents(): Promise { + const available: DetectedAgent[] = []; + + for (const { name, pkg } of AGENT_PLUGINS) { + try { + const mod = (await import(pkg)) as PluginModule; + if (typeof mod.detect === "function" && mod.detect()) { + available.push({ + name, + displayName: mod.manifest?.displayName ?? name, + }); + } + } catch { + // Plugin not installed or import failed — skip + } + } + + return available; +} + +/** + * Select the agent runtime to use for config generation. + * + * - No agents detected → default to "claude-code" + * - One agent available → auto-select it + * - Multiple agents available + human caller → prompt to pick + * - Multiple agents available + non-human → pick first (claude-code if available) + */ +export async function detectAgentRuntime(): Promise { + const available = await detectAvailableAgents(); + + if (available.length === 0) { + return "claude-code"; + } + + if (available.length === 1) { + return available[0].name; + } + + // Multiple agents available + if (!isHumanCaller()) { + // Non-interactive: prefer claude-code if available, else first + return available.find((a) => a.name === "claude-code")?.name ?? available[0].name; + } + + // Interactive: prompt human to pick using node:readline (no external deps) + const { createInterface } = await import("node:readline/promises"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + console.log("\n Multiple agent runtimes detected:\n"); + available.forEach((a, i) => { + console.log(` ${i + 1}. ${a.displayName} (${a.name})`); + }); + console.log(); + + const answer = await rl.question(` Choose default agent [1-${available.length}]: `); + rl.close(); + + const idx = parseInt(answer.trim(), 10) - 1; + if (idx >= 0 && idx < available.length) { + return available[idx].name; + } + + // Invalid input — default to first + return available[0].name; +} diff --git a/packages/cli/src/lib/detect-env.ts b/packages/cli/src/lib/detect-env.ts new file mode 100644 index 000000000..d7ff1d7d2 --- /dev/null +++ b/packages/cli/src/lib/detect-env.ts @@ -0,0 +1,69 @@ +import { git, gh, execSilent } from "./shell.js"; +import { detectDefaultBranch } from "./git-utils.js"; + +export 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; +} + +export async function detectEnvironment(workingDir: string): Promise { + // 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) { + 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"]); + 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, + }; +} diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts new file mode 100644 index 000000000..43f62a061 --- /dev/null +++ b/packages/cli/src/lib/running-state.ts @@ -0,0 +1,87 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export interface RunningState { + pid: number; + configPath: string; + port: number; + startedAt: string; + projects: string[]; +} + +const STATE_DIR = join(homedir(), ".agent-orchestrator"); +const STATE_FILE = join(STATE_DIR, "running.json"); + +function ensureDir(): void { + mkdirSync(STATE_DIR, { recursive: true }); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function readState(): RunningState | null { + try { + const raw = readFileSync(STATE_FILE, "utf-8"); + const state = JSON.parse(raw) as RunningState; + if (!state || typeof state.pid !== "number") return null; + return state; + } catch { + return null; + } +} + +function writeState(state: RunningState | null): void { + ensureDir(); + if (state === null) { + writeFileSync(STATE_FILE, "null", "utf-8"); + } else { + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), "utf-8"); + } +} + +/** + * Register the current AO instance as running. + * Prunes any stale entry first. + */ +export function register(entry: RunningState): void { + writeState(entry); +} + +/** + * Unregister the running AO instance. + */ +export function unregister(): void { + writeState(null); +} + +/** + * Get the currently running AO instance, if any. + * Auto-prunes stale entries (dead PIDs). + */ +export function getRunning(): RunningState | null { + const state = readState(); + if (!state) return null; + + if (!isProcessAlive(state.pid)) { + // Stale entry — process is dead, clean up + writeState(null); + return null; + } + + return state; +} + +/** + * Check if AO is already running. + * Returns the running state if alive, null otherwise. + */ +export function isAlreadyRunning(): RunningState | null { + return getRunning(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 203efd83e..af7b5519e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -78,6 +78,7 @@ export type { export { generateOrchestratorPrompt } from "./orchestrator-prompt.js"; export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js"; + // Global pause constants and utilities export { GLOBAL_PAUSE_UNTIL_KEY, diff --git a/packages/core/src/orchestrator-prompt.ts b/packages/core/src/orchestrator-prompt.ts index 084df91e7..cf4f4c3e1 100644 --- a/packages/core/src/orchestrator-prompt.ts +++ b/packages/core/src/orchestrator-prompt.ts @@ -54,9 +54,9 @@ Your role is to coordinate and manage worker agent sessions. You do NOT write co ao status # Spawn sessions for issues (GitHub: #123, Linear: INT-1234, etc.) -ao spawn ${projectId} INT-1234 -ao spawn ${projectId} --claim-pr 123 -ao batch-spawn ${projectId} INT-1 INT-2 INT-3 +ao spawn INT-1234 +ao spawn --claim-pr 123 +ao batch-spawn INT-1 INT-2 INT-3 # List sessions ao session ls -p ${projectId} @@ -80,8 +80,8 @@ ao open ${projectId} | Command | Description | |---------|-------------| | \`ao status\` | Show all sessions with PR/CI/review status | -| \`ao spawn [issue] [--claim-pr ]\` | Spawn a worker session, optionally attached to an existing PR | -| \`ao batch-spawn \` | Spawn multiple sessions in parallel | +| \`ao spawn [issue] [--claim-pr ] [-p project]\` | Spawn a worker session, optionally attached to an existing PR | +| \`ao batch-spawn [-p project]\` | Spawn multiple sessions in parallel | | \`ao session ls [-p project]\` | List all sessions (optionally filter by project) | | \`ao session claim-pr [session]\` | Attach an existing PR to a worker session | | \`ao session attach \` | Attach to a session's tmux window | diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index f22514607..21ab8081f 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -1070,6 +1070,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM AO_DATA_DIR: sessionsDir, // Pass sessions directory (not root dataDir) AO_SESSION_NAME: sessionId, // User-facing session name ...(tmuxName && { AO_TMUX_NAME: tmuxName }), // Tmux session name if using new arch + AO_CALLER_TYPE: "agent", + AO_PROJECT_ID: spawnConfig.projectId, + AO_CONFIG_PATH: config.configPath, + ...(config.port != null && { AO_PORT: String(config.port) }), }, }); } catch (err) { @@ -1365,6 +1369,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM AO_DATA_DIR: sessionsDir, AO_SESSION_NAME: sessionId, ...(tmuxName && { AO_TMUX_NAME: tmuxName }), + AO_CALLER_TYPE: "orchestrator", + AO_PROJECT_ID: orchestratorConfig.projectId, + AO_CONFIG_PATH: config.configPath, + ...(config.port != null && { AO_PORT: String(config.port) }), }, }); @@ -2371,6 +2379,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM AO_DATA_DIR: sessionsDir, AO_SESSION_NAME: sessionId, ...(tmuxName && { AO_TMUX_NAME: tmuxName }), + AO_CALLER_TYPE: "agent", + ...(projectId && { AO_PROJECT_ID: projectId }), + AO_CONFIG_PATH: config.configPath, + ...(config.port != null && { AO_PORT: String(config.port) }), }, }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 12c879892..39a279ac6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1113,12 +1113,18 @@ export interface PluginManifest { /** Version */ version: string; + + /** Human-readable display name (e.g. "Claude Code") */ + displayName?: string; } /** What a plugin module must export */ export interface PluginModule { manifest: PluginManifest; create(config?: Record): T; + + /** Optional: detect whether this plugin's runtime/binary is available on the system. */ + detect?(): boolean; } // ============================================================================= diff --git a/packages/plugins/agent-aider/src/index.ts b/packages/plugins/agent-aider/src/index.ts index 9f0e11270..3775db310 100644 --- a/packages/plugins/agent-aider/src/index.ts +++ b/packages/plugins/agent-aider/src/index.ts @@ -70,6 +70,7 @@ export const manifest = { slot: "agent" as const, description: "Agent plugin: Aider", version: "0.1.0", + displayName: "Aider", }; // ============================================================================= @@ -222,4 +223,13 @@ export function create(): Agent { return createAiderAgent(); } -export default { manifest, create } satisfies PluginModule; +export function detect(): boolean { + try { + require("node:child_process").execSync("which aider", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export default { manifest, create, detect } satisfies PluginModule; diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 7b463277f..8a2e44ff4 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -184,6 +184,7 @@ export const manifest = { slot: "agent" as const, description: "Agent plugin: Claude Code CLI", version: "0.1.0", + displayName: "Claude Code", }; // ============================================================================= @@ -840,4 +841,13 @@ export function create(): Agent { return createClaudeCodeAgent(); } -export default { manifest, create } satisfies PluginModule; +export function detect(): boolean { + try { + require("node:child_process").execSync("which claude", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export default { manifest, create, detect } satisfies PluginModule; diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index f9a31c72e..4c9a85bfc 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -69,6 +69,7 @@ export const manifest = { slot: "agent" as const, description: "Agent plugin: OpenAI Codex CLI", version: "0.1.1", + displayName: "OpenAI Codex", }; // ============================================================================= @@ -873,4 +874,13 @@ export type { ApprovalDecision, } from "./app-server-client.js"; -export default { manifest, create } satisfies PluginModule; +export function detect(): boolean { + try { + require("node:child_process").execSync("which codex", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export default { manifest, create, detect } satisfies PluginModule; diff --git a/packages/plugins/agent-opencode/src/index.ts b/packages/plugins/agent-opencode/src/index.ts index 8b97a487b..b362821cb 100644 --- a/packages/plugins/agent-opencode/src/index.ts +++ b/packages/plugins/agent-opencode/src/index.ts @@ -146,6 +146,7 @@ export const manifest = { slot: "agent" as const, description: "Agent plugin: OpenCode", version: "0.1.0", + displayName: "OpenCode", }; // ============================================================================= @@ -358,4 +359,13 @@ export function create(): Agent { return createOpenCodeAgent(); } -export default { manifest, create } satisfies PluginModule; +export function detect(): boolean { + try { + require("node:child_process").execSync("which opencode", { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export default { manifest, create, detect } satisfies PluginModule;