Skip to content
Open
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ cache the same queries run ~4.7× faster (e.g. `length` 966 ms →
- The emscripten (playground) build keeps the sequential engine —
no threads there.

## `toolpath-gemini`: the caller supplies the home directory — 2026-08-13

- **`toolpath-gemini`** (0.7.0): breaking. `PathResolver::new(home)`
takes the home directory as a required argument. The crate reads no
environment variable; it keeps the layout knowledge (`<home>/.gemini`)
and the caller owns "what is home". `GeminiConvo::new(home)` and
`ConvoIO::new(home)` take the same argument.

Removed: the `Default` impls on `PathResolver`, `ConvoIO`, and
`GeminiConvo`; `PathResolver::with_home`; the `NoHomeDirectory` error
variant. `with_gemini_dir` stays as the full override.

The home directory is always present, so `home_dir()`, `gemini_dir()`,
`projects_file()`, `tmp_dir()`, and `ConvoIO::gemini_dir_path()`
return a path instead of a `Result`.
- **`path-cli`** (unreleased): `providers::gemini_resolver` returns
`Option<PathResolver>`. `None` means the configuration carries no home
directory, so Gemini is out of reach: the harness bundle omits it, and
a command that targets Gemini reports "cannot determine the home
directory".

## Configured share remotes for `path share` — 2026-08-12

- **`path-cli`** (0.17.0): `path share` now resolves a default share
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ toolpath = { version = "0.7.0", path = "crates/toolpath" }
toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" }
toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" }
toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false }
toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false }
toolpath-gemini = { version = "0.7.0", path = "crates/toolpath-gemini", default-features = false }
toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" }
toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" }
toolpath-opencode = { version = "0.5.0", path = "crates/toolpath-opencode" }
Expand Down
150 changes: 71 additions & 79 deletions crates/path-cli/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use anyhow::{Context, Result, anyhow, bail};
use std::path::PathBuf;
use toolpath::v1::Graph;

use crate::config::config_dir;
use crate::config::Config;

/// An entry surfaced by `list_cached`.
#[derive(Debug, Clone)]
Expand All @@ -21,16 +21,16 @@ pub(crate) struct CacheEntry {
}

/// The cache directory: `$CONFIG_DIR/documents/`.
pub(crate) fn cache_dir() -> Result<PathBuf> {
Ok(config_dir()?.join(crate::config::DOCUMENTS_DIR_NAME))
pub(crate) fn cache_dir(config: &Config) -> Result<PathBuf> {
Ok(config.config_dir()?.join(crate::config::DOCUMENTS_DIR_NAME))
}

/// Path for a given cache id (does not check existence).
pub(crate) fn cache_path(id: &str) -> Result<PathBuf> {
pub(crate) fn cache_path(config: &Config, id: &str) -> Result<PathBuf> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.ends_with(".json") {
bail!("invalid cache id: {id:?}");
}
Ok(cache_dir()?.join(format!("{id}.json")))
Ok(cache_dir(config)?.join(format!("{id}.json")))
}

/// Write a toolpath document to the cache under `id`. Errors if the
Expand All @@ -39,18 +39,18 @@ pub(crate) fn cache_path(id: &str) -> Result<PathBuf> {
/// Uses `O_CREAT | O_EXCL` (`create_new`) when `force == false` so the
/// exists-check and the write are atomic — two concurrent `path import`
/// invocations racing the same id can't silently stomp each other.
pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result<PathBuf> {
pub(crate) fn write_cached(config: &Config, id: &str, doc: &Graph, force: bool) -> Result<PathBuf> {
use std::io::Write;

let dir = cache_dir()?;
let dir = cache_dir(config)?;
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}

let path = cache_path(id)?;
let path = cache_path(config, id)?;
let json = doc.to_json_pretty()?;

let mut opts = std::fs::OpenOptions::new();
Expand Down Expand Up @@ -87,7 +87,7 @@ pub(crate) fn write_cached(id: &str, doc: &Graph, force: bool) -> Result<PathBuf
/// Resolve a `<ref>` string to a filesystem path. A ref is either a
/// bare cache id (looks up `$CACHE_DIR/<ref>.json`) or a file path
/// (contains `/` or `\\`, or ends with `.json`).
pub(crate) fn cache_ref(s: &str) -> Result<PathBuf> {
pub(crate) fn cache_ref(config: &Config, s: &str) -> Result<PathBuf> {
if s.contains('/') || s.contains('\\') || s.ends_with(".json") {
let p = PathBuf::from(s);
if !p.exists() {
Expand All @@ -98,7 +98,7 @@ pub(crate) fn cache_ref(s: &str) -> Result<PathBuf> {
}
return Ok(p);
}
let p = cache_path(s)?;
let p = cache_path(config, s)?;
if !p.exists() {
bail!(
"cache entry {s} not found at {}; run `path cache ls` to see what's cached",
Expand All @@ -108,8 +108,8 @@ pub(crate) fn cache_ref(s: &str) -> Result<PathBuf> {
Ok(p)
}

pub(crate) fn list_cached() -> Result<Vec<CacheEntry>> {
let dir = cache_dir()?;
pub(crate) fn list_cached(config: &Config) -> Result<Vec<CacheEntry>> {
let dir = cache_dir(config)?;
if !dir.exists() {
return Ok(Vec::new());
}
Expand All @@ -136,8 +136,8 @@ pub(crate) fn list_cached() -> Result<Vec<CacheEntry>> {
Ok(out)
}

pub(crate) fn remove_cached(id: &str) -> Result<()> {
let path = cache_path(id)?;
pub(crate) fn remove_cached(config: &Config, id: &str) -> Result<()> {
let path = cache_path(config, id)?;
if !path.exists() {
return Err(anyhow!("cache entry {id} not found"));
}
Expand Down Expand Up @@ -173,19 +173,16 @@ pub(crate) fn pathbase_cache_id(owner: &str, repo: &str, id: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK};

fn with_cfg<F: FnOnce(&std::path::Path) -> R, R>(f: F) -> R {
/// A `Config` whose cache lands in a fresh tempdir. The `TempDir`
/// is returned with it: dropping it removes the directory.
fn config_in_tempdir() -> (Config, tempfile::TempDir) {
let temp = tempfile::tempdir().unwrap();
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var(CONFIG_DIR_ENV, temp.path());
}
let result = f(temp.path());
unsafe {
std::env::remove_var(CONFIG_DIR_ENV);
}
result
let config = Config {
toolpath_config_dir: Some(temp.path().to_path_buf()),
..Config::default()
};
(config, temp)
}

fn sample_doc() -> Graph {
Expand All @@ -194,100 +191,94 @@ mod tests {

#[test]
fn write_and_read_cache_entry() {
with_cfg(|_| {
let doc = sample_doc();
let p = write_cached("claude-abc", &doc, false).unwrap();
assert!(p.exists());
assert_eq!(p.file_name().unwrap(), "claude-abc.json");
});
let (config, _temp) = config_in_tempdir();
let doc = sample_doc();
let p = write_cached(&config, "claude-abc", &doc, false).unwrap();
assert!(p.exists());
assert_eq!(p.file_name().unwrap(), "claude-abc.json");
}

#[test]
fn write_errors_if_exists_without_force() {
with_cfg(|_| {
let doc = sample_doc();
write_cached("claude-abc", &doc, false).unwrap();
let err = write_cached("claude-abc", &doc, false).unwrap_err();
assert!(err.to_string().contains("already exists"));
});
let (config, _temp) = config_in_tempdir();
let doc = sample_doc();
write_cached(&config, "claude-abc", &doc, false).unwrap();
let err = write_cached(&config, "claude-abc", &doc, false).unwrap_err();
assert!(err.to_string().contains("already exists"));
}

#[test]
fn write_force_overwrites() {
with_cfg(|_| {
let doc = sample_doc();
write_cached("claude-abc", &doc, false).unwrap();
write_cached("claude-abc", &doc, true).unwrap();
});
let (config, _temp) = config_in_tempdir();
let doc = sample_doc();
write_cached(&config, "claude-abc", &doc, false).unwrap();
write_cached(&config, "claude-abc", &doc, true).unwrap();
}

#[test]
fn cache_ref_finds_existing_cache_entry() {
with_cfg(|_| {
let doc = sample_doc();
let p = write_cached("claude-abc", &doc, false).unwrap();
let resolved = cache_ref("claude-abc").unwrap();
assert_eq!(resolved, p);
});
let (config, _temp) = config_in_tempdir();
let doc = sample_doc();
let p = write_cached(&config, "claude-abc", &doc, false).unwrap();
let resolved = cache_ref(&config, "claude-abc").unwrap();
assert_eq!(resolved, p);
}

#[test]
fn cache_ref_returns_file_path_unchanged() {
let (config, _temp) = config_in_tempdir();
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "{}").unwrap();
let resolved = cache_ref(tmp.path().to_str().unwrap()).unwrap();
let resolved = cache_ref(&config, tmp.path().to_str().unwrap()).unwrap();
assert_eq!(resolved, tmp.path());
}

#[test]
fn cache_ref_errors_on_missing_id() {
with_cfg(|_| {
let err = cache_ref("does-not-exist").unwrap_err();
assert!(err.to_string().contains("not found"));
});
let (config, _temp) = config_in_tempdir();
let err = cache_ref(&config, "does-not-exist").unwrap_err();
assert!(err.to_string().contains("not found"));
}

#[test]
fn cache_path_rejects_slashes_and_json_suffix() {
assert!(cache_path("foo/bar").is_err());
assert!(cache_path("foo.json").is_err());
assert!(cache_path("").is_err());
let (config, _temp) = config_in_tempdir();
assert!(cache_path(&config, "foo/bar").is_err());
assert!(cache_path(&config, "foo.json").is_err());
assert!(cache_path(&config, "").is_err());
}

#[test]
fn list_empty_when_dir_missing() {
with_cfg(|_| {
assert!(list_cached().unwrap().is_empty());
});
let (config, _temp) = config_in_tempdir();
assert!(list_cached(&config).unwrap().is_empty());
}

#[test]
fn list_and_remove_roundtrip() {
with_cfg(|_| {
let doc = sample_doc();
write_cached("a", &doc, false).unwrap();
write_cached("b", &doc, false).unwrap();
let entries = list_cached().unwrap();
assert_eq!(entries.len(), 2);

remove_cached("a").unwrap();
let entries = list_cached().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "b");

assert!(remove_cached("a").is_err());
});
let (config, _temp) = config_in_tempdir();
let doc = sample_doc();
write_cached(&config, "a", &doc, false).unwrap();
write_cached(&config, "b", &doc, false).unwrap();
let entries = list_cached(&config).unwrap();
assert_eq!(entries.len(), 2);

remove_cached(&config, "a").unwrap();
let entries = list_cached(&config).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "b");

assert!(remove_cached(&config, "a").is_err());
}

#[cfg(unix)]
#[test]
fn writes_file_with_0600() {
use std::os::unix::fs::PermissionsExt;
with_cfg(|_| {
let p = write_cached("claude-abc", &sample_doc(), false).unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
});
let (config, _temp) = config_in_tempdir();
let p = write_cached(&config, "claude-abc", &sample_doc(), false).unwrap();
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}

#[test]
Expand All @@ -306,7 +297,8 @@ mod tests {
#[test]
fn make_id_result_survives_cache_path() {
// Regression: make_id output must be accepted by cache_path.
let (config, _temp) = config_in_tempdir();
let id = make_id("pathbase", "trc_01H.json");
assert!(cache_path(&id).is_ok());
assert!(cache_path(&config, &id).is_ok());
}
}
16 changes: 11 additions & 5 deletions crates/path-cli/src/cmd_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::cmd_pathbase::{
StoredSession, api_logout, api_me, api_redeem, clear_session, credentials_path, load_session,
prompt_line, resolve_url, store_session,
};
use crate::config::Config;

#[derive(Subcommand, Debug)]
pub enum AuthOp {
Expand All @@ -27,18 +28,23 @@ pub enum AuthOp {
Whoami,
}

pub fn run(op: AuthOp) -> Result<()> {
let path = credentials_path()?;
pub fn run(op: AuthOp, config: &Config) -> Result<()> {
let path = credentials_path(config)?;
match op {
AuthOp::Login { url, code } => login(&path, url, code),
AuthOp::Login { url, code } => login(config, &path, url, code),
AuthOp::Logout => logout(&path),
AuthOp::Status => status(&path),
AuthOp::Whoami => whoami(&path),
}
}

fn login(path: &Path, url: Option<String>, code_arg: Option<String>) -> Result<()> {
let base_url = resolve_url(url);
fn login(
config: &Config,
path: &Path,
url: Option<String>,
code_arg: Option<String>,
) -> Result<()> {
let base_url = resolve_url(config, url);
let auth_url = format!("{base_url}/auth/cli");

let code = match code_arg {
Expand Down
Loading
Loading