Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ Versions before 0.8.0 are in the [git tags](https://github.com/leemour/cli-core/
Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
[`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).

## 0.16.0 — 02.10.2026

### Added

- **`@leemour/cli-core/mcp` installs and checks local stdio MCP entries.** A CLI can add its existing
MCP server to Codex or Claude Code through those clients' own commands and probe the handshake and
tool list without calling a tool. Existing entries are refused, so callers must remove one in the
client before changing it.
Client commands use `cross-spawn` so npm's Windows `.cmd` shims can be started.

## 0.15.0 — 01.10.2026

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ streams.stderr // []
| `/codegen` | build time: an official API description → types, Valibot schemas, an operation manifest and a coverage page — see below |
| `/release` | release time: `releaseCheck` and the checks it runs — changelog shape, links, package contents, version in step — see below |
| `/skill` | the CLI's SKILL.md for coding agents: `skillCommand` (`show`, `install`), `skillHint`, `skillResource` — see below |
| `/mcp` | local stdio MCP setup for Codex and Claude Code, plus a read-only handshake and tool-list probe |

**Nothing in the root export is HTTP.** Status classification, `Retry-After` parsing and the fetch
seam live in `@leemour/cli-core/http`, so a CLI that speaks a socket never depends on a stack it
Expand Down
1 change: 1 addition & 0 deletions docs/dev/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ architecture proposal ([private repository](https://github.com/leemour/max-cli-p
| `/codegen/runtime` | the number helpers generated schemas import | `valibot` |
| `/release` | release checks a CLI runs before it publishes: `releaseCheck` and the checks it runs (changelog shape, document links, versions in step, package contents) | Node built-ins only |
| `/skill` | the CLI's SKILL.md for agents: the `skill` command, the daily hint, the MCP resource | Commander **at run time** (the only entry point that is), `/update`'s state file, the root's `Renderer` and `Streams` types |
| `/mcp` | local stdio MCP entry setup and a handshake probe | Node child processes; no messenger or MCP SDK dependency |
| `/testing` | `captureStreams`, `memoryKeyring`, `brokenKeyring`, `fakeClock` | the root |

Each is a separate `exports` entry in [`package.json`](../../package.json), built from
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@leemour/cli-core",
"version": "0.15.0",
"version": "0.16.0",
"description": "The parts every command line tool needs: output modes, terminal rendering, error model, exit codes, credentials, clocks",
"license": "MIT",
"author": "Viacheslav Ptsarev",
Expand Down Expand Up @@ -63,6 +63,10 @@
"types": "./dist/skill/index.d.ts",
"default": "./dist/skill/index.js"
},
"./mcp": {
"types": "./dist/mcp/index.d.ts",
"default": "./dist/mcp/index.js"
},
"./biome": "./config/biome.base.json",
"./tsconfig.base.json": "./config/tsconfig.base.json",
"./tsconfig.test.json": "./config/tsconfig.test.json",
Expand All @@ -87,6 +91,7 @@
},
"dependencies": {
"cli-table3": "^0.6.5",
"cross-spawn": "7.0.6",
"env-paths": "^4.0.0",
"picocolors": "^1.1.1",
"pino": "^10.3.1",
Expand All @@ -97,6 +102,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@types/cross-spawn": "6.0.6",
"@types/node": "^22.20.4",
"@vitest/coverage-v8": "^5.0.2",
"commander": "^15.0.0",
Expand Down
57 changes: 57 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions scripts/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,16 @@ import {
resolvePaths,
saveConfigFile,
} from "../src/index.js"
import { setupArguments } from "../src/mcp/index.js"
import { fakeClock } from "../src/testing/index.js"
import { installerOf, isNewer } from "../src/update/index.js"

const ESCAPE = String.fromCharCode(27)
checkMcpImport()
function checkMcpImport() {
const args = setupArguments("codex", "smoke", { type: "stdio", command: "/bin/node", args: ["mcp"] })
if (args.join(" ") !== "mcp add smoke -- /bin/node mcp") throw new Error("MCP setup export failed")
}
const runtime = typeof (globalThis as { Bun?: unknown }).Bun === "undefined" ? "node" : "bun"
const failures: string[] = []

Expand Down
87 changes: 87 additions & 0 deletions src/mcp/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest"
import { installStdioEntry, probeStdio, type StdioEntry, setupArguments } from "./index.js"

const entry: StdioEntry = {
type: "stdio",
command: "/usr/bin/node",
args: ["/opt/max/dist/bin/max.js", "work", "mcp"],
env: { MAX_CONFIG_DIR: "/tmp/work config" },
}

describe("local MCP setup", () => {
it("uses the clients' own commands and preserves paths and environment values as arguments", () => {
expect(setupArguments("codex", "max-work", entry)).toEqual([
"mcp",
"add",
"max-work",
"--env",
"MAX_CONFIG_DIR=/tmp/work config",
"--",
"/usr/bin/node",
"/opt/max/dist/bin/max.js",
"work",
"mcp",
])
expect(setupArguments("claude-code", "max-work", entry)).toEqual([
"mcp",
"add",
"--scope",
"user",
"max-work",
"--env",
"MAX_CONFIG_DIR=/tmp/work config",
"--",
"/usr/bin/node",
"/opt/max/dist/bin/max.js",
"work",
"mcp",
])
})

it("refuses an existing entry without removing it", () => {
const calls: string[][] = []
expect(() =>
installStdioEntry("codex", "max-work", entry, {
run: (_file, args) => {
calls.push(args)
return { status: 0, stdout: "{}", stderr: "" }
},
}),
).toThrow("already configured")
expect(calls).toEqual([["mcp", "get", "max-work", "--json"]])
})

it("adds a missing entry without touching other entries", () => {
const calls: string[][] = []
const installed = installStdioEntry("claude-code", "tg", entry, {
run: (_file, args) => {
calls.push(args)
return calls.length === 1
? { status: 1, stdout: 'No MCP server named "tg"', stderr: "" }
: { status: 0, stdout: "Added", stderr: "" }
},
})
expect(installed.name).toBe("tg")
expect(calls.map((args) => args.slice(0, 2))).toEqual([
["mcp", "get"],
["mcp", "add"],
])
})
})

describe("local MCP doctor", () => {
it("initializes a stdio server and lists tools without calling them", async () => {
const program = [
"const rl = require('node:readline').createInterface({input: process.stdin});",
"rl.on('line', line => { const m = JSON.parse(line);",
"if (m.method === 'initialize') process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result:{protocolVersion:'2025-11-25',capabilities:{tools:{}},serverInfo:{name:'fake',version:'1'}}})+'\\n');",
"if (m.method === 'tools/list') process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:m.id,result:{tools:[{name:'max_status',annotations:{readOnlyHint:true}},{name:'max_messages_send',annotations:{readOnlyHint:false}}]}})+'\\n');",
"if (m.method === 'tools/call') process.exit(10);",
"});",
].join("")
await expect(probeStdio({ type: "stdio", command: process.execPath, args: ["-e", program] })).resolves.toEqual({
tools: ["max_status", "max_messages_send"],
potentialWrites: ["max_messages_send"],
})
})
})
Loading
Loading