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

292
src/cli/commands/cron.rs Normal file
View File

@@ -0,0 +1,292 @@
use serde::Serialize;
use crate::Config;
use crate::cli::render::Theme;
use crate::cli::robot::RobotMeta;
use crate::core::cron::{
CronInstallResult, CronStatusResult, CronUninstallResult, cron_status, install_cron,
uninstall_cron,
};
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::paths::get_db_path;
use crate::core::time::ms_to_iso;
// ── install ──
pub fn run_cron_install(interval_minutes: u32) -> Result<CronInstallResult> {
install_cron(interval_minutes)
}
pub fn print_cron_install(result: &CronInstallResult) {
if result.replaced {
println!(
" {} cron entry updated (was already installed)",
Theme::success().render("Updated")
);
} else {
println!(
" {} cron entry installed",
Theme::success().render("Installed")
);
}
println!();
println!(" {} {}", Theme::dim().render("entry:"), result.entry);
println!(
" {} every {} minutes",
Theme::dim().render("interval:"),
result.interval_minutes
);
println!(
" {} {}",
Theme::dim().render("log:"),
result.log_path.display()
);
if cfg!(target_os = "macos") {
println!();
println!(
" {} On macOS, the terminal running cron may need",
Theme::warning().render("Note:")
);
println!(" Full Disk Access in System Settings > Privacy & Security.");
}
println!();
}
#[derive(Serialize)]
struct CronInstallJson {
ok: bool,
data: CronInstallData,
meta: RobotMeta,
}
#[derive(Serialize)]
struct CronInstallData {
action: &'static str,
entry: String,
interval_minutes: u32,
log_path: String,
replaced: bool,
}
pub fn print_cron_install_json(result: &CronInstallResult, elapsed_ms: u64) {
let output = CronInstallJson {
ok: true,
data: CronInstallData {
action: "install",
entry: result.entry.clone(),
interval_minutes: result.interval_minutes,
log_path: result.log_path.display().to_string(),
replaced: result.replaced,
},
meta: RobotMeta { elapsed_ms },
};
if let Ok(json) = serde_json::to_string(&output) {
println!("{json}");
}
}
// ── uninstall ──
pub fn run_cron_uninstall() -> Result<CronUninstallResult> {
uninstall_cron()
}
pub fn print_cron_uninstall(result: &CronUninstallResult) {
if result.was_installed {
println!(
" {} cron entry removed",
Theme::success().render("Removed")
);
} else {
println!(
" {} no lore-sync cron entry found",
Theme::dim().render("Nothing to remove:")
);
}
println!();
}
#[derive(Serialize)]
struct CronUninstallJson {
ok: bool,
data: CronUninstallData,
meta: RobotMeta,
}
#[derive(Serialize)]
struct CronUninstallData {
action: &'static str,
was_installed: bool,
}
pub fn print_cron_uninstall_json(result: &CronUninstallResult, elapsed_ms: u64) {
let output = CronUninstallJson {
ok: true,
data: CronUninstallData {
action: "uninstall",
was_installed: result.was_installed,
},
meta: RobotMeta { elapsed_ms },
};
if let Ok(json) = serde_json::to_string(&output) {
println!("{json}");
}
}
// ── status ──
pub fn run_cron_status(config: &Config) -> Result<CronStatusInfo> {
let status = cron_status()?;
// Query last sync run from DB
let last_sync = get_last_sync_time(config).unwrap_or_default();
Ok(CronStatusInfo { status, last_sync })
}
pub struct CronStatusInfo {
pub status: CronStatusResult,
pub last_sync: Option<LastSyncInfo>,
}
pub struct LastSyncInfo {
pub started_at_iso: String,
pub status: String,
}
fn get_last_sync_time(config: &Config) -> Result<Option<LastSyncInfo>> {
let db_path = get_db_path(config.storage.db_path.as_deref());
if !db_path.exists() {
return Ok(None);
}
let conn = create_connection(&db_path)?;
let result = conn.query_row(
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1",
[],
|row| {
let started_at: i64 = row.get(0)?;
let status: String = row.get(1)?;
Ok(LastSyncInfo {
started_at_iso: ms_to_iso(started_at),
status,
})
},
);
match result {
Ok(info) => Ok(Some(info)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
// Table may not exist if migrations haven't run yet
Err(rusqlite::Error::SqliteFailure(_, Some(ref msg))) if msg.contains("no such table") => {
Ok(None)
}
Err(e) => Err(e.into()),
}
}
pub fn print_cron_status(info: &CronStatusInfo) {
if info.status.installed {
println!(
" {} lore-sync is installed in crontab",
Theme::success().render("Installed")
);
if let Some(interval) = info.status.interval_minutes {
println!(
" {} every {} minutes",
Theme::dim().render("interval:"),
interval
);
}
if let Some(ref binary) = info.status.binary_path {
let label = if info.status.binary_mismatch {
Theme::warning().render("binary:")
} else {
Theme::dim().render("binary:")
};
println!(" {label} {binary}");
if info.status.binary_mismatch
&& let Some(ref current) = info.status.current_binary
{
println!(
" {}",
Theme::warning().render(&format!(" current binary is {current} (mismatch!)"))
);
}
}
if let Some(ref log) = info.status.log_path {
println!(" {} {}", Theme::dim().render("log:"), log.display());
}
} else {
println!(
" {} lore-sync is not installed in crontab",
Theme::dim().render("Not installed:")
);
println!(
" {} lore cron install",
Theme::dim().render("install with:")
);
}
if let Some(ref last) = info.last_sync {
println!(
" {} {} ({})",
Theme::dim().render("last sync:"),
last.started_at_iso,
last.status
);
}
println!();
}
#[derive(Serialize)]
struct CronStatusJson {
ok: bool,
data: CronStatusData,
meta: RobotMeta,
}
#[derive(Serialize)]
struct CronStatusData {
installed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
interval_minutes: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
binary_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
current_binary: Option<String>,
binary_mismatch: bool,
#[serde(skip_serializing_if = "Option::is_none")]
log_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cron_entry: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_sync_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
last_sync_status: Option<String>,
}
pub fn print_cron_status_json(info: &CronStatusInfo, elapsed_ms: u64) {
let output = CronStatusJson {
ok: true,
data: CronStatusData {
installed: info.status.installed,
interval_minutes: info.status.interval_minutes,
binary_path: info.status.binary_path.clone(),
current_binary: info.status.current_binary.clone(),
binary_mismatch: info.status.binary_mismatch,
log_path: info
.status
.log_path
.as_ref()
.map(|p| p.display().to_string()),
cron_entry: info.status.cron_entry.clone(),
last_sync_at: info.last_sync.as_ref().map(|s| s.started_at_iso.clone()),
last_sync_status: info.last_sync.as_ref().map(|s| s.status.clone()),
},
meta: RobotMeta { elapsed_ms },
};
if let Ok(json) = serde_json::to_string(&output) {
println!("{json}");
}
}

View File

@@ -1,5 +1,7 @@
pub mod auth_test;
pub mod count;
#[cfg(unix)]
pub mod cron;
pub mod doctor;
pub mod drift;
pub mod embed;
@@ -22,6 +24,12 @@ pub use count::{
print_count, print_count_json, print_event_count, print_event_count_json, run_count,
run_count_events,
};
#[cfg(unix)]
pub use cron::{
print_cron_install, print_cron_install_json, print_cron_status, print_cron_status_json,
print_cron_uninstall, print_cron_uninstall_json, run_cron_install, run_cron_status,
run_cron_uninstall,
};
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
pub use embed::{print_embed, print_embed_json, run_embed};
@@ -35,8 +43,7 @@ pub use init::{InitInputs, InitOptions, InitResult, run_init};
pub use list::{
ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser,
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,
query_notes, run_list_issues, run_list_mrs,
print_list_notes, print_list_notes_json, query_notes, run_list_issues, run_list_mrs,
};
pub use search::{
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,

View File

@@ -16,7 +16,9 @@ use std::io::IsTerminal;
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)")]
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")]
@@ -135,19 +137,35 @@ pub enum Commands {
Count(CountArgs),
/// Show sync state
#[command(visible_alias = "st")]
#[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 --force # Overwrite existing config
lore --robot init --gitlab-url https://gitlab.com \\
--token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")]
Init {
/// Skip overwrite confirmation
#[arg(short = 'f', long)]
@@ -174,11 +192,14 @@ pub enum Commands {
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,
},
@@ -202,9 +223,15 @@ pub enum Commands {
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
@@ -242,6 +269,10 @@ pub enum Commands {
Trace(TraceArgs),
/// 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"])]
@@ -259,6 +290,14 @@ pub enum Commands {
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),
#[command(hide = true)]
List {
#[arg(value_parser = ["issues", "mrs"])]
@@ -344,7 +383,7 @@ pub struct IssuesArgs {
pub fields: Option<Vec<String>>,
/// Filter by state (opened, closed, all)
#[arg(short = 's', long, help_heading = "Filters")]
#[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "closed", "all"])]
pub state: Option<String>,
/// Filter by project path
@@ -438,7 +477,7 @@ pub struct MrsArgs {
pub fields: Option<Vec<String>>,
/// Filter by state (opened, merged, closed, locked, all)
#[arg(short = 's', long, help_heading = "Filters")]
#[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "merged", "closed", "locked", "all"])]
pub state: Option<String>,
/// Filter by project path
@@ -535,15 +574,6 @@ pub struct NotesArgs {
#[arg(long, help_heading = "Output", value_delimiter = ',')]
pub fields: Option<Vec<String>>,
/// Output format (table, json, jsonl, csv)
#[arg(
long,
default_value = "table",
value_parser = ["table", "json", "jsonl", "csv"],
help_heading = "Output"
)]
pub format: String,
/// Filter by author username
#[arg(short = 'a', long, help_heading = "Filters")]
pub author: Option<String>,
@@ -655,6 +685,11 @@ pub struct IngestArgs {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore stats # Show document and index statistics
lore stats --check # Run integrity checks
lore stats --repair --dry-run # Preview what repair would fix
lore --robot stats # JSON output for automation")]
pub struct StatsArgs {
/// Run integrity checks
#[arg(long, overrides_with = "no_check")]
@@ -743,6 +778,10 @@ pub struct SearchArgs {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore generate-docs # Generate docs for dirty entities
lore generate-docs --full # Full rebuild of all documents
lore generate-docs --full -p group/repo # Full rebuild for one project")]
pub struct GenerateDocsArgs {
/// Full rebuild: seed all entities into dirty queue, then drain
#[arg(long)]
@@ -805,9 +844,17 @@ pub struct SyncArgs {
/// Show detailed timing breakdown for sync stages
#[arg(short = 't', long = "timings")]
pub timings: bool,
/// Acquire file lock before syncing (skip if another sync is running)
#[arg(long)]
pub lock: bool,
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore embed # Embed new/changed documents
lore embed --full # Re-embed all documents from scratch
lore embed --retry-failed # Retry previously failed embeddings")]
pub struct EmbedArgs {
/// Re-embed all documents (clears existing embeddings first)
#[arg(long, overrides_with = "no_full")]
@@ -1046,6 +1093,10 @@ pub struct TraceArgs {
}
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore count issues # Total issues in local database
lore count notes --for mr # Notes on merge requests only
lore count discussions --for issue # Discussions on issues only")]
pub struct CountArgs {
/// Entity type to count (issues, mrs, discussions, notes, events)
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
@@ -1055,3 +1106,25 @@ pub struct CountArgs {
#[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])]
pub for_entity: Option<String>,
}
#[derive(Parser)]
pub struct CronArgs {
#[command(subcommand)]
pub action: CronAction,
}
#[derive(Subcommand)]
pub enum CronAction {
/// Install cron job for automatic syncing
Install {
/// Sync interval in minutes (default: 8)
#[arg(long, default_value = "8")]
interval: u32,
},
/// Remove cron job
Uninstall,
/// Show current cron configuration
Status,
}