Build a production-ready CLI for any API in minutes. This guide walks you through turning nucleo into your own branded CLI tool — whether you're building a Spotify controller, a GitHub manager, or a wrapper for your company's internal API.
By the end of this guide, your CLI will have:
- Commands that talk to your API (with auth, pagination, and error handling)
- OAuth2 login (or API key auth — your choice)
- Multiple output formats (JSON, table, YAML, CSV)
- Shell completions (bash, zsh, fish, powershell)
- Claude Desktop integration via MCP
- A plugin system for community extensions
- CI/CD with GitHub Actions
- Rust 1.85+ — install via rustup
- Git — to clone the repo
- An API to wrap — any REST API with documentation
| I want to... | Do this |
|---|---|
| Build a CLI for a well-known API (Spotify, GitHub, Stripe, etc.) | Jump to Option A: Well-Known API |
| Build a CLI from an OpenAPI/Swagger spec | Jump to Option B: From an API Spec |
| Build a CLI for a custom/internal API | Jump to Option C: Custom API |
| Use Claude Code to do it all automatically | Jump to The Fast Way: Claude Code |
git clone https://github.com/mateonunez/nucleo.git my-cli
cd my-cli
cargo build # verify everything compilesEvery nucleo CLI is defined by 4 constants. Pick your names:
| Constant | What it does | Example (Spotify) |
|---|---|---|
APP_NAME |
Display name in help text | spotify-cli |
APP_DIR |
Config folder name (~/.config/<this>/) |
spotify-cli |
APP_PREFIX |
Env var prefix (<THIS>_TOKEN, etc.) |
SPOTIFY_CLI |
APP_BIN |
Binary name | spotify-cli |
Edit src/consts.rs:
pub const APP_NAME: &str = "spotify-cli";
pub const APP_DIR: &str = "spotify-cli";
pub const APP_PREFIX: &str = "SPOTIFY_CLI";
pub const APP_BIN: &str = "spotify-cli";Edit Cargo.toml:
[package]
name = "spotify-cli"
description = "Control Spotify from the terminal"
[[bin]]
name = "spotify-cli"
path = "src/main.rs"Edit src/main.rs — update the CLI struct:
#[command(
name = "spotify-cli",
version,
about = "spotify-cli — Control Spotify from the terminal",
long_about = None,
arg_required_else_help = true
)]Also update the shell completions line in the same file:
generate(*shell, &mut Cli::command(), "spotify-cli", &mut io::stdout());Verify it compiles:
cargo checknucleo ships with two example commands (ping and echo) that demonstrate the HTTP patterns. Remove them:
-
Delete the files:
rm src/commands/ping.rs src/commands/echo.rs
-
Edit
src/commands/mod.rs— remove these lines:pub mod echo; pub mod ping;
-
Edit
src/main.rs— remove thePingandEchovariants from theCommandenum and their match arms in the dispatch block. -
Verify:
cargo check
nucleo supports three auth methods out of the box. Choose the one your API uses.
If you're building for one of these APIs, the auth details are ready to go:
| API | Auth Method | Registration URL |
|---|---|---|
| Spotify | OAuth2 PKCE | developer.spotify.com/dashboard |
| GitHub | OAuth2 / Bearer | github.com/settings/developers |
| Stripe | API Key (Bearer) | dashboard.stripe.com/apikeys |
| Slack | OAuth2 | api.slack.com/apps |
| Discord | OAuth2 | discord.com/developers/applications |
| OpenAI | API Key (Bearer) | platform.openai.com/api-keys |
| Anthropic | API Key (x-api-key) | console.anthropic.com/settings/keys |
| Notion | OAuth2 | notion.so/my-integrations |
| Linear | OAuth2 | linear.app/settings/api |
| Vercel | API Key (Bearer) | vercel.com/account/tokens |
| OAuth2 | console.cloud.google.com/apis/credentials | |
| Cloudflare | API Key (Bearer) | dash.cloudflare.com/profile/api-tokens |
| Twilio | Basic Auth | console.twilio.com |
| Twitter/X | OAuth2 PKCE | developer.twitter.com/en/portal |
If your API has an OpenAPI or Swagger spec, check the securitySchemes section — it will tell you which auth method to use.
Ask your API provider which auth method they use, then follow the matching section below.
For APIs like Spotify, GitHub, Slack, Discord, Google, Twitter/X, etc.
This is the most common auth method for APIs that act on behalf of a user.
-
Register your app with the API provider (see the registration URL in the table above)
-
Set the redirect URI to
http://127.0.0.1:8888/callback -
Copy your client_id
-
Create your
config.json:
{
"urls": {},
"active_env": "production",
"presets": {
"production": {
"urls": {
"api": "https://api.spotify.com/v1"
},
"auth_method": "oauth2",
"oauth2": {
"client_id": "YOUR_CLIENT_ID_HERE",
"authorize_url": "https://accounts.spotify.com/authorize",
"token_url": "https://accounts.spotify.com/api/token",
"redirect_path": "/callback",
"scopes": [
"user-read-playback-state",
"playlist-read-private",
"user-library-read"
]
}
}
},
"plugins": { "directory": null, "registries": [] }
}- Copy it to your config directory:
mkdir -p ~/.config/spotify-cli
cp config.json ~/.config/spotify-cli/config.jsonWhen you run your CLI, users authenticate with:
spotify-cli auth login
# Opens browser → user authorizes → token saved automaticallyFor APIs like Stripe, OpenAI, Vercel, Cloudflare, etc.
No OAuth2 dance needed. Users just set an environment variable.
- Create your
config.json:
{
"urls": {
"api": "https://api.openai.com/v1"
},
"active_env": "",
"presets": {},
"plugins": { "directory": null, "registries": [] }
}- Users authenticate by setting:
export OPENAI_CLI_TOKEN="sk-your-api-key-here"Or by running:
openai-cli auth loginFor APIs like Twilio that use username:password.
- Create your
config.jsonwith anauthURL in the preset:
{
"urls": {},
"active_env": "production",
"presets": {
"production": {
"auth": "https://api.twilio.com/2010-04-01",
"api": "https://api.twilio.com/2010-04-01"
}
},
"plugins": { "directory": null, "registries": [] }
}- Users authenticate with:
twilio-cli auth login --username ACXXXXXXX
# Prompted for password securelyThis is where your CLI comes to life. Each command is a Rust file in src/commands/.
This is the most common pattern — fetch data from your API and display it.
Create src/commands/playlists.rs:
use clap::Args;
use crate::client;
use crate::config;
use crate::error::CliError;
use crate::formatter::{self, OutputFormat};
#[derive(Args, Debug)]
pub struct PlaylistsArgs {
/// Output format
#[arg(long, default_value = "json")]
pub format: String,
}
pub async fn handle(args: &PlaylistsArgs) -> Result<(), CliError> {
let urls = config::load_service_urls()?;
let url = config::require_url(&urls, "api")?;
let endpoint = format!("{url}/me/playlists");
let http = client::build_client()?;
let resp = client::send_authenticated(&http, |token| {
http.get(&endpoint).bearer_auth(token)
})
.await?;
let body = client::handle_api_response(resp).await?;
let fmt = OutputFormat::from_str(&args.format);
println!("{}", formatter::format_value(&body, &fmt));
Ok(())
}For endpoints that take IDs or query parameters:
Create src/commands/playlist.rs:
use clap::Args;
use crate::client;
use crate::config;
use crate::error::CliError;
use crate::formatter::{self, OutputFormat};
#[derive(Args, Debug)]
pub struct PlaylistArgs {
/// Playlist ID
pub id: String,
/// Maximum number of tracks to return
#[arg(long, default_value = "20")]
pub limit: u32,
/// Output format
#[arg(long, default_value = "json")]
pub format: String,
}
pub async fn handle(args: &PlaylistArgs) -> Result<(), CliError> {
let urls = config::load_service_urls()?;
let url = config::require_url(&urls, "api")?;
let endpoint = format!("{url}/playlists/{}/tracks", args.id);
let http = client::build_client()?;
let limit = args.limit;
let resp = client::send_authenticated(&http, |token| {
http.get(&endpoint)
.bearer_auth(token)
.query(&[("limit", limit.to_string())])
})
.await?;
let body = client::handle_api_response(resp).await?;
let fmt = OutputFormat::from_str(&args.format);
println!("{}", formatter::format_value(&body, &fmt));
Ok(())
}For resources that have multiple operations (list, get, create, etc.), use the subcommand pattern:
Create src/commands/player.rs:
use clap::Subcommand;
use crate::client;
use crate::config;
use crate::error::CliError;
use crate::formatter::{self, OutputFormat};
#[derive(Subcommand, Debug)]
pub enum PlayerCommand {
/// Get current playback state
Status {
#[arg(long, default_value = "json")]
format: String,
},
/// Start or resume playback
Play,
/// Pause playback
Pause,
}
pub async fn handle(cmd: &PlayerCommand) -> Result<(), CliError> {
match cmd {
PlayerCommand::Status { format } => status(format).await,
PlayerCommand::Play => play().await,
PlayerCommand::Pause => pause().await,
}
}
async fn status(format: &str) -> Result<(), CliError> {
let urls = config::load_service_urls()?;
let url = config::require_url(&urls, "api")?;
let endpoint = format!("{url}/me/player");
let http = client::build_client()?;
let resp = client::send_authenticated(&http, |token| {
http.get(&endpoint).bearer_auth(token)
})
.await?;
let body = client::handle_api_response(resp).await?;
let fmt = OutputFormat::from_str(format);
println!("{}", formatter::format_value(&body, &fmt));
Ok(())
}
async fn play() -> Result<(), CliError> {
let urls = config::load_service_urls()?;
let url = config::require_url(&urls, "api")?;
let endpoint = format!("{url}/me/player/play");
let http = client::build_client()?;
let resp = client::send_authenticated(&http, |token| {
http.put(&endpoint).bearer_auth(token)
})
.await?;
client::handle_api_response(resp).await?;
println!("Playback started.");
Ok(())
}
async fn pause() -> Result<(), CliError> {
let urls = config::load_service_urls()?;
let url = config::require_url(&urls, "api")?;
let endpoint = format!("{url}/me/player/pause");
let http = client::build_client()?;
let resp = client::send_authenticated(&http, |token| {
http.put(&endpoint).bearer_auth(token)
})
.await?;
client::handle_api_response(resp).await?;
println!("Playback paused.");
Ok(())
}For endpoints that accept a JSON body:
Create src/commands/create_playlist.rs:
use clap::Args;
use serde_json::Value;
use crate::client;
use crate::config;
use crate::error::CliError;
use crate::formatter::{self, OutputFormat};
#[derive(Args, Debug)]
pub struct CreatePlaylistArgs {
/// Raw JSON body (overrides individual flags)
#[arg(long)]
pub data: Option<String>,
/// Playlist name
#[arg(long)]
pub name: Option<String>,
/// Playlist description
#[arg(long)]
pub description: Option<String>,
/// Make the playlist public
#[arg(long)]
pub public: bool,
/// Output format
#[arg(long, default_value = "json")]
pub format: String,
}
pub async fn handle(args: &CreatePlaylistArgs) -> Result<(), CliError> {
let urls = config::load_service_urls()?;
let url = config::require_url(&urls, "api")?;
let endpoint = format!("{url}/me/playlists");
// Build body from --data or individual flags
let body: Value = if let Some(ref raw) = args.data {
serde_json::from_str(raw)
.map_err(|e| CliError::Validation(format!("Invalid JSON in --data: {e}")))?
} else {
let mut obj = serde_json::Map::new();
if let Some(ref name) = args.name {
obj.insert("name".into(), Value::String(name.clone()));
}
if let Some(ref desc) = args.description {
obj.insert("description".into(), Value::String(desc.clone()));
}
obj.insert("public".into(), Value::Bool(args.public));
if !obj.contains_key("name") {
return Err(CliError::Validation(
"Provide --name or --data '{\"name\": \"...\"}'".into(),
));
}
Value::Object(obj)
};
let http = client::build_client()?;
let resp = client::send_authenticated(&http, |token| {
http.post(&endpoint).bearer_auth(token).json(&body)
})
.await?;
let result = client::handle_api_response(resp).await?;
let fmt = OutputFormat::from_str(&args.format);
println!("{}", formatter::format_value(&result, &fmt));
Ok(())
}For each command file you created:
- Add the module to
src/commands/mod.rs:
pub mod playlists;
pub mod playlist;
pub mod player;
pub mod create_playlist;- Add the variant to the
Commandenum insrc/main.rs:
#[derive(Subcommand, Debug)]
enum Command {
// ... existing framework commands (auth, config, status, etc.) ...
/// List your playlists
Playlists(commands::playlists::PlaylistsArgs),
/// Get playlist tracks
Playlist(commands::playlist::PlaylistArgs),
/// Control playback
Player {
#[command(subcommand)]
command: commands::player::PlayerCommand,
},
/// Create a new playlist
CreatePlaylist(commands::create_playlist::CreatePlaylistArgs),
}- Add the dispatch arm in the
matchblock:
let result = match &cli.command {
// ... existing arms ...
Command::Playlists(args) => commands::playlists::handle(args).await,
Command::Playlist(args) => commands::playlist::handle(args).await,
Command::Player { command } => commands::player::handle(command).await,
Command::CreatePlaylist(args) => commands::create_playlist::handle(args).await,
};- Verify:
cargo checkCreate a .env.example so users know which env vars are available:
# spotify-cli environment overrides
# API token (skips login)
SPOTIFY_CLI_TOKEN=
# Override API base URL
SPOTIFY_CLI_API_URL=https://api.spotify.com/v1
# Project context (optional)
SPOTIFY_CLI_PROJECT_ID=
SPOTIFY_CLI_ENV_ID=
SPOTIFY_CLI_STAGE=# Compile
cargo build
# Run tests
cargo test
# Check for warnings
cargo clippy -- -D warnings
# See your CLI in action
cargo run -- --help
cargo run -- player status --format table# Install to your PATH
cargo install --path .
# Now use it directly
spotify-cli --help
spotify-cli auth login
spotify-cli playlists --format tableWhen forking nucleo, you keep all the framework infrastructure and replace only the domain-specific parts.
| Component | Why |
|---|---|
src/error.rs |
Error handling with typed exit codes |
src/formatter.rs |
6 output formats (JSON, table, YAML, CSV, IDs, Slack) |
src/client.rs |
HTTP client with retry, token refresh, 401 retry |
src/config.rs |
Layered config system with presets |
src/oauth2.rs |
OAuth2 Authorization Code + PKCE |
src/types/ |
Auth, pagination, config types |
src/mcp/ |
MCP server for Claude Desktop |
src/commands/auth.rs |
Login/logout/token |
src/commands/config_cmd.rs |
Config management |
src/commands/status.rs |
System status overview |
src/commands/plugins.rs |
Plugin lifecycle |
src/commands/setup.rs |
Interactive setup wizard |
src/commands/mcp_cmd.rs |
MCP server launcher |
| Component | What to change |
|---|---|
src/consts.rs |
Your 4 identity constants |
Cargo.toml |
Package name, description, binary name |
src/main.rs |
CLI name, about text, Command enum, dispatch |
src/commands/ping.rs |
Delete (example command) |
src/commands/echo.rs |
Delete (example command) |
config.json |
Your API URLs and auth config |
.env.example |
Your env var prefix |
src/mcp/tools.rs |
Your MCP tools (optional) |
MCP tools let your CLI work with Claude Desktop. For each command, add a tool to src/mcp/tools.rs:
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct PlaylistsParams {
/// Maximum number of playlists to return
pub limit: Option<u32>,
}
#[tool(
name = "spotify_playlists",
description = "List the user's Spotify playlists"
)]
async fn tool_playlists(
&self,
Parameters(params): Parameters<PlaylistsParams>,
) -> String {
let mut args = vec!["playlists".to_string(), "--format".to_string(), "json".to_string()];
if let Some(limit) = params.limit {
args.push("--limit".to_string());
args.push(limit.to_string());
}
self.run_owned(&args).await
}Then update the server info in src/mcp/mod.rs to match your CLI name.
Configure Claude Desktop to use your CLI:
{
"mcpServers": {
"spotify-cli": {
"command": "spotify-cli",
"args": ["mcp"]
}
}
}Or run the setup wizard:
spotify-cli setup --claude-desktopMany APIs return paginated results. Add --limit, --offset, and --all flags:
#[derive(Args, Debug)]
pub struct ListArgs {
#[arg(long, default_value = "20")]
pub limit: u32,
#[arg(long, default_value = "0")]
pub offset: u32,
/// Fetch all pages automatically
#[arg(long)]
pub all: bool,
#[arg(long, default_value = "json")]
pub format: String,
}When --all is set, loop through pages until the API returns an empty result or no next URL.
For search commands, add a required query argument:
#[derive(Args, Debug)]
pub struct SearchArgs {
/// Search query
pub query: String,
/// Type of item to search for
#[arg(long, default_value = "track")]
pub item_type: String,
#[arg(long, default_value = "json")]
pub format: String,
}If your API has staging/production environments, add multiple presets:
{
"presets": {
"production": {
"urls": { "api": "https://api.example.com/v1" },
"auth_method": "oauth2",
"oauth2": { "..." : "..." }
},
"staging": {
"urls": { "api": "https://api.staging.example.com/v1" },
"auth_method": "oauth2",
"oauth2": { "..." : "..." }
}
}
}Switch between them:
spotify-cli config env use stagingIf you use Claude Code, you can skip most of this guide.
npx skills add mateonunez/nucleoThis installs all nucleo skills, including /create-cli. Browse them at skills.sh/mateonunez/nucleo.
/create-cli
Claude will ask you 3 questions (CLI name, description, target API), then automatically:
- Discover your API endpoints (from OpenAPI specs, documentation pages, or built-in profiles for 15+ popular APIs)
- Configure authentication
- Generate all command files with proper error handling
- Create MCP tools for Claude Desktop
- Write tests
- Update the README
- Verify the build compiles
It supports OpenAPI/Swagger specs, live documentation URLs (ReadMe, Redoc, Swagger UI, Postman collections), and well-known APIs (Spotify, GitHub, Stripe, Slack, and more).
| Problem | Solution |
|---|---|
cargo check fails after removing ping/echo |
Make sure you removed all references from mod.rs and main.rs (both the enum variant and the match arm) |
| "Not authenticated" error | Run spotify-cli auth login or set SPOTIFY_CLI_TOKEN |
| "No 'api' URL configured" | Check ~/.config/spotify-cli/config.json has the api URL |
| OAuth2 login opens browser but nothing happens | Verify your redirect URI is set to http://127.0.0.1:8888/callback in your OAuth2 app settings |
| Commands return 401 | Your token may have expired. Run spotify-cli auth login again |
cargo clippy warnings |
Fix all warnings before shipping — nucleo's CI runs clippy with -D warnings |
- Add more commands as you discover new endpoints
- Create plugins for features in other languages
- Add templates for project scaffolding
- Run benchmarks to measure performance
- Set up CI/CD — nucleo's
.github/workflows/are ready to use
This guide covers the manual process. For the automated path, use Claude Code's /create-cli skill.