diff --git a/apps/desktop/src/main/host.ts b/apps/desktop/src/main/host.ts index 0ad6684..a1e8814 100644 --- a/apps/desktop/src/main/host.ts +++ b/apps/desktop/src/main/host.ts @@ -17,6 +17,7 @@ import { type SystemInfo, wsClientTransport, } from "@ateam/protocol"; +import { getAgent } from "@ateam/agents"; import { buildCloudInit, type ConnectionDTO, @@ -38,6 +39,7 @@ import { type CreateProgressEvent, HOST_CH, type HostStatus, + type InstallAgentResult, type InstallLogEvent, type ProviderOptions, type SecretsStatus, @@ -108,6 +110,8 @@ export interface Host { install(dest: string, opts?: { wsAddr?: string }): Promise; /** Create a box from scratch at a provider, provision it, and connect. */ createBox(spec: CreateBoxSpec): Promise; + /** Install an agent's CLI on a connected box, streaming the log; returns the login step. */ + installAgent(alias: string, agentId: string): Promise; /** Which provisioning secrets are saved (booleans, never the values). */ secretsStatus(): SecretsStatus; /** Persist provider credentials (encrypted). Returns the new saved-status. */ @@ -400,7 +404,42 @@ export function createHost({ localEngine, broadcast }: HostDeps): Host { // Reuse the streamed installer (Gap A): it derives the box's tailnet IP into // ATEAM_WS_ADDR (so the phone can connect) and connects on success. progress("Installing the engine"); - return install(boxAlias); + const status = await install(boxAlias); + + // Preinstall any requested agent CLIs (best-effort — the box is usable without + // them, and the OAuth login is a separate step the user does after). + for (const agentId of spec.agents ?? []) { + progress(`Installing ${getAgent(agentId)?.label ?? agentId}`); + try { + await installAgent(boxAlias, agentId); + } catch (err) { + progress( + `Couldn't install ${agentId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + return status; + } + + async function installAgent(alias: string, agentId: string): Promise { + const agent = getAgent(agentId); + if (!agent?.install) throw new Error(`Don't know how to install "${agentId}".`); + if (!backends.has(alias)) throw new Error(`Not connected to "${alias}".`); + // Run the official installer in a login shell (PATH/profile set up), then confirm + // the binary is on the login PATH the daemon will actually spawn it from. + const remote = `bash -lc '${agent.install} && command -v ${agent.bin}'`; + const result = await sshExec(alias, remote, { + onData: (chunk) => + broadcast(HOST_CH.evtInstallLog, { dest: alias, chunk } satisfies InstallLogEvent), + }); + if (result.code !== 0) { + throw new Error( + `Installing ${agent.label} on "${alias}" failed (exit ${result.code ?? "on a signal"}) — it may have installed but not landed on the login PATH.`, + ); + } + // The box's agent list now includes it — refresh so the composer's env-agents update. + broadcastConnections(); + return { agentId, loginCommand: agent.loginCommand }; } async function provision(alias: string, input: { cloneUrl: string }): Promise { @@ -427,6 +466,7 @@ export function createHost({ localEngine, broadcast }: HostDeps): Host { provision, install, createBox, + installAgent, secretsStatus, saveSecrets, providerOptions, @@ -447,6 +487,9 @@ export function registerHostIpc(host: Host): void { host.install(dest, opts), ); ipcMain.handle(HOST_CH.createBox, (_e, spec: CreateBoxSpec) => host.createBox(spec)); + ipcMain.handle(HOST_CH.installAgent, (_e, alias: string, agentId: string) => + host.installAgent(alias, agentId), + ); ipcMain.handle(HOST_CH.secretsStatus, () => host.secretsStatus()); ipcMain.handle( HOST_CH.saveSecrets, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 76c875f..6f567d5 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -125,6 +125,7 @@ const host: AteamHost = { provision: (alias, input) => ipcRenderer.invoke(HOST_CH.provision, alias, input), install: (dest, opts) => ipcRenderer.invoke(HOST_CH.install, dest, opts), createBox: (spec) => ipcRenderer.invoke(HOST_CH.createBox, spec), + installAgent: (alias, agentId) => ipcRenderer.invoke(HOST_CH.installAgent, alias, agentId), secretsStatus: () => ipcRenderer.invoke(HOST_CH.secretsStatus), saveSecrets: (patch) => ipcRenderer.invoke(HOST_CH.saveSecrets, patch), providerOptions: (token) => ipcRenderer.invoke(HOST_CH.providerOptions, token), diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 7f4b52c..71fc15e 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -391,6 +391,20 @@ export function App() { off(); } }, []); + // Install a coding agent's CLI on a connected box (streamed via the same install log). + const installAgentOnBox = useCallback( + async (alias: string, agentId: string, onLog: (chunk: string) => void) => { + const off = window.ateamHost.onInstallLog((e) => { + if (e.dest === alias) onLog(e.chunk); + }); + try { + return await window.ateamHost.installAgent(alias, agentId); + } finally { + off(); + } + }, + [], + ); const canRemote = activeRepoRemote !== null; const hasLocalMember = activeMembers.some((m) => m.alias === null); const composerEnvs = useMemo(() => { @@ -1083,6 +1097,7 @@ export function App() { envAgents={envAgents} onAdd={addTailscaleBox} onInstall={installBox} + onInstallAgent={installAgentOnBox} onClose={() => setComposerOpen(false)} onCreate={composeTask} /> diff --git a/apps/desktop/src/renderer/src/components/AgentPicker.tsx b/apps/desktop/src/renderer/src/components/AgentPicker.tsx new file mode 100644 index 0000000..a1583fe --- /dev/null +++ b/apps/desktop/src/renderer/src/components/AgentPicker.tsx @@ -0,0 +1,177 @@ +import { Check, Download } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { AgentDTO } from "@ateam/protocol"; + +// The composer's coding-agent control — the pill + popover the environment picker +// uses, so a *missing* agent on the selected box can be installed right here instead +// of being a dead "(not installed)" option. Installing runs the agent's official +// installer on the box over SSH (streamed); the one-time OAuth login is a follow-up +// the user runs in the box's terminal. + +const POP_W = 260; + +export function AgentPicker({ + agents, + value, + onChange, + isAvailable, + alias, + onInstallAgent, +}: { + agents: AgentDTO[]; + value: string; + onChange: (agentId: string) => void; + isAvailable: (agentId: string) => boolean; + /** The selected environment — install targets this box; null (local) can't install. */ + alias: string | null; + /** Install an agent on the box, streaming log lines; resolves with the login step. */ + onInstallAgent?: ( + alias: string, + agentId: string, + onLog: (chunk: string) => void, + ) => Promise<{ loginCommand?: string }>; +}) { + const [pos, setPos] = useState<{ bottom: number; left: number } | null>(null); + const [installing, setInstalling] = useState(null); + const [log, setLog] = useState(""); + const [error, setError] = useState(null); + const [loginFor, setLoginFor] = useState<{ agentId: string; command?: string } | null>(null); + const btnRef = useRef(null); + const popRef = useRef(null); + const logRef = useRef(null); + + const current = agents.find((a) => a.id === value); + const label = current?.label ?? value; + + const close = () => setPos(null); + const open = () => { + const r = btnRef.current?.getBoundingClientRect(); + if (!r) return; + const left = Math.max(8, Math.min(r.left, window.innerWidth - POP_W - 8)); + setPos({ bottom: window.innerHeight - r.top + 6, left }); + }; + + useEffect(() => { + if (!pos) return; + const onDoc = (e: MouseEvent) => { + const t = e.target as Node; + if (btnRef.current?.contains(t) || popRef.current?.contains(t)) return; + close(); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [pos]); + useEffect(() => { + if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; + }, [log]); + + const install = async (agentId: string) => { + if (!alias || !onInstallAgent || installing) return; + setInstalling(agentId); + setError(null); + setLog(""); + setLoginFor(null); + try { + const res = await onInstallAgent(alias, agentId, (chunk) => setLog((l) => l + chunk)); + // The box's agent list refreshes via onConnectionsChanged; select it now. + onChange(agentId); + setLoginFor({ agentId, command: res.loginCommand }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setInstalling(null); + } + }; + + return ( + <> + + {pos && + createPortal( +
+
+ Coding agent +
+ {agents.map((a) => { + const avail = isAvailable(a.id); + const canInstall = !avail && alias !== null && !!onInstallAgent; + return ( +
+ + {canInstall ? ( + + ) : null} + {installing === a.id && log ? ( +
+											{log}
+										
+ ) : null} + {loginFor?.agentId === a.id ? ( +
+ Installed ✓ — sign in on the box:{" "} + {loginFor.command ?? `${a.id} login`} +
+ ) : null} +
+ ); + })} + {error ?
{error}
: null} +
, + document.body, + )} + + ); +} diff --git a/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx b/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx index b4cf493..5bccd67 100644 --- a/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx +++ b/apps/desktop/src/renderer/src/components/CreateBoxDialog.tsx @@ -1,5 +1,6 @@ import { X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import type { AgentDTO } from "@ateam/protocol"; import type { ProviderOptions } from "../../../shared/host"; import { HetznerLogo } from "./HetznerLogo"; @@ -29,6 +30,8 @@ export function CreateBoxDialog({ const [stages, setStages] = useState([]); const [log, setLog] = useState(""); const [error, setError] = useState(null); + const [agents, setAgents] = useState([]); + const [preinstall, setPreinstall] = useState([]); const logRef = useRef(null); const loadOptions = async () => { @@ -51,6 +54,7 @@ export function CreateBoxDialog({ // A saved token means we can show real availability immediately. if (s.hetznerToken) void loadOptions(); }); + void window.ateam.agents.list().then(setAgents); // biome-ignore lint/correctness/useExhaustiveDependencies: run once on open }, []); useEffect(() => { @@ -92,6 +96,7 @@ export function CreateBoxDialog({ size, hetznerToken: token.trim() || undefined, tailscaleAuthKey: tsKey.trim() || undefined, + agents: preinstall.length ? preinstall : undefined, }); // A created box is always a remote engine (never the local null alias). if (status.alias) onDone(status.alias); @@ -120,7 +125,10 @@ export function CreateBoxDialog({
    {stages.map((s, i) => ( -
  • +
  • {s}
  • ))} @@ -168,7 +176,9 @@ export function CreateBoxDialog({ className="cb-input" type="password" value={token} - placeholder={saved.hetznerToken ? "saved ✓ — leave blank to reuse" : "paste your token"} + placeholder={ + saved.hetznerToken ? "saved ✓ — leave blank to reuse" : "paste your token" + } // A new token means a possibly different account — reload availability. onChange={(e) => { setToken(e.target.value); @@ -182,7 +192,9 @@ export function CreateBoxDialog({ className="cb-input" type="password" value={tsKey} - placeholder={saved.tailscaleAuthKey ? "saved ✓ — leave blank to reuse" : "tskey-auth-…"} + placeholder={ + saved.tailscaleAuthKey ? "saved ✓ — leave blank to reuse" : "tskey-auth-…" + } onChange={(e) => setTsKey(e.target.value)} /> @@ -205,7 +217,11 @@ export function CreateBoxDialog({ + {agents.length > 0 && ( +
    + Preinstall agents (optional) +
    + {agents.map((a) => ( + + ))} +
    + You sign in (OAuth) on the box afterward. +
    + )} {error &&
    {error}
    } - + onChange={setAgentId} + isAvailable={isAvail} + alias={alias} + onInstallAgent={onInstallAgent} + /> ; + /** Install an agent's CLI on a connected box (streamed via onInstallLog), then + * return the one-time OAuth login to run on the box. */ + installAgent(alias: string, agentId: string): Promise; /** Which provisioning secrets are already saved (booleans, never the values). */ secretsStatus(): Promise; /** Persist provider credentials (encrypted at rest). Empty string clears one. */ diff --git a/packages/agents/src/registry.ts b/packages/agents/src/registry.ts index feb3c34..07733d7 100644 --- a/packages/agents/src/registry.ts +++ b/packages/agents/src/registry.ts @@ -34,6 +34,11 @@ export interface AgentDefinition { agentsCommand?: string; /** How an initial task prompt is delivered (if supported). */ promptTransport?: PromptTransport; + /** Non-interactive command that installs this agent's CLI on a box (run in a + * login shell over SSH). Omitted if we don't know how to install it. */ + install?: string; + /** The one-time OAuth login the user runs on the box after install (browser flow). */ + loginCommand?: string; } // Registry of the supported agent CLIs. Command lines and the YOLO bypass @@ -50,6 +55,8 @@ export const AGENTS = [ yoloFlag: "--permission-mode auto", resumeCommand: "claude --continue", agentsCommand: "claude agents", + install: "curl -fsSL https://claude.ai/install.sh | bash", + loginCommand: "claude login", }, { id: "codex", @@ -59,6 +66,8 @@ export const AGENTS = [ command: "codex", yoloFlag: "--dangerously-bypass-approvals-and-sandbox", resumeCommand: "codex resume --last", + install: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", + loginCommand: "codex login", }, { id: "opencode", @@ -67,6 +76,8 @@ export const AGENTS = [ bin: "opencode", command: "opencode", resumeCommand: "opencode --continue", + install: "curl -fsSL https://opencode.ai/install | bash", + loginCommand: "opencode auth login", }, ] as const satisfies readonly AgentDefinition[];