Implementation Plan: AO CLI Onboarding Friction Reduction

Design doc: design-cli-redesign-analysis PR: #463 Branch: feat/onboarding-improvements Date: 2026-03-15
Goal: After this implementation, the entire AO onboarding is 2 steps:
npm install -g @composio/ao → ao start <project-url-or-path>
No init. No add-project. No flags. No port conflicts. Just works.
Important constraints:

Phase 0: Prerequisites

DONE in PR #463 0
npm Publishing & Dashboard Fixes

These are already implemented in PR #463. Do NOT redo them. Verify they work.

Phase 1: New Library Files (create these first)

These are standalone files with no dependencies on each other. Create all three before modifying any command files.

TODO 1
Create packages/cli/src/lib/running-state.ts

~80 lines. Tracks the single running AO instance via ~/.agent-orchestrator/running.json.

Schema

// ~/.agent-orchestrator/running.json
{
  "pid": 12345,
  "configPath": "/Users/me/my-app/agent-orchestrator.yaml",
  "port": 3000,
  "startedAt": "2026-03-15T10:30:00Z",
  "projects": ["my-app", "backend"]
}

Exports

import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

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");

// Register a new running instance. Call on ao start.
export function register(entry: RunningState): void

// Remove the running entry. Call on ao stop.
export function unregister(): void

// Get current running state. Returns null if not running.
// Auto-prunes if PID is dead.
export function getRunning(): RunningState | null

// Check if AO is already running. Returns the state or null.
export function isAlreadyRunning(): RunningState | null

// Internal: check if a PID is alive
function isProcessAlive(pid: number): boolean
// Use: try { process.kill(pid, 0); return true; } catch { return false; }

Key behaviors

TODO 2
Create packages/cli/src/lib/caller-context.ts

~40 lines. Detects who is calling the CLI and adjusts behavior.

export type CallerType = "human" | "orchestrator" | "agent";

// Detect caller type from environment
export function getCallerType(): CallerType {
  if (process.env.AO_CALLER_TYPE) {
    return process.env.AO_CALLER_TYPE as CallerType;
  }
  // If stdout is a TTY, it's likely a human
  return process.stdout.isTTY ? "human" : "agent";
}

export function isHumanCaller(): boolean {
  return getCallerType() === "human";
}

// Set environment variables for spawned processes
export function setCallerContext(env: Record<string, string>, opts: {
  callerType: CallerType;
  sessionId?: string;
  projectId?: string;
  configPath?: string;
  port?: number;
}): 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) env.AO_PORT = String(opts.port);
}

Environment variables

VariableValuesSet by
AO_CALLER_TYPEhuman | orchestrator | agentInferred or explicit
AO_SESSION_IDe.g. myproj-42Set when spawning agent sessions
AO_PROJECT_IDe.g. my-appSet at start and spawn
AO_CONFIG_PATHAbsolute pathSet at start
AO_PORTe.g. 3000Set at start

Behavior per caller type

Actionhumanorchestratoragent
Auto-open browserYesNoNo
Colored outputYesMinimalNo
Interactive promptsYes (TTY)NeverNever
JSON outputNoYesYes
TODO 3
Create packages/cli/src/lib/config-instruction.ts

~60 lines. Outputs the config schema as a text block for agents and humans.

// Returns a formatted text block explaining the full config schema.
// Used by:
//   1. ao config-help (CLI subcommand)
//   2. generateOrchestratorPrompt() in ao-core (auto-injected into system prompt)
export function getConfigInstruction(): string {
  return `
# Agent Orchestrator Config Reference
# File: agent-orchestrator.yaml

# Complete schema with all fields, valid values, and defaults.
# ... (include full YAML schema with inline comments)
# ... (include valid enum values for: runtime, agent, workspace, notifiers)
# ... (include an annotated example config)
  `.trim();
}

Content to include

TODO 4
Use plugin registry for agent runtime detection

No hardcoded runtime lists. Use the plugin registry (from PR #474) to discover available agent plugins dynamically.

Do NOT hardcode runtime names or binary paths. Every agent plugin declares its own detect() method. The CLI never needs to know how detection works for each runtime.

Add detect() to agent plugin interface

Each agent plugin in packages/plugins/agent-*/src/index.ts must export:

export default {
  name: "claude-code",        // plugin name
  displayName: "Claude Code", // human-readable
  priority: 100,              // higher = preferred
  detect(): boolean {         // is this runtime available?
    try { execSync("which claude", { stdio: "ignore" }); return true; }
    catch { return false; }
  },
  create() { /* existing factory */ }
};

Detection function in start.ts

async function detectAgentRuntime(registry: PluginRegistry): Promise<string> {
  const agents = registry.getAll("agent");
  const available = agents.filter(p => p.detect());

  if (available.length === 0) {
    console.warn("No agent runtimes detected. Defaulting to claude-code.");
    return "claude-code";
  }
  if (available.length === 1) return available[0].name;

  // Multiple available
  if (isHumanCaller()) {
    // Prompt user to pick
    return await promptChoice(
      available.sort((a, b) => b.priority - a.priority)
               .map(p => ({ name: p.displayName, value: p.name }))
    );
  }
  // Agent: pick highest priority
  return available.sort((a, b) => b.priority - a.priority)[0].name;
}

Key points

Phase 2: Modify start.ts — The Core Change

This is the biggest change. Modify packages/cli/src/commands/start.ts (currently 557 lines, target ~700 lines).

Do NOT break existing functionality. The current ao start with URL, project name, or no arg must continue to work exactly as before. You are ADDING new branches, not replacing existing ones.
TODO 5
Add imports to start.ts
// Add these imports at top of start.ts
import { register, unregister, isAlreadyRunning } from "../lib/running-state.js";
import { getCallerType, isHumanCaller, setCallerContext } from "../lib/caller-context.js";
import { detectEnvironment } from "../lib/detect-env.js"; // extracted from init.ts
// Agent runtime detection uses the plugin registry — no separate import needed
import { detectProjectType, generateRulesFromTemplates } from "../lib/project-detection.js";
import { generateSessionPrefix, findConfigFile } from "@composio/ao-core";
TODO 6
Extract detectEnvironment() from init.ts

Move detectEnvironment() (lines 45–102 of init.ts) into a new shared file:

Source: packages/cli/src/commands/init.ts lines 45–102

Destination: packages/cli/src/lib/detect-env.ts

Keep the function signature identical. Just move it to a shared location so both start.ts and the deprecated init.ts wrapper can import it.

TODO 7
Add "no config found" branch to registerStart

Currently, when no config exists, start.ts throws an error:

// CURRENT (line ~494 of start.ts)
if (err.message.includes("No agent-orchestrator.yaml found")) {
  console.error(chalk.red("\nNo config found. Run:"));
  console.error(chalk.cyan("  ao init\n"));
}

Replace with: absorb handleAutoMode() logic from init.ts (lines 353–475).

// NEW behavior when no config found:
// 1. Detect environment
const env = await detectEnvironment(process.cwd());

// 2. Detect agent runtime (uses plugin registry — see step 4)
const runtime = await detectAgentRuntime(registry);
// Plugin registry handles prompting for multiple runtimes automatically

// 3. Detect project type
const projectType = detectProjectType(process.cwd());

// 4. Generate config YAML (reuse init.ts handleAutoMode logic)
// Write to ./agent-orchestrator.yaml
// Print: "āœ“ Config auto-created with [runtime] for [project-type]"

// 5. Load the new config and continue to startup
config = loadConfig();
({ projectId, project } = resolveProject(config));
TODO 8
Add "path argument = new project" branch

When ao start ~/other-repo is called and that path is NOT in the current config:

// In registerStart action handler, after loading config:
if (projectArg && !isRepoUrl(projectArg)) {
  const resolvedPath = resolve(projectArg.replace(/^~/, homedir()));

  // Check if this project is already in config
  const existingProject = Object.entries(config.projects).find(
    ([_, p]) => resolve(p.path) === resolvedPath
  );

  if (existingProject) {
    // Already in config — just use it
    projectId = existingProject[0];
    project = existingProject[1];
  } else {
    // NEW PROJECT — absorb add-project.ts logic (lines 48-148)
    // 1. Detect git info (remote, default branch)
    // 2. Generate session prefix (ensure uniqueness)
    // 3. Detect project type + generate rules
    // 4. Append to config YAML
    // 5. Print: "āœ“ Added [project] to config"
    // 6. Reload config
    config = loadConfig();
    ({ projectId, project } = resolveProject(config, basename(resolvedPath)));
  }
}
TODO 9
Add already-running detection before startup

Insert this check AFTER config is loaded but BEFORE runStartup():

// Check if AO is already running
const running = isAlreadyRunning();
if (running && isProcessAlive(running.pid)) {
  if (isHumanCaller()) {
    // Show interactive menu
    console.log(chalk.cyan(`\nℹ AO is already running.`));
    console.log(`  Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`);
    console.log(`  PID: ${running.pid} | Up: ${formatUptime(running.startedAt)}`);
    console.log(`  Projects: ${running.projects.join(", ")}\n`);

    // Interactive prompt (use inquirer or readline)
    const choice = await promptChoice([
      "Continue with the current one (open dashboard)",
      "Start a new orchestrator on the same project",
      "Override current config and restart",
      "Quit"
    ]);

    if (choice === "Continue with the current one (open dashboard)") {
      await open(`http://localhost:${running.port}`);
      process.exit(0);

    } else if (choice === "Start a new orchestrator on the same project") {
      // Generate unique name: projectId + random 4-char suffix
      const suffix = Math.random().toString(36).slice(2, 6);
      const newId = `${projectId}-${suffix}`;

      // Duplicate the project config with new session prefix
      const newPrefix = generateSessionPrefix(newId);
      config.projects[newId] = {
        ...config.projects[projectId],
        sessionPrefix: newPrefix,
      };

      // Write updated config
      writeFileSync(configPath, yamlStringify(config, { indent: 2 }));
      console.log(chalk.green(`āœ“ New orchestrator "${newId}" added to config`));

      // Spawn new orchestrator session (don't restart dashboard)
      projectId = newId;
      project = config.projects[newId];
      // Continue to startup below — runStartup will spawn the new orchestrator

    } else if (choice === "Override current config and restart") {
      process.kill(running.pid, "SIGTERM");
      unregister();
      // Continue to startup below

    } else {
      process.exit(0);
    }
  } else {
    // Agent 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);
  }
}
TODO 10
Register in running.json after startup

At the end of runStartup(), after dashboard and orchestrator are running:

// After successful startup, register in running.json
register({
  pid: process.pid,
  configPath: resolve(findConfigFile() || ""),
  port: config.port ?? 3000,
  startedAt: new Date().toISOString(),
  projects: Object.keys(config.projects),
});
TODO 11
Update registerStop to use running.json

In the registerStop handler:

// Replace lsof-based PID discovery with running.json lookup
const running = getRunning();
if (running) {
  // Kill using running.pid instead of lsof
  // After stopping, unregister
  unregister();
  console.log(chalk.green(`\nāœ“ Stopped AO on port ${running.port}`));
  console.log(chalk.dim(`  Projects: ${running.projects.join(", ")}`));
  console.log(chalk.dim(`  Uptime: ${formatUptime(running.startedAt)}\n`));
} else {
  // Fallback to existing lsof method for backward compat
}

Phase 3: Deprecation Wrappers

TODO 12
Rewrite packages/cli/src/commands/init.ts → thin wrapper

Replace the entire 481-line file with ~15 lines:

import chalk from "chalk";
import type { Command } from "commander";

export function registerInit(program: Command): void {
  program
    .command("init")
    .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"
      ));
      // Delegate to start's config-creation logic without starting
      const { createConfigOnly } = await import("./start.js");
      await createConfigOnly();
    });
}
You'll need to export a createConfigOnly() function from start.ts that runs just the config creation logic (step 7) without starting the dashboard/orchestrator. This is equivalent to ao start --no-dashboard --no-orchestrator.
TODO 13
Rewrite packages/cli/src/commands/add-project.ts → thin wrapper

Replace the entire 148-line file with ~15 lines:

import chalk from "chalk";
import type { Command } from "commander";

export function registerAddProject(program: Command): void {
  program
    .command("add-project")
    .argument("<path>", "Path to the project repository")
    .description("[deprecated] Use 'ao start <path>' instead")
    .action(async (projectPath: string) => {
      console.log(chalk.yellow(
        "⚠ 'ao add-project' is deprecated. Use 'ao start <path>' instead.\n"
      ));
      // Delegate to start
      const { addProjectOnly } = await import("./start.js");
      await addProjectOnly(projectPath);
    });
}

Phase 4: Register ao config-help

TODO 14
Add config-help subcommand to index.ts
// In packages/cli/src/index.ts, add:
import { getConfigInstruction } from "./lib/config-instruction.js";

// After other registrations:
program
  .command("config-help")
  .description("Show config schema and guide for creating agent-orchestrator.yaml")
  .action(() => {
    console.log(getConfigInstruction());
  });

Phase 5: Wire into ao-core System Prompt

TODO 15
Auto-inject config instruction into orchestrator system prompt

In packages/core/src/orchestrator.ts (or wherever generateOrchestratorPrompt() lives):

import { getConfigInstruction } from "@composio/ao-cli/lib/config-instruction.js";
// OR if circular dependency, copy the function to ao-core

function generateOrchestratorPrompt(config): string {
  // ... existing prompt generation ...

  // Append config instruction so orchestrator knows the schema
  prompt += "\n\n## AO Config Reference\n" + getConfigInstruction();

  return prompt;
}

Phase 6: Inject Caller Context in ao spawn

TODO 16
Set environment variables when spawning agent sessions

In packages/cli/src/commands/spawn.ts, when creating a new agent session, inject the caller context:

import { setCallerContext } from "../lib/caller-context.js";

// When spawning an agent session:
const env = { ...process.env };
setCallerContext(env, {
  callerType: "agent",
  sessionId: sessionId,
  projectId: projectId,
  configPath: configPath,
  port: config.port ?? 3000,
});
// Pass env to the spawned process
TODO 17
Simplify ao spawn — make project arg optional

In packages/cli/src/commands/spawn.ts:

Change command signature

// CURRENT
.argument("<project>", "Project ID from config")
.argument("[issue]", "Issue identifier")

// NEW
.argument("<issue>", "Issue identifier (e.g. #42, INT-1234)")
.argument("[project]", "Project ID — auto-detected if omitted")

Add auto-detection logic

// At the top of the action handler:
function resolveProjectForSpawn(
  config: OrchestratorConfig,
  arg1: string,
  arg2?: string
): { projectId: string; issueId: string } {
  // Two args = explicit project + issue (backward compat)
  if (arg2) return { projectId: arg1, issueId: arg2 };

  // One arg = auto-detect project, arg1 is issue
  const issueId = arg1;

  // 1. AO_PROJECT_ID env var (set by ao start, inherited by all children)
  if (process.env.AO_PROJECT_ID) {
    return { projectId: process.env.AO_PROJECT_ID, issueId };
  }

  // 2. Single project in config
  const ids = Object.keys(config.projects);
  if (ids.length === 1) return { projectId: ids[0], issueId };

  // 3. Ambiguous
  throw new Error(
    `Multiple projects in config: ${ids.join(", ")}\n` +
    `Run from an orchestrator session, or specify: ao spawn <project> <issue>`
  );
}
Backward compatible: If user passes 2 args, first is project, second is issue (old behavior). If 1 arg, it's the issue and project is auto-detected. Existing scripts don't break.

Phase 7: Remove Dead Code

TODO 18
Remove --smart flag

Find and remove any references to the --smart flag in init.ts or start.ts. The owner confirmed nobody uses it.

Testing Checklist

After all steps are complete, verify these scenarios work:

#ScenarioExpected Result
1ao start in a dir with no configAuto-creates config, detects project type + runtime, starts dashboard
2ao start in a dir with existing configLoads config, starts normally
3ao start <github-url>Clones repo, auto-creates config, starts
4ao start ~/other-repo while AO runningAdds project to config, refreshes dashboard sidebar
5ao start while AO already running (human/TTY)Shows interactive menu: Open / Restart / Quit
6ao start while AO already running (agent/non-TTY)Prints info + exit 0
7ao start with stale running.json (PID dead)Prunes stale entry, starts normally
8ao start while running, pick "New orchestrator"Generates unique ID, adds config entry, spawns new orchestrator session. Same dashboard.
9ao start while running, pick "Override & restart"Kills existing, removes entry, starts fresh
10ao stopStops dashboard + orchestrator + lifecycle, prints confirmation
11ao init (deprecated)Shows deprecation warning, creates config without starting
12ao add-project ~/repo (deprecated)Shows deprecation warning, adds project
13ao config-helpPrints full config schema
14ao start with multiple runtimes installed (human/TTY)Prompts to pick runtime from detected plugins (no hardcoded list)
15ao spawn #42 from orchestrator terminalAuto-detects project via AO_PROJECT_ID, spawns session
16ao spawn my-app #42 (explicit project)Works same as before (backward compat)
17New agent plugin installed (e.g. @composio/ao-plugin-agent-foo)Auto-discovered by plugin registry on next ao start. No CLI code changes.
18npm install -g @composio/ao && ao startFull flow works end-to-end for npm user

File Summary

FileActionLines
lib/running-state.tsCreate~80
lib/caller-context.tsCreate~40
lib/config-instruction.tsCreate~60
packages/plugins/agent-*/src/index.tsEdit (each plugin)+5 each (add detect() + priority)
lib/detect-env.tsCreate (extracted from init.ts)~60
commands/start.tsHeavy edit557 → ~700
commands/init.tsRewrite → wrapper481 → ~15
commands/add-project.tsRewrite → wrapper148 → ~15
index.tsMinor edit+10
commands/spawn.tsEdit+30 (auto-detect project, env var injection)
Core orchestrator promptMinor edit+5
Net result: 1,186 lines → ~910 lines (23% reduction). Zero duplication. One command (ao start) handles everything. 2-step onboarding for npm users.