From e8c3e44badeb8cda4fe5679c8f60cb123dbf9083 Mon Sep 17 00:00:00 2001 From: BT-7274 Date: Fri, 12 Jun 2026 22:02:08 +0200 Subject: [PATCH] feat: scaffold nani CLI + MCP server Single Rust binary with three modes: - Human CLI with colored output - M2M mode (--json, --plain, --silent flags) - MCP server over stdio (nani mcp-server) HTTP client for config.saiden.dev REST API: - Projects: list, set metadata, delete - Secrets: get, set, list, delete - Files: get, put, list, delete Global flags: --json, --plain, --silent, --dry-run, --url, --api-key Config: ~/.config/nani/config.json + NANI_API_KEY/NANI_URL env vars MCP tools: nani_get, nani_set, nani_list, nani_delete, nani_file_get, nani_file_put Dependencies: clap (derive), reqwest (rustls-tls), serde, tokio, rmcp, colored Closes infra/nani#1 --- .gitignore | 2 + Cargo.toml | 26 ++++ README.md | 64 +++++++- src/cli.rs | 148 ++++++++++++++++++ src/client.rs | 308 ++++++++++++++++++++++++++++++++++++ src/commands.rs | 404 ++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 87 +++++++++++ src/main.rs | 15 ++ src/mcp.rs | 193 +++++++++++++++++++++++ src/output.rs | 96 ++++++++++++ 10 files changed, 1342 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/cli.rs create mode 100644 src/client.rs create mode 100644 src/commands.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/mcp.rs create mode 100644 src/output.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..f36c82e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nani" +version = "0.1.0" +edition = "2021" +description = "CLI and MCP server for config.saiden.dev" +license = "MIT" + +[[bin]] +name = "nani" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std"] } +dirs = "6" +colored = "3" +thiserror = "2" +rmcp = { version = "1", features = ["server", "macros", "transport-io"] } +schemars = "1" + +[profile.release] +strip = true +lto = true diff --git a/README.md b/README.md index 30fa7c0..d9e6224 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,65 @@ # nani -Secrets, config, and file store — Rust CLI, M2M, and MCP server for config.saiden.dev \ No newline at end of file +CLI and MCP server for [config.saiden.dev](https://config.saiden.dev) — secrets, config, and file management. + +## Install + +```bash +cargo install --path . +``` + +## Auth + +```bash +nani auth # store key to ~/.config/nani/config.json +nani auth show # show current config (masked key) +``` + +Or set `NANI_API_KEY` environment variable. + +## Usage + +```bash +# Projects +nani ls # list all projects +nani projects set --gitea # set project metadata +nani projects rm # delete project + +# Secrets +nani ls # list keys +nani get # get secret +nani set # set secret +nani set -f # set from file +nani rm # delete secret + +# Files +nani files ls # list files +nani files get # download file +nani files put -f # upload file +nani files rm # delete file +``` + +## Global flags + +| Flag | Description | +|------|-------------| +| `--json` | Structured JSON output (M2M mode) | +| `--plain` | Raw value only, no decoration (for piping) | +| `--silent` | No stdout, exit code only (0=ok, 1=error, 2=not-found) | +| `--dry-run` | Show HTTP request without executing | +| `--url ` | Override base URL | +| `--api-key ` | Override API key (one-shot) | + +## MCP Server + +```bash +nani mcp-server +``` + +Exposes tools over stdio JSON-RPC: `nani_get`, `nani_set`, `nani_list`, `nani_delete`, `nani_file_get`, `nani_file_put`. + +## Config + +- File: `~/.config/nani/config.json` +- `NANI_API_KEY` env var overrides config file +- `NANI_URL` env var overrides base URL diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..270052e --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,148 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command(name = "nani", version, about = "CLI for config.saiden.dev")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + /// Output structured JSON (M2M mode) + #[arg(long, global = true)] + pub json: bool, + + /// No stdout, exit code only (0=ok, 1=error, 2=not-found) + #[arg(long, global = true)] + pub silent: bool, + + /// Show the HTTP request without executing + #[arg(long, global = true)] + pub dry_run: bool, + + /// Raw value only, no decoration (for piping) + #[arg(long, global = true)] + pub plain: bool, + + /// Override base URL + #[arg(long, global = true)] + pub url: Option, + + /// Override API key (one-shot) + #[arg(long = "api-key", global = true)] + pub api_key_override: Option, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Store API key to config + Auth { + #[command(subcommand)] + action: Option, + + /// API key to store (shorthand for `nani auth set `) + #[arg(value_name = "API_KEY")] + api_key: Option, + }, + + /// Get a secret value + Get { + project: String, + env: String, + key: String, + }, + + /// Set a secret value + Set { + project: String, + env: String, + key: String, + /// Value to set (or use -f for file) + value: Option, + /// Read value from file + #[arg(short = 'f', long)] + file: Option, + }, + + /// List projects or secrets + Ls { + /// Project name + project: Option, + /// Environment name + env: Option, + }, + + /// Delete a secret + Rm { + project: String, + env: String, + key: String, + }, + + /// File operations + Files { + #[command(subcommand)] + action: FileAction, + }, + + /// Project operations + Projects { + #[command(subcommand)] + action: Option, + }, + + /// Start MCP server (stdio transport) + McpServer, +} + +#[derive(Subcommand, Debug)] +pub enum AuthAction { + /// Show current config (masked key) + Show, +} + +#[derive(Subcommand, Debug)] +pub enum FileAction { + /// Get file content + Get { + project: String, + env: String, + path: String, + }, + /// Upload file + Put { + project: String, + env: String, + path: String, + /// Read content from file + #[arg(short = 'f', long)] + file: Option, + /// Inline content + content: Option, + }, + /// List files + Ls { + project: String, + env: String, + }, + /// Delete a file + Rm { + project: String, + env: String, + path: String, + }, +} + +#[derive(Subcommand, Debug)] +pub enum ProjectAction { + /// Set project metadata + Set { + project: String, + #[arg(long)] + gitea: Option, + #[arg(long)] + desc: Option, + }, + /// Delete project + Rm { + project: String, + }, +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..e04b684 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,308 @@ +use reqwest::{Client, Method, StatusCode}; +use serde_json::Value; + +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[allow(dead_code)] + #[error("no API key configured (run `nani auth ` or set NANI_API_KEY)")] + NoApiKey, + #[error("HTTP {status}: {body}")] + Http { status: u16, body: String }, + #[error("request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("not found")] + NotFound, +} + +impl ApiError { + pub fn exit_code(&self) -> i32 { + match self { + ApiError::NotFound => 2, + _ => 1, + } + } +} + +#[derive(Debug, Clone)] +pub struct DryRunRequest { + pub method: String, + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Option, +} + +impl std::fmt::Display for DryRunRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{} {}", self.method, self.url)?; + for (k, v) in &self.headers { + writeln!(f, " {k}: {v}")?; + } + if let Some(body) = &self.body { + writeln!(f, " Body: {body}")?; + } + Ok(()) + } +} + +pub struct ApiClient { + base_url: String, + api_key: String, + client: Client, + dry_run: bool, +} + +impl ApiClient { + pub fn new(base_url: String, api_key: String, dry_run: bool) -> Self { + Self { + base_url, + api_key, + client: Client::new(), + dry_run, + } + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.base_url, path) + } + + fn dry_run_info( + &self, + method: &str, + path: &str, + body: Option<&str>, + ) -> Result { + let dr = DryRunRequest { + method: method.to_string(), + url: self.url(path), + headers: vec![ + ( + "Authorization".into(), + format!("Bearer {}...{}", &self.api_key[..4], &self.api_key[self.api_key.len().saturating_sub(4)..]), + ), + ("Content-Type".into(), "application/json".into()), + ], + body: body.map(|s| s.to_string()), + }; + Ok(serde_json::json!({ + "dry_run": true, + "method": dr.method, + "url": dr.url, + "headers": dr.headers.iter().map(|(k, v)| format!("{k}: {v}")).collect::>(), + "body": dr.body, + })) + } + + async fn request( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + if self.dry_run { + let body_str = body.as_ref().map(|b| b.to_string()); + return self.dry_run_info(method.as_str(), path, body_str.as_deref()); + } + + let url = self.url(path); + let mut req = self + .client + .request(method, &url) + .header("Authorization", format!("Bearer {}", self.api_key)); + + if let Some(b) = &body { + req = req.header("Content-Type", "application/json").json(b); + } + + let resp = req.send().await?; + let status = resp.status(); + + if status == StatusCode::NOT_FOUND { + return Err(ApiError::NotFound); + } + + let text = resp.text().await?; + + if !status.is_success() { + return Err(ApiError::Http { + status: status.as_u16(), + body: text, + }); + } + + // Try to parse as JSON; if it fails, wrap as raw text + match serde_json::from_str::(&text) { + Ok(v) => Ok(v), + Err(_) => Ok(serde_json::json!({ "raw": text })), + } + } + + async fn request_raw( + &self, + method: Method, + path: &str, + body: Option<&[u8]>, + content_type: Option<&str>, + ) -> Result, ApiError> { + if self.dry_run { + let body_str = body.map(|b| format!("[{} bytes]", b.len())); + let v = self.dry_run_info(method.as_str(), path, body_str.as_deref())?; + return Ok(v.to_string().into_bytes()); + } + + let url = self.url(path); + let mut req = self + .client + .request(method, &url) + .header("Authorization", format!("Bearer {}", self.api_key)); + + if let Some(ct) = content_type { + req = req.header("Content-Type", ct); + } + if let Some(b) = body { + req = req.body(b.to_vec()); + } + + let resp = req.send().await?; + let status = resp.status(); + + if status == StatusCode::NOT_FOUND { + return Err(ApiError::NotFound); + } + + let bytes = resp.bytes().await?; + + if !status.is_success() { + let text = String::from_utf8_lossy(&bytes); + return Err(ApiError::Http { + status: status.as_u16(), + body: text.to_string(), + }); + } + + Ok(bytes.to_vec()) + } + + // === Projects === + + pub async fn list_projects(&self) -> Result { + self.request(Method::GET, "/projects", None).await + } + + pub async fn get_project_meta(&self, project: &str) -> Result { + self.request(Method::GET, &format!("/{project}/meta"), None) + .await + } + + pub async fn set_project_meta(&self, project: &str, body: Value) -> Result { + self.request(Method::PUT, &format!("/{project}/meta"), Some(body)) + .await + } + + pub async fn delete_project(&self, project: &str) -> Result { + self.request(Method::DELETE, &format!("/{project}/meta"), None) + .await + } + + // === Secrets === + + pub async fn list_secrets(&self, project: &str, env: &str) -> Result { + self.request(Method::GET, &format!("/{project}/{env}/secrets"), None) + .await + } + + pub async fn get_secret( + &self, + project: &str, + env: &str, + key: &str, + ) -> Result { + self.request( + Method::GET, + &format!("/{project}/{env}/secrets/{key}"), + None, + ) + .await + } + + pub async fn set_secret( + &self, + project: &str, + env: &str, + key: &str, + value: &str, + ) -> Result { + self.request( + Method::PUT, + &format!("/{project}/{env}/secrets/{key}"), + Some(serde_json::json!({ "value": value })), + ) + .await + } + + pub async fn delete_secret( + &self, + project: &str, + env: &str, + key: &str, + ) -> Result { + self.request( + Method::DELETE, + &format!("/{project}/{env}/secrets/{key}"), + None, + ) + .await + } + + // === Files === + + pub async fn list_files(&self, project: &str, env: &str) -> Result { + self.request(Method::GET, &format!("/{project}/{env}/files"), None) + .await + } + + pub async fn get_file( + &self, + project: &str, + env: &str, + path: &str, + ) -> Result, ApiError> { + self.request_raw( + Method::GET, + &format!("/{project}/{env}/files/{path}"), + None, + None, + ) + .await + } + + pub async fn put_file( + &self, + project: &str, + env: &str, + path: &str, + content: &[u8], + content_type: Option<&str>, + ) -> Result, ApiError> { + self.request_raw( + Method::PUT, + &format!("/{project}/{env}/files/{path}"), + Some(content), + content_type.or(Some("application/octet-stream")), + ) + .await + } + + pub async fn delete_file( + &self, + project: &str, + env: &str, + path: &str, + ) -> Result { + self.request( + Method::DELETE, + &format!("/{project}/{env}/files/{path}"), + None, + ) + .await + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..ccc66b1 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,404 @@ +use crate::cli::*; +use crate::client::{ApiClient, ApiError}; +use crate::config::Config; +use crate::output::OutputMode; +use serde_json::Value; + +fn is_dry_run(v: &Value) -> bool { + v.get("dry_run").and_then(|d| d.as_bool()).unwrap_or(false) +} + +pub async fn run(cli: Cli) -> i32 { + let config = Config::load(); + let out = OutputMode { + json: cli.json, + silent: cli.silent, + plain: cli.plain, + }; + + match cli.command { + Commands::Auth { action, api_key } => run_auth(action, api_key, &config, &out), + Commands::McpServer => { + crate::mcp::run_server().await; + 0 + } + _ => { + let api_key = match config.effective_api_key(&cli.api_key_override) { + Some(k) => k, + None => { + out.error("no API key configured (run `nani auth ` or set NANI_API_KEY)"); + return 1; + } + }; + let base_url = config.effective_base_url(&cli.url); + let client = ApiClient::new(base_url, api_key, cli.dry_run); + + let result = match cli.command { + Commands::Get { project, env, key } => { + run_get(&client, &project, &env, &key, &out).await + } + Commands::Set { + project, + env, + key, + value, + file, + } => run_set(&client, &project, &env, &key, value, file, &out).await, + Commands::Ls { project, env } => run_ls(&client, project, env, &out).await, + Commands::Rm { project, env, key } => { + run_rm(&client, &project, &env, &key, &out).await + } + Commands::Files { action } => run_files(&client, action, &out).await, + Commands::Projects { action } => run_projects(&client, action, &out).await, + _ => unreachable!(), + }; + + match result { + Ok(code) => code, + Err(e) => { + out.error(&e.to_string()); + e.exit_code() + } + } + } + } +} + +fn run_auth( + action: Option, + api_key: Option, + config: &Config, + out: &OutputMode, +) -> i32 { + match action { + Some(AuthAction::Show) => { + if out.json { + let path = Config::path() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + out.json_value(&serde_json::json!({ + "api_key": config.masked_key(), + "base_url": config.base_url, + "config_path": path, + })); + } else { + out.message(&format!("Config: {}", Config::path().map(|p| p.display().to_string()).unwrap_or("unknown".into()))); + out.kv("api_key", &config.masked_key()); + out.kv("base_url", &config.base_url); + } + 0 + } + None => { + if let Some(key) = api_key { + let mut new_config = config.clone(); + new_config.api_key = key; + if new_config.base_url.is_empty() { + new_config.base_url = "https://config.saiden.dev".to_string(); + } + match new_config.save() { + Ok(_) => { + out.success("API key saved"); + 0 + } + Err(e) => { + out.error(&format!("failed to save config: {e}")); + 1 + } + } + } else { + out.error("usage: nani auth or nani auth show"); + 1 + } + } + } +} + +async fn run_get( + client: &ApiClient, + project: &str, + env: &str, + key: &str, + out: &OutputMode, +) -> Result { + let result = client.get_secret(project, env, key).await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else if out.plain { + let value = result + .get("value") + .and_then(|v| v.as_str()) + .unwrap_or(""); + println!("{value}"); + } else { + let value = result + .get("value") + .and_then(|v| v.as_str()) + .unwrap_or(""); + out.kv(key, value); + } + Ok(0) +} + +async fn run_set( + client: &ApiClient, + project: &str, + env: &str, + key: &str, + value: Option, + file: Option, + out: &OutputMode, +) -> Result { + let val = if let Some(f) = file { + std::fs::read_to_string(&f).map_err(|e| ApiError::Http { + status: 0, + body: format!("cannot read file {f}: {e}"), + })? + } else if let Some(v) = value { + v + } else { + out.error("provide a value or use -f "); + return Ok(1); + }; + + let result = client.set_secret(project, env, key, &val).await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else { + out.success(&format!("{key} stored in {project}/{env}")); + } + Ok(0) +} + +async fn run_ls( + client: &ApiClient, + project: Option, + env: Option, + out: &OutputMode, +) -> Result { + match (project, env) { + (Some(proj), Some(env)) => { + let result = client.list_secrets(&proj, &env).await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else { + let keys: Vec = result + .get("keys") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + out.list(&format!("{proj}/{env} ({} keys)", keys.len()), &keys); + } + Ok(0) + } + (Some(proj), None) => { + let result = client.get_project_meta(&proj).await?; + if is_dry_run(&result) { + out.json_value(&result); + } else { + out.json_value(&result); + } + Ok(0) + } + _ => { + let result = client.list_projects().await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else { + let projects: Vec = result + .get("projects") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| { + let name = v.get("project").and_then(|p| p.as_str())?; + let desc = v + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + if desc.is_empty() { + Some(name.to_string()) + } else { + Some(format!("{name} — {desc}")) + } + }) + .collect() + }) + .unwrap_or_default(); + out.list( + &format!("Projects ({})", projects.len()), + &projects, + ); + } + Ok(0) + } + } +} + +async fn run_rm( + client: &ApiClient, + project: &str, + env: &str, + key: &str, + out: &OutputMode, +) -> Result { + let result = client.delete_secret(project, env, key).await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else { + out.success(&format!("{key} deleted from {project}/{env}")); + } + Ok(0) +} + +async fn run_files( + client: &ApiClient, + action: FileAction, + out: &OutputMode, +) -> Result { + match action { + FileAction::Get { project, env, path } => { + let data = client.get_file(&project, &env, &path).await?; + if out.json { + let text = String::from_utf8_lossy(&data); + out.json_value(&serde_json::json!({ + "project": project, + "env": env, + "path": path, + "content": text, + })); + } else { + // Write raw bytes to stdout + use std::io::Write; + std::io::stdout().write_all(&data).ok(); + } + Ok(0) + } + FileAction::Put { + project, + env, + path, + file, + content, + } => { + let data = if let Some(f) = file { + std::fs::read(&f).map_err(|e| ApiError::Http { + status: 0, + body: format!("cannot read file {f}: {e}"), + })? + } else if let Some(c) = content { + c.into_bytes() + } else { + out.error("provide content or use -f "); + return Ok(1); + }; + + let result_bytes = client.put_file(&project, &env, &path, &data, None).await?; + // Check if this was a dry_run response + if let Ok(v) = serde_json::from_slice::(&result_bytes) { + if is_dry_run(&v) { + out.json_value(&v); + return Ok(0); + } + } + if out.json { + out.json_value(&serde_json::json!({ "status": "stored" })); + } else { + out.success(&format!("{path} stored in {project}/{env}")); + } + Ok(0) + } + FileAction::Ls { project, env } => { + let result = client.list_files(&project, &env).await?; + if out.json { + out.json_value(&result); + } else { + let files: Vec = result + .get("files") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| { + v.get("path").and_then(|p| p.as_str()).map(String::from) + }) + .collect() + }) + .unwrap_or_default(); + out.list(&format!("{project}/{env} files"), &files); + } + Ok(0) + } + FileAction::Rm { project, env, path } => { + let result = client.delete_file(&project, &env, &path).await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else { + out.success(&format!("{path} deleted from {project}/{env}")); + } + Ok(0) + } + } +} + +async fn run_projects( + client: &ApiClient, + action: Option, + out: &OutputMode, +) -> Result { + match action { + None => { + // Same as `nani ls` with no args + run_ls(client, None, None, out).await + } + Some(ProjectAction::Set { + project, + gitea, + desc, + }) => { + let mut body = serde_json::Map::new(); + if let Some(g) = gitea { + body.insert("gitea".into(), Value::String(g)); + } + if let Some(d) = desc { + body.insert("description".into(), Value::String(d)); + } + let result = client + .set_project_meta(&project, Value::Object(body)) + .await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else { + out.success(&format!("project {project} updated")); + } + Ok(0) + } + Some(ProjectAction::Rm { project }) => { + let result = client.delete_project(&project).await?; + if is_dry_run(&result) { + out.json_value(&result); + } else if out.json { + out.json_value(&result); + } else { + out.success(&format!("project {project} deleted")); + } + Ok(0) + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..23e8001 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub api_key: String, + #[serde(default = "default_base_url")] + pub base_url: String, +} + +fn default_base_url() -> String { + "https://config.saiden.dev".to_string() +} + +impl Config { + pub fn path() -> Option { + dirs::config_dir().map(|d| d.join("nani").join("config.json")) + } + + pub fn load() -> Self { + let path = match Self::path() { + Some(p) => p, + None => return Self::default(), + }; + match std::fs::read_to_string(&path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + pub fn save(&self) -> Result<(), String> { + let path = Self::path().ok_or("cannot determine config directory")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cannot create config dir: {e}"))?; + } + let data = + serde_json::to_string_pretty(self).map_err(|e| format!("serialize error: {e}"))?; + std::fs::write(&path, data).map_err(|e| format!("write error: {e}"))?; + Ok(()) + } + + /// Resolve effective API key: --key flag > NANI_API_KEY env > config file + pub fn effective_api_key(&self, flag_key: &Option) -> Option { + if let Some(k) = flag_key { + if !k.is_empty() { + return Some(k.clone()); + } + } + if let Ok(k) = std::env::var("NANI_API_KEY") { + if !k.is_empty() { + return Some(k); + } + } + if !self.api_key.is_empty() { + return Some(self.api_key.clone()); + } + None + } + + /// Resolve effective base URL: --url flag > NANI_URL env > config file > default + pub fn effective_base_url(&self, flag_url: &Option) -> String { + if let Some(u) = flag_url { + if !u.is_empty() { + return u.trim_end_matches('/').to_string(); + } + } + if let Ok(u) = std::env::var("NANI_URL") { + if !u.is_empty() { + return u.trim_end_matches('/').to_string(); + } + } + if !self.base_url.is_empty() { + return self.base_url.trim_end_matches('/').to_string(); + } + default_base_url() + } + + pub fn masked_key(&self) -> String { + if self.api_key.len() <= 8 { + return "****".to_string(); + } + let visible = &self.api_key[..4]; + format!("{visible}...{}", &self.api_key[self.api_key.len() - 4..]) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2b05667 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +mod cli; +mod client; +mod commands; +mod config; +mod mcp; +mod output; + +use clap::Parser; + +#[tokio::main] +async fn main() { + let cli = cli::Cli::parse(); + let code = commands::run(cli).await; + std::process::exit(code); +} diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..f03e835 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,193 @@ +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ServerCapabilities, ServerInfo}, + schemars, tool, tool_handler, tool_router, +}; +use serde::{Deserialize, Serialize}; + +use crate::client::ApiClient; +use crate::config::Config; + +#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)] +pub struct GetParams { + /// Project name + pub project: String, + /// Environment (e.g. "prod", "staging") + pub env: String, + /// Secret key name + pub key: String, +} + +#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)] +pub struct SetParams { + /// Project name + pub project: String, + /// Environment (e.g. "prod", "staging") + pub env: String, + /// Secret key name + pub key: String, + /// Secret value to store + pub value: String, +} + +#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)] +pub struct ListParams { + /// Project name (omit to list all projects) + pub project: Option, + /// Environment name (omit to show project meta) + pub env: Option, +} + +#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)] +pub struct DeleteParams { + /// Project name + pub project: String, + /// Environment (e.g. "prod", "staging") + pub env: String, + /// Secret key name + pub key: String, +} + +#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)] +pub struct FileGetParams { + /// Project name + pub project: String, + /// Environment + pub env: String, + /// File path + pub path: String, +} + +#[derive(Debug, Deserialize, Serialize, schemars::JsonSchema)] +pub struct FilePutParams { + /// Project name + pub project: String, + /// Environment + pub env: String, + /// File path + pub path: String, + /// File content + pub content: String, +} + +#[derive(Debug, Clone)] +pub struct NaniServer { + base_url: String, + api_key: String, + #[allow(dead_code)] + tool_router: ToolRouter, +} + +impl NaniServer { + pub fn new(base_url: String, api_key: String) -> Self { + Self { + base_url, + api_key, + tool_router: Self::tool_router(), + } + } + + fn client(&self) -> ApiClient { + ApiClient::new(self.base_url.clone(), self.api_key.clone(), false) + } +} + +#[tool_router] +impl NaniServer { + #[tool(description = "Get a secret value from a project/env")] + async fn nani_get(&self, params: Parameters) -> String { + let p = params.0; + let client = self.client(); + match client.get_secret(&p.project, &p.env, &p.key).await { + Ok(v) => serde_json::to_string(&v).unwrap_or_else(|_| "{}".into()), + Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), + } + } + + #[tool(description = "Set a secret value in a project/env")] + async fn nani_set(&self, params: Parameters) -> String { + let p = params.0; + let client = self.client(); + match client.set_secret(&p.project, &p.env, &p.key, &p.value).await { + Ok(v) => serde_json::to_string(&v).unwrap_or_else(|_| r#"{"status":"stored"}"#.into()), + Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), + } + } + + #[tool(description = "List projects, or list secret keys in a project/env")] + async fn nani_list(&self, params: Parameters) -> String { + let p = params.0; + let client = self.client(); + let result = match (p.project.as_deref(), p.env.as_deref()) { + (Some(proj), Some(env)) => client.list_secrets(proj, env).await, + (Some(proj), None) => client.get_project_meta(proj).await, + _ => client.list_projects().await, + }; + match result { + Ok(v) => serde_json::to_string(&v).unwrap_or_else(|_| "{}".into()), + Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), + } + } + + #[tool(description = "Delete a secret from a project/env")] + async fn nani_delete(&self, params: Parameters) -> String { + let p = params.0; + let client = self.client(); + match client.delete_secret(&p.project, &p.env, &p.key).await { + Ok(v) => serde_json::to_string(&v).unwrap_or_else(|_| r#"{"status":"deleted"}"#.into()), + Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), + } + } + + #[tool(description = "Get file content from a project/env")] + async fn nani_file_get(&self, params: Parameters) -> String { + let p = params.0; + let client = self.client(); + match client.get_file(&p.project, &p.env, &p.path).await { + Ok(data) => String::from_utf8_lossy(&data).to_string(), + Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), + } + } + + #[tool(description = "Upload file content to a project/env")] + async fn nani_file_put(&self, params: Parameters) -> String { + let p = params.0; + let client = self.client(); + match client + .put_file(&p.project, &p.env, &p.path, p.content.as_bytes(), None) + .await + { + Ok(_) => r#"{"status":"stored"}"#.to_string(), + Err(e) => serde_json::json!({"error": e.to_string()}).to_string(), + } + } +} + +#[tool_handler] +impl ServerHandler for NaniServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_instructions("nani — config.saiden.dev secrets and files manager") + } +} + +pub async fn run_server() { + let config = Config::load(); + let api_key = config.effective_api_key(&None).unwrap_or_default(); + let base_url = config.effective_base_url(&None); + + let server = NaniServer::new(base_url, api_key); + + let transport = rmcp::transport::io::stdio(); + let ct = match server.serve(transport).await { + Ok(ct) => ct, + Err(e) => { + eprintln!("MCP server error: {e}"); + std::process::exit(1); + } + }; + + // Wait until the client disconnects + let _ = ct.waiting().await; +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..b051fd9 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,96 @@ +use colored::Colorize; +use serde_json::Value; + +pub struct OutputMode { + pub json: bool, + pub silent: bool, + pub plain: bool, +} + +impl OutputMode { + /// Print a key=value pair + pub fn kv(&self, key: &str, value: &str) { + if self.silent { + return; + } + if self.json { + // JSON handled by caller + return; + } + if self.plain { + println!("{value}"); + return; + } + println!("{} {}", format!("{key}=").dimmed(), value); + } + + /// Print structured JSON + pub fn json_value(&self, value: &Value) { + if self.silent { + return; + } + if self.json { + println!("{}", serde_json::to_string_pretty(value).unwrap_or_default()); + return; + } + // Fallback: pretty-print + println!("{}", serde_json::to_string_pretty(value).unwrap_or_default()); + } + + /// Print a simple message + pub fn message(&self, msg: &str) { + if self.silent { + return; + } + if self.json { + return; + } + println!("{msg}"); + } + + /// Print a success message + pub fn success(&self, msg: &str) { + if self.silent { + return; + } + if self.json { + return; + } + println!("{} {msg}", "✓".green()); + } + + /// Print an error message + pub fn error(&self, msg: &str) { + if self.silent { + return; + } + if self.json { + let v = serde_json::json!({ "error": msg }); + eprintln!("{}", serde_json::to_string(&v).unwrap_or_default()); + return; + } + eprintln!("{} {msg}", "✗".red()); + } + + /// Print a list of items with optional header + pub fn list(&self, header: &str, items: &[String]) { + if self.silent { + return; + } + if self.json { + return; // JSON handled by caller + } + if self.plain { + for item in items { + println!("{item}"); + } + return; + } + if !header.is_empty() { + println!("{}", header.bold()); + } + for item in items { + println!(" {item}"); + } + } +}