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`. Overridable via the
18///   `TJ_CLASSIFIER_MODEL` env var; falls back to `DEFAULT_MODEL` (haiku โ€”
19///   cheaper than the user's session model).
20pub struct ClaudeCliClassifier {
21    pub command: String,
22    pub model: String,
23}
24
25/// Default model when `TJ_CLASSIFIER_MODEL` is not set.
26pub 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` in `--output-format json` is the model's text output.
40    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", // skip hooks/skills/CLAUDE.md to avoid recursion + speed up
56                &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        // The model's reply is in `result`; it MUST be JSON matching ClassifyOutput.
82        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// Tests use a tiny shell/.cmd shim to fake the `claude` CLI. Cross-platform
96// strategy: write the JSON envelope to a file, then a one-liner script that
97// `cat`s (Unix) or `type`s (Windows) it back. The `type` form sidesteps cmd
98// .exe escaping pain for the JSON payload's quotes.
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::event::EventType;
103
104    /// Build a fake `claude` shim that prints a canned `--output-format json`
105    /// envelope. Returns the path so we can point ClaudeCliClassifier at it.
106    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            // `type "PATH"` outputs file content verbatim; double quotes
125            // handle spaces, and JSON's special chars stay literal because
126            // type does not interpret content as commands.
127            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        // Fake CLI that pretends to be claude -p: returns the wrapper JSON
138        // with the inner classifier JSON as `result`.
139        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}