A composable command-line toolkit for automating repetitive development workflows, built with TypeScript, Node, and zx.
Every command reads plain lines (or JSON, where noted) from stdin and writes the same format to stdout. That's the only contract commands need to honor, which means any command can be piped into any other — including ones you add yourself.
devkit git:changed | devkit todo:find
devkit git:changed | devkit filter '\.ts$' | devkit exec 'eslint {}'Requirements: Node.js 18+ and Git.
From a checkout of this repository:
npm install
npm run buildThen choose how you want to run the CLI:
# Run the built CLI directly from this checkout
npm start -- --help
# Or make `devkit` available globally while you develop it
npm link
devkit --helpFor source-mode development without rebuilding, use npm run dev -- followed
by the command and options:
npm run dev -- git:changed --stageddevkit is intentionally small: each command does one thing and keeps stdout
pipeable for the next command.
| Command | Input | Output | Common options |
|---|---|---|---|
git:changed |
current Git repository | changed file paths, one/line | --base <ref> (default HEAD), --staged |
todo:find |
file args or file paths stdin | TODO/FIXME/HACK hits | --json for structured output |
filter |
stdin lines | matching stdin lines | -v/--invert, -i/--ignore-case |
exec |
stdin lines | command stdout | -p/--parallel; {} substitutes each line |
Run devkit <command> --help for full options.
Lists files changed in the current repository. By default it compares the
working tree with HEAD; use --staged to list only staged paths or
--base <ref> to compare against another ref.
devkit git:changed
devkit git:changed --staged
devkit git:changed --base mainScans files for TODO, FIXME, and HACK markers. Pass files explicitly, or
pipe file names from another command.
devkit todo:find src/cli.ts README.md
devkit git:changed | devkit todo:find
devkit todo:find --json src/cli.tsKeeps only piped lines that match a JavaScript regular expression.
devkit git:changed | devkit filter '\.ts$'
devkit git:changed | devkit filter -i 'readme|license'
devkit git:changed | devkit filter -v '\.md$'Runs a shell command template once for each piped line. Include {} where the
line should be inserted; if omitted, the line is appended to the template.
devkit git:changed | devkit exec 'wc -l {}'
devkit git:changed | devkit filter '\.ts$' | devkit exec 'npx prettier --check {}'This workflow starts with a Git working tree, narrows the file list, scans for work markers, and optionally runs another tool over the same changed files.
-
Install and expose the CLI:
npm install npm run build npm link
-
In any Git repository, make or stage a change that contains a marker such as:
// TODO: replace the placeholder implementation -
See the changed files that
devkitcan compose over:devkit git:changed # or, if you staged the files: devkit git:changed --staged -
Find only TODO/FIXME/HACK markers in changed TypeScript files:
devkit git:changed \ | devkit filter '\.ts$' \ | devkit todo:find
Example output:
src/example.ts:12: [TODO] replace the placeholder implementation -
Reuse the same changed-file stream with an external command:
devkit git:changed \ | devkit filter '\.ts$' \ | devkit exec 'npx prettier --check {}'
Because every command writes data to stdout and status messages to stderr, the
pipeline remains safe to extend with standard shell tools such as sort,
uniq, tee, or xargs.
Tests run on Vitest, split into two layers:
npm test # build + full suite (unit + integration)
npm run test:unit # unit tests only, no build needed
npm run test:watchtests/unit/— tests pure logic directly, no subprocess, no disk I/O. e.g.scanForTodos()and theprintLines/printJson/logStatusoutput contract. These are the fast, comprehensive layer — new commands should push their parsing/formatting logic intosrc/lib/specifically so it's unit-testable like this instead of buried in a command's.action().tests/integration/— spawns the actual built CLI (node dist/cli.js ...) as a real subprocess against a throwaway git repo fixture, piping one command's stdout into another's stdin exactly like a user would on the command line. This is what guarantees composability doesn't silently break — e.g.git:changed | todo:find,git:changed | filter | exec.npm testbuilds first (pretestscript) so these always run against current code, not a staledist/.
Adding a command? Pair it with:
- A unit test for any parsing/formatting you pulled into
src/lib/. - One integration test proving it composes with at least one other command via a real pipe.
Commands are self-contained modules under src/commands/. The pattern:
// src/commands/my-command.ts
import { Command } from "commander";
import { readStdinLines } from "../lib/stdin.js";
import { printLines } from "../lib/output.js";
export function registerMyCommand(program: Command): void {
program
.command("my:command")
.description("...")
.action(async () => {
const input = await readStdinLines(); // accept piped input
printLines(input); // emit pipeable output
});
}Then register it in src/commands/index.ts. Because every command shares the
same readStdin* / print* helpers in src/lib/, new commands automatically
compose with the existing ones — no glue code required.
- stdout is data, stderr is noise. Status/progress messages go through
logStatus()(stderr) so they never corrupt a pipeline; only the actual result goes to stdout. - Lines are the default interchange format, JSON is opt-in (
--jsonflags) for commands where structure matters (e.g.todo:find --json). - TTY detection means every command still works standalone — piping is optional, not required.
devkit-cli/
├── bin/devkit # published entrypoint (points at dist/cli.js)
├── src/
│ ├── cli.ts # program setup
│ ├── commands/ # one file per subcommand
│ └── lib/
│ ├── stdin.ts # readStdin / readStdinLines / readStdinJson
│ └── output.ts # printLines / printJson / logStatus
└── tsconfig.json