From a8d77bfe2fbd77263362dd0a2d2bb61a83ca1c42 Mon Sep 17 00:00:00 2001 From: Bruno Araujo Date: Tue, 19 May 2026 19:46:06 -0300 Subject: [PATCH 01/11] chore: add setup script and justfile for automation --- README.md | 38 ++++++++++++++++++++++---------- justfile | 34 +++++++++++++++++++++++++++++ scripts/setup.sh | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 justfile create mode 100755 scripts/setup.sh diff --git a/README.md b/README.md index 93996a8..274745c 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,28 @@ Additional crates (`opencherry_inject`, `opencherry_commit_msg`, ## Development -### System dependencies (Ubuntu 24.04+) +### Quick Start (Recommended) + +The easiest way to set up the project is using the provided setup script and `just` task runner. + +1. **Install all dependencies:** + ```bash + ./scripts/setup.sh + ``` + +2. **Run the app:** + ```bash + just dev + ``` + +3. **Run tests:** + ```bash + just test + ``` + +### Manual Setup + +#### System dependencies (Ubuntu 24.04+) ```bash sudo apt install libwebkit2gtk-4.1-dev \ @@ -109,28 +130,23 @@ sudo apt install libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev ``` -### Toolchain +#### Toolchain - Rust stable (1.80+) - Node.js 20+ - pnpm 9+ -### Run dev shell +#### Run dev shell ```bash -cd apps/desktop -pnpm install -pnpm tauri dev +pnpm -C apps/desktop install +pnpm -C apps/desktop tauri dev ``` ### Validate ```bash -cargo check --workspace -cargo test --workspace -pnpm -C apps/desktop test -pnpm -C apps/desktop build -pnpm -C apps/desktop test:e2e +just check ``` ## Packaging & local install diff --git a/justfile b/justfile new file mode 100644 index 0000000..70db0d5 --- /dev/null +++ b/justfile @@ -0,0 +1,34 @@ +# OpenCherry Task Runner + +# Setup the project dependencies +setup: + ./scripts/setup.sh + +# Run the application in development mode +dev: + pnpm -C apps/desktop tauri dev + +# Run all tests (Rust and Frontend) +test: test-rust test-frontend + +# Run Rust tests only +test-rust: + cargo test --workspace + +# Run Frontend tests only +test-frontend: + pnpm -C apps/desktop test + +# Run linting and formatting checks +check: + cargo fmt --all -- --check + cargo clippy --workspace --all-targets -- -D warnings + pnpm -C apps/desktop build + +# Build the production .deb package (Linux) +build-deb: + ./scripts/build-deb.sh + +# List all available commands +list: + @just --list diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..f33d6a9 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}🍒 Starting OpenCherry setup...${NC}" + +# 1. System Dependencies (Ubuntu/Debian) +if command -v apt-get &> /dev/null; then + echo -e "${BLUE}Installing system dependencies...${NC}" + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev \ + libsoup-3.0-dev \ + librsvg2-dev \ + libssl-dev \ + libayatana-appindicator3-dev \ + build-essential \ + curl \ + wget +else + echo -e "${BLUE}Non-Debian system detected. Please ensure you have the required Tauri dependencies installed manually.${NC}" +fi + +# 2. Rust Toolchain +if ! command -v cargo &> /dev/null; then + echo -e "${BLUE}Installing Rust via rustup...${NC}" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + source "$HOME/.cargo/env" +else + echo -e "${GREEN}Rust is already installed.${NC}" +fi + +# 3. Node.js & pnpm +if ! command -v node &> /dev/null; then + echo -e "${BLUE}Node.js not found. Please install Node.js 20+ manually.${NC}" + exit 1 +fi + +if ! command -v pnpm &> /dev/null; then + echo -e "${BLUE}Installing pnpm...${NC}" + sudo npm install -g pnpm@9 +else + echo -e "${GREEN}pnpm is already installed.${NC}" +fi + +# 4. Project Dependencies +echo -e "${BLUE}Installing project dependencies...${NC}" +pnpm -C apps/desktop install + +echo -e "${GREEN}✅ OpenCherry setup complete!${NC}" +echo -e "You can now run the project with: ${BLUE}pnpm -C apps/desktop tauri dev${NC}" +echo -e "Or if you have 'just' installed, simply run: ${BLUE}just dev${NC}" From 02135157d48f83eb04a417fafdd40aaceae97690 Mon Sep 17 00:00:00 2001 From: Bruno Araujo Date: Tue, 19 May 2026 21:54:09 -0300 Subject: [PATCH 02/11] feat: forensic agent detection, sibling grouping, and UI renaming UI --- Cargo.lock | 230 +++++++++ Cargo.toml | 5 + apps/desktop/src-tauri/Cargo.toml | 2 + apps/desktop/src-tauri/src/lib.rs | 77 ++- apps/desktop/src-tauri/src/main.rs | 177 ++++++- apps/desktop/src/App.test.tsx | 6 + apps/desktop/src/App.tsx | 174 ++++++- apps/desktop/src/styles.css | 83 +++- apps/desktop/src/types.ts | 4 +- crates/opencherry_agents/Cargo.toml | 1 + crates/opencherry_agents/src/lib.rs | 595 ++++++++++++++--------- crates/opencherry_core/src/lib.rs | 40 +- crates/opencherry_persistence/Cargo.toml | 1 + crates/opencherry_persistence/src/lib.rs | 137 +++++- my_cmdline.txt | Bin 0 -> 23 bytes resources/default_agents.json | 73 +++ 16 files changed, 1351 insertions(+), 254 deletions(-) create mode 100644 my_cmdline.txt create mode 100644 resources/default_agents.json diff --git a/Cargo.lock b/Cargo.lock index 1a0cf0c..ad8a31e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -479,6 +529,52 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -1827,6 +1923,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -2457,6 +2559,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "open" version = "5.3.4" @@ -2474,6 +2582,8 @@ name = "opencherry-desktop" version = "0.0.1" dependencies = [ "anyhow", + "clap", + "dirs", "git2", "opencherry_agents", "opencherry_core", @@ -2497,6 +2607,7 @@ version = "0.0.1" dependencies = [ "opencherry_core", "serde", + "serde_json", "sysinfo", "thiserror 2.0.18", "tracing", @@ -2521,6 +2632,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tracing", + "ureq", ] [[package]] @@ -3022,6 +3134,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3064,6 +3190,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3477,6 +3638,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -4410,6 +4577,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -4447,6 +4638,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.23.1" @@ -4714,6 +4911,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4997,6 +5212,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5552,6 +5776,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index a44404c..0c10d18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,9 @@ tauri-build = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" +# CLI +clap = { version = "4", features = ["derive"] } + # Error handling / logging anyhow = "1" thiserror = "2" @@ -48,6 +51,8 @@ sysinfo = "0.32" # Async tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "process", "io-util"] } +ureq = "2" +dirs = "6" # Filesystem watching notify = "7" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index be544ca..96f1586 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -37,6 +37,8 @@ thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } tokio = { workspace = true } +clap = { workspace = true } +dirs = "6" [features] default = ["custom-protocol"] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4dfdf0e..346ba52 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -190,12 +190,62 @@ fn remove_custom_theme(app: tauri::AppHandle, id: String) -> Result Result, String> { let dir = config_dir(&app)?; let repos = persist::list_repos(&dir).map_err(|e| e.to_string())?; + let defs = persist::list_agent_definitions(&dir).map_err(|e| e.to_string())?; Ok(opencherry_agents::correlate_agents_to_targets( - opencherry_agents::detect_running_agents(), + opencherry_agents::detect_running_agents(&defs), &repos, )) } +#[tauri::command] +fn list_agent_definitions(app: tauri::AppHandle) -> Result, String> { + let dir = config_dir(&app)?; + persist::list_agent_definitions(&dir).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn upsert_agent_definition( + app: tauri::AppHandle, + def: opencherry_core::AgentDefinition, +) -> Result<(), String> { + let dir = config_dir(&app)?; + persist::upsert_agent_definition(&dir, &def).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn remove_agent_definition(app: tauri::AppHandle, id: String) -> Result { + let dir = config_dir(&app)?; + persist::remove_agent_definition(&dir, &id).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn update_agent_name(app: tauri::AppHandle, id: String, name: String) -> Result<(), String> { + let dir = config_dir(&app)?; + let mut defs = persist::list_agent_definitions(&dir).map_err(|e| e.to_string())?; + if let Some(mut def) = defs.into_iter().find(|d| d.id == id) { + def.display_name = name.clone(); + def.kind = opencherry_core::AgentKind::Custom(name); + persist::upsert_agent_definition(&dir, &def).map_err(|e| e.to_string()) + } else { + Err(format!("agent definition '{id}' not found")) + } +} + +#[tauri::command] +fn sync_agent_rules(app: tauri::AppHandle, url: Option) -> Result { + let dir = config_dir(&app)?; + let target_url = url.unwrap_or_else(|| { + "https://raw.githubusercontent.com/4nrry/opencherry/main/resources/default_agents.json" + .to_string() + }); + persist::sync_agent_rules(&dir, &target_url).map_err(|e| e.to_string()) +} + +#[tauri::command] +fn debug_list_processes() -> Result { + Ok(opencherry_agents::debug_dump_processes()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tracing_subscriber::fmt() @@ -228,6 +278,12 @@ pub fn run() { publish_repo_branch, sync_repo_changes, list_agents, + list_agent_definitions, + upsert_agent_definition, + update_agent_name, + remove_agent_definition, + sync_agent_rules, + debug_list_processes, get_preferences, set_preferences, list_custom_themes, @@ -236,10 +292,21 @@ pub fn run() { ]) .setup(|app| { // Eagerly create config dir so first save doesn't race. - if let Ok(dir) = app.path().app_config_dir() { - let _ = std::fs::create_dir_all(&dir); - tracing::info!(path = %dir.display(), "config dir ready"); - } + let dir = app.path().app_config_dir().expect("failed to resolve config dir"); + let _ = std::fs::create_dir_all(&dir); + tracing::info!(path = %dir.display(), "config dir ready"); + + // Seed default rules if the database is empty or we have a local version. + // In a real app, this might be bundled. + let default_json = include_str!("../../../../resources/default_agents.json"); + let _ = persist::seed_default_rules(&dir, default_json); + + // Trigger background sync + let handle = app.handle().clone(); + std::thread::spawn(move || { + let _ = sync_agent_rules(handle, None); + }); + Ok(()) }) .run(tauri::generate_context!()) diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index a590e0a..3328e9d 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -1,6 +1,181 @@ // Prevents additional console window on Windows in release. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use clap::{Parser, Subcommand}; +use opencherry_persistence as persist; + +#[derive(Parser)] +#[command(name = "opencherry")] +#[command(about = "OpenCherry Desktop & CLI", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Agent registry management + Agent { + #[clap(subcommand)] + action: AgentAction, + }, +} + +#[derive(Subcommand, Clone)] +enum AgentAction { + /// Sync agent rules from a remote URL + Sync { + /// Optional URL to fetch rules from + #[clap(long)] + url: Option, + }, + /// List all currently defined agent rules + List, + /// Run agent detection and show results + Detect, + /// Dump raw process list for debugging + Raw, + /// Add a new agent definition + Add { + /// Unique ID for the agent + #[clap(long)] + id: String, + /// Display name for the agent + #[clap(long)] + name: String, + /// Executable basename to match + #[clap(long)] + exe: String, + /// If true, requires shell parent + #[clap(long)] + shell: bool, + }, + /// Update an existing agent definition + Update { + /// Unique ID for the agent + #[clap(long)] + id: String, + /// New display name + #[clap(long)] + name: Option, + /// New executable basename + #[clap(long)] + exe: Option, + /// New shell parent requirement + #[clap(long)] + shell: Option, + }, +} + fn main() { - opencherry_desktop_lib::run() + let cli = Cli::parse(); + + if let Some(command) = cli.command { + let config_dir = dirs::config_dir() + .expect("could not find config dir") + .join("ai.opencherry.desktop"); + + // Ensure config dir exists + let _ = std::fs::create_dir_all(&config_dir); + + // Seed if empty + if let Ok(defs) = persist::list_agent_definitions(&config_dir) { + if defs.is_empty() { + let default_json = include_str!("../../../../resources/default_agents.json"); + let _ = persist::seed_default_rules(&config_dir, default_json); + } + } + + match command { + Commands::Agent { action } => match action { + AgentAction::Sync { url } => { + let target_url = url.unwrap_or_else(|| { + "https://raw.githubusercontent.com/4nrry/opencherry/main/resources/default_agents.json" + .to_string() + }); + match persist::sync_agent_rules(&config_dir, &target_url) { + Ok(n) => println!("Successfully synced {} agent rules.", n), + Err(e) => eprintln!("Error syncing rules: {}", e), + } + } + AgentAction::List => match persist::list_agent_definitions(&config_dir) { + Ok(defs) => { + for def in defs { + println!( + "{}: {} ({})", + def.id, + def.display_name, + if def.is_builtin { "builtin" } else { "custom" } + ); + } + } + Err(e) => eprintln!("Error listing agents: {}", e), + }, + AgentAction::Detect => { + let defs = persist::list_agent_definitions(&config_dir).unwrap_or_default(); + let agents = opencherry_agents::detect_running_agents(&defs); + println!("Detected {} agents:", agents.len()); + for agent in agents { + println!( + "[{:?}] {} (PID: {}){} - {}", + agent.status, + agent.display_name, + agent.pid, + if let Some(ref p) = agent.parent_id { format!(" [Child of {}]", p.0) } else { "".to_string() }, + agent.command_line + ); + } + } + AgentAction::Raw => { + let raw = opencherry_agents::debug_dump_processes(); + println!("{}", serde_json::to_string_pretty(&raw).unwrap()); + } + AgentAction::Add { id, name, exe, shell } => { + let def = opencherry_core::AgentDefinition { + id: id.clone(), + kind: opencherry_core::AgentKind::Custom(name.clone()), + display_name: name, + rules: vec![opencherry_core::AgentRule { + exe_basename: Some(exe), + exe_path_contains: None, + argv_contains: None, + exclude_path_contains: None, + require_shell_parent: shell, + }], + is_builtin: false, + }; + if let Err(e) = persist::upsert_agent_definition(&config_dir, &def) { + eprintln!("Error adding agent: {}", e); + } else { + println!("Agent '{}' added successfully.", id); + } + } + AgentAction::Update { id, name, exe, shell } => { + // Load existing + let defs = persist::list_agent_definitions(&config_dir).unwrap_or_default(); + if let Some(mut def) = defs.into_iter().find(|d| d.id == id) { + if let Some(n) = name { + def.display_name = n.clone(); + def.kind = opencherry_core::AgentKind::Custom(n); + } + if let Some(e) = exe { + def.rules[0].exe_basename = Some(e); + } + if let Some(s) = shell { + def.rules[0].require_shell_parent = s; + } + if let Err(e) = persist::upsert_agent_definition(&config_dir, &def) { + eprintln!("Error updating agent: {}", e); + } else { + println!("Agent '{}' updated successfully.", id); + } + } else { + eprintln!("Agent '{}' not found.", id); + } + } + }, + } + } else { + opencherry_desktop_lib::run() + } } diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 9082d5c..d36de87 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -179,6 +179,7 @@ describe("TargetAgentsPanel", () => { function makeAgent(overrides: Partial = {}): DetectedAgent { return { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -591,6 +592,7 @@ describe("App", () => { const repoAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -660,6 +662,7 @@ describe("App", () => { const mixedAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -672,6 +675,7 @@ describe("App", () => { }, { id: "agent-2", + definition_id: "gemini-cli", kind: "gemini-cli", display_name: "Gemini CLI (pid 77)", pid: 77, @@ -872,6 +876,7 @@ describe("App", () => { const groupAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, @@ -968,6 +973,7 @@ describe("App", () => { const groupAgents: DetectedAgent[] = [ { id: "agent-1", + definition_id: "open-code", kind: "open-code", display_name: "OpenCode (pid 42)", pid: 42, diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index bf3588e..9a6099f 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -11,6 +11,7 @@ import { import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import type { + AgentKind, CommitResult, DetectedAgent, DiscardOutcome, @@ -37,6 +38,11 @@ function pathContains(path: string, base: string) { return normalizedPath === normalizedBase || normalizedPath.startsWith(`${normalizedBase}/`); } +function formatAgentKind(kind: AgentKind): string { + if (typeof kind === "string") return kind; + return kind.custom; +} + function agentMatchesRepoPath(agent: DetectedAgent, repoPath: string) { return ( agent.targets.repos.some((repo) => repo.path === repoPath) || @@ -90,6 +96,10 @@ async function syncChanges(path: string): Promise { return await invoke("sync_repo_changes", { path }); } +async function syncAgentRules(): Promise { + return await invoke("sync_agent_rules"); +} + export const __testables = { fetchRepos, fetchAgents, @@ -122,10 +132,15 @@ export default function App() { return agentMatchesRepoPath(agent, repo.path); }).length; - // Poll agents every 3s. + // Poll agents every 3s, but pause if any agent is being edited. + const [isEditingAny, setIsEditingAny] = createSignal(false); let agentsTimer: ReturnType | undefined; onMount(() => { - agentsTimer = setInterval(() => refetchAgents(), 3000); + agentsTimer = setInterval(() => { + if (!isEditingAny()) { + refetchAgents(); + } + }, 3000); }); onCleanup(() => { if (agentsTimer) clearInterval(agentsTimer); @@ -221,10 +236,12 @@ export default function App() { } > - }> + { await refetchAgents(); }} onEditStateChange={setIsEditingAny} />}> { await refetchAgents(); }} + onEditStateChange={setIsEditingAny} trackedRepoPaths={trackedRepoPaths()} onRegisterRepo={async (repo) => { setError(null); @@ -261,7 +278,12 @@ export default function App() { {(e) => } - + { await syncAgentRules(); await refetchAgents(); }} + onRename={async () => { await refetchAgents(); }} + onEditStateChange={setIsEditingAny} + /> Promise; onRegisterRepos: (repos: RepoRef[]) => Promise; onSelectRepo: (repo: RepoRef) => void; + onRename: () => Promise; + onEditStateChange?: (editing: boolean) => void; }) { const [snapshot, { refetch }] = createResource( () => props.group.path, @@ -395,6 +419,8 @@ function RepoGroupView(props: { title="Agents in This Group" agents={groupAgents()} empty="No detected agents mapped to this group." + onRename={props.onRename} + onEditStateChange={props.onEditStateChange} />
@@ -503,7 +529,7 @@ function RepoGroupView(props: { {repoAgents(entry.repo.path).length} agent{repoAgents(entry.repo.path).length === 1 ? "" : "s"} - {(agent) => {agent.kind}} + {(agent) => {formatAgentKind(agent.kind)}} @@ -524,6 +550,8 @@ function RepoView(props: { repo: RepoRef; agents: DetectedAgent[]; requestConfirm: (req: ConfirmRequest) => void; + onRename: () => Promise; + onEditStateChange?: (editing: boolean) => void; }) { const [status, { refetch }] = createResource( () => props.repo.path, @@ -667,6 +695,8 @@ function RepoView(props: { title="Agents in This Repo" agents={repoAgents()} empty="No detected agents mapped to this repository." + onRename={props.onRename} + onEditStateChange={props.onEditStateChange} />
@@ -1013,7 +1043,13 @@ function StatusCard(props: { ); } -export function TargetAgentsPanel(props: { title: string; agents: DetectedAgent[]; empty: string }) { +export function TargetAgentsPanel(props: { + title: string; + agents: DetectedAgent[]; + empty: string; + onRename?: (id: string, name: string) => void; + onEditStateChange?: (editing: boolean) => void; +}) { return (
@@ -1026,7 +1062,7 @@ export function TargetAgentsPanel(props: { title: string; agents: DetectedAgent[ >
    - {(a) => } + {(a) => }
@@ -1034,18 +1070,86 @@ export function TargetAgentsPanel(props: { title: string; agents: DetectedAgent[ ); } -function AgentRow(props: { agent: DetectedAgent }) { +function AgentRow(props: { + agent: DetectedAgent; + onRename?: (id: string, newName: string) => void; + onEditStateChange?: (editing: boolean) => void; +}) { const status = () => props.agent.status ?? "idle"; const isSubprocess = () => Boolean(props.agent.parent_id); + const isInactive = () => status() === "suspended" || status() === "zombie"; + + const [isEditing, setIsEditing] = createSignal(false); + const [tempName, setTempName] = createSignal(props.agent.display_name); + + let inputRef: HTMLInputElement | undefined; + + const toggleEditing = (editing: boolean) => { + setIsEditing(editing); + props.onEditStateChange?.(editing); + }; + + createEffect(() => { + if (isEditing()) { + inputRef?.focus(); + inputRef?.select(); + } + }); + + const handleRename = () => { + if (tempName() !== props.agent.display_name) { + props.onRename?.(props.agent.id, tempName()); + } + toggleEditing(false); + }; + return ( -
  • +
  • - {props.agent.display_name} + toggleEditing(true)} + > + {props.agent.display_name} + (pid {props.agent.pid}) + + } + > + setTempName(e.currentTarget.value)} + onBlur={handleRename} + onKeyDown={(e) => e.key === "Enter" && handleRename()} + /> + + + + + + + POSSIBLY SUSPENDED + +
    @@ -1056,7 +1160,7 @@ function AgentRow(props: { agent: DetectedAgent }) { ↳ subprocess - {props.agent.kind} + {formatAgentKind(props.agent.kind)} 0}> @@ -1072,15 +1176,53 @@ function AgentRow(props: { agent: DetectedAgent }) { ); } -function AgentsPanel(props: { agents: Resource }) { +function AgentsPanel(props: { + agents: Resource; + onSync: () => Promise; + onRename: () => Promise; + onEditStateChange?: (editing: boolean) => void; +}) { const unmatchedAgents = () => (props.agents() ?? []).filter((agent) => agent.targets.repos.length === 0 && agent.targets.groups.length === 0); + const [syncing, setSyncing] = createSignal(false); + + const handleSync = async () => { + setSyncing(true); + try { + await props.onSync(); + } finally { + setSyncing(false); + } + }; + + const handleRename = async (id: string, name: string) => { + try { + const agent = (props.agents() ?? []).find(a => a.id === id); + if (agent) { + await invoke("update_agent_name", { id: agent.definition_id, name }); + await props.onRename(); + } + } catch (e) { + console.error("Failed to rename agent:", e); + } + }; + return (
    -

    Detected agents

    - {(props.agents() ?? []).length} +
    +

    Detected agents

    + {(props.agents() ?? []).length} +
    +
    0}>