refactor(structure): reorganize codebase into domain-focused modules
This commit is contained in:
3
src/app/dispatch.rs
Normal file
3
src/app/dispatch.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
include!("errors.rs");
|
||||
include!("handlers.rs");
|
||||
include!("robot_docs.rs");
|
||||
478
src/app/errors.rs
Normal file
478
src/app/errors.rs
Normal file
@@ -0,0 +1,478 @@
|
||||
#[derive(Serialize)]
|
||||
struct FallbackErrorOutput {
|
||||
error: FallbackError,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct FallbackError {
|
||||
code: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
if let Some(gi_error) = e.downcast_ref::<LoreError>() {
|
||||
if robot_mode {
|
||||
let output = RobotErrorOutput::from(gi_error);
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(&output).unwrap_or_else(|_| {
|
||||
let fallback = FallbackErrorOutput {
|
||||
error: FallbackError {
|
||||
code: "INTERNAL_ERROR".to_string(),
|
||||
message: gi_error.to_string(),
|
||||
},
|
||||
};
|
||||
serde_json::to_string(&fallback)
|
||||
.unwrap_or_else(|_| r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"#.to_string())
|
||||
})
|
||||
);
|
||||
std::process::exit(gi_error.exit_code());
|
||||
} else {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
Theme::error().render(Icons::error()),
|
||||
Theme::error().bold().render(&gi_error.to_string())
|
||||
);
|
||||
if let Some(suggestion) = gi_error.suggestion() {
|
||||
eprintln!();
|
||||
eprintln!(" {suggestion}");
|
||||
}
|
||||
let actions = gi_error.actions();
|
||||
if !actions.is_empty() {
|
||||
eprintln!();
|
||||
for action in &actions {
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
Theme::dim().render("\u{2192}"),
|
||||
Theme::bold().render(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
eprintln!();
|
||||
std::process::exit(gi_error.exit_code());
|
||||
}
|
||||
}
|
||||
|
||||
if robot_mode {
|
||||
let output = FallbackErrorOutput {
|
||||
error: FallbackError {
|
||||
code: "INTERNAL_ERROR".to_string(),
|
||||
message: e.to_string(),
|
||||
},
|
||||
};
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string(&output).unwrap_or_else(|_| {
|
||||
r#"{"error":{"code":"INTERNAL_ERROR","message":"Serialization failed"}}"#
|
||||
.to_string()
|
||||
})
|
||||
);
|
||||
} else {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
Theme::error().render(Icons::error()),
|
||||
Theme::error().bold().render(&e.to_string())
|
||||
);
|
||||
eprintln!();
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
/// Emit stderr warnings for any corrections applied during Phase 1.5.
|
||||
fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) {
|
||||
if robot_mode {
|
||||
#[derive(Serialize)]
|
||||
struct CorrectionWarning<'a> {
|
||||
warning: CorrectionWarningInner<'a>,
|
||||
}
|
||||
#[derive(Serialize)]
|
||||
struct CorrectionWarningInner<'a> {
|
||||
r#type: &'static str,
|
||||
corrections: &'a [autocorrect::Correction],
|
||||
teaching: Vec<String>,
|
||||
}
|
||||
|
||||
let teaching: Vec<String> = result
|
||||
.corrections
|
||||
.iter()
|
||||
.map(autocorrect::format_teaching_note)
|
||||
.collect();
|
||||
|
||||
let warning = CorrectionWarning {
|
||||
warning: CorrectionWarningInner {
|
||||
r#type: "ARG_CORRECTED",
|
||||
corrections: &result.corrections,
|
||||
teaching,
|
||||
},
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&warning) {
|
||||
eprintln!("{json}");
|
||||
}
|
||||
} else {
|
||||
for c in &result.corrections {
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
Theme::warning().render("Auto-corrected:"),
|
||||
autocorrect::format_teaching_note(c)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase 1 & 4: Handle clap parsing errors with structured JSON output in robot mode.
|
||||
/// Also includes fuzzy command matching and flag-level suggestions.
|
||||
fn handle_clap_error(e: clap::Error, robot_mode: bool, corrections: &CorrectionResult) -> ! {
|
||||
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 full_msg = e.to_string();
|
||||
let message = full_msg
|
||||
.lines()
|
||||
.take(3)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let (suggestion, correction, valid_values) = match e.kind() {
|
||||
// Phase 4: Suggest similar command for unknown subcommands
|
||||
ErrorKind::InvalidSubcommand => {
|
||||
let suggestion = if let Some(invalid_cmd) = extract_invalid_subcommand(&e) {
|
||||
suggest_similar_command(&invalid_cmd)
|
||||
} else {
|
||||
"Run 'lore robot-docs' for valid commands".to_string()
|
||||
};
|
||||
(suggestion, None, None)
|
||||
}
|
||||
// Flag-level fuzzy matching for unknown flags
|
||||
ErrorKind::UnknownArgument => {
|
||||
let invalid_flag = extract_invalid_flag(&e);
|
||||
let similar = invalid_flag
|
||||
.as_deref()
|
||||
.and_then(|flag| autocorrect::suggest_similar_flag(flag, &corrections.args));
|
||||
let suggestion = if let Some(ref s) = similar {
|
||||
format!("Did you mean '{s}'? Run 'lore robot-docs' for all flags")
|
||||
} else {
|
||||
"Run 'lore robot-docs' for valid flags".to_string()
|
||||
};
|
||||
(suggestion, similar, None)
|
||||
}
|
||||
// Value-level suggestions for invalid enum values
|
||||
ErrorKind::InvalidValue => {
|
||||
let (flag, valid_vals) = extract_invalid_value_context(&e);
|
||||
let suggestion = if let Some(vals) = &valid_vals {
|
||||
format!(
|
||||
"Valid values: {}. Run 'lore robot-docs' for details",
|
||||
vals.join(", ")
|
||||
)
|
||||
} else if let Some(ref f) = flag {
|
||||
if let Some(vals) = autocorrect::valid_values_for_flag(f) {
|
||||
format!("Valid values for {f}: {}", vals.join(", "))
|
||||
} else {
|
||||
"Run 'lore robot-docs' for valid values".to_string()
|
||||
}
|
||||
} else {
|
||||
"Run 'lore robot-docs' for valid values".to_string()
|
||||
};
|
||||
let vals_vec = valid_vals.or_else(|| {
|
||||
flag.as_deref()
|
||||
.and_then(autocorrect::valid_values_for_flag)
|
||||
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
|
||||
});
|
||||
(suggestion, None, vals_vec)
|
||||
}
|
||||
ErrorKind::MissingRequiredArgument => {
|
||||
let suggestion = format!(
|
||||
"A required argument is missing. {}",
|
||||
if let Some(subcmd) = extract_subcommand_from_context(&e) {
|
||||
format!(
|
||||
"Example: {}. Run 'lore {subcmd} --help' for required arguments",
|
||||
command_example(&subcmd)
|
||||
)
|
||||
} else {
|
||||
"Run 'lore robot-docs' for command reference".to_string()
|
||||
}
|
||||
);
|
||||
(suggestion, None, None)
|
||||
}
|
||||
ErrorKind::MissingSubcommand => {
|
||||
let suggestion =
|
||||
"No command specified. Common commands: issues, mrs, search, sync, \
|
||||
timeline, who, me. Run 'lore robot-docs' for the full list"
|
||||
.to_string();
|
||||
(suggestion, None, None)
|
||||
}
|
||||
ErrorKind::TooFewValues | ErrorKind::TooManyValues => {
|
||||
let suggestion = if let Some(subcmd) = extract_subcommand_from_context(&e) {
|
||||
format!(
|
||||
"Example: {}. Run 'lore {subcmd} --help' for usage",
|
||||
command_example(&subcmd)
|
||||
)
|
||||
} else {
|
||||
"Run 'lore robot-docs' for command reference".to_string()
|
||||
};
|
||||
(suggestion, None, None)
|
||||
}
|
||||
_ => (
|
||||
"Run 'lore robot-docs' for valid commands".to_string(),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
};
|
||||
|
||||
let output = RobotErrorWithSuggestion {
|
||||
error: RobotErrorSuggestionData {
|
||||
code: error_code.to_string(),
|
||||
message,
|
||||
suggestion,
|
||||
correction,
|
||||
valid_values,
|
||||
},
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
/// Extract the invalid flag from a clap UnknownArgument error.
|
||||
/// Format is typically: "error: unexpected argument '--xyzzy' found"
|
||||
fn extract_invalid_flag(e: &clap::Error) -> Option<String> {
|
||||
let msg = e.to_string();
|
||||
if let Some(start) = msg.find('\'')
|
||||
&& let Some(end) = msg[start + 1..].find('\'')
|
||||
{
|
||||
let value = &msg[start + 1..start + 1 + end];
|
||||
if value.starts_with('-') {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract flag name and valid values from a clap InvalidValue error.
|
||||
/// Returns (flag_name, valid_values_if_listed_in_error).
|
||||
fn extract_invalid_value_context(e: &clap::Error) -> (Option<String>, Option<Vec<String>>) {
|
||||
let msg = e.to_string();
|
||||
|
||||
// Try to find the flag name from "[possible values: ...]" pattern or from the arg info
|
||||
// Clap format: "error: invalid value 'opend' for '--state <STATE>'"
|
||||
let flag = if let Some(for_pos) = msg.find("for '") {
|
||||
let after_for = &msg[for_pos + 5..];
|
||||
if let Some(end) = after_for.find('\'') {
|
||||
let raw = &after_for[..end];
|
||||
// Strip angle-bracket value placeholder: "--state <STATE>" -> "--state"
|
||||
Some(raw.split_whitespace().next().unwrap_or(raw).to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Try to extract possible values from the error message
|
||||
// Clap format: "[possible values: opened, closed, merged, locked, all]"
|
||||
let valid_values = if let Some(pv_pos) = msg.find("[possible values: ") {
|
||||
let after_pv = &msg[pv_pos + 18..];
|
||||
after_pv.find(']').map(|end| {
|
||||
after_pv[..end]
|
||||
.split(", ")
|
||||
.map(|s| s.trim().to_string())
|
||||
.collect()
|
||||
})
|
||||
} else {
|
||||
// Fall back to our static registry
|
||||
flag.as_deref()
|
||||
.and_then(autocorrect::valid_values_for_flag)
|
||||
.map(|v| v.iter().map(|s| (*s).to_string()).collect())
|
||||
};
|
||||
|
||||
(flag, valid_values)
|
||||
}
|
||||
|
||||
/// Extract the subcommand context from a clap error for better suggestions.
|
||||
/// Looks at the error message to find which command was being invoked.
|
||||
fn extract_subcommand_from_context(e: &clap::Error) -> Option<String> {
|
||||
let msg = e.to_string();
|
||||
|
||||
let known = [
|
||||
"issues",
|
||||
"mrs",
|
||||
"notes",
|
||||
"search",
|
||||
"sync",
|
||||
"ingest",
|
||||
"count",
|
||||
"status",
|
||||
"auth",
|
||||
"doctor",
|
||||
"stats",
|
||||
"timeline",
|
||||
"who",
|
||||
"me",
|
||||
"drift",
|
||||
"related",
|
||||
"trace",
|
||||
"file-history",
|
||||
"generate-docs",
|
||||
"embed",
|
||||
"token",
|
||||
"cron",
|
||||
"init",
|
||||
"migrate",
|
||||
];
|
||||
for cmd in known {
|
||||
if msg.contains(&format!("lore {cmd}")) || msg.contains(&format!("'{cmd}'")) {
|
||||
return Some(cmd.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Phase 4: Suggest similar command using fuzzy matching
|
||||
fn suggest_similar_command(invalid: &str) -> String {
|
||||
// Primary commands + common aliases for fuzzy matching
|
||||
const VALID_COMMANDS: &[(&str, &str)] = &[
|
||||
("issues", "issues"),
|
||||
("issue", "issues"),
|
||||
("mrs", "mrs"),
|
||||
("mr", "mrs"),
|
||||
("merge-requests", "mrs"),
|
||||
("search", "search"),
|
||||
("find", "search"),
|
||||
("query", "search"),
|
||||
("sync", "sync"),
|
||||
("ingest", "ingest"),
|
||||
("count", "count"),
|
||||
("status", "status"),
|
||||
("auth", "auth"),
|
||||
("doctor", "doctor"),
|
||||
("version", "version"),
|
||||
("init", "init"),
|
||||
("stats", "stats"),
|
||||
("stat", "stats"),
|
||||
("generate-docs", "generate-docs"),
|
||||
("embed", "embed"),
|
||||
("migrate", "migrate"),
|
||||
("health", "health"),
|
||||
("robot-docs", "robot-docs"),
|
||||
("completions", "completions"),
|
||||
("timeline", "timeline"),
|
||||
("who", "who"),
|
||||
("notes", "notes"),
|
||||
("note", "notes"),
|
||||
("drift", "drift"),
|
||||
("file-history", "file-history"),
|
||||
("trace", "trace"),
|
||||
("related", "related"),
|
||||
("me", "me"),
|
||||
("token", "token"),
|
||||
("cron", "cron"),
|
||||
// Hidden but may be known to agents
|
||||
("list", "list"),
|
||||
("show", "show"),
|
||||
("reset", "reset"),
|
||||
("backup", "backup"),
|
||||
];
|
||||
|
||||
let invalid_lower = invalid.to_lowercase();
|
||||
|
||||
// Find the best match using Jaro-Winkler similarity
|
||||
let best_match = VALID_COMMANDS
|
||||
.iter()
|
||||
.map(|(alias, canonical)| (*canonical, jaro_winkler(&invalid_lower, alias)))
|
||||
.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
|
||||
{
|
||||
let example = command_example(cmd);
|
||||
return format!(
|
||||
"Did you mean 'lore {cmd}'? Example: {example}. Run 'lore robot-docs' for all commands"
|
||||
);
|
||||
}
|
||||
|
||||
"Run 'lore robot-docs' for valid commands. Common: issues, mrs, search, sync, timeline, who"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Return a contextual usage example for a command.
|
||||
fn command_example(cmd: &str) -> &'static str {
|
||||
match cmd {
|
||||
"issues" => "lore --robot issues -n 10",
|
||||
"mrs" => "lore --robot mrs -n 10",
|
||||
"search" => "lore --robot search \"auth bug\"",
|
||||
"sync" => "lore --robot sync",
|
||||
"ingest" => "lore --robot ingest issues",
|
||||
"notes" => "lore --robot notes --for-issue 123",
|
||||
"count" => "lore --robot count issues",
|
||||
"status" => "lore --robot status",
|
||||
"stats" => "lore --robot stats",
|
||||
"timeline" => "lore --robot timeline \"auth flow\"",
|
||||
"who" => "lore --robot who --path src/",
|
||||
"health" => "lore --robot health",
|
||||
"generate-docs" => "lore --robot generate-docs",
|
||||
"embed" => "lore --robot embed",
|
||||
"robot-docs" => "lore robot-docs",
|
||||
"trace" => "lore --robot trace src/main.rs",
|
||||
"init" => "lore init",
|
||||
"related" => "lore --robot related issues 42 -n 5",
|
||||
"me" => "lore --robot me",
|
||||
"drift" => "lore --robot drift issues 42",
|
||||
"file-history" => "lore --robot file-history src/main.rs",
|
||||
"token" => "lore --robot token show",
|
||||
"cron" => "lore --robot cron status",
|
||||
"auth" => "lore --robot auth",
|
||||
"doctor" => "lore --robot doctor",
|
||||
"migrate" => "lore --robot migrate",
|
||||
"completions" => "lore completions bash",
|
||||
_ => "lore --robot <command>",
|
||||
}
|
||||
}
|
||||
|
||||
821
src/app/robot_docs.rs
Normal file
821
src/app/robot_docs.rs
Normal file
@@ -0,0 +1,821 @@
|
||||
#[derive(Serialize)]
|
||||
struct RobotDocsOutput {
|
||||
ok: bool,
|
||||
data: RobotDocsData,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RobotDocsData {
|
||||
name: String,
|
||||
version: String,
|
||||
description: String,
|
||||
activation: RobotDocsActivation,
|
||||
quick_start: serde_json::Value,
|
||||
commands: serde_json::Value,
|
||||
/// Deprecated command aliases (old -> new)
|
||||
aliases: serde_json::Value,
|
||||
/// Pre-clap error tolerance: what the CLI auto-corrects
|
||||
error_tolerance: serde_json::Value,
|
||||
exit_codes: serde_json::Value,
|
||||
/// Error codes emitted by clap parse failures
|
||||
clap_error_codes: serde_json::Value,
|
||||
error_format: String,
|
||||
workflows: serde_json::Value,
|
||||
config_notes: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RobotDocsActivation {
|
||||
flags: Vec<String>,
|
||||
env: String,
|
||||
auto: String,
|
||||
}
|
||||
|
||||
fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let version = env!("CARGO_PKG_VERSION").to_string();
|
||||
|
||||
let commands = serde_json::json!({
|
||||
"init": {
|
||||
"description": "Initialize configuration and database",
|
||||
"flags": ["--force", "--non-interactive", "--gitlab-url <URL>", "--token-env-var <VAR>", "--projects <paths>", "--default-project <path>"],
|
||||
"robot_flags": ["--gitlab-url", "--token-env-var", "--projects", "--default-project"],
|
||||
"example": "lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project,other/repo --default-project group/project",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"config_path": "string", "data_dir": "string", "user": {"username": "string", "name": "string"}, "projects": "[{path:string, name:string}]", "default_project": "string?"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"description": "Quick pre-flight check: config, database, schema version",
|
||||
"flags": [],
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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. Supports surgical per-IID mode.",
|
||||
"flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--no-status", "--dry-run", "--no-dry-run", "-t/--timings", "--lock", "--issue <IID>", "--mr <IID>", "-p/--project <path>", "--preflight-only"],
|
||||
"example": "lore --robot sync",
|
||||
"surgical_mode": {
|
||||
"description": "Sync specific issues or MRs by IID. Runs a scoped pipeline: preflight -> TOCTOU check -> ingest -> dependents -> docs -> embed.",
|
||||
"flags": ["--issue <IID> (repeatable)", "--mr <IID> (repeatable)", "-p/--project <path> (required)", "--preflight-only"],
|
||||
"examples": [
|
||||
"lore --robot sync --issue 7 -p group/project",
|
||||
"lore --robot sync --issue 7 --issue 42 --mr 10 -p group/project",
|
||||
"lore --robot sync --issue 7 -p group/project --preflight-only"
|
||||
],
|
||||
"constraints": ["--issue/--mr requires -p/--project (or defaultProject in config)", "--full and --issue/--mr are incompatible", "--preflight-only requires --issue or --mr", "Max 100 total targets"],
|
||||
"entity_result_outcomes": ["synced", "skipped_stale", "not_found", "preflight_failed", "error"]
|
||||
},
|
||||
"response_schema": {
|
||||
"normal": {
|
||||
"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}]"}
|
||||
},
|
||||
"surgical": {
|
||||
"ok": "bool",
|
||||
"data": {"surgical_mode": "true", "surgical_iids": "{issues:[int], merge_requests:[int]}", "entity_results": "[{entity_type:string, iid:int, outcome:string, error?:string, toctou_reason?:string}]", "preflight_only?": "bool", "issues_updated": "int", "mrs_updated": "int", "documents_regenerated": "int", "documents_embedded": "int", "discussions_fetched": "int"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"description": "List or show issues",
|
||||
"flags": ["<IID>", "-n/--limit", "--fields <list>", "-s/--state", "--status <name>", "-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",
|
||||
"notes": {
|
||||
"status_filter": "--status filters by work item status NAME (case-insensitive). Valid values are in meta.available_statuses of any issues list response.",
|
||||
"status_name": "status_name is the board column label (e.g. 'In review', 'Blocked'). This is the canonical status identifier for filtering."
|
||||
},
|
||||
"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, status_name:string?}]", "total_count": "int", "showing": "int"},
|
||||
"meta": {"elapsed_ms": "int", "available_statuses": "[string] — all distinct status names in the database, for use with --status filter"}
|
||||
},
|
||||
"show": {
|
||||
"ok": "bool",
|
||||
"data": "IssueDetail (full entity with description, discussions, notes, events)",
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"example_output": {"list": {"ok":true,"data":{"issues":[{"iid":3864,"title":"Switch Health Card","state":"opened","status_name":"In progress","labels":["customer:BNSF"],"assignees":["teernisse"],"discussion_count":12,"updated_at_iso":"2026-02-12T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":42}}},
|
||||
"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",
|
||||
"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"}
|
||||
}
|
||||
},
|
||||
"example_output": {"list": {"ok":true,"data":{"mrs":[{"iid":200,"title":"Add throw time chart","state":"opened","draft":false,"author_username":"teernisse","target_branch":"main","source_branch":"feat/throw-time","reviewers":["cseiber"],"discussion_count":5,"updated_at_iso":"2026-02-11T..."}],"total_count":1,"showing":1},"meta":{"elapsed_ms":38}}},
|
||||
"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", "--since", "--updated-since", "-n/--limit", "--fields <list>", "--explain", "--no-explain", "--fts-mode"],
|
||||
"example": "lore --robot search 'authentication bug' --mode hybrid --limit 10",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"results": "[{document_id:int, source_type:string, title:string, snippet:string, score:float, url:string?, author:string?, created_at:string?, updated_at:string?, project_path:string, labels:[string], paths:[string]}]", "total_results": "int", "query": "string", "mode": "string", "warnings": "[string]"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
},
|
||||
"example_output": {"ok":true,"data":{"query":"throw time","mode":"hybrid","total_results":3,"results":[{"document_id":42,"source_type":"issue","title":"Switch Health Card","score":0.92,"snippet":"...throw time data from BNSF...","project_path":"vs/typescript-code"}],"warnings":[]},"meta":{"elapsed_ms":85}},
|
||||
"fields_presets": {"minimal": ["document_id", "title", "source_type", "score"]}
|
||||
},
|
||||
"count": {
|
||||
"description": "Count entities in local database",
|
||||
"flags": ["<entity: issues|mrs|discussions|notes|events>", "-f/--for <issue|mr>"],
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"version": "string", "git_hash?": "string"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"completions": {
|
||||
"description": "Generate shell completions",
|
||||
"flags": ["<shell: bash|zsh|fish|powershell>"],
|
||||
"example": "lore completions bash > ~/.local/share/bash-completion/completions/lore"
|
||||
},
|
||||
"timeline": {
|
||||
"description": "Chronological timeline of events matching a keyword query or entity reference",
|
||||
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--no-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
|
||||
"query_syntax": {
|
||||
"search": "Any text -> hybrid search seeding (FTS5 + vector)",
|
||||
"entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)"
|
||||
},
|
||||
"example": "lore --robot timeline issue:42",
|
||||
"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", "search_mode": "string (hybrid|lexical|direct)"}
|
||||
},
|
||||
"fields_presets": {"minimal": ["timestamp", "type", "entity_iid", "detail"]}
|
||||
},
|
||||
"who": {
|
||||
"description": "People intelligence: experts, workload, active discussions, overlap, review patterns",
|
||||
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit", "--fields <list>", "--detail", "--no-detail", "--as-of <date>", "--explain-score", "--include-bots", "--include-closed", "--all-history"],
|
||||
"modes": {
|
||||
"expert": "lore who <file-path> -- Who knows about this area? (also: --path for root files)",
|
||||
"workload": "lore who <username> -- What is someone working on?",
|
||||
"reviews": "lore who <username> --reviews -- Review pattern analysis",
|
||||
"active": "lore who --active -- Active unresolved discussions",
|
||||
"overlap": "lore who --overlap <path> -- Who else is touching these files?"
|
||||
},
|
||||
"example": "lore --robot who src/features/auth/",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {
|
||||
"mode": "string",
|
||||
"input": {"target": "string|null", "path": "string|null", "project": "string|null", "since": "string|null", "limit": "int"},
|
||||
"resolved_input": {"mode": "string", "project_id": "int|null", "project_path": "string|null", "since_ms": "int", "since_iso": "string", "since_mode": "string (default|explicit|none)", "limit": "int"},
|
||||
"...": "mode-specific fields"
|
||||
},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
},
|
||||
"example_output": {"expert": {"ok":true,"data":{"mode":"expert","result":{"experts":[{"username":"teernisse","score":42,"note_count":15,"diff_note_count":8}]}},"meta":{"elapsed_ms":65}}},
|
||||
"fields_presets": {
|
||||
"expert_minimal": ["username", "score"],
|
||||
"workload_minimal": ["entity_type", "iid", "title", "state"],
|
||||
"active_minimal": ["entity_type", "iid", "title", "participants"]
|
||||
}
|
||||
},
|
||||
"trace": {
|
||||
"description": "Trace why code was introduced: file -> MR -> issue -> discussion. Follows rename chains by default.",
|
||||
"flags": ["<path>", "-p/--project <path>", "--discussions", "--no-follow-renames", "-n/--limit <N>"],
|
||||
"example": "lore --robot trace src/main.rs -p group/repo",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"path": "string", "resolved_paths": "[string]", "trace_chains": "[{mr_iid:int, mr_title:string, mr_state:string, mr_author:string, change_type:string, merged_at_iso:string?, updated_at_iso:string, web_url:string?, issues:[{iid:int, title:string, state:string, reference_type:string, web_url:string?}], discussions:[{discussion_id:string, mr_iid:int, author_username:string, body_snippet:string, path:string, created_at_iso:string}]}]"},
|
||||
"meta": {"tier": "string (api_only)", "line_requested": "int?", "elapsed_ms": "int", "total_chains": "int", "renames_followed": "bool"}
|
||||
}
|
||||
},
|
||||
"file-history": {
|
||||
"description": "Show MRs that touched a file, with rename chain resolution and optional DiffNote discussions",
|
||||
"flags": ["<path>", "-p/--project <path>", "--discussions", "--no-follow-renames", "--merged", "-n/--limit <N>"],
|
||||
"example": "lore --robot file-history src/main.rs -p group/repo",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"path": "string", "rename_chain": "[string]?", "merge_requests": "[{iid:int, title:string, state:string, author_username:string, change_type:string, merged_at_iso:string?, updated_at_iso:string, merge_commit_sha:string?, web_url:string?}]", "discussions": "[{discussion_id:string, author_username:string, body_snippet:string, path:string, created_at_iso:string}]?"},
|
||||
"meta": {"elapsed_ms": "int", "total_mrs": "int", "renames_followed": "bool", "paths_searched": "int"}
|
||||
}
|
||||
},
|
||||
"drift": {
|
||||
"description": "Detect discussion divergence from original issue intent",
|
||||
"flags": ["<entity_type: issues>", "<IID>", "--threshold <0.0-1.0>", "-p/--project <path>"],
|
||||
"example": "lore --robot drift issues 42 --threshold 0.4",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"entity_type": "string", "iid": "int", "title": "string", "threshold": "float", "divergent_discussions": "[{discussion_id:string, similarity:float, snippet:string}]"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"description": "List notes from discussions with rich filtering",
|
||||
"flags": ["--limit/-n <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--fields <list|minimal>", "--open"],
|
||||
"robot_flags": ["--format json", "--fields minimal"],
|
||||
"example": "lore --robot notes --author jdefting --since 1y --format json --fields minimal",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"description": "Manage cron-based automatic syncing (Unix only)",
|
||||
"subcommands": {
|
||||
"install": {"flags": ["--interval <minutes>"], "default_interval": 8},
|
||||
"uninstall": {"flags": []},
|
||||
"status": {"flags": []}
|
||||
},
|
||||
"example": "lore --robot cron status",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"action": "string (install|uninstall|status)", "installed?": "bool", "interval_minutes?": "int", "entry?": "string", "log_path?": "string", "replaced?": "bool", "was_installed?": "bool", "last_run_iso?": "string"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"description": "Manage stored GitLab token",
|
||||
"subcommands": {
|
||||
"set": {"flags": ["--token <value>"], "note": "Reads from stdin if --token omitted in non-interactive mode"},
|
||||
"show": {"flags": ["--unmask"]}
|
||||
},
|
||||
"example": "lore --robot token show",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"action": "string (set|show)", "token_masked?": "string", "token?": "string", "valid?": "bool", "username?": "string"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"me": {
|
||||
"description": "Personal work dashboard: open issues, authored/reviewing MRs, @mentioned-in items, activity feed, and cursor-based since-last-check inbox with computed attention states",
|
||||
"flags": ["--issues", "--mrs", "--mentions", "--activity", "--since <period>", "-p/--project <path>", "--all", "--user <username>", "--fields <list|minimal>", "--reset-cursor"],
|
||||
"example": "lore --robot me",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {
|
||||
"username": "string",
|
||||
"since_iso": "string?",
|
||||
"summary": {"project_count": "int", "open_issue_count": "int", "authored_mr_count": "int", "reviewing_mr_count": "int", "mentioned_in_count": "int", "needs_attention_count": "int"},
|
||||
"since_last_check": "{cursor_iso:string, total_event_count:int, groups:[{entity_type:string, entity_iid:int, entity_title:string, project:string, events:[{timestamp_iso:string, event_type:string, actor:string?, summary:string, body_preview:string?}]}]}?",
|
||||
"open_issues": "[{project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, status_name:string?, labels:[string], updated_at_iso:string, web_url:string?}]",
|
||||
"open_mrs_authored": "[{project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, draft:bool, detailed_merge_status:string?, author_username:string?, labels:[string], updated_at_iso:string, web_url:string?}]",
|
||||
"reviewing_mrs": "[same as open_mrs_authored]",
|
||||
"mentioned_in": "[{entity_type:string, project:string, iid:int, title:string, state:string, attention_state:string, attention_reason:string, updated_at_iso:string, web_url:string?}]",
|
||||
"activity": "[{timestamp_iso:string, event_type:string, entity_type:string, entity_iid:int, project:string, actor:string?, is_own:bool, summary:string, body_preview:string?}]"
|
||||
},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
},
|
||||
"fields_presets": {
|
||||
"me_items_minimal": ["iid", "title", "attention_state", "attention_reason", "updated_at_iso"],
|
||||
"me_mentions_minimal": ["entity_type", "iid", "title", "state", "attention_state", "attention_reason", "updated_at_iso"],
|
||||
"me_activity_minimal": ["timestamp_iso", "event_type", "entity_iid", "actor"]
|
||||
},
|
||||
"notes": {
|
||||
"attention_states": "needs_attention | not_started | awaiting_response | stale | not_ready",
|
||||
"event_types": "note | status_change | label_change | assign | unassign | review_request | milestone_change",
|
||||
"section_flags": "If none of --issues/--mrs/--mentions/--activity specified, all sections returned",
|
||||
"since_default": "1d for activity feed",
|
||||
"issue_filter": "Only In Progress / In Review status issues shown",
|
||||
"since_last_check": "Cursor-based inbox showing events since last run. Null on first run (no cursor yet). Groups events by entity (issue/MR). Sources: others' comments on your items, @mentions, assignment/review-request notes. Cursor auto-advances after each run. Use --reset-cursor to clear.",
|
||||
"cursor_persistence": "Stored per user in ~/.local/share/lore/me_cursor_<username>.json. --project filters display only for since-last-check; cursor still advances for all projects for that user."
|
||||
}
|
||||
},
|
||||
"robot-docs": {
|
||||
"description": "This command (agent self-discovery manifest)",
|
||||
"flags": ["--brief"],
|
||||
"example": "lore robot-docs --brief"
|
||||
}
|
||||
});
|
||||
|
||||
let quick_start = serde_json::json!({
|
||||
"glab_equivalents": [
|
||||
{ "glab": "glab issue list", "lore": "lore -J issues -n 50", "note": "Richer: includes labels, status, closing MRs, discussion counts" },
|
||||
{ "glab": "glab issue view 123", "lore": "lore -J issues 123", "note": "Includes full discussions, work-item status, cross-references" },
|
||||
{ "glab": "glab issue list -l bug", "lore": "lore -J issues --label bug", "note": "AND logic for multiple --label flags" },
|
||||
{ "glab": "glab mr list", "lore": "lore -J mrs", "note": "Includes draft status, reviewers, discussion counts" },
|
||||
{ "glab": "glab mr view 456", "lore": "lore -J mrs 456", "note": "Includes discussions, review threads, source/target branches" },
|
||||
{ "glab": "glab mr list -s opened", "lore": "lore -J mrs -s opened", "note": "States: opened, merged, closed, locked, all" },
|
||||
{ "glab": "glab api '/projects/:id/issues'", "lore": "lore -J issues -p project", "note": "Fuzzy project matching (suffix or substring)" }
|
||||
],
|
||||
"lore_exclusive": [
|
||||
"search: FTS5 + vector hybrid search across all entities",
|
||||
"who: Expert/workload/reviews analysis per file path or person",
|
||||
"timeline: Chronological event reconstruction across entities",
|
||||
"trace: Code provenance chains (file -> MR -> issue -> discussion)",
|
||||
"file-history: MR history per file with rename resolution",
|
||||
"notes: Rich note listing with author, type, resolution, path, and discussion filters",
|
||||
"stats: Database statistics with document/note/discussion counts",
|
||||
"count: Entity counts with state breakdowns",
|
||||
"embed: Generate vector embeddings for semantic search via Ollama",
|
||||
"cron: Automated sync scheduling (Unix)",
|
||||
"token: Secure token management with masked display",
|
||||
"me: Personal work dashboard with attention states, activity feed, cursor-based since-last-check inbox, and needs-attention triage"
|
||||
],
|
||||
"read_write_split": "lore = ALL reads (issues, MRs, search, who, timeline, intelligence). glab = ALL writes (create, update, approve, merge, CI/CD)."
|
||||
});
|
||||
|
||||
// --brief: strip response_schema and example_output from every command (~60% smaller)
|
||||
let mut commands = commands;
|
||||
if brief {
|
||||
strip_schemas(&mut commands);
|
||||
}
|
||||
|
||||
let exit_codes = serde_json::json!({
|
||||
"0": "Success",
|
||||
"1": "Internal error",
|
||||
"2": "Usage error (invalid flags or arguments)",
|
||||
"3": "Config invalid",
|
||||
"4": "Token not set",
|
||||
"5": "GitLab auth failed",
|
||||
"6": "Resource not found",
|
||||
"7": "Rate limited",
|
||||
"8": "Network error",
|
||||
"9": "Database locked",
|
||||
"10": "Database error",
|
||||
"11": "Migration failed",
|
||||
"12": "I/O error",
|
||||
"13": "Transform error",
|
||||
"14": "Ollama unavailable",
|
||||
"15": "Ollama model not found",
|
||||
"16": "Embedding failed",
|
||||
"17": "Not found",
|
||||
"18": "Ambiguous match",
|
||||
"19": "Health check failed",
|
||||
"20": "Config not found"
|
||||
});
|
||||
|
||||
let workflows = serde_json::json!({
|
||||
"first_setup": [
|
||||
"lore --robot init --gitlab-url https://gitlab.com --token-env-var GITLAB_TOKEN --projects group/project",
|
||||
"lore --robot doctor",
|
||||
"lore --robot sync"
|
||||
],
|
||||
"daily_sync": [
|
||||
"lore --robot sync"
|
||||
],
|
||||
"search": [
|
||||
"lore --robot search 'query' --mode hybrid"
|
||||
],
|
||||
"pre_flight": [
|
||||
"lore --robot health"
|
||||
],
|
||||
"temporal_intelligence": [
|
||||
"lore --robot sync",
|
||||
"lore --robot timeline '<keyword>' --since 30d",
|
||||
"lore --robot timeline '<keyword>' --depth 2"
|
||||
],
|
||||
"people_intelligence": [
|
||||
"lore --robot who src/path/to/feature/",
|
||||
"lore --robot who @username",
|
||||
"lore --robot who @username --reviews",
|
||||
"lore --robot who --active --since 7d",
|
||||
"lore --robot who --overlap src/path/",
|
||||
"lore --robot who --path README.md"
|
||||
],
|
||||
"surgical_sync": [
|
||||
"lore --robot sync --issue 7 -p group/project",
|
||||
"lore --robot sync --issue 7 --mr 10 -p group/project",
|
||||
"lore --robot sync --issue 7 -p group/project --preflight-only"
|
||||
],
|
||||
"personal_dashboard": [
|
||||
"lore --robot me",
|
||||
"lore --robot me --issues",
|
||||
"lore --robot me --activity --since 7d",
|
||||
"lore --robot me --project group/repo",
|
||||
"lore --robot me --fields minimal",
|
||||
"lore --robot me --reset-cursor"
|
||||
]
|
||||
});
|
||||
|
||||
// Phase 3: Deprecated command aliases
|
||||
let aliases = serde_json::json!({
|
||||
"deprecated_commands": {
|
||||
"list issues": "issues",
|
||||
"list mrs": "mrs",
|
||||
"show issue <IID>": "issues <IID>",
|
||||
"show mr <IID>": "mrs <IID>",
|
||||
"auth-test": "auth",
|
||||
"sync-status": "status"
|
||||
},
|
||||
"command_aliases": {
|
||||
"issue": "issues",
|
||||
"mr": "mrs",
|
||||
"merge-requests": "mrs",
|
||||
"merge-request": "mrs",
|
||||
"mergerequests": "mrs",
|
||||
"mergerequest": "mrs",
|
||||
"generate-docs": "generate-docs",
|
||||
"generatedocs": "generate-docs",
|
||||
"gendocs": "generate-docs",
|
||||
"gen-docs": "generate-docs",
|
||||
"robot-docs": "robot-docs",
|
||||
"robotdocs": "robot-docs"
|
||||
},
|
||||
"pre_clap_aliases": {
|
||||
"note": "Underscore/no-separator forms auto-corrected before parsing",
|
||||
"merge_requests": "mrs",
|
||||
"merge_request": "mrs",
|
||||
"mergerequests": "mrs",
|
||||
"mergerequest": "mrs",
|
||||
"generate_docs": "generate-docs",
|
||||
"generatedocs": "generate-docs",
|
||||
"gendocs": "generate-docs",
|
||||
"gen-docs": "generate-docs",
|
||||
"robot-docs": "robot-docs",
|
||||
"robotdocs": "robot-docs"
|
||||
},
|
||||
"prefix_matching": "Enabled via infer_subcommands. Unambiguous prefixes work: 'iss' -> issues, 'time' -> timeline, 'sea' -> search"
|
||||
});
|
||||
|
||||
let error_tolerance = serde_json::json!({
|
||||
"note": "The CLI auto-corrects common mistakes before parsing. Corrections are applied silently with a teaching note on stderr.",
|
||||
"auto_corrections": [
|
||||
{"type": "single_dash_long_flag", "example": "-robot -> --robot", "mode": "all"},
|
||||
{"type": "case_normalization", "example": "--Robot -> --robot, --State -> --state", "mode": "all"},
|
||||
{"type": "flag_prefix", "example": "--proj -> --project (when unambiguous)", "mode": "all"},
|
||||
{"type": "fuzzy_flag", "example": "--projct -> --project", "mode": "all (threshold 0.9 in robot, 0.8 in human)"},
|
||||
{"type": "subcommand_alias", "example": "merge_requests -> mrs, robotdocs -> robot-docs", "mode": "all"},
|
||||
{"type": "subcommand_fuzzy", "example": "issuess -> issues, timline -> timeline, serach -> search", "mode": "all (threshold 0.85)"},
|
||||
{"type": "flag_as_subcommand", "example": "--robot-docs -> robot-docs, --generate-docs -> generate-docs", "mode": "all"},
|
||||
{"type": "value_normalization", "example": "--state Opened -> --state opened", "mode": "all"},
|
||||
{"type": "value_fuzzy", "example": "--state opend -> --state opened", "mode": "all"},
|
||||
{"type": "prefix_matching", "example": "lore iss -> lore issues, lore time -> lore timeline", "mode": "all (via clap infer_subcommands)"}
|
||||
],
|
||||
"teaching_notes": "Auto-corrections emit a JSON warning on stderr: {\"warning\":{\"type\":\"ARG_CORRECTED\",\"corrections\":[...],\"teaching\":[...]}}"
|
||||
});
|
||||
|
||||
// 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 config_notes = serde_json::json!({
|
||||
"defaultProject": {
|
||||
"type": "string?",
|
||||
"description": "Fallback project path used when -p/--project is omitted. Must match a configured project path (exact or suffix). CLI -p always overrides.",
|
||||
"example": "group/project"
|
||||
}
|
||||
});
|
||||
|
||||
let output = RobotDocsOutput {
|
||||
ok: true,
|
||||
data: RobotDocsData {
|
||||
name: "lore".to_string(),
|
||||
version,
|
||||
description: "Local GitLab data management with semantic search".to_string(),
|
||||
activation: RobotDocsActivation {
|
||||
flags: vec!["--robot".to_string(), "-J".to_string(), "--json".to_string()],
|
||||
env: "LORE_ROBOT=1".to_string(),
|
||||
auto: "Non-TTY stdout".to_string(),
|
||||
},
|
||||
quick_start,
|
||||
commands,
|
||||
aliases,
|
||||
error_tolerance,
|
||||
exit_codes,
|
||||
clap_error_codes,
|
||||
error_format: "stderr JSON: {\"error\":{\"code\":\"...\",\"message\":\"...\",\"suggestion\":\"...\",\"actions\":[\"...\"]}}".to_string(),
|
||||
workflows,
|
||||
config_notes,
|
||||
},
|
||||
};
|
||||
|
||||
if robot_mode {
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
} else {
|
||||
println!("{}", serde_json::to_string_pretty(&output)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_who(
|
||||
config_override: Option<&str>,
|
||||
mut args: WhoArgs,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
if args.project.is_none() {
|
||||
args.project = config.default_project.clone();
|
||||
}
|
||||
let run = run_who(&config, &args)?;
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
if robot_mode {
|
||||
print_who_json(&run, &args, elapsed_ms);
|
||||
} else {
|
||||
print_who_human(&run.result, run.resolved_input.project_path.as_deref());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_me(
|
||||
config_override: Option<&str>,
|
||||
args: MeArgs,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = Config::load(config_override)?;
|
||||
run_me(&config, &args, robot_mode)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_drift(
|
||||
config_override: Option<&str>,
|
||||
entity_type: &str,
|
||||
iid: i64,
|
||||
threshold: f32,
|
||||
project: Option<&str>,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
let effective_project = config.effective_project(project);
|
||||
let response = run_drift(&config, entity_type, iid, threshold, effective_project).await?;
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
if robot_mode {
|
||||
print_drift_json(&response, elapsed_ms);
|
||||
} else {
|
||||
print_drift_human(&response);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_related(
|
||||
config_override: Option<&str>,
|
||||
query_or_type: &str,
|
||||
iid: Option<i64>,
|
||||
limit: usize,
|
||||
project: Option<&str>,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
let effective_project = config.effective_project(project);
|
||||
let response = run_related(&config, query_or_type, iid, limit, effective_project).await?;
|
||||
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||
|
||||
if robot_mode {
|
||||
print_related_json(&response, elapsed_ms);
|
||||
} else {
|
||||
print_related_human(&response);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_list_compat(
|
||||
config_override: Option<&str>,
|
||||
entity: &str,
|
||||
limit: usize,
|
||||
project_filter: Option<&str>,
|
||||
state_filter: Option<&str>,
|
||||
author_filter: Option<&str>,
|
||||
assignee_filter: Option<&str>,
|
||||
label_filter: Option<&[String]>,
|
||||
milestone_filter: Option<&str>,
|
||||
since_filter: Option<&str>,
|
||||
due_before_filter: Option<&str>,
|
||||
has_due_date: bool,
|
||||
sort: &str,
|
||||
order: &str,
|
||||
open_browser: bool,
|
||||
json_output: bool,
|
||||
draft: bool,
|
||||
no_draft: bool,
|
||||
reviewer_filter: Option<&str>,
|
||||
target_branch_filter: Option<&str>,
|
||||
source_branch_filter: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
let project_filter = config.effective_project(project_filter);
|
||||
|
||||
let state_normalized = state_filter.map(str::to_lowercase);
|
||||
match entity {
|
||||
"issues" => {
|
||||
let filters = ListFilters {
|
||||
limit,
|
||||
project: project_filter,
|
||||
state: state_normalized.as_deref(),
|
||||
author: author_filter,
|
||||
assignee: assignee_filter,
|
||||
labels: label_filter,
|
||||
milestone: milestone_filter,
|
||||
since: since_filter,
|
||||
due_before: due_before_filter,
|
||||
has_due_date,
|
||||
statuses: &[],
|
||||
sort,
|
||||
order,
|
||||
};
|
||||
|
||||
let result = run_list_issues(&config, filters)?;
|
||||
|
||||
if open_browser {
|
||||
open_issue_in_browser(&result);
|
||||
} else if json_output {
|
||||
print_list_issues_json(&result, start.elapsed().as_millis() as u64, None);
|
||||
} else {
|
||||
print_list_issues(&result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
"mrs" => {
|
||||
let filters = MrListFilters {
|
||||
limit,
|
||||
project: project_filter,
|
||||
state: state_normalized.as_deref(),
|
||||
author: author_filter,
|
||||
assignee: assignee_filter,
|
||||
reviewer: reviewer_filter,
|
||||
labels: label_filter,
|
||||
since: since_filter,
|
||||
draft,
|
||||
no_draft,
|
||||
target_branch: target_branch_filter,
|
||||
source_branch: source_branch_filter,
|
||||
sort,
|
||||
order,
|
||||
};
|
||||
|
||||
let result = run_list_mrs(&config, filters)?;
|
||||
|
||||
if open_browser {
|
||||
open_mr_in_browser(&result);
|
||||
} else if json_output {
|
||||
print_list_mrs_json(&result, start.elapsed().as_millis() as u64, None);
|
||||
} else {
|
||||
print_list_mrs(&result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::error().render(&format!("Unknown entity: {entity}"))
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_show_compat(
|
||||
config_override: Option<&str>,
|
||||
entity: &str,
|
||||
iid: i64,
|
||||
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)?;
|
||||
let project_filter = config.effective_project(project_filter);
|
||||
|
||||
match entity {
|
||||
"issue" => {
|
||||
let result = run_show_issue(&config, iid, project_filter)?;
|
||||
if robot_mode {
|
||||
print_show_issue_json(&result, start.elapsed().as_millis() as u64);
|
||||
} else {
|
||||
print_show_issue(&result);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"mr" => {
|
||||
let result = run_show_mr(&config, iid, project_filter)?;
|
||||
if robot_mode {
|
||||
print_show_mr_json(&result, start.elapsed().as_millis() as u64);
|
||||
} else {
|
||||
print_show_mr(&result);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::error().render(&format!("Unknown entity: {entity}"))
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user