File-based routing for CLIs. Write a folder of functions — get a command-line tool, --help, shell completions, and an MCP server from the same tree.
clfly is a TypeScript CLI framework where a commands/ directory is the single source of truth. Each file exports a schema (Zod 4, Valibot, ArkType — anything Standard Schema) and a default function. Everything else is derived: argument parsing, validation, help text, completions, and an MCP server that exposes every command as a tool AI agents can call.
mcp serve is to agents what --help is to humans — every tool answers for itself, in a uniform grammar, because both are projections of the same tree.
If you know Next.js routing or PostgREST, it's that idea pointed at argv: the tree is the interface. If you don't — you never write a parser, a help screen, or a tool manifest again.
// commands/users/list.ts
import { z } from "zod";
export const meta = { description: "List users in the workspace" };
export const args = z.object({
status: z.enum(["active", "churned"]).optional().describe("Filter by status"),
limit: z.coerce.number().default(50).describe("Max rows"),
});
export default async function (opts: z.infer<typeof args>, ctx) {
const rows = /* ... */;
if (ctx.json) return rows;
for (const row of rows) ctx.stdout.write(`${row.id}\t${row.status}\n`);
}commands/
users/
list.ts → mycli users list → MCP tool users_list
[id]/
show.ts → mycli users <id> show → MCP tool users_show
deploy.ts → mycli deploy → MCP tool deploy
mycli users list --status active
mycli users u_1 show
mycli deploy --env staging --dryRun
mycli users list --json
mycli mcp serve # same tree, now it's an MCP server over stdio
mycli sessions list # inspect .clfly/sessions (large-result spool store)
mycli users list --json --spool # opt-in: write full JSON under .clfly/ + print path/envelopeLarge MCP/--json results auto-spool (or --spool on the CLI) under a shared .clfly/sessions/ ledger — see docs/spec/session-store.md.
If you're building tooling that both humans and AI agents use, you're currently writing every schema twice — once for your CLI parser, once for your MCP tool definitions — and keeping them in sync by hand. clfly makes that structurally impossible to get wrong: there is only one schema, colocated with the function it validates, and every interface is a projection of it.
The schema is portable JSON Schema all the way down. The same file that answers mycli users list --status active at a terminal is, unmodified, a validated tool in Claude Desktop, Cursor, or any MCP client.
npm create clfly mycli
# or: clfly init mycli (from @clfly/cli)
cd mycli && pnpm install
pnpm exec tsx bin/mycli.ts --help
# Point an agent at the same tree:
claude mcp add mycli -- npx -y mycli mcp serve
# Scaffold via clfly itself (jailed to the project root):
claude mcp add clfly -- npx -y @clfly/cli mcp serve --root .Or wire core yourself:
pnpm add @clfly/core zod// bin/mycli.ts
import { createCli } from "@clfly/core";
const cli = createCli({
name: "mycli",
commandsDir: new URL("../commands", import.meta.url),
});
await cli.run(process.argv.slice(2));Add files under commands/. That's the framework. A working reference lives in examples/demo-cli.
For production startup speed, clfly build compiles the tree into a lazy, versioned manifest — no directory scanning at runtime, no loading commands you didn't invoke.
Each layer does one job, and the seams are standard formats rather than framework internals:
- yargs-parser tokenizes argv (
--flag,--key=value, aliases,--no-*). - Your schema owns types, coercion, defaults, enums, refinements, and error messages.
- The file tree is the router — directories nest subcommands,
[param]segments become positional arguments. - JSON Schema is the interchange format: help text, shell completions, and MCP
inputSchemaare all projected from it, never hand-maintained.
Command modules are plain data plus a function — no classes, no builder chains, no registration calls. Every command file is independently importable and unit-testable with zero framework imports.
- Validator-agnostic by contract. The compatibility surface is Standard Schema + JSON Schema, not any validator's major version. Zod is an optional peer dependency (
^4); Valibot and ArkType work identically. - Dev scans, prod compiles. Filesystem routing's known costs — cold-start scanning, "magic" outside the JS runtime — are solved the way Next.js solves them: live scanning in dev, a codegen'd manifest with lazy
import()thunks in production. The manifest carries aformatVersion, and the loader hard-fails on mismatch instead of misbehaving quietly. - Reserved surface is loud.
--help/-h,--version/-V,--json, and the top-levelmcpcommand are framework-owned. A schema that collides with them fails at build time, not silently at parse time. - Deprecation is a first-class field.
meta.deprecatedprojects into help output, JSON Schema'sdeprecatedkeyword, and MCP tool descriptions — a graceful path before removal, for humans and agents alike. - Testable core. Commands return values or throw typed errors; the bin wrapper owns
process.exit.--jsonmode serializes return values; errors become{ "error": … }on stderr.
clfly is dogfooded: the clfly binary itself (init, add, build, completions, mcp) is defined as a clfly command tree in packages/cli.
clfly stands on a lot of good work — none of it quite does this.
- oclif proved filesystem routing and manifests for CLIs, but commands are classes with framework-specific flag definitions — the schema is entangled code you can't hand to anything else.
- trpc-cli proved the schema pipeline (Standard Schema → JSON Schema → flags, validator-agnostic), but the command tree is a tRPC router you assemble by hand.
- stricli wrote the best critique of filesystem routing — scanning is slow and magic. The objection is right; the conclusion isn't. Compile the tree.
- citty, brocli, zod-opts each nail a piece —
defineCommandergonomics, schema-driven flags,.describe()→ help — without filesystem routing or a portable schema layer. - Next.js / express-file-routing showed a file tree with
[param]segments is a perfectly good router. For HTTP. Nobody had applied it to argv.
The gap all of them leave: no package treats a directory of (schema, function) pairs as the single source of truth and derives every interface — CLI, help, completions, MCP, and (roadmap) HTTP / OpenAPI — from that one tree.
- ✅ M1 — router, parsing, validation,
--help/--version, reserved-flag errors - ✅ M2 —
clfly buildmanifest, bash/zsh/fish completions, global--json - ✅ M3 —
mycli mcp serve: every command as an MCP tool (stdio,--rootscoping, optionaloutput→outputSchema/ structured content, session store for large results, tool annotations;listChangedlater) - ⬜ M4a —
clfly export openapi: OpenAPI 3.1 from the tree (RPC-over-POST paths; no server) - ⬜ M4b —
clfly http serve: the same mapping as a live JSON API (auth on by default) - ⬜ M5 — ecosystem packages:
@clfly/docs,clfly palette(TUI),@clfly/palette(web ⌘K)
Ecosystem packages consume only the build manifest and the exported OpenAPI document — never @clfly/core internals (ecosystem contract). Decisions live in docs/decisions.md.
Plugins, i18n, config-file merging, telemetry. The framework does not ship interactive prompts; the resolver seam for them is reserved (command bodies stay args → result). Also out of scope: GET/query-param HTTP mapping, content negotiation, streaming responses; web palette auth flows beyond passing a bearer token through; hosted anything — every transport is self-serve.
| Package | Role |
|---|---|
@clfly/core |
Router, parser, help, build, completions; MCP projection (server APIs at @clfly/core/mcp) |
@clfly/cli |
Product binary clfly (init, add, build, completions) |
@clfly/create |
Thin npm create clfly alias for init |
examples/demo-cli |
Reference CLI + MCP server |