Skip to content

How to Build Custom CLI Tools for Claude Code?

My team needed Claude to orchestrate workflows across Slack, Bitbucket, Google Docs, Harvest, and half a dozen other services. Problem: most of these services lack official command-line interfaces. I discovered a solution that changed our productivity: vibe-coding custom CLI wrappers.

Before: Claude couldn't interact with 80% of our tools
After: Claude orchestrates workflows across ALL services
Time investment: 2-4 hours per CLI wrapper

The Agent-Friendly CLI Pattern

I realized that CLIs designed for humans don’t work well for agents. The fundamental difference lies in how they handle output, interactivity, and errors.

┌─────────────────┬─────────────────────┬──────────────────────────┐
│ Feature │ Human CLI │ Agent-Friendly CLI │
├─────────────────┼─────────────────────┼──────────────────────────┤
│ Output format │ Human-readable text │ Structured JSON │
│ Interactivity │ Prompts for missing │ Non-interactive flags │
│ │ info │ │
│ Error handling │ Vague messages │ Specific error codes │
│ Documentation │ Man pages │ Inline JSON schema │
│ Authentication │ Interactive prompts │ Environment variables │
└─────────────────┴─────────────────────┴──────────────────────────┘

A Reddit user in r/ClaudeCode shared their team’s approach: “If a service does not have a CLI? You can have Claude vibe code one in an afternoon.” This insight led me to build wrappers for all our internal tools.

Building a Minimal Agent-Friendly CLI Wrapper

I started with a simple Node.js wrapper for a fictional project management API. Here’s the complete implementation:

pm-cli.js
#!/usr/bin/env node
import { program } from 'commander';
import chalk from 'chalk';
const API_BASE = process.env.PM_API_BASE || 'https://api.example.com';
const API_TOKEN = process.env.PM_API_TOKEN;
if (!API_TOKEN) {
console.error(JSON.stringify({
error: 'PM_API_TOKEN environment variable not set',
code: 'AUTH_MISSING'
}));
process.exit(1);
}
program
.name('pm-cli')
.description('Project Management CLI for Claude Code agents')
.option('--json', 'Output as JSON (default: true)', true)
.option('--no-json', 'Output as human-readable text');
program
.command('list-projects')
.description('List all projects')
.option('--status <status>', 'Filter by status')
.action(async (options) => {
try {
const params = new URLSearchParams();
if (options.status) params.append('status', options.status);
const response = await fetch(`${API_BASE}/projects?${params}`, {
headers: { 'Authorization': `Bearer ${API_TOKEN}` }
});
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const projects = await response.json();
if (program.opts().json) {
console.log(JSON.stringify({
success: true,
data: projects,
meta: { count: projects.length }
}, null, 2));
} else {
console.log(chalk.bold('Projects:'));
projects.forEach(p => console.log(` - ${p.name} (${p.status})`));
}
} catch (error) {
if (program.opts().json) {
console.log(JSON.stringify({
success: false,
error: error.message,
code: 'API_ERROR'
}));
process.exit(1);
} else {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
}
});
program
.command('create-task')
.description('Create a new task')
.requiredOption('--project <id>', 'Project ID')
.requiredOption('--title <title>', 'Task title')
.option('--description <desc>', 'Task description')
.option('--priority <priority>', 'Task priority (low/medium/high)', 'medium')
.action(async (options) => {
try {
const response = await fetch(`${API_BASE}/tasks`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
project_id: options.project,
title: options.title,
description: options.description,
priority: options.priority
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API returned ${response.status}`);
}
const task = await response.json();
if (program.opts().json) {
console.log(JSON.stringify({
success: true,
data: task
}, null, 2));
} else {
console.log(chalk.green(`Created task: ${task.id}`));
}
} catch (error) {
if (program.opts().json) {
console.log(JSON.stringify({
success: false,
error: error.message,
code: 'CREATE_ERROR'
}));
process.exit(1);
} else {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
}
});
program.parse();

Key design decisions I made:

1. JSON output by default (--json flag, easy to parse)
2. Non-interactive mode (all inputs via flags)
3. Specific error codes (AUTH_MISSING, API_ERROR, CREATE_ERROR)
4. Environment variable authentication (no prompts)
5. Consistent output structure (success, data, error, code)

Registering as MCP Server

I registered the CLI as an MCP server so Claude Code could discover and use it automatically:

.mcp.json
{
"mcpServers": {
"pm-server": {
"command": "node",
"args": ["/path/to/pm-cli.js"],
"env": {
"PM_API_TOKEN": "${PM_API_TOKEN}",
"PM_API_BASE": "https://api.example.com"
}
}
}
}

This configuration tells Claude Code where to find the CLI and which environment variables it needs.

Creating a Skill Wrapper

I organized the CLI integration into a skill for better discoverability:

SKILL.md
# Project Management CLI Skill
Use this skill to interact with the Project Management API through Claude Code.
## Available Commands
### List Projects
```bash
pm-cli list-projects [--status <status>]

Create Task

Terminal window
pm-cli create-task --project <id> --title <title> [--description <desc>] [--priority <priority>]

Environment Variables Required

  • PM_API_TOKEN: API authentication token
  • PM_API_BASE: API base URL (optional, has default)

Output Format

All commands return JSON with the following structure:

{
"success": true,
"data": {...},
"error": null,
"code": null
}

Error Handling

CodeDescription
AUTH_MISSINGPM_API_TOKEN not set
API_ERRORAPI request failed
CREATE_ERRORFailed to create resource
I placed this in my skills directory:
```text
~/.claude/skills/
└── pm-cli/
├── SKILL.md
├── .mcp.json
└── pm-cli.js

Design Principles I Followed

Principle 1: JSON Output by Default

I made JSON the default output format. Agents parse structured data reliably, while humans can use --no-json for readable output.

output-format.js
// Agent-friendly (default)
console.log(JSON.stringify({ success: true, data: result }));
// Human-friendly (optional)
console.log(`Created task: ${result.id}`);

Principle 2: Non-Interactive Mode

I avoided any prompts for user input. Every required parameter has a flag:

usage-examples.sh
# Good: All inputs via flags
pm-cli create-task --project proj-123 --title "Fix login bug"
# Bad: Would require interactive prompt
pm-cli create-task # Prompts: "Enter project ID:"

Principle 3: Specific Error Codes

I used error codes instead of vague messages:

error-codes.js
// Vague (bad for agents)
console.error('Something went wrong');
// Specific (good for agents)
console.log(JSON.stringify({
success: false,
error: 'Project not found',
code: 'PROJECT_NOT_FOUND'
}));
process.exit(1);

Principle 4: Schema Documentation

I documented the expected output schema directly in the skill:

output-schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["success"],
"properties": {
"success": { "type": "boolean" },
"data": { "type": "object" },
"error": { "type": ["string", "null"] },
"code": { "type": ["string", "null"] },
"meta": {
"type": "object",
"properties": {
"count": { "type": "number" }
}
}
}
}

Common Mistakes I Avoided

I learned these pitfalls from experience:

❌ Copying human CLI patterns (spinners, colored output, prompts)
❌ Missing authentication handling (forgot to check env vars)
❌ Inconsistent output format (sometimes JSON, sometimes plain text)
❌ Skipping MCP integration (harder for Claude to discover)
❌ No error codes (hard to debug programmatically)

The Productivity Impact

After building these wrappers, my team’s productivity improved significantly. Claude could now orchestrate workflows across all our tools:

Example workflow Claude now handles:
1. Check Harvest for time entries
2. Create tasks in project management tool
3. Post summary to Slack
4. Update Google Sheets with metrics

All of this happens without human intervention. The CLI wrappers translate between Claude’s agent logic and each service’s API.

Summary

In this post, I showed how to build custom CLI tools for Claude Code in hours using vibe coding. The key insight: design CLIs for agents, not humans. Use JSON output by default, non-interactive flags, specific error codes, and consistent schemas. Register them as MCP servers for automatic discovery. This approach unlocks Claude’s ability to orchestrate workflows across any service, even those without official CLIs.

The next time you encounter a service without a CLI, describe the requirements to Claude Code. Let it generate the wrapper. Test and iterate. Deploy as an MCP server. Repeat for every tool in your stack.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments