use std::process::ExitCode;
use claudette::{
briefing, clock, google_auth, probe_ollama, run_forge_mission, run_secretary,
run_secretary_repl, scheduler, secrets, telegram_mode, theme, try_load_session,
workspace_startup_diagnostics, SessionOptions,
};
use claudette::{ContentBlock, Session};
#[derive(Debug, Default)]
struct CliArgs {
resume: bool,
telegram: bool,
chat_ids: Vec<i64>,
prompt_words: Vec<String>,
tui: bool,
auth_google: bool,
auth_google_revoke: bool,
auth_google_scope: Option<String>,
briefing: bool,
briefing_time: Option<String>,
briefing_days: Option<String>,
help: bool,
version: bool,
allow_any_chat: bool,
forge: bool,
doctor: bool,
}
const HELP_TEXT: &str = "\
claudette — a local-first AI personal secretary, powered by Ollama.
USAGE:
claudette [FLAGS] [PROMPT...]
MODES (pick one; default is interactive REPL):
(none) Start the interactive REPL. Type /help once inside
to see the slash-command list.
\"<prompt>\" Single-shot: print one reply and exit.
--resume, -r Continue the most recent saved session. Works in
REPL and single-shot.
--telegram, -t Run as a Telegram bot. Requires TELEGRAM_BOT_TOKEN.
--tui Launch the fullscreen ratatui TUI (Chat / Tools /
Notes / Todos / HW tabs).
--forge \"<prompt>\" Run the prompt in forge-mode inside the active
brownfield mission. Errors if no mission is active —
start one with /brownfield <repo> first. v0a runs a
single brain turn with file/search/git/advanced/github
tools pre-enabled and exits at mission_submit (auto-PR).
TELEGRAM OPTIONS:
--chat <id> Restrict the Telegram bot to chat ID <id>.
Repeatable; can also be set via CLAUDETTE_TELEGRAM_CHAT
(comma-separated list). The bot default-denies when
no allowlist is provided.
--chat any Explicit accept-all: serve every incoming chat.
Required to start the bot with no allowlist. Prints
a loud warning since anyone who guesses the bot
username can DM and get a full assistant.
ONE-SHOT SETUP COMMANDS (each exits after doing its one job):
--doctor Probe every dependency (Ollama, embed model, OAuth
tokens, voice deps, secrets dir) and print a
green/red diagnostic report. Useful when a tool
fails inside the REPL and you don't yet know why.
--auth-google [scope]
Run the loopback OAuth flow for Google APIs. <scope>
is 'calendar' (default) or 'gmail'. Stores tokens
under ~/.claudette/secrets/.
--revoke Pair with --auth-google to revoke consent + delete
the local token file for that scope.
--briefing Write a recurring morning-briefing entry to
~/.claudette/schedule.jsonl and exit. The Telegram
bot picks it up next time it starts.
--time HH:MM Modifier for --briefing. Default: 07:00.
--days <spec> Modifier for --briefing. One of 'weekdays' (default),
'daily', or a single weekday name ('monday', etc).
MISC:
--help, -h Show this help and exit.
--version, -V Show the claudette version and exit.
ENVIRONMENT:
See README.md for the full env-var reference. Frequently used:
OLLAMA_HOST Ollama API endpoint (default localhost:11434).
CLAUDETTE_MODEL Override the brain model.
CLAUDETTE_CODER_MODEL Override the Codet coder model.
CLAUDETTE_SESSION Override the session-file path.
TELEGRAM_BOT_TOKEN Required for --telegram.
EXAMPLES:
claudette # start the REPL
claudette \"what time is it?\" # one-shot
claudette -r # resume last session
claudette --tui # fullscreen TUI
claudette --auth-google calendar # OAuth once
claudette --briefing --time 08:30 # weekday briefings at 08:30
claudette --telegram --chat 12345 # bot restricted to one chat
DOCS:
README.md Full feature / configuration reference
examples/ Scenario walkthroughs
CONTRIBUTING.md How to contribute
SECURITY.md Vulnerability reporting
";
fn main() -> ExitCode {
if let Ok(home) = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) {
let path = std::path::PathBuf::from(home)
.join(".claudette")
.join(".env");
let _ = dotenvy::from_path(&path);
}
theme::init();
let raw_args: Vec<String> = std::env::args().skip(1).collect();
let args = parse_args(&raw_args);
if args.help {
print!("{HELP_TEXT}");
return ExitCode::SUCCESS;
}
if args.version {
println!("claudette {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
for warning in workspace_startup_diagnostics() {
eprintln!(
"{} {}",
theme::warn(theme::WARN_GLYPH),
theme::warn(&warning)
);
}
let CliArgs {
resume,
telegram,
mut chat_ids,
prompt_words: prompt_args,
tui: tui_mode,
auth_google,
auth_google_revoke,
auth_google_scope,
briefing,
briefing_time,
briefing_days,
help: _,
version: _,
allow_any_chat,
forge,
doctor,
} = args;
if doctor {
let code = claudette::doctor::run();
return if code == 0 {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
};
}
if auth_google {
let ctx = match auth_google_scope.as_deref() {
None => google_auth::AuthContext::Calendar, Some(s) => match google_auth::AuthContext::parse(s) {
Some(c) => c,
None => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!(
"unknown --auth-google scope '{s}'. Try 'calendar' or 'gmail'."
))
);
return ExitCode::FAILURE;
}
},
};
let result = if auth_google_revoke {
google_auth::revoke(ctx)
} else {
google_auth::run_auth_flow(ctx)
};
return match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("{} {}", theme::error(theme::ERR_GLYPH), theme::error(&e));
ExitCode::FAILURE
}
};
}
if briefing {
return run_briefing_setup(briefing_time.as_deref(), briefing_days.as_deref());
}
if let Err(msg) = probe_ollama() {
eprintln!("{} {}", theme::error(theme::ERR_GLYPH), theme::error(&msg));
return ExitCode::FAILURE;
}
if forge {
if prompt_args.is_empty() {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(
"--forge requires a prompt. Try: claudette --forge \"fix the parser bug\""
)
);
return ExitCode::FAILURE;
}
let opts = SessionOptions {
resume,
autosave: resume,
};
let prompt = prompt_args.join(" ");
return match run_forge_mission(&prompt, opts) {
Ok(summary) => {
eprintln!();
eprintln!(
"{} {}",
theme::BOLT,
theme::info(&format!(
"forge iter={} in={} out={}",
summary.iterations, summary.usage.input_tokens, summary.usage.output_tokens,
))
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("{e:#}"))
);
ExitCode::FAILURE
}
};
}
if tui_mode {
let session = if resume {
match try_load_session() {
Ok(Some(s)) => s,
Ok(None) => Session::default(),
Err(e) => {
eprintln!("Failed to load session: {e:#}");
Session::default()
}
}
} else {
Session::default()
};
return match claudette::tui::run_tui(session) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("{e:#}"))
);
ExitCode::FAILURE
}
};
}
if telegram {
for id in secrets::load_chat_ids() {
if !chat_ids.contains(&id) {
chat_ids.push(id);
}
}
match telegram_mode::run_telegram_bot(chat_ids, allow_any_chat, resume) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("{e:#}"))
);
ExitCode::FAILURE
}
}
} else if prompt_args.is_empty() {
let opts = SessionOptions {
resume,
autosave: true,
};
match run_secretary_repl(opts) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("{e:#}"))
);
ExitCode::FAILURE
}
}
} else {
let opts = SessionOptions {
resume,
autosave: resume,
};
let prompt = prompt_args.join(" ");
match run_secretary(&prompt, opts) {
Ok(summary) => {
if let Some(last) = summary.assistant_messages.last() {
for block in &last.blocks {
if let ContentBlock::Text { text } = block {
println!("{text}");
}
}
}
eprintln!();
eprintln!(
"{} {}",
theme::BOLT,
theme::info(&format!(
"iter={} in={} out={}",
summary.iterations, summary.usage.input_tokens, summary.usage.output_tokens,
))
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("{e:#}"))
);
ExitCode::FAILURE
}
}
}
}
fn parse_args(args: &[String]) -> CliArgs {
let mut out = CliArgs::default();
let mut expect = ExpectNext::Nothing;
if let Ok(val) = std::env::var("CLAUDETTE_TELEGRAM_CHAT") {
for part in val.split(',') {
let trimmed = part.trim();
if trimmed.eq_ignore_ascii_case("any") {
out.allow_any_chat = true;
} else if let Ok(id) = trimmed.parse::<i64>() {
out.chat_ids.push(id);
}
}
}
for arg in args {
match expect {
ExpectNext::ChatId => {
if arg.eq_ignore_ascii_case("any") {
out.allow_any_chat = true;
} else if let Ok(id) = arg.parse::<i64>() {
out.chat_ids.push(id);
}
expect = ExpectNext::Nothing;
continue;
}
ExpectNext::Time => {
out.briefing_time = Some(arg.clone());
expect = ExpectNext::Nothing;
continue;
}
ExpectNext::Days => {
out.briefing_days = Some(arg.clone());
expect = ExpectNext::Nothing;
continue;
}
ExpectNext::AuthGoogleScope => {
expect = ExpectNext::Nothing;
if claudette::google_auth::AuthContext::parse(arg).is_some() {
out.auth_google_scope = Some(arg.clone());
continue;
}
}
ExpectNext::Nothing => {}
}
match arg.as_str() {
"--help" | "-h" => out.help = true,
"--version" | "-V" => out.version = true,
"--resume" | "-r" => out.resume = true,
"--telegram" | "-t" => out.telegram = true,
"--tui" => out.tui = true,
"--chat" => expect = ExpectNext::ChatId,
"--auth-google" => {
out.auth_google = true;
expect = ExpectNext::AuthGoogleScope;
}
"--revoke" => out.auth_google_revoke = true,
"--briefing" => out.briefing = true,
"--time" => expect = ExpectNext::Time,
"--days" => expect = ExpectNext::Days,
"--forge" => out.forge = true,
"--doctor" => out.doctor = true,
_ => out.prompt_words.push(arg.clone()),
}
}
out
}
enum ExpectNext {
Nothing,
ChatId,
Time,
Days,
AuthGoogleScope,
}
fn run_briefing_setup(time: Option<&str>, days: Option<&str>) -> ExitCode {
let time_str = time.unwrap_or("07:00");
let days_spec = days.unwrap_or("weekdays").to_lowercase();
let when = match days_spec.as_str() {
"weekdays" | "weekday" => format!("every weekday at {time_str}"),
"daily" | "everyday" | "every-day" => format!("daily at {time_str}"),
"mon" | "monday" | "tue" | "tuesday" | "wed" | "wednesday" | "thu" | "thursday" | "fri"
| "friday" | "sat" | "saturday" | "sun" | "sunday" => {
format!("every {days_spec} at {time_str}")
}
other => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!(
"--days '{other}' not recognised. Try 'weekdays', 'daily', or a weekday name."
))
);
return ExitCode::FAILURE;
}
};
let path = scheduler::default_path();
let clk: std::sync::Arc<dyn clock::Clock> = std::sync::Arc::new(clock::SystemClock);
match scheduler::Scheduler::load(path.clone(), clk.clone()) {
Ok((loaded, _firings)) => scheduler::install(loaded),
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("scheduler load failed: {e}"))
);
return ExitCode::FAILURE;
}
}
let mut guard = match scheduler::global().lock() {
Ok(g) => g,
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("scheduler lock failed: {e}"))
);
return ExitCode::FAILURE;
}
};
let existing: Vec<String> = guard
.list()
.iter()
.filter(|e| e.prompt == briefing::BRIEFING_PROMPT)
.map(|e| e.id.clone())
.collect();
for id in &existing {
let _ = guard.cancel(id);
}
match guard.add(
&when,
briefing::BRIEFING_PROMPT.to_string(),
None, Some(scheduler::CatchUp::Skip),
) {
Ok(entry) => {
drop(guard);
let replaced_note = if existing.is_empty() {
String::new()
} else {
format!(" (replaced {} previous entry/ies)", existing.len())
};
eprintln!(
"{} {}",
theme::SPARKLES,
theme::ok(&format!(
"scheduled briefing '{}' — {}{}",
entry.id, entry.original_expr, replaced_note
))
);
eprintln!(
" {} {}",
theme::dim("▸"),
theme::dim(&format!(
"next fire: {}",
entry
.next_fire_at
.with_timezone(&chrono::Local)
.to_rfc3339()
))
);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!(
"{} {}",
theme::error(theme::ERR_GLYPH),
theme::error(&format!("could not schedule briefing: {e}"))
);
ExitCode::FAILURE
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_no_flags() {
let a = parse_args(&["hello".into(), "world".into()]);
assert!(!a.resume);
assert!(!a.telegram);
assert!(!a.tui);
assert!(!a.auth_google);
assert!(!a.auth_google_revoke);
assert!(!a.briefing);
assert_eq!(
a.prompt_words,
vec!["hello".to_string(), "world".to_string()]
);
}
#[test]
fn parse_args_resume_long() {
let a = parse_args(&["--resume".into(), "what".into(), "time".into()]);
assert!(a.resume);
assert_eq!(a.prompt_words, vec!["what".to_string(), "time".to_string()]);
}
#[test]
fn parse_args_resume_short() {
let a = parse_args(&["-r".into()]);
assert!(a.resume);
assert!(a.prompt_words.is_empty());
}
#[test]
fn parse_args_resume_anywhere() {
let a = parse_args(&["go".into(), "-r".into(), "now".into()]);
assert!(a.resume);
assert_eq!(a.prompt_words, vec!["go".to_string(), "now".to_string()]);
}
#[test]
fn parse_args_telegram_mode() {
let a = parse_args(&["--telegram".into()]);
assert!(a.telegram);
assert!(a.prompt_words.is_empty());
}
#[test]
fn parse_args_telegram_with_chat() {
let a = parse_args(&[
"--telegram".into(),
"--resume".into(),
"--chat".into(),
"123456789".into(),
]);
assert!(a.telegram);
assert!(a.resume);
assert!(a.chat_ids.contains(&123456789));
assert!(!a.allow_any_chat);
}
#[test]
fn parse_args_chat_any_sets_accept_all() {
let a = parse_args(&["--telegram".into(), "--chat".into(), "any".into()]);
assert!(a.telegram);
assert!(a.allow_any_chat);
assert!(a.chat_ids.is_empty());
}
#[test]
fn parse_args_chat_any_case_insensitive() {
let a = parse_args(&["--telegram".into(), "--chat".into(), "ANY".into()]);
assert!(a.allow_any_chat);
}
#[test]
fn parse_args_chat_env_any_sets_accept_all() {
let prev = std::env::var("CLAUDETTE_TELEGRAM_CHAT").ok();
std::env::set_var("CLAUDETTE_TELEGRAM_CHAT", "ANY");
let a = parse_args(&["--telegram".into()]);
assert!(a.allow_any_chat);
assert!(a.chat_ids.is_empty());
match prev {
Some(v) => std::env::set_var("CLAUDETTE_TELEGRAM_CHAT", v),
None => std::env::remove_var("CLAUDETTE_TELEGRAM_CHAT"),
}
}
#[test]
fn parse_args_telegram_short() {
let a = parse_args(&["-t".into()]);
assert!(a.telegram);
}
#[test]
fn parse_args_tui_flag() {
let a = parse_args(&["--tui".into()]);
assert!(a.tui);
}
#[test]
fn parse_args_tui_with_resume() {
let a = parse_args(&["--tui".into(), "--resume".into()]);
assert!(a.tui);
assert!(a.resume);
}
#[test]
fn parse_args_auth_google_flag() {
let a = parse_args(&["--auth-google".into()]);
assert!(a.auth_google);
assert!(!a.auth_google_revoke);
assert_eq!(a.auth_google_scope, None);
assert!(a.prompt_words.is_empty());
}
#[test]
fn parse_args_auth_google_revoke() {
let a = parse_args(&["--auth-google".into(), "--revoke".into()]);
assert!(a.auth_google);
assert!(a.auth_google_revoke);
assert_eq!(a.auth_google_scope, None);
}
#[test]
fn parse_args_auth_google_with_gmail_scope() {
let a = parse_args(&["--auth-google".into(), "gmail".into()]);
assert!(a.auth_google);
assert_eq!(a.auth_google_scope.as_deref(), Some("gmail"));
assert!(a.prompt_words.is_empty());
}
#[test]
fn parse_args_auth_google_with_calendar_scope_then_revoke() {
let a = parse_args(&["--auth-google".into(), "calendar".into(), "--revoke".into()]);
assert!(a.auth_google);
assert_eq!(a.auth_google_scope.as_deref(), Some("calendar"));
assert!(a.auth_google_revoke);
}
#[test]
fn parse_args_auth_google_unknown_next_treated_as_prompt() {
let a = parse_args(&["--auth-google".into(), "nonsense".into(), "-r".into()]);
assert!(a.auth_google);
assert_eq!(a.auth_google_scope, None);
assert!(
a.resume,
"resume flag after unknown scope should still register"
);
assert!(a.prompt_words.contains(&"nonsense".to_string()));
}
#[test]
fn parse_args_briefing_defaults() {
let a = parse_args(&["--briefing".into()]);
assert!(a.briefing);
assert_eq!(a.briefing_time, None);
assert_eq!(a.briefing_days, None);
}
#[test]
fn parse_args_briefing_with_time_and_days() {
let a = parse_args(&[
"--briefing".into(),
"--time".into(),
"07:30".into(),
"--days".into(),
"weekdays".into(),
]);
assert!(a.briefing);
assert_eq!(a.briefing_time.as_deref(), Some("07:30"));
assert_eq!(a.briefing_days.as_deref(), Some("weekdays"));
}
#[test]
fn parse_args_help_long() {
let a = parse_args(&["--help".into()]);
assert!(a.help);
assert!(!a.version);
assert!(a.prompt_words.is_empty());
}
#[test]
fn parse_args_help_short() {
let a = parse_args(&["-h".into()]);
assert!(a.help);
}
#[test]
fn parse_args_version_long() {
let a = parse_args(&["--version".into()]);
assert!(a.version);
assert!(!a.help);
}
#[test]
fn parse_args_version_short() {
let a = parse_args(&["-V".into()]);
assert!(a.version);
}
#[test]
fn help_text_mentions_every_flag() {
for flag in [
"--resume",
"--telegram",
"--tui",
"--chat",
"--auth-google",
"--revoke",
"--briefing",
"--time",
"--days",
"--help",
"--version",
] {
assert!(
HELP_TEXT.contains(flag),
"HELP_TEXT missing documentation for {flag}"
);
}
}
}