Files
gitlore/src/cli/mod.rs
teernisse 32134ea933 feat(explain): implement lore explain command for auto-generating issue/MR narratives
Adds the full explain command with 7 output sections: entity summary, description,
key decisions (heuristic event-note correlation), activity summary, open threads,
related entities (closing MRs, cross-references), and timeline excerpt (reuses
existing pipeline). Supports --sections filtering, --since time scoping,
--no-timeline, --max-decisions, and robot mode JSON output.

Closes: bd-2i3z, bd-a3j8, bd-wb0b, bd-3q5e, bd-nj7f, bd-9lbr
2026-03-10 15:04:35 -04:00

434 lines
15 KiB
Rust

pub mod args;
pub mod autocorrect;
pub mod commands;
pub mod progress;
pub mod render;
pub mod robot;
use clap::{Parser, Subcommand};
use std::io::IsTerminal;
#[derive(Parser)]
#[command(name = "lore")]
#[command(version = env!("LORE_VERSION"), about = "Local GitLab data management with semantic search", long_about = None)]
#[command(subcommand_required = false)]
#[command(infer_subcommands = true)]
#[command(after_long_help = "\x1b[1mEnvironment:\x1b[0m
GITLAB_TOKEN GitLab personal access token (or name set in config)
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
LORE_CONFIG_PATH Override config file location
NO_COLOR Disable color output (any non-empty value)
LORE_ICONS Override icon set: nerd, unicode, or ascii
NERD_FONTS Enable Nerd Font icons when set to a non-empty value")]
pub struct Cli {
/// Path to config file
#[arg(short = 'c', long, global = true, help = "Path to config file")]
pub config: Option<String>,
/// 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,
/// JSON output (global shorthand)
#[arg(
short = 'J',
long = "json",
global = true,
help = "JSON output (global shorthand)"
)]
pub json: bool,
/// 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,
/// Icon set: nerd (Nerd Fonts), unicode, or ascii
#[arg(long, global = true, value_parser = ["nerd", "unicode", "ascii"], help = "Icon set: nerd (Nerd Fonts), unicode, or ascii")]
pub icons: Option<String>,
/// Suppress non-essential output
#[arg(
short = 'q',
long,
global = true,
overrides_with = "no_quiet",
help = "Suppress non-essential output"
)]
pub quiet: bool,
#[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)", 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,
#[command(subcommand)]
pub command: Option<Commands>,
}
impl Cli {
pub fn is_robot_mode(&self) -> bool {
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.
/// Also catches common agent typos like `-robot` and `--Robot`.
pub fn detect_robot_mode_from_env() -> bool {
let args: Vec<String> = std::env::args().collect();
args.iter().any(|a| {
a == "-J"
|| a.eq_ignore_ascii_case("--robot")
|| a.eq_ignore_ascii_case("-robot")
|| a.eq_ignore_ascii_case("--json")
|| a.eq_ignore_ascii_case("-json")
}) || std::env::var("LORE_ROBOT")
.ok()
.is_some_and(|v| !v.is_empty() && v != "0" && v != "false")
|| !std::io::stdout().is_terminal()
}
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum Commands {
/// List or show issues
#[command(visible_alias = "issue")]
Issues(IssuesArgs),
/// List or show merge requests
#[command(
visible_alias = "mr",
alias = "merge-requests",
alias = "merge-request"
)]
Mrs(MrsArgs),
/// List notes from discussions
#[command(visible_alias = "note")]
Notes(NotesArgs),
/// Ingest data from GitLab
Ingest(IngestArgs),
/// Count entities in local database
Count(CountArgs),
/// Show sync state
#[command(
visible_alias = "st",
after_help = "\x1b[1mExamples:\x1b[0m
lore status # Show last sync times per project
lore --robot status # JSON output for automation"
)]
Status,
/// Verify GitLab authentication
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore auth # Verify token and show user info
lore --robot auth # JSON output for automation")]
Auth,
/// Check environment health
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore doctor # Check config, token, database, Ollama
lore --robot doctor # JSON output for automation")]
Doctor,
/// Show version information
Version,
/// Initialize configuration and database
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore init # Interactive setup
lore init --refresh # Register projects from existing config
lore init --force # Overwrite existing config
lore --robot init --gitlab-url https://gitlab.com \\
--token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")]
Init {
/// Re-read config and register any new projects in the database
#[arg(long, conflicts_with = "force")]
refresh: bool,
/// Skip overwrite confirmation
#[arg(short = 'f', long)]
force: bool,
/// Fail if prompts would be shown
#[arg(long)]
non_interactive: bool,
/// GitLab base URL (required in robot mode)
#[arg(long)]
gitlab_url: Option<String>,
/// Environment variable name holding GitLab token (required in robot mode)
#[arg(long)]
token_env_var: Option<String>,
/// Comma-separated project paths (required in robot mode)
#[arg(long)]
projects: Option<String>,
/// Default project path (used when -p is omitted)
#[arg(long)]
default_project: Option<String>,
},
/// Back up local database (not yet implemented)
#[command(hide = true)]
Backup,
/// Reset local database (not yet implemented)
#[command(hide = true)]
Reset {
/// Skip confirmation prompt
#[arg(short = 'y', long)]
yes: bool,
},
/// Search indexed documents
#[command(visible_alias = "find", alias = "query")]
Search(SearchArgs),
/// Show document and index statistics
#[command(visible_alias = "stat")]
Stats(StatsArgs),
/// Generate searchable documents from ingested data
#[command(name = "generate-docs")]
GenerateDocs(GenerateDocsArgs),
/// Generate vector embeddings for documents via Ollama
Embed(EmbedArgs),
/// Run full sync pipeline: ingest -> generate-docs -> embed
Sync(SyncArgs),
/// Run pending database migrations
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore migrate # Apply pending migrations
lore --robot migrate # JSON output for automation")]
Migrate,
/// Quick health check: config, database, schema version
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore health # Quick pre-flight check (exit 0 = healthy)
lore --robot health # JSON output for automation")]
Health,
/// Machine-readable command manifest for agent self-discovery
#[command(name = "robot-docs")]
RobotDocs {
/// Omit response_schema from output (~60% smaller)
#[arg(long)]
brief: bool,
},
/// 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 {
/// Shell to generate completions for
#[arg(value_parser = ["bash", "zsh", "fish", "powershell"])]
shell: String,
},
/// Show a chronological timeline of events matching a query
Timeline(TimelineArgs),
/// People intelligence: experts, workload, active discussions, overlap
Who(WhoArgs),
/// Personal work dashboard: open issues, authored/reviewing MRs, activity
Me(MeArgs),
/// Show MRs that touched a file, with linked discussions
#[command(name = "file-history")]
FileHistory(FileHistoryArgs),
/// Trace why code was introduced: file -> MR -> issue -> discussion
Trace(TraceArgs),
/// Auto-generate a structured narrative of an issue or MR
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore explain issues 42 # Narrative for issue #42
lore explain mrs 99 -p group/repo # Narrative for MR !99 in specific project
lore -J explain issues 42 # JSON output for automation
lore explain issues 42 --sections key_decisions,open_threads # Specific sections only
lore explain issues 42 --since 30d # Narrative scoped to last 30 days
lore explain issues 42 --no-timeline # Skip timeline (faster)")]
Explain {
/// Entity type: "issues" or "mrs" (singular forms also accepted)
#[arg(value_parser = ["issues", "mrs", "issue", "mr"])]
entity_type: String,
/// Entity IID
iid: i64,
/// Scope to project (fuzzy match)
#[arg(short, long)]
project: Option<String>,
/// Select specific sections (comma-separated)
/// Valid: entity, description, key_decisions, activity, open_threads, related, timeline
#[arg(long, value_delimiter = ',', help_heading = "Output")]
sections: Option<Vec<String>>,
/// Skip timeline excerpt (faster execution)
#[arg(long, help_heading = "Output")]
no_timeline: bool,
/// Maximum key decisions to include
#[arg(long, default_value = "10", help_heading = "Output")]
max_decisions: usize,
/// Time scope for events/notes (e.g. 7d, 2w, 1m, or YYYY-MM-DD)
#[arg(long, help_heading = "Filters")]
since: Option<String>,
},
/// Detect discussion divergence from original intent
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore drift issues 42 # Check drift on issue #42
lore drift issues 42 --threshold 0.3 # Custom similarity threshold
lore --robot drift issues 42 -p group/repo # JSON output, scoped to project")]
Drift {
/// Entity type (currently only "issues" supported)
#[arg(value_parser = ["issues"])]
entity_type: String,
/// Entity IID
iid: i64,
/// Similarity threshold for drift detection (0.0-1.0)
#[arg(long, default_value = "0.4")]
threshold: f32,
/// Scope to project (fuzzy match)
#[arg(short, long)]
project: Option<String>,
},
/// Find semantically related entities via vector search
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore related issues 42 # Find entities related to issue #42
lore related mrs 99 -p group/repo # Related to MR #99 in specific project
lore related 'authentication flow' # Find entities matching free text query
lore --robot related issues 42 -n 5 # JSON output, limit 5 results")]
Related {
/// Entity type (issues, mrs) or free text query
query_or_type: String,
/// Entity IID (required when first arg is entity type)
iid: Option<i64>,
/// Maximum results
#[arg(short = 'n', long, default_value = "10")]
limit: usize,
/// Scope to project (fuzzy match)
#[arg(short, long)]
project: Option<String>,
},
/// Manage cron-based automatic syncing
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore cron install # Install cron job (every 8 minutes)
lore cron install --interval 15 # Custom interval
lore cron status # Check if cron is installed
lore cron uninstall # Remove cron job")]
Cron(CronArgs),
/// Manage stored GitLab token
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore token set # Interactive token entry + validation
lore token set --token glpat-xxx # Non-interactive token storage
echo glpat-xxx | lore token set # Pipe token from stdin
lore token show # Show token (masked)
lore token show --unmask # Show full token")]
Token(TokenArgs),
#[command(hide = true)]
List {
#[arg(value_parser = ["issues", "mrs"])]
entity: String,
#[arg(long, default_value = "50")]
limit: usize,
#[arg(long)]
project: Option<String>,
#[arg(long)]
state: Option<String>,
#[arg(long)]
author: Option<String>,
#[arg(long)]
assignee: Option<String>,
#[arg(long)]
label: Option<Vec<String>>,
#[arg(long)]
milestone: Option<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
due_before: Option<String>,
#[arg(long)]
has_due_date: bool,
#[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated")]
sort: String,
#[arg(long, value_parser = ["desc", "asc"], default_value = "desc")]
order: String,
#[arg(long)]
open: bool,
#[arg(long, conflicts_with = "no_draft")]
draft: bool,
#[arg(long, conflicts_with = "draft")]
no_draft: bool,
#[arg(long)]
reviewer: Option<String>,
#[arg(long)]
target_branch: Option<String>,
#[arg(long)]
source_branch: Option<String>,
},
#[command(hide = true, name = "auth-test")]
AuthTest,
#[command(hide = true, name = "sync-status")]
SyncStatus,
}
pub use args::{
CountArgs, CronAction, CronArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs, IngestArgs,
IssuesArgs, MeArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs,
TokenAction, TokenArgs, TraceArgs, WhoArgs,
};