mirror of
https://github.com/daymade/claude-code-skills.git
synced 2026-09-14 16:15:21 +08:00
1ce40f2ff0
- new skill openclaw: unified CLI to audit/diff/copy/add-model/list/switch models across OpenClaw (龙虾/lobster) instance configs; DeepSeek model patches, default-model/alias management, config validation - SANITIZED before publishing to this public repo: real private instance nicknames replaced with generic placeholders (甲虾/乙虾) - marketplace: register openclaw (51->52 plugins), metadata 1.70->1.71 - docs: README / README.zh-CN / CLAUDE skill lists synced 72->73, badges 1.71.0 security_scan clean; doc-skill-list consistency check green. Claude-Session: https://claude.ai/code/session_016DDkUYyDUMtpZX9k1zAiYh Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
7.1 KiB
Python
219 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared helpers for the openclaw skill.
|
|
|
|
Single source of truth for OpenClaw config I/O, discovery, backup, and
|
|
common transforms. Imported by all subcommand scripts in this skill.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Default search locations for OpenClaw configs, in order of preference.
|
|
DEFAULT_CONFIG_PATHS = [
|
|
Path.home() / "workspace" / ".force" / "openclaw" / "openclaw.json",
|
|
Path.home() / ".kimi_openclaw" / "openclaw.json",
|
|
Path.home() / ".openclaw" / "openclaw.json",
|
|
]
|
|
|
|
# Locations for the lobster nickname → config path registry.
|
|
LOBSTER_REGISTRY_PATHS = [
|
|
Path.home() / "workspace" / ".force" / "openclaw" / "lobsters.json",
|
|
Path.home() / ".kimi_openclaw" / "lobsters.json",
|
|
Path.home() / ".openclaw" / "lobsters.json",
|
|
]
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Raised when a config file cannot be loaded or is structurally invalid."""
|
|
|
|
|
|
def find_default_config() -> Path | None:
|
|
"""Return the first existing default config path, or None."""
|
|
for p in DEFAULT_CONFIG_PATHS:
|
|
if p.exists():
|
|
return p
|
|
return None
|
|
|
|
|
|
def load_lobster_registry() -> dict[str, str]:
|
|
"""Load the lobster nickname → config path registry if it exists."""
|
|
for p in LOBSTER_REGISTRY_PATHS:
|
|
if p.exists():
|
|
try:
|
|
with p.open("r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
return {str(k): str(v) for k, v in data.items()}
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
return {}
|
|
|
|
|
|
def resolve_lobster_config_path(name_or_path: str | Path | None) -> Path | None:
|
|
"""Resolve a string that may be a path or a lobster nickname.
|
|
|
|
- If None, return None.
|
|
- If it points to an existing file, return that path.
|
|
- Otherwise look it up in the lobster registry.
|
|
"""
|
|
if name_or_path is None:
|
|
return None
|
|
p = Path(name_or_path)
|
|
if p.exists() and p.is_file():
|
|
return p
|
|
registry = load_lobster_registry()
|
|
if str(name_or_path) in registry:
|
|
return Path(registry[str(name_or_path)])
|
|
return None
|
|
|
|
|
|
def resolve_config_path(path: Path | str | None) -> Path:
|
|
"""Resolve an explicit config path, lobster nickname, or default locations."""
|
|
if path is not None:
|
|
resolved = resolve_lobster_config_path(path)
|
|
if resolved is not None:
|
|
return resolved
|
|
return Path(path)
|
|
default = find_default_config()
|
|
if default is None:
|
|
raise ConfigError(
|
|
"No openclaw.json found in default locations:\n"
|
|
+ "\n".join(f" - {p}" for p in DEFAULT_CONFIG_PATHS)
|
|
)
|
|
return default
|
|
|
|
|
|
def load_config(path: Path | str) -> dict[str, Any]:
|
|
"""Load openclaw.json and return it as a dict."""
|
|
p = Path(path)
|
|
if not p.exists():
|
|
raise ConfigError(f"Config file not found: {p}")
|
|
try:
|
|
with p.open("r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
raise ConfigError(f"Invalid JSON in {p}: {e}") from e
|
|
|
|
|
|
def save_config(path: Path | str, config: dict[str, Any]) -> None:
|
|
"""Save config back to disk as formatted JSON."""
|
|
p = Path(path)
|
|
with p.open("w", encoding="utf-8") as f:
|
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
|
|
def backup_config(path: Path | str, max_backups: int = 20) -> Path:
|
|
"""Copy config to a timestamped backup beside the original.
|
|
|
|
Keeps at most ``max_backups`` most recent backups to prevent unbounded
|
|
growth of the config-backups directory.
|
|
"""
|
|
p = Path(path)
|
|
backup_dir = p.parent / "config-backups"
|
|
backup_dir.mkdir(exist_ok=True)
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
backup_path = backup_dir / f"{p.stem}-{ts}{p.suffix}"
|
|
shutil.copy2(p, backup_path)
|
|
|
|
# Prune oldest backups beyond the retention limit.
|
|
existing = sorted(backup_dir.glob(f"{p.stem}-*{p.suffix}"), key=os.path.getmtime)
|
|
for old in existing[:-max_backups]:
|
|
old.unlink()
|
|
|
|
return backup_path
|
|
|
|
|
|
def get_providers(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
"""Return the models.providers dict, or {} if missing."""
|
|
return config.get("models", {}).get("providers", {}) or {}
|
|
|
|
|
|
def get_aliases(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
"""Return agents.defaults.models alias map, or {} if missing."""
|
|
return config.get("agents", {}).get("defaults", {}).get("models", {}) or {}
|
|
|
|
|
|
def get_default_model(config: dict[str, Any]) -> str | None:
|
|
"""Return the current default model reference string, or None."""
|
|
default = config.get("agents", {}).get("defaults", {}).get("model")
|
|
if isinstance(default, str):
|
|
return default
|
|
if isinstance(default, dict):
|
|
return default.get("primary") or default.get("default")
|
|
return None
|
|
|
|
|
|
def set_default_model(config: dict[str, Any], ref: str) -> None:
|
|
"""
|
|
Set the default model reference.
|
|
|
|
Preserves the existing structure: if the current default is an object with
|
|
a 'primary' key, update that key; otherwise store as a plain string.
|
|
"""
|
|
defaults = config.setdefault("agents", {}).setdefault("defaults", {})
|
|
current = defaults.get("model")
|
|
if isinstance(current, dict):
|
|
defaults["model"] = {**current, "primary": ref}
|
|
else:
|
|
defaults["model"] = ref
|
|
|
|
|
|
def provider_model_ids(provider: dict[str, Any]) -> list[str]:
|
|
"""Return the list of model ids for a provider."""
|
|
return [m.get("id") for m in provider.get("models", []) if m.get("id")]
|
|
|
|
|
|
def split_model_ref(ref: str) -> tuple[str, str]:
|
|
"""Split 'provider/model-id' into (provider, model-id)."""
|
|
if "/" in ref:
|
|
provider, model_id = ref.split("/", 1)
|
|
return provider, model_id
|
|
raise ValueError(f"Model reference must be 'provider/model-id', got: {ref}")
|
|
|
|
|
|
def restart_gateway() -> bool:
|
|
"""
|
|
Attempt to restart the OpenClaw gateway.
|
|
|
|
Tries, in order:
|
|
1. openclaw gateway restart
|
|
2. systemctl --user restart openclaw-gateway
|
|
3. systemctl restart openclaw-gateway
|
|
"""
|
|
commands = [
|
|
["openclaw", "gateway", "restart"],
|
|
["systemctl", "--user", "restart", "openclaw-gateway"],
|
|
["systemctl", "restart", "openclaw-gateway"],
|
|
]
|
|
for cmd in commands:
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=60, check=False
|
|
)
|
|
if result.returncode == 0:
|
|
print(f"Gateway restarted with: {' '.join(cmd)}")
|
|
return True
|
|
except FileNotFoundError:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
print(
|
|
"Warning: Could not restart gateway automatically. "
|
|
"Please run 'openclaw gateway restart' or restart the OpenClaw service manually.",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
|
|
|
|
def pretty_json(value: Any) -> str:
|
|
return json.dumps(value, indent=2, ensure_ascii=False)
|