use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use crate::Client;
use crate::context::ContextReader;
use crate::context::claude::ClaudeReader;
use crate::context::codex::CodexReader;
use crate::context::crush::CrushReader;
use crate::context::gemini::GeminiReader;
use crate::context::goose::GooseReader;
use crate::context::jsonl::JsonlReader;
use crate::context::opencode::OpenCodeReader;
use crate::message::{ContextListing, TaggedListing};
#[must_use]
pub fn list_all_contexts() -> Vec<TaggedListing> {
let mut out = Vec::new();
for client in Client::ALL {
let provider = client.as_str();
if let Ok(listings) = list_provider_contexts(client) {
for listing in listings {
out.push(TaggedListing { provider, listing });
}
}
}
out
}
#[must_use]
pub fn filter_listings(listings: Vec<TaggedListing>, glob: &str) -> Vec<TaggedListing> {
listings
.into_iter()
.filter(|t| {
let tag = format!("{}:{}", t.provider, t.listing.id);
crate::text::glob_search(glob, &tag)
})
.collect()
}
fn data_dir_override() -> PathBuf {
if let Ok(dir) = std::env::var("GOOSEDUMP_DATA_DIR") {
let path = PathBuf::from(&dir);
if path.is_dir() {
return path;
}
}
dirs::data_dir().unwrap_or_else(|| PathBuf::from("."))
}
pub fn list_provider_contexts(client: Client) -> anyhow::Result<Vec<ContextListing>> {
match client {
Client::Claude => {
let files = find_jsonl_files(&resolve_claude_sessions_dir()?)?;
Ok(list_file_based(&files, |p| Box::new(ClaudeReader::new(p))))
}
Client::Codex => {
let files = find_jsonl_files(&resolve_codex_sessions_dir()?)?;
Ok(list_file_based(&files, |p| Box::new(CodexReader::new(p))))
}
Client::Opencode => OpenCodeReader::new(resolve_opencode_db()?).list_contexts(),
Client::Crush => CrushReader::new(resolve_crush_db()?).list_contexts(),
Client::Pi => {
let files = find_jsonl_files(&resolve_pi_sessions_dir()?)?;
Ok(list_file_based(&files, |p| Box::new(JsonlReader::new(p))))
}
Client::Gemini => {
let files = find_gemini_chats(&resolve_gemini_tmp_dir()?)?;
Ok(list_file_based(&files, |p| Box::new(GeminiReader::new(p))))
}
Client::Goose => GooseReader::new(resolve_goose_db()?).list_contexts(),
}
}
pub fn open_context(client: Client, context_id: &str) -> anyhow::Result<Box<dyn ContextReader>> {
match client {
Client::Claude => {
let files = find_jsonl_files(&resolve_claude_sessions_dir()?)?;
open_file_based(&files, context_id, |p| Box::new(ClaudeReader::new(p)))
}
Client::Codex => {
let files = find_jsonl_files(&resolve_codex_sessions_dir()?)?;
open_file_based(&files, context_id, |p| Box::new(CodexReader::new(p)))
}
Client::Opencode => Ok(Box::new(OpenCodeReader::new(resolve_opencode_db()?))),
Client::Crush => Ok(Box::new(CrushReader::new(resolve_crush_db()?))),
Client::Pi => {
let files = find_jsonl_files(&resolve_pi_sessions_dir()?)?;
open_file_based(&files, context_id, |p| Box::new(JsonlReader::new(p)))
}
Client::Gemini => {
let files = find_gemini_chats(&resolve_gemini_tmp_dir()?)?;
open_file_based(&files, context_id, |p| Box::new(GeminiReader::new(p)))
}
Client::Goose => Ok(Box::new(GooseReader::new(resolve_goose_db()?))),
}
}
pub fn delete_context(client: Client, context_id: &str) -> anyhow::Result<()> {
open_context(client, context_id)?.delete_context(context_id)
}
fn resolve_opencode_db() -> anyhow::Result<PathBuf> {
let data_dir = data_dir_override();
let db = data_dir.join("opencode").join("opencode.db");
if db.exists() {
Ok(db)
} else {
Err(anyhow::anyhow!("opencode.db not found at {}", db.display()))
}
}
fn resolve_goose_db() -> anyhow::Result<PathBuf> {
let data_dir = data_dir_override();
let db = data_dir.join("goose").join("sessions").join("sessions.db");
if db.exists() {
Ok(db)
} else {
Err(anyhow::anyhow!(
"goose sessions.db not found at {}",
db.display()
))
}
}
fn resolve_crush_db() -> anyhow::Result<PathBuf> {
let cwd = std::env::current_dir().context("cwd")?;
if let Some(config_path) = find_crush_config(&cwd) {
let config: serde_json::Value = {
let contents = fs::read_to_string(&config_path)
.with_context(|| format!("read {}", config_path.display()))?;
serde_json::from_str(&contents)?
};
let data_dir = config["options"]["data_directory"]
.as_str()
.or_else(|| config["data_directory"].as_str());
if let Some(dir) = data_dir {
let db = config_path
.parent()
.unwrap_or(Path::new("."))
.join(dir)
.join("crush.db");
if db.exists() {
return Ok(db);
}
}
}
Err(anyhow::anyhow!("crush.db not found"))
}
fn find_crush_config(cwd: &Path) -> Option<PathBuf> {
let mut current = Some(cwd.to_path_buf());
while let Some(dir) = current {
if let Some(path) = [".crush.json", "crush.json"]
.into_iter()
.map(|name| dir.join(name))
.find(|path| path.exists())
{
return Some(path);
}
current = dir.parent().map(std::path::Path::to_path_buf);
}
None
}
fn list_file_based(
files: &[PathBuf],
make_reader: impl Fn(PathBuf) -> Box<dyn ContextReader>,
) -> Vec<ContextListing> {
let mut listings = Vec::new();
for file in files {
if let Ok(mut l) = make_reader(file.clone()).list_contexts() {
listings.append(&mut l);
}
}
listings.sort_by(|a, b| b.detail.cmp(&a.detail));
listings
}
fn open_file_based(
files: &[PathBuf],
context_id: &str,
make_reader: impl Fn(PathBuf) -> Box<dyn ContextReader>,
) -> anyhow::Result<Box<dyn ContextReader>> {
if files.is_empty() {
return Err(anyhow::anyhow!("context '{context_id}' not found"));
}
let file_path = resolve_jsonl_file(files, context_id, &make_reader)?;
Ok(make_reader(file_path))
}
fn resolve_claude_sessions_dir() -> anyhow::Result<PathBuf> {
let config_dir = std::env::var("CLAUDE_CONFIG_DIR").map_or_else(
|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude")
},
PathBuf::from,
);
let projects = config_dir.join("projects");
if projects.is_dir() {
return Ok(projects);
}
Err(anyhow::anyhow!(
"claude projects directory not found at {}",
projects.display()
))
}
fn resolve_gemini_tmp_dir() -> anyhow::Result<PathBuf> {
let home = std::env::var("GEMINI_DIR").map_or_else(
|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".gemini")
},
PathBuf::from,
);
let tmp = home.join("tmp");
if tmp.is_dir() {
return Ok(tmp);
}
Err(anyhow::anyhow!(
"gemini tmp directory not found at {}",
tmp.display()
))
}
fn find_gemini_chats(tmp_dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut files = Vec::new();
let entries =
fs::read_dir(tmp_dir).with_context(|| format!("read dir {}", tmp_dir.display()))?;
for entry in entries {
let entry = entry?;
let chats = entry.path().join("chats");
if chats.is_dir() {
collect_json_files(&chats, &mut files)
.with_context(|| format!("read dir {}", chats.display()))?;
}
}
files.sort();
Ok(files)
}
fn collect_json_files(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_json_files(&path, files)?;
} else if path.extension() == Some(OsStr::new("json")) {
files.push(path);
}
}
Ok(())
}
fn resolve_codex_sessions_dir() -> anyhow::Result<PathBuf> {
if let Ok(dir) = std::env::var("CODEX_HOME") {
let sessions = PathBuf::from(&dir).join("sessions");
if sessions.is_dir() {
return Ok(sessions);
}
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
let sessions = home.join(".codex").join("sessions");
if sessions.is_dir() {
return Ok(sessions);
}
Err(anyhow::anyhow!("codex sessions directory not found"))
}
fn resolve_pi_sessions_dir() -> anyhow::Result<PathBuf> {
if let Ok(dir) = std::env::var("PI_CODING_AGENT_SESSION_DIR") {
let path = PathBuf::from(&dir);
if path.is_dir() {
return Ok(path);
}
}
let agent_dir = std::env::var("PI_CODING_AGENT_DIR").unwrap_or_else(|_| {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".pi/agent").display().to_string()
});
let sessions = PathBuf::from(&agent_dir).join("sessions");
if sessions.is_dir() {
return Ok(sessions);
}
Err(anyhow::anyhow!("pi sessions directory not found"))
}
fn find_jsonl_files(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_jsonl_files(dir, &mut files).with_context(|| format!("read dir {}", dir.display()))?;
files.sort();
Ok(files)
}
fn collect_jsonl_files(dir: &Path, files: &mut Vec<PathBuf>) -> std::io::Result<()> {
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_jsonl_files(&path, files)?;
} else if path.extension() == Some(OsStr::new("jsonl")) {
files.push(path);
}
}
Ok(())
}
fn resolve_jsonl_file(
files: &[PathBuf],
context_id: &str,
make_reader: impl Fn(PathBuf) -> Box<dyn ContextReader>,
) -> anyhow::Result<PathBuf> {
for file in files {
let reader = make_reader(file.clone());
if let Ok(listings) = reader.list_contexts() {
if listings.iter().any(|l| l.id == context_id) {
return Ok(file.clone());
}
}
}
let prefix_matches: Vec<&PathBuf> = files
.iter()
.filter(|f| {
let reader = make_reader((*f).clone());
reader
.list_contexts()
.is_ok_and(|listings| listings.iter().any(|l| l.id.starts_with(context_id)))
})
.collect();
match prefix_matches.len() {
0 => Err(anyhow::anyhow!("context '{context_id}' not found")),
1 => Ok(prefix_matches[0].clone()),
_ => Err(anyhow::anyhow!("ambiguous context id '{context_id}'")),
}
}