Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
275 changes: 239 additions & 36 deletions Cargo.lock

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,12 @@ copy-binary-windows:
@powershell.exe -Command "if (Test-Path ./target/x86_64-pc-windows-gnu/release/goosed.exe) { \
Write-Host 'Copying Windows binary and DLLs to ui/desktop/src/bin...'; \
Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goosed.exe' -Destination './ui/desktop/src/bin/' -Force; \
if (Test-Path ./target/x86_64-pc-windows-gnu/release/goose.exe) { \
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this is related to this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, missed CoPilot going a little crazy there.

Write-Host 'Copying Windows goose CLI binary...'; \
Copy-Item -Path './target/x86_64-pc-windows-gnu/release/goose.exe' -Destination './ui/desktop/src/bin/' -Force; \
} else { \
Write-Host 'Windows goose CLI binary not found.' -ForegroundColor Yellow; \
} \
Copy-Item -Path './target/x86_64-pc-windows-gnu/release/*.dll' -Destination './ui/desktop/src/bin/' -Force; \
} else { \
Write-Host 'Windows binary not found.' -ForegroundColor Red; \
Expand Down Expand Up @@ -234,6 +240,12 @@ make-ui-windows:
mkdir -p ./ui/desktop/src/bin && \
echo "Copying Windows binary and DLLs..." && \
cp -f ./target/x86_64-pc-windows-gnu/release/goosed.exe ./ui/desktop/src/bin/ && \
if [ -f "./target/x86_64-pc-windows-gnu/release/goose.exe" ]; then \
echo "Copying Windows goose CLI binary..." && \
cp -f ./target/x86_64-pc-windows-gnu/release/goose.exe ./ui/desktop/src/bin/ ; \
else \
echo "Windows goose CLI binary not found." ; \
fi && \
cp -f ./target/x86_64-pc-windows-gnu/release/*.dll ./ui/desktop/src/bin/ && \
echo "Starting Windows package build..." && \
(cd ui/desktop && npm run bundle:windows) && \
Expand Down
39 changes: 31 additions & 8 deletions crates/goose-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,10 @@ description.workspace = true
[lints]
workspace = true

[[bin]]
name = "goose"
path = "src/main.rs"


[dependencies]
goose = { path = "../goose" }
goose = { path = "../goose", features = ["repo-index"] }
goose-bench = { path = "../goose-bench" }
goose-mcp = { path = "../goose-mcp" }
mcp-client = { path = "../mcp-client" }
Expand All @@ -36,6 +34,20 @@ serde = { version = "1.0", features = ["derive"] } # For serialization
serde_yaml = "0.9"
tempfile = "3"
etcetera = "0.8.0"
reqwest = { version = "0.12.9", features = [
"rustls-tls-native-roots",
"json",
"cookies",
"gzip",
"brotli",
"deflate",
"zstd",
"charset",
"http2",
"stream"
], default-features = false }
walkdir = "2.5"
ignore = "0.4"
rand = "0.8.5"
rustyline = "15.0.0"
tracing = "0.1"
Expand All @@ -49,22 +61,33 @@ base64 = "0.22.1"
regex = "1.11.1"
nix = { version = "0.30.1", features = ["process", "signal"] }
tar = "0.4"
# File watching for optional background auto re-index (only activates when env enables)
notify = { version = "6.1", default-features = false, features = ["macos_fsevent", "serde"] }
# Web server dependencies
axum = { version = "0.8.1", features = ["ws", "macros"] }
tower-http = { version = "0.5", features = ["cors", "fs"] }
http = "1.0"
webbrowser = "1.0"


indicatif = "0.17.11"
tokio-util = "0.7.15"
is-terminal = "0.4.16"
anstream = "0.6.18"

[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }


[dev-dependencies]
tempfile = "3"
temp-env = { version = "0.3.6", features = ["async_closure"] }
test-case = "3.3"
tokio = { version = "1.43", features = ["rt", "macros"] }

[features]
# Forward the repo-index feature flag so conditional compilation in CLI works cleanly
repo-index = []

[[bin]]
name = "goose"
path = "src/main.rs"

[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
127 changes: 127 additions & 0 deletions crates/goose-cli/src/background_index.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
use std::{path::{Path, PathBuf}, sync::{Arc, atomic::{AtomicBool, Ordering}}, time::{Duration, Instant}};
use tokio::task::JoinHandle;
use tokio::sync::OnceCell;
use tracing::{info, warn, error, debug, trace, span, Level};
use anyhow::Result;

static STARTED: OnceCell<()> = OnceCell::const_new();

#[derive(Clone, Debug)]
pub struct AutoIndexConfig {
pub root: PathBuf,
pub output: PathBuf,
pub enable_watch: bool,
pub debounce: Duration,
pub initial_delay: Duration,
pub quiet: bool,
}

impl AutoIndexConfig {
pub fn from_env(root: &Path) -> Option<Self> {
if std::env::var("ALPHA_FEATURES").ok().as_deref() != Some("true") { return None; }
// Opt-out variable
if std::env::var("GOOSE_AUTO_INDEX").map(|v| v=="0" || v.to_lowercase()=="false").unwrap_or(false) { return None; }
let enable_watch = std::env::var("GOOSE_AUTO_INDEX_WATCH").map(|v| v=="1" || v.to_lowercase()=="true").unwrap_or(false);
let debounce_ms = std::env::var("GOOSE_AUTO_INDEX_DEBOUNCE_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500u64);
let initial_delay_ms = std::env::var("GOOSE_AUTO_INDEX_INITIAL_DELAY_MS").ok().and_then(|v| v.parse().ok()).unwrap_or(1500u64);
Some(Self { root: root.to_path_buf(), output: root.join(".goose-repo-index.jsonl"), enable_watch, debounce: Duration::from_millis(debounce_ms), initial_delay: Duration::from_millis(initial_delay_ms), quiet: false })
}
}

fn should_skip_repo(root: &Path) -> bool {
// Heuristic: skip if no "src" or "lib" or very small (# source files < 5) to save cycles
let mut source_like = 0usize;
if let Ok(read) = std::fs::read_dir(root) {
for entry in read.flatten().take(200) { // cheap scan
let p = entry.path();
if p.is_dir() { continue; }
if let Some(ext) = p.extension().and_then(|e| e.to_str()) {
match ext { "rs"|"py"|"js"|"ts"|"go"|"java"|"cs"|"cpp"|"cxx"|"cc"|"swift" => { source_like += 1; if source_like >= 5 { break; } }, _=>{} }
}
}
}
source_like < 5
}

pub async fn spawn_background_index() -> Option<JoinHandle<()>> {
// Only run once per process
if STARTED.set(()).is_err() { return None; }
let root = match std::env::current_dir() { Ok(r) => r, Err(_) => return None };
let cfg = match AutoIndexConfig::from_env(&root) { Some(c) => c, None => return None };
if should_skip_repo(&cfg.root) { debug!(?cfg.root, "auto-index skipped: too few source files"); return None; }
let lock_path = cfg.root.join(".goose-repo-index.lock");
if lock_path.exists() { debug!(?lock_path, "auto-index lock exists, skipping"); return None; }
if std::fs::write(&lock_path, b"indexing") .is_err() { return None; }
info!(root=%cfg.root.display(), watch=%cfg.enable_watch, "Starting background repo index (initial delay {:?})", cfg.initial_delay);

Some(tokio::spawn(async move {
// Initial delay to avoid impacting startup latency
tokio::time::sleep(cfg.initial_delay).await;
if let Err(e) = run_index_once(&cfg).await { error!(error=?e, "background index failed"); }
else { info!(root=%cfg.root.display(), "Background index complete"); }
if cfg.enable_watch { if let Err(e) = watch_loop(&cfg).await { warn!(error=?e, "auto-index watch loop terminated"); } }
let _ = std::fs::remove_file(&lock_path);
}))
}

async fn run_index_once(cfg: &AutoIndexConfig) -> Result<()> {
use goose::repo_index::RepoIndexOptions;
use std::fs::File;
use std::time::{SystemTime, UNIX_EPOCH};
let mut builder = RepoIndexOptions::builder().root(&cfg.root);
let write_file = std::env::var("GOOSE_AUTO_INDEX_WRITE_FILE").map(|v| v=="1" || v.to_lowercase()=="true").unwrap_or(false);
let mut maybe_file;
if write_file { maybe_file = Some(File::create(&cfg.output)?); builder = builder.output_writer(maybe_file.as_mut().unwrap()); }
else { builder = builder.output_null(); maybe_file = None; }
let opts = builder.build();
let build_start = std::time::Instant::now();
let stats = goose::repo_index::index_repository(opts)?;
let elapsed = build_start.elapsed();
let duration_ms = stats.duration.as_millis();
if !cfg.quiet { if write_file { eprintln!("(background) indexed {} files / {} entities (wrote {})", stats.files_indexed, stats.entities_indexed, cfg.output.display()); } else { eprintln!("(background) indexed {} files / {} entities (no file output)", stats.files_indexed, stats.entities_indexed); } }
info!(counter.goose.repo.builds = 1, event="repo.index.build", root=%cfg.root.display(), background=true, files=stats.files_indexed, entities=stats.entities_indexed, duration_ms=elapsed.as_millis() as u64, wrote_file=write_file, trigger="background", "Background repository index build complete");
// Write meta file for status command consumers
let meta_path = cfg.root.join(".goose-repo-index.meta.json");
let meta = serde_json::json!({
"files_indexed": stats.files_indexed,
"entities_indexed": stats.entities_indexed,
"duration_ms": duration_ms,
"wrote_file": write_file,
"output_file": if write_file { Some(cfg.output.file_name().unwrap().to_string_lossy().to_string()) } else { None },
"timestamp": SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
});
if let Err(e) = std::fs::write(&meta_path, serde_json::to_vec_pretty(&meta)?) { warn!(error=?e, "failed to write meta file"); }
Ok(())
}

async fn watch_loop(cfg: &AutoIndexConfig) -> Result<()> {
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::channel(64);
let root = cfg.root.clone();
let debounce = cfg.debounce;
// Wrap notify watcher in blocking task -> channel
let _blocking = std::thread::spawn(move || {
let tx2 = tx.clone();
let mut watcher: RecommendedWatcher = RecommendedWatcher::new(|res| {
if let Ok(event) = res { let _ = tx2.blocking_send(event); }
}, notify::Config::default()).expect("watcher");
let _ = watcher.watch(&root, RecursiveMode::Recursive);
// park thread until process exit
loop { std::thread::park(); }
});

let mut last_change: Option<Instant> = None;
let mut pending = false;
let mut ticker = tokio::time::interval(Duration::from_millis(500));
loop {
tokio::select! {
maybe_evt = rx.recv() => {
if let Some(evt) = maybe_evt { match evt.kind { EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_) => { last_change = Some(Instant::now()); pending = true; }, _=>{} } }
}
_ = ticker.tick() => {
if pending { if let Some(ts) = last_change { if ts.elapsed() >= debounce { pending = false; if let Err(e) = run_index_once(cfg).await { warn!(error=?e, "background re-index failed"); } else { info!(counter.goose.repo.builds = 1, event="repo.index.build", root=%cfg.root.display(), background=true, trigger="watch", reason="watch", "Re-index complete (watch)"); } } } }
}
}
}
}
Loading