How to Build a Claude CLI Tool for Your Terminal
If you want to build a Claude CLI tool, the fastest path is: pick a language runtime with good process/stdio support (Node.js is the most common choice), wrap Claude's chat completion endpoint in a small HTTP client, parse arguments with a lightweight CLI framework, and stream the response token-by-token to stdout so the tool feels responsive. That's the whole architecture — everything else is polish.
This guide walks through that architecture step by step: setting up the project, handling authentication, streaming responses, managing conversation state across commands, and packaging the tool so other people can install it with a single command. By the end you'll have a working claude command you can run from any terminal.
Project setup
Start with a standard Node.js CLI skeleton. You don't need a framework to get going — a single file with a shebang line works fine for prototyping.
mkdir claude-cli && cd claude-cli
npm init -y
npm install commander node-fetch dotenv
commander handles argument parsing, node-fetch makes HTTP requests, and dotenv loads your API key from a local .env file during development.
Create a bin/claude.js entry point:
#!/usr/bin/env node
require('dotenv').config();
const { program } = require('commander');
program
.name('claude')
.description('Talk to Claude from your terminal')
.argument('<prompt>', 'the message to send')
.option('-m, --model <model>', 'model name', 'claude-sonnet')
.action(async (prompt, opts) => {
await runPrompt(prompt, opts.model);
});
program.parse();
Reference it in package.json so npm link turns it into a global command:
{
"bin": { "claude": "./bin/claude.js" }
}
Run npm link locally and claude "explain async/await" should resolve to your script — even if runPrompt isn't implemented yet.
Authentication and request handling
A CLI tool needs an API key the user controls, stored outside the repo. The standard pattern is an environment variable plus an optional config file:
function getApiKey() {
if (process.env.SUBTOAPI_KEY) return process.env.SUBTOAPI_KEY;
const configPath = path.join(os.homedir(), '.claude-cli', 'config.json');
if (fs.existsSync(configPath)) {
return JSON.parse(fs.readFileSync(configPath, 'utf8')).apiKey;
}
throw new Error('No API key found. Run `claude config set-key <key>`');
}
This matters more than it looks: distributing a CLI tool means you can't assume a .env file exists on the user's machine. A home-directory config file (~/.claude-cli/config.json) is the convention used by most CLI tools (git, npm, aws-cli all do some variant of this).
If you're building against Claude directly, you'll need to handle Anthropic's raw messages API, request signing, and model-specific headers yourself. If you'd rather skip that layer, SubToAPI gives you a single HTTPS API with a sub_live_... key that proxies your Claude access — so your CLI's auth logic is just a bearer token, with no separate key rotation or header management to maintain. See the quickstart for the exact request format.
Streaming output to the terminal
A CLI that prints the full response only after it's done feels slow. Streaming makes it feel alive — tokens appear as they're generated, the same way Claude's own interface works.
async function runPrompt(prompt, model) {
const res = await fetch('https://api.subtoapi.app/v1/messages', {
method: 'POST',
headers: {
'Authorization': `Bearer ${getApiKey()}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model,
stream: true,
messages: [{ role: 'user', content: prompt }]
})
});
for await (const chunk of res.body) {
const lines = chunk.toString().split('\n').filter(Boolean);
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const data = JSON.parse(line.replace('data: ', ''));
if (data.delta?.text) process.stdout.write(data.delta.text);
}
}
process.stdout.write('\n');
}
This parses server-sent events and writes text deltas directly to stdout as they arrive. Full details on the event format are in the streaming docs.
Keeping conversation state between commands
A one-shot prompt tool is useful, but most people want a CLI that remembers context across invocations, like a persistent session. The simplest implementation stores the message history in a local JSON file:
const historyPath = path.join(os.homedir(), '.claude-cli', 'history.json');
function loadHistory() {
if (!fs.existsSync(historyPath)) return [];
return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
}
function saveHistory(messages) {
fs.mkdirSync(path.dirname(historyPath), { recursive: true });
fs.writeFileSync(historyPath, JSON.stringify(messages, null, 2));
}
Append each user message and assistant reply to this array before sending the next request, and pass the full array as messages in the request body. Add a claude reset subcommand that clears the file, and a claude --new flag for one-off prompts that skip history entirely.
Adding tool use (optional but powerful)
If your CLI needs Claude to run commands, query a database, or hit an internal API rather than just answer in text, you'll want tool/function calling. Define a tool schema, pass it in the request, and handle the tool_use block Claude returns by executing the corresponding function locally and sending the result back in a follow-up message. The tools documentation covers the request/response shape in detail — the pattern is the same whether you're calling Claude directly or through a proxy.
Packaging and distribution
Once the tool works locally, publishing it is straightforward:
npm login
npm publish --access public
Users then install it globally with npm install -g your-claude-cli and run it as a normal shell command. Add a postinstall script that prompts for an API key on first run, and a --help output via commander's built-in handling — both make the difference between a tool people use once and one they keep around.
Why route through an API layer instead of raw credentials
Building directly against a model provider's SDK works fine for personal scripts, but if you're distributing the CLI to a team or shipping it as a product, you probably don't want to hand out raw provider credentials to every user. Routing requests through an application-scoped key — like the ones SubToAPI issues — means you can revoke, rate-limit, and monitor usage per CLI installation without touching your underlying Claude access. Pricing starts at €9/month for solo use, with team seats for shared CLI deployments.
questions
Do I need Node.js specifically to build a Claude CLI tool? No — Python, Go, and Rust all work well. Node.js is popular because commander/yargs make argument parsing fast to set up and npm makes distribution trivial, but the HTTP and streaming logic translates directly to any language with an HTTP client.
How do I handle API keys securely in a distributed CLI? Store them in a user-owned config file (e.g. ~/.claude-cli/config.json) or environment variable, never hardcode them in the package, and give users a config set-key command so the key never touches version control.
Can I add streaming output without a framework? Yes. Streaming only requires reading the HTTP response body as a stream and parsing server-sent events as they arrive — no framework is needed, just fetch or an equivalent HTTP client with stream support.