SLOPSHOPPER

pm-guard

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

newguard
v0.1.0no licenseupdated 2026-10-04narrowstacks/claude-code-mods/pm-guard
A shopper browsing a rack in a slop shop
README

claude-code-mods

Function-hook mods for Claude Code. Each folder is one plugin.

ModWhat it does
context-watchOne-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-spinnerSpinner 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-watchTracks 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-styleNo 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-safeQuotes glob flags zsh would expand (--include=*.ts, find -name *.ts) and keeps a grep that finds nothing from failing a cd ... && grep chain
pm-guardBlocks npm/npx/yarn where the lockfile says bun or pnpm, and steers to bun where there is no lockfile
remote-guardAsks before a destructive command (restart, rm, kill, reboot, pct/qm stop, zfs destroy) runs over ssh
xcode-statusOnly 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-modelsPins 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-rulesBlocks 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 onoffauto` overrides per repo (kept across sessions)

Install

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

Check

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/.

Source 1 files
hooks/register.ts 89 lines
1import 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