#![deny(clippy::all)]
#![deny(clippy::pedantic)]
mod cache;
mod compact;
mod context;
mod display;
mod message;
mod resolver;
mod search;
mod text;
use argh::FromArgs;
use std::fmt;
use std::io::IsTerminal as _;
use std::{env, io, process, str::FromStr};
#[derive(Clone, Copy)]
enum Client {
Codex,
Crush,
Goose,
Opencode,
Pi,
}
impl Client {
const fn as_str(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Crush => "crush",
Self::Goose => "goose",
Self::Opencode => "opencode",
Self::Pi => "pi",
}
}
}
impl FromStr for Client {
type Err = &'static str;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"codex" => Ok(Self::Codex),
"crush" => Ok(Self::Crush),
"goose" => Ok(Self::Goose),
"opencode" => Ok(Self::Opencode),
"pi" => Ok(Self::Pi),
_ => Err("client must be codex, crush, goose, opencode, or pi"),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Scope {
Lineage,
All,
}
impl Scope {
const fn as_str(self) -> &'static str {
match self {
Self::Lineage => "lineage",
Self::All => "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"),
}
}
}
#[derive(FromArgs)]
#[argh(description = "coding agent context data browser")]
struct GoosedumpArgs {
#[argh(switch, description = "emit markdown source")]
markdown: bool,
#[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: Client,
}
#[derive(FromArgs)]
#[argh(subcommand, name = "show", description = "show a context transcript")]
struct ShowArgs {
#[argh(
positional,
description = "client: codex, crush, goose, opencode, or pi"
)]
client: Client,
#[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 = "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 = "client: codex, crush, goose, opencode, or pi"
)]
client: Client,
#[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 = "Scope::Lineage",
description = "scope: lineage or all"
)]
scope: Scope,
}
#[derive(FromArgs)]
#[argh(
subcommand,
name = "grep",
description = "filter messages by fuzzy pattern"
)]
struct GrepArgs {
#[argh(
positional,
description = "client: codex, crush, goose, opencode, or pi"
)]
client: Client,
#[argh(positional, description = "context/session id")]
context_id: String,
#[argh(positional, description = "fuzzy 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 = "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 = "client: codex, crush, goose, opencode, or pi"
)]
client: Client,
#[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 = "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 let Some(page) = page
&& page == 0
{
return Err(CommandError::Usage(
"goosedump: --page must be a positive integer",
));
}
Ok(())
}
fn format_hits(hits: &[message::SearchHit], clip: bool) -> String {
if clip {
display::plain_compact_hits(hits)
} else {
display::plain_hits(hits)
}
}
fn print_output(output: &str, markdown: bool) {
let output = if markdown {
display::markdown_source(output)
} else if io::stdout().is_terminal() {
display::markdown_tty(output)
} else {
output.to_string()
};
println!("{output}");
}
fn load_context(
client: Client,
context_id: &str,
scope: Scope,
ids_csv: Option<&str>,
) -> anyhow::Result<message::Context> {
load_context_with_source(client, context_id, scope, ids_csv).map(|loaded| loaded.context)
}
struct LoadedContext {
context: message::Context,
source_path: Option<std::path::PathBuf>,
}
fn load_context_with_source(
client: Client,
context_id: &str,
scope: Scope,
ids_csv: Option<&str>,
) -> anyhow::Result<LoadedContext> {
let (reader, listings) = resolver::resolve_client(client.as_str(), Some(context_id))?;
let source_path = listings
.and_then(|l| l.into_iter().find(|l| l.id == context_id))
.and_then(|l| l.path);
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(LoadedContext {
context: ctx,
source_path,
})
}
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, markdown: bool) -> CommandResult {
let (_, listings) = resolver::resolve_client(args.client.as_str(), None)
.map_err(|e| CommandError::Runtime(e.to_string()))?;
if let Some(listings) = listings {
print_output(&display::plain_contexts(&listings), markdown);
}
Ok(())
}
fn show_context(args: &ShowArgs, markdown: bool) -> CommandResult {
let ctx = load_context_for_command(
args.client,
&args.context_id,
args.scope,
args.ids.as_deref(),
)?;
let output = if args.clip {
display::plain_compact(&ctx.messages)
} else {
display::plain_messages(&ctx.messages)
};
print_output(&output, markdown);
Ok(())
}
fn compact_context(args: &CompactArgs, markdown: bool) -> 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.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(),
)?;
}
print_output(&compact::plain_summary(&ctx.messages), markdown);
Ok(())
}
fn grep_context(args: &GrepArgs, markdown: bool) -> CommandResult {
let loaded = load_context_for_command_with_source(
args.client,
&args.context_id,
args.scope,
args.ids.as_deref(),
)?;
let key = build_cache_key(
args.client,
&args.context_id,
args.scope,
args.ids.as_deref(),
&loaded,
);
let hits = search::grep(key.as_ref(), &loaded.context.messages, &args.pattern)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let output = if hits.is_empty() {
format!("no results for \"{}\"", args.pattern)
} else {
format!(
"results for \"{}\":\n{}",
args.pattern,
format_hits(&hits, args.clip)
)
};
print_output(&output, markdown);
Ok(())
}
fn search_context(args: &SearchArgs, markdown: bool) -> CommandResult {
validate_page(args.page)?;
let loaded = load_context_for_command_with_source(
args.client,
&args.context_id,
args.scope,
args.ids.as_deref(),
)?;
let page = args.page.unwrap_or(1);
let key = build_cache_key(
args.client,
&args.context_id,
args.scope,
args.ids.as_deref(),
&loaded,
);
let (hits, total) = search::query(
key.as_ref(),
&loaded.context.messages,
&args.query,
page,
DEFAULT_PAGE_SIZE,
)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))?;
let total_pages = total.div_ceil(DEFAULT_PAGE_SIZE);
let output = if hits.is_empty() {
if total == 0 {
format!("no results for \"{}\"", args.query)
} else {
format!(
"no more results for \"{}\" (page {page} of {total_pages})",
args.query
)
}
} else {
format!(
"results for \"{}\" (page {page} of {total_pages}):\n{}",
args.query,
format_hits(&hits, args.clip)
)
};
print_output(&output, markdown);
Ok(())
}
fn load_context_for_command_with_source(
client: Client,
context_id: &str,
scope: Scope,
ids_csv: Option<&str>,
) -> Result<LoadedContext, CommandError> {
load_context_with_source(client, context_id, scope, ids_csv)
.map_err(|e| CommandError::Runtime(format!("goosedump: {e}")))
}
fn cache_filter_id(scope: Scope, ids_csv: Option<&str>) -> String {
format!("scope={};ids={}", scope.as_str(), ids_csv.unwrap_or("*"))
}
fn build_cache_key(
client: Client,
context_id: &str,
scope: Scope,
ids_csv: Option<&str>,
loaded: &LoadedContext,
) -> Option<cache::CacheKey> {
let path = loaded.source_path.as_ref()?;
let filter_id = cache_filter_id(scope, ids_csv);
Some(cache::CacheKey {
client: client.as_str().to_string(),
context_id: context_id.to_string(),
filter_id,
source_path: path.clone(),
mtime: cache::mtime_of(path),
})
}
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 markdown = args.markdown;
let result = match &args.command {
GoosedumpCommand::List(args) => list_contexts(args, markdown),
GoosedumpCommand::Show(args) => show_context(args, markdown),
GoosedumpCommand::Compact(args) => compact_context(args, markdown),
GoosedumpCommand::Grep(args) => grep_context(args, markdown),
GoosedumpCommand::Search(args) => search_context(args, markdown),
};
if let Err(e) = result {
eprintln!("{e}");
process::exit(e.exit_code());
}
}