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 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-01-26 22:46:27 -05:00
parent 5fe76e46a3
commit 7d0d586932
2 changed files with 410 additions and 60 deletions

View File

@@ -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<String>,
/// 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<String>,
/// 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<String>,
/// Filter by author username
@@ -108,7 +121,7 @@ pub enum Commands {
#[arg(long)]
label: Option<Vec<String>>,
/// Filter by milestone title
/// Filter by milestone title (issues only)
#[arg(long)]
milestone: Option<String>,
@@ -116,11 +129,11 @@ pub enum Commands {
#[arg(long)]
since: Option<String>,
/// 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<String>,
/// 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<String>,
/// Filter by target branch (MRs only)
#[arg(long)]
target_branch: Option<String>,
/// Filter by source branch (MRs only)
#[arg(long)]
source_branch: Option<String>,
},
/// 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<String>,
/// Output as JSON
#[arg(long)]
json: bool,
},
}

View File

@@ -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<dyn std::error::Error>, robot_mode: bool) -> ! {
// Try to downcast to GiError for structured output
if let Some(gi_error) = e.downcast_ref::<GiError>() {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
/// 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
/// 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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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!(