From 64b2d36672744103e05fb21029a074d7c94abeeb Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 29 Jan 2026 08:01:31 +1100 Subject: [PATCH 1/3] adds a staged command and ability to install it --- .gitignore | 4 +- README.md | 14 ++++ bin/staged | 46 +++++++++++++ install.sh | 26 +++++++- src-tauri/src/lib.rs | 102 +++++++++++++++++++++++++++++ src/App.svelte | 41 +++++++++++- src/lib/services/window.ts | 17 +++++ src/lib/stores/repoState.svelte.ts | 16 ++++- 8 files changed, 258 insertions(+), 8 deletions(-) create mode 100755 bin/staged diff --git a/.gitignore b/.gitignore index bd560c4..d6d8367 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +.g3/ +analysis/ +requirements.md node_modules dist @@ -24,4 +27,3 @@ dist-ssr *.sln *.sw? .claude/ - diff --git a/README.md b/README.md index 2cd3f10..5666ea9 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,23 @@ The installer will: - Install dependencies - Build the application - Install to `/Applications/staged.app` +- Install the `staged` CLI to `/usr/local/bin` **Note**: This will build from source, which takes a few minutes. Requires git to be installed. +### Command Line Usage + +After installation, you can launch Staged from the terminal: + +```bash +staged # Open in current directory +staged /path/to/repo # Open in specified directory +``` + +Each invocation opens a new window, so you can have multiple repos open simultaneously. + +If you installed manually (not via the install script), copy `bin/staged` to somewhere in your PATH (e.g., `/usr/local/bin`). + ## Development ### Prerequisites diff --git a/bin/staged b/bin/staged new file mode 100755 index 0000000..aa8653d --- /dev/null +++ b/bin/staged @@ -0,0 +1,46 @@ +#!/bin/bash +# CLI launcher for Staged app +# Usage: staged [path] +# path - Optional directory to open (defaults to current directory) + +set -e + +# Resolve the target path +if [ -n "$1" ]; then + TARGET_PATH="$1" +else + TARGET_PATH="." +fi + +# Convert to absolute path +TARGET_PATH=$(cd "$TARGET_PATH" 2>/dev/null && pwd) || { + echo "Error: '$1' is not a valid directory" >&2 + exit 1 +} + +# Find the Staged app +# Priority: /Applications > ~/Applications > built app in repo +if [ -d "/Applications/staged.app" ]; then + APP_PATH="/Applications/staged.app" +elif [ -d "$HOME/Applications/staged.app" ]; then + APP_PATH="$HOME/Applications/staged.app" +else + # Try to find it relative to this script (for development) + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + REPO_ROOT="$(dirname "$SCRIPT_DIR")" + + if [ -d "$REPO_ROOT/src-tauri/target/release/bundle/macos/staged.app" ]; then + APP_PATH="$REPO_ROOT/src-tauri/target/release/bundle/macos/staged.app" + elif [ -d "$REPO_ROOT/src-tauri/target/debug/bundle/macos/staged.app" ]; then + APP_PATH="$REPO_ROOT/src-tauri/target/debug/bundle/macos/staged.app" + else + echo "Error: Could not find staged.app" >&2 + echo "Install it to /Applications or build with 'just build'" >&2 + exit 1 + fi +fi + +# Launch the app with the target path as argument +# Run in background and detach so CLI returns immediately +"$APP_PATH/Contents/MacOS/staged" "$TARGET_PATH" &>/dev/null & +disown diff --git a/install.sh b/install.sh index 35d86d0..ce8f1d5 100755 --- a/install.sh +++ b/install.sh @@ -163,6 +163,27 @@ install_to_system() { fi } +# Install CLI command +install_cli() { + if [ "$OS" = "macos" ]; then + CLI_PATH="bin/staged" + INSTALL_PATH="/usr/local/bin/staged" + + print_info "Installing CLI to $INSTALL_PATH..." + + # Create /usr/local/bin if it doesn't exist + if [ ! -d "/usr/local/bin" ]; then + sudo mkdir -p /usr/local/bin + fi + + if sudo cp "$CLI_PATH" "$INSTALL_PATH" && sudo chmod +x "$INSTALL_PATH"; then + print_success "CLI installed to $INSTALL_PATH" + else + print_warning "Failed to install CLI (you can manually copy bin/staged to your PATH)" + fi + fi +} + # Cleanup cleanup() { if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then @@ -188,6 +209,7 @@ main() { install_deps build_app install_to_system + install_cli cleanup echo "" @@ -196,7 +218,9 @@ main() { if [ "$OS" = "macos" ]; then echo "You can now launch Staged from your Applications folder," - echo "or run it from the command line with: open -a staged" + echo "or from the command line:" + echo " staged # opens in current directory" + echo " staged /path # opens in specified directory" fi echo "" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b27bcb1..d42a6db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ //! Tauri commands for the Staged diff viewer. //! //! This module provides the bridge between the frontend and the git/github modules. +//! Supports CLI arguments: `staged [path]` opens the app with the specified directory. pub mod ai; pub mod git; @@ -783,6 +784,82 @@ fn get_window_label(window: tauri::Window) -> String { window.label().to_string() } +/// Get the initial repository path from CLI arguments. +/// Returns the canonicalized path if a valid directory was provided, otherwise None. +#[tauri::command] +fn get_initial_path() -> Option { + let args: Vec = std::env::args().collect(); + + // Skip the binary name, look for a path argument (not starting with -) + for arg in args.iter().skip(1) { + if arg.starts_with('-') { + continue; + } + + // Try to canonicalize the path + let path = std::path::Path::new(arg); + if let Ok(canonical) = path.canonicalize() { + if canonical.is_dir() { + return canonical.to_str().map(|s| s.to_string()); + } + } + } + None +} + +/// Install the CLI command to /usr/local/bin using a helper script with sudo. +/// Returns Ok(path) on success, Err(message) on failure. +#[tauri::command] +fn install_cli() -> Result { + let cli_script = include_str!("../../bin/staged"); + let install_path = "/usr/local/bin/staged"; + + // Write script to a temp file first + let temp_path = std::env::temp_dir().join("staged-cli-install"); + std::fs::write(&temp_path, cli_script) + .map_err(|e| format!("Failed to write temp file: {}", e))?; + + // Use osascript to run sudo with a GUI prompt (macOS) + #[cfg(target_os = "macos")] + { + let script = format!( + r#"do shell script "cp '{}' '{}' && chmod +x '{}'" with administrator privileges"#, + temp_path.display(), + install_path, + install_path + ); + + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| format!("Failed to run installer: {}", e))?; + + // Clean up temp file + let _ = std::fs::remove_file(&temp_path); + + if output.status.success() { + Ok(install_path.to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") || stderr.contains("(-128)") { + Err("Installation cancelled".to_string()) + } else { + Err(format!("Installation failed: {}", stderr)) + } + } + } + + #[cfg(not(target_os = "macos"))] + { + let _ = std::fs::remove_file(&temp_path); + Err( + "CLI installation is only supported on macOS. Copy bin/staged to your PATH manually." + .to_string(), + ) + } +} + // ============================================================================= // Menu System // ============================================================================= @@ -791,6 +868,24 @@ fn get_window_label(window: tauri::Window) -> String { fn build_menu(app: &AppHandle) -> Result, Box> { let menu = Menu::new(app)?; + // App menu (macOS only, but harmless on other platforms) + let app_menu = Submenu::with_items( + app, + "Staged", + true, + &[ + &MenuItem::with_id( + app, + "install-cli", + "Install CLI Command...", + true, + None::<&str>, + )?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?; + let file_menu = Submenu::with_items( app, "File", @@ -827,6 +922,7 @@ fn build_menu(app: &AppHandle) -> Result, Box> ], )?; + menu.append(&app_menu)?; menu.append(&file_menu)?; menu.append(&edit_menu)?; Ok(menu) @@ -844,6 +940,9 @@ fn handle_menu_event(app: &AppHandle, event: MenuEvent) { "close-window" => { let _ = app.emit("menu:close-window", ()); } + "install-cli" => { + let _ = app.emit("menu:install-cli", ()); + } _ => {} } } @@ -939,6 +1038,9 @@ pub fn run() { unwatch_repo, // Window commands get_window_label, + // CLI commands + get_initial_path, + install_cli, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.svelte b/src/App.svelte index bddd0aa..1bd4afd 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,7 +11,7 @@ import FolderPickerModal from './lib/FolderPickerModal.svelte'; import TabBar from './lib/TabBar.svelte'; import { listRefs } from './lib/services/git'; - import { getWindowLabel } from './lib/services/window'; + import { getWindowLabel, installCli } from './lib/services/window'; import { windowState, addTab, @@ -92,6 +92,7 @@ let unsubscribeMenuOpenFolder: Unsubscribe | null = null; let unsubscribeMenuCloseTab: Unsubscribe | null = null; let unsubscribeMenuCloseWindow: Unsubscribe | null = null; + let unsubscribeMenuInstallCli: Unsubscribe | null = null; // Sidebar resize state let isDraggingSidebar = $state(false); @@ -281,6 +282,20 @@ await window.close(); } + async function handleMenuInstallCli() { + try { + const path = await installCli(); + alert( + `CLI installed successfully!\n\nYou can now run:\n staged # open current directory\n staged /path # open specific directory\n\nInstalled to: ${path}` + ); + } catch (e) { + const error = e as Error; + alert( + `Failed to install CLI:\n\n${error.message || error}\n\nYou may need to run manually:\n sudo cp /path/to/staged/bin/staged /usr/local/bin/` + ); + } + } + /** * Extract repository name from path. */ @@ -553,13 +568,20 @@ unsubscribeMenuOpenFolder = await listen('menu:open-folder', handleMenuOpenFolder); unsubscribeMenuCloseTab = await listen('menu:close-tab', handleMenuCloseTab); unsubscribeMenuCloseWindow = await listen('menu:close-window', handleMenuCloseWindow); + unsubscribeMenuInstallCli = await listen('menu:install-cli', handleMenuInstallCli); // Initialize repo state (resolves canonical path, adds to recent repos) const repoPath = await initRepoState(); if (repoPath) { - // Create initial tab if no tabs loaded from storage - if (windowState.tabs.length === 0) { + // Check if we already have a tab for this repo + const existingTabIndex = windowState.tabs.findIndex((t) => t.repoPath === repoPath); + + if (existingTabIndex >= 0) { + // Switch to existing tab for this repo + switchTab(existingTabIndex); + } else { + // Create new tab for the CLI path const repoName = extractRepoName(repoPath); addTab(repoPath, repoName, createDiffState, createCommentsState, createDiffSelection); } @@ -575,6 +597,18 @@ // Initialize the active tab await initializeNewTab(tab); } + } else if (windowState.tabs.length > 0) { + // No CLI path but we have restored tabs - use them + syncTabToGlobal(); + + const tab = getActiveTab(); + if (tab) { + await watchRepo(tab.repoPath); + // Initialize if needed + if (tab.diffState.currentSpec === null) { + await initializeNewTab(tab); + } + } } })(); }); @@ -586,6 +620,7 @@ unsubscribeMenuOpenFolder?.(); unsubscribeMenuCloseTab?.(); unsubscribeMenuCloseWindow?.(); + unsubscribeMenuInstallCli?.(); // Cleanup sidebar resize listeners document.removeEventListener('mousemove', handleSidebarResizeMove); document.removeEventListener('mouseup', handleSidebarResizeEnd); diff --git a/src/lib/services/window.ts b/src/lib/services/window.ts index 4f1e836..5ef9d55 100644 --- a/src/lib/services/window.ts +++ b/src/lib/services/window.ts @@ -14,6 +14,23 @@ export async function getWindowLabel(): Promise { return invoke('get_window_label'); } +/** + * Get the initial repository path from CLI arguments. + * Returns null if no valid path was provided. + */ +export async function getInitialPath(): Promise { + return invoke('get_initial_path'); +} + +/** + * Install the CLI command to /usr/local/bin. + * Returns the install path on success. + * Throws an error with a message on failure. + */ +export async function installCli(): Promise { + return invoke('install_cli'); +} + /** * Get the current window instance. */ diff --git a/src/lib/stores/repoState.svelte.ts b/src/lib/stores/repoState.svelte.ts index 1b04e38..f13403b 100644 --- a/src/lib/stores/repoState.svelte.ts +++ b/src/lib/stores/repoState.svelte.ts @@ -6,6 +6,7 @@ */ import { getRepoRoot } from '../services/git'; +import { getInitialPath } from '../services/window'; // ============================================================================= // Constants @@ -84,15 +85,24 @@ function extractRepoName(repoPath: string): string { // ============================================================================= /** - * Initialize repo state - load recent repos and resolve current directory to canonical path. + * Initialize repo state - load recent repos and resolve initial path. + * Priority: CLI argument > current directory * Returns the canonical repo path, or null if not in a git repo. */ export async function initRepoState(): Promise { repoState.recentRepos = loadRecentRepos(); - // Resolve current directory to canonical path + // Check for CLI argument first (e.g., `staged /path/to/repo`) + let initialPath = await getInitialPath(); + + // Fall back to current directory if no CLI argument + if (!initialPath) { + initialPath = '.'; + } + + // Resolve to canonical path and validate it's a git repo try { - const canonicalPath = await getRepoRoot('.'); + const canonicalPath = await getRepoRoot(initialPath); // Check if this path is already in recent repos const existing = repoState.recentRepos.find((r) => r.path === canonicalPath); From 7b5577ea9efc93d69ec740279d4885546efa5f32 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 29 Jan 2026 08:25:18 +1100 Subject: [PATCH 2/3] move to inside macos macro --- src-tauri/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 64622f1..357c4ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -810,7 +810,6 @@ fn get_initial_path() -> Option { #[tauri::command] fn install_cli() -> Result { let cli_script = include_str!("../../bin/staged"); - let install_path = "/usr/local/bin/staged"; // Write script to a temp file first let temp_path = std::env::temp_dir().join("staged-cli-install"); @@ -820,6 +819,7 @@ fn install_cli() -> Result { // Use osascript to run sudo with a GUI prompt (macOS) #[cfg(target_os = "macos")] { + let install_path = "/usr/local/bin/staged"; let script = format!( r#"do shell script "cp '{}' '{}' && chmod +x '{}'" with administrator privileges"#, temp_path.display(), From 71b18fb97ae968dcb1db6783f054e5858a6dd6bf Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 29 Jan 2026 08:58:54 +1100 Subject: [PATCH 3/3] test coverage --- src-tauri/src/lib.rs | 145 +++++++++++++++++++++++++++++++++---------- 1 file changed, 112 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 357c4ca..79ae505 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -809,6 +809,11 @@ fn get_initial_path() -> Option { /// Returns Ok(path) on success, Err(message) on failure. #[tauri::command] fn install_cli() -> Result { + let install_path = Path::new("/usr/local/bin/staged"); + install_cli_to(install_path, true) +} + +fn install_cli_to(install_path: &Path, use_admin: bool) -> Result { let cli_script = include_str!("../../bin/staged"); // Write script to a temp file first @@ -816,45 +821,119 @@ fn install_cli() -> Result { std::fs::write(&temp_path, cli_script) .map_err(|e| format!("Failed to write temp file: {}", e))?; - // Use osascript to run sudo with a GUI prompt (macOS) - #[cfg(target_os = "macos")] - { - let install_path = "/usr/local/bin/staged"; - let script = format!( - r#"do shell script "cp '{}' '{}' && chmod +x '{}'" with administrator privileges"#, - temp_path.display(), - install_path, - install_path - ); + if use_admin { + #[cfg(target_os = "macos")] + { + let script = format!( + r#"do shell script "cp '{}' '{}' && chmod +x '{}'" with administrator privileges"#, + temp_path.display(), + install_path.display(), + install_path.display() + ); + + let output = std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| format!("Failed to run installer: {}", e))?; + + let _ = std::fs::remove_file(&temp_path); + + if output.status.success() { + Ok(install_path.display().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") || stderr.contains("(-128)") { + Err("Installation cancelled".to_string()) + } else { + Err(format!("Installation failed: {}", stderr)) + } + } + } - let output = std::process::Command::new("osascript") - .arg("-e") - .arg(&script) - .output() - .map_err(|e| format!("Failed to run installer: {}", e))?; + #[cfg(not(target_os = "macos"))] + { + let _ = std::fs::remove_file(&temp_path); + Err( + "CLI installation is only supported on macOS. Copy bin/staged to your PATH manually." + .to_string(), + ) + } + } else { + // Non-admin install: direct copy (for testing or user-writable paths) + std::fs::copy(&temp_path, install_path) + .map_err(|e| format!("Failed to copy CLI script: {}", e))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(install_path) + .map_err(|e| format!("Failed to get permissions: {}", e))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(install_path, perms) + .map_err(|e| format!("Failed to set permissions: {}", e))?; + } - // Clean up temp file let _ = std::fs::remove_file(&temp_path); + Ok(install_path.display().to_string()) + } +} - if output.status.success() { - Ok(install_path.to_string()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - if stderr.contains("User canceled") || stderr.contains("(-128)") { - Err("Installation cancelled".to_string()) - } else { - Err(format!("Installation failed: {}", stderr)) - } - } +#[cfg(test)] +mod install_cli_tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_install_cli_writes_executable_script() { + let temp_dir = tempdir().unwrap(); + let install_path = temp_dir.path().join("staged"); + + let result = install_cli_to(&install_path, false); + assert!(result.is_ok(), "install_cli_to failed: {:?}", result); + assert!(install_path.exists(), "CLI script was not created"); + + let content = fs::read_to_string(&install_path).unwrap(); + assert!(content.contains("#!/bin/bash"), "Script missing shebang"); + assert!( + content.contains("staged.app"), + "Script missing app reference" + ); } - #[cfg(not(target_os = "macos"))] - { - let _ = std::fs::remove_file(&temp_path); - Err( - "CLI installation is only supported on macOS. Copy bin/staged to your PATH manually." - .to_string(), - ) + #[test] + #[cfg(unix)] + fn test_install_cli_sets_executable_permission() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempdir().unwrap(); + let install_path = temp_dir.path().join("staged"); + + install_cli_to(&install_path, false).unwrap(); + + let perms = fs::metadata(&install_path).unwrap().permissions(); + let mode = perms.mode(); + assert!(mode & 0o111 != 0, "Script is not executable: {:o}", mode); + } + + #[test] + fn test_install_cli_returns_install_path() { + let temp_dir = tempdir().unwrap(); + let install_path = temp_dir.path().join("staged"); + + let result = install_cli_to(&install_path, false).unwrap(); + assert_eq!(result, install_path.display().to_string()); + } + + #[test] + fn test_install_cli_fails_on_invalid_path() { + let install_path = Path::new("/nonexistent/directory/staged"); + + let result = install_cli_to(install_path, false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to copy")); } }