fix(cli): audit-driven improvements to flags, help, exit codes, and deprecation

Addresses findings from a comprehensive CLI readiness audit:

Flag design (I2):
- Add hidden --no-verbose flag with overrides_with semantics, matching
  the --no-quiet pattern already established for all other boolean flags.

Help text (I3):
- Add after_help examples to issues, mrs, search, sync, and timeline
  subcommands. Each shows 3-4 concrete, runnable commands with comments.

Help headings (I4/P5):
- Move --mode and --fts-mode from "Output" heading to "Mode" heading
  in the search subcommand. These control search strategy, not output
  format — "Output" is reserved for --limit, --explain, --fields.

Exit codes (I5):
- Health check failure now exits 19 (was 1). Exit code 1 is reserved
  for internal errors only. robot-docs updated to document code 19.

Deprecation visibility (P4):
- Deprecated commands (list, show, auth-test, sync-status) now emit
  structured JSON warnings to stderr in robot mode:
  {"warning":{"type":"DEPRECATED","message":"...","successor":"..."}}
  Previously these were silently swallowed in robot mode.

Version string (P1):
- Cli struct uses env!("LORE_VERSION") from build.rs so --version shows
  git hash (see previous commit).

Fields flag (P3):
- --fields help text updated to document the "minimal" preset.

Robot-docs (parallel work):
- response_schema added for every command, documenting the JSON shape
  agents will receive. Agents can now introspect expected fields before
  calling a command.
- error_format documents the new "actions" array.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-06 23:47:04 -05:00
parent cf6d27435a
commit b5f78e31a8
2 changed files with 248 additions and 62 deletions

View File

@@ -1,12 +1,13 @@
pub mod commands;
pub mod progress;
pub mod robot;
use clap::{Parser, Subcommand};
use std::io::IsTerminal;
#[derive(Parser)]
#[command(name = "lore")]
#[command(version, about = "Local GitLab data management with semantic search", long_about = None)]
#[command(version = env!("LORE_VERSION"), about = "Local GitLab data management with semantic search", long_about = None)]
#[command(subcommand_required = false)]
pub struct Cli {
/// Path to config file
@@ -54,9 +55,17 @@ pub struct Cli {
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)")]
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true, help = "Increase log verbosity (-v, -vv, -vvv)", overrides_with = "no_verbose")]
pub verbose: u8,
#[arg(
long = "no-verbose",
global = true,
hide = true,
overrides_with = "verbose"
)]
pub no_verbose: bool,
/// 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,
@@ -246,6 +255,11 @@ pub enum Commands {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore issues -n 10 # List 10 most recently updated issues
lore issues -s opened -l bug # Open issues labeled 'bug'
lore issues 42 -p group/repo # Show issue #42 in a specific project
lore issues --since 7d -a jsmith # Issues updated in last 7 days by jsmith")]
pub struct IssuesArgs {
/// Issue IID (omit to list, provide to show details)
pub iid: Option<i64>,
@@ -259,7 +273,7 @@ pub struct IssuesArgs {
)]
pub limit: usize,
/// Select output fields (comma-separated: iid,title,state,author,labels,updated)
/// Select output fields (comma-separated, or 'minimal' preset: iid,title,state,updated_at_iso)
#[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>,
@@ -331,6 +345,11 @@ pub struct IssuesArgs {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore mrs -s opened # List open merge requests
lore mrs -s merged --since 2w # MRs merged in the last 2 weeks
lore mrs 99 -p group/repo # Show MR !99 in a specific project
lore mrs -D --reviewer jsmith # Non-draft MRs reviewed by jsmith")]
pub struct MrsArgs {
/// MR IID (omit to list, provide to show details)
pub iid: Option<i64>,
@@ -344,7 +363,7 @@ pub struct MrsArgs {
)]
pub limit: usize,
/// Select output fields (comma-separated: iid,title,state,author,labels,updated)
/// Select output fields (comma-separated, or 'minimal' preset: iid,title,state,updated_at_iso)
#[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>,
@@ -480,12 +499,17 @@ pub struct StatsArgs {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore search 'authentication bug' # Hybrid search (default)
lore search 'deploy' --mode lexical --type mr # Lexical search, MRs only
lore search 'API rate limit' --since 30d # Recent results only
lore search 'config' -p group/repo --explain # With ranking explanation")]
pub struct SearchArgs {
/// Search 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 = "Mode")]
pub mode: String,
/// Filter by source type (issue, mr, discussion)
@@ -533,7 +557,7 @@ pub struct SearchArgs {
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 = "Mode")]
pub fts_mode: String,
}
@@ -549,6 +573,11 @@ pub struct GenerateDocsArgs {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore sync # Full pipeline: ingest + docs + embed
lore sync --no-embed # Skip embedding step
lore sync --full --force # Full re-sync, override stale lock
lore sync --dry-run # Preview what would change")]
pub struct SyncArgs {
/// Reset cursors, fetch everything
#[arg(long, overrides_with = "no_full")]
@@ -602,6 +631,10 @@ pub struct EmbedArgs {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore timeline 'deployment' # Events related to deployments
lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time
lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")]
pub struct TimelineArgs {
/// Search query (keywords to find in issues, MRs, and discussions)
pub query: String,

View File

@@ -23,6 +23,7 @@ use lore::cli::commands::{
run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status,
run_timeline,
};
use lore::cli::robot::RobotMeta;
use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
SearchArgs, StatsArgs, SyncArgs, TimelineArgs,
@@ -229,7 +230,11 @@ async fn main() {
target_branch,
source_branch,
}) => {
if !robot_mode {
if robot_mode {
eprintln!(
r#"{{"warning":{{"type":"DEPRECATED","message":"'lore list' is deprecated, use 'lore issues' or 'lore mrs'","successor":"issues / mrs"}}}}"#
);
} else {
eprintln!(
"{}",
style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'")
@@ -266,7 +271,11 @@ async fn main() {
iid,
project,
}) => {
if !robot_mode {
if robot_mode {
eprintln!(
r#"{{"warning":{{"type":"DEPRECATED","message":"'lore show' is deprecated, use 'lore {entity}s {iid}'","successor":"{entity}s"}}}}"#
);
} else {
eprintln!(
"{}",
style(format!(
@@ -286,7 +295,11 @@ async fn main() {
.await
}
Some(Commands::AuthTest) => {
if !robot_mode {
if robot_mode {
eprintln!(
r#"{{"warning":{{"type":"DEPRECATED","message":"'lore auth-test' is deprecated, use 'lore auth'","successor":"auth"}}}}"#
);
} else {
eprintln!(
"{}",
style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow()
@@ -295,7 +308,11 @@ async fn main() {
handle_auth_test(cli.config.as_deref(), robot_mode).await
}
Some(Commands::SyncStatus) => {
if !robot_mode {
if robot_mode {
eprintln!(
r#"{{"warning":{{"type":"DEPRECATED","message":"'lore sync-status' is deprecated, use 'lore status'","successor":"status"}}}}"#
);
} else {
eprintln!(
"{}",
style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow()
@@ -498,14 +515,7 @@ fn handle_issues(
args: IssuesArgs,
robot_mode: bool,
) -> 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 start = std::time::Instant::now();
let config = Config::load(config_override)?;
let asc = args.asc && !args.no_asc;
let has_due = args.has_due && !args.no_has_due;
@@ -515,7 +525,7 @@ fn handle_issues(
if let Some(iid) = args.iid {
let result = run_show_issue(&config, iid, args.project.as_deref())?;
if robot_mode {
print_show_issue_json(&result);
print_show_issue_json(&result, start.elapsed().as_millis() as u64);
} else {
print_show_issue(&result);
}
@@ -540,7 +550,11 @@ fn handle_issues(
if open {
open_issue_in_browser(&result);
} else if robot_mode {
print_list_issues_json(&result);
print_list_issues_json(
&result,
start.elapsed().as_millis() as u64,
args.fields.as_deref(),
);
} else {
print_list_issues(&result);
}
@@ -554,14 +568,7 @@ fn handle_mrs(
args: MrsArgs,
robot_mode: bool,
) -> 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 start = std::time::Instant::now();
let config = Config::load(config_override)?;
let asc = args.asc && !args.no_asc;
let open = args.open && !args.no_open;
@@ -570,7 +577,7 @@ fn handle_mrs(
if let Some(iid) = args.iid {
let result = run_show_mr(&config, iid, args.project.as_deref())?;
if robot_mode {
print_show_mr_json(&result);
print_show_mr_json(&result, start.elapsed().as_millis() as u64);
} else {
print_show_mr(&result);
}
@@ -597,7 +604,11 @@ fn handle_mrs(
if open {
open_mr_in_browser(&result);
} else if robot_mode {
print_list_mrs_json(&result);
print_list_mrs_json(
&result,
start.elapsed().as_millis() as u64,
args.fields.as_deref(),
);
} else {
print_list_mrs(&result);
}
@@ -613,6 +624,7 @@ async fn handle_ingest(
quiet: bool,
metrics: &MetricsLayer,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let dry_run = args.dry_run && !args.no_dry_run;
let config = Config::load(config_override)?;
@@ -689,7 +701,7 @@ async fn handle_ingest(
.await?;
if robot_mode {
print_ingest_summary_json(&result);
print_ingest_summary_json(&result, start.elapsed().as_millis() as u64);
} else {
print_ingest_summary(&result);
}
@@ -730,7 +742,11 @@ async fn handle_ingest(
.await?;
if robot_mode {
print_combined_ingest_json(&issues_result, &mrs_result);
print_combined_ingest_json(
&issues_result,
&mrs_result,
start.elapsed().as_millis() as u64,
);
} else {
print_ingest_summary(&issues_result);
print_ingest_summary(&mrs_result);
@@ -778,6 +794,7 @@ async fn handle_ingest(
struct CombinedIngestOutput {
ok: bool,
data: CombinedIngestData,
meta: RobotMeta,
}
#[derive(Serialize)]
@@ -800,6 +817,7 @@ struct CombinedIngestEntityStats {
fn print_combined_ingest_json(
issues: &lore::cli::commands::ingest::IngestResult,
mrs: &lore::cli::commands::ingest::IngestResult,
elapsed_ms: u64,
) {
let output = CombinedIngestOutput {
ok: true,
@@ -822,6 +840,7 @@ fn print_combined_ingest_json(
notes_upserted: mrs.notes_upserted,
},
},
meta: RobotMeta { elapsed_ms },
};
println!("{}", serde_json::to_string(&output).unwrap());
@@ -861,12 +880,13 @@ async fn handle_count(
args: CountArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
if args.entity == "events" {
let counts = run_count_events(&config)?;
if robot_mode {
print_event_count_json(&counts);
print_event_count_json(&counts, start.elapsed().as_millis() as u64);
} else {
print_event_count(&counts);
}
@@ -875,7 +895,7 @@ async fn handle_count(
let result = run_count(&config, &args.entity, args.for_entity.as_deref())?;
if robot_mode {
print_count_json(&result);
print_count_json(&result, start.elapsed().as_millis() as u64);
} else {
print_count(&result);
}
@@ -886,11 +906,12 @@ async fn handle_sync_status_cmd(
config_override: Option<&str>,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let result = run_sync_status(&config)?;
if robot_mode {
print_sync_status_json(&result);
print_sync_status_json(&result, start.elapsed().as_millis() as u64);
} else {
print_sync_status(&result);
}
@@ -1135,6 +1156,7 @@ async fn handle_init(
struct AuthTestOutput {
ok: bool,
data: AuthTestData,
meta: RobotMeta,
}
#[derive(Serialize)]
@@ -1149,6 +1171,7 @@ async fn handle_auth_test(
config_override: Option<&str>,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
match run_auth_test(config_override).await {
Ok(result) => {
if robot_mode {
@@ -1160,6 +1183,9 @@ async fn handle_auth_test(
name: result.name.clone(),
gitlab_url: result.base_url.clone(),
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
};
println!("{}", serde_json::to_string(&output)?);
} else {
@@ -1189,6 +1215,7 @@ async fn handle_auth_test(
struct DoctorOutput {
ok: bool,
data: DoctorData,
meta: RobotMeta,
}
#[derive(Serialize)]
@@ -1201,6 +1228,7 @@ async fn handle_doctor(
config_override: Option<&str>,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let result = run_doctor(config_override).await;
if robot_mode {
@@ -1210,6 +1238,9 @@ async fn handle_doctor(
success: result.success,
checks: result.checks,
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
};
println!("{}", serde_json::to_string(&output)?);
} else {
@@ -1227,6 +1258,7 @@ async fn handle_doctor(
struct VersionOutput {
ok: bool,
data: VersionData,
meta: RobotMeta,
}
#[derive(Serialize)]
@@ -1237,6 +1269,7 @@ struct VersionData {
}
fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let version = env!("CARGO_PKG_VERSION").to_string();
let git_hash = env!("GIT_HASH").to_string();
if robot_mode {
@@ -1250,6 +1283,9 @@ fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
Some(git_hash)
},
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
};
println!("{}", serde_json::to_string(&output)?);
} else if git_hash.is_empty() {
@@ -1322,6 +1358,7 @@ fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
struct MigrateOutput {
ok: bool,
data: MigrateData,
meta: RobotMeta,
}
#[derive(Serialize)]
@@ -1347,6 +1384,7 @@ async fn handle_migrate(
config_override: Option<&str>,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let db_path = get_db_path(config.storage.db_path.as_deref());
@@ -1395,6 +1433,9 @@ async fn handle_migrate(
after_version,
migrated: after_version > before_version,
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
};
println!("{}", serde_json::to_string(&output)?);
} else if after_version > before_version {
@@ -1418,12 +1459,13 @@ async fn handle_stats(
args: StatsArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let dry_run = args.dry_run && !args.no_dry_run;
let config = Config::load(config_override)?;
let check = (args.check && !args.no_check) || args.repair;
let result = run_stats(&config, check, args.repair, dry_run)?;
if robot_mode {
print_stats_json(&result);
print_stats_json(&result, start.elapsed().as_millis() as u64);
} else {
print_stats(&result);
}
@@ -1505,11 +1547,12 @@ async fn handle_generate_docs(
args: GenerateDocsArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let result = run_generate_docs(&config, args.full, args.project.as_deref(), None)?;
if robot_mode {
print_generate_docs_json(&result);
print_generate_docs_json(&result, start.elapsed().as_millis() as u64);
} else {
print_generate_docs(&result);
}
@@ -1521,6 +1564,7 @@ async fn handle_embed(
args: EmbedArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let full = args.full && !args.no_full;
let retry_failed = args.retry_failed && !args.no_retry_failed;
@@ -1537,7 +1581,7 @@ async fn handle_embed(
let result = run_embed(&config, full, retry_failed, None, &signal).await?;
if robot_mode {
print_embed_json(&result);
print_embed_json(&result, start.elapsed().as_millis() as u64);
} else {
print_embed(&result);
}
@@ -1649,6 +1693,7 @@ async fn handle_sync_cmd(
struct HealthOutput {
ok: bool,
data: HealthData,
meta: RobotMeta,
}
#[derive(Serialize)]
@@ -1664,6 +1709,7 @@ async fn handle_health(
config_override: Option<&str>,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config_path = get_config_path(config_override);
let config_found = config_path.exists();
@@ -1701,6 +1747,9 @@ async fn handle_health(
schema_current,
schema_version,
},
meta: RobotMeta {
elapsed_ms: start.elapsed().as_millis() as u64,
},
};
println!("{}", serde_json::to_string(&output)?);
} else {
@@ -1732,7 +1781,7 @@ async fn handle_health(
}
if !healthy {
std::process::exit(1);
std::process::exit(19);
}
Ok(())
@@ -1775,82 +1824,178 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
"description": "Initialize configuration and database",
"flags": ["--force", "--non-interactive", "--gitlab-url <URL>", "--token-env-var <VAR>", "--projects <paths>"],
"robot_flags": ["--gitlab-url", "--token-env-var", "--projects"],
"example": "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project"
"example": "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project",
"response_schema": {
"ok": "bool",
"data": {"config_path": "string", "data_dir": "string", "user": {"username": "string", "name": "string"}, "projects": "[{path:string, name:string}]"},
"meta": {"elapsed_ms": "int"}
}
},
"health": {
"description": "Quick pre-flight check: config, database, schema version",
"flags": [],
"example": "lore --robot health"
"example": "lore --robot health",
"response_schema": {
"ok": "bool",
"data": {"healthy": "bool", "config_found": "bool", "db_found": "bool", "schema_current": "bool", "schema_version": "int"},
"meta": {"elapsed_ms": "int"}
}
},
"auth": {
"description": "Verify GitLab authentication",
"flags": [],
"example": "lore --robot auth"
"example": "lore --robot auth",
"response_schema": {
"ok": "bool",
"data": {"authenticated": "bool", "username": "string", "name": "string", "gitlab_url": "string"},
"meta": {"elapsed_ms": "int"}
}
},
"doctor": {
"description": "Full environment health check (config, auth, DB, Ollama)",
"flags": [],
"example": "lore --robot doctor"
"example": "lore --robot doctor",
"response_schema": {
"ok": "bool",
"data": {"success": "bool", "checks": "{config:object, auth:object, database:object, ollama:object}"},
"meta": {"elapsed_ms": "int"}
}
},
"ingest": {
"description": "Sync data from GitLab",
"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",
"response_schema": {
"ok": "bool",
"data": {"resource_type": "string", "projects_synced": "int", "issues_fetched?": "int", "mrs_fetched?": "int", "upserted": "int", "labels_created": "int", "discussions_fetched": "int", "notes_upserted": "int"},
"meta": {"elapsed_ms": "int"}
}
},
"sync": {
"description": "Full sync pipeline: ingest -> generate-docs -> embed",
"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",
"response_schema": {
"ok": "bool",
"data": {"issues_updated": "int", "mrs_updated": "int", "documents_regenerated": "int", "documents_embedded": "int", "resource_events_synced": "int", "resource_events_failed": "int"},
"meta": {"elapsed_ms": "int", "stages?": "[{name:string, elapsed_ms:int, items_processed:int}]"}
}
},
"issues": {
"description": "List or show issues",
"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",
"response_schema": {
"list": {
"ok": "bool",
"data": {"issues": "[{iid:int, title:string, state:string, author_username:string, labels:[string], assignees:[string], discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string}]", "total_count": "int", "showing": "int"},
"meta": {"elapsed_ms": "int"}
},
"show": {
"ok": "bool",
"data": "IssueDetail (full entity with description, discussions, notes, events)",
"meta": {"elapsed_ms": "int"}
}
},
"fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]}
},
"mrs": {
"description": "List or show merge requests",
"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",
"response_schema": {
"list": {
"ok": "bool",
"data": {"mrs": "[{iid:int, title:string, state:string, author_username:string, labels:[string], draft:bool, target_branch:string, source_branch:string, discussion_count:int, unresolved_count:int, created_at_iso:string, updated_at_iso:string, web_url:string?, project_path:string, reviewers:[string]}]", "total_count": "int", "showing": "int"},
"meta": {"elapsed_ms": "int"}
},
"show": {
"ok": "bool",
"data": "MrDetail (full entity with description, discussions, notes, events)",
"meta": {"elapsed_ms": "int"}
}
},
"fields_presets": {"minimal": ["iid", "title", "state", "updated_at_iso"]}
},
"search": {
"description": "Search indexed documents (lexical, hybrid, semantic)",
"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",
"response_schema": {
"ok": "bool",
"data": {"results": "[{doc_id:int, source_type:string, title:string, snippet:string, score:float, project_path:string, web_url:string?}]", "total_count": "int", "query": "string", "mode": "string"},
"meta": {"elapsed_ms": "int"}
}
},
"count": {
"description": "Count entities in local database",
"flags": ["<entity: issues|mrs|discussions|notes|events>", "-f/--for <issue|mr>"],
"example": "lore --robot count issues"
"example": "lore --robot count issues",
"response_schema": {
"ok": "bool",
"data": {"entity": "string", "count": "int", "system_excluded?": "int", "breakdown?": {"opened": "int", "closed": "int", "merged?": "int", "locked?": "int"}},
"meta": {"elapsed_ms": "int"}
}
},
"stats": {
"description": "Show document and index statistics",
"flags": ["--check", "--no-check", "--repair", "--dry-run", "--no-dry-run"],
"example": "lore --robot stats"
"example": "lore --robot stats",
"response_schema": {
"ok": "bool",
"data": {"total_documents": "int", "indexed_documents": "int", "embedded_documents": "int", "stale_documents": "int", "integrity?": "object"},
"meta": {"elapsed_ms": "int"}
}
},
"status": {
"description": "Show sync state (cursors, last sync times)",
"flags": [],
"example": "lore --robot status"
"example": "lore --robot status",
"response_schema": {
"ok": "bool",
"data": {"projects": "[{path:string, issues_cursor:string?, mrs_cursor:string?, last_sync:string?}]"},
"meta": {"elapsed_ms": "int"}
}
},
"generate-docs": {
"description": "Generate searchable documents from ingested data",
"flags": ["--full", "-p/--project <path>"],
"example": "lore --robot generate-docs --full"
"example": "lore --robot generate-docs --full",
"response_schema": {
"ok": "bool",
"data": {"generated": "int", "updated": "int", "unchanged": "int", "deleted": "int"},
"meta": {"elapsed_ms": "int"}
}
},
"embed": {
"description": "Generate vector embeddings for documents via Ollama",
"flags": ["--full", "--no-full", "--retry-failed", "--no-retry-failed"],
"example": "lore --robot embed"
"example": "lore --robot embed",
"response_schema": {
"ok": "bool",
"data": {"embedded": "int", "skipped": "int", "failed": "int", "total_chunks": "int"},
"meta": {"elapsed_ms": "int"}
}
},
"migrate": {
"description": "Run pending database migrations",
"flags": [],
"example": "lore --robot migrate"
"example": "lore --robot migrate",
"response_schema": {
"ok": "bool",
"data": {"before_version": "int", "after_version": "int", "migrated": "bool"},
"meta": {"elapsed_ms": "int"}
}
},
"version": {
"description": "Show version information",
"flags": [],
"example": "lore --robot version"
"example": "lore --robot version",
"response_schema": {
"ok": "bool",
"data": {"version": "string", "git_hash?": "string"},
"meta": {"elapsed_ms": "int"}
}
},
"completions": {
"description": "Generate shell completions",
@@ -1860,7 +2005,12 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
"timeline": {
"description": "Chronological timeline of events matching a keyword query",
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--expand-mentions", "-n/--limit", "--max-seeds", "--max-entities", "--max-evidence"],
"example": "lore --robot timeline 'authentication' --since 30d"
"example": "lore --robot timeline '<keyword>' --since 30d",
"response_schema": {
"ok": "bool",
"data": {"entities": "[{type:string, iid:int, title:string, project_path:string}]", "events": "[{timestamp:string, type:string, entity_type:string, entity_iid:int, detail:string}]", "total_events": "int"},
"meta": {"elapsed_ms": "int"}
}
},
"robot-docs": {
"description": "This command (agent self-discovery manifest)",
@@ -1871,7 +2021,7 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
let exit_codes = serde_json::json!({
"0": "Success",
"1": "Internal error / health check failed / not implemented",
"1": "Internal error",
"2": "Usage error (invalid flags or arguments)",
"3": "Config invalid",
"4": "Token not set",
@@ -1889,6 +2039,7 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
"16": "Embedding failed",
"17": "Not found",
"18": "Ambiguous match",
"19": "Health check failed",
"20": "Config not found"
});
@@ -1953,7 +2104,7 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
aliases,
exit_codes,
clap_error_codes,
error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\"}}".to_string(),
error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\",\"actions\":[\"...\"]}}".to_string(),
workflows,
},
};
@@ -1991,6 +2142,7 @@ async fn handle_list_compat(
target_branch_filter: Option<&str>,
source_branch_filter: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
match entity {
@@ -2015,7 +2167,7 @@ async fn handle_list_compat(
if open_browser {
open_issue_in_browser(&result);
} else if json_output {
print_list_issues_json(&result);
print_list_issues_json(&result, start.elapsed().as_millis() as u64, None);
} else {
print_list_issues(&result);
}
@@ -2045,7 +2197,7 @@ async fn handle_list_compat(
if open_browser {
open_mr_in_browser(&result);
} else if json_output {
print_list_mrs_json(&result);
print_list_mrs_json(&result, start.elapsed().as_millis() as u64, None);
} else {
print_list_mrs(&result);
}
@@ -2066,13 +2218,14 @@ async fn handle_show_compat(
project_filter: Option<&str>,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
match entity {
"issue" => {
let result = run_show_issue(&config, iid, project_filter)?;
if robot_mode {
print_show_issue_json(&result);
print_show_issue_json(&result, start.elapsed().as_millis() as u64);
} else {
print_show_issue(&result);
}
@@ -2081,7 +2234,7 @@ async fn handle_show_compat(
"mr" => {
let result = run_show_mr(&config, iid, project_filter)?;
if robot_mode {
print_show_mr_json(&result);
print_show_mr_json(&result, start.elapsed().as_millis() as u64);
} else {
print_show_mr(&result);
}