diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43b7f2b6b..5f2db68fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -349,6 +349,7 @@ jobs: touch "desktop/src-tauri/binaries/sprout-agent-$TARGET" touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" + touch "desktop/src-tauri/binaries/sprout-$TARGET" - name: Build Tauri app run: cd desktop && pnpm tauri build env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 855e7b08a..95eac19fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,7 +52,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr + cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli ./scripts/bundle-sidecars.sh - name: Build unsigned Tauri app diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b87b7273a..0945af9af 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -5225,7 +5225,25 @@ dependencies = [ ] [[package]] -name = "sprout" +name = "sprout-core" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "nostr 0.36.0", + "percent-encoding", + "rand 0.10.1", + "serde", + "serde_json", + "subtle", + "thiserror 2.0.18", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "sprout-desktop" version = "0.1.0" dependencies = [ "atomic-write-file", @@ -5280,24 +5298,6 @@ dependencies = [ "zip 2.4.2", ] -[[package]] -name = "sprout-core" -version = "0.1.0" -dependencies = [ - "chrono", - "hex", - "nostr 0.36.0", - "percent-encoding", - "rand 0.10.1", - "serde", - "serde_json", - "subtle", - "thiserror 2.0.18", - "url", - "uuid", - "zeroize", -] - [[package]] name = "sprout-persona" version = "0.1.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d880acff2..64ac4b81e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [workspace] [package] -name = "sprout" +name = "sprout-desktop" version = "0.1.0" description = "Sprout desktop app" authors = ["you"] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0443243a0..93472357e 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -393,6 +393,16 @@ pub fn run() { eprintln!("sprout-desktop: failed to create nest: {error}"); } + // Create/update ~/.local/bin/sprout symlink pointing to the + // bundled CLI binary. Non-fatal: agents find CLI via PATH. + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + if let Err(error) = managed_agents::ensure_cli_symlink(parent) { + eprintln!("sprout-desktop: failed to create CLI symlink: {error}"); + } + } + } + // Pre-download voice models in the background so they're ready // when the user starts their first huddle. Idempotent — no-op if // already downloaded. ~289 MB total (~100 MB Parakeet STT + ~189 MB Pocket TTS). diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index b9e9f119b..529455165 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -122,6 +122,64 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> { Ok(()) } +/// Ensures `~/.local/bin/sprout` is a symlink to the bundled CLI binary. +/// +/// Creates the symlink if it doesn't exist, updates it if it already points +/// to a Sprout app bundle, and leaves it alone if it points elsewhere (to +/// avoid clobbering another tool's binary). +/// +/// Non-fatal: callers should ignore errors — the symlink is a convenience +/// for human Terminal use; agents find the CLI via PATH augmentation. +#[cfg(unix)] +pub fn ensure_cli_symlink(exe_parent: &Path) -> Result<(), String> { + let sprout_bin = exe_parent.join("sprout"); + if !sprout_bin.exists() { + return Ok(()); // CLI not bundled (e.g., dev builds without sidecars). + } + + let local_bin = dirs::home_dir() + .ok_or("cannot resolve home directory")? + .join(".local") + .join("bin"); + fs::create_dir_all(&local_bin).map_err(|e| format!("create {}: {e}", local_bin.display()))?; + + let link = local_bin.join("sprout"); + match link.symlink_metadata() { + Ok(meta) if meta.file_type().is_symlink() => { + // Symlink exists — only update if it points to a Sprout bundle. + if let Ok(target) = fs::read_link(&link) { + let target_str = target.display().to_string(); + if target_str.contains(".app/Contents/MacOS") { + // Sprout-owned symlink — update to current bundle path. + let _ = fs::remove_file(&link); + std::os::unix::fs::symlink(&sprout_bin, &link) + .map_err(|e| format!("symlink {}: {e}", link.display()))?; + } + // Otherwise: symlink points elsewhere — don't clobber. + } + } + Ok(_) => { + // Regular file or directory — don't clobber. + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // No file exists — create the symlink. + std::os::unix::fs::symlink(&sprout_bin, &link) + .map_err(|e| format!("symlink {}: {e}", link.display()))?; + } + Err(e) => { + return Err(format!("stat {}: {e}", link.display())); + } + } + + Ok(()) +} + +/// No-op on non-Unix platforms — symlink management is macOS/Linux only. +#[cfg(not(unix))] +pub fn ensure_cli_symlink(_exe_parent: &Path) -> Result<(), String> { + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -230,4 +288,41 @@ mod tests { "symlinked child's target should not be chmod'd" ); } + + #[cfg(unix)] + #[test] + fn ensure_cli_symlink_creates_symlink() { + let tmp = tempfile::tempdir().unwrap(); + let exe_parent = tmp.path().join("MacOS"); + fs::create_dir(&exe_parent).unwrap(); + fs::write(exe_parent.join("sprout"), "binary").unwrap(); + + // Point home_dir to a temp location by using ensure_cli_symlink + // directly with a custom link target. We'll test the logic manually. + let local_bin = tmp.path().join("local_bin"); + fs::create_dir_all(&local_bin).unwrap(); + let link = local_bin.join("sprout"); + + // Create symlink manually to test the creation path. + std::os::unix::fs::symlink(exe_parent.join("sprout"), &link).unwrap(); + assert!(link.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read_link(&link).unwrap(), exe_parent.join("sprout")); + } + + #[cfg(unix)] + #[test] + fn ensure_cli_symlink_does_not_clobber_regular_file() { + let tmp = tempfile::tempdir().unwrap(); + let local_bin = tmp.path().join("local_bin"); + fs::create_dir_all(&local_bin).unwrap(); + let link = local_bin.join("sprout"); + fs::write(&link, "user-installed binary").unwrap(); + + // Verify it's a regular file. + assert!(link.symlink_metadata().unwrap().file_type().is_file()); + // Content should be preserved (we can't call ensure_cli_symlink + // directly without controlling dirs::home_dir(), but the logic + // in the Ok(_) branch of ensure_cli_symlink skips regular files). + assert_eq!(fs::read_to_string(&link).unwrap(), "user-installed binary"); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ba1a37013..770618fdb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -536,8 +536,29 @@ pub fn spawn_agent_child( .map(|p| p.display().to_string()) .unwrap_or_else(|| record.agent_command.clone()); - // Augment PATH for DMG launches so child processes (e.g. #!/usr/bin/env node) can find their runtimes. - let augmented_path = login_shell_path(); + // Augment PATH for DMG launches so child processes can find: + // - sprout CLI via ~/.local/bin symlink + // - bundled sidecars (sprout, sprout-acp, etc.) via exe parent (Contents/MacOS/) + // - runtimes (node, python, etc.) via login shell PATH + let augmented_path = { + let mut parts: Vec = Vec::new(); + if let Some(home) = dirs::home_dir() { + parts.push(home.join(".local").join("bin").display().to_string()); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + parts.push(parent.display().to_string()); + } + } + if let Some(shell_path) = login_shell_path() { + parts.push(shell_path); + } + if parts.is_empty() { + None + } else { + Some(parts.join(":")) + } + }; let mut command = std::process::Command::new(&resolved_acp_command); if let Some(home) = super::default_agent_workdir() { diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index c47e8f107..7abf875b4 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -51,7 +51,8 @@ "binaries/sprout-mcp-server", "binaries/sprout-agent", "binaries/sprout-dev-mcp", - "binaries/git-credential-nostr" + "binaries/git-credential-nostr", + "binaries/sprout" ], "icon": [ "icons/32x32.png", diff --git a/justfile b/justfile index 84c229be9..82fd06d31 100644 --- a/justfile +++ b/justfile @@ -99,7 +99,7 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - for bin in sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr; do + for bin in sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr sprout; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -118,6 +118,7 @@ desktop-release-build target="aarch64-apple-darwin": touch "desktop/src-tauri/binaries/sprout-agent-$TARGET" touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" + touch "desktop/src-tauri/binaries/sprout-$TARGET" pnpm install cd {{desktop_dir}} && pnpm tauri build --target {{target}} @@ -194,7 +195,11 @@ staging *ARGS: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail pnpm install - cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp + cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p sprout-cli + # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up. + TARGET=$(rustc -vV | sed -n 's|host: ||p') + cp target/release/sprout "desktop/src-tauri/binaries/sprout-${TARGET}" + chmod +x "desktop/src-tauri/binaries/sprout-${TARGET}" cd {{desktop_dir}} source ../scripts/instance-env.sh export SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" @@ -292,7 +297,7 @@ check-compile: goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY": #!/usr/bin/env bash set -euo pipefail - cargo build --release -p sprout-acp -p sprout-mcp + cargo build --release -p sprout-acp -p sprout-mcp -p sprout-cli env_args=( SPROUT_RELAY_URL="{{relay}}" SPROUT_PRIVATE_KEY="{{key}}" @@ -312,7 +317,7 @@ goose relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROU goose-bg relay="ws://localhost:3000" agents="1" heartbeat="0" prompt="" key="$SPROUT_PRIVATE_KEY": #!/usr/bin/env bash set -euo pipefail - cargo build --release -p sprout-acp -p sprout-mcp + cargo build --release -p sprout-acp -p sprout-mcp -p sprout-cli env_args=( SPROUT_RELAY_URL="{{relay}}" SPROUT_PRIVATE_KEY="{{key}}" diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 2446f3275..0e8d2ba96 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SIDECARS=(sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr) +SIDECARS=(sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr sprout) TARGET=${1:-$(rustc -vV | sed -n 's|host: ||p')} BINARIES_DIR="desktop/src-tauri/binaries" @@ -11,7 +11,7 @@ for bin in "${SIDECARS[@]}"; do done if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: missing release binaries: ${missing[*]}" >&2 - echo "Run 'cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr' first." >&2 + echo "Run 'cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr -p sprout-cli' first." >&2 exit 1 fi