Steers npm/npx/yarn to the package manager the repo's lockfile names (bun or pnpm), bun where there is none

Function-hook mods for Claude Code. Each folder is one plugin.
| Mod | What it does | ||
|---|---|---|---|
context-watch | One-line gauge above the prompt for context fill, cost and hot rate limits; toasts at 60/80/90%; compaction keeps your instructions and edited files; /ctx opens a context breakdown pane | ||
live-spinner | Spinner shows what is running: the Bash description or a trimmed command, the ssh host, file being read or edited, search, subagent, MCP tool; a tool's own elapsed time after 15s; "Waiting for signing approval" when a signed git commit or tag sits quiet | ||
pr-watch | Tracks PRs from gh pr / gt submit / git push output, shows checks above the prompt, toasts on green/red/merged, blocks foreground CI polling loops; /prs | ||
house-style | No em dashes: system prompt rule, reminder to the model after a write that adds one, toast when a reply uses one | ||
handoff | /carryover [focus] writes a state note from the conversation and clears; the next prompt carries it. /carryover save, show, drop | ||
agent-jobs | /jobs opens a pane of running subagents and background shells with elapsed time and a Stop button; toasts when background work finishes | ||
zsh-safe | Quotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain | ||
pm-guard | Blocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile | ||
remote-guard | Asks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh | ||
xcode-status | Only in a folder with an .xcodeproj/.xcworkspace: one line with the last build or test result (flags runs that executed 0 tests) and the booted simulator; blocks a second xcodebuild while one is running; re-checks on /cd and keeps each project's last result | ||
agent-models | Pins the model each subagent type runs on (Explore, general-purpose, Plan, any other, plus custom type=model pairs), set in /config or with /agent-models <type> <model>; a model the caller names wins unless "Override explicit models" is on | ||
worktrees | /worktrees pane: each git worktree with branch, uncommitted changes, ahead/behind and PR state; Remove (after asking) on clean worktrees whose PR is merged or closed, plus "Remove N merged" to clear every merged one at once | ||
pr-rules | Blocks gh pr create / gt submit when the repo keeps an [Unreleased] changelog and the branch adds no entry (skip with --label no-changelog); where the repo's CLAUDE.md says to label every PR (or requireLabels is on), blocks a PR with no --label and lists the repo's labels. Repos with neither are untouched; `/pr-rules on | off | auto` overrides per repo (kept across sessions) |
claude plugin marketplace add narrowstacks/claude-code-mods
claude plugin install context-watch@claude-code-mods
Or load from a checkout without installing:
claude --plugin-dir ./context-watch --plugin-dir ./live-spinner
claude plugin validate ./context-watch
claude plugin test ./context-watch
tsc -p ./context-watch works once Claude Code has loaded the mod and written .claude-plugin/types/.
hooks/register.ts 89 lines1import type { EngineInterface, Register } from 'claude-code'
2
3export type Manager = 'bun' | 'pnpm' | 'npm' | 'yarn'
4
5const LOCKFILES: [string, Manager][] = [
6 ['bun.lock', 'bun'],
7 ['bun.lockb', 'bun'],
8 ['pnpm-lock.yaml', 'pnpm'],
9 ['package-lock.json', 'npm'],
10 ['yarn.lock', 'yarn'],
11]
12
13const RUNNERS: Record<Manager, string> = { bun: 'bunx', pnpm: 'pnpm dlx', npm: 'npx', yarn: 'yarn dlx' }
14
15// The program each top-level command starts with, and the directory a
16// leading `cd` moves to. Quoted text is not split.
17export const parseCommand = (command: string) => {
18 const segments = command.split(/&&|\|\||;|\||\n/).map(s => s.trim()).filter(Boolean)
19 const cd = command.match(/^\s*cd\s+("[^"]+"|'[^']+'|\S+)\s*(&&|;)/)?.[1]?.replace(/^["']|["']$/g, '')
20 const programs = segments
21 .map(s => s.replace(/^([A-Z_][A-Z0-9_]*=\S*\s+)+/, '').split(/\s+/)[0] ?? '')
22 .filter(Boolean)
23
24 return { programs, cd }
25}
26
27const managerOf = (program: string): Manager | undefined => {
28 if (program === 'npm' || program === 'npx') return 'npm'
29 if (program === 'yarn') return 'yarn'
30 if (program === 'pnpm' || program === 'pnpx') return 'pnpm'
31 if (program === 'bun' || program === 'bunx') return 'bun'
32
33 return undefined
34}
35
36// What to say when `used` runs where `wanted` belongs, or null when fine.
37export const verdict = (used: Manager, program: string, wanted: Manager | null): string | null => {
38 if (wanted === null) {
39 return used === 'npm' || used === 'yarn'
40 ? `pm-guard: no lockfile here, and the user's rule is bun by default. Use ${program === 'npx' ? 'bunx' : 'bun'} instead of ${program}.`
41 : null
42 }
43 if (used === wanted) {
44 return null
45 }
46
47 const isRunner = program === 'npx' || program === 'pnpx' || program === 'bunx'
48
49 return `pm-guard: this project uses ${wanted} (its lockfile says so). Use ${isRunner ? RUNNERS[wanted] : wanted} instead of ${program}.`
50}
51
52const resolve = async ($: EngineInterface, dir: string | undefined) => {
53 const cwd = await $.session.cwd()
54 if (dir === undefined) return cwd
55 if (dir.startsWith('/')) return dir
56 if (dir.startsWith('~')) return `${(await $.env.get('HOME')) ?? ''}${dir.slice(1)}`
57
58 return `${cwd}/${dir}`
59}
60
61const lockfileManager = async ($: EngineInterface, start: string): Promise<Manager | null> => {
62 let dir = start.replace(/\/+$/, '')
63 while (dir !== '') {
64 for (const [file, manager] of LOCKFILES) {
65 if (await $.fs.exists(`${dir}/${file}`)) return manager
66 }
67 if (await $.fs.exists(`${dir}/.git`)) return null
68 dir = dir.slice(0, dir.lastIndexOf('/'))
69 }
70
71 return null
72}
73
74export const register: Register = on => {
75 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
76 const { programs, cd } = parseCommand(e.command)
77 const used = programs.map(program => ({ program, manager: managerOf(program) }))
78 .find((one): one is { program: string; manager: Manager } => one.manager !== undefined)
79 if (used === undefined) {
80 return next(e)
81 }
82
83 const wanted = await lockfileManager($, await resolve($, cd))
84 const reason = verdict(used.manager, used.program, wanted)
85
86 return reason === null ? next(e) : { deny: reason }
87 })
88}
89