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