SLOPSHOPPER

rule-guard

Block tool calls that break the global CLAUDE.md rules (PR merge, AI trailers, npm, grep/find, commit before lint, banned words); list and toggle them with…

newpanespinnerrowsguardcommand
★ 9v0.1.0MITupdated 2026-10-15ysoftman/myenv/.claude/mods/rule-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rule-guard
│ ┃ rule-guard ✕ › fix the failing auth test and add an audit log call │ ┃ RULE-GUARD 5 on · 0 o │ ┃ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ rules ⏺ Update(src/auth.ts) │ ┃ │ ● no-pr-merge on 0 ⎿ Added 2 lines, removed 1 line │ ┃ │ PR 머지 금지: 사람이 GitHub 웹 UI 에서, ⏺ Bash(bun test) │ ┃ │ 받은 PR 만 ⎿ 3 pass, 1 fail │ ┃ │ ● no-ai-trailer on 0 │ ┃ │ 커밋·PR 에 AI attribution 트레일러 금지 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ● bun-over-npm on 0 │ ┃ │ npm/npx 대신 bun/bunx ✻ Worked for 42s · done 4:20 PM │ ┃ │ ● rg-fd on 0 │ ┃ │ grep/find 대신 rg/fd › /rule-guard │ ┃ │ ● lint-before-commit on 0 ⎿ │ ┃ │ 고친 파일은 lint/format 을 돌린 뒤 커밋 id 상태 차단 설명 │ ┃ │ · banned-words 설정 안 됨 0 no-pr-merge on 0 PR 머지 금지: 사람이 Git │ ┃ │ bannedPaths 아래 수정·커밋·PR·issue 에 금 UI 에서, approve 받은 PR 만 │ ┃ ╰─────────────────────────────────────────── no-ai-trailer on 0 커밋·PR 에 AI attributio │ ┃ ╭─────────────────────────────────────────── 트레일러 금지 │ ┃ │ lint 대기 3 bun-over-npm on 0 npm/npx 대신 bun/bunx │ ┃ │ /work/app/src/auth.ts rg-fd on 0 grep/find 대신 rg/fd │ ┃ │ /work/app/src/audit.ts lint-before-commit on 0 고친 파일은 lint/format │ ┃ │ /work/app/src/cache.ts 뒤 커밋 │ ┃ ╰─────────────────────────────────────────── banned-words 설정 안 됨 0 bannedPaths 아래 │ ┃ /rule-guard off|on <id> ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · rule-guard
RULE-GUARD 5 on · 0 off · 차단 0 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ╭──────────────────────────────────────────────────────╮ │ rules │ │ ● no-pr-merge on 0 │ │ PR 머지 금지: 사람이 GitHub 웹 UI 에서, approve │ │ 받은 PR 만 │ │ ● no-ai-trailer on 0 │ │ 커밋·PR 에 AI attribution 트레일러 금지 │ │ ● bun-over-npm on 0 │ │ npm/npx 대신 bun/bunx │ │ ● rg-fd on 0 │ │ grep/find 대신 rg/fd │ │ ● lint-before-commit on 0 │ │ 고친 파일은 lint/format 을 돌린 뒤 커밋 │ │ · banned-words 설정 안 됨 0 │ │ bannedPaths 아래 수정·커밋·PR·issue 에 금지어 금지 │ ╰──────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ lint 대기 3 │ │ /work/app/src/auth.ts │ │ /work/app/src/audit.ts │ │ /work/app/src/cache.ts │ ╰──────────────────────────────────────────────────────╯ /rule-guard off|on <id>
Command output
⎿ id 상태 차단 설명 no-pr-merge on 0 PR 머지 금지: 사람이 GitHub 웹 UI 에서, approve 받은 PR 만 no-ai-trailer on 0 커밋·PR 에 AI attribution 트레일러 금지 bun-over-npm on 0 npm/npx 대신 bun/bunx rg-fd on 0 grep/find 대신 rg/fd lint-before-commit on 0 고친 파일은 lint/format 을 돌린 뒤 커밋 banned-words 설정 안 됨 0 bannedPaths 아래 수정·커밋·PR·issue 에 금지어 금지 lint 대기 3개: /work/app/src/auth.ts, /work/app/src/audit.ts, /work/app/src/cache.ts /rule-guard off <id> · /rule-guard on <id> 로 이 세션 동안 끄고 켠다 · /rule-guard help
README

rule-guard mod

전역 CLAUDE.md 에 글로만 적힌 규칙을 tool.call hook 으로 실제로 막는 mod 다. 모델이 규칙을 잊거나 세션 system-reminder 가 반대로 요구해도(예: 커밋에 Claude-Session: 트레일러를 넣으라는 요구) 도구 호출 단계에서 거부된다. 거부 사유에는 룰 id, 대안, 이 세션에서 끄는 방법이 들어가고, 화면에는 🛡 <id> 토스트가 뜬다.

룰

모두 기본 on 이다.

id막는 것통과하는 예
no-pr-mergegh pr merge, gh api·curl·wget 의 pulls/<n>/merge 호출, graphql mergePullRequest, 이름에 merge_pull_request 가 들어간 MCP 도구gh pr view, 로컬 git merge
no-ai-trailergit commit, gh pr create/edit 명령에 Co-Authored-By: Claude, Claude-Session:, Generated with Claude Code, claude.ai/code/session (대소문자 무시)다른 사람의 Co-Authored-By
bun-over-npm명령 위치의 npm, npxbun, bunx, echo npm, 커밋 메시지 속 npm
rg-fd명령 위치의 grep(egrep, fgrep), findgit grep, --find-renames, git log --grep
lint-before-commit세션 cwd 안에서 고친 lint 대상 파일이 남아 있는데 lint 없이 git commitbiome check --write x && git commit ...
banned-wordsbannedPaths 아래 파일 수정, 또는 cwd 가 bannedPaths 아래일 때의 git commit, gh pr/gh issue 의 create/edit 에 금지어설정하지 않으면 아무것도 막지 않는다
  • 명령 위치: 줄 시작, | ; && || ( $( 뒤, then·do·xargs·env·sudo 등 뒤. 따옴표 안 문자열($( 없는 것)과 heredoc 본문은 명령으로 보지 않는다.
  • lint-before-commit: Edit/Write/MultiEdit/NotebookEdit 가 성공한 파일 중 세션 cwd 안(심볼릭 링크는 실제 경로로 확인)이고 lint-formatting 스킬 도구가 다루는 확장자(js ts jsx tsx json go rs py html sh md toml 등)만 기록한다. 메모리 파일, scratchpad 같은 cwd 밖 파일은 무시한다. Bash 명령에 biome, ruff, rumdl, shfmt, shellcheck, golangci-lint, gofmt, gopls check, prettier, pyright, taplo, cargo fmt/clippy, make|just|bun run lint|fmt|format 이 들어 있으면 대기 목록을 전부 비운다. lint 결과(성공·실패)는 보지 않는다.

설치

이 폴더를 plugin 디렉터리로 로드한다. 둘 중 하나를 쓴다.

  • ~/.claude/settings.json 의 env.CLAUDE_CODE_PLUGIN_DIRS 에 이 폴더 경로를 : 로 이어 붙인다.
  {
    "env": {
      "CLAUDE_CODE_PLUGIN_DIRS": "~/workspace/myenv/.claude/mods/summary:~/workspace/myenv/.claude/mods/rule-guard"
    }
  }
  • claude --plugin-dir <이 폴더> 로 실행한다.

Claude Code 2.1.299 에서 확인했다.

사용

  • /rule-guard: 룰마다 id, 상태(on / off / 설정 안 됨), 이 세션 차단 횟수, 설명을 표로 보여 준다. lint 대기 파일이 있으면 목록도 보여 준다.
  • /rule-guard-pane: 같은 상태를 패널로 열고 닫는다. 전체 화면이면 대화 기록 오른쪽에 붙고, 아니면 프롬프트 위에 뜬다. 상태가 바뀌면 패널도 바로 바뀐다. 다른 탭에 가려져 있으면 닫지 않고 앞으로 꺼낸다. 출력은 남기지 않는다. Claude 를 시작하면 패널이 바로 열린다(터미널이 좁으면 대기, /reload-plugins 때는 다시 열지 않음).
  • /rule-guard off <id>, /rule-guard on <id>: 이 세션 동안만 끄고 켠다. 잘못된 id 면 사용 가능한 id 를 알려 준다.
  • /rule-guard help: 사용법. 단축키가 없으면 설정 방법도 보여 준다.

모델은 슬래시 명령을 실행할 수 없어서 룰을 끄는 건 사용자만 할 수 있다. 예를 들어 호환성 문제로 npm 이 꼭 필요하면 /rule-guard off bun-over-npm 으로 끈다. 끈 상태와 차단 횟수는 /reload-plugins 를 해도 세션 동안 유지되고, 새 세션에서는 다시 모두 켜진다.

패널 단축키

keybindings.json 의 command:<이름> action 은 그 슬래시 명령을 입력한 것처럼 실행한다. action 에는 인자를 넣을 수 없어서 인자 없는 /rule-guard-pane 을 따로 둔다. ~/.claude/keybindings.json 에 다음을 넣는다. 파일이 이미 있으면 덮어쓰지 말고 "Chat" 블록의 bindings 에 한 줄만 추가한다(없으면 블록을 추가한다).

{
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "ctrl+x r": "command:rule-guard-pane"
      }
    }
  ]
}
  • 다른 키에 묶어도 된다. 힌트 줄·패널·help·명령 목록 안내가 그 키를 따른다.
  • 바인딩이 있으면 프롬프트 아래 힌트 줄 끝에 ctrl+x r rule-guard 가 보인다.
  • 패널 탭 이동은 엔진 action pane:previous/pane:next 를 "Global" 블록에 묶는다(예: "ctrl+<": "pane:previous", "ctrl+>": "pane:next", 터미널에 따라 ctrl+shift+,/ctrl+shift+. 도 함께). 묶으면 패널 하단에 tabs 로 보인다.
  • 엔진 action 을 빌리지 않으므로 diff 패널과 겹치지 않는다.

banned-words 설정

bannedWords(쉼표 구분 금지어)와 bannedPaths(쉼표 구분 경로 prefix, ~ 사용 가능)를 둘 다 넣어야 동작한다. 하나라도 비어 있으면 /rule-guard 에 "설정 안 됨" 으로 나오고 아무것도 막지 않는다. /config 메뉴의 rule-guard 항목에서 넣거나 ~/.claude/settings.json 에 직접 넣는다. 키는 plugin 이름 rule-guard 다(엔진은 rule-guard@inline 도 읽는다).

{
  "pluginConfigs": {
    "rule-guard": {
      "options": {
        "bannedWords": "secretword,another-secret",
        "bannedPaths": "~/workspace/public-repo"
      }
    }
  }
}

금지어는 대소문자를 무시하고 부분 문자열로 찾는다. 경로는 적힌 경로와 심볼릭 링크를 따라간 실제 경로를 모두 확인한다.

개발

claude plugin validate .
claude plugin test .
biome check --write hooks/register.tsx tests/rule-guard.test.tsx types/index.d.ts

타입 체크는 mod 가 한 번 로드돼 .claude-plugin/types/ 가 생긴 뒤 bunx -p typescript tsc -p . 로 한다.

Source 2 files
hooks/register.tsx 655 lines
1import type { EngineInterface, Register, ToolCallInput } from "claude-code";
2import { atom, read, update } from "claude-code";
3
4const off = atom({ plugin: "rule-guard", key: "off" } as const, []);
5
6const blocked = atom({ plugin: "rule-guard", key: "blocked" } as const, {});
7
8// files edited since the last lint/format run
9const unlinted = atom({ plugin: "rule-guard", key: "pending" } as const, []);
10
11const chord = atom({ plugin: "rule-guard", key: "chord" } as const, "");
12
13const tabs = atom({ plugin: "rule-guard", key: "tabs" } as const, "");
14
15// a reload raises session.start again, so the pane opens on its own only once
16const autoOpened = atom(
17	{ plugin: "rule-guard", key: "autoOpened" } as const,
18	false,
19);
20
21const PANE = "rule-guard";
22
23// keybindings.json runs it as "command:rule-guard-pane", which takes no arguments
24const PANE_COMMAND = "rule-guard-pane";
25
26const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write", "NotebookEdit"]);
27
28// a name where the shell runs a command: line start, after ; & | ( { ` $(,
29// a keyword, or a wrapper, with flags and VAR=x before it
30const at = (name: string) =>
31	new RegExp(
32		String.raw`(?:^|[\n;&|({\x60]|\$\(|\b(?:then|do|else|xargs|env|sudo|time|exec|command|nohup)\b)\s*(?:-\S+\s+|\w+=\S*\s+)*(?:` +
33			name +
34			String.raw`)(?=$|[\s;&|)\x60])`,
35	);
36
37const GIT_COMMIT = at(
38	String.raw`git(?:\s+-[cC]\s+\S+|\s+--[\w-]+(?:=\S+)?)*\s+commit`,
39);
40
41const GH_PR_WRITE = at(String.raw`gh\s+pr\s+(?:create|edit)`);
42
43const PUBLISH = at(
44	String.raw`git(?:\s+-[cC]\s+\S+|\s+--[\w-]+(?:=\S+)?)*\s+commit|gh\s+(?:pr|issue)\s+(?:create|edit)`,
45);
46
47const GH_MERGE = at(String.raw`gh\s+pr\s+merge`);
48
49const API = at(String.raw`gh\s+api|curl|wget`);
50
51const MERGE_API = /\bpulls\/[^\s/]+\/merge\b|\bmergePullRequest\b/;
52
53const NPM = at("npm|npx");
54
55const GREP_FIND = at("[ef]?grep|find");
56
57const TRAILER =
58	/co-authored-by:\s*claude|claude-session:|generated with \[?claude code|claude\.ai\/code\/session/i;
59
60const LINT =
61	/\b(?:biome|ruff|rumdl|shfmt|shellcheck|golangci-lint|gofmt|gopls\s+check|prettier|pyright|taplo|cargo\s+(?:fmt|clippy)|(?:make|just|bun\s+run)\s+(?:lint|fmt|format))\b/i;
62
63// what the lint-formatting skill's tools cover
64const LINTED = /\.(?:[cm]?[jt]sx?|jsonc?|go|rs|pyi?|html?|sh|bash|md|toml)$/;
65
66// heredoc bodies and quoted text that runs nothing, taken out
67export const shell = (cmd: string) =>
68	cmd
69		.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n[\s\S]*?\n\s*\2(?=\n|$)/g, "<<$2\n")
70		.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (s) =>
71			s.startsWith("'") || !/[$`]/.test(s) ? `${s[0]}${s[0]}` : s,
72		);
73
74const fields = (e: object) =>
75	e as {
76		file_path?: unknown;
77		notebook_path?: unknown;
78		new_string?: unknown;
79		content?: unknown;
80		new_source?: unknown;
81		edits?: { new_string?: unknown }[];
82	};
83
84const pathOf = (e: object) => {
85	const { file_path, notebook_path } = fields(e);
86	const path = file_path ?? notebook_path;
87	return typeof path === "string" ? path : undefined;
88};
89
90const newText = (e: object) => {
91	const { new_string, content, new_source, edits = [] } = fields(e);
92	return [new_string, content, new_source, ...edits.map((x) => x.new_string)]
93		.filter((s) => typeof s === "string")
94		.join("\n");
95};
96
97// the path as spelled and where it really lands (a new file: its folder's)
98const places = async ($: EngineInterface, path: string) => {
99	const cut = path.lastIndexOf("/");
100	const own = await $.fs.stat(path, { resolve: true }).catch(() => undefined);
101	const dir = own
102		? undefined
103		: await $.fs
104				.stat(path.slice(0, cut) || "/", { resolve: true })
105				.catch(() => undefined);
106	const real =
107		own?.realPath ??
108		(dir?.realPath && `${dir.realPath.replace(/\/$/, "")}${path.slice(cut)}`);
109	return real && real !== path ? [path, real] : [path];
110};
111
112const under = (paths: string[], roots: string[]) =>
113	paths.some((p) =>
114		roots.some((r) => p === r || p.startsWith(r.endsWith("/") ? r : `${r}/`)),
115	);
116
117// the banned word this call would write under bannedPaths
118const bannedWord = async (
119	$: EngineInterface,
120	e: ToolCallInput,
121	code: string,
122	cwd: string,
123	words: string[],
124	paths: string[],
125) => {
126	const file = EDIT_TOOLS.has(String(e.tool)) ? pathOf(e) : undefined;
127	const text =
128		file !== undefined
129			? newText(e)
130			: e.tool === "Bash" && PUBLISH.test(code)
131				? e.command
132				: "";
133	const hit = words.find((w) => text.toLowerCase().includes(w.toLowerCase()));
134	if (hit === undefined || paths.length === 0) {
135		return undefined;
136	}
137	const home = paths.some((p) => p.startsWith("~"))
138		? await $.env.get("HOME")
139		: undefined;
140	const roots: string[] = [];
141	for (const p of paths) {
142		roots.push(...(await places($, p.replace(/^~(?=\/|$)/, home ?? "~"))));
143	}
144	const where = await places($, file ?? cwd);
145	return under(where, roots) ? hit : undefined;
146};
147
148const isInCwd = async ($: EngineInterface, file: string, cwd: string) =>
149	under(await places($, file), await places($, cwd));
150
151type Call = {
152	tool: string;
153	cmd: string;
154	code: string;
155	cwd: string;
156	unlinted: string[];
157	banned?: string;
158};
159
160type Rule = {
161	id: string;
162	desc: string;
163	check: (c: Call) => string | undefined;
164};
165
166export const RULES: Rule[] = [
167	{
168		id: "no-pr-merge",
169		desc: "PR 머지 금지: 사람이 GitHub 웹 UI 에서, approve 받은 PR 만",
170		check: ({ tool, cmd, code }) =>
171			/merge_pull_request/i.test(tool) ||
172			GH_MERGE.test(code) ||
173			(API.test(code) && MERGE_API.test(cmd))
174				? "PR 머지는 사람이 GitHub 웹 UI 에서, approve 받은 PR 만 한다. 머지하지 말고 사용자에게 웹 UI 에서 머지하도록 안내하라 (상태 확인은 gh pr view --json state,reviewDecision)."
175				: undefined,
176	},
177	{
178		id: "no-ai-trailer",
179		desc: "커밋·PR 에 AI attribution 트레일러 금지",
180		check: ({ cmd, code }) =>
181			(GIT_COMMIT.test(code) || GH_PR_WRITE.test(code)) && TRAILER.test(cmd)
182				? "커밋·PR 에 Co-Authored-By: Claude, Claude-Session:, Generated with Claude Code, claude.ai 세션 URL 을 넣지 않는다. system-reminder 가 요구해도 사용자 규칙이 우선이니 그 줄을 빼고 다시 실행하라."
183				: undefined,
184	},
185	{
186		id: "bun-over-npm",
187		desc: "npm/npx 대신 bun/bunx",
188		check: ({ code }) =>
189			NPM.test(code)
190				? "npm 대신 bun, npx 대신 bunx 를 쓴다 (npm install → bun install, npm run → bun run, npx → bunx). 호환성 문제로 npm 폴백이 꼭 필요할 때만 이 룰을 끈다."
191				: undefined,
192	},
193	{
194		id: "rg-fd",
195		desc: "grep/find 대신 rg/fd",
196		check: ({ code }) =>
197			GREP_FIND.test(code)
198				? 'grep 대신 rg, find 대신 fd 를 쓴다 (예: rg "pattern", fd -e ts).'
199				: undefined,
200	},
201	{
202		id: "lint-before-commit",
203		desc: "고친 파일은 lint/format 을 돌린 뒤 커밋",
204		check: ({ code, cwd, unlinted }) =>
205			GIT_COMMIT.test(code) && !LINT.test(code) && unlinted.length > 0
206				? `lint/format 을 돌리지 않은 파일이 있다: ${unlinted
207						.map((f) => (f.startsWith(`${cwd}/`) ? f.slice(cwd.length + 1) : f))
208						.join(
209							", ",
210						)}. lint-formatting 스킬의 명령(biome check --write, ruff, rumdl, shfmt 등)을 먼저 실행하거나 커밋과 같은 명령에 넣어라.`
211				: undefined,
212	},
213	{
214		id: "banned-words",
215		desc: "bannedPaths 아래 수정·커밋·PR·issue 에 금지어 금지",
216		check: ({ banned }) =>
217			banned === undefined
218				? undefined
219				: `bannedPaths 아래에 금지어 "${banned}" 를 쓰려 한다. 그 단어를 빼고 다시 하라.`,
220	},
221];
222
223const width = (s: string) =>
224	[...s].reduce((n, c) => n + ((c.codePointAt(0) ?? 0) > 0x1100 ? 2 : 1), 0);
225
226const pad = (s: string, n: number) => s + " ".repeat(Math.max(0, n - width(s)));
227
228const usage = (key: string, tabKeys: string) =>
229	[
230		"/rule-guard             룰 목록, on/off, 이 세션 차단 횟수",
231		`/${PANE_COMMAND}        패널 열기/닫기${key === "" ? "" : ` (${key})`}`,
232		...(tabKeys === "" ? [] : [`${pad(tabKeys, 24)}이전/다음 패널 탭`]),
233		"/rule-guard off <id>    이 세션 동안 룰 끄기",
234		"/rule-guard on <id>     다시 켜기",
235		"/rule-guard help        이 도움말",
236		"",
237		`id: ${RULES.map((r) => r.id).join(", ")}`,
238		"끄기는 이 세션에만 적용된다. 모델은 슬래시 명령을 실행할 수 없어 사용자만 바꿀 수 있다.",
239		"banned-words 는 settings.json 의 pluginConfigs 나 /config 에서 rule-guard 의 bannedWords, bannedPaths(쉼표 구분)를 넣어야 동작한다.",
240		...(key !== "" && tabKeys !== ""
241			? []
242			: [
243					"",
244					"단축키는 mod 가 등록하지 않는다. ~/.claude/keybindings.json 에 직접 추가한다(기존 블록에 합친다, README 참고):",
245					...(key === ""
246						? [
247								`  "Chat":   "ctrl+x r": "command:${PANE_COMMAND}"   패널 열기/닫기`,
248							]
249						: []),
250					...(tabKeys === ""
251						? [
252								'  "Global": "ctrl+<": "pane:previous", "ctrl+>": "pane:next"   이전/다음 패널 탭',
253								'            터미널에 따라 "ctrl+shift+," / "ctrl+shift+." 도 함께 묶는다',
254							]
255						: []),
256					"다른 키에 묶어도 되고, 안내는 묶은 키를 따른다.",
257				]),
258	].join("\n");
259
260const boundKeys = async ($: EngineInterface) => {
261	try {
262		const { bindings = [] } = JSON.parse(
263			await $.fs.read(`${await $.env.get("HOME")}/.claude/keybindings.json`),
264		) as {
265			bindings?: { context?: string; bindings?: Record<string, unknown> }[];
266		};
267		return bindings.flatMap((b) => Object.entries(b.bindings ?? {}));
268	} catch {
269		return [];
270	}
271};
272
273const syncChord = async ($: EngineInterface) => {
274	const bound = await boundKeys($);
275	const keyOf = (action: string) =>
276		bound.find(([, a]) => a === action)?.[0] ?? "";
277	const key = keyOf(`command:${PANE_COMMAND}`);
278	if ((await read($, chord)) !== key) {
279		await update($, chord, () => key);
280	}
281	const tabKeys = [keyOf("pane:previous"), keyOf("pane:next")]
282		.filter(Boolean)
283		.join(" ");
284	if ((await read($, tabs)) !== tabKeys) {
285		await update($, tabs, () => tabKeys);
286	}
287	return key;
288};
289
290// Shown: close. Open behind another pane's tab: reopen it, since opening an
291// open id only retitles it while a new pane becomes the shown tab.
292const toggle = async ($: EngineInterface) => {
293	const pane = (await $.ui.panes()).find((p) => p.id === PANE);
294	if (pane !== undefined) {
295		await $.ui.close({ id: PANE });
296	}
297	if (!pane?.isShown) {
298		await $.ui.open({ id: PANE, title: PANE, columns: 44 });
299	}
300};
301
302type Span = [string, Record<string, unknown>?];
303
304// the summary mod's palette, so the two panes look alike
305const C = {
306	subtext: "#a6adc8",
307	overlay: "#7f849c",
308	surface1: "#45475a",
309	surface0: "#313244",
310	mauve: "#cba6f7",
311	lavender: "#b4befe",
312	blue: "#89b4fa",
313	green: "#a6e3a1",
314	yellow: "#f9e2af",
315	red: "#f38ba8",
316};
317
318const MARK: Record<string, [string, string]> = {
319	on: ["●", C.green],
320	off: ["○", C.yellow],
321	"설정 안 됨": ["·", C.overlay],
322};
323
324const STATUS: Record<string, string> = {
325	on: "success",
326	off: "warning",
327	"설정 안 됨": "inactive",
328};
329
330const countStyle = (n: number) =>
331	n === 0 ? { dimColor: true } : { color: "error", bold: true };
332
333const ROW = /^(\S+)(\s+)(on|off|설정 안 됨)(\s+)(\d+)(\s+)(.*)$/;
334
335const paint = (line: string): Span[] => {
336	const row = ROW.exec(line);
337	if (row) {
338		const [, id = "", s1 = "", st = "", s2 = "", n = "", s3 = "", desc = ""] =
339			row;
340		return [
341			[id, { color: "suggestion", bold: true }],
342			[s1],
343			[st, { color: STATUS[st] }],
344			[s2],
345			[n, countStyle(Number(n))],
346			[s3],
347			[desc],
348		];
349	}
350	if (/^id\s+상태/.test(line)) return [[line, { underline: true }]];
351	if (line.startsWith("lint 대기")) return [[line, { color: "warning" }]];
352	if (line.includes("알 수 없는")) return [[line, { color: "error" }]];
353	const ack = /^(.*이 세션 동안 )(on|off)$/.exec(line);
354	if (ack) {
355		const [, pre = "", st = ""] = ack;
356		return [[pre], [st, { color: STATUS[st], bold: true }]];
357	}
358	const use = /^(\/[\w-]+(?: \w+(?: <id>)?)?)( {2,}.*)$/.exec(line);
359	if (use) {
360		const [, cmd = "", rest = ""] = use;
361		return [[cmd, { color: "suggestion" }], [rest]];
362	}
363	return [[line, { dimColor: true }]];
364};
365
366const statusOf = async ($: EngineInterface, isSet: boolean) => {
367	const disabled = await read($, off);
368	const counts = await read($, blocked);
369	return RULES.map((r) => ({
370		id: r.id,
371		desc: r.desc,
372		status:
373			r.id === "banned-words" && !isSet
374				? "설정 안 됨"
375				: disabled.includes(r.id)
376					? "off"
377					: "on",
378		count: counts[r.id] ?? 0,
379	}));
380};
381
382const table = async ($: EngineInterface, isSet: boolean) => {
383	const files = await read($, unlinted);
384	const rows = [
385		["id", "상태", "차단", "설명"],
386		...(await statusOf($, isSet)).map((r) => [
387			r.id,
388			r.status,
389			String(r.count),
390			r.desc,
391		]),
392	];
393	const widths = [0, 1, 2].map((i) =>
394		Math.max(...rows.map((row) => width(row[i] ?? ""))),
395	);
396	return [
397		"",
398		...rows.map((row) =>
399			row
400				.map((cell, i) => (i < 3 ? pad(cell, widths[i] ?? 0) : cell))
401				.join("  "),
402		),
403		...(files.length > 0
404			? ["", `lint 대기 ${files.length}개: ${files.join(", ")}`]
405			: []),
406		"",
407		"/rule-guard off <id> · /rule-guard on <id> 로 이 세션 동안 끄고 켠다 · /rule-guard help",
408	].join("\n");
409};
410
411export const register: Register = (on, options) => {
412	const list = (v: unknown) =>
413		String(v ?? "")
414			.split(",")
415			.map((s) => s.trim())
416			.filter(Boolean);
417	const words = list(options.bannedWords);
418	const paths = list(options.bannedPaths);
419	const isSet = words.length > 0 && paths.length > 0;
420
421	on("session.start", async ($, e, next) => {
422		const key = await syncChord($);
423		await $.command.register({
424			name: "rule-guard",
425			description:
426				"rule-guard 룰 목록·상태·차단 횟수, 이 세션 on/off · help: 사용법",
427			argumentHint: "[off <id> | on <id> | help]",
428			immediate: true,
429		});
430		await $.command.register({
431			name: PANE_COMMAND,
432			description: `rule-guard 패널 열기/닫기${key === "" ? "" : ` (${key})`}`,
433			immediate: true,
434		});
435		if (e.isInteractive && !(await read($, autoOpened))) {
436			await update($, autoOpened, () => true);
437			await $.ui.open({ id: PANE, title: PANE, columns: 44 });
438		}
439		return next(e);
440	}).catch((_, e, next) => next(e));
441
442	// no text, so the shortcut leaves nothing in the transcript
443	on("command.run", { command: PANE_COMMAND }, async ($) => {
444		await syncChord($);
445		await toggle($);
446		return {};
447	});
448
449	on("command.run", { command: "rule-guard" }, async ($, e) => {
450		const [verb = "", id = ""] = e.args.trim().split(/\s+/);
451		const key = await syncChord($);
452		if (verb === "") {
453			return { text: await table($, isSet) };
454		}
455		if (verb === "help") {
456			return { text: `\n${usage(key, await read($, tabs))}` };
457		}
458		if (verb !== "on" && verb !== "off") {
459			return {
460				text: `알 수 없는 인자: ${e.args.trim()}\n\n${usage(key, await read($, tabs))}`,
461			};
462		}
463		if (!RULES.some((r) => r.id === id)) {
464			return {
465				text: `알 수 없는 룰 id: ${id || "(없음)"}\n사용 가능한 id: ${RULES.map((r) => r.id).join(", ")}`,
466			};
467		}
468		await update($, off, (ids) =>
469			verb === "off"
470				? [...ids.filter((x) => x !== id), id]
471				: ids.filter((x) => x !== id),
472		);
473		return { text: `${id}: 이 세션 동안 ${verb}` };
474	});
475
476	on(
477		"ui.render",
478		{ component: "CommandOutput", props: { command: "rule-guard" } },
479		async ($, e) => {
480			const { Box, Text } = $.ui.resolve(e);
481			return (
482				<Box flexDirection="row">
483					<Text dimColor>{"  ⎿  "}</Text>
484					<Box flexDirection="column">
485						{e.props.text.split("\n").map((line) => (
486							<Text>
487								{line === ""
488									? " "
489									: paint(line).map(([s, p]) => <Text {...p}>{s}</Text>)}
490							</Text>
491						))}
492					</Box>
493				</Box>
494			);
495		},
496	);
497
498	on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
499		const key = await read($, chord);
500		if (key === "") {
501			return next(e);
502		}
503		const tail = [e.props.tail, `${key} ${PANE}`].filter(Boolean).join(" · ");
504		return next({ ...e, props: { ...e.props, tail } });
505	});
506
507	on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
508		const { Box, Text } = $.ui.resolve(e);
509		const rules = await statusOf($, isSet);
510		const files = await read($, unlinted);
511		const key = await read($, chord);
512		const tabKeys = await read($, tabs);
513		const columns = Math.max(30, e.props.bodyColumns);
514		const idWidth = Math.max(...rules.map((r) => width(r.id)));
515		const count = (s: string) => rules.filter((r) => r.status === s).length;
516		const total = rules.reduce((n, r) => n + r.count, 0);
517		const keycap = (k: string) => (
518			<Text backgroundColor={C.surface0} color={C.lavender}>{` ${k} `}</Text>
519		);
520		return (
521			<Box flexDirection="column" width={columns}>
522				<Box paddingX={1} justifyContent="space-between">
523					<Text bold color={C.mauve}>
524						RULE-GUARD
525					</Text>
526					<Text color={C.subtext}>
527						{`${count("on")} on · ${count("off")} off · 차단 ${total}`}
528					</Text>
529				</Box>
530				<Box paddingX={1}>
531					<Text color={C.surface1}>{"━".repeat(columns - 2)}</Text>
532				</Box>
533				<Box
534					flexDirection="column"
535					borderStyle="round"
536					borderColor={C.surface1}
537					paddingX={1}
538				>
539					<Text bold color={C.lavender}>
540						rules
541					</Text>
542					{rules.map((r) => (
543						<Box key={r.id} flexDirection="column">
544							<Text wrap="truncate-end">
545								<Text color={MARK[r.status]?.[1]}>{MARK[r.status]?.[0]}</Text>{" "}
546								<Text bold color={C.blue}>
547									{pad(r.id, idWidth)}
548								</Text>
549								{"  "}
550								<Text color={MARK[r.status]?.[1]}>{pad(r.status, 10)}</Text>
551								{"  "}
552								<Text
553									bold={r.count > 0}
554									color={r.count > 0 ? C.red : C.overlay}
555								>
556									{String(r.count)}
557								</Text>
558							</Text>
559							<Box paddingLeft={2}>
560								<Text color={C.subtext}>{r.desc}</Text>
561							</Box>
562						</Box>
563					))}
564				</Box>
565				{files.length > 0 && (
566					<Box
567						flexDirection="column"
568						borderStyle="round"
569						borderColor={C.yellow}
570						paddingX={1}
571					>
572						<Text bold color={C.yellow}>
573							{`lint 대기 ${files.length}`}
574						</Text>
575						{files.map((f) => (
576							<Text color={C.subtext} wrap="truncate-start">
577								{f}
578							</Text>
579						))}
580					</Box>
581				)}
582				<Box paddingX={1} columnGap={2} flexWrap="wrap">
583					{key !== "" && (
584						<Text>
585							{keycap(key)}
586							<Text color={C.subtext}> close</Text>
587						</Text>
588					)}
589					{tabKeys !== "" && (
590						<Text>
591							{keycap(tabKeys)}
592							<Text color={C.subtext}> tabs</Text>
593						</Text>
594					)}
595					{keycap("/rule-guard off|on <id>")}
596				</Box>
597			</Box>
598		);
599	});
600
601	on("tool.call", async ($, e, next) => {
602		const tool = String(e.tool);
603		const cmd = e.tool === "Bash" ? e.command : "";
604		const code = shell(cmd);
605		const disabled = await read($, off);
606		const cwd = await $.session.cwd();
607		const call: Call = {
608			tool,
609			cmd,
610			code,
611			cwd,
612			unlinted: await read($, unlinted),
613			banned: disabled.includes("banned-words")
614				? undefined
615				: await bannedWord($, e, code, cwd, words, paths),
616		};
617		for (const rule of RULES) {
618			const why = disabled.includes(rule.id) ? undefined : rule.check(call);
619			if (why !== undefined) {
620				await update($, blocked, (n) => ({
621					...n,
622					[rule.id]: (n[rule.id] ?? 0) + 1,
623				}));
624				$.ui.toast(`🛡 ${rule.id}`);
625				return {
626					deny: `rule-guard ${rule.id}: ${why} 이 세션에서 이 룰을 끄려면 사용자에게 /rule-guard off ${rule.id} 를 요청하라.`,
627				};
628			}
629		}
630		const ran = await next(e);
631		if (ran.deny !== undefined) {
632			return ran;
633		}
634		if (LINT.test(code) && call.unlinted.length > 0) {
635			await update($, unlinted, () => []);
636		}
637		const file = EDIT_TOOLS.has(tool) && !ran.isError ? pathOf(e) : undefined;
638		if (
639			file !== undefined &&
640			LINTED.test(file) &&
641			!call.unlinted.includes(file) &&
642			(await isInCwd($, file, cwd))
643		) {
644			await update($, unlinted, (files) => [...files, file]);
645		}
646		return ran;
647	}).catch((_, e, next) =>
648		next.called
649			? next(e)
650			: {
651					deny: `rule-guard: 룰 검사가 실패해 막았다 (${next.error.message ?? next.error.kind}).`,
652				},
653	);
654};
655
types/index.d.ts 15 lines
1export type BlockCounts = Record<string, number>;
2
3declare module "claude-code" {
4	interface PluginState {
5		"rule-guard": {
6			off: string[];
7			blocked: BlockCounts;
8			pending: string[];
9			chord: string;
10			tabs: string;
11			autoOpened: boolean;
12		};
13	}
14}
15