CLI-Tools mit Claude Code entwickeln: Node.js, TypeScript, Tests und sichere Releases
Baue ein Node.js/TypeScript-CLI mit Claude Code: Argumente, Config, stdin/stdout, Exit Codes, Tests, CI und Secrets.
Ein CLI-Tool beginnt oft als kleines privates Skript. Dann kommen Flags, Konfigurationsdateien, CI-Ausfuehrung, npm-Verteilung und weitere Nutzer dazu. Ab diesem Moment reicht “laeuft bei mir” nicht mehr. Ein brauchbares CLI braucht nachvollziehbares Argument-Parsing, sauberes stdout, Warnungen auf stderr, klare Exit Codes, Tests, Dry-Run-Verhalten und einen sicheren Umgang mit Secrets.
Claude Code passt gut zu dieser Aufgabe, weil CLI-Entwicklung mehrere Dateien gleichzeitig betrifft: package.json, TypeScript-Quelle, Tests, GitHub Actions und Dokumentation. Die Gefahr liegt in einem zu vagen Prompt. “Erstelle ein CLI” liefert schnell Demo-Code, aber haeufig ohne stabiles Ausgabeformat, ohne Release-Pruefung und mit unsicherem Token-Handling.
In diesem Artikel bauen wir clipilot, ein kleines Node.js/TypeScript-CLI. Ziel ist nicht eine auffaellige Demo, sondern eine Basis, die reviewbar, testbar und sicher in Automatisierung nutzbar ist.
Praeziser Prompt fuer Claude Code
stdin ist die Standardeingabe, stdout die Standardausgabe, stderr die Fehlerausgabe. Ein Exit Code ist die Zahl, mit der Shell und CI entscheiden, ob ein Befehl erfolgreich war. Bei CLI-Tools ist diese Vereinbarung wichtiger als die interne Implementierung.
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
Pruefe die Ausgabe mit offiziellen Quellen: Node.js process, Node.js fs, npm package.json bin, Commander, GitHub Actions und die Claude Code Dokumentation. Der Prompt muss Verhalten stabilisieren, nicht nur Dateien anfordern.
Projektstruktur und npm bin
Erstelle zuerst das Projekt. Das Feld bin sorgt dafuer, dass npm einen ausfuehrbaren Befehl auf die gebaute JavaScript-Datei legt.
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"]
}
So vermeidest du einen klassischen Release-Fehler: TypeScript baut an eine Stelle, bin zeigt aber auf eine andere. Wenn Claude Code die Build-Konfiguration aendert, soll es outDir, Shebang und package.json gemeinsam pruefen.
Ausfuehrbares TypeScript-CLI
Speichere die folgende Datei als src/cli.ts. Sie enthaelt Commander, Config Loading, stdin, getrenntes stdout/stderr und Exit Codes.
#!/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;
});
}
Teste es lokal:
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 gibt 2 zurueck, wenn endpoint fehlt. Das ist ein Release-Warnsignal, keine normale Exception.
Config, stdin/stdout und Exit Codes
Eine explizite Config macht Automatisierung stabil.
{
"defaultName": "Masa",
"output": "json",
"endpoint": "https://api.example.com"
}
stdout sollte nur enthalten, was ein anderes Programm weiterverarbeiten soll. Im JSON-Modus bedeutet das: nur JSON. Menschliche Hinweise, Warnungen und Diagnoseausgaben gehoeren auf stderr. Exit Codes muessen ebenfalls stabil sein: 0 Erfolg, 1 echter Fehler, 2 unvollstaendige Release-Konfiguration.
Praxisfaelle
| Fall | Nutzung | Risiko |
|---|---|---|
| Content-Batch-Verarbeitung | Slugs per stdin senden, JSON empfangen | Logs in stdout zerstoeren Parser |
| Repo-Health-Check | check in CI ausfuehren | Warnung endet mit 0 und Release laeuft weiter |
| Internes API-Tool | Endpoint via .clipilotrc.json wechseln | Token landet in der Config-Datei |
| npm-CLI fuer Entwickler | clipilot ueber bin bereitstellen | Build-Pfad oder Shebang ist falsch |
Gib Claude Code mindestens drei solche Faelle. Je klarer Eingabe, Ausgabe und Fehlerfall sind, desto realistischer wird das CLI. Naechste Schritte findest du unter npm-Paket erstellen, Claude Code CI/CD Setup und Claude Code Einstieg.
Vitest fuer CLI-Verhalten
Ein CLI testet man als Prozess. stdout, stderr, Status und stdin muessen gemeinsam geprueft werden.
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");
});
});
Diese Tests verhindern leise Regressionen: Banner vor JSON, geaenderte stdin-Prioritaet oder Fehler mit Exit Code 0.
GitHub Actions Dry-Run
CI sollte das CLI vor dem Release in einem sicheren Modus ausfuehren.
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
Dry-run zeigt, was passieren wuerde, ohne Dateien zu schreiben, Production-APIs aufzurufen oder zu deployen. Secrets sollten nicht als --token xxx uebergeben werden, weil Argumente in Shell-Historie, CI-Logs oder Prozesslisten auftauchen koennen. Nutze Umgebungsvariablen, GitHub Actions Secrets oder stdin.
Fallen und CTA
Typische Fallen sind: Logs in stdout, Fehler mit Exit Code 0, interaktive Fragen trotz Pipe-Eingabe, Tokens in Config-Dateien, falscher bin-Pfad und ein --dry-run, der doch echte API-Aufrufe macht. Lass Claude Code vor dem Release genau diese Punkte pruefen.
Als schnelle Referenz eignet sich das kostenlose Cheatsheet. Fuer wiederverwendbare Prompts und Setup-Muster ist der Claude Code setup guide ein sinnvoller naechster Schritt.
Masa hat diesen Ablauf mit einem kleinen Content-CLI getestet. Der groesste Gewinn war nicht Tippgeschwindigkeit, sondern ein klarer Review-Massstab. Sobald stdin, stdout, stderr, Exit Codes, Dry-Run und Secrets im Prompt festgelegt waren, liess sich Claude Codes Ausgabe gegen einen Vertrag pruefen.
Zusammenfassung
Bei Claude Code CLI-Entwicklung kommt die Qualitaet aus dem Startvertrag. Definiere Argumente, Config-Prioritaet, stdin/stdout/stderr, Exit Codes, npm bin, Tests, CI-Dry-Run und Secret-Handling, bevor der Code generiert wird.
Ein kleines CLI wird Produktionsautomatisierung, sobald es in CI oder im Terminal eines Teammitglieds laeuft. Behandle es ab dem ersten Prompt entsprechend.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Workflow von Obsidian zu CLAUDE.md
Obsidian-Arbeitsnotizen in CLAUDE.md-Betriebsnotizen verwandeln und Kontext nicht ständig neu erklären.
Claude Code Revenue CTA Routing: Artikel zu PDF, Gumroad und Beratung führen
Ein Claude-Code-Ablauf, der Leser nach Absicht zu Gratis-PDF, Gumroad oder Beratung führt.
Claude-Code-Team-Handoff-Regeln: Belege, Berechtigungen, Rollback und Umsatzpfade
Ein praktisches Claude-Code-Handoff für Review-Belege, Berechtigungen, Rollback, Gratis-PDF, Gumroad und Beratung.