Skip to content

Commit 262d6a4

Browse files
committed
feat: add UniFFI bindgen and feature-gate ClaudeCliProvider for mobile
- Add uniffi-bindgen binary to mobile crate for Swift/Kotlin binding generation (validated: produces ~40KB Swift + Kotlin files) - Feature-gate ClaudeCliProvider behind `claude-cli` feature (default on for desktop, excluded on mobile) since it requires subprocess execution not available on iOS/Android - Add `claude-cli` feature to localgpt-core default features - Fix AgentHandle Send+Sync assertion to avoid dead_code warnings - Update CLAUDE.md with binding generation commands and feature docs
1 parent ee91186 commit 262d6a4

8 files changed

Lines changed: 67 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,15 +72,16 @@ localgpt-gen ──→ localgpt-core
7272
Platform-independent library. Compiles for iOS/Android targets.
7373

7474
- **agent/** - LLM interaction layer
75-
- `providers.rs` - Trait `LLMProvider` with implementations for OpenAI, Anthropic, Ollama, Claude CLI, and GLM (Z.AI). Model prefix determines provider (`claude-cli/*` → Claude CLI, `gpt-*` → OpenAI, `claude-*` → Anthropic API, `glm-*` → GLM, else Ollama)
75+
- `providers.rs` - Trait `LLMProvider` with implementations for OpenAI, Anthropic, Ollama, Claude CLI (feature-gated: `claude-cli`), and GLM (Z.AI). Model prefix determines provider (`claude-cli/*` → Claude CLI, `gpt-*` → OpenAI, `claude-*` → Anthropic API, `glm-*` → GLM, else Ollama)
7676
- `session.rs` - Conversation state with automatic compaction when approaching context window limits
7777
- `session_store.rs` - Session metadata store (`sessions.json`) with CLI session ID persistence
7878
- `system_prompt.rs` - Builds system prompt with identity, safety, workspace info, tools, skills, and special tokens
7979
- `skills.rs` - Loads SKILL.md files from workspace/skills/ for specialized task handling
8080
- `tools/mod.rs` - Safe tools only: `memory_search`, `memory_get`, `web_fetch`, `web_search`
8181
- `AgentHandle` - Thread-safe `Arc<tokio::sync::Mutex<Agent>>` wrapper for mobile/server use
8282
- **memory/** - Markdown-based knowledge store (SQLite FTS5, file watcher, workspace templates)
83-
- Feature-gated: `embeddings-local` (fastembed/ONNX, default), `embeddings-openai`, `embeddings-gguf`, `embeddings-none`
83+
- Feature-gated embeddings: `embeddings-local` (fastembed/ONNX, default), `embeddings-openai`, `embeddings-gguf`, `embeddings-none`
84+
- Feature-gated provider: `claude-cli` (default) — subprocess-based Claude CLI; excluded on mobile
8485
- **heartbeat/** - Autonomous task runner on configurable interval
8586
- **config/** - TOML configuration at `~/.localgpt/config.toml`. `Config::load()` for desktop, `Config::load_from_dir()` for mobile
8687
- **commands.rs** - Shared slash command definitions used by CLI and Telegram
@@ -283,13 +284,30 @@ Plus any skill slash commands (e.g., `/github-pr`, `/commit`) based on installed
283284

284285
## Mobile Development
285286

287+
### Generate Bindings (dev machine)
288+
289+
```bash
290+
# Build the mobile crate (includes uniffi-bindgen binary)
291+
cargo build -p localgpt-mobile
292+
293+
# Generate Swift bindings
294+
target/debug/uniffi-bindgen generate \
295+
--library target/debug/liblocalgpt_mobile.dylib \
296+
--language swift --out-dir mobile/ios/Generated
297+
298+
# Generate Kotlin bindings
299+
target/debug/uniffi-bindgen generate \
300+
--library target/debug/liblocalgpt_mobile.dylib \
301+
--language kotlin --out-dir mobile/android/Generated
302+
```
303+
286304
### iOS
287305

288306
```bash
289307
# Prerequisites
290308
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
291309

292-
# Build and generate Swift bindings
310+
# Build and generate Swift bindings + XCFramework
293311
cd mobile/ios/scripts
294312
./build-rust.sh # Release build (default)
295313
./build-rust.sh debug # Debug build

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/core/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ repository.workspace = true
77
description = "Core library for LocalGPT — agent, memory, config, security"
88

99
[features]
10-
default = ["embeddings-local"]
10+
default = ["embeddings-local", "claude-cli"]
1111
# Local embeddings via fastembed (ONNX). Desktop default.
1212
embeddings-local = ["fastembed"]
13+
# Claude CLI provider (requires subprocess execution — not available on mobile)
14+
claude-cli = []
1315
# GGUF embedding model support via llama.cpp (requires C++ compiler)
1416
embeddings-gguf = ["llama-cpp-2"]
1517
# OpenAI API embeddings (no native deps, always works on mobile)

crates/core/src/agent/mod.rs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,13 +1230,11 @@ pub struct AgentHandle {
12301230
inner: Arc<tokio::sync::Mutex<Agent>>,
12311231
}
12321232

1233-
// Compile-time guarantee that AgentHandle is safe to share across threads.
1234-
const _: () = {
1235-
fn assert_send_sync<T: Send + Sync>() {}
1236-
fn check() {
1237-
assert_send_sync::<AgentHandle>();
1238-
}
1239-
};
1233+
// Compile-time assertion: AgentHandle must be Send + Sync.
1234+
fn _assert_agent_handle_is_send_sync() {
1235+
fn require_send_sync<T: Send + Sync>() {}
1236+
require_send_sync::<AgentHandle>();
1237+
}
12401238

12411239
impl AgentHandle {
12421240
/// Create a new handle wrapping an existing Agent.

crates/core/src/agent/providers.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@ use reqwest::Client;
66
use serde::{Deserialize, Serialize};
77
use serde_json::{Value, json};
88
use std::pin::Pin;
9+
#[cfg(feature = "claude-cli")]
910
use std::process::Stdio;
11+
#[cfg(feature = "claude-cli")]
1012
use std::sync::Mutex as StdMutex;
13+
#[cfg(feature = "claude-cli")]
1114
use tokio::io::{AsyncBufReadExt, BufReader};
15+
#[cfg(feature = "claude-cli")]
1216
use tracing::{debug, info};
17+
#[cfg(not(feature = "claude-cli"))]
18+
use tracing::debug;
1319

1420
use crate::config::Config;
1521

@@ -216,6 +222,7 @@ fn normalize_model_id(provider: &str, model_id: &str) -> String {
216222
}
217223

218224
pub fn create_provider(model: &str, config: &Config) -> Result<Box<dyn LLMProvider>> {
225+
#[cfg(feature = "claude-cli")]
219226
let workspace = config.workspace_path();
220227

221228
// Resolve aliases first (e.g., "opus" → "anthropic/claude-opus-4-5")
@@ -279,13 +286,21 @@ pub fn create_provider(model: &str, config: &Config) -> Result<Box<dyn LLMProvid
279286
)?))
280287
}
281288

289+
#[cfg(feature = "claude-cli")]
282290
"claude-cli" => {
283291
let cli_config = config.providers.claude_cli.as_ref();
284292
let command = cli_config.map(|c| c.command.as_str()).unwrap_or("claude");
285293
Ok(Box::new(ClaudeCliProvider::new(
286294
command, &model_id, workspace,
287295
)?))
288296
}
297+
#[cfg(not(feature = "claude-cli"))]
298+
"claude-cli" => {
299+
anyhow::bail!(
300+
"Claude CLI provider is not available in this build.\n\
301+
The 'claude-cli' feature is required for subprocess-based providers."
302+
)
303+
}
289304

290305
"ollama" => {
291306
let ollama_config = config.providers.ollama.as_ref().ok_or_else(|| {
@@ -322,6 +337,7 @@ pub fn create_provider(model: &str, config: &Config) -> Result<Box<dyn LLMProvid
322337

323338
_ => {
324339
// Fallback: try Claude CLI if configured
340+
#[cfg(feature = "claude-cli")]
325341
if let Some(cli_config) = &config.providers.claude_cli {
326342
return Ok(Box::new(ClaudeCliProvider::new(
327343
&cli_config.command,
@@ -1258,6 +1274,7 @@ impl LLMProvider for OllamaProvider {
12581274
}
12591275
}
12601276

1277+
#[cfg(feature = "claude-cli")]
12611278
/// Claude CLI Provider - invokes the `claude` CLI command
12621279
/// No tool support (text in → text out only)
12631280
/// No streaming (CLI output is collected then returned)
@@ -1274,9 +1291,11 @@ pub struct ClaudeCliProvider {
12741291
cli_session_id: StdMutex<Option<String>>,
12751292
}
12761293

1294+
#[cfg(feature = "claude-cli")]
12771295
/// Provider name for CLI session storage
12781296
const CLAUDE_CLI_PROVIDER: &str = "claude-cli";
12791297

1298+
#[cfg(feature = "claude-cli")]
12801299
impl ClaudeCliProvider {
12811300
pub fn new(command: &str, model: &str, workspace: std::path::PathBuf) -> Result<Self> {
12821301
// Load existing CLI session from session store
@@ -1452,6 +1471,7 @@ impl ClaudeCliProvider {
14521471
}
14531472
}
14541473

1474+
#[cfg(feature = "claude-cli")]
14551475
/// Load CLI session ID from session store
14561476
fn load_cli_session_from_store(session_key: &str, provider: &str) -> Option<String> {
14571477
use super::session_store::SessionStore;
@@ -1460,6 +1480,7 @@ fn load_cli_session_from_store(session_key: &str, provider: &str) -> Option<Stri
14601480
store.get_cli_session_id(session_key, provider)
14611481
}
14621482

1483+
#[cfg(feature = "claude-cli")]
14631484
/// Save CLI session ID to session store
14641485
fn save_cli_session_to_store(
14651486
session_key: &str,
@@ -1474,6 +1495,7 @@ fn save_cli_session_to_store(
14741495
Ok(())
14751496
}
14761497

1498+
#[cfg(feature = "claude-cli")]
14771499
fn normalize_claude_model(model: &str) -> String {
14781500
match model.to_lowercase().as_str() {
14791501
"opus" | "opus-4.5" | "opus-4" | "claude-opus-4-5" => "opus",
@@ -1484,6 +1506,7 @@ fn normalize_claude_model(model: &str) -> String {
14841506
.to_string()
14851507
}
14861508

1509+
#[cfg(feature = "claude-cli")]
14871510
/// Check if a message is the synthetic security block appended by `messages_for_api_call`.
14881511
fn is_security_block(msg: &Message) -> bool {
14891512
msg.role == Role::User
@@ -1492,6 +1515,7 @@ fn is_security_block(msg: &Message) -> bool {
14921515
.contains(crate::security::HARDCODED_SECURITY_SUFFIX)
14931516
}
14941517

1518+
#[cfg(feature = "claude-cli")]
14951519
fn build_prompt_from_messages(messages: &[Message]) -> String {
14961520
// Get the last *real* user message as the prompt, skipping the security block
14971521
messages
@@ -1502,6 +1526,7 @@ fn build_prompt_from_messages(messages: &[Message]) -> String {
15021526
.unwrap_or_default()
15031527
}
15041528

1529+
#[cfg(feature = "claude-cli")]
15051530
fn extract_system_prompt(messages: &[Message]) -> Option<String> {
15061531
let system = messages
15071532
.iter()
@@ -1524,6 +1549,7 @@ fn extract_system_prompt(messages: &[Message]) -> Option<String> {
15241549
}
15251550
}
15261551

1552+
#[cfg(feature = "claude-cli")]
15271553
/// Parse Claude CLI JSON output, returning (response_text, session_id)
15281554
fn parse_claude_cli_output(stdout: &str) -> Result<(String, Option<String>)> {
15291555
// Claude CLI outputs JSON with message content and session info
@@ -1553,6 +1579,7 @@ fn parse_claude_cli_output(stdout: &str) -> Result<(String, Option<String>)> {
15531579
Ok((stdout.trim().to_string(), None))
15541580
}
15551581

1582+
#[cfg(feature = "claude-cli")]
15561583
#[async_trait]
15571584
impl LLMProvider for ClaudeCliProvider {
15581585
fn reset_session(&self) {

crates/mobile/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ description = "LocalGPT mobile bindings via UniFFI"
1010
crate-type = ["staticlib", "cdylib"]
1111
name = "localgpt_mobile"
1212

13+
[[bin]]
14+
name = "uniffi-bindgen"
15+
path = "uniffi-bindgen.rs"
16+
1317
[dependencies]
1418
localgpt-core = { path = "../core", default-features = false, features = ["embeddings-openai"] }
15-
uniffi = "0.29"
19+
uniffi = { version = "0.29", features = ["cli"] }
1620
tokio = { workspace = true }
1721
anyhow = { workspace = true }
1822
thiserror = { workspace = true }

crates/mobile/uniffi-bindgen.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
fn main() {
2+
uniffi::uniffi_bindgen_main()
3+
}

mobile/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ ios/build/
55
ios/DerivedData/
66

77
# Android build artifacts
8+
android/Generated/
89
android/.gradle/
910
android/build/
1011
android/app/build/

0 commit comments

Comments
 (0)