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        // `--bare` would skip hooks/CLAUDE.md/skills (cheap + non-recursive),
61        // but it ALSO bypasses the subscription credentials file and only
62        // works with ANTHROPIC_API_KEY — see claude-memory-0kk. Most users
63        // pay through Claude Pro/Max, so we drop --bare and gate recursion
64        // explicitly with the TJ_IN_CLASSIFIER env var: ingest-hook checks
65        // it on entry and short-circuits, breaking the loop without
66        // touching auth.
67        let output = std::process::Command::new(program)
68            .args(&base_args)
69            .args([
70                "-p",
71                "--model",
72                &self.model,
73                "--output-format",
74                "json",
75                &prompt,
76            ])
77            .env("TJ_IN_CLASSIFIER", "1")
78            .output()
79            .with_context(|| format!("spawn `{}` for classification", self.command))?;
80
81        if !output.status.success() {
82            let stderr = String::from_utf8_lossy(&output.stderr);
83            return Err(anyhow!(
84                "claude -p exited with {} — stderr: {}",
85                output.status,
86                stderr.trim()
87            ));
88        }
89
90        let stdout = String::from_utf8(output.stdout).context("claude -p stdout not UTF-8")?;
91        // Wrappers like `aimux run` prepend status lines to stdout
92        // (e.g. "Auto-sync: 0 created, 0 repaired, 1 conflicts\n").
93        // claude's JSON envelope is always a single object, so we
94        // anchor the parse at the first `{` and ignore any prelude.
95        let envelope = stdout
96            .find('{')
97            .map(|i| &stdout[i..])
98            .unwrap_or(stdout.as_str())
99            .trim();
100        let cli_result: CliResult = serde_json::from_str(envelope)
101            .with_context(|| format!("parse claude -p JSON envelope; got: {envelope}"))?;
102
103        if cli_result.is_error {
104            return Err(anyhow!(
105                "claude -p reported error: {}. If 'Not logged in' — run `claude /login` first.",
106                cli_result.result
107            ));
108        }
109
110        // The model's reply is in `result`; it MUST be JSON matching ClassifyOutput.
111        let inner_text = cli_result
112            .result
113            .trim()
114            .trim_start_matches("```json")
115            .trim_start_matches("```")
116            .trim_end_matches("```")
117            .trim();
118        let out: ClassifyOutput = serde_json::from_str(inner_text)
119            .with_context(|| format!("classifier inner JSON parse failed; got: {inner_text}"))?;
120        Ok(out)
121    }
122}
123
124// Tests use a tiny shell/.cmd shim to fake the `claude` CLI. Cross-platform
125// strategy: write the JSON envelope to a file, then a one-liner script that
126// `cat`s (Unix) or `type`s (Windows) it back. The `type` form sidesteps cmd
127// .exe escaping pain for the JSON payload's quotes.
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::event::EventType;
132
133    /// Build a fake `claude` shim that prints a canned `--output-format json`
134    /// envelope. Returns the path so we can point ClaudeCliClassifier at it.
135    fn fake_claude(dir: &std::path::Path, envelope: &str) -> std::path::PathBuf {
136        let json_path = dir.join("fake-claude-output.json");
137        std::fs::write(&json_path, envelope).unwrap();
138
139        #[cfg(unix)]
140        {
141            use std::os::unix::fs::PermissionsExt;
142            let path = dir.join("fake-claude.sh");
143            let script = format!("#!/bin/sh\ncat \"{}\"\n", json_path.to_string_lossy());
144            std::fs::write(&path, script).unwrap();
145            let mut perms = std::fs::metadata(&path).unwrap().permissions();
146            perms.set_mode(0o755);
147            std::fs::set_permissions(&path, perms).unwrap();
148            path
149        }
150        #[cfg(windows)]
151        {
152            let path = dir.join("fake-claude.cmd");
153            // `type "PATH"` outputs file content verbatim; double quotes
154            // handle spaces, and JSON's special chars stay literal because
155            // type does not interpret content as commands.
156            let script = format!("@echo off\r\ntype \"{}\"\r\n", json_path.to_string_lossy());
157            std::fs::write(&path, script).unwrap();
158            path
159        }
160    }
161
162    // The fake-claude shim is a `.cmd` script on Windows. Rust 1.77.2+
163    // refuses to pass `--bare`-style args to .cmd because of the
164    // BatBadBut CVE (CVE-2024-24576) — `Command::new("foo.cmd").args(...)`
165    // returns "batch file arguments are invalid" for any arg with `"` etc.
166    // Real `claude` is a native binary, so production is unaffected; this
167    // is purely a test-fake limitation. Skip the affected tests on Windows.
168    /// Build a fake_claude that prepends a wrapper-style status line
169    /// before the JSON envelope (mimics `aimux run`'s "Auto-sync: …" output).
170    #[cfg(unix)]
171    fn fake_claude_with_prelude(
172        dir: &std::path::Path,
173        prelude: &str,
174        envelope: &str,
175    ) -> std::path::PathBuf {
176        use std::os::unix::fs::PermissionsExt;
177        let json_path = dir.join("fake-claude-output.json");
178        std::fs::write(&json_path, envelope).unwrap();
179        let path = dir.join("fake-claude-prelude.sh");
180        let script = format!(
181            "#!/bin/sh\necho '{}'\ncat \"{}\"\n",
182            prelude,
183            json_path.to_string_lossy()
184        );
185        std::fs::write(&path, script).unwrap();
186        let mut perms = std::fs::metadata(&path).unwrap().permissions();
187        perms.set_mode(0o755);
188        std::fs::set_permissions(&path, perms).unwrap();
189        path
190    }
191
192    #[test]
193    #[cfg(unix)]
194    fn classifier_strips_wrapper_prelude_before_envelope() {
195        // Reproduces aimux's "Auto-sync: 0 created, 0 repaired, 1 conflicts"
196        // line that appears before claude's JSON envelope. The parser
197        // must anchor at the first `{` so the prelude is ignored.
198        let dir = tempfile::TempDir::new().unwrap();
199        let inner = r#"{"event_type":"finding","task_id_guess":"tj-x","confidence":0.9,"evidence_strength":null,"suggested_text":"ok"}"#;
200        let envelope = serde_json::json!({
201            "type": "result",
202            "subtype": "success",
203            "is_error": false,
204            "result": inner,
205        });
206        let fake = fake_claude_with_prelude(
207            dir.path(),
208            "Auto-sync: 0 created, 0 repaired, 1 conflicts",
209            &envelope.to_string(),
210        );
211
212        let c = ClaudeCliClassifier {
213            command: fake.to_string_lossy().to_string(),
214            model: "haiku".into(),
215        };
216        let out = c
217            .classify(&ClassifyInput {
218                text: "x".into(),
219                author_hint: "user".into(),
220                recent_tasks: vec![],
221            })
222            .unwrap();
223        assert_eq!(out.event_type, EventType::Finding);
224        assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
225    }
226
227    #[test]
228    #[cfg_attr(
229        windows,
230        ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
231    )]
232    fn classifier_parses_cli_envelope_and_returns_classified_output() {
233        let dir = tempfile::TempDir::new().unwrap();
234
235        // Fake CLI that pretends to be claude -p: returns the wrapper JSON
236        // with the inner classifier JSON as `result`.
237        let inner = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
238        let envelope = serde_json::json!({
239            "type": "result",
240            "subtype": "success",
241            "is_error": false,
242            "result": inner,
243        });
244        let fake = fake_claude(dir.path(), &envelope.to_string());
245
246        let c = ClaudeCliClassifier {
247            command: fake.to_string_lossy().to_string(),
248            model: "haiku".into(),
249        };
250        let out = c
251            .classify(&ClassifyInput {
252                text: "We adopted Rust.".into(),
253                author_hint: "assistant".into(),
254                recent_tasks: vec![],
255            })
256            .unwrap();
257
258        assert_eq!(out.event_type, EventType::Decision);
259        assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
260        assert!((out.confidence - 0.93).abs() < 1e-6);
261    }
262
263    #[test]
264    #[cfg_attr(
265        windows,
266        ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
267    )]
268    fn classifier_surfaces_not_logged_in_with_friendly_hint() {
269        let dir = tempfile::TempDir::new().unwrap();
270        let envelope = serde_json::json!({
271            "type": "result",
272            "subtype": "success",
273            "is_error": true,
274            // ASCII-only payload: Windows `type` (used by fake-claude.cmd)
275            // emits via the console code page, which mangles non-ASCII bytes
276            // (U+00B7 etc.) before our UTF-8 decode in `classify`. Real
277            // `claude` always emits UTF-8 directly, so this is a fake-only
278            // concern, not a production behavior change.
279            "result": "Not logged in - Please run /login",
280        });
281        let fake = fake_claude(dir.path(), &envelope.to_string());
282
283        let c = ClaudeCliClassifier {
284            command: fake.to_string_lossy().to_string(),
285            model: "haiku".into(),
286        };
287        let err = c
288            .classify(&ClassifyInput {
289                text: "x".into(),
290                author_hint: "user".into(),
291                recent_tasks: vec![],
292            })
293            .unwrap_err()
294            .to_string();
295        assert!(err.contains("Not logged in"));
296        assert!(err.contains("claude /login"));
297    }
298
299    #[test]
300    #[cfg_attr(
301        windows,
302        ignore = "fake-claude.cmd cannot accept argv with quotes (BatBadBut)"
303    )]
304    fn classifier_command_with_spaces_runs_wrapper_then_target() {
305        // Simulates `aimux run dt claude` style wrappers: a launcher
306        // script that ignores its first argv, then forwards everything
307        // else to the real fake-claude. We verify TJ_CLASSIFIER_CLI
308        // splitting works end-to-end.
309        let dir = tempfile::TempDir::new().unwrap();
310
311        let inner = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
312        let envelope = serde_json::json!({
313            "type": "result",
314            "subtype": "success",
315            "is_error": false,
316            "result": inner,
317        });
318        let real_fake = fake_claude(dir.path(), &envelope.to_string());
319
320        // Wrapper script that takes a "profile" arg and delegates.
321        #[cfg(unix)]
322        let wrapper = {
323            use std::os::unix::fs::PermissionsExt;
324            let path = dir.path().join("fake-aimux.sh");
325            // shellcheck-clean: we intentionally drop $1 (profile name)
326            // and forward $2..$N to the real fake.
327            let script = format!(
328                "#!/bin/sh\nshift\nshift\nshift\nexec \"{}\" \"$@\"\n",
329                real_fake.to_string_lossy()
330            );
331            std::fs::write(&path, script).unwrap();
332            let mut perms = std::fs::metadata(&path).unwrap().permissions();
333            perms.set_mode(0o755);
334            std::fs::set_permissions(&path, perms).unwrap();
335            path
336        };
337        #[cfg(windows)]
338        let wrapper = {
339            let path = dir.path().join("fake-aimux.cmd");
340            // Drop %1 %2 %3 (run dt claude) and pass the rest.
341            let script = format!(
342                "@echo off\r\ncall \"{}\" %4 %5 %6 %7 %8 %9\r\n",
343                real_fake.to_string_lossy()
344            );
345            std::fs::write(&path, script).unwrap();
346            path
347        };
348
349        let c = ClaudeCliClassifier {
350            command: format!("{} run dt claude", wrapper.to_string_lossy()),
351            model: "haiku".into(),
352        };
353        let out = c
354            .classify(&ClassifyInput {
355                text: "x".into(),
356                author_hint: "user".into(),
357                recent_tasks: vec![],
358            })
359            .unwrap();
360        assert_eq!(out.event_type, EventType::Finding);
361    }
362}