feat(main): Wire SIGPIPE, color, quiet, completions, and negation flag handling

Runtime setup:
- Reset SIGPIPE to SIG_DFL on Unix at the very start of main() so
  piping to head/grep doesn't cause a panic.
- Apply --color flag to console::set_colors_enabled() after CLI parse.
- Extract quiet flag and thread it to handle_ingest.

Command dispatch:
- Add Completions match arm using clap_complete::generate().
- Resolve all --no-X negation flags in handlers: asc, has_due, open
  (issues/mrs), force/full (ingest/sync), check (stats), explain
  (search), retry_failed (embed).
- Auto-enable --check when --repair is used in handle_stats.
- Suppress deprecation warnings in robot mode for List, Show, AuthTest,
  and SyncStatus deprecated aliases.

Stubs:
- Change handle_backup/handle_reset from ok:true to structured error
  JSON on stderr with exit code 1. Remove unused NotImplementedOutput
  and NotImplementedData structs.

Version:
- Include GIT_HASH env var in handle_version output (human and robot).
- Add git_hash field to VersionData with skip_serializing_if for None.

Robot-docs:
- Update exit code table with codes 14-18 (Ollama, NotFound, Ambiguous)
  and code 20 (ConfigNotFound). Clarify code 1 and 2 descriptions.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-30 16:54:53 -05:00
parent 667f70e177
commit 03ea51513d

View File

@@ -20,6 +20,7 @@ use lore::cli::commands::{
print_show_mr_json, print_sync_status, print_sync_status_json, run_auth_test, run_count, print_show_mr_json, print_sync_status, print_sync_status_json, run_auth_test, run_count,
run_doctor, run_embed, run_generate_docs, run_ingest, run_init, run_list_issues, run_list_mrs, run_doctor, run_embed, run_generate_docs, run_ingest, run_init, run_list_issues, run_list_mrs,
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, SyncOptions, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, SyncOptions,
IngestDisplay,
}; };
use lore::cli::{ use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs, Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -32,6 +33,12 @@ use lore::core::paths::get_db_path;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// Reset SIGPIPE to default behavior so piping (e.g. `lore issues | head`) doesn't panic
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
// Initialize logging with indicatif support for clean progress bar output // Initialize logging with indicatif support for clean progress bar output
let indicatif_layer = tracing_indicatif::IndicatifLayer::new(); let indicatif_layer = tracing_indicatif::IndicatifLayer::new();
@@ -52,6 +59,16 @@ async fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let robot_mode = cli.is_robot_mode(); let robot_mode = cli.is_robot_mode();
// Apply color settings (console crate handles NO_COLOR/CLICOLOR natively in "auto" mode)
match cli.color.as_str() {
"never" => console::set_colors_enabled(false),
"always" => console::set_colors_enabled(true),
"auto" => {} // console crate handles this natively
_ => unreachable!(),
}
let quiet = cli.quiet;
let result = match cli.command { let result = match cli.command {
Commands::Issues(args) => handle_issues(cli.config.as_deref(), args, robot_mode).await, Commands::Issues(args) => handle_issues(cli.config.as_deref(), args, robot_mode).await,
Commands::Mrs(args) => handle_mrs(cli.config.as_deref(), args, robot_mode).await, Commands::Mrs(args) => handle_mrs(cli.config.as_deref(), args, robot_mode).await,
@@ -59,7 +76,7 @@ async fn main() {
Commands::Stats(args) => handle_stats(cli.config.as_deref(), args, robot_mode).await, Commands::Stats(args) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
Commands::Embed(args) => handle_embed(cli.config.as_deref(), args, robot_mode).await, Commands::Embed(args) => handle_embed(cli.config.as_deref(), args, robot_mode).await,
Commands::Sync(args) => handle_sync_cmd(cli.config.as_deref(), args, robot_mode).await, Commands::Sync(args) => handle_sync_cmd(cli.config.as_deref(), args, robot_mode).await,
Commands::Ingest(args) => handle_ingest(cli.config.as_deref(), args, robot_mode).await, Commands::Ingest(args) => handle_ingest(cli.config.as_deref(), args, robot_mode, quiet).await,
Commands::Count(args) => { Commands::Count(args) => {
handle_count(cli.config.as_deref(), args, robot_mode).await handle_count(cli.config.as_deref(), args, robot_mode).await
} }
@@ -67,6 +84,7 @@ async fn main() {
Commands::Auth => handle_auth_test(cli.config.as_deref(), robot_mode).await, Commands::Auth => handle_auth_test(cli.config.as_deref(), robot_mode).await,
Commands::Doctor => handle_doctor(cli.config.as_deref(), robot_mode).await, Commands::Doctor => handle_doctor(cli.config.as_deref(), robot_mode).await,
Commands::Version => handle_version(robot_mode), Commands::Version => handle_version(robot_mode),
Commands::Completions { shell } => handle_completions(&shell),
Commands::Init { Commands::Init {
force, force,
non_interactive, non_interactive,
@@ -116,10 +134,12 @@ async fn main() {
target_branch, target_branch,
source_branch, source_branch,
} => { } => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'").yellow() "{}",
); style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'").yellow()
);
}
handle_list_compat( handle_list_compat(
cli.config.as_deref(), cli.config.as_deref(),
&entity, &entity,
@@ -150,14 +170,16 @@ async fn main() {
iid, iid,
project, project,
} => { } => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style(format!( "{}",
"warning: 'lore show' is deprecated, use 'lore {}s {}'", style(format!(
entity, iid "warning: 'lore show' is deprecated, use 'lore {}s {}'",
)) entity, iid
.yellow() ))
); .yellow()
);
}
handle_show_compat( handle_show_compat(
cli.config.as_deref(), cli.config.as_deref(),
&entity, &entity,
@@ -168,17 +190,21 @@ async fn main() {
.await .await
} }
Commands::AuthTest => { Commands::AuthTest => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow() "{}",
); style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow()
);
}
handle_auth_test(cli.config.as_deref(), robot_mode).await handle_auth_test(cli.config.as_deref(), robot_mode).await
} }
Commands::SyncStatus => { Commands::SyncStatus => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow() "{}",
); style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow()
);
}
handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await
} }
}; };
@@ -259,7 +285,10 @@ async fn handle_issues(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let order = if args.asc { "asc" } else { "desc" }; let asc = args.asc && !args.no_asc;
let has_due = args.has_due && !args.no_has_due;
let open = args.open && !args.no_open;
let order = if asc { "asc" } else { "desc" };
if let Some(iid) = args.iid { if let Some(iid) = args.iid {
// Show mode // Show mode
@@ -281,14 +310,14 @@ async fn handle_issues(
milestone: args.milestone.as_deref(), milestone: args.milestone.as_deref(),
since: args.since.as_deref(), since: args.since.as_deref(),
due_before: args.due_before.as_deref(), due_before: args.due_before.as_deref(),
has_due_date: args.has_due, has_due_date: has_due,
sort: &args.sort, sort: &args.sort,
order, order,
}; };
let result = run_list_issues(&config, filters)?; let result = run_list_issues(&config, filters)?;
if args.open { if open {
open_issue_in_browser(&result); open_issue_in_browser(&result);
} else if robot_mode { } else if robot_mode {
print_list_issues_json(&result); print_list_issues_json(&result);
@@ -306,7 +335,9 @@ async fn handle_mrs(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let order = if args.asc { "asc" } else { "desc" }; let asc = args.asc && !args.no_asc;
let open = args.open && !args.no_open;
let order = if asc { "asc" } else { "desc" };
if let Some(iid) = args.iid { if let Some(iid) = args.iid {
// Show mode // Show mode
@@ -337,7 +368,7 @@ async fn handle_mrs(
let result = run_list_mrs(&config, filters)?; let result = run_list_mrs(&config, filters)?;
if args.open { if open {
open_mr_in_browser(&result); open_mr_in_browser(&result);
} else if robot_mode { } else if robot_mode {
print_list_mrs_json(&result); print_list_mrs_json(&result);
@@ -353,8 +384,17 @@ async fn handle_ingest(
config_override: Option<&str>, config_override: Option<&str>,
args: IngestArgs, args: IngestArgs,
robot_mode: bool, robot_mode: bool,
quiet: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let display = if robot_mode || quiet {
IngestDisplay::silent()
} else {
IngestDisplay::interactive()
};
let force = args.force && !args.no_force;
let full = args.full && !args.no_full;
match args.entity.as_deref() { match args.entity.as_deref() {
Some(resource_type) => { Some(resource_type) => {
@@ -363,9 +403,9 @@ async fn handle_ingest(
&config, &config,
resource_type, resource_type,
args.project.as_deref(), args.project.as_deref(),
args.force, force,
args.full, full,
robot_mode, display,
) )
.await?; .await?;
@@ -377,7 +417,7 @@ async fn handle_ingest(
} }
None => { None => {
// Ingest everything: issues then MRs // Ingest everything: issues then MRs
if !robot_mode { if !robot_mode && !quiet {
println!( println!(
"{}", "{}",
style("Ingesting all content (issues + merge requests)...").blue() style("Ingesting all content (issues + merge requests)...").blue()
@@ -389,9 +429,9 @@ async fn handle_ingest(
&config, &config,
"issues", "issues",
args.project.as_deref(), args.project.as_deref(),
args.force, force,
args.full, full,
robot_mode, display,
) )
.await?; .await?;
@@ -399,9 +439,9 @@ async fn handle_ingest(
&config, &config,
"mrs", "mrs",
args.project.as_deref(), args.project.as_deref(),
args.force, force,
args.full, full,
robot_mode, display,
) )
.await?; .await?;
@@ -823,65 +863,81 @@ struct VersionOutput {
#[derive(Serialize)] #[derive(Serialize)]
struct VersionData { struct VersionData {
version: String, version: String,
#[serde(skip_serializing_if = "Option::is_none")]
git_hash: Option<String>,
} }
fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> { fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
let version = env!("CARGO_PKG_VERSION").to_string(); let version = env!("CARGO_PKG_VERSION").to_string();
let git_hash = env!("GIT_HASH").to_string();
if robot_mode { if robot_mode {
let output = VersionOutput { let output = VersionOutput {
ok: true, ok: true,
data: VersionData { version }, data: VersionData {
version,
git_hash: if git_hash.is_empty() {
None
} else {
Some(git_hash)
},
},
}; };
println!("{}", serde_json::to_string(&output)?); println!("{}", serde_json::to_string(&output)?);
} else { } else if git_hash.is_empty() {
println!("lore version {}", version); println!("lore version {}", version);
} else {
println!("lore version {} ({})", version, git_hash);
} }
Ok(()) Ok(())
} }
/// JSON output for not-implemented commands. fn handle_completions(shell: &str) -> Result<(), Box<dyn std::error::Error>> {
#[derive(Serialize)] use clap::CommandFactory;
struct NotImplementedOutput { use clap_complete::{Shell, generate};
ok: bool,
data: NotImplementedData,
}
#[derive(Serialize)] let shell = match shell {
struct NotImplementedData { "bash" => Shell::Bash,
status: String, "zsh" => Shell::Zsh,
command: String, "fish" => Shell::Fish,
"powershell" => Shell::PowerShell,
_ => unreachable!(),
};
let mut cmd = Cli::command();
generate(shell, &mut cmd, "lore", &mut std::io::stdout());
Ok(())
} }
fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> { fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
if robot_mode { if robot_mode {
let output = NotImplementedOutput { let output = RobotErrorWithSuggestion {
ok: true, error: RobotErrorSuggestionData {
data: NotImplementedData { code: "NOT_IMPLEMENTED".to_string(),
status: "not_implemented".to_string(), message: "The 'backup' command is not yet implemented.".to_string(),
command: "backup".to_string(), suggestion: "Use manual database backup: cp ~/.local/share/lore/lore.db ~/.local/share/lore/lore.db.bak".to_string(),
}, },
}; };
println!("{}", serde_json::to_string(&output)?); eprintln!("{}", serde_json::to_string(&output)?);
} else { } else {
println!("lore backup - not yet implemented"); eprintln!("{} The 'backup' command is not yet implemented.", style("Error:").red());
} }
Ok(()) std::process::exit(1);
} }
fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> { fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
if robot_mode { if robot_mode {
let output = NotImplementedOutput { let output = RobotErrorWithSuggestion {
ok: true, error: RobotErrorSuggestionData {
data: NotImplementedData { code: "NOT_IMPLEMENTED".to_string(),
status: "not_implemented".to_string(), message: "The 'reset' command is not yet implemented.".to_string(),
command: "reset".to_string(), suggestion: "Manually delete the database: rm ~/.local/share/lore/lore.db".to_string(),
}, },
}; };
println!("{}", serde_json::to_string(&output)?); eprintln!("{}", serde_json::to_string(&output)?);
} else { } else {
println!("lore reset - not yet implemented"); eprintln!("{} The 'reset' command is not yet implemented.", style("Error:").red());
} }
Ok(()) std::process::exit(1);
} }
/// JSON output for migrate command. /// JSON output for migrate command.
@@ -987,7 +1043,9 @@ async fn handle_stats(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let result = run_stats(&config, args.check, args.repair)?; // Auto-enable --check when --repair is used
let check = (args.check && !args.no_check) || args.repair;
let result = run_stats(&config, check, args.repair)?;
if robot_mode { if robot_mode {
print_stats_json(&result); print_stats_json(&result);
} else { } else {
@@ -1002,6 +1060,7 @@ async fn handle_search(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let explain = args.explain && !args.no_explain;
let fts_mode = match args.fts_mode.as_str() { let fts_mode = match args.fts_mode.as_str() {
"raw" => lore::search::FtsQueryMode::Raw, "raw" => lore::search::FtsQueryMode::Raw,
@@ -1020,7 +1079,7 @@ async fn handle_search(
}; };
let start = std::time::Instant::now(); let start = std::time::Instant::now();
let response = run_search(&config, &args.query, cli_filters, fts_mode, args.explain)?; let response = run_search(&config, &args.query, cli_filters, fts_mode, explain)?;
let elapsed_ms = start.elapsed().as_millis() as u64; let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode { if robot_mode {
@@ -1053,7 +1112,8 @@ async fn handle_embed(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let result = run_embed(&config, args.retry_failed).await?; let retry_failed = args.retry_failed && !args.no_retry_failed;
let result = run_embed(&config, retry_failed).await?;
if robot_mode { if robot_mode {
print_embed_json(&result); print_embed_json(&result);
} else { } else {
@@ -1069,10 +1129,11 @@ async fn handle_sync_cmd(
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let options = SyncOptions { let options = SyncOptions {
full: args.full, full: args.full && !args.no_full,
force: args.force, force: args.force && !args.no_force,
no_embed: args.no_embed, no_embed: args.no_embed,
no_docs: args.no_docs, no_docs: args.no_docs,
robot_mode,
}; };
let start = std::time::Instant::now(); let start = std::time::Instant::now();
@@ -1301,8 +1362,8 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
let exit_codes = serde_json::json!({ let exit_codes = serde_json::json!({
"0": "Success", "0": "Success",
"1": "Internal error / health check failed", "1": "Internal error / health check failed / not implemented",
"2": "Config not found / missing flags", "2": "Usage error (invalid flags or arguments)",
"3": "Config invalid", "3": "Config invalid",
"4": "Token not set", "4": "Token not set",
"5": "GitLab auth failed", "5": "GitLab auth failed",
@@ -1313,7 +1374,13 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
"10": "Database error", "10": "Database error",
"11": "Migration failed", "11": "Migration failed",
"12": "I/O error", "12": "I/O error",
"13": "Transform error" "13": "Transform error",
"14": "Ollama unavailable",
"15": "Ollama model not found",
"16": "Embedding failed",
"17": "Not found",
"18": "Ambiguous match",
"20": "Config not found"
}); });
let workflows = serde_json::json!({ let workflows = serde_json::json!({