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