Skip to main content

ski/
init.rs

1//! `ski init` — one-shot setup of ski's hooks for a host into the user's config.
2//!
3//! For **opencode** it drops the bundled `ski.ts` plugin into the global plugin
4//! directory. For **Claude Code** it merges the three hooks straight into
5//! `~/.claude/settings.json` — the install path for users who can't (or don't
6//! want to) go through the `/plugin` marketplace. The Claude merge is additive
7//! and idempotent: it never rewrites unrelated settings and won't double-add a
8//! hook that is already wired (whether by a previous `init` or by the plugin).
9
10use crate::hook::Host;
11use crate::paths;
12use anyhow::{Context, Result};
13use serde_json::{json, Value};
14use std::fs;
15
16/// The bundled opencode plugin, embedded at build time so an installed `ski`
17/// needs no access to the source tree to set opencode up.
18const OPENCODE_PLUGIN: &str = include_str!("../opencode/ski.ts");
19
20/// The Claude hooks ski installs, as `(event, matcher, ski subcommand)`. The
21/// matchers mirror `hooks/hooks.json` so a manual install behaves identically to
22/// the marketplace plugin. `pub(crate)` so `ski doctor` verifies exactly the set
23/// `ski init` installs.
24pub(crate) const CLAUDE_HOOKS: &[(&str, Option<&str>, &str)] = &[
25    ("UserPromptSubmit", None, "hook"),
26    ("PostToolUse", Some("Read|Skill"), "observe"),
27    (
28        "SessionStart",
29        Some("startup|resume|compact"),
30        "session-start",
31    ),
32];
33
34pub fn run(host: Host, global: bool) -> Result<()> {
35    if !global {
36        anyhow::bail!(
37            "per-project install is not implemented yet; pass -g/--global for a \
38             user-wide install"
39        );
40    }
41    match host {
42        Host::Opencode => init_opencode(),
43        Host::Claude => init_claude(),
44    }
45}
46
47/// Write the bundled plugin to `~/.config/opencode/plugin/ski.ts`. Overwriting is
48/// safe — the file is ours and regenerable — and keeps an existing install up to
49/// date with this binary's version.
50fn init_opencode() -> Result<()> {
51    let dir = paths::opencode_plugin_dir();
52    fs::create_dir_all(&dir)
53        .with_context(|| format!("creating opencode plugin dir {}", dir.display()))?;
54    let dest = dir.join("ski.ts");
55    fs::write(&dest, OPENCODE_PLUGIN).with_context(|| format!("writing {}", dest.display()))?;
56    println!("installed opencode plugin -> {}", dest.display());
57    print_next_steps("opencode");
58    Ok(())
59}
60
61/// Merge ski's hooks into `~/.claude/settings.json`, creating the file if absent
62/// and backing up any existing one to `settings.json.bak` first.
63fn init_claude() -> Result<()> {
64    let path = paths::claude_settings_path();
65    if let Some(parent) = path.parent() {
66        fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
67    }
68
69    let mut root: Value = if path.exists() {
70        let raw =
71            fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
72        fs::write(path.with_extension("json.bak"), &raw)
73            .with_context(|| format!("backing up {}", path.display()))?;
74        serde_json::from_str(&raw)
75            .with_context(|| format!("{} is not valid JSON", path.display()))?
76    } else {
77        json!({})
78    };
79
80    // The hook points at this very binary by absolute path, so it works no matter
81    // what PATH the hook subprocess inherits.
82    let exe = std::env::current_exe().context("locating the ski binary")?;
83    let exe = exe.display();
84
85    let obj = root
86        .as_object_mut()
87        .context("settings.json must be a JSON object")?;
88    let hooks = obj
89        .entry("hooks")
90        .or_insert_with(|| json!({}))
91        .as_object_mut()
92        .context("\"hooks\" in settings.json must be an object")?;
93
94    let mut added = 0;
95    for &(event, matcher, sub) in CLAUDE_HOOKS {
96        let arr = hooks
97            .entry(event)
98            .or_insert_with(|| json!([]))
99            .as_array_mut()
100            .with_context(|| format!("\"hooks.{event}\" must be an array"))?;
101        if arr.iter().any(|g| group_runs_ski(g, sub)) {
102            continue; // already wired (by a prior init or the plugin)
103        }
104        let command = format!("\"{exe}\" {sub} --host claude");
105        let entry = match matcher {
106            Some(m) => {
107                json!({ "matcher": m, "hooks": [{ "type": "command", "command": command }] })
108            }
109            None => json!({ "hooks": [{ "type": "command", "command": command }] }),
110        };
111        arr.push(entry);
112        added += 1;
113    }
114
115    let mut out = serde_json::to_string_pretty(&root)?;
116    out.push('\n');
117    fs::write(&path, out).with_context(|| format!("writing {}", path.display()))?;
118
119    if added == 0 {
120        println!("ski hooks already present in {}", path.display());
121    } else {
122        println!("wired {added} ski hook(s) into {}", path.display());
123    }
124    print_next_steps("claude");
125    Ok(())
126}
127
128/// Post-install pointers. The two things every new install trips over: the
129/// first ranked prompt otherwise blocks on the one-time model download, and a
130/// zero-skill library silently injects nothing.
131fn print_next_steps(host: &str) {
132    println!("next steps:");
133    println!(
134        "  ski index --host {host}    # pre-download the embedding models (one-time, ~275 MB)\n\
135         \x20                            and build the index — otherwise your first prompt blocks on it"
136    );
137    println!("  ski why \"set up a python project\"    # verify skills are discovered and ranked");
138    println!("  ski doctor --host {host}    # check the whole install end to end");
139}
140
141/// Whether a settings.json hook group already runs `ski <sub> --host claude` —
142/// matches both the marketplace command (via `ski-bootstrap.sh`) and a direct
143/// binary call, since both end in `<sub> --host claude`. Shared with `ski
144/// doctor`, so init and doctor agree on what "wired" means.
145pub(crate) fn group_runs_ski(group: &Value, sub: &str) -> bool {
146    let needle = format!("{sub} --host claude");
147    group
148        .get("hooks")
149        .and_then(Value::as_array)
150        .map(|hs| {
151            hs.iter().any(|h| {
152                h.get("command")
153                    .and_then(Value::as_str)
154                    .is_some_and(|c| c.contains(&needle))
155            })
156        })
157        .unwrap_or(false)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn detects_existing_marketplace_hook() {
166        let g = json!({
167            "hooks": [{
168                "type": "command",
169                "command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/ski-bootstrap.sh\" hook --host claude"
170            }]
171        });
172        assert!(group_runs_ski(&g, "hook"));
173        assert!(!group_runs_ski(&g, "observe"));
174    }
175
176    #[test]
177    fn detects_direct_binary_hook() {
178        let g = json!({
179            "hooks": [{ "type": "command", "command": "\"/home/u/.local/bin/ski\" observe --host claude" }]
180        });
181        assert!(group_runs_ski(&g, "observe"));
182    }
183
184    #[test]
185    fn ignores_unrelated_hook() {
186        let g = json!({
187            "hooks": [{ "type": "command", "command": "echo hi" }]
188        });
189        assert!(!group_runs_ski(&g, "hook"));
190    }
191}