1use crate::hook::Host;
11use crate::paths;
12use anyhow::{Context, Result};
13use serde_json::{json, Value};
14use std::fs;
15
16const OPENCODE_PLUGIN: &str = include_str!("../opencode/ski.ts");
19
20pub(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
47fn 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
61fn 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 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; }
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
128fn 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
141pub(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}