import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test, vi } from "vitest"; import { loadUnifiedConfig } from "#src/config-loader"; import { registerPermissionSystemCommand } from "#src/config-modal"; import type { CommandConfigStore } from "#src/config-store"; import { DEFAULT_EXTENSION_CONFIG, normalizePermissionSystemConfig, type PermissionSystemExtensionConfig, } from "#src/extension-config"; import type { Rule, Ruleset } from "#src/rule"; vi.mock("@earendil-works/pi-coding-agent", () => ({ getSettingsListTheme: () => ({}), })); vi.mock("@earendil-works/pi-tui", () => ({ SettingsList: class { handleInput(): void {} updateValue(): void {} render(): string[] { return []; } invalidate(): void {} }, })); type Notification = { message: string; level: "info" | "warning" | "error" }; type CommandContextStub = { hasUI: boolean; ui: { notify(message: string, level: "info" | "warning" | "error"): void; custom( renderer: (...args: unknown[]) => unknown, options?: unknown, ): Promise; }; }; function createCommandContext(hasUI: boolean): { ctx: CommandContextStub; notifications: Notification[]; getCustomCalls(): number; } { const notifications: Notification[] = []; let customCalls = 0; return { ctx: { hasUI, ui: { notify(message: string, level: "info" | "warning" | "error") { notifications.push({ message, level }); }, async custom( _renderer: (...args: unknown[]) => unknown, _options?: unknown, ): Promise { customCalls += 1; return undefined as T; }, }, }, notifications, getCustomCalls: () => customCalls, }; } function lastNotification(notifications: Notification[]): Notification { return notifications[notifications.length - 1]; } test("permission-system command completions expose top-level config actions", () => { const baseDir = mkdtempSync( join(tmpdir(), "pi-permission-system-command-completions-"), ); const configPath = join(baseDir, "config.json"); let config: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG }; try { const configStore: CommandConfigStore = { current: () => config, save: (next) => { config = next; }, }; const controller = { config: configStore, configPath, getActiveAgentConfigRules: () => [] as Ruleset, }; let definition: { description: string; getArgumentCompletions?: ( argumentPrefix: string, ) => Array<{ value: string; label: string; description?: string }> | null; handler: (args: string, ctx: CommandContextStub) => Promise; } | null = null; registerPermissionSystemCommand( { registerCommand(_name: string, nextDefinition: typeof definition) { definition = nextDefinition; }, } as never, controller, ); expect(definition!.getArgumentCompletions).toBeTypeOf("function"); const topLevel = definition!.getArgumentCompletions?.(""); expect(Array.isArray(topLevel)).toBeTruthy(); expect(topLevel?.some((item) => item.value === "show")).toBeTruthy(); expect(topLevel?.some((item) => item.value === "reset")).toBeTruthy(); const filtered = definition!.getArgumentCompletions?.("pa"); expect(filtered?.map((item) => item.value)).toEqual(["path"]); expect(definition!.getArgumentCompletions?.("path extra")).toBe(null); expect(definition!.getArgumentCompletions?.("zzz")).toBe(null); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); test("permission-system command handlers manage config summary, persistence, and modal routing", async () => { const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-command-")); const configPath = join(baseDir, "config.json"); let config: PermissionSystemExtensionConfig = { debugLog: true, permissionReviewLog: false, yoloMode: true, doublePressToConfirm: true, }; try { writeFileSync( configPath, `${JSON.stringify(normalizePermissionSystemConfig(config), null, 2)}\n`, "utf-8", ); const configStore: CommandConfigStore = { current: () => config, save: (next) => { const currentConfig = normalizePermissionSystemConfig( loadUnifiedConfig(configPath).config, ); const normalized = normalizePermissionSystemConfig(next); writeFileSync( configPath, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8", ); config = normalizePermissionSystemConfig( loadUnifiedConfig(configPath).config, ); expect(config).not.toEqual(currentConfig); }, }; const controller = { config: configStore, configPath, getActiveAgentConfigRules: () => [] as Ruleset, }; let registeredName = ""; let definition: { description: string; getArgumentCompletions?: ( argumentPrefix: string, ) => Array<{ value: string; label: string; description?: string }> | null; handler: (args: string, ctx: CommandContextStub) => Promise; } | null = null; registerPermissionSystemCommand( { registerCommand(name: string, nextDefinition: typeof definition) { registeredName = name; definition = nextDefinition; }, } as never, controller, ); expect(registeredName).toBe("permission-system"); expect(definition!.description).toContain("Configure pi-permission-system"); const infoCtx = createCommandContext(true); await definition!.handler("show", infoCtx.ctx); expect(lastNotification(infoCtx.notifications).message).toContain( "yoloMode=on", ); expect(lastNotification(infoCtx.notifications).message).toContain( "debugLog=on", ); await definition!.handler("path", infoCtx.ctx); expect(lastNotification(infoCtx.notifications).message).toBe( `permission-system config: ${configPath}`, ); await definition!.handler("help", infoCtx.ctx); expect(lastNotification(infoCtx.notifications).message).toContain( "Usage: /permission-system", ); await definition!.handler("reset", infoCtx.ctx); expect(config).toEqual(DEFAULT_EXTENSION_CONFIG); expect(lastNotification(infoCtx.notifications).message).toBe( "Permission system settings reset to defaults.", ); const persisted = JSON.parse(readFileSync(configPath, "utf8")) as Record< string, unknown >; expect(persisted).toEqual(DEFAULT_EXTENSION_CONFIG); await definition!.handler("unknown", infoCtx.ctx); expect(lastNotification(infoCtx.notifications).level).toBe("warning"); expect(lastNotification(infoCtx.notifications).message).toContain( "Usage: /permission-system", ); const headlessCtx = createCommandContext(false); await definition!.handler("", headlessCtx.ctx); expect(lastNotification(headlessCtx.notifications).message).toBe( "/permission-system requires interactive TUI mode.", ); const modalCtx = createCommandContext(true); await definition!.handler("", modalCtx.ctx); expect(modalCtx.getCustomCalls()).toBe(1); } finally { rmSync(baseDir, { recursive: true, force: true }); } }); test("show output includes rule origins when getComposedRules is provided", async () => { const config = { ...DEFAULT_EXTENSION_CONFIG }; const composedRules: Rule[] = [ { surface: "read", pattern: "*", action: "allow", layer: "config", origin: "global", }, { surface: "bash", pattern: "rm *", action: "deny", layer: "config", origin: "project", }, ]; const controller = { config: { current: () => config, save: () => {} } as CommandConfigStore, configPath: "/fake/config.json", getActiveAgentConfigRules: () => composedRules, }; let definition: { handler: (args: string, ctx: CommandContextStub) => Promise; } | null = null; registerPermissionSystemCommand( { registerCommand(_name: string, nextDef: typeof definition) { definition = nextDef; }, } as never, controller, ); const ctx = createCommandContext(true); await definition!.handler("show", ctx.ctx); const msg = lastNotification(ctx.notifications).message; expect(msg).toContain("global"); expect(msg).toContain("project"); expect(msg).toContain("read"); expect(msg).toContain("bash"); }); test("show output omits rule summary when getComposedRules is not provided", async () => { const config = { ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }; const controller = { config: { current: () => config, save: () => {} } as CommandConfigStore, configPath: "/fake/config.json", getActiveAgentConfigRules: () => [] as Ruleset, }; let definition: { handler: (args: string, ctx: CommandContextStub) => Promise; } | null = null; registerPermissionSystemCommand( { registerCommand(_name: string, nextDef: typeof definition) { definition = nextDef; }, } as never, controller, ); const ctx = createCommandContext(true); await definition!.handler("show", ctx.ctx); const msg = lastNotification(ctx.notifications).message; // Config knobs still present. expect(msg).toContain("yoloMode=on"); // No rule annotation lines. expect(msg).not.toContain("(global)"); });