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,
},
}