feat: xiaohe 命令组 + settings.json 拆分 + workspace 同步引擎

- xiaohe:默认转发 default_agent(settings.json default_agent,兼容 myclaude 拼写),
  子命令 init(首用引导 workspace 路径+骨架+sync+启动器)/ sync(runtime→workspace
  哈希三态同步:新增/覆盖/本地改动跳过;git 检出拒绝)
- settings.py:~/.xiaohe/agent/settings.json 优先,config.json settings 段兜底
- get_workspace_root:env > settings > project_root/workspace > ~/workspace
- build_cli 增 offer_install:缺二进制时交互提示安装(claude/codex 有 install_cmd)
- 测试 +11(settings/sync 引擎三态/幂等/git 拒绝)
This commit is contained in:
Zhengshou Lai
2026-07-16 18:10:01 +08:00
parent 5c3f90e910
commit f3d5414bf9
8 changed files with 593 additions and 14 deletions
+48
View File
@@ -0,0 +1,48 @@
"""Tests for myagents.settings."""
import json
from pathlib import Path
import pytest
from myagents import settings as settings_mod
@pytest.fixture()
def fake_config_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
config_dir = tmp_path / ".xiaohe" / "agent"
monkeypatch.setattr(settings_mod, "CONFIG_DIR", config_dir)
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", config_dir / "settings.json")
monkeypatch.setattr(
settings_mod, "LEGACY_CONFIG_PATH", config_dir / "config.json"
)
return config_dir
class TestLoadSettings:
def test_empty_when_no_files(self, fake_config_dir: Path) -> None:
assert settings_mod.load_settings() == {}
def test_settings_json_wins_over_legacy(self, fake_config_dir: Path) -> None:
fake_config_dir.mkdir(parents=True)
(fake_config_dir / "config.json").write_text(
json.dumps({"keys": {}, "settings": {"a": "legacy", "b": "legacy"}})
)
(fake_config_dir / "settings.json").write_text(json.dumps({"b": "new"}))
assert settings_mod.load_settings() == {"a": "legacy", "b": "new"}
def test_corrupt_files_yield_empty(self, fake_config_dir: Path) -> None:
fake_config_dir.mkdir(parents=True)
(fake_config_dir / "settings.json").write_text("{not json")
assert settings_mod.load_settings() == {}
class TestSaveAndSet:
def test_set_setting_roundtrip(self, fake_config_dir: Path) -> None:
settings_mod.set_setting("workspace_root", "/tmp/ws")
assert settings_mod.get_setting("workspace_root") == "/tmp/ws"
on_disk = json.loads((fake_config_dir / "settings.json").read_text())
assert on_disk == {"workspace_root": "/tmp/ws"}
def test_get_setting_default(self, fake_config_dir: Path) -> None:
assert settings_mod.get_setting("missing", "dflt") == "dflt"
+87
View File
@@ -0,0 +1,87 @@
"""Tests for myagents.commands.sync_workspace."""
import json
from pathlib import Path
import click
import pytest
from myagents.commands import sync_workspace as sync_mod
@pytest.fixture()
def runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
rt = tmp_path / "runtime"
(rt / ".agents" / "skills" / "demo").mkdir(parents=True)
(rt / ".agents" / "skills" / "demo" / "SKILL.md").write_text("demo v1")
(rt / "agents.md").write_text("agent rules v1")
(rt / "VERSION").write_text("v0.0.0-test\n")
monkeypatch.setattr(sync_mod, "get_runtime_root", lambda: rt)
return rt
@pytest.fixture()
def workspace(tmp_path: Path) -> Path:
ws = tmp_path / "workspace"
ws.mkdir()
return ws
def _read_stamp(ws: Path) -> dict:
return json.loads((ws / sync_mod.STAMP_NAME).read_text())
class TestSyncWorkspace:
def test_new_files_copied_and_symlinks_made(
self, runtime: Path, workspace: Path
) -> None:
report = sync_mod.sync_workspace(workspace)
skill = workspace / ".agents" / "skills" / "demo" / "SKILL.md"
assert skill.read_text() == "demo v1"
assert (workspace / "agents.md").read_text() == "agent rules v1"
assert (workspace / "CLAUDE.md").is_symlink()
assert (workspace / ".claude").is_symlink()
assert report["added"] and not report["skipped"]
assert _read_stamp(workspace)["version"] == "v0.0.0-test"
def test_second_run_is_noop(self, runtime: Path, workspace: Path) -> None:
sync_mod.sync_workspace(workspace)
report = sync_mod.sync_workspace(workspace)
assert report == {"added": [], "updated": [], "skipped": [], "removed": []}
def test_upgrade_overwrites_untouched_file(
self, runtime: Path, workspace: Path
) -> None:
sync_mod.sync_workspace(workspace)
(runtime / "agents.md").write_text("agent rules v2")
report = sync_mod.sync_workspace(workspace)
assert (workspace / "agents.md").read_text() == "agent rules v2"
assert "agents.md" in report["updated"]
def test_user_modified_file_is_skipped(
self, runtime: Path, workspace: Path
) -> None:
sync_mod.sync_workspace(workspace)
(workspace / "agents.md").write_text("my local edits")
(runtime / "agents.md").write_text("agent rules v2")
report = sync_mod.sync_workspace(workspace)
assert (workspace / "agents.md").read_text() == "my local edits"
assert "agents.md" in report["skipped"]
def test_removed_from_runtime_is_removed_from_workspace(
self, runtime: Path, workspace: Path
) -> None:
sync_mod.sync_workspace(workspace)
(runtime / ".agents" / "skills" / "demo" / "SKILL.md").unlink()
report = sync_mod.sync_workspace(workspace)
assert not (workspace / ".agents" / "skills" / "demo" / "SKILL.md").exists()
assert report["removed"]
def test_git_checkout_refused_without_force(
self, runtime: Path, workspace: Path
) -> None:
(workspace / ".git").mkdir()
with pytest.raises(click.ClickException):
sync_mod.sync_workspace(workspace)
report = sync_mod.sync_workspace(workspace, force=True)
assert report["added"]