← Projects

clfly(active)

File-based routing for CLIs — one commands/ tree becomes CLI, completions, and MCP.

npm create clfly mycli

One commands/ tree of schema-declared functions → argv (--help), shell completions, and mcp serve. Schema once, derive the interface (PostgREST’s move on CLIs).

Migration: wrap an existing CLI as schemas over shell-outs and get an agent-callable surface without rewriting it.

Status
published (@clfly/cli, @clfly/core, @clfly/create on npm)
Tests
90 core tests passing
Distribution
one commands/ tree → CLI, shell completions, MCP serve

Surfaces

Router, parsing, validation, help/version, build manifest, bash/zsh/fish completions, --json, and mcp serve (stdio, --root scoping, optional output schemas / structured content, session store for large results). Self-hosting: clfly scaffolds clfly CLIs via its own tree. Spec is implementation-neutral.

One schema, three readers

Contract is Standard JSON Schema. clfly asks every schema for ~standard.jsonSchema.input({ target: "draft-07" }) and refuses vendors that can’t produce it:

export function toJsonSchema(schema: AnySchema): JsonSchema {
  const std = schema["~standard"];
  if (std.jsonSchema?.input) {
    return std.jsonSchema.input({ target: "draft-07" }) as JsonSchema;
  }
  throw new ClflyError(
    `Schema (vendor: ${std.vendor}) does not implement Standard JSON Schema ` +
      `(~standard.jsonSchema). Use Zod 4, ArkType, or Valibot with JSON Schema support.`,
  );
}

Vendor-specific reads stay optional sugar (Zod .meta({ alias }) for short flags). One draft-07 document feeds three readers.

Argv. Position in the tree decides surface: [param] segments become positionals (also answer to --name); an exported positionals tuple claims the rest; remaining properties become flags. Tokenizer coerces booleans only (for --no-*); numbers stay strings until the validator owns types — z.coerce.number() converts and applies defaults. Avoids two type systems disagreeing at the tokenizer/validator boundary. --help renders from the same records.

MCP. Path → tool name by dropping dynamics and joining with underscores (users/[id]/showusers_show). Path params merge back as required string properties — argv carries data in the address; MCP is flat. Collisions hard-fail. Optional output schemas → outputSchema / structured content. Large results spool under .clfly/sessions/.

Completions. Bash/zsh/fish from the same build-manifest records.

Worked example

commands/users/[id]/show.ts:

import { z } from "zod";
import type { Context } from "@clfly/core";

export const meta = { description: "Show a single user" };

export const args = z.object({
  id: z.string().describe("User id"),
});

export default async function (opts: z.infer<typeof args>, ctx: Context) {
  const user = { id: opts.id, status: "active" };
  if (ctx.json) return user;
  ctx.stdout.write(`${user.id}\t${user.status}\n`);
}

Human help:

Usage: demo users <id> show [options]

Show a single user

Arguments:
  <id>  User id (also --id)

Options:
  -h, --help     Show help
  -V, --version  Show version
      --json     Emit JSON

Agent tool:

{
  "name": "users_show",
  "description": "Show a single user",
  "inputSchema": {
    "type": "object",
    "properties": { "id": { "type": "string", "description": "User id" } },
    "required": ["id"]
  }
}

One file, one schema. .describe("User id") appears in both because both are projections.

Two routers, one tree

Dev: live filesystem walk ([name] → dynamic segments, index → directory leaves). Prod: build bakes the walk into a manifest — flags, positionals, schemas precomputed, handlers behind lazy () => import(...) thunks. createCli prefers the manifest. Zero-build dev loop; shipped CLI pays no scan cost at startup. Manifest doubles as completions input.