use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use clap::ValueEnum;
use serde_json::{Map, Value};
use super::init::{Change, InitArgs};
const PERMISSION_ENTRY: &str = "mcp__plugin_basemind_basemind__*";
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum SettingsTarget {
Local,
Shared,
None,
}
pub(crate) enum SettingsPlan {
Skip,
Write(PathBuf),
}
pub(crate) fn resolve_settings_target(args: &InitArgs) -> Result<SettingsTarget> {
if let Some(target) = args.settings_target {
return Ok(target);
}
if !args.yes && std::io::stdin().is_terminal() {
return prompt_settings_target();
}
Ok(SettingsTarget::None)
}
fn prompt_settings_target() -> Result<SettingsTarget> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
println!("Auto-approve basemind's MCP tools in Claude Code?");
println!(" 1) .claude/settings.local.json — personal, gitignored (recommended)");
println!(" 2) .claude/settings.json — committed, shared with everyone on the repo");
println!(" 3) none — skip this step");
write!(stdout, "Choose [1-3, blank = 1]: ").context("write prompt")?;
stdout.flush().context("flush prompt")?;
let mut line = String::new();
stdin.read_line(&mut line).context("read stdin")?;
Ok(match line.trim() {
"" | "1" => SettingsTarget::Local,
"2" => SettingsTarget::Shared,
"3" => SettingsTarget::None,
_ => SettingsTarget::Local,
})
}
pub(crate) fn resolve_settings_plan(root: &Path, target: SettingsTarget) -> SettingsPlan {
match target {
SettingsTarget::None => SettingsPlan::Skip,
SettingsTarget::Local => SettingsPlan::Write(root.join(".claude").join("settings.local.json")),
SettingsTarget::Shared => SettingsPlan::Write(root.join(".claude").join("settings.json")),
}
}
pub(crate) fn plan_settings_change(plan: &SettingsPlan) -> Result<Option<Change>> {
let path = match plan {
SettingsPlan::Skip => {
return Ok(Some(Change::NoOp {
note: "settings: skipped (no --settings-target)".to_string(),
}));
}
SettingsPlan::Write(path) => path,
};
let existing = match std::fs::read_to_string(path) {
Ok(c) => Some(c),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(anyhow::Error::new(e).context(format!("read {}", path.display()))),
};
let (contents, changed) = merged_settings_contents(existing.as_deref())
.with_context(|| format!("merge basemind permissions into {}", path.display()))?;
if !changed {
return Ok(Some(Change::NoOp {
note: format!("settings: basemind permission already present ({})", path.display()),
}));
}
Ok(Some(Change::Write {
path: path.clone(),
note: "added basemind's MCP tools to permissions.allow",
contents,
}))
}
fn merged_settings_contents(existing: Option<&str>) -> Result<(String, bool)> {
let mut root: Value = match existing {
Some(s) if !s.trim().is_empty() => serde_json::from_str(s).context("parse existing settings JSON")?,
_ => Value::Object(Map::new()),
};
let obj = root
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings file root is not a JSON object"))?;
let permissions = obj.entry("permissions").or_insert_with(|| Value::Object(Map::new()));
let permissions_obj = permissions
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("existing `permissions` is not a JSON object"))?;
let allow = permissions_obj
.entry("allow")
.or_insert_with(|| Value::Array(Vec::new()));
let allow_arr = allow
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("existing `permissions.allow` is not a JSON array"))?;
let already_present = allow_arr.iter().any(|v| v.as_str() == Some(PERMISSION_ENTRY));
if !already_present {
allow_arr.push(Value::String(PERMISSION_ENTRY.to_string()));
}
let mut rendered = serde_json::to_string_pretty(&root).context("render settings JSON")?;
rendered.push('\n');
Ok((rendered, !already_present))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_settings_plan_maps_targets_to_their_paths() {
let root = Path::new("/repo");
match resolve_settings_plan(root, SettingsTarget::Local) {
SettingsPlan::Write(path) => assert_eq!(path, root.join(".claude").join("settings.local.json")),
SettingsPlan::Skip => panic!("Local must not skip"),
}
match resolve_settings_plan(root, SettingsTarget::Shared) {
SettingsPlan::Write(path) => assert_eq!(path, root.join(".claude").join("settings.json")),
SettingsPlan::Skip => panic!("Shared must not skip"),
}
assert!(matches!(
resolve_settings_plan(root, SettingsTarget::None),
SettingsPlan::Skip
));
}
#[test]
fn merge_creates_permissions_allow_from_nothing() {
let (contents, changed) = merged_settings_contents(None).expect("merge into empty file");
assert!(changed);
let parsed: Value = serde_json::from_str(&contents).expect("valid JSON");
assert_eq!(
parsed["permissions"]["allow"],
Value::Array(vec![Value::String(PERMISSION_ENTRY.to_string())])
);
}
#[test]
fn merge_appends_without_touching_unrelated_top_level_keys() {
let existing = r#"{
"skillOverrides": {
"init": "off"
},
"otherKey": [1, 2, 3]
}"#;
let (contents, changed) = merged_settings_contents(Some(existing)).expect("merge");
assert!(changed);
let parsed: Value = serde_json::from_str(&contents).expect("valid JSON");
assert_eq!(parsed["skillOverrides"]["init"], "off", "unrelated key preserved");
assert_eq!(parsed["otherKey"], Value::Array(vec![1.into(), 2.into(), 3.into()]));
assert_eq!(
parsed["permissions"]["allow"],
Value::Array(vec![Value::String(PERMISSION_ENTRY.to_string())])
);
let skill_pos = contents.find("skillOverrides").expect("skillOverrides present");
let other_pos = contents.find("otherKey").expect("otherKey present");
let permissions_pos = contents.find("\"permissions\"").expect("permissions present");
assert!(skill_pos < other_pos, "skillOverrides must stay before otherKey");
assert!(
other_pos < permissions_pos,
"the newly-added permissions key must land last"
);
}
#[test]
fn merge_is_idempotent_when_the_entry_is_already_present() {
let existing = format!(
r#"{{
"permissions": {{
"allow": ["{PERMISSION_ENTRY}", "some/other/tool"]
}}
}}"#
);
let (_, changed) = merged_settings_contents(Some(&existing)).expect("merge");
assert!(!changed, "already present — no rewrite needed");
}
#[test]
fn merge_dedupes_rather_than_appending_a_second_copy() {
let existing = format!(r#"{{"permissions": {{"allow": ["{PERMISSION_ENTRY}"]}}}}"#);
let (contents, changed) = merged_settings_contents(Some(&existing)).expect("merge");
assert!(!changed);
let parsed: Value = serde_json::from_str(&contents).expect("valid JSON");
let allow = parsed["permissions"]["allow"].as_array().expect("array");
assert_eq!(allow.len(), 1, "must not duplicate the entry");
}
#[test]
fn plan_settings_change_skip_is_a_noop() {
let change = plan_settings_change(&SettingsPlan::Skip).expect("plan");
assert!(matches!(change, Some(Change::NoOp { .. })));
}
#[test]
fn plan_settings_change_writes_a_fresh_local_settings_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join(".claude").join("settings.local.json");
let change = plan_settings_change(&SettingsPlan::Write(path.clone()))
.expect("plan")
.expect("a change");
match change {
Change::Write {
path: written,
contents,
..
} => {
assert_eq!(written, path);
assert!(contents.contains(PERMISSION_ENTRY));
}
Change::NoOp { note } => panic!("expected a write, got no-op: {note}"),
}
}
}