Skip to content
Open
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
224 changes: 224 additions & 0 deletions .planning/claude-code-provider/PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# Plan — `claude-code` Provider for OpenHuman

**Owner:** jamie · **Status:** Locked v1 · **Branch:** `feat/claude-code-provider`

## 1. Goal

Add `claude-code` as a selectable LLM provider in OpenHuman that drives Anthropic's `claude` CLI (`--output-format stream-json --verbose --print --resume`) instead of calling the Anthropic HTTP API directly. Existing API providers stay. Native OpenHuman tools remain Rust-side and are exposed to the CLI over MCP so CC can call them.

Reference implementation: `C:\Users\artic\GitHub\opencode` — `packages/opencode/src/provider/claude-code/`.

## 2. Non-goals (v1)

- Subscription/OAuth auth (Claude Pro/Max) — defer to v2. v1 uses `ANTHROPIC_API_KEY` and any pre-existing `~/.claude/.credentials.json`.
- Exposing **write** tools (memory mutation, channel send, etc.) via MCP — defer to v1.1 after threat model.
- Co-enabling CC's built-in tools (`Bash`/`Read`/`Edit`) — disabled in v1 via `--disallowedTools`.
- Cost accounting wired into `cost.rs` — defer to v1.1.
- Process pool / cold-spawn optimization — defer to v2 if needed.

## 3. Architecture (confirmed via Backend Architect review)

```
Frontend ──invoke──> Tauri shell ──HTTP+bearer──> openhuman-core (Axum :7788)
├─ /rpc (existing JSON-RPC)
└─ /mcp (NEW — MCP server, SSE)
│ mcp__openhuman__*
ChatRequest ──Provider::chat──> ClaudeCodeProvider ──spawn──> `claude --print
--output-format stream-json
--verbose --resume <uuid>
--mcp-config <tmp.json>
--disallowedTools <CC builtins>`
▲ │
SSE+bearer │ stdout JSONL
stream_parser ─→ event_mapper
ProviderDelta stream
→ harness turn loop
```

**Key files (existing, do not invent):**
- `src/openhuman/inference/provider/traits.rs` — `Provider` trait, `ProviderDelta`, `ToolsPayload`, `ChatRequest`.
- `src/openhuman/inference/provider/factory.rs` — `create_chat_provider_from_string(role, provider, config)`. String-grammar dispatch.
- `src/openhuman/inference/provider/openhuman_backend.rs` — reference impl with auth.
- `src/openhuman/inference/provider/compatible.rs` — reference impl with streaming + Anthropic-style auth.
- `src/openhuman/config/schema/cloud_providers.rs` — `CloudProviderType`, `AuthStyle`.
- `src/core/` — Axum server, bearer auth middleware, existing `/rpc` route.

## 4. Module layout

### 4.1 Provider

```
src/openhuman/inference/provider/claude_code/
mod.rs — pub struct ClaudeCodeProvider; impl Provider for ...
driver.rs — process spawn, stdin/stdout/stderr piping, kill-on-drop,
tokio::sync::Semaphore(4) concurrency cap
stream_parser.rs — line-buffered JSONL → ClaudeCodeEvent
event_mapper.rs — ClaudeCodeEvent → ProviderDelta + tool-call accumulator
session_store.rs — ThreadId ↔ CC session UUID, persisted under config dir
input_builder.rs — ChatRequest → CLI argv + stdin payload
mcp_config.rs — generate per-launch mcp-config JSON (bearer + url),
write to temp, delete on drop
version_check.rs — `claude --version` parse + MIN_VERSION gate
auth.rs — API key resolution: env > config > ~/.claude/.credentials.json
schemas.rs — serde types for CC's stream-json envelope
types.rs — internal types
tests/
fixtures/ — canned JSONL transcripts pulled from opencode fork's test fixtures
parser.rs — golden tests on each fixture
mapper.rs — event→delta correctness
driver.rs — spawn happy-path + version-fail + missing-binary
```

### 4.2 MCP server (sibling, not under provider)

```
src/openhuman/mcp_server/
mod.rs — Axum sub-router mounted at /mcp on core HTTP
transport.rs — SSE transport (MCP HTTP server protocol)
tool_registry.rs — bridge to existing tool dispatch
schemas.rs — MCP wire types
bus.rs — EventBus subscriber for tool-result fan-out
tests/
```

Wire mount in `src/core/all.rs` next to JSON-RPC route. Reuses existing bearer-auth middleware — **no new auth surface**.

### 4.3 Config

Add to `src/openhuman/config/schema/cloud_providers.rs`:
- `CloudProviderType::ClaudeCode`
- Fields: `binary_path: Option<PathBuf>`, `min_version: String`, `disallowed_builtins: Vec<String>` (defaults to all of CC's built-in tool names).

### 4.4 RPC additions

New controller methods (per AGENTS.md `RpcOutcome<T>` contract, exposed via registry):
- `openhuman.claude_code_status` → `{ installed, version, path, min_satisfied, auth_state, last_error }`
- `openhuman.claude_code_check_version` — re-probe `claude --version`
- `openhuman.claude_code_set_auth` — store API key in credentials domain
- Extend `openhuman.providers_list` to surface CC entry with `requires_external_binary: true`

Per layout rule, these live in `src/openhuman/inference/rpc.rs` extension (or new `inference/claude_code_rpc.rs`).

### 4.5 Frontend

Files under `app/src/`:
- `app/src/components/settings/ProviderSettings/ClaudeCodeSection.tsx` — install status, install instructions, API key input, version display.
- `app/src/components/settings/ProviderSettings/index.tsx` — add picker entry.
- `app/src/services/api/claudeCode.ts` — thin RPC wrappers.
- `app/src/store/slices/claudeCodeSlice.ts` — status state.

## 5. Provider dispatch grammar

`factory.rs::create_chat_provider_from_string`:
- New arm matches `"claude-code:<model>[@<temp>]"` (e.g. `claude-code:sonnet-4-5`, `claude-code:opus-4-7@0.7`).
- Model string passed verbatim to `--model`.
- Temperature → input payload (CC stream-json supports it in the input message).

Existing `provider_for_role` reading `chat_provider`, `agentic_provider`, etc., now resolves CC for any role.

## 6. Tool exposure via MCP

**v1 surface (read-only safe subset)** — to be confirmed once we read the existing tool registry:
- `memory_search`, `memory_get`
- `threads_list`, `threads_get`, `threads_messages`
- `channels_list`, `channels_messages_read`
- `people_search`, `people_get`
- `webhooks_list`

CC auto-prefixes MCP tools → CC sees them as `mcp__openhuman__memory_search` etc. **No collision risk** with CC built-ins.

CC built-ins (`Bash`, `Read`, `Write`, `Edit`, `Grep`, `Glob`, `WebFetch`, `WebSearch`, `Task`, `TodoWrite`, etc.) disabled via `--disallowedTools` for v1.

## 7. Auth (v1)

`auth.rs` resolution order:
1. `ChatRequest`/Config explicit key (per-thread/per-agent override)
2. `ANTHROPIC_API_KEY` env
3. `~/.claude/.credentials.json` (read-only — never write it; if present, set `ANTHROPIC_API_KEY` in spawned process env)
4. None → `claude_code_status.auth_state = "missing"`, provider returns clear error on `chat()`

API key set per-process via env var on spawn (`Command::env`), not as CLI arg (would leak in process listings).

## 8. Concurrency & lifecycle

- One CC process per turn (`--print` exits after assistant response). Reuse session UUID across turns via `--resume`.
- Global `Semaphore(4)` in `driver.rs` to cap concurrent processes.
- `Child` wrapped in a guard that calls `kill_on_drop(true)` + waits for exit; abort on harness interrupt.
- Hard timeout: 5 min per turn (configurable). Surface as `ProviderError::Timeout`.

## 9. Risks / open questions

| # | Risk | Mitigation |
|---|------|------------|
| R1 | CC stream-json schema drift between versions | Pin `MIN_VERSION` (initially `2.0.0`); `version_check` blocks startup with clear error. Re-test on every CC release. |
| R2 | Windows `claude.cmd` shim | `driver.rs` uses `where claude` resolution + spawns via `cmd /c` on Windows when target is `.cmd`. |
| R3 | `OPENHUMAN_CORE_TOKEN` rotates per launch | mcp-config JSON regenerated each session, written to tempfile, deleted on drop. Never cached. |
| R4 | CC built-ins re-enabled accidentally | v1 hard-codes `--disallowedTools` list; flag in config but undocumented until threat model. |
| R5 | Cost data lost (no `cost.rs` wiring) | v1.1. v1 logs `result.total_cost_usd` to debug log. |
| R6 | MCP server perf under tool spam | SSE on same Axum runtime — same backpressure story as `/rpc`. Add semaphore on tool-dispatch handler if it becomes a hotspot. |
| R7 | Subscription users without API key can't use v1 | Clear UX in settings: "v1 requires API key; subscription support coming." |

## 10. Phases & checkpoints

### Phase 1 — Skeleton + version check (1–2 days)
- Create branch `feat/claude-code-provider` off `upstream/main`.
- Add `CloudProviderType::ClaudeCode` config variant.
- Scaffold `claude_code/` module with `version_check.rs`, `auth.rs`, `types.rs`, `schemas.rs`, `mod.rs` (Provider impl returning `not_implemented` for `chat`).
- Add `claude_code_status` + `claude_code_check_version` RPC.
- Frontend: minimal settings panel showing install status only.
- Unit tests: version parsing, auth resolution.
- **Checkpoint**: settings panel shows `installed: true/false`, version, path on real Windows install.

### Phase 2 — Driver + stream parsing (2–3 days)
- `input_builder.rs`, `driver.rs` (spawn, kill-on-drop, semaphore), `stream_parser.rs`, `event_mapper.rs`, `session_store.rs`.
- Pull JSONL fixtures from opencode `packages/opencode/test/fixtures/claude-code-stream/`. Re-license headers if needed.
- Unit tests against fixtures: every event type maps to correct `ProviderDelta`.
- **Skip MCP for now**: spawn CC with `--disallowedTools <all>` and no MCP — just verify text streaming round-trip.
- Wire into `factory.rs` grammar.
- **Checkpoint**: pick provider in dev settings → run a turn → text streams back correctly. Multi-turn `--resume` works.

### Phase 3 — MCP server (2–3 days)
- `src/openhuman/mcp_server/` scaffold. Mount `/mcp` SSE route under existing auth.
- Expose v1 read-only tool subset via `tool_registry.rs`.
- `mcp_config.rs` generates per-launch JSON, driver passes `--mcp-config` + `--strict-mcp-config`.
- Integration test: spawn CC, ask "list my threads", verify tool call lands and result returns.
- **Checkpoint**: end-to-end roundtrip — CC calls `mcp__openhuman__threads_list`, gets result, continues turn.

### Phase 4 — Frontend polish + docs (1 day)
- Settings UI: install instructions per-OS, API key entry, "test connection" button.
- Per-role override UI if existing provider-selection UI supports it.
- Add docs entry in `gitbooks/developing/` covering the provider.
- Update `CLAUDE.md` if anything contract-changing landed (e.g. new `/mcp` route).

### Phase 5 — E2E + ship (1–2 days)
- E2E spec: configure CC provider, send a message, verify response.
- Rust integration test exercising `Provider::chat` against a mocked `claude` binary (`scripts/test-rust-with-mock.sh` harness extension).
- Coverage ≥ 80% on changed lines (merge gate).
- PR to `tinyhumansai/openhuman:main` from `senamakel:feat/claude-code-provider`.

**Total estimate:** 7–11 days of focused work.

## 11. Testing strategy

- **Unit (Vitest)** — frontend slice + components.
- **Unit (cargo)** — parser, mapper, auth, version check (all against fixtures, no real CC binary).
- **Rust integration** — driver against mocked binary that emits canned JSONL on stdin → stdout.
- **E2E (WDIO)** — happy path with CC mocked at the binary level via `OPENHUMAN_CLAUDE_BINARY` env override.

## 12. Rollout

- Behind a settings toggle (defaults to off) for first release. No auto-selection.
- Document beta status in settings panel until v1.1 (cost wiring + write tools) lands.

## 13. Locked decisions

1. **MIN_VERSION**: `2.0.0`. `version_check.rs` blocks startup below this.
2. **Read-only MCP tool subset (v1)**: `memory_search`, `memory_get`, `threads_list`, `threads_get`, `threads_messages`, `channels_list`, `channels_messages_read`, `people_search`, `people_get`, `webhooks_list`. Exposed as `mcp__openhuman__<name>`. Write tools deferred to v1.1.
3. **Per-role provider selection**: CC selectable independently for `chat`, `agentic`, `reasoning` roles via factory string grammar. No single global toggle.
4. **UI branding**: "Claude Code CLI" in all settings copy, provider picker labels, and status panel headings.
6 changes: 5 additions & 1 deletion app/src/components/settings/panels/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
} from '../../../utils/tauriCommands/heartbeat';
import { ConfirmationModal } from '../../intelligence/ConfirmationModal';
import SettingsHeader from '../components/SettingsHeader';
import { ClaudeCodeStatusCard } from './ai/ClaudeCodeStatusCard';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { useReembedBackfillModal } from './useReembedBackfillModal';

Expand Down Expand Up @@ -83,7 +84,8 @@ type WorkloadGroup = 'chat' | 'background';
type ProviderRef =
| { kind: 'openhuman' }
| { kind: 'cloud'; providerSlug: string; model: string; temperature?: number | null }
| { kind: 'local'; model: string; temperature?: number | null };
| { kind: 'local'; model: string; temperature?: number | null }
| { kind: 'claude-code'; model: string; temperature?: number | null };

Comment on lines +87 to 89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle claude-code exhaustively in route display paths.

Adding the new variant here is correct, but WorkloadRow and save-bar diffSummary still fall through to local formatting, so claude-code routes are shown as Ollama / local:*.

💡 Suggested patch
diff --git a/app/src/components/settings/panels/AIPanel.tsx b/app/src/components/settings/panels/AIPanel.tsx
@@
-  } else {
-    resolved = `Ollama · ${ref_.model}`;
+  } else if (ref_.kind === 'local') {
+    resolved = `Ollama · ${ref_.model}`;
+  } else {
+    resolved = `Claude Code CLI · ${ref_.model}`;
   }
@@
-          if (r.kind === 'cloud') return `${r.providerSlug}:${r.model}${tempSuffix}`;
-          return `local:${r.model}${tempSuffix}`;
+          if (r.kind === 'cloud') return `${r.providerSlug}:${r.model}${tempSuffix}`;
+          if (r.kind === 'local') return `local:${r.model}${tempSuffix}`;
+          return `claude-code:${r.model}${tempSuffix}`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/components/settings/panels/AIPanel.tsx` around lines 87 - 89,
WorkloadRow and the save-bar diffSummary are falling through to the local branch
for the new union variant { kind: 'claude-code'; ... }; update both to handle
the 'claude-code' discriminant exhaustively by adding an explicit branch/case
for kind === 'claude-code' (or pattern-match on kind) that returns the intended
display string (e.g., "Claude Code" and the specific model name/route) instead
of treating it as local/Ollama; modify the logic in the WorkloadRow component
and the diffSummary generation to include that case so all route display paths
correctly represent claude-code routes.

type Workload = { id: WorkloadId; group: WorkloadGroup; label: string; description: string };

Expand Down Expand Up @@ -752,6 +754,7 @@ function summarizeSpendSample(transactions: CreditTransaction[]) {
function describeProvider(ref: ProviderRef, providers: CloudProvider[]): string {
if (ref.kind === 'openhuman') return 'OpenHuman';
if (ref.kind === 'local') return `Local ${ref.model}`;
if (ref.kind === 'claude-code') return `Claude Code CLI ${ref.model || 'default model'}`;
const provider = providers.find(p => p.slug === ref.providerSlug);
return `${provider?.label ?? ref.providerSlug} ${ref.model || 'custom model'}`;
}
Expand Down Expand Up @@ -2041,6 +2044,7 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
)}

<div className={embedded ? 'space-y-6' : 'space-y-6 p-4'}>
<ClaudeCodeStatusCard />
{/* ═══════════════════════════════════════════════════════════════
AUTH — provider authentication (cloud providers + local Ollama
setup). Everything the user needs to wire a model up.
Expand Down
119 changes: 119 additions & 0 deletions app/src/components/settings/panels/ai/ClaudeCodeStatusCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useState } from 'react';

import {
type ClaudeCodeStatus,
openhumanClaudeCodeStatus,
} from '../../../../utils/tauriCommands/config';

/**
* Status card for the Claude Code CLI provider.
*
* Probes the local `claude` binary on mount (and on a manual Refresh) and
* surfaces install / version state to the user. Read-only — does not write
* any settings. Embed inside the AI settings panel above the routing
* dropdowns once per-role selection wiring lands.
*/
export function ClaudeCodeStatusCard() {
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);

const probe = useCallback(async () => {
setLoading(true);
setError(null);
try {
const resp = await openhumanClaudeCodeStatus();
setStatus(resp.result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus(null);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void probe();
}, [probe]);

return (
<section
data-testid="claude-code-status-card"
className="rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<header className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
Claude Code CLI
</h3>
<button
type="button"
onClick={() => {
void probe();
}}
disabled={loading}
className="text-xs text-neutral-500 hover:text-neutral-900 disabled:opacity-50 dark:text-neutral-400 dark:hover:text-neutral-100">
{loading ? 'Probing…' : 'Refresh'}
</button>
</header>
<StatusBody status={status} error={error} />
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
Use the <code>claude-code:&lt;model&gt;</code> provider string to route chat, agentic, or
reasoning workloads through your local Claude Code CLI install.
</p>
</section>
);
}

function StatusBody({ status, error }: { status: ClaudeCodeStatus | null; error: string | null }) {
if (error) {
return <p className="text-xs text-rose-600 dark:text-rose-400">Failed to probe: {error}</p>;
}
if (!status) {
return <p className="text-xs text-neutral-500 dark:text-neutral-400">Probing…</p>;
}
switch (status.status) {
case 'ok':
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-neutral-500">Status</dt>
<dd className="text-emerald-600 dark:text-emerald-400">Installed ({status.version})</dd>
<dt className="text-neutral-500">Path</dt>
<dd className="font-mono text-neutral-700 dark:text-neutral-300">{status.path}</dd>
</dl>
);
case 'not_installed':
return (
<p className="text-xs text-amber-600 dark:text-amber-400">
Claude Code CLI is not installed. Install via{' '}
<code>npm install -g @anthropic-ai/claude-code</code> or follow{' '}
<a
href="https://docs.anthropic.com/en/docs/claude-code"
target="_blank"
rel="noreferrer noopener"
className="underline hover:text-amber-700 dark:hover:text-amber-300">
Anthropic's docs
</a>
.
</p>
);
case 'outdated':
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-neutral-500">Status</dt>
<dd className="text-rose-600 dark:text-rose-400">
Outdated — found {status.version}, need ≥ {status.min_required}
</dd>
<dt className="text-neutral-500">Path</dt>
<dd className="font-mono text-neutral-700 dark:text-neutral-300">{status.path}</dd>
</dl>
);
case 'unusable':
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-neutral-500">Status</dt>
<dd className="text-rose-600 dark:text-rose-400">Unusable — {status.reason}</dd>
<dt className="text-neutral-500">Path</dt>
<dd className="font-mono text-neutral-700 dark:text-neutral-300">{status.path}</dd>
</dl>
);
}
}
Loading
Loading