diff --git a/README.md b/README.md index 016c62c6..5cf04411 100644 --- a/README.md +++ b/README.md @@ -417,8 +417,9 @@ OpenBot-managed model, so it uses the login you already set up for that CLI. The threads, streams into the run card, hands off with `@mentions` and can be stopped like any other. Claude Code is supported; other CLIs can be added as adapters (`backend/openbot/runtime/cli_agents`). -Install the CLI and log in once in a terminal, then create the bot through the API (there is no -editor option for it yet): +Install the CLI and log in once in a terminal. The bot editor then offers Claude Code as a provider, +with the model and access level below, and shows where it found the CLI, its version and whether it +is logged in (as `claude auth status` reports it). The same through the API: ```bash curl -X POST http://127.0.0.1:8000/api/v1/bots -H 'Content-Type: application/json' -d '{ diff --git a/backend/openbot/api/providers.py b/backend/openbot/api/providers.py index 3f5fba94..da517900 100644 --- a/backend/openbot/api/providers.py +++ b/backend/openbot/api/providers.py @@ -1,6 +1,8 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from openbot.api.deps import get_services +from openbot.runtime.cli_agents import ADAPTERS, CliAgentError, adapter_for +from openbot.runtime.cli_agents.base import check_version, child_env, find_executable, login_status from openbot.runtime.providers import list_ollama_models, provider_configured, provider_status from openbot.services import Services @@ -12,5 +14,33 @@ async def providers(services: Services = Depends(get_services)): st = services.settings emb_provider = st.embedding_model.split(":", 1)[0] ollama_models = await list_ollama_models(st, getattr(services, "http_client", None)) - return {"providers": provider_status(st, ollama_models), "embedding_model": st.embedding_model, - "embeddings_configured": provider_configured(st, emb_provider)} + # Only a file lookup; the CLI itself runs in cli_agent below, once the editor asks for it. + cli_agents = [{"id": a.provider, "label": a.label, "found": find_executable(a, st) is not None, + "models": list(a.models)} for a in ADAPTERS.values()] + return {"providers": provider_status(st, ollama_models), "cli_agents": cli_agents, + "embedding_model": st.embedding_model, "embeddings_configured": provider_configured(st, emb_provider)} + + +@router.get("/cli/{provider}") +async def cli_agent(provider: str, services: Services = Depends(get_services)): + """Where the CLI is, its version and whether it is logged in. The login state is the exit code of + the CLI's own status command; its output is not passed on.""" + adapter = adapter_for(provider) + if adapter is None: + raise HTTPException(404, "not a CLI provider") + out = {"id": adapter.provider, "label": adapter.label, "path": find_executable(adapter, services.settings), + "version": None, "logged_in": None, "problem": None} + if out["path"] is None: + return {**out, "problem": adapter.not_found.removeprefix("error: ")} + env = child_env() + try: + version = await check_version(adapter, out["path"], env) + except CliAgentError as e: + return {**out, "problem": str(e).removeprefix("error: ")} + except OSError as e: + return {**out, "problem": f"{adapter.label} could not be started: {e}"} + out["version"] = ".".join(map(str, version)) if version else None + out["logged_in"] = await login_status(adapter, out["path"], env) + if out["logged_in"] is False: + out["problem"] = f"{adapter.label} is not logged in. {adapter.login_hint}".rstrip() + return out diff --git a/backend/openbot/runtime/cli_agents/base.py b/backend/openbot/runtime/cli_agents/base.py index 0befc519..342fcd0b 100644 --- a/backend/openbot/runtime/cli_agents/base.py +++ b/backend/openbot/runtime/cli_agents/base.py @@ -126,6 +126,7 @@ class CliAdapter(ABC): # The CLI's own "am I logged in" command; its exit code is the answer (0 means yes). status_argv: tuple[str, ...] = () login_hint: str = "" + models: tuple[str, ...] = () # suggestions for the editor; any model the CLI accepts works session_id_re: re.Pattern[str] = re.compile(r"^[0-9a-fA-F-]{8,64}$") def install_dirs(self, *, home: str, env: Mapping[str, str], win: bool) -> list[str]: @@ -310,9 +311,11 @@ async def login_status(adapter: CliAdapter, executable: str, env: dict[str, str] _versions: dict[tuple[str, float], tuple[int, int, int]] = {} -async def check_version(adapter: CliAdapter, executable: str, env: dict[str, str]) -> None: +async def check_version(adapter: CliAdapter, executable: str, env: dict[str, str]) -> tuple[int, int, int] | None: + """The installed version, or None for a CLI without a minimum. Raises CliAgentError when it is too old + or cannot be read.""" if adapter.min_version == (0, 0, 0): - return + return None try: key = (executable, os.path.getmtime(executable)) except OSError: @@ -330,6 +333,7 @@ async def check_version(adapter: CliAdapter, executable: str, env: dict[str, str if _versions[key] < adapter.min_version: raise CliAgentError(f"error: {adapter.label} {'.'.join(map(str, _versions[key]))} is too old; OpenBot needs " f"{'.'.join(map(str, adapter.min_version))} or later. {adapter.update_hint}".rstrip()) + return _versions[key] async def _tail(stream: asyncio.StreamReader | None) -> str: diff --git a/backend/openbot/runtime/cli_agents/claude_code.py b/backend/openbot/runtime/cli_agents/claude_code.py index 6fa54b2e..36e1d91d 100644 --- a/backend/openbot/runtime/cli_agents/claude_code.py +++ b/backend/openbot/runtime/cli_agents/claude_code.py @@ -177,6 +177,7 @@ class ClaudeCode(CliAdapter): update_hint = "Update it with `claude update`." status_argv = ("auth", "status") login_hint = "Run `claude auth login` in a terminal." + models = ("sonnet", "opus", "haiku") def install_dirs(self, *, home: str, env: Mapping[str, str], win: bool) -> list[str]: p = ntpath if win else posixpath diff --git a/backend/tests/test_cli_agents.py b/backend/tests/test_cli_agents.py index adae2ff1..364d3737 100644 --- a/backend/tests/test_cli_agents.py +++ b/backend/tests/test_cli_agents.py @@ -750,5 +750,43 @@ async def test_api_accepts_cli_bots_without_a_model_and_checks_their_settings(cl assert bad.status_code == 422 and "model" in bad.text +async def test_providers_lists_the_cli_by_whether_it_is_found_without_running_it(client, settings, fake, tmp_path): + home, exe = fake + settings.claude_code_path = str(tmp_path / "no-claude") + r = await client.get("/api/v1/providers") + assert r.json()["cli_agents"] == [{"id": "claude-code", "label": "Claude Code", "found": False, + "models": ["sonnet", "opus", "haiku"]}] + assert "claude-code" not in {p["id"] for p in r.json()["providers"]} + settings.claude_code_path = str(exe) + assert (await client.get("/api/v1/providers")).json()["cli_agents"][0]["found"] is True + assert not (home / "versions.txt").exists() + + +async def test_cli_status_reports_path_version_and_login(client, settings, fake): + home, exe = fake + settings.claude_code_path = str(exe) + r = await client.get("/api/v1/providers/cli/claude-code") + assert r.status_code == 200 + assert r.json() == {"id": "claude-code", "label": "Claude Code", "path": str(exe), "version": "2.1.283", + "logged_in": True, "problem": None} + (home / "auth_exit.txt").write_text("1") + r = await client.get("/api/v1/providers/cli/claude-code") + assert r.json()["logged_in"] is False and r.json()["problem"] == "Claude Code is not logged in. Run `claude auth login` in a terminal." + assert (await client.get("/api/v1/providers/cli/openai")).status_code == 404 + + +async def test_cli_status_says_why_the_cli_is_unusable(client, settings, fake, tmp_path, monkeypatch): + home, exe = fake + settings.claude_code_path = str(tmp_path / "no-claude") + r = (await client.get("/api/v1/providers/cli/claude-code")).json() + assert r["path"] is None and r["problem"].startswith("Claude Code CLI (claude) not found") and "CLAUDE_CODE_PATH" in r["problem"] + settings.claude_code_path = str(exe) + monkeypatch.setenv("FAKE_CLAUDE_VERSION", "2.1.100 (Claude Code)") + (home / "auth_exit.txt").write_text("1") + r = (await client.get("/api/v1/providers/cli/claude-code")).json() + assert r["version"] is None and r["logged_in"] is None + assert r["problem"] == "Claude Code 2.1.100 is too old; OpenBot needs 2.1.248 or later. Update it with `claude update`." + + def test_settings_default_timeout(settings): assert settings.cli_agent_timeout == 1800 and settings.claude_code_path is None diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bb1175b7..9472e384 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Actor, AppSetting, Bot, BotCatalogEntry, DirectoryListing, McpCatalogEntry, McpConnectResult, McpServer, McpServerInput, BotInboxItem, BotInput, BotMemory, BotScratchpad, InboxItem, Message, ModelsOut, ProvidersOut, PurgeResult, Run, RunDetail, SetupStatus, SkillInfo, Thread, ThreadDetail, ThreadUsage, ToolInfo, DatabaseLocation } from "./types"; +import type { Actor, AppSetting, Bot, BotCatalogEntry, DirectoryListing, McpCatalogEntry, McpConnectResult, McpServer, McpServerInput, BotInboxItem, BotInput, BotMemory, BotScratchpad, InboxItem, Message, ModelsOut, ProvidersOut, CliAgentStatus, PurgeResult, Run, RunDetail, SetupStatus, SkillInfo, Thread, ThreadDetail, ThreadUsage, ToolInfo, DatabaseLocation } from "./types"; /** Outgoing attachment for postMessage: a data URL plus an optional display name. */ export interface ScheduledMessage { id: string; thread_id: string; content: string; due_at: string; status: string; attempts: number; last_error?: string | null; result_message_id?: string | null; to_handles: string[]; } @@ -88,5 +88,6 @@ export const Api = { createScheduled: (body: { thread_id: string; content: string; due_at: string; to: string[] }) => api("/scheduled", { method: "POST", json: body }), cancelScheduled: (id: string) => api(`/scheduled/${id}/cancel`, { method: "POST" }), getProviders: () => api("/providers"), + getCliAgent: (provider: string) => api(`/providers/cli/${encodeURIComponent(provider)}`), getModels: (provider: string) => api(`/models?provider=${encodeURIComponent(provider)}`), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f9ecc3bc..38ca3f15 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -32,7 +32,11 @@ export interface PurgeResult { cancelled_runs: number; purged_items: number; } export interface ToolInfo { name: string; description: string; source: string; args_schema: Record; } export interface SkillInfo { id: string; name: string; description: string; path: string; } export interface ProviderInfo { id: string; configured: boolean; models: string[]; default_model: string; } -export interface ProvidersOut { providers: ProviderInfo[]; embedding_model: string; embeddings_configured: boolean; } +/** A coding agent CLI a bot can run on; `found` is only a file lookup. */ +export interface CliAgentInfo { id: string; label: string; found: boolean; models: string[]; } +export interface ProvidersOut { providers: ProviderInfo[]; cli_agents?: CliAgentInfo[]; embedding_model: string; embeddings_configured: boolean; } +/** GET /providers/cli/{id}: what running the CLI found out, or why it can't run. */ +export interface CliAgentStatus { id: string; label: string; path: string | null; version: string | null; logged_in: boolean | null; problem: string | null; } export interface CatalogModel { id: string; name: string; family: string; description: string; reasoning: boolean; effort_levels: string[]; image_input: boolean; context: number | null; output: number | null; cost_input: number | null; cost_output: number | null; cost_cache_read?: number | null; cost_cache_write?: number | null; cost_tiers?: Record[]; release_date: string; status: string | null; task_ranks?: Record; } export interface ModelsOut { provider: string; configured: boolean; source: "catalog" | "builtin"; stale: boolean; fetched_at: string | null; models: CatalogModel[]; } export interface BusEvent { event: string; thread_id: string | null; data: any; } diff --git a/frontend/src/pages/BotEditorPage.test.tsx b/frontend/src/pages/BotEditorPage.test.tsx index 9613aecb..61bd7df1 100644 --- a/frontend/src/pages/BotEditorPage.test.tsx +++ b/frontend/src/pages/BotEditorPage.test.tsx @@ -167,3 +167,57 @@ it("lists discovered skills and saves the enabled ones", async () => { await until(() => update.mock.calls.length > 0); expect(update.mock.calls[0][1].skill_names).toEqual(["py"]); }); + +const pick = (select: HTMLSelectElement, value: string) => act(async () => { + Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(select, value); + select.dispatchEvent(new Event("change", { bubbles: true })); +}); +const providerSelect = () => el.querySelectorAll("select")[0]; +const withCli = (found: boolean): ProvidersOut => ({ ...providers, + providers: providers.providers.map((p) => (p.id === "openrouter" ? { ...p, configured: true } : p)), + cli_agents: [{ id: "claude-code", label: "Claude Code", found, models: ["sonnet", "opus"] }] }); +const ready = { id: "claude-code", label: "Claude Code", path: "/usr/local/bin/claude", version: "2.1.283", logged_in: true, problem: null }; + +it("offers Claude Code only when the CLI is found, and replaces the tools with its access level", async () => { + vi.mocked(Api.getProviders).mockResolvedValue(withCli(false)); + const status = vi.spyOn(Api, "getCliAgent").mockResolvedValue(ready); + vi.spyOn(Api, "getBot").mockResolvedValue({ ...bot("b1", "Opus"), provider: "anthropic", model: "claude-opus-5-5" }); + await show("/edit/b1"); + await until(() => nameInput() === "Opus" && qc.getQueryData(["providers"]) !== undefined); + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + expect([...providerSelect().options].map((o) => o.value)).not.toContain("claude-code"); + + vi.mocked(Api.getProviders).mockResolvedValue(withCli(true)); + qc.clear(); + await show("/edit/b1"); + await until(() => [...providerSelect()?.options ?? []].some((o) => o.value === "claude-code")); + expect(status).not.toHaveBeenCalled(); + await pick(providerSelect(), "claude-code"); + await until(() => el.textContent!.includes("Claude Code 2.1.283 at /usr/local/bin/claude, logged in.")); + expect(el.querySelector("input[list=cli-models]")!.value).toBe(""); + expect(el.querySelector("select[aria-label=Access]")!.value).toBe("read-only"); + expect(el.textContent).toContain("Changes nothing, runs no commands."); + expect(el.textContent).not.toContain("run_shell"); + expect(el.textContent).not.toContain("default OpenRouter model"); + expect(effortSelect()).toBeNull(); + + const update = vi.spyOn(Api, "updateBot").mockImplementation(async (id, b) => ({ ...bot(id, "Opus"), ...b } as Bot)); + await pick(el.querySelector("select[aria-label=Access]")!, "edit"); + expect(el.textContent).toContain("Reads and changes files in the thread's working directory. Runs no commands."); + await submit(); + await until(() => update.mock.calls.length > 0); + expect(update.mock.calls[0][1]).toMatchObject({ provider: "claude-code", model: "", model_settings: { permission: "edit" } }); +}); + +it("says why a CLI bot cannot run, also when the CLI is gone", async () => { + vi.mocked(Api.getProviders).mockResolvedValue(withCli(false)); + vi.spyOn(Api, "getCliAgent").mockResolvedValue({ ...ready, path: null, version: null, logged_in: null, + problem: "Claude Code CLI (claude) not found. Install it, log in once in a terminal, then restart OpenBot." }); + vi.spyOn(Api, "getBot").mockResolvedValue({ ...bot("b1", "Coder"), provider: "claude-code", model: "sonnet", model_settings: { permission: "edit" } }); + await show("/edit/b1"); + await until(() => el.textContent!.includes("Claude Code CLI (claude) not found.")); + expect(providerSelect().value).toBe("claude-code"); + expect(providerSelect().selectedOptions[0].textContent).toBe("Claude Code (not found)"); + expect(el.querySelector("select[aria-label=Access]")!.value).toBe("edit"); + expect(el.querySelector("input[list=cli-models]")!.value).toBe("sonnet"); +}); diff --git a/frontend/src/pages/BotEditorPage.tsx b/frontend/src/pages/BotEditorPage.tsx index a71f802f..59eaf6ce 100644 --- a/frontend/src/pages/BotEditorPage.tsx +++ b/frontend/src/pages/BotEditorPage.tsx @@ -30,6 +30,9 @@ const Toggle = ({ checked, onChange, children }: { checked: boolean; onChange: ( ); +// The CLI hints quote commands in backticks. +const withCode = (text: string) => text.split("`").map((part, i) => (i % 2 ? {part} : part)); + const empty: BotInput = { handle: "", name: "", description: "", icon: DEFAULT_BOT_ICON, instructions: "", provider: "auto", model: "", model_settings: {}, tool_names: [], approval_tools: [], skill_names: [], memory_enabled: true, enabled: true }; @@ -91,6 +94,11 @@ export default function BotEditorPage() { const isAuto = form.provider === "auto"; const auto = providers.data?.providers.find((p) => p.id === "auto"); const prov = providers.data?.providers.find((p) => p.id === form.provider); + const cliAgents = providers.data?.cli_agents ?? []; + const cli = cliAgents.find((a) => a.id === form.provider); + // Runs the CLI (version, login), so only once a bot is on it; refetched on focus, e.g. after a login. + const cliStatus = useQuery({ queryKey: ["cli-agent", cli?.id], queryFn: () => Api.getCliAgent(cli!.id), enabled: !!cli }); + const permission = form.model_settings.permission === "edit" ? "edit" : "read-only"; // The model whose effort levels apply: the bot's own, or for "auto" whatever the server resolves to. const autoTarget = isAuto ? splitAutoDefault(auto?.default_model) : null; const effortProvider = isAuto ? autoTarget?.provider : form.provider; @@ -108,7 +116,7 @@ export default function BotEditorPage() { // effective_bot_profile runs a bot on its own provider and model; only a cloud bot whose provider has // no key falls back to the default OpenRouter model, when OpenRouter is configured. const configured = (id: string) => providers.data?.providers.find((p) => p.id === id)?.configured ?? false; - const rerouted = !isAuto && form.provider !== "ollama" && form.provider !== "openrouter" + const rerouted = !isAuto && !cli && form.provider !== "ollama" && form.provider !== "openrouter" && !configured(form.provider) && configured("openrouter"); const grouped = groupTools(tools.data?.tools ?? []); const unavailable = unavailableGrants(form.tool_names, tools.data?.tools ?? []); @@ -160,7 +168,7 @@ export default function BotEditorPage() {