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
83 changes: 83 additions & 0 deletions cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* cli.ts — Subcommand dispatcher for the nerf-server binary.
*
* Lets the same compiled binary run as either an MCP stdio server (default,
* no args) or as a one-shot CLI for hook integrations (`clear-indicator`,
* `refresh-indicator`). Hook scripts in claudecode-workflow shell out to
* these subcommands at PreCompact and SessionStart:compact to keep the
* statusline widget aligned with the real context size across compaction
* boundaries.
*/

import { removeIndicator } from "./statusline.ts";
import { NERF_INDICATOR_PREFIX, updateStatuslineIndicator } from "./indicator.ts";
import { resolveSessionId } from "./session.ts";
import { readConfig } from "./config.ts";

export const KNOWN_SUBCOMMANDS = [
"clear-indicator",
"refresh-indicator",
] as const;
export type Subcommand = (typeof KNOWN_SUBCOMMANDS)[number];

export function isSubcommand(arg: string | undefined): arg is Subcommand {
return (
arg !== undefined &&
(KNOWN_SUBCOMMANDS as readonly string[]).includes(arg)
);
}

/**
* Parse `--session-id <value>` from a flat argv tail. Returns undefined when
* the flag is absent, has no value following it, or has an empty-string
* value — empty session IDs are useless to downstream resolvers and would
* cause the boundary contract (override-present-or-not) to leak across the
* `parseSessionIdFlag` → `resolveSessionId` seam.
*/
export function parseSessionIdFlag(args: string[]): string | undefined {
for (let i = 0; i < args.length; i++) {
if (args[i] === "--session-id" && i + 1 < args.length) {
const value = args[i + 1];
return value.length > 0 ? value : undefined;
}
}
return undefined;
}

/**
* Remove all `nerf:`-prefixed entries from the shared statusline file.
* Idempotent and safe to call when the statusline file does not exist.
*/
export function clearIndicatorCommand(): void {
removeIndicator(NERF_INDICATOR_PREFIX);
}

/**
* Resolve the active session, run the analyzer, and write a fresh indicator
* to the statusline. Mirrors what `nerf_status` does as a side-effect, but
* without producing any human-facing text output.
*/
export async function refreshIndicatorCommand(args: string[]): Promise<void> {
const explicitSessionId = parseSessionIdFlag(args);
const sessionId = resolveSessionId(explicitSessionId);
const config = readConfig(sessionId);
await updateStatuslineIndicator(sessionId, config);
}

export async function runSubcommand(
name: Subcommand,
args: string[],
): Promise<void> {
switch (name) {
case "clear-indicator":
clearIndicatorCommand();
return;
case "refresh-indicator":
await refreshIndicatorCommand(args);
return;
default: {
const exhaustive: never = name;
throw new Error(`Unhandled subcommand: ${exhaustive}`);
}
}
}
10 changes: 10 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,18 @@ import { handleBudget } from "./budget.ts";
import { handleScope } from "./scope.ts";
import { removeIndicator } from "./statusline.ts";
import { NERF_INDICATOR_PREFIX } from "./indicator.ts";
import { isSubcommand, runSubcommand } from "./cli.ts";
import { log } from "./logger.ts";

// Subcommand dispatch — one-shot CLI mode for hook integrations. When argv[2]
// is a known subcommand we run it and exit before any MCP setup happens, so
// the binary doesn't try to read stdin or register signal handlers.
const argv = process.argv.slice(2);
if (argv.length > 0 && isSubcommand(argv[0])) {
await runSubcommand(argv[0], argv.slice(1));
process.exit(0);
}

/**
* Shared optional parameter included in every tool schema.
*/
Expand Down
126 changes: 126 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Tests for cli.ts — Subcommand dispatch surface.
*
* Pure tests cover flag parsing and subcommand recognition. Smoke tests
* verify the dispatch wrapper doesn't throw under common edge cases — the
* substantive indicator-write behavior is already covered in
* indicator.test.ts and statusline.test.ts.
*/

import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
import { createHash } from "node:crypto";
import {
isSubcommand,
parseSessionIdFlag,
KNOWN_SUBCOMMANDS,
runSubcommand,
clearIndicatorCommand,
} from "../cli.ts";
import { resolveProjectRoot } from "../statusline.ts";

describe("isSubcommand", () => {
test("returns true for known subcommands", () => {
expect(isSubcommand("clear-indicator")).toBe(true);
expect(isSubcommand("refresh-indicator")).toBe(true);
});

test("returns false for unknown strings", () => {
expect(isSubcommand("foo")).toBe(false);
expect(isSubcommand("")).toBe(false);
expect(isSubcommand("clear")).toBe(false);
});

test("returns false for undefined", () => {
expect(isSubcommand(undefined)).toBe(false);
});
});

describe("parseSessionIdFlag", () => {
test("returns undefined when flag is absent", () => {
expect(parseSessionIdFlag([])).toBeUndefined();
expect(parseSessionIdFlag(["foo", "bar"])).toBeUndefined();
});

test("returns the value following --session-id", () => {
expect(parseSessionIdFlag(["--session-id", "abc-123"])).toBe("abc-123");
});

test("returns the value when other args precede --session-id", () => {
expect(parseSessionIdFlag(["foo", "--session-id", "xyz"])).toBe("xyz");
});

test("returns undefined when --session-id is the last arg", () => {
expect(parseSessionIdFlag(["--session-id"])).toBeUndefined();
});

test("returns the first value when --session-id appears more than once", () => {
expect(parseSessionIdFlag(["--session-id", "a", "--session-id", "b"]))
.toBe("a");
});

test("returns undefined when --session-id has an empty-string value", () => {
// Empty session IDs are useless to resolveSessionId and break the
// override-present-or-not contract. Treat them as absent.
expect(parseSessionIdFlag(["--session-id", ""])).toBeUndefined();
});
});

describe("KNOWN_SUBCOMMANDS", () => {
test("includes both expected commands", () => {
expect(KNOWN_SUBCOMMANDS).toContain("clear-indicator");
expect(KNOWN_SUBCOMMANDS).toContain("refresh-indicator");
});
});

/**
* The smoke tests below exercise the production code paths but rely on the
* absence of an agent identity file for THIS project root, which forces
* resolveStatuslineFile() to return null and turns indicator helpers into
* graceful no-ops. We guard the agent file at the start of each test and
* restore it at the end so a developer who happens to have one cached in
* /tmp doesn't see flaky failures.
*/
describe("subcommand smoke tests (no-agent-file path)", () => {
let agentFile: string;
let savedAgentContent: string | null = null;

beforeEach(() => {
const projectRoot = resolveProjectRoot();
const dirHash = createHash("md5").update(projectRoot).digest("hex");
agentFile = `/tmp/claude-agent-${dirHash}.json`;
if (existsSync(agentFile)) {
savedAgentContent = readFileSync(agentFile, "utf-8");
rmSync(agentFile, { force: true });
} else {
savedAgentContent = null;
}
});

afterEach(() => {
if (savedAgentContent !== null) {
writeFileSync(agentFile, savedAgentContent, "utf-8");
}
});

test("clearIndicatorCommand does not throw without an agent file", () => {
expect(() => clearIndicatorCommand()).not.toThrow();
});

test("runSubcommand('clear-indicator') resolves without throwing", async () => {
await expect(runSubcommand("clear-indicator", [])).resolves.toBeUndefined();
});

test("runSubcommand('refresh-indicator') resolves without throwing when no transcript exists", async () => {
// resolveSessionId may fall through to the synthetic fallback. With no
// transcript at the resolved path, getContextUsage returns null and
// updateStatuslineIndicator collapses to a removeIndicator-only no-op.
await expect(runSubcommand("refresh-indicator", [])).resolves.toBeUndefined();
});

test("runSubcommand('refresh-indicator') accepts an explicit --session-id", async () => {
await expect(
runSubcommand("refresh-indicator", ["--session-id", "0".repeat(8) + "-0000-0000-0000-" + "0".repeat(12)]),
).resolves.toBeUndefined();
});
});
Loading