Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '{
Expand Down
36 changes: 33 additions & 3 deletions backend/openbot/api/providers.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
8 changes: 6 additions & 2 deletions backend/openbot/runtime/cli_agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions backend/openbot/runtime/cli_agents/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions backend/tests/test_cli_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -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[]; }
Expand Down Expand Up @@ -88,5 +88,6 @@ export const Api = {
createScheduled: (body: { thread_id: string; content: string; due_at: string; to: string[] }) => api<ScheduledMessage>("/scheduled", { method: "POST", json: body }),
cancelScheduled: (id: string) => api<ScheduledMessage>(`/scheduled/${id}/cancel`, { method: "POST" }),
getProviders: () => api<ProvidersOut>("/providers"),
getCliAgent: (provider: string) => api<CliAgentStatus>(`/providers/cli/${encodeURIComponent(provider)}`),
getModels: (provider: string) => api<ModelsOut>(`/models?provider=${encodeURIComponent(provider)}`),
};
6 changes: 5 additions & 1 deletion frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; }
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<string, unknown>[]; release_date: string; status: string | null; task_ranks?: Record<string, number>; }
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; }
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/pages/BotEditorPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLSelectElement>("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<HTMLInputElement>("input[list=cli-models]")!.value).toBe("");
expect(el.querySelector<HTMLSelectElement>("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<HTMLSelectElement>("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<HTMLSelectElement>("select[aria-label=Access]")!.value).toBe("edit");
expect(el.querySelector<HTMLInputElement>("input[list=cli-models]")!.value).toBe("sonnet");
});
Loading
Loading