⚠️ Preview Release: This is an early preview version. Expect frequent breaking changes until a stable release is published. APIs, commands, and behaviors may change without prior notice.
A command framework that serves one command tree through both shell and MCP — from a single definition with zero runtime dependencies.
npm install @jlceda/mcli@jlceda/mcli ships with a single direct dependency — ajv(validated JSON Schema data) which is bundled into the ESM artifact at build time and requires no additional install from your host project.
// app.ts
import { createMcli } from "@jlceda/mcli";
export const app = createMcli({
name: "my-cli",
version: "1.0.0",
});
const issue = app.group("issue", { summary: "Work with issues" });
issue.command("list", {
summary: "List issues",
description: "List issues from a repository.",
input: {
type: "object",
properties: {
repo: { type: "string", description: "Repository in owner/name format" },
state: { type: "string", enum: ["open", "closed", "all"], default: "open" },
limit: { type: "number", description: "Max results", default: 20 },
},
required: ["repo"],
},
examples: [
{
title: "List open issues",
input: { repo: "acme/app", state: "open" },
},
],
handler: async (input) => {
const issues = await fetchIssues(input);
return {
data: { issues },
next: [{ path: "issue.get", reason: "Read issue detail" }],
};
},
display: (result) => {
const { issues } = result.data as any;
console.log(`Found ${issues.length} issues`);
for (const i of issues) {
console.log(` #${i.id} ${i.state.padEnd(6)} ${i.title}`);
}
},
});// bin.ts — your CLI entry
#!/usr/bin/env node
import { app } from "./app";
import { runCli } from "@jlceda/mcli";
await runCli(app, process.argv.slice(2));# Human-readable (display callback invoked)
node bin.js issue list --repo acme/app
# Found 2 issues
# 1 open Fix login
# 2 open Add tests
# JSON (no display callback → fallback to JSON.stringify(result))
node bin.js issue list --repo acme/app --json
# { "ok": true, "path: "issue.list", "data": { "issues": [...] }, "next": [...] }
# MCP stdio
node bin.js --mcp stdio
# MCP stateless Streamable HTTP
node bin.js --mcp http --port 3030Commands are registered via group() binders — no dot-paths allowed:
const issue = app.group("issue", { summary: "Work with issues" });
issue.command("list", {...}); // CLI path: issue list, MCP path: issue.list
issue.command("close", {...}); // CLI path: issue close, MCP path: issue.close
// Nesting works too:
const sub = issue.group("label", { summary: "Label ops" });
sub.command("add", {...}); // CLI path: issue label add, MCP path: issue.label.addapp.command("foo.bar", ...) throws — you must use group binders for nesting.
Every command is a node. The path issue.list maps to:
| Layer | Reference |
|---|---|
| CLI argv | issue list |
| MCP call | { path: "issue.list", input: {...} } |
| Shell help | my-cli issue list --help |
| Discovery | mcli.discover({ query: "list issues" }) |
| Order | Tool | Purpose |
|---|---|---|
| 1 | mcli.help |
ALWAYS call first. Returns subcommands, schemas, usage examples. |
| 2 | mcli.discover |
Find specific commands by keyword after help. |
| 3 | mcli.call |
Execute a command. Returns real data (search results, page content...). |
Agent workflow:
Model: "what can this tool do?"
→ mcli.help()
→ { children: [{ path: "issue.list", summary: "..." }, ...] }
Model: "find issue-related commands"
→ mcli.discover({ query: "issue" })
→ [ { path: "issue.list", summary: "List issues" }, ... ]
Model: "list open issues for acme/app"
→ mcli.call({ path: "issue.list", input: { repo: "acme/app", state: "open" } })
→ { ok: true, data: { issues: [...] }, next: [{ path: "issue.get", reason: "Read detail" }] }
The next field in results suggests follow-up actions — chain tools naturally.
command has display? ─── yes → display(result)
│
no
├─ --json? → { ok, path, data, next }
└─ default → JSON.stringify(result, null, 2)
display lets each command control its human-readable shape. The data field carries structured output for JSON consumers.
| Flag | Protocol | Use case |
|---|---|---|
--mcp stdio |
stdio | Local CLI tools, Claude Code, Cursor |
--mcp http |
stateless Streamable HTTP | Remote services, browser agents, multi-client |
HTTP mode is stateless — each request creates a fresh transport, no session lifecycle. Optional --token for auth, --cors for browser access.
function createMcli(opts: { name: string; version: string; summary?: string }): McliApp;Returns a CommandGroup binder for progressive registration.
interface CommandGroup {
command(name: string, opts: CommandOptions): void;
group(name: string, opts: GroupOptions): CommandGroup; // nesting
}
const github = app.group("github", {
summary: "Work with GitHub",
description: "Longer-form description.",
});
github.command("list", {...}); // path: github.list
const issue = github.group("issue", { summary: "Issues" });
issue.command("list", {...}); // path: github.issue.listname — simple identifier, no dots allowed. Throws if contains ..
Idempotent — re-registering updates metadata.
Top-level command (no nesting). For nested commands, use app.group().command().
app.command("hello", {
summary: "Say hello",
input: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
handler: async (input) => ({ data: { message: `Hello ${input.name}` } }),
});name — simple identifier, no dots allowed. Throws if contains ..
interface CommandOptions {
summary: string;
description?: string;
input: JSONSchema;
examples?: CommandExample[];
related?: string[];
handler: (
input: Record<string, unknown>,
ctx: CommandContext,
) => Promise<CommandResult> | CommandResult;
display?: (result: CommandResult) => void;
}input — JSON Schema. Validation via ajv. default values are filled before handler runs.
examples — Each example's input is validated against the command's input schema at registration time. Invalid examples throw immediately.
handler receives:
input— validated + default-filled objectctx—{ services?: Record<string, unknown> }(reserved)
handler returns:
interface CommandResult {
data?: unknown; // structured output
next?: Array<{ path: string; reason: string }>; // Agent hints for follow-up
}display — optional CLI renderer. Receives the CommandResult. Not used in MCP mode.
Look up a node (returns null if unknown) / list all leaf commands (used by discover).
CLI driver. Call from your own bin entry:
await runCli(app, process.argv.slice(2));Handles --help / --search / --json / --mcp dispatch, plus direct command invocation.
Commands declare input as JSON Schema. Supported:
| Kind | Example |
|---|---|
| string | { type: "string", description: "..." } |
| number | { type: "number" } |
| boolean | { type: "boolean" } |
| enum | { type: "string", enum: ["open", "closed"] } |
| object (nested) | { type: "object", properties: { ... }, required: [...] } |
| optional | omit from required |
| default | { type: "number", default: 20 } |
# Shell (text)
Error: (root): must have required property 'repo'
# Shell (--json)
{
"ok": false,
"path": "issue.list",
"error": {
"code": "VALIDATION_ERROR",
"message": "(root): must have required property 'repo'",
"details": [{ "field": "(root)", "message": "must have required property 'repo'" }]
}
}
# MCP (mcli.call)
{ "isError": true, "content": [...] }
| Error class | Code |
|---|---|
McliValidationError |
input failed JSON Schema |
McliExecutionError |
handler threw / missing handler |
Registration-time errors:
- Dot in command/group name →
"Command(name) must not contain dots. Use app.group() to nest commands." - Invalid example input → throws immediately on
command()call
@jlceda/mcli ships with a single direct dependency — ajv (JSON Schema validator), which is bundled into the ESM artifact (dist/index.js) at build time.
Host projects install nothing extra beyond @jlceda/mcli.
npm run build # dist/index.js + dist/index.d.ts
npm test # vitest, 62 tests
npm pack # produce tarballShips only dist/. @jlceda/mcli → dist/index.js.
MIT