Generate Runner
Trigger
- Keywords: generate runner, create runner, custom runner, eject runner, runner for python, runner for rust, runner for go
When NOT to Use
| Need |
Use Instead |
| Run precommit checks |
/precommit or /precommit-fast |
| Install existing runner |
/project-setup (auto-installs) |
| Configure the shipped runner's lint globs |
Check first that the runtime runner has no @generated_at header, then edit .claude/runner-config.json — and set lintArgMode for the same script role, or the globs stay inert (see § Lint Argument Injection) |
Lint Argument Injection
Scope: the runners this plugin ships — scripts/precommit-runner.js and scripts/verify-runner.js. What /precommit, /precommit-fast and /verify actually execute is the runtime copy at .claude/scripts/*, installed by /install-scripts. A runner generated by this skill is written to the same path but is user-owned: its template does not append lint arguments, so lintGlobs / lintArgMode have no effect on it. The discriminator is the eject header (§ Step 4) — all three of @generatedat, @pluginversion, @template, each on its own line with a value, in the opening comment block. Read the file before configuring it.
Known limitation. A project that installed a runner before this fix keeps running the old version, which passes ESLint-only flags into whatever lint script the repo declares — a file-rewriting non-ESLint linter reads them as paths and edits sources. Fixing the plugin source does not reach that copy, and detecting it reliably needs per-release provenance, which is deliberately out of scope. Remedy: /install-scripts precommit-runner.js verify-runner.js --force, with --dry-run first to read the write set (the installer copies "scripts + dependencies" without defining the closure, so the dry run is the authoritative list). The runners are named rather than swept in by --all, which would force-overwrite every core script; confirm lib/utils.js — where the fixed runners decide whether to inject — appears in the report.
The shipped runner appends nothing to your lint script unless you ask it to. lintGlobs alone does nothing; it needs lintArgMode for the same script role.
| Setting |
Where |
Shape |
lintArgMode |
.claude/runner-config.json, or package.json → sd0x |
{"lint": "eslint"} / {"lint:fix": "none"} — keyed by script role, never a bare string. Accepted values: "eslint" and "none" |
lintGlobs |
same two places |
["src/**/*.{ts,tsx,js,jsx}", …] — applied only when that role's lintArgMode is "eslint" |
Precedence, per role: .claude/runner-config.json first, first valid value wins; package.json → sd0x is the fallback. An unusable value — a bare string, a value outside eslint/none, a non-role-keyed shape — is warned about and falls through rather than latching, so "none" in .claude can suppress an opt-in package.json declares. (An unreadable .claude/runner-config.json warns via loadLintGlobs(); an unreadable package.json stays silent.)
In package.json, to inject ESLint's flags and globs into lint:fix only:
{ "sd0x": { "lintArgMode": { "lint:fix": "eslint" }, "lintGlobs": ["src/**/*.{ts,js}"] } }
precommit reads the lint:fix role; verify reads lint — keyed separately because a repo routinely runs different engines for each (this one runs markdownlint for lint:fix).
Why it is opt-in. The runner used to inject by default and guess from the script text whether the recipient was ESLint. markdownlint-cli2 treats every unrecognised argument as a file glob, so under --fix it rewrote JavaScript as Markdown; one run corrupted 71 files in this repo. Four successive detection grammars each misclassified something, so detection was removed rather than deepened.
Workflow
flowchart LR
A[Detect Ecosystem] --> B[Select Template]
B --> C[Customize]
C --> D[Write Runner]
D --> E[Verify]
Step 1: Detect Ecosystem
Scan project root for manifest files:
| Manifest |
Ecosystem |
Template ID |
pnpm-lock.yaml |
Node.js (pnpm) |
node-pnpm |
yarn.lock |
Node.js (yarn) |
node-yarn |
package-lock.json or package.json |
Node.js (npm) |
node-npm |
pyproject.toml |
Python |
python |
Cargo.toml |
Rust |
rust |
go.mod |
Go |
go |
If multiple detected, prefer Node.js > Python > Rust > Go. If none detected, ask user.
Step 2: Select Template
Load template from references/templates.md for the detected ecosystem.
Step 3: Customize
Read project-specific configuration:
| Source |
What |
package.json scripts |
Lint command, test command, build command |
.claude/runner-config.json |
Not read for lint configuration. Neither lintGlobs nor lintArgMode reaches a generated runner — the template does not append lint arguments at all (§ Lint Argument Injection), so listing either here would present an inert value as a customization input |
| Lock file |
Package manager selection |
Step 4: Write Runner
Write to .claude/scripts/precommit-runner.js (Node) or .claude/scripts/precommit-runner.sh (non-Node).
A non-Node runner is generated for manual invocation, not for /precommit. /precommit,
/precommit-fast and /verify probe the .js path only and execute it on existence; none of
them detects a .sh runner today. So a Python, Rust or Go runner generated here is a real,
working script that those commands will not run — invoke it directly, or keep the ecosystem's
checks in the shipped runner, which orchestrates pytest/cargo/go as first-class steps
(skills/precommit/SKILL.md § Step 2). Teaching the three commands to detect and order a .sh
runner is a change to runner dispatch, not to this skill, and is not implied by generating one.
Include eject header:
@generated_at <ISO 8601>
@plugin_version <current version>
@template <template-id>
@ecosystem <ecosystem>
Conflict handling: If target file exists, AskUserQuestion with diff preview.
Step 5: Verify
- File written successfully
- Script is executable (non-Node:
chmod +x)
- Syntax check, named so it can actually be run:
node --check <path> for the Node runner, bash -n <path> for a shell one
Arguments
| Argument |
Description |
Default |
--ecosystem <name> |
Force ecosystem (skip detection) |
auto-detect |
--output <path> |
Custom output path |
Ecosystem-dependent, per § Step 4: .claude/scripts/precommit-runner.js for Node, .claude/scripts/precommit-runner.sh otherwise |
--force |
Overwrite existing without asking |
off |
Output
## Generated Runner
- Ecosystem: <detected>
- Template: <template-id>
- Output: <path>
- Package manager: <pm>
The generated runner is **user-owned** — plugin updates will not overwrite it.
Edit freely to customize for your project.
Verification
References
- Per-ecosystem templates:
references/templates.md
Examples
Input: /generate-runner
Action: Detect Node.js (yarn) → load node-yarn template → customize → write .claude/scripts/precommit-runner.js
Input: /generate-runner --ecosystem python
Action: Load python template → customize → write .claude/scripts/precommit-runner.sh
Input: /generate-runner --force
Action: Detect ecosystem → overwrite existing runner without asking