This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
bun install # Install dependencies
bun run bashful.ts <command> # Wrap a command using --help
bun run bashful.ts curl \| wget # Multiple commands, one endpoint each
bun run bashful.ts curl --help \| wget --help # Explicit help commands (pipe mode)
bun run bashful.ts --debug curl \| wget # Run with debug logging
bun run bashful.ts --config policy.json curl # Run with an access-control policy
bun run start # Alias: wraps curlbun test # Run all tests
bun test -t "authorizeFlags" # Run one describe block / test by name
bunx tsc --noEmit # Typecheck (tsconfig is noEmit; there is no build step)CI (.github/workflows/test.yml) runs bun install + bun test on push/PR to main. Lint is not wired into package.json — ESLint, gitleaks, and whitespace fixers run via .pre-commit-config.yaml.
Env vars: PORT (default 3000), HOST (default 127.0.0.1), BASHFUL_CONFIG (policy file path).
docs/usage.md (invocation, endpoints, payloads, access control), docs/architecture.md (schema synthesis, enforcement layering, invariants), docs/testing.md (suite layout, integration-test mechanics). Keep them in sync when changing behaviour they describe.
The entire application lives in a single file: bashful.ts.
Startup flow:
- Ingestion — Executes
<command> --help(or<explicit command>inpipemode) viaBun.spawnSyncand captures stdout+stderr. - Schema synthesis — Parses the help text with a heuristic regex (the "Bashful Regex") that extracts short flags (
-x), long flags (--foo), argument types (<val>,[val], orALL_CAPS), and descriptions into aschemaobject keyed by flag name. - Policy — Loads an optional JSON config (
--config <file>,$BASHFUL_CONFIG, or./bashful.config.json) and refuses to wrap any denied command. Denied flags are stripped from the served schema so the UI can't offer them. - Server — Spins up a
Bun.serveHTTP server on port 3000 with three routes:GET /(also/docs,/ui) — Returns a self-contained HTML page (the Swagger-like UI) with the schema baked in via template literal.GET /<command>/schema— Returns the parsed schema as JSON.POST /<command>orGET /<command>— Checks the payload against the policy (403 with areasonif blocked), then translates the JSON body (or query params) back into CLI arguments and executes the command withBun.spawn, streaming stdout as the response.
Payload conventions (POST /<command>):
_args: positional arguments (string or string array)_stdin: string piped to the command's stdin. Absent → stdin is closed, not inherited (else a stdin-reading tool hangs).- Boolean flags:
{ "silent": true }→--silent - Value flags:
{ "output": "file.html" }→--output file.html - Array values repeat the flag:
{ "header": ["a", "b"] }→--header a --header b - Unknown single-char keys fall back to
-xshort-flag style; objects are rejected with 400. Accept: application/jsonbuffers and returns{exitCode, stdout, stderr, timedOut}instead of streaming.
Multiple commands: Separate commands with \| (escaped pipe character). Each segment becomes its own endpoint and tab in the UI.
bashful.ts curl \| wget— two endpoints:/curland/wgetbashful.ts curl --help \| wget --help— pipe mode per segment: runs the full command as-is to get help text (useful when--helpalone fails or outputs to stderr)
Access control: an optional config file gates commands, flags, and flag values. mode is blacklist (allow unless denied) or whitelist (deny unless allowed). commands.allow/deny gate whole commands; flags.<cmd>.allow/deny gate individual flags; flags.<cmd>.denyCombinations/allowCombinations gate sets of flags used together; flags.<cmd>.values maps a flag to a regex its value must match. The "*" key under flags applies to every command; "*" inside a list means "everything". Rules name payload keys (output, _args), not CLI spellings (--output). Deny always beats allow. See bashful.config.example.json and docs/usage.md.
--debug flag: logs startup time, number of parsed flags, config load, blocked requests, and each execution command.
Pure core, imperative shell. Everything above the // ── Entry point ── divider in bashful.ts is exported pure functions (splitSegments, parseSchema, buildCLIArgs, normalizeConfig, authorizeCommand, authorizeFlags, filterSchema, …); everything below runs under if (import.meta.main). Tests import the module directly, so new logic belongs above the divider — anything below it only runs as a process and can't be unit-tested.
Invariants that are easy to undo accidentally:
- The server binds to
127.0.0.1by default. It executes arbitrary CLI commands, so it must not listen on0.0.0.0unless the operator deliberately setsHOST. - Browser hardening works only as a set — no CORS headers unless
--allow-originnames one; exec requiresPOST+Content-Type: application/json(this is what forces a preflight, which then fails); theHostheader must be loopback (defeats DNS rebinding);GETexec is off unless--allow-get. Loosening any single one re-opens command execution to any web page the user visits. Seedocs/architecture.md#invariants. - The exec route merges stdout and stderr into one stream. Many CLIs write their real output or diagnostics to stderr; returning stdout alone silently yields empty responses.
Tests (bashful.test.ts) are unit tests plus integration tests that spawn the real server via bun run ./bashful.ts on fixed ports (3005–3009). New integration suites need their own unused port, and policy fixtures are written to tmpdir() and passed with --config.
Windows: safeSpawn retries a failed ENOENT spawn through cmd /c, which is how shell builtins and .cmd shims resolve. The \| command separator is escaped because an unescaped | would be consumed by the shell.