mod adapters;
mod agents;
mod agent_context;
mod checkpoint;
pub mod diff;
pub mod messaging;
pub mod relevance;
mod report;
mod utils;
pub mod update_check;
mod teardown;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use serde_json::json;
#[derive(Parser)]
#[command(name = "chorus")]
#[command(about = "Agent Chorus CLI", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Read {
#[arg(long, value_enum)]
agent: AgentType,
#[arg(long)]
id: Option<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
chats_dir: Option<String>,
#[arg(long, default_value = "1")]
last: usize,
#[arg(long)]
json: bool,
#[arg(long)]
metadata_only: bool,
#[arg(long)]
audit_redactions: bool,
},
Compare {
#[arg(long = "source", required = true)]
sources: Vec<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
json: bool,
#[arg(long, default_value = "10")]
last: usize,
},
Report {
#[arg(long)]
handoff: String,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
json: bool,
},
List {
#[arg(long, value_enum)]
agent: AgentType,
#[arg(long)]
cwd: Option<String>,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long)]
json: bool,
},
Search {
#[arg(index = 1)]
query: String,
#[arg(long, value_enum)]
agent: AgentType,
#[arg(long)]
cwd: Option<String>,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long)]
json: bool,
},
#[command(name = "trash-talk")]
TrashTalk {
#[arg(long)]
cwd: Option<String>,
},
Teardown {
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
global: bool,
#[arg(long)]
json: bool,
},
#[command(name = "agent-context")]
AgentContext {
#[command(subcommand)]
command: ContextPackCommand,
},
#[command(name = "context-pack", hide = true)]
ContextPack {
#[command(subcommand)]
command: ContextPackCommand,
},
Diff {
#[arg(long, value_enum)]
agent: AgentType,
#[arg(long)]
from: String,
#[arg(long)]
to: String,
#[arg(long)]
cwd: Option<String>,
#[arg(long, default_value = "1")]
last: usize,
#[arg(long)]
json: bool,
},
Relevance {
#[arg(long)]
list: bool,
#[arg(long)]
test: Option<String>,
#[arg(long)]
suggest: bool,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
json: bool,
},
Send {
#[arg(long)]
from: String,
#[arg(long)]
to: String,
#[arg(long)]
message: String,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
json: bool,
},
Messages {
#[arg(long)]
agent: String,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
clear: bool,
#[arg(long)]
json: bool,
},
Checkpoint {
#[arg(long)]
from: String,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
message: Option<String>,
#[arg(long)]
json: bool,
},
#[cfg(feature = "update-check")]
#[command(hide = true)]
UpdateWorker,
}
#[derive(Subcommand)]
enum ContextPackCommand {
Build {
#[arg(long)]
reason: Option<String>,
#[arg(long)]
base: Option<String>,
#[arg(long)]
head: Option<String>,
#[arg(long)]
pack_dir: Option<String>,
#[arg(long = "changed-file")]
changed_files: Vec<String>,
#[arg(long)]
force_snapshot: bool,
},
#[command(name = "sync-main")]
SyncMain {
#[arg(long)]
local_ref: String,
#[arg(long)]
local_sha: String,
#[arg(long)]
remote_ref: String,
#[arg(long)]
remote_sha: String,
},
#[command(name = "install-hooks")]
InstallHooks {
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
dry_run: bool,
},
Rollback {
#[arg(long)]
snapshot: Option<String>,
#[arg(long)]
pack_dir: Option<String>,
},
Verify {
#[arg(long)]
pack_dir: Option<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
ci: bool,
#[arg(long)]
base: Option<String>,
},
#[command(name = "check-freshness")]
CheckFreshness {
#[arg(long)]
base: Option<String>,
#[arg(long)]
cwd: Option<String>,
},
Init {
#[arg(long)]
pack_dir: Option<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
force: bool,
},
Seal {
#[arg(long)]
reason: Option<String>,
#[arg(long)]
base: Option<String>,
#[arg(long)]
head: Option<String>,
#[arg(long)]
pack_dir: Option<String>,
#[arg(long)]
cwd: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
force_snapshot: bool,
},
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
enum AgentType {
Codex,
Gemini,
Claude,
Cursor,
}
impl AgentType {
fn as_str(&self) -> &'static str {
match self {
AgentType::Codex => "codex",
AgentType::Gemini => "gemini",
AgentType::Claude => "claude",
AgentType::Cursor => "cursor",
}
}
}
fn main() {
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {
let raw_args: Vec<String> = std::env::args().collect();
let has_json = raw_args.iter().any(|a| a == "--json");
if has_json {
let msg = e.to_string();
let code = if msg.contains("invalid value") && msg.contains("--agent") {
agents::ChorusErrorCode::UnsupportedAgent
} else {
agents::classify_error(&msg)
};
let error_json = serde_json::json!({
"error_code": code.as_str(),
"message": msg.to_string().lines().next().unwrap_or(""),
});
println!("{}", serde_json::to_string_pretty(&error_json).unwrap_or_default());
std::process::exit(1);
} else {
e.exit();
}
}
};
let json_mode = is_json_mode(&cli.command);
if let Err(err) = run(cli) {
if json_mode {
let msg = format!("{:#}", err);
let code = agents::classify_error(&msg);
let error_json = serde_json::json!({
"error_code": code.as_str(),
"message": msg,
});
println!("{}", serde_json::to_string_pretty(&error_json).unwrap_or_default());
} else {
eprintln!("{:#}", err);
}
std::process::exit(1);
}
}
fn is_json_mode(command: &Commands) -> bool {
match command {
Commands::Read { json, .. } => *json,
Commands::Compare { json, .. } => *json,
Commands::Report { json, .. } => *json,
Commands::List { json, .. } => *json,
Commands::Search { json, .. } => *json,
Commands::TrashTalk { .. } => false,
Commands::Diff { json, .. } => *json,
Commands::Relevance { json, .. } => *json,
Commands::Send { json, .. } => *json,
Commands::Messages { json, .. } => *json,
Commands::Checkpoint { json, .. } => *json,
Commands::Teardown { json, .. } => *json,
Commands::AgentContext { .. } | Commands::ContextPack { .. } => false,
#[cfg(feature = "update-check")]
Commands::UpdateWorker => false,
}
}
fn run(cli: Cli) -> Result<()> {
match cli.command {
Commands::Read {
agent,
id,
cwd,
chats_dir,
last,
json,
metadata_only,
audit_redactions,
} => {
let effective_cwd = effective_cwd(cwd);
let last_n = last.max(1);
let adapter = adapters::get_adapter(agent.as_str())
.with_context(|| format!("Unsupported agent: {}", agent.as_str()))?;
let session = adapter.read_session(
id.as_deref(),
&effective_cwd,
chats_dir.as_deref(),
last_n,
)?;
let redaction_audit = if audit_redactions {
let (_, audit) = agents::redact_sensitive_text_with_audit(&session.content);
Some(audit)
} else {
None
};
if json {
let content_value = if metadata_only {
serde_json::Value::Null
} else {
serde_json::Value::String(session.content.clone())
};
let mut report = json!({
"chorus_output_version": 1,
"agent": session.agent,
"source": session.source,
"content": content_value,
"warnings": session.warnings,
"session_id": session.session_id,
"cwd": session.cwd,
"timestamp": session.timestamp,
"message_count": session.message_count,
"messages_returned": session.messages_returned,
});
if let Some(ref audit) = redaction_audit {
report.as_object_mut().unwrap().insert(
"redactions".to_string(),
serde_json::to_value(audit)?,
);
}
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
for warning in &session.warnings {
eprintln!("{}", utils::sanitize_for_terminal(warning));
}
println!("--- BEGIN CHORUS OUTPUT ---");
println!("SOURCE: {} Session ({})", format_agent_name(session.agent), utils::sanitize_for_terminal(&session.source));
if !metadata_only {
println!("---");
println!("{}", utils::sanitize_for_terminal(&session.content));
}
if let Some(ref audit) = redaction_audit {
if !audit.is_empty() {
println!("---");
println!("Redaction audit:");
for entry in audit {
println!(" {} — {} occurrence(s)", entry.pattern, entry.count);
}
}
}
println!("--- END CHORUS OUTPUT ---");
}
}
Commands::Compare { sources, cwd, json, last } => {
let effective_cwd = effective_cwd(cwd);
let mut source_specs = sources
.iter()
.map(|raw| report::parse_source_arg(raw))
.collect::<Result<Vec<report::SourceSpec>>>()?;
for spec in &mut source_specs {
spec.last_n = Some(last.max(1));
}
let request = report::ReportRequest {
mode: "analyze".to_string(),
task: "Compare agent outputs".to_string(),
success_criteria: vec![
"Identify agreements and contradictions".to_string(),
"Highlight unavailable sources".to_string(),
],
sources: source_specs,
constraints: Vec::new(),
};
let result = report::build_report(&request, &effective_cwd);
emit_report_output(&result, json)?;
}
Commands::Report { handoff, cwd, json } => {
let effective_cwd = effective_cwd(cwd);
let request = report::load_handoff(&handoff)
.with_context(|| format!("Failed to load handoff packet from {}", handoff))?;
let result = report::build_report(&request, &effective_cwd);
emit_report_output(&result, json)?;
}
Commands::List { agent, cwd, limit, json } => {
let normalized_cwd = cwd.map(|value| {
utils::normalize_path(&value)
.map(|path| path.to_string_lossy().to_string())
.unwrap_or(value)
});
let adapter = adapters::get_adapter(agent.as_str())
.with_context(|| format!("Unsupported agent: {}", agent.as_str()))?;
let entries = adapter.list_sessions(normalized_cwd.as_deref(), limit)?;
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if entries.is_empty() {
println!("No sessions found.");
} else {
println!(" {:<8} {:<12} {:<24} CWD", "AGENT", "SESSION", "TIMESTAMP");
println!(" {:<8} {:<12} {:<24} {}", "─".repeat(8), "─".repeat(12), "─".repeat(24), "─".repeat(20));
for entry in &entries {
let agent_name = entry.get("agent").and_then(|v| v.as_str()).unwrap_or("");
let sid = entry.get("session_id").and_then(|v| v.as_str()).unwrap_or("");
let sid_short = if sid.len() > 12 { &sid[..12] } else { sid };
let ts_raw = entry.get("modified_at").and_then(|v| v.as_str()).unwrap_or("unknown");
let ts = format_timestamp(ts_raw);
let cwd_val = entry.get("cwd").and_then(|v| v.as_str()).unwrap_or("");
let cwd_display = if cwd_val.is_empty() { String::new() } else { truncate_cwd(cwd_val) };
println!(" {:<8} {:<12} {:<24} {}", agent_name, sid_short, ts, cwd_display);
}
println!("\n {} session{} found.", entries.len(), if entries.len() == 1 { "" } else { "s" });
}
}
Commands::Search { query, agent, cwd, limit, json } => {
let normalized_cwd = cwd.map(|value| {
utils::normalize_path(&value)
.map(|path| path.to_string_lossy().to_string())
.unwrap_or(value)
});
let adapter = adapters::get_adapter(agent.as_str())
.with_context(|| format!("Unsupported agent: {}", agent.as_str()))?;
let entries = adapter.search_sessions(&query, normalized_cwd.as_deref(), limit)?;
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if entries.is_empty() {
println!("Search for \"{}\": no matching sessions found.", query);
} else {
println!("Search for \"{}\": {} result{}\n",
query, entries.len(), if entries.len() == 1 { "" } else { "s" });
for entry in &entries {
let agent_name = entry.get("agent").and_then(|v| v.as_str()).unwrap_or("");
let sid = entry.get("session_id").and_then(|v| v.as_str()).unwrap_or("");
let sid_short = if sid.len() > 12 { &sid[..12] } else { sid };
let ts_raw = entry.get("modified_at").and_then(|v| v.as_str()).unwrap_or("unknown");
let ts = format_timestamp(ts_raw);
let snippet = entry.get("match_snippet")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| format!("\n \"{}\"", s.trim()))
.unwrap_or_default();
println!(" {:<8} {:<12} {}{}", agent_name, sid_short, ts, snippet);
}
}
}
Commands::TrashTalk { cwd } => {
let effective = effective_cwd(cwd);
agents::trash_talk(&effective);
}
Commands::Diff { agent, from, to, cwd, last, json } => {
let effective_cwd = effective_cwd(cwd);
let last_n = last.max(1);
let result = diff::diff_sessions(agent.as_str(), &from, &to, &effective_cwd, last_n)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("Diff: {} session {} vs {}", result.agent, result.session_a, result.session_b);
println!(" +{} added, -{} removed, {} unchanged\n", result.added_lines, result.removed_lines, result.equal_lines);
const CONTEXT_LINES: usize = 2;
let mut equal_run: Vec<&str> = Vec::new();
let flush_equal_run = |run: &[&str]| {
if run.len() <= 5 {
for line in run {
println!(" {}", line);
}
} else {
for line in &run[..CONTEXT_LINES] {
println!(" {}", line);
}
println!(" ... ({} unchanged lines)", run.len() - 2 * CONTEXT_LINES);
for line in &run[run.len() - CONTEXT_LINES..] {
println!(" {}", line);
}
}
};
for hunk in &result.hunks {
match hunk.tag.as_str() {
"equal" => {
equal_run.push(&hunk.content);
}
_ => {
if !equal_run.is_empty() {
flush_equal_run(&equal_run);
equal_run.clear();
}
match hunk.tag.as_str() {
"add" => println!("+ {}", hunk.content),
"remove" => println!("- {}", hunk.content),
_ => println!(" {}", hunk.content),
}
}
}
}
if !equal_run.is_empty() {
flush_equal_run(&equal_run);
}
}
}
Commands::Relevance { list, test, suggest, cwd, json } => {
let effective = std::path::PathBuf::from(effective_cwd(cwd));
if let Some(file_path) = test {
let result = relevance::test_file(&effective, &file_path);
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
let status = if result.relevant { "RELEVANT" } else { "NOT RELEVANT" };
println!("{}: {}", result.path, status);
if let Some(ref matched) = result.matched_by {
println!(" matched by: {}", matched);
}
}
} else if suggest {
let suggestions = relevance::suggest_patterns(&effective);
if json {
println!("{}", serde_json::to_string_pretty(&suggestions)?);
} else if suggestions.is_empty() {
println!("No additional pattern suggestions for this project.");
} else {
for s in &suggestions {
println!("[{}] {} — {}", s.suggestion_type, s.pattern, s.reason);
}
}
} else if list {
let info = relevance::list_patterns(&effective);
if json {
println!("{}", serde_json::to_string_pretty(&info)?);
} else {
println!("Source: {}", info.source);
println!("\nInclude:");
for p in &info.include {
println!(" {}", p);
}
println!("\nExclude:");
for p in &info.exclude {
println!(" {}", p);
}
}
} else {
println!("Usage: chorus relevance --list | --test <path> | --suggest");
println!(" Add --json for structured output");
println!(" Add --cwd <dir> to specify working directory");
}
}
Commands::Send { from, to, message, cwd, json } => {
let effective = effective_cwd(cwd);
let msg = messaging::send_message(&from, &to, &message, &effective)?;
if json {
println!("{}", serde_json::to_string_pretty(&msg)?);
} else {
println!("Message sent from {} to {} at {}", msg.from, msg.to, msg.timestamp);
}
}
Commands::Messages { agent, cwd, clear, json } => {
let effective = effective_cwd(cwd);
let messages = messaging::read_messages(&agent, &effective)?;
if json {
println!("{}", serde_json::to_string_pretty(&messages)?);
} else if messages.is_empty() {
println!("No messages for {}.", agent);
} else {
for msg in &messages {
println!("[{}] from={} → to={}: {}", msg.timestamp, msg.from, msg.to, msg.content);
}
}
if clear {
let count = messaging::clear_messages(&agent, &effective)?;
if !json {
println!("Cleared {} message(s).", count);
}
}
}
Commands::Checkpoint { from, cwd, message, json } => {
let effective = effective_cwd(cwd);
let outcome = checkpoint::run(&from, &effective, message.as_deref())?;
match outcome {
None => {
if json {
println!("{}", serde_json::to_string_pretty(&json!({
"ok": true,
"from": from,
"recipients": Vec::<String>::new(),
"message": null,
"note": "No .agent-chorus/ present — checkpoint was a no-op.",
}))?);
} else {
println!(
"No .agent-chorus/ directory in {} — checkpoint skipped.",
effective
);
}
}
Some(result) => {
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!(
"Checkpoint from {} to {} recipient(s): {}",
result.from,
result.recipients.len(),
result.recipients.join(", ")
);
println!(" {}", result.message);
}
}
}
}
Commands::Teardown { cwd, dry_run, json, global } => {
let effective_cwd = effective_cwd(cwd);
let result = teardown::run_teardown(&effective_cwd, dry_run, global)?;
if json {
println!("{}", serde_json::to_string_pretty(&json!({
"cwd": result.cwd,
"dry_run": result.dry_run,
"global": result.global,
"operations": result.operations,
"warnings": result.warnings,
"changed": result.changed,
}))?);
} else {
let mode = if result.dry_run { "(dry run) " } else { "" };
println!("Agent Chorus teardown {}complete for {}", mode, result.cwd);
for warning in &result.warnings {
println!("- [warn] {}", warning);
}
for op in &result.operations {
let status = op.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
let path = op.get("path").and_then(|s| s.as_str()).unwrap_or("");
let note = op.get("note").and_then(|s| s.as_str()).unwrap_or("");
println!("- [{}] {} ({})", status, path, note);
}
}
}
Commands::ContextPack { command } => {
eprintln!("Warning: 'context-pack' is deprecated, use 'agent-context' instead.");
handle_context_pack(command)?;
}
Commands::AgentContext { command } => {
handle_context_pack(command)?;
}
#[cfg(feature = "update-check")]
Commands::UpdateWorker => {
update_check::run_worker();
}
}
#[cfg(feature = "update-check")]
update_check::maybe_notify(&cli.command);
Ok(())
}
fn handle_context_pack(command: ContextPackCommand) -> Result<()> {
match command {
ContextPackCommand::Build {
reason,
base,
head,
pack_dir,
changed_files,
force_snapshot,
} => {
agent_context::build(agent_context::BuildOptions {
reason,
base,
head,
pack_dir,
changed_files,
force_snapshot,
})?;
}
ContextPackCommand::SyncMain {
local_ref,
local_sha,
remote_ref,
remote_sha,
} => {
agent_context::sync_main(
&local_ref,
&local_sha,
&remote_ref,
&remote_sha,
)?;
}
ContextPackCommand::InstallHooks { cwd, dry_run } => {
let target_cwd = effective_cwd(cwd);
agent_context::install_hooks(&target_cwd, dry_run)?;
}
ContextPackCommand::Rollback { snapshot, pack_dir } => {
agent_context::rollback(snapshot.as_deref(), pack_dir.as_deref())?;
}
ContextPackCommand::Verify { pack_dir, cwd, ci, base } => {
let target_cwd = effective_cwd(cwd);
agent_context::verify(agent_context::VerifyOptions {
pack_dir,
cwd: target_cwd,
ci,
base,
})?;
}
ContextPackCommand::CheckFreshness { base, cwd } => {
let target_cwd = effective_cwd(cwd);
agent_context::check_freshness(
base.as_deref().unwrap_or("origin/main"),
&target_cwd,
)?;
}
ContextPackCommand::Init {
pack_dir,
cwd,
force,
} => {
agent_context::init(agent_context::InitOptions {
pack_dir,
cwd,
force,
})?;
}
ContextPackCommand::Seal {
reason,
base,
head,
pack_dir,
cwd,
force,
force_snapshot,
} => {
agent_context::seal(agent_context::SealOptions {
reason,
base,
head,
pack_dir,
cwd,
force,
force_snapshot,
})?;
}
}
Ok(())
}
fn emit_report_output(report_value: &serde_json::Value, json_output: bool) -> Result<()> {
if json_output {
println!("{}", serde_json::to_string_pretty(report_value)?);
} else {
println!("{}", utils::sanitize_for_terminal(&report::report_to_markdown(report_value)));
}
Ok(())
}
fn effective_cwd(cwd: Option<String>) -> String {
cwd.unwrap_or_else(|| {
std::env::current_dir()
.map(|path| path.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string())
})
}
fn format_agent_name(agent: &str) -> &'static str {
match agent {
"codex" => "Codex",
"gemini" => "Gemini",
"claude" => "Claude",
"cursor" => "Cursor",
_ => "Unknown",
}
}
fn format_timestamp(iso: &str) -> String {
let parts: Vec<&str> = iso.splitn(2, 'T').collect();
if parts.len() < 2 {
return iso.to_string();
}
let date_parts: Vec<&str> = parts[0].split('-').collect();
let time_str = parts[1].trim_end_matches('Z').split('.').next().unwrap_or("");
let time_parts: Vec<&str> = time_str.split(':').collect();
if date_parts.len() < 3 || time_parts.len() < 3 {
return iso.to_string();
}
let year: u32 = date_parts[0].parse().unwrap_or(0);
let month: u32 = date_parts[1].parse().unwrap_or(0);
let day: u32 = date_parts[2].parse().unwrap_or(0);
let hour: u32 = time_parts[0].parse().unwrap_or(0);
let minute: u32 = time_parts[1].parse().unwrap_or(0);
let second: u32 = time_parts[2].parse().unwrap_or(0);
let (h12, ampm) = if hour == 0 {
(12, "AM")
} else if hour < 12 {
(hour, "AM")
} else if hour == 12 {
(12, "PM")
} else {
(hour - 12, "PM")
};
format!("{}/{}/{}, {}:{:02}:{:02} {}", month, day, year, h12, minute, second, ampm)
}
fn truncate_cwd(path: &str) -> String {
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() <= 3 {
return path.to_string();
}
format!("…/{}", parts[parts.len() - 2..].join("/"))
}