diff --git a/.agents/skills/axi/SKILL.md b/.agents/skills/axi/SKILL.md index b6e0819a..eb149213 100644 --- a/.agents/skills/axi/SKILL.md +++ b/.agents/skills/axi/SKILL.md @@ -180,7 +180,7 @@ help[2]: - **Claude Code**: use native hooks in `~/.claude/settings.json` or project `.claude/settings.json`. Prefer `SessionStart` to inject compact context via stdout - **Codex**: use native hooks in `~/.codex/hooks.json` or `/.codex/hooks.json`, and ensure `[features].hooks = true` in `config.toml`. Prefer `SessionStart` for ambient context via stdout -- **OpenCode**: use a managed plugin in `~/.config/opencode/plugins/`. Prefer ambient system-context injection for the home view rather than adding a custom tool +- **OpenCode**: use a managed plugin in `~/.config/opencode/plugins/` or `/.opencode/plugins/`. Prefer ambient system-context injection for the home view rather than adding a custom tool **Also ship an installable skill (secondary recommendation):** diff --git a/packages/axi-sdk-js/README.md b/packages/axi-sdk-js/README.md index e0166a22..c2c048df 100644 --- a/packages/axi-sdk-js/README.md +++ b/packages/axi-sdk-js/README.md @@ -121,6 +121,8 @@ Most AXI authors should not need these directly. | `detectInstallMethod()`, `planUpgrade()` | Inspect an entrypoint path and map it to the upgrade command the built-in updater would use | | `compareSemver()`, `isUpdateAvailable()` | Semver helpers used by the updater, including prerelease ordering | | `installSessionStartHooks()` | Install or repair Claude Code hooks, Codex hooks, and OpenCode ambient context plugins directly | +| `sessionStartHookStatus()` | Report install status for Claude Code, Codex, and OpenCode at a given scope, without writing | +| `uninstallSessionStartHooks()` | Remove marker-matched managed hooks/plugin at a given scope, without touching unrelated entries | | `resolvePortableHookCommand()` | Resolve a hook command to a safe binary name or absolute path | | `PortableHookCommandContext` | Context for resolving portable hook commands | | `shouldInstallHooksForNodeAxiExecPath()` | Check whether an executable path is safe for hook installation | @@ -164,6 +166,42 @@ await installSessionStartHooks({ Claude Code and Codex receive native `SessionStart` hooks, while OpenCode receives a managed plugin in `~/.config/opencode/plugins/` that injects the AXI home view as ambient model context. +### Hook Scope: User vs Project + +By default, `installSessionStartHooks()` and its `sessionStartHookStatus()` / `uninstallSessionStartHooks()` counterparts target **user scope**: each agent's home-directory config (`~/.claude/settings.json`, `~/.codex/hooks.json`, `~/.config/opencode/plugins/`). Pass `scope: "project"` (and optionally `projectDir`, which defaults to `process.cwd()`) to target the equivalent **per-repository** config instead: + +```ts +await installSessionStartHooks({ scope: "project" }); +``` + +| Agent | User scope (default) | Project scope (`scope: "project"`) | +| ------------- | ------------------------------------ | ------------------------------------ | +| Claude Code | `~/.claude/settings.json` | `/.claude/settings.json` | +| Codex hooks | `~/.codex/hooks.json` | `/.codex/hooks.json` | +| Codex feature | `~/.codex/config.toml` (always here) | `~/.codex/config.toml` (always here) | +| OpenCode | `~/.config/opencode/plugins/` | `/.opencode/plugins/` | + +The Codex `[features].hooks = true` feature flag is always ensured in the **user-level** `config.toml`, even when installing at project scope - Codex only honors repo-level hooks once that user-level flag is on, so project-scope install still writes it, and `sessionStartHookStatus()` reports it back as `codex.userFeatureEnabled` / `codex.userFeaturePath` regardless of the scope you asked about. Uninstalling never touches that flag, since it is shared across every AXI a user has installed hooks for. + +Omitting `scope` (or passing `scope: "user"`) reproduces the exact pre-scope behavior - `projectDir` is ignored in that case. + +```ts +import { + installSessionStartHooks, + sessionStartHookStatus, + uninstallSessionStartHooks, +} from "axi-sdk-js"; + +await installSessionStartHooks({ scope: "project" }); + +const status = sessionStartHookStatus({ scope: "project" }); +// { marker, scope: "project", claude: { installed, path }, codex: { installed, path, userFeatureEnabled, userFeaturePath }, opencode: { installed, path } } + +await uninstallSessionStartHooks({ scope: "project" }); +``` + +`sessionStartHookStatus()` performs no writes and throws if the hook marker can't be resolved from `options.marker` or inferred from the current process. `uninstallSessionStartHooks()` mirrors `installSessionStartHooks()`'s permissive default and silently no-ops in that case, and it only ever removes entries whose command contains the managed marker - unrelated hooks, groups, and unmanaged OpenCode plugin files are left alone (the latter reported via `onError`, same as install's overwrite protection). + ### Hook Command Portability Hook commands use a plain binary name such as `gh-axi` only when that name contains the hook marker and `binaryNames` resolves through the current `PATH` to the same executable; otherwise they use the absolute `execPath`. diff --git a/packages/axi-sdk-js/src/hooks.ts b/packages/axi-sdk-js/src/hooks.ts index 6b554dcb..9379c56e 100644 --- a/packages/axi-sdk-js/src/hooks.ts +++ b/packages/axi-sdk-js/src/hooks.ts @@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, realpathSync, + rmSync, statSync, writeFileSync, } from "node:fs"; @@ -41,17 +42,60 @@ export interface NodeAxiExecPathPolicy { distEntrypoints?: string[]; } -export interface InstallSessionStartHooksOptions { +/** + * `"user"` (the default) targets each agent's home-directory config, exactly + * as before scope support existed. `"project"` targets the equivalent + * per-repository config under `projectDir`, the way Claude Code natively + * supports `/.claude/settings.json`. + */ +export type SessionStartHookScope = "user" | "project"; + +interface SessionStartHookScopeOptions { + homeDir?: string; + scope?: SessionStartHookScope; + projectDir?: string; +} + +export interface InstallSessionStartHooksOptions extends SessionStartHookScopeOptions { marker?: string; execPath?: string; binaryNames?: string[]; distEntrypoints?: string[]; timeoutSeconds?: number; - homeDir?: string; shouldInstall?: (execPath: string) => boolean; onError?: (message: string) => void; } +export interface SessionStartHookStatusOptions extends SessionStartHookScopeOptions { + marker?: string; + execPath?: string; +} + +export interface UninstallSessionStartHooksOptions extends SessionStartHookScopeOptions { + marker?: string; + execPath?: string; + onError?: (message: string) => void; +} + +export interface SessionStartHookAgentStatus { + installed: boolean; + path: string; +} + +export interface SessionStartHookCodexStatus extends SessionStartHookAgentStatus { + /** Whether `[features].hooks = true` is set in the USER-level `config.toml`. */ + userFeatureEnabled: boolean; + userFeaturePath: string; +} + +export interface SessionStartHookStatus { + marker: string; + scope: SessionStartHookScope; + claude: SessionStartHookAgentStatus; + codex: SessionStartHookCodexStatus; + opencode: SessionStartHookAgentStatus; +} + const OPENCODE_PLUGIN_MANAGED_PREFIX = "axi-sdk-js managed opencode plugin:"; export interface PortableHookCommandContext { @@ -138,6 +182,77 @@ export function computeSessionStartHookUpdate( return [updated, true]; } +export function computeSessionStartHookRemoval( + settings: HookSettings, + marker: string, +): [HookSettings, boolean] { + if (!settings.hooks) { + return [settings, false]; + } + + const updated = structuredClone(settings); + const hooks = updated.hooks; + if (!hooks) { + return [settings, false]; + } + + let changed = false; + + if (Array.isArray(hooks.session_start)) { + const remaining = hooks.session_start.filter( + (hook) => !isManagedHook(hook, marker), + ); + if (remaining.length !== hooks.session_start.length) { + changed = true; + if (remaining.length === 0) { + delete hooks.session_start; + } else { + hooks.session_start = remaining; + } + } + } + + if (Array.isArray(hooks.SessionStart)) { + const remainingGroups: HookGroup[] = []; + let sessionStartChanged = false; + for (const group of hooks.SessionStart) { + if (!Array.isArray(group.hooks)) { + remainingGroups.push(group); + continue; + } + + const remainingHooks = group.hooks.filter( + (hook) => !isManagedHook(hook, marker), + ); + + if (remainingHooks.length === group.hooks.length) { + remainingGroups.push(group); + continue; + } + + sessionStartChanged = true; + if (remainingHooks.length > 0) { + remainingGroups.push({ ...group, hooks: remainingHooks }); + } + } + + if (sessionStartChanged) { + changed = true; + if (remainingGroups.length > 0) { + hooks.SessionStart = remainingGroups; + } else { + delete hooks.SessionStart; + } + } + } + + if (Object.keys(hooks).length === 0) { + delete updated.hooks; + } + + return changed ? [updated, true] : [settings, false]; +} + export function computeCodexConfigUpdate(content: string): [string, boolean] { const newline = content.includes("\r\n") ? "\r\n" : "\n"; const normalized = content.length === 0 ? "" : content; @@ -310,20 +425,68 @@ export const ${exportName} = async ({ directory }) => { `; } -function installOpenCodeAmbientPlugin( +function openCodePluginFileName(marker: string): string { + return `axi-${sanitizeOpenCodePluginFilePart(marker)}.js`; +} + +/** + * OpenCode loads local plugins from `~/.config/opencode/plugins/` (global) + * and, symmetrically, `/.opencode/plugins/` (project-level) - + * both directories are documented and loaded the same way, so project scope + * mirrors the global path 1:1. See https://opencode.ai/docs/plugins/. + */ +function openCodePluginDir( + scope: SessionStartHookScope, home: string, + root: string, +): string { + return scope === "project" + ? join(root, ".opencode", "plugins") + : join(home, ".config", "opencode", "plugins"); +} + +interface ResolvedHookScopeTargets { + scope: SessionStartHookScope; + home: string; + root: string; + claudeSettingsPath: string; + codexHooksPath: string; + /** Always the USER-level config, even at project scope - repo-level Codex + * hooks still require the user-level `[features].hooks` feature flag. */ + codexConfigPath: string; + openCodePluginPath: string; +} + +function resolveHookScopeTargets( + marker: string, + options: SessionStartHookScopeOptions, +): ResolvedHookScopeTargets { + const home = options.homeDir ?? homedir(); + const scope = options.scope ?? "user"; + const root = + scope === "project" ? resolve(options.projectDir ?? process.cwd()) : home; + + return { + scope, + home, + root, + claudeSettingsPath: join(root, ".claude", "settings.json"), + codexHooksPath: join(root, ".codex", "hooks.json"), + codexConfigPath: join(home, ".codex", "config.toml"), + openCodePluginPath: join( + openCodePluginDir(scope, home, root), + openCodePluginFileName(marker), + ), + }; +} + +function installOpenCodeAmbientPlugin( + pluginPath: string, marker: string, command: string, timeoutSeconds: number, onError?: (message: string) => void, ): void { - const pluginPath = join( - home, - ".config", - "opencode", - "plugins", - `axi-${sanitizeOpenCodePluginFilePart(marker)}.js`, - ); const managedMarker = `${OPENCODE_PLUGIN_MANAGED_PREFIX} ${marker}`; const next = buildOpenCodeAmbientPluginSource( marker, @@ -564,15 +727,12 @@ export function installSessionStartHooks( buildDefaultPortableCommandContext(), ); - const home = options.homeDir ?? homedir(); - const jsonTargets = [ - join(home, ".claude", "settings.json"), - join(home, ".codex", "hooks.json"), - ]; - const codexConfigPath = join(home, ".codex", "config.toml"); + const targets = resolveHookScopeTargets(marker, options); + const jsonTargets = [targets.claudeSettingsPath, targets.codexHooksPath]; + const codexConfigPath = targets.codexConfigPath; installOpenCodeAmbientPlugin( - home, + targets.openCodePluginPath, marker, command, options.timeoutSeconds ?? 10, @@ -615,3 +775,154 @@ export function installSessionStartHooks( options.onError?.(`${codexConfigPath}: ${message}`); } } + +function resolveStatusMarker( + options: { marker?: string; execPath?: string }, + callerName: string, +): string { + const inferred = inferHookOptions(options.execPath ?? process.argv[1]); + const marker = options.marker ?? inferred?.marker; + if (!marker) { + throw new Error( + `${callerName}: unable to infer a hook marker from the current process; pass { marker } explicitly`, + ); + } + return marker; +} + +function hasManagedJsonHookEntry(path: string, marker: string): boolean { + if (!existsSync(path)) { + return false; + } + + try { + const settings = JSON.parse(readFileSync(path, "utf-8")) as HookSettings; + const groups = settings.hooks?.SessionStart ?? []; + const inGroups = groups.some((group) => + (group.hooks ?? []).some((hook) => isManagedHook(hook, marker)), + ); + const legacy = settings.hooks?.session_start ?? []; + return inGroups || legacy.some((hook) => isManagedHook(hook, marker)); + } catch { + return false; + } +} + +function hasManagedOpenCodePlugin(path: string, marker: string): boolean { + if (!existsSync(path)) { + return false; + } + + try { + return readFileSync(path, "utf-8").includes( + `${OPENCODE_PLUGIN_MANAGED_PREFIX} ${marker}`, + ); + } catch { + return false; + } +} + +function isCodexHooksFeatureEnabled(path: string): boolean { + if (!existsSync(path)) { + return false; + } + + try { + // computeCodexConfigUpdate reports `changed: false` exactly when + // `hooks = true` is already set correctly, so a would-be no-op means the + // feature is already enabled. + const [, changed] = computeCodexConfigUpdate(readFileSync(path, "utf-8")); + return !changed; + } catch { + return false; + } +} + +/** + * Reports whether managed SessionStart hooks (Claude Code, Codex) and the + * OpenCode ambient plugin are installed for the given marker and scope. + * Performs no writes. Throws if the marker cannot be resolved from either + * `options.marker` or the current process's inferred identity. + */ +export function sessionStartHookStatus( + options: SessionStartHookStatusOptions = {}, +): SessionStartHookStatus { + const marker = resolveStatusMarker(options, "sessionStartHookStatus"); + const targets = resolveHookScopeTargets(marker, options); + + return { + marker, + scope: targets.scope, + claude: { + installed: hasManagedJsonHookEntry(targets.claudeSettingsPath, marker), + path: targets.claudeSettingsPath, + }, + codex: { + installed: hasManagedJsonHookEntry(targets.codexHooksPath, marker), + path: targets.codexHooksPath, + userFeatureEnabled: isCodexHooksFeatureEnabled(targets.codexConfigPath), + userFeaturePath: targets.codexConfigPath, + }, + opencode: { + installed: hasManagedOpenCodePlugin(targets.openCodePluginPath, marker), + path: targets.openCodePluginPath, + }, + }; +} + +/** + * Removes only marker-matched managed hook entries at the given scope: + * the Claude Code and Codex SessionStart hook entries, and the OpenCode + * ambient plugin file (only when it still carries the managed marker). + * Unrelated hooks/groups and the Codex user-level `[features].hooks` flag + * (shared across every AXI installed for that user) are left untouched. + */ +export function uninstallSessionStartHooks( + options: UninstallSessionStartHooksOptions = {}, +): void { + const inferred = inferHookOptions(options.execPath ?? process.argv[1]); + const marker = options.marker ?? inferred?.marker; + if (!marker) { + return; + } + + const targets = resolveHookScopeTargets(marker, options); + + for (const target of [targets.claudeSettingsPath, targets.codexHooksPath]) { + try { + if (!existsSync(target)) { + continue; + } + + const current = JSON.parse(readFileSync(target, "utf-8")) as HookSettings; + const [updated, changed] = computeSessionStartHookRemoval( + current, + marker, + ); + + if (changed) { + writeFileSync(target, `${JSON.stringify(updated, null, 2)}\n`, "utf-8"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options.onError?.(`${target}: ${message}`); + } + } + + const pluginPath = targets.openCodePluginPath; + try { + if (existsSync(pluginPath)) { + const managedMarker = `${OPENCODE_PLUGIN_MANAGED_PREFIX} ${marker}`; + if (readFileSync(pluginPath, "utf-8").includes(managedMarker)) { + rmSync(pluginPath, { force: true }); + } else { + options.onError?.( + `${pluginPath}: refusing to remove unmanaged OpenCode plugin`, + ); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options.onError?.(`${pluginPath}: ${message}`); + } +} diff --git a/packages/axi-sdk-js/test/hooks.test.ts b/packages/axi-sdk-js/test/hooks.test.ts index fd21238a..576dcf5e 100644 --- a/packages/axi-sdk-js/test/hooks.test.ts +++ b/packages/axi-sdk-js/test/hooks.test.ts @@ -15,11 +15,14 @@ import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { computeCodexConfigUpdate, + computeSessionStartHookRemoval, computeSessionStartHookUpdate, extractNpmShimScriptPath, installSessionStartHooks, resolvePortableHookCommand, + sessionStartHookStatus, shouldInstallHooksForNodeAxiExecPath, + uninstallSessionStartHooks, } from "../src/hooks.js"; describe("computeSessionStartHookUpdate", () => { @@ -161,6 +164,122 @@ describe("computeSessionStartHookUpdate", () => { }); }); +describe("computeSessionStartHookRemoval", () => { + it("is a no-op when there are no hooks at all", () => { + const settings = {}; + const [updated, changed] = computeSessionStartHookRemoval( + settings, + "gh-axi", + ); + + expect(changed).toBe(false); + expect(updated).toBe(settings); + }); + + it("is a no-op when the marker is not present", () => { + const settings = { + hooks: { + SessionStart: [ + { + matcher: "", + hooks: [{ type: "command", command: "/usr/local/bin/other" }], + }, + ], + }, + }; + + const [updated, changed] = computeSessionStartHookRemoval( + settings, + "gh-axi", + ); + + expect(changed).toBe(false); + expect(updated).toBe(settings); + }); + + it("removes a managed hook while preserving unrelated groups", () => { + const [updated, changed] = computeSessionStartHookRemoval( + { + hooks: { + SessionStart: [ + { + matcher: "", + hooks: [{ type: "command", command: "/usr/local/bin/other" }], + }, + { + matcher: "", + hooks: [{ type: "command", command: "/usr/local/bin/gh-axi" }], + }, + ], + }, + }, + "gh-axi", + ); + + expect(changed).toBe(true); + expect(updated.hooks?.SessionStart).toEqual([ + { + matcher: "", + hooks: [{ type: "command", command: "/usr/local/bin/other" }], + }, + ]); + }); + + it("drops the hooks key entirely once the last managed entry is removed", () => { + const [updated, changed] = computeSessionStartHookRemoval( + { + hooks: { + SessionStart: [ + { + matcher: "", + hooks: [{ type: "command", command: "/usr/local/bin/gh-axi" }], + }, + ], + }, + }, + "gh-axi", + ); + + expect(changed).toBe(true); + expect(updated.hooks).toBeUndefined(); + }); + + it("removes managed legacy codex session_start entries", () => { + const [updated, changed] = computeSessionStartHookRemoval( + { + hooks: { + session_start: [ + { type: "command", command: "/old/path/gh-axi" }, + { type: "command", command: "/usr/local/bin/other" }, + ], + }, + }, + "gh-axi", + ); + + expect(changed).toBe(true); + expect(updated.hooks?.session_start).toEqual([ + { type: "command", command: "/usr/local/bin/other" }, + ]); + }); + + it("leaves SessionStart untouched when only a legacy entry is removed", () => { + const [updated, changed] = computeSessionStartHookRemoval( + { + hooks: { + session_start: [{ type: "command", command: "/old/path/gh-axi" }], + SessionStart: [], + }, + }, + "gh-axi", + ); + + expect(changed).toBe(true); + expect(updated.hooks?.session_start).toBeUndefined(); + expect(updated.hooks?.SessionStart).toEqual([]); + }); +}); + describe("computeCodexConfigUpdate", () => { it("creates a features section for empty config", () => { expect(computeCodexConfigUpdate("")).toEqual([ @@ -737,3 +856,240 @@ describe("installSessionStartHooks (OpenCode plugin)", () => { expect(existsSync(pluginPath(home))).toBe(false); }); }); + +describe("session hook scope (user vs project)", () => { + let tmp: string; + let home: string; + let projectDir: string; + let execFile: string; + + function claudeSettingsPath(root: string) { + return join(root, ".claude", "settings.json"); + } + + function codexHooksPath(root: string) { + return join(root, ".codex", "hooks.json"); + } + + function openCodePluginPath(root: string, isProjectScope: boolean) { + return isProjectScope + ? join(root, ".opencode", "plugins", "axi-gh-axi.js") + : join(root, ".config", "opencode", "plugins", "axi-gh-axi.js"); + } + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "axi-sdk-js-hook-scope-")); + home = join(tmp, "home"); + projectDir = join(tmp, "project"); + mkdirSync(home, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + + const pkgBin = join(tmp, "pkg", "dist", "bin"); + mkdirSync(pkgBin, { recursive: true }); + execFile = join(pkgBin, "gh-axi.js"); + writeFileSync(execFile, "// stub\n", "utf-8"); + }); + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it("defaults to user scope: omitting `scope` only touches home paths, even when projectDir is passed", () => { + installSessionStartHooks({ + marker: "gh-axi", + execPath: execFile, + homeDir: home, + projectDir, + }); + + expect(existsSync(claudeSettingsPath(home))).toBe(true); + expect(existsSync(codexHooksPath(home))).toBe(true); + expect(existsSync(openCodePluginPath(home, false))).toBe(true); + + expect(existsSync(claudeSettingsPath(projectDir))).toBe(false); + expect(existsSync(codexHooksPath(projectDir))).toBe(false); + expect(existsSync(openCodePluginPath(projectDir, true))).toBe(false); + + const status = sessionStartHookStatus({ marker: "gh-axi", homeDir: home }); + expect(status.scope).toBe("user"); + expect(status.claude.installed).toBe(true); + expect(status.codex.installed).toBe(true); + expect(status.opencode.installed).toBe(true); + }); + + it("installs project-scoped Claude/Codex hooks and an OpenCode plugin under projectDir, while the Codex feature flag stays at user scope", () => { + installSessionStartHooks({ + marker: "gh-axi", + execPath: execFile, + homeDir: home, + scope: "project", + projectDir, + }); + + const claudeSettings = JSON.parse( + readFileSync(claudeSettingsPath(projectDir), "utf-8"), + ); + expect(claudeSettings.hooks.SessionStart[0].hooks[0].command).toBe( + execFile, + ); + + const codexHooks = JSON.parse( + readFileSync(codexHooksPath(projectDir), "utf-8"), + ); + expect(codexHooks.hooks.SessionStart[0].hooks[0].command).toBe(execFile); + + expect(existsSync(openCodePluginPath(projectDir, true))).toBe(true); + + // Never writes the user-scope hook files or plugin. + expect(existsSync(claudeSettingsPath(home))).toBe(false); + expect(existsSync(codexHooksPath(home))).toBe(false); + expect(existsSync(openCodePluginPath(home, false))).toBe(false); + + // Repo-level Codex hooks still require the USER-level feature flag. + const codexConfig = readFileSync( + join(home, ".codex", "config.toml"), + "utf-8", + ); + expect(codexConfig).toContain("hooks = true"); + }); + + it("reports scope-accurate status, including the shared Codex user-level flag", () => { + installSessionStartHooks({ + marker: "gh-axi", + execPath: execFile, + homeDir: home, + scope: "project", + projectDir, + }); + + const projectStatus = sessionStartHookStatus({ + marker: "gh-axi", + homeDir: home, + scope: "project", + projectDir, + }); + expect(projectStatus.scope).toBe("project"); + expect(projectStatus.claude).toEqual({ + installed: true, + path: claudeSettingsPath(projectDir), + }); + expect(projectStatus.codex.installed).toBe(true); + expect(projectStatus.codex.path).toBe(codexHooksPath(projectDir)); + expect(projectStatus.codex.userFeatureEnabled).toBe(true); + expect(projectStatus.codex.userFeaturePath).toBe( + join(home, ".codex", "config.toml"), + ); + expect(projectStatus.opencode).toEqual({ + installed: true, + path: openCodePluginPath(projectDir, true), + }); + + const userStatus = sessionStartHookStatus({ + marker: "gh-axi", + homeDir: home, + }); + expect(userStatus.scope).toBe("user"); + expect(userStatus.claude.installed).toBe(false); + expect(userStatus.codex.installed).toBe(false); + expect(userStatus.opencode.installed).toBe(false); + // The feature flag is user-level and shared, so it reads enabled from + // either scope once the project-scoped install has ensured it. + expect(userStatus.codex.userFeatureEnabled).toBe(true); + }); + + it("uninstall removes only marker-matched entries at the requested scope", () => { + installSessionStartHooks({ + marker: "gh-axi", + execPath: execFile, + homeDir: home, + scope: "user", + }); + installSessionStartHooks({ + marker: "gh-axi", + execPath: execFile, + homeDir: home, + scope: "project", + projectDir, + }); + + uninstallSessionStartHooks({ + marker: "gh-axi", + homeDir: home, + scope: "project", + projectDir, + }); + + const projectSettings = JSON.parse( + readFileSync(claudeSettingsPath(projectDir), "utf-8"), + ); + expect(projectSettings.hooks).toBeUndefined(); + expect(existsSync(openCodePluginPath(projectDir, true))).toBe(false); + + // The user-scope install is untouched. + const userSettings = JSON.parse( + readFileSync(claudeSettingsPath(home), "utf-8"), + ); + expect(userSettings.hooks.SessionStart[0].hooks[0].command).toBe(execFile); + expect(existsSync(openCodePluginPath(home, false))).toBe(true); + + // Uninstall never touches the shared user-level Codex feature flag. + const codexConfig = readFileSync( + join(home, ".codex", "config.toml"), + "utf-8", + ); + expect(codexConfig).toContain("hooks = true"); + + const projectStatus = sessionStartHookStatus({ + marker: "gh-axi", + homeDir: home, + scope: "project", + projectDir, + }); + expect(projectStatus.claude.installed).toBe(false); + expect(projectStatus.codex.installed).toBe(false); + expect(projectStatus.opencode.installed).toBe(false); + }); + + it("uninstall does not remove an unmanaged project-scope OpenCode plugin", () => { + const target = openCodePluginPath(projectDir, true); + mkdirSync(join(projectDir, ".opencode", "plugins"), { recursive: true }); + writeFileSync( + target, + "export const UserPlugin = async () => ({})\n", + "utf-8", + ); + const errors: string[] = []; + + uninstallSessionStartHooks({ + marker: "gh-axi", + homeDir: home, + scope: "project", + projectDir, + onError: (message) => errors.push(message), + }); + + expect(readFileSync(target, "utf-8")).toBe( + "export const UserPlugin = async () => ({})\n", + ); + expect(errors[0]).toContain("refusing to remove unmanaged OpenCode plugin"); + }); + + it("throws from sessionStartHookStatus when the hook marker cannot be inferred", () => { + expect(() => + sessionStartHookStatus({ + execPath: join(tmp, "random", "script.mjs"), + homeDir: home, + }), + ).toThrow(/unable to infer/); + }); + + it("is a no-op from uninstallSessionStartHooks when the hook marker cannot be inferred", () => { + expect(() => + uninstallSessionStartHooks({ + execPath: join(tmp, "random", "script.mjs"), + homeDir: home, + }), + ).not.toThrow(); + expect(existsSync(claudeSettingsPath(home))).toBe(false); + }); +});