#![deny(clippy::all)]
#![deny(clippy::pedantic)]
mod compact;
mod context;
mod display;
mod json;
mod message;
mod resolver;
mod search;
mod text;
use argh::FromArgs;
use std::fmt;
use std::{env, process, str::FromStr};
#[derive(Clone, Copy)]
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, 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 {
client: Client,
context_id: String,
}
impl FromStr for Target {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let (client, context_id) = value
.split_once(':')
.ok_or_else(|| "target must be <provider>:<context-id>".to_string())?;
let client = Client::from_str(client)?;
if context_id.is_empty() {
return Err("target must be <provider>:<context-id>".to_string());
}
Ok(Self {
client,
context_id: context_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),
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "list",
description = "list contexts across providers, optionally filtered by glob"
)]
struct ListArgs {
#[argh(
positional,
description = "glob searched in <provider>:<id> tags, e.g. goose or goose:*"
)]
filter: Option<String>,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "show", description = "show a context transcript")]
struct ShowArgs {
#[argh(positional, description = "target: <provider>:<context-id>")]
target: Target,
#[argh(option, description = "filter to comma-separated entry IDs")]
ids: Option<String>,
#[argh(
option,
default = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "compact",
description = "compact a context for compaction"
)]
struct CompactArgs {
#[argh(positional, description = "target: <provider>:<context-id>")]
target: Target,
#[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 = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "grep",
description = "filter messages by glob pattern"
)]
struct GrepArgs {
#[argh(positional, description = "target: <provider>:<context-id>")]
target: Target,
#[argh(positional, description = "glob pattern")]
pattern: String,
#[argh(option, description = "filter to comma-separated entry IDs")]
ids: Option<String>,
#[argh(
option,
default = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "search",
description = "rank messages by query relevance"
)]
struct SearchArgs {
#[argh(positional, description = "target: <provider>:<context-id>")]
target: Target,
#[argh(positional, description = "search query")]
query: String,
#[argh(option, description = "filter to comma-separated entry IDs")]
ids: Option<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>,
}
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 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_json(value: &serde_json::Value) {
use std::io::Write as _;
let mut out = std::io::stdout().lock();
if let Err(e) = writeln!(out, "{}", json::to_string(value)) {
if e.kind() == std::io::ErrorKind::BrokenPipe {
process::exit(0);
}
process::exit(1);
}
}
fn load_context(
client: Client,
context_id: &str,
scope: Scope,
ids_csv: Option<&str>,
) -> anyhow::Result<message::Context> {
let reader = resolver::open_context(client, context_id)?;
let mut ctx = reader.read_context(context_id)?;
if scope == 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_for_command(
client: Client,
context_id: &str,
scope: Scope,
ids_csv: Option<&str>,
) -> Result<message::Context, CommandError> {
load_context(client, context_id, scope, ids_csv)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))
}
fn list_contexts(args: &ListArgs) {
let listings = resolver::list_all_contexts();
let listings = match &args.filter {
Some(glob) => resolver::filter_listings(listings, glob),
None => listings,
};
for t in &listings {
println!("{}:{}", t.provider, t.listing.id);
}
}
fn show_context(args: &ShowArgs) -> CommandResult {
let ctx = load_context_for_command(
args.target.client,
&args.target.context_id,
args.scope,
args.ids.as_deref(),
)?;
print_json(&json::messages(&ctx.messages));
Ok(())
}
fn compact_context(args: &CompactArgs) -> CommandResult {
if args.ids.is_some() && (args.r#from.is_some() || args.until.is_some()) {
return Err(CommandError::Usage(
"goosedump: --from/--until cannot be combined with --ids",
));
}
let mut ctx = load_context_for_command(
args.target.client,
&args.target.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(),
)?;
}
print_json(&compact::summary(&ctx.messages));
Ok(())
}
fn grep_context(args: &GrepArgs) -> CommandResult {
let ctx = load_context_for_command(
args.target.client,
&args.target.context_id,
args.scope,
args.ids.as_deref(),
)?;
let hits = search::grep(&ctx.messages, &args.pattern);
print_json(&serde_json::json!({
"pattern": args.pattern,
"hits": json::hits(&hits),
}));
Ok(())
}
fn search_context(args: &SearchArgs) -> CommandResult {
validate_page(args.page)?;
let ctx = load_context_for_command(
args.target.client,
&args.target.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);
print_json(&serde_json::json!({
"query": args.query,
"page": page,
"totalPages": total_pages,
"total": total,
"hits": json::hits(&hits),
}));
Ok(())
}
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);
Ok(())
}
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(e) = result {
eprintln!("{e}");
process::exit(e.exit_code());
}
}