use clap::CommandFactory;
use clap_complete::{generate, Shell};
use colored::Colorize;
use serde_json;
use std::io;
use std::time::Duration;
use kto::agent;
use kto::cli::{Cli, CompletionShell};
use kto::config::Config;
use kto::db::Database;
use kto::fetch::{self, check_playwright, PlaywrightStatus};
use kto::watch::Change;
use kto::error::Result;
#[cfg(feature = "tui")]
pub fn cmd_ui() -> Result<()> {
kto::tui::run()
}
#[cfg(not(feature = "tui"))]
pub fn cmd_ui() -> Result<()> {
eprintln!("TUI not available. Rebuild with: cargo build --features tui");
Ok(())
}
pub fn cmd_export(watch: Option<String>) -> Result<()> {
let db = Database::open()?;
let watches = if let Some(id_or_name) = watch {
let watch = db.get_watch(&id_or_name)?
.ok_or_else(|| kto::KtoError::WatchNotFound(id_or_name.clone()))?;
vec![watch]
} else {
db.list_watches()?
};
let json = serde_json::to_string_pretty(&watches)?;
println!("{}", json);
Ok(())
}
pub fn cmd_import(dry_run: bool) -> Result<()> {
use std::io::{self, Read};
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
let watches: Vec<kto::watch::Watch> = serde_json::from_str(&input)
.map_err(|e| kto::KtoError::ConfigError(format!("Invalid JSON: {}", e)))?;
if watches.is_empty() {
println!("No watches to import.");
return Ok(());
}
let db = Database::open()?;
let existing_names: std::collections::HashSet<String> = db.list_watches()?
.into_iter()
.map(|w| w.name)
.collect();
println!("\n{} watch(es) to import:\n", watches.len());
for watch in &watches {
let status = if existing_names.contains(&watch.name) {
"SKIP (exists)"
} else {
"NEW"
};
println!(" [{}] {} - {}", status, watch.name, watch.url);
}
if dry_run {
println!("\n(dry-run mode - no changes made)");
return Ok(());
}
let mut imported = 0;
let mut skipped = 0;
for watch in watches {
if existing_names.contains(&watch.name) {
skipped += 1;
continue;
}
let mut new_watch = watch;
new_watch.id = uuid::Uuid::new_v4();
new_watch.created_at = chrono::Utc::now();
db.insert_watch(&new_watch)?;
imported += 1;
}
println!("\nImported {} watch(es), skipped {} (already exist).", imported, skipped);
Ok(())
}
pub fn cmd_diff(id_or_name: &str, limit: usize) -> Result<()> {
let db = Database::open()?;
let watch = db.get_watch(id_or_name)?
.ok_or_else(|| kto::KtoError::WatchNotFound(id_or_name.to_string()))?;
let changes = db.get_recent_changes(&watch.id, limit)?;
if changes.is_empty() {
println!("\nNo changes recorded for '{}'.", watch.name);
println!("Run `kto test \"{}\"` to check for changes.", watch.name);
return Ok(());
}
println!("\nRecent changes for '{}':\n", watch.name);
for (i, change) in changes.iter().enumerate() {
let ago = chrono::Utc::now().signed_duration_since(change.detected_at);
let time_ago = if ago.num_seconds() < 60 {
format!("{}s ago", ago.num_seconds())
} else if ago.num_minutes() < 60 {
format!("{}m ago", ago.num_minutes())
} else if ago.num_hours() < 24 {
format!("{}h ago", ago.num_hours())
} else {
format!("{}d ago", ago.num_days())
};
println!("{}. {} ({})", i + 1, change.detected_at.format("%Y-%m-%d %H:%M"), time_ago);
if let Some(ref resp) = change.agent_response {
if let Some(summary) = resp.get("summary").and_then(|s: &serde_json::Value| s.as_str()) {
println!(" AI: {}", summary.cyan());
}
}
for line in change.diff.lines().take(20) {
if line.starts_with('+') && !line.starts_with("+++") {
println!(" {}", line.green());
} else if line.starts_with('-') && !line.starts_with("---") {
println!(" {}", line.red());
} else if line.starts_with('@') {
println!(" {}", line.cyan());
} else {
println!(" {}", line);
}
}
if change.diff.lines().count() > 20 {
println!(" ... ({} more lines)", change.diff.lines().count() - 20);
}
println!();
}
Ok(())
}
pub fn cmd_memory(id_or_name: &str, json: bool, clear: bool) -> Result<()> {
let db = Database::open()?;
let watch = db.get_watch(id_or_name)?
.ok_or_else(|| kto::KtoError::WatchNotFound(id_or_name.to_string()))?;
if clear {
db.clear_agent_memory(&watch.id)?;
println!("Cleared AI memory for '{}'.", watch.name);
return Ok(());
}
let memory = db.get_agent_memory(&watch.id)?;
if json {
println!("{}", serde_json::to_string_pretty(&memory)?);
return Ok(());
}
println!("\nAI Memory for '{}':\n", watch.name);
if memory.counters.is_empty() {
println!(" Counters: (none)");
} else {
println!(" {}:", "Counters".bold());
for (key, value) in &memory.counters {
println!(" {}: {}", key, value.to_string().cyan());
}
}
println!();
if memory.last_values.is_empty() {
println!(" Last Values: (none)");
} else {
println!(" {}:", "Last Values".bold());
for (key, value) in &memory.last_values {
let display = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => value.to_string(),
};
println!(" {}: {}", key, display.green());
}
}
println!();
if memory.notes.is_empty() {
println!(" Notes: (none)");
} else {
println!(" {}:", "Notes".bold());
for note in &memory.notes {
println!(" • {}", note.yellow());
}
}
if let Some(ref config) = watch.agent_config {
println!();
println!(" {}:", "Agent Config".bold());
println!(" Enabled: {}", if config.enabled { "yes".green() } else { "no".red() });
if let Some(ref inst) = config.instructions {
println!(" Intent: \"{}\"", inst);
}
}
println!();
println!(" Tip: Use 'kto memory \"{}\" --clear' to reset memory.", watch.name);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum SkipReason {
Filter,
AiSkip,
QuietHours,
None,
}
impl SkipReason {
fn code(&self) -> &'static str {
match self {
SkipReason::Filter => "filter",
SkipReason::AiSkip => "ai_skip",
SkipReason::QuietHours => "quiet",
SkipReason::None => "notified",
}
}
fn label(&self) -> &'static str {
match self {
SkipReason::Filter => "⊘ filtered",
SkipReason::AiSkip => "○ AI skip",
SkipReason::QuietHours => "◑ quiet",
SkipReason::None => "✓ sent",
}
}
}
fn get_skip_reason(change: &Change) -> SkipReason {
if change.notified {
return SkipReason::None;
}
if !change.filter_passed {
return SkipReason::Filter;
}
if let Some(ref resp) = change.agent_response {
if let Some(notify) = resp.get("notify").and_then(|v| v.as_bool()) {
if !notify {
return SkipReason::AiSkip;
}
}
}
SkipReason::QuietHours
}
fn truncate_at_word(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
return s.to_string();
}
let truncated: String = s.chars().take(max_len).collect();
if let Some(last_space) = truncated.rfind(' ') {
if last_space > max_len / 2 {
return format!("{}…", &truncated[..last_space]);
}
}
format!("{}…", truncated)
}
fn clean_diff_preview(diff: &str, max_len: usize) -> String {
let mut result = String::new();
let mut chars = diff.chars().peekable();
while let Some(c) = chars.next() {
if c == '[' {
if let Some(&marker) = chars.peek() {
if marker == '-' || marker == '+' {
chars.next(); result.push(marker);
while let Some(inner) = chars.next() {
if inner == ']' {
result.push(' ');
break;
}
result.push(inner);
}
continue;
}
}
}
if c == '\n' {
result.push(' ');
} else {
result.push(c);
}
}
let cleaned: String = result.split_whitespace().collect::<Vec<_>>().join(" ");
truncate_at_word(&cleaned, max_len)
}
fn get_change_summary(change: &Change) -> String {
if let Some(ref resp) = change.agent_response {
if let Some(title) = resp.get("title").and_then(|v| v.as_str()) {
if !title.is_empty() {
return truncate_at_word(title, 50);
}
}
if let Some(summary) = resp.get("summary").and_then(|v| v.as_str()) {
if !summary.is_empty() {
return truncate_at_word(summary, 50);
}
}
}
clean_diff_preview(&change.diff, 50)
}
pub fn cmd_logs(lines: usize, follow: bool, json: bool) -> Result<()> {
let db = Database::open()?;
let changes = db.get_all_recent_changes(lines)?;
if changes.is_empty() {
if json {
println!("[]");
} else {
println!("No changes recorded yet.");
}
if !follow {
return Ok(());
}
} else if json {
print_changes_json(&changes);
} else {
println!("\nRecent changes:\n");
for (change, watch_name) in &changes {
print_change_log(change, watch_name);
}
}
if follow {
if !json {
println!("\nWatching for new changes... (Ctrl+C to stop)\n");
}
let mut last_seen = changes.first().map(|(c, _)| c.detected_at);
loop {
std::thread::sleep(Duration::from_secs(2));
let new_changes = db.get_all_recent_changes(10)?;
for (change, watch_name) in new_changes {
if let Some(last) = last_seen {
if change.detected_at <= last {
continue;
}
}
if json {
print_change_json(&change, &watch_name);
} else {
print_change_log(&change, &watch_name);
}
last_seen = Some(change.detected_at);
}
}
}
Ok(())
}
fn print_changes_json(changes: &[(Change, String)]) {
let entries: Vec<serde_json::Value> = changes.iter().map(|(change, watch_name)| {
change_to_json(change, watch_name)
}).collect();
if let Ok(json) = serde_json::to_string_pretty(&entries) {
println!("{}", json);
}
}
fn print_change_json(change: &Change, watch_name: &str) {
if let Ok(json) = serde_json::to_string(&change_to_json(change, watch_name)) {
println!("{}", json);
}
}
fn change_to_json(change: &Change, watch_name: &str) -> serde_json::Value {
let skip_reason = get_skip_reason(change);
let mut entry = serde_json::json!({
"timestamp": change.detected_at.to_rfc3339(),
"watch": watch_name,
"status": skip_reason.code(),
"notified": change.notified,
"filter_passed": change.filter_passed,
"diff_size": change.diff.len(),
});
if let Some(ref resp) = change.agent_response {
if let Some(title) = resp.get("title").and_then(|v| v.as_str()) {
entry["ai_title"] = serde_json::Value::String(title.to_string());
}
if let Some(summary) = resp.get("summary").and_then(|v| v.as_str()) {
entry["ai_summary"] = serde_json::Value::String(summary.to_string());
}
if let Some(notify) = resp.get("notify").and_then(|v| v.as_bool()) {
entry["ai_notify"] = serde_json::Value::Bool(notify);
}
}
entry
}
fn print_change_log(change: &Change, watch_name: &str) {
let time = change.detected_at.format("%Y-%m-%d %H:%M:%S");
let skip_reason = get_skip_reason(change);
let watch_display = truncate_at_word(watch_name, 20);
let summary = get_change_summary(change);
let status_display = match skip_reason {
SkipReason::None => skip_reason.label().green(),
SkipReason::Filter => skip_reason.label().yellow(),
SkipReason::AiSkip => skip_reason.label().blue(),
SkipReason::QuietHours => skip_reason.label().cyan(),
};
println!(" {} │ {:20} │ {:12} │ {}",
time, watch_display, status_display, summary);
}
pub fn cmd_doctor() -> Result<()> {
println!("\nkto doctor\n");
println!(" kto binary: v{}", env!("CARGO_PKG_VERSION"));
match agent::claude_version() {
Some(v) => println!(" Claude CLI: {} (installed)", v),
None => println!(" Claude CLI: NOT INSTALLED"),
}
let node = std::process::Command::new("node")
.arg("--version")
.output();
match node {
Ok(o) if o.status.success() => {
let v = String::from_utf8_lossy(&o.stdout);
println!(" Node.js: {}", v.trim());
}
_ => println!(" Node.js: NOT INSTALLED"),
}
match check_playwright() {
PlaywrightStatus::Ready => println!(" Playwright: ready"),
PlaywrightStatus::NodeMissing => println!(" Playwright: Node.js required"),
PlaywrightStatus::PlaywrightMissing => println!(" Playwright: not installed"),
PlaywrightStatus::BrowserMissing => println!(" Playwright: browser not installed"),
}
match Database::open() {
Ok(_) => println!(" Database: OK"),
Err(e) => println!(" Database: ERROR - {}", e),
}
println!();
Ok(())
}
pub fn cmd_enable_js() -> Result<()> {
println!("\nSetting up JavaScript rendering...\n");
let node_available = std::process::Command::new("node")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !node_available {
println!(" Node.js is required for JavaScript rendering.");
println!(" Install from: https://nodejs.org/");
return Ok(());
}
let data_dir = Config::data_dir()?;
std::fs::create_dir_all(&data_dir)?;
let node_modules = data_dir.join("node_modules").join("playwright");
let needs_install = !node_modules.exists();
if needs_install {
let package_json = data_dir.join("package.json");
if !package_json.exists() {
println!(" Initializing kto JavaScript environment...");
let output = std::process::Command::new("npm")
.args(["init", "-y"])
.current_dir(&data_dir)
.output()?;
if !output.status.success() {
return Err(kto::KtoError::PlaywrightError("Failed to initialize npm".into()));
}
}
println!(" Installing Playwright...");
let output = std::process::Command::new("npm")
.args(["install", "playwright"])
.current_dir(&data_dir)
.status()?;
if !output.success() {
return Err(kto::KtoError::PlaywrightError("Failed to install Playwright".into()));
}
} else {
println!(" Playwright package is installed.");
}
let status = check_playwright();
if !status.is_ready() {
println!(" Installing Chromium browser (~280MB)...");
let output = std::process::Command::new("npx")
.args(["playwright", "install", "chromium"])
.current_dir(&data_dir)
.status()?;
if !output.success() {
return Err(kto::KtoError::PlaywrightError("Failed to install Chromium".into()));
}
} else {
println!(" Chromium browser is installed.");
}
if atty::is(atty::Stream::Stdin) {
println!(" Installing system dependencies for Chromium...");
println!(" (This requires sudo - you may be prompted for your password)\n");
let deps_result = std::process::Command::new("sudo")
.args(["npx", "playwright", "install-deps", "chromium"])
.current_dir(&data_dir)
.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status();
match deps_result {
Ok(status) if status.success() => {
println!("\n System dependencies installed.");
}
Ok(_) => {
return Err(kto::KtoError::PlaywrightError(
"Failed to install system dependencies".into()
));
}
Err(e) => {
return Err(kto::KtoError::PlaywrightError(
format!("Failed to run sudo: {}", e)
));
}
}
} else {
return Err(kto::KtoError::PlaywrightError(
"Cannot install system dependencies in non-interactive mode. Please run `kto enable-js` in a terminal.".into()
));
}
fetch::ensure_render_script()?;
println!("\n JavaScript rendering is now enabled.");
println!(" Create a watch with: kto new \"https://...\" --js");
Ok(())
}
pub fn cmd_completions(shell: CompletionShell) -> Result<()> {
let mut cmd = Cli::command();
let shell = match shell {
CompletionShell::Bash => Shell::Bash,
CompletionShell::Zsh => Shell::Zsh,
CompletionShell::Fish => Shell::Fish,
CompletionShell::Powershell => Shell::PowerShell,
};
generate(shell, &mut cmd, "kto", &mut io::stdout());
Ok(())
}
pub fn cmd_init() -> Result<()> {
use inquire::{Confirm, Password, Select, Text};
println!("\n{}", "Welcome to kto!".bold());
println!("This wizard will help you set up kto for the first time.\n");
println!("{}", "Step 1: Checking dependencies...".bold());
println!();
cmd_doctor()?;
let claude_available = agent::claude_version().is_some();
if !claude_available {
println!("\n{}: Claude CLI not found. AI features will be unavailable.", "Note".yellow());
println!("Install from: https://claude.ai/cli\n");
}
let playwright_status = fetch::check_playwright();
if !playwright_status.is_ready() {
let setup_js = Confirm::new("JavaScript rendering is not set up. Install it now?")
.with_default(false)
.with_help_message("Required for JS-heavy sites (SPAs). Downloads ~280MB")
.prompt();
match setup_js {
Ok(true) => {
println!("\n Setting up JavaScript rendering...\n");
if let Err(e) = cmd_enable_js() {
println!(" {}: JS setup failed: {}", "Warning".yellow(), e);
println!(" You can try again later with: kto enable-js\n");
}
}
Ok(false) | Err(_) => {
println!(" Skipping JS setup. Run `kto enable-js` later if needed.\n");
}
}
}
println!("\n{}", "Step 2: Set up notifications".bold());
let config = Config::load()?;
let run_setup = if let Some(ref target) = config.default_notify {
println!(" Current: {}", crate::commands::describe_notify_target(target));
let change = Confirm::new("Would you like to change notification settings?")
.with_default(false)
.prompt();
matches!(change, Ok(true))
} else {
let setup = Confirm::new("Would you like to set up notifications now?")
.with_default(true)
.prompt();
matches!(setup, Ok(true))
};
if run_setup {
let options = vec![
"ntfy (recommended - free, simple push notifications)",
"Slack (webhook)",
"Discord (webhook)",
"Telegram (bot)",
"Gotify (self-hosted)",
"Pushover",
"Matrix",
"Skip for now",
];
let choice = Select::new("Which notification service would you like to use?", options)
.prompt();
match choice {
Ok(selected) => {
if selected.starts_with("ntfy") {
let topic = Text::new("Enter your ntfy topic (e.g., my-alerts):")
.prompt();
if let Ok(topic) = topic {
if !topic.is_empty() {
crate::commands::cmd_notify_set(
Some(topic), None, None, None, None, None,
None, None, None, None, None, None, None,
)?;
println!(" {} ntfy notifications configured!", "✓".green());
}
}
} else if selected.starts_with("Slack") {
let webhook = Password::new("Enter your Slack webhook URL:")
.without_confirmation()
.prompt();
if let Ok(webhook) = webhook {
if !webhook.is_empty() {
crate::commands::cmd_notify_set(
None, Some(webhook), None, None, None, None,
None, None, None, None, None, None, None,
)?;
println!(" {} Slack notifications configured!", "✓".green());
}
}
} else if selected.starts_with("Discord") {
let webhook = Password::new("Enter your Discord webhook URL:")
.without_confirmation()
.prompt();
if let Ok(webhook) = webhook {
if !webhook.is_empty() {
crate::commands::cmd_notify_set(
None, None, Some(webhook), None, None, None,
None, None, None, None, None, None, None,
)?;
println!(" {} Discord notifications configured!", "✓".green());
}
}
} else if selected.starts_with("Telegram") {
let token = Password::new("Enter your Telegram bot token:")
.without_confirmation()
.prompt();
let chat = Text::new("Enter your Telegram chat ID:")
.prompt();
if let (Ok(token), Ok(chat)) = (token, chat) {
if !token.is_empty() && !chat.is_empty() {
crate::commands::cmd_notify_set(
None, None, None, None, None, None,
Some(token), Some(chat), None, None, None, None, None,
)?;
println!(" {} Telegram notifications configured!", "✓".green());
}
}
} else if selected.starts_with("Gotify") {
let server = Text::new("Enter your Gotify server URL:")
.prompt();
let token = Password::new("Enter your Gotify app token:")
.without_confirmation()
.prompt();
if let (Ok(server), Ok(token)) = (server, token) {
if !server.is_empty() && !token.is_empty() {
crate::commands::cmd_notify_set(
None, None, None, Some(server), Some(token), None,
None, None, None, None, None, None, None,
)?;
println!(" {} Gotify notifications configured!", "✓".green());
}
}
} else if selected.starts_with("Pushover") {
let user = Password::new("Enter your Pushover user key:")
.without_confirmation()
.prompt();
let token = Password::new("Enter your Pushover API token:")
.without_confirmation()
.prompt();
if let (Ok(user), Ok(token)) = (user, token) {
if !user.is_empty() && !token.is_empty() {
crate::commands::cmd_notify_set(
None, None, None, None, None, None,
None, None, Some(user), Some(token), None, None, None,
)?;
println!(" {} Pushover notifications configured!", "✓".green());
}
}
} else if selected.starts_with("Matrix") {
let server = Text::new("Enter your Matrix homeserver URL:")
.prompt();
let room = Text::new("Enter your Matrix room ID:")
.prompt();
let token = Password::new("Enter your Matrix access token:")
.without_confirmation()
.prompt();
if let (Ok(server), Ok(room), Ok(token)) = (server, room, token) {
if !server.is_empty() && !room.is_empty() && !token.is_empty() {
crate::commands::cmd_notify_set(
None, None, None, None, None, None,
None, None, None, None, Some(server), Some(room), Some(token),
)?;
println!(" {} Matrix notifications configured!", "✓".green());
}
}
} else {
println!(" Skipping notification setup.");
}
}
Err(_) => {
println!(" Skipping notification setup.");
}
}
} else {
if config.default_notify.is_some() {
println!(" Keeping current notification settings.");
} else {
println!(" Skipping notification setup.");
println!(" You can configure notifications later with: kto notify set");
}
}
println!("\n{}", "Step 3: Create your first watch".bold());
let db = Database::open()?;
let watches = db.list_watches()?;
if !watches.is_empty() {
println!(" You already have {} watch(es) configured.", watches.len());
} else {
let create_watch = Confirm::new("Would you like to create your first watch now?")
.with_default(true)
.prompt();
match create_watch {
Ok(true) => {
let url = Text::new("Enter a URL to watch (or describe what you want to monitor):")
.with_help_message("e.g., https://news.ycombinator.com for AI news")
.prompt();
if let Ok(url) = url {
if !url.is_empty() {
crate::commands::cmd_new(
Some(url),
None,
"15m".to_string(),
false, false, false,
claude_available, None, None, false,
vec![], false, false, false,
)?;
}
}
}
Ok(false) | Err(_) => {
println!(" Skipping watch creation.");
println!(" You can create a watch later with: kto new \"https://...\"");
}
}
}
println!("\n{}", "Step 4: Run in background".bold());
let install_service = Confirm::new("Would you like to install kto as a background service?")
.with_default(true)
.with_help_message("Recommended: kto will check your watches automatically")
.prompt();
match install_service {
Ok(true) => {
crate::commands::cmd_service_install(false, 5)?;
}
Ok(false) | Err(_) => {
println!(" Skipping service installation.");
println!(" You can install the service later with: kto service install");
}
}
println!("\n{}", "Step 5: Shell completions".bold());
let shell_path = std::env::var("SHELL").unwrap_or_default();
let detected_shell = if shell_path.contains("zsh") {
Some(("zsh", "~/.zshrc"))
} else if shell_path.contains("bash") {
Some(("bash", "~/.bashrc"))
} else if shell_path.contains("fish") {
Some(("fish", "~/.config/fish/completions/kto.fish"))
} else {
None
};
if let Some((shell_name, config_path)) = detected_shell {
println!(" Detected shell: {}", shell_name);
let install_completions = Confirm::new(&format!("Add tab completions to {}?", config_path))
.with_default(true)
.prompt();
match install_completions {
Ok(true) => {
let home = std::env::var("HOME").unwrap_or_default();
let full_path = config_path.replace("~", &home);
if shell_name == "fish" {
let fish_dir = format!("{}/.config/fish/completions", home);
if let Err(e) = std::fs::create_dir_all(&fish_dir) {
println!(" {}: Could not create fish completions directory: {}", "Warning".yellow(), e);
} else {
let fish_path = format!("{}/kto.fish", fish_dir);
match std::fs::File::create(&fish_path) {
Ok(mut file) => {
let mut cmd = Cli::command();
clap_complete::generate(Shell::Fish, &mut cmd, "kto", &mut file);
println!(" {} Completions written to {}", "✓".green(), fish_path);
}
Err(e) => {
println!(" {}: Could not write completions: {}", "Warning".yellow(), e);
}
}
}
} else {
let completion_line = format!("\n# kto shell completions\neval \"$(kto completions {})\"\n", shell_name);
let existing = std::fs::read_to_string(&full_path).unwrap_or_default();
if existing.contains("kto completions") {
println!(" Completions already configured in {}", config_path);
} else {
match std::fs::OpenOptions::new().append(true).open(&full_path) {
Ok(mut file) => {
use std::io::Write;
if let Err(e) = file.write_all(completion_line.as_bytes()) {
println!(" {}: Could not write to {}: {}", "Warning".yellow(), config_path, e);
} else {
println!(" {} Completions added to {}", "✓".green(), config_path);
println!(" Run `source {}` or restart your shell to activate", config_path);
}
}
Err(e) => {
println!(" {}: Could not open {}: {}", "Warning".yellow(), config_path, e);
println!(" You can manually add: eval \"$(kto completions {})\"", shell_name);
}
}
}
}
}
Ok(false) | Err(_) => {
println!(" Skipping completions setup.");
println!(" You can add them later with: kto completions {}", shell_name);
}
}
} else {
println!(" Could not detect shell. You can manually set up completions:");
println!(" kto completions bash >> ~/.bashrc");
println!(" kto completions zsh >> ~/.zshrc");
println!(" kto completions fish > ~/.config/fish/completions/kto.fish");
}
println!("\n{}", "Setup complete!".green().bold());
println!();
println!("Useful commands:");
println!(" kto list List all watches");
println!(" kto new \"https://...\" Create a new watch");
println!(" kto ui Interactive dashboard");
println!(" kto service status Check service status");
println!(" kto --help Show all commands");
println!();
Ok(())
}