Skip to main content

tj_core/classifier/
cli.rs

1//! Subscription-based classifier: shells out to `claude -p` (Claude Code CLI)
2//! and uses the user's existing Pro/Max subscription instead of an API key.
3//!
4//! Rationale: most Claude Code users have a Pro/Max subscription but no
5//! separate `ANTHROPIC_API_KEY` from console.anthropic.com. The CLI's `--print`
6//! mode runs inference through the same auth as the interactive session, so
7//! we can reuse it for classification without a second paid product.
8
9use super::*;
10use anyhow::{anyhow, Context};
11use serde::Deserialize;
12
13/// Backend that invokes `claude -p` via subprocess.
14///
15/// Configuration:
16/// - `command`: program name (default `"claude"`); override for tests/dev.
17/// - `model`: model alias passed via `--model` (default `"haiku"`; cheaper than the user's session model).
18pub 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` in `--output-format json` is the model's text output.
35    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", // skip hooks/skills/CLAUDE.md to avoid recursion + speed up
51                &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        // The model's reply is in `result`; it MUST be JSON matching ClassifyOutput.
77        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// Tests use a `#!/bin/bash` shim to fake the `claude` CLI; gating to Unix
91// so Windows clippy/build doesn't see the imports/helper as unused.
92#[cfg(all(test, unix))]
93mod tests {
94    use super::*;
95    use crate::event::EventType;
96    use std::os::unix::fs::PermissionsExt;
97
98    /// Build a fake `claude` script that prints a canned `--output-format json` envelope.
99    /// Returns the path so we can point ClaudeCliClassifier at it.
100    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        // Fake CLI that pretends to be claude -p: returns the wrapper JSON
115        // with the inner classifier JSON as `result`.
116        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}