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`: full command line that produces `claude` invocation;
17///   default `"claude"`. May contain spaces to wrap the binary in a
18///   workspace orchestrator like `aimux run dt claude` or a Nix
19///   shell. Override via `TJ_CLASSIFIER_CLI` env var.
20/// - `model`: model alias passed via `--model`. Overridable via
21///   `TJ_CLASSIFIER_MODEL`; falls back to `DEFAULT_MODEL` (haiku —
22///   cheaper than the user's session model).
23pub struct ClaudeCliClassifier {
24    pub command: String,
25    pub model: String,
26}
27
28/// Default model when `TJ_CLASSIFIER_MODEL` is not set.
29pub 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` in `--output-format json` is the model's text output.
43    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        // Split command on whitespace so users can wrap the binary
52        // in a workspace orchestrator: `aimux run dt claude`,
53        // `nix run nixpkgs#claude --`, etc.
54        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", // skip hooks/skills/CLAUDE.md to avoid recursion + speed up
69                &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        // The model's reply is in `result`; it MUST be JSON matching ClassifyOutput.
95        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// Tests use a tiny shell/.cmd shim to fake the `claude` CLI. Cross-platform
109// strategy: write the JSON envelope to a file, then a one-liner script that
110// `cat`s (Unix) or `type`s (Windows) it back. The `type` form sidesteps cmd
111// .exe escaping pain for the JSON payload's quotes.
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::event::EventType;
116
117    /// Build a fake `claude` shim that prints a canned `--output-format json`
118    /// envelope. Returns the path so we can point ClaudeCliClassifier at it.
119    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            // `type "PATH"` outputs file content verbatim; double quotes
138            // handle spaces, and JSON's special chars stay literal because
139            // type does not interpret content as commands.
140            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    // The fake-claude shim is a `.cmd` script on Windows. Rust 1.77.2+
147    // refuses to pass `--bare`-style args to .cmd because of the
148    // BatBadBut CVE (CVE-2024-24576) — `Command::new("foo.cmd").args(...)`
149    // returns "batch file arguments are invalid" for any arg with `"` etc.
150    // Real `claude` is a native binary, so production is unaffected; this
151    // is purely a test-fake limitation. Skip the affected tests on Windows.
152    #[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        // Fake CLI that pretends to be claude -p: returns the wrapper JSON
161        // with the inner classifier JSON as `result`.
162        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            // ASCII-only payload: Windows `type` (used by fake-claude.cmd)
200            // emits via the console code page, which mangles non-ASCII bytes
201            // (U+00B7 etc.) before our UTF-8 decode in `classify`. Real
202            // `claude` always emits UTF-8 directly, so this is a fake-only
203            // concern, not a production behavior change.
204            "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        // Simulates `aimux run dt claude` style wrappers: a launcher
231        // script that ignores its first argv, then forwards everything
232        // else to the real fake-claude. We verify TJ_CLASSIFIER_CLI
233        // splitting works end-to-end.
234        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        // Wrapper script that takes a "profile" arg and delegates.
246        #[cfg(unix)]
247        let wrapper = {
248            use std::os::unix::fs::PermissionsExt;
249            let path = dir.path().join("fake-aimux.sh");
250            // shellcheck-clean: we intentionally drop $1 (profile name)
251            // and forward $2..$N to the real fake.
252            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            // Drop %1 %2 %3 (run dt claude) and pass the rest.
266            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}