feat(cron): add lore cron command for automated sync scheduling

Add lore cron {install,uninstall,status} to manage a crontab entry that
runs lore sync on a configurable interval. Supports both human and robot
output modes.

Core implementation (src/core/cron.rs):
  - install_cron: appends a tagged crontab entry, detects existing entries
  - uninstall_cron: removes the tagged entry
  - cron_status: reads crontab + checks last-sync time from the database
  - Unix-only (#[cfg(unix)]) — compiles out on Windows

CLI wiring:
  - CronAction enum and CronArgs in cli/mod.rs with after_help examples
  - Robot JSON envelope with RobotMeta timing for all 3 sub-actions
  - Dispatch in main.rs

Also in this commit:
  - Add after_help example blocks to Status, Auth, Doctor, Init, Migrate,
    Health commands for better discoverability
  - Add LORE_ICONS env var documentation to CLI help text
  - Simplify notes format dispatch in main.rs (removed csv/jsonl paths)
  - Update commands/mod.rs re-exports for cron + notes cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-18 13:29:07 -05:00
parent 1808a4da8e
commit 53ce20595b
6 changed files with 844 additions and 43 deletions

View File

@@ -11,26 +11,29 @@ use lore::cli::autocorrect::{self, CorrectionResult};
use lore::cli::commands::{
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser,
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_doctor_results,
print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json,
print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history,
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json,
print_list_notes_jsonl, print_search_results, print_search_results_json, print_show_issue,
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed,
run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues,
run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status,
run_timeline, run_who,
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_cron_install,
print_cron_install_json, print_cron_status, print_cron_status_json, print_cron_uninstall,
print_cron_uninstall_json, print_doctor_results, print_drift_human, print_drift_json,
print_dry_run_preview, print_dry_run_preview_json, print_embed, print_embed_json,
print_event_count, print_event_count_json, print_file_history, print_file_history_json,
print_generate_docs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json,
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
print_list_notes, print_list_notes_json, print_search_results, print_search_results_json,
print_show_issue, print_show_issue_json, print_show_mr, print_show_mr_json, print_stats,
print_stats_json, print_sync, print_sync_json, print_sync_status, print_sync_status_json,
print_timeline, print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human,
print_who_json, query_notes, run_auth_test, run_count, run_count_events, run_cron_install,
run_cron_status, run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history,
run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs,
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline,
run_who,
};
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas};
use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs, IngestArgs, IssuesArgs,
MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs, TraceArgs, WhoArgs,
Cli, Commands, CountArgs, CronAction, CronArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs,
IngestArgs, IssuesArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs,
TraceArgs, WhoArgs,
};
use lore::core::db::{
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
@@ -203,6 +206,7 @@ async fn main() {
handle_file_history(cli.config.as_deref(), args, robot_mode)
}
Some(Commands::Trace(args)) => handle_trace(cli.config.as_deref(), args, robot_mode),
Some(Commands::Cron(args)) => handle_cron(cli.config.as_deref(), args, robot_mode),
Some(Commands::Drift {
entity_type,
iid,
@@ -922,21 +926,14 @@ fn handle_notes(
let result = query_notes(&conn, &filters, &config)?;
let format = if robot_mode && args.format == "table" {
"json"
} else {
&args.format
};
match format {
"json" => print_list_notes_json(
if robot_mode {
print_list_notes_json(
&result,
start.elapsed().as_millis() as u64,
args.fields.as_deref(),
),
"jsonl" => print_list_notes_jsonl(&result),
"csv" => print_list_notes_csv(&result),
_ => print_list_notes(&result),
);
} else {
print_list_notes(&result);
}
Ok(())
@@ -1642,6 +1639,7 @@ struct VersionOutput {
#[derive(Serialize)]
struct VersionData {
name: &'static str,
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
git_hash: Option<String>,
@@ -1655,6 +1653,7 @@ fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
let output = VersionOutput {
ok: true,
data: VersionData {
name: "lore",
version,
git_hash: if git_hash.is_empty() {
None
@@ -2182,6 +2181,24 @@ async fn handle_sync_cmd(
return Ok(());
}
// Acquire file lock if --lock was passed (used by cron to skip overlapping runs)
let _sync_lock = if args.lock {
match lore::core::cron::acquire_sync_lock() {
Ok(Some(guard)) => Some(guard),
Ok(None) => {
// Another sync is running — silently exit (expected for cron)
tracing::debug!("--lock: another sync is running, skipping");
return Ok(());
}
Err(e) => {
tracing::warn!(error = %e, "--lock: failed to acquire file lock, skipping sync");
return Ok(());
}
}
} else {
None
};
let db_path = get_db_path(config.storage.db_path.as_deref());
let recorder_conn = create_connection(&db_path)?;
let run_id = uuid::Uuid::new_v4().simple().to_string();
@@ -2254,6 +2271,47 @@ async fn handle_sync_cmd(
}
}
fn handle_cron(
config_override: Option<&str>,
args: CronArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
match args.action {
CronAction::Install { interval } => {
let result = run_cron_install(interval)?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
print_cron_install_json(&result, elapsed_ms);
} else {
print_cron_install(&result);
}
}
CronAction::Uninstall => {
let result = run_cron_uninstall()?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
print_cron_uninstall_json(&result, elapsed_ms);
} else {
print_cron_uninstall(&result);
}
}
CronAction::Status => {
let config = Config::load(config_override)?;
let info = run_cron_status(&config)?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
print_cron_status_json(&info, elapsed_ms);
} else {
print_cron_status(&info);
}
}
}
Ok(())
}
#[derive(Serialize)]
struct HealthOutput {
ok: bool,