Skip to content

feat(sdk): project-scope session-start hook install, status, and uninstall - #140

Merged
kunchenguid merged 4 commits into
kunchenguid:mainfrom
themightychris:feat/sdk-hook-scope
Aug 19, 2026
Merged

feat(sdk): project-scope session-start hook install, status, and uninstall#140
kunchenguid merged 4 commits into
kunchenguid:mainfrom
themightychris:feat/sdk-hook-scope

Conversation

@themightychris

Copy link
Copy Markdown
Contributor

Intent

Add opt-in project-scope support to axi-sdk-js's session-hook machinery so -axi tools can offer 'setup hooks --scope project': scope ('user'|'project', default user — zero behavior change for existing callers) and projectDir options on installSessionStartHooks, plus NEW sessionStartHookStatus() and uninstallSessionStartHooks() counterparts (none existed before — every tool hand-rolls status/uninstall today; these are new API surface designed in this change, reusing the existing marker-inference and JSON-patch machinery). Project scope: /.claude/settings.json for Claude Code (native), /.codex/hooks.json for Codex with the [features].hooks flag still ensured in the USER config.toml (repo hooks require the user-level flag), /.opencode/plugins/ for OpenCode (documented project-plugin dir per opencode.ai/docs/plugins). A resolveHookScopeTargets() helper centralizes scope→path resolution so install/status/uninstall can't drift. 15 new tests (49 in hooks.test.ts, 168 total in package); format/lint/build/test all clean. Motivated by a Jarvus tool survey: only specops supports project-scoped ambient hooks today, hand-rolled; landing this in the SDK gives every -axi tool the capability in one place.

What Changed

  • installSessionStartHooks() gains opt-in scope: "user" | "project" (default "user", unchanged behavior for existing callers) and projectDir options. Project scope targets <repo>/.claude/settings.json for Claude Code, <repo>/.codex/hooks.json for Codex (while still ensuring the [features].hooks flag in the user-level config.toml), and <repo>/.opencode/plugins/ for OpenCode.
  • Adds new sessionStartHookStatus() and uninstallSessionStartHooks() APIs, backed by a new computeSessionStartHookRemoval() JSON-patch helper and a shared resolveHookScopeTargets() helper that centralizes scope→path resolution across install, status, and uninstall.
  • Documents the new options and APIs in the SDK README, notes the OpenCode project-plugin directory in the AXI skill, and adds 15 tests covering scope resolution, status reporting, and uninstall across all three agents.

Risk Assessment

✅ Low: The fix round correctly scopes SessionStart removal with a per-section flag, adds a genuine fail-before/pass-after regression test, and introduces no new behavior beyond the prescribed fix; all remaining prior findings were informational and explicitly acknowledged by the user.

Testing

Ran the targeted hooks test file (50/50 passing, covering the new scope/status/uninstall API and the default-user-scope backward-compatibility guarantee), then built the package and ran an end-to-end demo driving the public API from the built dist exactly as a tool's setup hooks --scope project would: the transcript shows project-scoped Claude/Codex/OpenCode artifacts created under the repo, the Codex feature flag correctly ensured in the user-level config.toml, scope-accurate status reporting, and marker-scoped uninstall that preserves unrelated hooks and the shared user flag. All intent constraints demonstrated; no issues found.

Evidence: Project-scope hooks lifecycle demo transcript (install → status → uninstall)
=== 1. install with scope: 'project' (what `setup hooks --scope project` runs) ===

$ find <repo> -type f
<tmp>/my-repo/.opencode/plugins/axi-gh-axi.js
<tmp>/my-repo/.claude/settings.json
<tmp>/my-repo/.codex/hooks.json

$ cat <tmp>/my-repo/.claude/settings.json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/other-tool-hook.sh"
          }
        ]
      },
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "/var/folders/3t/56bdpv2146vbx5drqw9mzkn40000gn/T/axi-hook-scope-demo-YUEO2G/pkg/dist/bin/gh-axi.js",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

$ cat <tmp>/my-repo/.codex/hooks.json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "/var/folders/3t/56bdpv2146vbx5drqw9mzkn40000gn/T/axi-hook-scope-demo-YUEO2G/pkg/dist/bin/gh-axi.js",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

$ head -3 <repo>/.opencode/plugins/axi-gh-axi.js
// axi-sdk-js managed opencode plugin: gh-axi
// This file is generated by axi-sdk-js. It is safe to edit only if you remove the managed marker above.
import { spawn } from "node:child_process";

-- Codex [features].hooks flag lands in USER config, not the repo:

$ cat <tmp>/home/.codex/config.toml
[features]
hooks = true

-- user-level Claude settings untouched by project install:

$ cat <tmp>/home/.claude/settings.json
{
  "theme": "dark"
}

=== 2. sessionStartHookStatus at both scopes ===
project scope: {
  "marker": "gh-axi",
  "scope": "project",
  "claude": {
    "installed": true,
    "path": "<tmp>/my-repo/.claude/settings.json"
  },
  "codex": {
    "installed": true,
    "path": "<tmp>/my-repo/.codex/hooks.json",
    "userFeatureEnabled": true,
    "userFeaturePath": "<tmp>/home/.codex/config.toml"
  },
  "opencode": {
    "installed": true,
    "path": "<tmp>/my-repo/.opencode/plugins/axi-gh-axi.js"
  }
}
user scope (default): {
  "marker": "gh-axi",
  "scope": "user",
  "claude": {
    "installed": false,
    "path": "<tmp>/home/.claude/settings.json"
  },
  "codex": {
    "installed": false,
    "path": "<tmp>/home/.codex/hooks.json",
    "userFeatureEnabled": true,
    "userFeaturePath": "<tmp>/home/.codex/config.toml"
  },
  "opencode": {
    "installed": false,
    "path": "<tmp>/home/.config/opencode/plugins/axi-gh-axi.js"
  }
}

=== 3. uninstall at project scope ===

-- managed entry removed, the other tool's hook survives:

$ cat <tmp>/my-repo/.claude/settings.json
{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/other-tool-hook.sh"
          }
        ]
      }
    ]
  }
}

-- codex hooks.json after uninstall:

$ cat <tmp>/my-repo/.codex/hooks.json
{}

-- OpenCode plugin file removed: true
-- USER-level Codex feature flag (shared) still enabled: true

status after uninstall: {"claude":false,"codex":false,"opencode":false,"codexUserFlag":true}

Demo complete.
Evidence: Demo driver script (imports built dist as a -axi tool would)
// End-to-end demo of axi-sdk-js project-scope session hooks, driven the way a
// `-axi` tool's `setup hooks --scope project` command would call the SDK.
import {
  installSessionStartHooks,
  sessionStartHookStatus,
  uninstallSessionStartHooks,
} from "/Users/chris/.no-mistakes/worktrees/72b32df6f718/01KZVH8TRK0XQ4QTXK51YXBKT3/packages/axi-sdk-js/dist/index.js";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
import { execSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";

const tmp = mkdtempSync(join(tmpdir(), "axi-hook-scope-demo-"));
const home = join(tmp, "home");           // fake user $HOME
const repo = join(tmp, "my-repo");        // fake project checkout
mkdirSync(home, { recursive: true });
mkdirSync(repo, { recursive: true });

// Pre-existing user-level Claude settings + an unrelated project hook, to show
// scope isolation and non-destructive JSON patching.
mkdirSync(join(home, ".claude"), { recursive: true });
writeFileSync(join(home, ".claude", "settings.json"), JSON.stringify({ theme: "dark" }, null, 2) + "\n");
mkdirSync(join(repo, ".claude"), { recursive: true });
writeFileSync(
  join(repo, ".claude", "settings.json"),
  JSON.stringify({ hooks: { SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "./scripts/other-tool-hook.sh" }] }] } }, null, 2) + "\n",
);

// Stand in for an installed gh-axi dist entrypoint.
const bin = join(tmp, "pkg", "dist", "bin");
mkdirSync(bin, { recursive: true });
const execFile = join(bin, "gh-axi.js");
writeFileSync(execFile, "// gh-axi entrypoint stub\n");

const show = (label, cmd) => {
  console.log(`\n$ ${label}`);
  console.log(execSync(cmd, { encoding: "utf-8" }).trimEnd());
};
const cat = (p) => {
  console.log(`\n$ cat ${p.replace(tmp, "<tmp>")}`);
  console.log(readFileSync(p, "utf-8").trimEnd());
};

console.log("=== 1. install with scope: 'project' (what `setup hooks --scope project` runs) ===");
installSessionStartHooks({
  marker: "gh-axi",
  execPath: execFile,
  homeDir: home,
  scope: "project",
  projectDir: repo,
  onError: (m) => console.error("onError:", m),
});

show("find <repo> -type f", `find ${repo} -type f | sed 's|${tmp}|<tmp>|'`);
cat(join(repo, ".claude", "settings.json"));
cat(join(repo, ".codex", "hooks.json"));
console.log("\n$ head -3 <repo>/.opencode/plugins/axi-gh-axi.js");
console.log(readFileSync(join(repo, ".opencode", "plugins", "axi-gh-axi.js"), "utf-8").split("\n").slice(0, 3).join("\n"));
console.log("\n-- Codex [features].hooks flag lands in USER config, not the repo:");
cat(join(home, ".codex", "config.toml"));
console.log("\n-- user-level Claude settings untouched by project install:");
cat(join(home, ".claude", "settings.json"));

console.log("\n=== 2. sessionStartHookStatus at both scopes ===");
const projectStatus = sessionStartHookStatus({ marker: "gh-axi", homeDir: home, scope: "project", projectDir: repo });
console.log("project scope:", JSON.stringify(projectStatus, (k, v) => (typeof v === "string" ? v.replace(tmp, "<tmp>") : v), 2));
const userStatus = sessionStartHookStatus({ marker: "gh-axi", homeDir: home });
console.log("user scope (default):", JSON.stringify(userStatus, (k, v) => (typeof v === "string" ? v.replace(tmp, "<tmp>") : v), 2));

console.log("\n=== 3. uninstall at project scope ===");
uninstallSessionStartHooks({
  marker: "gh-axi",
  homeDir: home,
  scope: "project",
  projectDir: repo,
  onError: (m) => console.error("onError:", m),
});
console.log("\n-- managed entry removed, the other tool's hook survives:");
cat(join(repo, ".claude", "settings.json"));
console.log("\n-- codex hooks.json after uninstall:");
cat(join(repo, ".codex", "hooks.json"));
console.log("\n-- OpenCode plugin file removed:", !existsSync(join(repo, ".opencode", "plugins", "axi-gh-axi.js")));
console.log("-- USER-level Codex feature flag (shared) still enabled:", readFileSync(join(home, ".codex", "config.toml"), "utf-8").includes("hooks = true"));

const after = sessionStartHookStatus({ marker: "gh-axi", homeDir: home, scope: "project", projectDir: repo });
console.log("\nstatus after uninstall:", JSON.stringify({ claude: after.claude.installed, codex: after.codex.installed, opencode: after.opencode.installed, codexUserFlag: after.codex.userFeatureEnabled }));

rmSync(tmp, { recursive: true, force: true });
console.log("\nDemo complete.");
Evidence: Key transcript excerpt: project-scope install results
$ find <repo> -type f
<tmp>/my-repo/.opencode/plugins/axi-gh-axi.js
<tmp>/my-repo/.claude/settings.json
<tmp>/my-repo/.codex/hooks.json

-- Codex [features].hooks flag lands in USER config, not the repo:
$ cat <tmp>/home/.codex/config.toml
[features]
hooks = true

-- user-level Claude settings untouched by project install:
$ cat <tmp>/home/.claude/settings.json
{ "theme": "dark" }

(after project-scope uninstall)
-- managed entry removed, the other tool's hook survives
-- OpenCode plugin file removed: true
-- USER-level Codex feature flag (shared) still enabled: true
status after uninstall: {"claude":false,"codex":false,"opencode":false,"codexUserFlag":true}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed ✅
  • ℹ️ packages/axi-sdk-js/src/hooks.ts:238 - computeSessionStartHookRemoval shares one changed flag between the legacy session_start block and the SessionStart block. If a managed legacy entry is removed while hooks.SessionStart is an empty array (e.g. {hooks: {session_start: [managed], SessionStart: []}}), the if (changed) branch at line 238 deletes the untouched SessionStart: [] key (or re-assigns unchanged groups). The effect is semantically inert for Claude/Codex, but a per-section flag would keep the removal strictly scoped to marker-matched entries.
  • ℹ️ packages/axi-sdk-js/src/hooks.ts:883 - uninstallSessionStartHooks() returns silently (without invoking onError) when the marker cannot be inferred and none is passed, so an explicit uninstall invoked from a non-inferable context reports nothing while removing nothing. The README documents this as a deliberate mirror of install's permissive default and a test pins it, so this is an acknowledged tradeoff rather than a defect; if tools ever surface confusing 'uninstalled but still active' reports, routing this case through onError would be the follow-up.
  • ℹ️ packages/axi-sdk-js/test/hooks.test.ts:843 - Intent conformance: the user intent states "15 new tests (49 in hooks.test.ts, 168 total in package)". The 49-test count in hooks.test.ts matches, but the file grew from 37 to 49 test cases with no parameterized (it.each) expansion and no other test file changed in this range, so the change adds 12 new tests, not 15. Every functional acceptance criterion (scope default, project paths, user-level Codex flag, status, uninstall, marker inference edge cases) has test coverage, so this appears to be a narrative miscount rather than missing coverage — surfacing per the conformance rule instead of resolving it.

🔧 Fix: scope SessionStart removal with per-section changed flag
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • npx vitest run test/hooks.test.ts in packages/axi-sdk-js — 50/50 pass, including the 7-test session hook scope (user vs project) suite exercising install/status/uninstall against real temp filesystems
  • npm run build in packages/axi-sdk-js — TypeScript build of the new API surface succeeds
  • End-to-end lifecycle demo (node demo-project-scope.mjs) importing the built dist/index.js as a -axi tool would: installSessionStartHooks({scope:'project'}) → sessionStartHookStatus at both scopes → uninstallSessionStartHooks({scope:'project'}), with a fake $HOME and repo dir seeded with pre-existing user settings and an unrelated project hook
  • Verified in the demo transcript: project install writes <repo>/.claude/settings.json, <repo>/.codex/hooks.json, <repo>/.opencode/plugins/axi-gh-axi.js; Codex [features].hooks=true lands in USER config.toml; user-scope files untouched; unrelated project hook preserved through install and uninstall; uninstall removes only marker-matched entries and leaves the shared user flag
  • Confirmed worktree clean after removing transient dist/ build output
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

themightychris and others added 4 commits August 12, 2026 11:49
Add scope?: "user" | "project" (default "user", zero behavior change)
and projectDir?: string to installSessionStartHooks, plus two new
counterpart functions:

- sessionStartHookStatus(): read-only status per agent/scope, including
  the shared Codex user-level [features].hooks flag.
- uninstallSessionStartHooks(): removes only marker-matched managed
  entries at the requested scope.

Project scope targets:
- Claude Code: <projectDir>/.claude/settings.json (native project scope)
- Codex hooks: <projectDir>/.codex/hooks.json, while the
  [features].hooks = true flag is always ensured in the USER-level
  config.toml, since repo-level Codex hooks still require it there
- OpenCode: <projectDir>/.opencode/plugins/, symmetric with the
  documented global ~/.config/opencode/plugins/ convention
  (https://opencode.ai/docs/plugins/)

Introduces computeSessionStartHookRemoval() as the removal counterpart
to computeSessionStartHookUpdate(), and a resolveHookScopeTargets()
helper shared by install/status/uninstall so scope-path resolution
lives in one place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Document scope/projectDir on installSessionStartHooks, the new
sessionStartHookStatus()/uninstallSessionStartHooks() exports, the
per-agent user-vs-project path table, and the Codex user-level
feature-flag interaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@themightychris

Copy link
Copy Markdown
Contributor Author

This would close #45

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: reviewed the diff. This is opt-in new SDK behavior (scope defaults to "user", existing callers unchanged) plus status/uninstall helpers. Project-scope install is marker-scoped; uninstall refuses unmanaged OpenCode plugins and does not clear the shared Codex user-level hooks flag. Addresses #45. CI is green.

Approving and squash-merging.

@kunchenguid kunchenguid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Speaking as Kun's firstmate: approving. Opt-in project-scope hooks; default user/home unchanged; CI green.

@kunchenguid
kunchenguid merged commit b7dbc13 into kunchenguid:main Aug 19, 2026
4 checks passed
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is merged. Thank you @themightychris — really appreciate you taking the time on this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants