#![deny(clippy::all)]
#![deny(clippy::pedantic)]
mod compact;
mod context;
mod display;
mod emit;
mod format;
mod import;
mod index;
mod message;
mod model;
mod resolver;
mod search;
mod text;
use argh::FromArgs;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::{env, process, str::FromStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Client {
Claude,
Codex,
Crush,
Gemini,
Goose,
Opencode,
Pi,
}
impl Client {
pub const ALL: [Self; 7] = [
Self::Claude,
Self::Codex,
Self::Crush,
Self::Gemini,
Self::Goose,
Self::Opencode,
Self::Pi,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
Self::Crush => "crush",
Self::Gemini => "gemini",
Self::Goose => "goose",
Self::Opencode => "opencode",
Self::Pi => "pi",
}
}
}
impl FromStr for Client {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::ALL
.into_iter()
.find(|client| client.as_str() == value)
.ok_or_else(|| {
format!(
"provider must be one of: {}",
Self::ALL.map(Self::as_str).join(", ")
)
})
}
}
#[derive(Clone, Copy)]
enum Style {
Json,
Plain,
}
impl FromStr for Style {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"json" => Ok(Self::Json),
"plain" => Ok(Self::Plain),
_ => Err("--format must be one of: json, plain".to_string()),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Lineage,
All,
}
impl FromStr for Scope {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"lineage" => Ok(Self::Lineage),
"all" => Ok(Self::All),
_ => Err("--scope must be lineage or all"),
}
}
}
struct Target {
provider: Option<Client>,
id: String,
}
impl FromStr for Target {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.is_empty() {
return Err("target id must not be empty".to_string());
}
let (provider, id) = match value.split_once(':') {
Some((prefix, rest)) if !rest.is_empty() => match Client::from_str(prefix) {
Ok(client) => (Some(client), rest),
Err(_) => (None, value),
},
_ => (None, value),
};
Ok(Self {
provider,
id: id.to_string(),
})
}
}
#[derive(FromArgs)]
#[argh(description = "coding agent context data browser")]
struct GoosedumpArgs {
#[argh(subcommand)]
command: GoosedumpCommand,
}
#[derive(FromArgs)]
#[argh(subcommand)]
enum GoosedumpCommand {
List(ListArgs),
Show(ShowArgs),
Compact(CompactArgs),
Grep(GrepArgs),
Search(SearchArgs),
Delete(DeleteArgs),
Import(ImportArgs),
Pull(PullArgs),
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "pull",
description = "download compaction models"
)]
struct PullArgs {}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "list",
description = "list contexts by id glob"
)]
struct ListArgs {
#[argh(
positional,
description = "context id/glob, e.g. claude:sesh-*"
)]
filter: Option<String>,
#[argh(
option,
description = "filter by working dir glob, e.g. \"$PWD\""
)]
path: Option<String>,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "delete",
description = "delete contexts by id glob"
)]
struct DeleteArgs {
#[argh(
positional,
description = "context id/glob, e.g. claude:sesh-*"
)]
pattern: String,
#[argh(
option,
description = "filter by working dir glob, e.g. \"$PWD\""
)]
path: Option<String>,
#[argh(switch, description = "list matches without deleting")]
dry_run: bool,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "import",
description = "import contexts into a provider by id glob"
)]
struct ImportArgs {
#[argh(positional, description = "destination provider")]
provider: Client,
#[argh(
positional,
description = "context id/glob, e.g. claude:sesh-*"
)]
pattern: String,
#[argh(
option,
description = "filter by working dir glob, e.g. \"$PWD\""
)]
path: Option<String>,
#[argh(switch, description = "list matches without importing")]
dry_run: bool,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "show", description = "show a context transcript")]
struct ShowArgs {
#[argh(
positional,
description = "provider-qualified context id, e.g. claude:sesh-1"
)]
target: Target,
#[argh(option, short = 'm', description = "filter to entry ID (repeatable)")]
message_id: Vec<String>,
#[argh(
option,
default = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
#[argh(
option,
description = "render in this provider's shape (default: source provider)"
)]
as_provider: Option<Client>,
#[argh(option, description = "filter to entries from entry ID onward")]
r#from: Option<String>,
#[argh(option, description = "filter to entries before entry ID")]
until: Option<String>,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "compact",
description = "emit a compaction summary"
)]
struct CompactArgs {
#[argh(
positional,
description = "provider-qualified context id, e.g. claude:sesh-1"
)]
target: Target,
#[argh(option, short = 'm', description = "filter to entry ID (repeatable)")]
message_id: Vec<String>,
#[argh(option, description = "filter to entries from entry ID onward")]
r#from: Option<String>,
#[argh(option, description = "filter to entries before entry ID")]
until: Option<String>,
#[argh(
option,
default = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
#[argh(
option,
description = "render in this provider's shape (default: source provider)"
)]
as_provider: Option<Client>,
#[argh(
option,
default = "Style::Json",
description = "output format: json (default) or plain"
)]
format: Style,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "grep",
description = "filter messages by glob"
)]
struct GrepArgs {
#[argh(
positional,
description = "provider-qualified context id, e.g. claude:sesh-1"
)]
target: Target,
#[argh(positional, description = "glob pattern")]
pattern: String,
#[argh(option, short = 'm', description = "filter to entry ID (repeatable)")]
message_id: Vec<String>,
#[argh(
option,
default = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
#[argh(
option,
description = "render in this provider's shape (default: source provider)"
)]
as_provider: Option<Client>,
#[argh(option, description = "filter to entries from entry ID onward")]
r#from: Option<String>,
#[argh(option, description = "filter to entries before entry ID")]
until: Option<String>,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "search",
description = "rank messages by query"
)]
struct SearchArgs {
#[argh(
positional,
description = "provider-qualified context id, e.g. claude:sesh-1"
)]
target: Target,
#[argh(positional, description = "search query")]
query: String,
#[argh(option, short = 'm', description = "filter to entry ID (repeatable)")]
message_id: Vec<String>,
#[argh(
option,
default = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
#[argh(
option,
short = 'p',
description = "page ranked results (5 hits per page)"
)]
page: Option<usize>,
#[argh(
option,
description = "render in this provider's shape (default: source provider)"
)]
as_provider: Option<Client>,
#[argh(option, description = "filter to entries from entry ID onward")]
r#from: Option<String>,
#[argh(option, description = "filter to entries before entry ID")]
until: Option<String>,
}
const DEFAULT_PAGE_SIZE: usize = 5;
type CommandResult = Result<(), CommandError>;
enum CommandError {
Usage(&'static str),
Runtime(String),
}
impl CommandError {
const fn exit_code(&self) -> i32 {
match self {
Self::Usage(_) => 2,
Self::Runtime(_) => 1,
}
}
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Usage(msg) => f.write_str(msg),
Self::Runtime(msg) => f.write_str(msg),
}
}
}
impl From<&'static str> for CommandError {
fn from(value: &'static str) -> Self {
Self::Usage(value)
}
}
fn no_match_message(pattern: &str, path: Option<&str>) -> String {
match path {
Some(path) => format!("goosedump: no contexts match '{pattern}' (path: {path})"),
None => format!("goosedump: no contexts match '{pattern}'"),
}
}
fn validate_page(page: Option<usize>) -> CommandResult {
if page.is_some_and(|page| page == 0) {
return Err(CommandError::Usage(
"goosedump: --page must be a positive integer",
));
}
Ok(())
}
fn print_native(text: &str) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
if let Err(e) = write!(out, "{text}") {
if e.kind() == std::io::ErrorKind::BrokenPipe {
process::exit(0);
}
process::exit(1);
}
}
fn resolve_target(
index: &index::Index,
target: &Target,
) -> Result<index::IndexEntry, CommandError> {
let provider = target.provider.ok_or_else(|| {
CommandError::Runtime(format!(
"goosedump: target '{}' must be qualified as provider:id",
target.id
))
})?;
index
.lookup_entry(provider, &target.id)
.cloned()
.ok_or_else(|| {
CommandError::Runtime(format!(
"goosedump: context '{}:{}' not found",
provider.as_str(),
target.id
))
})
}
fn load_context(
entry: &index::IndexEntry,
scope: Scope,
message_ids: &[String],
) -> anyhow::Result<message::Context> {
let reader = resolver::open_indexed_context(entry);
let mut ctx = reader.read_context(&entry.id)?;
if scope == Scope::Lineage {
let lineage_ids = context::active_lineage_ids(&ctx.entries);
ctx.messages = context::filter_messages(ctx.messages, &lineage_ids);
}
if !message_ids.is_empty() {
ctx.messages = context::filter_messages(ctx.messages, message_ids);
}
Ok(ctx)
}
fn load_context_for_command(
target: &Target,
scope: Scope,
message_ids: &[String],
from: Option<&str>,
until: Option<&str>,
) -> Result<(index::IndexEntry, message::Context), CommandError> {
let index = index::Index::load_or_refresh()
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let entry = resolve_target(&index, target)?;
let mut ctx = load_context(&entry, scope, message_ids)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
if from.is_some() || until.is_some() {
ctx.messages = context::filter_messages_range(ctx.messages, from, until)?;
}
Ok((entry, ctx))
}
fn print_index_entries(entries: &[&index::IndexEntry]) -> CommandResult {
let stdout = std::io::stdout();
let mut out = stdout.lock();
display::print_sessions(entries, &mut out)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))
}
fn list_contexts(args: &ListArgs) -> CommandResult {
let index = index::Index::load_or_refresh()
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let filter = index::Filter::parse(args.filter.as_deref(), args.path.as_deref());
let matched = index.filter(&filter);
print_index_entries(&matched)
}
fn delete_contexts(args: &DeleteArgs) -> CommandResult {
let mut index = index::Index::load_or_refresh()
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let filter = index::Filter::parse(Some(&args.pattern), args.path.as_deref());
let matched: Vec<index::IndexEntry> = index.filter(&filter).into_iter().cloned().collect();
if matched.is_empty() {
return Err(CommandError::Runtime(no_match_message(
&args.pattern,
args.path.as_deref(),
)));
}
let root_ids: Vec<String> = matched.iter().map(|e| e.id.clone()).collect();
let descendant_ids = index.descendants_of(&root_ids);
let mut descendants: Vec<index::IndexEntry> = Vec::new();
for (provider, id) in &descendant_ids {
if let Some(entry) = index.lookup_entry(*provider, id) {
descendants.push(entry.clone());
}
}
for entry in &matched {
if !args.dry_run {
resolver::open_indexed_context(entry)
.delete_context(&entry.id)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
index.remove_entry(entry.provider, &entry.id);
}
}
for entry in &descendants {
if !args.dry_run {
resolver::open_indexed_context(entry)
.delete_context(&entry.id)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
index.remove_entry(entry.provider, &entry.id);
}
}
if !args.dry_run {
index
.save_default()
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
}
let mut all = matched.clone();
all.extend(descendants.clone());
for entry in &all {
println!("{}", entry.id);
}
Ok(())
}
fn import_contexts(args: &ImportArgs) -> CommandResult {
let dst = args.provider;
let index = index::Index::load_or_refresh()
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let filter = index::Filter::parse(Some(&args.pattern), args.path.as_deref());
let matched: Vec<index::IndexEntry> = index.filter(&filter).into_iter().cloned().collect();
if matched.is_empty() {
return Err(CommandError::Runtime(no_match_message(
&args.pattern,
args.path.as_deref(),
)));
}
let mut seen = destination_fingerprints(dst);
let mut imported = Vec::new();
for entry in &matched {
if entry.provider == dst {
continue;
}
let ctx = resolver::open_indexed_context(entry)
.read_context(&entry.id)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let fingerprint = context::content_fingerprint(&ctx);
if seen.contains(&fingerprint) {
continue;
}
if !args.dry_run {
String::try_from(import::Importer {
client: dst,
ctx: &ctx,
})
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
}
seen.insert(fingerprint);
imported.push(entry.clone());
}
let refs: Vec<&index::IndexEntry> = imported.iter().collect();
print_index_entries(&refs)?;
Ok(())
}
fn destination_fingerprints(dst: Client) -> std::collections::HashSet<String> {
let mut seen = std::collections::HashSet::new();
if let Ok(listings) = resolver::list_provider_contexts(dst) {
for listing in listings {
let reader = resolver::open_listed_context(dst, &listing);
if let Ok(ctx) = reader.read_context(&listing.id) {
seen.insert(context::content_fingerprint(&ctx));
}
}
}
seen
}
fn show_context(args: &ShowArgs) -> CommandResult {
let (entry, ctx) = load_context_for_command(
&args.target,
args.scope,
&args.message_id,
args.r#from.as_deref(),
args.until.as_deref(),
)?;
print_native(&format::render(
args.as_provider.unwrap_or(entry.provider),
&ctx.entries,
&ctx.messages,
&entry.id,
ctx.cwd.as_deref(),
));
Ok(())
}
fn pull_model(_args: &PullArgs) -> CommandResult {
let dest = model::model_cache_dir(model::EMBEDDER_NAME);
model::pull_files(model::EMBEDDER_REPO, &model::EMBEDDER_FILES, &dest)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
println!("{}", dest.display());
let dest = model::model_cache_dir(model::TEXTGEN_NAME);
model::pull_files(
model::TEXTGEN_WEIGHTS_REPO,
&[model::TEXTGEN_WEIGHTS_FILE],
&dest,
)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
model::pull_files(
model::TEXTGEN_TOKENIZER_REPO,
&[model::TEXTGEN_TOKENIZER_FILE],
&dest,
)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
println!("{}", dest.display());
Ok(())
}
fn compact_context(args: &CompactArgs) -> CommandResult {
let (entry, ctx) = load_context_for_command(
&args.target,
args.scope,
&args.message_id,
args.r#from.as_deref(),
args.until.as_deref(),
)?;
let client = args.as_provider.unwrap_or(entry.provider);
let summary = compact::summarize(&ctx.messages)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let output = match args.format {
Style::Json => emit::render(client, &summary, &entry.id, ctx.cwd.as_deref()),
Style::Plain => emit::print(client, &summary),
};
print_native(&output);
Ok(())
}
fn grep_context(args: &GrepArgs) -> CommandResult {
let (entry, ctx) = load_context_for_command(
&args.target,
args.scope,
&args.message_id,
args.r#from.as_deref(),
args.until.as_deref(),
)?;
let hits = search::grep(&ctx.messages, &args.pattern);
print_native(&render_hits(
args.as_provider.unwrap_or(entry.provider),
&ctx,
&hits,
&entry.id,
));
Ok(())
}
fn search_context(args: &SearchArgs) -> CommandResult {
validate_page(args.page)?;
let (entry, ctx) = load_context_for_command(
&args.target,
args.scope,
&args.message_id,
args.r#from.as_deref(),
args.until.as_deref(),
)?;
let page = args.page.unwrap_or(1);
let (hits, _) = search::query(&ctx.messages, &args.query, page, DEFAULT_PAGE_SIZE);
print_native(&render_hits(
args.as_provider.unwrap_or(entry.provider),
&ctx,
&hits,
&entry.id,
));
Ok(())
}
fn render_hits(
client: Client,
ctx: &message::Context,
hits: &[message::SearchHit],
context_id: &str,
) -> String {
let ids: Vec<String> = hits.iter().map(|h| h.entry_id.clone()).collect();
let matched = if ids.is_empty() {
Vec::new()
} else {
context::filter_messages(ctx.messages.clone(), &ids)
};
format::render(
client,
&ctx.entries,
&matched,
context_id,
ctx.cwd.as_deref(),
)
}
fn print_version() {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
}
fn requested_version(tokens: &[&str]) -> bool {
matches!(tokens, ["-V" | "--version"])
}
fn print_usage_and_exit() -> ! {
if let Err(early_exit) = GoosedumpArgs::from_args(&["goosedump"], &["help"]) {
print!("{}", early_exit.output);
}
process::exit(2);
}
fn main() {
let cmdline: Vec<String> = env::args().collect();
if cmdline.len() <= 1 {
print_usage_and_exit();
}
let tokens: Vec<&str> = cmdline[1..].iter().map(String::as_str).collect();
if requested_version(&tokens) {
print_version();
return;
}
let args = match GoosedumpArgs::from_args(&["goosedump"], &tokens) {
Ok(args) => args,
Err(early) => {
if early.status.is_ok() {
print!("{}", early.output);
return;
}
eprint!("{}", early.output);
process::exit(2);
}
};
let result = match &args.command {
GoosedumpCommand::List(args) => list_contexts(args),
GoosedumpCommand::Show(args) => show_context(args),
GoosedumpCommand::Compact(args) => compact_context(args),
GoosedumpCommand::Grep(args) => grep_context(args),
GoosedumpCommand::Search(args) => search_context(args),
GoosedumpCommand::Delete(args) => delete_contexts(args),
GoosedumpCommand::Import(args) => import_contexts(args),
GoosedumpCommand::Pull(args) => pull_model(args),
};
if let Err(e) = result {
eprintln!("{e}");
process::exit(e.exit_code());
}
}