#![deny(clippy::all)]
#![deny(clippy::pedantic)]
mod compact;
mod context;
mod display;
mod message;
mod resolver;
mod search;
mod text;
use argh::FromArgs;
use std::{env, process};
#[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),
}
#[derive(FromArgs)]
#[argh(subcommand, name = "list", description = "list contexts for a client")]
struct ListArgs {
#[argh(
positional,
description = "client: codex, crush, goose, opencode, or pi"
)]
client: String,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "show", description = "show a context transcript")]
struct ShowArgs {
#[argh(
positional,
description = "client: codex, crush, goose, opencode, or pi"
)]
client: String,
#[argh(positional, description = "context/session id")]
context_id: String,
#[argh(switch, description = "clipped entry-id overview")]
clip: bool,
#[argh(option, description = "filter to comma-separated entry IDs")]
ids: Option<String>,
#[argh(
option,
default = "\"lineage\".to_string()",
description = "scope: lineage or all"
)]
scope: String,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "compact",
description = "compact a context for compaction"
)]
struct CompactArgs {
#[argh(
positional,
description = "client: codex, crush, goose, opencode, or pi"
)]
client: String,
#[argh(positional, description = "context/session id")]
context_id: String,
#[argh(option, description = "filter to comma-separated entry IDs")]
ids: Option<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 = "\"lineage\".to_string()",
description = "scope: lineage or all"
)]
scope: String,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "grep", description = "filter messages by regex")]
struct GrepArgs {
#[argh(
positional,
description = "client: codex, crush, goose, opencode, or pi"
)]
client: String,
#[argh(positional, description = "context/session id")]
context_id: String,
#[argh(positional, description = "regex pattern")]
pattern: String,
#[argh(switch, description = "clipped hit overview")]
clip: bool,
#[argh(option, description = "filter to comma-separated entry IDs")]
ids: Option<String>,
#[argh(
option,
default = "\"lineage\".to_string()",
description = "scope: lineage or all"
)]
scope: String,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "search",
description = "rank messages by query relevance"
)]
struct SearchArgs {
#[argh(
positional,
description = "client: codex, crush, goose, opencode, or pi"
)]
client: String,
#[argh(positional, description = "context/session id")]
context_id: String,
#[argh(positional, description = "search query")]
query: String,
#[argh(switch, description = "clipped hit overview")]
clip: bool,
#[argh(option, description = "filter to comma-separated entry IDs")]
ids: Option<String>,
#[argh(
option,
default = "\"lineage\".to_string()",
description = "scope: lineage or all"
)]
scope: String,
#[argh(
option,
short = 'p',
description = "page ranked results (5 hits per page)"
)]
page: Option<usize>,
}
const DEFAULT_PAGE_SIZE: usize = 5;
fn validate_client(client: &str) -> Result<(), &'static str> {
if !matches!(client, "codex" | "crush" | "goose" | "opencode" | "pi") {
return Err("goosedump: client must be codex, crush, goose, opencode, or pi");
}
Ok(())
}
fn validate_scope(scope: &str) -> Result<(), &'static str> {
if scope != "lineage" && scope != "all" {
return Err("goosedump: --scope must be lineage or all");
}
Ok(())
}
fn validate_page(page: Option<usize>) -> Result<(), &'static str> {
if let Some(page) = page
&& page == 0
{
return Err("goosedump: --page must be a positive integer");
}
Ok(())
}
fn print_hits(hits: &[message::SearchHit], clip: bool) {
if clip {
println!("{}", display::plain_compact_hits(hits));
} else {
println!("{}", display::plain_hits(hits));
}
}
fn load_context(
client: &str,
context_id: &str,
scope: &str,
ids_csv: Option<&str>,
) -> anyhow::Result<message::Context> {
let (reader, _) = resolver::resolve_client(client, Some(context_id))?;
let mut ctx = reader.read_context(context_id)?;
if scope == "lineage" {
let lineage_ids = context::active_lineage_ids(&ctx.entries);
ctx.messages = context::filter_messages(ctx.messages, &lineage_ids);
}
if let Some(ids_csv) = ids_csv {
let ids = crate::text::split_csv(ids_csv);
ctx.messages = context::filter_messages(ctx.messages, &ids);
}
Ok(ctx)
}
fn load_context_or_exit(
client: &str,
context_id: &str,
scope: &str,
ids_csv: Option<&str>,
) -> message::Context {
match load_context(client, context_id, scope, ids_csv) {
Ok(ctx) => ctx,
Err(e) => {
eprintln!("goosedump: {e}");
process::exit(1);
}
}
}
fn list_contexts(args: &ListArgs) -> Result<(), &'static str> {
validate_client(&args.client)?;
let (_, listings) = match resolver::resolve_client(&args.client, None) {
Ok(r) => r,
Err(e) => {
eprintln!("{e}");
process::exit(1);
}
};
if let Some(listings) = listings {
println!("{}", display::plain_contexts(&listings));
}
Ok(())
}
fn show_context(args: &ShowArgs) -> Result<(), &'static str> {
validate_client(&args.client)?;
validate_scope(&args.scope)?;
let ctx = load_context_or_exit(
&args.client,
&args.context_id,
&args.scope,
args.ids.as_deref(),
);
if args.clip {
println!("{}", display::plain_compact(&ctx.messages));
} else {
println!("{}", display::plain_messages(&ctx.messages));
}
Ok(())
}
fn compact_context(args: &CompactArgs) -> Result<(), &'static str> {
validate_client(&args.client)?;
validate_scope(&args.scope)?;
if args.ids.is_some() && (args.r#from.is_some() || args.until.is_some()) {
return Err("goosedump: --from/--until cannot be combined with --ids");
}
let mut ctx = load_context_or_exit(
&args.client,
&args.context_id,
&args.scope,
args.ids.as_deref(),
);
if args.r#from.is_some() || args.until.is_some() {
ctx.messages = context::filter_messages_range(
ctx.messages,
args.r#from.as_deref(),
args.until.as_deref(),
)?;
}
println!("{}", compact::plain_summary(&ctx.messages));
Ok(())
}
fn grep_context(args: &GrepArgs) -> Result<(), &'static str> {
validate_client(&args.client)?;
validate_scope(&args.scope)?;
let ctx = load_context_or_exit(
&args.client,
&args.context_id,
&args.scope,
args.ids.as_deref(),
);
let hits = search::grep(&ctx.messages, &args.pattern);
if hits.is_empty() {
println!("no results for \"{}\"", args.pattern);
} else {
println!("results for \"{}\":", args.pattern);
print_hits(&hits, args.clip);
}
Ok(())
}
fn search_context(args: &SearchArgs) -> Result<(), &'static str> {
validate_client(&args.client)?;
validate_scope(&args.scope)?;
validate_page(args.page)?;
let ctx = load_context_or_exit(
&args.client,
&args.context_id,
&args.scope,
args.ids.as_deref(),
);
let page = args.page.unwrap_or(1);
let (hits, total) = search::query(&ctx.messages, &args.query, page, DEFAULT_PAGE_SIZE);
let total_pages = total.div_ceil(DEFAULT_PAGE_SIZE);
if hits.is_empty() {
if total == 0 {
println!("no results for \"{}\"", args.query);
} else {
println!(
"no more results for \"{}\" (page {page} of {total_pages})",
args.query
);
}
} else {
println!(
"results for \"{}\" (page {page} of {total_pages}):",
args.query
);
print_hits(&hits, args.clip);
}
Ok(())
}
fn print_version() {
println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
}
fn requested_version() -> bool {
let mut args = env::args_os();
let _program = args.next();
matches!(args.next().as_deref(), Some(arg) if arg == "-V" || arg == "--version")
&& args.next().is_none()
}
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 mut args = env::args_os();
let _program = args.next();
if args.next().is_none() {
print_usage_and_exit();
}
if requested_version() {
print_version();
return;
}
let args: GoosedumpArgs = argh::from_env();
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),
};
if let Err(msg) = result {
eprintln!("{msg}");
process::exit(2);
}
}