from __future__ import annotations import hashlib import json from dataclasses import dataclass, field from pathlib import Path from typing import Any DEFAULT_CONFIG_DIR = Path('.claude') PROJECT_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.json' LOCAL_SETTINGS_PATH = DEFAULT_CONFIG_DIR / 'settings.local.json' LEGACY_CONFIG_PATHS = ( Path('.claw-config.json'), Path('.codex-config.json'), ) @dataclass(frozen=True) class ConfigSource: name: str path: str settings: dict[str, Any] @dataclass(frozen=True) class ConfigMutation: source_name: str key_path: str store_path: str before_sha256: str | None after_sha256: str before_preview: str | None after_preview: str effective_key_count: int @dataclass class ConfigRuntime: cwd: Path sources: tuple[ConfigSource, ...] = field(default_factory=tuple) @classmethod def from_workspace(cls, cwd: Path) -> 'ConfigRuntime': root = cwd.resolve() sources: list[ConfigSource] = [] for source_name, path in _discover_source_paths(root): payload = _load_json_object(path) if payload is None: continue sources.append( ConfigSource( name=source_name, path=str(path), settings=payload, ) ) return cls(cwd=root, sources=tuple(sources)) def has_config(self) -> bool: return bool(self.sources) def effective_settings(self) -> dict[str, Any]: merged: dict[str, Any] = {} for source in self.sources: merged = _deep_merge(merged, source.settings) return merged def list_keys( self, *, source: str | None = None, prefix: str | None = None, limit: int | None = None, ) -> tuple[str, ...]: payload = self._payload_for_source(source) flattened = sorted(_flatten_keys(payload)) if prefix: flattened = [key for key in flattened if key.startswith(prefix)] if limit is not None and limit >= 0: flattened = flattened[:limit] return tuple(flattened) def get_value( self, key_path: str, *, source: str | None = None, ) -> Any: payload = self._payload_for_source(source) return _get_nested_value(payload, key_path) def set_value( self, key_path: str, value: Any, *, source: str = 'local', ) -> ConfigMutation: resolved_source, path = self._resolve_writable_source(source) before_payload = _load_json_object(path) or {} before_text = path.read_text(encoding='utf-8') if path.exists() else None updated_payload = json.loads(json.dumps(before_payload)) _set_nested_value(updated_payload, key_path, value) after_text = json.dumps(updated_payload, ensure_ascii=True, indent=2, sort_keys=True) + '\n' path.parent.mkdir(parents=True, exist_ok=True) path.write_text(after_text, encoding='utf-8') refreshed = ConfigRuntime.from_workspace(self.cwd) self.sources = refreshed.sources return ConfigMutation( source_name=resolved_source, key_path=key_path, store_path=str(path), before_sha256=_sha256_or_none(before_text), after_sha256=_sha256(after_text), before_preview=_preview(before_text), after_preview=_preview(after_text), effective_key_count=len(_flatten_keys(self.effective_settings())), ) def render_summary(self) -> str: lines = [ f'Config sources: {len(self.sources)}', f'Effective keys: {len(_flatten_keys(self.effective_settings()))}', ] if not self.sources: lines.append('- No local config files discovered.') lines.append(f'- Project settings path: {(self.cwd / PROJECT_SETTINGS_PATH).resolve()}') lines.append(f'- Local settings path: {(self.cwd / LOCAL_SETTINGS_PATH).resolve()}') return '\n'.join(lines) for source in self.sources: lines.append( f'- {source.name}: {source.path} ({len(_flatten_keys(source.settings))} key(s))' ) return '\n'.join(lines) def render_keys( self, *, source: str | None = None, prefix: str | None = None, limit: int | None = None, ) -> str: keys = self.list_keys(source=source, prefix=prefix, limit=limit) if not keys: return '(no config keys)' return '\n'.join(keys) def render_value( self, key_path: str, *, source: str | None = None, ) -> str: return json.dumps( self.get_value(key_path, source=source), ensure_ascii=True, indent=2, sort_keys=True, ) def render_effective_config(self) -> str: return json.dumps(self.effective_settings(), ensure_ascii=True, indent=2, sort_keys=True) def render_source(self, source: str) -> str: resolved = self._find_source(source) if resolved is None: return f'# Config\n\nUnknown config source: {source}' return json.dumps(resolved.settings, ensure_ascii=True, indent=2, sort_keys=True) def _payload_for_source(self, source: str | None) -> dict[str, Any]: if source is None: return self.effective_settings() resolved = self._find_source(source) if resolved is None: raise KeyError(source) return resolved.settings def _find_source(self, source: str) -> ConfigSource | None: alias = _normalize_source_name(source) for config_source in self.sources: if _normalize_source_name(config_source.name) == alias: return config_source return None def _resolve_writable_source(self, source: str) -> tuple[str, Path]: alias = _normalize_source_name(source) if alias in {'project', 'project-settings', 'settings'}: return 'project', (self.cwd / PROJECT_SETTINGS_PATH).resolve() if alias in {'local', 'local-settings'}: return 'local', (self.cwd / LOCAL_SETTINGS_PATH).resolve() if alias in {'legacy', 'legacy-project'}: return 'legacy', (self.cwd / LEGACY_CONFIG_PATHS[0]).resolve() raise KeyError(source) def _discover_source_paths(cwd: Path) -> tuple[tuple[str, Path], ...]: candidates = [ ('legacy-claw', (cwd / LEGACY_CONFIG_PATHS[0]).resolve()), ('legacy-codex', (cwd / LEGACY_CONFIG_PATHS[1]).resolve()), ('project', (cwd / PROJECT_SETTINGS_PATH).resolve()), ('local', (cwd / LOCAL_SETTINGS_PATH).resolve()), ] discovered: list[tuple[str, Path]] = [] seen: set[Path] = set() for source_name, path in candidates: if path in seen or not path.exists() or not path.is_file(): continue seen.add(path) discovered.append((source_name, path)) return tuple(discovered) def _load_json_object(path: Path) -> dict[str, Any] | None: try: payload = json.loads(path.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): return None return dict(payload) if isinstance(payload, dict) else None def _flatten_keys(payload: dict[str, Any], *, prefix: str = '') -> tuple[str, ...]: keys: list[str] = [] for key, value in payload.items(): if not isinstance(key, str): continue combined = f'{prefix}.{key}' if prefix else key keys.append(combined) if isinstance(value, dict): keys.extend(_flatten_keys(value, prefix=combined)) return tuple(keys) def _get_nested_value(payload: dict[str, Any], key_path: str) -> Any: current: Any = payload for segment in _split_key_path(key_path): if not isinstance(current, dict) or segment not in current: raise KeyError(key_path) current = current[segment] return current def _set_nested_value(payload: dict[str, Any], key_path: str, value: Any) -> None: current: dict[str, Any] = payload segments = _split_key_path(key_path) for segment in segments[:-1]: child = current.get(segment) if not isinstance(child, dict): child = {} current[segment] = child current = child current[segments[-1]] = value def _split_key_path(key_path: str) -> tuple[str, ...]: segments = tuple( segment.strip() for segment in key_path.split('.') if segment.strip() ) if not segments: raise KeyError(key_path) return segments def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: merged = dict(base) for key, value in overlay.items(): if isinstance(value, dict) and isinstance(merged.get(key), dict): merged[key] = _deep_merge(merged[key], value) else: merged[key] = value return merged def _normalize_source_name(source: str) -> str: return source.strip().lower().replace('_', '-') def _sha256(text: str) -> str: return hashlib.sha256(text.encode('utf-8')).hexdigest() def _sha256_or_none(text: str | None) -> str | None: if text is None: return None return _sha256(text) def _preview(text: str | None, limit: int = 220) -> str | None: if text is None: return None stripped = text.strip() if len(stripped) <= limit: return stripped return stripped[:limit] + '...'