use anyhow::Result;
use clap::{CommandFactory, Parser, Subcommand};
use std::io::{self, IsTerminal, Write};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[cfg(unix)]
fn reset_sigpipe() {
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
#[cfg(not(unix))]
fn reset_sigpipe() {
}
mod capture;
mod cli;
mod cloud;
mod config;
mod daemon;
mod git;
mod mcp;
mod storage;
mod summarize;
use cli::commands;
use config::Config;
const YELLOW: &str = "\x1b[33m";
const GREEN: &str = "\x1b[32m";
const RED: &str = "\x1b[31m";
const BOLD: &str = "\x1b[1m";
const RESET: &str = "\x1b[0m";
const PROMPT_TIMEOUT_SECS: u64 = 30;
#[derive(Parser)]
#[command(name = "lore")]
#[command(version)]
#[command(about = "Reasoning history for code - capture the story behind your commits")]
#[command(
long_about = "Lore captures AI coding sessions and links them to git commits,\n\
preserving the reasoning behind code changes.\n\n\
Git captures code history (what changed). Lore captures reasoning\n\
history (how and why it changed through human-AI collaboration)."
)]
#[command(after_help = "EXAMPLES:\n \
lore import Import sessions from Claude Code\n \
lore sessions List recent sessions\n \
lore show abc123 View session details\n \
lore show --commit HEAD View sessions linked to HEAD\n \
lore link abc123 Link session to HEAD\n \
lore search \"auth\" Search sessions for text\n \
lore insights Show AI development insights\n \
lore daemon start Start background watcher\n\n\
For more information about a command, run 'lore <command> --help'.")]
pub struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(long, global = true)]
no_init: bool,
}
#[derive(Subcommand)]
enum Commands {
#[command(
long_about = "Runs a guided setup wizard that detects installed AI coding tools\n\
and creates an initial configuration file. Use this when first\n\
installing Lore or to reconfigure your setup."
)]
Init(commands::init::Args),
#[command(
long_about = "Displays an overview of the Lore database including session counts,\n\
watcher availability, daemon status, links to the current commit,\n\
and a list of recent sessions."
)]
Status(commands::status::Args),
#[command(
long_about = "Reports the active session ID for the current working directory.\n\
Queries the daemon if running, otherwise checks the database for\n\
the most recent session."
)]
Current(commands::current::Args),
#[command(
long_about = "Provides a summary of recent sessions for the current repository.\n\
Shows session ID, tool, start time, message count, and linked commits.\n\
Use --last for detailed info about the most recent session only."
)]
Context(commands::context::Args),
#[command(
long_about = "Displays a table of imported sessions with their IDs, timestamps,\n\
message counts, branches, and directories. Sessions can be filtered\n\
by repository path."
)]
Sessions(commands::sessions::Args),
#[command(
long_about = "Displays the full conversation history for a session, or lists\n\
all sessions linked to a specific commit when using --commit.\n\
\n\
Supports multiple output formats:\n\
- text: colored terminal output (default)\n\
- json: machine-readable structured output\n\
- markdown: formatted for documentation"
)]
Show(commands::show::Args),
#[command(
long_about = "Creates associations between AI coding sessions and git commits.\n\
Links can be created manually by specifying session IDs, or\n\
automatically using --auto to find sessions by time proximity\n\
and file overlap."
)]
Link(commands::link::Args),
#[command(
long_about = "Removes links between sessions and commits. Can remove a specific\n\
link using --commit, or remove all links for a session."
)]
Unlink(commands::unlink::Args),
#[command(
long_about = "Adds an annotation (bookmark or note) to the current active session\n\
or a specified session. Annotations help mark important moments\n\
in a session for later reference."
)]
Annotate(commands::annotate::Args),
#[command(
long_about = "Tags sessions for organization and filtering. Each session can\n\
have multiple tags. Use --remove to remove a tag. Tags are shown\n\
in 'lore show' output and can be filtered with 'lore sessions --tag'."
)]
Tag(commands::tag::Args),
#[command(
long_about = "Manages session summaries that provide concise descriptions of\n\
what happened in a session. Summaries help with quickly understanding\n\
session context when continuing work or reviewing history."
)]
Summarize(commands::summarize::Args),
#[command(
long_about = "Permanently removes a session and all its associated data\n\
(messages and links) from the database. This operation cannot\n\
be undone."
)]
Delete(commands::delete::Args),
#[command(
long_about = "Uses git blame to find the commit that introduced a specific\n\
line of code, then looks up any sessions linked to that commit.\n\
Displays the session info and relevant message excerpts."
)]
Blame(commands::blame::Args),
#[command(
long_about = "Exports session data as markdown or JSON. Supports redaction\n\
of sensitive information like API keys, tokens, passwords,\n\
and email addresses. Use --redact for built-in patterns or\n\
--redact-pattern for custom regex patterns."
)]
Export(commands::export::Args),
#[command(
long_about = "Searches message content using SQLite FTS5 full-text search.\n\
Supports filtering by repository, date range, and message role.\n\
The search index is built automatically on first use."
)]
Search(commands::search::Args),
#[command(
long_about = "Provides subcommands to show, get, and set configuration values.\n\
Configuration is stored in ~/.lore/config.yaml."
)]
Config(commands::config::Args),
#[command(
long_about = "Discovers and imports session files from AI coding tools into\n\
the Lore database. Tracks imported files to avoid duplicates.\n\n\
Supported tools:\n \
- Aider (markdown chat history files)\n \
- Claude Code (JSONL files in ~/.claude/projects/)\n \
- Cline (VS Code extension storage)\n \
- Codex CLI (JSONL files in ~/.codex/sessions/)\n \
- Continue.dev (JSON files in ~/.continue/sessions/)\n \
- Gemini CLI (JSON files in ~/.gemini/tmp/)"
)]
Import(commands::import::Args),
#[command(
long_about = "Surfaces analytics about AI-assisted development patterns\n\
including commit coverage, tool usage breakdown, activity patterns,\n\
and most-touched files. Use --since to scope to a time period."
)]
Insights(commands::insights::Args),
#[command(
long_about = "Installs, uninstalls, or checks the status of git hooks that\n\
integrate Lore with your git workflow. The post-commit hook\n\
automatically links sessions to commits."
)]
Hooks(commands::hooks::Args),
#[command(
long_about = "Controls the background daemon that watches for new AI coding\n\
sessions and automatically imports them into the database."
)]
Daemon(commands::daemon::Args),
#[command(
long_about = "Database management commands for maintenance and statistics.\n\
Includes vacuum (reclaim space), prune (delete old sessions),\n\
and stats (show database statistics)."
)]
Db(commands::db::Args),
#[command(
long_about = "Opens a browser to authenticate with the Lore cloud service.\n\
After authentication, your API key is stored securely in the\n\
OS keychain (or a fallback file if keychain is unavailable)."
)]
Login(commands::login::Args),
#[command(
long_about = "Removes stored credentials and encryption keys from the keychain\n\
and any fallback files."
)]
Logout(commands::logout::Args),
#[command(
long_about = "Cloud sync commands for backing up sessions and syncing across\n\
machines. Session content is encrypted end-to-end using a\n\
passphrase that only you know."
)]
Cloud(commands::cloud::Args),
#[command(
long_about = "Performs comprehensive health checks on the Lore installation.\n\
Checks configuration, database, daemon status, watchers, and MCP server.\n\
Returns exit code 0 for OK, 1 for warnings, 2 for errors."
)]
Doctor(commands::doctor::Args),
#[command(
long_about = "Runs the MCP (Model Context Protocol) server on stdio.\n\
This allows AI coding tools like Claude Code to query Lore\n\
session data directly."
)]
Mcp(commands::mcp::Args),
#[command(
long_about = "Generates shell completion scripts for various shells.\n\
Output to stdout for redirection to the appropriate file."
)]
Completions(commands::completions::Args),
}
fn is_configured() -> bool {
Config::config_path()
.map(|path| path.exists())
.unwrap_or(false)
}
fn should_skip_first_run_prompt(command: &Commands) -> bool {
matches!(
command,
Commands::Init(_)
| Commands::Config(_)
| Commands::Completions(_)
| Commands::Doctor(_)
| Commands::Mcp(_)
| Commands::Login(_)
| Commands::Logout(_)
)
}
fn is_daemon_foreground(command: &Commands) -> bool {
matches!(
command,
Commands::Daemon(commands::daemon::Args {
command: commands::daemon::DaemonSubcommand::Start { foreground: true }
})
)
}
fn is_interactive() -> bool {
io::stdin().is_terminal()
}
fn stdout_is_tty() -> bool {
io::stdout().is_terminal()
}
#[derive(Debug, PartialEq)]
enum PromptResult {
Yes,
No,
Timeout,
}
fn prompt_for_init() -> Result<PromptResult> {
let use_color = stdout_is_tty();
if use_color {
print!(
"{BOLD}{YELLOW}Lore isn't configured yet. Run setup?{RESET} [{GREEN}Y{RESET}/{RED}n{RESET}] "
);
} else {
print!("Lore isn't configured yet. Run setup? [Y/n] ");
}
io::stdout().flush()?;
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
let _ = tx.send(input);
}
});
match rx.recv_timeout(Duration::from_secs(PROMPT_TIMEOUT_SECS)) {
Ok(input) => {
let input = input.trim().to_lowercase();
if input.is_empty() || input == "y" || input == "yes" {
Ok(PromptResult::Yes)
} else {
Ok(PromptResult::No)
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {
println!();
Ok(PromptResult::Timeout)
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
println!();
Ok(PromptResult::No)
}
}
}
fn create_minimal_config() -> Result<()> {
let config = Config::default();
config.save()
}
fn command_name(command: &Commands) -> &'static str {
match command {
Commands::Init(_) => "init",
Commands::Status(_) => "status",
Commands::Current(_) => "current",
Commands::Context(_) => "context",
Commands::Sessions(_) => "sessions",
Commands::Show(_) => "show",
Commands::Link(_) => "link",
Commands::Unlink(_) => "unlink",
Commands::Annotate(_) => "annotate",
Commands::Tag(_) => "tag",
Commands::Summarize(_) => "summarize",
Commands::Delete(_) => "delete",
Commands::Blame(_) => "blame",
Commands::Export(_) => "export",
Commands::Search(_) => "search",
Commands::Config(_) => "config",
Commands::Import(_) => "import",
Commands::Insights(_) => "insights",
Commands::Hooks(_) => "hooks",
Commands::Daemon(_) => "daemon",
Commands::Db(_) => "db",
Commands::Login(_) => "login",
Commands::Logout(_) => "logout",
Commands::Cloud(_) => "cloud",
Commands::Doctor(_) => "doctor",
Commands::Mcp(_) => "mcp",
Commands::Completions(_) => "completions",
}
}
fn main() -> Result<()> {
reset_sigpipe();
let cli = Cli::parse();
if !is_daemon_foreground(&cli.command) {
let filter = if cli.verbose {
"lore=debug"
} else {
"lore=info"
};
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| filter.into()),
)
.with(tracing_subscriber::fmt::layer().without_time())
.init();
}
if !cli.no_init
&& !is_configured()
&& !should_skip_first_run_prompt(&cli.command)
&& is_interactive()
{
match prompt_for_init()? {
PromptResult::Yes => {
commands::init::run(commands::init::Args { force: true })?;
println!();
println!(
"Setup complete! Running 'lore {}'...",
command_name(&cli.command)
);
println!();
}
PromptResult::No => {
println!("Okay, run 'lore init' anytime to configure.");
println!();
create_minimal_config()?;
}
PromptResult::Timeout => {
println!("No response, continuing without setup...");
println!();
create_minimal_config()?;
}
}
}
match cli.command {
Commands::Init(args) => commands::init::run(args),
Commands::Status(args) => commands::status::run(args),
Commands::Current(args) => commands::current::run(args),
Commands::Context(args) => commands::context::run(args),
Commands::Sessions(args) => commands::sessions::run(args),
Commands::Show(args) => commands::show::run(args),
Commands::Link(args) => commands::link::run(args),
Commands::Unlink(args) => commands::unlink::run(args),
Commands::Annotate(args) => commands::annotate::run(args),
Commands::Tag(args) => commands::tag::run(args),
Commands::Summarize(args) => commands::summarize::run(args),
Commands::Delete(args) => commands::delete::run(args),
Commands::Blame(args) => commands::blame::run(args),
Commands::Export(args) => commands::export::run(args),
Commands::Search(args) => commands::search::run(args),
Commands::Config(args) => commands::config::run(args),
Commands::Import(args) => commands::import::run(args),
Commands::Insights(args) => commands::insights::run(args),
Commands::Hooks(args) => commands::hooks::run(args),
Commands::Daemon(args) => commands::daemon::run(args),
Commands::Db(args) => commands::db::run(args),
Commands::Login(args) => commands::login::run(args),
Commands::Logout(args) => commands::logout::run(args),
Commands::Cloud(args) => commands::cloud::run(args),
Commands::Doctor(args) => commands::doctor::run(args),
Commands::Mcp(args) => commands::mcp::run(args),
Commands::Completions(args) => {
let mut cmd = Cli::command();
commands::completions::run(args, &mut cmd)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::OutputFormat;
#[test]
fn test_should_skip_first_run_prompt_init() {
let command = Commands::Init(commands::init::Args { force: false });
assert!(should_skip_first_run_prompt(&command));
}
#[test]
fn test_should_skip_first_run_prompt_init_force() {
let command = Commands::Init(commands::init::Args { force: true });
assert!(should_skip_first_run_prompt(&command));
}
#[test]
fn test_should_skip_first_run_prompt_config() {
let command = Commands::Config(commands::config::Args {
command: None,
format: OutputFormat::Text,
});
assert!(should_skip_first_run_prompt(&command));
}
#[test]
fn test_should_not_skip_first_run_prompt_status() {
let command = Commands::Status(commands::status::Args {
format: OutputFormat::Text,
});
assert!(!should_skip_first_run_prompt(&command));
}
#[test]
fn test_should_not_skip_first_run_prompt_sessions() {
let command = Commands::Sessions(commands::sessions::Args {
repo: None,
tag: None,
limit: 20,
format: OutputFormat::Text,
});
assert!(!should_skip_first_run_prompt(&command));
}
#[test]
fn test_should_not_skip_first_run_prompt_import() {
let command = Commands::Import(commands::import::Args {
force: false,
dry_run: false,
});
assert!(!should_skip_first_run_prompt(&command));
}
#[test]
fn test_cli_no_init_flag_parses() {
use clap::Parser;
let cli = Cli::try_parse_from(["lore", "--no-init", "status"]);
assert!(cli.is_ok());
let cli = cli.unwrap();
assert!(cli.no_init);
}
#[test]
fn test_cli_no_init_flag_default_false() {
use clap::Parser;
let cli = Cli::try_parse_from(["lore", "status"]);
assert!(cli.is_ok());
let cli = cli.unwrap();
assert!(!cli.no_init);
}
#[test]
fn test_cli_no_init_flag_with_verbose() {
use clap::Parser;
let cli = Cli::try_parse_from(["lore", "--no-init", "--verbose", "sessions"]);
assert!(cli.is_ok());
let cli = cli.unwrap();
assert!(cli.no_init);
assert!(cli.verbose);
}
#[test]
fn test_command_name_status() {
let command = Commands::Status(commands::status::Args {
format: OutputFormat::Text,
});
assert_eq!(command_name(&command), "status");
}
#[test]
fn test_command_name_sessions() {
let command = Commands::Sessions(commands::sessions::Args {
repo: None,
tag: None,
limit: 20,
format: OutputFormat::Text,
});
assert_eq!(command_name(&command), "sessions");
}
#[test]
fn test_command_name_import() {
let command = Commands::Import(commands::import::Args {
force: false,
dry_run: false,
});
assert_eq!(command_name(&command), "import");
}
#[test]
fn test_command_name_init() {
let command = Commands::Init(commands::init::Args { force: false });
assert_eq!(command_name(&command), "init");
}
#[test]
fn test_prompt_result_equality() {
assert_eq!(PromptResult::Yes, PromptResult::Yes);
assert_eq!(PromptResult::No, PromptResult::No);
assert_eq!(PromptResult::Timeout, PromptResult::Timeout);
assert_ne!(PromptResult::Yes, PromptResult::No);
assert_ne!(PromptResult::Yes, PromptResult::Timeout);
assert_ne!(PromptResult::No, PromptResult::Timeout);
}
#[test]
fn test_completions_bash() {
use clap_complete::{generate, Shell};
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(Shell::Bash, &mut cmd, "lore", &mut buf);
let output = String::from_utf8(buf).expect("valid utf8");
assert!(
output.contains("_lore"),
"Should contain bash completion function"
);
}
#[test]
fn test_completions_zsh() {
use clap_complete::{generate, Shell};
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(Shell::Zsh, &mut cmd, "lore", &mut buf);
let output = String::from_utf8(buf).expect("valid utf8");
assert!(
output.contains("#compdef lore"),
"Should contain zsh compdef"
);
}
#[test]
fn test_completions_fish() {
use clap_complete::{generate, Shell};
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(Shell::Fish, &mut cmd, "lore", &mut buf);
let output = String::from_utf8(buf).expect("valid utf8");
assert!(
output.contains("complete -c lore"),
"Should contain fish completion"
);
}
#[test]
fn test_completions_powershell() {
use clap_complete::{generate, Shell};
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(Shell::PowerShell, &mut cmd, "lore", &mut buf);
let output = String::from_utf8(buf).expect("valid utf8");
assert!(
output.contains("Register-ArgumentCompleter"),
"Should contain powershell completer"
);
}
#[test]
fn test_completions_elvish() {
use clap_complete::{generate, Shell};
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(Shell::Elvish, &mut cmd, "lore", &mut buf);
let output = String::from_utf8(buf).expect("valid utf8");
assert!(
output.contains("set edit:completion"),
"Should contain elvish completion"
);
}
}