Claude Skill

scaffold-cli

Scaffolds a TypeScript CLI and npm package with the house toolchain, dual tsdown outputs, CLI contracts, changesets, and publishing templates. Use when asked to "scaffold a CLI" or "start an npm package". For an existing package release use autoship; for existing API ergonomics u

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download mblode-agent-skills-skills_scaffold-cli-24f4fd8.zip · 14 KB
Part of mblode/agent-skills — 22 skills

Install

skills CLI npx skills add https://github.com/mblode/agent-skills/tree/main/skills/scaffold-cli
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install mblode-agent-skills@llmmart
Git git clone https://github.com/mblode/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole mblode/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Scaffold CLI

  • IS: bootstrapping a brand-new TypeScript CLI or npm package (Node 24, TypeScript 7) from the pinned templates in references/, through to a green first CI run and a package npm can publish over OIDC.
  • IS NOT: a Next.js web app (use scaffold-nextjs), folder structure or module contracts for an existing codebase (use codebase-architecture), auditing an existing CLI's ergonomics (use dx-audit), or shipping a release of an existing package (use autoship).

The templates encode the house toolchain. Substitute project values and verify template APIs against installed package versions; repair proven incompatibilities instead of blindly reproducing them. The toolchain is the opinion: tsdown not tsup, vitest not jest, oxlint and oxfmt via ultracite not eslint or prettier, node:util styleText not chalk, @clack/prompts not ora. Swapping any of them or restructuring the layout produces a repo the templates' notes no longer describe.

Reference Files

File Read When
references/scaffold-configs.md Step 3: package.json, tsconfig, tsdown, gitignore, license, changeset config, GitHub Actions
references/scaffold-source.md Steps 4-5: src/cli.ts, src/index.ts, src/types.ts, AGENTS.md, README.md, skills/SKILL.md
references/agent-friendly-cli.md Step 4, only when a command takes an identifier, path, or URL, or mutates state: input validation, dry-run, confirmation, schema
references/post-scaffold.md Steps 6-8: post-scaffold commands, the lefthook.yml replacement, validation checklist, GitHub and npm bootstrap, troubleshooting

Scaffold Workflow

Copy this checklist to track progress:

Scaffold progress:
- [ ] Step 1: Gather project info
- [ ] Step 2: Create directory structure
- [ ] Step 3: Generate config files
- [ ] Step 4: Generate source files
- [ ] Step 5: Generate docs and skill
- [ ] Step 6: Run post-scaffold commands
- [ ] Step 7: Validate scaffold
- [ ] Step 8: Bootstrap GitHub and npm (with the user's go-ahead)

Step 1: Gather project info

Ask only for what the user didn't provide:

Variable Example Default Used in
{{name}} md-tools required package.json name, README title, npm package
{{description}} CLI tool to convert content to markdown required package.json, README, SKILL.md
{{bin}} md same as {{name}} package.json bin field, CLI examples, skills folder
{{repo}} acme/md-tools required package.json repository, GitHub repo, npm trusted publisher
{{author}} Your Name required package.json, LICENSE
{{year}} 2026 current year LICENSE

{{repo}} must be the exact GitHub owner/name: npm provenance rejects a publish whose repository.url differs from the repo it came from.

Step 2: Create directory structure

{{name}}/
  .changeset/
  .github/
    workflows/
  src/
  skills/{{bin}}/

Step 3: Generate config files

Load references/scaffold-configs.md. Generate every file there, replacing each {{placeholder}}:

package.json, tsconfig.json, tsdown.config.ts, .gitignore, LICENSE.md, .changeset/config.json, .changeset/README.md, .github/workflows/ci.yml, .github/workflows/npm-publish.yml

Step 4: Generate source files

Load references/scaffold-source.md. Generate:

  • src/cli.ts: Commander entry point with agent-friendly defaults (--output text|json, --no-input, stdout data / stderr log split, JSON error envelope)
  • src/index.ts: Public API exports
  • src/types.ts: Shared type definitions

When a command takes an identifier, path, or URL, or mutates state, also load references/agent-friendly-cli.md and copy the matching pinned pattern. Skip it for a CLI with no such command.

Step 5: Generate docs and skill

From the same references/scaffold-source.md, generate:

  • AGENTS.md: commands, architecture, gotchas, agent invariants
  • README.md: install, usage, API, agent skill install, license
  • skills/{{bin}}/SKILL.md: agent skill definition

Use AGENTS.md directly; do not create a CLAUDE.md wrapper or symlink.

Step 6: Run post-scaffold commands

Load references/post-scaffold.md. Run the command sequence in the order given, including the lefthook.yml overwrite between ultracite init and the first commit.

Step 7: Validate scaffold

Run the validation checklist in references/post-scaffold.md. Every item is a command whose output is the evidence; the checklist includes publint, arethetypeswrong, the hook exercised against real files, and the placeholder sweep.

Step 8: Bootstrap GitHub and npm

For a local scaffold, stop after Step 7. If the user already requested remote setup or publication, carry out that authorized scope. Otherwise present the prepared repository/package identity before asking to create or publish it. Otherwise follow "Bootstrap GitHub and npm" in references/post-scaffold.md: create and push the repo, enable Actions-created PRs, publish 0.0.1 once by hand so the package exists, then register the workflow as a trusted publisher. Terminal evidence is a green CI run on the pushed commit and npm view {{name}} version printing 0.0.1. From here every release belongs to autoship.

Dependencies

Runtime: @clack/prompts, commander

Development (in the package.json template): @changesets/cli, @types/node, tsdown, typescript, vitest

Added by ultracite init, never listed by hand: ultracite (pinned exact by init), oxlint, oxfmt, lefthook, plus the check, fix, and prepare scripts. By-hand entries produce duplicate scripts and version skew against what init installs.

Gotchas

  • tsdown emits .mjs by default. With platform: node (the default) fixedExtension is on, so a config without outputOptions.entryFileNames: "[name].js" builds dist/cli.mjs, dist/index.mjs, and dist/index.d.mts, and bin and exports point at files that do not exist. The outputOptions block in the template is what keeps them .js; do not trim it as noise.
  • No shebang in src/cli.ts. tsdown's banner injects #!/usr/bin/env node; a source shebang doubles it in dist/cli.js. The two build entries stay separate: the CLI entry has the banner and dts: false, the library entry has dts: true and no banner.
  • ultracite init --quiet without --linter oxlint installs Biome. Quiet mode defaults the linter to Biome instead of prompting, so the repo silently ends up on the wrong toolchain. Pass every flag in the post-scaffold command.
  • git init before ultracite init. Init adds prepare: lefthook install and runs it at once; lefthook install writes into .git/hooks and fails without a repo.
  • Replace the generated lefthook.yml before the first commit. It runs npx ultracite fix with no file arguments, so a one-line change reformats the whole tree (31 files in the reference repo), and its **/*.ts globs never match root files, so package.json and tsdown.config.ts edits bypass the hook entirely. Adding {staged_files} alone makes it worse: a JSON-only commit (the shape of the changesets bot's Version Packages commit) then hits oxlint with no lintable file and exits 1. Use the version in references/post-scaffold.md, with *.{...} globs, never **/.
  • touch <file> && git add <file> stages nothing. An unchanged file has no staged diff, so the hook skips and the check proves nothing. Exercise the hook with npx lefthook run pre-commit --file <path>.
  • "test": "vitest run" without --passWithNoTests exits 1 on a repo with zero test files, so the first CI run goes red.
  • @changesets/cli@3 pairs with changesets/action@v2 and the publish-script: input. @v1 cannot drive changesets v3, and @v2 given the v1 publish: input versions the package and then completes green without publishing. The templates carry the matching pair; do not downgrade one side.
  • npm cannot register a trusted publisher for a package that does not exist yet. The first release run fails E404 or ENEEDAUTH until Step 8's one-time manual npm publish has created the package and the workflow is registered. That publish happens before any changeset exists, so it is the one manual publish autoship's rules do not forbid.
  • Node 22 ships npm 10.9.x; OIDC publishing needs npm 11.5.1 or later. Node 24 ships npm 11.19, which is why both workflows pin node-version: 24. Lowering it to 22 breaks publishing with ENEEDAUTH.
  • Agent-facing output is a format contract. Data on stdout, logs and progress on stderr; a stray console.log breaks a consumer parsing --output json. Never prompt when stdin is not a TTY: honor --no-input and take every value as a flag, or the process hangs under a pipe.

Related Skills

  • autoship: every release after the bootstrap publish: changeset, CI watch, Version Packages PR, publish verification, and diagnosis of a release that did not publish.
  • dx-audit: audit the CLI's flags, errors, and types once real commands exist.
  • agents-md: grow the generated AGENTS.md as the codebase gains structure.
  • readme-creator: rewrite the README once there is a real usage story to tell.

Maintenance only: evals/evals.json contains regression scenarios for changes to this skill; it does not load during a user task.

Files (agent-skills)
  • evals
    • evals.json 1.4 KB
      {
        "skill_name": "scaffold-cli",
        "evals": [
          {
            "id": 1,
            "prompt": "Create a local-only TypeScript CLI scaffold named @acme/report with bin report. Use the house stack. Do not create a remote or publish.",
            "expected_output": "Generate and validate the local package while respecting local-only scope.",
            "files": [],
            "assertions": [
              "Keeps CLI and library build outputs distinct",
              "Checks bin and exports against emitted files",
              "Does not create a GitHub repo or publish"
            ]
          },
          {
            "id": 2,
            "prompt": "A scaffold template produces dist/cli.mjs but package.json bin names dist/cli.js. Fix the scaffold.",
            "expected_output": "Reconcile the actual emitted extension and manifest contract.",
            "files": [],
            "assertions": [
              "Checks the build output",
              "Makes the manifest and emitted entry agree",
              "Does not claim successful packaging from source alone"
            ]
          }
        ],
        "routing": {
          "should_trigger": [
            "Create a local-only TypeScript CLI scaffold named @acme/report with bin report. Use the house stack. Do not create a remote or publish.",
            "A scaffold template produces dist/cli.mjs but package.json bin names dist/cli.js. Fix the scaffold."
          ],
          "near_miss": [
            {
              "prompt": "Publish the next version of this existing changesets package.",
              "expected": "autoship"
            }
          ]
        }
      }
      
  • references
    • agent-friendly-cli.md 3.5 KB
      # Agent-Friendly CLI Patterns
      
      Pinned patterns to copy into the scaffolded CLI when a command needs them. Copy the block verbatim, adjust only the command and field names. The base `src/cli.ts` already ships the globals these build on: `--output text|json`, `--no-input`, the stdout data / stderr log split, and the top-level JSON error envelope. Copy a pattern only when its condition holds; do not add all of them by default.
      
      ## Contents
      
      - [Input validation helper](#input-validation-helper)
      - [Dry-run pattern](#dry-run-pattern)
      - [Confirmation and no-input](#confirmation-and-no-input)
      - [Schema command](#schema-command)
      
      ---
      
      ## Input validation helper
      
      Copy into `src/` when a command takes an identifier, path, or URL segment. An agent will pass plausible but wrong values, and this is the last checkpoint before they reach the filesystem or a URL.
      
      ```typescript
      import { resolve, sep } from "node:path";
      
      function hasControlChar(value: string): boolean {
        return [...value].some((ch) => ch.charCodeAt(0) < 0x20); // rejects bytes 0x00 to 0x1f
      }
      
      export function assertSafeId(value: string): string {
        if (hasControlChar(value) || /[?#%]/.test(value) || !/^[\w.-]+$/.test(value)) {
          throw new TypeError(`invalid id ${JSON.stringify(value)}: expected [A-Za-z0-9_.-]`);
        }
        return value;
      }
      
      export function containedPath(baseDir: string, userPath: string): string {
        const full = resolve(baseDir, userPath);
        if (full !== resolve(baseDir) && !full.startsWith(resolve(baseDir) + sep)) {
          throw new Error(`path ${JSON.stringify(userPath)} escapes ${baseDir}`);
        }
        return full;
      }
      
      export function urlSegment(value: string): string {
        return encodeURIComponent(value); // never splice raw input into a URL path
      }
      ```
      
      ## Dry-run pattern
      
      Copy into any command that mutates state. `--dry-run` validates and reports the plan without executing.
      
      ```typescript
      program
        .command("delete <id>")
        .option("--dry-run", "validate and report without executing")
        .action((id: string, flags: { dryRun?: boolean }) => {
          assertSafeId(id);
          if (flags.dryRun) {
            console.error(`would delete ${id}`); // report to stderr, no side effect
            return;
          }
          // ... perform the mutation
        });
      ```
      
      ## Confirmation and no-input
      
      Copy into destructive commands. Confirm in a TTY; when stdin is not a TTY, require an explicit `--yes` and fail naming the flag rather than hang on a prompt.
      
      ```typescript
      import { confirm, isCancel } from "@clack/prompts";
      
      async function confirmDestructive(
        action: string,
        flags: { yes?: boolean; input?: boolean },
      ): Promise<boolean> {
        if (!process.stdin.isTTY || flags.input === false) {
          if (!flags.yes) {
            console.error(`refusing to ${action} without --yes`);
            process.exitCode = 1;
            return false;
          }
          return true;
        }
        const ok = await confirm({ message: `${action}?` });
        return !isCancel(ok) && ok === true;
      }
      ```
      
      ## Schema command
      
      Copy when the CLI has more than a couple of commands. It prints the command tree, options, defaults, and required-ness as JSON so an agent discovers the surface without scraping `--help`.
      
      ```typescript
      program
        .command("schema")
        .description("print the command surface as JSON")
        .action(() => {
          const schema = program.commands.map((cmd) => ({
            command: cmd.name(),
            description: cmd.description(),
            options: cmd.options.map((opt) => ({
              flag: opt.long,
              description: opt.description,
              default: opt.defaultValue,
              required: opt.required,
            })),
          }));
          process.stdout.write(JSON.stringify(schema));
        });
      ```
      
    • post-scaffold.md 6.1 KB
      # Post-Scaffold Commands
      
      Run in order after all files are generated.
      
      ## Contents
      
      - Command Sequence
      - Replace the generated lefthook.yml
      - Command Notes
      - Validation Checklist
      - Troubleshooting
      
      ## Command Sequence
      
      ```bash
      cd {{name}}
      git init
      npx ultracite@latest init --linter oxlint --integrations lefthook --pm npm --quiet
      ```
      
      Then **overwrite the generated `lefthook.yml`** with the version below before committing:
      see "Replace the generated lefthook.yml" for why.
      
      ```bash
      # Use AGENTS.md directly; no compatibility symlink.
      git add .
      git commit -m "Initial commit"
      ```
      
      ## Replace the generated lefthook.yml
      
      `ultracite init` emits a single job that runs `npx ultracite fix` with **no file arguments**,
      so every commit formats the whole repo, silently rewriting files the commit never touched,
      including in-progress work elsewhere in the tree. Overwrite it with:
      
      ```yaml
      # Two jobs, not one, for two independent reasons.
      #
      # 1. Scope. `npx ultracite fix` with no file arguments formats the WHOLE repo,
      #    so committing one file silently rewrites unrelated files in the working
      #    tree. Passing {staged_files} keeps the fixer to what is being committed.
      #
      # 2. Empty sets. `ultracite fix` runs oxfmt then oxlint, and oxlint exits
      #    non-zero when handed no lintable files. Globbing json/css into the same
      #    job as ts/tsx would therefore fail any JSON-only commit, which is exactly
      #    what the changesets bot produces for "Version Packages", breaking the
      #    release workflow. So lint only what oxlint can lint, and format the rest
      #    with oxfmt directly.
      #
      # md/mdx are absent from both globs on the same reasoning: neither tool handles
      # markdown, so a docs-only commit would hit the identical empty-set failure.
      pre-commit:
        parallel: true
        jobs:
          - name: ultracite
            glob: "*.{js,jsx,ts,tsx}"
            run: npx ultracite fix {staged_files}
            stage_fixed: true
          - name: oxfmt
            glob: "*.{json,jsonc,css}"
            # oxfmt ignores lockfiles, and exits non-zero when every file it is given
            # is ignored, so a lockfile-only commit would fail the hook.
            exclude:
              - "package-lock.json"
              - "**/package-lock.json"
            run: npx oxfmt --write {staged_files}
            stage_fixed: true
      ```
      
      Do **not** fix this by only adding `{staged_files}` to the generated single job. That
      narrows the scope but introduces reason 2's bug: the job's glob still matches JSON, so a
      JSON-only staged set reaches oxlint empty and fails the commit. The split is what makes
      both correct at once.
      
      ## Command Notes
      
      - `git init` must precede `ultracite init`: the lefthook integration adds a `prepare: lefthook install` script and runs it immediately; `lefthook install` writes into `.git/hooks` and fails without a repo.
      - `npx ultracite init` runs `npm install` itself, then writes `oxlint.config.ts`, `oxfmt.config.ts`, `lefthook.yml`, and updates `package.json` (adds `check`, `fix`, `prepare: lefthook install` scripts and the `oxlint`/`oxfmt`/`lefthook`/`ultracite` devDeps). `--linter oxlint` skips the linter prompt; `--quiet` suppresses the rest.
      - Keep AGENTS.md as the only shared instruction file.
      - The initial commit captures the clean scaffold state, including ultracite-generated files.
      
      ## Validation Checklist
      
      Verify every item by running the command and checking its output; do not mark done without the command's evidence.
      
      ```text
      Validation:
      - [ ] `npm run build` succeeds (produces dist/cli.js and dist/index.js, plus dist/index.d.ts)
      - [ ] `head -1 dist/cli.js` prints exactly one `#!/usr/bin/env node` shebang
      - [ ] `npm run typecheck` passes with no errors
      - [ ] `npm run check` passes with no errors
      - [ ] `npm run test` passes (0 test files; requires --passWithNoTests in the test script)
      - [ ] `node dist/cli.js --version` prints 0.0.1
      - [ ] `node dist/cli.js --help` shows the description and lists the `--output` and `--no-input` global options
      - [ ] `node dist/cli.js --version | cat` prints 0.0.1 with no ANSI escape codes (color is suppressed when stdout is not a TTY)
      - [ ] AGENTS.md exists without a CLAUDE.md wrapper or symlink
      - [ ] `grep -c staged_files lefthook.yml` returns 2 (the generated single-job version was replaced)
      - [ ] a JSON-only commit passes the hook: `touch package.json && git add package.json && npx lefthook run pre-commit` exits 0 (this is the changesets-bot release path)
      - [ ] `.github/workflows/ci.yml` and `.github/workflows/npm-publish.yml` exist
      - [ ] `skills/{{bin}}/SKILL.md` has frontmatter with name and description
      - [ ] `grep -rn '{{[a-z]' --exclude-dir=node_modules --exclude-dir=.git .` returns nothing (no leftover template placeholders; the pattern skips the `${{ secrets... }}` syntax in workflows)
      ```
      
      ## Troubleshooting
      
      - `ultracite init` fails or hangs: re-run without `--quiet` to see the blocking prompt, answer interactively, then continue.
      - Claude Code requires its built-in agents-md mod enabled with an AGENTS.md-loading mode; use the agents-md skill if instructions do not load.
      - `npm install` fails: verify Node >= 24.11 with `node --version`; the engines field rejects older versions.
      - `npm install` prints a peer warning for `typescript` against `tsdown`: expected and harmless. `tsdown@0.22.x` still lists its optional `typescript` peer as `^5 || ^6`, but its `.d.ts` engine (`rolldown-plugin-dts`) supports `^7`, so `dist/index.d.ts` still generates. Do not downgrade TypeScript.
      - `npm run build` fails with unresolved imports: every relative import needs a `.js` extension (NodeNext requires them even for `.ts` sources).
      - `npm run test` exits 1 with "No test files found": the test script is missing `--passWithNoTests`.
      - `git commit` blocked by a hook, with real lint errors in the output: lefthook is active from `ultracite init`; run `npm run fix` and retry rather than bypassing with `--no-verify`.
      - `git commit` blocked by a hook reporting "No files found to lint" / "Expected at least one target file": this is the empty-set failure, not a lint error, and `npm run fix` cannot clear it. It means `lefthook.yml` still routes non-lintable files (JSON, CSS, or anything oxlint ignores, such as dot-directory configs) into the `ultracite fix` job. Apply the two-job `lefthook.yml` above.
      
    • scaffold-configs.md 6.6 KB
      # Scaffold Config Templates
      
      ## Contents
      
      - [package.json](#packagejson)
      - [tsconfig.json](#tsconfigjson)
      - [tsdown.config.ts](#tsdownconfigts)
      - [.gitignore](#gitignore)
      - [LICENSE.md](#licensemd)
      - [.changeset/config.json](#changesetconfigjson)
      - [.changeset/README.md](#changesetreadmemd)
      - [.github/workflows/ci.yml](#githubworkflowsciyml)
      - [.github/workflows/npm-publish.yml](#githubworkflowsnpm-publishyml)
      
      ---
      
      ## package.json
      
      ```json
      {
        "name": "{{name}}",
        "version": "0.0.1",
        "description": "{{description}}",
        "type": "module",
        "main": "./dist/index.js",
        "types": "./dist/index.d.ts",
        "bin": {
          "{{bin}}": "./dist/cli.js"
        },
        "exports": {
          ".": {
            "types": "./dist/index.d.ts",
            "import": "./dist/index.js",
            "default": "./dist/index.js"
          }
        },
        "files": [
          "dist",
          "README.md",
          "LICENSE.md"
        ],
        "scripts": {
          "build": "tsdown",
          "dev": "tsdown --watch",
          "start": "node dist/cli.js",
          "typecheck": "tsc --noEmit",
          "test": "vitest run --passWithNoTests",
          "changeset": "changeset",
          "changeset:version": "changeset version",
          "release": "npm run build && changeset publish"
        },
        "keywords": [],
        "author": "{{author}}",
        "license": "MIT",
        "repository": {
          "type": "git",
          "url": "git+https://github.com/{{repo}}.git"
        },
        "homepage": "https://github.com/{{repo}}#readme",
        "bugs": {
          "url": "https://github.com/{{repo}}/issues"
        },
        "engines": {
          "node": ">=24.11"
        },
        "dependencies": {
          "@clack/prompts": "^1.7.0",
          "commander": "^15.0.0"
        },
        "devDependencies": {
          "@changesets/cli": "^2.31.0",
          "@types/node": "^24.13.3",
          "tsdown": "^0.22.4",
          "typescript": "^7.0.2",
          "ultracite": "^7.9.3",
          "vitest": "^4.1.10"
        }
      }
      ```
      
      `ultracite init` (post-scaffold) adds the `oxlint`, `oxfmt`, and `lefthook` devDependencies plus the `check`, `fix`, and `prepare` scripts, which is why this template omits them.
      
      ## tsconfig.json
      
      ```json
      {
        "compilerOptions": {
          "target": "ES2024",
          "module": "NodeNext",
          "moduleResolution": "NodeNext",
          "lib": ["ES2024"],
          "outDir": "./dist",
          "rootDir": "./src",
          "strict": true,
          "esModuleInterop": true,
          "skipLibCheck": true,
          "forceConsistentCasingInFileNames": true,
          "resolveJsonModule": true,
          "declaration": true,
          "declarationMap": true,
          "sourceMap": true,
          "isolatedModules": true
        },
        "include": ["src/**/*"],
        "exclude": ["node_modules", "dist", "**/*.test.ts"]
      }
      ```
      
      ## tsdown.config.ts
      
      ```typescript
      import { defineConfig } from "tsdown";
      
      export default defineConfig([
        {
          entry: { cli: "src/cli.ts" },
          format: ["esm"],
          clean: true,
          sourcemap: true,
          target: "node24",
          banner: { js: "#!/usr/bin/env node" },
        },
        {
          entry: { index: "src/index.ts" },
          format: ["esm"],
          dts: true,
          sourcemap: true,
          target: "node24",
        },
      ]);
      ```
      
      `banner` injects the shebang into `dist/cli.js` at build, which is why `src/cli.ts` carries none.
      
      ## .gitignore
      
      ```
      node_modules/
      dist/
      *.tsbuildinfo
      .env
      .env.local
      .DS_Store
      ```
      
      ## LICENSE.md
      
      ```
      The MIT License (MIT)
      
      Copyright (c) {{year}} {{author}}
      
      Permission is hereby granted, free of charge, to any person obtaining a copy
      of this software and associated documentation files (the "Software"), to deal
      in the Software without restriction, including without limitation the rights
      to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      copies of the Software, and to permit persons to whom the Software is
      furnished to do so, subject to the following conditions:
      
      The above copyright notice and this permission notice shall be included in all
      copies or substantial portions of the Software.
      
      THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      SOFTWARE.
      ```
      
      ## .changeset/config.json
      
      ```json
      {
        "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
        "changelog": "@changesets/cli/changelog",
        "commit": false,
        "fixed": [],
        "linked": [],
        "access": "public",
        "baseBranch": "main",
        "updateInternalDependencies": "patch",
        "ignore": []
      }
      ```
      
      ## .changeset/README.md
      
      ```markdown
      # Changesets
      
      Run `npm run changeset` to add a changeset when making changes to {{name}}.
      
      This generates a changeset file that describes the change and its semver bump type (patch, minor, or major). Changesets are consumed during release to update the version and generate changelog entries.
      ```
      
      ## .github/workflows/ci.yml
      
      ```yaml
      name: CI
      
      on:
        push:
          branches:
            - main
        pull_request:
      
      jobs:
        build:
          runs-on: ubuntu-latest
          steps:
            - name: Checkout
              uses: actions/checkout@v6
              with:
                fetch-depth: 0
      
            - name: Setup Node
              uses: actions/setup-node@v6
              with:
                node-version: 24
                cache: npm
      
            - name: Install
              run: npm ci
      
            - name: Changeset Status
              if: github.event_name == 'pull_request'
              run: npx changeset status --since origin/main
      
            - name: Lint
              run: npm run check
      
            - name: Typecheck
              run: npm run typecheck
      
            - name: Test
              run: npm run test
      
            - name: Build
              run: npm run build
      ```
      
      Notes:
      
      - `npm run check` is the ultracite-added lint script; it exists by CI time because post-scaffold `ultracite init` runs before the initial commit.
      - `Changeset Status` fails PRs without a changeset on purpose; `fetch-depth: 0` is required for the `--since origin/main` comparison.
      
      ## .github/workflows/npm-publish.yml
      
      ```yaml
      name: Release
      
      on:
        push:
          branches:
            - main
        workflow_dispatch:
      
      permissions:
        contents: write
        pull-requests: write
        id-token: write
      
      jobs:
        release:
          runs-on: ubuntu-latest
          steps:
            - name: Checkout
              uses: actions/checkout@v6
              with:
                fetch-depth: 0
            - name: Set up Node
              uses: actions/setup-node@v6
              with:
                node-version: 24
                registry-url: https://registry.npmjs.org
                cache: npm
            - name: Upgrade npm for OIDC trusted publishing
              run: npm install -g npm@latest
            - name: Install dependencies
              run: npm ci
            - name: Create release PR or publish
              uses: changesets/action@v2
              with:
                publish-script: npm run release
              env:
                GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      ```
      
    • scaffold-source.md 5.3 KB
      # Scaffold Source Templates
      
      ## Contents
      
      - [src/cli.ts](#srcclits)
      - [src/index.ts](#srcindexts)
      - [src/types.ts](#srctypests)
      - [AGENTS.md](#agentsmd)
      - [README.md](#readmemd)
      - [skills/SKILL.md](#skillsskillmd)
      
      ---
      
      ## src/cli.ts
      
      No shebang here: tsdown's `banner` injects it at build; a source shebang would double it in `dist/cli.js`.
      
      The entry ships agent-friendly defaults: a global `--output text|json`, `--no-input`, a stdout data / stderr log split, and a top-level handler that prints a JSON error envelope with a non-zero exit in json mode. Per-command patterns (input validation, dry-run, confirmation, schema) live in `references/agent-friendly-cli.md`; copy them in as commands are added.
      
      ```typescript
      import { styleText } from "node:util";
      import { Command } from "commander";
      
      // stdout carries data only; stderr carries logs, progress, and human hints.
      const isInteractive =
        Boolean(process.stdout.isTTY) && !process.env.NO_COLOR && !process.env.CI;
      
      const program = new Command();
      
      program
        .name("{{bin}}")
        .description("{{description}}")
        .version("0.0.1")
        .option("--output <format>", "output format: text or json", "text")
        .option("--no-input", "never prompt; fail if a required value is missing");
      
      // Register commands here
      // import { registerExampleCommand } from "./commands/example.js";
      // registerExampleCommand(program);
      
      program.parseAsync().catch((error: unknown) => {
        const message = error instanceof Error ? error.message : String(error);
        if (program.opts().output === "json") {
          process.stdout.write(
            JSON.stringify({ error: true, code: "UNEXPECTED", message, details: {} }),
          );
        } else {
          const label = isInteractive ? styleText("red", "Error:") : "Error:";
          process.stderr.write(`${label} ${message}\n`);
        }
        process.exitCode = 1;
      });
      ```
      
      ## src/index.ts
      
      ```typescript
      // Public API exports
      // export { example } from "./example.js";
      ```
      
      ## src/types.ts
      
      ```typescript
      // Shared type definitions
      ```
      
      ## AGENTS.md
      
      AGENTS.md is the shared instruction file; no CLAUDE.md symlink is needed.
      
      ```markdown
      # {{name}}
      
      {{description}}
      
      ## Commands
      
      \`\`\`bash
      npm install        # setup (requires Node >= 24.11)
      npm run build      # tsdown, outputs to dist/
      npm run dev        # tsdown --watch
      npm run test       # vitest run --passWithNoTests
      npm run typecheck  # tsc --noEmit
      npm run fix        # ultracite fix: format + lint autofix
      npm run check      # ultracite check: lint (CI)
      \`\`\`
      
      ## Architecture
      
      \`\`\`
      src/
        cli.ts              # Commander entry point
        index.ts            # Public API exports
        types.ts            # Shared type definitions
      \`\`\`
      
      ## Gotchas
      
      - **ESM only**: This project uses `"type": "module"`. Use `.js` extensions in imports (e.g., `import { foo } from "./foo.js"`); extensionless imports fail the NodeNext typecheck.
      - **Dual build**: `tsdown.config.ts` produces two entry points, `cli.js` (with shebang) and `index.js` (with .d.ts). Do not merge them, and do not add a shebang to `src/cli.ts`.
      - **Linting via ultracite**: Run `npm run fix` (autofix) or `npm run check` (CI lint) instead of calling oxlint or oxfmt directly.
      - **Git hooks via lefthook**: The `prepare` script runs `lefthook install` on every `npm install`; no manual hook setup.
      - **No chalk/ora**: Use `import { styleText } from "node:util"` for colors (stable in Node 22.13+) and the `@clack/prompts` spinner for progress indicators.
      
      ## Agent invariants
      
      - Prefer `--output json` for machine-readable output; the default `text` is for humans and strips to plain when piped.
      - The CLI never prompts when stdin is not a TTY. Pass `--no-input` and provide every value as a flag.
      - Mutating commands support `--dry-run`; run it first. Destructive ops require `--yes` when stdin is not a TTY.
      - Exit codes: 0 on success, non-zero on failure. In `--output json` mode, errors print a `{ error, code, message, details }` envelope on stdout with a human hint on stderr.
      - The core logic lives in `src/index.ts` and can also back an MCP server; keep CLI-only concerns in `src/cli.ts`.
      ```
      
      ## README.md
      
      ```markdown
      # {{name}}
      
      {{description}}
      
      ## Installation
      
      \`\`\`bash
      npm install -g {{name}}
      \`\`\`
      
      Or use directly with npx:
      
      \`\`\`bash
      npx {{name}} --help
      \`\`\`
      
      ## Usage
      
      \`\`\`bash
      {{bin}} --help
      \`\`\`
      
      ## Programmatic API
      
      \`\`\`typescript
      import {} from "{{name}}";
      \`\`\`
      
      ## Usage with AI Agents
      
      Add the skill to your AI coding assistant:
      
      \`\`\`bash
      npx skills add {{repo}}
      \`\`\`
      
      This works with Claude Code, Codex, Gemini CLI, GitHub Copilot, Goose, OpenCode, and Windsurf.
      
      ## Requirements
      
      - Node.js >= 24.11
      
      ## License
      
      [MIT](LICENSE.md)
      ```
      
      ## skills/SKILL.md
      
      Create at `skills/{{bin}}/SKILL.md`, frontmatter and body in one file:
      
      ```markdown
      ---
      name: {{bin}}
      description: {{description}}. Use when the user wants to use {{bin}}, run {{bin}} commands, or asks about {{name}} features.
      ---
      
      # {{name}}
      
      {{description}}
      
      ## Commands
      
      | Command | What it does |
      |---------|-------------|
      | `{{bin}} --help` | Show available commands and options |
      | `{{bin}} --version` | Show version number |
      | `{{bin}} --output json` | Emit machine-readable JSON instead of text |
      | `{{bin}} --no-input` | Never prompt; provide every value as a flag |
      
      ## Invariants
      
      - Data goes to stdout, logs and progress to stderr.
      - Exit code is 0 on success, non-zero on failure.
      - Mutating commands support `--dry-run`; run it before executing.
      ```
      
  • SKILL.md 9.7 KB
    ---
    name: scaffold-cli
    description: Scaffolds a TypeScript CLI and npm package with the house toolchain, dual tsdown outputs, CLI contracts, changesets, and publishing templates. Use when asked to "scaffold a CLI" or "start an npm package". For an existing package release use autoship; for existing API ergonomics use dx-audit.
    compatibility: Requires a shell, Git, Node.js, and npm registry access. Remote publishing requires the relevant account authentication.
    ---
    
    # Scaffold CLI
    
    - **IS:** bootstrapping a brand-new TypeScript CLI or npm package (Node 24, TypeScript 7) from the pinned templates in `references/`, through to a green first CI run and a package npm can publish over OIDC.
    - **IS NOT:** a Next.js web app (use `scaffold-nextjs`), folder structure or module contracts for an existing codebase (use `codebase-architecture`), auditing an existing CLI's ergonomics (use `dx-audit`), or shipping a release of an existing package (use `autoship`).
    
    The templates encode the house toolchain. Substitute project values and verify template APIs against installed package versions; repair proven incompatibilities instead of blindly reproducing them. The toolchain is the opinion: tsdown not tsup, vitest not jest, oxlint and oxfmt via ultracite not eslint or prettier, `node:util` `styleText` not chalk, `@clack/prompts` not ora. Swapping any of them or restructuring the layout produces a repo the templates' notes no longer describe.
    
    ## Reference Files
    
    | File | Read When |
    |------|-----------|
    | `references/scaffold-configs.md` | Step 3: package.json, tsconfig, tsdown, gitignore, license, changeset config, GitHub Actions |
    | `references/scaffold-source.md` | Steps 4-5: src/cli.ts, src/index.ts, src/types.ts, AGENTS.md, README.md, skills/SKILL.md |
    | `references/agent-friendly-cli.md` | Step 4, only when a command takes an identifier, path, or URL, or mutates state: input validation, dry-run, confirmation, schema |
    | `references/post-scaffold.md` | Steps 6-8: post-scaffold commands, the lefthook.yml replacement, validation checklist, GitHub and npm bootstrap, troubleshooting |
    
    ## Scaffold Workflow
    
    Copy this checklist to track progress:
    
    ```text
    Scaffold progress:
    - [ ] Step 1: Gather project info
    - [ ] Step 2: Create directory structure
    - [ ] Step 3: Generate config files
    - [ ] Step 4: Generate source files
    - [ ] Step 5: Generate docs and skill
    - [ ] Step 6: Run post-scaffold commands
    - [ ] Step 7: Validate scaffold
    - [ ] Step 8: Bootstrap GitHub and npm (with the user's go-ahead)
    ```
    
    ### Step 1: Gather project info
    
    Ask only for what the user didn't provide:
    
    | Variable | Example | Default | Used in |
    |----------|---------|---------|---------|
    | `{{name}}` | `md-tools` | required | package.json name, README title, npm package |
    | `{{description}}` | `CLI tool to convert content to markdown` | required | package.json, README, SKILL.md |
    | `{{bin}}` | `md` | same as `{{name}}` | package.json bin field, CLI examples, skills folder |
    | `{{repo}}` | `acme/md-tools` | required | package.json repository, GitHub repo, npm trusted publisher |
    | `{{author}}` | `Your Name` | required | package.json, LICENSE |
    | `{{year}}` | `2026` | current year | LICENSE |
    
    `{{repo}}` must be the exact GitHub `owner/name`: npm provenance rejects a publish whose `repository.url` differs from the repo it came from.
    
    ### Step 2: Create directory structure
    
    ```
    {{name}}/
      .changeset/
      .github/
        workflows/
      src/
      skills/{{bin}}/
    ```
    
    ### Step 3: Generate config files
    
    Load `references/scaffold-configs.md`. Generate every file there, replacing each `{{placeholder}}`:
    
    `package.json`, `tsconfig.json`, `tsdown.config.ts`, `.gitignore`, `LICENSE.md`, `.changeset/config.json`, `.changeset/README.md`, `.github/workflows/ci.yml`, `.github/workflows/npm-publish.yml`
    
    ### Step 4: Generate source files
    
    Load `references/scaffold-source.md`. Generate:
    
    - `src/cli.ts`: Commander entry point with agent-friendly defaults (`--output text|json`, `--no-input`, stdout data / stderr log split, JSON error envelope)
    - `src/index.ts`: Public API exports
    - `src/types.ts`: Shared type definitions
    
    When a command takes an identifier, path, or URL, or mutates state, also load `references/agent-friendly-cli.md` and copy the matching pinned pattern. Skip it for a CLI with no such command.
    
    ### Step 5: Generate docs and skill
    
    From the same `references/scaffold-source.md`, generate:
    
    - `AGENTS.md`: commands, architecture, gotchas, agent invariants
    - `README.md`: install, usage, API, agent skill install, license
    - `skills/{{bin}}/SKILL.md`: agent skill definition
    
    Use AGENTS.md directly; do not create a CLAUDE.md wrapper or symlink.
    
    ### Step 6: Run post-scaffold commands
    
    Load `references/post-scaffold.md`. Run the command sequence in the order given, including the `lefthook.yml` overwrite between `ultracite init` and the first commit.
    
    ### Step 7: Validate scaffold
    
    Run the validation checklist in `references/post-scaffold.md`. Every item is a command whose output is the evidence; the checklist includes `publint`, `arethetypeswrong`, the hook exercised against real files, and the placeholder sweep.
    
    ### Step 8: Bootstrap GitHub and npm
    
    For a local scaffold, stop after Step 7. If the user already requested remote setup or publication, carry out that authorized scope. Otherwise present the prepared repository/package identity before asking to create or publish it. Otherwise follow "Bootstrap GitHub and npm" in `references/post-scaffold.md`: create and push the repo, enable Actions-created PRs, publish 0.0.1 once by hand so the package exists, then register the workflow as a trusted publisher. Terminal evidence is a green CI run on the pushed commit and `npm view {{name}} version` printing `0.0.1`. From here every release belongs to `autoship`.
    
    ## Dependencies
    
    **Runtime:** `@clack/prompts`, `commander`
    
    **Development (in the package.json template):** `@changesets/cli`, `@types/node`, `tsdown`, `typescript`, `vitest`
    
    **Added by `ultracite init`, never listed by hand:** `ultracite` (pinned exact by init), `oxlint`, `oxfmt`, `lefthook`, plus the `check`, `fix`, and `prepare` scripts. By-hand entries produce duplicate scripts and version skew against what init installs.
    
    ## Gotchas
    
    - **tsdown emits `.mjs` by default.** With `platform: node` (the default) `fixedExtension` is on, so a config without `outputOptions.entryFileNames: "[name].js"` builds `dist/cli.mjs`, `dist/index.mjs`, and `dist/index.d.mts`, and `bin` and `exports` point at files that do not exist. The `outputOptions` block in the template is what keeps them `.js`; do not trim it as noise.
    - **No shebang in `src/cli.ts`.** tsdown's `banner` injects `#!/usr/bin/env node`; a source shebang doubles it in `dist/cli.js`. The two build entries stay separate: the CLI entry has the banner and `dts: false`, the library entry has `dts: true` and no banner.
    - **`ultracite init --quiet` without `--linter oxlint` installs Biome.** Quiet mode defaults the linter to Biome instead of prompting, so the repo silently ends up on the wrong toolchain. Pass every flag in the post-scaffold command.
    - **`git init` before `ultracite init`.** Init adds `prepare: lefthook install` and runs it at once; `lefthook install` writes into `.git/hooks` and fails without a repo.
    - **Replace the generated `lefthook.yml` before the first commit.** It runs `npx ultracite fix` with no file arguments, so a one-line change reformats the whole tree (31 files in the reference repo), and its `**/*.ts` globs never match root files, so `package.json` and `tsdown.config.ts` edits bypass the hook entirely. Adding `{staged_files}` alone makes it worse: a JSON-only commit (the shape of the changesets bot's Version Packages commit) then hits oxlint with no lintable file and exits 1. Use the version in `references/post-scaffold.md`, with `*.{...}` globs, never `**/`.
    - **`touch <file> && git add <file>` stages nothing.** An unchanged file has no staged diff, so the hook skips and the check proves nothing. Exercise the hook with `npx lefthook run pre-commit --file <path>`.
    - **`"test": "vitest run"` without `--passWithNoTests`** exits 1 on a repo with zero test files, so the first CI run goes red.
    - **`@changesets/cli@3` pairs with `changesets/action@v2` and the `publish-script:` input.** `@v1` cannot drive changesets v3, and `@v2` given the v1 `publish:` input versions the package and then completes green without publishing. The templates carry the matching pair; do not downgrade one side.
    - **npm cannot register a trusted publisher for a package that does not exist yet.** The first release run fails `E404` or `ENEEDAUTH` until Step 8's one-time manual `npm publish` has created the package and the workflow is registered. That publish happens before any changeset exists, so it is the one manual publish `autoship`'s rules do not forbid.
    - **Node 22 ships npm 10.9.x; OIDC publishing needs npm 11.5.1 or later.** Node 24 ships npm 11.19, which is why both workflows pin `node-version: 24`. Lowering it to 22 breaks publishing with `ENEEDAUTH`.
    - **Agent-facing output is a format contract.** Data on stdout, logs and progress on stderr; a stray `console.log` breaks a consumer parsing `--output json`. Never prompt when stdin is not a TTY: honor `--no-input` and take every value as a flag, or the process hangs under a pipe.
    
    ## Related Skills
    
    - `autoship`: every release after the bootstrap publish: changeset, CI watch, Version Packages PR, publish verification, and diagnosis of a release that did not publish.
    - `dx-audit`: audit the CLI's flags, errors, and types once real commands exist.
    - `agents-md`: grow the generated AGENTS.md as the codebase gains structure.
    - `readme-creator`: rewrite the README once there is a real usage story to tell.
    
    Maintenance only: `evals/evals.json` contains regression scenarios for changes to this skill; it does not load during a user task.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related