TypeScript scripts for Node-specific tasks (markdown linting, AST processing, etc.) that lack mature Python equivalents. Runs on Bun — no build step required.
- Bun ≥1.3.14 — the runtime and package manager for this workspace.
Install:
curl -fsSL https://bun.sh/install | bash - TypeScript 6.x — for type checking only (
tsc --noEmit).
All dependencies are declared in package.json and resolved via bun install.
scripts/node/
src/
cli/ # CLI entry points (one per script, cleye framework)
lint-md.ts # Markdown linter — cleye CLI wrapper
example.ts # Example script — cleye CLI wrapper
lib/
lint-md/ # Per-script library package for lint-md
core.ts # remark processing logic
rules.ts # Custom remark plugins
example/ # Per-script library package for example
core.ts # Example logic
shared/ # Utilities shared across all scripts
exit-codes.ts # ExitCode enum (CLEAN, VIOLATIONS, CONFIG_ERROR, INVALID_INPUT)
format.ts # formatViolation(), die() — error/output formatting helpers
path.ts # resolveTarget(), isExempted() — path resolution utilities
tests/
lint-md.test.ts # Unit tests for lib/lint-md/
lint-md.cli.test.ts # CLI integration tests (Bun.spawnSync)
example.test.ts # Unit tests for lib/example/
example.cli.test.ts # CLI integration tests for example
shared.test.ts # Tests for lib/shared/
.gitkeep # Ensures tests/ directory is versioned
package.json # Script entries, dependencies, devDependencies
tsconfig.json # TypeScript config (type checking only — noEmit)
biome.json # Lint and format configuration
README.md # This file
.gitignore # Git ignores (node_modules, bun.lock, etc.)
bun.lock # Lockfile (auto-generated by bun install)
Key conventions:
src/cli/— One file per CLI command, uses cleye for argument parsing.src/lib/<name>/— Per-script library modules (e.g.,src/lib/lint-md/core.ts).src/lib/shared/— Code shared across all scripts (exit codes, formatting, path utils).tests/— One.test.tsfile per lib module, one.cli.test.tsfile per CLI command.
All CLI entry points use cleye for argument parsing.
import { cli } from 'cleye';
const argv = cli({
name: 'lint-md',
version: '1.0.0',
parameters: ['<input-path>'],
});
// argv._.inputPath is typed as string
// --help is auto-generated# Via package.json script entry:
bun run --cwd scripts/node lint:md -- <path>
# Directly with Bun:
bun src/cli/lint-md.ts -- <path>All scripts accept --help (auto-generated by cleye) and --version.
| Code | Constant | Meaning |
|---|---|---|
0 |
ExitCode.CLEAN |
Success — no violations |
1 |
ExitCode.VIOLATIONS |
Violations found (e.g., lint errors) |
2 |
ExitCode.CONFIG_ERROR |
Configuration error |
3 |
ExitCode.INVALID_INPUT |
Invalid input (file not found, bad args) |
The ExitCode enum is defined in src/lib/shared/exit-codes.ts and reused by all scripts.
import { ExitCode } from '../lib/shared/exit-codes.ts';
function die(message: string, code: ExitCode): never {
process.stderr.write(`Error: ${message}\n`);
process.exit(code);
}
// Usage:
die('file not found', ExitCode.INVALID_INPUT);- All error messages are written to stderr via
process.stderr.write(). - Format:
Error: <human-readable description> - Include the specific cause (file name, invalid value, etc.).
- Never include stack traces in user-facing output.
- Exit with the appropriate non-zero
ExitCode.
The die() helper in src/lib/shared/format.ts encapsulates this pattern.
Biome handles both linting and formatting in a single tool.
# Check lint + format (read-only):
bun run --cwd scripts/node lint
# Apply fixes automatically:
bun run --cwd scripts/node lint:fixThese map to biome check src/ and biome check --apply src/ respectively.
Configuration is in biome.json — includes src/**/*.ts and tests/**/*.ts.
TypeScript is used for type checking only — Bun handles runtime execution directly.
bun run --cwd scripts/node typecheckThis maps to tsc --noEmit using tsconfig.json with strict: true and noEmit: true.
Tests use bun test — zero configuration, built-in coverage.
# Run all tests:
bun run --cwd scripts/node test
# With coverage:
bun run --cwd scripts/node test:coverage| File | What it tests |
|---|---|
tests/<name>.test.ts |
Unit tests for src/lib/<name>/ modules |
tests/<name>.cli.test.ts |
CLI integration tests (spawn entry points, assert exit codes) |
tests/shared.test.ts |
Tests for shared utilities in src/lib/shared/ |
import { describe, expect, it } from 'bun:test';
import { spawnSync } from 'bun';
describe('lint-md CLI', () => {
it('exits 0 on clean markdown', () => {
const proc = spawnSync(['bun', 'src/cli/lint-md.ts', 'test/fixtures/clean.md'], {
cwd: import.meta.dir,
});
expect(proc.exitCode).toBe(0);
expect(proc.stderr.toString()).toBe('');
});
it('exits 3 on invalid input', () => {
const proc = spawnSync(['bun', 'src/cli/lint-md.ts', '/nonexistent/path.md'], {
cwd: import.meta.dir,
});
expect(proc.exitCode).toBe(3);
expect(proc.stderr.toString()).toContain('Error:');
});
});import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'bun';
import { describe, expect, it, beforeAll, afterAll } from 'bun:test';
describe('lint-md with temp files', () => {
let tmpDir: string;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'lint-md-test-'));
writeFileSync(join(tmpDir, 'good.md'), '# Title\n\nSome content.');
});
afterAll(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('lints a temp file', () => {
const proc = spawnSync(['bun', 'src/cli/lint-md.ts', join(tmpDir, 'good.md')], {
cwd: import.meta.dir,
});
expect(proc.exitCode).toBe(0);
});
});Code shared across all scripts lives in src/lib/shared/:
| File | Exports | Purpose |
|---|---|---|
exit-codes.ts |
ExitCode enum |
Standard exit codes (CLEAN, VIOLATIONS, CONFIG_ERROR, INVALID_INPUT) |
format.ts |
formatViolation(), die() |
Error/output formatting helpers |
path.ts |
resolveTarget(), isExempted() |
Path resolution and exemption utilities |
import { ExitCode } from '../lib/shared/exit-codes.ts';
import { formatViolation, die } from '../lib/shared/format.ts';
import { resolveTarget, isExempted } from '../lib/shared/path.ts';Skills invoke Node scripts using the canonical pattern:
bun run --cwd ~/.config/opencode/scripts/node <script> -- [args]# Lint a markdown file:
bun run --cwd ~/.config/opencode/scripts/node lint:md -- ~/.config/opencode/.proposals/my-proposal.md
# Run the example script:
bun run --cwd ~/.config/opencode/scripts/node exampleRun the linter:
\`\`\`bash
bun run --cwd ~/.config/opencode/scripts/node lint:md -- <path>
\`\`\`The --cwd flag ensures Bun uses the correct package.json for dependency resolution.
The -- separator prevents cleye from interpreting file paths as flags.
Python is the default platform for script tasks. Node is used only when the core logic requires a Node-specific library (remark, mdast, babel, TypeScript compiler API) that has no mature Python equivalent. See the Node Script Support proposal for the full decision framework.
-
Create CLI entry point —
src/cli/<name>.ts- Use cleye for argument parsing.
- Import
ExitCodefrom../lib/shared/exit-codes.ts. - Write errors to stderr with
die()from../lib/shared/format.ts.
-
Create library module —
src/lib/<name>/core.ts- Implement the core logic here.
- Keep CLI entry point thin (parse args → call core → exit).
-
Register in
package.json- Add a
"scripts"entry:"<name>": "bun src/cli/<name>.ts --" - Add any new dependencies to
"dependencies".
- Add a
-
Add tests
tests/<name>.test.ts— unit tests for library functions.tests/<name>.cli.test.ts— CLI integration tests.
-
Verify
bun run --cwd scripts/node lint # biome check bun run --cwd scripts/node typecheck # tsc --noEmit bun run --cwd scripts/node test # bun test
-
Create or update a skill to invoke the new script (see Skill Integration above).