From 6577bf2636d5979b1ce1bbcc3f0e7de6f41d6efd Mon Sep 17 00:00:00 2001 From: himkt Date: Fri, 22 May 2026 23:55:34 +0900 Subject: [PATCH 1/3] feat: add claude configs again --- .../claude/files/bin/english_review.py | 238 ++++++++ home/modules/claude/files/bin/status.py | 77 +++ .../modules/claude/files/bin/validate_bash.py | 562 ++++++++++++++++++ .../claude/files/rules/bash-command.md | 29 + .../claude/files/rules/git-workflow.md | 11 + home/modules/claude/files/rules/removal.md | 36 ++ .../claude/files/rules/tool-discovery.md | 6 + home/modules/claude/files/settings.json | 170 ++++++ .../files/skills/bilingual-explain/SKILL.md | 78 +++ .../files/skills/english-review/SKILL.md | 40 ++ .../files/skills/english-review/format.md | 85 +++ .../claude/files/skills/github-cli/SKILL.md | 54 ++ .../skills/nixos-boot-troubleshoot/SKILL.md | 137 +++++ .../files/skills/pathfinder-explain/SKILL.md | 38 ++ 14 files changed, 1561 insertions(+) create mode 100755 home/modules/claude/files/bin/english_review.py create mode 100755 home/modules/claude/files/bin/status.py create mode 100755 home/modules/claude/files/bin/validate_bash.py create mode 100644 home/modules/claude/files/rules/bash-command.md create mode 100644 home/modules/claude/files/rules/git-workflow.md create mode 100644 home/modules/claude/files/rules/removal.md create mode 100644 home/modules/claude/files/rules/tool-discovery.md create mode 100644 home/modules/claude/files/settings.json create mode 100644 home/modules/claude/files/skills/bilingual-explain/SKILL.md create mode 100644 home/modules/claude/files/skills/english-review/SKILL.md create mode 100644 home/modules/claude/files/skills/english-review/format.md create mode 100644 home/modules/claude/files/skills/github-cli/SKILL.md create mode 100644 home/modules/claude/files/skills/nixos-boot-troubleshoot/SKILL.md create mode 100644 home/modules/claude/files/skills/pathfinder-explain/SKILL.md diff --git a/home/modules/claude/files/bin/english_review.py b/home/modules/claude/files/bin/english_review.py new file mode 100755 index 00000000..e35e15e2 --- /dev/null +++ b/home/modules/claude/files/bin/english_review.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""English-review Stop hook. Samples sessions, scores newly-added user messages since the last watermark, and stores results in SQLite.""" + +import json +import os +import pathlib +import random +import re +import sqlite3 +import subprocess +import sys +import traceback +from datetime import datetime + +SAMPLE_RATE = 0.1 +MODEL = "claude-opus-4-7" +DB_DIR = pathlib.Path.home() / ".local" / "share" / "claude-code" / "english-review" +DB_PATH = DB_DIR / "reviews.db" +FORMAT_SPEC_PATH = pathlib.Path(__file__).resolve().parent.parent / "skills" / "english-review" / "format.md" +NO_ENGLISH_SENTINEL = "NO_ENGLISH_CONTENT" + +_FENCE_RE = re.compile(r"```.*?```", re.DOTALL) + +PROMPT_TEMPLATE = """\ +Review the user's English writing from a chat transcript below. \ +Ignore the assistant's replies; focus only on the user-authored text. + +Follow the output format defined in the spec below exactly. Do not add \ +preamble, commentary, or headers beyond what the spec allows. + +--- FORMAT SPEC BEGINS --- +{format_spec} +--- FORMAT SPEC ENDS --- + +Additional rule for this run: if the user text contains no English worth \ +reviewing (entirely in another language, or only trivial greetings), output \ +exactly this single line and nothing else: +{sentinel} + +--- USER TEXT BEGINS --- +{user_text} +--- USER TEXT ENDS --- +""" + +SYSTEM_PROMPT = ( + "You are a writing assistant that reviews English text. " + "You have no tools available. Output only what the user asks for, in the format they specify." +) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + last_user_message_uuid TEXT NOT NULL, + review_markdown TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS session_progress ( + session_id TEXT PRIMARY KEY, + last_scored_uuid TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS errors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + traceback TEXT NOT NULL, + created_at TEXT NOT NULL +); +""" + + +def open_db() -> sqlite3.Connection: + DB_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH, timeout=30) + conn.executescript(SCHEMA) + return conn + + +def get_last_scored_uuid(conn: sqlite3.Connection, session_id: str) -> str | None: + row = conn.execute( + "SELECT last_scored_uuid FROM session_progress WHERE session_id = ?", + (session_id,), + ).fetchone() + return row[0] if row else None + + +def extract_new_user_text( + transcript_path: pathlib.Path, + last_scored_uuid: str | None, +) -> tuple[str, str | None]: + """Return (joined_user_text, last_uuid_seen) for user entries after the watermark.""" + parts: list[str] = [] + last_uuid: str | None = None + skipping = last_scored_uuid is not None + with transcript_path.open() as f: + for line in f: + line = line.strip() + if not line: + continue + try: + rec = json.loads(line) + except json.JSONDecodeError: + continue + uuid = rec.get("uuid") + if skipping: + if uuid == last_scored_uuid: + skipping = False + continue + if rec.get("type") != "user": + continue + if rec.get("isSidechain"): + continue + msg = rec.get("message") or {} + content = msg.get("content") + chunk: list[str] = [] + if isinstance(content, str): + chunk.append(content) + elif isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text") + if isinstance(text, str): + chunk.append(text) + if not chunk: + continue + parts.append("\n".join(chunk)) + if uuid: + last_uuid = uuid + if skipping: + return "", None + joined = "\n\n".join(p for p in parts if p) + return _FENCE_RE.sub("[code omitted]", joined).strip(), last_uuid + + +def load_format_spec() -> str: + return FORMAT_SPEC_PATH.read_text(encoding="utf-8").strip() + + +def run_review(user_text: str, format_spec: str) -> str: + prompt = PROMPT_TEMPLATE.format( + sentinel=NO_ENGLISH_SENTINEL, + format_spec=format_spec, + user_text=user_text, + ) + env = {**os.environ, "ENGLISH_REVIEW_HOOK_IN_PROGRESS": "1"} + result = subprocess.run( + [ + "claude", "-p", prompt, + "--model", MODEL, + "--system-prompt", SYSTEM_PROMPT, + "--no-session-persistence", + "--tools", "", + "--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', + "--disable-slash-commands", + "--settings", '{"disableAllHooks": true}', + ], + env=env, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return "" + return result.stdout.strip() + + +def insert_review(conn: sqlite3.Connection, session_id: str, last_uuid: str, review: str) -> None: + now = datetime.now().astimezone().isoformat(timespec="seconds") + conn.execute( + "INSERT INTO reviews (session_id, last_user_message_uuid, review_markdown, created_at) VALUES (?, ?, ?, ?)", + (session_id, last_uuid, review, now), + ) + + +def upsert_progress(conn: sqlite3.Connection, session_id: str, last_uuid: str) -> None: + now = datetime.now().astimezone().isoformat(timespec="seconds") + conn.execute( + "INSERT INTO session_progress (session_id, last_scored_uuid, updated_at) VALUES (?, ?, ?) " + "ON CONFLICT(session_id) DO UPDATE SET last_scored_uuid = excluded.last_scored_uuid, updated_at = excluded.updated_at", + (session_id, last_uuid, now), + ) + + +def log_error(session_id: str | None) -> None: + try: + conn = open_db() + try: + now = datetime.now().astimezone().isoformat(timespec="seconds") + conn.execute( + "INSERT INTO errors (session_id, traceback, created_at) VALUES (?, ?, ?)", + (session_id, traceback.format_exc(), now), + ) + conn.commit() + finally: + conn.close() + except Exception: + pass # last-resort swallow; the hook must never break the parent session + + +def main() -> None: + session_id: str | None = None + try: + if os.environ.get("ENGLISH_REVIEW_HOOK_IN_PROGRESS"): + return # spawned by our own claude -p call; break the recursion + if random.random() >= SAMPLE_RATE: + return + data = json.load(sys.stdin) + if data.get("stop_hook_active"): + return + transcript_path = pathlib.Path(data["transcript_path"]) + session_id = data["session_id"] + + conn = open_db() + try: + last_scored_uuid = get_last_scored_uuid(conn, session_id) + user_text, last_uuid = extract_new_user_text(transcript_path, last_scored_uuid) + if not user_text or not last_uuid: + return + format_spec = load_format_spec() + if not format_spec: + return + review = run_review(user_text, format_spec) + if review and review != NO_ENGLISH_SENTINEL: + insert_review(conn, session_id, last_uuid, review) + # Advance the watermark whether or not we produced a review, + # so the same passages aren't re-scored on the next Stop. + upsert_progress(conn, session_id, last_uuid) + conn.commit() + finally: + conn.close() + except Exception: + log_error(session_id) + finally: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/home/modules/claude/files/bin/status.py b/home/modules/claude/files/bin/status.py new file mode 100755 index 00000000..8f717737 --- /dev/null +++ b/home/modules/claude/files/bin/status.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +import json +import sys + +RESET = "\x1b[0m" +DIM = "\x1b[2m" +BOLD = "\x1b[1m" +WHITE = "\x1b[38;2;255;255;255m" +GREEN = "\x1b[38;2;0;200;80m" +YELLOW = "\x1b[38;2;255;200;60m" +RED = "\x1b[38;2;255;0;60m" + +ICON_FOLDER = "\U0001F4C2" +ICON_DOLLAR = "$" + +RINGS = ["○", "◔", "◑", "◕", "●"] + + +def format_tokens(tokens: int) -> str: + if tokens >= 1_000_000: + v = tokens / 1_000_000 + return f"{v:.0f}M" if v.is_integer() else f"{v:.1f}M" + if tokens >= 1_000: + v = tokens / 1_000 + return f"{v:.0f}K" if v.is_integer() else f"{v:.1f}K" + return str(tokens) + + +def threshold_color(value: float, warn: float, crit: float) -> str: + if value < warn: + return GREEN + if value < crit: + return YELLOW + return RED + + +def ring(pct: float) -> str: + idx = min(int(pct / 25), 4) + return RINGS[idx] + + +def fmt_meter(pct: float, used: int, total: int) -> str: + p = round(pct) + color = threshold_color(pct, 50, 80) + size = f"{format_tokens(used)}/{format_tokens(total)}" + return f"{color}{ring(pct)} {size} ({p}%){RESET}" + + +def main() -> None: + data = json.loads(sys.stdin.read()) + model = data["model"]["display_name"] + cwd = data["workspace"]["current_dir"] + cost = data["cost"]["total_cost_usd"] + + line1 = [ + f"{BOLD}{WHITE}{model}{RESET}", + f"{ICON_FOLDER} {YELLOW}{cwd}{RESET}", + ] + cost_color = threshold_color(cost, 50, 100) + line2 = [f"{cost_color}$ {cost:.4f}{RESET}"] + + try: + tokens = sum(data["context_window"]["current_usage"].values()) + window_size = data["context_window"]["context_window_size"] + if window_size > 0: + pct = tokens / window_size * 100 + line2.append(fmt_meter(pct, tokens, window_size)) + except Exception: + pass # NOTE(himkt): usage metrics not available on startup. + + sep = f" {DIM}|{RESET} " + print(sep.join(line1)) + print(sep.join(line2)) + + +if __name__ == "__main__": + main() diff --git a/home/modules/claude/files/bin/validate_bash.py b/home/modules/claude/files/bin/validate_bash.py new file mode 100755 index 00000000..42a9c1ae --- /dev/null +++ b/home/modules/claude/files/bin/validate_bash.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 + +"""POSIX-flavored shell command parser. + +Public API: + parse(command_string: str) -> list[dict] + One dict per command. The input is split on '|', ';', '&&', '||'; + each piece becomes one segment. Each dict has keys: + command str + positionals list[str] + keywords dict[str, str|bool|list] + redirect list[dict] (operator + optional target) + expansions list[dict] (token + reason; empty when safe) + +Raises ValueError on malformed input or unsupported syntax ('<<', '<<<', +'&', subshells, etc.). + +Each token (command, positionals, keyword keys/values, redirect targets) +is scanned for shell-expansion vectors, in three layers: + 1. Multi-char introducers: $(...), $((...)), $[...], ${...}, $'...', + $"...", `...`, <(...), >(...). + 2. $VAR and special-variable expansion. + 3. Stray '$' (defense-in-depth catch-all). +Findings populate seg["expansions"] as [{"token": str, "reason": str}, ...]. + +Bare operators (<, >, >>, &&, ;) inside a token are NOT flagged at the +token level — they are detected structurally when unquoted (_tokenize, +_split_commands), and are inert as literal characters when quoted. + +CLI subcommands (stdin: {"tool_input": {"command": "..."}}): + parse - print parsed result as JSON + validate - exit 2 if the command has multiple segments, any redirect, + or any non-empty seg["expansions"] + test - run embedded unittest suite +""" + +import json +import re +import shlex +import sys + + +_DIGITS = frozenset("0123456789") + +_OOS_OPS = { + "<<": "heredoc/here-string is not supported", + "<<<": "heredoc/here-string is not supported", + "&": "background execution is not supported", +} + +_DIVIDERS = ("|", ";", "&&", "||") + +_REDIR_OPS_NO_TARGET = ("2>&1", "1>&2") + + +def _shlex_tokens(s): + lex = shlex.shlex(s, posix=True, punctuation_chars=True) + lex.whitespace_split = True + return list(lex) + + +def _try_digit_prefix(raw, i): + """Return (op, advance) if a digit at i starts a redirect, else (None, 0).""" + if i + 1 >= len(raw) or raw[i] not in _DIGITS: + return None, 0 + fd, nxt = raw[i], raw[i + 1] + if nxt in (">", ">>", "<"): + return fd + nxt, 2 + if nxt != ">&": + return None, 0 + if i + 2 >= len(raw) or raw[i + 2] not in _DIGITS: + raise ValueError(f"fd-dup '{fd}>&' is missing single-digit target fd") + op = f"{fd}>&{raw[i + 2]}" + if op not in _REDIR_OPS_NO_TARGET: + raise ValueError(f"unsupported fd-dup '{op}' (only 2>&1 and 1>&2 are allowed)") + return op, 3 + + +def _split_commands(raw): + segments = [[]] + last = None + for t in raw: + if t in _DIVIDERS: + if not segments[-1]: + raise ValueError(f"empty command before '{t}'") + segments.append([]) + last = t + else: + segments[-1].append(t) + if not segments[-1]: + raise ValueError(f"empty command after '{last}'") + return segments + + +def _tokenize(raw): + out = [] + i = 0 + while i < len(raw): + t = raw[i] + if msg := _OOS_OPS.get(t): + raise ValueError(msg) + op, advance = _try_digit_prefix(raw, i) + if op is not None: + out.append(("REDIR", op)) + i += advance + continue + if t == ">&": + raise ValueError("fd-dup requires explicit source fd; got '>&...'") + if t in (">", ">>", "<", "&>"): + out.append(("REDIR", t)) + elif t and all(c in "();<>|&" for c in t): + raise ValueError(f"unsupported operator: '{t}'") + else: + out.append(("WORD", t)) + i += 1 + return out + + +def _extract_redirects(tokens): + redirects = [] + words = [] + i = 0 + while i < len(tokens): + kind, text = tokens[i] + if kind == "WORD": + words.append(text) + i += 1 + elif text in _REDIR_OPS_NO_TARGET: + redirects.append({"operator": text}) + i += 1 + elif i + 1 < len(tokens) and tokens[i + 1][0] == "WORD": + redirects.append({"operator": text, "target": tokens[i + 1][1]}) + i += 2 + else: + raise ValueError(f"redirect operator '{text}' has no target") + return words, redirects + + +def _classify(words): + command = None + positionals = [] + keywords = {} + positional_only = False + i = 0 + while i < len(words): + w = words[i] + i += 1 + if positional_only: + positionals.append(w) + elif w == "--": + positional_only = True + elif w.startswith("--") and "=" in w: + key, _, value = w.partition("=") + if key == "--": + raise ValueError(f"empty key in '{w}'") + keywords.setdefault(key, []).append(value) + elif w.startswith("-") and w not in ("--", "-"): + if i < len(words) and not words[i].startswith("-"): + keywords.setdefault(w, []).append(words[i]) + i += 1 + else: + keywords.setdefault(w, []).append(True) + elif command is None: + command = w + else: + positionals.append(w) + if command is None: + raise ValueError("no command token in input") + return command, positionals, {k: v[0] if len(v) == 1 else v for k, v in keywords.items()} + + +# Layer 1: multi-char introducers that begin (or wholly form) a shell- +# expansion vector. Order matters — '$((' must precede '$(' so the longer +# prefix wins. +_EXPANSION_PATTERNS = ( + ("$((", "arithmetic expansion '$((...))'"), + ("$(", "command substitution '$(...)'"), + ("${", "variable expansion '${...}'"), + ("$[", "arithmetic expansion '$[...]'"), + ("$'", "ANSI-C quoting \"$'...'\""), + ('$"', "locale-aware string '$\"...\"'"), + ("`", "backtick command substitution"), + ("<(", "process substitution '<(...)'"), + (">(", "process substitution '>(...)'"), +) + +# Layer 2: $VAR, $_x, $1, $@, $*, $#, $?, $!, $$, $- — names, positional +# and special vars. ('-' must be last in the char class to avoid being +# parsed as a range.) +_VAR_RE = re.compile(r"\$[A-Za-z0-9_@*#?!$-]") + + +def _scan_injection(s): + """Return rejection reason if s embeds a shell-expansion vector, else None. + + Detection layers, in order: + 1. Multi-char introducers (_EXPANSION_PATTERNS). + 2. $VAR / special-variable expansion (_VAR_RE). + 3. Stray '$' fallback — defense-in-depth for any '$' that escaped + layers 1 and 2. + + Bare operators (<, >, >>, &&, ;) inside a token are NOT scanned here: + they are detected structurally by _tokenize and _split_commands when + unquoted, and are inert literal characters when quoted into a token + (bash does not re-parse them at runtime). + """ + for needle, msg in _EXPANSION_PATTERNS: + if needle in s: + return msg + if _VAR_RE.search(s): + return "variable expansion '$VAR'" + if "$" in s: + return "stray '$'" + return None + + +def _segment_words(seg): + """Yield every user-supplied word in a parsed segment. + + Both keyword keys and values are scanned: a payload like + `cmd --"$VAR"=foo` parses to key '--$VAR', which would re-evaluate + if reassembled into a shell command. + """ + yield seg["command"] + yield from seg["positionals"] + for k, v in seg["keywords"].items(): + yield k + for x in (v if isinstance(v, list) else [v]): + if isinstance(x, str): + yield x + for r in seg["redirect"]: + if "target" in r: + yield r["target"] + + +def _find_expansions(seg): + """Return [{token, reason}, ...] for every expansion in seg's words. + + Order follows _segment_words: command, positionals, keyword keys/values, + redirect targets. One entry per occurrence — duplicates are recorded + twice. Each reason is _scan_injection's first match within the token. + """ + return [{"token": w, "reason": reason} + for w in _segment_words(seg) + if (reason := _scan_injection(w))] + + +def _parse_segment(raw): + words, redirects = _extract_redirects(_tokenize(raw)) + command, positionals, keywords = _classify(words) + seg = {"command": command, "positionals": positionals, + "keywords": keywords, "redirect": redirects} + seg["expansions"] = _find_expansions(seg) + return seg + + +def parse(command_string): + if not isinstance(command_string, str): + raise ValueError(f"command must be a string, got {type(command_string).__name__}") + if not command_string.strip(): + raise ValueError("empty input") + + return [_parse_segment(seg) for seg in _split_commands(_shlex_tokens(command_string))] + + +def _check_safe(result): + """Return rejection reason string, or None if the command is safe.""" + if len(result) > 1: + return "multiple commands are not allowed" + for seg in result: + if seg["redirect"]: + return "redirects are not allowed" + if seg["expansions"]: + return seg["expansions"][0]["reason"] + return None + + +# Optional remediation hints appended to BLOCKED: messages, keyed by the +# reason string that _check_safe (or _scan_injection) returns. Add an entry +# here when a rejection has a clear, single-line "do this instead" suggestion. +_REASON_HINTS = { + "multiple commands are not allowed": + "Use separate Bash calls when chaining is needed.", +} + + +def _format_blocked_message(reason): + hint = _REASON_HINTS.get(reason) + return f"BLOCKED: {reason}. {hint}" if hint else f"BLOCKED: {reason}" + + +def _run_tests(): + import unittest + + class CommandParserTests(unittest.TestCase): + def assertSingle(self, src): + r = parse(src) + self.assertEqual(len(r), 1, f"{src!r} parsed to {len(r)} segments") + return r[0] + + def assertField(self, src, field, expected): + self.assertEqual(self.assertSingle(src)[field], expected) + + def test_quoting(self): + for src, pos in [ + ("echo 'a\\b\"c'", ["a\\b\"c"]), + ('echo "a\\"b\\\\c"', ['a"b\\c']), + ('echo "\\n"', ['\\n']), + ("echo foo\\ bar", ["foo bar"]), + ("echo a\\\\b", ["a\\b"]), + ("echo ''", [""]), + ('echo "a"\'b\'c', ["abc"]), + ]: + with self.subTest(src=src): + self.assertField(src, "positionals", pos) + + def test_whitespace(self): + expected = {"command": "cmd", "positionals": ["arg"], "keywords": {}, + "redirect": [], "expansions": []} + for src in ("cmd\narg", "cmd\rarg", "cmd\targ", " cmd arg "): + with self.subTest(src=repr(src)): + self.assertEqual(self.assertSingle(src), expected) + + def test_keywords(self): + for src, kw in [ + ("python3 -v", {"-v": True}), + ("cmd -c hello", {"-c": "hello"}), + ("cmd --name=foo", {"--name": "foo"}), + ('cmd --path="my dir"', {"--path": "my dir"}), + ("cmd --key=", {"--key": ""}), + ("cmd -I a -I b", {"-I": ["a", "b"]}), + ("cmd -v -v", {"-v": [True, True]}), + ("cmd -x 1 -x", {"-x": ["1", True]}), + ("cmd -- -v --foo bar", {}), # -- ends flag parsing + ]: + with self.subTest(src=src): + self.assertField(src, "keywords", kw) + + def test_redirects(self): + for src, redir in [ + ("cmd > out", [{"operator": ">", "target": "out"}]), + ("cmd >> out", [{"operator": ">>", "target": "out"}]), + ("cmd 1> out", [{"operator": "1>", "target": "out"}]), + ("cmd 2> err", [{"operator": "2>", "target": "err"}]), + ("cmd 2>> err", [{"operator": "2>>", "target": "err"}]), + ("cmd 0< in", [{"operator": "0<", "target": "in"}]), + ("cmd &> all", [{"operator": "&>", "target": "all"}]), + ("cmd 2>&1", [{"operator": "2>&1"}]), + ("cmd 1>&2", [{"operator": "1>&2"}]), + ]: + with self.subTest(src=src): + self.assertField(src, "redirect", redir) + + def test_dividers_split_segments(self): + # |, ;, &&, || all split into separate segments uniformly. + for src, n in [ + ("cmd", 1), + ("a | b", 2), + ("a ; b", 2), + ("a && b", 2), + ("a || b", 2), + ("a | b ; c && d || e", 5), + ('echo "a|b;c&&d"', 1), # quoted: stays in one token + ]: + with self.subTest(src=src): + self.assertEqual(len(parse(src)), n) + + def test_errors(self): + for src in [ + "", " ", + "cat 'foo", 'echo "foo', "echo foo\\", + "echo >", "echo > >", + "|", "| cmd", "cmd |", "a | | b", + "; cmd", "cmd ;", "&& cmd", "cmd ||", + "cmd &", "cat < out", "--name=foo", "cmd --=value", + "cmd >&1", "cmd 3>&1", "cmd 2>&", "cmd 2>&x", + "cmd <>file", "cmd ()", "cmd >>>file", "cmd >|file", + 123, None, [], {}, + ]: + with self.subTest(src=src): + with self.assertRaises(ValueError): + parse(src) + + def test_expansions(self): + # (input, [(token, reason), ...]) — covers detection across all + # word positions and all reason categories. Verified end-to-end + # via parse(). + cases = [ + # Safe. + ("echo hello", []), + ("cmd --foo=bar", []), + # Specific shell-expansion patterns. + ('echo "$(x)"', [("$(x)", "command substitution '$(...)'")]), + ('echo "${x}"', [("${x}", "variable expansion '${...}'")]), + ('echo "$((1+1))"', [("$((1+1))", "arithmetic expansion '$((...))'")]), + ('echo "$[1+1]"', [("$[1+1]", "arithmetic expansion '$[...]'")]), + ('echo "$\'x\'"', [("$'x'", "ANSI-C quoting \"$'...'\"")]), + ('echo "$\\"x\\""', [('$"x"', "locale-aware string '$\"...\"'")]), + ("echo '`id`'", [("`id`", "backtick command substitution")]), + ('echo "<(x)"', [("<(x)", "process substitution '<(...)'")]), + ('echo ">(x)"', [(">(x)", "process substitution '>(...)'")]), + # $VAR and special vars. + ('echo "$X"', [("$X", "variable expansion '$VAR'")]), + ('echo "$5"', [("$5", "variable expansion '$VAR'")]), + ('echo "$@"', [("$@", "variable expansion '$VAR'")]), + ('echo "$$"', [("$$", "variable expansion '$VAR'")]), + ('echo "$-"', [("$-", "variable expansion '$VAR'")]), + # Layer-3 catch-all: stray '$' that didn't form a recognized + # expansion pattern. + ("echo 'price $'", [("price $", "stray '$'")]), + # Offender at command position. + ('"$cmd" arg', [("$cmd", "variable expansion '$VAR'")]), + # Offender in keyword value. + ('cmd --opt="$x"', [("$x", "variable expansion '$VAR'")]), + # Offender in keyword key (injected via quoting). + ('cmd --"$VAR"=foo', [("--$VAR", "variable expansion '$VAR'")]), + # Offender in redirect target. + ('cmd > "$out"', [("$out", "variable expansion '$VAR'")]), + # Multi-list keyword: each list element scanned. + ('cmd -I a -I "$X"', [("$X", "variable expansion '$VAR'")]), + # Multiple offenders preserve discovery order. + ('echo "$X" "${Y}"', [ + ("$X", "variable expansion '$VAR'"), + ("${Y}", "variable expansion '${...}'"), + ]), + # Duplicate offender recorded twice (per occurrence). + ('echo "$X" "$X"', [ + ("$X", "variable expansion '$VAR'"), + ("$X", "variable expansion '$VAR'"), + ]), + # Single token with multiple patterns: substring beats regex. + ('echo "$VAR$(x)"', [("$VAR$(x)", "command substitution '$(...)'")]), + ] + for src, expected in cases: + with self.subTest(src=src): + actual = self.assertSingle(src)["expansions"] + self.assertEqual( + [(e["token"], e["reason"]) for e in actual], expected, + ) + + def test_quoted_operators_allowed(self): + """Operators surviving into a token are inert; allowed. + + Redirects (<, >, >>) and command separators (;, &&, ||) are + detected structurally when unquoted (see test_redirects, + test_dividers_split_segments). When they appear inside a quoted + token, bash treats them as literal characters at runtime, so + _scan_injection lets them pass. + """ + for src in [ + "echo 'a;b'", + "echo '
'", + "echo 'a&&b'", + "echo 'a>>b'", + "echo 'a>b'", + "echo 'a||b'", + # Motivating case: a jq comparison passed as a flag value. + "gh api repo --jq '.x > 0'", + "jq '[.[] | select(.n >= 5)]'", + ]: + with self.subTest(src=src): + self.assertField(src, "expansions", []) + + def test_unquoted_operators_still_rejected(self): + """Structural detection of unquoted operators is unaffected by + the token-level relaxation.""" + for src, reason in [ + ("cmd a > b", "redirects are not allowed"), + ("cmd a >> b", "redirects are not allowed"), + ("cmd a < b", "redirects are not allowed"), + ("cmd a 2> b", "redirects are not allowed"), + ("cmd a; cmd b", "multiple commands are not allowed"), + ("cmd a && cmd b", "multiple commands are not allowed"), + ("cmd a || cmd b", "multiple commands are not allowed"), + ("cmd a | cmd b", "multiple commands are not allowed"), + ]: + with self.subTest(src=src): + self.assertEqual(_check_safe(parse(src)), reason) + + def test_blocked_message(self): + """Reasons with a registered hint get an actionable suffix; others + fall back to bare 'BLOCKED: '.""" + self.assertEqual( + _format_blocked_message("multiple commands are not allowed"), + "BLOCKED: multiple commands are not allowed. " + "Use separate Bash calls when chaining is needed.", + ) + for reason in [ + "redirects are not allowed", + "command substitution '$(...)'", + "variable expansion '$VAR'", + "stray '$'", + ]: + with self.subTest(reason=reason): + self.assertEqual( + _format_blocked_message(reason), f"BLOCKED: {reason}", + ) + + def test_check_safe(self): + def seg(cmd="cmd", redir=()): + s = {"command": cmd, "positionals": [], "keywords": {}, + "redirect": list(redir)} + s["expansions"] = _find_expansions(s) + return s + + plain = seg() + with_redir = seg(redir=[{"operator": ">", "target": "out"}]) + with_expansion = seg(cmd="$x") + for result, expected in [ + ([plain], None), + ([plain, plain], "multiple commands are not allowed"), + ([with_redir], "redirects are not allowed"), + ([with_expansion], "variable expansion '$VAR'"), + # Multi-segment check fires before redirect/expansion checks. + ([with_redir, plain], "multiple commands are not allowed"), + # Validate end-to-end via parse(): real-world payloads. + (parse('echo "$(x)"'), "command substitution '$(...)'"), + (parse("cmd > out"), "redirects are not allowed"), + (parse("a; b"), "multiple commands are not allowed"), + ]: + with self.subTest(result=result): + self.assertEqual(_check_safe(result), expected) + + def test_segment_words_yield_order(self): + seg = { + "command": "cmd", + "positionals": ["a", "b"], + "keywords": {"-x": "v1", "-I": ["v2", "v3"], "-flag": True}, + "redirect": [{"operator": ">", "target": "out"}, + {"operator": "2>&1"}], + } + self.assertEqual( + list(_segment_words(seg)), + ["cmd", "a", "b", "-x", "v1", "-I", "v2", "v3", "-flag", "out"], + ) + + suite = unittest.TestLoader().loadTestsFromTestCase(CommandParserTests) + return unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() + + +if __name__ == "__main__": + import argparse + + ap = argparse.ArgumentParser(prog="parser.py") + ap.add_argument("subcommand", choices=("parse", "validate", "test")) + subcmd = ap.parse_args().subcommand + + if subcmd == "test": + sys.exit(0 if _run_tests() else 1) + + try: + result = parse(json.load(sys.stdin)["tool_input"]["command"]) + except (ValueError, KeyError, TypeError) as e: + print(f"parse error: {e}", file=sys.stderr) + sys.exit(2) + + if subcmd == "parse": + print(json.dumps(result)) + elif reason := _check_safe(result): + print(_format_blocked_message(reason), file=sys.stderr) + sys.exit(2) diff --git a/home/modules/claude/files/rules/bash-command.md b/home/modules/claude/files/rules/bash-command.md new file mode 100644 index 00000000..7230b15d --- /dev/null +++ b/home/modules/claude/files/rules/bash-command.md @@ -0,0 +1,29 @@ +# Bash Command + +Rules to ensure Bash commands match `permissions.allow` patterns in settings.json. +Using shell operators breaks pattern matching and triggers user approval prompts that block work. + +- NEVER use `&&` or `;` to chain commands. Each command must be a separate Bash tool call. Pipes (`|`) are allowed +- **ESPECIALLY NEVER `cd /path && command`** — this is the single most common violation. `cd` MUST be a separate Bash call. The working directory persists between Bash calls, so there is zero reason to chain +- NEVER use redirects (`>`, `>>`, `<`). Use the Write tool for file output +- NEVER use command substitution (`$()` or backticks) unless absolutely unavoidable +- When you need to work in a specific directory, run `cd /path/to/dir` as a separate Bash call FIRST, then run subsequent commands in separate Bash calls (the working directory persists between Bash calls) + +## Tool Substitution + +Use dedicated tools instead of the following Bash commands. These are denied in settings.json. + +| Prohibited Command | Use Instead | Notes | +|-------------------|-------------|-------| +| `find` | Glob | Pattern-based file search | +| `ls`, `tree` | Glob | For directory listing. Use Read to inspect a single directory when needed | +| `grep`, `rg` | Grep | Content search across files | +| `cat`, `head`, `tail` | Read | Read supports line offset and limit for partial reads | +| `sed`, `awk` | Edit | Exact string replacement in files | +| `mkdir`, `touch` | Write | Write auto-creates parent directories and can create empty files | +| `echo`, `printf` | Write (files) or direct text output (communication) | Never use shell output redirection | +| `curl`, `wget` | WebFetch (public URLs) or delegate to a Visual Reviewer / agent-browser teammate (local dev servers) | Never fetch HTTP yourself with curl/wget — for public URLs use the WebFetch tool, for local dev server diagnostics delegate to a teammate using the agent-browser CLI | + +Additional guidance: +- Use Explore agent (Agent tool with subagent_type=Explore) for broader codebase navigation when simple Glob/Grep is insufficient +- Exception for `mkdir`/`touch`: `.keep` files for directories needed before a non-Write tool writes to them diff --git a/home/modules/claude/files/rules/git-workflow.md b/home/modules/claude/files/rules/git-workflow.md new file mode 100644 index 00000000..e9876b59 --- /dev/null +++ b/home/modules/claude/files/rules/git-workflow.md @@ -0,0 +1,11 @@ +# Git Workflow + +Rules for consistent, clean git commit history across all projects. + +- NEVER use HEREDOC syntax for git commit. Always use simple `git commit -m "message"` format +- NEVER use `git -C `. It breaks auto-approval patterns in settings.json. The working directory is already the project root, so `-C` is unnecessary +- Commit messages MUST be a single line. Multi-line commit messages are strictly forbidden +- Write commit messages in English, concise and descriptive +- Use conventional commit prefixes: feat, fix, chore, refactor, docs +- NEVER use `git add -f` or `git add --force`. If a file is gitignored, it is gitignored for a reason. Do not force-stage it under any circumstances +- NEVER commit files from `design-docs/` or `researches/` directories. They are gitignored (globally) and must stay out of version control diff --git a/home/modules/claude/files/rules/removal.md b/home/modules/claude/files/rules/removal.md new file mode 100644 index 00000000..f699edca --- /dev/null +++ b/home/modules/claude/files/rules/removal.md @@ -0,0 +1,36 @@ +# Removal + +When removing a feature, option, file, or concept from a project, delete every corresponding mention from the repository in the same change. Do not leave historical deprecation notices behind. + +The git history and the design document (if any) are the historical record. Source code, user-facing docs, skills, and examples should describe only the current state. + +## Forbidden patterns + +- Callouts like `**X deprecated**: see design 0000NNN for the restoration plan` in user-facing docs +- Sentences like `pre-existing X rows are preserved for forensic visibility` in schema/data-model docs (keep the column behavior accurate; do not document the removed value) +- Comments like `# X was deprecated in design NNNN` in source code +- Pointers like `(See §13 for the restoration plan)` in README / SKILL.md / cli-options +- "Multi-runner support" / "Backend selector" / similar Features bullets that describe an option that no longer exists +- Flag rows in CLI tables documenting removed flags ("--coding-agent — deprecated") +- Test cases that assert the removed behavior is rejected (positive removal tests are fine; sentinel-style "deprecated → error" tests are not — once the flag/code is gone, the absence is the test) + +## What stays + +- The design doc that authorized the removal — it is the canonical historical record. +- Migration / restoration plans inside that design doc. +- Git commit messages and the git log. +- Tests that exercise the *current* behavior (e.g., a regression guard that the removed flag no longer parses, asserted via Click's default "no such option" error — that is testing the absence, not advertising the deletion). + +## Why + +Deprecation notices and "for history" pointers turn user-facing surfaces into archaeological digs. New contributors and users encounter mentions of features that do not exist and have to reason about why. The cleanup should be total: after the removal lands, the repository reads as if the removed feature never existed. The historical record lives where it belongs — in git and in the design doc that scoped the change. + +## Scope + +This rule applies whenever code, options, files, or features are removed. Common triggers: + +- Dropping a feature flag entirely +- Removing a CLI subcommand or option +- Deleting a code path / module / class / function +- Renaming with a hard-break (no aliases) — every mention of the old name goes +- Deprecating in v1 and removing in v2 — the v2 removal must complete the cleanup; v1 is the only place a deprecation note should ever live, and only briefly diff --git a/home/modules/claude/files/rules/tool-discovery.md b/home/modules/claude/files/rules/tool-discovery.md new file mode 100644 index 00000000..0e262b90 --- /dev/null +++ b/home/modules/claude/files/rules/tool-discovery.md @@ -0,0 +1,6 @@ +# Tool Discovery + +Beyond the dedicated-tool guidance in the main system prompt, also actively check: + +1. **Skills**: The system-reminder lists available skills. Use matching skills (e.g., `/pathfinder-explain`, `/github-cli`). +2. **MCP tools**: Check for `mcp__*` tools in your available tool list and prefer them over manual alternatives when relevant. diff --git a/home/modules/claude/files/settings.json b/home/modules/claude/files/settings.json new file mode 100644 index 00000000..41e98130 --- /dev/null +++ b/home/modules/claude/files/settings.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "respectGitignore": false, + "attribution": { + "commit": "" + }, + "permissions": { + "allow": [ + "Edit", + "Bash(find *)", + "Bash(gh api repos/*/*/issues/*/comments)", + "Bash(gh api repos/*/*/issues/*/comments *)", + "Bash(gh api repos/*/*/issues/*/comments/* *)", + "Bash(gh api repos/*/*/pulls/*/comments)", + "Bash(gh api repos/*/*/pulls/*/comments *)", + "Bash(gh api repos/*/*/pulls/*/comments/* *)", + "Bash(gh api repos/*/*/pulls/*/reviews)", + "Bash(gh api repos/*/*/pulls/*/reviews *)", + "Bash(gh api repos/*/*/pulls/*/reviews/* *)", + "Bash(gh api repos/*/*/pulls/*/requested_reviewers)", + "Bash(gh api repos/*/*/pulls/*/requested_reviewers *)", + "Bash(gh auth status)", + "Bash(gh issue list *)", + "Bash(gh issue view *)", + "Bash(gh pr checks *)", + "Bash(gh pr edit * --add-reviewer @copilot)", + "Bash(gh pr diff *)", + "Bash(gh pr list)", + "Bash(gh pr list *)", + "Bash(gh pr view *)", + "Bash(gh repo view *)", + "Bash(gh run list *)", + "Bash(gh run view *)", + "Bash(gh search *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git diff *)", + "Bash(git grep *)", + "Bash(git log *)", + "Bash(git ls-tree *)", + "Bash(git ls-files *)", + "Bash(git branch *)", + "Bash(git status)", + "Bash(grep *)", + "Bash(ls)", + "Bash(ls *)", + "Bash(printf *)", + "Bash(sleep)", + "Bash(sleep *)", + "Bash(stat *)", + "Bash(tree)", + "Bash(tree *)", + "Bash(uv run pytest *)", + "Bash(uv run ruff check *)", + "Bash(uv run ruff format *)", + "Bash(uv sync --frozen)", + "Bash(uv sync --frozen *)", + "Bash(wc *)", + "Bash(wget *)", + "NotebookEdit", + "Read(/settings.json)", + "Read(/skills/**)", + "Read(/plugins/**)", + "Read(//tmp/claude-code/**)", + "Read(~/.agent-browser/tmp)", + "WebFetch", + "WebSearch", + "Write", + "Write(//tmp/claude-code/**)", + "mcp__pathfinder-python__definition", + "Skill(github-cli)", + "Skill(nixos-boot-troubleshoot)", + "Skill(pathfinder-explain)", + "Skill(review-claude-config)", + "Skill(slidev:slidev)", + "Skill(update-config)", + "Bash(cafleet *)", + "Skill(cafleet:agent-team-monitoring)", + "Skill(cafleet:agent-team-supervision)", + "Skill(cafleet:base-dir)", + "Skill(cafleet:cafleet)", + "Skill(cafleet:create-figure)", + "Skill(cafleet:design-doc)", + "Skill(cafleet:design-doc-create)", + "Skill(cafleet:design-doc-execute)", + "Skill(cafleet:design-doc-interview)", + "Skill(cafleet:my-slidev)", + "Skill(cafleet:research-presentation)", + "Skill(cafleet:research-report)" + ], + "deny": [ + "Bash(awk *)", + "Bash(curl *)", + "Bash(echo *)", + "Bash(git -C *)", + "Bash(kill *)", + "Bash(mkdir *)", + "Bash(python *)", + "Bash(python3 *)", + "Bash(touch *)", + "Bash(xargs *)" + ], + "ask": [ + "Bash(git push)", + "Bash(git push *)", + "Bash(uv sync)", + "Bash(cafleet * member exec *)" + ], + "additionalDirectories": [ + "~/.claude" + ] + }, + "model": "opus[1m]", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "~/.claude/bin/validate_bash.py validate" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "~/.claude/bin/english_review.py" + } + ] + } + ] + }, + "statusLine": { + "type": "command", + "command": "~/.claude/bin/status.py" + }, + "enabledPlugins": { + "agent-browser@himkt-marketplace": true, + "slidev@himkt-marketplace": true, + "cafleet@cafleet": true + }, + "extraKnownMarketplaces": { + "himkt-marketplace": { + "source": { + "source": "github", + "repo": "himkt/himkt-marketplace" + }, + "autoUpdate": true + }, + "cafleet": { + "source": { + "source": "directory", + "repo": "himkt/cafleet", + "path": "/home/himkt/work/himkt/cafleet" + }, + "autoUpdate": true + } + }, + "language": "Use professional English", + "alwaysThinkingEnabled": true, + "effortLevel": "xhigh", + "autoMemoryEnabled": false, + "autoCompactEnabled": false, + "agentPushNotifEnabled": true, + "autoInstallIdeExtension": false +} diff --git a/home/modules/claude/files/skills/bilingual-explain/SKILL.md b/home/modules/claude/files/skills/bilingual-explain/SKILL.md new file mode 100644 index 00000000..e8daef96 --- /dev/null +++ b/home/modules/claude/files/skills/bilingual-explain/SKILL.md @@ -0,0 +1,78 @@ +--- +name: bilingual-explain +description: > + Format responses for non-native English speakers by using simplified English + and adding a Japanese explanation of the original message. Use when the user + invokes /bilingual-explain, or asks for easy English with Japanese notes. + Applies to the current response and all subsequent responses in the session + until the user cancels it. +--- + +# Bilingual Explain + +Make responses accessible to non-native English speakers by combining plain +English with a Japanese explanation of the same content. + +## When to apply + +- User invokes `/bilingual-explain`. +- User explicitly asks for simplified English plus Japanese notes. + +Once activated, keep using this format for every subsequent response in the +session until the user asks to stop. + +## Response format + +Every response must contain two sections, in this order. + +### Section 1: Plain English + +- Use short sentences. Aim for under 15 words per sentence. +- Prefer common, everyday words. Avoid idioms, phrasal verbs, and rare jargon. +- When a technical term is unavoidable, keep it in English and add a short + gloss in parentheses on first use. +- Use active voice. Be direct. +- Use bullet points for lists and steps when they improve clarity. + +### Section 2: Japanese explanation + +- Start this section with the heading `## 日本語での説明`. +- Explain the same content in natural Japanese. This is an explanation, not a + literal translation. Rephrase freely when that makes the meaning clearer. +- Keep technical terms (API names, code identifiers, library names) in their + original English form. Do not translate code or identifiers. +- Use polite form (です・ます調) by default. +- When the English section shows a command, file path, or code snippet, repeat + the same literal value in the Japanese section. Do not localize it. + +## Content rules + +- Both sections must cover the same information. Do not introduce new facts in + one that are missing from the other. +- Keep the two sections consistent. If you correct a mistake in one, correct + it in the other. +- Show code blocks, file paths, and command examples once per section, in + their original form. +- Length guidance from the main system prompt still applies. Count the two + sections together when judging length. Do not double the budget. + +## Example output + +User asks: "How do I list files changed in the last commit?" + +> ### Plain English +> +> Run `git show --name-only HEAD`. This shows the files changed in the most +> recent commit. Use `--stat` instead of `--name-only` if you also want line +> counts. +> +> ### 日本語での説明 +> +> `git show --name-only HEAD` を実行すると、直前のコミットで変更された +> ファイルの一覧が表示されます。変更行数も確認したい場合は `--name-only` の +> 代わりに `--stat` を使ってください。 + +## Stopping + +If the user says "stop", "cancel", "plain only", or any similar request, +return to the normal single-language response format. diff --git a/home/modules/claude/files/skills/english-review/SKILL.md b/home/modules/claude/files/skills/english-review/SKILL.md new file mode 100644 index 00000000..5fc31926 --- /dev/null +++ b/home/modules/claude/files/skills/english-review/SKILL.md @@ -0,0 +1,40 @@ +--- +name: english-review +description: > + Review the user's English writing and produce structured feedback with a + fluency score, passage-level revisions, and sophistication suggestions that + go beyond simple grammar fixes. Use when the user invokes /english-review, + asks to review or polish their English, or asks for feedback on chat + messages, commit messages, or prose they have written. This is also the + canonical format used by the Stop-hook English reviewer. +--- + +# English Review + +Help the user write more polished, natural, and sophisticated English. +Grammar correctness is the floor, not the ceiling — always look for stylistic +and idiomatic upgrades, not just errors. + +## How to run a review + +1. Identify the target text: + - If the user invokes `/english-review` with no argument, review the most + recent English text they have written in the current conversation. + - If they paste text or reference a file, review that instead. +2. Read `format.md` in this skill's directory. It defines the review criteria, + output shape, formatting rules, and a worked example. +3. Produce output that follows `format.md` exactly. Do not restate or + paraphrase the rules in your response — just emit the formatted output. + +## When the text has nothing to review + +If the target text contains no English content worth reviewing (entirely in +another language, or only trivial greetings), say so in one plain sentence +instead of forcing the template. + +## Notes for maintainers + +- `format.md` is the single source of truth for the output format. +- The Stop-hook reviewer at `bin/english_review.py` reads `format.md` at + runtime and injects it into its prompt, so updating `format.md` keeps both + consumers in sync automatically. diff --git a/home/modules/claude/files/skills/english-review/format.md b/home/modules/claude/files/skills/english-review/format.md new file mode 100644 index 00000000..6cc42d15 --- /dev/null +++ b/home/modules/claude/files/skills/english-review/format.md @@ -0,0 +1,85 @@ +# English Review Output Format + +Canonical specification for English-review output. Used by the +`english-review` skill (manual invocation) and by the Stop-hook reviewer at +`bin/english_review.py`. Keep this file as the single source of truth — if you +change the format, both consumers pick it up automatically. + +## Review criteria + +Evaluate the text against all of the following, in roughly this priority order: + +1. **Grammar and syntax** — article use, agreement, tense, pluralization, + prepositions, sentence structure. +2. **Naturalness and idiomatic phrasing** — would a fluent speaker say it this + way? Flag literal translations and non-native collocations. +3. **Word choice and precision** — is there a sharper, more specific, or more + appropriate word? Watch register (too casual / too stiff for the context). +4. **Clarity and concision** — trim filler, resolve ambiguous references, + prefer concrete over vague. +5. **Sophistication** — even when the text is already correct, suggest + upgrades: stronger verbs, varied sentence openers, parallel structure, + cohesion markers, tone calibration. This dimension is important and must + not be skipped when the text supports it. + +## Output shape + +``` +score: N / 10 + +### -> + +- +- + +### -> + +- +``` + +## Rules + +- The very first line is `score: N / 10`, where `N` is an integer 1–10: + - 1–3: hard to parse, many errors. + - 4–6: understandable but clearly non-native, frequent issues. + - 7–8: fluent with occasional awkwardness. + - 9–10: native-like, polished. +- One blank line after the score, then one `###` section per revision. +- In each heading, quote the **original passage verbatim** on the left, then + ` -> ` (space, ASCII hyphen, greater-than, space), then the improved version. +- Under each heading, give 1–3 short bullet reasons. One idea per bullet. No + nested lists, no paragraphs. +- Do **not** wrap passages in backticks, quotes, or code fences. +- Cover a mix of issues. When the text supports it, include at least one + section that is a **sophistication upgrade** (style, tone, variety), not + merely a grammar correction. +- No preamble, no headers above `###`, no closing remarks, no summary. +- Do not invent passages that are not in the source text. + +## Example + +Input (user-authored text): + +> i wanna improve english. this is points i want to work on. + +Output: + +``` +score: 5 / 10 + +### i wanna improve english -> I want to improve my English + +- `wanna` is too casual for written text; `want to` fits a broader register. +- `english` is a proper noun and must be capitalized. +- A possessive (`my`) is needed before `English` when referring to your own skill. + +### this is points -> these are the points + +- `points` is plural, so the verb and determiner must agree: `these are`. +- Add `the` to point to a specific, known set. + +### i want to work on -> I'd like to focus on + +- `I'd like to` softens the tone and sounds less mechanical in prose. +- `focus on` is a sharper, more idiomatic verb than `work on` for this context. +``` diff --git a/home/modules/claude/files/skills/github-cli/SKILL.md b/home/modules/claude/files/skills/github-cli/SKILL.md new file mode 100644 index 00000000..d92b777a --- /dev/null +++ b/home/modules/claude/files/skills/github-cli/SKILL.md @@ -0,0 +1,54 @@ +--- +name: github-cli +description: Use this skill when the user shares a GitHub URL (issue or pull request). Automatically fetch details using GitHub CLI (gh command). Triggered by URLs like github.com/himkt/.claude/issues/123 or github.com/himkt/.claude/pull/123. Do NOT run gh commands directly — always invoke this skill first. +--- + +# GitHub CLI Skill + +Fetch GitHub issues and pull requests with `gh` + `--json` + `--jq`. + +## Workflow + +1. Parse `{owner}`, `{repo}`, `{number}` from the URL. If only a number was given, run `gh repo view --json nameWithOwner -q .nameWithOwner`. +2. Always pass `--jq` to project fields and, when relevant, drop out-of-scope rows. Unfiltered comment/review/file payloads run to thousands of lines; a focused filter shrinks input 10–100×. + +Use `gh --jq` directly — do not pipe to external `jq`. The local Bash validator rejects multi-command pipes. + +Common `select(...)` predicates: + +- Time window: `select((.created_at | fromdateiso8601) > (now - 10800))` +- Author: `select(.user.login == "...")` +- State: `select(.state == "OPEN")` +- Unresolved threads: `select(.in_reply_to_id == null)` + +## Fetch commands + +```bash +# Issue +gh issue view {url} --json title,author,body,state,labels,comments --jq '...' + +# PR overview +gh pr view {url} --json title,author,body,state,reviewDecision,baseRefName,headRefName,comments --jq '...' + +# Diff (do NOT use gh api for diffs) +gh pr diff {url} # full +gh pr diff {url} --name-only # files only + +# Inline review comments +gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate --jq '[.[] | {user: .user.login, path, line, body, html_url, created_at}]' + +# Review summaries +gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate --jq '[.[] | {user: .user.login, state, body, submitted_at}]' + +# CI checks +gh pr checks {url} +``` + +## Creating a PR + +```bash +gh pr create --fill # auto-populate title+body from commits +gh pr edit {number} --add-reviewer @copilot # always request Copilot review immediately after +``` + +Use `--title` / `--body-file` only when the user explicitly asks for a custom title/body. diff --git a/home/modules/claude/files/skills/nixos-boot-troubleshoot/SKILL.md b/home/modules/claude/files/skills/nixos-boot-troubleshoot/SKILL.md new file mode 100644 index 00000000..b5df92c3 --- /dev/null +++ b/home/modules/claude/files/skills/nixos-boot-troubleshoot/SKILL.md @@ -0,0 +1,137 @@ +--- +name: nixos-boot-troubleshoot +description: > + Systematic investigation of NixOS boot failures (crash after reboot, kernel panic, + black screen after update, system won't boot). Guides through journal log analysis, + generation comparison, and fix proposal. Use when a user reports their NixOS system + fails to boot or crashes after reboot but works with nixos-rebuild switch. +--- + +# NixOS Boot Failure Troubleshooting + +Use this procedure when the user reports any of: +- NixOS crashes after reboot +- Boot failure +- Kernel panic +- Black screen after update +- NixOS won't boot +- System works with `nixos-rebuild switch` but crashes on reboot + +> **Key insight**: `nixos-rebuild switch` does **not** change the running kernel. A kernel regression is invisible after `switch` because the old kernel is still running. The regression only manifests after a full reboot when the new kernel loads. This makes kernel regressions particularly confusing to diagnose. + +## Procedure + +Follow these 7 steps in order. + +### Step 1: Gather Context + +Ask the user: +- When did the problem start? (after a specific `nixos-rebuild switch`? after an update?) +- What is the failure symptom? (black screen, kernel panic, hangs at boot, display manager crash) +- Can they currently access the system? (booted from an older generation? on the broken boot?) + +### Step 2: Check System Generations + +Run: + +```bash +sudo nixos-rebuild list-generations +``` + +Identify: +- The currently active generation +- Recent generations and their timestamps +- Which generation likely introduced the failure (correlate with the time the user reported the failure starting) +- Compare kernel versions between generations if visible in the output + +### Step 3: Analyze Previous Boot Logs + +Run these commands sequentially to inspect the failed boot: + +1. **Critical errors** — kernel crashes and critical service failures from the previous boot: + ```bash + journalctl -b -1 --priority=crit + ``` + +2. **Kernel log** — full kernel messages including driver stack traces: + ```bash + journalctl -b -1 -k + ``` + +3. **Failed services** — currently failed services (useful if on the broken boot): + ```bash + systemctl --failed + ``` + +Interpret the output using these patterns: + +| Log Pattern | Likely Cause | +|-------------|-------------| +| Stack trace mentioning `xe`, `i915`, `amdgpu`, `nvidia` | GPU driver regression | +| `systemd-cryptsetup` errors, LUKS-related messages | LUKS decryption failure | +| `gdm`, `sddm`, `lightdm` crash/restart loops | Display manager crash | +| Kernel oops/panic with module name | Kernel module regression | +| No journal entries for `-b -1` at all | initrd failure (system never reached journald) | + +### Step 4: Compare with Current Boot + +If the system is currently running (booted from an older generation or the issue is intermittent): + +1. Check the current kernel version: + ```bash + uname -r + ``` + +2. Inspect the current boot's kernel log: + ```bash + journalctl -b 0 -k + ``` + +Compare the `-b 0` output with the `-b -1` output from Step 3 to identify: +- Kernel version differences +- Driver modules loaded vs failed +- Hardware initialization differences + +### Step 5: Compare Generations + +When a working and broken generation are identified: + +1. **Compare system closure differences** — this shows package version differences between generations including kernel version changes: + ```bash + nix store diff-closures /nix/var/nix/profiles/system-WORKING-link /nix/var/nix/profiles/system-BROKEN-link + ``` + Replace `WORKING` and `BROKEN` with the actual generation numbers. + +2. **Discover the NixOS configuration** dynamically — do not hardcode paths: + - Use Glob to find `flake.nix` in the repo root + - Use Grep to search for `nixosConfigurations` to find the configuration entry point + - Trace imports to find files that set boot and display options + +3. **Check specific configuration areas** based on the cause identified in Steps 3-4. Use Grep to search across the repo for: + - For kernel issues: `boot.kernelPackages`, `boot.kernelModules`, `boot.extraModulePackages` + - For display manager issues: `services.displayManager`, `services.xserver.displayManager` + - For initrd issues: `boot.initrd` + +### Step 6: Identify Root Cause + +Synthesize findings from Steps 3-5 into a root cause diagnosis. Present to the user: +- **What failed**: the specific driver, service, or subsystem +- **Why it failed**: version change, configuration change, or upstream regression +- **Which generation / package change introduced the failure** + +### Step 7: Propose Fix Options + +Present fix options to the user **without auto-applying any changes**. Let the user decide which fix to apply. + +| Cause | Fix Options | +|-------|------------| +| **Kernel regression** | 1. Pin `boot.kernelPackages` to a specific version (e.g., `pkgs.linuxPackages_latest`, `pkgs.linuxPackages_6_12`). 2. Use an older nixpkgs input that has the working kernel version. | +| **GPU driver regression** | 1. Pin kernel (same as above). 2. Blacklist the problematic module via `boot.blacklistedKernelModules`. 3. Switch to a different driver (e.g., `i915` instead of `xe`). | +| **Display manager crash** | 1. Switch display manager (e.g., GDM to SDDM). 2. Check and fix display manager configuration. 3. Disable and re-enable the service. | +| **initrd / LUKS failure** | 1. Ensure required kernel modules are in `boot.initrd.availableKernelModules`. 2. Check LUKS device UUIDs match actual disk layout. | +| **Quick mitigation** | Boot from a known-good generation via the systemd-boot menu (select older generation at boot) to restore functionality while investigating. | + +For each fix, show the user: +- The specific Nix configuration change (code snippet) +- Where in their repo to apply it (discover the file dynamically using Grep) +- The command to apply: `sudo nixos-rebuild switch` diff --git a/home/modules/claude/files/skills/pathfinder-explain/SKILL.md b/home/modules/claude/files/skills/pathfinder-explain/SKILL.md new file mode 100644 index 00000000..f33e6313 --- /dev/null +++ b/home/modules/claude/files/skills/pathfinder-explain/SKILL.md @@ -0,0 +1,38 @@ +--- +name: pathfinder-explain +description: Explain the implementation of a symbol by tracing its definition using LSP. Use when user asks to explain, trace, or understand how a specific symbol (function, class, variable) is implemented. Takes symbol name as argument. Do NOT rely on grep and reading files alone — use this skill for accurate LSP-based tracing. +context: fork +--- + +# Pathfinder Explain + +Explain the implementation of a symbol by tracing its definition using LSP. + +Symbol to explain: $ARGUMENTS + +## Instructions + +1. Find where the symbol `$ARGUMENTS` is imported or defined in the current codebase using Grep +2. Use `mcp__pathfinder-{lang}__definition` tool to jump to its actual definition +3. Read the source code of the definition +4. If the definition references other symbols, recursively trace those definitions as needed +5. Provide a comprehensive explanation based on the actual source code + +## CRITICAL + +- ALWAYS use the LSP definition tool (`mcp__pathfinder-python__definition`, `mcp__pathfinder-typescript__definition`, etc.) to trace symbols +- Do NOT rely on general knowledge or guessing - read the actual implementation + +## Character Position Calculation + +When calling LSP definition tool, count the character position carefully: +1. The position is 0-indexed (first character is position 0) +2. Count each character one by one, including spaces +3. Do NOT guess or estimate - count precisely + +## If LSP Returns Empty Targets + +The position is likely wrong. Do NOT give up: +1. Re-read the line and count character position from 0 +2. Try position +1 or -1 if still failing +3. Do NOT fall back to other methods until you've tried at least 3 different positions From 391e9ce215711c1e8327d2806a50245496631f56 Mon Sep 17 00:00:00 2001 From: himkt Date: Fri, 22 May 2026 23:57:34 +0900 Subject: [PATCH 2/3] feat: add make recipe to deploy non-Nix managed files --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 0e9ff5c6..3e2e9ca8 100644 --- a/Makefile +++ b/Makefile @@ -41,5 +41,9 @@ simple-deploy: python3 bin/simple-deploy.py --dry-run python3 bin/simple-deploy.py +simple-deploy-nix-unsupported-only: + python3 bin/simple-deploy.py --nix-unsupported-only --dry-run + python3 bin/simple-deploy.py --nix-unsupported-only + simple-unlink: python3 bin/simple-deploy.py --unlink From edf21a2effb56eb72a55a6dc908d0520ae8578d8 Mon Sep 17 00:00:00 2001 From: himkt Date: Fri, 22 May 2026 23:58:06 +0900 Subject: [PATCH 3/3] feat: add option to deploy only non-Nix managed files --- bin/simple-deploy.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/bin/simple-deploy.py b/bin/simple-deploy.py index 81f5c19d..2e9e58bd 100755 --- a/bin/simple-deploy.py +++ b/bin/simple-deploy.py @@ -5,12 +5,12 @@ from pathlib import Path DEPLOY_MAP = [ - ("home/modules/claude-code/files", ".claude"), - ("home/modules/git/files", ".config/git"), - ("home/modules/mise/files", ".config/mise"), - ("home/modules/nvim/files", ".config/nvim"), - ("home/modules/tmux/files", ".config/tmux"), - ("home/modules/uv/files", ".config/uv"), + ("home/modules/claude/files", ".claude", True), + ("home/modules/git/files", ".config/git", False), + ("home/modules/mise/files", ".config/mise", False), + ("home/modules/nvim/files", ".config/nvim", False), + ("home/modules/tmux/files", ".config/tmux", False), + ("home/modules/uv/files", ".config/uv", False), ] @@ -21,9 +21,11 @@ def get_repo_root() -> Path: return root -def expand_map(repo_root: Path, home: Path) -> list[tuple[Path, Path]]: +def expand_map(repo_root: Path, home: Path, nix_unsupported_only: bool) -> list[tuple[Path, Path]]: pairs = [] - for source_dir, dest_dir in DEPLOY_MAP: + for source_dir, dest_dir, nix_supported in DEPLOY_MAP: + if nix_unsupported_only and not nix_supported: + continue src = repo_root / source_dir if not src.is_dir(): continue @@ -92,11 +94,12 @@ def main() -> None: parser = argparse.ArgumentParser(description="Deploy dotfiles via symlinks for non-Nix environments.") parser.add_argument("--unlink", action="store_true", help="Remove symlinks created by this script") parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making changes") + parser.add_argument("--nix-unsupported-only", action="store_true", help="Process only files that are not managed by Nix") args = parser.parse_args() repo_root = get_repo_root() home = Path.home() - pairs = expand_map(repo_root, home) + pairs = expand_map(repo_root, home, args.nix_unsupported_only) if args.unlink: unlink(pairs, home, args.dry_run)