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
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ claude plugin marketplace add .
claude plugin install ratel-mcp@ratel
```

Claude Code plugins cannot currently set a top-level `statusLine` default.
Install the Ratel statusline separately with `ratel-mcp statusline install`
or from the Claude Code agent page in `ratel-mcp ui`. See Claude's
[statusline docs](https://code.claude.com/docs/en/statusline) and
[plugin reference](https://code.claude.com/docs/en/plugins-reference).

## CLI quickstart

`ratel-mcp` mirrors `claude mcp add`'s flag layout — any invocation that works against Claude Code's CLI works here unchanged.
Expand All @@ -101,6 +107,9 @@ ratel-mcp mcp import --agent codex
ratel-mcp mcp link
ratel-mcp mcp link --agent claude-code

# Install the Claude Code statusline
ratel-mcp statusline install

# Start the gateway over stdio (this is what linked agents spawn)
ratel-mcp serve --config ~/.ratel/config.json
```
Expand All @@ -111,6 +120,7 @@ Run `ratel-mcp <group>` for the verbs in a group:
|---|---|
| `mcp` | `add`, `remove`, `list`, `get`, `edit`, `import`, `link`, `auth` |
| `backup` | `list` |
| `statusline` | render from stdin, `install`, `uninstall` |
| (top-level) | `serve`, `ui` |

### `ratel-mcp mcp add` — Claude-compatible
Expand Down Expand Up @@ -145,6 +155,36 @@ By default, `mcp add` connects to the upstream and stores its server-level `inst

When you run `ratel-mcp serve --config a.json --config b.json --config c.json`, the configs are merged in order — last wins on `mcpServers` key collisions. The `link` command wires the right `--config` chain into Claude Code at each scope. The `import` wizard migrates selected native MCP entries into Ratel and can clean those imported entries out of the agent config as its second stage.

### Claude Code statusline

`ratel-mcp statusline` is a Claude Code statusline command. Claude passes a
JSON payload on stdin; the command writes two statusline rows to stdout and
fails open with a loading/no-telemetry line if the payload or telemetry is
missing.

```bash
ratel-mcp statusline install # write ~/.claude/settings.json
ratel-mcp statusline install --force # replace another configured statusLine
ratel-mcp statusline uninstall # remove only a Ratel-owned statusLine
```

Install writes a user-scoped Claude Code setting:

```json
{
"statusLine": {
"type": "command",
"command": "ratel-mcp statusline",
"padding": 0,
"refreshInterval": 30
}
}
```

The statusline reports Ratel as enabled when Claude Code is configured to start
Ratel through a linked MCP entry or an enabled `ratel-mcp@...` plugin. It does
not install or enable the plugin itself.

### OAuth flow

HTTP and SSE upstreams that require OAuth authorization run through `ratel-mcp`'s loopback PKCE flow. From the CLI:
Expand All @@ -158,7 +198,7 @@ When the gateway boots, every HTTP/SSE upstream with stored tokens runs through

### Telemetry

`ratel-mcp serve` writes one JSON line per event to `~/.ratel/telemetry/<project-slug>/<ISO-ts>-<short>.jsonl` by default — every search, invoke, gateway call, upstream MCP call, and OAuth event flows through the same JSONL ([ADR 0009](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0009-trace-events-core-owned-schema.md)). Best-effort, sampleable, lossy on backpressure — query-log shaped, not oplog.
`ratel-mcp serve` writes one JSON line per event to `~/.ratel/telemetry/<project-slug>/<ISO-ts>-<short>.jsonl` by default — every search, invoke, gateway call, upstream MCP call, OAuth event, and Ratel's upstream tool-payload token estimate flows through the same JSONL ([ADR 0009](https://github.com/ratel-ai/ratel/blob/main/docs/adr/0009-trace-events-core-owned-schema.md)). Best-effort, sampleable, lossy on backpressure — query-log shaped, not oplog.

| Flag | Env | Purpose |
|---|---|---|
Expand Down
21 changes: 21 additions & 0 deletions apps/ratel-mcp/plugin/skills/ratel-mcp/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Top-level commands:
- `ratel-mcp mcp` manages upstream MCP server entries.
- `ratel-mcp backup` manages backup snapshots.
- `ratel-mcp ui` launches the local browser UI.
- `ratel-mcp statusline` renders or manages the Claude Code Ratel statusline.
- `ratel-mcp --version` or `ratel-mcp version` prints the CLI version.
- `ratel-mcp help` prints top-level usage.

Expand All @@ -62,6 +63,13 @@ Top-level commands:
- `link` rewrites an agent's config to point at Ratel for entries already in Ratel scopes.
- `auth` runs OAuth for HTTP/SSE upstreams or checks stored auth state.

`ratel-mcp statusline` verbs:

- no verb renders the Claude Code statusline from stdin.
- `install` writes the user-scope Claude Code `~/.claude/settings.json` statusLine.
- `uninstall` removes only a Ratel-owned statusLine.
- `install --force` replaces another configured statusLine.

## Common Workflows

Inspect configured upstreams:
Expand Down Expand Up @@ -129,6 +137,19 @@ ratel-mcp mcp link --agent codex
ratel-mcp mcp link --agent claude-code
```

Install the Claude Code statusline:

```bash
ratel-mcp statusline install
ratel-mcp statusline install --force
ratel-mcp statusline uninstall
```

Claude Code plugins cannot currently set top-level `statusLine` defaults
directly; use the CLI or the Claude Code agent page in `ratel-mcp ui`. The
statusline reports Ratel as on when Claude Code starts Ratel via a linked MCP
entry or an enabled `ratel-mcp@...` plugin.

Authorize HTTP/SSE upstreams:

```bash
Expand Down
12 changes: 12 additions & 0 deletions apps/ratel-mcp/src/cli/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ describe("parseArgs — group/verb routing", () => {
expect(r.verb).toBeUndefined();
});

it("recognizes the statusline group and install verbs", () => {
expect(parseArgs(["statusline"]).group).toBe("statusline");
expect(parseArgs(["statusline"]).verb).toBeUndefined();
expect(parseArgs(["statusline", "install", "--yes"]).verb).toBe("install");
expect(parseArgs(["statusline", "uninstall"]).verb).toBe("uninstall");
});

it.each([
"add",
"remove",
Expand Down Expand Up @@ -75,6 +82,11 @@ describe("parseArgs — group/verb routing", () => {
expect(() => parseArgs(["mcp", "fly"])).toThrow(/fly/);
});

it("rejects an unknown statusline verb", () => {
expect(() => parseArgs(["statusline", "replace"])).toThrow(ArgError);
expect(() => parseArgs(["statusline", "replace"])).toThrow(/replace/);
});

it("does not treat a path-shaped first arg as an unknown group", () => {
// we accept this as group=help so the dispatcher can show usage
// (we no longer infer `run` from a positional file)
Expand Down
17 changes: 16 additions & 1 deletion apps/ratel-mcp/src/cli/args.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
export type Group = "mcp" | "backup" | "skill" | "serve" | "ui" | "help" | "version";
export type Group = "mcp" | "backup" | "skill" | "statusline" | "serve" | "ui" | "help" | "version";

export type McpVerb = "add" | "remove" | "list" | "get" | "edit" | "import" | "link" | "auth";

export type BackupVerb = "list";

export type StatuslineVerb = "install" | "uninstall";

export type SkillVerb =
| "activate"
| "deactivate"
Expand All @@ -26,6 +28,8 @@ const MCP_VERBS: ReadonlySet<string> = new Set([

const BACKUP_VERBS: ReadonlySet<string> = new Set(["list"]);

const STATUSLINE_VERBS: ReadonlySet<string> = new Set(["install", "uninstall"]);

const SKILL_VERBS: ReadonlySet<string> = new Set([
"activate",
"deactivate",
Expand Down Expand Up @@ -130,6 +134,17 @@ export function parseArgs(argv: string[]): ParsedArgs {
verb = candidate;
i = 2;
}
} else if (first === "statusline") {
group = "statusline";
i = 1;
if (argv.length > 1 && !argv[1].startsWith("-")) {
const candidate = argv[1];
if (!STATUSLINE_VERBS.has(candidate)) {
throw new ArgError(`unknown statusline verb: ${candidate}`);
}
verb = candidate;
i = 2;
}
} else if (first === "serve") {
group = "serve";
i = 1;
Expand Down
101 changes: 100 additions & 1 deletion apps/ratel-mcp/src/cli/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,66 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import type { BackupFs, JsonFs } from "@ratel-ai/mcp-core";
import { AUTH_TOOL_ID } from "@ratel-ai/mcp-core";
import { INVOKE_TOOL_ID, SEARCH_CAPABILITIES_ID, SEARCH_TOOLS_ID } from "@ratel-ai/sdk";
import { describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { runCli } from "./cli.js";

const HOME = "/home/u";
const ROOT = "/repo";
const SETTINGS = "/home/u/.claude/settings.json";

class MemFs implements BackupFs, JsonFs {
files = new Map<string, string>();

async read(path: string) {
return this.files.get(path) ?? null;
}

async write(path: string, contents: string) {
this.files.set(path, contents);
}

async writeAtomic(path: string, contents: string) {
this.files.set(path, contents);
}

async remove(path: string) {
this.files.delete(path);
}

async mkdirp() {}

async exists(path: string) {
return this.files.has(path);
}

async list(path: string) {
const prefix = path.endsWith("/") ? path : `${path}/`;
const names = new Set<string>();
for (const file of this.files.keys()) {
if (!file.startsWith(prefix)) continue;
const rest = file.slice(prefix.length);
const slash = rest.indexOf("/");
names.add(slash >= 0 ? rest.slice(0, slash) : rest);
}
return Array.from(names);
}
}

let previousTelemetry: string | undefined;

beforeEach(() => {
previousTelemetry = process.env.RATEL_TELEMETRY;
process.env.RATEL_TELEMETRY = "off";
});

afterEach(() => {
if (previousTelemetry === undefined) delete process.env.RATEL_TELEMETRY;
else process.env.RATEL_TELEMETRY = previousTelemetry;
});

async function fakeUpstream() {
const server = new Server({ name: "fake", version: "0.0.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
Expand Down Expand Up @@ -204,3 +259,47 @@ describe("runCli — help and routing", () => {
await expect(runCli(["backup", "purge"], { logger: () => {} })).rejects.toThrow(/purge/);
});
});

describe("runCli — statusline", () => {
it("renders statusline output to stdout from stdin", async () => {
const fs = new MemFs();
const stdout: string[] = [];
await runCli(["statusline"], {
env: { homeDir: HOME, projectRoot: ROOT },
fs,
logger: () => {},
stdin: async () =>
JSON.stringify({
model: { display_name: "Claude Sonnet" },
workspace: { project_dir: ROOT },
context_window: { context_window_size: 100_000, used_percentage: 10 },
cost: { total_duration_ms: 60_000 },
}),
stdout: (message) => stdout.push(message),
});

expect(stdout.join("")).toContain("Claude Sonnet");
expect(stdout.join("")).toContain("10k / 100k");
});

it("installs and uninstalls the Claude statusline with --yes", async () => {
const fs = new MemFs();
fs.files.set("/repo/dist/bin.js", "");
const logs: string[] = [];
await runCli(["statusline", "install", "--yes"], {
env: { homeDir: HOME, projectRoot: ROOT },
fs,
logger: (message) => logs.push(message),
});

expect(JSON.parse(fs.files.get(SETTINGS) as string).statusLine.command).toContain("statusline");
expect(logs.join("\n")).toContain("installed Ratel statusline");

await runCli(["statusline", "uninstall", "--yes"], {
env: { homeDir: HOME, projectRoot: ROOT },
fs,
logger: (message) => logs.push(message),
});
expect(JSON.parse(fs.files.get(SETTINGS) as string).statusLine).toBeUndefined();
});
});
11 changes: 11 additions & 0 deletions apps/ratel-mcp/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { BACKUP_USAGE, runBackup } from "./handlers/backup.js";
import { MCP_USAGE, runMcp } from "./handlers/mcp.js";
import { runServe } from "./handlers/serve.js";
import { runSkill, SKILL_USAGE } from "./handlers/skill.js";
import { runStatusline } from "./handlers/statusline.js";
import type { HandlerCtx } from "./handlers/types.js";
import { runUi } from "./handlers/ui.js";
import { type PromptAdapter, silentPromptAdapter } from "./prompts.js";
Expand All @@ -30,6 +31,8 @@ export interface RunCliOptions {
env?: HierarchyEnv;
now?: () => Date;
cliVersion?: string;
stdin?: () => Promise<string>;
stdout?: (message: string) => void;
}

export interface RunCliResult {
Expand All @@ -44,6 +47,7 @@ Commands:
mcp manage MCP servers (add, remove, list, get, edit, import, link, auth)
backup manage backup snapshots (list)
skill move skills between Claude Code and Ratel (activate, deactivate, list)
statusline render or install the Claude Code Ratel statusline
ui launch a local browser UI mirroring the CLI [--port N] [--no-open]

Run \`ratel-mcp <group>\` for the verbs available in a group.`;
Expand Down Expand Up @@ -95,6 +99,8 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
fs: options.fs ?? nodeFs,
log,
prompts: options.prompts ?? silentPromptAdapter(),
stdin: options.stdin,
stdout: options.stdout,
};

if (parsed.group === "ui") {
Expand All @@ -116,6 +122,11 @@ export async function runCli(argv: string[], options: RunCliOptions = {}): Promi
return {};
}

if (parsed.group === "statusline") {
await runStatusline(ctx);
return {};
}

throw new ArgError(`unhandled command: ${parsed.group} ${parsed.verb}`);
}

Expand Down
14 changes: 14 additions & 0 deletions apps/ratel-mcp/src/cli/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { execSync } from "node:child_process";

export function currentGitBranch(): string | null {
try {
const out = execSync("git branch --show-current", {
stdio: ["ignore", "pipe", "ignore"],
})
.toString()
.trim();
return out || null;
} catch {
return null;
}
}
Loading
Loading