feat: add doctor and update maintenance tooling (#437)

* feat: add ao doctor maintenance checks

* feat: add ao update refresh script

* feat: add shared CLI script runner

* feat: add doctor and update CLI commands

* docs: document doctor and update commands

* fix: harden maintenance safety checks

* fix: harden ao update behavior

* fix: strip inline comments from doctor config values
This commit is contained in:
Harsh Batheja 2026-03-12 20:59:22 +05:30 committed by GitHub
parent 3deea32bda
commit 3d518aed2f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1131 additions and 17 deletions

View File

@ -89,16 +89,16 @@ ao spawn my-project 123
Eight slots. Every abstraction is swappable.
| Slot | Default | Alternatives |
|------|---------|-------------|
| Runtime | tmux | docker, k8s, process |
| Agent | claude-code | codex, aider, opencode |
| Workspace | worktree | clone |
| Tracker | github | linear |
| SCM | github | — |
| Notifier | desktop | slack, composio, webhook |
| Terminal | iterm2 | web |
| Lifecycle | core | — |
| Slot | Default | Alternatives |
| --------- | ----------- | ------------------------ |
| Runtime | tmux | docker, k8s, process |
| Agent | claude-code | codex, aider, opencode |
| Workspace | worktree | clone |
| Tracker | github | linear |
| SCM | github | — |
| Notifier | desktop | slack, composio, webhook |
| Terminal | iterm2 | web |
| Lifecycle | core | — |
All interfaces defined in [`packages/core/src/types.ts`](packages/core/src/types.ts). A plugin implements one interface and exports a `PluginModule`. That's it.
@ -131,7 +131,7 @@ reactions:
action: send-to-agent
escalateAfter: 30m
approved-and-green:
auto: false # flip to true for auto-merge
auto: false # flip to true for auto-merge
action: notify
```
@ -149,8 +149,25 @@ ao session ls # List sessions
ao session kill <session> # Kill a session
ao session restore <session> # Revive a crashed agent
ao dashboard # Open web dashboard
ao doctor [--fix] # Check install, runtime, and stale temp issues
ao update # Update local AO install and run smoke tests
```
## Maintenance
```bash
# Run deterministic install and runtime checks
ao doctor
# Apply safe cleanup and launcher fixes
ao doctor --fix
# Update this local AO checkout, rebuild critical packages, and verify the launcher
ao update
```
`ao doctor` checks PATH and launcher resolution, required binaries, tmux and GitHub CLI health, config support directories, stale AO temp files, and core build/runtime sanity. `ao update` fast-forwards the local install repo on `main`, runs `pnpm install`, clean-rebuilds `@composio/ao-core`, `@composio/ao-cli`, and `@composio/ao-web`, refreshes the global `ao` launcher with `npm link`, and finishes with CLI smoke tests.
## Why Agent Orchestrator?
Running one AI agent in a terminal is easy. Running 30 across different issues, branches, and PRs is a coordination problem.
@ -178,12 +195,12 @@ See [CLAUDE.md](CLAUDE.md) for code conventions and architecture details.
## Documentation
| Doc | What it covers |
|-----|---------------|
| [Setup Guide](SETUP.md) | Detailed installation and configuration |
| [Examples](examples/) | Config templates (GitHub, Linear, multi-project, auto-merge) |
| [CLAUDE.md](CLAUDE.md) | Architecture, conventions, plugin pattern |
| [Troubleshooting](TROUBLESHOOTING.md) | Common issues and fixes |
| Doc | What it covers |
| ------------------------------------- | ------------------------------------------------------------ |
| [Setup Guide](SETUP.md) | Detailed installation and configuration |
| [Examples](examples/) | Config templates (GitHub, Linear, multi-project, auto-merge) |
| [CLAUDE.md](CLAUDE.md) | Architecture, conventions, plugin pattern |
| [Troubleshooting](TROUBLESHOOTING.md) | Common issues and fixes |
## Contributing

View File

@ -392,6 +392,28 @@ See [CLAUDE.md](./CLAUDE.md) for plugin development guidelines.
## Troubleshooting
### Run `ao doctor`
Use the built-in doctor before debugging a broken install by hand:
```bash
ao doctor
ao doctor --fix
```
`ao doctor` reports deterministic PASS/WARN/FAIL checks for PATH and launcher resolution, required binaries, tmux and GitHub CLI health, stale AO temp files, config support directories, and core build/runtime sanity. `--fix` only applies safe fixes such as creating missing AO support directories, refreshing the local launcher link, and removing stale AO temp files.
### Run `ao update`
When you installed AO from this repository and want to refresh that local install:
```bash
git switch main
ao update
```
`ao update` is intentionally conservative: it requires a clean working tree on `main`, fast-forwards from `origin/main`, reinstalls dependencies, clean-rebuilds the critical core/CLI/web packages, refreshes the launcher with `npm link`, and runs CLI smoke tests. Use `ao update --skip-smoke` to stop after rebuild, or `ao update --smoke-only` to rerun just the smoke checks.
### "No agent-orchestrator.yaml found"
**Problem:** The orchestrator can't find your config file.

View File

@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Command } from "commander";
const { mockExecuteScriptCommand } = vi.hoisted(() => ({
mockExecuteScriptCommand: vi.fn(),
}));
vi.mock("../../src/lib/script-runner.js", () => ({
executeScriptCommand: (...args: unknown[]) => mockExecuteScriptCommand(...args),
}));
import { registerDoctor } from "../../src/commands/doctor.js";
describe("doctor command", () => {
let program: Command;
beforeEach(() => {
program = new Command();
program.exitOverride();
registerDoctor(program);
mockExecuteScriptCommand.mockReset();
mockExecuteScriptCommand.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it("runs the doctor script with no extra args by default", async () => {
await program.parseAsync(["node", "test", "doctor"]);
expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-doctor.sh", []);
});
it("passes through --fix", async () => {
await program.parseAsync(["node", "test", "doctor", "--fix"]);
expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-doctor.sh", ["--fix"]);
});
});

View File

@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Command } from "commander";
const { mockExecuteScriptCommand } = vi.hoisted(() => ({
mockExecuteScriptCommand: vi.fn(),
}));
vi.mock("../../src/lib/script-runner.js", () => ({
executeScriptCommand: (...args: unknown[]) => mockExecuteScriptCommand(...args),
}));
import { registerUpdate } from "../../src/commands/update.js";
describe("update command", () => {
let program: Command;
beforeEach(() => {
program = new Command();
program.exitOverride();
registerUpdate(program);
mockExecuteScriptCommand.mockReset();
mockExecuteScriptCommand.mockResolvedValue(undefined);
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`process.exit(${code})`);
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("runs the update script with default args", async () => {
await program.parseAsync(["node", "test", "update"]);
expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-update.sh", []);
});
it("passes through --skip-smoke", async () => {
await program.parseAsync(["node", "test", "update", "--skip-smoke"]);
expect(mockExecuteScriptCommand).toHaveBeenCalledWith("ao-update.sh", ["--skip-smoke"]);
});
it("rejects conflicting smoke flags", async () => {
await expect(
program.parseAsync(["node", "test", "update", "--skip-smoke", "--smoke-only"]),
).rejects.toThrow("process.exit(1)");
expect(mockExecuteScriptCommand).not.toHaveBeenCalled();
expect(vi.mocked(console.error)).toHaveBeenCalledWith(
"`ao update` does not allow `--skip-smoke` together with `--smoke-only`.",
);
});
});

View File

@ -0,0 +1,179 @@
import { describe, it, expect } from "vitest";
import {
chmodSync,
existsSync,
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
const scriptPath = join(repoRoot, "scripts", "ao-doctor.sh");
function writeExecutable(path: string, content: string): void {
writeFileSync(path, content);
chmodSync(path, 0o755);
}
function createFakeBinary(binDir: string, name: string, body: string): void {
writeExecutable(join(binDir, name), `#!/bin/bash\nset -e\n${body}\n`);
}
function createHealthyRepo(tempRoot: string): string {
const fakeRepo = join(tempRoot, "repo");
mkdirSync(join(fakeRepo, "node_modules"), { recursive: true });
mkdirSync(join(fakeRepo, "packages", "core", "dist"), { recursive: true });
mkdirSync(join(fakeRepo, "packages", "cli", "dist"), { recursive: true });
mkdirSync(join(fakeRepo, "packages", "agent-orchestrator", "bin"), { recursive: true });
mkdirSync(join(fakeRepo, "packages", "web"), { recursive: true });
writeFileSync(join(fakeRepo, "packages", "core", "dist", "index.js"), "export {};\n");
writeFileSync(join(fakeRepo, "packages", "cli", "dist", "index.js"), "export {};\n");
writeFileSync(
join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js"),
'#!/usr/bin/env node\nconsole.log("0.1.0");\n',
);
chmodSync(join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js"), 0o755);
return fakeRepo;
}
function createHealthyPath(binDir: string): void {
createFakeBinary(
binDir,
"node",
'if [ "$1" = "--version" ]; then\n printf "v20.11.1\\n"\n exit 0\nfi\nexit 0',
);
createFakeBinary(
binDir,
"git",
'if [ "$1" = "--version" ]; then\n printf "git version 2.43.0\\n"\n exit 0\nfi\nexit 0',
);
createFakeBinary(
binDir,
"pnpm",
'if [ "$1" = "--version" ]; then\n printf "9.15.4\\n"\n exit 0\nfi\nexit 0',
);
createFakeBinary(
binDir,
"npm",
'if [ "$1" = "bin" ]; then\n printf "/tmp/npm-bin\\n"\n exit 0\nfi\nexit 0',
);
createFakeBinary(
binDir,
"tmux",
'if [ "$1" = "-V" ]; then\n printf "tmux 3.4\\n"\n exit 0\nfi\nif [ "$1" = "list-sessions" ]; then\n exit 1\nfi\nexit 0',
);
createFakeBinary(
binDir,
"gh",
'if [ "$1" = "--version" ]; then\n printf "gh version 2.50.0\\n"\n exit 0\nfi\nif [ "$1" = "auth" ] && [ "$2" = "status" ]; then\n exit 0\nfi\nexit 0',
);
createFakeBinary(binDir, "ao", 'printf "/fake/ao\\n" >/dev/null\nexit 0');
}
describe("scripts/ao-doctor.sh", () => {
it("reports a healthy install as PASS", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-script-"));
const fakeRepo = createHealthyRepo(tempRoot);
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });
createHealthyPath(binDir);
const configPath = join(tempRoot, "agent-orchestrator.yaml");
const dataDir = join(tempRoot, "data");
const worktreeDir = join(tempRoot, "worktrees");
mkdirSync(dataDir, { recursive: true });
mkdirSync(worktreeDir, { recursive: true });
writeFileSync(
configPath,
[`dataDir: ${dataDir}`, `worktreeDir: ${worktreeDir}`, "projects: {}"].join("\n"),
);
const result = spawnSync("bash", [scriptPath], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
AO_REPO_ROOT: fakeRepo,
AO_CONFIG_PATH: configPath,
},
encoding: "utf8",
});
rmSync(tempRoot, { recursive: true, force: true });
expect(result.status).toBe(0);
expect(result.stdout).toContain("PASS");
expect(result.stdout).toContain("Environment looks healthy");
});
it("applies safe fixes for missing launcher, missing dirs, and stale temp files", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-doctor-fix-"));
const fakeRepo = createHealthyRepo(tempRoot);
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });
createHealthyPath(binDir);
rmSync(join(binDir, "ao"), { force: true });
const npmLog = join(tempRoot, "npm.log");
createFakeBinary(
binDir,
"npm",
`printf '%s\\n' "$*" >> ${JSON.stringify(npmLog)}\nif [ "$1" = "bin" ]; then\n printf "/tmp/npm-bin\\n"\nfi\nexit 0`,
);
const configPath = join(tempRoot, "agent-orchestrator.yaml");
const dataDir = join(tempRoot, "data");
const worktreeDir = join(tempRoot, "worktrees");
const commentedDataDir = `${dataDir} # session metadata`;
const commentedWorktreeDir = `${worktreeDir} # ephemeral worktrees`;
writeFileSync(
configPath,
[`dataDir: ${commentedDataDir}`, `worktreeDir: ${commentedWorktreeDir}`, "projects: {}"].join(
"\n",
),
);
const tmpRoot = join(tempRoot, "tmp-root");
mkdirSync(tmpRoot, { recursive: true });
const staleFile = join(tmpRoot, "ao-stale.tmp");
writeFileSync(staleFile, "stale\n");
const oldTimestamp = new Date(Date.now() - 2 * 60 * 60 * 1000);
utimesSync(staleFile, oldTimestamp, oldTimestamp);
const result = spawnSync("bash", [scriptPath, "--fix"], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
AO_REPO_ROOT: fakeRepo,
AO_CONFIG_PATH: configPath,
AO_DOCTOR_TMP_ROOT: tmpRoot,
},
encoding: "utf8",
});
const npmCommands = readFileSync(npmLog, "utf8");
const staleStillExists = existsSync(staleFile);
const dataDirExists = existsSync(dataDir);
const worktreeDirExists = existsSync(worktreeDir);
const commentedDataDirExists = existsSync(commentedDataDir);
const commentedWorktreeDirExists = existsSync(commentedWorktreeDir);
rmSync(tempRoot, { recursive: true, force: true });
expect(result.status).toBe(0);
expect(result.stdout).toContain("FIXED");
expect(npmCommands).toContain("link");
expect(result.stdout).toContain("launcher");
expect(result.stdout).toContain("stale temp files");
expect(staleStillExists).toBe(false);
expect(dataDirExists).toBe(true);
expect(worktreeDirExists).toBe(true);
expect(commentedDataDirExists).toBe(false);
expect(commentedWorktreeDirExists).toBe(false);
});
});

View File

@ -0,0 +1,226 @@
import { describe, it, expect } from "vitest";
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
const scriptPath = join(repoRoot, "scripts", "ao-update.sh");
function writeExecutable(path: string, content: string): void {
writeFileSync(path, content);
chmodSync(path, 0o755);
}
function createFakeBinary(binDir: string, name: string, body: string): void {
writeExecutable(join(binDir, name), `#!/bin/bash\nset -e\n${body}\n`);
}
describe("scripts/ao-update.sh", () => {
it("runs the expected fetch, rebuild, and launcher refresh flow", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-script-"));
const fakeRepo = join(tempRoot, "repo");
mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true });
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });
const commandLog = join(tempRoot, "commands.log");
createFakeBinary(
binDir,
"git",
`printf 'git %s\\n' "$*" >> ${JSON.stringify(commandLog)}\ncase "$*" in\n "rev-parse --is-inside-work-tree") printf 'true\\n' ;;
"status --porcelain") ;;
"branch --show-current") printf 'main\\n' ;;
"fetch origin main") ;;
"pull --ff-only origin main") ;;
*) ;;
esac\nexit 0`,
);
createFakeBinary(
binDir,
"pnpm",
`printf 'pnpm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf '9.15.4\\n'\nfi\nexit 0`,
);
createFakeBinary(
binDir,
"npm",
`printf 'npm %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nexit 0`,
);
createFakeBinary(
binDir,
"node",
`printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}\nif [ "$1" = "--version" ]; then\n printf 'v20.11.1\\n'\nfi\nexit 0`,
);
const result = spawnSync("bash", [scriptPath, "--skip-smoke"], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
AO_REPO_ROOT: fakeRepo,
},
encoding: "utf8",
});
const commands = readFileSync(commandLog, "utf8");
rmSync(tempRoot, { recursive: true, force: true });
expect(result.status).toBe(0);
expect(commands).toContain("git fetch origin main");
expect(commands).toContain("git pull --ff-only origin main");
expect(commands).toContain("pnpm install");
expect(commands).toContain("pnpm --filter @composio/ao-core clean");
expect(commands).toContain("pnpm --filter @composio/ao-cli build");
expect(commands).toContain("npm link");
});
it("runs the built-in smoke commands in smoke-only mode", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-smoke-"));
const fakeRepo = join(tempRoot, "repo");
mkdirSync(join(fakeRepo, "packages", "agent-orchestrator", "bin"), { recursive: true });
writeFileSync(
join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js"),
"#!/usr/bin/env node\n",
);
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });
const commandLog = join(tempRoot, "commands.log");
createFakeBinary(
binDir,
"node",
`if [ "$1" = "--version" ]; then printf 'v20.11.1\\n'; fi
printf 'node %s\\n' "$*" >> ${JSON.stringify(commandLog)}
exit 0`,
);
const result = spawnSync("bash", [scriptPath, "--smoke-only"], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
AO_REPO_ROOT: fakeRepo,
},
encoding: "utf8",
});
const commands = readFileSync(commandLog, "utf8");
rmSync(tempRoot, { recursive: true, force: true });
expect(result.status).toBe(0);
expect(commands).toContain(
`node ${join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js")} --version`,
);
expect(commands).toContain(
`node ${join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js")} doctor --help`,
);
expect(commands).toContain(
`node ${join(fakeRepo, "packages", "agent-orchestrator", "bin", "ao.js")} update --help`,
);
});
it("fails fast on a dirty install repo with an actionable message", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-dirty-"));
const fakeRepo = join(tempRoot, "repo");
mkdirSync(fakeRepo, { recursive: true });
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });
createFakeBinary(
binDir,
"git",
`case "$*" in
"rev-parse --is-inside-work-tree") printf "true\\n" ;;
"status --porcelain") printf " M README.md\\n" ;;
"branch --show-current") printf "main\\n" ;;
esac
exit 0`,
);
createFakeBinary(
binDir,
"pnpm",
'if [ "$1" = "--version" ]; then printf "9.15.4\\n"; fi\nexit 0',
);
createFakeBinary(binDir, "npm", "exit 0");
createFakeBinary(
binDir,
"node",
'if [ "$1" = "--version" ]; then printf "v20.11.1\\n"; fi\nexit 0',
);
const result = spawnSync("bash", [scriptPath], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
AO_REPO_ROOT: fakeRepo,
},
encoding: "utf8",
});
rmSync(tempRoot, { recursive: true, force: true });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Working tree is dirty");
expect(result.stderr).toContain("commit or stash");
});
it("rejects conflicting smoke flags in the script", () => {
const result = spawnSync("bash", [scriptPath, "--skip-smoke", "--smoke-only"], {
encoding: "utf8",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("Conflicting options");
});
it("reports when the update itself dirties the checkout", () => {
const tempRoot = mkdtempSync(join(tmpdir(), "ao-update-post-dirty-"));
const fakeRepo = join(tempRoot, "repo");
mkdirSync(join(fakeRepo, "packages", "cli"), { recursive: true });
const binDir = join(tempRoot, "bin");
mkdirSync(binDir, { recursive: true });
createFakeBinary(
binDir,
"git",
`case "$*" in
"rev-parse --is-inside-work-tree") printf "true\\n" ;;
"status --porcelain")
if [ -f ${JSON.stringify(join(tempRoot, "post-dirty"))} ]; then
printf " M pnpm-lock.yaml\\n"
fi
;;
"branch --show-current") printf "main\\n" ;;
"pull --ff-only origin main") touch ${JSON.stringify(join(tempRoot, "post-dirty"))} ;;
esac
exit 0`,
);
createFakeBinary(
binDir,
"pnpm",
'if [ "$1" = "--version" ]; then printf "9.15.4\\n"; fi\nexit 0',
);
createFakeBinary(binDir, "npm", "exit 0");
createFakeBinary(
binDir,
"node",
'if [ "$1" = "--version" ]; then printf "v20.11.1\\n"; fi\nexit 0',
);
const result = spawnSync("bash", [scriptPath, "--skip-smoke"], {
env: {
...process.env,
PATH: `${binDir}:${process.env.PATH || ""}`,
AO_REPO_ROOT: fakeRepo,
},
encoding: "utf8",
});
rmSync(tempRoot, { recursive: true, force: true });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Update modified tracked files");
});
});

View File

@ -0,0 +1,17 @@
import type { Command } from "commander";
import { executeScriptCommand } from "../lib/script-runner.js";
export function registerDoctor(program: Command): void {
program
.command("doctor")
.description("Run install, environment, and runtime health checks")
.option("--fix", "Apply safe fixes for launcher and stale temp issues")
.action(async (opts: { fix?: boolean }) => {
const args: string[] = [];
if (opts.fix) {
args.push("--fix");
}
await executeScriptCommand("ao-doctor.sh", args);
});
}

View File

@ -0,0 +1,28 @@
import type { Command } from "commander";
import { executeScriptCommand } from "../lib/script-runner.js";
export function registerUpdate(program: Command): void {
program
.command("update")
.description(
"Fast-forward the local install repo, rebuild critical packages, and run smoke tests",
)
.option("--skip-smoke", "Skip smoke tests after rebuilding")
.option("--smoke-only", "Run smoke tests without fetching or rebuilding")
.action(async (opts: { skipSmoke?: boolean; smokeOnly?: boolean }) => {
if (opts.skipSmoke && opts.smokeOnly) {
console.error("`ao update` does not allow `--skip-smoke` together with `--smoke-only`.");
process.exit(1);
}
const args: string[] = [];
if (opts.skipSmoke) {
args.push("--skip-smoke");
}
if (opts.smokeOnly) {
args.push("--smoke-only");
}
await executeScriptCommand("ao-update.sh", args);
});
}

View File

@ -12,6 +12,8 @@ import { registerOpen } from "./commands/open.js";
import { registerStart, registerStop } from "./commands/start.js";
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";
const program = new Command();
@ -33,5 +35,7 @@ registerDashboard(program);
registerOpen(program);
registerLifecycleWorker(program);
registerVerify(program);
registerDoctor(program);
registerUpdate(program);
program.parse();

View File

@ -0,0 +1,54 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../");
export function resolveRepoRoot(): string {
const override = process.env["AO_REPO_ROOT"];
return override ? resolve(override) : DEFAULT_REPO_ROOT;
}
export function resolveScriptPath(scriptName: string): string {
const scriptPath = resolve(resolveRepoRoot(), "scripts", scriptName);
if (!existsSync(scriptPath)) {
throw new Error(`Script not found: ${scriptPath}`);
}
return scriptPath;
}
export async function runRepoScript(scriptName: string, args: string[]): Promise<number> {
const shell = process.env["AO_BASH_PATH"] || "bash";
const scriptPath = resolveScriptPath(scriptName);
return await new Promise<number>((resolveExit, reject) => {
const child = spawn(shell, [scriptPath, ...args], {
cwd: resolveRepoRoot(),
env: process.env,
stdio: "inherit",
});
child.on("error", reject);
child.on("exit", (code, signal) => {
if (signal) {
resolveExit(1);
return;
}
resolveExit(code ?? 1);
});
});
}
export async function executeScriptCommand(scriptName: string, args: string[]): Promise<void> {
try {
const exitCode = await runRepoScript(scriptName, args);
if (exitCode !== 0) {
process.exit(exitCode);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

342
scripts/ao-doctor.sh Executable file
View File

@ -0,0 +1,342 @@
#!/bin/bash
set -uo pipefail
FIX_MODE=false
while [ $# -gt 0 ]; do
case "$1" in
--fix)
FIX_MODE=true
;;
-h|--help)
cat <<'EOF'
Usage: ao doctor [--fix]
Checks install, PATH, binaries, service health, stale temp files, and runtime sanity.
Options:
--fix Apply safe fixes for missing launcher links, missing support dirs, and stale temp files
EOF
exit 0
;;
*)
printf 'Unknown option: %s\n' "$1" >&2
exit 1
;;
esac
shift
done
REPO_ROOT="${AO_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
DEFAULT_CONFIG_HOME="${HOME:-$REPO_ROOT}"
PASS_COUNT=0
WARN_COUNT=0
FAIL_COUNT=0
FIX_COUNT=0
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf 'PASS %s\n' "$1"
}
warn() {
WARN_COUNT=$((WARN_COUNT + 1))
printf 'WARN %s\n' "$1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
printf 'FAIL %s\n' "$1"
}
fixed() {
FIX_COUNT=$((FIX_COUNT + 1))
printf 'FIXED %s\n' "$1"
}
expand_home() {
case "$1" in
~/*)
printf '%s/%s' "$DEFAULT_CONFIG_HOME" "${1#~/}"
;;
*)
printf '%s' "$1"
;;
esac
}
find_config() {
if [ -n "${AO_CONFIG_PATH:-}" ] && [ -f "$AO_CONFIG_PATH" ]; then
printf '%s\n' "$AO_CONFIG_PATH"
return 0
fi
local current_dir="$PWD"
while [ "$current_dir" != "/" ]; do
if [ -f "$current_dir/agent-orchestrator.yaml" ]; then
printf '%s\n' "$current_dir/agent-orchestrator.yaml"
return 0
fi
if [ -f "$current_dir/agent-orchestrator.yml" ]; then
printf '%s\n' "$current_dir/agent-orchestrator.yml"
return 0
fi
current_dir="$(dirname "$current_dir")"
done
if [ -f "$REPO_ROOT/agent-orchestrator.yaml" ]; then
printf '%s\n' "$REPO_ROOT/agent-orchestrator.yaml"
return 0
fi
if [ -f "$DEFAULT_CONFIG_HOME/.agent-orchestrator.yaml" ]; then
printf '%s\n' "$DEFAULT_CONFIG_HOME/.agent-orchestrator.yaml"
return 0
fi
return 1
}
read_config_value() {
local key="$1"
local file="$2"
local raw
local value
raw="$(grep -E "^[[:space:]]*${key}:" "$file" | head -n 1 | cut -d: -f2- || true)"
raw="${raw%%[[:space:]]#*}"
value="$(printf '%s' "$raw" | tr -d '"' | xargs 2>/dev/null || true)"
printf '%s' "$value"
}
ensure_dir() {
local dir_path="$1"
local label="$2"
local fix_hint="$3"
if [ -d "$dir_path" ]; then
pass "$label exists at $dir_path"
return 0
fi
if [ "$FIX_MODE" = true ]; then
if mkdir -p "$dir_path"; then
fixed "$label created at $dir_path"
return 0
fi
fail "$label could not be created at $dir_path. Fix: $fix_hint"
return 1
fi
warn "$label is missing at $dir_path. Fix: $fix_hint"
}
check_command() {
local name="$1"
local required="$2"
local fix_hint="$3"
local command_path
command_path="$(command -v "$name" 2>/dev/null || true)"
if [ -z "$command_path" ]; then
if [ "$required" = "required" ]; then
fail "$name is not in PATH. Fix: $fix_hint"
else
warn "$name is not in PATH. Fix: $fix_hint"
fi
return 1
fi
pass "$name resolves to $command_path"
return 0
}
check_node() {
if ! check_command "node" "required" "install Node.js 20+ and reopen your shell"; then
return
fi
local version major
version="$(node --version 2>/dev/null || true)"
major="${version#v}"
major="${major%%.*}"
if [ -z "$major" ] || [ "$major" -lt 20 ]; then
fail "Node.js 20+ is required, found ${version:-unknown}. Fix: install Node.js 20+"
return
fi
pass "Node.js version ${version} is supported"
}
check_git() {
if ! check_command "git" "required" "install git 2.25+ and reopen your shell"; then
return
fi
local version major minor
version="$(git --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n 1)"
major="${version%%.*}"
minor="${version#*.}"
minor="${minor%%.*}"
if [ -z "$version" ] || [ "$major" -lt 2 ] || { [ "$major" -eq 2 ] && [ "$minor" -lt 25 ]; }; then
fail "git 2.25+ is required, found ${version:-unknown}. Fix: upgrade git"
return
fi
pass "git version ${version} supports worktrees"
}
check_pnpm() {
if ! check_command "pnpm" "required" "enable corepack or run npm install -g pnpm"; then
return
fi
local version
version="$(pnpm --version 2>/dev/null || true)"
pass "pnpm version ${version:-unknown} is available"
}
check_launcher() {
local ao_path
ao_path="$(command -v ao 2>/dev/null || true)"
if [ -n "$ao_path" ]; then
pass "ao launcher resolves to $ao_path"
return
fi
if [ "$FIX_MODE" = true ] && command -v npm >/dev/null 2>&1 && [ -d "$REPO_ROOT/packages/cli" ]; then
if (cd "$REPO_ROOT/packages/cli" && npm link >/dev/null 2>&1) && command -v ao >/dev/null 2>&1; then
fixed "ao launcher refreshed with npm link"
return
fi
warn "ao launcher refresh failed. Fix: cd $REPO_ROOT/packages/cli && npm link"
return
fi
warn "ao launcher is not in PATH. Fix: cd $REPO_ROOT && bash scripts/setup.sh"
}
check_tmux() {
if ! command -v tmux >/dev/null 2>&1; then
warn "tmux is not installed. Fix: install tmux for the default runtime"
return
fi
if tmux -V >/dev/null 2>&1 && tmux start-server >/dev/null 2>&1; then
pass "tmux is installed and the server can start"
return
fi
warn "tmux is installed but failed a basic server health check. Fix: restart tmux or reinstall it"
}
check_gh() {
if ! command -v gh >/dev/null 2>&1; then
warn "GitHub CLI is not installed. Fix: install gh from https://cli.github.com/"
return
fi
if gh auth status >/dev/null 2>&1; then
pass "gh is installed and authenticated"
return
fi
warn "gh is installed but not authenticated. Fix: run gh auth login"
}
check_install_layout() {
if [ -d "$REPO_ROOT/node_modules" ]; then
pass "dependencies are installed at $REPO_ROOT/node_modules"
else
fail "dependencies are missing at $REPO_ROOT/node_modules. Fix: run pnpm install"
fi
if [ -f "$REPO_ROOT/packages/core/dist/index.js" ]; then
pass "core package is built"
else
fail "core package is not built. Fix: run pnpm --filter @composio/ao-core build"
fi
if [ -f "$REPO_ROOT/packages/cli/dist/index.js" ]; then
pass "CLI package is built"
else
fail "CLI package is not built. Fix: run pnpm --filter @composio/ao-cli build"
fi
}
check_runtime_sanity() {
if [ ! -f "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" ]; then
fail "launcher entrypoint is missing. Fix: reinstall from a clean checkout"
return
fi
if node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" --version >/dev/null 2>&1; then
pass "launcher runtime sanity check passed (ao --version)"
else
fail "launcher runtime sanity check failed. Fix: run pnpm build and refresh the launcher"
fi
}
check_config_dirs() {
local config_path data_dir worktree_dir
config_path="$(find_config || true)"
if [ -z "$config_path" ]; then
warn "No agent-orchestrator config was found. Fix: run ao init --auto in a target repo"
return
fi
pass "config found at $config_path"
data_dir="$(read_config_value dataDir "$config_path")"
worktree_dir="$(read_config_value worktreeDir "$config_path")"
if [ -z "$data_dir" ]; then
data_dir="$DEFAULT_CONFIG_HOME/.agent-orchestrator"
fi
if [ -z "$worktree_dir" ]; then
worktree_dir="$DEFAULT_CONFIG_HOME/.worktrees"
fi
data_dir="$(expand_home "$data_dir")"
worktree_dir="$(expand_home "$worktree_dir")"
ensure_dir "$data_dir" "metadata directory" "mkdir -p $data_dir"
ensure_dir "$worktree_dir" "worktree directory" "mkdir -p $worktree_dir"
}
check_stale_temp_files() {
local temp_root stale_count deleted_count
temp_root="${AO_DOCTOR_TMP_ROOT:-${TMPDIR:-/tmp}/agent-orchestrator}"
if [ ! -d "$temp_root" ]; then
pass "temp root exists check skipped because $temp_root does not exist"
return
fi
stale_count="$(find "$temp_root" -maxdepth 1 -type f -mmin +60 \( -name 'ao-*.tmp' -o -name 'ao-*.pid' -o -name 'ao-*.lock' \) | wc -l | tr -d ' ')"
if [ "$stale_count" = "0" ]; then
pass "no stale temp files were detected under $temp_root"
return
fi
if [ "$FIX_MODE" = true ]; then
deleted_count="$(find "$temp_root" -maxdepth 1 -type f -mmin +60 \( -name 'ao-*.tmp' -o -name 'ao-*.pid' -o -name 'ao-*.lock' \) -delete -print | wc -l | tr -d ' ')"
if [ "$deleted_count" = "$stale_count" ]; then
fixed "$deleted_count stale temp files removed from $temp_root"
return
fi
warn "Only removed $deleted_count of $stale_count stale temp files from $temp_root. Fix: inspect that directory manually"
return
fi
warn "$stale_count stale temp files older than 60 minutes found under $temp_root. Fix: rerun ao doctor --fix"
}
printf 'Agent Orchestrator Doctor\n\n'
check_node
check_git
check_pnpm
check_launcher
check_tmux
check_gh
check_config_dirs
check_stale_temp_files
check_install_layout
check_runtime_sanity
printf '\nResults: %s PASS, %s WARN, %s FAIL, %s FIXED\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" "$FIX_COUNT"
if [ "$FAIL_COUNT" -gt 0 ]; then
printf 'Environment needs attention before AO is safe to update or run.\n' >&2
exit 1
fi
printf 'Environment looks healthy enough to run Agent Orchestrator.\n'

130
scripts/ao-update.sh Executable file
View File

@ -0,0 +1,130 @@
#!/bin/bash
set -euo pipefail
SKIP_SMOKE=false
SMOKE_ONLY=false
TARGET_BRANCH="${AO_UPDATE_BRANCH:-main}"
while [ $# -gt 0 ]; do
case "$1" in
--skip-smoke)
SKIP_SMOKE=true
;;
--smoke-only)
SMOKE_ONLY=true
;;
-h|--help)
cat <<'EOF'
Usage: ao update [--skip-smoke] [--smoke-only]
Fast-forwards the local Agent Orchestrator install repo to main, installs deps,
clean-rebuilds critical packages, refreshes the ao launcher, and runs smoke tests.
Options:
--skip-smoke Skip smoke tests after rebuild
--smoke-only Run smoke tests without fetching or rebuilding
EOF
exit 0
;;
*)
printf 'Unknown option: %s\n' "$1" >&2
exit 1
;;
esac
shift
done
if [ "$SKIP_SMOKE" = true ] && [ "$SMOKE_ONLY" = true ]; then
printf 'Conflicting options: use either --skip-smoke or --smoke-only, not both.\n' >&2
exit 1
fi
REPO_ROOT="${AO_REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
require_command() {
local name="$1"
local fix_hint="$2"
if ! command -v "$name" >/dev/null 2>&1; then
printf 'Missing required command: %s. Fix: %s\n' "$name" "$fix_hint" >&2
exit 1
fi
}
run_cmd() {
printf -- '-> %s\n' "$*"
"$@"
}
run_smoke_tests() {
printf '\nRunning smoke tests...\n'
run_cmd node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" --version
run_cmd node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" doctor --help
run_cmd node "$REPO_ROOT/packages/agent-orchestrator/bin/ao.js" update --help
}
ensure_repo_clean() {
local reason="$1"
local status_output
status_output="$(git status --porcelain)"
if [ -n "$status_output" ]; then
printf '%s\n' "$reason" >&2
exit 1
fi
}
ensure_on_target_branch() {
local current_branch
current_branch="$(git branch --show-current)"
if [ "$current_branch" != "$TARGET_BRANCH" ]; then
printf 'Current branch is %s, expected %s. Fix: git switch %s && rerun ao update.\n' \
"$current_branch" "$TARGET_BRANCH" "$TARGET_BRANCH" >&2
exit 1
fi
}
printf 'Agent Orchestrator Update\n\n'
require_command node "install Node.js 20+"
cd "$REPO_ROOT"
if [ "$SMOKE_ONLY" = false ]; then
require_command git "install git 2.25+"
require_command pnpm "enable corepack or run npm install -g pnpm"
require_command npm "install npm with Node.js"
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
printf 'The update command must run inside the Agent Orchestrator git checkout.\n' >&2
exit 1
fi
ensure_repo_clean "Working tree is dirty. Fix: commit or stash local changes before running ao update."
ensure_on_target_branch
run_cmd git fetch origin "$TARGET_BRANCH"
run_cmd git pull --ff-only origin "$TARGET_BRANCH"
run_cmd pnpm install
run_cmd pnpm --filter @composio/ao-core clean
run_cmd pnpm --filter @composio/ao-cli clean
run_cmd pnpm --filter @composio/ao-web clean
run_cmd pnpm --filter @composio/ao-core build
run_cmd pnpm --filter @composio/ao-cli build
run_cmd pnpm --filter @composio/ao-web build
printf '\nRefreshing ao launcher...\n'
(
cd "$REPO_ROOT/packages/cli"
run_cmd npm link
)
ensure_repo_clean "Update modified tracked files. Inspect git status, review the changes, and rerun after restoring a clean checkout if needed."
fi
if [ "$SKIP_SMOKE" = false ]; then
run_smoke_tests
fi
printf '\nUpdate complete.\n'