tj_core/classifier/
cli.rs1use super::*;
10use anyhow::{anyhow, Context};
11use serde::Deserialize;
12
13pub struct ClaudeCliClassifier {
21 pub command: String,
22 pub model: String,
23}
24
25pub const DEFAULT_MODEL: &str = "haiku";
27
28impl Default for ClaudeCliClassifier {
29 fn default() -> Self {
30 Self {
31 command: "claude".into(),
32 model: std::env::var("TJ_CLASSIFIER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()),
33 }
34 }
35}
36
37#[derive(Deserialize)]
38struct CliResult {
39 result: String,
41 is_error: bool,
42}
43
44impl Classifier for ClaudeCliClassifier {
45 fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
46 let prompt = crate::classifier::prompt::build(input);
47
48 let output = std::process::Command::new(&self.command)
49 .args([
50 "-p",
51 "--model",
52 &self.model,
53 "--output-format",
54 "json",
55 "--bare", &prompt,
57 ])
58 .output()
59 .with_context(|| format!("spawn `{}` for classification", self.command))?;
60
61 if !output.status.success() {
62 let stderr = String::from_utf8_lossy(&output.stderr);
63 return Err(anyhow!(
64 "claude -p exited with {} โ stderr: {}",
65 output.status,
66 stderr.trim()
67 ));
68 }
69
70 let stdout = String::from_utf8(output.stdout).context("claude -p stdout not UTF-8")?;
71 let cli_result: CliResult = serde_json::from_str(stdout.trim())
72 .with_context(|| format!("parse claude -p JSON envelope; got: {}", stdout.trim()))?;
73
74 if cli_result.is_error {
75 return Err(anyhow!(
76 "claude -p reported error: {}. If 'Not logged in' โ run `claude /login` first.",
77 cli_result.result
78 ));
79 }
80
81 let inner_text = cli_result
83 .result
84 .trim()
85 .trim_start_matches("```json")
86 .trim_start_matches("```")
87 .trim_end_matches("```")
88 .trim();
89 let out: ClassifyOutput = serde_json::from_str(inner_text)
90 .with_context(|| format!("classifier inner JSON parse failed; got: {inner_text}"))?;
91 Ok(out)
92 }
93}
94
95#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::event::EventType;
103
104 fn fake_claude(dir: &std::path::Path, envelope: &str) -> std::path::PathBuf {
107 let json_path = dir.join("fake-claude-output.json");
108 std::fs::write(&json_path, envelope).unwrap();
109
110 #[cfg(unix)]
111 {
112 use std::os::unix::fs::PermissionsExt;
113 let path = dir.join("fake-claude.sh");
114 let script = format!("#!/bin/sh\ncat \"{}\"\n", json_path.to_string_lossy());
115 std::fs::write(&path, script).unwrap();
116 let mut perms = std::fs::metadata(&path).unwrap().permissions();
117 perms.set_mode(0o755);
118 std::fs::set_permissions(&path, perms).unwrap();
119 path
120 }
121 #[cfg(windows)]
122 {
123 let path = dir.join("fake-claude.cmd");
124 let script = format!("@echo off\r\ntype \"{}\"\r\n", json_path.to_string_lossy());
128 std::fs::write(&path, script).unwrap();
129 path
130 }
131 }
132
133 #[test]
134 fn classifier_parses_cli_envelope_and_returns_classified_output() {
135 let dir = tempfile::TempDir::new().unwrap();
136
137 let inner = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
140 let envelope = serde_json::json!({
141 "type": "result",
142 "subtype": "success",
143 "is_error": false,
144 "result": inner,
145 });
146 let fake = fake_claude(dir.path(), &envelope.to_string());
147
148 let c = ClaudeCliClassifier {
149 command: fake.to_string_lossy().to_string(),
150 model: "haiku".into(),
151 };
152 let out = c
153 .classify(&ClassifyInput {
154 text: "We adopted Rust.".into(),
155 author_hint: "assistant".into(),
156 recent_tasks: vec![],
157 })
158 .unwrap();
159
160 assert_eq!(out.event_type, EventType::Decision);
161 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
162 assert!((out.confidence - 0.93).abs() < 1e-6);
163 }
164
165 #[test]
166 fn classifier_surfaces_not_logged_in_with_friendly_hint() {
167 let dir = tempfile::TempDir::new().unwrap();
168 let envelope = serde_json::json!({
169 "type": "result",
170 "subtype": "success",
171 "is_error": true,
172 "result": "Not logged in ยท Please run /login",
173 });
174 let fake = fake_claude(dir.path(), &envelope.to_string());
175
176 let c = ClaudeCliClassifier {
177 command: fake.to_string_lossy().to_string(),
178 model: "haiku".into(),
179 };
180 let err = c
181 .classify(&ClassifyInput {
182 text: "x".into(),
183 author_hint: "user".into(),
184 recent_tasks: vec![],
185 })
186 .unwrap_err()
187 .to_string();
188 assert!(err.contains("Not logged in"));
189 assert!(err.contains("claude /login"));
190 }
191}