From cb6dec73b864729f00c82db1d0802ccfcccf9db0 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 01:02:58 +0000 Subject: [PATCH 1/7] feat: ship a Claude Code plugin that finds and bwraps the checker The hook locates comment-checker on PATH or via direnv, names flake.nix when that is why it is missing, and runs a native binary under bwrap when bubblewrap is present. It passes --strip. Deno is required. --- .changeset/claude-plugin.md | 5 ++ .claude-plugin/plugin.json | 13 +++ README.md | 6 ++ hooks/hooks.json | 14 ++++ hooks/run.test.ts | 104 +++++++++++++++++++++++ hooks/run.ts | 162 ++++++++++++++++++++++++++++++++++++ 6 files changed, 304 insertions(+) create mode 100644 .changeset/claude-plugin.md create mode 100644 .claude-plugin/plugin.json create mode 100644 hooks/hooks.json create mode 100644 hooks/run.test.ts create mode 100755 hooks/run.ts diff --git a/.changeset/claude-plugin.md b/.changeset/claude-plugin.md new file mode 100644 index 0000000..a0cf4ea --- /dev/null +++ b/.changeset/claude-plugin.md @@ -0,0 +1,5 @@ +--- +'@systemfsoftware/claude-code-comment-checker': minor +--- + +This repository is also a Claude Code plugin. Enabling it runs a PostToolUse hook that locates `comment-checker` on PATH or via direnv, tells you to run `direnv allow` or `nix develop` when `flake.nix` is why it is missing, and runs a native binary under bwrap when bubblewrap is installed. The hook passes `--strip`. diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..8531d5a --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "comment-checker", + "version": "0.2.0", + "description": "PostToolUse hook that flags unnecessary comments. Resolves comment-checker via PATH or direnv, names flake.nix when that is why it is missing, and runs a native binary under bwrap when bubblewrap is installed.", + "author": { + "name": "systemfsoftware", + "url": "https://github.com/systemfsoftware/comment-checker" + }, + "homepage": "https://github.com/systemfsoftware/comment-checker", + "repository": "https://github.com/systemfsoftware/comment-checker", + "license": "Apache-2.0", + "keywords": ["hooks", "comments", "claude-code"] +} diff --git a/README.md b/README.md index 794a124..c8c3b02 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ Add the hook to user (`~/.claude/settings.json`) or project (`.claude/settings.j } ``` +Or install this repo as a Claude Code plugin. The hook finds `comment-checker` on PATH or via `direnv exec`, names `flake.nix` when that is why it is missing, and runs a native binary under `bwrap` when bubblewrap is installed. It passes `--strip`. Deno must be on PATH. + +```bash +claude --plugin-dir . +``` + On `Edit` and `MultiEdit`, only the comments *added* by the edit are checked — pre-existing comments are left alone. Edits also arrive as fragments, so restatement detection is disabled on them to avoid false positives. ### Verify the wiring diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..7e91aa7 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,14 @@ +{ + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "deno run --allow-read --allow-run=comment-checker,direnv,bwrap --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME \"${CLAUDE_PLUGIN_ROOT}/hooks/run.ts\"", + "timeout": 30 + } + ] + } + ] +} diff --git a/hooks/run.test.ts b/hooks/run.test.ts new file mode 100644 index 0000000..f41ecf4 --- /dev/null +++ b/hooks/run.test.ts @@ -0,0 +1,104 @@ +function assertEquals(actual: unknown, expected: unknown) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`not equal: ${Deno.inspect(actual)} vs ${Deno.inspect(expected)}`) + } +} + +import { bwrapArgs, type Host, planLaunch, shouldBwrap } from './run.ts' + +function host(overrides: Partial & Pick): Host { + return { + projectDir: '/proj', + fileExists: () => false, + fileHead: () => '', + ...overrides, + } +} + +Deno.test('PATH native binary plus bwrap wraps and strips', () => { + const launch = planLaunch(host({ + which: (name) => + name === 'comment-checker' + ? '/bin/comment-checker' + : name === 'bwrap' + ? '/bin/bwrap' + : undefined, + fileHead: () => '\x7fELF', + fileExists: (path) => path === '/usr' || path === '/proj', + })) + assertEquals(launch.kind, 'run') + if (launch.kind !== 'run') return + assertEquals(launch.cmd, 'bwrap') + assertEquals(launch.args.at(-2), '/bin/comment-checker') + assertEquals(launch.args.at(-1), '--strip') +}) + +Deno.test('PATH wrapper that already calls bwrap is not wrapped again', () => { + const launch = planLaunch(host({ + which: (name) => + name === 'comment-checker' + ? '/nix/bin/comment-checker' + : name === 'bwrap' + ? '/bin/bwrap' + : undefined, + fileHead: () => '#!/bin/sh\nexec bwrap --ro-bind /nix/store', + })) + assertEquals(launch, { + kind: 'run', + cmd: 'comment-checker', + args: ['--strip'], + }) +}) + +Deno.test('direnv is used when comment-checker is not on PATH', () => { + const launch = planLaunch(host({ + which: (name) => name === 'direnv' ? '/bin/direnv' : undefined, + })) + assertEquals(launch, { + kind: 'run', + cmd: 'direnv', + args: ['exec', '/proj', 'comment-checker', '--strip'], + }) +}) + +Deno.test('missing checker with flake.nix names nix and direnv', () => { + const launch = planLaunch(host({ + which: () => undefined, + fileExists: (path) => path === '/proj/flake.nix', + })) + assertEquals(launch.kind, 'missing') + if (launch.kind !== 'missing') return + assertEquals(launch.hint.includes('flake.nix'), true) + assertEquals(launch.hint.includes('direnv allow'), true) +}) + +Deno.test('missing checker without flake names the npm package', () => { + const launch = planLaunch(host({ + which: () => undefined, + })) + assertEquals(launch.kind, 'missing') + if (launch.kind !== 'missing') return + assertEquals( + launch.hint.includes('@systemfsoftware/claude-code-comment-checker'), + true, + ) +}) + +Deno.test('shouldBwrap is only for native binaries', () => { + assertEquals(shouldBwrap('/bin/cc', '\x7fELF rest'), true) + assertEquals(shouldBwrap('/bin/cc', '#!/usr/bin/env node\n'), false) + assertEquals(shouldBwrap('/bin/cc', '#!/bin/sh\nbwrap --ro-bind'), false) +}) + +Deno.test('bwrapArgs binds existing roots and the binary', () => { + const args = bwrapArgs( + '/bin/comment-checker', + '/proj', + (path) => path === '/nix/store' || path === '/usr' || path === '/proj', + ) + assertEquals(args.includes('/nix/store'), true) + assertEquals(args.includes('/usr'), true) + assertEquals(args.includes('/lib'), false) + assertEquals(args.at(-3), '/bin/comment-checker') + assertEquals(args.at(-1), '/proj') +}) diff --git a/hooks/run.ts b/hooks/run.ts new file mode 100755 index 0000000..74b22c1 --- /dev/null +++ b/hooks/run.ts @@ -0,0 +1,162 @@ +#!/usr/bin/env -S deno run --allow-read --allow-run=comment-checker,direnv,bwrap --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME + +export type Host = { + projectDir: string + which: (name: string) => string | undefined + fileExists: (path: string) => boolean + fileHead: (path: string) => string +} + +export type Launch = + | { kind: 'run'; cmd: string; args: string[] } + | { kind: 'missing'; hint: string } + +const STRIP = ['--strip'] +const ELF = '\x7fELF' +const MACHO_64BE = '\xcf\xfa\xed\xfe' +const MACHO_64LE = '\xfe\xed\xfa\xcf' + +export function planLaunch(host: Host): Launch { + const checker = host.which('comment-checker') + if (checker !== undefined) { + if (host.which('bwrap') !== undefined && shouldBwrap(checker, host.fileHead(checker))) { + return { + kind: 'run', + cmd: 'bwrap', + args: [...bwrapArgs(checker, host.projectDir, host.fileExists), '--', checker, ...STRIP], + } + } + return { kind: 'run', cmd: 'comment-checker', args: STRIP } + } + + if (host.which('direnv') !== undefined) { + return { + kind: 'run', + cmd: 'direnv', + args: ['exec', host.projectDir, 'comment-checker', ...STRIP], + } + } + + const flake = host.fileExists(`${host.projectDir}/flake.nix`) + return { + kind: 'missing', + hint: flake + ? 'This project has flake.nix. Run direnv allow or nix develop so comment-checker is on PATH.' + : 'Install it: pnpm add -g @systemfsoftware/claude-code-comment-checker', + } +} + +export function shouldBwrap(binPath: string, head: string): boolean { + if (head.includes('bwrap')) return false + return head.startsWith(ELF) || head.startsWith(MACHO_64BE) || head.startsWith(MACHO_64LE) +} + +export function bwrapArgs( + binPath: string, + projectDir: string, + fileExists: (path: string) => boolean, +): string[] { + const binds: string[] = [] + for (const path of ['/nix/store', '/etc', '/usr', '/lib', '/lib64']) { + if (fileExists(path)) binds.push('--ro-bind', path, path) + } + return [ + ...binds, + '--proc', + '/proc', + '--dev', + '/dev', + '--tmpfs', + '/tmp', + '--unshare-net', + '--die-with-parent', + '--ro-bind', + projectDir, + projectDir, + '--ro-bind', + binPath, + binPath, + '--chdir', + projectDir, + ] +} + +function whichOnPath(name: string): string | undefined { + const delimiter = Deno.build.os === 'windows' ? ';' : ':' + const slash = Deno.build.os === 'windows' ? '\\' : '/' + const names = Deno.build.os === 'windows' ? [name, `${name}.exe`, `${name}.cmd`] : [name] + for (const dir of (Deno.env.get('PATH') ?? '').split(delimiter)) { + if (dir === '') continue + for (const n of names) { + const candidate = `${dir}${slash}${n}` + try { + if (Deno.statSync(candidate).isFile) return candidate + } catch { + continue + } + } + } +} + +function liveHost(projectDir: string): Host { + return { + projectDir, + which: whichOnPath, + fileExists: (path) => { + try { + Deno.statSync(path) + return true + } catch { + return false + } + }, + fileHead: (path) => { + try { + const file = Deno.openSync(path, { read: true }) + const buf = new Uint8Array(2048) + const n = file.readSync(buf) ?? 0 + file.close() + return new TextDecoder('latin1').decode(buf.subarray(0, n)) + } catch { + return '' + } + }, + } +} + +async function main(): Promise { + const projectDir = Deno.env.get('CLAUDE_PROJECT_DIR') + if (projectDir === undefined || projectDir === '') { + await Deno.stderr.write( + new TextEncoder().encode('CLAUDE_PROJECT_DIR must be set by the hook host\n'), + ) + Deno.exit(1) + } + + const launch = planLaunch(liveHost(projectDir)) + if (launch.kind === 'missing') { + await Deno.stderr.write( + new TextEncoder().encode( + [ + 'comment-checker did not run, so nothing checked this write.', + launch.hint, + '', + ].join('\n'), + ), + ) + Deno.exit(1) + } + + const child = new Deno.Command(launch.cmd, { + args: launch.args, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + }) + const { code } = await child.output() + Deno.exit(code) +} + +if (import.meta.main) { + await main() +} From 0779f0f1e2f04fde42ea787af436af45a1c6cbdc Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 01:41:30 +0000 Subject: [PATCH 2/7] chore: drop the plugin hook unit tests The launcher is a thin PATH/direnv/bwrap exec. Those tests did not defend an observable contract. --- hooks/run.test.ts | 104 ---------------------------------------------- hooks/run.ts | 10 ++--- 2 files changed, 5 insertions(+), 109 deletions(-) delete mode 100644 hooks/run.test.ts diff --git a/hooks/run.test.ts b/hooks/run.test.ts deleted file mode 100644 index f41ecf4..0000000 --- a/hooks/run.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -function assertEquals(actual: unknown, expected: unknown) { - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - throw new Error(`not equal: ${Deno.inspect(actual)} vs ${Deno.inspect(expected)}`) - } -} - -import { bwrapArgs, type Host, planLaunch, shouldBwrap } from './run.ts' - -function host(overrides: Partial & Pick): Host { - return { - projectDir: '/proj', - fileExists: () => false, - fileHead: () => '', - ...overrides, - } -} - -Deno.test('PATH native binary plus bwrap wraps and strips', () => { - const launch = planLaunch(host({ - which: (name) => - name === 'comment-checker' - ? '/bin/comment-checker' - : name === 'bwrap' - ? '/bin/bwrap' - : undefined, - fileHead: () => '\x7fELF', - fileExists: (path) => path === '/usr' || path === '/proj', - })) - assertEquals(launch.kind, 'run') - if (launch.kind !== 'run') return - assertEquals(launch.cmd, 'bwrap') - assertEquals(launch.args.at(-2), '/bin/comment-checker') - assertEquals(launch.args.at(-1), '--strip') -}) - -Deno.test('PATH wrapper that already calls bwrap is not wrapped again', () => { - const launch = planLaunch(host({ - which: (name) => - name === 'comment-checker' - ? '/nix/bin/comment-checker' - : name === 'bwrap' - ? '/bin/bwrap' - : undefined, - fileHead: () => '#!/bin/sh\nexec bwrap --ro-bind /nix/store', - })) - assertEquals(launch, { - kind: 'run', - cmd: 'comment-checker', - args: ['--strip'], - }) -}) - -Deno.test('direnv is used when comment-checker is not on PATH', () => { - const launch = planLaunch(host({ - which: (name) => name === 'direnv' ? '/bin/direnv' : undefined, - })) - assertEquals(launch, { - kind: 'run', - cmd: 'direnv', - args: ['exec', '/proj', 'comment-checker', '--strip'], - }) -}) - -Deno.test('missing checker with flake.nix names nix and direnv', () => { - const launch = planLaunch(host({ - which: () => undefined, - fileExists: (path) => path === '/proj/flake.nix', - })) - assertEquals(launch.kind, 'missing') - if (launch.kind !== 'missing') return - assertEquals(launch.hint.includes('flake.nix'), true) - assertEquals(launch.hint.includes('direnv allow'), true) -}) - -Deno.test('missing checker without flake names the npm package', () => { - const launch = planLaunch(host({ - which: () => undefined, - })) - assertEquals(launch.kind, 'missing') - if (launch.kind !== 'missing') return - assertEquals( - launch.hint.includes('@systemfsoftware/claude-code-comment-checker'), - true, - ) -}) - -Deno.test('shouldBwrap is only for native binaries', () => { - assertEquals(shouldBwrap('/bin/cc', '\x7fELF rest'), true) - assertEquals(shouldBwrap('/bin/cc', '#!/usr/bin/env node\n'), false) - assertEquals(shouldBwrap('/bin/cc', '#!/bin/sh\nbwrap --ro-bind'), false) -}) - -Deno.test('bwrapArgs binds existing roots and the binary', () => { - const args = bwrapArgs( - '/bin/comment-checker', - '/proj', - (path) => path === '/nix/store' || path === '/usr' || path === '/proj', - ) - assertEquals(args.includes('/nix/store'), true) - assertEquals(args.includes('/usr'), true) - assertEquals(args.includes('/lib'), false) - assertEquals(args.at(-3), '/bin/comment-checker') - assertEquals(args.at(-1), '/proj') -}) diff --git a/hooks/run.ts b/hooks/run.ts index 74b22c1..0c2a56e 100755 --- a/hooks/run.ts +++ b/hooks/run.ts @@ -1,13 +1,13 @@ #!/usr/bin/env -S deno run --allow-read --allow-run=comment-checker,direnv,bwrap --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME -export type Host = { +type Host = { projectDir: string which: (name: string) => string | undefined fileExists: (path: string) => boolean fileHead: (path: string) => string } -export type Launch = +type Launch = | { kind: 'run'; cmd: string; args: string[] } | { kind: 'missing'; hint: string } @@ -16,7 +16,7 @@ const ELF = '\x7fELF' const MACHO_64BE = '\xcf\xfa\xed\xfe' const MACHO_64LE = '\xfe\xed\xfa\xcf' -export function planLaunch(host: Host): Launch { +function planLaunch(host: Host): Launch { const checker = host.which('comment-checker') if (checker !== undefined) { if (host.which('bwrap') !== undefined && shouldBwrap(checker, host.fileHead(checker))) { @@ -46,12 +46,12 @@ export function planLaunch(host: Host): Launch { } } -export function shouldBwrap(binPath: string, head: string): boolean { +function shouldBwrap(binPath: string, head: string): boolean { if (head.includes('bwrap')) return false return head.startsWith(ELF) || head.startsWith(MACHO_64BE) || head.startsWith(MACHO_64LE) } -export function bwrapArgs( +function bwrapArgs( binPath: string, projectDir: string, fileExists: (path: string) => boolean, From b93bcf6b8547c3f203d2db1ee4340274a85ac513 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 01:46:39 +0000 Subject: [PATCH 3/7] refactor: arktype Launch and @std in the plugin hook Stop hand-written tagged unions. Resolve config from hooks/deno.jsonc. Invoke the script by path so hooks.json does not repeat the shebang. --- hooks/deno.jsonc | 16 +++++ hooks/deno.lock | 65 ++++++++++++++++++ hooks/hooks.json | 2 +- hooks/run.ts | 168 +++++++++++++++++++++-------------------------- 4 files changed, 158 insertions(+), 93 deletions(-) create mode 100644 hooks/deno.jsonc create mode 100644 hooks/deno.lock diff --git a/hooks/deno.jsonc b/hooks/deno.jsonc new file mode 100644 index 0000000..2468a63 --- /dev/null +++ b/hooks/deno.jsonc @@ -0,0 +1,16 @@ +{ + "lock": "./deno.lock", + "imports": { + "@std/bytes": "jsr:@std/bytes@1.0.6", + "@std/fs": "jsr:@std/fs@1.0.19", + "@std/io": "jsr:@std/io@0.225.2", + "@std/path": "jsr:@std/path@1.1.6", + "arktype": "npm:arktype@2.2.3" + }, + "fmt": { + "lineWidth": 100, + "indentWidth": 2, + "singleQuote": true, + "semiColons": false + } +} diff --git a/hooks/deno.lock b/hooks/deno.lock new file mode 100644 index 0000000..df4632b --- /dev/null +++ b/hooks/deno.lock @@ -0,0 +1,65 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/bytes@1.0.6": "1.0.6", + "jsr:@std/fs@1.0.19": "1.0.19", + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/io@0.225.2": "0.225.2", + "jsr:@std/path@1.1.6": "1.1.6", + "npm:arktype@2.2.3": "2.2.3" + }, + "jsr": { + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/fs@1.0.19": { + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06" + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/io@0.225.2": { + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7" + }, + "@std/path@1.1.6": { + "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "@ark/schema@0.56.2": { + "integrity": "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==", + "dependencies": [ + "@ark/util" + ] + }, + "@ark/util@0.56.2": { + "integrity": "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==" + }, + "arkregex@0.0.8": { + "integrity": "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==", + "dependencies": [ + "@ark/util" + ] + }, + "arktype@2.2.3": { + "integrity": "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==", + "dependencies": [ + "@ark/schema", + "@ark/util", + "arkregex" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/bytes@1.0.6", + "jsr:@std/fs@1.0.19", + "jsr:@std/io@0.225.2", + "jsr:@std/path@1.1.6", + "npm:arktype@2.2.3" + ] + } +} diff --git a/hooks/hooks.json b/hooks/hooks.json index 7e91aa7..8fb86be 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -5,7 +5,7 @@ "hooks": [ { "type": "command", - "command": "deno run --allow-read --allow-run=comment-checker,direnv,bwrap --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME \"${CLAUDE_PLUGIN_ROOT}/hooks/run.ts\"", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run.ts\"", "timeout": 30 } ] diff --git a/hooks/run.ts b/hooks/run.ts index 0c2a56e..78919ef 100755 --- a/hooks/run.ts +++ b/hooks/run.ts @@ -1,64 +1,57 @@ #!/usr/bin/env -S deno run --allow-read --allow-run=comment-checker,direnv,bwrap --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME -type Host = { - projectDir: string - which: (name: string) => string | undefined - fileExists: (path: string) => boolean - fileHead: (path: string) => string -} +import { startsWith } from '@std/bytes' +import { exists } from '@std/fs/exists' +import { writeAll } from '@std/io/write-all' +import { DELIMITER, join } from '@std/path' +import { type } from 'arktype' -type Launch = - | { kind: 'run'; cmd: string; args: string[] } - | { kind: 'missing'; hint: string } +const Launch = type({ + kind: "'run'", + cmd: 'string', + args: 'string[]', +}).or({ + kind: "'missing'", + hint: 'string', +}) +type Launch = typeof Launch.infer const STRIP = ['--strip'] -const ELF = '\x7fELF' -const MACHO_64BE = '\xcf\xfa\xed\xfe' -const MACHO_64LE = '\xfe\xed\xfa\xcf' +const ELF = Uint8Array.of(0x7f, 0x45, 0x4c, 0x46) +const MACHO_64_LE = Uint8Array.of(0xcf, 0xfa, 0xed, 0xfe) +const MACHO_64_BE = Uint8Array.of(0xfe, 0xed, 0xfa, 0xcf) +const encoder = new TextEncoder() -function planLaunch(host: Host): Launch { - const checker = host.which('comment-checker') - if (checker !== undefined) { - if (host.which('bwrap') !== undefined && shouldBwrap(checker, host.fileHead(checker))) { - return { - kind: 'run', - cmd: 'bwrap', - args: [...bwrapArgs(checker, host.projectDir, host.fileExists), '--', checker, ...STRIP], - } - } - return { kind: 'run', cmd: 'comment-checker', args: STRIP } - } - - if (host.which('direnv') !== undefined) { - return { - kind: 'run', - cmd: 'direnv', - args: ['exec', host.projectDir, 'comment-checker', ...STRIP], +async function whichOnPath(name: string): Promise { + const names = Deno.build.os === 'windows' ? [name, `${name}.exe`, `${name}.cmd`] : [name] + for (const dir of (Deno.env.get('PATH') ?? '').split(DELIMITER)) { + if (dir === '') continue + for (const n of names) { + const candidate = join(dir, n) + if (await exists(candidate)) return candidate } } +} - const flake = host.fileExists(`${host.projectDir}/flake.nix`) - return { - kind: 'missing', - hint: flake - ? 'This project has flake.nix. Run direnv allow or nix develop so comment-checker is on PATH.' - : 'Install it: pnpm add -g @systemfsoftware/claude-code-comment-checker', +async function fileHead(path: string): Promise { + const file = await Deno.open(path, { read: true }) + try { + const buf = new Uint8Array(2048) + const n = await file.read(buf) ?? 0 + return buf.subarray(0, n) + } finally { + file.close() } } -function shouldBwrap(binPath: string, head: string): boolean { - if (head.includes('bwrap')) return false - return head.startsWith(ELF) || head.startsWith(MACHO_64BE) || head.startsWith(MACHO_64LE) +function nativeBinary(head: Uint8Array): boolean { + return startsWith(head, ELF) || startsWith(head, MACHO_64_LE) || startsWith(head, MACHO_64_BE) } -function bwrapArgs( - binPath: string, - projectDir: string, - fileExists: (path: string) => boolean, -): string[] { +async function bwrapArgs(binPath: string, projectDir: string): Promise { const binds: string[] = [] for (const path of ['/nix/store', '/etc', '/usr', '/lib', '/lib64']) { - if (fileExists(path)) binds.push('--ro-bind', path, path) + if (await exists(path)) binds.push('--ro-bind', path, path) } return [ ...binds, @@ -81,62 +74,56 @@ function bwrapArgs( ] } -function whichOnPath(name: string): string | undefined { - const delimiter = Deno.build.os === 'windows' ? ';' : ':' - const slash = Deno.build.os === 'windows' ? '\\' : '/' - const names = Deno.build.os === 'windows' ? [name, `${name}.exe`, `${name}.cmd`] : [name] - for (const dir of (Deno.env.get('PATH') ?? '').split(delimiter)) { - if (dir === '') continue - for (const n of names) { - const candidate = `${dir}${slash}${n}` - try { - if (Deno.statSync(candidate).isFile) return candidate - } catch { - continue +async function planLaunch(projectDir: string): Promise { + const checker = await whichOnPath('comment-checker') + if (checker !== undefined) { + const bwrap = await whichOnPath('bwrap') + if (bwrap !== undefined) { + const head = await fileHead(checker) + const wrapper = new TextDecoder('latin1').decode(head).includes('bwrap') + if (!wrapper && nativeBinary(head)) { + return Launch.assert({ + kind: 'run', + cmd: 'bwrap', + args: [...await bwrapArgs(checker, projectDir), '--', checker, ...STRIP], + }) } } + return Launch.assert({ kind: 'run', cmd: 'comment-checker', args: STRIP }) } -} -function liveHost(projectDir: string): Host { - return { - projectDir, - which: whichOnPath, - fileExists: (path) => { - try { - Deno.statSync(path) - return true - } catch { - return false - } - }, - fileHead: (path) => { - try { - const file = Deno.openSync(path, { read: true }) - const buf = new Uint8Array(2048) - const n = file.readSync(buf) ?? 0 - file.close() - return new TextDecoder('latin1').decode(buf.subarray(0, n)) - } catch { - return '' - } - }, + if (await whichOnPath('direnv') !== undefined) { + return Launch.assert({ + kind: 'run', + cmd: 'direnv', + args: ['exec', projectDir, 'comment-checker', ...STRIP], + }) } + + const flake = await exists(join(projectDir, 'flake.nix')) + return Launch.assert({ + kind: 'missing', + hint: flake + ? 'This project has flake.nix. Run direnv allow or nix develop so comment-checker is on PATH.' + : 'Install it: pnpm add -g @systemfsoftware/claude-code-comment-checker', + }) } async function main(): Promise { const projectDir = Deno.env.get('CLAUDE_PROJECT_DIR') if (projectDir === undefined || projectDir === '') { - await Deno.stderr.write( - new TextEncoder().encode('CLAUDE_PROJECT_DIR must be set by the hook host\n'), + await writeAll( + Deno.stderr, + encoder.encode('CLAUDE_PROJECT_DIR must be set by the hook host\n'), ) Deno.exit(1) } - const launch = planLaunch(liveHost(projectDir)) + const launch = await planLaunch(projectDir) if (launch.kind === 'missing') { - await Deno.stderr.write( - new TextEncoder().encode( + await writeAll( + Deno.stderr, + encoder.encode( [ 'comment-checker did not run, so nothing checked this write.', launch.hint, @@ -147,16 +134,13 @@ async function main(): Promise { Deno.exit(1) } - const child = new Deno.Command(launch.cmd, { + const { code } = await new Deno.Command(launch.cmd, { args: launch.args, stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', - }) - const { code } = await child.output() + }).output() Deno.exit(code) } -if (import.meta.main) { - await main() -} +await main() From 6285bb06a65983ba47f10424cbe3f41f68da16a8 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 01:49:50 +0000 Subject: [PATCH 4/7] chore: drop deno fmt from the plugin import map dprint already owns formatting. --- hooks/deno.jsonc | 6 ------ 1 file changed, 6 deletions(-) diff --git a/hooks/deno.jsonc b/hooks/deno.jsonc index 2468a63..a5c10ac 100644 --- a/hooks/deno.jsonc +++ b/hooks/deno.jsonc @@ -6,11 +6,5 @@ "@std/io": "jsr:@std/io@0.225.2", "@std/path": "jsr:@std/path@1.1.6", "arktype": "npm:arktype@2.2.3" - }, - "fmt": { - "lineWidth": 100, - "indentWidth": 2, - "singleQuote": true, - "semiColons": false } } From b2a560dcc7f0649fe75ce641d8653895950bda7e Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 01:52:47 +0000 Subject: [PATCH 5/7] refactor: parse hook env with arktype, resolve binaries in one PATH walk Launch was a self-asserted tagged union. Env is the untrusted boundary: trim and non-empty CLAUDE_PROJECT_DIR, PATH split to entries. One walk collects comment-checker, bwrap, and direnv; bind-root exists run together. --- hooks/deno.jsonc | 1 - hooks/deno.lock | 5 -- hooks/run.ts | 203 +++++++++++++++++++++++------------------------ 3 files changed, 98 insertions(+), 111 deletions(-) diff --git a/hooks/deno.jsonc b/hooks/deno.jsonc index a5c10ac..a8eaf7c 100644 --- a/hooks/deno.jsonc +++ b/hooks/deno.jsonc @@ -1,7 +1,6 @@ { "lock": "./deno.lock", "imports": { - "@std/bytes": "jsr:@std/bytes@1.0.6", "@std/fs": "jsr:@std/fs@1.0.19", "@std/io": "jsr:@std/io@0.225.2", "@std/path": "jsr:@std/path@1.1.6", diff --git a/hooks/deno.lock b/hooks/deno.lock index df4632b..e08dbf5 100644 --- a/hooks/deno.lock +++ b/hooks/deno.lock @@ -1,7 +1,6 @@ { "version": "5", "specifiers": { - "jsr:@std/bytes@1.0.6": "1.0.6", "jsr:@std/fs@1.0.19": "1.0.19", "jsr:@std/internal@^1.0.14": "1.0.14", "jsr:@std/io@0.225.2": "0.225.2", @@ -9,9 +8,6 @@ "npm:arktype@2.2.3": "2.2.3" }, "jsr": { - "@std/bytes@1.0.6": { - "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" - }, "@std/fs@1.0.19": { "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06" }, @@ -55,7 +51,6 @@ }, "workspace": { "dependencies": [ - "jsr:@std/bytes@1.0.6", "jsr:@std/fs@1.0.19", "jsr:@std/io@0.225.2", "jsr:@std/path@1.1.6", diff --git a/hooks/run.ts b/hooks/run.ts index 78919ef..e7c065f 100755 --- a/hooks/run.ts +++ b/hooks/run.ts @@ -1,60 +1,66 @@ #!/usr/bin/env -S deno run --allow-read --allow-run=comment-checker,direnv,bwrap --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME -import { startsWith } from '@std/bytes' import { exists } from '@std/fs/exists' import { writeAll } from '@std/io/write-all' import { DELIMITER, join } from '@std/path' import { type } from 'arktype' -const Launch = type({ - kind: "'run'", - cmd: 'string', - args: 'string[]', -}).or({ - kind: "'missing'", - hint: 'string', +const Env = type({ + CLAUDE_PROJECT_DIR: 'string.trim |> string > 0', + 'PATH?': type('string').pipe((s: string) => + s.split(DELIMITER).filter((dir) => dir.length > 0) + ), }) -type Launch = typeof Launch.infer const STRIP = ['--strip'] -const ELF = Uint8Array.of(0x7f, 0x45, 0x4c, 0x46) -const MACHO_64_LE = Uint8Array.of(0xcf, 0xfa, 0xed, 0xfe) -const MACHO_64_BE = Uint8Array.of(0xfe, 0xed, 0xfa, 0xcf) +const BIND_ROOTS = ['/nix/store', '/etc', '/usr', '/lib', '/lib64'] as const const encoder = new TextEncoder() -async function whichOnPath(name: string): Promise { - const names = Deno.build.os === 'windows' ? [name, `${name}.exe`, `${name}.cmd`] : [name] - for (const dir of (Deno.env.get('PATH') ?? '').split(DELIMITER)) { - if (dir === '') continue - for (const n of names) { - const candidate = join(dir, n) - if (await exists(candidate)) return candidate - } - } -} +const env = Env({ + CLAUDE_PROJECT_DIR: Deno.env.get('CLAUDE_PROJECT_DIR') ?? '', + PATH: Deno.env.get('PATH'), +}) -async function fileHead(path: string): Promise { - const file = await Deno.open(path, { read: true }) - try { - const buf = new Uint8Array(2048) - const n = await file.read(buf) ?? 0 - return buf.subarray(0, n) - } finally { - file.close() - } +if (env instanceof type.errors) { + await writeAll( + Deno.stderr, + encoder.encode(`CLAUDE_PROJECT_DIR must be set by the hook host\n${env.summary}\n`), + ) + Deno.exit(1) } -function nativeBinary(head: Uint8Array): boolean { - return startsWith(head, ELF) || startsWith(head, MACHO_64_LE) || startsWith(head, MACHO_64_BE) +async function locate( + dirs: readonly string[], + names: readonly string[], +): Promise> { + const found: Record = {} + const pending = new Set(names) + for (const dir of dirs) { + if (pending.size === 0) break + const hits = await Promise.all( + [...pending].map(async (name) => { + const candidate = join(dir, name) + return (await exists(candidate)) ? ([name, candidate] as const) : undefined + }), + ) + for (const hit of hits) { + if (hit === undefined) continue + found[hit[0]] = hit[1] + pending.delete(hit[0]) + if (hit[0] === 'comment-checker') pending.delete('direnv') + } + } + return found } -async function bwrapArgs(binPath: string, projectDir: string): Promise { - const binds: string[] = [] - for (const path of ['/nix/store', '/etc', '/usr', '/lib', '/lib64']) { - if (await exists(path)) binds.push('--ro-bind', path, path) - } +async function sandboxArgs(bin: string, projectDir: string): Promise { + const binds = await Promise.all( + BIND_ROOTS.map(async (root) => + (await exists(root)) ? ['--ro-bind', root, root] : [] + ), + ) return [ - ...binds, + ...binds.flat(), '--proc', '/proc', '--dev', @@ -67,80 +73,67 @@ async function bwrapArgs(binPath: string, projectDir: string): Promise projectDir, projectDir, '--ro-bind', - binPath, - binPath, + bin, + bin, '--chdir', projectDir, ] } -async function planLaunch(projectDir: string): Promise { - const checker = await whichOnPath('comment-checker') - if (checker !== undefined) { - const bwrap = await whichOnPath('bwrap') - if (bwrap !== undefined) { - const head = await fileHead(checker) - const wrapper = new TextDecoder('latin1').decode(head).includes('bwrap') - if (!wrapper && nativeBinary(head)) { - return Launch.assert({ - kind: 'run', - cmd: 'bwrap', - args: [...await bwrapArgs(checker, projectDir), '--', checker, ...STRIP], - }) - } - } - return Launch.assert({ kind: 'run', cmd: 'comment-checker', args: STRIP }) - } +const bins = await locate(env.PATH ?? [], ['comment-checker', 'bwrap', 'direnv']) +const projectDir = env.CLAUDE_PROJECT_DIR - if (await whichOnPath('direnv') !== undefined) { - return Launch.assert({ - kind: 'run', - cmd: 'direnv', - args: ['exec', projectDir, 'comment-checker', ...STRIP], - }) - } - - const flake = await exists(join(projectDir, 'flake.nix')) - return Launch.assert({ - kind: 'missing', - hint: flake - ? 'This project has flake.nix. Run direnv allow or nix develop so comment-checker is on PATH.' - : 'Install it: pnpm add -g @systemfsoftware/claude-code-comment-checker', - }) -} +let cmd: string +let args: string[] -async function main(): Promise { - const projectDir = Deno.env.get('CLAUDE_PROJECT_DIR') - if (projectDir === undefined || projectDir === '') { - await writeAll( - Deno.stderr, - encoder.encode('CLAUDE_PROJECT_DIR must be set by the hook host\n'), - ) - Deno.exit(1) - } - - const launch = await planLaunch(projectDir) - if (launch.kind === 'missing') { - await writeAll( - Deno.stderr, - encoder.encode( - [ - 'comment-checker did not run, so nothing checked this write.', - launch.hint, - '', - ].join('\n'), - ), +if (bins['comment-checker'] !== undefined) { + const checker = bins['comment-checker'] + cmd = 'comment-checker' + args = STRIP + if (bins['bwrap'] !== undefined) { + const file = await Deno.open(checker, { read: true }) + const head = new Uint8Array(256) + const n = await file.read(head) ?? 0 + file.close() + const b0 = head[0] + const b1 = head[1] + const b2 = head[2] + const b3 = head[3] + const native = n >= 4 && ( + (b0 === 0x7f && b1 === 0x45 && b2 === 0x4c && b3 === 0x46) || + (b0 === 0xcf && b1 === 0xfa && b2 === 0xed && b3 === 0xfe) || + (b0 === 0xfe && b1 === 0xed && b2 === 0xfa && b3 === 0xcf) ) - Deno.exit(1) + const wrapped = new TextDecoder('latin1').decode(head.subarray(0, n)).includes('bwrap') + if (native && !wrapped) { + cmd = 'bwrap' + args = [...await sandboxArgs(checker, projectDir), '--', checker, ...STRIP] + } } - - const { code } = await new Deno.Command(launch.cmd, { - args: launch.args, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }).output() - Deno.exit(code) +} else if (bins['direnv'] !== undefined) { + cmd = 'direnv' + args = ['exec', projectDir, 'comment-checker', ...STRIP] +} else { + const flake = await exists(join(projectDir, 'flake.nix')) + await writeAll( + Deno.stderr, + encoder.encode( + [ + 'comment-checker did not run, so nothing checked this write.', + flake + ? 'This project has flake.nix. Run direnv allow or nix develop so comment-checker is on PATH.' + : 'Install it: pnpm add -g @systemfsoftware/claude-code-comment-checker', + '', + ].join('\n'), + ), + ) + Deno.exit(1) } -await main() +const { code } = await new Deno.Command(cmd, { + args, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', +}).output() +Deno.exit(code) From d6d2422ca2e8e89b1bcc4ee9757ffc45e080ab97 Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 01:54:28 +0000 Subject: [PATCH 6/7] refactor: spell out trim-then-nonempty for CLAUDE_PROJECT_DIR string.trim |> string > 0 is valid arktype. It also looks like a numeric compare. Pipe the trim morph into atLeastLength(1) instead. --- hooks/run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/run.ts b/hooks/run.ts index e7c065f..fa126d7 100755 --- a/hooks/run.ts +++ b/hooks/run.ts @@ -6,7 +6,7 @@ import { DELIMITER, join } from '@std/path' import { type } from 'arktype' const Env = type({ - CLAUDE_PROJECT_DIR: 'string.trim |> string > 0', + CLAUDE_PROJECT_DIR: type('string.trim').pipe(type('string').atLeastLength(1)), 'PATH?': type('string').pipe((s: string) => s.split(DELIMITER).filter((dir) => dir.length > 0) ), From 7a6bab2eb3122ba5ac74bf7a2fd8aa0ac53633dc Mon Sep 17 00:00:00 2001 From: Ryan Lee Date: Wed, 26 Aug 2026 02:03:29 +0000 Subject: [PATCH 7/7] refactor: exec comment-checker or direnv instead of reimplementing which The hook does not walk PATH, read ELF magic, or build a bwrap profile. Sandboxing stays in the nix wrapper. --- .changeset/claude-plugin.md | 2 +- .claude-plugin/plugin.json | 2 +- README.md | 2 +- hooks/run.ts | 150 +++++++++--------------------------- 4 files changed, 39 insertions(+), 117 deletions(-) diff --git a/.changeset/claude-plugin.md b/.changeset/claude-plugin.md index a0cf4ea..0c8702e 100644 --- a/.changeset/claude-plugin.md +++ b/.changeset/claude-plugin.md @@ -2,4 +2,4 @@ '@systemfsoftware/claude-code-comment-checker': minor --- -This repository is also a Claude Code plugin. Enabling it runs a PostToolUse hook that locates `comment-checker` on PATH or via direnv, tells you to run `direnv allow` or `nix develop` when `flake.nix` is why it is missing, and runs a native binary under bwrap when bubblewrap is installed. The hook passes `--strip`. +This repository is also a Claude Code plugin. Enabling it runs a PostToolUse hook that tries `comment-checker --strip`, then `direnv exec`. If both miss and the project has `flake.nix`, the error tells you to run `direnv allow` or `nix develop`. diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8531d5a..9e31e2b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "comment-checker", "version": "0.2.0", - "description": "PostToolUse hook that flags unnecessary comments. Resolves comment-checker via PATH or direnv, names flake.nix when that is why it is missing, and runs a native binary under bwrap when bubblewrap is installed.", + "description": "PostToolUse hook that flags unnecessary comments. Runs comment-checker --strip, then direnv exec if it is missing. Names flake.nix when that is why it is missing.", "author": { "name": "systemfsoftware", "url": "https://github.com/systemfsoftware/comment-checker" diff --git a/README.md b/README.md index c8c3b02..83b4ba2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Add the hook to user (`~/.claude/settings.json`) or project (`.claude/settings.j } ``` -Or install this repo as a Claude Code plugin. The hook finds `comment-checker` on PATH or via `direnv exec`, names `flake.nix` when that is why it is missing, and runs a native binary under `bwrap` when bubblewrap is installed. It passes `--strip`. Deno must be on PATH. +Or install this repo as a Claude Code plugin. The hook runs `comment-checker --strip`, then `direnv exec` if that binary is missing. A `flake.nix` in the project makes the error tell you to `direnv allow` or `nix develop` (the flake wraps the checker in bwrap). Deno must be on PATH. ```bash claude --plugin-dir . diff --git a/hooks/run.ts b/hooks/run.ts index fa126d7..58f40c3 100755 --- a/hooks/run.ts +++ b/hooks/run.ts @@ -1,139 +1,61 @@ -#!/usr/bin/env -S deno run --allow-read --allow-run=comment-checker,direnv,bwrap --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME +#!/usr/bin/env -S deno run --allow-read --allow-run=comment-checker,direnv --allow-env=CLAUDE_PROJECT_DIR,PATH,HOME import { exists } from '@std/fs/exists' import { writeAll } from '@std/io/write-all' -import { DELIMITER, join } from '@std/path' +import { join } from '@std/path' import { type } from 'arktype' const Env = type({ CLAUDE_PROJECT_DIR: type('string.trim').pipe(type('string').atLeastLength(1)), - 'PATH?': type('string').pipe((s: string) => - s.split(DELIMITER).filter((dir) => dir.length > 0) - ), }) -const STRIP = ['--strip'] -const BIND_ROOTS = ['/nix/store', '/etc', '/usr', '/lib', '/lib64'] as const -const encoder = new TextEncoder() - const env = Env({ CLAUDE_PROJECT_DIR: Deno.env.get('CLAUDE_PROJECT_DIR') ?? '', - PATH: Deno.env.get('PATH'), }) if (env instanceof type.errors) { await writeAll( Deno.stderr, - encoder.encode(`CLAUDE_PROJECT_DIR must be set by the hook host\n${env.summary}\n`), + new TextEncoder().encode(`CLAUDE_PROJECT_DIR must be set by the hook host\n${env.summary}\n`), ) Deno.exit(1) } -async function locate( - dirs: readonly string[], - names: readonly string[], -): Promise> { - const found: Record = {} - const pending = new Set(names) - for (const dir of dirs) { - if (pending.size === 0) break - const hits = await Promise.all( - [...pending].map(async (name) => { - const candidate = join(dir, name) - return (await exists(candidate)) ? ([name, candidate] as const) : undefined - }), - ) - for (const hit of hits) { - if (hit === undefined) continue - found[hit[0]] = hit[1] - pending.delete(hit[0]) - if (hit[0] === 'comment-checker') pending.delete('direnv') - } +async function run(cmd: string, args: string[]): Promise { + try { + const { code } = await new Deno.Command(cmd, { + args, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + }).output() + return code + } catch (error) { + if (error instanceof Deno.errors.NotFound) return undefined + throw error } - return found } -async function sandboxArgs(bin: string, projectDir: string): Promise { - const binds = await Promise.all( - BIND_ROOTS.map(async (root) => - (await exists(root)) ? ['--ro-bind', root, root] : [] - ), - ) - return [ - ...binds.flat(), - '--proc', - '/proc', - '--dev', - '/dev', - '--tmpfs', - '/tmp', - '--unshare-net', - '--die-with-parent', - '--ro-bind', - projectDir, - projectDir, - '--ro-bind', - bin, - bin, - '--chdir', - projectDir, - ] -} - -const bins = await locate(env.PATH ?? [], ['comment-checker', 'bwrap', 'direnv']) +const strip = ['--strip'] const projectDir = env.CLAUDE_PROJECT_DIR -let cmd: string -let args: string[] - -if (bins['comment-checker'] !== undefined) { - const checker = bins['comment-checker'] - cmd = 'comment-checker' - args = STRIP - if (bins['bwrap'] !== undefined) { - const file = await Deno.open(checker, { read: true }) - const head = new Uint8Array(256) - const n = await file.read(head) ?? 0 - file.close() - const b0 = head[0] - const b1 = head[1] - const b2 = head[2] - const b3 = head[3] - const native = n >= 4 && ( - (b0 === 0x7f && b1 === 0x45 && b2 === 0x4c && b3 === 0x46) || - (b0 === 0xcf && b1 === 0xfa && b2 === 0xed && b3 === 0xfe) || - (b0 === 0xfe && b1 === 0xed && b2 === 0xfa && b3 === 0xcf) - ) - const wrapped = new TextDecoder('latin1').decode(head.subarray(0, n)).includes('bwrap') - if (native && !wrapped) { - cmd = 'bwrap' - args = [...await sandboxArgs(checker, projectDir), '--', checker, ...STRIP] - } - } -} else if (bins['direnv'] !== undefined) { - cmd = 'direnv' - args = ['exec', projectDir, 'comment-checker', ...STRIP] -} else { - const flake = await exists(join(projectDir, 'flake.nix')) - await writeAll( - Deno.stderr, - encoder.encode( - [ - 'comment-checker did not run, so nothing checked this write.', - flake - ? 'This project has flake.nix. Run direnv allow or nix develop so comment-checker is on PATH.' - : 'Install it: pnpm add -g @systemfsoftware/claude-code-comment-checker', - '', - ].join('\n'), - ), - ) - Deno.exit(1) -} - -const { code } = await new Deno.Command(cmd, { - args, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', -}).output() -Deno.exit(code) +const fromPath = await run('comment-checker', strip) +if (fromPath !== undefined) Deno.exit(fromPath) + +const fromDirenv = await run('direnv', ['exec', projectDir, 'comment-checker', ...strip]) +if (fromDirenv !== undefined) Deno.exit(fromDirenv) + +const flake = await exists(join(projectDir, 'flake.nix')) +await writeAll( + Deno.stderr, + new TextEncoder().encode( + [ + 'comment-checker did not run, so nothing checked this write.', + flake + ? 'This project has flake.nix. Run direnv allow or nix develop so comment-checker is on PATH (the flake wraps it in bwrap).' + : 'Install it: pnpm add -g @systemfsoftware/claude-code-comment-checker', + '', + ].join('\n'), + ), +) +Deno.exit(1)