Merge pull request 'feat: scaffold nani CLI + MCP server' (#2) from feature/initial-build into main
feat: scaffold nani CLI + MCP server (#2) Closes #1
This commit was merged in pull request #2.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
+26
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user