diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 928c0ed..102825d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,5 +1,11 @@ # Architecture +## Runtime Shape + +The Controller runs as a browser frontend backed by a local Rust server. Vite +serves the Svelte app from `src/`; the backend in `server/` exposes HTTP routes +under `/api/*` and broadcasts app events over `/ws`. + ## Agent Compatibility: Claude Code & Codex The Controller supports both Claude Code and Codex as coding agents. Compatibility is maintained through two mechanisms: a shared instruction file and skill synchronization. @@ -13,7 +19,7 @@ Claude Code reads project instructions from `CLAUDE.md`. Rather than maintaining The symlink is non-invasive: if a real `CLAUDE.md` already exists, it's left alone. -See: `src-tauri/src/commands.rs` (`ensure_claude_md_symlink`) +See: `server/src/commands.rs` (`ensure_claude_md_symlink`) ### Skill Synchronization on Bootstrap @@ -24,7 +30,7 @@ Skills live in `skills/the-controller-*/` inside the repo. On app startup, `sync The sync is idempotent, worktree-aware (resolves to the main repo via `git rev-parse --git-common-dir`), and cleans up stale symlinks whose targets no longer exist. Regular files are never overwritten — only symlinks are managed. -See: `src-tauri/src/skills.rs` +See: `server/src/skills.rs` ## Why We Vendorize Skills @@ -90,7 +96,7 @@ This is the authoritative record. If a worktree directory exists but has no matc Labels are auto-generated: `session-{N}-{6-char-uuid}`. The number increments based on the highest existing session number in the project. The UUID suffix ensures uniqueness across parallel creation. -See: `commands.rs` (`next_session_label`) +See: `server/src/commands.rs` (`next_session_label`) ### Worktree Creation @@ -101,7 +107,7 @@ When a session is created: 3. The main repo's `.env` is symlinked into the worktree so secrets are shared 4. If the repo has no commits (unborn branch), the repo path is used directly — no worktree is created -See: `worktree.rs` (`create_worktree`) +See: `server/src/worktree.rs` (`create_worktree`) ### Worktree Cleanup @@ -109,7 +115,7 @@ See: `worktree.rs` (`create_worktree`) - **Delete project**: iterates all sessions, closes PTYs, and removes each worktree - **Failed spawn**: if PTY spawn fails after worktree creation, the worktree and branch are rolled back automatically -See: `worktree.rs` (`remove_worktree`), `commands.rs` (`cleanup_failed_session_spawn`) +See: `server/src/worktree.rs` (`remove_worktree`), `server/src/commands.rs` (`cleanup_failed_session_spawn`) ### Recovery diff --git a/README.md b/README.md index 57dc38f..1434273 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # The Controller -A shapeable, personal desktop system — starting with terminal multiplexing. +A shapeable, personal web control surface — starting with terminal multiplexing. -Built with Tauri v2 + Svelte 5 + Rust. +Built with Svelte 5 + a local Axum/Rust backend. > grug have many claude terminal. alt-tab alt-tab alt-tab. where thing go. brain so smooth. so tired. > then grug find controller. all terminal one place. grug not lose thing no more. @@ -13,18 +13,20 @@ Built with Tauri v2 + Svelte 5 + Rust. Install prerequisites: -- [Rust](https://rustup.rs/) + Tauri v2 -- [Node.js](https://nodejs.org/) + npm +- [Rust](https://rustup.rs/) +- [Node.js](https://nodejs.org/) + pnpm - tmux (`brew install tmux`) -- espeak-ng (`brew install espeak-ng`) — required for voice mode TTS Then: ```bash -npm install -npm run tauri dev +pnpm install +./dev.sh ``` +Open `http://localhost:1420`. `dev.sh` starts the Rust backend on port 3001 +and the Vite frontend on port 1420. + ### tmux Configuration If you use Claude Code inside tmux to develop this project, add the following to `~/.tmux.conf` for a cleaner UI: @@ -108,7 +110,8 @@ Not sure what a feature does or how something works? Just ask Claude. The defaul Or browse the docs directly: - [Keyboard Shortcuts & Modes](docs/keyboard-modes.md) — all hotkeys, workspace modes, and how to stage/preview changes -- [Domain Knowledge](docs/domain-knowledge.md) — hard-won lessons about Tauri, tmux, and session architecture +- [Domain Knowledge](docs/domain-knowledge.md) — hard-won lessons about the backend, tmux, and session architecture +- [Web Backend Parity Audit](docs/web-backend-parity-audit.md) — how the web frontend maps to the old desktop command surface - [Demo Recording](docs/demo.md) — how to record demos of The Controller ## Caveats @@ -117,6 +120,6 @@ The Controller is a strongly opinionated power tool — built for efficiency, si This project is in early stages. Some features may be overhauled or removed entirely without concern for backwards compatibility. Things will stabilize eventually, but not in the near term. -**Maintain your own fork.** This is the single best way to use The Controller without being caught off guard by breaking changes. Keep your customizations on your own branch and periodically rebase onto the latest commits from `master`. We may provide a skill (`the-controller-maintain-fork`) to automate this — PRs welcome. +**Maintain your own fork.** This is the single best way to use The Controller without being caught off guard by breaking changes. Keep your customizations on your own branch and periodically rebase onto the latest commits from `main`. We may provide a skill (`the-controller-maintain-fork`) to automate this — PRs welcome. Several things are still being refined, including the [contribution guide](CONTRIBUTING.md). diff --git a/docs/domain-knowledge.md b/docs/domain-knowledge.md index d13f284..8f1e076 100644 --- a/docs/domain-knowledge.md +++ b/docs/domain-knowledge.md @@ -49,9 +49,9 @@ Affected files: - `server/src/pty_manager.rs` — `spawn_session`, `close_session`, `attach_tmux_session` - `server/src/main.rs` — axum entry point; schedulers + status_socket start here -## Shell Environment Inheritance (macOS GUI) +## Shell Environment Inheritance -macOS GUI apps inherit a minimal launchd environment missing `.zshrc` vars. `shell_env::inherit_shell_env()` resolves the user's full shell env at startup and applies it to the process. Must run before any threads (`set_var` is not thread-safe). For tmux, all process env vars are passed via `-e` flags in `build_create_args` because tmux sessions inherit the **server's** environment, not the client's. +The server may start from a shell, launcher, or automation process that does not have the user's full login environment. `shell_env::inherit_shell_env()` resolves the user's shell env at startup and applies it to the process. It must run before any threads (`set_var` is not thread-safe). For tmux, all process env vars are passed via `-e` flags in `build_create_args` because tmux sessions inherit the **server's** environment, not the client's. Affected files: `server/src/shell_env.rs`, `server/src/main.rs`, `server/src/tmux.rs` diff --git a/docs/keyboard-modes.md b/docs/keyboard-modes.md index 6d2663d..c8cee2e 100644 --- a/docs/keyboard-modes.md +++ b/docs/keyboard-modes.md @@ -4,16 +4,14 @@ All keyboard input flows through `HotkeyManager.svelte`. Hotkey definitions live ## Workspace Modes -The Controller has six workspace modes, each with its own hotkeys. Press `Space` then a key to switch: +The Controller has four workspace modes, each with its own hotkeys. Press `Space` then a key to switch: | Key | Mode | |-----|------| | d | Development — manage sessions, branches, projects | | a | Agents — toggle auto-workers and maintainers | -| r | Architecture — generate project architecture docs | -| n | Notes — markdown notes organized by folder | -| i | Infrastructure — deploy and rollback projects | -| v | Voice — voice interaction mode | +| k | Kanban — organize GitHub issues | +| c | Chat — use daemon-backed chat sessions | ## Keyboard State Machine @@ -36,8 +34,7 @@ The Controller has six workspace modes, each with its own hotkeys. Press `Space` v +-------------------+ | Workspace Mode | -| Picker (d/a/r/n/ | -| i/v) | +| Picker (d/a/k/c) | +-------------------+ ``` @@ -92,36 +89,20 @@ These work in all workspace modes when no terminal or editable element is focuse | c | Clear maintainer reports | | t | Toggle between Runs / Issues view | -## Ambient Mode — Architecture Keys +## Ambient Mode — Kanban Keys | Key | Action | |-----|--------| -| r | Generate / regenerate architecture for focused project | +| Space then k | Open the Kanban board for the focused project | +| Drag issue cards | Move issues between lifecycle columns | -## Ambient Mode — Notes Keys +## Ambient Mode — Chat Keys | Key | Action | |-----|--------| -| n | Create new note | -| d | Delete focused note or folder | -| r | Rename focused note or folder | -| y | Duplicate focused note | -| p | Cycle note preview mode (edit / preview / split) | -| o / i / a | Open note for editing (vim-style) | - -## Ambient Mode — Infrastructure Keys - -| Key | Action | -|-----|--------| -| d | Deploy focused project | -| r | Rollback last deployment | - -## Ambient Mode — Voice Keys - -| Key | Action | -|-----|--------| -| d | Toggle debug panel | -| t | Toggle transcript panel | +| Space then c | Open daemon-backed chat mode | +| j / k | Move through visible chat sessions | +| l / Enter | Select a chat session or expand/collapse a project | ## Agent Panel Keys @@ -145,7 +126,7 @@ Press `v` again to unstage (kills the staged instance). **What happens when you stage:** 1. Worktree is committed (prompts Claude to commit if dirty) 2. Branch is rebased onto main if behind -3. `npm install` runs in the worktree if needed +3. `pnpm install` runs in the worktree if needed 4. `./dev.sh ` launches a separate Controller instance 5. Main Controller title bar shows "staging: session-label" diff --git a/docs/web-backend-parity-audit.md b/docs/web-backend-parity-audit.md new file mode 100644 index 0000000..977a40d --- /dev/null +++ b/docs/web-backend-parity-audit.md @@ -0,0 +1,77 @@ +# Web Backend Parity Audit + +This audit tracks the removal of the Tauri desktop shell. The supported runtime +is now: + +- `src/`: Svelte frontend served by Vite +- `server/`: local Axum backend exposing `/api/*` and `/ws` + +The browser frontend keeps the desktop frontend's command surface by routing +former Tauri `invoke(...)` calls through HTTP. Events that used to travel +through Tauri now travel through the shared WebSocket broadcaster. + +## Command Coverage + +The old desktop frontend registered 59 Tauri commands. The web backend exposes +59 HTTP routes. + +Two desktop-native commands do not have same-name HTTP routes: + +- `capture_app_screenshot`: replaced by `src/lib/native.ts`, which captures the + browser DOM with `html2canvas`, then saves it through `/api/save_screenshot`. +- `copy_image_file_to_clipboard`: replaced by the browser drag/drop path in + `src/lib/Terminal.svelte`, which reads dropped image files and writes them + with `ClipboardItem`. + +The backend also exposes two web-only routes: + +- `save_screenshot`: persists browser-captured screenshots to a temporary PNG. +- `list_archived_projects`: supports archived project inventory reads. + +## Event Coverage + +The frontend listens through `src/lib/backend.ts`, which opens one shared +WebSocket connection to `/ws`. The Rust backend emits the same event names +through `server/src/emitter.rs`. + +Covered event families: + +- `pty-output:{session_id}` +- `session-status-changed:{session_id}` +- `session-status-hook:{session_id}` +- `session-cleanup:{session_id}` +- `staging-status` +- `merge-status` +- `secure-env-requested` +- `maintainer-status:{project_id}` +- `maintainer-error:{project_id}` +- `auto-worker-status:{project_id}` + +## Regression Guard + +`src/lib/web-backend-audit.test.ts` enforces three checks: + +1. Every production frontend `command("...")` literal has a matching `/api/...` + route. +2. The old desktop command surface remains covered by HTTP routes or by the two + browser replacements. +3. Active docs do not point users at stale Tauri commands, `src-tauri/`, or + removed workspace modes. + +Run it directly with: + +```bash +pnpm test src/lib/web-backend-audit.test.ts +``` + +Run the whole web/backend validation set with: + +```bash +pnpm check +pnpm test +pnpm build +cd server && cargo fmt --check +cd server && cargo clippy -- -D warnings +cd server && cargo test +pnpm exec playwright test --project=e2e e2e/specs/smoke.spec.ts +``` diff --git a/e2e/eval.sh b/e2e/eval.sh index 1fbef42..841f626 100755 --- a/e2e/eval.sh +++ b/e2e/eval.sh @@ -79,8 +79,8 @@ echo "Eval ports: Axum=$AXUM_PORT, Vite=$VITE_PORT" # --- Ensure node_modules --- if [[ ! -d "$WORKTREE/node_modules" ]]; then - echo "Installing npm dependencies in worktree..." - (cd "$WORKTREE" && npm install --silent) + echo "Installing pnpm dependencies in worktree..." + (cd "$WORKTREE" && pnpm install --silent) fi # --- Start Axum server --- @@ -90,7 +90,7 @@ AXUM_PID=$! # --- Start Vite dev server --- echo "Starting Vite dev server on port $VITE_PORT..." -(cd "$WORKTREE" && DEV_PORT="$VITE_PORT" AXUM_PORT="$AXUM_PORT" npm run dev -- --strictPort) & +(cd "$WORKTREE" && DEV_PORT="$VITE_PORT" AXUM_PORT="$AXUM_PORT" pnpm dev -- --strictPort) & VITE_PID=$! # --- Wait for servers to be ready --- diff --git a/e2e/specs/chat-mode.spec.ts b/e2e/specs/chat-mode.spec.ts index 0f082c0..c878f62 100644 --- a/e2e/specs/chat-mode.spec.ts +++ b/e2e/specs/chat-mode.spec.ts @@ -39,8 +39,8 @@ test("chat mode shows DaemonEmptyState when daemon is unreachable", async ({ pag await switchToChatMode(page); // Core assertion: the DaemonEmptyState component renders its heading when - // the daemon cannot be reached. In browser-mode e2e, `read_daemon_token` - // is not exposed by the Axum server, so bootstrap fails deterministically. + // the daemon cannot be reached. The Axum server exposes `read_daemon_token`, + // but this test does not start the daemon, so bootstrap fails deterministically. await expect(page.getByRole("heading", { name: "Daemon not running" })).toBeVisible({ timeout: 5_000, }); diff --git a/playwright.config.ts b/playwright.config.ts index 739c5d3..5f9f5e3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ timeout: 120_000, }, { - command: "npm run dev", + command: "pnpm dev", port: 1420, reuseExistingServer: true, }, diff --git a/server/src/auto_worker.rs b/server/src/auto_worker.rs index f5a2d06..d2800b2 100644 --- a/server/src/auto_worker.rs +++ b/server/src/auto_worker.rs @@ -491,7 +491,7 @@ fn emit_status( "issue_title": issue_title.unwrap_or(""), }); let _ = state.emitter.emit( - &format!("auto-worker-status:{}", project_id), + &format!("auto-worker-status:{project_id}"), &payload.to_string(), ); } @@ -586,14 +586,14 @@ fn fetch_issues_sync(repo_path: &str) -> Result, String> { ]) .current_dir(repo_path) .output() - .map_err(|e| format!("Failed to run gh: {}", e))?; + .map_err(|e| format!("Failed to run gh: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh issue list failed: {}", stderr)); + return Err(format!("gh issue list failed: {stderr}")); } - serde_json::from_slice(&output.stdout).map_err(|e| format!("Failed to parse gh output: {}", e)) + serde_json::from_slice(&output.stdout).map_err(|e| format!("Failed to parse gh output: {e}")) } fn finish_label_edit(state: &AppState, repo_path: &str, edit: F) -> Result<(), String> @@ -637,11 +637,11 @@ fn edit_label_sync( .args(["issue", "edit", &issue_number.to_string(), mode, label]) .current_dir(repo_path) .output() - .map_err(|e| format!("Failed to run gh: {}", e))?; + .map_err(|e| format!("Failed to run gh: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh issue edit failed: {}", stderr)); + return Err(format!("gh issue edit failed: {stderr}")); } Ok(()) } @@ -679,15 +679,15 @@ fn issue_is_closed_sync(repo_path: &str, issue_number: u64) -> Result bool { } fn has_merged_pr_sync(repo_path: &str, issue_number: u64) -> bool { - let search_query = format!("#{}", issue_number); + let search_query = format!("#{issue_number}"); let output = Command::new("gh") .args([ "pr", @@ -816,10 +816,7 @@ fn has_merged_pr_sync(repo_path: &str, issue_number: u64) -> bool { false } Err(e) => { - eprintln!( - "Auto-worker: failed to run gh pr list for #{}: {}", - issue_number, e - ); + eprintln!("Auto-worker: failed to run gh pr list for #{issue_number}: {e}"); false } } @@ -830,11 +827,11 @@ fn close_issue_sync(repo_path: &str, issue_number: u64) -> Result<(), String> { .args(["issue", "close", &issue_number.to_string()]) .current_dir(repo_path) .output() - .map_err(|e| format!("Failed to run gh: {}", e))?; + .map_err(|e| format!("Failed to run gh: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh issue close failed: {}", stderr)); + return Err(format!("gh issue close failed: {stderr}")); } Ok(()) } diff --git a/server/src/cli_install.rs b/server/src/cli_install.rs index 7adf1a7..0dd0c67 100644 --- a/server/src/cli_install.rs +++ b/server/src/cli_install.rs @@ -44,7 +44,7 @@ pub fn install_controller_cli() { let dest = bin_dir.join("controller-cli"); if let Err(e) = std::fs::copy(&source, &dest) { - eprintln!("Warning: could not install controller-cli: {}", e); + eprintln!("Warning: could not install controller-cli: {e}"); return; } diff --git a/server/src/commands.rs b/server/src/commands.rs index 2bb608d..9f59f3c 100644 --- a/server/src/commands.rs +++ b/server/src/commands.rs @@ -20,7 +20,7 @@ pub fn ensure_claude_md_symlink(dir: &Path) -> Result<(), String> { if agents_md.exists() && !claude_md.exists() { #[cfg(unix)] std::os::unix::fs::symlink("agents.md", &claude_md) - .map_err(|e| format!("failed to create CLAUDE.md symlink: {}", e))?; + .map_err(|e| format!("failed to create CLAUDE.md symlink: {e}"))?; #[cfg(windows)] std::os::windows::fs::symlink_file("agents.md", &claude_md) .map_err(|e| format!("failed to create CLAUDE.md symlink: {}", e))?; @@ -32,7 +32,7 @@ pub fn ensure_claude_md_symlink(dir: &Path) -> Result<(), String> { /// and names starting with `.`. pub(crate) fn validate_project_name(name: &str) -> Result<(), String> { if name.is_empty() || name.contains('/') || name.contains('\\') || name.starts_with('.') { - return Err(format!("Invalid project name: {}", name)); + return Err(format!("Invalid project name: {name}")); } Ok(()) } @@ -88,10 +88,7 @@ where match rollback { Ok(()) => Err(action_err), - Err(rollback_err) => Err(format!( - "{} (rollback failed: {})", - action_err, rollback_err - )), + Err(rollback_err) => Err(format!("{action_err} (rollback failed: {rollback_err})")), } } } @@ -162,7 +159,7 @@ pub fn render_agents_md(name: &str) -> String { fn rollback_scaffold_dir(repo_path: &Path, error: String) -> String { match std::fs::remove_dir_all(repo_path) { Ok(_) => error, - Err(cleanup_error) => format!("{} (cleanup failed: {})", error, cleanup_error), + Err(cleanup_error) => format!("{error} (cleanup failed: {cleanup_error})"), } } @@ -177,7 +174,7 @@ fn parse_github_nwo(url: &str) -> Result { return Ok(rest.trim_end_matches(".git").to_string()); } - Err(format!("Not a GitHub remote URL: {}", url)) + Err(format!("Not a GitHub remote URL: {url}")) } fn github_cli_command() -> std::process::Command { @@ -208,7 +205,7 @@ fn rollback_scaffold_state(repo_path: &Path, error: String) -> String { "remote cleanup failed: {}", String::from_utf8_lossy(&output.stderr).trim() )), - Err(e) => cleanup_errors.push(format!("remote cleanup failed: {}", e)), + Err(e) => cleanup_errors.push(format!("remote cleanup failed: {e}")), } } } @@ -216,7 +213,7 @@ fn rollback_scaffold_state(repo_path: &Path, error: String) -> String { } if let Err(cleanup_error) = std::fs::remove_dir_all(repo_path) { - cleanup_errors.push(format!("local cleanup failed: {}", cleanup_error)); + cleanup_errors.push(format!("local cleanup failed: {cleanup_error}")); } if cleanup_errors.is_empty() { @@ -233,7 +230,7 @@ fn scaffold_project_blocking(name: String, repo_path: PathBuf) -> Result Result Result Result Result<(String, String), String> { let repo = - git2::Repository::open(&repo_path).map_err(|e| format!("Failed to open repo: {}", e))?; + git2::Repository::open(&repo_path).map_err(|e| format!("Failed to open repo: {e}"))?; let head = repo .head() - .map_err(|e| format!("Failed to get HEAD: {}", e))?; + .map_err(|e| format!("Failed to get HEAD: {e}"))?; let branch = head.shorthand().unwrap_or("HEAD").to_string(); let commit = head .peel_to_commit() - .map_err(|e| format!("Failed to peel to commit: {}", e))?; + .map_err(|e| format!("Failed to peel to commit: {e}"))?; let short_hash = commit.id().to_string()[..7].to_string(); Ok((branch, short_hash)) @@ -1020,7 +1015,7 @@ pub fn save_session_prompt_impl( let name = { let truncated: String = prompt_text.chars().take(60).collect(); if truncated.len() < prompt_text.len() { - format!("{}...", truncated) + format!("{truncated}...") } else { truncated } @@ -1108,8 +1103,7 @@ pub fn save_onboarding_config_impl(state: &AppState, projects_root: String) -> R let path = Path::new(&projects_root); if !path.is_dir() { return Err(format!( - "projects_root is not an existing directory: {}", - projects_root + "projects_root is not an existing directory: {projects_root}" )); } @@ -1136,7 +1130,7 @@ pub async fn scaffold_project_impl(state: &AppState, name: String) -> Result Result log, Err(e) => { let _ = state .emitter - .emit(&format!("maintainer-status:{}", project_id), "error"); + .emit(&format!("maintainer-status:{project_id}"), "error"); let _ = state .emitter - .emit(&format!("maintainer-error:{}", project_id), &e.to_string()); + .emit(&format!("maintainer-error:{project_id}"), &e.to_string()); return Err(e); } }; @@ -1484,7 +1478,7 @@ pub async fn trigger_maintainer_check_impl( let _ = state .emitter - .emit(&format!("maintainer-status:{}", project_id), "idle"); + .emit(&format!("maintainer-status:{project_id}"), "idle"); Ok(log) } @@ -1497,7 +1491,7 @@ pub fn clear_maintainer_reports_impl(state: &AppState, project_id: String) -> Re .map_err(|e| e.to_string())?; let _ = state .emitter - .emit(&format!("maintainer-status:{}", project_id), "idle"); + .emit(&format!("maintainer-status:{project_id}"), "idle"); Ok(()) } diff --git a/server/src/commands/daemon.rs b/server/src/commands/daemon.rs index f3ba31c..57b583d 100644 --- a/server/src/commands/daemon.rs +++ b/server/src/commands/daemon.rs @@ -13,7 +13,7 @@ pub(crate) fn daemon_token_path() -> PathBuf { pub(crate) fn read_token_from(path: &std::path::Path) -> Result { let bytes = std::fs::read(path) .map_err(|e| format!("read daemon token at {}: {}", path.display(), e))?; - let s = String::from_utf8(bytes).map_err(|e| format!("token not utf-8: {}", e))?; + let s = String::from_utf8(bytes).map_err(|e| format!("token not utf-8: {e}"))?; Ok(s.trim().to_string()) } @@ -21,7 +21,7 @@ pub async fn read_daemon_token() -> Result { let path = daemon_token_path(); tokio::task::spawn_blocking(move || read_token_from(&path)) .await - .map_err(|e| format!("join error: {}", e))? + .map_err(|e| format!("join error: {e}"))? } #[cfg(test)] diff --git a/server/src/commands/github.rs b/server/src/commands/github.rs index 70fcd2c..deefe48 100644 --- a/server/src/commands/github.rs +++ b/server/src/commands/github.rs @@ -27,7 +27,7 @@ fn parse_github_nwo(url: &str) -> Result { return Ok(rest.trim_end_matches(".git").to_string()); } - Err(format!("Not a GitHub remote URL: {}", url)) + Err(format!("Not a GitHub remote URL: {url}")) } /// Parse a GitHub issue URL like "https://github.com/owner/repo/issues/42" and return the issue number. @@ -39,14 +39,14 @@ fn parse_github_issue_url(url: &str) -> Result { return Ok(num); } } - Err(format!("Could not parse issue number from URL: {}", url)) + Err(format!("Could not parse issue number from URL: {url}")) } /// Extract the GitHub owner/repo from a local git repository's origin remote. /// Handles both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git) URLs. fn extract_github_repo(repo_path: &str) -> Result { let repo = - git2::Repository::discover(repo_path).map_err(|e| format!("Failed to open repo: {}", e))?; + git2::Repository::discover(repo_path).map_err(|e| format!("Failed to open repo: {e}"))?; let remote = repo .find_remote("origin") .map_err(|_| "No 'origin' remote found".to_string())?; @@ -60,7 +60,7 @@ fn extract_github_repo(repo_path: &str) -> Result { async fn extract_github_repo_async(repo_path: String) -> Result { tokio::task::spawn_blocking(move || extract_github_repo(&repo_path)) .await - .map_err(|e| format!("Task failed: {}", e))? + .map_err(|e| format!("Task failed: {e}"))? } async fn fetch_github_issues(repo_path: String) -> Result, String> { @@ -79,15 +79,15 @@ async fn fetch_github_issues(repo_path: String) -> Result, Stri ]) .output() .await - .map_err(|e| format!("Failed to run gh: {}", e))?; + .map_err(|e| format!("Failed to run gh: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh issue list failed: {}", stderr)); + return Err(format!("gh issue list failed: {stderr}")); } let issues: Vec = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("Failed to parse gh output: {}", e))?; + .map_err(|e| format!("Failed to parse gh output: {e}"))?; Ok(issues) } @@ -101,7 +101,7 @@ pub async fn list_github_issues( let cache = state .issue_cache .lock() - .map_err(|e| format!("Cache lock error: {}", e))?; + .map_err(|e| format!("Cache lock error: {e}"))?; match cache.get(&repo_path) { Some(entry) if entry.is_fresh() => { return Ok(entry.issues.clone()); @@ -134,7 +134,7 @@ pub async fn list_github_issues( let mut cache = state .issue_cache .lock() - .map_err(|e| format!("Cache lock error: {}", e))?; + .map_err(|e| format!("Cache lock error: {e}"))?; cache.insert(repo_path, issues.clone()); } Ok(issues) @@ -142,10 +142,9 @@ pub async fn list_github_issues( pub async fn generate_issue_body(repo_path: String, title: String) -> Result { let prompt = format!( - "Write a concise GitHub issue body for an issue titled: \"{}\". \ + "Write a concise GitHub issue body for an issue titled: \"{title}\". \ Include a Summary section and a Details section. \ - Keep it under 200 words. Return only the markdown body, nothing else.", - title + Keep it under 200 words. Return only the markdown body, nothing else." ); let output = tokio::process::Command::new("claude") .args(["--print", &prompt]) @@ -153,7 +152,7 @@ pub async fn generate_issue_body(repo_path: String, title: String) -> Result Result, String> { @@ -479,15 +478,15 @@ pub async fn list_assigned_issues(repo_path: String) -> Result = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("Failed to parse gh output: {}", e))?; + .map_err(|e| format!("Failed to parse gh output: {e}"))?; // Filter to only issues that have at least one assignee let assigned = all_issues @@ -518,15 +517,15 @@ pub async fn get_worker_reports(repo_path: String) -> Result, ]) .output() .await - .map_err(|e| format!("Failed to run gh: {}", e))?; + .map_err(|e| format!("Failed to run gh: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("gh issue list failed: {}", stderr)); + return Err(format!("gh issue list failed: {stderr}")); } let raw: Vec = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("Failed to parse gh output: {}", e))?; + .map_err(|e| format!("Failed to parse gh output: {e}"))?; let reports = parse_worker_reports(raw); diff --git a/server/src/config.rs b/server/src/config.rs index 8c1c98d..9bbc31b 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -102,15 +102,14 @@ pub fn generate_names_via_cli(description: &str) -> Result, String> } let prompt = format!( - "Suggest 3 short, lowercase, hyphenated project directory names for: {}. Return only the 3 names, one per line, nothing else.", - description + "Suggest 3 short, lowercase, hyphenated project directory names for: {description}. Return only the 3 names, one per line, nothing else." ); let output = Command::new("claude") .args(["--print", &prompt]) .env_remove("CLAUDECODE") .output() - .map_err(|e| format!("Failed to run claude CLI: {}", e))?; + .map_err(|e| format!("Failed to run claude CLI: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/server/src/labels.rs b/server/src/labels.rs index 71603d2..26e1e7b 100644 --- a/server/src/labels.rs +++ b/server/src/labels.rs @@ -27,14 +27,12 @@ pub fn validate_triage_label(label: &str) -> Result<&str, String> { if label.starts_with("priority:") || label.starts_with("complexity:") { if label.contains(": ") { return Err(format!( - "Label '{}' has a space after the colon. Use the canonical format (e.g. 'priority:high', not 'priority: high')", - label + "Label '{label}' has a space after the colon. Use the canonical format (e.g. 'priority:high', not 'priority: high')" )); } if !TRIAGE_LABELS.contains(&label) { return Err(format!( - "Unknown triage label '{}'. Valid labels: {:?}", - label, TRIAGE_LABELS + "Unknown triage label '{label}'. Valid labels: {TRIAGE_LABELS:?}" )); } } diff --git a/server/src/main.rs b/server/src/main.rs index 60cbf51..adda77c 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -128,13 +128,13 @@ async fn main() { let port: u16 = match std::env::var("PORT") { Ok(val) => val.parse().unwrap_or_else(|_| { - eprintln!("Invalid PORT value '{}', must be a u16", val); + eprintln!("Invalid PORT value '{val}', must be a u16"); std::process::exit(1); }), Err(_) => 3001, }; - println!("Server listening on http://localhost:{}", port); - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)) + println!("Server listening on http://localhost:{port}"); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) .await .unwrap(); axum::serve(listener, app).await.unwrap(); @@ -247,7 +247,7 @@ async fn connect_session( .ok_or_else(|| { ( StatusCode::NOT_FOUND, - format!("session not found: {}", session_id), + format!("session not found: {session_id}"), ) })? }; @@ -265,7 +265,7 @@ async fn connect_session( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })??; @@ -292,7 +292,7 @@ async fn load_project( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -319,7 +319,7 @@ async fn create_project( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -343,7 +343,7 @@ async fn delete_project( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -426,7 +426,7 @@ async fn close_session( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -449,7 +449,7 @@ async fn create_session( } else { Some( serde_json::from_value(args["githubIssue"].clone()) - .map_err(|e| (StatusCode::BAD_REQUEST, format!("githubIssue: {}", e)))?, + .map_err(|e| (StatusCode::BAD_REQUEST, format!("githubIssue: {e}")))?, ) }; @@ -468,7 +468,7 @@ async fn create_session( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -485,7 +485,7 @@ async fn read_daemon_token() -> Result, (StatusCode, String)> { async fn log_frontend_error(Json(args): Json) -> Result, (StatusCode, String)> { let message = args["message"].as_str().unwrap_or(""); - eprintln!("[FRONTEND] {}", message); + eprintln!("[FRONTEND] {message}"); Ok(Json(Value::Null)) } @@ -565,7 +565,7 @@ async fn merge_session_branch( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -606,7 +606,7 @@ async fn merge_session_branch( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })?; if !still_rebasing { @@ -620,10 +620,7 @@ async fn merge_session_branch( Err(( StatusCode::INTERNAL_SERVER_ERROR, - format!( - "Merge failed after {} attempts due to recurring conflicts", - MAX_RETRIES - ), + format!("Merge failed after {MAX_RETRIES} attempts due to recurring conflicts"), )) } @@ -728,7 +725,7 @@ async fn get_session_commits( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -763,7 +760,7 @@ async fn get_repo_head(Json(args): Json) -> Result, (StatusCo .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -802,10 +799,7 @@ async fn list_directories_at(Json(args): Json) -> Result, (St .to_string(); let p = std::path::PathBuf::from(&path); if !p.is_dir() { - return Err(( - StatusCode::BAD_REQUEST, - format!("Not a directory: {}", path), - )); + return Err((StatusCode::BAD_REQUEST, format!("Not a directory: {path}"))); } let entries = config::list_directories(&p) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -826,7 +820,7 @@ async fn check_claude_cli() -> Result, (StatusCode, String)> { .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })?; Ok(Json(Value::String(result))) @@ -844,7 +838,7 @@ async fn generate_project_names( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -1034,7 +1028,7 @@ async fn kanban_load_order( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -1052,7 +1046,7 @@ async fn kanban_save_order( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; @@ -1238,7 +1232,7 @@ async fn submit_secure_env_value( .map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, - format!("Task failed: {}", e), + format!("Task failed: {e}"), ) })? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; diff --git a/server/src/maintainer.rs b/server/src/maintainer.rs index 638bdbf..e732bdb 100644 --- a/server/src/maintainer.rs +++ b/server/src/maintainer.rs @@ -290,12 +290,12 @@ pub fn extract_json(output: &str) -> Option<&str> { fn parse_findings_output(output: &str) -> Result { let json_str = extract_json(output).ok_or("No JSON found in output")?; let raw: RawFindingsOutput = - serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {}", e))?; + serde_json::from_str(json_str).map_err(|e| format!("Failed to parse JSON: {e}"))?; let mut findings = Vec::with_capacity(raw.findings.len()); for (idx, finding) in raw.findings.into_iter().enumerate() { let sanitized = - sanitize_finding(finding).ok_or_else(|| format!("Invalid finding at index {}", idx))?; + sanitize_finding(finding).ok_or_else(|| format!("Invalid finding at index {idx}"))?; findings.push(sanitized); } @@ -702,7 +702,7 @@ fn run_gh_checked( ) -> Result { let output = command .output() - .map_err(|e| format!("Failed to run gh: {}", e))?; + .map_err(|e| format!("Failed to run gh: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -763,7 +763,7 @@ fn list_open_maintainer_issues( let output = run_gh_checked(cmd, "gh issue list failed")?; let raw_issues: Vec = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("Failed to parse gh issue list output: {}", e))?; + .map_err(|e| format!("Failed to parse gh issue list output: {e}"))?; Ok(raw_issues .into_iter() @@ -797,7 +797,7 @@ fn list_closed_maintainer_issues( let output = run_gh_checked(cmd, "gh issue list (closed) failed")?; let raw_issues: Vec = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("Failed to parse gh issue list output: {}", e))?; + .map_err(|e| format!("Failed to parse gh issue list output: {e}"))?; Ok(raw_issues .into_iter() @@ -816,9 +816,9 @@ fn parse_issue_number_from_url(url: &str) -> Result { let last = trimmed .rsplit('/') .next() - .ok_or_else(|| format!("Could not parse issue number from URL: {}", url))?; + .ok_or_else(|| format!("Could not parse issue number from URL: {url}"))?; last.parse::() - .map_err(|_| format!("Could not parse issue number from URL: {}", url)) + .map_err(|_| format!("Could not parse issue number from URL: {url}")) } fn create_issue( @@ -938,11 +938,11 @@ pub fn run_maintainer_check( .current_dir(repo_path) .env_remove("CLAUDECODE") .output() - .map_err(|e| format!("Failed to run codex exec: {}", e))?; + .map_err(|e| format!("Failed to run codex exec: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - return Err(format!("codex exec failed: {}", stderr)); + return Err(format!("codex exec failed: {stderr}")); } let findings_output = parse_findings_output(&String::from_utf8_lossy(&output.stdout))?; @@ -1053,7 +1053,7 @@ pub fn run_maintainer_check( skip_reasons.push(format!("{} semantic", filtered_findings.skipped_semantic)); } if skipped_closed > 0 { - skip_reasons.push(format!("{} closed", skipped_closed)); + skip_reasons.push(format!("{skipped_closed} closed")); } parts.push(format!( "skipped {} ({})", diff --git a/server/src/pty_manager.rs b/server/src/pty_manager.rs index a680c3f..fc63c45 100644 --- a/server/src/pty_manager.rs +++ b/server/src/pty_manager.rs @@ -114,7 +114,7 @@ impl PtyManager { pixel_width: 0, pixel_height: 0, }) - .map_err(|e| format!("failed to open pty: {}", e))?; + .map_err(|e| format!("failed to open pty: {e}"))?; let mut cmd = CommandBuilder::new(command); cmd.cwd(working_dir); @@ -133,25 +133,25 @@ impl PtyManager { let child = pair .slave .spawn_command(cmd) - .map_err(|e| format!("failed to spawn {}: {}", command, e))?; + .map_err(|e| format!("failed to spawn {command}: {e}"))?; drop(pair.slave); let writer = pair .master .take_writer() - .map_err(|e| format!("failed to get pty writer: {}", e))?; + .map_err(|e| format!("failed to get pty writer: {e}"))?; let mut reader = pair .master .try_clone_reader() - .map_err(|e| format!("failed to get pty reader: {}", e))?; + .map_err(|e| format!("failed to get pty reader: {e}"))?; let alive = Arc::new(Mutex::new(true)); let alive_clone = Arc::clone(&alive); - let output_event = format!("pty-output:{}", session_id); - let status_event = format!("session-status-changed:{}", session_id); + let output_event = format!("pty-output:{session_id}"); + let status_event = format!("session-status-changed:{session_id}"); thread::spawn(move || { let mut buf = [0u8; 4096]; @@ -211,7 +211,7 @@ impl PtyManager { pixel_width: 0, pixel_height: 0, }) - .map_err(|e| format!("failed to open pty: {}", e))?; + .map_err(|e| format!("failed to open pty: {e}"))?; let tmux_bin = TmuxManager::tmux_binary().ok_or_else(|| "tmux binary not found".to_string())?; @@ -220,25 +220,25 @@ impl PtyManager { let child = pair .slave .spawn_command(cmd) - .map_err(|e| format!("failed to spawn tmux attach: {}", e))?; + .map_err(|e| format!("failed to spawn tmux attach: {e}"))?; drop(pair.slave); let writer = pair .master .take_writer() - .map_err(|e| format!("failed to get pty writer: {}", e))?; + .map_err(|e| format!("failed to get pty writer: {e}"))?; let mut reader = pair .master .try_clone_reader() - .map_err(|e| format!("failed to get pty reader: {}", e))?; + .map_err(|e| format!("failed to get pty reader: {e}"))?; let alive = Arc::new(Mutex::new(true)); let alive_clone = Arc::clone(&alive); - let output_event = format!("pty-output:{}", session_id); - let status_event = format!("session-status-changed:{}", session_id); + let output_event = format!("pty-output:{session_id}"); + let status_event = format!("session-status-changed:{session_id}"); thread::spawn(move || { let mut buf = [0u8; 4096]; @@ -295,7 +295,7 @@ impl PtyManager { pixel_width: 0, pixel_height: 0, }) - .map_err(|e| format!("failed to open pty: {}", e))?; + .map_err(|e| format!("failed to open pty: {e}"))?; let mut cmd = CommandBuilder::new(program); for arg in args { @@ -306,25 +306,25 @@ impl PtyManager { let child = pair .slave .spawn_command(cmd) - .map_err(|e| format!("failed to spawn {}: {}", program, e))?; + .map_err(|e| format!("failed to spawn {program}: {e}"))?; drop(pair.slave); let writer = pair .master .take_writer() - .map_err(|e| format!("failed to get pty writer: {}", e))?; + .map_err(|e| format!("failed to get pty writer: {e}"))?; let mut reader = pair .master .try_clone_reader() - .map_err(|e| format!("failed to get pty reader: {}", e))?; + .map_err(|e| format!("failed to get pty reader: {e}"))?; let alive = Arc::new(Mutex::new(true)); let alive_clone = Arc::clone(&alive); - let output_event = format!("pty-output:{}", session_id); - let status_event = format!("session-status-changed:{}", session_id); + let output_event = format!("pty-output:{session_id}"); + let status_event = format!("session-status-changed:{session_id}"); thread::spawn(move || { let mut buf = [0u8; 4096]; @@ -368,17 +368,17 @@ impl PtyManager { let session = self .sessions .get_mut(&session_id) - .ok_or_else(|| format!("session not found: {}", session_id))?; + .ok_or_else(|| format!("session not found: {session_id}"))?; session .writer .write_all(data) - .map_err(|e| format!("failed to write to pty: {}", e))?; + .map_err(|e| format!("failed to write to pty: {e}"))?; session .writer .flush() - .map_err(|e| format!("failed to flush pty writer: {}", e))?; + .map_err(|e| format!("failed to flush pty writer: {e}"))?; Ok(()) } @@ -390,7 +390,7 @@ impl PtyManager { let session = self .sessions .get_mut(&session_id) - .ok_or_else(|| format!("session not found: {}", session_id))?; + .ok_or_else(|| format!("session not found: {session_id}"))?; if session.tmux_session { TmuxManager::send_keys_hex(session_id, data) @@ -398,11 +398,11 @@ impl PtyManager { session .writer .write_all(data) - .map_err(|e| format!("failed to write to pty: {}", e))?; + .map_err(|e| format!("failed to write to pty: {e}"))?; session .writer .flush() - .map_err(|e| format!("failed to flush pty writer: {}", e))?; + .map_err(|e| format!("failed to flush pty writer: {e}"))?; Ok(()) } } @@ -411,7 +411,7 @@ impl PtyManager { let session = self .sessions .get(&session_id) - .ok_or_else(|| format!("session not found: {}", session_id))?; + .ok_or_else(|| format!("session not found: {session_id}"))?; // Resize via tmux so the claude process sees the new size if session.tmux_session { @@ -427,7 +427,7 @@ impl PtyManager { pixel_width: 0, pixel_height: 0, }) - .map_err(|e| format!("failed to resize pty: {}", e)) + .map_err(|e| format!("failed to resize pty: {e}")) } pub fn is_alive(&self, session_id: Uuid) -> bool { diff --git a/server/src/session_args.rs b/server/src/session_args.rs index 3a93217..62bcb29 100644 --- a/server/src/session_args.rs +++ b/server/src/session_args.rs @@ -6,11 +6,10 @@ const BACKGROUND_WORKFLOW_SUFFIX: &str = "\n\nYou are an autonomous background w /// When `background` is true, appends the autonomous workflow instructions. pub fn build_issue_prompt(issue_number: u64, title: &str, url: &str, background: bool) -> String { let base = format!( - "You are working on GitHub issue #{}: {}\nIssue URL: {}\nPlease include 'closes #{}' in any PR descriptions or final commit messages.", - issue_number, title, url, issue_number + "You are working on GitHub issue #{issue_number}: {title}\nIssue URL: {url}\nPlease include 'closes #{issue_number}' in any PR descriptions or final commit messages." ); if background { - format!("{}{}", base, BACKGROUND_WORKFLOW_SUFFIX) + format!("{base}{BACKGROUND_WORKFLOW_SUFFIX}") } else { base } diff --git a/server/src/shell_env.rs b/server/src/shell_env.rs index fb3cf36..e9bfb49 100644 --- a/server/src/shell_env.rs +++ b/server/src/shell_env.rs @@ -33,7 +33,7 @@ pub fn inherit_shell_env() { fn resolve_shell_env() -> HashMap { let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - let script = format!("echo '{}'; /usr/bin/env", ENV_MARKER); + let script = format!("echo '{ENV_MARKER}'; /usr/bin/env"); let mut child = match Command::new(&shell) .args(["-ilc", &script]) diff --git a/server/src/skills.rs b/server/src/skills.rs index 91951ac..9346d54 100644 --- a/server/src/skills.rs +++ b/server/src/skills.rs @@ -153,11 +153,11 @@ pub fn sync_skills() { }; if let Err(e) = sync_claude_skills(&skills_dir) { - eprintln!("Warning: failed to sync Claude skills: {}", e); + eprintln!("Warning: failed to sync Claude skills: {e}"); } if let Err(e) = sync_codex_skills(&skills_dir) { - eprintln!("Warning: failed to sync Codex skills: {}", e); + eprintln!("Warning: failed to sync Codex skills: {e}"); } } diff --git a/server/src/status_socket.rs b/server/src/status_socket.rs index 17cbc14..6ce519c 100644 --- a/server/src/status_socket.rs +++ b/server/src/status_socket.rs @@ -10,7 +10,7 @@ use crate::worktree::WorktreeManager; const DEFAULT_SOCKET_PATH: &str = "/tmp/the-controller.sock"; /// Return the socket path for a specific staged session. pub fn staged_socket_path(session_id: &Uuid) -> String { - format!("/tmp/the-controller-staged-{}.sock", session_id) + format!("/tmp/the-controller-staged-{session_id}.sock") } /// Return the socket path, checking the CONTROLLER_SOCKET env var first. @@ -71,7 +71,7 @@ fn write_socket_response(stream: &mut UnixStream, response: &crate::secure_env:: crate::secure_env::format_secure_env_response(response) ); if let Err(err) = stream.write_all(line.as_bytes()) { - eprintln!("Failed to write socket response: {}", err); + eprintln!("Failed to write socket response: {err}"); } } @@ -136,8 +136,7 @@ pub fn start_listener(state: Arc) { match UnixStream::connect(&path) { Ok(_) => { eprintln!( - "Warning: another instance appears to be running (socket {} is active)", - path + "Warning: another instance appears to be running (socket {path} is active)" ); return; } @@ -151,7 +150,7 @@ pub fn start_listener(state: Arc) { let listener = match UnixListener::bind(&path) { Ok(l) => l, Err(e) => { - eprintln!("Failed to bind Unix socket at {}: {}", path, e); + eprintln!("Failed to bind Unix socket at {path}: {e}"); return; } }; @@ -169,7 +168,7 @@ pub fn start_listener(state: Arc) { }); } Err(e) => { - eprintln!("Error accepting connection on status socket: {}", e); + eprintln!("Error accepting connection on status socket: {e}"); } } } @@ -180,7 +179,7 @@ fn handle_connection(stream: UnixStream, state: &Arc, emitter: &Arc stream, Err(err) => { - eprintln!("Failed to clone status socket stream: {}", err); + eprintln!("Failed to clone status socket stream: {err}"); return; } }; @@ -195,9 +194,9 @@ fn handle_connection(stream: UnixStream, state: &Arc, emitter: &Arc, emitter: &Arc format!("staged:{}\n", port), - Ok(Err(e)) => format!("error:{}\n", e), + Ok(Ok(port)) => format!("staged:{port}\n"), + Ok(Err(e)) => format!("error:{e}\n"), Err(_) => "error:internal channel error\n".to_string(), }; if let Err(e) = writer.write_all(response.as_bytes()) { - eprintln!("Failed to write stage response: {}", e); + eprintln!("Failed to write stage response: {e}"); } return; } @@ -235,7 +234,7 @@ fn handle_connection(stream: UnixStream, state: &Arc, emitter: &Arc match response_rx.recv() { Ok(response) => write_socket_response(&mut writer, &response), Err(err) => { - eprintln!("Failed to receive secure env response: {}", err); + eprintln!("Failed to receive secure env response: {err}"); write_socket_response( &mut writer, &crate::secure_env::SecureEnvResponse { @@ -251,7 +250,7 @@ fn handle_connection(stream: UnixStream, state: &Arc, emitter: &Arc { - eprintln!("Invalid secure env socket message: {}", err); + eprintln!("Invalid secure env socket message: {err}"); write_socket_response( &mut writer, &crate::secure_env::SecureEnvResponse { @@ -266,7 +265,7 @@ fn handle_connection(stream: UnixStream, state: &Arc, emitter: &Arc { - eprintln!("Error reading from status socket connection: {}", e); + eprintln!("Error reading from status socket connection: {e}"); break; } } @@ -287,7 +286,7 @@ fn handle_cleanup(state: &Arc, session_id: Uuid) { if let Some(pos) = project.sessions.iter().position(|s| s.id == session_id) { let session = project.sessions.remove(pos); if let Err(e) = storage.save_project(project) { - eprintln!("cleanup: failed to save project: {}", e); + eprintln!("cleanup: failed to save project: {e}"); } // Delete the worktree if let (Some(wt_path), Some(branch)) = @@ -296,7 +295,7 @@ fn handle_cleanup(state: &Arc, session_id: Uuid) { if let Err(e) = WorktreeManager::remove_worktree(wt_path, &project.repo_path, branch) { - eprintln!("cleanup: failed to remove worktree: {}", e); + eprintln!("cleanup: failed to remove worktree: {e}"); } } break; @@ -311,9 +310,9 @@ fn handle_cleanup(state: &Arc, session_id: Uuid) { } // Tell the frontend to refresh its project list - let event_name = format!("session-cleanup:{}", session_id); + let event_name = format!("session-cleanup:{session_id}"); if let Err(e) = state.emitter.emit(&event_name, "cleanup") { - eprintln!("Failed to emit {}: {}", event_name, e); + eprintln!("Failed to emit {event_name}: {e}"); } } @@ -321,14 +320,9 @@ fn handle_cleanup(state: &Arc, session_id: Uuid) { /// Configures hooks that report session status changes over the Unix socket. pub fn hook_settings_json(session_id: Uuid) -> String { let path = socket_path(); - let working_cmd = format!( - "echo \"working:{}\" | nc -U -w 2 {} 2>/dev/null; true", - session_id, path - ); - let idle_cmd = format!( - "echo \"idle:{}\" | nc -U -w 2 {} 2>/dev/null; true", - session_id, path - ); + let working_cmd = + format!("echo \"working:{session_id}\" | nc -U -w 2 {path} 2>/dev/null; true"); + let idle_cmd = format!("echo \"idle:{session_id}\" | nc -U -w 2 {path} 2>/dev/null; true"); serde_json::json!({ "hooks": { diff --git a/server/src/tmux.rs b/server/src/tmux.rs index 9ccf594..402bf3c 100644 --- a/server/src/tmux.rs +++ b/server/src/tmux.rs @@ -28,7 +28,7 @@ impl TmuxManager { } pub fn session_name(session_id: Uuid) -> String { - format!("{}{}", SESSION_PREFIX, session_id) + format!("{SESSION_PREFIX}{session_id}") } pub fn has_session(session_id: Uuid) -> bool { @@ -70,7 +70,7 @@ impl TmuxManager { // Prepend ~/.the-controller/bin to PATH so controller-cli is available if let Some(path_val) = crate::cli_install::path_with_controller_bin() { args.push("-e".to_string()); - args.push(format!("PATH={}", path_val)); + args.push(format!("PATH={path_val}")); } // Pass all current process env vars so the tmux session inherits // the full shell environment even when the tmux server was started @@ -85,7 +85,7 @@ impl TmuxManager { "_" | "SHLVL" | "OLDPWD" | "PWD" => continue, _ => { args.push("-e".to_string()); - args.push(format!("{}={}", key, val)); + args.push(format!("{key}={val}")); } } } @@ -120,7 +120,7 @@ impl TmuxManager { .env("THE_CONTROLLER_SESSION_ID", session_id.to_string()) .env_remove("CLAUDECODE") .output() - .map_err(|e| format!("failed to run tmux: {}", e))?; + .map_err(|e| format!("failed to run tmux: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -145,7 +145,7 @@ impl TmuxManager { pub fn send_keys_hex(session_id: Uuid, data: &[u8]) -> Result<(), String> { let name = Self::session_name(session_id); let tmux_bin = Self::tmux_binary().ok_or_else(|| "tmux binary not found".to_string())?; - let hex_bytes: Vec = data.iter().map(|b| format!("{:02x}", b)).collect(); + let hex_bytes: Vec = data.iter().map(|b| format!("{b:02x}")).collect(); let mut args = vec![ "send-keys".to_string(), "-H".to_string(), @@ -157,7 +157,7 @@ impl TmuxManager { let output = Command::new(&tmux_bin) .args(&args) .output() - .map_err(|e| format!("failed to run tmux send-keys: {}", e))?; + .map_err(|e| format!("failed to run tmux send-keys: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -175,7 +175,7 @@ impl TmuxManager { let output = Command::new(&tmux_bin) .args(["kill-session", "-t", &name]) .output() - .map_err(|e| format!("failed to run tmux: {}", e))?; + .map_err(|e| format!("failed to run tmux: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -234,7 +234,7 @@ impl TmuxManager { &rows.to_string(), ]) .output() - .map_err(|e| format!("failed to run tmux: {}", e))?; + .map_err(|e| format!("failed to run tmux: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/server/src/token_usage.rs b/server/src/token_usage.rs index d41ba21..142842d 100644 --- a/server/src/token_usage.rs +++ b/server/src/token_usage.rs @@ -17,7 +17,7 @@ pub fn get_token_usage(working_dir: &str, kind: &str) -> Result get_claude_token_usage(working_dir), "codex" => get_codex_token_usage(working_dir), - _ => Err(format!("Unknown session kind: {}", kind)), + _ => Err(format!("Unknown session kind: {kind}")), } } @@ -62,7 +62,7 @@ fn claude_project_dir(working_dir: &str) -> Result { } } - best.ok_or_else(|| format!("No Claude project directory found for {}", working_dir)) + best.ok_or_else(|| format!("No Claude project directory found for {working_dir}")) } /// Find the most recently modified `.jsonl` file in a directory. @@ -193,8 +193,7 @@ fn find_codex_session_file(sessions_dir: &Path, working_dir: &str) -> Result Result { - let repo = - Repository::open(repo_path).map_err(|e| format!("failed to open repo: {}", e))?; + let repo = Repository::open(repo_path).map_err(|e| format!("failed to open repo: {e}"))?; // Check if the repo has any commits (HEAD exists) let head = match repo.head() { @@ -32,7 +31,7 @@ impl WorktreeManager { // Repo has no commits — can't create worktree, use repo path directly return Err("unborn_branch".to_string()); } - Err(e) => return Err(format!("failed to get HEAD: {}", e)), + Err(e) => return Err(format!("failed to get HEAD: {e}")), }; if worktree_dir.exists() { @@ -45,11 +44,11 @@ impl WorktreeManager { // Create the parent directory if let Some(parent) = worktree_dir.parent() { std::fs::create_dir_all(parent) - .map_err(|e| format!("failed to create worktree parent dir: {}", e))?; + .map_err(|e| format!("failed to create worktree parent dir: {e}"))?; } let commit = head .peel_to_commit() - .map_err(|e| format!("failed to peel HEAD to commit: {}", e))?; + .map_err(|e| format!("failed to peel HEAD to commit: {e}"))?; // Delete stale branch if it exists (left over from a previous session) if let Ok(mut existing) = repo.find_branch(branch_name, git2::BranchType::Local) { @@ -58,7 +57,7 @@ impl WorktreeManager { let branch = repo .branch(branch_name, &commit, false) - .map_err(|e| format!("failed to create branch '{}': {}", branch_name, e))?; + .map_err(|e| format!("failed to create branch '{branch_name}': {e}"))?; // Create the worktree with the new branch as its HEAD let reference = branch.into_reference(); @@ -66,7 +65,7 @@ impl WorktreeManager { opts.reference(Some(&reference)); repo.worktree(branch_name, worktree_dir, Some(&opts)) - .map_err(|e| format!("failed to create worktree: {}", e))?; + .map_err(|e| format!("failed to create worktree: {e}"))?; // Symlink .env from the main repo into the worktree so all sessions // share the same secrets file (and controller-cli env set updates are @@ -75,7 +74,7 @@ impl WorktreeManager { let env_dst = worktree_dir.join(".env"); #[cfg(unix)] if let Err(e) = std::os::unix::fs::symlink(&env_src, &env_dst) { - eprintln!("Warning: failed to symlink .env to worktree: {}", e); + eprintln!("Warning: failed to symlink .env to worktree: {e}"); } #[cfg(windows)] if let Err(e) = std::os::windows::fs::symlink_file(&env_src, &env_dst) { @@ -87,8 +86,7 @@ impl WorktreeManager { /// Detect the main branch name (main or master) for a repository. pub fn detect_main_branch(repo_path: &str) -> Result { - let repo = - Repository::open(repo_path).map_err(|e| format!("failed to open repo: {}", e))?; + let repo = Repository::open(repo_path).map_err(|e| format!("failed to open repo: {e}"))?; for name in &["main", "master"] { if repo.find_branch(name, git2::BranchType::Local).is_ok() { @@ -99,7 +97,7 @@ impl WorktreeManager { // Fall back to whatever HEAD points to let head = repo .head() - .map_err(|e| format!("failed to get HEAD: {}", e))?; + .map_err(|e| format!("failed to get HEAD: {e}"))?; if let Some(shorthand) = head.shorthand() { return Ok(shorthand.to_string()); } @@ -114,7 +112,7 @@ impl WorktreeManager { .args(["pull"]) .current_dir(repo_path) .output() - .map_err(|e| format!("failed to run git pull: {}", e))?; + .map_err(|e| format!("failed to run git pull: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); @@ -150,7 +148,7 @@ impl WorktreeManager { .args(["rebase", &main_branch]) .current_dir(worktree_path) .output() - .map_err(|e| format!("failed to run git rebase: {}", e))?; + .map_err(|e| format!("failed to run git rebase: {e}"))?; if !rebase_output.status.success() { // Leave the rebase in progress — don't abort. @@ -173,7 +171,7 @@ impl WorktreeManager { .args(["push", "-u", "origin", branch_name, "--force-with-lease"]) .current_dir(worktree_path) .output() - .map_err(|e| format!("failed to run git push: {}", e))?; + .map_err(|e| format!("failed to run git push: {e}"))?; if !push_output.status.success() { let stderr = String::from_utf8_lossy(&push_output.stderr); @@ -185,7 +183,7 @@ impl WorktreeManager { .args(["pr", "create", "--fill", "--head", branch_name]) .current_dir(worktree_path) .output() - .map_err(|e| format!("failed to run gh pr create: {}", e))?; + .map_err(|e| format!("failed to run gh pr create: {e}"))?; if !pr_output.status.success() { let stderr = String::from_utf8_lossy(&pr_output.stderr); @@ -195,7 +193,7 @@ impl WorktreeManager { .args(["pr", "view", branch_name, "--json", "url", "-q", ".url"]) .current_dir(worktree_path) .output() - .map_err(|e| format!("failed to get existing PR: {}", e))?; + .map_err(|e| format!("failed to get existing PR: {e}"))?; if view_output.status.success() { let url = String::from_utf8_lossy(&view_output.stdout) @@ -236,23 +234,22 @@ impl WorktreeManager { branch: &str, main_branch: &str, ) -> Result { - let repo = - Repository::open(repo_path).map_err(|e| format!("failed to open repo: {}", e))?; + let repo = Repository::open(repo_path).map_err(|e| format!("failed to open repo: {e}"))?; let branch_commit = repo .find_branch(branch, git2::BranchType::Local) - .map_err(|e| format!("branch '{}' not found: {}", branch, e))? + .map_err(|e| format!("branch '{branch}' not found: {e}"))? .get() .peel_to_commit() - .map_err(|e| format!("failed to resolve branch commit: {}", e))? + .map_err(|e| format!("failed to resolve branch commit: {e}"))? .id(); let main_commit = repo .find_branch(main_branch, git2::BranchType::Local) - .map_err(|e| format!("branch '{}' not found: {}", main_branch, e))? + .map_err(|e| format!("branch '{main_branch}' not found: {e}"))? .get() .peel_to_commit() - .map_err(|e| format!("failed to resolve main commit: {}", e))? + .map_err(|e| format!("failed to resolve main commit: {e}"))? .id(); if branch_commit == main_commit { @@ -261,7 +258,7 @@ impl WorktreeManager { let merge_base = repo .merge_base(branch_commit, main_commit) - .map_err(|e| format!("failed to find merge base: {}", e))?; + .map_err(|e| format!("failed to find merge base: {e}"))?; // Branch needs rebase if main has commits not in branch (behind or diverged) Ok(merge_base != main_commit) @@ -275,7 +272,7 @@ impl WorktreeManager { .args(["rebase", main_branch]) .current_dir(worktree_path) .output() - .map_err(|e| format!("failed to run git rebase: {}", e))?; + .map_err(|e| format!("failed to run git rebase: {e}"))?; if output.status.success() { Ok(true) @@ -293,14 +290,14 @@ impl WorktreeManager { /// Check if a worktree has a clean working tree (no uncommitted or untracked changes). pub fn is_worktree_clean(worktree_path: &str) -> Result { let repo = Repository::open(worktree_path) - .map_err(|e| format!("failed to open worktree repo: {}", e))?; + .map_err(|e| format!("failed to open worktree repo: {e}"))?; let statuses = repo .statuses(Some( git2::StatusOptions::new() .include_untracked(true) .recurse_untracked_dirs(false), )) - .map_err(|e| format!("failed to check worktree status: {}", e))?; + .map_err(|e| format!("failed to check worktree status: {e}"))?; Ok(statuses.is_empty()) } @@ -319,19 +316,18 @@ impl WorktreeManager { // Remove the worktree directory if it exists if worktree_dir.exists() { std::fs::remove_dir_all(worktree_dir) - .map_err(|e| format!("failed to remove worktree dir: {}", e))?; + .map_err(|e| format!("failed to remove worktree dir: {e}"))?; } // Prune the worktree reference - let repo = - Repository::open(repo_path).map_err(|e| format!("failed to open repo: {}", e))?; + let repo = Repository::open(repo_path).map_err(|e| format!("failed to open repo: {e}"))?; if let Ok(wt) = repo.find_worktree(branch_name) { let mut prune_opts = git2::WorktreePruneOptions::new(); prune_opts.valid(true); prune_opts.working_tree(true); wt.prune(Some(&mut prune_opts)) - .map_err(|e| format!("failed to prune worktree: {}", e))?; + .map_err(|e| format!("failed to prune worktree: {e}"))?; } // Clean up the branch so it doesn't block future worktree creation diff --git a/src/lib/web-backend-audit.test.ts b/src/lib/web-backend-audit.test.ts new file mode 100644 index 0000000..ed5ae25 --- /dev/null +++ b/src/lib/web-backend-audit.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { globSync } from "glob"; + +function read(path: string): string { + return readFileSync(path, "utf8"); +} + +function serverRoutes(): Set { + const source = read("server/src/main.rs"); + return new Set([...source.matchAll(/"\/api\/([a-zA-Z0-9_]+)"/g)].map((m) => m[1])); +} + +function productionFrontendCommands(): Set { + const files = globSync("src/**/*.{ts,svelte}", { + ignore: ["src/**/*.test.ts", "src/**/*.test.svelte.ts", "src/**/__mocks__/**"], + }); + const commands = new Set(); + + for (const file of files) { + const source = read(file); + for (const match of source.matchAll(/command(?:<[^>]+>)?\(\s*["']([a-zA-Z0-9_]+)["']/g)) { + commands.add(match[1]); + } + } + + return commands; +} + +describe("web backend migration audit", () => { + it("keeps every production frontend command backed by an HTTP route", () => { + const routes = serverRoutes(); + const missing = [...productionFrontendCommands()].filter((cmd) => !routes.has(cmd)); + + expect(missing).toEqual([]); + }); + + it("keeps the desktop command surface covered by routes or browser replacements", () => { + const routes = serverRoutes(); + const requiredRoutes = [ + "restore_sessions", + "connect_session", + "create_project", + "load_project", + "list_projects", + "delete_project", + "get_agents_md", + "update_agents_md", + "create_session", + "write_to_pty", + "send_raw_to_pty", + "resize_pty", + "close_session", + "set_initial_prompt", + "submit_secure_env_value", + "cancel_secure_env_request", + "start_claude_login", + "stop_claude_login", + "home_dir", + "check_onboarding", + "save_onboarding_config", + "check_claude_cli", + "list_directories_at", + "list_root_directories", + "generate_project_names", + "scaffold_project", + "list_github_issues", + "kanban_load_order", + "kanban_save_order", + "list_assigned_issues", + "generate_issue_body", + "create_github_issue", + "close_github_issue", + "delete_github_issue", + "post_github_comment", + "add_github_label", + "remove_github_label", + "merge_session_branch", + "get_session_commits", + "configure_maintainer", + "get_maintainer_status", + "get_maintainer_history", + "trigger_maintainer_check", + "clear_maintainer_reports", + "get_maintainer_issues", + "get_maintainer_issue_detail", + "configure_auto_worker", + "get_auto_worker_queue", + "get_worker_reports", + "save_session_prompt", + "list_project_prompts", + "stage_session", + "unstage_session", + "get_repo_head", + "get_session_token_usage", + "log_frontend_error", + "read_daemon_token", + "save_screenshot", + ]; + + const missing = requiredRoutes.filter((route) => !routes.has(route)); + expect(missing).toEqual([]); + + const nativeReplacements = read("src/lib/native.ts"); + expect(nativeReplacements).toContain("html2canvas"); + expect(nativeReplacements).toContain("ClipboardItem"); + }); + + it("keeps active docs aligned with the web frontend plus backend runtime", () => { + const activeDocs = [ + "README.md", + "ARCHITECTURE.md", + "docs/keyboard-modes.md", + "e2e/specs/chat-mode.spec.ts", + ]; + + const stalePatterns = [ + /Built with Tauri/i, + /Tauri v2/i, + /npm run tauri dev/i, + /src-tauri\/src/i, + /six workspace modes/i, + /Ambient Mode . Architecture Keys/i, + /Ambient Mode . Notes Keys/i, + /Ambient Mode . Infrastructure Keys/i, + /Ambient Mode . Voice Keys/i, + /read_daemon_token`[\s\S]{0,120}not exposed/i, + ]; + + const stale = activeDocs.flatMap((file) => { + const source = read(file); + return stalePatterns + .filter((pattern) => pattern.test(source)) + .map((pattern) => `${file}: ${pattern}`); + }); + + expect(stale).toEqual([]); + }); +});