import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const app = vi.hoisted(() => ({ runCli: vi.fn(async () => ({ ok: true, exitCode: 0, stdout: "", humanOutput: "" })), })); const lifecycle = vi.hoisted(() => ({ getServiceStatus: vi.fn(() => ({ installed: false, running: false, pid: null, plistPath: "/tmp/oppi-test.plist", label: "dev.chaosdonkey.oppi", })), installService: vi.fn(() => ({ ok: true, message: "should not run" })), readInstalledPlist: vi.fn(() => null), restartService: vi.fn(() => ({ ok: true, message: "should not run" })), stopService: vi.fn(() => ({ ok: true, message: "should not run" })), uninstallService: vi.fn(() => ({ ok: true, message: "should not run" })), })); vi.mock("../src/launchd.js", () => lifecycle); vi.mock("../src/cli/runner.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCli: app.runCli }; }); import { runCliMain } from "../src/cli.js"; import { helpPathFor, isNestedHelpRequest, listCliHelpTopicPaths, resolveHelpTopic, } from "../src/cli/help.js"; import { parseCliArgs } from "../src/cli/args.js"; import { captureCliOutput } from "../src/cli/output.js"; const tempDirs: string[] = []; const MUTATING_AND_LIFECYCLE_HELP_PATHS = [ ["init"], ["serve"], ["start"], ["pair"], ["update"], ["token", "rotate"], ["config", "set"], ["server", "install"], ["server", "uninstall"], ["server", "restart"], ["server", "stop"], ["server", "status"], ["workspace", "create"], ["workspace", "update"], ["workspace", "delete"], ["worktree", "create"], ["worktree", "open"], ["worktree", "remove"], ["schedule", "create"], ["schedule", "update"], ["schedule", "run"], ["schedule", "pause"], ["schedule", "resume"], ["schedule", "archive"], ["schedule", "restore"], ["session", "create"], ["session", "send"], ["session", "abort"], ["session", "stop"], ["session", "resume"], ["session", "fork"], ["session", "delete"], ["agent", "create"], ["agent", "update"], ["agent", "archive"], ] as const; beforeEach(() => { vi.clearAllMocks(); const home = mkdtempSync(join(tmpdir(), "oppi-help-home-")); const dataDir = mkdtempSync(join(tmpdir(), "oppi-help-data-")); tempDirs.push(home, dataDir); vi.stubEnv("HOME", home); vi.stubEnv("OPPI_DATA_DIR", dataDir); }); afterEach(() => { vi.unstubAllEnvs(); for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); describe("centralized nested-help dispatch", () => { it("treats the incident token sequence as help before server lifecycle dispatch", () => { const parsed = parseCliArgs(["server", "install", "help"]); expect(isNestedHelpRequest(parsed.command, parsed.positional, parsed.flags)).toBe(true); expect(helpPathFor(parsed.command, parsed.positional)).toEqual(["server", "install"]); }); it("renders exact `server install help` safely without calling lifecycle code", async () => { const captured = await captureCliOutput(() => runCliMain(["server", "install", "help"]), { includeHuman: true, }); expect(captured.exitCode).toBe(0); expect(captured.stdout).toBe(""); expect(captured.humanStdout).toContain("Install background server"); expect(lifecycle.installService).not.toHaveBeenCalled(); expect(lifecycle.restartService).not.toHaveBeenCalled(); expect(lifecycle.stopService).not.toHaveBeenCalled(); expect(lifecycle.uninstallService).not.toHaveBeenCalled(); expect(lifecycle.getServiceStatus).not.toHaveBeenCalled(); expect(lifecycle.readInstalledPlist).not.toHaveBeenCalled(); expect(app.runCli).not.toHaveBeenCalled(); }); it("keeps JSON help deterministic and side-effect-free for the incident spelling", async () => { const captured = await captureCliOutput( () => runCliMain(["server", "install", "help", "--json"]), { includeHuman: true }, ); expect(captured.exitCode).toBe(0); expect(JSON.parse(captured.stdout)).toMatchObject({ ok: true, data: { help: { path: ["server", "install"] } }, }); expect(captured.humanStdout).toContain("Install background server"); expect(lifecycle.installService).not.toHaveBeenCalled(); expect(lifecycle.getServiceStatus).not.toHaveBeenCalled(); expect(lifecycle.readInstalledPlist).not.toHaveBeenCalled(); expect(app.runCli).not.toHaveBeenCalled(); }); it.each(MUTATING_AND_LIFECYCLE_HELP_PATHS.map((path) => [path] as const))( "does not dispatch mutating or lifecycle command %s for a bare help token", async (path) => { const args = [...path, "help"]; const captured = await captureCliOutput(() => runCliMain(args), { includeHuman: true }); expect(captured.exitCode, args.join(" ")).toBe(0); expect(captured.humanStdout, args.join(" ")).toContain("Usage:"); expect(app.runCli, args.join(" ")).not.toHaveBeenCalled(); expect(lifecycle.installService, args.join(" ")).not.toHaveBeenCalled(); expect(lifecycle.restartService, args.join(" ")).not.toHaveBeenCalled(); expect(lifecycle.stopService, args.join(" ")).not.toHaveBeenCalled(); expect(lifecycle.uninstallService, args.join(" ")).not.toHaveBeenCalled(); expect(lifecycle.getServiceStatus, args.join(" ")).not.toHaveBeenCalled(); expect(lifecycle.readInstalledPlist, args.join(" ")).not.toHaveBeenCalled(); }, ); it("preserves normal app-state dispatch after the help guard", async () => { await runCliMain(["session", "list", "--json"]); expect(app.runCli).toHaveBeenCalledOnce(); expect(app.runCli).toHaveBeenCalledWith(["session", "list", "--json"]); }); it.each([ ["server", "install", "ignored", "help"], ["session", "delete", "ignored", "help"], ["workspace", "remove", "ignored", "help"], ] as const)("short-circuits misplaced help after ignored positionals: %s", async (...args) => { const captured = await captureCliOutput(() => runCliMain(args), { includeHuman: true }); expect(captured.exitCode, args.join(" ")).toBe(0); expect(captured.humanStdout, args.join(" ")).toContain("Usage:"); expect(app.runCli, args.join(" ")).not.toHaveBeenCalled(); expect(lifecycle.installService, args.join(" ")).not.toHaveBeenCalled(); }); it.each([ [["session", "watch"]], [["session", "changes"]], [["session", "diff"]], [["session", "dialogs"]], [["session", "respond"]], [["skill"]], [["skill", "list"]], [["skill", "get"]], [["skill", "file"]], [["skill", "update-file"]], ])("does not discover removed %j help", (path) => { expect(resolveHelpTopic(path)).toBeUndefined(); }); it("does not advertise session watch from remaining session help", () => { const session = resolveHelpTopic(["session"]); const wait = resolveHelpTopic(["session", "wait"]); expect(session).toBeDefined(); expect(wait).toBeDefined(); expect(session?.subcommands?.some((item) => item.name.startsWith("watch"))).toBe(false); expect(JSON.stringify(session)).not.toMatch(/session watch/); expect(JSON.stringify(wait)).not.toMatch(/watch/); }); it("documents session list date filters and accepted time syntax", () => { const list = resolveHelpTopic(["session", "list"]); expect(list?.usage).toContain("--since