use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use niuse_core::Store;
const NOTES_SERVER: &str = include_str!("../../assets/packs/notes/server.py");
const CALENDAR_SERVER: &str = include_str!("../../assets/packs/calendar/server.py");
pub struct InitOptions {
pub notes: Option<PathBuf>,
pub calendar: Option<PathBuf>,
pub config: Option<PathBuf>,
pub packs: Option<PathBuf>,
pub force: bool,
}
pub fn init(store: &Store, options: InitOptions) -> Result<()> {
if options.notes.is_none() && options.calendar.is_none() {
bail!("mcp init needs --notes <dir> and/or --calendar <file.ics>");
}
let config = match options.config {
Some(path) => path,
None => crate::projects::default_root()?.join("mcp.toml"),
};
let packs = options.packs.unwrap_or_else(|| store.root().join("packs"));
let python = python()?;
let mut servers: Vec<(String, String)> = Vec::new();
if let Some(notes) = options.notes {
if !notes.is_dir() {
bail!("--notes is not a directory: {}", notes.display());
}
let script = packs.join("notes/server.py");
write_script(&script, NOTES_SERVER)?;
servers.push((
"notes".to_string(),
format!(
"[[server]]\nname = \"notes\"\ncommand = {}\nargs = [{}]\nread_only = true\n\n[server.env]\nNOTES_DIR = {}\n",
toml_string(&python),
toml_string(&script.display().to_string()),
toml_string(¬es.display().to_string())
),
));
}
if let Some(calendar) = options.calendar {
if !calendar.is_file() {
bail!("--calendar is not a file: {}", calendar.display());
}
let script = packs.join("calendar/server.py");
write_script(&script, CALENDAR_SERVER)?;
servers.push((
"calendar".to_string(),
format!(
"[[server]]\nname = \"calendar\"\ncommand = {}\nargs = [{}]\nread_only = true\n\n[server.env]\nCALENDAR_ICS = {}\n",
toml_string(&python),
toml_string(&script.display().to_string()),
toml_string(&calendar.display().to_string())
),
));
}
let header = "# Generated by `niuse mcp init`. Read-only servers only.\n# Review before editing; niucode never auto-discovers this file.\n";
let existing = if config.exists() {
fs::read_to_string(&config).with_context(|| format!("read {}", config.display()))?
} else {
String::new()
};
let mut names: HashSet<String> = HashSet::new();
if config.exists() && !options.force {
let parsed = niucode_mcp::McpConfig::load(&config).with_context(|| {
format!(
"{} is not a valid manifest; fix it or pass --force",
config.display()
)
})?;
names = parsed
.server
.into_iter()
.map(|server| server.name)
.collect();
}
let mut added = Vec::new();
let mut kept = Vec::new();
let mut blocks = Vec::new();
for (name, block) in servers {
if !options.force && names.contains(&name) {
kept.push(name);
} else {
added.push(name);
blocks.push(block);
}
}
if let Some(parent) = config.parent() {
create_private_dir(parent)?;
}
if options.force || !config.exists() {
let body = if blocks.is_empty() {
header.to_string()
} else {
format!("{header}\n{}\n", blocks.join("\n"))
};
write_private(&config, &body)?;
} else if !blocks.is_empty() {
let mut body = existing;
if !body.ends_with('\n') {
body.push('\n');
}
body.push('\n');
body.push_str(&blocks.join("\n"));
body.push('\n');
write_private(&config, &body)?;
}
println!("mcp config {}", config.display());
println!("packs {}", packs.display());
if !added.is_empty() {
println!("added {}", added.join(", "));
}
if !kept.is_empty() {
println!("kept {}", kept.join(", "));
}
if added.is_empty() && kept.is_empty() {
println!("unchanged no servers requested");
} else if added.is_empty() {
println!("unchanged all requested servers already exist");
}
println!("next niuse tui (or: niuse exec \"list my notes\")");
Ok(())
}
fn python() -> Result<String> {
if let Some(path) = std::env::var_os("PYTHON") {
let path = PathBuf::from(path);
if path.is_file() {
return Ok(path.display().to_string());
}
}
for candidate in [
"/usr/bin/python3",
"/usr/local/bin/python3",
"/opt/homebrew/bin/python3",
] {
if Path::new(candidate).is_file() {
return Ok(candidate.to_string());
}
}
if let Some(paths) = std::env::var_os("PATH") {
for directory in std::env::split_paths(&paths) {
let candidate = directory.join("python3");
if candidate.is_file() {
return Ok(candidate.display().to_string());
}
}
}
bail!("python3 not found; install it or set PYTHON to an absolute interpreter path")
}
fn toml_string(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn write_script(path: &Path, body: &str) -> Result<()> {
if let Some(parent) = path.parent() {
create_private_dir(parent)?;
}
write_private(path, body)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod {}", path.display()))?;
}
Ok(())
}
fn create_private_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path).with_context(|| format!("create {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.with_context(|| format!("chmod {}", path.display()))?;
}
Ok(())
}
fn write_private(path: &Path, body: &str) -> Result<()> {
fs::write(path, body).with_context(|| format!("write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod {}", path.display()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toml_strings_escape_quotes_and_backslashes() {
assert_eq!(toml_string(r#"/tmp/a"b\c"#), r#""/tmp/a\"b\\c""#);
}
#[test]
fn init_requires_a_source() {
let directory = tempfile::tempdir().unwrap();
let store = Store::at(directory.path().join(".niuse"));
store.init().unwrap();
let error = init(
&store,
InitOptions {
notes: None,
calendar: None,
config: Some(directory.path().join("mcp.toml")),
packs: None,
force: false,
},
)
.unwrap_err();
assert!(error.to_string().contains("--notes"));
}
}