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