use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::analytics::event::{Decision, Event, ToolKind};
use crate::analytics::store;
use crate::utils;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const FRAMES_PER_CHECK: u32 = 3;
const FRAME_DELAY: Duration = Duration::from_millis(70);
enum Status {
Pass,
Fail { remedy: Vec<String> },
Info,
}
struct Check {
label: String,
detail: Option<String>,
status: Status,
}
struct Row {
pending_label: &'static str,
run: fn() -> Check,
result: Option<Check>,
skipped: bool,
}
impl Row {
fn is_failed(&self) -> bool {
matches!(
self.result,
Some(Check {
status: Status::Fail { .. },
..
})
)
}
}
pub fn run(no_color: bool) -> anyhow::Result<()> {
let theme = Theme::new(!no_color);
let mut rows = build_rows();
if io::stdout().is_terminal() {
animate(&theme, &mut rows)?;
} else {
run_static(&mut rows);
let mut stdout = io::stdout().lock();
render_block_to(&mut stdout, &theme, &rows, 0)?;
}
if rows.iter().any(Row::is_failed) {
std::process::exit(1);
}
Ok(())
}
fn build_rows() -> Vec<Row> {
vec![
Row {
pending_label: "sidekick version",
run: check_version,
result: None,
skipped: false,
},
Row {
pending_label: "nvim on PATH",
run: check_nvim_on_path,
result: None,
skipped: false,
},
Row {
pending_label: "Claude Code hook registered",
run: check_claude_hook,
result: None,
skipped: false,
},
Row {
pending_label: "nvim alias",
run: check_shell_alias,
result: None,
skipped: false,
},
Row {
pending_label: "Neovim sockets opened here",
run: check_sockets,
result: None,
skipped: false,
},
Row {
pending_label: "last activity",
run: check_last_hook,
result: None,
skipped: false,
},
]
}
fn animate(theme: &Theme, rows: &mut [Row]) -> io::Result<()> {
let mut stdout = io::stdout().lock();
let mut last_height = 0usize;
let mut spin = 0usize;
write!(stdout, "\x1b[?25l")?;
let result = animate_inner(&mut stdout, theme, rows, &mut last_height, &mut spin);
write!(stdout, "\x1b[?25h")?;
stdout.flush()?;
result
}
fn animate_inner(
stdout: &mut impl Write,
theme: &Theme,
rows: &mut [Row],
last_height: &mut usize,
spin: &mut usize,
) -> io::Result<()> {
redraw(stdout, theme, rows, *spin, last_height)?;
for i in 0..rows.len() {
for _ in 0..FRAMES_PER_CHECK {
*spin = spin.wrapping_add(1);
thread::sleep(FRAME_DELAY);
redraw(stdout, theme, rows, *spin, last_height)?;
}
rows[i].result = Some((rows[i].run)());
if rows[i].is_failed() {
for row in rows.iter_mut().skip(i + 1) {
row.skipped = true;
}
redraw(stdout, theme, rows, *spin, last_height)?;
return Ok(());
}
redraw(stdout, theme, rows, *spin, last_height)?;
}
Ok(())
}
fn redraw(
w: &mut impl Write,
theme: &Theme,
rows: &[Row],
spin: usize,
last_height: &mut usize,
) -> io::Result<()> {
if *last_height > 0 {
write!(w, "\x1b[{}A\r", *last_height)?;
}
write!(w, "\x1b[J")?;
*last_height = render_block_to(w, theme, rows, spin)?;
w.flush()
}
fn run_static(rows: &mut [Row]) {
let mut failed = false;
for row in rows.iter_mut() {
if failed {
row.skipped = true;
continue;
}
row.result = Some((row.run)());
if row.is_failed() {
failed = true;
}
}
}
fn render_block_to(
w: &mut impl Write,
theme: &Theme,
rows: &[Row],
spin: usize,
) -> io::Result<usize> {
let mut height = 0;
writeln!(w)?;
height += 1;
writeln!(w, " {}", theme.bold("sidekick doctor"))?;
height += 1;
writeln!(w)?;
height += 1;
for row in rows {
for line in render_row(theme, row, spin) {
writeln!(w, "{line}")?;
height += 1;
}
}
writeln!(w)?;
height += 1;
Ok(height)
}
fn render_row(theme: &Theme, row: &Row, spin: usize) -> Vec<String> {
if row.skipped {
let marker = theme.dim("⊝");
let body = theme.dim(&format!("{} (skipped)", row.pending_label));
return vec![format!(" {marker} {body}")];
}
match &row.result {
None => {
let marker = theme.cyan(SPINNER_FRAMES[spin % SPINNER_FRAMES.len()]);
vec![format!(" {marker} {}", row.pending_label)]
}
Some(check) => {
let marker = match &check.status {
Status::Pass => theme.green("✓"),
Status::Fail { .. } => theme.red("✗"),
Status::Info => theme.dim("·"),
};
let mut out = vec![format!(" {marker} {}", check.label)];
if let Some(detail) = &check.detail {
for line in detail.lines() {
out.push(format!(" {}", theme.dim(line)));
}
}
if let Status::Fail { remedy } = &check.status {
for line in remedy {
out.push(format!(" {line}"));
}
}
out
}
}
}
fn check_version() -> Check {
let version = env!("CARGO_PKG_VERSION");
let exe = std::env::current_exe()
.ok()
.map(|p| display_path(&p))
.unwrap_or_else(|| "(unknown path)".to_string());
Check {
label: format!("sidekick v{version} on PATH"),
detail: Some(exe),
status: Status::Pass,
}
}
fn check_nvim_on_path() -> Check {
match Command::new("nvim").arg("--version").output() {
Ok(out) if out.status.success() => {
let first_line = String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.unwrap_or("nvim")
.trim()
.to_string();
let label = if first_line.is_empty() {
"nvim on PATH".to_string()
} else {
format!("{first_line} on PATH")
};
Check {
label,
detail: None,
status: Status::Pass,
}
}
_ => Check {
label: "Neovim (`nvim`) not on PATH".into(),
detail: None,
status: Status::Fail {
remedy: vec!["Install Neovim: https://neovim.io/".into()],
},
},
}
}
fn check_claude_hook() -> Check {
let mut matched: Vec<PathBuf> = Vec::new();
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".claude").join("settings.json"));
candidates.push(home.join(".claude").join("settings.local.json"));
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.join(".claude").join("settings.json"));
}
for path in &candidates {
if file_mentions_sidekick_hook(path) {
matched.push(path.clone());
}
}
if let Some(home) = dirs::home_dir() {
walk_for_json_mentioning_hook(&home.join(".claude").join("plugins"), &mut matched, 4);
}
matched.sort();
matched.dedup();
if matched.is_empty() {
Check {
label: "Claude Code hook not registered".into(),
detail: None,
status: Status::Fail {
remedy: vec![
"Install the plugin: /plugin install sidekick@nishant-plugins".into(),
"Or add `sidekick hook` to ~/.claude/settings.json".into(),
],
},
}
} else {
let detail = matched
.iter()
.map(|p| display_path(p))
.collect::<Vec<_>>()
.join("\n");
Check {
label: "Claude Code hook registered".into(),
detail: Some(detail),
status: Status::Pass,
}
}
}
fn check_shell_alias() -> Check {
let Ok(shell) = std::env::var("SHELL") else {
return Check {
label: "nvim alias: $SHELL is not set".into(),
detail: None,
status: Status::Info,
};
};
let shell_name = Path::new(&shell)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("shell")
.to_string();
match Command::new(&shell)
.args(["-i", "-c", "type nvim"])
.output()
{
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.contains("sidekick neovim") {
Check {
label: format!("nvim alias: nvim → sidekick neovim ({shell_name})"),
detail: None,
status: Status::Pass,
}
} else {
let current =
first_meaningful_line(&stdout).unwrap_or_else(|| "(no output)".to_string());
Check {
label: format!("nvim alias not set ({shell_name})"),
detail: Some(format!("`type nvim` → {current}")),
status: Status::Fail {
remedy: vec![format!(
"Add to your {shell_name} config: alias nvim='sidekick neovim'"
)],
},
}
}
}
Err(e) => Check {
label: format!("nvim alias: couldn't run {shell_name}"),
detail: Some(e.to_string()),
status: Status::Info,
},
}
}
fn first_meaningful_line(s: &str) -> Option<String> {
s.lines()
.find(|l| !l.trim().is_empty())
.map(|l| l.trim().to_string())
}
fn check_sockets() -> Check {
match utils::find_matching_sockets() {
Ok(sockets) if !sockets.is_empty() => {
let count = sockets.len();
let detail = sockets
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join("\n");
Check {
label: format!(
"{count} Neovim socket{} opened here",
if count == 1 { "" } else { "s" }
),
detail: Some(detail),
status: Status::Info,
}
}
_ => Check {
label: "no Neovim opened here".into(),
detail: None,
status: Status::Info,
},
}
}
fn check_last_hook() -> Check {
let last = store::read_all()
.unwrap_or_default()
.into_iter()
.filter_map(|e| match e {
Event::HookDecision(d) => Some(d),
_ => None,
})
.max_by_key(|d| d.at);
match last {
Some(d) => {
let when = relative_time(d.at);
let tool = match d.tool {
ToolKind::Edit => "Edit",
ToolKind::Write => "Write",
ToolKind::MultiEdit => "MultiEdit",
};
let decision = match d.decision {
Decision::Allow => "allowed",
Decision::Deny => "blocked",
};
let file = Path::new(&d.file)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| d.file.clone());
Check {
label: format!("last activity: {when}"),
detail: Some(format!("{decision} · {tool} · {file}")),
status: Status::Info,
}
}
None => Check {
label: "last activity: never".into(),
detail: Some("Ask Claude to edit a file to see one.".into()),
status: Status::Info,
},
}
}
fn relative_time(at: DateTime<Utc>) -> String {
let secs = Utc::now().signed_duration_since(at).num_seconds().max(0);
if secs < 60 {
format!("{secs}s ago")
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86_400)
}
}
fn file_mentions_sidekick_hook(path: &Path) -> bool {
std::fs::read_to_string(path)
.map(|c| c.contains("sidekick hook"))
.unwrap_or(false)
}
fn walk_for_json_mentioning_hook(dir: &Path, matched: &mut Vec<PathBuf>, depth: usize) {
if depth == 0 {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk_for_json_mentioning_hook(&path, matched, depth - 1);
} else if path.extension().and_then(|e| e.to_str()) == Some("json")
&& file_mentions_sidekick_hook(&path)
{
matched.push(path);
}
}
}
fn display_path(p: &Path) -> String {
if let Some(home) = dirs::home_dir()
&& let Ok(rel) = p.strip_prefix(&home)
{
return format!("~/{}", rel.display());
}
p.display().to_string()
}
struct Theme {
color: bool,
}
impl Theme {
fn new(color: bool) -> Self {
Self { color }
}
fn wrap(&self, code: &str, s: &str) -> String {
if self.color {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
}
fn green(&self, s: &str) -> String {
self.wrap("32", s)
}
fn red(&self, s: &str) -> String {
self.wrap("31", s)
}
fn cyan(&self, s: &str) -> String {
self.wrap("36", s)
}
fn dim(&self, s: &str) -> String {
self.wrap("2", s)
}
fn bold(&self, s: &str) -> String {
self.wrap("1", s)
}
}