Files
zk-data-agent/src/config_runtime.py
T
Abdelrahman Abdallah 783145fe6a add mcp and online search
2026-04-05 02:35:49 +02:00

297 lines
9.5 KiB
Python

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] + '...'