generated from nhcarrigan/template
feat: add ability to configure the agent (also theme switcher) (#3)
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #3 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #3.
This commit is contained in:
Generated
+18
@@ -1403,6 +1403,8 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-store",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
@@ -3761,6 +3763,22 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-store"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.9.2"
|
||||
|
||||
@@ -22,4 +22,6 @@ serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
parking_lot = "0.12"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tauri-plugin-store = "2.4.2"
|
||||
tempfile = "3"
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||
use crate::wsl_bridge::SharedBridge;
|
||||
|
||||
const CONFIG_STORE_KEY: &str = "config";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_claude(
|
||||
app: AppHandle,
|
||||
bridge: State<'_, SharedBridge>,
|
||||
working_dir: String,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
options: ClaudeStartOptions,
|
||||
) -> Result<(), String> {
|
||||
let mut bridge = bridge.lock();
|
||||
bridge.start(app, &working_dir, allowed_tools.unwrap_or_default())
|
||||
bridge.start(app, options)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -42,3 +45,30 @@ pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<St
|
||||
pub async fn select_wsl_directory() -> Result<String, String> {
|
||||
Ok("/home".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
|
||||
let store = app
|
||||
.store("hikari-config.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(CONFIG_STORE_KEY) {
|
||||
Some(value) => {
|
||||
serde_json::from_value(value.clone()).map_err(|e| e.to_string())
|
||||
}
|
||||
None => Ok(HikariConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> {
|
||||
let store = app
|
||||
.store("hikari-config.json")
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(&config).map_err(|e| e.to_string())?;
|
||||
store.set(CONFIG_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ClaudeStartOptions {
|
||||
#[serde(default)]
|
||||
pub working_dir: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub custom_instructions: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub mcp_servers_json: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct HikariConfig {
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub custom_instructions: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub mcp_servers_json: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub auto_granted_tools: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub theme: Theme,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Theme {
|
||||
#[default]
|
||||
Dark,
|
||||
Light,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = HikariConfig::default();
|
||||
assert!(config.model.is_none());
|
||||
assert!(config.api_key.is_none());
|
||||
assert!(config.custom_instructions.is_none());
|
||||
assert!(config.mcp_servers_json.is_none());
|
||||
assert!(config.auto_granted_tools.is_empty());
|
||||
assert_eq!(config.theme, Theme::Dark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serialization() {
|
||||
let config = HikariConfig {
|
||||
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||
api_key: None,
|
||||
custom_instructions: Some("Be helpful".to_string()),
|
||||
mcp_servers_json: None,
|
||||
auto_granted_tools: vec!["Read".to_string(), "Glob".to_string()],
|
||||
theme: Theme::Light,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
let deserialized: HikariConfig = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(deserialized.model, config.model);
|
||||
assert_eq!(deserialized.custom_instructions, config.custom_instructions);
|
||||
assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools);
|
||||
assert_eq!(deserialized.theme, Theme::Light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_serialization() {
|
||||
let dark = Theme::Dark;
|
||||
let light = Theme::Light;
|
||||
|
||||
assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\"");
|
||||
assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\"");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod commands;
|
||||
mod config;
|
||||
mod types;
|
||||
mod wsl_bridge;
|
||||
|
||||
@@ -13,6 +14,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.manage(bridge)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_claude,
|
||||
@@ -21,6 +23,8 @@ pub fn run() {
|
||||
is_claude_running,
|
||||
get_working_directory,
|
||||
select_wsl_directory,
|
||||
get_config,
|
||||
save_config,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -4,10 +4,12 @@ use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
use crate::config::ClaudeStartOptions;
|
||||
use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent};
|
||||
|
||||
const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"];
|
||||
@@ -69,6 +71,7 @@ pub struct WslBridge {
|
||||
stdin: Option<ChildStdin>,
|
||||
working_directory: String,
|
||||
session_id: Option<String>,
|
||||
mcp_config_file: Option<NamedTempFile>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
@@ -78,22 +81,50 @@ impl WslBridge {
|
||||
stdin: None,
|
||||
working_directory: String::new(),
|
||||
session_id: None,
|
||||
mcp_config_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, app: AppHandle, working_dir: &str, allowed_tools: Vec<String>) -> Result<(), String> {
|
||||
pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> {
|
||||
if self.process.is_some() {
|
||||
return Err("Process already running".to_string());
|
||||
}
|
||||
|
||||
self.working_directory = working_dir.to_string();
|
||||
let working_dir = &options.working_dir;
|
||||
self.working_directory = working_dir.clone();
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Connecting);
|
||||
|
||||
// Create temp file for MCP config if provided
|
||||
let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json {
|
||||
if !mcp_json.trim().is_empty() {
|
||||
// Validate JSON before writing
|
||||
serde_json::from_str::<serde_json::Value>(mcp_json)
|
||||
.map_err(|e| format!("Invalid MCP servers JSON: {}", e))?;
|
||||
|
||||
let mut temp_file = NamedTempFile::new()
|
||||
.map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?;
|
||||
temp_file
|
||||
.write_all(mcp_json.as_bytes())
|
||||
.map_err(|e| format!("Failed to write MCP config: {}", e))?;
|
||||
temp_file
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush MCP config: {}", e))?;
|
||||
|
||||
let path = temp_file.path().to_string_lossy().to_string();
|
||||
self.mcp_config_file = Some(temp_file);
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Detect if we're running inside WSL or on Windows
|
||||
let is_wsl = detect_wsl();
|
||||
eprintln!("[DEBUG] is_wsl: {}", is_wsl);
|
||||
eprintln!("[DEBUG] allowed_tools: {:?}", allowed_tools);
|
||||
eprintln!("[DEBUG] options: {:?}", options);
|
||||
|
||||
let mut command = if is_wsl {
|
||||
// Running inside WSL - call claude directly
|
||||
@@ -111,12 +142,39 @@ impl WslBridge {
|
||||
"--verbose",
|
||||
]);
|
||||
|
||||
// Add model if specified
|
||||
if let Some(ref model) = options.model {
|
||||
if !model.is_empty() {
|
||||
cmd.args(["--model", model]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add allowed tools if any
|
||||
for tool in &allowed_tools {
|
||||
for tool in &options.allowed_tools {
|
||||
cmd.args(["--allowedTools", tool]);
|
||||
}
|
||||
|
||||
// Add custom instructions as system prompt if specified
|
||||
if let Some(ref instructions) = options.custom_instructions {
|
||||
if !instructions.is_empty() {
|
||||
cmd.args(["--system-prompt", instructions]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add MCP config if provided
|
||||
if let Some(ref mcp_path) = mcp_config_path {
|
||||
cmd.args(["--mcp-config", mcp_path]);
|
||||
}
|
||||
|
||||
cmd.current_dir(working_dir);
|
||||
|
||||
// Set API key as environment variable if specified
|
||||
if let Some(ref api_key) = options.api_key {
|
||||
if !api_key.is_empty() {
|
||||
cmd.env("ANTHROPIC_API_KEY", api_key);
|
||||
}
|
||||
}
|
||||
|
||||
cmd
|
||||
} else {
|
||||
// Running on Windows - use wsl with bash login shell to ensure PATH is loaded
|
||||
@@ -125,15 +183,45 @@ impl WslBridge {
|
||||
|
||||
// Build the claude command with all arguments
|
||||
let mut claude_cmd = format!(
|
||||
"cd '{}' && claude --output-format stream-json --input-format stream-json --verbose",
|
||||
"cd '{}' && ",
|
||||
working_dir
|
||||
);
|
||||
|
||||
// Set API key as environment variable if specified
|
||||
if let Some(ref api_key) = options.api_key {
|
||||
if !api_key.is_empty() {
|
||||
claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key));
|
||||
}
|
||||
}
|
||||
|
||||
claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose");
|
||||
|
||||
// Add model if specified
|
||||
if let Some(ref model) = options.model {
|
||||
if !model.is_empty() {
|
||||
claude_cmd.push_str(&format!(" --model '{}'", model));
|
||||
}
|
||||
}
|
||||
|
||||
// Add allowed tools if any
|
||||
for tool in &allowed_tools {
|
||||
for tool in &options.allowed_tools {
|
||||
claude_cmd.push_str(&format!(" --allowedTools '{}'", tool));
|
||||
}
|
||||
|
||||
// Add custom instructions as system prompt if specified
|
||||
if let Some(ref instructions) = options.custom_instructions {
|
||||
if !instructions.is_empty() {
|
||||
// Escape single quotes in instructions
|
||||
let escaped = instructions.replace('\'', "'\\''");
|
||||
claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped));
|
||||
}
|
||||
}
|
||||
|
||||
// Add MCP config if provided
|
||||
if let Some(ref mcp_path) = mcp_config_path {
|
||||
claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path));
|
||||
}
|
||||
|
||||
// Use bash -lc to load login profile (ensures PATH includes claude)
|
||||
cmd.args(["-e", "bash", "-lc", &claude_cmd]);
|
||||
|
||||
@@ -212,6 +300,7 @@ impl WslBridge {
|
||||
}
|
||||
self.stdin = None;
|
||||
self.session_id = None;
|
||||
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||
emit_connection_status(app, ConnectionStatus::Disconnected);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user