use crate::config::Paths;
use std::path::PathBuf;
use std::sync::OnceLock;
const FILES: [(&str, &str); 4] = [
(
".claude-plugin/plugin.json",
include_str!("../mod/.claude-plugin/plugin.json"),
),
("hooks/hooks.json", include_str!("../mod/hooks/hooks.json")),
(
"hooks/register.tsx",
include_str!("../mod/hooks/register.tsx"),
),
("types/index.d.ts", include_str!("../mod/types/index.d.ts")),
];
pub const SINCE: (u32, u32, u32) = (2, 1, 287);
pub fn dir(paths: &Paths) -> PathBuf {
paths
.data_dir
.join("claude-mod")
.join(crate::version::VERSION)
}
pub fn write(paths: &Paths, url: &str) -> std::io::Result<PathBuf> {
let root = dir(paths);
let link = serde_json::json!({
"url": url,
"token_file": paths.token_path.to_string_lossy(),
})
.to_string();
let mut all: Vec<(&str, &str)> = FILES.to_vec();
all.push(("snyvi.json", &link));
for (rel, text) in all {
let at = root.join(rel);
if std::fs::read_to_string(&at).ok().as_deref() == Some(text) {
continue;
}
if let Some(parent) = at.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&at, text)?;
}
Ok(root)
}
pub fn parse_version(out: &str) -> Option<(u32, u32, u32)> {
let v = out.split_whitespace().next()?;
let mut n = v.split('.').map(|p| p.parse::<u32>().ok());
Some((n.next()??, n.next()??, n.next()??))
}
static HAS_MODS: OnceLock<bool> = OnceLock::new();
pub fn claude_has_mods() -> bool {
HAS_MODS.get().copied().unwrap_or(false)
}
pub fn start(paths: &Paths) {
warm();
if let Err(e) = write(paths, &crate::config::base_url()) {
eprintln!("snyvi: could not write the Claude Code mod: {e}");
}
}
fn warm() {
std::thread::spawn(|| {
let ok = crate::platform::find_on_path("claude")
.and_then(|exe| {
std::process::Command::new(exe)
.arg("--version")
.stdin(std::process::Stdio::null())
.output()
.ok()
})
.and_then(|o| parse_version(&String::from_utf8_lossy(&o.stdout)))
.is_some_and(|v| v >= SINCE);
let _ = HAS_MODS.set(ok);
});
}
pub fn plugin_dirs(root: &std::path::Path) -> String {
let sep = if cfg!(windows) { ";" } else { ":" };
match std::env::var("CLAUDE_CODE_PLUGIN_DIRS") {
Ok(had) if !had.trim().is_empty() => format!("{had}{sep}{}", root.display()),
_ => root.display().to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_version_is_three_numbers_and_mods_came_in_2_1_287() {
assert_eq!(parse_version("2.1.291 (Claude Code)\n"), Some((2, 1, 291)));
assert_eq!(parse_version("garbage"), None);
assert!(parse_version("2.1.291").unwrap() >= SINCE);
assert!(parse_version("2.1.286").unwrap() < SINCE);
assert!(parse_version("2.2.0").unwrap() >= SINCE);
}
#[test]
fn the_mod_is_written_whole_and_is_this_versions() {
let tmp = std::env::temp_dir().join(format!("snyvi-mod-{}", std::process::id()));
let paths = Paths {
data_dir: tmp.join("data"),
config_dir: tmp.join("config"),
docs_dir: tmp.join("docs"),
db_path: tmp.join("db"),
token_path: tmp.join("token"),
};
let root = write(&paths, "http://127.0.0.1:7999").unwrap();
for (rel, _) in FILES {
assert!(root.join(rel).is_file(), "{rel}");
}
let link: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(root.join("snyvi.json")).unwrap())
.unwrap();
assert_eq!(link["url"], "http://127.0.0.1:7999");
let manifest: serde_json::Value = serde_json::from_str(FILES[0].1).unwrap();
assert_eq!(manifest["version"], crate::version::VERSION);
let _ = std::fs::remove_dir_all(&tmp);
}
}