File size: 5,297 Bytes
bbc65cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 | """Config I/O for Claude Code Control Panel.
Reads and writes ~/.claude/settings.json with atomic operations (backup + tmp + rename).
Also handles hook configuration and coaching script parameters.
"""
import json
import os
import shutil
import tempfile
from datetime import datetime
from pathlib import Path
SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
COACH_SCRIPT = Path.home() / ".claude" / "hooks" / "coaching" / "coach-git-test.sh"
def read_settings() -> dict:
"""Read and parse ~/.claude/settings.json."""
if not SETTINGS_PATH.exists():
return {}
with SETTINGS_PATH.open() as f:
return json.load(f)
def write_settings(settings: dict) -> None:
"""Atomically write settings.json (backup + tmp + rename)."""
# Create backup
if SETTINGS_PATH.exists():
backup = SETTINGS_PATH.with_suffix(
f".json.backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
)
shutil.copy2(SETTINGS_PATH, backup)
# Write to temp file, then atomic rename
fd, tmp_path = tempfile.mkstemp(
dir=SETTINGS_PATH.parent, suffix=".tmp", prefix="settings-"
)
try:
with os.fdopen(fd, "w") as f:
json.dump(settings, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp_path, SETTINGS_PATH)
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
def update_setting(key_path: str, value) -> dict:
"""Update a nested key in settings.json using dot notation.
Example: update_setting("env.SLASH_COMMAND_TOOL_CHAR_BUDGET", "15000")
"""
settings = read_settings()
keys = key_path.split(".")
obj = settings
for key in keys[:-1]:
if key not in obj:
obj[key] = {}
obj = obj[key]
obj[keys[-1]] = value
write_settings(settings)
return settings
def read_hook_list() -> list[dict]:
"""Extract all hooks from settings.json as a flat list.
Returns list of dicts with:
event, group_index, matcher, command, timeout, async_, short_name
"""
settings = read_settings()
hooks_config = settings.get("hooks", {})
result = []
for event_name, hook_groups in hooks_config.items():
for group_idx, group in enumerate(hook_groups):
matcher = group.get("matcher", "")
for hook in group.get("hooks", []):
command = hook.get("command", "")
short_name = _derive_hook_name(command)
result.append({
"event": event_name,
"group_index": group_idx,
"matcher": matcher,
"command": command,
"timeout": hook.get("timeout", 10),
"async_": hook.get("async", False),
"short_name": short_name,
})
return result
def _derive_hook_name(command: str) -> str:
"""Extract a readable name from a hook command path."""
parts = command.strip().split()
for part in reversed(parts):
if "/" in part:
name = Path(part).stem
return name.replace("_", " ").replace("-", " ").title()
return command[:30]
def update_hook(event: str, group_index: int, field: str, value) -> dict:
"""Update a specific hook parameter in settings.json.
field can be: 'timeout', 'async'
"""
settings = read_settings()
hooks_config = settings.get("hooks", {})
if event not in hooks_config:
raise KeyError(f"Event '{event}' not found in hooks config")
if group_index >= len(hooks_config[event]):
raise IndexError(f"Group index {group_index} out of range")
group = hooks_config[event][group_index]
for hook in group.get("hooks", []):
if field == "timeout":
hook["timeout"] = value
elif field == "async":
if value:
hook["async"] = True
elif "async" in hook:
del hook["async"]
write_settings(settings)
return settings
def read_coaching_rate_limit() -> int:
"""Read RATE_LIMIT_SECONDS from coach-git-test.sh."""
if not COACH_SCRIPT.exists():
return 300
with COACH_SCRIPT.open() as f:
for line in f:
if line.strip().startswith("RATE_LIMIT_SECONDS="):
try:
return int(line.split("=", 1)[1].strip().split("#")[0].strip())
except ValueError:
return 300
return 300
def write_coaching_rate_limit(seconds: int) -> None:
"""Update RATE_LIMIT_SECONDS in coach-git-test.sh."""
if not COACH_SCRIPT.exists():
return
lines = COACH_SCRIPT.read_text().splitlines(keepends=True)
new_lines = []
for line in lines:
if line.strip().startswith("RATE_LIMIT_SECONDS="):
new_lines.append(f"RATE_LIMIT_SECONDS={seconds} # {seconds // 60} minutes\n")
else:
new_lines.append(line)
# Atomic write
fd, tmp = tempfile.mkstemp(dir=COACH_SCRIPT.parent, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
f.writelines(new_lines)
os.replace(tmp, COACH_SCRIPT)
except Exception:
if os.path.exists(tmp):
os.unlink(tmp)
raise
|