#!/usr/bin/env bun
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import type { ChannelInfo, ChatId, Event } from "@ace/channel/protocol";
import { appUrl, token } from "./auth";
import { backup } from "./backup";
import * as catalog from "./catalog";
import { Connection, request } from "./client";
import { config } from "./config";
import * as directory from "./directory";
import * as web from "./web";
import { serve } from "./gateway";
import { GatewayClient } from "./gateway-client";
import { forget, store } from "./keys";
import { dir as logDir, failure, log, open as openLog } from "./log";
import { type Filter, follow, print } from "./logs";
import { health } from "./health";
import { preference } from "./preferences";
import type { HostRequest, WindowInfo } from "./protocol";
import { setModel } from "./settings";
import { archive, host, isRunning, kill, models, parseModel, project, remove } from "./manage";
const HELP = `ace — channels on this host
ace new [--project
] [--name ] [--model ] [--hosted ]
ace ls [--all]
ace info
ace rename rename without changing its lanes
ace tabs [--port ] [--json] list open windows and their current channel's tabs
ace tab rename rename one tab; an empty name restores its default
ace backup snapshot local channel data into a new directory
ace say [--chat ] post without invoking the agent
ace ask [--chat ] [--model ] [--detach]
ace watch [--chat ]
ace chat [--model ] open another chat
ace stop [--chat ] stop the chat's current run
ace kill stop all work in the channel now
ace share on|off let others invoke agents
ace models
ace model [provider/id|auto] show or set the model for a chat's first agent run
ace key set store a key in the OS keychain, read from stdin
ace key rm
ace directory [ | off] the team's directory, which lists every host's channels
ace web [ | off] the deployed web app your devices may use with this host
ace serve [--port 4140] serve the app and its WebSocket gateway
ace logs [--channel [] [--trace ] [--level warn] [--since 10m] [--grep ]
[-n 200] [--follow] [--json] every process's logs, merged in time order
ace open [--port 4140] open this host's authenticated app
ace archive | unarchive | delete
Models are provider/id, such as openai/gpt-6-astra or anthropic/claude-opus-5-5.
A credential such as OPENAI_API_KEY comes from ACE_OPENAI_API_KEY, then OPENAI_API_KEY, then the
keychain. Store keys there for the desktop app, which starts without your shell's environment.`;
const { values: flags, positionals } = parseArgs({
allowPositionals: true,
options: {
project: { type: "string" },
name: { type: "string" },
model: { type: "string" },
hosted: { type: "string" },
chat: { type: "string" },
port: { type: "string" },
all: { type: "boolean" },
detach: { type: "boolean" },
channel: { type: "string" },
trace: { type: "string" },
level: { type: "string" },
since: { type: "string" },
grep: { type: "string" },
lines: { type: "string", short: "n" },
follow: { type: "boolean", short: "f" },
json: { type: "boolean" },
help: { type: "boolean", short: "h" },
},
});
const [command, ref, ...rest] = positionals;
const model = parseModel;
function chat(): ChatId | undefined {
return flags.chat === undefined ? undefined : Number(flags.chat);
}
const dim = (text: string) => `\x1b[2m${text}\x1b[0m`;
const bold = (text: string) => `\x1b[1m${text}\x1b[0m`;
function clip(text: string, length: number): string {
const line = text.replace(/\s+/g, " ").trim();
return line.length > length ? `${line.slice(0, length - 1)}…` : line;
}
function render(event: Event, info: ChannelInfo) {
const tag = event.chat === 1 && info.chats.length === 1 ? "" : dim(`[${event.chat}] `);
switch (event.kind) {
case "message":
return console.log(`${tag}${bold(event.author)}: ${event.text}`);
case "reply": {
const ending = event.error
? `\x1b[31m${event.text ? "\n" : ""}Run failed: ${event.error}\x1b[0m`
: event.stopped
? dim(`${event.text ? "\n" : ""}Stopped.`)
: "";
return console.log(`${tag}${bold(event.model)}: ${event.text}${ending}`);
}
case "tool":
return console.log(dim(`${tag} → ${event.name} ${clip(JSON.stringify(event.args), 100)}`));
case "result":
return console.log(dim(`${tag} ${event.error ? "✗" : "←"} ${clip(event.text, 100)}`));
case "run":
return event.state === "end" && console.log(dim(`${tag} (run ended)`));
}
}
async function watch(id: string, until?: (event: Event) => boolean, replay = true) {
const connection = await Connection.open(id);
const info = await connection.request({ op: "info" });
let live = false;
const done = Promise.withResolvers();
await connection.request({ op: "watch", chat: chat() }, {
event(event) {
if (event.kind === "live") return void (live = true);
if (live || replay) render(event, info);
if (live && until?.(event)) done.resolve();
},
});
return { connection, done: done.promise };
}
/** `10m`, `2h`, `1d`, or an ISO time. */
function since(value: string): number {
const match = /^(\d+)([smhd])$/.exec(value);
if (!match) return Date.parse(value);
const unit = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[match[2] as "s" | "m" | "h" | "d"];
return Date.now() - Number(match[1]) * unit;
}
async function local(request: HostRequest): Promise {
const port = Number(flags.port || config.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Invalid host port");
const info = await health(port);
if (!info || info.home !== config.home) {
throw new Error("Start this host with ace serve or open Ace before using tab commands");
}
const client = new GatewayClient(`ws://127.0.0.1:${port}/ws`, token());
const done = Promise.withResolvers();
let sent = false;
const check = () => {
if (client.status === "closed") return done.reject(new Error("Disconnected from the host"));
if (client.status !== "open" || sent) return;
sent = true;
client.request(request).then(done.resolve, done.reject);
};
const unsubscribe = client.subscribe(check);
const timer = setTimeout(
() => done.reject(new Error("The host did not answer within 10 seconds")),
10_000,
);
try {
check();
return await done.promise;
} finally {
clearTimeout(timer);
unsubscribe();
client.close();
}
}
function printTabs(windows: WindowInfo[]): void {
if (flags.json) return console.log(JSON.stringify(windows, null, 2));
if (!windows.length) return console.log("No open channel windows");
console.log("Window\tChannel\tTab\tType\tActive\tName");
for (const window of windows) {
for (const tab of window.tabs) {
console.log([
window.id,
window.channel.id,
tab.id,
tab.type,
tab.active ? "yes" : "no",
JSON.stringify(tab.name),
].join("\t"));
}
}
}
async function main() {
if (flags.help || !command) return console.log(HELP);
if (command !== "serve" && command !== "logs") {
openLog("cli");
log("info", "cli.command", { command, ...(ref ? { ref } : {}) });
}
switch (command) {
case "tabs":
if (positionals.length !== 1) throw new Error("ace tabs [--port ] [--json]");
return printTabs(await local({ op: "windows" }));
case "tab": {
if (ref !== "rename" || rest.length !== 4) {
throw new Error("ace tab rename [--port ] [--json]");
}
const [window, channel, tab, value] = rest as [string, string, string, string];
const name = value.trim();
if (name.length > 80) throw new Error("Tab names must be at most 80 characters");
const updated = await local({ op: "tab-rename", window, channel, tab, name });
return printTabs([updated]);
}
case "logs": {
const filter: Filter = {};
// Ids of channels this host doesn't know, such as a peer's, filter as given.
if (flags.channel) {
filter.channel = catalog.list().find((r) => r.name === flags.channel)?.id ?? flags.channel;
}
if (flags.trace) filter.trace = flags.trace;
if (flags.level) filter.level = flags.level;
if (flags.since) filter.since = since(flags.since);
if (flags.grep) filter.grep = flags.grep;
if (flags.follow) return follow(filter, !!flags.json);
print(filter, Number(flags.lines || 200), !!flags.json);
if (!flags.json) console.error(`\x1b[2m${logDir}\x1b[0m`);
return;
}
case "new": {
const dir = project(resolve(flags.project || "."));
const model = flags.model ? parseModel(flags.model) : undefined;
const record = flags.hosted
? await host(dir, model, flags.name, flags.hosted)
: catalog.create(dir, model, flags.name);
return console.log(`${record.name}\t${record.id}`);
}
case "ls": {
for (const record of catalog.list()) {
if (record.archived && !flags.all) continue;
const running = isRunning(record.id) ? "running" : "dormant";
const state = record.archived ? "archived" : running;
console.log(
[
record.name,
record.id,
state,
record.model ? `${record.model.provider}/${record.model.modelId}` : "automatic",
record.project,
].join("\t"),
);
}
return;
}
case "models": {
for (const m of await models()) console.log(`${m.provider}/${m.modelId}`);
return;
}
case "directory": {
if (ref) directory.configure(ref === "off" ? undefined : ref);
return console.log(directory.url() ?? "No directory");
}
case "web": {
if (ref) web.configure(ref === "off" ? undefined : ref);
return console.log(web.app() ?? "No web app");
}
case "model": {
if (ref) await setModel(ref === "auto" ? null : parseModel(ref));
const saved = preference();
console.log(saved ? `${saved.provider}/${saved.modelId}` : "Automatic");
if (config.model) console.log(`Overridden by ACE_MODEL=${config.model}`);
return;
}
case "open": {
const port = Number(flags.port || config.port);
const info = await health(port);
if (!info || info.home !== config.home) {
throw new Error("Start this host with ace serve first");
}
const url = appUrl(port);
url.hash = new URLSearchParams({ token: token() }).toString();
const result = Bun.spawnSync([process.platform === "darwin" ? "open" : "xdg-open", url.href]);
if (!result.success) throw new Error("Could not open Ace in your browser");
return;
}
case "serve":
return serve(Number(flags.port || config.port));
case "key": {
const name = rest[0];
if (!name || !/^[A-Z][A-Z0-9_]*$/.test(name)) {
throw new Error("ace key set|rm , such as OPENAI_API_KEY");
}
if (ref === "rm") {
return console.log(await forget(name) ? `Removed ${name}` : `No ${name} in the keychain`);
}
if (ref !== "set") throw new Error("ace key set|rm ");
// From stdin so the key stays out of shell history and the process list.
if (process.stdin.isTTY) process.stderr.write(`${name} (end with Ctrl-D): `);
const value = (await new Response(Bun.stdin.stream()).text()).trim();
if (!value) throw new Error("No key on stdin");
await store(name, value);
return console.log(`Stored ${name} in the keychain`);
}
}
if (!ref) throw new Error(`ace ${command} needs a channel`);
const record = catalog.find(ref);
const text = rest.join(" ");
switch (command) {
case "backup":
if (rest.length !== 1) throw new Error("ace backup ");
console.log(await backup(record, rest[0]!));
console.log("Channel data saved. Back up project files and lane worktrees separately.");
return;
case "info":
return console.log(JSON.stringify(await request(record.id, { op: "info" }), null, 2));
case "rename":
if (rest.length !== 1) throw new Error("ace rename ");
await request(record.id, { op: "rename", author: catalog.user, name: rest[0]! });
return;
case "say":
if (!text) throw new Error("Nothing to say");
await request(record.id, { op: "say", chat: chat(), author: catalog.user, text });
return;
case "ask": {
if (!text) throw new Error("Nothing to ask");
const selected = flags.model ? model(flags.model) : undefined;
const body = {
op: "ask",
chat: chat(),
author: catalog.user,
text,
model: selected,
} as const;
if (flags.detach) return void (await request(record.id, body));
const { connection } = await watch(record.id, undefined, false);
const { submission } = await connection.request<{ submission: number }>(body);
process.on("SIGINT", () => {
console.log(dim(`\nDetached; the run continues. Stop it with: ace stop ${record.name}`));
process.exit(130);
});
const status = await connection.request({ op: "wait", submission });
if (status === "unanswered") console.log(dim(" (stopped before answering)"));
return connection.close();
}
case "watch": {
const { connection } = await watch(record.id);
return connection.closed;
}
case "chat": {
const body = {
op: "chat",
author: catalog.user,
model: flags.model ? model(flags.model) : undefined,
} as const;
const created = await request<{ chat: number }>(record.id, body);
return console.log(created.chat);
}
case "stop":
await request(record.id, { op: "stop", chat: chat() });
return;
case "kill":
return kill(record.id);
case "share":
await request(record.id, { op: "share", author: catalog.user, shared: rest[0] === "on" });
return;
case "archive":
return void (await archive(record.id, true));
case "unarchive":
return void (await archive(record.id, false));
case "delete":
await remove(record.id);
return console.log(`Deleted ${record.name}. Its lane branches remain in ${record.project}.`);
}
throw new Error(`Unknown command ${command}\n\n${HELP}`);
}
main().then(
() => process.exit(0),
(error: Error) => {
log("error", "cli.failed", { command, ...failure(error) });
console.error(error.message);
process.exit(1);
},
);
]