Asks before a destructive command runs on another machine over ssh (restarts, deletes, kills, reboots)

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 74 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const allowedHosts = atom({ plugin: 'remote-guard', key: 'allowedHosts' } as const, [] as string[])
5
6// ssh options that take a value: the token after them is not the host.
7const VALUED = new Set(['-b', '-c', '-D', '-E', '-e', '-F', '-I', '-i', '-J', '-L', '-l', '-m', '-O', '-o', '-p', '-Q', '-R', '-S', '-W', '-w', '-B'])
8
9const DESTRUCTIVE: RegExp[] = [
10 /\brm\s+(-\w*\s+)*\S/,
11 /\b(systemctl|service)\b.*\b(restart|stop|disable|mask|kill)\b/,
12 /\b(reboot|shutdown|poweroff|halt)\b/,
13 /\b(kill|pkill|killall)\b/,
14 /\bdocker\b.*\b(rm|rmi|stop|restart|kill|prune|down)\b/,
15 /\b(pct|qm)\s+(stop|destroy|shutdown|reboot|reset|rollback)\b/,
16 /\bzfs\s+(destroy|rollback)\b/,
17 /\bzpool\s+(destroy|export)\b/,
18 /\b(mkfs|wipefs|fdisk|parted)\b/,
19 /\bdd\s+.*\bof=/,
20 /\b(apt|apt-get|dnf|yum)\s+(remove|purge|autoremove)\b/,
21 /\bmv\s+\S+\s+\/(etc|usr|var|boot)\b/,
22 />\s*\/etc\//,
23]
24
25const unquote = (text: string) => text.trim().replace(/^(['"])([\s\S]*)\1$/, '$2')
26
27// Each ssh invocation in the command: the host and what it runs there.
28export const sshCalls = (command: string): { host: string; remote: string }[] => {
29 const calls: { host: string; remote: string }[] = []
30 for (const match of command.matchAll(/(?:^|[;&|(]\s*|\s)ssh\s+([^;&|]*(?:'[^']*'|"[^"]*")?[^;&|]*)/g)) {
31 const tokens = (match[1] ?? '').match(/'[^']*'|"[^"]*"|\S+/g) ?? []
32 let i = 0
33 while (i < tokens.length && (tokens[i] ?? '').startsWith('-')) {
34 i += VALUED.has(tokens[i] ?? '') ? 2 : 1
35 }
36 const host = tokens[i]
37 if (host !== undefined) {
38 calls.push({ host: host.replace(/^.*@/, ''), remote: unquote(tokens.slice(i + 1).join(' ')) })
39 }
40 }
41
42 return calls
43}
44
45export const isDestructive = (remote: string) => DESTRUCTIVE.some(pattern => pattern.test(remote))
46
47const RUN = 'Run it'
48const CANCEL = 'Cancel'
49const allowLabel = (host: string) => `Always allow on ${host} this session`
50
51export const register: Register = on => {
52 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
53 const allowed = await read($, allowedHosts)
54 const risky = sshCalls(e.command).find(call => isDestructive(call.remote) && !allowed.includes(call.host))
55 if (risky === undefined) {
56 return next(e)
57 }
58
59 const shown = risky.remote.length > 120 ? `${risky.remote.slice(0, 119)}…` : risky.remote
60 const always = allowLabel(risky.host)
61 const answer = await $.ui
62 .ask(`Run this on ${risky.host}? ${shown}`, { options: [RUN, always, CANCEL], header: 'Remote' })
63 .catch(() => CANCEL)
64
65 if (answer === always) {
66 await update($, allowedHosts, hosts => [...hosts, risky.host])
67 }
68
69 return answer === RUN || answer === always
70 ? next(e)
71 : { deny: `remote-guard: the user did not approve running this on ${risky.host}${answer !== CANCEL ? `. They said: ${answer}` : ''}.` }
72 })
73}
74types/index.d.ts 8 lines1export type AllowedHosts = string[]
2
3declare module 'claude-code' {
4 interface PluginState {
5 'remote-guard': { allowedHosts: AllowedHosts }
6 }
7}
8