diff --git a/README.md b/README.md index b2e2b8f..83f16aa 100644 --- a/README.md +++ b/README.md @@ -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** diff --git a/assets/README_ZH.md b/assets/README_ZH.md index 23c43d8..0c3f955 100644 --- a/assets/README_ZH.md +++ b/assets/README_ZH.md @@ -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 持续读取会话、产出技能、写回存储。加入群组只需正确配置共享存储,无需部署服务端。 **首次启动检查** diff --git a/skillclaw/claw_adapter.py b/skillclaw/claw_adapter.py index 601564a..2654208 100644 --- a/skillclaw/claw_adapter.py +++ b/skillclaw/claw_adapter.py @@ -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" # ------------------------------------------------------------------ # diff --git a/skillclaw/cli.py b/skillclaw/cli.py index b6f5166..7afda59 100644 --- a/skillclaw/cli.py +++ b/skillclaw/cli.py @@ -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: @@ -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 @@ -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 diff --git a/skillclaw/config.py b/skillclaw/config.py index 0c2db74..bf682d7 100644 --- a/skillclaw/config.py +++ b/skillclaw/config.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field +from .paths import has_instance_home, skillclaw_home + @dataclass class SkillClawConfig: @@ -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 @@ -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" @@ -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 = "" diff --git a/skillclaw/config_store.py b/skillclaw/config_store.py index bdf8570..ec158d3 100644 --- a/skillclaw/config_store.py +++ b/skillclaw/config_store.py @@ -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" @@ -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 = { @@ -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": @@ -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 diff --git a/skillclaw/launcher.py b/skillclaw/launcher.py index 4bc94c7..284a293 100644 --- a/skillclaw/launcher.py +++ b/skillclaw/launcher.py @@ -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 @@ -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 # @@ -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): diff --git a/skillclaw/paths.py b/skillclaw/paths.py new file mode 100644 index 0000000..eae355d --- /dev/null +++ b/skillclaw/paths.py @@ -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" diff --git a/skillclaw/runtime_state.py b/skillclaw/runtime_state.py index c30b379..8619d5f 100644 --- a/skillclaw/runtime_state.py +++ b/skillclaw/runtime_state.py @@ -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: diff --git a/skillclaw/setup_wizard.py b/skillclaw/setup_wizard.py index 26236e3..dbc1b9e 100644 --- a/skillclaw/setup_wizard.py +++ b/skillclaw/setup_wizard.py @@ -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": { @@ -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() @@ -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, diff --git a/tests/test_instance_home.py b/tests/test_instance_home.py new file mode 100644 index 0000000..be25a6c --- /dev/null +++ b/tests/test_instance_home.py @@ -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"