ski/paths.rs
1//! Canonical on-disk locations, shared by every subcommand.
2//!
3//! - Index (the big embedding cache) lives under `$XDG_DATA_HOME/ski`.
4//! - Session state (per-conversation dedup) lives under `$XDG_STATE_HOME/ski`.
5//!
6//! Both fall back to the XDG defaults relative to `$HOME` when the env vars are
7//! unset, matching the rest of the toolchain.
8
9use crate::hook::Host;
10use std::path::PathBuf;
11
12fn home() -> PathBuf {
13 PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
14}
15
16/// `$XDG_DATA_HOME/ski` (default `~/.local/share/ski`).
17pub fn data_dir() -> PathBuf {
18 std::env::var_os("XDG_DATA_HOME")
19 .map(PathBuf::from)
20 .unwrap_or_else(|| home().join(".local/share"))
21 .join("ski")
22}
23
24/// Persistent skill index for `host`. Each host indexes only its own skill
25/// library (see [`crate::config::Config::for_host`]), so the files are kept
26/// apart; Claude keeps the original `index.json` to avoid orphaning it.
27pub fn index_path(host: Host) -> PathBuf {
28 let name = match host {
29 Host::Claude => "index.json",
30 Host::Opencode => "index-opencode.json",
31 };
32 data_dir().join(name)
33}
34
35/// `$XDG_CONFIG_HOME/ski` (default `~/.config/ski`). Home for the downloaded
36/// fastembed model cache, so the ONNX models are kept once per user instead of
37/// dropping a `.fastembed_cache` into whatever directory the hook happens to run
38/// from (commonly a git work tree).
39pub fn config_dir() -> PathBuf {
40 std::env::var_os("XDG_CONFIG_HOME")
41 .map(PathBuf::from)
42 .unwrap_or_else(|| home().join(".config"))
43 .join("ski")
44}
45
46/// Cache directory for downloaded embedding/reranker model files, passed to
47/// fastembed's `with_cache_dir`. Lives under [`config_dir`].
48pub fn model_cache_dir() -> PathBuf {
49 config_dir().join("models")
50}
51
52/// Optional user config (`~/.config/ski/config.toml`). Absent by default; when
53/// present its fields override the compiled defaults (see [`crate::config`]).
54pub fn config_path() -> PathBuf {
55 config_dir().join("config.toml")
56}
57
58/// `~/.claude/settings.json` — Claude Code's user settings. `ski init --host
59/// claude` merges its hooks here, the marketplace-free install path for users
60/// who can't run `/plugin`.
61pub fn claude_settings_path() -> PathBuf {
62 home().join(".claude").join("settings.json")
63}
64
65/// opencode's global plugin directory (`$XDG_CONFIG_HOME/opencode/plugin`,
66/// default `~/.config/opencode/plugin`), where `ski init --host opencode` drops
67/// `ski.ts`.
68pub fn opencode_plugin_dir() -> PathBuf {
69 std::env::var_os("XDG_CONFIG_HOME")
70 .map(PathBuf::from)
71 .unwrap_or_else(|| home().join(".config"))
72 .join("opencode")
73 .join("plugin")
74}
75
76/// `$XDG_STATE_HOME/ski` (default `~/.local/state/ski`).
77pub fn state_dir() -> PathBuf {
78 std::env::var_os("XDG_STATE_HOME")
79 .map(PathBuf::from)
80 .unwrap_or_else(|| home().join(".local/state"))
81 .join("ski")
82}
83
84/// Directory holding one JSON file per conversation.
85pub fn sessions_dir() -> PathBuf {
86 state_dir().join("sessions")
87}
88
89/// Opt-in telemetry event log (append-only JSONL). Written only when
90/// `SKI_TELEMETRY` is truthy; read by `ski history`. See [`crate::telemetry`].
91pub fn telemetry_path() -> PathBuf {
92 state_dir().join("telemetry.jsonl")
93}
94
95/// State file for a single conversation. The id is sanitized so a hostile or
96/// odd session id can't escape the sessions directory.
97pub fn session_path(session_id: &str) -> PathBuf {
98 sessions_dir().join(format!("{}.json", sanitize(session_id)))
99}
100
101fn sanitize(id: &str) -> String {
102 let s: String = id
103 .chars()
104 .map(|c| {
105 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
106 c
107 } else {
108 '_'
109 }
110 })
111 .collect();
112 if s.is_empty() {
113 "default".to_string()
114 } else {
115 s
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn session_path_sanitizes_traversal() {
125 let p = session_path("../../etc/passwd");
126 let name = p.file_name().unwrap().to_str().unwrap();
127 assert!(!name.contains('/'));
128 assert!(!name.contains('.') || name.ends_with(".json"));
129 assert_eq!(p.parent().unwrap(), sessions_dir());
130 }
131
132 #[test]
133 fn empty_id_falls_back() {
134 assert!(session_path("").ends_with("default.json"));
135 }
136}