Skip to main content

rtk_mcp_cc/
lib.rs

1//! Shared logic for the `rtk-mcp-cc` plugin.
2//!
3//! Everything here is pure and unit-tested: settings resolution, the argument
4//! vectors handed to `rtk`, and the hook's pass-through decision. The binaries
5//! in `src/bin/` only move bytes and spawn processes.
6//!
7//! The guiding constraint is that **this plugin owns no rewriting logic**.
8//! `rtk` already speaks Claude Code's `PreToolUse` schema natively via
9//! `rtk hook claude` — it reads the event on stdin and writes
10//! `hookSpecificOutput.updatedInput` on stdout. Re-implementing that here would
11//! duplicate knowledge rtk owns and drift the moment rtk changes. So the hook
12//! delegates, and this crate's job is only to delegate *safely*.
13
14use std::path::Path;
15
16// ── Settings ───────────────────────────────────────────────────────────
17
18/// Resolved plugin settings.
19///
20/// Precedence, lowest to highest: built-in defaults, then
21/// `.claude/rtk-mcp-cc.local.md` frontmatter, then environment variables.
22/// Environment wins so a user can disable or redirect the hook for a single
23/// session without editing a file.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Config {
26    /// When false, the hook passes every command through untouched.
27    pub enabled: bool,
28    /// The `rtk` executable to invoke. Looked up on `PATH` when it has no
29    /// path separator.
30    pub rtk_bin: String,
31    /// Pass `--ultra-compact` to rtk (Level 2 optimizations).
32    pub ultra_compact: bool,
33    /// Pass `--skip-env` to rtk (sets `SKIP_ENV_VALIDATION=1` for children).
34    pub skip_env: bool,
35}
36
37impl Default for Config {
38    fn default() -> Self {
39        Self {
40            enabled: true,
41            rtk_bin: "rtk".to_string(),
42            ultra_compact: false,
43            skip_env: false,
44        }
45    }
46}
47
48impl Config {
49    /// Parse settings from the YAML frontmatter of a `.local.md` file.
50    ///
51    /// Unknown keys and malformed values are ignored: a broken settings file
52    /// degrades to the defaults rather than breaking the user's session. This
53    /// matters more here than in most plugins, because this hook sits in front
54    /// of *every* shell command.
55    pub fn from_markdown(source: &str) -> Self {
56        let mut config = Config::default();
57        let Some(frontmatter) = extract_frontmatter(source) else {
58            return config;
59        };
60
61        for line in frontmatter.lines() {
62            let line = line.trim();
63            if line.is_empty() || line.starts_with('#') {
64                continue;
65            }
66            let Some((key, value)) = line.split_once(':') else {
67                continue;
68            };
69            let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
70            match key.trim() {
71                "enabled" => {
72                    if let Some(b) = parse_bool(value) {
73                        config.enabled = b;
74                    }
75                }
76                "rtk_bin" => {
77                    if !value.is_empty() {
78                        config.rtk_bin = value.to_string();
79                    }
80                }
81                "ultra_compact" => {
82                    if let Some(b) = parse_bool(value) {
83                        config.ultra_compact = b;
84                    }
85                }
86                "skip_env" => {
87                    if let Some(b) = parse_bool(value) {
88                        config.skip_env = b;
89                    }
90                }
91                _ => {}
92            }
93        }
94        config
95    }
96
97    /// Load `<project_dir>/.claude/rtk-mcp-cc.local.md`, then apply the
98    /// environment overrides.
99    pub fn load(project_dir: &Path) -> Self {
100        let path = project_dir.join(".claude").join("rtk-mcp-cc.local.md");
101        let mut config = match std::fs::read_to_string(path) {
102            Ok(text) => Config::from_markdown(&text),
103            Err(_) => Config::default(),
104        };
105        config.apply_env(|key| std::env::var(key).ok());
106        config
107    }
108
109    /// Apply environment overrides through an injectable lookup.
110    ///
111    /// `RTK_BIN` is spelled without a plugin prefix on purpose: it is the same
112    /// variable the sibling `rtk-mcp-qwen` extension honours, so a user who
113    /// relocates rtk sets one variable for both.
114    pub fn apply_env<F>(&mut self, get: F)
115    where
116        F: Fn(&str) -> Option<String>,
117    {
118        if let Some(v) = get("RTK_BIN") {
119            if !v.trim().is_empty() {
120                self.rtk_bin = v.trim().to_string();
121            }
122        }
123        if let Some(v) = get("RTK_CC_DISABLE") {
124            if parse_bool(&v).unwrap_or(false) {
125                self.enabled = false;
126            }
127        }
128        if let Some(v) = get("RTK_CC_ULTRA_COMPACT") {
129            if let Some(b) = parse_bool(&v) {
130                self.ultra_compact = b;
131            }
132        }
133        if let Some(v) = get("RTK_CC_SKIP_ENV") {
134            if let Some(b) = parse_bool(&v) {
135                self.skip_env = b;
136            }
137        }
138    }
139
140    /// The global rtk flags implied by this config, in a stable order.
141    fn global_flags(&self) -> Vec<String> {
142        let mut flags = Vec::new();
143        if self.ultra_compact {
144            flags.push("--ultra-compact".to_string());
145        }
146        if self.skip_env {
147            flags.push("--skip-env".to_string());
148        }
149        flags
150    }
151}
152
153/// Accept the spellings people actually write in config files and shells.
154fn parse_bool(value: &str) -> Option<bool> {
155    match value.trim().to_ascii_lowercase().as_str() {
156        "1" | "true" | "yes" | "on" => Some(true),
157        "0" | "false" | "no" | "off" => Some(false),
158        _ => None,
159    }
160}
161
162/// Return the text between the leading `---` fences, if present.
163fn extract_frontmatter(source: &str) -> Option<&str> {
164    let rest = source.strip_prefix("---")?.trim_start_matches(['\r', '\n']);
165    let end = rest.find("\n---")?;
166    Some(&rest[..end])
167}
168
169// ── Hook ───────────────────────────────────────────────────────────────
170
171/// The argument vector for the delegated hook call: `rtk hook claude [flags]`.
172pub fn hook_args(config: &Config) -> Vec<String> {
173    let mut args = vec!["hook".to_string(), "claude".to_string()];
174    args.extend(config.global_flags());
175    args
176}
177
178/// Decide what this hook writes to stdout.
179///
180/// `run` receives the rtk argument vector and the raw stdin payload, and
181/// returns rtk's stdout on a clean exit — or `None` if rtk could not be
182/// spawned, exited non-zero, or produced non-UTF-8.
183///
184/// Every failure path returns an empty string, which is Claude Code's "no
185/// opinion" response: the command runs exactly as the model wrote it. That is
186/// the whole safety story of this plugin — a broken or absent rtk must never
187/// cost the user a shell command.
188///
189/// Note that empty output is also rtk's *own* signal for "nothing to rewrite",
190/// so the failure path and the no-op path coincide by construction rather than
191/// by coincidence.
192pub fn delegate<F>(stdin_payload: &str, config: &Config, run: F) -> String
193where
194    F: FnOnce(&[String], &str) -> Option<String>,
195{
196    if !config.enabled {
197        return String::new();
198    }
199    if stdin_payload.trim().is_empty() {
200        return String::new();
201    }
202
203    let Some(stdout) = run(&hook_args(config), stdin_payload) else {
204        return String::new();
205    };
206    if stdout.trim().is_empty() {
207        return String::new();
208    }
209
210    // Forward only what parses as JSON. rtk writes its advisory notices to
211    // stderr, but a future version — or a shim on someone's PATH named `rtk` —
212    // could put prose on stdout, and Claude Code treats unparsable hook output
213    // as an error. Validating here keeps a stray line from turning into a
214    // failed tool call.
215    if serde_json::from_str::<serde_json::Value>(stdout.trim()).is_err() {
216        tracing::warn!("rtk produced non-JSON stdout; passing the command through unchanged");
217        return String::new();
218    }
219    stdout
220}
221
222// ── Analytics argument vectors (MCP tools) ─────────────────────────────
223
224/// Options for the `rtk_gain` tool.
225#[derive(Debug, Default, Clone)]
226pub struct GainOptions {
227    pub history: bool,
228    pub project_only: bool,
229    pub format: Option<String>,
230}
231
232/// Options for the `rtk_discover` tool.
233#[derive(Debug, Default, Clone)]
234pub struct DiscoverOptions {
235    pub project: Option<String>,
236    pub limit: Option<u32>,
237    pub since: Option<u32>,
238    pub all_projects: bool,
239    pub format: Option<String>,
240}
241
242/// Build the argv for `rtk gain`.
243///
244/// `--reset` is deliberately unreachable from here. It zeroes the user's saved
245/// statistics, and an MCP tool that can silently destroy data is not worth the
246/// convenience.
247pub fn gain_args(opts: &GainOptions, config: &Config) -> Result<Vec<String>, String> {
248    let mut args = vec!["gain".to_string()];
249    if opts.history {
250        args.push("--history".to_string());
251    }
252    if opts.project_only {
253        args.push("--project".to_string());
254    }
255    if let Some(fmt) = &opts.format {
256        let fmt = validate_format(fmt, &["text", "json", "csv"])?;
257        args.push("--format".to_string());
258        args.push(fmt);
259    }
260    args.extend(config.global_flags());
261    Ok(args)
262}
263
264/// Build the argv for `rtk discover`.
265pub fn discover_args(opts: &DiscoverOptions, config: &Config) -> Result<Vec<String>, String> {
266    let mut args = vec!["discover".to_string()];
267    if let Some(project) = &opts.project {
268        if !project.trim().is_empty() {
269            args.push("--project".to_string());
270            args.push(project.trim().to_string());
271        }
272    }
273    if let Some(limit) = opts.limit {
274        args.push("--limit".to_string());
275        args.push(limit.to_string());
276    }
277    if let Some(since) = opts.since {
278        args.push("--since".to_string());
279        args.push(since.to_string());
280    }
281    if opts.all_projects {
282        args.push("--all".to_string());
283    }
284    if let Some(fmt) = &opts.format {
285        let fmt = validate_format(fmt, &["text", "json"])?;
286        args.push("--format".to_string());
287        args.push(fmt);
288    }
289    args.extend(config.global_flags());
290    Ok(args)
291}
292
293/// Build the argv for `rtk hook check <command>` — a dry run that reports how a
294/// command *would* be rewritten, without executing anything.
295pub fn check_args(command: &str, config: &Config) -> Result<Vec<String>, String> {
296    if command.trim().is_empty() {
297        return Err("command must not be empty".to_string());
298    }
299    let mut args = vec!["hook".to_string(), "check".to_string()];
300    args.extend(config.global_flags());
301    // Passed as a single argument. `rtk hook check` accepts both a quoted
302    // command and a bare argv tail and treats them identically, so the single
303    // argument avoids any question of who re-splits the string.
304    args.push("--".to_string());
305    args.push(command.trim().to_string());
306    Ok(args)
307}
308
309/// Build the argv for `rtk proxy -- <argv>`.
310///
311/// This one *executes*. It takes an already-split argument vector rather than a
312/// command string precisely so nothing here ever reaches a shell: the words the
313/// caller supplies are the words that are executed, and no quoting, globbing,
314/// pipeline or `;` is interpreted along the way.
315pub fn proxy_args(argv: &[String], config: &Config) -> Result<Vec<String>, String> {
316    let cleaned: Vec<String> = argv
317        .iter()
318        .filter(|a| !a.is_empty())
319        .map(|a| a.to_string())
320        .collect();
321    if cleaned.is_empty() {
322        return Err("argv must contain at least the program to run".to_string());
323    }
324    let mut args = vec!["proxy".to_string()];
325    args.extend(config.global_flags());
326    args.push("--".to_string());
327    args.extend(cleaned);
328    Ok(args)
329}
330
331fn validate_format(value: &str, allowed: &[&str]) -> Result<String, String> {
332    let lowered = value.trim().to_ascii_lowercase();
333    if allowed.contains(&lowered.as_str()) {
334        Ok(lowered)
335    } else {
336        Err(format!(
337            "unsupported format {value:?}; expected one of {}",
338            allowed.join(", ")
339        ))
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    // ── settings ───────────────────────────────────────────────────
348
349    #[test]
350    fn defaults_are_enabled_and_use_path_rtk() {
351        let config = Config::default();
352        assert!(config.enabled);
353        assert_eq!(config.rtk_bin, "rtk");
354        assert!(!config.ultra_compact);
355        assert!(!config.skip_env);
356    }
357
358    #[test]
359    fn frontmatter_overrides_defaults() {
360        let config = Config::from_markdown(
361            "---\nenabled: false\nrtk_bin: /opt/rtk/bin/rtk\nultra_compact: true\n---\n\nprose\n",
362        );
363        assert!(!config.enabled);
364        assert_eq!(config.rtk_bin, "/opt/rtk/bin/rtk");
365        assert!(config.ultra_compact);
366        assert!(!config.skip_env);
367    }
368
369    #[test]
370    fn malformed_settings_degrade_to_defaults() {
371        // No frontmatter at all, an unknown key, and an unparsable value.
372        assert_eq!(
373            Config::from_markdown("no frontmatter here"),
374            Config::default()
375        );
376        assert_eq!(
377            Config::from_markdown("---\nnonsense: yes\nenabled: banana\n---\n"),
378            Config::default()
379        );
380    }
381
382    #[test]
383    fn env_overrides_file() {
384        let mut config = Config::from_markdown("---\nenabled: true\nrtk_bin: from-file\n---\n");
385        config.apply_env(|key| match key {
386            "RTK_BIN" => Some("from-env".to_string()),
387            "RTK_CC_DISABLE" => Some("1".to_string()),
388            _ => None,
389        });
390        assert_eq!(config.rtk_bin, "from-env");
391        assert!(!config.enabled);
392    }
393
394    #[test]
395    fn disable_flag_only_disables_when_truthy() {
396        let mut config = Config::default();
397        config.apply_env(|key| (key == "RTK_CC_DISABLE").then(|| "0".to_string()));
398        assert!(config.enabled, "RTK_CC_DISABLE=0 must not disable the hook");
399    }
400
401    #[test]
402    fn blank_rtk_bin_does_not_erase_the_default() {
403        let mut config = Config::default();
404        config.apply_env(|key| (key == "RTK_BIN").then(|| "   ".to_string()));
405        assert_eq!(config.rtk_bin, "rtk");
406    }
407
408    // ── hook argv ──────────────────────────────────────────────────
409
410    #[test]
411    fn hook_args_are_bare_by_default() {
412        assert_eq!(hook_args(&Config::default()), vec!["hook", "claude"]);
413    }
414
415    #[test]
416    fn hook_args_carry_flags_in_stable_order() {
417        let config = Config {
418            ultra_compact: true,
419            skip_env: true,
420            ..Config::default()
421        };
422        assert_eq!(
423            hook_args(&config),
424            vec!["hook", "claude", "--ultra-compact", "--skip-env"]
425        );
426    }
427
428    // ── hook decision ──────────────────────────────────────────────
429
430    const EVENT: &str = r#"{"tool_name":"Bash","tool_input":{"command":"cat README.md"}}"#;
431    const REWRITE: &str = r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"command":"rtk read README.md"}}}"#;
432
433    #[test]
434    fn forwards_rtk_json_verbatim() {
435        let out = delegate(EVENT, &Config::default(), |_, _| Some(REWRITE.to_string()));
436        assert_eq!(out, REWRITE);
437    }
438
439    #[test]
440    fn hands_rtk_the_original_payload() {
441        let mut seen = String::new();
442        delegate(EVENT, &Config::default(), |_, payload| {
443            seen = payload.to_string();
444            None
445        });
446        assert_eq!(seen, EVENT, "rtk must receive the event byte-for-byte");
447    }
448
449    #[test]
450    fn disabled_config_never_spawns_rtk() {
451        let config = Config {
452            enabled: false,
453            ..Config::default()
454        };
455        let out = delegate(EVENT, &config, |_, _| {
456            panic!("rtk must not be spawned when the plugin is disabled")
457        });
458        assert!(out.is_empty());
459    }
460
461    #[test]
462    fn empty_stdin_is_a_no_op() {
463        let out = delegate("   \n", &Config::default(), |_, _| {
464            panic!("rtk must not be spawned for an empty payload")
465        });
466        assert!(out.is_empty());
467    }
468
469    #[test]
470    fn missing_rtk_fails_open() {
471        // `None` is how the binary reports "could not spawn" / "exited non-zero".
472        assert!(delegate(EVENT, &Config::default(), |_, _| None).is_empty());
473    }
474
475    #[test]
476    fn empty_rtk_output_is_a_no_op() {
477        // rtk's own "nothing to rewrite" signal, including the idempotent case
478        // where the command was already rewritten by another rtk hook.
479        assert!(delegate(EVENT, &Config::default(), |_, _| Some(String::new())).is_empty());
480    }
481
482    #[test]
483    fn non_json_rtk_output_is_suppressed() {
484        let out = delegate(EVENT, &Config::default(), |_, _| {
485            Some("rtk: something went sideways\n".to_string())
486        });
487        assert!(out.is_empty(), "prose on stdout must not reach Claude Code");
488    }
489
490    // ── analytics argv ─────────────────────────────────────────────
491
492    #[test]
493    fn gain_args_default_to_a_bare_summary() {
494        let args = gain_args(&GainOptions::default(), &Config::default()).unwrap();
495        assert_eq!(args, vec!["gain"]);
496    }
497
498    #[test]
499    fn gain_args_accept_history_project_and_format() {
500        let opts = GainOptions {
501            history: true,
502            project_only: true,
503            format: Some("JSON".to_string()),
504        };
505        let args = gain_args(&opts, &Config::default()).unwrap();
506        assert_eq!(
507            args,
508            vec!["gain", "--history", "--project", "--format", "json"]
509        );
510    }
511
512    #[test]
513    fn gain_args_reject_unknown_formats() {
514        let opts = GainOptions {
515            format: Some("yaml".to_string()),
516            ..GainOptions::default()
517        };
518        assert!(gain_args(&opts, &Config::default()).is_err());
519    }
520
521    #[test]
522    fn gain_args_can_never_reach_reset() {
523        // A guard against someone later adding a passthrough option: whatever
524        // the caller asks for, `--reset` must not appear.
525        let opts = GainOptions {
526            history: true,
527            project_only: true,
528            format: Some("csv".to_string()),
529        };
530        let args = gain_args(&opts, &Config::default()).unwrap();
531        assert!(!args.iter().any(|a| a == "--reset"));
532    }
533
534    #[test]
535    fn discover_args_carry_every_option() {
536        let opts = DiscoverOptions {
537            project: Some("  aiplugins  ".to_string()),
538            limit: Some(5),
539            since: Some(7),
540            all_projects: true,
541            format: Some("json".to_string()),
542        };
543        let args = discover_args(&opts, &Config::default()).unwrap();
544        assert_eq!(
545            args,
546            vec![
547                "discover",
548                "--project",
549                "aiplugins",
550                "--limit",
551                "5",
552                "--since",
553                "7",
554                "--all",
555                "--format",
556                "json"
557            ]
558        );
559    }
560
561    #[test]
562    fn discover_rejects_csv_which_it_does_not_support() {
563        // `rtk gain` takes csv; `rtk discover` does not. The difference is real
564        // and this test is what keeps the two from being copy-pasted together.
565        let opts = DiscoverOptions {
566            format: Some("csv".to_string()),
567            ..DiscoverOptions::default()
568        };
569        assert!(discover_args(&opts, &Config::default()).is_err());
570    }
571
572    #[test]
573    fn check_args_pass_the_command_as_one_argument() {
574        let args = check_args("grep -rn foo src/", &Config::default()).unwrap();
575        assert_eq!(args, vec!["hook", "check", "--", "grep -rn foo src/"]);
576    }
577
578    #[test]
579    fn check_args_reject_an_empty_command() {
580        assert!(check_args("   ", &Config::default()).is_err());
581    }
582
583    #[test]
584    fn proxy_args_preserve_argv_without_splitting() {
585        let argv = vec![
586            "git".to_string(),
587            "commit".to_string(),
588            "-m".to_string(),
589            "a message; with punctuation".to_string(),
590        ];
591        let args = proxy_args(&argv, &Config::default()).unwrap();
592        assert_eq!(
593            args,
594            vec![
595                "proxy",
596                "--",
597                "git",
598                "commit",
599                "-m",
600                "a message; with punctuation"
601            ],
602            "argv elements must survive intact — no shell is involved"
603        );
604    }
605
606    #[test]
607    fn proxy_args_reject_an_empty_argv() {
608        assert!(proxy_args(&[], &Config::default()).is_err());
609        assert!(proxy_args(&["".to_string()], &Config::default()).is_err());
610    }
611
612    #[test]
613    fn global_flags_reach_the_analytics_commands_too() {
614        let config = Config {
615            ultra_compact: true,
616            ..Config::default()
617        };
618        assert!(gain_args(&GainOptions::default(), &config)
619            .unwrap()
620            .contains(&"--ultra-compact".to_string()));
621        assert!(discover_args(&DiscoverOptions::default(), &config)
622            .unwrap()
623            .contains(&"--ultra-compact".to_string()));
624    }
625}