tj_core/classifier/
cli.rs1use 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 cli_result: CliResult = serde_json::from_str(stdout.trim())
92 .with_context(|| format!("parse claude -p JSON envelope; got: {}", stdout.trim()))?;
93
94 if cli_result.is_error {
95 return Err(anyhow!(
96 "claude -p reported error: {}. If 'Not logged in' — run `claude /login` first.",
97 cli_result.result
98 ));
99 }
100
101 let inner_text = cli_result
103 .result
104 .trim()
105 .trim_start_matches("```json")
106 .trim_start_matches("```")
107 .trim_end_matches("```")
108 .trim();
109 let out: ClassifyOutput = serde_json::from_str(inner_text)
110 .with_context(|| format!("classifier inner JSON parse failed; got: {inner_text}"))?;
111 Ok(out)
112 }
113}
114
115#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::event::EventType;
123
124 fn fake_claude(dir: &std::path::Path, envelope: &str) -> std::path::PathBuf {
127 let json_path = dir.join("fake-claude-output.json");
128 std::fs::write(&json_path, envelope).unwrap();
129
130 #[cfg(unix)]
131 {
132 use std::os::unix::fs::PermissionsExt;
133 let path = dir.join("fake-claude.sh");
134 let script = format!("#!/bin/sh\ncat \"{}\"\n", json_path.to_string_lossy());
135 std::fs::write(&path, script).unwrap();
136 let mut perms = std::fs::metadata(&path).unwrap().permissions();
137 perms.set_mode(0o755);
138 std::fs::set_permissions(&path, perms).unwrap();
139 path
140 }
141 #[cfg(windows)]
142 {
143 let path = dir.join("fake-claude.cmd");
144 let script = format!("@echo off\r\ntype \"{}\"\r\n", json_path.to_string_lossy());
148 std::fs::write(&path, script).unwrap();
149 path
150 }
151 }
152
153 #[test]
160 #[cfg_attr(
161 windows,
162 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
163 )]
164 fn classifier_parses_cli_envelope_and_returns_classified_output() {
165 let dir = tempfile::TempDir::new().unwrap();
166
167 let inner = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
170 let envelope = serde_json::json!({
171 "type": "result",
172 "subtype": "success",
173 "is_error": false,
174 "result": inner,
175 });
176 let fake = fake_claude(dir.path(), &envelope.to_string());
177
178 let c = ClaudeCliClassifier {
179 command: fake.to_string_lossy().to_string(),
180 model: "haiku".into(),
181 };
182 let out = c
183 .classify(&ClassifyInput {
184 text: "We adopted Rust.".into(),
185 author_hint: "assistant".into(),
186 recent_tasks: vec![],
187 })
188 .unwrap();
189
190 assert_eq!(out.event_type, EventType::Decision);
191 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
192 assert!((out.confidence - 0.93).abs() < 1e-6);
193 }
194
195 #[test]
196 #[cfg_attr(
197 windows,
198 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
199 )]
200 fn classifier_surfaces_not_logged_in_with_friendly_hint() {
201 let dir = tempfile::TempDir::new().unwrap();
202 let envelope = serde_json::json!({
203 "type": "result",
204 "subtype": "success",
205 "is_error": true,
206 "result": "Not logged in - Please run /login",
212 });
213 let fake = fake_claude(dir.path(), &envelope.to_string());
214
215 let c = ClaudeCliClassifier {
216 command: fake.to_string_lossy().to_string(),
217 model: "haiku".into(),
218 };
219 let err = c
220 .classify(&ClassifyInput {
221 text: "x".into(),
222 author_hint: "user".into(),
223 recent_tasks: vec![],
224 })
225 .unwrap_err()
226 .to_string();
227 assert!(err.contains("Not logged in"));
228 assert!(err.contains("claude /login"));
229 }
230
231 #[test]
232 #[cfg_attr(
233 windows,
234 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
235 )]
236 fn classifier_command_with_spaces_runs_wrapper_then_target() {
237 let dir = tempfile::TempDir::new().unwrap();
242
243 let inner = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
244 let envelope = serde_json::json!({
245 "type": "result",
246 "subtype": "success",
247 "is_error": false,
248 "result": inner,
249 });
250 let real_fake = fake_claude(dir.path(), &envelope.to_string());
251
252 #[cfg(unix)]
254 let wrapper = {
255 use std::os::unix::fs::PermissionsExt;
256 let path = dir.path().join("fake-aimux.sh");
257 let script = format!(
260 "#!/bin/sh\nshift\nshift\nshift\nexec \"{}\" \"$@\"\n",
261 real_fake.to_string_lossy()
262 );
263 std::fs::write(&path, script).unwrap();
264 let mut perms = std::fs::metadata(&path).unwrap().permissions();
265 perms.set_mode(0o755);
266 std::fs::set_permissions(&path, perms).unwrap();
267 path
268 };
269 #[cfg(windows)]
270 let wrapper = {
271 let path = dir.path().join("fake-aimux.cmd");
272 let script = format!(
274 "@echo off\r\ncall \"{}\" %4 %5 %6 %7 %8 %9\r\n",
275 real_fake.to_string_lossy()
276 );
277 std::fs::write(&path, script).unwrap();
278 path
279 };
280
281 let c = ClaudeCliClassifier {
282 command: format!("{} run dt claude", wrapper.to_string_lossy()),
283 model: "haiku".into(),
284 };
285 let out = c
286 .classify(&ClassifyInput {
287 text: "x".into(),
288 author_hint: "user".into(),
289 recent_tasks: vec![],
290 })
291 .unwrap();
292 assert_eq!(out.event_type, EventType::Finding);
293 }
294}