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 mut args: Vec<&str> = base_args.clone();
76 let is_bare_claude = base_args.is_empty()
77 && std::path::Path::new(program)
78 .file_name()
79 .and_then(|s| s.to_str())
80 .map(|s| s == "claude" || s == "claude.exe")
81 .unwrap_or(false);
82 if is_bare_claude {
83 args.push("--strict-mcp-config");
84 args.push("--mcp-config");
85 args.push(r#"{"mcpServers":{}}"#);
86 }
87
88 let output = std::process::Command::new(program)
96 .args(&args)
97 .args([
98 "-p",
99 "--model",
100 &self.model,
101 "--output-format",
102 "json",
103 &prompt,
104 ])
105 .env("TJ_IN_CLASSIFIER", "1")
106 .output()
107 .with_context(|| format!("spawn `{}` for classification", self.command))?;
108
109 if !output.status.success() {
110 let stderr = String::from_utf8_lossy(&output.stderr);
111 return Err(anyhow!(
112 "claude -p exited with {} — stderr: {}",
113 output.status,
114 stderr.trim()
115 ));
116 }
117
118 let stdout = String::from_utf8(output.stdout).context("claude -p stdout not UTF-8")?;
119 let envelope = stdout
124 .find('{')
125 .map(|i| &stdout[i..])
126 .unwrap_or(stdout.as_str())
127 .trim();
128 let cli_result: CliResult = serde_json::from_str(envelope)
129 .with_context(|| format!("parse claude -p JSON envelope; got: {envelope}"))?;
130
131 if cli_result.is_error {
132 return Err(anyhow!(
133 "claude -p reported error: {}. If 'Not logged in' — run `claude /login` first.",
134 cli_result.result
135 ));
136 }
137
138 let inner_text = cli_result
140 .result
141 .trim()
142 .trim_start_matches("```json")
143 .trim_start_matches("```")
144 .trim_end_matches("```")
145 .trim();
146 let out: ClassifyOutput = serde_json::from_str(inner_text)
147 .with_context(|| format!("classifier inner JSON parse failed; got: {inner_text}"))?;
148 Ok(out)
149 }
150}
151
152#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::event::EventType;
160
161 fn fake_claude(dir: &std::path::Path, envelope: &str) -> std::path::PathBuf {
164 let json_path = dir.join("fake-claude-output.json");
165 std::fs::write(&json_path, envelope).unwrap();
166
167 #[cfg(unix)]
168 {
169 use std::os::unix::fs::PermissionsExt;
170 let path = dir.join("fake-claude.sh");
171 let script = format!("#!/bin/sh\ncat \"{}\"\n", json_path.to_string_lossy());
172 std::fs::write(&path, script).unwrap();
173 let mut perms = std::fs::metadata(&path).unwrap().permissions();
174 perms.set_mode(0o755);
175 std::fs::set_permissions(&path, perms).unwrap();
176 path
177 }
178 #[cfg(windows)]
179 {
180 let path = dir.join("fake-claude.cmd");
181 let script = format!("@echo off\r\ntype \"{}\"\r\n", json_path.to_string_lossy());
185 std::fs::write(&path, script).unwrap();
186 path
187 }
188 }
189
190 #[cfg(unix)]
199 fn fake_claude_with_prelude(
200 dir: &std::path::Path,
201 prelude: &str,
202 envelope: &str,
203 ) -> std::path::PathBuf {
204 use std::os::unix::fs::PermissionsExt;
205 let json_path = dir.join("fake-claude-output.json");
206 std::fs::write(&json_path, envelope).unwrap();
207 let path = dir.join("fake-claude-prelude.sh");
208 let script = format!(
209 "#!/bin/sh\necho '{}'\ncat \"{}\"\n",
210 prelude,
211 json_path.to_string_lossy()
212 );
213 std::fs::write(&path, script).unwrap();
214 let mut perms = std::fs::metadata(&path).unwrap().permissions();
215 perms.set_mode(0o755);
216 std::fs::set_permissions(&path, perms).unwrap();
217 path
218 }
219
220 #[test]
221 #[cfg(unix)]
222 fn classifier_strips_wrapper_prelude_before_envelope() {
223 let dir = tempfile::TempDir::new().unwrap();
227 let inner = r#"{"event_type":"finding","task_id_guess":"tj-x","confidence":0.9,"evidence_strength":null,"suggested_text":"ok"}"#;
228 let envelope = serde_json::json!({
229 "type": "result",
230 "subtype": "success",
231 "is_error": false,
232 "result": inner,
233 });
234 let fake = fake_claude_with_prelude(
235 dir.path(),
236 "Auto-sync: 0 created, 0 repaired, 1 conflicts",
237 &envelope.to_string(),
238 );
239
240 let c = ClaudeCliClassifier {
241 command: fake.to_string_lossy().to_string(),
242 model: "haiku".into(),
243 };
244 let out = c
245 .classify(&ClassifyInput {
246 text: "x".into(),
247 author_hint: "user".into(),
248 recent_tasks: vec![],
249 })
250 .unwrap();
251 assert_eq!(out.event_type, EventType::Finding);
252 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
253 }
254
255 #[test]
256 #[cfg_attr(
257 windows,
258 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
259 )]
260 fn classifier_parses_cli_envelope_and_returns_classified_output() {
261 let dir = tempfile::TempDir::new().unwrap();
262
263 let inner = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
266 let envelope = serde_json::json!({
267 "type": "result",
268 "subtype": "success",
269 "is_error": false,
270 "result": inner,
271 });
272 let fake = fake_claude(dir.path(), &envelope.to_string());
273
274 let c = ClaudeCliClassifier {
275 command: fake.to_string_lossy().to_string(),
276 model: "haiku".into(),
277 };
278 let out = c
279 .classify(&ClassifyInput {
280 text: "We adopted Rust.".into(),
281 author_hint: "assistant".into(),
282 recent_tasks: vec![],
283 })
284 .unwrap();
285
286 assert_eq!(out.event_type, EventType::Decision);
287 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
288 assert!((out.confidence - 0.93).abs() < 1e-6);
289 }
290
291 #[test]
292 #[cfg_attr(
293 windows,
294 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
295 )]
296 fn classifier_surfaces_not_logged_in_with_friendly_hint() {
297 let dir = tempfile::TempDir::new().unwrap();
298 let envelope = serde_json::json!({
299 "type": "result",
300 "subtype": "success",
301 "is_error": true,
302 "result": "Not logged in - Please run /login",
308 });
309 let fake = fake_claude(dir.path(), &envelope.to_string());
310
311 let c = ClaudeCliClassifier {
312 command: fake.to_string_lossy().to_string(),
313 model: "haiku".into(),
314 };
315 let err = c
316 .classify(&ClassifyInput {
317 text: "x".into(),
318 author_hint: "user".into(),
319 recent_tasks: vec![],
320 })
321 .unwrap_err()
322 .to_string();
323 assert!(err.contains("Not logged in"));
324 assert!(err.contains("claude /login"));
325 }
326
327 #[test]
328 #[cfg_attr(
329 windows,
330 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
331 )]
332 fn classifier_command_with_spaces_runs_wrapper_then_target() {
333 let dir = tempfile::TempDir::new().unwrap();
338
339 let inner = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
340 let envelope = serde_json::json!({
341 "type": "result",
342 "subtype": "success",
343 "is_error": false,
344 "result": inner,
345 });
346 let real_fake = fake_claude(dir.path(), &envelope.to_string());
347
348 #[cfg(unix)]
350 let wrapper = {
351 use std::os::unix::fs::PermissionsExt;
352 let path = dir.path().join("fake-aimux.sh");
353 let script = format!(
356 "#!/bin/sh\nshift\nshift\nshift\nexec \"{}\" \"$@\"\n",
357 real_fake.to_string_lossy()
358 );
359 std::fs::write(&path, script).unwrap();
360 let mut perms = std::fs::metadata(&path).unwrap().permissions();
361 perms.set_mode(0o755);
362 std::fs::set_permissions(&path, perms).unwrap();
363 path
364 };
365 #[cfg(windows)]
366 let wrapper = {
367 let path = dir.path().join("fake-aimux.cmd");
368 let script = format!(
370 "@echo off\r\ncall \"{}\" %4 %5 %6 %7 %8 %9\r\n",
371 real_fake.to_string_lossy()
372 );
373 std::fs::write(&path, script).unwrap();
374 path
375 };
376
377 let c = ClaudeCliClassifier {
378 command: format!("{} run dt claude", wrapper.to_string_lossy()),
379 model: "haiku".into(),
380 };
381 let out = c
382 .classify(&ClassifyInput {
383 text: "x".into(),
384 author_hint: "user".into(),
385 recent_tasks: vec![],
386 })
387 .unwrap();
388 assert_eq!(out.event_type, EventType::Finding);
389 }
390}