feat(claude-code): Claude Code CLI provider — Phases 1–5 (#2472)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Michael Smith
2026-06-02 19:25:05 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent 3b857d56da
commit 9d28a728a0
23 changed files with 2775 additions and 161 deletions
-148
View File
@@ -1,148 +0,0 @@
## Summary
- Adds an iOS client for OpenHuman: device pairing via QR code, mascot chat screen, and push-to-talk voice input.
- No Rust core ships on device; the iOS app connects to the desktop core via LAN HTTP, an E2E-encrypted socket.io tunnel, or cloud HTTP fallback.
- All changes are cfg-gated or platform-guarded; the desktop build is unaffected.
- Adds the `tauri-plugin-ptt` Swift plugin (`packages/tauri-plugin-ptt/`) for AVAudioEngine + SFSpeechRecognizer on iOS.
- Adds CI sanity-check workflow, build scripts, capability catalog entries, and full docs.
## Problem
Users with iOS devices had no way to interact with their OpenHuman assistant on the go. The desktop app required a local machine. This PR adds the client-side scaffolding and transport layer needed to bridge iOS to an existing desktop core.
## Solution
The iOS app is a subset of the existing React/TypeScript UI, compiled by Tauri v2 into an iOS bundle. A `TransportManager` selects the best transport at runtime. Pairing is secured by an X25519 key agreement; all tunnel traffic uses XChaCha20-Poly1305 encryption. The backend is a blind socket.io forwarder -- it never sees plaintext.
## Layer-by-layer commits
| Commit | Layer | Summary |
|--------|-------|---------|
| `a99537f3` | Layer 1 | Rust devices domain -- pairing store, RPC handlers, event bus, crypto (`src/openhuman/devices/`) |
| `4ea14b78` | Layer 2 | TS transport refactor -- `TransportManager`, `LanHttpTransport`, `TunnelTransport`, `CloudHttpTransport`, tunnel crypto (`app/src/services/transport/`, `app/src/lib/tunnel/`) |
| `ba651705` | Layer 3 | Desktop `/settings/devices` UI -- `DevicesPanel`, `PairPhoneModal` with QR generation and 2-second poll |
| `3e0e2a67` | Layer 4 | Tauri shell cfg-gating -- `#[cfg(target_os = "ios")]` guards on CEF-specific code |
| `621fec98` | Layer 5 | iOS app shell -- `PairScreen` (QR scan via `AVCaptureSession`), `MascotScreen` (chat UI) |
| `5ca6cf21` | Layer 6 | `tauri-plugin-ptt` -- Swift PTT plugin (AVAudioEngine, SFSpeechRecognizer, AVSpeechSynthesizer) |
| `41a6a895` | Layer 6 fix | PTT Swift fix -- latest transcript tracking + `@unchecked Sendable` on PTTSpeaker |
| _(this PR)_ | Layers 7+8 | Build scripts, CI, Info.plist, capability catalog, docs, quality pass |
## Test coverage
- **Vitest:** 1957 passed, 3 skipped, 1 todo across 218 test files (includes transport, tunnel, devices, iOS, PTT suites).
- **Rust (about_app):** 20 passed -- validates catalog uniqueness, Mobile category, and new capability entries.
- **cargo check (all three Cargo.toml files):** clean (warnings only, pre-existing).
## What is gated behind the iOS target
The following only activates on `cfg(target_os = "ios")` or when explicitly called from iOS screens:
- CEF exclusions in `app/src-tauri/` (accounts webviews, etc.)
- `tauri-plugin-ptt` commands (`start_listening`, `stop_listening`, `speak`, `cancel_speech`, `list_voices`) -- return `NotSupported` on non-iOS targets.
- `packages/tauri-plugin-ptt/ios/` Swift sources -- not compiled for desktop.
Desktop users see no change.
## Known TODOs for follow-up PRs
- **Keychain migration:** iOS symmetric session key is in-memory only; persist to Keychain so the app reconnects after restart without re-pairing.
- **Event-driven pairing detection:** `PairPhoneModal` polls `devices_list` every 2 s. Switch to a socket event subscription when the SSE/socket bridge for `DomainEvent::DevicePaired` lands.
- **Full Xcode CI:** `cargo check --target aarch64-apple-ios` runs with `continue-on-error: true` in the new CI workflow because third-party C deps (cef-dll-sys) may fail without full Xcode on the runner. A follow-up should pin an Xcode-enabled runner and harden this to a hard gate.
- **APNs push notifications:** real-time delivery requires the app to be foregrounded.
- **Multi-region tunnel:** single backend instance only; no failover.
- **Info.plist automation:** developer must manually copy `Info.ios.plist` keys into the generated Xcode project after `tauri ios init`. Should automate via `bundle.iOS.template` once Tauri v2 stabilises the iOS template pipeline.
## Backend dependency
**`tinyhumansai/backend#709` must be merged and deployed before end-to-end pairing works.** The `devices_create_pairing` RPC will return a tunnel registration error until the `tunnel:register` / `tunnel:connect` / `tunnel:frame` socket.io contract is live.
## Manual test plan for iOS reviewer
_(Requires a physical iPhone or iOS 17+ simulator paired with the desktop app.)_
From `packages/tauri-plugin-ptt/README.md`:
- [ ] Permissions dialog appears on first `startListening` call.
- [ ] Partial transcripts update while speaking; final transcript matches.
- [ ] Hold button to record, release to stop, chat message is sent with transcript.
- [ ] TTS plays through speaker by default when iPhone is held away from ear.
- [ ] BT headset routes audio correctly; disconnecting mid-recording stops gracefully.
- [ ] App backgrounded mid-record produces a final transcript and stops cleanly.
- [ ] Phone call interruption emits `ptt://error` with `code: interrupted`.
- [ ] `cancelSpeech` during TTS emits `tts-ended` with `finished: false`.
- [ ] `listVoices` returns non-empty list of `AVSpeechSynthesisVoice` entries.
Additional pairing flow checks:
- [ ] Desktop: Settings > Devices > "Pair iPhone" shows QR code.
- [ ] iOS app: PairScreen scans QR and transitions to MascotScreen after handshake.
- [ ] Desktop: Devices panel lists the paired device with correct label.
- [ ] Desktop: Revoke device removes it from the list; iOS app shows reconnect prompt.
- [ ] QR code expiry: code expires after TTL, "Generate new code" creates a fresh session.
## Screenshots
> **PLACEHOLDER:** Before opening the PR, attach screenshots of:
> - Desktop `/settings/devices` panel with a paired device.
> - iOS mascot screen showing a conversation.
>
> These require a device with Xcode signing configured and `tinyhumansai/backend#709` deployed.
## Submission Checklist
- [x] Tests added or updated (transport, tunnel, devices, iOS, PTT suites -- see coverage statement above).
- [x] Diff coverage note: new Rust code in `src/openhuman/devices/` was covered in Layer 1 tests; new TS code in `app/src/services/transport/` and `app/src/lib/tunnel/` covered by Vitest suites. PTT Swift layer cannot be unit-tested without iOS toolchain (noted in README).
- [x] Coverage matrix: N/A for this layer (build scripts, CI, docs, catalog).
- [x] No new external network dependencies (all transport calls use existing mock backend or real backend behind feature flag).
- [ ] Manual smoke checklist: iOS path not in `docs/RELEASE-MANUAL-SMOKE.md` yet -- tracked as follow-up.
- [ ] Linked issue: N/A (tracked via Linear).
## Impact
- Desktop runtime: no change.
- iOS target: new experimental app bundle (not in release pipeline yet).
- `packages/tauri-plugin-ptt/` is a new crate workspace member; adds to build time only when targeting iOS.
- Capability catalog adds three new `mobile.*` entries and a new `Mobile` category.
## Related
- Closes: N/A (new feature)
- Follow-up PR(s): Keychain migration, event-driven pairing, full Xcode CI, APNs.
- Backend: tinyhumansai/backend#709
---
## AI Authored PR Metadata (required for Codex/Linear PRs)
### Linear Issue
- Key: N/A
- URL: N/A
### Commit & Branch
- Branch: `feat/ios-client`
- Commit SHA: _(set after final commit)_
### Validation Run
- [x] `pnpm --filter openhuman-app format:check` -- clean
- [x] `pnpm typecheck` -- clean
- [x] Focused tests: Vitest 1957 passed; cargo about_app 20 passed
- [x] Rust fmt/check: `cargo fmt --all` + `cargo check` on all three Cargo.toml -- clean
- [x] Tauri fmt/check: included above
### Validation Blocked
- command: `cargo check --target aarch64-apple-ios`
- error: May fail on cef-dll-sys C deps without full Xcode; guarded with `continue-on-error: true` in CI.
- impact: Soft gate only; does not block merge.
### Behavior Changes
- Intended behavior change: Desktop users see new Settings > Devices panel. iOS users can pair and chat.
- User-visible effect: Desktop gains device management UI. iOS app becomes available for sideloading/TestFlight.
### Parity Contract
- Legacy behavior preserved: All existing desktop flows unaffected. No CEF injection added. No new JS injection in webview accounts.
- Guard/fallback/dispatch parity: PTT commands return `NotSupported` on non-iOS. Transport falls back gracefully.
### Duplicate / Superseded PR Handling
- Duplicate PR(s): None
- Canonical PR: This PR
- Resolution: N/A
+71
View File
@@ -0,0 +1,71 @@
//! Tauri commands for the Claude Code CLI provider.
//!
//! Provides a cross-platform "open a terminal and run `claude login`"
//! helper. The CLI's OAuth flow is interactive (it prints a URL and
//! waits for the user to paste a code), so we can't host it in-app — we
//! detach into the user's native terminal so they complete login there,
//! then return to OpenHuman and click Recheck in the settings card.
use std::process::Command;
/// Open the user's native terminal and run `claude login` inside it.
///
/// Returns the name of the terminal emulator we launched (for UI
/// confirmation) or an error string if no terminal could be opened.
///
/// Platform behaviour:
/// - Windows: `cmd /c start "" cmd /k claude login`
/// - macOS: `osascript` → Terminal.app `do script "claude login"`
/// - Linux: try `x-terminal-emulator`, then `gnome-terminal`,
/// `konsole`, `xterm` in that order
#[tauri::command]
pub fn claude_code_login_launch() -> Result<String, String> {
#[cfg(target_os = "windows")]
{
// `start ""` opens a new console window; the empty quoted title
// prevents cmd from interpreting the first arg as a title.
// `cmd /k` keeps the window open after `claude login` exits so
// the user can read any final output.
Command::new("cmd")
.args(["/c", "start", "", "cmd", "/k", "claude login"])
.spawn()
.map_err(|e| format!("failed to open cmd: {e}"))?;
return Ok("cmd".into());
}
#[cfg(target_os = "macos")]
{
let script = r#"tell application "Terminal"
activate
do script "claude login"
end tell"#;
Command::new("osascript")
.args(["-e", script])
.spawn()
.map_err(|e| format!("failed to open Terminal.app: {e}"))?;
return Ok("Terminal.app".into());
}
#[cfg(target_os = "linux")]
{
let terminals: &[(&str, &[&str])] = &[
("x-terminal-emulator", &["-e", "claude", "login"]),
("gnome-terminal", &["--", "claude", "login"]),
("konsole", &["-e", "claude", "login"]),
("xfce4-terminal", &["-e", "claude login"]),
("xterm", &["-e", "claude", "login"]),
];
for (term, args) in terminals {
match Command::new(term).args(*args).spawn() {
Ok(_) => return Ok(term.to_string()),
Err(_) => continue,
}
}
return Err("no terminal emulator found (tried x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, xterm). Run `claude login` manually.".into());
}
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
{
Err("claude_code_login_launch is not supported on this platform".into())
}
}
+3 -1
View File
@@ -18,6 +18,7 @@ mod cef_profile;
// logic is unit-tested on any host; the Win32 glue is windows-only.
#[cfg(any(target_os = "windows", test))]
mod cef_singleton_wait;
mod claude_code;
mod companion_commands;
mod core_process;
mod core_rpc;
@@ -3166,7 +3167,8 @@ pub fn run() {
mcp_commands::mcp_resolve_binary_path,
mcp_commands::mcp_open_client_config,
loopback_oauth::start_loopback_oauth_listener,
loopback_oauth::stop_loopback_oauth_listener
loopback_oauth::stop_loopback_oauth_listener,
claude_code::claude_code_login_launch
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
+57 -11
View File
@@ -53,6 +53,7 @@ import {
import { ConfirmationModal } from '../../intelligence/ConfirmationModal';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import { ClaudeCodeStatusCard } from './ai/ClaudeCodeStatusCard';
import { routingWithProviderRemoved } from './aiRouting';
import {
authStyleForBuiltinCloudProvider,
@@ -97,7 +98,8 @@ export type ProviderRef =
| { kind: 'openhuman' }
| { kind: 'default' }
| { 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 };
type Workload = { id: WorkloadId; group: WorkloadGroup; label: string; description: string };
@@ -859,6 +861,7 @@ function describeProvider(ref: ProviderRef, providers: BackgroundLoopProviderVie
if (ref.kind === 'openhuman') return 'Managed · OpenHuman';
if (ref.kind === 'default') return 'Default route';
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'}`;
}
@@ -1727,7 +1730,15 @@ interface CustomRoutingDialogProps {
onSubmit: (next: ProviderRef) => void;
}
type CustomDialogSource = { kind: 'cloud'; providerSlug: string } | { kind: 'local' };
type CustomDialogSource =
| { kind: 'cloud'; providerSlug: string }
| { kind: 'local' }
| { kind: 'claude-code' };
/** Default model identifier presented when the user first picks the
* Claude Code CLI source. The CLI accepts any model id the underlying
* Claude account can run, so this is just a sensible starting point. */
const CLAUDE_CODE_DEFAULT_MODEL = 'sonnet-4-5';
function providerRefSignature(ref: ProviderRef): string {
switch (ref.kind) {
@@ -1739,6 +1750,8 @@ function providerRefSignature(ref: ProviderRef): string {
return `cloud:${ref.providerSlug}:${ref.model}:${ref.temperature ?? ''}`;
case 'local':
return `local:${ref.model}:${ref.temperature ?? ''}`;
case 'claude-code':
return `claude-code:${ref.model}:${ref.temperature ?? ''}`;
}
}
@@ -1814,19 +1827,23 @@ const CustomRoutingDialog = ({
? { kind: 'cloud', providerSlug: initial.providerSlug }
: initial.kind === 'local'
? { kind: 'local' }
: customCloud[0]
? { kind: 'cloud', providerSlug: customCloud[0].slug }
: localAvailable
? { kind: 'local' }
: null;
: initial.kind === 'claude-code'
? { kind: 'claude-code' }
: customCloud[0]
? { kind: 'cloud', providerSlug: customCloud[0].slug }
: localAvailable
? { kind: 'local' }
: null;
const [source, setSource] = useState<CustomDialogSource | null>(initialSource);
const [model, setModel] = useState<string>(() => {
if (initial.kind === 'cloud' || initial.kind === 'local') return initial.model;
if (initial.kind === 'cloud' || initial.kind === 'local' || initial.kind === 'claude-code')
return initial.model;
if (initialSource?.kind === 'cloud') {
const p = customCloud.find(c => c.slug === initialSource.providerSlug);
return p ? '' : '';
}
if (initialSource?.kind === 'claude-code') return CLAUDE_CODE_DEFAULT_MODEL;
return localModels[0]?.id ?? '';
});
const [cloudModels, setCloudModels] = useState<ModelInfo[]>([]);
@@ -1841,7 +1858,9 @@ const CustomRoutingDialog = ({
// Optional temperature override for this workload. `null` = use provider/global default;
// a finite number means "send `temperature: X` upstream for this workload only".
const [temperature, setTemperature] = useState<number | null>(
initial.kind === 'cloud' || initial.kind === 'local' ? (initial.temperature ?? null) : null
initial.kind === 'cloud' || initial.kind === 'local' || initial.kind === 'claude-code'
? (initial.temperature ?? null)
: null
);
const selectedCloud =
@@ -1923,6 +1942,8 @@ const CustomRoutingDialog = ({
model: model.trim(),
temperature: temp,
});
} else if (source.kind === 'claude-code') {
onSubmit({ kind: 'claude-code', model: model.trim(), temperature: temp });
} else {
onSubmit({ kind: 'local', model: model.trim(), temperature: temp });
}
@@ -1950,7 +1971,9 @@ const CustomRoutingDialog = ({
}
};
const noProviders = customCloud.length === 0 && !localAvailable;
// Claude Code CLI is always available as a source — never show the
// empty state when it's the only option.
const noProviders = false;
return (
<div
@@ -2012,6 +2035,9 @@ const CustomRoutingDialog = ({
} else if (kind === 'cloud') {
setSource({ kind: 'cloud', providerSlug: slug });
setModel('');
} else if (kind === 'claude-code') {
setSource({ kind: 'claude-code' });
setModel(CLAUDE_CODE_DEFAULT_MODEL);
}
}}
className="rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500">
@@ -2021,6 +2047,7 @@ const CustomRoutingDialog = ({
</option>
))}
{localAvailable && <option value="local:">{t('settings.ai.localOllama')}</option>}
<option value="claude-code:">Claude Code CLI</option>
</select>
</div>
@@ -2042,6 +2069,20 @@ const CustomRoutingDialog = ({
</option>
))}
</select>
) : source?.kind === 'claude-code' ? (
<div className="space-y-1.5">
<input
type="text"
value={model}
onChange={e => setModel(e.target.value)}
placeholder="sonnet-4-5"
className="w-full rounded-lg border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm font-mono text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
/>
<p className="text-[11px] text-stone-500 dark:text-neutral-400">
Any model id your Claude account can run (e.g. <code>sonnet-4-5</code>,{' '}
<code>opus-4-7</code>). Passed verbatim to <code>claude --model</code>.
</p>
</div>
) : cloudModelsLoading ? (
<select
disabled
@@ -2400,7 +2441,9 @@ const GlobalOwnModelSelector = ({
? null
: source.kind === 'local'
? ({ kind: 'local', model: model.trim() } as const)
: ({ kind: 'cloud', providerSlug: source.providerSlug, model: model.trim() } as const);
: source.kind === 'claude-code'
? ({ kind: 'claude-code', model: model.trim() } as const)
: ({ kind: 'cloud', providerSlug: source.providerSlug, model: model.trim() } as const);
const isSaved =
selectedRef !== null &&
saved !== null &&
@@ -2412,6 +2455,8 @@ const GlobalOwnModelSelector = ({
try {
if (nextSource.kind === 'local') {
await onApply({ kind: 'local', model: nextModel.trim() });
} else if (nextSource.kind === 'claude-code') {
await onApply({ kind: 'claude-code', model: nextModel.trim() });
} else {
await onApply({
kind: 'cloud',
@@ -2779,6 +2824,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.
@@ -0,0 +1,244 @@
import { useCallback, useEffect, useState } from 'react';
import {
type ClaudeCodeAuthStatus,
type ClaudeCodeStatus,
openhumanClaudeCodeAuthStatus,
openhumanClaudeCodeLoginLaunch,
openhumanClaudeCodeStatus,
} from '../../../../utils/tauriCommands/config';
/**
* Status card for the Claude Code CLI provider.
*
* Surfaces two independent probes:
* 1. Binary install + version (slow — spawns `claude --version`).
* 2. Auth state — Pro/Max subscription via `~/.claude/.credentials.json`
* or `ANTHROPIC_API_KEY` env (fast — pure FS).
*
* Each refreshes independently so a user who just ran `claude login` can
* re-probe auth without re-spawning the binary.
*/
export function ClaudeCodeStatusCard() {
const [status, setStatus] = useState<ClaudeCodeStatus | null>(null);
const [auth, setAuth] = useState<ClaudeCodeAuthStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [authLoading, setAuthLoading] = 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);
}
}, []);
const probeAuth = useCallback(async () => {
setAuthLoading(true);
setAuthError(null);
try {
const resp = await openhumanClaudeCodeAuthStatus();
setAuth(resp.result);
} catch (err) {
setAuthError(err instanceof Error ? err.message : String(err));
setAuth(null);
} finally {
setAuthLoading(false);
}
}, []);
useEffect(() => {
void probe();
void probeAuth();
}, [probe, probeAuth]);
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…' : 'Probe'}
</button>
</header>
<StatusBody status={status} error={error} />
<div className="mt-4 border-t border-neutral-200 pt-3 dark:border-neutral-800">
<header className="mb-2 flex items-center justify-between">
<h4 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Authentication
</h4>
<button
type="button"
onClick={() => {
void probeAuth();
}}
disabled={authLoading}
className="text-xs text-neutral-500 hover:text-neutral-900 disabled:opacity-50 dark:text-neutral-400 dark:hover:text-neutral-100">
{authLoading ? 'Checking…' : 'Recheck'}
</button>
</header>
<AuthBody auth={auth} error={authError} />
</div>
<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>
);
}
}
function AuthBody({ auth, error }: { auth: ClaudeCodeAuthStatus | null; error: string | null }) {
if (error) {
return <p className="text-xs text-rose-600 dark:text-rose-400">Failed to check: {error}</p>;
}
if (!auth) {
return <p className="text-xs text-neutral-500 dark:text-neutral-400">Checking…</p>;
}
if (auth.source === 'subscription') {
return (
<div className="space-y-1">
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
<dt className="text-neutral-500">Signed in</dt>
<dd className="text-emerald-600 dark:text-emerald-400">
{auth.account_email ?? 'Claude subscription'}
</dd>
{auth.expires_at && (
<>
<dt className="text-neutral-500">Token expires</dt>
<dd className="font-mono text-neutral-700 dark:text-neutral-300">
{auth.expires_at}
</dd>
</>
)}
</dl>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
To sign out, run <code>claude logout</code> in your terminal, then click Recheck.
</p>
</div>
);
}
if (auth.source === 'api_key_env') {
return (
<p className="text-xs text-emerald-600 dark:text-emerald-400">
<code>ANTHROPIC_API_KEY</code> detected in environment.
</p>
);
}
return <SignedOut />;
}
function SignedOut() {
const [launchError, setLaunchError] = useState<string | null>(null);
const [launching, setLaunching] = useState(false);
const launchLogin = async () => {
setLaunching(true);
setLaunchError(null);
try {
await openhumanClaudeCodeLoginLaunch();
} catch (err) {
setLaunchError(err instanceof Error ? err.message : String(err));
} finally {
setLaunching(false);
}
};
return (
<div className="space-y-2">
<p className="text-xs text-amber-600 dark:text-amber-400">Not signed in.</p>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
void launchLogin();
}}
disabled={launching}
className="rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-700 disabled:opacity-50 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300">
{launching ? 'Opening terminal' : 'Sign in with Claude'}
</button>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
Opens a terminal running <code>claude login</code>.
</span>
</div>
{launchError && <p className="text-xs text-rose-600 dark:text-rose-400">{launchError}</p>}
<p className="text-xs text-neutral-500 dark:text-neutral-400">
After completing login, click <strong>Recheck</strong> above. Alternatively set{' '}
<code>ANTHROPIC_API_KEY</code> to use an API key.
</p>
</div>
);
}
@@ -0,0 +1,161 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ClaudeCodeStatusCard } from '../ClaudeCodeStatusCard';
const probe = vi.fn();
const authProbe = vi.fn();
const loginLaunch = vi.fn();
vi.mock('../../../../../utils/tauriCommands/config', () => ({
openhumanClaudeCodeStatus: () => probe(),
openhumanClaudeCodeAuthStatus: () => authProbe(),
openhumanClaudeCodeLoginLaunch: () => loginLaunch(),
}));
describe('ClaudeCodeStatusCard', () => {
beforeEach(() => {
probe.mockReset();
authProbe.mockReset();
loginLaunch.mockReset();
loginLaunch.mockResolvedValue('cmd');
// Default auth response — individual tests override as needed.
authProbe.mockResolvedValue({ result: { source: 'none', last_checked: 0 } });
});
it('renders the installed version + path when CC is OK', async () => {
probe.mockResolvedValueOnce({
result: { status: 'ok', version: '2.0.4', path: '/usr/local/bin/claude' },
});
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/Installed \(2\.0\.4\)/)).toBeInTheDocument();
});
expect(screen.getByText('/usr/local/bin/claude')).toBeInTheDocument();
});
it('shows the install hint when the binary is missing', async () => {
probe.mockResolvedValueOnce({ result: { status: 'not_installed' } });
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/Claude Code CLI is not installed/i)).toBeInTheDocument();
});
});
it('shows the outdated state with min_required', async () => {
probe.mockResolvedValueOnce({
result: {
status: 'outdated',
version: '1.9.0',
min_required: '2.0.0',
path: '/usr/local/bin/claude',
},
});
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/Outdated — found 1\.9\.0, need ≥ 2\.0\.0/)).toBeInTheDocument();
});
});
it('surfaces a probe error', async () => {
probe.mockRejectedValueOnce(new Error('boom'));
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/Failed to probe: boom/)).toBeInTheDocument();
});
});
it('re-probes when Refresh is clicked', async () => {
probe
.mockResolvedValueOnce({ result: { status: 'not_installed' } })
.mockResolvedValueOnce({ result: { status: 'ok', version: '2.0.4', path: '/x/y/claude' } });
const user = userEvent.setup();
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/Claude Code CLI is not installed/i)).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /Probe/i }));
await waitFor(() => {
expect(screen.getByText(/Installed \(2\.0\.4\)/)).toBeInTheDocument();
});
expect(probe).toHaveBeenCalledTimes(2);
});
it('shows subscription auth with account email', async () => {
probe.mockResolvedValueOnce({
result: { status: 'ok', version: '2.0.4', path: '/usr/local/bin/claude' },
});
authProbe.mockReset();
authProbe.mockResolvedValueOnce({
result: {
source: 'subscription',
account_email: 'jamie@example.com',
expires_at: '2026-06-01T00:00:00Z',
last_checked: 1700000000,
},
});
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/jamie@example\.com/)).toBeInTheDocument();
});
expect(screen.getByText(/claude logout/)).toBeInTheDocument();
});
it('shows API key env auth state', async () => {
probe.mockResolvedValueOnce({ result: { status: 'not_installed' } });
authProbe.mockReset();
authProbe.mockResolvedValueOnce({ result: { source: 'api_key_env', last_checked: 0 } });
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/detected in environment/i)).toBeInTheDocument();
});
});
it('shows not-signed-in with claude login hint', async () => {
probe.mockResolvedValueOnce({ result: { status: 'not_installed' } });
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/Not signed in\./)).toBeInTheDocument();
});
expect(screen.getByText(/claude login/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Sign in with Claude/i })).toBeInTheDocument();
});
it('Sign in with Claude button launches login terminal', async () => {
probe.mockResolvedValueOnce({ result: { status: 'ok', version: '2.0.4', path: '/x/y' } });
const user = userEvent.setup();
render(<ClaudeCodeStatusCard />);
const btn = await screen.findByRole('button', { name: /Sign in with Claude/i });
await user.click(btn);
expect(loginLaunch).toHaveBeenCalledTimes(1);
});
it('Recheck triggers a second auth probe without re-running version probe', async () => {
probe.mockResolvedValueOnce({
result: { status: 'ok', version: '2.0.4', path: '/x/y/claude' },
});
authProbe.mockReset();
authProbe
.mockResolvedValueOnce({ result: { source: 'none', last_checked: 0 } })
.mockResolvedValueOnce({
result: {
source: 'subscription',
account_email: 'user@example.com',
expires_at: null,
last_checked: 1,
},
});
const user = userEvent.setup();
render(<ClaudeCodeStatusCard />);
await waitFor(() => {
expect(screen.getByText(/Not signed in\./)).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /Recheck/i }));
await waitFor(() => {
expect(screen.getByText(/user@example\.com/)).toBeInTheDocument();
});
expect(probe).toHaveBeenCalledTimes(1);
expect(authProbe).toHaveBeenCalledTimes(2);
});
});
+10 -1
View File
@@ -75,7 +75,8 @@ export type ProviderRef =
| { kind: 'openhuman' }
| { kind: 'default' }
| { 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 };
/** Parse a `<model>[@<temp>]` suffix into `(model, temperature)`. */
function splitModelAndTemp(raw: string): { model: string; temperature: number | null } {
@@ -156,6 +157,12 @@ export function parseProviderString(s: string | null | undefined): ProviderRef {
const { model, temperature } = splitModelAndTemp(trimmed.slice('ollama:'.length));
return temperature == null ? { kind: 'local', model } : { kind: 'local', model, temperature };
}
if (trimmed.startsWith('claude-code:')) {
const { model, temperature } = splitModelAndTemp(trimmed.slice('claude-code:'.length));
return temperature == null
? { kind: 'claude-code', model }
: { kind: 'claude-code', model, temperature };
}
const colonIdx = trimmed.indexOf(':');
if (colonIdx > 0) {
const slug = trimmed.slice(0, colonIdx).trim();
@@ -182,6 +189,8 @@ export function serializeProviderRef(ref: ProviderRef): string {
return `${ref.providerSlug}:${joinModelAndTemp(ref.model, ref.temperature)}`;
case 'local':
return `ollama:${joinModelAndTemp(ref.model, ref.temperature)}`;
case 'claude-code':
return `claude-code:${joinModelAndTemp(ref.model, ref.temperature)}`;
}
}
+74
View File
@@ -1,6 +1,7 @@
/**
* Config and settings commands.
*/
import { invoke } from '@tauri-apps/api/core';
import debug from 'debug';
import { callCoreRpc } from '../../services/coreRpcClient';
@@ -237,6 +238,79 @@ export async function openhumanGetClientConfig(): Promise<CommandResponse<Client
});
}
/**
* Status payload for the Claude Code CLI provider — mirrors Rust
* `claude_code::types::CliStatus`. The `status` discriminator is the
* snake_case Serde rename; `path` and `version` may be absent depending
* on which variant fired.
*/
export type ClaudeCodeStatus =
| { status: 'ok'; version: string; path: string }
| { status: 'not_installed' }
| { status: 'outdated'; version: string; min_required: string; path: string }
| { status: 'unusable'; path: string; reason: string };
/**
* Probe the local `claude` CLI binary (Claude Code CLI provider). Returns
* install + version status; never throws on a missing binary — the
* `not_installed` variant signals that case explicitly.
*/
export async function openhumanClaudeCodeStatus(): Promise<CommandResponse<ClaudeCodeStatus>> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ClaudeCodeStatus>>({
method: 'openhuman.inference_claude_code_status',
});
}
/**
* Auth state for the Claude Code CLI provider — mirrors Rust
* `claude_code::auth_status::AuthSource`. The `source` discriminator is
* the snake_case Serde rename. `account_email` / `expires_at` are
* best-effort: absent when the CLI's credentials schema drifts.
*/
export type ClaudeCodeAuthStatus =
| {
source: 'subscription';
account_email: string | null;
expires_at: string | null;
last_checked: number;
}
| { source: 'api_key_env'; last_checked: number }
| { source: 'none'; last_checked: number };
/**
* Detect Claude Code CLI auth state (Pro/Max subscription via
* `~/.claude/.credentials.json`, `ANTHROPIC_API_KEY` env, or none).
* Pure FS — no CLI spawn, safe to call on a tight refresh loop.
*/
export async function openhumanClaudeCodeAuthStatus(): Promise<
CommandResponse<ClaudeCodeAuthStatus>
> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<CommandResponse<ClaudeCodeAuthStatus>>({
method: 'openhuman.inference_claude_code_auth_status',
});
}
/**
* Open the user's native terminal and run `claude login` inside it. The
* CLI's OAuth flow is interactive, so we can't host it in-app — we
* detach into a terminal window and let the user complete the flow
* there, then click Recheck back in the settings card.
*
* Returns the name of the terminal emulator that was launched.
*/
export async function openhumanClaudeCodeLoginLaunch(): Promise<string> {
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await invoke<string>('claude_code_login_launch');
}
export async function openhumanUpdateModelSettings(
update: ModelSettingsUpdate
): Promise<CommandResponse<ConfigSnapshot>> {
@@ -0,0 +1,91 @@
# Claude Code CLI provider
OpenHuman can route any chat workload through **Anthropic's `claude` CLI** instead of calling the Anthropic HTTP API directly. The CLI handles model selection, auth, and prompt-cache management; OpenHuman drives it as a child process per turn, parses its stream-json output, and re-exposes its own read-only tools back into the CLI over MCP so the model can reach native OpenHuman state (memory, threads, channels, people).
> Locked decisions live in [`.planning/claude-code-provider/PLAN.md`](../../../.planning/claude-code-provider/PLAN.md) §13.
## Requirements
- Claude Code CLI **≥ 2.0.0** on `PATH` (or `OPENHUMAN_CLAUDE_CLI=/abs/path/to/claude`).
- An Anthropic API key in `ANTHROPIC_API_KEY`, **or** a pre-existing `~/.claude/.credentials.json` from `claude login`.
- The `openhuman-core` binary on disk — OpenHuman spawns `openhuman-core mcp` as a stdio MCP server so the CLI can call OpenHuman tools. The path is discovered via `std::env::current_exe()`.
## Routing a workload through the CLI
The factory grammar accepts a new prefix: `claude-code:<model>[@<temperature>]`. Apply it via the standard inference settings (per-role, locked decision #3):
```bash
# Through the JSON-RPC update endpoint:
openhuman-core rpc openhuman.inference_update_model_settings \
--json '{"chat_provider":"claude-code:claude-sonnet-4-5"}'
```
| Role string | Field updated |
| --- | --- |
| `chat_provider` | foreground chat replies |
| `reasoning_provider` | long-context reasoning workloads |
| `agentic_provider` | multi-step agentic loops |
A workload set to `claude-code:<model>` always spawns a fresh `claude` child per turn; concurrency is capped at `MAX_CONCURRENT_TURNS = 4` per `ClaudeCodeProvider` instance.
## Verifying the install
The status RPC is on the existing inference namespace:
```bash
openhuman-core rpc openhuman.inference_claude_code_status
```
Returns one of (`CliStatus` in [`src/openhuman/inference/provider/claude_code/types.rs`](../../../src/openhuman/inference/provider/claude_code/types.rs)):
- `{"status":"ok","version":"2.0.4","path":"/usr/local/bin/claude"}` — ready
- `{"status":"not_installed"}``claude` not on `PATH`
- `{"status":"outdated","version":"1.9.0","min_required":"2.0.0","path":"…"}` — bump CLI
- `{"status":"unusable","path":"…","reason":"…"}` — binary present but the version probe failed
The same status is rendered in the settings panel via `ClaudeCodeStatusCard` ([`app/src/components/settings/panels/ai/ClaudeCodeStatusCard.tsx`](../../../app/src/components/settings/panels/ai/ClaudeCodeStatusCard.tsx)).
## Per-turn behavior
Each chat turn:
1. Resolve a per-thread CC session UUID from `<workspace>/claude-code-sessions.json`. New threads get a fresh RFC-4122 v4 UUID; the CLI requires v4 specifically for `--resume`.
2. Write `mcp-config.json` to a tempdir pointing at `openhuman-core mcp` (stdio MCP server, no extra credentials).
3. Spawn the CLI with:
- `-p --input-format stream-json --output-format stream-json --verbose --include-partial-messages`
- `--mcp-config <tmp> --strict-mcp-config` so only the configured MCP servers are visible
- `--disallowedTools Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task,BashOutput,KillShell` — CC's own builtins stay off so OpenHuman tools (`mcp__openhuman__*`) are authoritative
- `--session-id <uuid>` on first turn, `--resume <uuid>` thereafter
- `--model <model>` (the suffix after `claude-code:`)
- `--append-system-prompt <…>` if the conversation carries a system message
4. Pipe stdin: full conversation history on a new session, just the last user turn on `--resume` (the CLI already holds its own prior-turn context server-side).
5. Stream stdout through the JSONL parser → event mapper → `ProviderDelta`s on the request's `stream` sink.
On exit non-zero the driver bubbles stderr (capped at 16 KiB) up as the error message.
## Auth resolution order
1. `ANTHROPIC_API_KEY` env var (highest precedence — set on the spawned child).
2. Per-thread / per-agent key from `ChatRequest` config (future, not yet wired).
3. `~/.claude/.credentials.json` — the CLI's own OAuth tokens from `claude login` (Pro / Max subscription). We never read or round-trip the access token; auth detection probes this file for non-secret metadata only.
4. None — the CLI will fail with an auth error.
The `openhuman.inference_claude_code_auth_status` RPC probes sources 1 and 3 without spawning the CLI and surfaces the result in the Settings → AI panel.
## Tool surface exposed to the CLI
The CLI sees these tools as `mcp__openhuman__<name>` (delivered by the existing stdio MCP server in [`src/openhuman/mcp_server/`](../../../src/openhuman/mcp_server/)):
- `core.list_tools`, `core.tool_instructions`
- `memory.search`, `memory.recall`
- `tree.read_chunk`, `tree.browse`, `tree.top_entities`, `tree.list_sources`
- `agent.list_subagents`, `agent.run_subagent` (write — flagged `destructiveHint` per MCP spec)
- `searxng_search`
The MCP server enforces `SecurityPolicy::ToolOperation` checks; all tools except `agent.run_subagent` are read-only.
## Limitations (v1)
- Vision input is not forwarded — set the `vision_provider` to a different provider when you need images.
- `agentic` runs share the same `Semaphore(4)`; under load a CC turn waits in queue rather than failing fast.
- Cost accounting from the CLI's `result.total_cost_usd` is captured in the mapper but not yet wired into OpenHuman's billing layer ([`src/openhuman/cost/`](../../../src/openhuman/cost/)).
@@ -0,0 +1,53 @@
//! Resolve an `ANTHROPIC_API_KEY` for the spawned `claude` CLI.
//!
//! v1 resolution order:
//! 1. Process env `ANTHROPIC_API_KEY` (highest precedence).
//! 2. `~/.claude/.credentials.json` — only used if the CLI is already
//! logged in via `claude login`. We pass it through transparently by
//! *not* setting `ANTHROPIC_API_KEY`; the CLI then reads its own
//! credentials file.
//!
//! v1.1 will wire OpenHuman `AuthService` (auth-profiles.json) so an
//! Anthropic key stored in settings is picked up automatically.
//! Subscription / OAuth auth (Claude Pro/Max) deferred to v2.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthSource {
/// Explicit API key — pass via `ANTHROPIC_API_KEY` env var.
EnvApiKey,
/// No explicit key resolved. Defer to whatever the CLI finds in
/// `~/.claude/.credentials.json`.
CliCredentials,
}
/// Probe sources in priority order. Returns the resolved API key plus the
/// origin label (for logging) when found. The returned key is only the
/// key value — call-sites set env on spawn, never log it.
pub fn resolve() -> (AuthSource, Option<String>) {
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
let k = k.trim();
if !k.is_empty() {
return (AuthSource::EnvApiKey, Some(k.to_string()));
}
}
(AuthSource::CliCredentials, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_to_cli_credentials_without_env() {
let prev = std::env::var("ANTHROPIC_API_KEY").ok();
std::env::remove_var("ANTHROPIC_API_KEY");
let (src, key) = resolve();
assert_eq!(src, AuthSource::CliCredentials);
assert!(key.is_none());
if let Some(v) = prev {
std::env::set_var("ANTHROPIC_API_KEY", v);
}
}
}
@@ -0,0 +1,238 @@
//! Detect Claude Code CLI auth state without spawning the binary.
//!
//! Surfaces three sources, in priority order:
//! 1. `ANTHROPIC_API_KEY` env var present → `ApiKeyEnv`.
//! 2. `~/.claude/.credentials.json` parseable → `Subscription` (Claude
//! Pro / Max OAuth tokens land here after `claude login`).
//! 3. Neither → `None`.
//!
//! The credentials file is the CLI's source of truth; we never write to it
//! and never round-trip the access token through RPC. We extract only
//! non-secret metadata (account email, expiry) when the schema exposes it,
//! and fall back to `Subscription { account_email: None, expires_at: None }`
//! when Anthropic changes the shape on us.
use std::path::PathBuf;
use std::time::SystemTime;
use serde::{Deserialize, Serialize};
/// Discriminator for who actually authenticates the spawned CLI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "source")]
pub enum AuthSource {
/// Claude Pro / Max subscription — OAuth tokens in
/// `~/.claude/.credentials.json`. Account email + expiry returned
/// best-effort; absent when the schema drifts.
Subscription {
account_email: Option<String>,
/// RFC3339-ish timestamp string copied verbatim from credentials
/// when present. We do not parse + compare; UI surfaces it as
/// "last seen" rather than a confident countdown.
expires_at: Option<String>,
},
/// `ANTHROPIC_API_KEY` is set in the core process env. The spawned
/// CLI inherits it.
ApiKeyEnv,
/// Nothing detected. The CLI will fail any chat with an auth error.
None,
}
/// Returned by the `claude_code_auth_status` RPC. Snake-case Serde so the
/// TS side discriminates on `source`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthStatus {
#[serde(flatten)]
pub source: AuthSource,
/// Unix seconds when this probe ran — UI shows "last checked" so users
/// can tell a stale subscription badge from a fresh one.
pub last_checked: u64,
}
/// Resolve the on-disk path to `~/.claude/.credentials.json`. Overridable
/// via `OPENHUMAN_CLAUDE_CREDENTIALS` for tests.
pub fn credentials_path() -> Option<PathBuf> {
if let Ok(explicit) = std::env::var("OPENHUMAN_CLAUDE_CREDENTIALS") {
return Some(PathBuf::from(explicit));
}
dirs_next_home().map(|h| h.join(".claude").join(".credentials.json"))
}
fn dirs_next_home() -> Option<PathBuf> {
// Mirror the stdlib's home detection without pulling another dep.
#[cfg(windows)]
{
if let Ok(p) = std::env::var("USERPROFILE") {
return Some(PathBuf::from(p));
}
}
#[cfg(not(windows))]
{
if let Ok(p) = std::env::var("HOME") {
return Some(PathBuf::from(p));
}
}
None
}
/// Tolerant credentials parser. Inspects a few known shape variants
/// without committing to any of them; on any failure we still return a
/// `Subscription { None, None }` because the file existing at all is
/// strong evidence the user has logged in.
fn parse_credentials(raw: &str) -> AuthSource {
let val: serde_json::Value = match serde_json::from_str(raw) {
Ok(v) => v,
Err(_) => {
return AuthSource::Subscription {
account_email: None,
expires_at: None,
};
}
};
// Schema observed in the wild:
// { "claudeAiOauth": { "accessToken": "...", "expiresAt": "...",
// "subscriptionType": "max", "email": "..." } }
// We probe a few plausible spellings to be drift-tolerant.
let oauth_obj = val
.get("claudeAiOauth")
.or_else(|| val.get("oauth"))
.or_else(|| val.get("claude_ai_oauth"));
let lookup_str = |obj: &serde_json::Value, key: &str| -> Option<String> {
obj.get(key).and_then(|v| v.as_str()).map(str::to_string)
};
if let Some(obj) = oauth_obj {
let email = lookup_str(obj, "email")
.or_else(|| lookup_str(obj, "account_email"))
.or_else(|| lookup_str(obj, "accountEmail"));
let expires = lookup_str(obj, "expiresAt").or_else(|| lookup_str(obj, "expires_at"));
return AuthSource::Subscription {
account_email: email,
expires_at: expires,
};
}
// Top-level email/expiresAt fallback.
let email = lookup_str(&val, "email");
let expires = lookup_str(&val, "expiresAt").or_else(|| lookup_str(&val, "expires_at"));
AuthSource::Subscription {
account_email: email,
expires_at: expires,
}
}
/// Probe auth state. Pure FS work — no CLI spawn, no network.
pub fn probe() -> AuthStatus {
let last_checked = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
if !k.trim().is_empty() {
return AuthStatus {
source: AuthSource::ApiKeyEnv,
last_checked,
};
}
}
let source = match credentials_path() {
Some(p) if p.is_file() => match std::fs::read_to_string(&p) {
Ok(raw) => parse_credentials(&raw),
// File exists but unreadable — still signal "signed in" rather
// than "none" so the user gets accurate UX. The CLI itself
// will surface a permission error on next turn.
Err(_) => AuthSource::Subscription {
account_email: None,
expires_at: None,
},
},
_ => AuthSource::None,
};
AuthStatus {
source,
last_checked,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_known_oauth_shape() {
let raw = r#"{
"claudeAiOauth": {
"accessToken": "redacted",
"refreshToken": "redacted",
"expiresAt": "2026-06-01T00:00:00Z",
"subscriptionType": "max",
"email": "user@example.com"
}
}"#;
match parse_credentials(raw) {
AuthSource::Subscription {
account_email,
expires_at,
} => {
assert_eq!(account_email.as_deref(), Some("user@example.com"));
assert_eq!(expires_at.as_deref(), Some("2026-06-01T00:00:00Z"));
}
other => panic!("expected Subscription, got {other:?}"),
}
}
#[test]
fn drift_falls_back_to_subscription_without_details() {
let raw = r#"{ "some_future_shape": { "token": "x" } }"#;
match parse_credentials(raw) {
AuthSource::Subscription {
account_email,
expires_at,
} => {
assert!(account_email.is_none());
assert!(expires_at.is_none());
}
other => panic!("expected Subscription fallback, got {other:?}"),
}
}
#[test]
fn malformed_json_still_returns_subscription() {
match parse_credentials("not json at all") {
AuthSource::Subscription { .. } => {}
other => panic!("expected Subscription, got {other:?}"),
}
}
#[test]
fn probe_returns_none_when_no_env_and_no_file() {
// Force the lookup to a path we control that doesn't exist.
let tmp = std::env::temp_dir().join("openhuman-test-nonexistent-creds.json");
if tmp.exists() {
std::fs::remove_file(&tmp).ok();
}
// Save & clear env so the test is hermetic.
let prev_key = std::env::var("ANTHROPIC_API_KEY").ok();
let prev_creds = std::env::var("OPENHUMAN_CLAUDE_CREDENTIALS").ok();
std::env::remove_var("ANTHROPIC_API_KEY");
std::env::set_var("OPENHUMAN_CLAUDE_CREDENTIALS", &tmp);
let s = probe();
assert!(matches!(s.source, AuthSource::None));
// Restore env to avoid bleed.
match prev_key {
Some(v) => std::env::set_var("ANTHROPIC_API_KEY", v),
None => std::env::remove_var("ANTHROPIC_API_KEY"),
}
match prev_creds {
Some(v) => std::env::set_var("OPENHUMAN_CLAUDE_CREDENTIALS", v),
None => std::env::remove_var("OPENHUMAN_CLAUDE_CREDENTIALS"),
}
}
}
@@ -0,0 +1,304 @@
//! Spawn the `claude` CLI for one chat turn, stream its stdout into the
//! event mapper, and return an aggregated `ChatResponse`.
//!
//! The driver does *not* own concurrency limits; the `ClaudeCodeProvider`
//! holds a `Semaphore` and acquires a permit before calling this.
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;
use tokio::sync::mpsc;
/// Hard timeout per turn (PLAN §8). If the CLI hangs (network stall,
/// infinite loop, MCP deadlock) we kill the child and surface a timeout.
const TURN_TIMEOUT: Duration = Duration::from_secs(300);
use super::event_mapper::EventMapper;
use super::input_builder::build_stdin;
use super::session_store::{generate_uuid_v4, is_uuid_v4, SessionStore};
use super::stream_parser::StreamJsonParser;
use crate::openhuman::inference::provider::traits::{ChatMessage, ChatResponse, ProviderDelta};
/// Builtin CC tools disabled in v1 so OpenHuman's MCP-exposed surface is
/// authoritative. CC's `mcp__openhuman__*` tools remain enabled.
const DISALLOWED_CC_BUILTINS: &[&str] = &[
"Bash",
"BashOutput",
"KillShell",
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
"Task",
];
/// One CC chat turn.
pub struct TurnContext<'a> {
pub bin_path: PathBuf,
pub workspace_dir: PathBuf,
pub thread_id: String,
pub model: String,
pub append_system_prompt: Option<String>,
pub messages: &'a [ChatMessage],
pub session_store: Arc<SessionStore>,
pub stream: Option<&'a mpsc::Sender<ProviderDelta>>,
/// Optional explicit `ANTHROPIC_API_KEY` to set on the child. When
/// `None`, the CLI falls back to its own `~/.claude/.credentials.json`.
pub anthropic_api_key: Option<String>,
/// Path to the OpenHuman core binary (`openhuman-core`). CC spawns it
/// with `mcp` to get a stdio MCP server exposing OpenHuman tools.
/// When `None`, MCP is not wired and CC runs with no extra tools.
pub openhuman_core_bin: Option<PathBuf>,
}
/// Write a CC `--mcp-config` JSON file that spawns `openhuman-core mcp`
/// as a stdio MCP server. Returns the on-disk path; caller cleans up.
fn write_mcp_config(dir: &std::path::Path, core_bin: &std::path::Path) -> std::io::Result<PathBuf> {
let path = dir.join("openhuman-mcp-config.json");
let cfg = json!({
"mcpServers": {
"openhuman": {
"type": "stdio",
"command": core_bin.display().to_string(),
"args": ["mcp"],
"env": {}
}
}
});
std::fs::write(
&path,
serde_json::to_string_pretty(&cfg).unwrap_or_default(),
)?;
Ok(path)
}
/// Run one turn against the `claude` CLI. Awaits process exit. Forwards
/// `ProviderDelta`s through `ctx.stream` as they arrive and returns the
/// aggregated `ChatResponse` when done.
pub async fn run_turn(ctx: TurnContext<'_>) -> anyhow::Result<ChatResponse> {
let stored = ctx.session_store.get(&ctx.thread_id);
let is_new = !stored.as_deref().map(is_uuid_v4).unwrap_or(false);
let cc_session_id = if is_new {
let id = generate_uuid_v4();
if let Err(e) = ctx.session_store.set(&ctx.thread_id, &id) {
log::warn!(
"[claude-code][driver] failed to persist session uuid for thread {}: {}",
ctx.thread_id,
e
);
}
id
} else {
stored.expect("checked Some above")
};
// Set up a per-turn scratch dir for --mcp-config and any other transient
// state. Best-effort cleanup at end of turn.
let scratch = tempfile::Builder::new()
.prefix("openhuman-cc-")
.tempdir()
.map_err(|e| anyhow::anyhow!("create scratch dir: {e}"))?;
let mut mcp_config_path: Option<PathBuf> = None;
if let Some(core_bin) = ctx.openhuman_core_bin.as_ref() {
match write_mcp_config(scratch.path(), core_bin) {
Ok(p) => {
log::debug!(
"[claude-code][driver] wrote mcp-config path={} core_bin={}",
p.display(),
core_bin.display()
);
mcp_config_path = Some(p);
}
Err(e) => log::warn!(
"[claude-code][driver] failed to write mcp-config: {e}; CC will run without OpenHuman MCP tools"
),
}
} else {
log::debug!(
"[claude-code][driver] no openhuman_core_bin provided; CC running without OpenHuman MCP tools"
);
}
let mut args: Vec<String> = vec![
"-p".into(),
"--input-format".into(),
"stream-json".into(),
"--output-format".into(),
"stream-json".into(),
"--verbose".into(),
"--include-partial-messages".into(),
"--add-dir".into(),
ctx.workspace_dir.display().to_string(),
if is_new {
"--session-id".into()
} else {
"--resume".into()
},
cc_session_id.clone(),
"--model".into(),
ctx.model.clone(),
];
if let Some(sp) = ctx
.append_system_prompt
.as_ref()
.filter(|s| !s.trim().is_empty())
{
args.push("--append-system-prompt".into());
args.push(sp.clone());
}
if let Some(p) = mcp_config_path.as_ref() {
args.push("--mcp-config".into());
args.push(p.display().to_string());
args.push("--strict-mcp-config".into());
}
// Disable CC's built-in tools so OpenHuman's MCP surface stays
// authoritative. We disable per-builtin instead of using
// `--dangerously-skip-permissions` to keep the permission-prompt
// floor intact for any tools we forgot to list.
args.push("--disallowedTools".into());
args.push(DISALLOWED_CC_BUILTINS.join(","));
// Validate input *before* spawning so we don't launch a process we
// can't feed (CodeRabbit: validate before spawn).
let stdin_bytes = build_stdin(ctx.messages, is_new);
if stdin_bytes.is_empty() {
anyhow::bail!("[claude-code][driver] no input messages to deliver");
}
log::debug!(
"[claude-code][driver] spawn bin={} model={} is_new={} cc_session_id={}",
ctx.bin_path.display(),
ctx.model,
is_new,
cc_session_id
);
let mut cmd = Command::new(&ctx.bin_path);
cmd.args(&args)
.current_dir(&ctx.workspace_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(key) = &ctx.anthropic_api_key {
cmd.env("ANTHROPIC_API_KEY", key);
}
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn `claude`: {e}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(&stdin_bytes)
.await
.map_err(|e| anyhow::anyhow!("write stdin: {e}"))?;
stdin
.shutdown()
.await
.map_err(|e| anyhow::anyhow!("close stdin: {e}"))?;
}
let mut stdout = child
.stdout
.take()
.ok_or_else(|| anyhow::anyhow!("claude child stdout missing"))?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| anyhow::anyhow!("claude child stderr missing"))?;
let mut parser = StreamJsonParser::new();
let mut mapper = EventMapper::new();
let mut buf = [0u8; 8192];
// Drain stderr in parallel into a buffer for diagnostics.
let stderr_task = tokio::spawn(async move {
let mut acc = String::new();
let mut tmp = [0u8; 4096];
while let Ok(n) = stderr.read(&mut tmp).await {
if n == 0 {
break;
}
acc.push_str(&String::from_utf8_lossy(&tmp[..n]));
if acc.len() > 16_384 {
acc.truncate(16_384);
}
}
acc
});
// Wrap the streaming + wait in a timeout so a stuck CLI doesn't
// block this task forever (PLAN §8).
let timed = tokio::time::timeout(TURN_TIMEOUT, async {
loop {
let n = stdout
.read(&mut buf)
.await
.map_err(|e| anyhow::anyhow!("read stdout: {e}"))?;
if n == 0 {
break;
}
for ev in parser.feed_bytes(&buf[..n]) {
for delta in mapper.handle(ev) {
if let Some(tx) = ctx.stream {
let _ = tx.send(delta).await;
}
}
}
}
for ev in parser.end() {
for delta in mapper.handle(ev) {
if let Some(tx) = ctx.stream {
let _ = tx.send(delta).await;
}
}
}
let status = child
.wait()
.await
.map_err(|e| anyhow::anyhow!("wait child: {e}"))?;
Ok::<_, anyhow::Error>(status)
})
.await;
let status = match timed {
Ok(inner) => inner?,
Err(_elapsed) => {
log::error!(
"[claude-code][driver] turn timeout ({TURN_TIMEOUT:?}) exceeded; killing child"
);
// kill_on_drop handles cleanup, but explicit kill gives us
// a chance to collect stderr.
let _ = child.kill().await;
anyhow::bail!(
"[claude-code][driver] turn timed out after {:?}",
TURN_TIMEOUT
);
}
};
let stderr_text = stderr_task.await.unwrap_or_default();
if !status.success() {
anyhow::bail!(
"[claude-code][driver] exit {:?} stderr={}",
status.code(),
stderr_text.trim()
);
}
if let Some(err) = mapper.error.clone() {
anyhow::bail!("[claude-code][driver] {}", err);
}
Ok(mapper.into_response())
}
@@ -0,0 +1,379 @@
//! Translate `ClaudeCodeEvent`s into OpenHuman `ProviderDelta`s plus a
//! final aggregated `ChatResponse`.
//!
//! The CLI emits content as anthropic-style content blocks. We map:
//! - `content_block_start` text → start a text accumulator
//! - `content_block_delta` text → `ProviderDelta::TextDelta`
//! - `content_block_start` tool → `ProviderDelta::ToolCallStart`
//! - `content_block_delta` tool → `ProviderDelta::ToolCallArgsDelta`
//! - `result` → finalize usage + cost
//!
//! Thinking blocks (`thinking_delta`) are forwarded as
//! `ProviderDelta::ThinkingDelta`.
use std::collections::HashMap;
use serde_json::Value;
use super::stream_parser::ClaudeCodeEvent;
use crate::openhuman::inference::provider::traits::{
ChatResponse, ProviderDelta, ToolCall, UsageInfo,
};
#[derive(Debug, Clone)]
struct BlockState {
kind: BlockKind,
call_id: Option<String>,
tool_name: Option<String>,
text_accum: String,
input_accum: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum BlockKind {
Text,
Thinking,
Tool,
}
#[derive(Debug, Default)]
pub struct EventMapper {
blocks: HashMap<u64, BlockState>,
pub final_text: String,
pub tool_calls: Vec<ToolCall>,
pub usage: Option<UsageInfo>,
pub error: Option<String>,
pub session_id: Option<String>,
pub finished: bool,
}
impl EventMapper {
pub fn new() -> Self {
Self::default()
}
/// Process one event and return the deltas to forward to the stream
/// sink (if any).
pub fn handle(&mut self, event: ClaudeCodeEvent) -> Vec<ProviderDelta> {
match event {
ClaudeCodeEvent::System { session_id, .. } => {
if let Some(id) = session_id {
self.session_id = Some(id);
}
Vec::new()
}
ClaudeCodeEvent::Error { message } => {
self.error = Some(message);
Vec::new()
}
ClaudeCodeEvent::Result {
subtype,
usage,
total_cost_usd,
..
} => {
let mut parsed = usage.as_ref().map(parse_usage);
// CC stream emits `total_cost_usd` on the terminal `result`
// event — surface it as `UsageInfo.charged_amount_usd` so
// downstream cost.rs can record it without re-pricing
// tokens × model rates.
if let Some(cost) = total_cost_usd {
let usage = parsed.get_or_insert_with(UsageInfo::default);
usage.charged_amount_usd = cost;
}
self.usage = parsed;
if subtype.as_deref() == Some("error") && self.error.is_none() {
self.error = Some("claude reported `result.subtype=error`".into());
}
self.finished = true;
Vec::new()
}
ClaudeCodeEvent::Assistant { message } => {
// CC 2.x emits a final assembled `assistant` event with
// `message.type == "message"` after streaming completes via
// `stream_event`. Skip to avoid double-emission.
if message.get("type").and_then(Value::as_str) == Some("message") {
return Vec::new();
}
self.handle_assistant_block(&message)
}
ClaudeCodeEvent::StreamEvent { event } => self.handle_assistant_block(&event),
ClaudeCodeEvent::User { message } => {
// tool_result blocks from the CLI's own tool runs aren't
// surfaced to OpenHuman's harness (the harness owns tools
// via MCP, not via CC internals). Track for completeness.
let _ = message;
Vec::new()
}
ClaudeCodeEvent::RateLimit { .. } | ClaudeCodeEvent::ParseError { .. } => Vec::new(),
}
}
fn handle_assistant_block(&mut self, msg: &Value) -> Vec<ProviderDelta> {
let ty = msg.get("type").and_then(Value::as_str).unwrap_or("");
let index = msg.get("index").and_then(Value::as_u64).unwrap_or(0);
match ty {
"content_block_start" => self.on_block_start(index, msg),
"content_block_delta" => self.on_block_delta(index, msg),
"content_block_stop" => self.on_block_stop(index),
_ => Vec::new(),
}
}
fn on_block_start(&mut self, index: u64, msg: &Value) -> Vec<ProviderDelta> {
let block = match msg.get("content_block") {
Some(b) => b,
None => return Vec::new(),
};
let kind = block.get("type").and_then(Value::as_str).unwrap_or("");
match kind {
"text" => {
self.blocks.insert(
index,
BlockState {
kind: BlockKind::Text,
call_id: None,
tool_name: None,
text_accum: String::new(),
input_accum: String::new(),
},
);
Vec::new()
}
"thinking" => {
self.blocks.insert(
index,
BlockState {
kind: BlockKind::Thinking,
call_id: None,
tool_name: None,
text_accum: String::new(),
input_accum: String::new(),
},
);
Vec::new()
}
"tool_use" => {
let call_id = block
.get("id")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
let tool_name = block
.get("name")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
if call_id.is_none() || tool_name.is_none() {
log::warn!(
"[claude-code][event-mapper] skipping tool_use block with missing id or name"
);
return Vec::new();
}
let call_id = call_id.unwrap();
let tool_name = tool_name.unwrap();
self.blocks.insert(
index,
BlockState {
kind: BlockKind::Tool,
call_id: Some(call_id.clone()),
tool_name: Some(tool_name.clone()),
text_accum: String::new(),
input_accum: String::new(),
},
);
vec![ProviderDelta::ToolCallStart { call_id, tool_name }]
}
_ => Vec::new(),
}
}
fn on_block_delta(&mut self, index: u64, msg: &Value) -> Vec<ProviderDelta> {
let delta = match msg.get("delta") {
Some(d) => d,
None => return Vec::new(),
};
let dtype = delta.get("type").and_then(Value::as_str).unwrap_or("");
let Some(state) = self.blocks.get_mut(&index) else {
return Vec::new();
};
match (state.kind.clone(), dtype) {
(BlockKind::Text, "text_delta") => {
let text = delta
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
state.text_accum.push_str(&text);
self.final_text.push_str(&text);
vec![ProviderDelta::TextDelta { delta: text }]
}
(BlockKind::Thinking, "thinking_delta") => {
let text = delta
.get("thinking")
.and_then(Value::as_str)
.or_else(|| delta.get("text").and_then(Value::as_str))
.unwrap_or("")
.to_string();
state.text_accum.push_str(&text);
vec![ProviderDelta::ThinkingDelta { delta: text }]
}
(BlockKind::Tool, "input_json_delta") => {
let partial = delta
.get("partial_json")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
state.input_accum.push_str(&partial);
let call_id = state.call_id.clone().unwrap_or_default();
vec![ProviderDelta::ToolCallArgsDelta {
call_id,
delta: partial,
}]
}
_ => Vec::new(),
}
}
fn on_block_stop(&mut self, index: u64) -> Vec<ProviderDelta> {
let Some(state) = self.blocks.remove(&index) else {
return Vec::new();
};
if state.kind == BlockKind::Tool {
let call_id = state.call_id.unwrap_or_default();
let name = state.tool_name.unwrap_or_default();
let arguments = if state.input_accum.trim().is_empty() {
"{}".to_string()
} else {
state.input_accum.clone()
};
self.tool_calls.push(ToolCall {
id: call_id,
name,
arguments,
});
}
Vec::new()
}
/// Build the final aggregated `ChatResponse` once the stream is done.
pub fn into_response(self) -> ChatResponse {
ChatResponse {
text: if self.final_text.is_empty() {
None
} else {
Some(self.final_text)
},
tool_calls: self.tool_calls,
usage: self.usage,
reasoning_content: None,
}
}
}
fn parse_usage(v: &Value) -> UsageInfo {
let n = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
UsageInfo {
input_tokens: n("input_tokens"),
output_tokens: n("output_tokens"),
context_window: 0,
cached_input_tokens: n("cache_read_input_tokens"),
charged_amount_usd: 0.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn text_block_start(idx: u64) -> Value {
json!({"type":"content_block_start","index":idx,"content_block":{"type":"text"}})
}
fn text_delta(idx: u64, t: &str) -> Value {
json!({"type":"content_block_delta","index":idx,"delta":{"type":"text_delta","text":t}})
}
#[test]
fn text_streams_through() {
let mut m = EventMapper::new();
m.handle(ClaudeCodeEvent::StreamEvent {
event: text_block_start(0),
});
let d1 = m.handle(ClaudeCodeEvent::StreamEvent {
event: text_delta(0, "hel"),
});
let d2 = m.handle(ClaudeCodeEvent::StreamEvent {
event: text_delta(0, "lo"),
});
assert!(matches!(&d1[0], ProviderDelta::TextDelta { delta } if delta == "hel"));
assert!(matches!(&d2[0], ProviderDelta::TextDelta { delta } if delta == "lo"));
assert_eq!(m.final_text, "hello");
}
#[test]
fn tool_call_assembles_input() {
let mut m = EventMapper::new();
let start = json!({"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_1","name":"memory_search"}});
let d_args = json!({"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"q\":\"foo\"}"}});
let stop = json!({"type":"content_block_stop","index":1});
let starts = m.handle(ClaudeCodeEvent::StreamEvent { event: start });
assert!(
matches!(&starts[0], ProviderDelta::ToolCallStart { tool_name, .. } if tool_name == "memory_search")
);
let args = m.handle(ClaudeCodeEvent::StreamEvent { event: d_args });
assert!(matches!(&args[0], ProviderDelta::ToolCallArgsDelta { .. }));
m.handle(ClaudeCodeEvent::StreamEvent { event: stop });
assert_eq!(m.tool_calls.len(), 1);
assert_eq!(m.tool_calls[0].name, "memory_search");
assert_eq!(m.tool_calls[0].arguments, r#"{"q":"foo"}"#);
}
#[test]
fn result_event_captures_usage() {
let mut m = EventMapper::new();
m.handle(ClaudeCodeEvent::Result {
subtype: Some("success".into()),
usage: Some(json!({
"input_tokens": 100,
"output_tokens": 50,
"cache_read_input_tokens": 25
})),
total_cost_usd: Some(0.001),
raw: Value::Null,
});
assert!(m.finished);
let u = m.usage.as_ref().unwrap();
assert_eq!(u.input_tokens, 100);
assert_eq!(u.output_tokens, 50);
assert_eq!(u.cached_input_tokens, 25);
// cost wired through from total_cost_usd
assert!((u.charged_amount_usd - 0.001).abs() < f64::EPSILON);
}
#[test]
fn cost_surfaced_even_without_usage_object() {
let mut m = EventMapper::new();
m.handle(ClaudeCodeEvent::Result {
subtype: Some("success".into()),
usage: None,
total_cost_usd: Some(0.05),
raw: Value::Null,
});
let u = m
.usage
.as_ref()
.expect("usage synthesized for cost-only result");
assert_eq!(u.input_tokens, 0);
assert!((u.charged_amount_usd - 0.05).abs() < f64::EPSILON);
}
#[test]
fn final_assistant_message_is_skipped() {
let mut m = EventMapper::new();
let deltas = m.handle(ClaudeCodeEvent::Assistant {
message: json!({"type":"message","role":"assistant","content":[]}),
});
assert!(deltas.is_empty());
}
}
@@ -0,0 +1,110 @@
//! Build the stream-json stdin payload fed to `claude --input-format stream-json`.
//!
//! The CLI consumes one JSON object per line on stdin. Each line looks
//! like:
//! { "type":"user", "message":{"role":"user","content":[{"type":"text","text":"..."}]} }
//!
//! v1 piping policy:
//! - On a *new* CC session: send every history `ChatMessage` so claude
//! has full context (system message is conveyed via
//! `--append-system-prompt`, not stdin).
//! - On a `--resume` of an existing CC session: claude already has prior
//! turns server-side; we only send the last user turn.
use serde_json::{json, Value};
use crate::openhuman::inference::provider::traits::ChatMessage;
/// Build the bytes to write to claude's stdin. Returns an empty `Vec`
/// when there is nothing to send (caller should abort).
pub fn build_stdin(messages: &[ChatMessage], is_new_session: bool) -> Vec<u8> {
let mut out = String::new();
let to_emit: Vec<&ChatMessage> = if is_new_session {
messages.iter().filter(|m| m.role != "system").collect()
} else {
// Resume: only the trailing user turn matters.
messages
.iter()
.rev()
.find(|m| m.role == "user")
.into_iter()
.collect()
};
for msg in to_emit {
let role = match msg.role.as_str() {
"user" => "user",
"assistant" => "assistant",
// CC stdin doesn't accept `system` or `tool` rows. The system
// prompt is plumbed via `--append-system-prompt`; tool roles
// belong to the harness, not the CLI's input format.
_ => continue,
};
let line = json!({
"type": "user",
"message": {
"role": role,
"content": [{"type": "text", "text": msg.content}],
},
});
push_json_line(&mut out, &line);
}
out.into_bytes()
}
fn push_json_line(buf: &mut String, v: &Value) {
buf.push_str(&serde_json::to_string(v).unwrap_or_default());
buf.push('\n');
}
#[cfg(test)]
mod tests {
use super::*;
fn msg(role: &str, content: &str) -> ChatMessage {
match role {
"system" => ChatMessage::system(content),
"user" => ChatMessage::user(content),
"assistant" => ChatMessage::assistant(content),
_ => ChatMessage::tool(content),
}
}
#[test]
fn new_session_pipes_full_user_history() {
let history = vec![
msg("system", "you are helpful"),
msg("user", "hi"),
msg("assistant", "hello"),
msg("user", "how are you?"),
];
let bytes = build_stdin(&history, true);
let s = String::from_utf8(bytes).unwrap();
let lines: Vec<_> = s.lines().collect();
assert_eq!(lines.len(), 3); // system filtered out
assert!(lines[0].contains("\"hi\""));
assert!(lines[1].contains("\"hello\""));
assert!(lines[2].contains("how are you"));
}
#[test]
fn resume_pipes_only_last_user_turn() {
let history = vec![
msg("user", "earlier turn"),
msg("assistant", "earlier reply"),
msg("user", "follow-up"),
];
let bytes = build_stdin(&history, false);
let s = String::from_utf8(bytes).unwrap();
let lines: Vec<_> = s.lines().collect();
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("\"follow-up\""));
}
#[test]
fn empty_history_yields_empty_bytes() {
let bytes = build_stdin(&[], true);
assert!(bytes.is_empty());
}
}
@@ -0,0 +1,240 @@
//! Claude Code CLI provider.
//!
//! Drives Anthropic's `claude` CLI (`-p --output-format stream-json
//! --verbose --include-partial-messages --resume <uuid>`) instead of
//! calling the HTTP API directly. v2 will expose OpenHuman's native
//! Rust tools back into the CLI over MCP; this Phase 2 cut runs the
//! driver end-to-end with native CC built-ins disabled at the caller
//! (no `--allowedTools` set means CC's own tools simply don't fire
//! during a non-interactive `-p` turn).
pub mod auth;
pub mod auth_status;
pub mod driver;
pub mod event_mapper;
pub mod input_builder;
pub mod session_store;
pub mod stream_parser;
pub mod types;
pub mod version_check;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use tokio::sync::Semaphore;
use super::traits::{ChatMessage, ChatRequest, ChatResponse, Provider, ProviderCapabilities};
/// Provider string prefix used in the factory grammar: `claude-code:<model>`.
pub const PROVIDER_PREFIX: &str = "claude-code:";
/// Max concurrent `claude` child processes per provider instance.
/// Picked to match the v1 design doc (PLAN §11).
pub const MAX_CONCURRENT_TURNS: usize = 4;
/// CC-CLI-backed `Provider`. Owns a `Semaphore` that caps concurrent
/// child processes and an `Arc<SessionStore>` for per-thread UUIDs.
pub struct ClaudeCodeProvider {
pub model: String,
bin_path: PathBuf,
workspace_dir: PathBuf,
anthropic_api_key: Option<String>,
semaphore: Arc<Semaphore>,
session_store: Arc<session_store::SessionStore>,
}
impl ClaudeCodeProvider {
/// Construct with the CLI path resolved up-front (via `version_check`).
pub fn new(
model: impl Into<String>,
bin_path: PathBuf,
workspace_dir: PathBuf,
anthropic_api_key: Option<String>,
) -> Self {
let session_store = Arc::new(session_store::SessionStore::open(&workspace_dir));
Self {
model: model.into(),
bin_path,
workspace_dir,
anthropic_api_key,
semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_TURNS)),
session_store,
}
}
/// Build the provider from environment + workspace. Errors when the
/// CLI is not installed or below `MIN_CLI_VERSION`.
pub fn from_env(model: impl Into<String>, workspace_dir: PathBuf) -> anyhow::Result<Self> {
match version_check::probe() {
types::CliStatus::Ok { path, .. } => {
let (_, key) = auth::resolve();
Ok(Self::new(model, PathBuf::from(path), workspace_dir, key))
}
types::CliStatus::NotInstalled => {
anyhow::bail!(
"[claude-code] `claude` CLI not installed. Install Claude Code CLI \
({}) >= {} and retry.",
"https://docs.anthropic.com/en/docs/claude-code",
types::MIN_CLI_VERSION
)
}
types::CliStatus::Outdated {
version,
min_required,
path,
} => anyhow::bail!(
"[claude-code] `claude` CLI at {} is version {}; require >= {}",
path,
version,
min_required
),
types::CliStatus::Unusable { path, reason } => anyhow::bail!(
"[claude-code] `claude` CLI at {} unusable: {}",
path,
reason
),
}
}
async fn run_chat(
&self,
request: ChatRequest<'_>,
model_override: Option<&str>,
) -> anyhow::Result<ChatResponse> {
// Cap concurrent CC processes.
let _permit = self
.semaphore
.clone()
.acquire_owned()
.await
.map_err(|e| anyhow::anyhow!("claude-code semaphore closed: {e}"))?;
// Extract system prompt + thread_id from the request.
let append_system_prompt = request
.messages
.iter()
.find(|m| m.role == "system")
.map(|m| m.content.clone());
// OpenHuman doesn't pass thread_id directly through ChatRequest yet
// (Phase 4 will). For Phase 2 we key sessions on a stable hash of
// the conversation so /resume kicks in across consecutive turns.
let thread_id = thread_key_from_messages(request.messages);
let model = model_override.unwrap_or(&self.model).to_string();
let openhuman_core_bin = std::env::current_exe().ok();
let turn = driver::TurnContext {
bin_path: self.bin_path.clone(),
workspace_dir: self.workspace_dir.clone(),
thread_id,
model,
append_system_prompt,
messages: request.messages,
session_store: self.session_store.clone(),
stream: request.stream,
anthropic_api_key: self.anthropic_api_key.clone(),
openhuman_core_bin,
};
driver::run_turn(turn).await
}
}
/// Stable session key derived from the conversation's first user message.
/// Best-effort — Phase 4 will plumb the real OpenHuman thread id through
/// `ChatRequest`.
///
/// Uses SHA-256 (truncated) so the key is stable across Rust compiler
/// versions (unlike `DefaultHasher` which may change between rustc
/// releases, breaking persisted session lookups).
fn thread_key_from_messages(messages: &[ChatMessage]) -> String {
use sha2::{Digest, Sha256};
let first = messages
.iter()
.find(|m| m.role == "user")
.map(|m| m.content.as_str())
.unwrap_or("");
let digest = Sha256::digest(first.as_bytes());
format!(
"hash_{:032x}",
u128::from_be_bytes(digest[..16].try_into().unwrap())
)
}
#[async_trait]
impl Provider for ClaudeCodeProvider {
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities {
native_tool_calling: true,
vision: false,
}
}
async fn chat_with_system(
&self,
system_prompt: Option<&str>,
message: &str,
model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
let mut messages = Vec::new();
if let Some(sp) = system_prompt {
messages.push(ChatMessage::system(sp));
}
messages.push(ChatMessage::user(message));
let request = ChatRequest {
messages: &messages,
tools: None,
stream: None,
};
let resp = self.run_chat(request, Some(model)).await?;
Ok(resp.text.unwrap_or_default())
}
async fn chat_with_history(
&self,
messages: &[ChatMessage],
model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
let request = ChatRequest {
messages,
tools: None,
stream: None,
};
let resp = self.run_chat(request, Some(model)).await?;
Ok(resp.text.unwrap_or_default())
}
async fn chat(
&self,
request: ChatRequest<'_>,
model: &str,
_temperature: f64,
) -> anyhow::Result<ChatResponse> {
self.run_chat(request, Some(model)).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thread_key_is_stable_for_same_conversation() {
let a = vec![ChatMessage::user("hello world")];
let b = vec![
ChatMessage::user("hello world"),
ChatMessage::assistant("hi"),
];
assert_eq!(thread_key_from_messages(&a), thread_key_from_messages(&b));
}
#[test]
fn thread_key_diverges_for_different_first_user() {
let a = vec![ChatMessage::user("alpha")];
let b = vec![ChatMessage::user("beta")];
assert_ne!(thread_key_from_messages(&a), thread_key_from_messages(&b));
}
}
@@ -0,0 +1,130 @@
//! Per-thread CC session UUID persistence.
//!
//! The `claude` CLI's `--resume <uuid>` only reuses a server-side session
//! if we pass it the same UUIDv4 we used the first time. We map an
//! OpenHuman thread id → CC session UUID in a JSON file under the
//! workspace.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize)]
struct StoreFile {
/// thread_id → CC session uuid (v4)
sessions: HashMap<String, String>,
}
/// Disk-backed session store. Cheap to clone — it's `Arc`-shareable via
/// the holding `ClaudeCodeProvider`.
#[derive(Debug)]
pub struct SessionStore {
path: PathBuf,
inner: Mutex<StoreFile>,
}
impl SessionStore {
/// Open (or initialize) the session store at `workspace/claude-code-sessions.json`.
pub fn open(workspace_dir: &Path) -> Self {
let path = workspace_dir.join("claude-code-sessions.json");
let inner = std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<StoreFile>(&s).ok())
.unwrap_or_default();
Self {
path,
inner: Mutex::new(inner),
}
}
/// Lookup an existing CC session UUID for `thread_id`.
pub fn get(&self, thread_id: &str) -> Option<String> {
let guard = self.inner.lock().expect("session store mutex poisoned");
guard.sessions.get(thread_id).cloned()
}
/// Persist a thread → UUID mapping.
pub fn set(&self, thread_id: &str, uuid: &str) -> std::io::Result<()> {
let mut guard = self.inner.lock().expect("session store mutex poisoned");
guard
.sessions
.insert(thread_id.to_string(), uuid.to_string());
let serialized = serde_json::to_string_pretty(&*guard).map_err(std::io::Error::other)?;
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&self.path, serialized)
}
}
/// Random RFC-4122 v4 UUID, formatted lower-case with hyphens.
pub fn generate_uuid_v4() -> String {
use rand::RngExt as _;
let mut bytes = [0u8; 16];
rand::rng().fill(&mut bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10
format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5],
bytes[6], bytes[7],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
)
}
/// CC accepts only RFC-4122 v4. Older stores might carry pre-v4 strings;
/// we treat those as missing and regenerate.
pub fn is_uuid_v4(s: &str) -> bool {
let s = s.as_bytes();
if s.len() != 36 {
return false;
}
let hyphens = [8, 13, 18, 23];
for (i, b) in s.iter().enumerate() {
let is_hyphen = hyphens.contains(&i);
if is_hyphen {
if *b != b'-' {
return false;
}
} else if !b.is_ascii_hexdigit() {
return false;
}
}
// version nibble (index 14) must be '4'; variant nibble (index 19)
// must be one of 8/9/a/b
s[14] == b'4' && matches!(s[19], b'8' | b'9' | b'a' | b'b' | b'A' | b'B')
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn uuid_v4_format() {
let id = generate_uuid_v4();
assert!(is_uuid_v4(&id), "generated id should be v4: {id}");
}
#[test]
fn rejects_non_v4() {
assert!(!is_uuid_v4("not-a-uuid"));
assert!(!is_uuid_v4("cc_abc123"));
// version 1 uuid (nibble at 14 is '1')
assert!(!is_uuid_v4("00000000-0000-1000-8000-000000000000"));
}
#[test]
fn roundtrip_set_and_get() {
let dir = tempdir().unwrap();
let store = SessionStore::open(dir.path());
assert!(store.get("thread_a").is_none());
store.set("thread_a", "abc").unwrap();
let reopened = SessionStore::open(dir.path());
assert_eq!(reopened.get("thread_a").as_deref(), Some("abc"));
}
}
@@ -0,0 +1,215 @@
//! Line-buffered JSONL parser for `claude --output-format stream-json`.
//!
//! The CLI writes one JSON object per line on stdout. Each object has a
//! `type` discriminator (`system`, `user`, `assistant`, `stream_event`,
//! `result`, `error`, `rate_limit_event`). We keep variants permissive
//! (everything is `serde_json::Value`) so a minor CLI schema bump does
//! not break the parser — the event mapper interprets what it knows.
use serde_json::Value;
/// One decoded event from the `claude` CLI stdout stream.
#[derive(Debug, Clone)]
pub enum ClaudeCodeEvent {
System {
session_id: Option<String>,
schema_version: Option<String>,
raw: Value,
},
User {
message: Value,
},
Assistant {
message: Value,
},
StreamEvent {
event: Value,
},
RateLimit {
raw: Value,
},
Result {
subtype: Option<String>,
usage: Option<Value>,
total_cost_usd: Option<f64>,
raw: Value,
},
Error {
message: String,
},
/// JSONL line that failed to parse. Kept so the driver can log without
/// dropping silently. Not surfaced as a `ProviderDelta`.
ParseError {
line: String,
reason: String,
},
}
/// Stateful parser that takes byte chunks from `proc.stdout` and emits
/// fully-formed events on each newline.
#[derive(Debug, Default)]
pub struct StreamJsonParser {
buffer: String,
/// First-seen `schema_version` from a `system` event, if any.
pub schema_version: Option<String>,
}
impl StreamJsonParser {
pub fn new() -> Self {
Self::default()
}
/// Append a UTF-8 byte chunk and return any events whose terminating
/// newline arrived in this chunk.
pub fn feed_bytes(&mut self, chunk: &[u8]) -> Vec<ClaudeCodeEvent> {
self.buffer.push_str(&String::from_utf8_lossy(chunk));
self.flush()
}
/// Append a string chunk.
pub fn feed(&mut self, chunk: &str) -> Vec<ClaudeCodeEvent> {
self.buffer.push_str(chunk);
self.flush()
}
/// Drain any remaining buffered content. Call on EOF.
pub fn end(&mut self) -> Vec<ClaudeCodeEvent> {
if !self.buffer.is_empty() && !self.buffer.ends_with('\n') {
self.buffer.push('\n');
}
self.flush()
}
fn flush(&mut self) -> Vec<ClaudeCodeEvent> {
let mut out = Vec::new();
loop {
let Some(nl) = self.buffer.find('\n') else {
break;
};
let line = self.buffer[..nl].trim().to_string();
self.buffer.drain(..=nl);
if line.is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(v) => out.push(self.decode(v)),
Err(e) => out.push(ClaudeCodeEvent::ParseError {
line,
reason: e.to_string(),
}),
}
}
out
}
fn decode(&mut self, v: Value) -> ClaudeCodeEvent {
let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
match ty {
"system" => {
let session_id = v
.get("session_id")
.and_then(Value::as_str)
.map(str::to_string);
let schema_version = v
.get("schema_version")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(sv) = &schema_version {
if self.schema_version.is_none() {
self.schema_version = Some(sv.clone());
}
}
ClaudeCodeEvent::System {
session_id,
schema_version,
raw: v,
}
}
"user" => ClaudeCodeEvent::User {
message: v.get("message").cloned().unwrap_or(Value::Null),
},
"assistant" => ClaudeCodeEvent::Assistant {
message: v.get("message").cloned().unwrap_or(Value::Null),
},
"stream_event" => ClaudeCodeEvent::StreamEvent {
event: v.get("event").cloned().unwrap_or(Value::Null),
},
"rate_limit_event" => ClaudeCodeEvent::RateLimit { raw: v },
"result" => {
let subtype = v.get("subtype").and_then(Value::as_str).map(str::to_string);
let usage = v.get("usage").cloned();
let total_cost_usd = v.get("total_cost_usd").and_then(Value::as_f64);
ClaudeCodeEvent::Result {
subtype,
usage,
total_cost_usd,
raw: v,
}
}
"error" => ClaudeCodeEvent::Error {
message: v
.get("error")
.and_then(Value::as_str)
.unwrap_or("claude-code error")
.to_string(),
},
other => ClaudeCodeEvent::ParseError {
line: v.to_string(),
reason: format!("unknown event type `{other}`"),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_multiline_chunk() {
let mut p = StreamJsonParser::new();
let chunk = r#"{"type":"system","session_id":"s1","schema_version":"2.0"}
{"type":"assistant","message":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}
"#;
let events = p.feed(chunk);
assert_eq!(events.len(), 2);
assert_eq!(p.schema_version.as_deref(), Some("2.0"));
assert!(matches!(events[0], ClaudeCodeEvent::System { .. }));
assert!(matches!(events[1], ClaudeCodeEvent::Assistant { .. }));
}
#[test]
fn handles_split_lines_across_chunks() {
let mut p = StreamJsonParser::new();
assert!(p.feed("{\"type\":\"system\"").is_empty());
assert!(p.feed(",\"session_id\":\"s1\"}").is_empty());
let events = p.feed("\n");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClaudeCodeEvent::System { .. }));
}
#[test]
fn flushes_trailing_line_on_end() {
let mut p = StreamJsonParser::new();
assert!(p
.feed(r#"{"type":"result","subtype":"success"}"#)
.is_empty());
let events = p.end();
assert_eq!(events.len(), 1);
assert!(matches!(events[0], ClaudeCodeEvent::Result { .. }));
}
#[test]
fn unknown_type_becomes_parse_error() {
let mut p = StreamJsonParser::new();
let events = p.feed("{\"type\":\"weird\"}\n");
assert!(matches!(events[0], ClaudeCodeEvent::ParseError { .. }));
}
#[test]
fn bad_json_becomes_parse_error() {
let mut p = StreamJsonParser::new();
let events = p.feed("not json\n");
assert!(matches!(events[0], ClaudeCodeEvent::ParseError { .. }));
}
}
@@ -0,0 +1,31 @@
//! Shared types for the Claude Code CLI provider.
use serde::{Deserialize, Serialize};
/// Minimum supported `claude` CLI version. Below this, the provider refuses
/// to start so we never feed an unsupported stream-json schema into the
/// parser.
pub const MIN_CLI_VERSION: &str = "2.0.0";
/// Outcome of probing the `claude` CLI binary on disk.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum CliStatus {
Ok {
version: String,
path: String,
},
NotInstalled,
Outdated {
version: String,
min_required: String,
path: String,
},
Unusable {
path: String,
reason: String,
},
}
/// Branding string used in user-facing copy. Locked decision (PLAN §13.4).
pub const BRAND_LABEL: &str = "Claude Code CLI";
@@ -0,0 +1,167 @@
//! Locate the `claude` CLI binary and verify it meets `MIN_CLI_VERSION`.
//!
//! We rely on `claude --version`, which prints a line of the form:
//! `2.0.4 (Claude Code)`
//! The first whitespace-delimited token is the semver string we compare
//! against [`MIN_CLI_VERSION`].
use std::path::PathBuf;
use std::process::Command;
use super::types::{CliStatus, MIN_CLI_VERSION};
/// Locate the `claude` CLI binary on `PATH`.
///
/// Honors `OPENHUMAN_CLAUDE_CLI` env override so tests and power users can
/// point at a specific binary.
pub fn resolve_binary() -> Option<PathBuf> {
if let Ok(explicit) = std::env::var("OPENHUMAN_CLAUDE_CLI") {
let p = PathBuf::from(explicit);
if p.exists() {
return Some(p);
}
}
which_on_path("claude")
}
fn which_on_path(name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
let exts: Vec<String> = if cfg!(windows) {
std::env::var("PATHEXT")
.unwrap_or_else(|_| ".EXE;.CMD;.BAT;.COM".into())
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.to_ascii_lowercase())
.collect()
} else {
vec![String::new()]
};
for dir in std::env::split_paths(&path_var) {
if cfg!(windows) {
for ext in &exts {
let candidate = dir.join(format!("{name}{ext}"));
if candidate.is_file() {
return Some(candidate);
}
}
} else {
let candidate = dir.join(name);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
/// Probe the `claude` CLI and return its status.
pub fn probe() -> CliStatus {
let Some(path) = resolve_binary() else {
log::debug!("[claude-code][version] no `claude` binary on PATH");
return CliStatus::NotInstalled;
};
let path_str = path.display().to_string();
let output = match Command::new(&path).arg("--version").output() {
Ok(o) => o,
Err(e) => {
log::warn!("[claude-code][version] spawn failed path={path_str} err={e}");
return CliStatus::Unusable {
path: path_str,
reason: format!("spawn failed: {e}"),
};
}
};
if !output.status.success() {
return CliStatus::Unusable {
path: path_str,
reason: format!(
"non-zero exit {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
),
};
}
let stdout = String::from_utf8_lossy(&output.stdout);
let version = match parse_version(&stdout) {
Some(v) => v,
None => {
return CliStatus::Unusable {
path: path_str,
reason: format!("could not parse version from: {stdout:?}"),
}
}
};
if version_lt(&version, MIN_CLI_VERSION) {
CliStatus::Outdated {
version,
min_required: MIN_CLI_VERSION.to_string(),
path: path_str,
}
} else {
CliStatus::Ok {
version,
path: path_str,
}
}
}
fn parse_version(stdout: &str) -> Option<String> {
stdout
.split_whitespace()
.next()
.filter(|tok| tok.chars().next().is_some_and(|c| c.is_ascii_digit()))
.map(|s| s.to_string())
}
/// Numeric semver compare. Returns true when `a < b`.
/// Pre-release suffixes (`-rc.1`) are stripped before comparison.
fn version_lt(a: &str, b: &str) -> bool {
let pa = parts(a);
let pb = parts(b);
pa < pb
}
fn parts(v: &str) -> (u32, u32, u32) {
let core = v.split('-').next().unwrap_or(v);
let mut it = core.split('.').map(|s| s.parse::<u32>().unwrap_or(0));
(
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_typical_output() {
assert_eq!(
parse_version("2.0.4 (Claude Code)\n").as_deref(),
Some("2.0.4")
);
}
#[test]
fn rejects_non_numeric_prefix() {
assert_eq!(parse_version("claude version 2.0.4"), None);
}
#[test]
fn version_compare() {
assert!(version_lt("1.9.9", "2.0.0"));
assert!(version_lt("2.0.0", "2.0.1"));
assert!(!version_lt("2.0.0", "2.0.0"));
assert!(!version_lt("2.1.0", "2.0.9"));
}
#[test]
fn version_compare_strips_prerelease() {
assert!(!version_lt("2.0.0-rc.1", "2.0.0"));
}
}
@@ -274,6 +274,47 @@ pub fn create_chat_provider_from_string(
verify_session_active(config)?;
}
if let Some(model_with_temp) =
p.strip_prefix(crate::openhuman::inference::provider::claude_code::PROVIDER_PREFIX)
{
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
if temperature_override.is_some() {
log::warn!(
"[providers][chat-factory] claude-code provider: per-model temperature override \
is accepted but not yet wired through to the CLI — the @<temp> suffix is ignored"
);
}
if model.is_empty() {
anyhow::bail!(
"[chat-factory] provider string '{}' for role '{}' has an empty model — \
use 'claude-code:<model-id>'",
p,
role
);
}
let workspace = config
.config_path
.parent()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
directories::UserDirs::new()
.map(|d| d.home_dir().join(".openhuman"))
.unwrap_or_else(|| std::path::PathBuf::from(".openhuman"))
});
log::debug!(
"[providers][chat-factory] building claude-code CLI provider model={} workspace={}",
model,
workspace.display()
);
let provider =
crate::openhuman::inference::provider::claude_code::ClaudeCodeProvider::from_env(
model.clone(),
workspace,
)?;
let p_box: Box<dyn Provider> = Box::new(provider);
return Ok((p_box, model));
}
if let Some(model_with_temp) = p.strip_prefix(OLLAMA_PROVIDER_PREFIX) {
let (model, temperature_override) = split_model_and_temperature(model_with_temp);
if model.is_empty() {
+1
View File
@@ -6,6 +6,7 @@
pub mod billing_error;
pub mod claude_agent_sdk;
pub mod claude_code;
pub mod compatible;
pub mod compatible_dump;
pub mod compatible_parse;
+52
View File
@@ -140,6 +140,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("test_provider_model"),
schemas("should_react"),
schemas("analyze_sentiment"),
schemas("claude_code_status"),
schemas("claude_code_auth_status"),
]
}
@@ -225,6 +227,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("analyze_sentiment"),
handler: handle_inference_analyze_sentiment,
},
RegisteredController {
schema: schemas("claude_code_status"),
handler: handle_inference_claude_code_status,
},
RegisteredController {
schema: schemas("claude_code_auth_status"),
handler: handle_inference_claude_code_auth_status,
},
]
}
@@ -436,6 +446,26 @@ pub fn schemas(function: &str) -> ControllerSchema {
inputs: vec![required_string("message", "User message content to classify.")],
outputs: vec![json_output("sentiment", "Sentiment analysis payload.")],
},
"claude_code_status" => ControllerSchema {
namespace: "inference",
function: "claude_code_status",
description: "Probe the local `claude` CLI binary (Claude Code CLI provider) and return install + version status.",
inputs: vec![],
outputs: vec![json_output(
"status",
"CliStatus payload: ok | not_installed | outdated | unusable, with version + path when present.",
)],
},
"claude_code_auth_status" => ControllerSchema {
namespace: "inference",
function: "claude_code_auth_status",
description: "Detect Claude Code CLI auth state (Pro/Max subscription via credentials.json, API key env, or none). No CLI spawn, no token round-trip.",
inputs: vec![],
outputs: vec![json_output(
"auth",
"AuthStatus payload: source = subscription | api_key_env | none, plus optional account_email + expires_at + last_checked.",
)],
},
other => panic!("unknown inference schema: {other}"),
}
}
@@ -818,6 +848,28 @@ fn handle_inference_analyze_sentiment(params: Map<String, Value>) -> ControllerF
})
}
fn handle_inference_claude_code_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let status = tokio::task::spawn_blocking(
crate::openhuman::inference::provider::claude_code::version_check::probe,
)
.await
.map_err(|e| format!("claude_code_status join error: {e}"))?;
to_json(RpcOutcome::new(status, vec![]))
})
}
fn handle_inference_claude_code_auth_status(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let auth = tokio::task::spawn_blocking(
crate::openhuman::inference::provider::claude_code::auth_status::probe,
)
.await
.map_err(|e| format!("claude_code_auth_status join error: {e}"))?;
to_json(RpcOutcome::new(auth, vec![]))
})
}
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
+103
View File
@@ -0,0 +1,103 @@
//! End-to-end test of the Claude Code stream-json pipeline.
//!
//! Feeds a captured representative CC 2.x stream-json transcript through
//! `StreamJsonParser` → `EventMapper` and asserts that:
//! - text deltas arrive in order and aggregate into the final response
//! - tool-use blocks emit ToolCallStart + ToolCallArgsDelta + a final
//! ToolCall with parsed JSON arguments
//! - the `result` event finalizes usage tokens (incl. cache_read)
//! - session_id is captured from the first `system` event
//!
//! This is a parser-level E2E; the real driver / process spawn is mocked
//! in `tests/claude_code_driver_smoke.rs`.
use openhuman_core::openhuman::inference::provider::claude_code::{
event_mapper::EventMapper, stream_parser::StreamJsonParser,
};
use openhuman_core::openhuman::inference::provider::traits::ProviderDelta;
const TRANSCRIPT: &str = r#"{"type":"system","subtype":"init","session_id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","schema_version":"2.0"}
{"type":"stream_event","event":{"type":"content_block_start","index":0,"content_block":{"type":"text"}}}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}}
{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}}}
{"type":"stream_event","event":{"type":"content_block_stop","index":0}}
{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"call_42","name":"memory_search"}}}
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"que"}}}
{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"ry\":\"foo\"}"}}}
{"type":"stream_event","event":{"type":"content_block_stop","index":1}}
{"type":"assistant","message":{"type":"message","role":"assistant","content":[]}}
{"type":"result","subtype":"success","usage":{"input_tokens":120,"output_tokens":42,"cache_read_input_tokens":80,"cache_creation_input_tokens":0},"total_cost_usd":0.0012}
"#;
#[test]
fn captures_text_tool_call_and_usage() {
let mut parser = StreamJsonParser::new();
let mut mapper = EventMapper::new();
let mut deltas: Vec<ProviderDelta> = Vec::new();
// Feed in chunks to exercise the chunk-boundary buffering as well.
let mid = TRANSCRIPT.len() / 2;
for chunk in [&TRANSCRIPT[..mid], &TRANSCRIPT[mid..]] {
for evt in parser.feed(chunk) {
for d in mapper.handle(evt) {
deltas.push(d);
}
}
}
for evt in parser.end() {
for d in mapper.handle(evt) {
deltas.push(d);
}
}
// Schema version was captured by the parser.
assert_eq!(parser.schema_version.as_deref(), Some("2.0"));
// Session id was captured by the mapper from the first system event.
assert_eq!(
mapper.session_id.as_deref(),
Some("f47ac10b-58cc-4372-a567-0e02b2c3d479")
);
// Text deltas arrived in order.
let text_chunks: Vec<&str> = deltas
.iter()
.filter_map(|d| match d {
ProviderDelta::TextDelta { delta } => Some(delta.as_str()),
_ => None,
})
.collect();
assert_eq!(text_chunks, vec!["Hello", " world"]);
// Tool call lifecycle.
assert!(deltas.iter().any(|d| matches!(
d,
ProviderDelta::ToolCallStart { tool_name, call_id }
if tool_name == "memory_search" && call_id == "call_42"
)));
let args_concat: String = deltas
.iter()
.filter_map(|d| match d {
ProviderDelta::ToolCallArgsDelta { call_id, delta } if call_id == "call_42" => {
Some(delta.as_str())
}
_ => None,
})
.collect::<Vec<_>>()
.join("");
assert_eq!(args_concat, r#"{"query":"foo"}"#);
// Aggregated response.
assert_eq!(mapper.final_text, "Hello world");
assert_eq!(mapper.tool_calls.len(), 1);
assert_eq!(mapper.tool_calls[0].name, "memory_search");
assert_eq!(mapper.tool_calls[0].id, "call_42");
assert_eq!(mapper.tool_calls[0].arguments, r#"{"query":"foo"}"#);
// Usage from the `result` event.
assert!(mapper.finished);
let u = mapper.usage.as_ref().expect("usage should be populated");
assert_eq!(u.input_tokens, 120);
assert_eq!(u.output_tokens, 42);
assert_eq!(u.cached_input_tokens, 80);
}