feat: scaffold nani CLI + MCP server #2

Merged
madcat merged 1 commits from feature/initial-build into main 2026-06-12 20:02:29 +00:00
10 changed files with 1342 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
/target
Cargo.lock
+26
View File
@@ -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
+63 -1
View File
@@ -1,3 +1,65 @@
# nani # nani
Secrets, config, and file store — Rust CLI, M2M, and MCP server for config.saiden.dev 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 <api-key> # 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 <project> --gitea <url> # set project metadata
nani projects rm <project> # delete project
# Secrets
nani ls <project> <env> # list keys
nani get <project> <env> <key> # get secret
nani set <project> <env> <key> <value> # set secret
nani set <project> <env> <key> -f <file> # set from file
nani rm <project> <env> <key> # delete secret
# Files
nani files ls <project> <env> # list files
nani files get <project> <env> <path> # download file
nani files put <project> <env> <path> -f <file> # upload file
nani files rm <project> <env> <path> # 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 <base>` | Override base URL |
| `--api-key <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
+148
View File
@@ -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<String>,
/// Override API key (one-shot)
#[arg(long = "api-key", global = true)]
pub api_key_override: Option<String>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
/// Store API key to config
Auth {
#[command(subcommand)]
action: Option<AuthAction>,
/// API key to store (shorthand for `nani auth set <key>`)
#[arg(value_name = "API_KEY")]
api_key: Option<String>,
},
/// 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<String>,
/// Read value from file
#[arg(short = 'f', long)]
file: Option<String>,
},
/// List projects or secrets
Ls {
/// Project name
project: Option<String>,
/// Environment name
env: Option<String>,
},
/// Delete a secret
Rm {
project: String,
env: String,
key: String,
},
/// File operations
Files {
#[command(subcommand)]
action: FileAction,
},
/// Project operations
Projects {
#[command(subcommand)]
action: Option<ProjectAction>,
},
/// 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<String>,
/// Inline content
content: Option<String>,
},
/// 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<String>,
#[arg(long)]
desc: Option<String>,
},
/// Delete project
Rm {
project: String,
},
}
+308
View File
@@ -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 <key>` 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<String>,
}
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<Value, ApiError> {
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::<Vec<_>>(),
"body": dr.body,
}))
}
async fn request(
&self,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value, ApiError> {
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::<Value>(&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<Vec<u8>, 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<Value, ApiError> {
self.request(Method::GET, "/projects", None).await
}
pub async fn get_project_meta(&self, project: &str) -> Result<Value, ApiError> {
self.request(Method::GET, &format!("/{project}/meta"), None)
.await
}
pub async fn set_project_meta(&self, project: &str, body: Value) -> Result<Value, ApiError> {
self.request(Method::PUT, &format!("/{project}/meta"), Some(body))
.await
}
pub async fn delete_project(&self, project: &str) -> Result<Value, ApiError> {
self.request(Method::DELETE, &format!("/{project}/meta"), None)
.await
}
// === Secrets ===
pub async fn list_secrets(&self, project: &str, env: &str) -> Result<Value, ApiError> {
self.request(Method::GET, &format!("/{project}/{env}/secrets"), None)
.await
}
pub async fn get_secret(
&self,
project: &str,
env: &str,
key: &str,
) -> Result<Value, ApiError> {
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<Value, ApiError> {
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<Value, ApiError> {
self.request(
Method::DELETE,
&format!("/{project}/{env}/secrets/{key}"),
None,
)
.await
}
// === Files ===
pub async fn list_files(&self, project: &str, env: &str) -> Result<Value, ApiError> {
self.request(Method::GET, &format!("/{project}/{env}/files"), None)
.await
}
pub async fn get_file(
&self,
project: &str,
env: &str,
path: &str,
) -> Result<Vec<u8>, 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<Vec<u8>, 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<Value, ApiError> {
self.request(
Method::DELETE,
&format!("/{project}/{env}/files/{path}"),
None,
)
.await
}
}
+404
View File
@@ -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 <key>` 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<AuthAction>,
api_key: Option<String>,
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 <api-key> or nani auth show");
1
}
}
}
}
async fn run_get(
client: &ApiClient,
project: &str,
env: &str,
key: &str,
out: &OutputMode,
) -> Result<i32, ApiError> {
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<String>,
file: Option<String>,
out: &OutputMode,
) -> Result<i32, ApiError> {
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 <file>");
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<String>,
env: Option<String>,
out: &OutputMode,
) -> Result<i32, ApiError> {
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<String> = 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<String> = 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<i32, ApiError> {
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<i32, ApiError> {
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 <file>");
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::<Value>(&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<String> = 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<ProjectAction>,
out: &OutputMode,
) -> Result<i32, ApiError> {
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)
}
}
}
+87
View File
@@ -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<PathBuf> {
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<String>) -> Option<String> {
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>) -> 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..])
}
}
+15
View File
@@ -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);
}
+193
View File
@@ -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<String>,
/// Environment name (omit to show project meta)
pub env: Option<String>,
}
#[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<Self>,
}
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<GetParams>) -> 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<SetParams>) -> 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<ListParams>) -> 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<DeleteParams>) -> 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<FileGetParams>) -> 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<FilePutParams>) -> 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;
}
+96
View File
@@ -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}");
}
}
}