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)
61 .args(&base_args)
62 .args([
63 "-p",
64 "--model",
65 &self.model,
66 "--output-format",
67 "json",
68 "--bare", &prompt,
70 ])
71 .output()
72 .with_context(|| format!("spawn `{}` for classification", self.command))?;
73
74 if !output.status.success() {
75 let stderr = String::from_utf8_lossy(&output.stderr);
76 return Err(anyhow!(
77 "claude -p exited with {} — stderr: {}",
78 output.status,
79 stderr.trim()
80 ));
81 }
82
83 let stdout = String::from_utf8(output.stdout).context("claude -p stdout not UTF-8")?;
84 let cli_result: CliResult = serde_json::from_str(stdout.trim())
85 .with_context(|| format!("parse claude -p JSON envelope; got: {}", stdout.trim()))?;
86
87 if cli_result.is_error {
88 return Err(anyhow!(
89 "claude -p reported error: {}. If 'Not logged in' — run `claude /login` first.",
90 cli_result.result
91 ));
92 }
93
94 let inner_text = cli_result
96 .result
97 .trim()
98 .trim_start_matches("```json")
99 .trim_start_matches("```")
100 .trim_end_matches("```")
101 .trim();
102 let out: ClassifyOutput = serde_json::from_str(inner_text)
103 .with_context(|| format!("classifier inner JSON parse failed; got: {inner_text}"))?;
104 Ok(out)
105 }
106}
107
108#[cfg(test)]
113mod tests {
114 use super::*;
115 use crate::event::EventType;
116
117 fn fake_claude(dir: &std::path::Path, envelope: &str) -> std::path::PathBuf {
120 let json_path = dir.join("fake-claude-output.json");
121 std::fs::write(&json_path, envelope).unwrap();
122
123 #[cfg(unix)]
124 {
125 use std::os::unix::fs::PermissionsExt;
126 let path = dir.join("fake-claude.sh");
127 let script = format!("#!/bin/sh\ncat \"{}\"\n", json_path.to_string_lossy());
128 std::fs::write(&path, script).unwrap();
129 let mut perms = std::fs::metadata(&path).unwrap().permissions();
130 perms.set_mode(0o755);
131 std::fs::set_permissions(&path, perms).unwrap();
132 path
133 }
134 #[cfg(windows)]
135 {
136 let path = dir.join("fake-claude.cmd");
137 let script = format!("@echo off\r\ntype \"{}\"\r\n", json_path.to_string_lossy());
141 std::fs::write(&path, script).unwrap();
142 path
143 }
144 }
145
146 #[test]
153 #[cfg_attr(
154 windows,
155 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
156 )]
157 fn classifier_parses_cli_envelope_and_returns_classified_output() {
158 let dir = tempfile::TempDir::new().unwrap();
159
160 let inner = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
163 let envelope = serde_json::json!({
164 "type": "result",
165 "subtype": "success",
166 "is_error": false,
167 "result": inner,
168 });
169 let fake = fake_claude(dir.path(), &envelope.to_string());
170
171 let c = ClaudeCliClassifier {
172 command: fake.to_string_lossy().to_string(),
173 model: "haiku".into(),
174 };
175 let out = c
176 .classify(&ClassifyInput {
177 text: "We adopted Rust.".into(),
178 author_hint: "assistant".into(),
179 recent_tasks: vec![],
180 })
181 .unwrap();
182
183 assert_eq!(out.event_type, EventType::Decision);
184 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
185 assert!((out.confidence - 0.93).abs() < 1e-6);
186 }
187
188 #[test]
189 #[cfg_attr(
190 windows,
191 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
192 )]
193 fn classifier_surfaces_not_logged_in_with_friendly_hint() {
194 let dir = tempfile::TempDir::new().unwrap();
195 let envelope = serde_json::json!({
196 "type": "result",
197 "subtype": "success",
198 "is_error": true,
199 "result": "Not logged in - Please run /login",
205 });
206 let fake = fake_claude(dir.path(), &envelope.to_string());
207
208 let c = ClaudeCliClassifier {
209 command: fake.to_string_lossy().to_string(),
210 model: "haiku".into(),
211 };
212 let err = c
213 .classify(&ClassifyInput {
214 text: "x".into(),
215 author_hint: "user".into(),
216 recent_tasks: vec![],
217 })
218 .unwrap_err()
219 .to_string();
220 assert!(err.contains("Not logged in"));
221 assert!(err.contains("claude /login"));
222 }
223
224 #[test]
225 #[cfg_attr(
226 windows,
227 ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
228 )]
229 fn classifier_command_with_spaces_runs_wrapper_then_target() {
230 let dir = tempfile::TempDir::new().unwrap();
235
236 let inner = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
237 let envelope = serde_json::json!({
238 "type": "result",
239 "subtype": "success",
240 "is_error": false,
241 "result": inner,
242 });
243 let real_fake = fake_claude(dir.path(), &envelope.to_string());
244
245 #[cfg(unix)]
247 let wrapper = {
248 use std::os::unix::fs::PermissionsExt;
249 let path = dir.path().join("fake-aimux.sh");
250 let script = format!(
253 "#!/bin/sh\nshift\nshift\nshift\nexec \"{}\" \"$@\"\n",
254 real_fake.to_string_lossy()
255 );
256 std::fs::write(&path, script).unwrap();
257 let mut perms = std::fs::metadata(&path).unwrap().permissions();
258 perms.set_mode(0o755);
259 std::fs::set_permissions(&path, perms).unwrap();
260 path
261 };
262 #[cfg(windows)]
263 let wrapper = {
264 let path = dir.path().join("fake-aimux.cmd");
265 let script = format!(
267 "@echo off\r\ncall \"{}\" %4 %5 %6 %7 %8 %9\r\n",
268 real_fake.to_string_lossy()
269 );
270 std::fs::write(&path, script).unwrap();
271 path
272 };
273
274 let c = ClaudeCliClassifier {
275 command: format!("{} run dt claude", wrapper.to_string_lossy()),
276 model: "haiku".into(),
277 };
278 let out = c
279 .classify(&ClassifyInput {
280 text: "x".into(),
281 author_hint: "user".into(),
282 recent_tasks: vec![],
283 })
284 .unwrap();
285 assert_eq!(out.event_type, EventType::Finding);
286 }
287}