use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;
use anyhow::Result;
use serde_json::Value;
use crate::config::{Config, FileConfig};
use crate::embed::{self, EmbedKind};
use crate::hook::Host;
use crate::index::Index;
use crate::skill::{self, Skill};
use crate::{init, paths, rank, telemetry};
const SMOKE_PROMPT: &str = "set up a new python project";
#[derive(Clone, Copy, PartialEq, Eq)]
enum Status {
Ok,
Info,
Warn,
Fail,
Skip,
}
impl Status {
fn label(self) -> &'static str {
match self {
Status::Ok => "ok",
Status::Info => "info",
Status::Warn => "warn",
Status::Fail => "FAIL",
Status::Skip => "skip",
}
}
}
struct Check {
status: Status,
area: &'static str,
detail: String,
fix: Option<String>,
}
#[derive(Default)]
struct Report {
checks: Vec<Check>,
}
impl Report {
fn push(&mut self, status: Status, area: &'static str, detail: impl Into<String>) {
self.checks.push(Check {
status,
area,
detail: detail.into(),
fix: None,
});
}
fn push_fix(
&mut self,
status: Status,
area: &'static str,
detail: impl Into<String>,
fix: impl Into<String>,
) {
self.checks.push(Check {
status,
area,
detail: detail.into(),
fix: Some(fix.into()),
});
}
fn count(&self, s: Status) -> usize {
self.checks.iter().filter(|c| c.status == s).count()
}
}
pub fn run(host: Host) -> Result<()> {
let (mut cfg, file) = Config::load(host);
let mut report = Report::default();
println!(
"ski doctor — host {}, ski v{}, backend {}",
host_name(host),
env!("CARGO_PKG_VERSION"),
backend_desc(&cfg.model),
);
println!();
check_hooks(host, &mut report);
check_config(&mut report);
let discovery = skill::discover_all(&cfg.roots);
check_skills(&cfg, &discovery, &mut report);
let idx = check_index(host, &cfg, &discovery.skills, &mut report);
let models_ready = check_models(&cfg, &mut report);
check_state(&mut report);
check_telemetry(&cfg, &mut report);
check_smoke(&mut cfg, &file, idx.as_ref(), models_ready, &mut report);
for c in &report.checks {
println!("{:<4} {:<9} {}", c.status.label(), c.area, c.detail);
if let Some(fix) = &c.fix {
println!("{:<4} {:<9} fix: {fix}", "", "");
}
}
println!();
let fails = report.count(Status::Fail);
let warns = report.count(Status::Warn);
if fails > 0 {
println!("{fails} problem(s) found — see the FAIL line(s) above");
std::process::exit(1);
} else if warns > 0 {
println!("no blocking problems; {warns} warning(s) above");
} else {
println!(
"all checks passed — the next prompt in {} ranks against your library",
host_name(host)
);
}
Ok(())
}
fn host_name(host: Host) -> &'static str {
match host {
Host::Claude => "claude",
Host::Opencode => "opencode",
}
}
fn backend_desc(model: &str) -> String {
if embed::is_dense(model) {
format!("{model} (dense + reranker)")
} else if cfg!(feature = "fastembed") {
format!("bag-of-words ('{model}' is not a recognized embedding model id)")
} else {
"bag-of-words (offline build)".to_string()
}
}
fn check_hooks(host: Host, report: &mut Report) {
match host {
Host::Claude => check_hooks_claude(report),
Host::Opencode => check_hooks_opencode(report),
}
}
fn check_hooks_claude(report: &mut Report) {
let path = paths::claude_settings_path();
let total = init::CLAUDE_HOOKS.len();
let settings: Option<Value> = fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok());
let missing = settings
.as_ref()
.map(|root| missing_hooks(root))
.unwrap_or_else(|| init::CLAUDE_HOOKS.iter().map(|&(_, _, sub)| sub).collect());
if missing.is_empty() {
report.push(
Status::Ok,
"hooks",
format!("{total}/{total} hooks wired in {}", tilde(&path)),
);
} else if let Some(plugin) = claude_plugin_dir() {
report.push(
Status::Ok,
"hooks",
format!("marketplace plugin installed ({})", tilde(&plugin)),
);
} else if missing.len() == total {
report.push_fix(
Status::Fail,
"hooks",
format!(
"no ski hooks in {} and no marketplace plugin found — ski never runs",
tilde(&path)
),
"ski init -g claude (or in Claude Code: /plugin install skill-inject@skill-inject)",
);
} else {
report.push_fix(
Status::Fail,
"hooks",
format!(
"only {}/{total} hooks wired in {} (missing: {})",
total - missing.len(),
tilde(&path),
missing.join(", ")
),
"ski init -g claude (adds only the missing hooks; existing settings are kept)",
);
}
}
fn check_hooks_opencode(report: &mut Report) {
let plugin = paths::opencode_plugin_dir().join("ski.ts");
if plugin.is_file() {
report.push(
Status::Ok,
"hooks",
format!("plugin installed at {}", tilde(&plugin)),
);
} else {
report.push_fix(
Status::Fail,
"hooks",
format!("no plugin at {} — ski never runs", tilde(&plugin)),
"ski init -g opencode",
);
}
}
fn missing_hooks(root: &Value) -> Vec<&'static str> {
init::CLAUDE_HOOKS
.iter()
.filter_map(|&(event, _, sub)| {
let wired = root
.get("hooks")
.and_then(|h| h.get(event))
.and_then(Value::as_array)
.map(|arr| arr.iter().any(|g| init::group_runs_ski(g, sub)))
.unwrap_or(false);
(!wired).then_some(sub)
})
.collect()
}
fn claude_plugin_dir() -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
let root = PathBuf::from(home).join(".claude").join("plugins");
find_named(&root, "skill-inject", 3)
}
fn find_named(dir: &Path, needle: &str, depth: usize) -> Option<PathBuf> {
let entries = fs::read_dir(dir).ok()?;
let mut subdirs = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains(needle))
{
return Some(path);
}
subdirs.push(path);
}
if depth == 0 {
return None;
}
subdirs
.iter()
.find_map(|d| find_named(d, needle, depth - 1))
}
fn check_config(report: &mut Report) {
let path = paths::config_path();
let Ok(raw) = fs::read_to_string(&path) else {
report.push(
Status::Info,
"config",
format!("no config file (compiled defaults) — {}", tilde(&path)),
);
return;
};
if let Err(e) = FileConfig::parse(&raw) {
let msg = e.to_string();
let first = msg.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
report.push_fix(
Status::Fail,
"config",
format!(
"{} is invalid ({first}) — the WHOLE file is ignored, all overrides inactive",
tilde(&path)
),
"fix the line above; `ski why <prompt>` then reflects your overrides again",
);
return;
}
let Ok(table) = toml::from_str::<toml::Table>(&raw) else {
return; };
let set: Vec<&str> = table
.keys()
.map(String::as_str)
.filter(|k| FileConfig::KEYS.contains(k))
.collect();
let detail = if set.is_empty() {
format!("{} (no overrides set)", tilde(&path))
} else {
format!(
"{} ({} override(s): {})",
tilde(&path),
set.len(),
set.join(", ")
)
};
report.push(Status::Ok, "config", detail);
let unknown = unknown_keys(&table);
if !unknown.is_empty() {
report.push_fix(
Status::Warn,
"config",
format!("unknown key(s) silently ignored: {}", unknown.join(", ")),
"probably a typo — an unknown key never applies (see README \"Configuration\" for valid keys)",
);
}
for (key, allowed) in [
("inject_mode", &["directive", "body"][..]),
("directive_strength", &["auto", "soft", "hard"][..]),
] {
if let Some(v) = table.get(key).and_then(|v| v.as_str()) {
if !allowed.contains(&v.trim().to_ascii_lowercase().as_str()) {
report.push_fix(
Status::Warn,
"config",
format!("{key} = {v:?} is not recognized — the default is used instead"),
format!("use one of: {}", allowed.join(", ")),
);
}
}
}
}
fn unknown_keys(table: &toml::Table) -> Vec<String> {
table
.keys()
.filter(|k| !FileConfig::KEYS.contains(&k.as_str()))
.cloned()
.collect()
}
fn check_skills(cfg: &Config, discovery: &skill::Discovery, report: &mut Report) {
let env_note = if std::env::var_os("SKI_ROOTS").is_some() {
" [SKI_ROOTS override active]"
} else {
""
};
let roots = cfg
.roots
.iter()
.map(|r| tilde(r))
.collect::<Vec<_>>()
.join(", ");
if cfg.roots.is_empty() {
report.push_fix(
Status::Fail,
"skills",
format!("no discovery roots configured{env_note} — there is nothing to inject"),
"declare `skills.paths` in opencode.json, or point `roots` in ski's config.toml / SKI_ROOTS at your library",
);
} else if discovery.skills.is_empty() {
report.push_fix(
Status::Fail,
"skills",
format!("no skills found under: {roots}{env_note} — there is nothing to inject"),
"install skills under a discovery root, or point `roots` in config.toml / SKI_ROOTS at your library",
);
} else {
report.push(
Status::Ok,
"skills",
format!(
"{} skill(s) under {} root(s){env_note}",
discovery.skills.len(),
cfg.roots.len()
),
);
}
if let Some((first, _)) = discovery.skipped.first() {
report.push_fix(
Status::Warn,
"skills",
format!(
"{} SKILL.md file(s) skipped (unusable frontmatter), e.g. {}",
discovery.skipped.len(),
tilde(first)
),
"`ski index` lists every skipped file with the parse reason",
);
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Drift {
new: usize,
changed: usize,
removed: usize,
}
impl Drift {
fn is_fresh(&self) -> bool {
self.new == 0 && self.changed == 0 && self.removed == 0
}
}
fn drift(skills: &[Skill], idx: &Index) -> Drift {
let mut d = Drift::default();
for s in skills {
match idx.get(&s.id) {
None => d.new += 1,
Some(e) if e.hash != s.hash => d.changed += 1,
Some(_) => {}
}
}
let live: BTreeSet<&str> = skills.iter().map(|s| s.id.as_str()).collect();
d.removed = idx
.skills
.iter()
.filter(|e| !live.contains(e.id.as_str()))
.count();
d
}
fn check_index(host: Host, cfg: &Config, skills: &[Skill], report: &mut Report) -> Option<Index> {
let path = paths::index_path(host);
let expected = embed::expected_id(&cfg.model);
match Index::load(&path) {
Err(e) => {
report.push_fix(
Status::Warn,
"index",
format!(
"{} is unreadable ({e}) — the next hook run rebuilds it",
tilde(&path)
),
"ski index",
);
None
}
Ok(None) => {
report.push_fix(
Status::Warn,
"index",
format!(
"not built yet ({}) — the first prompt builds it",
tilde(&path)
),
"ski index (front-loads the build so the first prompt isn't slow)",
);
None
}
Ok(Some(idx)) => {
if idx.model != expected {
report.push_fix(
Status::Warn,
"index",
format!(
"built with embedder '{}' but '{expected}' is active — the next hook run re-embeds everything",
idx.model
),
"ski index",
);
} else {
let d = drift(skills, &idx);
if d.is_fresh() {
report.push(
Status::Ok,
"index",
format!(
"{} entries, up to date ({})",
idx.skills.len(),
tilde(&path)
),
);
} else {
report.push_fix(
Status::Warn,
"index",
format!(
"stale vs the library ({} new, {} changed, {} removed) — refreshed automatically at the next session start",
d.new, d.changed, d.removed
),
"ski index",
);
}
}
Some(idx)
}
}
}
fn check_models(cfg: &Config, report: &mut Report) -> bool {
if !embed::is_dense(&cfg.model) {
report.push(
Status::Info,
"models",
"none needed (bag-of-words backend)".to_string(),
);
return true;
}
let dir = paths::model_cache_dir();
let populated = fs::read_dir(&dir)
.map(|mut d| d.next().is_some())
.unwrap_or(false);
if populated {
let mb = dir_size(&dir, 8) / (1024 * 1024);
report.push(
Status::Ok,
"models",
format!("cached at {} (~{mb} MB)", tilde(&dir)),
);
} else {
report.push_fix(
Status::Warn,
"models",
"not downloaded yet — the first ranked prompt blocks on a one-time ~275 MB download",
"ski index (downloads now, while you watch; every run after is offline)",
);
}
populated
}
fn dir_size(dir: &Path, depth: usize) -> u64 {
let Ok(entries) = fs::read_dir(dir) else {
return 0;
};
entries
.flatten()
.map(|e| {
let p = e.path();
match e.metadata() {
Ok(m) if m.is_file() => m.len(),
Ok(m) if m.is_dir() && depth > 0 => dir_size(&p, depth - 1),
_ => 0,
}
})
.sum()
}
fn check_state(report: &mut Report) {
let dir = paths::sessions_dir();
match probe_write(&dir) {
Ok(()) => report.push(
Status::Ok,
"state",
format!("session/telemetry dir writable ({})", tilde(&paths::state_dir())),
),
Err(e) => report.push_fix(
Status::Fail,
"state",
format!(
"cannot write under {} ({e}) — dedup won't persist, the same skill re-injects every prompt",
tilde(&dir)
),
"fix the directory permissions (or the XDG_STATE_HOME it points into)",
),
}
}
fn probe_write(dir: &Path) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
let probe = dir.join(format!(".doctor-probe-{}", std::process::id()));
fs::write(&probe, b"ok")?;
fs::remove_file(&probe)
}
fn check_telemetry(cfg: &Config, report: &mut Report) {
telemetry::init(cfg.telemetry);
if telemetry::enabled() {
report.push(
Status::Info,
"telemetry",
format!(
"on — `ski history` / `ski suggest` read {}",
tilde(&paths::telemetry_path())
),
);
} else {
report.push(
Status::Info,
"telemetry",
"off — set `telemetry = true` in config.toml (or SKI_TELEMETRY=1) to enable `ski history`",
);
}
}
fn check_smoke(
cfg: &mut Config,
file: &FileConfig,
idx: Option<&Index>,
models_ready: bool,
report: &mut Report,
) {
let Some(idx) = idx else {
report.push(Status::Skip, "smoke", "skipped — no index yet (see above)");
return;
};
if idx.skills.is_empty() {
report.push(Status::Skip, "smoke", "skipped — the index is empty");
return;
}
if idx.model != embed::expected_id(&cfg.model) {
report.push(
Status::Skip,
"smoke",
"skipped — the index was built with a different embedder (see above)",
);
return;
}
if !models_ready {
report.push(
Status::Skip,
"smoke",
"skipped — models not downloaded yet (run `ski index` first)",
);
return;
}
let t0 = Instant::now();
match smoke(cfg, file, idx) {
Ok(top) => report.push(
Status::Ok,
"smoke",
format!(
"embedded + ranked {} skills in {} ms, model load included (stage-1 top: {top})",
idx.skills.len(),
t0.elapsed().as_millis()
),
),
Err(e) => report.push_fix(
Status::Fail,
"smoke",
format!("embed+rank failed: {e}"),
"ski index --rebuild (then re-run `ski doctor`)",
),
}
}
fn smoke(cfg: &mut Config, file: &FileConfig, idx: &Index) -> Result<String> {
let embedder = embed::build(&cfg.model)?;
cfg.calibrate_to(embedder.as_ref());
file.apply_cosine(cfg); let query = embedder
.embed(&[SMOKE_PROMPT.to_string()], EmbedKind::Query)?
.remove(0);
if idx.dim != 0 && query.len() != idx.dim {
anyhow::bail!(
"embedding dimension mismatch: query {} vs index {}",
query.len(),
idx.dim
);
}
let none = BTreeSet::new();
let hits = rank::rank_all_ctx(&query, None, &none, &none, SMOKE_PROMPT, idx, cfg);
Ok(hits
.first()
.map(|h| h.name.clone())
.unwrap_or_else(|| "-".to_string()))
}
fn tilde(path: &Path) -> String {
if let Some(home) = std::env::var_os("HOME") {
if let Ok(rest) = path.strip_prefix(&home) {
return format!("~/{}", rest.display());
}
}
path.display().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::index::Entry;
use serde_json::json;
#[test]
fn missing_hooks_empty_when_fully_wired() {
let root = json!({
"hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/ski-bootstrap.sh\" hook --host claude" }] }],
"PostToolUse": [{ "matcher": "Read|Skill", "hooks": [{ "type": "command",
"command": "\"/home/u/.local/bin/ski\" observe --host claude" }] }],
"SessionStart": [{ "matcher": "startup|resume|compact", "hooks": [{ "type": "command",
"command": "\"/home/u/.local/bin/ski\" session-start --host claude" }] }],
}
});
assert!(missing_hooks(&root).is_empty());
}
#[test]
fn missing_hooks_lists_unwired_subcommands() {
let root = json!({
"hooks": {
"UserPromptSubmit": [{ "hooks": [{ "type": "command",
"command": "\"/x/ski\" hook --host claude" }] }],
"PostToolUse": [{ "hooks": [{ "type": "command", "command": "echo unrelated" }] }],
}
});
assert_eq!(missing_hooks(&root), ["observe", "session-start"]);
}
#[test]
fn missing_hooks_all_on_empty_settings() {
assert_eq!(
missing_hooks(&json!({})),
["hook", "observe", "session-start"]
);
}
#[test]
fn unknown_keys_flags_typos_only() {
let table: toml::Table =
toml::from_str("denny = [\"x\"]\nmax_skills = 3\nrerank_min = -1.0").unwrap();
assert_eq!(unknown_keys(&table), ["denny"]);
}
fn skill(id: &str, hash: &str) -> Skill {
Skill {
id: id.into(),
name: id.into(),
description: format!("does {id}"),
body_head: String::new(),
keywords: Vec::new(),
trigger_phrases: Vec::new(),
path: PathBuf::from(format!("/s/{id}/SKILL.md")),
hash: hash.into(),
}
}
fn entry(id: &str, hash: &str) -> Entry {
Entry {
id: id.into(),
name: id.into(),
description: String::new(),
path: format!("/s/{id}/SKILL.md"),
keywords: Vec::new(),
trigger_phrases: Vec::new(),
body_head: String::new(),
hash: hash.into(),
embedding: Vec::new(),
}
}
#[test]
fn drift_counts_new_changed_removed() {
let idx = Index {
model: "m".into(),
dim: 0,
skills: vec![entry("a", "h1"), entry("b", "h2"), entry("gone", "h3")],
};
let live = vec![
skill("a", "h1"),
skill("b", "h2-new"),
skill("brand-new", "h4"),
];
let d = drift(&live, &idx);
assert_eq!(
d,
Drift {
new: 1,
changed: 1,
removed: 1
}
);
assert!(!d.is_fresh());
assert!(drift(
&[skill("a", "h1")],
&Index {
model: "m".into(),
dim: 0,
skills: vec![entry("a", "h1")],
}
)
.is_fresh());
}
#[test]
fn tilde_shortens_home_paths_only() {
let Some(home) = std::env::var_os("HOME") else {
return;
};
let home = PathBuf::from(home);
assert_eq!(tilde(&home.join(".config/ski")), "~/.config/ski");
assert_eq!(tilde(Path::new("/etc/hosts")), "/etc/hosts");
}
}