DEV Community

スシロー
スシロー

Posted on

Build a Node.js CLI That Generates package.json with Claude — A Reusable AI Side-Project Template (2026)

By the end of this article you'll have a working pkginit CLI you can run with npx, that asks three questions, calls the Claude API to draft a package.json (deps, scripts, engines, exports), and writes it to disk only after JSON validation passes. You'll also see the two failure modes that wasted my afternoon — a markdown-fenced response that broke JSON.parse, and an infinite retry loop that fired Opus calls back-to-back — and exactly how to defend against both.

This is the scaffold I copy whenever I start a new AI micro-tool, so the second half shows how to repurpose it.

Why generating package.json with Claude beats a static template

npm init -y gives you a dead stub. Cookiecutter-style generators give you someone else's opinions frozen in 2021. The interesting middle ground: feed Claude a short natural-language description of the project plus a few hard constraints, and let it pick sane scripts, a current engines.node range, and the right type/exports shape for ESM.

The trick is that an LLM is great at the fuzzy part (mapping "a small Fastify API with Vitest" to a dependency set) but terrible at the contract part (emitting strictly valid JSON every single time). So the architecture is: Claude proposes, your code disposes. Never write the model's raw text to package.json.

Wiring the Anthropic SDK into a Node CLI

Start with the dependencies. As of mid-2026 the relevant model IDs are claude-opus-4-8 (strongest), claude-sonnet-4-6 (balanced), and claude-haiku-4-5-20251001 (cheap/fast). For a structured-extraction job like this, Sonnet is the sweet spot — Opus is overkill for emitting one JSON object.

mkdir pkginit && cd pkginit
npm init -y
npm install @anthropic-ai/sdk@^0.65.0
npm pkg set type=module bin.pkginit=./cli.js
Enter fullscreen mode Exit fullscreen mode

Here's the core call. The single most important detail: I use Claude's tool-calling instead of "please reply with JSON". A tools definition with an input_schema forces the model to return arguments that match a JSON Schema, which sidesteps the markdown-fence problem entirely (more on that below).

// generate.js
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env

const PKG_TOOL = {
  name: "emit_package_json",
  description: "Return a complete, valid package.json object.",
  input_schema: {
    type: "object",
    properties: {
      name: { type: "string" },
      version: { type: "string" },
      type: { type: "string", enum: ["module", "commonjs"] },
      description: { type: "string" },
      scripts: { type: "object", additionalProperties: { type: "string" } },
      dependencies: { type: "object", additionalProperties: { type: "string" } },
      devDependencies: { type: "object", additionalProperties: { type: "string" } },
      engines: { type: "object", additionalProperties: { type: "string" } }
    },
    required: ["name", "version", "type", "scripts"]
  }
};

export async function draftPackageJson({ name, description, stack }) {
  const res = await client.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 1024,
    tools: [PKG_TOOL],
    tool_choice: { type: "tool", name: "emit_package_json" }, // force the tool
    messages: [
      {
        role: "user",
        content:
          `Project name: ${name}\n` +
          `Description: ${description}\n` +
          `Stack/notes: ${stack}\n\n` +
          `Pick reasonable pinned-range versions (caret), a test script, ` +
          `a build/start script if relevant, and engines.node >= 20.`
      }
    ]
  });

  const toolUse = res.content.find((b) => b.type === "tool_use");
  if (!toolUse) throw new Error("Model did not call emit_package_json");
  return toolUse.input; // already an object — no JSON.parse needed
}
Enter fullscreen mode Exit fullscreen mode

Notice there is no JSON.parse of free text anywhere. tool_use.input arrives as a parsed object because the SDK validated it against the schema. That one decision deletes an entire category of bug.

The CLI layer with Node's built-in readline (no inquirer)

The whole point of a reusable template is keeping the dependency tree thin, so I lean on node:readline/promises instead of pulling in a prompt library:

// cli.js
#!/usr/bin/env node
import { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { writeFile, access } from "node:fs/promises";
import { draftPackageJson } from "./generate.js";

async function fileExists(p) {
  try { await access(p); return true; } catch { return false; }
}

const rl = createInterface({ input: stdin, output: stdout });
try {
  const name = await rl.question("Package name: ");
  const description = await rl.question("One-line description: ");
  const stack = await rl.question("Stack/tools (e.g. 'Fastify + Vitest'): ");

  const pkg = await draftPackageJson({ name, description, stack });

  // Validate before touching disk
  if (!pkg.name || !/^\d+\.\d+\.\d+/.test(pkg.version ?? "")) {
    throw new Error(`Invalid package object: ${JSON.stringify(pkg)}`);
  }
  if (await fileExists("package.json")) {
    const ok = await rl.question("package.json exists. Overwrite? (y/N) ");
    if (ok.toLowerCase() !== "y") { console.log("Aborted."); process.exit(0); }
  }

  await writeFile("package.json", JSON.stringify(pkg, null, 2) + "\n");
  console.log("✔ Wrote package.json");
} finally {
  rl.close();
}
Enter fullscreen mode Exit fullscreen mode

Run it locally with node cli.js, or npm link and call pkginit from anywhere. Set ANTHROPIC_API_KEY in your shell first.

Failure #1: the markdown fence that silently broke JSON.parse

My first version did not use tool-calling. I asked Claude to "respond with only the package.json" and ran JSON.parse(res.content[0].text). It worked maybe four times out of five. The fifth time the response came back wrapped like this:

Enter fullscreen mode Exit fullscreen mode


json
{ "name": "..." }

Enter fullscreen mode Exit fullscreen mode


javascript

JSON.parse choked on the leading backticks with SyntaxError: Unexpected token \` — and because I'd wrapped the call in a try/catch that just logged "retrying", it looped. Which leads directly to the second, more expensive failure.

Failure #2: the retry loop that fired Opus calls back-to-back

The naive "on parse error, retry" handler had no ceiling and no backoff:

js
// DON'T do this
while (true) {
try { return JSON.parse(await callModel()); }
catch { /* swallow and loop */ }
}

Because the prompt was deterministic-ish, the model kept returning the same fenced output, so the loop never converged. With Opus 4.8 as the model that meant a burst of identical paid calls in a few seconds before I killed the process. Two fixes, both worth baking into the template permanently:

js
// retry.js — bounded, with the error fed back to the model
export async function withRetry(fn, { tries = 3 } = {}) {
let lastErr;
for (let i = 0; i < tries; i++) {
try {
return await fn(i, lastErr);
} catch (err) {
lastErr = err;
// exponential backoff: 0.5s, 1s, 2s — using a fixed base, no Math.random in CI
await new Promise((r) => setTimeout(r, 500 * 2 ** i));
}
}
throw new Error(
Failed after ${tries} attempts: ${lastErr?.message});
}

The real lesson isn't "add backoff" — it's that tool-calling removed the need for the retry loop in the first place. Once input_schema guaranteed an object, the parse error vanished, and the only retries left are genuine network/429 cases, which the Anthropic SDK already retries internally with proper backoff. Layering my own unbounded loop on top of the SDK's retries was the actual sin.

Turning pkginit into a repeatable AI-tool scaffold

The structure here — cli.js (I/O) → generate.js (one forced tool call) → schema validation → disk write — is the skeleton I reuse for almost any "describe it in English, get a config file out" tool. To fork it for a new tool you change exactly three things:

  1. The input_schema — swap package.json fields for tsconfig.json, a GitHub Actions workflow, a Dockerfile spec, etc.
  2. The prompt in draftPackageJson.
  3. The post-validation — e.g. for a workflow file, assert on and jobs exist before writing .github/workflows/ci.yml.

A natural next step is generating a CI workflow that runs Claude itself as a PR reviewer. The same forced-tool pattern produces the YAML; you commit it under .github/workflows/, add ANTHROPIC_API_KEY to your repo secrets, and a GitHub Actions job posts review comments on each PR. Because the generator validates the YAML shape before writing, you avoid the classic "workflow file is malformed so Actions silently never runs" trap.

Cost note (the one honest number)

This tool makes exactly one model call per run with max_tokens: 1024. A package.json draft is well under that ceiling, so output cost is bounded by 1024 tokens regardless of how the model behaves — that's the whole reason the max_tokens cap matters as a safety rail, not just a quality knob. Pin it low for structured-output tools; it's your hard ceiling against a runaway generation.

Takeaways you can act on today

  • Force JSON with tool_choice + input_schema, never JSON.parse of free text. This alone kills the fenced-output bug.
  • Validate the model's object against your own assertions before writing to disk — the schema guarantees shape, not semantics (a version of "latest" passes a string check but is wrong).
  • Don't wrap the SDK in your own unbounded retry loop; it already backs off on 429. Bound every loop that contains a paid call.
  • Keep max_tokens tight on structured-output tools so a single run can't surprise you.

Clone the three files, change the schema, and you've got the next tool.

Top comments (0)