1use super::*;
10use anyhow::{anyhow, Context};
11use serde::Deserialize;
12
13pub struct ClaudeCliClassifier {
24 pub command: String,
25 pub model: String,
26}
27
28pub const DEFAULT_MODEL: &str = "haiku";
30
31impl Default for ClaudeCliClassifier {
32 fn default() -> Self {
33 Self {
34 command: std::env::var("TJ_CLASSIFIER_CLI").unwrap_or_else(|_| "claude".into()),
35 model: std::env::var("TJ_CLASSIFIER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()),
36 }
37 }
38}
39
40#[derive(Deserialize)]
41struct CliResult {
42 result: String,
44 is_error: bool,
45}
46
47impl Classifier for ClaudeCliClassifier {
48 fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
49 let prompt = crate::classifier::prompt::build(input);
50
51 let mut parts = self.command.split_whitespace();
55 let program = parts
56 .next()
57 .ok_or_else(|| anyhow!("TJ_CLASSIFIER_CLI is empty"))?;
58 let base_args: Vec<&str> = parts.collect();
59
60 let output = std::process::Command::new(program)
68 .args(&base_args)
69 .args([
70 "-p",
71 "--model",
72 &self.model,
73 "--output-format",
74 "json",
75 &prompt,
76 ])
77 .env("TJ_IN_CLASSIFIER", "1")
78 .output()
79 .with_context(|| format!("spawn `{}` for classification", self.command))?;
80
81 if !output.status.success() {
82 let stderr = String::from_utf8_lossy(&output.stderr);
83 return Err(anyhow!(
84 "claude -p exited with {} — stderr: {}",
85 output.status,
86 stderr.trim()
87 ));
88 }
89
90 let stdout = String::from_utf8(output.stdout).context("claude -p stdout not UTF-8")?;
91 let envelope = stdout
96 .find('{')
97 .map(|i| &stdout[i..])
98 .unwrap_or(stdout.as_str())
99 .trim();
100 let cli_result: CliResult = serde_json::from_str(envelope)
101 .with_context(|| format!("parse claude -p JSON envelope; got: {envelope}"))?;
102
103 if cli_result.is_error {
104 return Err(anyhow!(
105 "claude -p reported error: {}. If 'Not logged in' — run `claude /login` first.",
106 cli_result.result
107 ));
108 }
109
110 let inner_text = cli_result
112 .result
113 .trim()
114 .trim_start_matches("```json")
115 .trim_start_matches("```")
116 .trim_end_matches("```")
117 .trim();
118 let out: ClassifyOutput = serde_json::from_str(inner_text)
119 .with_context(|| format!("classifier inner JSON parse failed; got: {inner_text}"))?;
120 Ok(out)
121 }
122}
123
124#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::event::EventType;
132
133 fn fake_claude(dir: &std::path::Path, envelope: &str) -> std::path::PathBuf {
136 let json_path = dir.join("fake-claude-output.json");
137 std::fs::write(&json_path, envelope).unwrap();
138
139 #[cfg(unix)]
140 {
141 use std::os::unix::fs::PermissionsExt;
142 let path = dir.join("fake-claude.sh");
143 let script = format!("#!/bin/sh\ncat \"{}\"\n", json_path.to_string_lossy());
144 std::fs::write(&path, script).unwrap();
145 let mut perms = std::fs::metadata(&path).unwrap().permissions();
146 perms.set_mode(0o755);
147 std::fs::set_permissions(&path, perms).unwrap();
148 path
149 }
150 #[cfg(windows)]
151 {
152 let path = dir.join("fake-claude.cmd");
153 let script = format!("@echo off\r\ntype \"{}\"\r\n", json_path.to_string_lossy());
157 std::fs::write(&path, script).unwrap();
158 path
159 }
160 }
161
162 #[cfg(unix)]
171 fn fake_claude_with_prelude(
172 dir: &std::path::Path,
173 prelude: &str,
174 envelope: &str,
175 ) -> std::path::PathBuf {
176 use std::os::unix::fs::PermissionsExt;
177 let json_path = dir.join("fake-claude-output.json");
178 std::fs::write(&json_path, envelope).unwrap();
179 let path = dir.join("fake-claude-prelude.sh");
180 let script = format!(
181 "#!/bin/sh\necho '{}'\ncat \"{}\"\n",
182 prelude,
183 json_path.to_string_lossy()
184 );
185 std::fs::write(&path, script).unwrap();
186 let mut perms = std::fs::metadata(&path).unwrap().permissions();
187 perms.set_mode(0o755);
188 std::fs::set_permissions(&path, perms).unwrap();
189 path
190 }
191
192 #[test]
193 #[cfg(unix)]
194 fn classifier_strips_wrapper_prelude_before_envelope() {
195 let dir = tempfile::TempDir::new().unwrap();
199 let inner = r#"{"event_type":"finding","task_id_guess":"tj-x","confidence":0.9,"evidence_strength":null,"suggested_text":"ok"}"#;
200 let envelope = serde_json::json!({
201 "type": "result",
202 "subtype": "success",
203 "is_error": false,
204 "result": inner,
205 });
206 let fake = fake_claude_with_prelude(
207 dir.path(),
208 "Auto-sync: 0 created, 0 repaired, 1 conflicts",
209 &envelope.to_string(),
210 );
211
212 let c = ClaudeCliClassifier {
213 command: fake.to_string_lossy().to_string(),
214 model: "haiku".into(),
215 };
216 let out = c
217 .classify(&ClassifyInput {
218 text: "x".into(),
219 author_hint: "user".into(),
220 recent_tasks: vec![],
221 })
222 .unwrap();
223 assert_eq!(out.event_type, EventType::Finding);
224 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
225 }
226
227 #[test]
228 #[cfg_attr(
229 windows,
230 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
231 )]
232 fn classifier_parses_cli_envelope_and_returns_classified_output() {
233 let dir = tempfile::TempDir::new().unwrap();
234
235 let inner = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
238 let envelope = serde_json::json!({
239 "type": "result",
240 "subtype": "success",
241 "is_error": false,
242 "result": inner,
243 });
244 let fake = fake_claude(dir.path(), &envelope.to_string());
245
246 let c = ClaudeCliClassifier {
247 command: fake.to_string_lossy().to_string(),
248 model: "haiku".into(),
249 };
250 let out = c
251 .classify(&ClassifyInput {
252 text: "We adopted Rust.".into(),
253 author_hint: "assistant".into(),
254 recent_tasks: vec![],
255 })
256 .unwrap();
257
258 assert_eq!(out.event_type, EventType::Decision);
259 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
260 assert!((out.confidence - 0.93).abs() < 1e-6);
261 }
262
263 #[test]
264 #[cfg_attr(
265 windows,
266 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
267 )]
268 fn classifier_surfaces_not_logged_in_with_friendly_hint() {
269 let dir = tempfile::TempDir::new().unwrap();
270 let envelope = serde_json::json!({
271 "type": "result",
272 "subtype": "success",
273 "is_error": true,
274 "result": "Not logged in - Please run /login",
280 });
281 let fake = fake_claude(dir.path(), &envelope.to_string());
282
283 let c = ClaudeCliClassifier {
284 command: fake.to_string_lossy().to_string(),
285 model: "haiku".into(),
286 };
287 let err = c
288 .classify(&ClassifyInput {
289 text: "x".into(),
290 author_hint: "user".into(),
291 recent_tasks: vec![],
292 })
293 .unwrap_err()
294 .to_string();
295 assert!(err.contains("Not logged in"));
296 assert!(err.contains("claude /login"));
297 }
298
299 #[test]
300 #[cfg_attr(
301 windows,
302 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
303 )]
304 fn classifier_command_with_spaces_runs_wrapper_then_target() {
305 let dir = tempfile::TempDir::new().unwrap();
310
311 let inner = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
312 let envelope = serde_json::json!({
313 "type": "result",
314 "subtype": "success",
315 "is_error": false,
316 "result": inner,
317 });
318 let real_fake = fake_claude(dir.path(), &envelope.to_string());
319
320 #[cfg(unix)]
322 let wrapper = {
323 use std::os::unix::fs::PermissionsExt;
324 let path = dir.path().join("fake-aimux.sh");
325 let script = format!(
328 "#!/bin/sh\nshift\nshift\nshift\nexec \"{}\" \"$@\"\n",
329 real_fake.to_string_lossy()
330 );
331 std::fs::write(&path, script).unwrap();
332 let mut perms = std::fs::metadata(&path).unwrap().permissions();
333 perms.set_mode(0o755);
334 std::fs::set_permissions(&path, perms).unwrap();
335 path
336 };
337 #[cfg(windows)]
338 let wrapper = {
339 let path = dir.path().join("fake-aimux.cmd");
340 let script = format!(
342 "@echo off\r\ncall \"{}\" %4 %5 %6 %7 %8 %9\r\n",
343 real_fake.to_string_lossy()
344 );
345 std::fs::write(&path, script).unwrap();
346 path
347 };
348
349 let c = ClaudeCliClassifier {
350 command: format!("{} run dt claude", wrapper.to_string_lossy()),
351 model: "haiku".into(),
352 };
353 let out = c
354 .classify(&ClassifyInput {
355 text: "x".into(),
356 author_hint: "user".into(),
357 recent_tasks: vec![],
358 })
359 .unwrap();
360 assert_eq!(out.event_type, EventType::Finding);
361 }
362}