bctx 0.1.38

bctx CLI — intercept CLI commands and compress output for LLM coding agents
//! Editing agent config files the user owns without ever losing their settings.
//!
//! A file is only rewritten when it is plain JSON with the shape bctx expects. JSONC
//! (comments, trailing commas, which Zed and VS Code accept) is never rewritten: bctx can
//! read it to see whether its entry is already there, but writing it back would drop the
//! user's comments. Anything bctx cannot safely edit becomes a "manual step": the file is
//! left as it is and stdout says exactly what to merge in by hand.

use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};

static MANUAL_STEPS: AtomicUsize = AtomicUsize::new(0);

/// Start a run with no manual steps counted.
pub(crate) fn reset_manual_steps() {
    MANUAL_STEPS.store(0, Ordering::SeqCst);
}

/// How many config files this run left for the user to edit by hand.
pub(crate) fn manual_steps() -> usize {
    MANUAL_STEPS.load(Ordering::SeqCst)
}

pub(crate) enum Style {
    Pretty,
    Compact,
}

enum Loaded {
    /// Plain JSON object (or a missing/empty file): safe to edit and rewrite.
    Strict(Value),
    /// JSONC: readable, never rewritten.
    Lenient(Value),
    /// Neither; the reason says why.
    Unusable(String),
}

fn load(path: &Path) -> Result<Loaded> {
    let raw = match std::fs::read_to_string(path) {
        Ok(raw) => raw,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Loaded::Strict(json!({}))),
        Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
            return Ok(Loaded::Unusable("is not UTF-8 text".into()))
        }
        Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
    };
    let raw = raw.strip_prefix('\u{feff}').unwrap_or(&raw);
    if raw.trim().is_empty() {
        return Ok(Loaded::Strict(json!({})));
    }
    Ok(match serde_json::from_str::<Value>(raw) {
        Ok(cfg) if cfg.is_object() => Loaded::Strict(cfg),
        Ok(_) => Loaded::Unusable("is not a JSON object".into()),
        Err(e) => match serde_json::from_str::<Value>(&strip_jsonc(raw)) {
            Ok(cfg) if cfg.is_object() => Loaded::Lenient(cfg),
            _ => Loaded::Unusable(format!("is not valid JSON ({e})")),
        },
    })
}

/// Read a config for inspection only: plain JSON, else JSONC. `None` if neither parses.
pub(crate) fn parse_lenient(raw: &str) -> Option<Value> {
    let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
    serde_json::from_str(raw)
        .ok()
        .or_else(|| serde_json::from_str(&strip_jsonc(raw)).ok())
}

/// Merge bctx's entry into the config at `path`.
///
/// `present` says whether bctx is already configured in a parsed config; `apply` edits a
/// plain-JSON config and returns false when a key has a shape bctx does not expect.
/// Returns true when the agent is configured (written now, or already there in a JSONC
/// file), false when the file was left for a manual step.
pub(crate) fn merge(
    path: &Path,
    add: &Value,
    present: impl Fn(&Value) -> bool,
    apply: impl FnOnce(&mut Value) -> bool,
    style: Style,
) -> Result<bool> {
    match load(path)? {
        Loaded::Strict(mut cfg) => {
            if !apply(&mut cfg) {
                manual(path, "has a key bctx does not expect", add, Hint::Shape);
                return Ok(false);
            }
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            let text = match style {
                Style::Pretty => serde_json::to_string_pretty(&cfg)?,
                Style::Compact => serde_json::to_string(&cfg)?,
            };
            std::fs::write(path, text)?;
            Ok(true)
        }
        Loaded::Lenient(cfg) if present(&cfg) => {
            println!(
                "  ✓ {} already lists bctx (it has comments, so bctx left it as is)",
                path.display()
            );
            Ok(true)
        }
        Loaded::Lenient(_) => {
            manual(path, "has comments or trailing commas", add, Hint::Comments);
            Ok(false)
        }
        Loaded::Unusable(why) => {
            manual(path, &why, add, Hint::Fix);
            Ok(false)
        }
    }
}

/// A config bctx edits as text (YAML) but cannot safely change: print what to add by hand
/// and count it as a manual step.
pub(crate) fn manual_text(path: &Path, why: &str, snippet: &[String]) {
    MANUAL_STEPS.fetch_add(1, Ordering::SeqCst);
    println!("  ! {} {why}; bctx left it untouched.", path.display());
    println!("    Add this to it by hand and run `bctx init` again:");
    for line in snippet {
        println!("      {line}");
    }
}

/// What the user can do instead of merging by hand.
enum Hint {
    Comments,
    Shape,
    Fix,
}

fn manual(path: &Path, why: &str, add: &Value, hint: Hint) {
    MANUAL_STEPS.fetch_add(1, Ordering::SeqCst);
    let keys: Vec<String> = add
        .as_object()
        .map(|m| m.keys().map(|k| format!("\"{k}\"")).collect())
        .unwrap_or_default();
    println!("  ! {} {why}; bctx left it untouched.", path.display());
    let instead = match hint {
        Hint::Comments => "or remove its comments and trailing commas",
        Hint::Shape => "or fix the shape of that key",
        Hint::Fix => "or make it valid JSON",
    };
    println!(
        "    Merge this into it by hand (into {} as bctx's entry), {instead}",
        keys.join(" / ")
    );
    println!("    and run `bctx init` again:");
    for line in serde_json::to_string_pretty(add)
        .unwrap_or_default()
        .lines()
    {
        println!("      {line}");
    }
}

/// Set `cfg[key]["bctx"] = entry`, creating `key` when missing. False if `key` holds
/// something other than an object.
pub(crate) fn set_bctx(cfg: &mut Value, key: &str, entry: Value) -> bool {
    let Some(root) = cfg.as_object_mut() else {
        return false;
    };
    let slot = root.entry(key).or_insert_with(|| json!({}));
    if slot.is_null() {
        *slot = json!({});
    }
    match slot.as_object_mut() {
        Some(map) => {
            map.insert("bctx".into(), entry);
            true
        }
        None => false,
    }
}

pub(crate) fn has_bctx(cfg: &Value, key: &str) -> bool {
    cfg.get(key).and_then(|v| v.get("bctx")).is_some()
}

/// The array at `cfg[key]`, created when missing or null. `None` if it holds anything else.
pub(crate) fn array_at<'a>(cfg: &'a mut Value, key: &str) -> Option<&'a mut Vec<Value>> {
    let root = cfg.as_object_mut()?;
    let slot = root.entry(key).or_insert_with(|| json!([]));
    if slot.is_null() {
        *slot = json!([]);
    }
    slot.as_array_mut()
}

/// The object at `cfg[key]`, created when missing or null. `None` if it holds anything else.
pub(crate) fn object_at<'a>(cfg: &'a mut Value, key: &str) -> Option<&'a mut Value> {
    let root = cfg.as_object_mut()?;
    let slot = root.entry(key).or_insert_with(|| json!({}));
    if slot.is_null() {
        *slot = json!({});
    }
    slot.is_object().then_some(slot)
}

/// Remove `//` and `/* */` comments and trailing commas, leaving string contents intact.
pub(crate) fn strip_jsonc(raw: &str) -> String {
    let mut out: Vec<char> = Vec::with_capacity(raw.len());
    let mut chars = raw.chars().peekable();
    let mut in_string = false;
    while let Some(c) = chars.next() {
        if in_string {
            out.push(c);
            if c == '\\' {
                if let Some(escaped) = chars.next() {
                    out.push(escaped);
                }
            } else if c == '"' {
                in_string = false;
            }
            continue;
        }
        match c {
            '"' => {
                in_string = true;
                out.push(c);
            }
            '/' if chars.peek() == Some(&'/') => {
                for skipped in chars.by_ref() {
                    if skipped == '\n' {
                        out.push('\n');
                        break;
                    }
                }
            }
            '/' if chars.peek() == Some(&'*') => {
                chars.next();
                let mut prev = '\0';
                for skipped in chars.by_ref() {
                    if prev == '*' && skipped == '/' {
                        break;
                    }
                    prev = skipped;
                }
                // A block comment separates tokens like whitespace does.
                out.push(' ');
            }
            '}' | ']' => {
                let last = out.iter().rposition(|ch| !ch.is_whitespace());
                if let Some(i) = last {
                    if out[i] == ',' {
                        out.remove(i);
                    }
                }
                out.push(c);
            }
            _ => out.push(c),
        }
    }
    out.into_iter().collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn strips_comments_and_trailing_commas_but_not_string_contents() {
        let raw = r#"{
  // line comment
  "url": "http://example.com/a//b", /* block */
  "quote": "a \" // not a comment",
  "list": [1, 2,],
}"#;
        let v: Value = serde_json::from_str(&strip_jsonc(raw)).expect("parses after stripping");
        assert_eq!(v["url"], "http://example.com/a//b");
        assert_eq!(v["quote"], "a \" // not a comment");
        assert_eq!(v["list"], json!([1, 2]));
    }

    #[test]
    fn a_block_comment_separates_tokens() {
        assert!(serde_json::from_str::<Value>(&strip_jsonc("[1/**/2]")).is_err());
        assert_eq!(
            strip_jsonc("{\"a\":/* x */1}").replace(' ', ""),
            "{\"a\":1}"
        );
    }

    #[test]
    fn plain_json_is_unchanged_by_stripping() {
        let raw = r#"{"a": [1, {"b": "c,"}], "d": "e]"}"#;
        assert_eq!(strip_jsonc(raw), raw);
    }

    #[test]
    fn set_bctx_refuses_a_key_of_the_wrong_shape() {
        let mut cfg = json!({ "mcpServers": [] });
        assert!(!set_bctx(&mut cfg, "mcpServers", json!({})));
        assert_eq!(cfg, json!({ "mcpServers": [] }));
        let mut cfg = json!({ "mcpServers": null });
        assert!(set_bctx(
            &mut cfg,
            "mcpServers",
            json!({ "command": "bctx" })
        ));
        assert_eq!(cfg["mcpServers"]["bctx"]["command"], "bctx");
    }
}