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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ await peer.stop();

Every release is also mirrored to the GitHub Packages registry as `@exadev/cc-peer` (GitHub Packages requires owner-scoped names), and single-executable binaries ship as release assets for every platform/architecture pair Node's own SEA feature supports (see Limitations for the one exception).

`npx cc-peer --help` prints the options: `--port` and `--token` choose the listening port and bearer token, `--no-token` disables authentication, `--name` sets the peer name, and `--home` overrides the home directory holding `.claude`. An unknown option exits non-zero without starting anything.

The REST facade (`npx cc-peer`) serves `GET /sessions`, `POST /messages`, `POST /idle-subscriptions`, `GET /events` (SSE), and a self-describing `GET /openapi.json` on loopback with a bearer token.

### Session discovery and reply aliases
Expand Down
100 changes: 82 additions & 18 deletions src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,96 @@ export interface CliArgs {
noToken: boolean;
}

export function parseArgs(argv: readonly string[]): CliArgs {
/** Outcome of parsing the command line: run the facade, print usage, or reject the arguments. */
export type CliCommand =
| { kind: "run"; args: CliArgs }
| { kind: "help" }
| { kind: "error"; message: string };

export const USAGE = `Usage: cc-peer [options]

Starts the cc-peer REST facade on loopback and registers a peer for this process.

Options:
--port <number> Port to listen on (default: an ephemeral port)
--token <string> Bearer token to require (default: a generated token)
--no-token Disable bearer-token authentication
--name <string> Peer name shown to Claude Code sessions
--home <path> Home directory containing .claude (default: the current user's)
-h, --help Print this help and exit
`;

const MAX_PORT = 65_535;

const VALUE_FLAGS: ReadonlySet<string> = new Set([
"--port",
"--token",
"--name",
"--home",
]);

/**
* Parses command-line arguments (without the node and script entries) without any side effects.
* `-h` and `--help` win over everything else; an unknown argument, a value flag with no value, or a
* `--port` that is not an integer from 0 to 65535 yields an error command.
*/
export function parseArgs(argv: readonly string[]): CliCommand {
if (argv.includes("--help") || argv.includes("-h")) {
return { kind: "help" };
}
const args: CliArgs = { noToken: false };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = argv[i + 1];
if (arg === "--port" && next !== undefined) {
args.port = Number.parseInt(next, 10);
i += 1;
} else if (arg === "--token" && next !== undefined) {
args.token = next;
i += 1;
} else if (arg === "--name" && next !== undefined) {
args.name = next;
i += 1;
} else if (arg === "--home" && next !== undefined) {
args.home = next;
i += 1;
} else if (arg === "--no-token") {
if (arg === undefined) continue;
if (arg === "--no-token") {
args.noToken = true;
continue;
}
if (!VALUE_FLAGS.has(arg)) {
return { kind: "error", message: `unknown argument: ${arg}` };
}
const value = argv[i + 1];
if (value === undefined) {
return { kind: "error", message: `${arg} requires a value` };
}
i += 1;
if (arg === "--port") {
const port = Number(value);
if (!/^\d+$/.test(value) || port > MAX_PORT) {
return { kind: "error", message: `invalid --port: ${value}` };
}
args.port = port;
} else if (arg === "--token") {
args.token = value;
} else if (arg === "--name") {
args.name = value;
} else {
args.home = value;
}
}
return args;
return { kind: "run", args };
}

export async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
/**
* Runs the CLI. Arguments are parsed before any peer is created or socket bound, so `--help` and
* argument errors have no side effects beyond output; an argument error sets a non-zero exit code.
*/
export async function main(
argv: readonly string[] = process.argv.slice(2),
): Promise<void> {
const command = parseArgs(argv);
if (command.kind === "help") {
process.stdout.write(USAGE);
return;
}
if (command.kind === "error") {
process.stderr.write(
`[cc-peer] ${command.message}\nRun 'cc-peer --help' for usage.\n`,
);
process.exitCode = 2;
return;
}
const { args } = command;

const peer = await CcPeer.create({
...(args.name !== undefined ? { name: args.name } : {}),
Expand Down
71 changes: 71 additions & 0 deletions src/bin/main.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from "vitest";

import { parseArgs } from "./main.js";

describe("parseArgs", () => {
test("parses every option into run args", () => {
expect(
parseArgs([
"--port",
"8080",
"--token",
"t",
"--name",
"n",
"--home",
"/h",
"--no-token",
]),
).toEqual({
kind: "run",
args: {
port: 8080,
token: "t",
name: "n",
home: "/h",
noToken: true,
},
});
});

test("no arguments runs with defaults", () => {
expect(parseArgs([])).toEqual({ kind: "run", args: { noToken: false } });
});

test.each([["--help"], ["-h"]])("%s requests help", (flag) => {
expect(parseArgs([flag])).toEqual({ kind: "help" });
});

test("help wins over other arguments, including unknown ones", () => {
expect(parseArgs(["--port", "1", "--bogus", "--help"])).toEqual({
kind: "help",
});
});

test("rejects an unknown argument", () => {
expect(parseArgs(["--bogus"])).toEqual({
kind: "error",
message: "unknown argument: --bogus",
});
});

test.each([["--port"], ["--token"], ["--name"], ["--home"]])(
"rejects %s with no value",
(flag) => {
expect(parseArgs([flag])).toEqual({
kind: "error",
message: `${flag} requires a value`,
});
},
);

test.each([["abc"], ["1.5"], ["-1"], ["65536"], [""]])(
"rejects invalid port %j",
(port) => {
expect(parseArgs(["--port", port])).toEqual({
kind: "error",
message: `invalid --port: ${port}`,
});
},
);
});
70 changes: 70 additions & 0 deletions test/cli.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, test } from "vitest";
import { spawn } from "node:child_process";
import { mkdtemp, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";

import { REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS } from "../src/test/timeouts.js";

const ENTRY = fileURLToPath(new URL("../src/bin/cc-peer.ts", import.meta.url));

/** Runs the CLI entry in a real process with an empty home, killing it if it fails to exit on its own. */
async function runCli(args: readonly string[]): Promise<{
code: number | null;
stdout: string;
stderr: string;
home: string;
}> {
const home = await mkdtemp(join(tmpdir(), "cc-peer-cli-"));
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", "tsx", ENTRY, ...args], {
env: { ...process.env, HOME: home, USERPROFILE: home },
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
const timer = setTimeout(() => {
child.kill("SIGKILL");
}, REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS / 2);
child.on("error", reject);
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr, home });
});
});
}

describe("cc-peer CLI argument handling", () => {
test.each([["--help"], ["-h"]])(
"%s prints usage and exits 0 without registering a peer or minting a token",
async (flag) => {
const result = await runCli([flag]);
expect(result.code).toBe(0);
expect(result.stdout).toContain("Usage: cc-peer");
expect(result.stderr).not.toContain("bearer token");
expect(result.stderr).not.toContain("listening");
expect(await readdir(result.home)).toEqual([]);
},
REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS,
);

test(
"an unknown flag exits non-zero with a usage hint and starts nothing",
async () => {
const result = await runCli(["--bogus"]);
expect(result.code).toBe(2);
expect(result.stderr).toContain("unknown argument: --bogus");
expect(result.stderr).toContain("cc-peer --help");
expect(result.stderr).not.toContain("bearer token");
expect(result.stderr).not.toContain("listening");
expect(await readdir(result.home)).toEqual([]);
},
REAL_PROCESS_SPAWN_TEST_TIMEOUT_MS,
);
});
Loading