Claude Code से CLI टूल बनाना: Node.js, TypeScript, टेस्ट और सुरक्षित रिलीज
Claude Code से Node.js/TypeScript CLI बनाएं: arguments, config, stdin/stdout, exit codes, tests, CI और secrets.
CLI टूल अक्सर एक छोटे निजी script से शुरू होता है। फिर उसमें flags जुड़ते हैं, config file आती है, CI में चलाना पड़ता है, npm पर बांटना पड़ता है और टीम के दूसरे लोग भी उसे इस्तेमाल करने लगते हैं। तब सिर्फ “मेरे सिस्टम पर चल रहा है” काफी नहीं होता। अच्छे CLI में साफ argument parsing, machine-readable stdout, warning के लिए stderr, सही exit codes, tests, dry-run mode और secrets की सुरक्षित handling होनी चाहिए।
Claude Code इस काम के लिए उपयोगी है, क्योंकि CLI development में कई files साथ बदलती हैं: package.json, TypeScript entry file, tests, GitHub Actions और documentation। लेकिन अगर prompt सिर्फ “एक CLI बना दो” है, तो अक्सर demo code मिलता है: help दिखती है, पर output contract, release safety और secrets के rules कमजोर रहते हैं।
इस guide में हम clipilot नाम का छोटा Node.js/TypeScript CLI बनाएंगे। मकसद सिर्फ तेज code generation नहीं है। मकसद है Claude Code से ऐसा code निकलवाना जिसे review, test और CI में भरोसे से चलाया जा सके।
Claude Code को स्पष्ट prompt दें
stdin standard input है, stdout standard output है, stderr error output है, और exit code वह number है जिससे shell या CI decide करता है कि command सफल थी या नहीं। CLI में यही contract सबसे जरूरी है।
Create a Node.js 22 + TypeScript CLI named clipilot.
Requirements:
- Implement init, run, and check subcommands with commander
- Configure package.json bin so it can be installed as an npm CLI
- Load .clipilotrc.json or an explicit --config JSON file
- If stdin is present, use it as the run command input
- Write machine-readable results to stdout and warnings/errors to stderr
- Use exit code 0 for success, 2 for release configuration warnings, and 1 for exceptions
- Do not accept secrets through command-line flags; use environment variables or stdin
- Add Vitest coverage for help, stdin, config loading, and failure behavior
- Add a GitHub Actions workflow that runs test, build, and a dry-run command
Generated code review करते समय official docs देखें: Node.js process, Node.js fs, npm package.json bin, Commander, GitHub Actions और Claude Code documentation। Prompt में behavior fix करें, सिर्फ file names नहीं।
Project skeleton और package.json bin
सबसे पहले minimum project बनाएं। bin field npm को बताता है कि install होने पर कौन सा command executable बनेगा।
mkdir clipilot
cd clipilot
npm init -y
npm i commander
npm i -D typescript tsx vitest @types/node
mkdir src tests
{
"name": "clipilot",
"version": "0.1.0",
"type": "module",
"private": true,
"bin": {
"clipilot": "./dist/cli.js"
},
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"commander": "^12.1.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
यह setup एक आम गलती रोकता है: TypeScript एक जगह build करे और bin दूसरी जगह point करे। Claude Code से कहें कि build बदलते समय outDir, shebang और package.json को साथ में verify करे।
Runnable TypeScript CLI
नीचे का code src/cli.ts में paste करें। इसमें Commander usage, config loading, stdin handling, stdout/stderr separation और exit code control एक साथ हैं।
#!/usr/bin/env node
import { access, readFile, writeFile } from "node:fs/promises";
import { constants } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { Command } from "commander";
type OutputMode = "text" | "json";
type AppConfig = {
defaultName: string;
output: OutputMode;
endpoint?: string;
};
type RawConfig = Partial<AppConfig> & Record<string, unknown>;
const defaultConfig: AppConfig = {
defaultName: "world",
output: "text"
};
async function exists(filePath: string) {
try {
await access(filePath, constants.F_OK);
return true;
} catch {
return false;
}
}
function normalizeConfig(raw: RawConfig, source: string): AppConfig {
const output = raw.output ?? defaultConfig.output;
if (output !== "text" && output !== "json") {
throw new Error(`Invalid output in ${source}: expected "text" or "json"`);
}
const defaultName =
typeof raw.defaultName === "string" && raw.defaultName.trim()
? raw.defaultName
: defaultConfig.defaultName;
const endpoint =
typeof raw.endpoint === "string" && raw.endpoint.trim()
? raw.endpoint
: undefined;
return {
defaultName,
output,
...(endpoint ? { endpoint } : {})
};
}
export async function loadConfig(configPath?: string): Promise<AppConfig> {
const candidates = configPath
? [path.resolve(configPath)]
: [
path.resolve(process.cwd(), ".clipilotrc.json"),
path.join(homedir(), ".clipilotrc.json")
];
for (const candidate of candidates) {
if (await exists(candidate)) {
const raw = JSON.parse(await readFile(candidate, "utf8")) as RawConfig;
return normalizeConfig(raw, candidate);
}
if (configPath) {
throw new Error(`Config file not found: ${candidate}`);
}
}
return defaultConfig;
}
async function readStdin() {
if (process.stdin.isTTY) return "";
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8").trim();
}
export function createProgram() {
const program = new Command();
program
.name("clipilot")
.description("A safe sample CLI generated with Claude Code")
.version(process.env.npm_package_version ?? "0.1.0")
.showHelpAfterError()
.option("-c, --config <path>", "Path to a JSON config file");
program
.command("init")
.description("Create .clipilotrc.json in the current directory")
.option("-f, --force", "Overwrite an existing config file")
.action(async (options: { force?: boolean }) => {
const filePath = path.resolve(process.cwd(), ".clipilotrc.json");
if ((await exists(filePath)) && !options.force) {
throw new Error(`${filePath} already exists. Use --force to overwrite it.`);
}
await writeFile(filePath, `${JSON.stringify(defaultConfig, null, 2)}\n`, "utf8");
process.stdout.write(`Created ${filePath}\n`);
});
program
.command("run")
.description("Read a message from an argument or stdin")
.argument("[message]", "Message to process")
.option("--json", "Print machine-readable JSON")
.option("--dry-run", "Show what would happen without side effects")
.action(async (message: string | undefined, options: { json?: boolean; dryRun?: boolean }) => {
const globalOptions = program.opts<{ config?: string }>();
const config = await loadConfig(globalOptions.config);
const pipedInput = await readStdin();
const text = (pipedInput || message || `hello ${config.defaultName}`).trim();
const payload = {
ok: true,
dryRun: Boolean(options.dryRun),
message: text,
endpoint: config.endpoint ?? null
};
if (options.json || config.output === "json") {
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
return;
}
process.stdout.write(`[${payload.dryRun ? "dry-run" : "run"}] ${payload.message}\n`);
});
program
.command("check")
.description("Validate release-sensitive configuration")
.action(async () => {
const globalOptions = program.opts<{ config?: string }>();
const config = await loadConfig(globalOptions.config);
if (!config.endpoint) {
process.stderr.write("WARN endpoint is not set; release check failed.\n");
process.exitCode = 2;
return;
}
process.stdout.write("Configuration OK\n");
});
return program;
}
async function main(argv = process.argv) {
await createProgram().parseAsync(argv);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}
Run करें:
npm run dev -- --help
npm run dev -- init
npm run dev -- run "hello cli"
printf "from stdin\n" | npm run dev -- run --json --dry-run
npm run dev -- check
check में endpoint न होने पर exit code 2 आता है। इसका अर्थ है command चली, लेकिन release configuration पूरी नहीं है।
Config, stdin/stdout और exit codes
Config को JSON file में रखें और secrets अलग रखें।
{
"defaultName": "Masa",
"output": "json",
"endpoint": "https://api.example.com"
}
stdout में वही output होना चाहिए जिसे अगला command पढ़े। JSON mode में केवल JSON लिखें। Warning, progress और debug message stderr में भेजें। Exit codes को document करें: 0 success, 1 error, 2 release warning। Team चाहे तो अलग number चुन सकती है, लेकिन tests और CI उसी meaning को enforce करें।
Practical use cases
| Use case | CLI कैसे चलेगा | Risk |
|---|---|---|
| Article batch processing | stdin से slugs दें और JSON result लें | stdout में log मिला तो parser टूटेगा |
| Repository health check | CI में check चलाएं | warning के बाद भी 0 आया तो release जारी रहेगा |
| API operation tool | .clipilotrc.json से endpoint बदलें | token config file में चला जाएगा |
| npm developer CLI | bin से clipilot command दें | build path या shebang गलत होगा |
Claude Code को कम से कम तीन real examples दें। Input, expected output और failure mode साफ होंगे तो generated CLI ज्यादा उपयोगी होगा। आगे के लिए npm package guide, Claude Code CI/CD setup और getting started guide देखें।
Vitest से CLI test करना
CLI को process की तरह test करें: stdout, stderr, status और stdin priority।
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const rootDir = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
const tempDirs: string[] = [];
function makeTempDir() {
const dir = mkdtempSync(path.join(tmpdir(), "clipilot-"));
tempDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function runCli(args: string[], options: { input?: string } = {}) {
return spawnSync(npxBin, ["tsx", path.join(rootDir, "src/cli.ts"), ...args], {
cwd: rootDir,
input: options.input,
encoding: "utf8"
});
}
describe("clipilot CLI", () => {
it("prints help", () => {
const result = runCli(["--help"]);
expect(result.status).toBe(0);
expect(result.stdout).toContain("clipilot");
expect(result.stdout).toContain("run");
});
it("uses stdin before the message argument", () => {
const result = runCli(["run", "from-argument", "--json", "--dry-run"], {
input: "from stdin\n"
});
const body = JSON.parse(result.stdout);
expect(result.status).toBe(0);
expect(body.message).toBe("from stdin");
expect(body.dryRun).toBe(true);
});
it("loads an explicit config file", () => {
const dir = makeTempDir();
const configPath = path.join(dir, ".clipilotrc.json");
writeFileSync(
configPath,
JSON.stringify({ defaultName: "Masa", output: "json", endpoint: "https://api.example.com" })
);
const result = runCli(["--config", configPath, "run"]);
const body = JSON.parse(result.stdout);
expect(result.status).toBe(0);
expect(body.message).toBe("hello Masa");
expect(body.endpoint).toBe("https://api.example.com");
});
it("returns exit code 1 when the config file is missing", () => {
const dir = makeTempDir();
const result = runCli(["--config", path.join(dir, "missing.json"), "run"]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Config file not found");
});
});
ये tests JSON से पहले banner, stdin priority बदलने, या failure को 0 से खत्म करने जैसी regressions पकड़ते हैं।
GitHub Actions dry-run
CI में safe mode चलाएं। Dry-run का अर्थ है कि command बताएगी क्या होगा, लेकिन file write, production API call या deploy नहीं करेगी।
name: clipilot-cli
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
- run: npm run build
- name: Dry-run CLI through stdin
run: |
printf 'hello from ci\n' | npm run dev -- run --json --dry-run
Secrets को --token xxx जैसे argument से न लें। Arguments shell history, CI log और process list में दिख सकते हैं। Environment variables, GitHub Actions Secrets या stdin ज्यादा सुरक्षित हैं।
Pitfalls और CTA
Common pitfalls हैं: stdout में human logs, failure के बाद भी exit code 0, pipe input के बाद interactive prompt, config file में token, गलत bin path, और --dry-run में real API call। Release से पहले Claude Code से इन्हीं points पर review करवाएं।
Daily reference के लिए free cheatsheet से शुरू करें। Reusable prompts और setup patterns चाहिए तो Claude Code setup guide देखें।
Masa ने यह workflow एक छोटे content-processing CLI पर आजमाया। सबसे बड़ा लाभ typing speed नहीं था, बल्कि review clarity थी। stdin, stdout, stderr, exit codes, dry-run और secrets rules पहले तय थे, इसलिए Claude Code output को guesswork नहीं, contract से judge किया गया।
Summary
Claude Code से CLI बनाते समय contract पहले तय करें। Arguments, config priority, stdin/stdout/stderr, exit codes, npm bin, Vitest tests, CI dry-run और secrets handling एक ही design का हिस्सा हैं।
छोटा CLI भी CI या teammate के terminal में पहुंचते ही production automation बन जाता है। पहले prompt से ही उसे उसी स्तर पर design करें।
मुफ़्त PDF: Claude Code cheatsheet
Email डालें और commands, review habits तथा safe workflow वाली एक-page PDF पाएँ.
हम आपका data सुरक्षित रखते हैं और spam नहीं भेजते.
लेखक के बारे में
Masa
Claude Code workflow और team adoption पर काम करने वाला engineer.
संबंधित लेख
Claude Code Obsidian to CLAUDE.md workflow: context बार-बार न समझाएं
Obsidian notes को CLAUDE.md operating notes में बदलकर Claude Code sessions को resume करना आसान बनाएं.
Claude Code Revenue CTA Routing: article से PDF, Gumroad और consultation तक
Reader intent के आधार पर free PDF, Gumroad products और consultation तक CTA route करने वाला workflow.
Claude Code टीम हैंडऑफ नियम: review proof, permissions, rollback और revenue path
Claude Code टीम काम के लिए evidence, permission rules, rollback, free PDF, Gumroad और consultation path वाला handoff.