===== .mcp.json ==================================== { "mcpServers": { "Sjis-tools": { "type": "stdio", "command": "python", "args": ["sjis-tools/server.py"] } } } ===== .claude/settings.json ==================================== { "hooks": { "PreToolUse": [ { "matcher": "Read|Edit|Write", "hooks": [ { "type": "command", "command": "python \"sjis-tools\\hook.py\"" } ] } ] } } ===== sjis-tools/hook.py ==================================== #!/usr/bin/env python3 """ PreToolUse hook for source file protection in Claude Code Rules: - Read (txt/c/cpp/h/hpp): DENY if SJIS detected - Edit (c/cpp/h/hpp): DENY - Write (c/cpp/h/hpp): DENY if file exists """ import json import sys from pathlib import Path READ_CHECK_EXTENSIONS = {'.txt', '.c', '.cpp', '.h', '.hpp'} EDIT_BLOCKED_EXTENSIONS = {'.c', '.cpp', '.h', '.hpp'} WRITE_BLOCKED_EXTENSIONS = {'.c', '.cpp', '.h', '.hpp'} def get_file_path(tool_input): return tool_input.get("file_path") or tool_input.get("path") or tool_input.get("filePath", "") def deny(reason): output = { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason } } print(json.dumps(output)) sys.exit(0) def is_likely_sjis(file_path): try: content = Path(file_path).read_bytes() try: content.decode('utf-8') return False except UnicodeDecodeError: pass try: content.decode('cp932') return True except UnicodeDecodeError: return False except Exception: return False def handle_read(file_path, path_obj): if path_obj.suffix.lower() not in READ_CHECK_EXTENSIONS: sys.exit(0) if not path_obj.exists(): sys.exit(0) if is_likely_sjis(file_path): deny( f"このファイルは Shift-JIS エンコードです: {path_obj.name}\n" f"\n" f"Read の代わりに sjis_read ツールを使用してください。\n" f" 例: sjis_read(file_path=\"{file_path}\")" ) sys.exit(0) def handle_edit(file_path, path_obj): if path_obj.suffix.lower() in EDIT_BLOCKED_EXTENSIONS: deny( f"Edit は {path_obj.suffix} ファイルへの使用が禁止されています(エンコード破損防止)。\n" f"\n" f"Edit の代わりに sjis_edit ツールを使用してください。\n" f" 例: sjis_edit(file_path=\"{file_path}\", old_string=\"...\", new_string=\"...\")" ) sys.exit(0) def handle_write(file_path, path_obj): if path_obj.suffix.lower() in WRITE_BLOCKED_EXTENSIONS: if path_obj.exists(): deny( f"既存の {path_obj.suffix} ファイルへの Write は禁止されています(エンコード破損防止)。\n" f"\n" f"Write の代わりに sjis_write ツールを使用してください。\n" f" 例: sjis_write(file_path=\"{file_path}\", content=\"...\")" ) sys.exit(0) try: input_data = json.load(sys.stdin) tool_name = input_data.get("tool_name", "").lower() tool_input = input_data.get("tool_input", {}) file_path = get_file_path(tool_input) if not file_path: sys.exit(0) path_obj = Path(file_path) try: parts = path_obj.resolve().parts except Exception: parts = path_obj.parts if '.claude' in parts: sys.exit(0) if "read" in tool_name: handle_read(file_path, path_obj) elif "edit" in tool_name: handle_edit(file_path, path_obj) elif "write" in tool_name: handle_write(file_path, path_obj) sys.exit(0) except Exception: sys.exit(0) ===== sjis-tools/server.py ==================================== #!/usr/bin/env python3 import json import os import re import sys import tempfile import time from pathlib import Path # Windows の stdout/stdin を UTF-8 に固定する(CP932 だと \ufffd を出力できずクラッシュする) sys.stdout.reconfigure(encoding="utf-8") sys.stdin.reconfigure(encoding="utf-8") # ────────────────────────────────────────────── # Tool implementations # ────────────────────────────────────────────── DEFAULT_READ_LIMIT = 2000 LINE_TRUNCATE_CHARS = 2000 # 出力肥大化防止用のデフォルト上限 DEFAULT_GLOB_LIMIT = 500 DEFAULT_GREP_FILE_LIMIT = 200 # files_with_matches / count の最大ファイル数 DEFAULT_GREP_CONTENT_LIMIT = 50 # content モードの最大ファイル数 DEFAULT_GREP_TOTAL_LINES = 2000 # content モードの合計出力行数上限 DEFAULT_TIMEOUT = 30.0 BINARY_CHECK_SIZE = 8192 # バイナリ判定で読むバイト数 def read_sjis(path: str, offset: int = 0, limit=None) -> str: _check_not_utf8(path) p = Path(path) text = p.read_text(encoding="cp932", errors="replace") all_lines = text.splitlines(keepends=True) total_lines = len(all_lines) effective_limit = limit if limit is not None else DEFAULT_READ_LIMIT window = all_lines[offset:offset + effective_limit] def fmt_line(i, line): # 長すぎる行を切り詰める(改行を除いた本文部分が対象) nl = "\n" if line.endswith("\n") else "" body = line.rstrip("\n") if len(body) > LINE_TRUNCATE_CHARS: body = body[:LINE_TRUNCATE_CHARS] + "... [truncated]" return f"{i + offset + 1:>6}\t{body}{nl}" result = "".join(fmt_line(i, ln) for i, ln in enumerate(window)) remaining = total_lines - offset - len(window) if remaining > 0 and limit is None: result += ( f"\n[File truncated: showing lines {offset + 1}–{offset + len(window)} of {total_lines}. " f"Use offset and limit parameters to read more.]" ) return result def _atomic_write(path: Path, content: str, encoding: str = "cp932") -> None: """一時ファイルに書き込んでからリネームすることで、書き込み失敗時に元ファイルを破壊しない。""" parent = path.parent fd, tmp_path = tempfile.mkstemp(dir=parent, suffix=".tmp") try: with os.fdopen(fd, "w", encoding=encoding) as f: f.write(content) # Windows では上書き先が既存の場合 os.rename が失敗するので os.replace を使う os.replace(tmp_path, path) except Exception: # 書き込みやリネームに失敗した場合、一時ファイルを掃除して例外を再送出 try: os.unlink(tmp_path) except OSError: pass raise def write_sjis(path: str, content: str) -> str: _check_not_utf8(path) # 既存ファイルがUTF-8なら上書き拒否 _atomic_write(Path(path), content) return f"Written: {path}" def edit_sjis(path: str, old_string: str, new_string: str, replace_all: bool = False) -> str: _check_not_utf8(path) p = Path(path) text = p.read_text(encoding="cp932", errors="replace") if old_string not in text: raise ValueError("old_string not found in file") if replace_all: new_text = text.replace(old_string, new_string) else: count = text.count(old_string) if count > 1: raise ValueError(f"old_string is not unique ({count} matches). Add more context or use replace_all=true.") new_text = text.replace(old_string, new_string, 1) _atomic_write(p, new_text) return f"Edited: {path}" def glob_files(pattern: str, path: str = ".", head_limit: int = 0, timeout: float = DEFAULT_TIMEOUT) -> str: base = Path(path) deadline = time.monotonic() + timeout collected = [] for p in base.glob(pattern): if time.monotonic() >= deadline: collected.sort(key=lambda x: x.stat().st_mtime, reverse=True) limit = head_limit if head_limit else DEFAULT_GLOB_LIMIT collected = collected[:limit] result = "\n".join(str(m) for m in collected) return result + f"\n\n[TIMEOUT: glob aborted after {timeout:.0f}s — {len(collected)} results shown (partial)]" collected.append(p) if not collected: return "(no matches)" collected.sort(key=lambda x: x.stat().st_mtime, reverse=True) effective_limit = head_limit if head_limit else DEFAULT_GLOB_LIMIT truncated = len(collected) > effective_limit collected = collected[:effective_limit] result = "\n".join(str(m) for m in collected) if truncated: result += f"\n\n[Output truncated: showing {effective_limit} of many matches. Use head_limit to adjust.]" return result # ファイルタイプ → glob パターンのマッピング(rg --type 互換) TYPE_GLOBS = { "c": ["*.c", "*.h"], "cpp": ["*.cpp", "*.cc", "*.cxx", "*.h", "*.hpp", "*.hxx"], "py": ["*.py", "*.pyi"], "js": ["*.js", "*.mjs", "*.cjs"], "ts": ["*.ts", "*.tsx", "*.mts"], "rust": ["*.rs"], "go": ["*.go"], "java": ["*.java"], "html": ["*.html", "*.htm"], "css": ["*.css", "*.scss", "*.sass"], "json": ["*.json"], "xml": ["*.xml"], "md": ["*.md", "*.markdown"], "txt": ["*.txt"], "sh": ["*.sh", "*.bash"], "bat": ["*.bat", "*.cmd"], "make": ["Makefile", "GNUmakefile", "*.mk", "*.mak"], } def _is_binary(path: Path) -> bool: """先頭数KBにヌルバイトがあればバイナリとみなす。""" try: with open(path, "rb") as f: chunk = f.read(BINARY_CHECK_SIZE) return b"\x00" in chunk except Exception: return True def _check_not_utf8(path: str) -> None: """ファイルが明らかにUTF-8である場合は ValueError を送出する。 対象: - UTF-8 BOM 付きファイル - UTF-8 strict デコード成功 かつ Shift-JIS strict デコード失敗のファイル ASCII のみのファイル(両方成功)は判断不能なので通す。 """ p = Path(path) if not p.exists() or not p.is_file(): return try: raw = p.read_bytes() except Exception: return if raw.startswith(b"\xef\xbb\xbf"): raise ValueError( f"[sjis-tools] ERROR: '{path}' は UTF-8 (BOM付き) ファイルです。" " sjis_* ツールではなく通常の Read / Write / Edit ツールを使用してください。" ) try: raw.decode("utf-8") except UnicodeDecodeError: return # UTF-8 として無効 → Shift-JIS ファイルの可能性が高い、通す try: raw.decode("cp932") return # UTF-8 かつ Shift-JIS 両方有効(ASCII範囲)→ 判断不能、通す except UnicodeDecodeError: pass raise ValueError( f"[sjis-tools] ERROR: '{path}' は UTF-8 ファイルです(Shift-JIS として無効なバイト列を含む)。" " sjis_* ツールではなく通常の Read / Write / Edit ツールを使用してください。" ) def _iter_candidates(base: Path, glob_pattern, file_type, deadline: float): """ファイル候補をイテレータとして返す。事前に全件リスト化しないことで巨大ディレクトリでのメモリ・時間浪費を防ぐ。""" if base.is_file(): yield base return if file_type and not glob_pattern: ext_pats = TYPE_GLOBS.get(file_type, []) if not ext_pats: ext_pats = ["*"] seen = set() for ext_pat in ext_pats: for p in base.glob(f"**/{ext_pat}"): if time.monotonic() >= deadline: return if p.is_file() and p not in seen: seen.add(p) yield p else: g = glob_pattern or "**/*" for p in base.glob(g): if time.monotonic() >= deadline: return if p.is_file(): yield p def grep_sjis( pattern: str, path: str = ".", glob_pattern=None, output_mode: str = "files_with_matches", context: int = 0, lines_before: int = 0, lines_after: int = 0, case_insensitive: bool = False, head_limit: int = 0, offset: int = 0, multiline: bool = False, file_type: str = None, timeout: float = DEFAULT_TIMEOUT, ) -> str: flags = re.IGNORECASE if case_insensitive else 0 if multiline: flags |= re.MULTILINE | re.DOTALL rx = re.compile(pattern, flags) before = max(context, lines_before) after = max(context, lines_after) base = Path(path) deadline = time.monotonic() + timeout results = [] file_matches = [] count_map = {} timed_out = False total_content_lines = 0 # output_mode に応じたデフォルト上限 if output_mode == "content": auto_limit = DEFAULT_GREP_CONTENT_LIMIT else: auto_limit = DEFAULT_GREP_FILE_LIMIT effective_limit = head_limit if head_limit else auto_limit hit_limit = False for fp in _iter_candidates(base, glob_pattern, file_type, deadline): if time.monotonic() >= deadline: timed_out = True break if _is_binary(fp): continue try: text = fp.read_text(encoding="cp932", errors="replace") except Exception: continue lines = text.splitlines() matched_indices = [i for i, ln in enumerate(lines) if rx.search(ln)] if not matched_indices: continue if output_mode == "files_with_matches": file_matches.append(str(fp)) if len(file_matches) >= offset + effective_limit: hit_limit = True break elif output_mode == "count": count_map[str(fp)] = len(matched_indices) if len(count_map) >= offset + effective_limit: hit_limit = True break else: # content shown = set() for idx in matched_indices: start = max(0, idx - before) end = min(len(lines) - 1, idx + after) for i in range(start, end + 1): shown.add(i) shown = sorted(shown) file_header = f"# {fp}" block_lines = [file_header] prev = None for i in shown: if prev is not None and i > prev + 1: block_lines.append("--") sep = ":" if rx.search(lines[i]) else "-" line_text = lines[i] if len(line_text) > LINE_TRUNCATE_CHARS: line_text = line_text[:LINE_TRUNCATE_CHARS] + "... [truncated]" block_lines.append(f"{i + 1:>6}{sep}{line_text}") prev = i results.append("\n".join(block_lines)) total_content_lines += len(block_lines) if len(results) >= offset + effective_limit or total_content_lines >= DEFAULT_GREP_TOTAL_LINES: hit_limit = True break notices = [] if timed_out: notices.append(f"[TIMEOUT: search aborted after {timeout:.0f}s — results are partial]") if hit_limit: notices.append(f"[Output truncated at limit. Use offset/head_limit to paginate.]") notice_str = "\n\n" + "\n".join(notices) if notices else "" if output_mode == "files_with_matches": out = file_matches if offset: out = out[offset:] out = out[:effective_limit] body = "\n".join(out) if out else "(no matches)" return body + notice_str elif output_mode == "count": items = list(count_map.items()) if offset: items = items[offset:] items = items[:effective_limit] body = "\n".join(f"{v}\t{k}" for k, v in items) if items else "(no matches)" return body + notice_str else: if offset: results = results[offset:] results = results[:effective_limit] body = "\n\n".join(results) if results else "(no matches)" return body + notice_str def detect_encoding(path: str) -> str: """ファイルのエンコーディングを推定する。外部ライブラリ不要。""" p = Path(path) raw = p.read_bytes() # BOM チェック if raw.startswith(b"\xef\xbb\xbf"): return "UTF-8 (with BOM)" if raw.startswith(b"\xff\xfe"): return "UTF-16 LE" if raw.startswith(b"\xfe\xff"): return "UTF-16 BE" # UTF-8 strict decode を試みる try: raw.decode("utf-8") return "UTF-8" except UnicodeDecodeError: pass # SJIS (CP932) strict decode を試みる try: raw.decode("cp932") return "Shift-JIS (CP932)" except UnicodeDecodeError: pass # EUC-JP を試みる try: raw.decode("euc_jp") return "EUC-JP" except UnicodeDecodeError: pass return "Unknown (binary or unrecognized encoding)" def convert_encoding(path: str, from_encoding: str, to_encoding: str, output_path=None) -> str: """ファイルのエンコーディングを変換する。output_path 省略時は上書き。""" ENC_ALIASES = { "sjis": "cp932", "s-jis": "cp932", "shift_jis": "cp932", "cp932": "cp932", "utf8": "utf-8", "utf-8": "utf-8", "eucjp": "euc_jp", "euc-jp": "euc_jp", "euc_jp": "euc_jp", } src_enc = ENC_ALIASES.get(from_encoding.lower(), from_encoding) dst_enc = ENC_ALIASES.get(to_encoding.lower(), to_encoding) src = Path(path) text = src.read_text(encoding=src_enc, errors="replace") dst = Path(output_path) if output_path else src _atomic_write(dst, text, encoding=dst_enc) return f"Converted: {path} ({from_encoding} -> {to_encoding}) -> {dst}" # ────────────────────────────────────────────── # Tool definitions # ────────────────────────────────────────────── TOOLS = [ { "name": "sjis_read", "description": "Read a Shift-JIS (CP932) encoded file. Returns content with line numbers.", "inputSchema": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute or relative path to the file"}, "offset": {"type": "integer", "description": "Line number to start reading from (0-indexed)", "default": 0}, "limit": {"type": "integer", "description": "Maximum number of lines to read (default: 2000). Provide offset+limit to page through large files."} }, "required": ["file_path"] } }, { "name": "sjis_write", "description": "Write content to a file encoded in Shift-JIS (CP932). Overwrites existing content.", "inputSchema": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute or relative path to the file"}, "content": {"type": "string", "description": "Content to write"} }, "required": ["file_path", "content"] } }, { "name": "sjis_edit", "description": "Exact string replacement in a Shift-JIS (CP932) encoded file. old_string must be unique in the file unless replace_all is true.", "inputSchema": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute or relative path to the file"}, "old_string": {"type": "string", "description": "The exact string to replace"}, "new_string": {"type": "string", "description": "The replacement string"}, "replace_all": {"type": "boolean", "description": "Replace all occurrences (default: false)", "default": False} }, "required": ["file_path", "old_string", "new_string"] } }, { "name": "sjis_glob", "description": "Find files matching a glob pattern. Returns matching paths sorted by modification time (newest first).", "inputSchema": { "type": "object", "properties": { "pattern": {"type": "string", "description": "Glob pattern, e.g. '**/*.txt' or 'src/**/*.dic'"}, "path": {"type": "string", "description": "Base directory to search in (default: current directory)", "default": "."}, "head_limit": {"type": "integer", "description": "Limit output to first N results", "default": 0}, "timeout": {"type": "number", "description": "Timeout in seconds (default: 30)", "default": 30} }, "required": ["pattern"] } }, { "name": "sjis_grep", "description": ( "Search file contents using a regex pattern, decoding files as Shift-JIS (CP932). " "output_mode: 'files_with_matches' (default) returns file paths; " "'content' returns matching lines with optional context; " "'count' returns match counts per file." ), "inputSchema": { "type": "object", "properties": { "pattern": {"type": "string", "description": "Regular expression pattern to search for"}, "path": {"type": "string", "description": "File or directory to search (default: current directory)", "default": "."}, "glob": {"type": "string", "description": "Glob pattern to filter files, e.g. '**/*.txt'"}, "type": {"type": "string", "description": "File type to search (e.g. cpp, py, js, rust). Common types: c, cpp, py, js, ts, rust, go, java, html, css, json, xml, md"}, "output_mode": { "type": "string", "enum": ["files_with_matches", "content", "count"], "description": "Output format (default: files_with_matches)", "default": "files_with_matches" }, "context": {"type": "integer", "description": "Lines to show before/after each match (requires output_mode: content)", "default": 0}, "-A": {"type": "integer", "description": "Number of lines to show after each match (requires output_mode: content)", "default": 0}, "-B": {"type": "integer", "description": "Number of lines to show before each match (requires output_mode: content)", "default": 0}, "case_insensitive": {"type": "boolean", "description": "Case-insensitive matching", "default": False}, "head_limit": {"type": "integer", "description": "Limit output to first N results", "default": 0}, "offset": {"type": "integer", "description": "Skip first N results before applying head_limit", "default": 0}, "multiline": {"type": "boolean", "description": "Enable multiline mode (. matches newlines)", "default": False}, "timeout": {"type": "number", "description": "Timeout in seconds. Search stops after this time and returns partial results (default: 30)", "default": 30} }, "required": ["pattern"] } }, { "name": "sjis_detect", "description": "Detect the character encoding of a file (UTF-8, Shift-JIS, EUC-JP, etc.) without external libraries.", "inputSchema": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Absolute or relative path to the file"} }, "required": ["file_path"] } }, { "name": "sjis_convert", "description": "Convert a file's character encoding (e.g. UTF-8 to Shift-JIS, or vice versa).", "inputSchema": { "type": "object", "properties": { "file_path": {"type": "string", "description": "Path to the source file"}, "from_encoding": {"type": "string", "description": "Source encoding: utf-8, sjis, euc-jp, etc."}, "to_encoding": {"type": "string", "description": "Target encoding: utf-8, sjis, euc-jp, etc."}, "output_path": {"type": "string", "description": "Output file path. Omit to overwrite the source file."} }, "required": ["file_path", "from_encoding", "to_encoding"] } } ] # ────────────────────────────────────────────── # JSON-RPC handler # ────────────────────────────────────────────── def handle(req: dict): method = req.get("method") id_ = req.get("id") if method == "initialize": return {"jsonrpc": "2.0", "id": id_, "result": { "protocolVersion": "2024-11-05", "capabilities": {"tools": {}}, "serverInfo": {"name": "Sjis-tools", "version": "1.7.0"} }} elif method == "tools/list": return {"jsonrpc": "2.0", "id": id_, "result": {"tools": TOOLS}} elif method == "tools/call": name = req["params"]["name"] args = req["params"].get("arguments", {}) try: if name == "sjis_read": result = read_sjis(args["file_path"], args.get("offset", 0), args.get("limit")) elif name == "sjis_write": result = write_sjis(args["file_path"], args["content"]) elif name == "sjis_edit": result = edit_sjis( args["file_path"], args["old_string"], args["new_string"], args.get("replace_all", False), ) elif name == "sjis_glob": result = glob_files(args["pattern"], args.get("path", "."), args.get("head_limit", 0), args.get("timeout", DEFAULT_TIMEOUT)) elif name == "sjis_grep": result = grep_sjis( args["pattern"], args.get("path", "."), args.get("glob"), args.get("output_mode", "files_with_matches"), args.get("context", 0), args.get("-B", 0), args.get("-A", 0), args.get("case_insensitive", False), args.get("head_limit", 0), args.get("offset", 0), args.get("multiline", False), args.get("type"), args.get("timeout", 30.0), ) elif name == "sjis_detect": result = detect_encoding(args["file_path"]) elif name == "sjis_convert": result = convert_encoding( args["file_path"], args["from_encoding"], args["to_encoding"], args.get("output_path"), ) else: raise ValueError(f"Unknown tool: {name}") return {"jsonrpc": "2.0", "id": id_, "result": { "content": [{"type": "text", "text": result}] }} except Exception as e: return {"jsonrpc": "2.0", "id": id_, "result": { "content": [{"type": "text", "text": str(e)}], "isError": True }} elif method in ("notifications/initialized", "notifications/cancelled"): return None else: return {"jsonrpc": "2.0", "id": id_, "error": {"code": -32601, "message": "Method not found"}} if __name__ == "__main__": for line in sys.stdin: line = line.strip() if not line: continue try: req = json.loads(line) except json.JSONDecodeError: continue resp = handle(req) if resp is not None: print(json.dumps(resp, ensure_ascii=False), flush=True)