Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,10 @@ Streaming requires structured OpenAI tool calls; textual tool-tag repair is avai

## Quick Reference

For one proxy per worktree, set an absolute `SKILLCLAW_HOME` before every CLI invocation (for example, `export SKILLCLAW_HOME="$HOME/.skillclaw-instances/project-main"`). Then run setup and choose a distinct proxy port. A different home isolates config, PID/lock, default log, records/cache, skills, dashboard DB and adapter backups. `SKILLCLAW_CONFIG_FILE` still overrides just the config-file location; explicit data paths remain unchanged.

A new instance home defaults to `claw_type: none`: configure each client/profile manually for its instance port. Agent configuration files themselves are external to the instance home and are not automatically isolated. Use distinct absolute `skills.dir` paths when selecting worktree libraries, and distinct sharing groups/roots or credentials if evolution evidence must stay separate. `group_id` alone is not an access-control boundary. Keep the same home when running `status` or `stop`; changing only the config-file variable does not isolate runtime state.

**Operational model** — Every user has their own `skillclaw` process and `~/.skillclaw/config.yaml`. Each group maps to a `group_id`; one evolve server watches that namespace, turns sessions into skills, and writes them back. Joining a group only requires the correct sharing config on the client side.

**First-run checks**
Expand Down
4 changes: 4 additions & 0 deletions assets/README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,10 @@ OpenAI-compatible `/v1/chat/completions` 的 `stream: true` 现在会实时转

## 快速参考

每个 worktree 使用一个代理时,在每次 CLI 调用前设置绝对路径 `SKILLCLAW_HOME`,例如 `export SKILLCLAW_HOME="$HOME/.skillclaw-instances/project-main"`,再执行 setup 并选择独立端口。不同 home 会隔离配置、PID/锁、默认日志、记录/缓存、skills、dashboard 数据库和 adapter 备份。`SKILLCLAW_CONFIG_FILE` 仍优先指定配置文件;已有显式数据路径不会被改写。

新的实例 home 默认选择 `claw_type: none`,请手动让各客户端/profile 使用对应代理端口;agent 自身的配置文件不在实例目录里,不会自动隔离。选择 worktree 技能目录时使用不同的绝对 `skills.dir`;需要分离演化证据时,还应设置不同共享组/根目录或凭据,`group_id` 本身不是权限边界。`status`、`stop` 必须使用同一个 home,仅切换配置文件变量不足以隔离运行状态。

**运行关系** — 每个用户有自己的 `skillclaw` 进程和 `~/.skillclaw/config.yaml`;每个群组对应一个 `group_id`,由一个 Evolver 持续读取会话、产出技能、写回存储。加入群组只需正确配置共享存储,无需部署服务端。

**首次启动检查**
Expand Down
11 changes: 6 additions & 5 deletions skillclaw/claw_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,28 +37,29 @@
import yaml

from .hermes_config import load_hermes_mapping, resolve_hermes_config
from .paths import skillclaw_home

if TYPE_CHECKING:
from .config import SkillClawConfig

logger = logging.getLogger(__name__)
_LEGACY_SKILLCLAW_SKILLS_DIR = Path.home() / ".skillclaw" / "skills"
_LEGACY_SKILLCLAW_SKILLS_DIR = skillclaw_home() / "skills"
_HERMES_HOME = Path.home() / ".hermes"
_HERMES_SKILLS_DIR = _HERMES_HOME / "skills"
_HERMES_BACKUP_DIR = Path.home() / ".skillclaw" / "backups" / "hermes"
_HERMES_BACKUP_DIR = skillclaw_home() / "backups" / "hermes"
_CODEX_HOME = Path.home() / ".codex"
_CODEX_CONFIG_PATH = _CODEX_HOME / "config.toml"
_CODEX_PROFILE_CONFIG_PATH = _CODEX_HOME / "skillclaw.config.toml"
_CODEX_SKILLS_DIR = _CODEX_HOME / "skills"
_CODEX_BACKUP_DIR = Path.home() / ".skillclaw" / "backups" / "codex"
_CODEX_BACKUP_DIR = skillclaw_home() / "backups" / "codex"
_CLAUDE_HOME = Path.home() / ".claude"
_CLAUDE_SETTINGS_PATH = _CLAUDE_HOME / "settings.json"
_CLAUDE_SKILLS_DIR = _CLAUDE_HOME / "skills"
_CLAUDE_BACKUP_DIR = Path.home() / ".skillclaw" / "backups" / "claude"
_CLAUDE_BACKUP_DIR = skillclaw_home() / "backups" / "claude"
_OPENCODE_CONFIG_DIR = Path.home() / ".config" / "opencode"
_OPENCODE_CONFIG_PATH = _OPENCODE_CONFIG_DIR / "opencode.json"
_OPENCODE_SKILLS_DIR = _OPENCODE_CONFIG_DIR / "skills"
_OPENCODE_BACKUP_DIR = Path.home() / ".skillclaw" / "backups" / "opencode"
_OPENCODE_BACKUP_DIR = skillclaw_home() / "backups" / "opencode"


# ------------------------------------------------------------------ #
Expand Down
10 changes: 5 additions & 5 deletions skillclaw/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,12 @@
sys.exit(1)

from . import runtime_state
from .config_store import CONFIG_FILE, ConfigStore
from .config_store import ConfigStore
from .paths import skillclaw_home


def _default_daemon_log_path() -> Path:
return Path.home() / ".skillclaw" / "skillclaw.log"
return skillclaw_home() / "skillclaw.log"


def _effective_proxy_port(config_store: ConfigStore, override_port: int | None) -> int:
Expand Down Expand Up @@ -330,9 +331,8 @@ def stop():
"""Stop a running SkillClaw instance."""
import os
import signal
from pathlib import Path

pid_file = Path.home() / ".skillclaw" / "skillclaw.pid"
pid_file = runtime_state.pid_file_path()
if not pid_file.exists():
click.echo("SkillClaw is not running (no PID file found).")
return
Expand Down Expand Up @@ -386,7 +386,7 @@ def config_cmd(key_or_action: str, value: str | None):
if not cs.exists():
click.echo("No config file found. Run 'skillclaw setup' first.")
return
click.echo(f"Config file: {CONFIG_FILE}\n")
click.echo(f"Config file: {cs.config_file}\n")
click.echo(cs.describe())
return

Expand Down
12 changes: 9 additions & 3 deletions skillclaw/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from dataclasses import dataclass, field

from .paths import has_instance_home, skillclaw_home


@dataclass
class SkillClawConfig:
Expand All @@ -29,7 +31,9 @@ class SkillClawConfig:
# Skills #
# ------------------------------------------------------------------ #
use_skills: bool = False
skills_dir: str = "memory_data/skills"
skills_dir: str = field(
default_factory=lambda: str(skillclaw_home() / "skills") if has_instance_home() else "memory_data/skills"
)
skills_public_root: str = ""
retrieval_mode: str = "template"
# Embedding configuration: "local" for SentenceTransformer, "api" for OpenAI-compatible APIs
Expand All @@ -55,7 +59,9 @@ class SkillClawConfig:
served_model_name: str = "skillclaw-model"
proxy_api_key: str = ""
record_enabled: bool = True
record_dir: str = "records/"
record_dir: str = field(
default_factory=lambda: str(skillclaw_home() / "records") if has_instance_home() else "records/"
)

# Which CLI agent to auto-configure on startup.
claw_type: str = "openclaw"
Expand Down Expand Up @@ -145,7 +151,7 @@ class SkillClawConfig:
dashboard_enabled: bool = False
dashboard_host: str = "127.0.0.1"
dashboard_port: int = 3788
dashboard_db_path: str = "~/.skillclaw/dashboard.db"
dashboard_db_path: str = field(default_factory=lambda: str(skillclaw_home() / "dashboard.db"))
dashboard_sync_on_start: bool = True
dashboard_include_shared: bool = True
dashboard_evolve_server_url: str = ""
Expand Down
25 changes: 20 additions & 5 deletions skillclaw/config_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
from .chat_upstream import validate_fallbacks
from .config import SkillClawConfig
from .hermes_config import resolve_hermes_config
from .paths import has_instance_home, skillclaw_home

CONFIG_DIR = Path.home() / ".skillclaw"
CONFIG_DIR = skillclaw_home()
CONFIG_FILE = CONFIG_DIR / "config.yaml"
_DEFAULT_SKILLS_DIR = CONFIG_DIR / "skills"
_DEFAULT_HERMES_SKILLS_DIR = Path.home() / ".hermes" / "skills"
Expand All @@ -39,7 +40,7 @@ def _resolve_config_file(config_file: Path | None = None) -> Path:
if env_config_file:
return Path(env_config_file).expanduser()

return CONFIG_FILE
return skillclaw_home() / "config.yaml" if has_instance_home() else CONFIG_FILE


_DEFAULTS: dict = {
Expand Down Expand Up @@ -211,6 +212,8 @@ def _normalize_reload_interval(value: Any) -> int:
def default_skills_dir_for_claw(claw_type: str, hermes_config_path: str = "") -> Path:
"""Return the default local skills directory for the selected agent."""
normalized = str(claw_type or "").strip().lower()
if has_instance_home():
return skillclaw_home() / "skills"
if normalized == "hermes":
return resolve_hermes_config(hermes_config_path).parent / "skills"
if normalized == "codex":
Expand Down Expand Up @@ -275,16 +278,28 @@ def exists(self) -> bool:
return self.config_file.exists()

def load(self) -> dict:
defaults = _deep_merge({}, _DEFAULTS)
if has_instance_home():
root = skillclaw_home()
defaults = _deep_merge(
defaults,
{
"claw_type": "none",
"skills": {"dir": str(root / "skills")},
"record": {"dir": str(root / "records")},
"dashboard": {"db_path": str(root / "dashboard.db")},
},
)
if not self.config_file.exists():
return _deep_merge({}, _DEFAULTS)
return defaults
try:
import yaml

with open(self.config_file, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return _deep_merge(_DEFAULTS, data)
return _deep_merge(defaults, data)
except Exception:
return _deep_merge({}, _DEFAULTS)
return defaults

def save(self, data: dict):
import yaml
Expand Down
10 changes: 5 additions & 5 deletions skillclaw/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@
from typing import Optional

from .config_store import ConfigStore
from .runtime_state import pid_file_path

logger = logging.getLogger(__name__)

_PID_FILE = Path.home() / ".skillclaw" / "skillclaw.pid"


class SkillClawLauncher:
"""Start/stop SkillClaw services based on ConfigStore."""

def __init__(self, config_store: ConfigStore):
self.cs = config_store
self._pid_file = pid_file_path()
self._api_server = None
self._stop_event = threading.Event()
self._validation_worker = None
Expand Down Expand Up @@ -56,7 +56,7 @@ def stop(self):
self._api_server.stop()
except Exception:
pass
_PID_FILE.unlink(missing_ok=True)
self._pid_file.unlink(missing_ok=True)

# ------------------------------------------------------------------ #
# Core startup #
Expand Down Expand Up @@ -202,8 +202,8 @@ async def _run(self, cfg):
# ------------------------------------------------------------------ #

def _write_pid(self):
_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
_PID_FILE.write_text(str(os.getpid()))
self._pid_file.parent.mkdir(parents=True, exist_ok=True)
self._pid_file.write_text(str(os.getpid()))

def _setup_signal_handlers(self):
def _handler(signum, frame):
Expand Down
15 changes: 15 additions & 0 deletions skillclaw/paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Paths belonging to one SkillClaw instance."""

from __future__ import annotations

import os
from pathlib import Path


def has_instance_home() -> bool:
return bool(os.environ.get("SKILLCLAW_HOME", "").strip())


def skillclaw_home() -> Path:
value = os.environ.get("SKILLCLAW_HOME", "").strip()
return Path(value).expanduser().resolve() if value else Path.home() / ".skillclaw"
4 changes: 3 additions & 1 deletion skillclaw/runtime_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from pathlib import Path
from typing import Any

from .paths import skillclaw_home


def _state_dir() -> Path:
return Path.home() / ".skillclaw"
return skillclaw_home()


def pid_file_path() -> Path:
Expand Down
5 changes: 3 additions & 2 deletions skillclaw/setup_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .claw_adapter import CLAW_TYPES
from .config_store import CONFIG_DIR, ConfigStore, default_llm_api_mode_for_claw, resolve_skills_dir
from .hermes_config import import_hermes_upstream, resolve_hermes_config
from .paths import has_instance_home

_PROVIDER_PRESETS = {
"kimi": {
Expand Down Expand Up @@ -126,7 +127,7 @@ def run(self):
print("\n" + "=" * 60)
print(" SkillClaw Setup")
print("=" * 60)
print("\nThis wizard will create ~/.skillclaw/config.yaml")
print(f"\nThis wizard will create {ConfigStore().config_file}")
print("You can re-run 'skillclaw setup' at any time to reconfigure.\n")

cs = ConfigStore()
Expand All @@ -135,7 +136,7 @@ def run(self):
# ---- CLI agent (claw type) ----
print("\n--- CLI Agent ---")
print("SkillClaw will auto-configure the chosen agent to route its LLM\ncalls through the SkillClaw proxy.")
current_claw = existing.get("claw_type", "openclaw")
current_claw = existing.get("claw_type", "none" if has_instance_home() else "openclaw")
claw_type = _prompt_choice(
"CLI agent to configure",
CLAW_TYPES,
Expand Down
88 changes: 88 additions & 0 deletions tests/test_instance_home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import os
from pathlib import Path
from unittest.mock import Mock

from click.testing import CliRunner

from skillclaw import cli, runtime_state
from skillclaw.config_store import ConfigStore
from skillclaw.launcher import SkillClawLauncher


def test_instance_defaults_and_config_override(monkeypatch, tmp_path):
root = tmp_path / "instance"
monkeypatch.setenv("SKILLCLAW_HOME", str(root))
monkeypatch.delenv("SKILLCLAW_CONFIG_FILE", raising=False)
store = ConfigStore()
assert store.config_file == root / "config.yaml"
cfg = store.to_skillclaw_config()
assert cfg.claw_type == "none"
assert cfg.skills_dir == str(root / "skills")
assert cfg.record_dir == str(root / "records")
assert cfg.dashboard_db_path == str(root / "dashboard.db")
assert cli._default_daemon_log_path() == root / "skillclaw.log"
other = tmp_path / "explicit.yaml"
monkeypatch.setenv("SKILLCLAW_CONFIG_FILE", str(other))
assert ConfigStore().config_file == other
assert ConfigStore(tmp_path / "arg.yaml").config_file == tmp_path / "arg.yaml"
assert runtime_state.pid_file_path() == root / "skillclaw.pid"


def test_explicit_data_paths_remain_unchanged(monkeypatch, tmp_path):
monkeypatch.setenv("SKILLCLAW_HOME", str(tmp_path / "instance"))
store = ConfigStore(tmp_path / "config.yaml")
store.save(
{
"skills": {"dir": "/explicit/skills"},
"record": {"dir": "/explicit/records"},
"dashboard": {"db_path": "/explicit/dashboard.db"},
}
)
cfg = store.to_skillclaw_config()
assert cfg.skills_dir == "/explicit/skills"
assert cfg.record_dir == "/explicit/records"
assert cfg.dashboard_db_path == "/explicit/dashboard.db"


def test_two_instances_have_independent_locks_and_pid_cleanup(monkeypatch, tmp_path):
a, b = tmp_path / "a", tmp_path / "b"
monkeypatch.setenv("SKILLCLAW_HOME", str(a))
launcher_a = SkillClawLauncher(ConfigStore())
with runtime_state.daemon_start_lock():
launcher_a._write_pid()
assert runtime_state.read_pid() == os.getpid()
monkeypatch.setenv("SKILLCLAW_HOME", str(b))
launcher_b = SkillClawLauncher(ConfigStore())
with runtime_state.daemon_start_lock():
launcher_b._write_pid()
assert (a / "daemon_start.lock").exists()
assert (b / "daemon_start.lock").exists()
launcher_a.stop()
assert not (a / "skillclaw.pid").exists()
assert (b / "skillclaw.pid").exists()
launcher_b.stop()
assert not list(tmp_path.rglob("*.lock"))


def test_stop_only_signals_selected_instance_and_config_show_reports_real_path(monkeypatch, tmp_path):
a, b = tmp_path / "a", tmp_path / "b"
for root, pid in [(a, 12345), (b, 23456)]:
root.mkdir()
(root / "skillclaw.pid").write_text(str(pid))
monkeypatch.setenv("SKILLCLAW_HOME", str(a))
monkeypatch.delenv("SKILLCLAW_CONFIG_FILE", raising=False)
kill = Mock()
monkeypatch.setattr(os, "kill", kill)
runner = CliRunner()
assert runner.invoke(cli.skillclaw, ["stop"]).exit_code == 0
assert kill.call_args.args[0] == 12345
assert (b / "skillclaw.pid").read_text() == "23456"
store = ConfigStore()
store.save({"proxy": {"port": 31001}})
result = runner.invoke(cli.skillclaw, ["config", "show"])
assert result.exit_code == 0 and str(a / "config.yaml") in result.output


def test_no_instance_home_preserves_default_state_root(monkeypatch):
monkeypatch.delenv("SKILLCLAW_HOME", raising=False)
assert runtime_state.pid_file_path() == Path.home() / ".skillclaw" / "skillclaw.pid"
Loading