feat(cli): Improve help text, error handling, and add fuzzy command suggestions

CLI help improvements (cli/mod.rs):
- Add descriptive help text to all global flags (-c, --robot, -J, etc.)
- Add descriptions to all subcommands (Issues, Mrs, Sync, etc.)
- Add --no-quiet flag for explicit quiet override
- Shell completions now shows installation instructions for each shell
- Optional subcommand: running bare 'lore' shows help in terminal mode,
  robot-docs in robot mode

Structured clap error handling (main.rs):
- Early robot mode detection before parsing (env + args)
- JSON error output for parse failures in robot mode
- Semantic error codes: UNKNOWN_COMMAND, UNKNOWN_FLAG, MISSING_REQUIRED,
  INVALID_VALUE, ARGUMENT_CONFLICT, etc.
- Fuzzy command suggestion using Jaro-Winkler similarity (>0.7 threshold)
- Help/version requests handled normally (exit 0, not error)

Robot-docs enhancements (main.rs):
- Document deprecated command aliases (list issues -> issues, etc.)
- Document clap error codes for programmatic error handling
- Include completions command in manifest
- Update flag documentation to show short forms (-n, -s, -p, etc.)

Dependencies:
- Add strsim 0.11 for Jaro-Winkler fuzzy matching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-05 11:22:38 -05:00
parent ab43bbd2db
commit c730b0ec54
5 changed files with 519 additions and 64 deletions

1
Cargo.lock generated
View File

@@ -1129,6 +1129,7 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"sqlite-vec", "sqlite-vec",
"strsim",
"tempfile", "tempfile",
"thiserror", "thiserror",
"tokio", "tokio",

View File

@@ -47,6 +47,7 @@ flate2 = "1"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
regex = "1" regex = "1"
strsim = "0.11"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"

View File

@@ -17,10 +17,13 @@ pub use count::{
print_count, print_count_json, print_event_count, print_event_count_json, run_count, print_count, print_count_json, print_event_count, print_event_count_json, run_count,
run_count_events, run_count_events,
}; };
pub use doctor::{print_doctor_results, run_doctor}; pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
pub use embed::{print_embed, print_embed_json, run_embed}; pub use embed::{print_embed, print_embed_json, run_embed};
pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs}; pub use generate_docs::{print_generate_docs, print_generate_docs_json, run_generate_docs};
pub use ingest::{IngestDisplay, print_ingest_summary, print_ingest_summary_json, run_ingest}; pub use ingest::{
DryRunPreview, IngestDisplay, print_dry_run_preview, print_dry_run_preview_json,
print_ingest_summary, print_ingest_summary_json, run_ingest, run_ingest_dry_run,
};
pub use init::{InitInputs, InitOptions, InitResult, run_init}; pub use init::{InitInputs, InitOptions, InitResult, run_init};
pub use list::{ pub use list::{
ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues, ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues,

View File

@@ -6,71 +6,127 @@ use std::io::IsTerminal;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "lore")] #[command(name = "lore")]
#[command(version, about, long_about = None)] #[command(version, about = "Local GitLab data management with semantic search", long_about = None)]
#[command(subcommand_required = false)]
pub struct Cli { pub struct Cli {
#[arg(short = 'c', long, global = true)] /// Path to config file
#[arg(short = 'c', long, global = true, help = "Path to config file")]
pub config: Option<String>, pub config: Option<String>,
#[arg(long, global = true, env = "LORE_ROBOT")] /// Machine-readable JSON output (auto-enabled when piped)
#[arg(
long,
global = true,
env = "LORE_ROBOT",
help = "Machine-readable JSON output (auto-enabled when piped)"
)]
pub robot: bool, pub robot: bool,
#[arg(short = 'J', long = "json", global = true)] /// JSON output (global shorthand)
#[arg(
short = 'J',
long = "json",
global = true,
help = "JSON output (global shorthand)"
)]
pub json: bool, pub json: bool,
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto")] /// Color output: auto (default), always, or never
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto", help = "Color output: auto (default), always, or never")]
pub color: String, pub color: String,
#[arg(short = 'q', long, global = true)] /// Suppress non-essential output
#[arg(
short = 'q',
long,
global = true,
overrides_with = "no_quiet",
help = "Suppress non-essential output"
)]
pub quiet: bool, pub quiet: bool,
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)] #[arg(
long = "no-quiet",
global = true,
hide = true,
overrides_with = "quiet"
)]
pub no_quiet: bool,
/// Increase log verbosity (-v, -vv, -vvv)
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true, help = "Increase log verbosity (-v, -vv, -vvv)")]
pub verbose: u8, pub verbose: u8,
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")] /// Log format for stderr output: text (default) or json
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text", help = "Log format for stderr output: text (default) or json")]
pub log_format: String, pub log_format: String,
#[command(subcommand)] #[command(subcommand)]
pub command: Commands, pub command: Option<Commands>,
} }
impl Cli { impl Cli {
pub fn is_robot_mode(&self) -> bool { pub fn is_robot_mode(&self) -> bool {
self.robot || self.json || !std::io::stdout().is_terminal() self.robot || self.json || !std::io::stdout().is_terminal()
} }
/// Detect robot mode from environment before parsing succeeds.
/// Used for structured error output when clap parsing fails.
pub fn detect_robot_mode_from_env() -> bool {
let args: Vec<String> = std::env::args().collect();
args.iter()
.any(|a| a == "--robot" || a == "-J" || a == "--json")
|| std::env::var("LORE_ROBOT").is_ok()
|| !std::io::stdout().is_terminal()
}
} }
#[derive(Subcommand)] #[derive(Subcommand)]
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
pub enum Commands { pub enum Commands {
/// List or show issues
Issues(IssuesArgs), Issues(IssuesArgs),
/// List or show merge requests
Mrs(MrsArgs), Mrs(MrsArgs),
/// Ingest data from GitLab
Ingest(IngestArgs), Ingest(IngestArgs),
/// Count entities in local database
Count(CountArgs), Count(CountArgs),
/// Show sync state
Status, Status,
/// Verify GitLab authentication
Auth, Auth,
/// Check environment health
Doctor, Doctor,
/// Show version information
Version, Version,
/// Initialize configuration and database
Init { Init {
/// Skip overwrite confirmation
#[arg(short = 'f', long)] #[arg(short = 'f', long)]
force: bool, force: bool,
/// Fail if prompts would be shown
#[arg(long)] #[arg(long)]
non_interactive: bool, non_interactive: bool,
/// GitLab base URL (required in robot mode)
#[arg(long)] #[arg(long)]
gitlab_url: Option<String>, gitlab_url: Option<String>,
/// Environment variable name holding GitLab token (required in robot mode)
#[arg(long)] #[arg(long)]
token_env_var: Option<String>, token_env_var: Option<String>,
/// Comma-separated project paths (required in robot mode)
#[arg(long)] #[arg(long)]
projects: Option<String>, projects: Option<String>,
}, },
@@ -84,26 +140,41 @@ pub enum Commands {
yes: bool, yes: bool,
}, },
/// Search indexed documents
Search(SearchArgs), Search(SearchArgs),
/// Show document and index statistics
Stats(StatsArgs), Stats(StatsArgs),
/// Generate searchable documents from ingested data
#[command(name = "generate-docs")] #[command(name = "generate-docs")]
GenerateDocs(GenerateDocsArgs), GenerateDocs(GenerateDocsArgs),
/// Generate vector embeddings for documents via Ollama
Embed(EmbedArgs), Embed(EmbedArgs),
/// Run full sync pipeline: ingest -> generate-docs -> embed
Sync(SyncArgs), Sync(SyncArgs),
/// Run pending database migrations
Migrate, Migrate,
/// Quick health check: config, database, schema version
Health, Health,
/// Machine-readable command manifest for agent self-discovery
#[command(name = "robot-docs")] #[command(name = "robot-docs")]
RobotDocs, RobotDocs,
#[command(hide = true)] /// Generate shell completions
#[command(long_about = "Generate shell completions for lore.\n\n\
Installation:\n \
bash: lore completions bash > ~/.local/share/bash-completion/completions/lore\n \
zsh: lore completions zsh > ~/.zfunc/_lore && echo 'fpath+=~/.zfunc' >> ~/.zshrc\n \
fish: lore completions fish > ~/.config/fish/completions/lore.fish\n \
pwsh: lore completions powershell >> $PROFILE")]
Completions { Completions {
/// Shell to generate completions for
#[arg(value_parser = ["bash", "zsh", "fish", "powershell"])] #[arg(value_parser = ["bash", "zsh", "fish", "powershell"])]
shell: String, shell: String,
}, },
@@ -171,8 +242,10 @@ pub enum Commands {
#[derive(Parser)] #[derive(Parser)]
pub struct IssuesArgs { pub struct IssuesArgs {
/// Issue IID (omit to list, provide to show details)
pub iid: Option<i64>, pub iid: Option<i64>,
/// Maximum results
#[arg( #[arg(
short = 'n', short = 'n',
long = "limit", long = "limit",
@@ -181,30 +254,43 @@ pub struct IssuesArgs {
)] )]
pub limit: usize, pub limit: usize,
/// Select output fields (comma-separated: iid,title,state,author,labels,updated)
#[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>,
/// Filter by state (opened, closed, all)
#[arg(short = 's', long, help_heading = "Filters")] #[arg(short = 's', long, help_heading = "Filters")]
pub state: Option<String>, pub state: Option<String>,
/// Filter by project path
#[arg(short = 'p', long, help_heading = "Filters")] #[arg(short = 'p', long, help_heading = "Filters")]
pub project: Option<String>, pub project: Option<String>,
/// Filter by author username
#[arg(short = 'a', long, help_heading = "Filters")] #[arg(short = 'a', long, help_heading = "Filters")]
pub author: Option<String>, pub author: Option<String>,
/// Filter by assignee username
#[arg(short = 'A', long, help_heading = "Filters")] #[arg(short = 'A', long, help_heading = "Filters")]
pub assignee: Option<String>, pub assignee: Option<String>,
/// Filter by label (repeatable, AND logic)
#[arg(short = 'l', long, help_heading = "Filters")] #[arg(short = 'l', long, help_heading = "Filters")]
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
/// Filter by milestone title
#[arg(short = 'm', long, help_heading = "Filters")] #[arg(short = 'm', long, help_heading = "Filters")]
pub milestone: Option<String>, pub milestone: Option<String>,
/// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub since: Option<String>, pub since: Option<String>,
/// Filter by due date (before this date, YYYY-MM-DD)
#[arg(long = "due-before", help_heading = "Filters")] #[arg(long = "due-before", help_heading = "Filters")]
pub due_before: Option<String>, pub due_before: Option<String>,
/// Show only issues with a due date
#[arg( #[arg(
long = "has-due", long = "has-due",
help_heading = "Filters", help_heading = "Filters",
@@ -215,15 +301,18 @@ pub struct IssuesArgs {
#[arg(long = "no-has-due", hide = true, overrides_with = "has_due")] #[arg(long = "no-has-due", hide = true, overrides_with = "has_due")]
pub no_has_due: bool, pub no_has_due: bool,
/// Sort field (updated, created, iid)
#[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")]
pub sort: String, pub sort: String,
/// Sort ascending (default: descending)
#[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")]
pub asc: bool, pub asc: bool,
#[arg(long = "no-asc", hide = true, overrides_with = "asc")] #[arg(long = "no-asc", hide = true, overrides_with = "asc")]
pub no_asc: bool, pub no_asc: bool,
/// Open first matching item in browser
#[arg( #[arg(
short = 'o', short = 'o',
long, long,
@@ -238,8 +327,10 @@ pub struct IssuesArgs {
#[derive(Parser)] #[derive(Parser)]
pub struct MrsArgs { pub struct MrsArgs {
/// MR IID (omit to list, provide to show details)
pub iid: Option<i64>, pub iid: Option<i64>,
/// Maximum results
#[arg( #[arg(
short = 'n', short = 'n',
long = "limit", long = "limit",
@@ -248,27 +339,39 @@ pub struct MrsArgs {
)] )]
pub limit: usize, pub limit: usize,
/// Select output fields (comma-separated: iid,title,state,author,labels,updated)
#[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>,
/// Filter by state (opened, merged, closed, locked, all)
#[arg(short = 's', long, help_heading = "Filters")] #[arg(short = 's', long, help_heading = "Filters")]
pub state: Option<String>, pub state: Option<String>,
/// Filter by project path
#[arg(short = 'p', long, help_heading = "Filters")] #[arg(short = 'p', long, help_heading = "Filters")]
pub project: Option<String>, pub project: Option<String>,
/// Filter by author username
#[arg(short = 'a', long, help_heading = "Filters")] #[arg(short = 'a', long, help_heading = "Filters")]
pub author: Option<String>, pub author: Option<String>,
/// Filter by assignee username
#[arg(short = 'A', long, help_heading = "Filters")] #[arg(short = 'A', long, help_heading = "Filters")]
pub assignee: Option<String>, pub assignee: Option<String>,
/// Filter by reviewer username
#[arg(short = 'r', long, help_heading = "Filters")] #[arg(short = 'r', long, help_heading = "Filters")]
pub reviewer: Option<String>, pub reviewer: Option<String>,
/// Filter by label (repeatable, AND logic)
#[arg(short = 'l', long, help_heading = "Filters")] #[arg(short = 'l', long, help_heading = "Filters")]
pub label: Option<Vec<String>>, pub label: Option<Vec<String>>,
/// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub since: Option<String>, pub since: Option<String>,
/// Show only draft MRs
#[arg( #[arg(
short = 'd', short = 'd',
long, long,
@@ -277,6 +380,7 @@ pub struct MrsArgs {
)] )]
pub draft: bool, pub draft: bool,
/// Exclude draft MRs
#[arg( #[arg(
short = 'D', short = 'D',
long = "no-draft", long = "no-draft",
@@ -285,21 +389,26 @@ pub struct MrsArgs {
)] )]
pub no_draft: bool, pub no_draft: bool,
/// Filter by target branch
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub target: Option<String>, pub target: Option<String>,
/// Filter by source branch
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub source: Option<String>, pub source: Option<String>,
/// Sort field (updated, created, iid)
#[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")] #[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")]
pub sort: String, pub sort: String,
/// Sort ascending (default: descending)
#[arg(long, help_heading = "Sorting", overrides_with = "no_asc")] #[arg(long, help_heading = "Sorting", overrides_with = "no_asc")]
pub asc: bool, pub asc: bool,
#[arg(long = "no-asc", hide = true, overrides_with = "asc")] #[arg(long = "no-asc", hide = true, overrides_with = "asc")]
pub no_asc: bool, pub no_asc: bool,
/// Open first matching item in browser
#[arg( #[arg(
short = 'o', short = 'o',
long, long,
@@ -314,65 +423,95 @@ pub struct MrsArgs {
#[derive(Parser)] #[derive(Parser)]
pub struct IngestArgs { pub struct IngestArgs {
/// Entity to ingest (issues, mrs). Omit to ingest everything
#[arg(value_parser = ["issues", "mrs"])] #[arg(value_parser = ["issues", "mrs"])]
pub entity: Option<String>, pub entity: Option<String>,
/// Filter to single project
#[arg(short = 'p', long)] #[arg(short = 'p', long)]
pub project: Option<String>, pub project: Option<String>,
/// Override stale sync lock
#[arg(short = 'f', long, overrides_with = "no_force")] #[arg(short = 'f', long, overrides_with = "no_force")]
pub force: bool, pub force: bool,
#[arg(long = "no-force", hide = true, overrides_with = "force")] #[arg(long = "no-force", hide = true, overrides_with = "force")]
pub no_force: bool, pub no_force: bool,
/// Full re-sync: reset cursors and fetch all data from scratch
#[arg(long, overrides_with = "no_full")] #[arg(long, overrides_with = "no_full")]
pub full: bool, pub full: bool,
#[arg(long = "no-full", hide = true, overrides_with = "full")] #[arg(long = "no-full", hide = true, overrides_with = "full")]
pub no_full: bool, pub no_full: bool,
/// Preview what would be synced without making changes
#[arg(long, overrides_with = "no_dry_run")]
pub dry_run: bool,
#[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")]
pub no_dry_run: bool,
} }
#[derive(Parser)] #[derive(Parser)]
pub struct StatsArgs { pub struct StatsArgs {
/// Run integrity checks
#[arg(long, overrides_with = "no_check")] #[arg(long, overrides_with = "no_check")]
pub check: bool, pub check: bool,
#[arg(long = "no-check", hide = true, overrides_with = "check")] #[arg(long = "no-check", hide = true, overrides_with = "check")]
pub no_check: bool, pub no_check: bool,
/// Repair integrity issues (auto-enables --check)
#[arg(long)] #[arg(long)]
pub repair: bool, pub repair: bool,
/// Preview what would be repaired without making changes (requires --repair)
#[arg(long, overrides_with = "no_dry_run")]
pub dry_run: bool,
#[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")]
pub no_dry_run: bool,
} }
#[derive(Parser)] #[derive(Parser)]
pub struct SearchArgs { pub struct SearchArgs {
/// Search query string
pub query: String, pub query: String,
/// Search mode (lexical, hybrid, semantic)
#[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Output")] #[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Output")]
pub mode: String, pub mode: String,
/// Filter by source type (issue, mr, discussion)
#[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion"], help_heading = "Filters")] #[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion"], help_heading = "Filters")]
pub source_type: Option<String>, pub source_type: Option<String>,
/// Filter by author username
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub author: Option<String>, pub author: Option<String>,
/// Filter by project path
#[arg(short = 'p', long, help_heading = "Filters")] #[arg(short = 'p', long, help_heading = "Filters")]
pub project: Option<String>, pub project: Option<String>,
/// Filter by label (repeatable, AND logic)
#[arg(long, action = clap::ArgAction::Append, help_heading = "Filters")] #[arg(long, action = clap::ArgAction::Append, help_heading = "Filters")]
pub label: Vec<String>, pub label: Vec<String>,
/// Filter by file path (trailing / for prefix match)
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub path: Option<String>, pub path: Option<String>,
/// Filter by created after (7d, 2w, or YYYY-MM-DD)
#[arg(long, help_heading = "Filters")] #[arg(long, help_heading = "Filters")]
pub after: Option<String>, pub after: Option<String>,
/// Filter by updated after (7d, 2w, or YYYY-MM-DD)
#[arg(long = "updated-after", help_heading = "Filters")] #[arg(long = "updated-after", help_heading = "Filters")]
pub updated_after: Option<String>, pub updated_after: Option<String>,
/// Maximum results (default 20, max 100)
#[arg( #[arg(
short = 'n', short = 'n',
long = "limit", long = "limit",
@@ -381,57 +520,75 @@ pub struct SearchArgs {
)] )]
pub limit: usize, pub limit: usize,
/// Show ranking explanation per result
#[arg(long, help_heading = "Output", overrides_with = "no_explain")] #[arg(long, help_heading = "Output", overrides_with = "no_explain")]
pub explain: bool, pub explain: bool,
#[arg(long = "no-explain", hide = true, overrides_with = "explain")] #[arg(long = "no-explain", hide = true, overrides_with = "explain")]
pub no_explain: bool, pub no_explain: bool,
/// FTS query mode: safe (default) or raw
#[arg(long = "fts-mode", default_value = "safe", value_parser = ["safe", "raw"], help_heading = "Output")] #[arg(long = "fts-mode", default_value = "safe", value_parser = ["safe", "raw"], help_heading = "Output")]
pub fts_mode: String, pub fts_mode: String,
} }
#[derive(Parser)] #[derive(Parser)]
pub struct GenerateDocsArgs { pub struct GenerateDocsArgs {
/// Full rebuild: seed all entities into dirty queue, then drain
#[arg(long)] #[arg(long)]
pub full: bool, pub full: bool,
/// Filter to single project
#[arg(short = 'p', long)] #[arg(short = 'p', long)]
pub project: Option<String>, pub project: Option<String>,
} }
#[derive(Parser)] #[derive(Parser)]
pub struct SyncArgs { pub struct SyncArgs {
/// Reset cursors, fetch everything
#[arg(long, overrides_with = "no_full")] #[arg(long, overrides_with = "no_full")]
pub full: bool, pub full: bool,
#[arg(long = "no-full", hide = true, overrides_with = "full")] #[arg(long = "no-full", hide = true, overrides_with = "full")]
pub no_full: bool, pub no_full: bool,
/// Override stale lock
#[arg(long, overrides_with = "no_force")] #[arg(long, overrides_with = "no_force")]
pub force: bool, pub force: bool,
#[arg(long = "no-force", hide = true, overrides_with = "force")] #[arg(long = "no-force", hide = true, overrides_with = "force")]
pub no_force: bool, pub no_force: bool,
/// Skip embedding step
#[arg(long)] #[arg(long)]
pub no_embed: bool, pub no_embed: bool,
/// Skip document regeneration
#[arg(long)] #[arg(long)]
pub no_docs: bool, pub no_docs: bool,
/// Skip resource event fetching (overrides config)
#[arg(long = "no-events")] #[arg(long = "no-events")]
pub no_events: bool, pub no_events: bool,
/// Preview what would be synced without making changes
#[arg(long, overrides_with = "no_dry_run")]
pub dry_run: bool,
#[arg(long = "no-dry-run", hide = true, overrides_with = "dry_run")]
pub no_dry_run: bool,
} }
#[derive(Parser)] #[derive(Parser)]
pub struct EmbedArgs { pub struct EmbedArgs {
/// Re-embed all documents (clears existing embeddings first)
#[arg(long, overrides_with = "no_full")] #[arg(long, overrides_with = "no_full")]
pub full: bool, pub full: bool,
#[arg(long = "no-full", hide = true, overrides_with = "full")] #[arg(long = "no-full", hide = true, overrides_with = "full")]
pub no_full: bool, pub no_full: bool,
/// Retry previously failed embeddings
#[arg(long, overrides_with = "no_retry_failed")] #[arg(long, overrides_with = "no_retry_failed")]
pub retry_failed: bool, pub retry_failed: bool,
@@ -441,9 +598,11 @@ pub struct EmbedArgs {
#[derive(Parser)] #[derive(Parser)]
pub struct CountArgs { pub struct CountArgs {
/// Entity type to count (issues, mrs, discussions, notes, events)
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])] #[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
pub entity: String, pub entity: String,
/// Parent type filter: issue or mr (for discussions/notes)
#[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])] #[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])]
pub for_entity: Option<String>, pub for_entity: Option<String>,
} }

View File

@@ -2,6 +2,7 @@ use clap::Parser;
use console::style; use console::style;
use dialoguer::{Confirm, Input}; use dialoguer::{Confirm, Input};
use serde::Serialize; use serde::Serialize;
use strsim::jaro_winkler;
use tracing_subscriber::Layer; use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
@@ -10,13 +11,14 @@ use lore::Config;
use lore::cli::commands::{ use lore::cli::commands::{
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters, IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
SearchCliFilters, SyncOptions, open_issue_in_browser, open_mr_in_browser, print_count, SearchCliFilters, SyncOptions, open_issue_in_browser, open_mr_in_browser, print_count,
print_count_json, print_doctor_results, print_embed, print_embed_json, print_event_count, print_count_json, print_doctor_results, print_dry_run_preview, print_dry_run_preview_json,
print_event_count_json, print_generate_docs, print_generate_docs_json, print_ingest_summary, print_embed, print_embed_json, print_event_count, print_event_count_json, print_generate_docs,
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues,
print_list_mrs_json, print_search_results, print_search_results_json, print_show_issue, print_list_issues_json, print_list_mrs, print_list_mrs_json, print_search_results,
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json, print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr,
print_sync, print_sync_json, print_sync_status, print_sync_status_json, run_auth_test, print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
run_count, run_count_events, run_doctor, run_embed, run_generate_docs, run_ingest, run_init, print_sync_status, print_sync_status_json, run_auth_test, run_count, run_count_events,
run_doctor, run_embed, run_generate_docs, run_ingest, run_ingest_dry_run, run_init,
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_sync_status,
}; };
@@ -40,7 +42,15 @@ async fn main() {
libc::signal(libc::SIGPIPE, libc::SIG_DFL); libc::signal(libc::SIGPIPE, libc::SIG_DFL);
} }
let cli = Cli::parse(); // Phase 1: Early robot mode detection for structured clap errors
let robot_mode_early = Cli::detect_robot_mode_from_env();
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
handle_clap_error(e, robot_mode_early);
}
};
let robot_mode = cli.is_robot_mode(); let robot_mode = cli.is_robot_mode();
let logging_config = lore::Config::load(cli.config.as_deref()) let logging_config = lore::Config::load(cli.config.as_deref())
@@ -127,15 +137,29 @@ async fn main() {
let quiet = cli.quiet; 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), // Phase 2: Handle no-args case - in robot mode, output robot-docs; otherwise show help
Commands::Mrs(args) => handle_mrs(cli.config.as_deref(), args, robot_mode), None => {
Commands::Search(args) => handle_search(cli.config.as_deref(), args, robot_mode).await, if robot_mode {
Commands::Stats(args) => handle_stats(cli.config.as_deref(), args, robot_mode).await, handle_robot_docs(robot_mode)
Commands::Embed(args) => handle_embed(cli.config.as_deref(), args, robot_mode).await, } else {
Commands::Sync(args) => { use clap::CommandFactory;
let mut cmd = Cli::command();
cmd.print_help().ok();
println!();
Ok(())
}
}
Some(Commands::Issues(args)) => handle_issues(cli.config.as_deref(), args, robot_mode),
Some(Commands::Mrs(args)) => handle_mrs(cli.config.as_deref(), args, robot_mode),
Some(Commands::Search(args)) => {
handle_search(cli.config.as_deref(), args, robot_mode).await
}
Some(Commands::Stats(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
Some(Commands::Embed(args)) => handle_embed(cli.config.as_deref(), args, robot_mode).await,
Some(Commands::Sync(args)) => {
handle_sync_cmd(cli.config.as_deref(), args, robot_mode, &metrics_layer).await handle_sync_cmd(cli.config.as_deref(), args, robot_mode, &metrics_layer).await
} }
Commands::Ingest(args) => { Some(Commands::Ingest(args)) => {
handle_ingest( handle_ingest(
cli.config.as_deref(), cli.config.as_deref(),
args, args,
@@ -145,19 +169,19 @@ async fn main() {
) )
.await .await
} }
Commands::Count(args) => handle_count(cli.config.as_deref(), args, robot_mode).await, Some(Commands::Count(args)) => handle_count(cli.config.as_deref(), args, robot_mode).await,
Commands::Status => handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await, Some(Commands::Status) => handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await,
Commands::Auth => handle_auth_test(cli.config.as_deref(), robot_mode).await, Some(Commands::Auth) => handle_auth_test(cli.config.as_deref(), robot_mode).await,
Commands::Doctor => handle_doctor(cli.config.as_deref(), robot_mode).await, Some(Commands::Doctor) => handle_doctor(cli.config.as_deref(), robot_mode).await,
Commands::Version => handle_version(robot_mode), Some(Commands::Version) => handle_version(robot_mode),
Commands::Completions { shell } => handle_completions(&shell), Some(Commands::Completions { shell }) => handle_completions(&shell),
Commands::Init { Some(Commands::Init {
force, force,
non_interactive, non_interactive,
gitlab_url, gitlab_url,
token_env_var, token_env_var,
projects, projects,
} => { }) => {
handle_init( handle_init(
cli.config.as_deref(), cli.config.as_deref(),
force, force,
@@ -169,16 +193,16 @@ async fn main() {
) )
.await .await
} }
Commands::GenerateDocs(args) => { Some(Commands::GenerateDocs(args)) => {
handle_generate_docs(cli.config.as_deref(), args, robot_mode).await handle_generate_docs(cli.config.as_deref(), args, robot_mode).await
} }
Commands::Backup => handle_backup(robot_mode), Some(Commands::Backup) => handle_backup(robot_mode),
Commands::Reset { yes: _ } => handle_reset(robot_mode), Some(Commands::Reset { yes: _ }) => handle_reset(robot_mode),
Commands::Migrate => handle_migrate(cli.config.as_deref(), robot_mode).await, Some(Commands::Migrate) => handle_migrate(cli.config.as_deref(), robot_mode).await,
Commands::Health => handle_health(cli.config.as_deref(), robot_mode).await, Some(Commands::Health) => handle_health(cli.config.as_deref(), robot_mode).await,
Commands::RobotDocs => handle_robot_docs(robot_mode), Some(Commands::RobotDocs) => handle_robot_docs(robot_mode),
Commands::List { Some(Commands::List {
entity, entity,
limit, limit,
project, project,
@@ -198,7 +222,7 @@ async fn main() {
reviewer, reviewer,
target_branch, target_branch,
source_branch, source_branch,
} => { }) => {
if !robot_mode { if !robot_mode {
eprintln!( eprintln!(
"{}", "{}",
@@ -231,11 +255,11 @@ async fn main() {
) )
.await .await
} }
Commands::Show { Some(Commands::Show {
entity, entity,
iid, iid,
project, project,
} => { }) => {
if !robot_mode { if !robot_mode {
eprintln!( eprintln!(
"{}", "{}",
@@ -255,7 +279,7 @@ async fn main() {
) )
.await .await
} }
Commands::AuthTest => { Some(Commands::AuthTest) => {
if !robot_mode { if !robot_mode {
eprintln!( eprintln!(
"{}", "{}",
@@ -264,7 +288,7 @@ async fn main() {
} }
handle_auth_test(cli.config.as_deref(), robot_mode).await handle_auth_test(cli.config.as_deref(), robot_mode).await
} }
Commands::SyncStatus => { Some(Commands::SyncStatus) => {
if !robot_mode { if !robot_mode {
eprintln!( eprintln!(
"{}", "{}",
@@ -338,11 +362,143 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
std::process::exit(1); std::process::exit(1);
} }
/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode.
/// Also includes fuzzy command matching to suggest similar commands.
fn handle_clap_error(e: clap::Error, robot_mode: bool) -> ! {
use clap::error::ErrorKind;
// Always let clap handle --help and --version normally (print and exit 0).
// These are intentional user actions, not errors, even when stdout is redirected.
if matches!(e.kind(), ErrorKind::DisplayHelp | ErrorKind::DisplayVersion) {
e.exit()
}
if robot_mode {
let error_code = map_clap_error_kind(e.kind());
let message = e
.to_string()
.lines()
.next()
.unwrap_or("Parse error")
.to_string();
// Phase 4: Try to suggest similar command for unknown commands
let suggestion = if e.kind() == ErrorKind::InvalidSubcommand {
if let Some(invalid_cmd) = extract_invalid_subcommand(&e) {
suggest_similar_command(&invalid_cmd)
} else {
"Run 'lore robot-docs' for valid commands".to_string()
}
} else {
"Run 'lore robot-docs' for valid commands".to_string()
};
let output = RobotErrorWithSuggestion {
error: RobotErrorSuggestionData {
code: error_code.to_string(),
message,
suggestion,
},
};
eprintln!(
"{}",
serde_json::to_string(&output).unwrap_or_else(|_| {
r#"{"error":{"code":"PARSE_ERROR","message":"Parse error"}}"#.to_string()
})
);
std::process::exit(2);
} else {
e.exit()
}
}
/// Map clap ErrorKind to semantic error codes
fn map_clap_error_kind(kind: clap::error::ErrorKind) -> &'static str {
use clap::error::ErrorKind;
match kind {
ErrorKind::InvalidSubcommand => "UNKNOWN_COMMAND",
ErrorKind::UnknownArgument => "UNKNOWN_FLAG",
ErrorKind::MissingRequiredArgument => "MISSING_REQUIRED",
ErrorKind::InvalidValue => "INVALID_VALUE",
ErrorKind::ValueValidation => "INVALID_VALUE",
ErrorKind::TooManyValues => "TOO_MANY_VALUES",
ErrorKind::TooFewValues => "TOO_FEW_VALUES",
ErrorKind::ArgumentConflict => "ARGUMENT_CONFLICT",
ErrorKind::MissingSubcommand => "MISSING_COMMAND",
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => "HELP_REQUESTED",
_ => "PARSE_ERROR",
}
}
/// Extract the invalid subcommand from a clap error (Phase 4)
fn extract_invalid_subcommand(e: &clap::Error) -> Option<String> {
// Parse the error message to find the invalid subcommand
// Format is typically: "error: unrecognized subcommand 'foo'"
let msg = e.to_string();
if let Some(start) = msg.find('\'')
&& let Some(end) = msg[start + 1..].find('\'')
{
return Some(msg[start + 1..start + 1 + end].to_string());
}
None
}
/// Phase 4: Suggest similar command using fuzzy matching
fn suggest_similar_command(invalid: &str) -> String {
const VALID_COMMANDS: &[&str] = &[
"issues",
"mrs",
"search",
"sync",
"ingest",
"count",
"status",
"auth",
"doctor",
"version",
"init",
"stats",
"generate-docs",
"embed",
"migrate",
"health",
"robot-docs",
"completions",
];
let invalid_lower = invalid.to_lowercase();
// Find the best match using Jaro-Winkler similarity
let best_match = VALID_COMMANDS
.iter()
.map(|cmd| (*cmd, jaro_winkler(&invalid_lower, cmd)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
if let Some((cmd, score)) = best_match
&& score > 0.7
{
return format!(
"Did you mean 'lore {}'? Run 'lore robot-docs' for all commands",
cmd
);
}
"Run 'lore robot-docs' for valid commands".to_string()
}
fn handle_issues( fn handle_issues(
config_override: Option<&str>, config_override: Option<&str>,
args: IssuesArgs, args: IssuesArgs,
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// Warn about unimplemented --fields
if args.fields.is_some() && !robot_mode {
eprintln!(
"{}",
style("warning: --fields is not yet implemented, showing all fields").yellow()
);
}
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let asc = args.asc && !args.no_asc; let asc = args.asc && !args.no_asc;
let has_due = args.has_due && !args.no_has_due; let has_due = args.has_due && !args.no_has_due;
@@ -391,6 +547,14 @@ fn handle_mrs(
args: MrsArgs, args: MrsArgs,
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// Warn about unimplemented --fields
if args.fields.is_some() && !robot_mode {
eprintln!(
"{}",
style("warning: --fields is not yet implemented, showing all fields").yellow()
);
}
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let asc = args.asc && !args.no_asc; let asc = args.asc && !args.no_asc;
let open = args.open && !args.no_open; let open = args.open && !args.no_open;
@@ -442,16 +606,47 @@ async fn handle_ingest(
quiet: bool, quiet: bool,
metrics: &MetricsLayer, metrics: &MetricsLayer,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let dry_run = args.dry_run && !args.no_dry_run;
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let force = args.force && !args.no_force;
let full = args.full && !args.no_full;
// Handle dry run mode - show preview without making any changes
if dry_run {
match args.entity.as_deref() {
Some(resource_type) => {
let preview =
run_ingest_dry_run(&config, resource_type, args.project.as_deref(), full)?;
if robot_mode {
print_dry_run_preview_json(&preview);
} else {
print_dry_run_preview(&preview);
}
}
None => {
let issues_preview =
run_ingest_dry_run(&config, "issues", args.project.as_deref(), full)?;
let mrs_preview =
run_ingest_dry_run(&config, "mrs", args.project.as_deref(), full)?;
if robot_mode {
print_combined_dry_run_json(&issues_preview, &mrs_preview);
} else {
print_dry_run_preview(&issues_preview);
println!();
print_dry_run_preview(&mrs_preview);
}
}
}
return Ok(());
}
let display = if robot_mode || quiet { let display = if robot_mode || quiet {
IngestDisplay::silent() IngestDisplay::silent()
} else { } else {
IngestDisplay::interactive() IngestDisplay::interactive()
}; };
let force = args.force && !args.no_force;
let full = args.full && !args.no_full;
let entity_label = args.entity.as_deref().unwrap_or("all"); let entity_label = args.entity.as_deref().unwrap_or("all");
let command = format!("ingest:{entity_label}"); let command = format!("ingest:{entity_label}");
let db_path = get_db_path(config.storage.db_path.as_deref()); let db_path = get_db_path(config.storage.db_path.as_deref());
@@ -469,6 +664,7 @@ async fn handle_ingest(
args.project.as_deref(), args.project.as_deref(),
force, force,
full, full,
false,
display, display,
None, None,
) )
@@ -495,6 +691,7 @@ async fn handle_ingest(
args.project.as_deref(), args.project.as_deref(),
force, force,
full, full,
false,
display, display,
None, None,
) )
@@ -506,6 +703,7 @@ async fn handle_ingest(
args.project.as_deref(), args.project.as_deref(),
force, force,
full, full,
false,
display, display,
None, None,
) )
@@ -592,6 +790,35 @@ fn print_combined_ingest_json(
println!("{}", serde_json::to_string(&output).unwrap()); println!("{}", serde_json::to_string(&output).unwrap());
} }
#[derive(Serialize)]
struct CombinedDryRunOutput {
ok: bool,
dry_run: bool,
data: CombinedDryRunData,
}
#[derive(Serialize)]
struct CombinedDryRunData {
issues: lore::cli::commands::DryRunPreview,
merge_requests: lore::cli::commands::DryRunPreview,
}
fn print_combined_dry_run_json(
issues: &lore::cli::commands::DryRunPreview,
mrs: &lore::cli::commands::DryRunPreview,
) {
let output = CombinedDryRunOutput {
ok: true,
dry_run: true,
data: CombinedDryRunData {
issues: issues.clone(),
merge_requests: mrs.clone(),
},
};
println!("{}", serde_json::to_string(&output).unwrap());
}
async fn handle_count( async fn handle_count(
config_override: Option<&str>, config_override: Option<&str>,
args: CountArgs, args: CountArgs,
@@ -921,6 +1148,18 @@ async fn handle_auth_test(
} }
} }
#[derive(Serialize)]
struct DoctorOutput {
ok: bool,
data: DoctorData,
}
#[derive(Serialize)]
struct DoctorData {
success: bool,
checks: lore::cli::commands::DoctorChecks,
}
async fn handle_doctor( async fn handle_doctor(
config_override: Option<&str>, config_override: Option<&str>,
robot_mode: bool, robot_mode: bool,
@@ -928,7 +1167,14 @@ async fn handle_doctor(
let result = run_doctor(config_override).await; let result = run_doctor(config_override).await;
if robot_mode { if robot_mode {
println!("{}", serde_json::to_string_pretty(&result)?); let output = DoctorOutput {
ok: true,
data: DoctorData {
success: result.success,
checks: result.checks,
},
};
println!("{}", serde_json::to_string(&output)?);
} else { } else {
print_doctor_results(&result); print_doctor_results(&result);
} }
@@ -1133,9 +1379,10 @@ async fn handle_stats(
args: StatsArgs, args: StatsArgs,
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let dry_run = args.dry_run && !args.no_dry_run;
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let check = (args.check && !args.no_check) || args.repair; let check = (args.check && !args.no_check) || args.repair;
let result = run_stats(&config, check, args.repair)?; let result = run_stats(&config, check, args.repair, dry_run)?;
if robot_mode { if robot_mode {
print_stats_json(&result); print_stats_json(&result);
} else { } else {
@@ -1219,6 +1466,8 @@ async fn handle_sync_cmd(
robot_mode: bool, robot_mode: bool,
metrics: &MetricsLayer, metrics: &MetricsLayer,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let dry_run = args.dry_run && !args.no_dry_run;
let mut config = Config::load(config_override)?; let mut config = Config::load(config_override)?;
if args.no_events { if args.no_events {
config.sync.fetch_resource_events = false; config.sync.fetch_resource_events = false;
@@ -1230,8 +1479,15 @@ async fn handle_sync_cmd(
no_docs: args.no_docs, no_docs: args.no_docs,
no_events: args.no_events, no_events: args.no_events,
robot_mode, robot_mode,
dry_run,
}; };
// For dry_run, skip recording and just show the preview
if dry_run {
run_sync(&config, options, None).await?;
return Ok(());
}
let db_path = get_db_path(config.storage.db_path.as_deref()); let db_path = get_db_path(config.storage.db_path.as_deref());
let recorder_conn = create_connection(&db_path)?; let recorder_conn = create_connection(&db_path)?;
let run_id = uuid::Uuid::new_v4().simple().to_string(); let run_id = uuid::Uuid::new_v4().simple().to_string();
@@ -1371,7 +1627,11 @@ struct RobotDocsData {
description: String, description: String,
activation: RobotDocsActivation, activation: RobotDocsActivation,
commands: serde_json::Value, commands: serde_json::Value,
/// Deprecated command aliases (old -> new)
aliases: serde_json::Value,
exit_codes: serde_json::Value, exit_codes: serde_json::Value,
/// Error codes emitted by clap parse failures
clap_error_codes: serde_json::Value,
error_format: String, error_format: String,
workflows: serde_json::Value, workflows: serde_json::Value,
} }
@@ -1410,37 +1670,37 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
}, },
"ingest": { "ingest": {
"description": "Sync data from GitLab", "description": "Sync data from GitLab",
"flags": ["--project <path>", "--force", "--full", "<entity: issues|mrs>"], "flags": ["--project <path>", "--force", "--no-force", "--full", "--no-full", "--dry-run", "--no-dry-run", "<entity: issues|mrs>"],
"example": "lore --robot ingest issues --project group/repo" "example": "lore --robot ingest issues --project group/repo"
}, },
"sync": { "sync": {
"description": "Full sync pipeline: ingest -> generate-docs -> embed", "description": "Full sync pipeline: ingest -> generate-docs -> embed",
"flags": ["--full", "--force", "--no-embed", "--no-docs"], "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--dry-run", "--no-dry-run"],
"example": "lore --robot sync" "example": "lore --robot sync"
}, },
"issues": { "issues": {
"description": "List or show issues", "description": "List or show issues",
"flags": ["<IID>", "--limit", "--state", "--project", "--author", "--assignee", "--label", "--milestone", "--since", "--due-before", "--has-due", "--sort", "--asc"], "flags": ["<IID>", "-n/--limit", "--fields <list>", "-s/--state", "-p/--project", "-a/--author", "-A/--assignee", "-l/--label", "-m/--milestone", "--since", "--due-before", "--has-due", "--no-has-due", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"],
"example": "lore --robot issues --state opened --limit 10" "example": "lore --robot issues --state opened --limit 10"
}, },
"mrs": { "mrs": {
"description": "List or show merge requests", "description": "List or show merge requests",
"flags": ["<IID>", "--limit", "--state", "--project", "--author", "--assignee", "--reviewer", "--label", "--since", "--draft", "--no-draft", "--target", "--source", "--sort", "--asc"], "flags": ["<IID>", "-n/--limit", "--fields <list>", "-s/--state", "-p/--project", "-a/--author", "-A/--assignee", "-r/--reviewer", "-l/--label", "--since", "-d/--draft", "-D/--no-draft", "--target", "--source", "--sort", "--asc", "--no-asc", "-o/--open", "--no-open"],
"example": "lore --robot mrs --state opened" "example": "lore --robot mrs --state opened"
}, },
"search": { "search": {
"description": "Search indexed documents (lexical, hybrid, semantic)", "description": "Search indexed documents (lexical, hybrid, semantic)",
"flags": ["<QUERY>", "--mode", "--type", "--author", "--project", "--label", "--path", "--after", "--updated-after", "--limit", "--explain", "--fts-mode"], "flags": ["<QUERY>", "--mode", "--type", "--author", "-p/--project", "--label", "--path", "--after", "--updated-after", "-n/--limit", "--explain", "--no-explain", "--fts-mode"],
"example": "lore --robot search 'authentication bug' --mode hybrid --limit 10" "example": "lore --robot search 'authentication bug' --mode hybrid --limit 10"
}, },
"count": { "count": {
"description": "Count entities in local database", "description": "Count entities in local database",
"flags": ["<entity: issues|mrs|discussions|notes>", "--for <issue|mr>"], "flags": ["<entity: issues|mrs|discussions|notes|events>", "-f/--for <issue|mr>"],
"example": "lore --robot count issues" "example": "lore --robot count issues"
}, },
"stats": { "stats": {
"description": "Show document and index statistics", "description": "Show document and index statistics",
"flags": ["--check", "--repair"], "flags": ["--check", "--no-check", "--repair", "--dry-run", "--no-dry-run"],
"example": "lore --robot stats" "example": "lore --robot stats"
}, },
"status": { "status": {
@@ -1450,12 +1710,12 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
}, },
"generate-docs": { "generate-docs": {
"description": "Generate searchable documents from ingested data", "description": "Generate searchable documents from ingested data",
"flags": ["--full", "--project <path>"], "flags": ["--full", "-p/--project <path>"],
"example": "lore --robot generate-docs --full" "example": "lore --robot generate-docs --full"
}, },
"embed": { "embed": {
"description": "Generate vector embeddings for documents via Ollama", "description": "Generate vector embeddings for documents via Ollama",
"flags": ["--full", "--retry-failed"], "flags": ["--full", "--no-full", "--retry-failed", "--no-retry-failed"],
"example": "lore --robot embed" "example": "lore --robot embed"
}, },
"migrate": { "migrate": {
@@ -1468,6 +1728,11 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
"flags": [], "flags": [],
"example": "lore --robot version" "example": "lore --robot version"
}, },
"completions": {
"description": "Generate shell completions",
"flags": ["<shell: bash|zsh|fish|powershell>"],
"example": "lore completions bash > ~/.local/share/bash-completion/completions/lore"
},
"robot-docs": { "robot-docs": {
"description": "This command (agent self-discovery manifest)", "description": "This command (agent self-discovery manifest)",
"flags": [], "flags": [],
@@ -1515,6 +1780,30 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
] ]
}); });
// Phase 3: Deprecated command aliases
let aliases = serde_json::json!({
"list issues": "issues",
"list mrs": "mrs",
"show issue <IID>": "issues <IID>",
"show mr <IID>": "mrs <IID>",
"auth-test": "auth",
"sync-status": "status"
});
// Phase 3: Clap error codes (emitted by handle_clap_error)
let clap_error_codes = serde_json::json!({
"UNKNOWN_COMMAND": "Unrecognized subcommand (includes fuzzy suggestion)",
"UNKNOWN_FLAG": "Unrecognized command-line flag",
"MISSING_REQUIRED": "Required argument not provided",
"INVALID_VALUE": "Invalid value for argument",
"TOO_MANY_VALUES": "Too many values provided",
"TOO_FEW_VALUES": "Too few values provided",
"ARGUMENT_CONFLICT": "Conflicting arguments",
"MISSING_COMMAND": "No subcommand provided (in non-robot mode, shows help)",
"HELP_REQUESTED": "Help or version flag used",
"PARSE_ERROR": "General parse error"
});
let output = RobotDocsOutput { let output = RobotDocsOutput {
ok: true, ok: true,
data: RobotDocsData { data: RobotDocsData {
@@ -1527,7 +1816,9 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
auto: "Non-TTY stdout".to_string(), auto: "Non-TTY stdout".to_string(),
}, },
commands, commands,
aliases,
exit_codes, exit_codes,
clap_error_codes,
error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\"}}".to_string(), error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\"}}".to_string(),
workflows, workflows,
}, },
@@ -1639,14 +1930,14 @@ async fn handle_show_compat(
entity: &str, entity: &str,
iid: i64, iid: i64,
project_filter: Option<&str>, project_filter: Option<&str>,
json: 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)?;
match entity { match entity {
"issue" => { "issue" => {
let result = run_show_issue(&config, iid, project_filter)?; let result = run_show_issue(&config, iid, project_filter)?;
if json { if robot_mode {
print_show_issue_json(&result); print_show_issue_json(&result);
} else { } else {
print_show_issue(&result); print_show_issue(&result);
@@ -1655,7 +1946,7 @@ async fn handle_show_compat(
} }
"mr" => { "mr" => {
let result = run_show_mr(&config, iid, project_filter)?; let result = run_show_mr(&config, iid, project_filter)?;
if json { if robot_mode {
print_show_mr_json(&result); print_show_mr_json(&result);
} else { } else {
print_show_mr(&result); print_show_mr(&result);