From 7d0d586932a5398675c12cf0256ce66d72f92fdc Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Mon, 26 Jan 2026 22:46:27 -0500 Subject: [PATCH] feat(cli): Add global robot mode for machine-readable output Introduces a unified robot mode that enables JSON output across all commands, designed for AI agent and script consumption. Robot mode activation (any of): - --robot flag: Explicit opt-in - GI_ROBOT=1 env var: For persistent configuration - Non-TTY stdout: Auto-detect when piped (e.g., gi list issues | jq) Implementation: - Cli::is_robot_mode(): Centralized detection logic - All command handlers receive robot_mode boolean - Errors emit structured JSON to stderr with exit codes - Success responses emit JSON to stdout Behavior changes in robot mode: - No color/emoji output (no ANSI escapes) - No progress spinners or interactive prompts - Timestamps as ISO 8601 strings (not relative "2 hours ago") - Full content (no truncation of descriptions/notes) - Structured error objects with code, message, suggestion This enables reliable parsing by Claude Code, shell scripts, and automation pipelines. The auto-detect on non-TTY means simple piping "just works" without explicit flags. Per-command --json flags remain for explicit control and override robot mode when needed for human-friendly terminal + JSON file output. Co-Authored-By: Claude Opus 4.5 --- src/cli/mod.rs | 51 +++++- src/main.rs | 419 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 410 insertions(+), 60 deletions(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5be101f..0e445cc 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,7 @@ pub mod commands; use clap::{Parser, Subcommand}; +use std::io::IsTerminal; /// GitLab Inbox - Unified notification management #[derive(Parser)] @@ -13,11 +14,23 @@ pub struct Cli { #[arg(short, long, global = true)] pub config: Option, + /// Machine-readable JSON output (auto-enabled when piped) + #[arg(long, global = true, env = "GI_ROBOT")] + pub robot: bool, + #[command(subcommand)] pub command: Commands, } +impl Cli { + /// Check if robot mode is active (explicit flag, env var, or non-TTY stdout) + pub fn is_robot_mode(&self) -> bool { + self.robot || !std::io::stdout().is_terminal() + } +} + #[derive(Subcommand)] +#[allow(clippy::large_enum_variant)] pub enum Commands { /// Initialize configuration and database Init { @@ -62,7 +75,7 @@ pub enum Commands { /// Ingest data from GitLab Ingest { /// Resource type to ingest - #[arg(long, value_parser = ["issues", "merge_requests"])] + #[arg(long, value_parser = ["issues", "mrs"])] r#type: String, /// Filter to single project @@ -92,8 +105,8 @@ pub enum Commands { #[arg(long)] project: Option, - /// Filter by state - #[arg(long, value_parser = ["opened", "closed", "all"])] + /// Filter by state (opened|closed|all for issues; opened|merged|closed|locked|all for MRs) + #[arg(long)] state: Option, /// Filter by author username @@ -108,7 +121,7 @@ pub enum Commands { #[arg(long)] label: Option>, - /// Filter by milestone title + /// Filter by milestone title (issues only) #[arg(long)] milestone: Option, @@ -116,11 +129,11 @@ pub enum Commands { #[arg(long)] since: Option, - /// Filter by due date (before this date, YYYY-MM-DD) + /// Filter by due date (before this date, YYYY-MM-DD) (issues only) #[arg(long)] due_before: Option, - /// Show only issues with a due date + /// Show only issues with a due date (issues only) #[arg(long)] has_due_date: bool, @@ -132,13 +145,33 @@ pub enum Commands { #[arg(long, value_parser = ["desc", "asc"], default_value = "desc")] order: String, - /// Open first matching issue in browser + /// Open first matching item in browser #[arg(long)] open: bool, /// Output as JSON #[arg(long)] json: bool, + + /// Show only draft MRs (MRs only) + #[arg(long, conflicts_with = "no_draft")] + draft: bool, + + /// Exclude draft MRs (MRs only) + #[arg(long, conflicts_with = "draft")] + no_draft: bool, + + /// Filter by reviewer username (MRs only) + #[arg(long)] + reviewer: Option, + + /// Filter by target branch (MRs only) + #[arg(long)] + target_branch: Option, + + /// Filter by source branch (MRs only) + #[arg(long)] + source_branch: Option, }, /// Count entities in local database @@ -164,5 +197,9 @@ pub enum Commands { /// Filter by project path (required if iid is ambiguous) #[arg(long)] project: Option, + + /// Output as JSON + #[arg(long)] + json: bool, }, } diff --git a/src/main.rs b/src/main.rs index d4db2ed..6a6889d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,21 +3,26 @@ use clap::Parser; use console::style; use dialoguer::{Confirm, Input}; +use serde::Serialize; use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use gi::Config; use gi::cli::commands::{ - InitInputs, InitOptions, ListFilters, open_issue_in_browser, print_count, - print_doctor_results, print_ingest_summary, print_list_issues, print_list_issues_json, - print_show_issue, print_sync_status, run_auth_test, run_count, run_doctor, run_ingest, - run_init, run_list_issues, run_show_issue, run_sync_status, + InitInputs, InitOptions, ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, + print_count, print_count_json, print_doctor_results, print_ingest_summary, + print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, + print_list_mrs_json, print_show_issue, print_show_issue_json, print_show_mr, + print_show_mr_json, print_sync_status, print_sync_status_json, run_auth_test, run_count, + run_doctor, run_ingest, run_init, run_list_issues, run_list_mrs, run_show_issue, run_show_mr, + run_sync_status, }; -use gi::core::db::{create_connection, get_schema_version, run_migrations}; -use gi::core::paths::get_db_path; use gi::cli::{Cli, Commands}; +use gi::core::db::{create_connection, get_schema_version, run_migrations}; +use gi::core::error::{GiError, RobotErrorOutput}; use gi::core::paths::get_config_path; +use gi::core::paths::get_db_path; #[tokio::main] async fn main() { @@ -39,34 +44,36 @@ async fn main() { .init(); let cli = Cli::parse(); + let robot_mode = cli.is_robot_mode(); let result = match cli.command { Commands::Init { force, non_interactive, - } => handle_init(cli.config.as_deref(), force, non_interactive).await, - Commands::AuthTest => handle_auth_test(cli.config.as_deref()).await, - Commands::Doctor { json } => handle_doctor(cli.config.as_deref(), json).await, - Commands::Version => { - println!("gi version {}", env!("CARGO_PKG_VERSION")); - Ok(()) - } - Commands::Backup => { - println!("gi backup - not yet implemented"); - Ok(()) - } - Commands::Reset { confirm: _ } => { - println!("gi reset - not yet implemented"); - Ok(()) - } - Commands::Migrate => handle_migrate(cli.config.as_deref()).await, - Commands::SyncStatus => handle_sync_status(cli.config.as_deref()).await, + } => handle_init(cli.config.as_deref(), force, non_interactive, robot_mode).await, + Commands::AuthTest => handle_auth_test(cli.config.as_deref(), robot_mode).await, + Commands::Doctor { json } => handle_doctor(cli.config.as_deref(), json || robot_mode).await, + Commands::Version => handle_version(robot_mode), + Commands::Backup => handle_backup(robot_mode), + Commands::Reset { confirm: _ } => handle_reset(robot_mode), + Commands::Migrate => handle_migrate(cli.config.as_deref(), robot_mode).await, + Commands::SyncStatus => handle_sync_status(cli.config.as_deref(), robot_mode).await, Commands::Ingest { r#type, project, force, full, - } => handle_ingest(cli.config.as_deref(), &r#type, project.as_deref(), force, full).await, + } => { + handle_ingest( + cli.config.as_deref(), + &r#type, + project.as_deref(), + force, + full, + robot_mode, + ) + .await + } Commands::List { entity, limit, @@ -83,6 +90,11 @@ async fn main() { order, open, json, + draft, + no_draft, + reviewer, + target_branch, + source_branch, } => { handle_list( cli.config.as_deref(), @@ -100,30 +112,106 @@ async fn main() { &sort, &order, open, - json, + json || robot_mode, + draft, + no_draft, + reviewer.as_deref(), + target_branch.as_deref(), + source_branch.as_deref(), ) .await } Commands::Count { entity, r#type } => { - handle_count(cli.config.as_deref(), &entity, r#type.as_deref()).await + handle_count(cli.config.as_deref(), &entity, r#type.as_deref(), robot_mode).await } Commands::Show { entity, iid, project, - } => handle_show(cli.config.as_deref(), &entity, iid, project.as_deref()).await, + json, + } => { + handle_show( + cli.config.as_deref(), + &entity, + iid, + project.as_deref(), + json || robot_mode, + ) + .await + } }; if let Err(e) = result { - eprintln!("{} {}", style("Error:").red(), e); - std::process::exit(1); + handle_error(e, robot_mode); } } +/// Fallback error output for non-GiError errors in robot mode. +#[derive(Serialize)] +struct FallbackErrorOutput { + error: FallbackError, +} + +#[derive(Serialize)] +struct FallbackError { + code: String, + message: String, +} + +fn handle_error(e: Box, robot_mode: bool) -> ! { + // Try to downcast to GiError for structured output + if let Some(gi_error) = e.downcast_ref::() { + if robot_mode { + let output = RobotErrorOutput::from(gi_error); + // Use serde_json for safe serialization; fallback constructs JSON safely + eprintln!( + "{}", + serde_json::to_string(&output).unwrap_or_else(|_| { + // Fallback uses serde to ensure proper escaping + let fallback = FallbackErrorOutput { + error: FallbackError { + code: "INTERNAL_ERROR".to_string(), + message: gi_error.to_string(), + }, + }; + serde_json::to_string(&fallback) + .unwrap_or_else(|_| r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"#.to_string()) + }) + ); + std::process::exit(gi_error.exit_code()); + } else { + eprintln!("{} {}", style("Error:").red(), gi_error); + if let Some(suggestion) = gi_error.suggestion() { + eprintln!("{} {}", style("Hint:").yellow(), suggestion); + } + std::process::exit(gi_error.exit_code()); + } + } + + // Fallback for non-GiError errors - use serde for proper JSON escaping + if robot_mode { + let output = FallbackErrorOutput { + error: FallbackError { + code: "INTERNAL_ERROR".to_string(), + message: e.to_string(), + }, + }; + eprintln!( + "{}", + serde_json::to_string(&output) + .unwrap_or_else(|_| r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"#.to_string()) + ); + } else { + eprintln!("{} {}", style("Error:").red(), e); + } + std::process::exit(1); +} + async fn handle_init( config_override: Option<&str>, force: bool, non_interactive: bool, + _robot_mode: bool, // TODO: Add robot mode support for init (requires non-interactive implementation) ) -> Result<(), Box> { let config_path = get_config_path(config_override); let mut confirmed_overwrite = force; @@ -244,16 +332,57 @@ async fn handle_init( Ok(()) } -async fn handle_auth_test(config_override: Option<&str>) -> Result<(), Box> { +/// JSON output for auth-test command. +#[derive(Serialize)] +struct AuthTestOutput { + ok: bool, + data: AuthTestData, +} + +#[derive(Serialize)] +struct AuthTestData { + authenticated: bool, + username: String, + name: String, + gitlab_url: String, +} + +async fn handle_auth_test( + config_override: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { match run_auth_test(config_override).await { Ok(result) => { - println!("Authenticated as @{} ({})", result.username, result.name); - println!("GitLab: {}", result.base_url); + if robot_mode { + let output = AuthTestOutput { + ok: true, + data: AuthTestData { + authenticated: true, + username: result.username.clone(), + name: result.name.clone(), + gitlab_url: result.base_url.clone(), + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else { + println!("Authenticated as @{} ({})", result.username, result.name); + println!("GitLab: {}", result.base_url); + } Ok(()) } Err(e) => { - eprintln!("{}", style(format!("Error: {e}")).red()); - std::process::exit(1); + if robot_mode { + let output = FallbackErrorOutput { + error: FallbackError { + code: "AUTH_FAILED".to_string(), + message: e.to_string(), + }, + }; + eprintln!("{}", serde_json::to_string(&output)?); + } else { + eprintln!("{}", style(format!("Error: {e}")).red()); + } + std::process::exit(5); // AUTH_FAILED exit code } } } @@ -283,12 +412,17 @@ async fn handle_ingest( project_filter: Option<&str>, force: bool, full: bool, + robot_mode: bool, ) -> Result<(), Box> { let config = Config::load(config_override)?; - match run_ingest(&config, resource_type, project_filter, force, full).await { + match run_ingest(&config, resource_type, project_filter, force, full, robot_mode).await { Ok(result) => { - print_ingest_summary(&result); + if robot_mode { + print_ingest_summary_json(&result); + } else { + print_ingest_summary(&result); + } Ok(()) } Err(e) => { @@ -298,6 +432,7 @@ async fn handle_ingest( } } +#[allow(clippy::too_many_arguments)] async fn handle_list( config_override: Option<&str>, entity: &str, @@ -315,6 +450,11 @@ async fn handle_list( order: &str, open_browser: bool, json_output: bool, + draft: bool, + no_draft: bool, + reviewer_filter: Option<&str>, + target_branch_filter: Option<&str>, + source_branch_filter: Option<&str>, ) -> Result<(), Box> { let config = Config::load(config_override)?; @@ -348,7 +488,33 @@ async fn handle_list( Ok(()) } "mrs" => { - println!("MR listing not yet implemented. Only 'issues' is supported in CP1."); + let filters = MrListFilters { + limit, + project: project_filter, + state: state_filter, + author: author_filter, + assignee: assignee_filter, + reviewer: reviewer_filter, + labels: label_filter, + since: since_filter, + draft, + no_draft, + target_branch: target_branch_filter, + source_branch: source_branch_filter, + sort, + order, + }; + + let result = run_list_mrs(&config, filters)?; + + if open_browser { + open_mr_in_browser(&result); + } else if json_output { + print_list_mrs_json(&result); + } else { + print_list_mrs(&result); + } + Ok(()) } _ => { @@ -362,21 +528,31 @@ async fn handle_count( config_override: Option<&str>, entity: &str, type_filter: Option<&str>, + robot_mode: bool, ) -> Result<(), Box> { let config = Config::load(config_override)?; let result = run_count(&config, entity, type_filter)?; - print_count(&result); + if robot_mode { + print_count_json(&result); + } else { + print_count(&result); + } Ok(()) } async fn handle_sync_status( config_override: Option<&str>, + robot_mode: bool, ) -> Result<(), Box> { let config = Config::load(config_override)?; let result = run_sync_status(&config)?; - print_sync_status(&result); + if robot_mode { + print_sync_status_json(&result); + } else { + print_sync_status(&result); + } Ok(()) } @@ -385,17 +561,27 @@ async fn handle_show( entity: &str, iid: i64, project_filter: Option<&str>, + json: bool, ) -> Result<(), Box> { let config = Config::load(config_override)?; match entity { "issue" => { let result = run_show_issue(&config, iid, project_filter)?; - print_show_issue(&result); + if json { + print_show_issue_json(&result); + } else { + print_show_issue(&result); + } Ok(()) } "mr" => { - println!("MR details not yet implemented. Only 'issue' is supported in CP1."); + let result = run_show_mr(&config, iid, project_filter)?; + if json { + print_show_mr_json(&result); + } else { + print_show_mr(&result); + } Ok(()) } _ => { @@ -405,32 +591,159 @@ async fn handle_show( } } -async fn handle_migrate(config_override: Option<&str>) -> Result<(), Box> { +/// JSON output for version command. +#[derive(Serialize)] +struct VersionOutput { + ok: bool, + data: VersionData, +} + +#[derive(Serialize)] +struct VersionData { + version: String, +} + +fn handle_version(robot_mode: bool) -> Result<(), Box> { + let version = env!("CARGO_PKG_VERSION").to_string(); + if robot_mode { + let output = VersionOutput { + ok: true, + data: VersionData { version }, + }; + println!("{}", serde_json::to_string(&output)?); + } else { + println!("gi version {}", version); + } + Ok(()) +} + +/// JSON output for not-implemented commands. +#[derive(Serialize)] +struct NotImplementedOutput { + ok: bool, + data: NotImplementedData, +} + +#[derive(Serialize)] +struct NotImplementedData { + status: String, + command: String, +} + +fn handle_backup(robot_mode: bool) -> Result<(), Box> { + if robot_mode { + let output = NotImplementedOutput { + ok: true, + data: NotImplementedData { + status: "not_implemented".to_string(), + command: "backup".to_string(), + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else { + println!("gi backup - not yet implemented"); + } + Ok(()) +} + +fn handle_reset(robot_mode: bool) -> Result<(), Box> { + if robot_mode { + let output = NotImplementedOutput { + ok: true, + data: NotImplementedData { + status: "not_implemented".to_string(), + command: "reset".to_string(), + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else { + println!("gi reset - not yet implemented"); + } + Ok(()) +} + +/// JSON output for migrate command. +#[derive(Serialize)] +struct MigrateOutput { + ok: bool, + data: MigrateData, +} + +#[derive(Serialize)] +struct MigrateData { + before_version: i32, + after_version: i32, + migrated: bool, +} + +/// JSON error output with suggestion field. +#[derive(Serialize)] +struct RobotErrorWithSuggestion { + error: RobotErrorSuggestionData, +} + +#[derive(Serialize)] +struct RobotErrorSuggestionData { + code: String, + message: String, + suggestion: String, +} + +async fn handle_migrate( + config_override: Option<&str>, + robot_mode: bool, +) -> Result<(), Box> { let config = Config::load(config_override)?; let db_path = get_db_path(config.storage.db_path.as_deref()); if !db_path.exists() { - eprintln!( - "{}", - style(format!("Database not found at {}", db_path.display())).red() - ); - eprintln!("{}", style("Run 'gi init' first to create the database.").yellow()); - std::process::exit(1); + if robot_mode { + let output = RobotErrorWithSuggestion { + error: RobotErrorSuggestionData { + code: "DB_ERROR".to_string(), + message: format!("Database not found at {}", db_path.display()), + suggestion: "Run 'gi init' first".to_string(), + }, + }; + eprintln!("{}", serde_json::to_string(&output)?); + } else { + eprintln!( + "{}", + style(format!("Database not found at {}", db_path.display())).red() + ); + eprintln!( + "{}", + style("Run 'gi init' first to create the database.").yellow() + ); + } + std::process::exit(10); // DB_ERROR exit code } let conn = create_connection(&db_path)?; let before_version = get_schema_version(&conn); - println!( - "{}", - style(format!("Current schema version: {}", before_version)).blue() - ); + if !robot_mode { + println!( + "{}", + style(format!("Current schema version: {}", before_version)).blue() + ); + } run_migrations(&conn)?; let after_version = get_schema_version(&conn); - if after_version > before_version { + if robot_mode { + let output = MigrateOutput { + ok: true, + data: MigrateData { + before_version, + after_version, + migrated: after_version > before_version, + }, + }; + println!("{}", serde_json::to_string(&output)?); + } else if after_version > before_version { println!( "{}", style(format!(