36 Commits
Author SHA1 Message Date
Zhengshou Lai bcd780b045 fix(npm): 官方源装 optional 平台包,拒绝 stub/错架构二进制
国内镜像常只同步主包,Windows 上 claude.exe/codex 会变成 1KB stub。
2026-08-20 10:40:43 +08:00
Zhengshou Lai 3d8887bba1 fix(ollama): os.getuid() 仅 POSIX —— Windows import 崩溃(xiaohe.exe smoke 失败根因) 2026-08-18 00:56:29 +08:00
Zhengshou Lai f14c39d7c0 feat(agents): 新增 mydsh 启动器 —— DeepSeek Harness CLI 包装
- launcher _BACKENDS 加 dsh:profile 透传、zstd 会话列表(session/title 或首条用户消息)、
  --resume 透传、DSH_HOME 环境变量与 expanduser
- entrypoints/cli/completion/打包/卸载全触点 + TestDsh 用例(裸跑/版本/透传/列表/缺 bin)
2026-08-17 19:54:13 +08:00
Zhengshou Lai f12cd5fcf1 feat(ollama): adapter 增强 — system 提升重构 + 保活终止 + 运行时 num_ctx 探测
- ollama_adapter: 拆出 is_adapter_endpoint/upstream_target/parse_num_ctx/
  runtime_num_ctx/listen_url;system 提升保留 cache_control;转发响应始终
  connection: close 终止(修 UI 卡 working);支持 https 上游与 600s 超时
- commands/ollama: 用 listen_url() 替代硬编码;mac launchd 已加载时重启而非
  跳过;sysd 启动前探测 active;新增 _wait_healthy
- tests: 覆盖 endpoint 判定、num_ctx 解析、转发保活与命令健康等待
2026-08-16 08:51:37 +08:00
Zhengshou Lai fa792674d3 myclaude -r: 清块后 \r 归位列 0,修复 Resuming 前空格式错位
raw 模式下 ONLCR 关闭,_draw_picker_block 每行以 \n 结尾时光标停在行末列而非列 0;\x1b[12A 上移、\x1b[12M 删除后列不变,rich console.print 的 Resuming 从 ~40 列打印,前面出现一长串空格。清块后补 \r 归位。
2026-08-16 00:06:44 +08:00
Zhengshou Lai 4a37d8d218 chore(version): bump 0.2.1 (ollama adapter rides xiaohe train) 2026-08-15 23:55:25 +08:00
Zhengshou Lai 0b0de97363 feat(ollama): Anthropic→Ollama 归一化 adapter + launchd/systemd 常驻管理 2026-08-15 23:31:39 +08:00
Zhengshou Lai 75a72a8b24 myclaude -r: 修快速连按方向键被退出 + 隐藏光标 + 退出删除行不留空行
- _read_key 改按需读 CSI 序列(方向键读到终止符即返回),连按 \x1b[B 不再被吞进同一序列误判为 ESC 退出
- picker 全程隐藏光标(\x1b[?25l),退出恢复(\x1b[?25h),末尾不再突兀闪现光标
- 退出改用 Delete Line(\x1b[{n}M)删除块区域,替代逐行清空,不再留下 N 个空行
- tests: 新增快速连按方向键回归测试
2026-08-15 22:38:15 +08:00
Zhengshou Lai c342c4e94a myclaude -r: 固定块 ANSI 重绘修复对齐与滚动冲突 + cli 测试 mock 修复
- picker 弃用 Live 整屏接管,改固定 12 行块增量重绘(\x1b[{n}A 回到块顶逐行覆盖),scrollback 不受影响
- 每行单行渲染,title 列定宽 43 + CJK 感知截断,布局按 80 列设计
- tests: 新增 TestPickerBlock;test_cli which mock 改 side_effect 只命中后端二进制,避免 _npm_global_bin 多跑 subprocess.run;裸 --resume 测试更新为新 picker 语义
2026-08-15 22:25:39 +08:00
Zhengshou Lai 969fc9ac27 myclaude -r: 交互式 resume picker(方向键/翻页/即时过滤) 2026-08-15 15:44:33 +08:00
Zhengshou Lai 88170bdf57 myclaude -r: unify resume picker with xiaohe client sessions
Bare -r/--resume now opens a numbered picker merging jsonl (source of
truth) with the xiaohe hosted-session index, deduped by cli_session_id
and sorted by real content activity (not jsonl mtime, which the xiaohe
projection re-touches). -l gains a Src column (xiaohe/cli); -r <id> and
-c stay passthrough. Adds read-only xiaohe_sessions data layer + tests.
2026-08-15 13:42:53 +08:00
Zhengshou Lai d6a0f455b0 Forward xiaohe upgrade --beta so the wrapper matches the agent CLI. 2026-08-14 16:26:39 +08:00
Zhengshou Lai c2d8e1c014 Refuse make install from GHA runner worktrees.
Keep host ~/.local/bin myagents links from pointing at ephemeral _work checkouts.
2026-08-11 22:16:56 +08:00
Zhengshou Lai 3d40e27a93 Remove MetaBot coupling from uninstall and workspace init.
Keep silent cleanup of legacy metabot stubs; drop METABOT_HOME symlink.
2026-08-11 17:23:23 +08:00
Zhengshou Lai 18154020a3 Prefer xiaohe ensure-agent hints and support non-interactive yes installs.
Install scripts and product CLI can install the default backend without a picker,
and skip messages point at xiaohe when it is on PATH.
2026-08-10 07:46:55 +08:00
Zhengshou Lai 5efc8380fb chore: bump myagents to 0.2.0
Pinned 0.1.0 left stale launcher wheels after xiaohe upgrades (missing backend_available).
2026-08-09 17:18:50 +08:00
Zhengshou Lai 3f79301eb3 feat: ensure-agent + resolve npm-global CLIs without PATH
Add guided multi-select ensure-agent, resolve backend binaries via npm
prefix when not on PATH, and optionally persist npm bin in shell rc.
2026-08-09 16:53:06 +08:00
Zhengshou Lai 9344b376da fix: clarify npm-global claude install progress vs skills-node 2026-08-09 16:42:28 +08:00
Zhengshou Lai 6702f67462 feat: guided auto-install for missing agent CLIs
Offer install with default Yes on TTY; auto-install when non-interactive
(install/CI). Resolve binaries from npm global prefix after install.
2026-08-09 15:54:23 +08:00
Zhengshou Lai c3e119e3c9 feat: offer backend CLI install on missing binary (entrypoints) + shared backend_available/ensure_backend helpers
- Standalone my* wrappers pass offer_install=True so a missing backend CLI
  triggers the interactive 'Install now? [y/N]' prompt (was a bare not-found).
- Add backend_available()/ensure_backend() to expose the backend registry
  (binary/env_bin/install_cmd) for xiaohe init / ensure-deps to reuse.
2026-08-09 15:08:03 +08:00
Zhengshou Lai a95c920d8a chore(myagents): 产品包名 xiaohe-api → xiaohe 2026-08-07 09:34:14 +08:00
Zhengshou Lai 1bce85cc19 perf(sessions): cache first-prompt titles, skip local-command noise in -l
Cursor/Claude session listing now scans only the head of each jsonl for a
title (aiTitle/customTitle/first user prompt), caches by (path, mtime, size),
and filters local-command protocol rows. COLUMNS=120 pinned in the table test.
2026-08-02 16:09:01 +08:00
Zhengshou Lai ffac0ba37d feat: add mycursor backend; drop myagents xiaohe entrypoint
Cursor Agent CLI wrapper (mycursor) with resume/session listing.
Product xiaohe CLI stays in xiaohe-api only — no dual entry.
2026-07-29 21:54:30 +08:00
Zhengshou Lai 6ec2a3ebe6 docs(skill): point paths at packages/myagents 2026-07-29 21:36:21 +08:00
Zhengshou Lai d99c5534cb refactor: drop workspace sync launcher path; simplify upgrade/switch
Align with xiaohe wheel/pip install: remove sync_workspace and desktop
launcher coupling from upgrade/switch flows.
2026-07-25 12:43:24 +08:00
Zhengshou Lai 37961feb21 feat: add myagents backends command (list supported agent backends) 2026-07-25 00:42:56 +08:00
Zhengshou Lai 7b6da50c93 test(upgrade): make pip-flag tests hermetic, cover uv branch
uv run injects VIRTUAL_ENV, which silently selects the uv pip branch and
broke test_uses_break_system_packages_on_externally_managed. Both tests
now delete VIRTUAL_ENV to pin the plain-pip branch; new test pins the
uv branch via mocks.
2026-07-18 11:47:08 +08:00
Zhengshou Lai 8be2a2dcd0 chore: drop dead templates/workspace and stale doc references
Nothing in the codebase ever read templates/workspace/; workspace
bootstrap is handled by the xiaohe runtime sync (MANAGED_PATHS).
README/AGENTS.md structure trees and the first-run claim updated.
2026-07-18 11:47:08 +08:00
Zhengshou Lai 12d746e0f8 fix(xiaohe): completion 缺失 _xiaohe + 首次运行无认证时引导设置 provider
- completion_install: completions_installed() 原只检查 progs[0](myagents),
  已存在则短路,导致 xiaohe 等后加 prog 的 completion 从未安装。
  改为遍历全部 prog,任一缺失即触发重新安装。
- first_run.py: xiaohe_main() 启动时检测是否有任何可用认证(env var、
  credentials、settings.json、已存 keys),无则弹交互式 provider 选择菜单。
- install.sh.in: 移除末尾 xiaohe init + scripts/init.sh wizard,安装脚本
  只装软件不初始化配置。
2026-07-16 23:25:04 +08:00
Zhengshou Lai 3d840a1ca3 fix(provider): _ProviderGroup 自定义 invoke 支持 invoke_without_command + 选项后置解析
Click 8.4.2 的 resolve_command 不兼容 invoke_without_command + 非子命令
位置参数。自定义 _ProviderGroup.invoke() 在首参数非子命令时路由到
group callback,并添加 _reparse_options 处理 allow_interspersed_args=False
时选项在 provider_id 后不被解析的问题。

修复 ctx.args = ctx._protected_args 后 clear() 导致引用共享清空的 bug。

测试:更新 test_switch.py 匹配新的 switch version/agent 子命令结构。
2026-07-16 21:27:57 +08:00
Zhengshou Lai 8a36245b5c chore: 累积 2026-07-16 21:17:31 +08:00
Zhengshou Lai d6344fef75 chore: 累积更新 2026-07-16 21:16:27 +08:00
Zhengshou Lai 46a41eb82a fix(provider): 测试与入口修复 2026-07-16 21:13:24 +08:00
Zhengshou Lai dd2011ae2d refactor(backend): backend 改名 provider,拆出 info 子命令 2026-07-16 21:07:16 +08:00
Zhengshou Lai e021aa9dbf fix(backend): 改回全局 ~/.claude/settings.json + 补充测试
- claude_settings: 目标文件改为全局 ~/.claude/settings.json,与 cc-switch 行为一致
- claude_settings: 切回 claude 时保留用户已有的 ANTHROPIC_API_KEY
- backend: 修正 save_project_settings -> save_settings,提示文本同步
- test_backends: 清理未用 import
- test_entrypoints: 新增 backend 命令注册测试
- 新增 test_claude_settings / test_secrets / test_backend_command(共 36 tests)
2026-07-16 20:52:12 +08:00
Zhengshou Lai 3ec60aa6b3 feat(backend): 内置 Claude Code 后端切换——DeepSeek / Kimi / Kimi Code 2026-07-16 20:45:20 +08:00
46 changed files with 5598 additions and 1603 deletions
+4 -4
View File
@@ -26,7 +26,7 @@ triggers:
- 用户想启动某个底层 agent`myagents claude``myagents kimi``myagents codex`
- 用户想恢复之前的会话:`myagents claude -r <session-id>``myagents kimi -S <session-id>`
- 用户想查看可恢复会话:`myagents claude -l`
- 开发 myagents CLI 本身:在 `contrib/myagents` 目录运行 `myagents claude --cwd .`
- 开发 myagents CLI 本身:在 `packages/myagents` 目录运行 `myagents claude --cwd .`
## 与其他 Skill 的分工
@@ -74,7 +74,7 @@ myagents completion install
## 开发维护
```bash
cd ~/workspace/contrib/myagents
cd ~/workspace/packages/myagents
make install # 可编辑安装 + 创建 ~/.local/bin 入口链接 + 安装 shell 补全
make uninstall # 移除链接与补全
python3 -m pytest tests/ -q
@@ -90,5 +90,5 @@ python3 -m ruff check myagents tests scripts
## 参考资料
- 设计约束与开发规范见 `contrib/myagents/AGENTS.md`
- 安装与完整命令表见 `contrib/myagents/README.md`
- 设计约束与开发规范见 `packages/myagents/AGENTS.md`
- 安装与完整命令表见 `packages/myagents/README.md`
-2
View File
@@ -48,8 +48,6 @@ myagents/
│ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
│ ├── commands/ # 子命令
│ └── ...
├── templates/ # 模板目录
│ └── workspace/ # workspace 模板 (AGENTS.md, CLAUDE.md symlink, .gitignore)
└── .agents/ # Agent 运行时配置(含向后兼容的 .claude 软链接)
workspace/ # 主工作环境 (默认 --cwd 目标, 独立目录)
+13 -1
View File
@@ -1,7 +1,7 @@
ROOT_DIR := $(shell pwd)
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
USER_BIN_DIR := $(HOME)/.local/bin
COMMANDS := myagents myclaude mykimi mycodex myhermes
COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor mydsh
.PHONY: help install uninstall _symlink-commands
@@ -11,7 +11,15 @@ help:
@echo " install Sync venv, symlink bin, install shell completions"
@echo " uninstall Remove bin, completions and user symlinks"
# Working tree: xiaohe-agent/packages/myagents (submodule of apaam/myagents).
# Refuse ephemeral GHA runner checkouts (_work) so ~/.local/bin cannot point there.
install:
@case "$(ROOT_DIR)" in \
*/_work/*|*/.xiaohe/actions-runner/*) \
echo "ERROR: refuse install from GHA runner worktree: $(ROOT_DIR)" >&2; \
echo "Use: cd <xiaohe-agent>/packages/myagents && make install" >&2; \
exit 1 ;; \
esac
@cd "$(ROOT_DIR)" && \
if command -v uv >/dev/null 2>&1; then \
uv sync; \
@@ -22,6 +30,10 @@ install:
@$(MAKE) _symlink-commands
@echo "Installing shell completions…"
@myagents completion install || echo "Warning: completion install failed"
@case "$(ROOT_DIR)" in \
*/_work/*|*/.xiaohe/actions-runner/*) \
echo "ERROR: install root still a runner tree: $(ROOT_DIR)" >&2; exit 1 ;; \
esac
_symlink-commands:
@mkdir -p "$(USER_BIN_DIR)"
+1 -2
View File
@@ -21,7 +21,6 @@ myagents/
│ ├── project_root.py # 项目/工作区根目录解析
│ └── commands/ # update / upgrade 子命令
├── tests/
├── templates/ # workspace 模板
├── pyproject.toml
├── Makefile
└── README.md
@@ -136,7 +135,7 @@ python3 -m ruff check myagents tests scripts
## 完整部署指南
如果你要部署的是**小荷助理**完整环境(含 Claude Code、cc-switch、Metabot、飞书机器人等),请参见 [xiaohe-agent 部署文档](../../README.md)。
如果你要部署的是**小荷助理**完整环境(Session API、Desktop、Skills Hub 等),请参见 [xiaohe-agent 仓库 README](../../README.md)(产品包名 **xiaohe**
## License
+141
View File
@@ -0,0 +1,141 @@
"""Provider metadata for Claude Code backend switching."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Provider:
"""A Claude Code-compatible model provider."""
id: str
display_name: str
base_url: str | None
default_model: str | None
key_name: str
auth_env_var: str = "ANTHROPIC_AUTH_TOKEN"
#: Built-in providers. Keys are normalized provider ids (hyphens).
PROVIDERS: dict[str, Provider] = {
"deepseek": Provider(
id="deepseek",
display_name="DeepSeek",
base_url="https://api.deepseek.com/anthropic",
default_model="deepseek-v4-pro",
key_name="deepseek",
),
"kimi": Provider(
id="kimi",
display_name="Kimi",
base_url="https://api.moonshot.ai/anthropic",
default_model="kimi-k2.6",
key_name="kimi",
),
"kimi-code": Provider(
id="kimi-code",
display_name="Kimi Code",
base_url="https://api.kimi.com/coding",
default_model="kimi-for-coding",
key_name="kimi_code",
),
"claude": Provider(
id="claude",
display_name="Claude (Anthropic)",
base_url=None,
default_model=None,
key_name="claude",
auth_env_var="ANTHROPIC_API_KEY",
),
}
#: Env vars that control Claude Code model selection / endpoint.
AUTH_ENV_VARS: tuple[str, ...] = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_MODEL",
)
#: Env vars that map Claude model tiers to a single provider model.
MODEL_TIER_ENV_VARS: tuple[str, ...] = (
"ANTHROPIC_DEFAULT_FABLE_MODEL",
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
"CLAUDE_CODE_SUBAGENT_MODEL",
)
#: All env vars managed by the backend switcher.
MANAGED_ENV_VARS: tuple[str, ...] = AUTH_ENV_VARS + MODEL_TIER_ENV_VARS
#: Provider ids that route to a third-party endpoint.
THIRD_PARTY_PROVIDER_IDS: tuple[str, ...] = ("deepseek", "kimi", "kimi-code")
def normalize_provider_id(name: str) -> str:
"""Normalize user input: underscores and case-insensitive to hyphens."""
return name.strip().lower().replace("_", "-")
def get_provider(name: str) -> Provider:
"""Return a provider by id, accepting aliases like ``kimi_code``."""
normalized = normalize_provider_id(name)
if normalized not in PROVIDERS:
raise KeyError(normalized)
return PROVIDERS[normalized]
def list_providers() -> list[Provider]:
"""Return built-in providers in a stable order."""
return list(PROVIDERS.values())
def is_third_party(provider: Provider) -> bool:
"""Whether the provider is a non-Anthropic endpoint."""
return provider.id in THIRD_PARTY_PROVIDER_IDS
def build_provider_env(provider: Provider, key: str, model: str | None = None) -> dict[str, str]:
"""Return the env dict to apply for a provider.
For third-party providers this sets the auth token, base url, and model
tier env vars. For the official ``claude`` provider the dict is empty;
the caller should remove managed env vars instead.
"""
if not is_third_party(provider):
return {}
env: dict[str, str] = {
provider.auth_env_var: key,
}
if provider.base_url:
env["ANTHROPIC_BASE_URL"] = provider.base_url
active_model = model or provider.default_model or ""
if active_model:
env["ANTHROPIC_MODEL"] = active_model
for tier_var in MODEL_TIER_ENV_VARS:
env[tier_var] = active_model
return env
def detect_provider(env: dict[str, str]) -> Provider | None:
"""Best-effort detect the active provider from a Claude Code env dict."""
base_url = env.get("ANTHROPIC_BASE_URL", "")
if not base_url:
# No base_url means official Claude or unset.
if env.get("ANTHROPIC_API_KEY") and not env.get("ANTHROPIC_AUTH_TOKEN"):
return PROVIDERS["claude"]
return None
for provider in PROVIDERS.values():
if provider.base_url and provider.base_url.rstrip("/") == base_url.rstrip("/"):
return provider
return None
+122
View File
@@ -0,0 +1,122 @@
"""Read/write global Claude Code settings.json for backend switching.
This module targets the user-level file at ``~/.claude/settings.json``,
mirroring the behavior of ``cc-switch``: the selected backend applies
globally to all Claude Code sessions.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from myagents.backends import (
MANAGED_ENV_VARS,
PROVIDERS,
Provider,
build_provider_env,
detect_provider,
)
def _settings_path() -> Path:
"""Return the global Claude Code settings path."""
return Path.home() / ".claude" / "settings.json"
def _load_json(path: Path) -> dict[str, Any]:
"""Load JSON; return empty dict on missing/corrupt."""
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _save_json(path: Path, data: dict[str, Any]) -> None:
"""Persist settings atomically with safe permissions."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def load_settings() -> dict[str, Any]:
"""Load the global Claude Code settings dict."""
return _load_json(_settings_path())
def save_settings(data: dict[str, Any]) -> None:
"""Persist the global Claude Code settings dict."""
_save_json(_settings_path(), data)
def get_env() -> dict[str, str]:
"""Return the current ``env`` block from global settings."""
env = load_settings().get("env", {})
return {k: v for k, v in env.items() if isinstance(v, str)}
def get_active_provider() -> Provider | None:
"""Detect the active provider from global env."""
return detect_provider(get_env())
def apply_provider(
provider: Provider,
key: str | None = None,
model: str | None = None,
base_url: str | None = None,
) -> dict[str, Any]:
"""Return updated global settings with the given provider applied.
This does not write to disk; callers should pass the result to
``save_settings``.
"""
settings = load_settings()
env: dict[str, str] = {
k: v for k, v in settings.get("env", {}).items() if isinstance(v, str)
}
# Remove stale managed env vars first, but preserve a user-managed
# ANTHROPIC_API_KEY when switching back to official Claude.
preserved_api_key = env.get("ANTHROPIC_API_KEY")
for var in MANAGED_ENV_VARS:
env.pop(var, None)
if provider.id == "claude":
# Official Claude: no third-party env vars needed. Restore a user-managed
# ANTHROPIC_API_KEY if present; otherwise leave it cleared.
if preserved_api_key:
env["ANTHROPIC_API_KEY"] = preserved_api_key
else:
if not key:
raise ValueError(f"API key required for provider '{provider.id}'")
provider_env = build_provider_env(provider, key, model=model)
if base_url:
provider_env["ANTHROPIC_BASE_URL"] = base_url
env.update(provider_env)
if env:
settings["env"] = env
else:
settings.pop("env", None)
return settings
def reset_backend() -> dict[str, Any]:
"""Convenience: return settings with all managed backend env vars removed."""
return apply_provider(PROVIDERS["claude"])
def describe_active_backend() -> str:
"""Human-readable description of the active global backend."""
provider = get_active_provider()
env = get_env()
if provider is None:
if env.get("ANTHROPIC_API_KEY") and not env.get("ANTHROPIC_AUTH_TOKEN"):
return "Claude (official API key)"
return "Claude (default / not configured)"
if provider.id == "claude":
return "Claude (official)"
model = env.get("ANTHROPIC_MODEL") or provider.default_model or "unknown"
return f"{provider.display_name} ({model})"
+22 -2
View File
@@ -6,6 +6,8 @@ import click
from myagents.commands import update_cmd, upgrade_cmd
from myagents.commands.completion import build_completion_group
from myagents.commands.ensure_agent import ensure_agent_cmd
from myagents.commands.ollama import ollama_cmd
from myagents.launcher import build_cli
@@ -14,6 +16,8 @@ def _progs():
from myagents.entrypoints import (
claude_cli,
codex_cli,
cursor_cli,
dsh_cli,
hermes_cli,
kimi_cli,
)
@@ -24,6 +28,8 @@ def _progs():
("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
("mycursor", lambda: cursor_cli),
("mydsh", lambda: dsh_cli),
]
@@ -40,20 +46,34 @@ def _package_version() -> str:
def cli(ctx: click.Context) -> None:
"""Myagents: unified launcher for AI coding agents.
Use ``myagents claude``, ``myagents kimi``, ``myagents codex`` or
``myagents hermes`` to start an agent in workspace/.
Use ``myagents claude``, ``myagents kimi``, ``myagents codex``,
``myagents hermes``, ``myagents cursor`` or ``myagents dsh`` to start an
agent in workspace/.
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
ctx.exit(0)
@cli.command("backends")
def backends_cmd() -> None:
"""List available agent backends (one per line, for machine consumption)."""
from myagents.launcher import _BACKENDS
for name in _BACKENDS:
click.echo(name)
cli.add_command(build_cli("claude"), name="claude")
cli.add_command(build_cli("kimi"), name="kimi")
cli.add_command(build_cli("codex"), name="codex")
cli.add_command(build_cli("hermes"), name="hermes")
cli.add_command(build_cli("cursor"), name="cursor")
cli.add_command(build_cli("dsh"), name="dsh")
cli.add_command(ensure_agent_cmd)
cli.add_command(update_cmd)
cli.add_command(upgrade_cmd, name="upgrade")
cli.add_command(ollama_cmd)
cli.add_command(build_completion_group(_progs))
+8
View File
@@ -43,6 +43,14 @@ def _print_script(prog_name: str, shell: str) -> None:
from myagents.entrypoints import hermes_cli
cli_obj = hermes_cli
elif prog_name == "mycursor":
from myagents.entrypoints import cursor_cli
cli_obj = cursor_cli
elif prog_name == "mydsh":
from myagents.entrypoints import dsh_cli
cli_obj = dsh_cli
else:
raise click.ClickException(f"Unknown command: {prog_name}")
+12 -10
View File
@@ -132,16 +132,18 @@ def completions_installed(
*,
install_root: Path | None = None,
) -> bool:
"""Check whether the zsh completion for the first prog is current."""
if not progs:
return True
prog_name, _ = progs[0]
for target in completion_targets(prog_name, install_root=install_root):
if target.shell == "zsh" and target.installed:
marker = f"_{prog_name.upper()}_COMPLETE"
if marker in target.path.read_text(encoding="utf-8"):
return True
return False
"""Check whether the zsh completion for every prog is current."""
for prog_name, _ in progs:
ok = False
for target in completion_targets(prog_name, install_root=install_root):
if target.shell == "zsh" and target.installed:
marker = f"_{prog_name.upper()}_COMPLETE"
if marker in target.path.read_text(encoding="utf-8"):
ok = True
break
if not ok:
return False
return bool(progs)
def ensure_completions_installed(
+180
View File
@@ -0,0 +1,180 @@
"""``myagents ensure-agent`` — install missing agent backend CLIs."""
from __future__ import annotations
import click
from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table
from myagents.launcher import (
_BACKENDS,
_is_interactive,
backend_available,
ensure_backend,
)
console = Console()
def installable_backends() -> list[str]:
"""Backends that have a one-shot ``install_cmd`` (claude, codex, …)."""
return [name for name, cfg in _BACKENDS.items() if cfg.get("install_cmd")]
def run_ensure_agents(
*,
prefer: list[str] | None = None,
yes: bool = False,
) -> list[str]:
"""Show status, let user pick missing CLIs, install them.
``prefer``: when interactive, Enter defaults to these names (intersected
with missing) instead of all missing — used by xiaohe for its default
agent. ``yes``: skip the picker and install prefer (or all missing).
Returns backends that remain missing afterwards.
"""
targets = installable_backends()
if not targets:
console.print(
"[yellow]No auto-installable backends registered.[/yellow]"
)
return []
console.print(
"[bold]Agent backend CLIs[/bold] (npm global — not skill Node deps)"
)
table = Table()
table.add_column("Backend", style="dim")
table.add_column("Status")
missing: list[str] = []
for name in targets:
ok = backend_available(name)
if not ok:
missing.append(name)
table.add_row(
name,
"[green]✓ installed[/green]" if ok else "[red]✗ missing[/red]",
)
console.print(table)
manual = [
name for name, cfg in _BACKENDS.items() if not cfg.get("install_cmd")
]
if manual:
console.print(f"[dim]Manual install only: {', '.join(manual)}[/dim]")
if not missing:
console.print("[green]All installable agent CLIs ready.[/green]")
return []
interactive = _is_interactive() and not yes
if not interactive:
to_install = (
[n for n in (prefer or []) if n in missing] or list(missing)
)
console.print(
f"[dim]Non-interactive — installing: {', '.join(to_install)}[/dim]"
)
else:
console.print()
console.print("[bold]Install which missing CLI(s)?[/bold]")
for i, name in enumerate(missing, 1):
tag = (
" [cyan](recommended)[/cyan]"
if prefer and name in prefer
else ""
)
console.print(f" {i}) [cyan]{name}[/cyan]{tag}")
console.print(f" {len(missing) + 1}) [dim]None — skip[/dim]")
default_set = (
[n for n in prefer if n in missing] if prefer else list(missing)
)
enter_label = (
f"recommended [{', '.join(default_set)}]"
if prefer and default_set
else "all missing"
)
console.print()
try:
choice = Prompt.ask(
f"Numbers (comma-separated; Enter = {enter_label})",
default="",
)
except (click.Abort, EOFError):
return list(missing)
choice = choice.strip()
if not choice:
to_install = default_set
else:
to_install = []
seen: set[str] = set()
for part in choice.split(","):
part = part.strip()
if not part.isdigit():
console.print(f"[red]Invalid choice: {choice}[/red]")
return list(missing)
idx = int(part) - 1
if idx == len(missing):
continue
if idx < 0 or idx >= len(missing):
console.print(f"[red]Invalid choice: {choice}[/red]")
return list(missing)
name = missing[idx]
if name not in seen:
seen.add(name)
to_install.append(name)
if not to_install:
console.print("[dim]Nothing selected.[/dim]")
return list(missing)
failed: list[str] = []
for name in to_install:
# Selection already confirmed — skip the second Install now? prompt.
if ensure_backend(name, yes=True):
console.print(f"[green]{name} CLI ready.[/green]")
else:
failed.append(name)
console.print(f"[yellow]{name} CLI still missing.[/yellow]")
still = [n for n in missing if n not in to_install or n in failed]
if failed:
console.print()
hint = (
"xiaohe ensure-agent"
if __import__("shutil").which("xiaohe")
else "myagents ensure-agent"
)
console.print(
"[yellow]Some installs failed.[/yellow] Install Node.js if needed, "
f"then: [cyan]{hint}[/cyan]."
)
elif not still:
console.print("[green]Selected agent CLIs ready.[/green]")
if __import__("shutil").which("xiaohe"):
console.print(" Next: [cyan]xiaohe[/cyan]")
else:
console.print(
" Launch: [cyan]myclaude[/cyan] / [cyan]mydsh[/cyan] / "
"[cyan]mycodex[/cyan] / …"
)
return still
@click.command("ensure-agent")
def ensure_agent_cmd() -> None:
"""Check/install agent backend CLIs (npm global; not skill Node deps).
Guided multi-select of missing CLIs on a TTY. Non-interactive installs
all missing auto-installable backends. Just run: myagents ensure-agent
"""
still = run_ensure_agents()
# Only fail hard when we attempted installs and some failed, or
# non-interactive left missing after auto-install.
if still and not _is_interactive():
raise click.ClickException(
"missing agent CLI(s): " + ", ".join(still)
)
+173
View File
@@ -0,0 +1,173 @@
"""First-run detection: guide user through provider setup when nothing is configured.
Called from ``xiaohe_main()`` on every invocation. When no auth is detected
(no env vars, no stored keys, no login credentials), prints a menu and
walks the user through selecting a provider and entering an API key.
"""
from __future__ import annotations
import os
from pathlib import Path
import click
from rich.console import Console
from myagents.backends import (
PROVIDERS,
THIRD_PARTY_PROVIDER_IDS,
Provider,
)
from myagents.claude_settings import (
apply_provider,
get_active_provider,
load_settings,
save_settings,
)
from myagents.secrets import has_key, set_key
stderr_console = Console(stderr=True)
def _has_claude_login() -> bool:
"""Check for Claude Code OAuth login credentials file."""
creds = Path.home() / ".claude" / ".credentials.json"
return creds.is_file()
def is_any_auth_configured() -> bool:
"""Return True when at least one usable auth method is available."""
# 1. Official Claude: env var or login credentials file
if os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN"):
return True
if _has_claude_login():
return True
# 2. Third-party provider active in Claude Code settings
if get_active_provider() is not None:
return True
# 3. ANTHROPIC_API_KEY set in ~/.claude/settings.json env block
env = load_settings().get("env", {})
if isinstance(env, dict) and env.get("ANTHROPIC_API_KEY"):
return True
# 4. Stored third-party keys (even if not currently active)
for pid in THIRD_PARTY_PROVIDER_IDS:
if has_key(PROVIDERS[pid].key_name):
return True
return False
def _secure_prompt_key(provider: Provider) -> str:
"""Prompt for an API key with hidden input."""
try:
return click.prompt(
f"Enter {provider.display_name} API key",
hide_input=True,
err=True,
)
except click.UsageError:
try:
with open("/dev/tty", encoding="utf-8") as tty:
return tty.readline().rstrip("\n")
except OSError as exc:
raise click.ClickException(
f"Cannot read API key interactively: {exc}"
) from exc
def _persist_claude_api_key(key: str) -> None:
"""Write ANTHROPIC_API_KEY to ~/.claude/settings.json env block."""
settings = load_settings()
env: dict = {k: v for k, v in settings.get("env", {}).items()
if isinstance(v, str)}
env["ANTHROPIC_API_KEY"] = key
settings["env"] = env
save_settings(settings)
def _run_setup_wizard() -> None:
"""Interactive guided setup: choose provider and enter API key."""
providers = [
PROVIDERS["claude"],
PROVIDERS["deepseek"],
PROVIDERS["kimi"],
PROVIDERS["kimi-code"],
]
stderr_console.print(
"\n[bold yellow]No model provider configured yet.[/bold yellow]"
)
stderr_console.print(
"[dim]Claude Code needs authentication. Pick one:[/dim]\n"
)
for i, p in enumerate(providers, 1):
stderr_console.print(f" {i}) {p.display_name}")
stderr_console.print(
" [dim]Or press Enter to skip (set up later with:"
" xiaohe switch provider <name>)[/dim]"
)
try:
choice = click.prompt(
"Choose", default="", show_default=False, err=True
)
except (click.Abort, EOFError):
stderr_console.print("[dim]Skipped. Run 'xiaohe switch provider' later.[/dim]")
return
choice = choice.strip()
if not choice:
stderr_console.print("[dim]Skipped. Run 'xiaohe switch provider' later.[/dim]")
return
try:
idx = int(choice) - 1
if idx < 0 or idx >= len(providers):
raise ValueError
except ValueError:
stderr_console.print(f"[red]Invalid choice: {choice}[/red]")
return
provider = providers[idx]
stderr_console.print(f"\n[bold]{provider.display_name}[/bold] selected.")
if provider.id == "claude":
key = _secure_prompt_key(provider)
if not key:
stderr_console.print("[yellow]No key entered — skipped.[/yellow]")
return
_persist_claude_api_key(key)
stderr_console.print(
f"[green]{provider.display_name} API key saved to"
f" ~/.claude/settings.json.[/green]"
)
else:
key = _secure_prompt_key(provider)
if not key:
stderr_console.print("[yellow]No key entered — skipped.[/yellow]")
return
set_key(provider.key_name, key)
settings = apply_provider(provider, key=key)
save_settings(settings)
stderr_console.print(
f"[green]Switched provider to {provider.display_name}.[/green]"
)
stderr_console.print()
def ensure_auth_configured() -> None:
"""Check auth state on ``xiaohe`` startup; run wizard if unconfigured."""
if is_any_auth_configured():
return
try:
_run_setup_wizard()
except Exception:
# Never block xiaohe launch on a setup error.
stderr_console.print(
"[yellow]Setup interrupted."
" Run 'xiaohe switch provider' later to configure.[/yellow]"
)
+45
View File
@@ -0,0 +1,45 @@
"""``info`` — show current version, agent, and provider."""
from __future__ import annotations
from importlib.metadata import PackageNotFoundError, version
import click
from rich.console import Console
from rich.table import Table
console = Console()
def _package_version() -> str:
try:
return version("myagents")
except PackageNotFoundError:
return "0.0.0"
@click.command("info")
def info_cmd() -> None:
"""Show status: version, agent, and provider."""
from myagents.claude_settings import describe_active_backend
from myagents.commands.provider import provider_list_table
from myagents.commands.switch import _resolve_agent
table = Table(show_header=False, box=None, padding=(0, 1))
table.add_column("key", style="dim")
table.add_column("value")
table.add_row("Version", f"myagents {_package_version()}")
default_agent = _resolve_agent()
table.add_row("Agent", f"my{default_agent}")
table.add_row("Provider", describe_active_backend())
console.print(table)
console.print()
provider_list_table()
console.print()
console.print("[dim]Product CLI (xiaohe):[/dim]")
console.print(" xiaohe switch agent <name>")
console.print(" xiaohe switch provider <name>")
-1
View File
@@ -19,7 +19,6 @@ stderr_console = Console(stderr=True)
_LINK_MAP: dict[str, list[str]] = {
"myacademia": ["path_myacademia"],
"myslides": ["path_myslides", "MYSLIDES_ROOT"],
"metabot": ["METABOT_HOME"],
"mytoolkit": ["path_mytoolkit", "MYTOOLKIT_ROOT"],
"mywebpage": ["path_mywebpage", "MYWEBPAGE_ROOT"],
}
+329
View File
@@ -0,0 +1,329 @@
"""``ollama`` — manage the local Ollama adapter (Claude Code <-> Ollama).
Claude Code talks Anthropic protocol; Ollama serves it natively on
``/v1/messages`` but rejects mid-conversation ``role: system`` messages
(Qwen chat templates require a leading system message). The adapter in
:mod:`myagents.ollama_adapter` normalizes requests before forwarding.
The adapter runs as a persistent service on 127.0.0.1:8199; this command
manages the platform service that keeps it alive:
myagents ollama up # install + start the service
myagents ollama down # stop and remove the service
myagents ollama status # service / health state
Platform backends:
macOS — launchd agent (LaunchAgents + ``launchctl bootstrap``)
Linux — systemd user unit (``systemctl --user enable --now``)
Windows— not supported yet
"""
from __future__ import annotations
import contextlib
import os
import subprocess
import sys
import time
import urllib.request
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
import click
from rich.console import Console
from myagents.ollama_adapter import listen_url
console = Console()
_UID = os.getuid() if hasattr(os, "getuid") else None # POSIX-only; launchctl paths are macOS
_LOG_DIR = Path.home() / ".xiaohe" / "logs"
def health_ok() -> bool:
"""True when the adapter endpoint responds."""
try:
with urllib.request.urlopen(f"{listen_url()}/health", timeout=1) as resp:
return resp.status == 200
except OSError:
return False
def _wait_healthy(*, attempts: int = 20, delay: float = 0.5) -> bool:
for _ in range(attempts):
if health_ok():
return True
time.sleep(delay)
return False
# ── macOS: launchd ───────────────────────────────────────────────────────
LABEL = "team.xiaohe.ollama-adapter"
_PLIST_DIR = Path.home() / "Library" / "LaunchAgents"
PLIST_PATH = _PLIST_DIR / f"{LABEL}.plist"
def _mac_plist_text() -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{sys.executable}</string>
<string>-m</string>
<string>myagents.ollama_adapter</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>{Path.home()}</string>
<key>StandardOutPath</key>
<string>{_LOG_DIR / "ollama-adapter.out.log"}</string>
<key>StandardErrorPath</key>
<string>{_LOG_DIR / "ollama-adapter.err.log"}</string>
</dict>
</plist>
"""
def _mac_loaded() -> bool:
result = subprocess.run(
["launchctl", "print", f"gui/{_UID}/{LABEL}"],
capture_output=True,
)
return result.returncode == 0
def _mac_write() -> None:
_PLIST_DIR.mkdir(parents=True, exist_ok=True)
_LOG_DIR.mkdir(parents=True, exist_ok=True)
PLIST_PATH.write_text(_mac_plist_text(), encoding="utf-8")
def _mac_start() -> None:
if _mac_loaded():
_mac_stop()
result = subprocess.run(
["launchctl", "bootstrap", f"gui/{_UID}", str(PLIST_PATH)],
capture_output=True,
text=True,
)
if result.returncode != 0 and not _mac_loaded():
raise click.ClickException(
f"launchctl bootstrap failed: {result.stderr.strip()}"
)
def _mac_stop() -> None:
if _mac_loaded():
subprocess.run(
["launchctl", "bootout", f"gui/{_UID}/{LABEL}"],
capture_output=True,
)
# ── Linux: systemd user unit ─────────────────────────────────────────────
UNIT_NAME = "xiaohe-ollama-adapter.service"
UNIT_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PATH = UNIT_DIR / UNIT_NAME
def _sysd_unit_text() -> str:
return f"""[Unit]
Description=xiaohe ollama adapter (Claude Code <-> Ollama bridge)
After=network-online.target
[Service]
Type=simple
ExecStart={sys.executable} -m myagents.ollama_adapter
Restart=always
RestartSec=2
WorkingDirectory={Path.home()}
[Install]
WantedBy=default.target
"""
def _systemctl(*args: str, check: bool = False) -> subprocess.CompletedProcess:
result = subprocess.run(
["systemctl", "--user", *args], capture_output=True, text=True
)
if check and result.returncode != 0:
raise click.ClickException(
f"systemctl --user {' '.join(args)} failed: {result.stderr.strip()}"
)
return result
def _sysd_state() -> tuple[str, str]:
"""Return (enabled|disabled|static|not-found, active|inactive|failed)."""
enabled = _systemctl("is-enabled", UNIT_NAME).stdout.strip()
active = _systemctl("is-active", UNIT_NAME).stdout.strip()
return enabled or "unknown", active or "unknown"
def _sysd_write() -> None:
UNIT_DIR.mkdir(parents=True, exist_ok=True)
UNIT_PATH.write_text(_sysd_unit_text(), encoding="utf-8")
_systemctl("daemon-reload", check=True)
def _sysd_start() -> None:
already = _sysd_state()[1] == "active"
_sysd_write()
# Best-effort linger so the service survives logout (needs no login
# session); harmless when it fails on systems without logind.
with contextlib.suppress(OSError):
subprocess.run(
["loginctl", "enable-linger", os.environ.get("USER", "")],
capture_output=True,
)
_systemctl("enable", "--now", UNIT_NAME, check=True)
if already:
_systemctl("restart", UNIT_NAME, check=True)
def _sysd_stop() -> None:
_systemctl("disable", "--now", UNIT_NAME)
if UNIT_PATH.exists():
UNIT_PATH.unlink()
_systemctl("daemon-reload")
# ── platform dispatch ────────────────────────────────────────────────────
@dataclass
class _Backend:
name: str
service_path: Path
is_loaded: Callable[[], bool]
write: Callable[[], None]
start: Callable[[], None]
stop: Callable[[], None]
def _platform() -> str:
"""Return ``sys.platform`` (function so Pyright can't constant-fold it)."""
return sys.platform
def _backend() -> _Backend:
if _platform() == "darwin":
return _Backend(
name="launchd",
service_path=PLIST_PATH,
is_loaded=_mac_loaded,
write=_mac_write,
start=_mac_start,
stop=_mac_stop,
)
if _platform() == "linux":
return _Backend(
name="systemd",
service_path=UNIT_PATH,
is_loaded=lambda: _sysd_state()[1] == "active",
write=_sysd_write,
start=_sysd_start,
stop=_sysd_stop,
)
raise click.ClickException(
f"ollama adapter is not supported on {sys.platform} yet."
)
def ensure_running() -> bool:
"""Idempotently make sure the adapter is up (used by ``switch provider``)."""
if health_ok():
return True
backend = _backend()
backend.write()
backend.start()
return _wait_healthy()
@click.group("ollama", invoke_without_command=True)
@click.pass_context
def ollama_cmd(ctx: click.Context) -> None:
"""Manage the local Ollama adapter (Claude Code <-> Ollama bridge)."""
if ctx.invoked_subcommand is None:
console.print("[bold]Use one of:[/bold] up, down, status")
@ollama_cmd.command("up")
def ollama_up() -> None:
"""Install (or bounce) the platform service, waiting for health."""
backend = _backend()
backend.write()
backend.start()
if _wait_healthy():
console.print(
f"[bold green]ollama-adapter up[/bold green] "
f"({listen_url()} -> Ollama, {backend.name})"
)
else:
raise click.ClickException(
"ollama-adapter failed to become healthy; check the service logs "
f"({backend.name})"
)
@ollama_cmd.command("down")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation")
def ollama_down(yes: bool) -> None:
"""Stop and remove the platform service."""
backend = _backend()
installed = backend.is_loaded() or backend.service_path.exists()
if not installed:
console.print("[yellow]ollama-adapter not installed.[/yellow]")
return
if not yes and not click.confirm(
"Stop and remove ollama-adapter?", default=False, err=True
):
console.print("Cancelled.")
return
backend.stop()
console.print("[green]ollama-adapter stopped and removed.[/green]")
@ollama_cmd.command("status")
def ollama_status() -> None:
"""Show service and health state."""
up = health_ok()
endpoint = (
f"[green]up[/green] {listen_url()}"
if up
else f"[red]down[/red] {listen_url()}"
)
console.print(f" endpoint : {endpoint}")
try:
backend = _backend()
except click.ClickException as exc:
console.print(f" platform : [dim]{exc}[/dim]")
return
console.print(f" platform : {backend.name}")
if _platform() == "darwin":
loaded = _mac_loaded()
state = "[green]loaded[/green]" if loaded else "[dim]not loaded[/dim]"
console.print(f" launchd agent : {state}")
elif _platform() == "linux":
enabled, active = _sysd_state()
console.print(f" systemd unit : enabled={enabled} active={active}")
if not up:
err_log = _LOG_DIR / "ollama-adapter.err.log"
if err_log.exists():
tail = err_log.read_text(encoding="utf-8", errors="replace").strip()
if tail:
for line in tail.splitlines()[-5:]:
console.print(f" [dim]{line}[/dim]")
+279
View File
@@ -0,0 +1,279 @@
"""``xiaohe switch provider`` — switch Claude Code's LLM provider."""
from __future__ import annotations
import click
from rich.console import Console
from myagents.backends import (
Provider,
get_provider,
is_third_party,
list_providers,
)
from myagents.claude_settings import (
apply_provider,
describe_active_backend,
get_active_provider,
save_settings,
)
from myagents.secrets import get_key, has_key, remove_key, set_key
from myagents.settings import set_setting
stderr_console = Console(stderr=True)
console = Console()
def _secure_prompt_key(provider: Provider) -> str:
prompt_text = f"Enter {provider.display_name} API key"
try:
return click.prompt(prompt_text, hide_input=True, err=True)
except click.UsageError:
try:
with open("/dev/tty", encoding="utf-8") as tty: # noqa: PTH123
return tty.readline().rstrip("\n")
except OSError as exc:
raise click.ClickException(
f"Cannot read API key interactively: {exc}"
) from exc
def provider_list_table() -> None:
"""Print the provider list (reusable from info command)."""
active = get_active_provider()
console.print("[bold]Available providers:[/bold]")
for provider in list_providers():
marker = ""
if active and active.id == provider.id:
marker = " [green](current)[/green]"
key_status = "[dim]no key[/dim]"
if has_key(provider.key_name):
key_status = "[cyan]key stored[/cyan]"
if provider.id == "claude":
key_status = "[dim]official[/dim]"
console.print(
f" {provider.id:12} {provider.display_name:18} "
f"{provider.default_model or '-':20} {key_status}{marker}"
)
def _do_switch(
provider: Provider,
key: str | None = None,
model: str | None = None,
base_url: str | None = None,
yes: bool = False,
) -> None:
if is_third_party(provider):
resolved_key = key or get_key(provider.key_name)
if not resolved_key:
resolved_key = _secure_prompt_key(provider)
if not resolved_key:
raise click.ClickException("API key is required.")
else:
resolved_key = None
summary = provider.display_name
if model:
summary += f" (model: {model})"
if base_url:
summary += f" (base-url: {base_url})"
if not yes and not click.confirm(
f"Switch LLM provider to {summary}?",
default=True,
err=True,
):
console.print("Cancelled.")
return
settings = apply_provider(
provider, key=resolved_key, model=model, base_url=base_url
)
save_settings(settings)
set_setting("backend_provider", provider.id)
console.print(f"[bold green]Switched to {summary}.[/bold green]")
if provider.id == "claude":
console.print(
"[dim]Cleared third-party provider env vars from ~/.claude/settings.json.[/dim]"
)
class _ProviderGroup(click.Group):
"""A Group that supports both subcommands and positional provider IDs.
Click 8.4.2 ``resolve_command`` raises ``NoSuchCommand`` before
``invoke_without_command`` can be consulted; we short-circuit that here.
"""
def invoke(self, ctx: click.Context):
if not ctx._protected_args:
if self.invoke_without_command:
with ctx:
return click.Group.invoke(self, ctx) # type: ignore[arg-type]
ctx.fail("Missing command.")
cmd_name = ctx._protected_args[0]
cmd = self.get_command(ctx, cmd_name)
if cmd is not None:
return click.Group.invoke(self, ctx) # type: ignore[arg-type]
if self.invoke_without_command:
# Re-assemble args that Group.parse_args split apart.
# Because allow_interspersed_args is False, options that
# appear after the first positional arg (e.g. the provider_id)
# land in ctx.args — re-parse them here.
merged = [*ctx._protected_args, *ctx.args]
self._reparse_options(ctx, merged)
ctx.args = list(ctx._protected_args)
ctx._protected_args.clear()
with ctx:
return click.Group.invoke(self, ctx) # type: ignore[arg-type]
ctx.fail(f"No such command '{cmd_name}'.")
@staticmethod
def _reparse_options(ctx: click.Context, args: list[str]) -> None:
"""Extract known group options from *args*, updating ctx.params.
This handles the ``provider_id --key val --yes`` case where
Group.parse_args stops option parsing after the first positional."""
parser = click.parser.OptionParser()
for param in ctx.command.params:
param.add_to_parser(parser, ctx)
opts, remaining, _ = parser.parse_args(args=args)
ctx._protected_args = remaining
for param in ctx.command.params:
value = opts.get(param.name)
if value is not None:
ctx.params[param.name] = param.type_cast_value(ctx, value)
elif param.name not in ctx.params:
ctx.params[param.name] = None
@click.group("provider", invoke_without_command=True, cls=_ProviderGroup)
@click.option("--key", help="API key (scripting only; appears in shell history)")
@click.option("--model", help="Override the default model")
@click.option("--base-url", help="Override the provider base URL")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
@click.pass_context
def provider_cmd(
ctx: click.Context,
key: str | None,
model: str | None,
base_url: str | None,
yes: bool,
) -> None:
"""Switch Claude Code's LLM provider.
\b
xiaohe switch provider # list available providers
xiaohe switch provider deepseek # switch to DeepSeek
xiaohe switch provider claude # reset to official Claude
"""
if ctx.invoked_subcommand is not None:
return
# Use ctx.args (positional args left after option parsing) to avoid
# Click's argument consuming subcommand names like "list"/"current".
provider_id: str | None = ctx.args[0] if ctx.args else None
if not provider_id:
provider_list_table()
return
# Reject stray positional args — only one provider_id is meaningful.
if len(ctx.args) > 1:
raise click.ClickException(
f"Unexpected extra argument(s): {' '.join(ctx.args[1:])}"
)
try:
provider = get_provider(provider_id)
except KeyError:
known = ", ".join(p.id for p in list_providers())
raise click.ClickException(
f"Unknown provider '{provider_id}'. Choose from: {known}"
)
_do_switch(provider, key=key, model=model, base_url=base_url, yes=yes)
@provider_cmd.command("list")
def provider_list() -> None:
"""List available providers."""
provider_list_table()
@provider_cmd.command("current")
def provider_current() -> None:
"""Show the active provider."""
console.print(f"[bold]Active provider:[/bold] {describe_active_backend()}")
@provider_cmd.group("key")
def provider_key() -> None:
"""Manage stored API keys."""
@provider_key.command("set")
@click.argument("provider_id")
@click.option("--key", help="API key (scripting only; appears in shell history)")
def provider_key_set(provider_id: str, key: str | None) -> None:
"""Store an API key without switching."""
try:
provider = get_provider(provider_id)
except KeyError:
known = ", ".join(p.id for p in list_providers())
raise click.ClickException(
f"Unknown provider '{provider_id}'. Choose from: {known}"
)
if provider.id == "claude":
raise click.ClickException(
"Use the ANTHROPIC_API_KEY environment variable for official Claude auth."
)
resolved_key = key
if not resolved_key:
resolved_key = _secure_prompt_key(provider)
if not resolved_key:
raise click.ClickException("API key cannot be empty.")
set_key(provider.key_name, resolved_key)
console.print(
f"[green]Stored {provider.display_name} key in ~/.xiaohe/agent/config.json.[/green]"
)
@provider_key.command("rm")
@click.argument("provider_id")
@click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt")
def provider_key_rm(provider_id: str, yes: bool) -> None:
"""Remove the stored API key for a provider."""
try:
provider = get_provider(provider_id)
except KeyError:
known = ", ".join(p.id for p in list_providers())
raise click.ClickException(
f"Unknown provider '{provider_id}'. Choose from: {known}"
)
if not yes and not click.confirm(
f"Remove stored key for {provider.display_name}?",
default=False,
err=True,
):
console.print("Cancelled.")
return
if remove_key(provider.key_name):
console.print(f"[green]Removed {provider.display_name} key.[/green]")
else:
console.print(f"[yellow]No stored key for {provider.display_name}.[/yellow]")
if __name__ == "__main__":
provider_cmd()
+57 -64
View File
@@ -1,84 +1,77 @@
"""``xiaohe switch`` — move ``current`` to another installed runtime version.
"""``switch`` — agent / provider helpers (used by product ``xiaohe`` CLI)."""
Rollback/roll-forward counterpart to ``xiaohe upgrade``: repoints the
``current`` symlink, reinstalls the CLI tools from that runtime, and re-syncs
the workspace. Run without an argument to list installed versions and pick
one interactively.
"""
from __future__ import annotations
import click
from rich.console import Console
from myagents.commands.sync_workspace import _print_report, sync_workspace
from myagents.commands.upgrade import _current_version, _install_tools, _runtime_root
from myagents.project_root import get_workspace_root
from myagents.launcher import _BACKENDS
from myagents.settings import get_setting, set_setting
stderr_console = Console(stderr=True)
console = Console()
def _installed_versions() -> list[str]:
root = _runtime_root()
if not root.is_dir():
return []
return sorted(
d.name for d in root.iterdir() if d.is_dir() and not d.is_symlink()
)
_KNOWN_BACKENDS = tuple(_BACKENDS)
@click.command("switch")
@click.argument("version", required=False)
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt")
def switch_cmd(version: str | None, yes: bool) -> None:
"""Switch the active runtime to another installed version (rollback)."""
versions = _installed_versions()
current = _current_version()
if not versions:
raise click.ClickException(
"No installed runtime versions found (~/.xiaohe/runtime)."
)
def agent_list_table() -> None:
"""Print the agent list (reusable from info command)."""
default = _resolve_agent()
console.print("[bold]Available agents:[/bold]")
for name in _KNOWN_BACKENDS:
marker = " [green](current)[/green]" if name == default else ""
console.print(f" {name}{marker}")
if not version:
console.print("[bold]Installed versions:[/bold]")
for name in versions:
marker = " [green](current)[/green]" if name == current else ""
def _resolve_agent() -> str:
import re
agent = str(get_setting("default_agent", "") or "")
agent = re.sub(r"^my", "", agent.strip().lower())
return agent if agent in _KNOWN_BACKENDS else "claude"
@click.command("agent")
@click.argument("agent", required=False)
def switch_agent(agent: str | None) -> None:
"""Switch the default agent CLI."""
known = list(_KNOWN_BACKENDS)
default = _resolve_agent()
if not agent:
console.print("[bold]Available agents:[/bold]")
for name in known:
marker = " [green](current)[/green]" if name == default else ""
console.print(f" {name}{marker}")
version = str(click.prompt("Switch to", err=True))
agent = str(click.prompt("Switch to", err=True))
if version not in versions:
agent = agent.strip().lower()
if agent.startswith("my"):
agent = agent[2:]
if agent not in _KNOWN_BACKENDS:
raise click.ClickException(
f"Version {version!r} is not installed. Installed: {', '.join(versions)}"
f"Unknown agent '{agent}'. Choose from: {', '.join(known)}"
)
if version == current:
console.print(f"Already on {version}.")
return
if not yes and not click.confirm(
f"Switch runtime {current} -> {version} and reinstall tools?",
default=True,
err=True,
):
console.print("Cancelled.")
if agent == default:
console.print(f"Already on {agent}.")
return
target = _runtime_root() / version
current_link = _runtime_root() / "current"
current_link.unlink(missing_ok=True)
current_link.symlink_to(target)
set_setting("default_agent", agent)
console.print(f"[bold green]Switched default agent to {agent}.[/bold green]")
console.print(f"[dim]Use 'xiaohe' (or 'my{agent}') to launch.[/dim]")
warnings = _install_tools(target)
workspace = get_workspace_root(create=False)
if workspace.is_dir():
try:
report = sync_workspace(workspace)
except click.ClickException as exc:
stderr_console.print(f"[yellow]workspace sync skipped: {exc.message}[/yellow]")
else:
_print_report(workspace, report)
@click.group("switch", invoke_without_command=True)
@click.pass_context
def switch_cmd(ctx: click.Context) -> None:
"""Switch agent or provider."""
if ctx.invoked_subcommand is None:
console.print("[bold]Use one of:[/bold]")
console.print(" xiaohe switch agent — switch default agent CLI")
console.print(" xiaohe switch provider — switch LLM provider")
console.print()
console.print(
"[dim]Package version: xiaohe upgrade [<version>][/dim]"
)
for warning in warnings:
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
console.print(
f"\n[bold green]Switched: {current} -> {version}[/bold green] "
"(open a new terminal to pick up the change)"
)
switch_cmd.add_command(switch_agent)
-375
View File
@@ -1,375 +0,0 @@
"""``xiaohe init`` / ``xiaohe sync`` — workspace bootstrap and content sync.
Model: the install package lives at ~/.xiaohe/runtime/ (the "runtime"); the
workspace is a user-chosen directory (settings.json workspace_root) that
receives synced skills/docs from the runtime and keeps all personal data.
Sync is hash-based with a stamp file (<workspace>/.xiaohe-sync.json):
- file only in runtime -> copy (new)
- file changed in runtime,
untouched in workspace -> overwrite (upgrade)
- file modified in workspace -> skip, report (user edit wins)
- file gone from runtime -> remove from workspace if untouched
A workspace that is a git checkout is never synced unless --force.
"""
import hashlib
import json
import os
import shutil
from pathlib import Path
import click
from rich.console import Console
from myagents.project_root import get_workspace_root
from myagents.settings import get_setting, set_setting
stderr_console = Console(stderr=True)
console = Console()
STAMP_NAME = ".xiaohe-sync.json"
# Root-level symlinks replicated as symlinks (targets are synced real files).
MANAGED_SYMLINKS = {"CLAUDE.md": "agents.md", ".claude": ".agents"}
# Managed content: relative paths in the runtime tree. Directories are
# synced recursively with symlinks dereferenced (workspace must be
# self-contained — contrib/ is not synced, it is pip/npm-installed).
MANAGED_PATHS = [
".agents/skills",
".agents/settings.json",
".agents/README.md",
"agents.md",
"README.md",
"PERSONAL.md.example",
"assistant/prompts",
"assistant/pending/README.md",
"assistant/pending/CLAUDE.md",
"assistant/agent-tasks.template.md",
]
SKELETON_DIRS = [
"tmp",
"assistant/logs",
"assistant/checkpoints",
"assistant/knowledge",
"assistant/pending",
]
def get_runtime_root() -> Path | None:
"""Runtime tree: $XIAOHE_RUNTIME > ~/.xiaohe/runtime/current."""
env = os.environ.get("XIAOHE_RUNTIME")
candidates = [Path(env)] if env else []
candidates.append(Path.home() / ".xiaohe" / "runtime" / "current")
for cand in candidates:
cand = cand.expanduser()
if (cand / "agents.md").is_file() or (cand / ".agents" / "skills").is_dir():
return cand.resolve()
return None
def _runtime_version(runtime: Path) -> str:
version_file = runtime / "VERSION"
if version_file.is_file():
return version_file.read_text(encoding="utf-8").strip()
return ""
def _hash_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(65536), b""):
digest.update(chunk)
return digest.hexdigest()
def _enumerate_managed(runtime: Path) -> dict[str, str]:
"""rel-path -> sha256 for every managed file in the runtime tree.
Symlinked dirs (e.g. .agents/skills/mytoolkit -> contrib/...) are followed
and their content is hashed through the link, so the workspace receives
real self-contained files. Directory inodes are tracked to break cycles.
"""
files: dict[str, str] = {}
for rel in MANAGED_PATHS:
src = runtime / rel
if src.is_file():
files[rel] = _hash_file(src)
continue
if not src.is_dir():
continue
seen: set[tuple[int, int]] = set()
for dirpath, dirnames, filenames in os.walk(src, followlinks=True):
try:
real = Path(dirpath).resolve().stat()
except OSError:
dirnames[:] = []
continue
key = (real.st_dev, real.st_ino)
if key in seen:
dirnames[:] = []
continue
seen.add(key)
for name in sorted(filenames):
path = Path(dirpath) / name
if path.is_file():
files[str(path.relative_to(runtime))] = _hash_file(path)
return files
def _load_stamp(workspace: Path) -> dict:
stamp_path = workspace / STAMP_NAME
try:
data = json.loads(stamp_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {"version": "", "files": {}}
if not isinstance(data, dict) or not isinstance(data.get("files"), dict):
return {"version": "", "files": {}}
return data
def _write_stamp(workspace: Path, version: str, files: dict[str, str]) -> None:
stamp = {"version": version, "files": files}
(workspace / STAMP_NAME).write_text(
json.dumps(stamp, ensure_ascii=False, indent=1) + "\n", encoding="utf-8"
)
def sync_workspace(workspace: Path, force: bool = False) -> dict:
"""Sync managed content from the runtime into workspace. Returns a report."""
runtime = get_runtime_root()
if runtime is None:
raise click.ClickException(
"runtime not found (expected ~/.xiaohe/runtime/current or "
"$XIAOHE_RUNTIME) — reinstall or set XIAOHE_RUNTIME."
)
if (workspace / ".git").exists() and not force:
raise click.ClickException(
f"{workspace} is a git checkout (dev workspace) — refusing to sync. "
"Use --force if you really mean it."
)
runtime_files = _enumerate_managed(runtime)
stamp = _load_stamp(workspace)
stamped: dict[str, str] = stamp.get("files", {})
report = {"added": [], "updated": [], "skipped": [], "removed": []}
new_stamp: dict[str, str] = {}
for rel, runtime_hash in sorted(runtime_files.items()):
target = workspace / rel
stamped_hash = stamped.get(rel)
if stamped_hash is None:
# New managed file; do not clobber an existing divergent file.
if target.exists() and _hash_file(target) != runtime_hash:
report["skipped"].append(rel)
continue
action = "added"
else:
workspace_hash = _hash_file(target) if target.is_file() else None
if workspace_hash is not None and workspace_hash != stamped_hash:
report["skipped"].append(rel) # user-modified
new_stamp[rel] = stamped_hash
continue
if workspace_hash == runtime_hash:
new_stamp[rel] = runtime_hash
continue # already in sync
action = "updated"
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(runtime / rel, target)
new_stamp[rel] = runtime_hash
report[action].append(rel)
for rel, stamped_hash in sorted(stamped.items()):
if rel in runtime_files:
continue
target = workspace / rel
if target.is_file() and _hash_file(target) == stamped_hash:
target.unlink()
report["removed"].append(rel)
elif target.exists():
report["skipped"].append(rel)
new_stamp[rel] = stamped_hash
for link, target_rel in MANAGED_SYMLINKS.items():
link_path = workspace / link
# Never clobber: an existing entry (real file, dir, or link) stays put.
if link_path.is_symlink() or link_path.exists():
continue
if (workspace / target_rel).exists():
link_path.symlink_to(target_rel)
_write_stamp(workspace, _runtime_version(runtime), new_stamp)
return report
def _print_report(workspace: Path, report: dict) -> None:
console.print(f"[bold]Synced into[/bold] {workspace}")
for action, label in (("added", "green"), ("updated", "cyan")):
if report[action]:
console.print(f" [{label}]{action}: {len(report[action])}[/{label}]")
if report["removed"]:
console.print(f" [yellow]removed: {len(report['removed'])}[/yellow]")
if report["skipped"]:
console.print(
f" [yellow]skipped (locally modified): {len(report['skipped'])}[/yellow]"
)
for rel in report["skipped"][:10]:
console.print(f" [dim]- {rel}[/dim]")
_LAUNCHER_NAME = "Xiaohe Agent"
_LEGACY_LAUNCHER_NAMES = ("XiaoheAgent.command", "XiaoheAgent.app", "XiaoheAgent.desktop")
_APP_INFO_PLIST = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Xiaohe Agent</string>
<key>CFBundleDisplayName</key><string>Xiaohe Agent</string>
<key>CFBundleIdentifier</key><string>com.xiaohe.agent</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleExecutable</key><string>XiaoheAgent</string>
<key>CFBundleIconFile</key><string>XiaoheAgent</string>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
"""
_APP_EXECUTABLE = """#!/usr/bin/env bash
# Xiaohe Agent launcher — opens Terminal running xiaohe.
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
if command -v xiaohe >/dev/null 2>&1; then
osascript -e 'tell application "Terminal" to activate' \\
-e 'tell application "Terminal" to do script "xiaohe"'
else
osascript -e 'display dialog "xiaohe command not found — run install.sh first" buttons {"OK"}'
fi
"""
def _icon_assets() -> tuple[Path | None, Path | None]:
"""(icns, png) shipped in the runtime tree, if present."""
runtime = get_runtime_root()
if runtime is None:
return None, None
icons = runtime / "assets" / "icon"
icns = icons / "XiaoheAgent.icns"
png = icons / "xiaohe-icon-512.png"
return (icns if icns.is_file() else None), (png if png.is_file() else None)
def _remove_legacy_launchers(directory: Path) -> None:
for name in _LEGACY_LAUNCHER_NAMES:
legacy = directory / name
if legacy.is_dir():
shutil.rmtree(legacy)
elif legacy.exists():
legacy.unlink()
def _create_launcher() -> None:
"""Double-click launcher that opens a terminal running ``xiaohe``."""
home = Path.home()
icns, png = _icon_assets()
if os.uname().sysname == "Darwin": # noqa: PLR2004 — platform check
desktop = home / "Desktop"
if not desktop.is_dir():
return
_remove_legacy_launchers(desktop)
app = desktop / f"{_LAUNCHER_NAME}.app"
macos = app / "Contents" / "MacOS"
resources = app / "Contents" / "Resources"
macos.mkdir(parents=True, exist_ok=True)
resources.mkdir(parents=True, exist_ok=True)
(app / "Contents" / "Info.plist").write_text(_APP_INFO_PLIST, encoding="utf-8")
executable = macos / "XiaoheAgent"
executable.write_text(_APP_EXECUTABLE, encoding="utf-8")
executable.chmod(0o755)
if icns is not None:
shutil.copy2(icns, resources / "XiaoheAgent.icns")
console.print(f" [green]launcher:[/green] {app}")
else:
apps = home / ".local" / "share" / "applications"
apps.mkdir(parents=True, exist_ok=True)
_remove_legacy_launchers(apps)
icon_line = "Icon=utilities-terminal\n"
if png is not None:
icon_dir = home / ".local" / "share" / "icons" / "hicolor" / "512x512" / "apps"
icon_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(png, icon_dir / "xiaohe-agent.png")
icon_line = "Icon=xiaohe-agent\n"
(apps / f"{_LAUNCHER_NAME}.desktop").write_text(
"[Desktop Entry]\n"
"Type=Application\n"
"Name=Xiaohe Agent\n"
"Comment=Xiaohe Agent terminal\n"
"Exec=xiaohe\n"
"Terminal=true\n"
f"{icon_line}"
"Categories=Utility;\n",
encoding="utf-8",
)
console.print(f" [green]launcher:[/green] {apps / f'{_LAUNCHER_NAME}.desktop'}")
@click.command("init")
@click.argument("path", required=False)
@click.option("--force", is_flag=True, help="Sync even into a git checkout.")
def init_cmd(path: str | None, force: bool) -> None:
"""First-run workspace setup: choose path, lay skeleton, sync content."""
configured = str(get_setting("workspace_root", "") or "").strip()
if path:
workspace = Path(path).expanduser().resolve()
elif configured:
workspace = Path(configured).expanduser().resolve()
else:
default = str(Path.home() / "workspace")
answer = click.prompt(
"Workspace path", default=default, show_default=True, err=True
)
workspace = Path(answer).expanduser().resolve()
if not configured or configured != str(workspace):
set_setting("workspace_root", str(workspace))
console.print(f"[green]workspace_root saved to settings.json:[/green] {workspace}")
workspace.mkdir(parents=True, exist_ok=True)
for rel in SKELETON_DIRS:
(workspace / rel).mkdir(parents=True, exist_ok=True)
if get_runtime_root() is not None:
report = sync_workspace(workspace, force=force)
_print_report(workspace, report)
else:
stderr_console.print(
"[yellow]runtime not found — skeleton only; "
"skills/docs will sync after reinstall.[/yellow]"
)
# Worklog task board: instantiate the live file from the synced template.
template = workspace / "assistant" / "agent-tasks.template.md"
live = workspace / "assistant" / "agent-tasks.md"
if template.is_file() and not live.exists():
shutil.copy2(template, live)
console.print(" [green]created:[/green] assistant/agent-tasks.md (from template)")
_create_launcher()
console.print("\n[bold green]Done.[/bold green] Daily use: [cyan]xiaohe[/cyan]")
@click.command("sync")
@click.option("--force", is_flag=True, help="Sync even into a git checkout.")
def sync_cmd(force: bool) -> None:
"""Sync skills/docs from the installed runtime into the workspace."""
workspace = get_workspace_root(create=False)
if not workspace.is_dir():
raise click.ClickException(
f"workspace {workspace} does not exist — run 'xiaohe init' first."
)
report = sync_workspace(workspace, force=force)
_print_report(workspace, report)
+49 -24
View File
@@ -1,10 +1,8 @@
"""``xiaohe uninstall`` — remove the CLI layer, keep runtime/config/workspace.
"""``uninstall`` — remove CLI entry points; keep config/workspace by default.
Default: removes the entry points (xiaohe/myclaude/.../mytoolkit), metabot
CLI, shell completions, and the desktop launcher. Keeps ~/.xiaohe (runtime,
settings, keys), ~/.metabot, ~/.mytoolkit and the workspace. ``--all`` also
deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit (every saved credential);
the workspace is never touched.
Also removes leftover legacy stubs (metabot / mb / mm / doubao-tts) if present.
``--all`` deletes ~/.xiaohe, ~/.mytoolkit and leftover ~/.metabot;
workspace is never touched.
"""
import os
@@ -20,12 +18,30 @@ stderr_console = Console(stderr=True)
console = Console()
BIN_NAMES = (
"xiaohe", "myagents", "myclaude", "mykimi", "mycodex", "myhermes",
"mytoolkit", "metabot", "mb", "mm", "doubao-tts",
"xiaohe",
"myagents",
"myclaude",
"mykimi",
"mycodex",
"myhermes",
"mycursor",
"mytoolkit",
)
METABOT_COMPLETIONS = (
"mb", "mm", "metabot", "doubao-tts",
"_mb", "_mm", "_metabot", "_doubao-tts",
LEGACY_BIN_NAMES = (
"metabot",
"mb",
"mm",
"doubao-tts",
)
LEGACY_COMPLETIONS = (
"mb",
"mm",
"metabot",
"doubao-tts",
"_mb",
"_mm",
"_metabot",
"_doubao-tts",
)
PY_PACKAGES = ("mytoolkit", "myagents")
@@ -34,7 +50,7 @@ def _launcher_paths(home: Path) -> list[Path]:
apps = home / ".local" / "share" / "applications"
icon = (
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
/ "apps" / "xiaohe-agent.png"
/ "apps" / "xiaohe.png"
)
return [
home / "Desktop" / "Xiaohe Agent.app",
@@ -43,6 +59,8 @@ def _launcher_paths(home: Path) -> list[Path]:
apps / "Xiaohe Agent.desktop",
apps / "XiaoheAgent.desktop",
icon,
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
/ "apps" / "xiaohe.png",
]
@@ -78,7 +96,7 @@ def _pip_uninstall() -> list[str]:
@click.command("uninstall")
@click.option("--all", "remove_all", is_flag=True,
help="Also remove ~/.xiaohe (runtime, settings, API keys)")
help="Also remove ~/.xiaohe (settings, API keys)")
@click.option("--yes", is_flag=True,
help="Skip the first confirmation (--all still requires the full chain)")
def uninstall_cmd(remove_all: bool, yes: bool) -> None:
@@ -89,19 +107,26 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
from myagents.project_root import get_workspace_root
console.print("[bold]Will remove:[/bold]")
console.print(" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,")
console.print(" myagents, mytoolkit, metabot, mb, mm, doubao-tts")
console.print(
" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,"
)
console.print(" mycursor, myagents, mytoolkit")
console.print(
" - legacy leftovers if present: metabot, mb, mm, doubao-tts"
)
console.print(" - shell completions for the above")
console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)")
console.print(" - pip packages: mytoolkit, myagents")
if remove_all:
console.print(" - [red]~/.xiaohe (runtime, settings.json, config.json keys)[/red]")
console.print(" - [red]~/.metabot (Feishu bot config)[/red]")
console.print(" - [red]~/.xiaohe (settings, secrets, keys)[/red]")
console.print(" - [red]~/.mytoolkit (mytoolkit config, incl. keys)[/red]")
console.print(
" - [red]~/.metabot (legacy leftover, if present)[/red]"
)
console.print("[bold]Will keep:[/bold]")
if not remove_all:
console.print(" - ~/.xiaohe (runtime, settings, keys)")
console.print(" - ~/.metabot and ~/.mytoolkit (bot/tool configs)")
console.print(" - ~/.xiaohe (settings, keys)")
console.print(" - ~/.mytoolkit (tool configs)")
console.print(f" - workspace: {get_workspace_root(create=False)}")
console.print(" - rc-file edits (PATH line, ANTHROPIC_* exports) and the claude CLI")
@@ -112,8 +137,8 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
# Deleting these dirs destroys settings and every saved credential —
# always require the full confirmation chain, even with --yes.
if not click.confirm(
"This also deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit — runtime, "
"bot config and ALL saved API keys. Continue?",
"This also deletes ~/.xiaohe, ~/.mytoolkit and any leftover "
"~/.metabot config and ALL saved API keys. Continue?",
default=False,
err=True,
):
@@ -133,9 +158,9 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
# Entry-point scripts in ~/.local/bin (defensive: pip removes its own).
local_bin = home / ".local" / "bin"
for name in BIN_NAMES:
for name in (*BIN_NAMES, *LEGACY_BIN_NAMES):
_remove_path(local_bin / name, removed)
for name in METABOT_COMPLETIONS:
for name in LEGACY_COMPLETIONS:
_remove_path(local_bin / "completions" / name, removed)
comp_dir = local_bin / "completions"
if comp_dir.is_dir() and not any(comp_dir.iterdir()):
@@ -148,8 +173,8 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
if remove_all:
_remove_path(home / ".xiaohe", removed)
_remove_path(home / ".metabot", removed)
_remove_path(home / ".mytoolkit", removed)
_remove_path(home / ".metabot", removed)
for warning in warnings:
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
+31 -266
View File
@@ -1,277 +1,42 @@
"""``xiaohe upgrade`` — download the latest runtime package and switch to it.
"""``upgrade`` — forward to ``xiaohe upgrade`` (xiaohe wheel)."""
Flow: query version (from the published install.sh) -> confirm -> download
tarball with a progress bar -> install to ~/.xiaohe/runtime/<version> and
repoint ``current`` (keep the two newest) -> reinstall CLI tools -> sync
workspace content. Credentials come from XIAOHE_USER/XIAOHE_PASS, --user/
--password, or an interactive prompt (never stored).
"""
from __future__ import annotations
import base64
import hashlib
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.error
import urllib.request
from pathlib import Path
import click
from rich.console import Console
from rich.progress import (
BarColumn,
DownloadColumn,
Progress,
SpinnerColumn,
TextColumn,
TimeRemainingColumn,
TransferSpeedColumn,
)
from myagents.commands.sync_workspace import (
_print_report,
sync_workspace,
)
from myagents.project_root import get_workspace_root
from myagents.settings import get_setting
stderr_console = Console(stderr=True)
console = Console()
DEFAULT_BASE_URL = "http://1.14.226.205:8088/xiaohe-agent"
KEEP_VERSIONS = 2
def _base_url() -> str:
return str(get_setting("vps_baseurl", "") or DEFAULT_BASE_URL).rstrip("/")
def _runtime_root() -> Path:
return Path.home() / ".xiaohe" / "runtime"
def _current_version() -> str:
current = _runtime_root() / "current"
if not current.is_symlink():
return ""
try:
return current.resolve(strict=True).name
except OSError:
return ""
def _auth_header(user: str, password: str) -> dict[str, str]:
token = base64.b64encode(f"{user}:{password}".encode()).decode()
return {"Authorization": f"Basic {token}"}
def _map_url_error(exc: Exception, url: str) -> click.ClickException:
if isinstance(exc, urllib.error.HTTPError) and exc.code in (401, 403):
return click.ClickException("Wrong account or password (server returned 401).")
if isinstance(exc, urllib.error.HTTPError):
return click.ClickException(f"Download failed: HTTP {exc.code}{url}")
reason = getattr(exc, "reason", exc)
return click.ClickException(f"Cannot reach download server: {reason}{url}")
def _fetch_text(url: str, user: str, password: str) -> str:
req = urllib.request.Request(url, headers=_auth_header(user, password))
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode("utf-8", errors="replace")
except (urllib.error.URLError, OSError) as exc:
raise _map_url_error(exc, url) from exc
def _download(url: str, user: str, password: str, dest: Path) -> None:
req = urllib.request.Request(url, headers=_auth_header(user, password))
try:
with urllib.request.urlopen(req, timeout=60) as resp:
total = int(resp.headers.get("Content-Length") or 0) or None
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
BarColumn(),
DownloadColumn(),
TransferSpeedColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
task = progress.add_task("Downloading runtime", total=total)
with dest.open("wb") as fh:
while chunk := resp.read(1 << 16):
fh.write(chunk)
progress.update(task, advance=len(chunk))
except (urllib.error.URLError, OSError) as exc:
raise _map_url_error(exc, url) from exc
def _parse_version(install_sh: str) -> str | None:
match = re.search(r'^VERSION="([^"]+)"', install_sh, re.MULTILINE)
return match.group(1) if match else None
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 16), b""):
digest.update(chunk)
return digest.hexdigest()
def _verify_checksum(
tarball: Path, sha256_text: str, url: str
) -> None:
"""Verify tarball against the published "<sha256> <name>" line."""
expected = sha256_text.split()[0].strip().lower() if sha256_text.split() else ""
if not expected or not all(c in "0123456789abcdef" for c in expected):
raise click.ClickException(f"Checksum file looks wrong — {url}")
actual = _sha256(tarball)
if actual != expected:
raise click.ClickException(
"Checksum mismatch — the download is corrupted or tampered with. "
"Aborting; nothing was installed."
)
def _extract(tarball: Path, dest: Path) -> None:
with tarfile.open(tarball) as tf:
try:
tf.extractall(dest, filter="data")
except TypeError: # Python < 3.11.4 lacks the filter argument
tf.extractall(dest)
def _pip_install(pkg_dir: Path) -> subprocess.CompletedProcess:
import os
import sysconfig
if os.environ.get("VIRTUAL_ENV") and shutil.which("uv"):
cmd = ["uv", "pip", "install", "-e", str(pkg_dir), "--quiet"]
else:
cmd = [sys.executable, "-m", "pip", "install"]
# Ubuntu 24.04+ marks the system Python externally-managed (PEP 668);
# without the flag pip refuses to install there at all.
stdlib = Path(sysconfig.get_path("stdlib"))
if (stdlib / "EXTERNALLY-MANAGED").exists():
cmd.append("--break-system-packages")
cmd += ["-e", str(pkg_dir), "--quiet"]
return subprocess.run(cmd, capture_output=True, text=True)
def _install_tools(runtime: Path) -> list[str]:
"""Reinstall pip/npm tools from the new runtime. Returns warnings."""
warnings: list[str] = []
for pkg in ("mytoolkit", "myagents"):
with console.status(f"Installing {pkg} ..."):
result = _pip_install(runtime / "contrib" / pkg)
if result.returncode != 0:
tail = (result.stderr or "").strip().splitlines()[-1:]
warnings.append(f"{pkg} install failed: {tail[0] if tail else 'unknown error'}")
metabot = runtime / "contrib" / "metabot"
if shutil.which("npm") and (metabot / "package.json").is_file():
steps = (["npm", "install", "--silent"], ["npm", "run", "build", "--silent"],
["npm", "run", "update-cli", "--silent"])
for cmd in steps:
with console.status(f"metabot: {' '.join(cmd[:2])} ..."):
result = subprocess.run(cmd, cwd=metabot, capture_output=True, text=True)
if result.returncode != 0:
warnings.append(f"metabot `{' '.join(cmd[:2])}` failed — rerun manually later")
break
elif not shutil.which("npm"):
warnings.append("npm not found — skipping metabot (xiaohe itself is unaffected)")
return warnings
def _prune() -> None:
versions = sorted(
(d for d in _runtime_root().iterdir() if d.is_dir() and not d.is_symlink()),
key=lambda d: d.stat().st_mtime,
reverse=True,
)
for old in versions[KEEP_VERSIONS:]:
shutil.rmtree(old)
@click.command("upgrade")
@click.option("--user", envvar="XIAOHE_USER", help="Download-site account (or set XIAOHE_USER)")
@click.option("--password", envvar="XIAOHE_PASS", help="Download-site password (or set XIAOHE_PASS)")
@click.option("--force", is_flag=True, help="Reinstall even if already up to date")
def upgrade_cmd(user: str | None, password: str | None, force: bool) -> None:
"""Upgrade xiaohe: fetch the latest runtime, reinstall tools, sync workspace."""
base = _base_url()
current = _current_version()
if not current:
@click.argument("version", required=False)
@click.option("--force", is_flag=True, help="Reinstall even if already on that version")
@click.option(
"--beta",
is_flag=True,
help="Forward to xiaohe upgrade --beta",
)
@click.option("--user", "user", default=None, hidden=True)
@click.option("--password", default=None, hidden=True)
def upgrade_cmd(
version: str | None,
force: bool,
beta: bool,
user: str | None,
password: str | None,
) -> None:
"""Forward to ``xiaohe upgrade`` (xiaohe wheel)."""
del user, password
xiaohe = shutil.which("xiaohe")
if not xiaohe:
raise click.ClickException(
"No installed runtime found (~/.xiaohe/runtime) — run install.sh first."
"Install xiaohe, then run: xiaohe upgrade"
)
user = str(user or click.prompt("Download account", err=True))
password = str(password or click.prompt("Password", hide_input=True, err=True))
console.print(f"[dim]Current version: {current}[/dim]")
with console.status("Checking latest version ..."):
install_sh = _fetch_text(f"{base}/install.sh", user, password)
latest = _parse_version(install_sh)
if not latest:
raise click.ClickException(
"Could not parse a version from install.sh — server content looks wrong."
)
if latest == current and not force:
console.print(f"[green]Already up to date ({latest}).[/green] Use --force to reinstall.")
return
if not force and not click.confirm(
f"New version {latest} (current: {current}). Upgrade now?", default=True, err=True
):
console.print("Cancelled.")
return
tmpdir = Path(tempfile.mkdtemp(prefix="xiaohe-upgrade-"))
try:
tarball = tmpdir / "xiaohe-agent-latest.tar.gz"
_download(f"{base}/xiaohe-agent-latest.tar.gz", user, password, tarball)
with console.status("Verifying checksum ..."):
sha_url = f"{base}/xiaohe-agent-latest.tar.gz.sha256"
_verify_checksum(tarball, _fetch_text(sha_url, user, password), sha_url)
target = _runtime_root() / latest
with console.status(f"Installing to {target} ..."):
extract_dir = tmpdir / "extract"
_extract(tarball, extract_dir)
inner = extract_dir / "workspace"
if not (inner / "setup.sh").is_file():
raise click.ClickException("Tarball looks wrong (setup.sh missing).")
if target.exists():
shutil.rmtree(target)
_runtime_root().mkdir(parents=True, exist_ok=True)
shutil.move(str(inner), str(target))
current_link = _runtime_root() / "current"
current_link.unlink(missing_ok=True)
current_link.symlink_to(target)
finally:
shutil.rmtree(tmpdir, ignore_errors=True)
warnings = _install_tools(target)
_prune()
workspace = get_workspace_root(create=False)
if workspace.is_dir():
try:
report = sync_workspace(workspace)
except click.ClickException as exc:
# e.g. dev machine: workspace is a git checkout — not a failure.
stderr_console.print(f"[yellow]workspace sync skipped: {exc.message}[/yellow]")
else:
_print_report(workspace, report)
else:
console.print(f"[yellow]workspace {workspace} missing — run 'xiaohe init' first.[/yellow]")
for warning in warnings:
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
console.print(
f"\n[bold green]Upgraded: {current} -> {latest}[/bold green] "
"(open a new terminal; roll back anytime with 'xiaohe switch')"
)
cmd = [xiaohe, "upgrade"]
if version:
cmd.append(version)
if force:
cmd.append("--force")
if beta:
cmd.append("--beta")
raise SystemExit(subprocess.call(cmd))
+29 -43
View File
@@ -1,7 +1,8 @@
"""``xiaohe version`` — show whether the CLI runs a dev checkout or an installed runtime."""
"""``version`` — show package install location."""
import shutil
import subprocess
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
import click
@@ -14,11 +15,15 @@ def _git_describe(tree: Path) -> str:
try:
describe = subprocess.run(
["git", "-C", str(tree), "describe", "--tags", "--dirty", "--always"],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
branch = subprocess.run(
["git", "-C", str(tree), "branch", "--show-current"],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.TimeoutExpired):
return "unknown"
@@ -30,57 +35,38 @@ def _git_describe(tree: Path) -> str:
def describe_tree(tree: Path) -> tuple[str, str]:
"""Classify a package source tree: ('installed'|'development'|'unknown', detail)."""
parts = tree.parts
if ".xiaohe" in parts and "runtime" in parts:
try:
idx = parts.index("runtime")
return "installed", parts[idx + 1]
except (ValueError, IndexError):
return "installed", "unknown"
if (tree / ".git").exists():
"""Classify a package source tree."""
if (tree / ".git").exists() or (tree.parent / ".git").exists():
# packages/myagents → monorepo root may hold .git
probe = tree
for _ in range(3):
if (probe / ".git").exists():
return "development", _git_describe(probe)
probe = probe.parent
return "development", _git_describe(tree)
return "unknown", ""
def _pkg_tree(module_file: str) -> Path:
# <repo>/myagents/__init__.py -> <repo>
return Path(module_file).resolve().parents[1]
def _report_pkg(label: str, module_file: str) -> str:
tree = _pkg_tree(module_file)
mode, detail = describe_tree(tree)
line = {
"installed": f"installed runtime [cyan]{detail}[/cyan]",
"development": f"[green]development checkout[/green] — {detail}",
}.get(mode, "unknown")
console.print(f" {label}: {line}")
console.print(f" [dim]{tree}[/dim]")
return mode
@click.command("version")
def version_cmd() -> None:
"""Show which tree the CLI runs from: dev checkout or installed runtime."""
"""Show which tree the CLI runs from."""
import myagents
bin_path = shutil.which("xiaohe") or "?"
console.print(f"[bold]xiaohe[/bold] (bin: {bin_path})")
mode = _report_pkg("myagents", myagents.__file__)
bin_path = shutil.which("myagents") or "?"
console.print(f"[bold]myagents[/bold] (bin: {bin_path})")
try:
import mytoolkit
console.print(f" version: [cyan]{version('myagents')}[/cyan]")
except PackageNotFoundError:
pass
_report_pkg("mytoolkit", mytoolkit.__file__)
except ImportError:
console.print(" mytoolkit: [yellow]not importable[/yellow]")
current = Path.home() / ".xiaohe" / "runtime" / "current"
if current.is_symlink():
console.print(f"runtime snapshot: [cyan]{current.resolve().name}[/cyan] [dim]({current.parent})[/dim]")
if mode == "installed":
console.print(
"\n[dim]Dev machine? Point the bins at your repo instead:[/dim]\n"
" pip install -e <repo>/contrib/mytoolkit -e <repo>/contrib/myagents"
)
tree = _pkg_tree(myagents.__file__)
mode, detail = describe_tree(tree)
if mode == "development":
console.print(f" source: [green]development[/green] — {detail}")
else:
console.print(f" source: installed [dim]{tree}[/dim]")
+25 -48
View File
@@ -1,46 +1,16 @@
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, xiaohe."""
import re
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, mycursor."""
from myagents.launcher import build_cli
claude_cli = build_cli("claude", prog_name="myclaude")
kimi_cli = build_cli("kimi", prog_name="mykimi")
codex_cli = build_cli("codex", prog_name="mycodex")
hermes_cli = build_cli("hermes", prog_name="myhermes")
_KNOWN_BACKENDS = ("claude", "kimi", "codex", "hermes")
def default_agent() -> str:
"""Default backend for the ``xiaohe`` alias.
Reads ``default_agent`` from xiaohe settings (accepts both "claude" and
"myclaude" spellings); falls back to claude.
"""
from myagents.settings import get_setting
agent = str(get_setting("default_agent", "") or "")
agent = re.sub(r"^my", "", agent.strip().lower())
return agent if agent in _KNOWN_BACKENDS else "claude"
def build_xiaohe_cli():
"""The ``xiaohe`` command group: agent forwarding + init/sync/upgrade/switch/uninstall/version."""
from myagents.commands.switch import switch_cmd
from myagents.commands.sync_workspace import init_cmd, sync_cmd
from myagents.commands.uninstall import uninstall_cmd
from myagents.commands.upgrade import upgrade_cmd
from myagents.commands.version import version_cmd
xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True)
xiaohe_cli.add_command(init_cmd)
xiaohe_cli.add_command(sync_cmd)
xiaohe_cli.add_command(upgrade_cmd)
xiaohe_cli.add_command(switch_cmd)
xiaohe_cli.add_command(uninstall_cmd)
xiaohe_cli.add_command(version_cmd)
return xiaohe_cli
# offer_install=True so a missing backend CLI (e.g. `claude`) triggers the
# interactive "Install now? [y/N]" prompt instead of a bare not-found error.
# Backends without an install_cmd (hermes/cursor) fall back to the manual msg.
claude_cli = build_cli("claude", prog_name="myclaude", offer_install=True)
kimi_cli = build_cli("kimi", prog_name="mykimi", offer_install=True)
codex_cli = build_cli("codex", prog_name="mycodex", offer_install=True)
hermes_cli = build_cli("hermes", prog_name="myhermes", offer_install=True)
cursor_cli = build_cli("cursor", prog_name="mycursor", offer_install=True)
dsh_cli = build_cli("dsh", prog_name="mydsh", offer_install=True)
def _progs():
@@ -52,7 +22,8 @@ def _progs():
("mykimi", lambda: kimi_cli),
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
("xiaohe", build_xiaohe_cli),
("mycursor", lambda: cursor_cli),
("mydsh", lambda: dsh_cli),
]
@@ -96,15 +67,21 @@ def hermes_main() -> None:
hermes_cli()
def xiaohe_main() -> None:
"""Run ``xiaohe``: default-agent launcher plus init/sync subcommands.
Same options as the corresponding ``my<agent>`` command; when the
underlying CLI is missing, offers to install it interactively.
"""
def cursor_main() -> None:
"""Run ``mycursor``."""
from myagents.commands.completion_install import (
ensure_completions_installed,
)
ensure_completions_installed(_progs())
build_xiaohe_cli()()
cursor_cli()
def dsh_main() -> None:
"""Run ``mydsh``."""
from myagents.commands.completion_install import (
ensure_completions_installed,
)
ensure_completions_installed(_progs())
dsh_cli()
+1341 -62
View File
File diff suppressed because it is too large Load Diff
+287
View File
@@ -0,0 +1,287 @@
"""Anthropic -> Ollama proxy with system-message hoisting.
Ollama already speaks ``/v1/messages``. Claude Code still injects
``role: "system"`` turns inside ``messages[]`` (agent-types, hooks, skills).
Qwen-family chat templates require a leading system message, so Ollama
returns HTTP 500. This process is a thin HTTP proxy: hoist those turns
into the top-level Anthropic ``system`` field, then forward every other
request unchanged.
Listen: ``XIAOHE_OLLAMA_ADAPTER_HOST`` / ``XIAOHE_OLLAMA_ADAPTER_PORT``
(default 127.0.0.1:8199). Upstream: ``OLLAMA_HOST`` (default
http://127.0.0.1:11434), same as the ollama CLI (scheme optional).
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
from http.client import HTTPConnection, HTTPSConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
from urllib.request import Request, urlopen
DEFAULT_HOST = os.environ.get("XIAOHE_OLLAMA_ADAPTER_HOST", "127.0.0.1")
DEFAULT_PORT = int(os.environ.get("XIAOHE_OLLAMA_ADAPTER_PORT", "8199"))
OLLAMA_DEFAULT_PORT = 11434
UPSTREAM_TIMEOUT = int(os.environ.get("XIAOHE_OLLAMA_ADAPTER_TIMEOUT", "600"))
OLLAMA_BASE = os.environ.get("OLLAMA_HOST", f"http://127.0.0.1:{OLLAMA_DEFAULT_PORT}")
_LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1", "0.0.0.0"})
_log = logging.getLogger("myagents.ollama_adapter")
def listen_url() -> str:
"""URL Claude Code should use as ``ANTHROPIC_BASE_URL``."""
return f"http://{DEFAULT_HOST}:{DEFAULT_PORT}"
def is_adapter_endpoint(base_url: str) -> bool:
"""True when *base_url* points at this adapter's listen address."""
raw = (base_url or "").strip()
if not raw:
return False
if "://" not in raw:
raw = f"http://{raw}"
left = urlsplit(raw)
right = urlsplit(listen_url())
left_host = (left.hostname or "").lower()
right_host = (right.hostname or "").lower()
if left_host in _LOOPBACK and right_host in _LOOPBACK:
hosts_match = True
else:
hosts_match = left_host == right_host
left_scheme = (left.scheme or "http").lower()
left_port = left.port or (443 if left_scheme == "https" else 80)
right_port = right.port or DEFAULT_PORT
return hosts_match and left_port == right_port
def upstream_target(base: str | None = None) -> tuple[str, str, int]:
"""Return ``(scheme, host, port)`` for an Ollama base URL or host:port."""
raw = (base if base is not None else OLLAMA_BASE).strip()
if not raw:
raw = f"http://127.0.0.1:{OLLAMA_DEFAULT_PORT}"
if "://" not in raw:
raw = f"http://{raw}"
parts = urlsplit(raw)
scheme = (parts.scheme or "http").lower()
host = parts.hostname or "127.0.0.1"
if parts.port:
port = parts.port
elif scheme == "https":
port = 443
else:
port = OLLAMA_DEFAULT_PORT
return scheme, host, port
def parse_num_ctx(parameters: str | None) -> int | None:
"""Read Ollama's runtime ``num_ctx`` from a ``/api/show`` parameters blob."""
if not parameters:
return None
for line in parameters.splitlines():
parts = line.split()
if len(parts) >= 2 and parts[0].lower() == "num_ctx":
try:
n = int(parts[1])
except ValueError:
continue
if n >= 1024:
return n
return None
def runtime_num_ctx(model: str, base: str | None = None) -> int | None:
"""Ollama's allocated context (``num_ctx``), not architecture max.
``/api/tags`` reports ``details.context_length`` = 262144 for Qwen3.5
even when the Modelfile capped ``num_ctx`` at 32k. Claude Code must
be told the runtime cap or it will send prompts the daemon rejects.
"""
name = (model or "").strip()
if not name:
return None
scheme, host, port = upstream_target(base)
req = Request(
f"{scheme}://{host}:{port}/api/show",
data=json.dumps({"name": name}).encode(),
headers={"content-type": "application/json"},
method="POST",
)
try:
with urlopen(req, timeout=2) as resp:
data = json.loads(resp.read().decode())
except (OSError, json.JSONDecodeError, ValueError):
return None
if not isinstance(data, dict):
return None
raw = data.get("parameters")
return parse_num_ctx(raw if isinstance(raw, str) else None)
def _system_blocks(raw: object) -> list[dict]:
"""Normalize a top-level ``system`` value or message content to blocks."""
if isinstance(raw, str) and raw:
return [{"type": "text", "text": raw}]
if not isinstance(raw, list):
return []
blocks: list[dict] = []
for item in raw:
if isinstance(item, str) and item:
blocks.append({"type": "text", "text": item})
elif (
isinstance(item, dict)
and item.get("type") == "text"
and item.get("text")
):
blocks.append(dict(item))
return blocks
def normalize_system(body: dict) -> dict:
"""Move ``role: "system"`` messages into the top-level ``system`` field.
Mutates and returns *body*. Non-system messages keep their order.
Extra fields on text blocks (e.g. ``cache_control``) are preserved.
"""
sys_blocks = _system_blocks(body.get("system"))
kept: list = []
for message in body.get("messages") or []:
if isinstance(message, dict) and message.get("role") == "system":
sys_blocks.extend(_system_blocks(message.get("content")))
else:
kept.append(message)
body["system"] = sys_blocks
body["messages"] = kept
return body
def _upstream_conn() -> HTTPConnection:
scheme, host, port = upstream_target()
if scheme == "https":
return HTTPSConnection(host, port, timeout=UPSTREAM_TIMEOUT)
return HTTPConnection(host, port, timeout=UPSTREAM_TIMEOUT)
class _Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _json(self, status: int, data: dict) -> None:
body = json.dumps(data).encode()
self.send_response(status)
self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(body)))
self.send_header("connection", "close")
self.close_connection = True
self.end_headers()
self.wfile.write(body)
def _outgoing_headers(self, raw: bytes) -> dict[str, str]:
headers: dict[str, str] = {}
if raw:
headers["content-type"] = (
self.headers.get("content-type") or "application/json"
)
headers["content-length"] = str(len(raw))
for key in (
"x-api-key",
"authorization",
"anthropic-version",
"anthropic-beta",
):
val = self.headers.get(key)
if val:
headers[key] = val
if "x-api-key" not in headers and "authorization" not in headers:
headers["x-api-key"] = "ollama"
return headers
def _relay_response(self, resp) -> None:
"""Copy upstream status/body and always terminate the client response.
HTTP/1.1 keep-alive without Content-Length leaves the client waiting
forever (UI stuck on "working").
"""
self.send_response(resp.status)
content_type = resp.getheader("content-type")
if content_type:
self.send_header("content-type", content_type)
content_length = resp.getheader("content-length")
if content_length:
self.send_header("content-length", content_length)
else:
self.send_header("cache-control", "no-cache")
self.send_header("connection", "close")
self.close_connection = True
self.end_headers()
try:
while True:
chunk = resp.read(65536)
if not chunk:
break
self.wfile.write(chunk)
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
return
def _forward(self, method: str, path: str, raw: bytes) -> None:
headers = self._outgoing_headers(raw)
conn = _upstream_conn()
try:
conn.request(method, path, body=raw or None, headers=headers)
resp = conn.getresponse()
except OSError as exc:
_log.warning("upstream %s failed: %s", OLLAMA_BASE, exc)
self._json(502, {"error": {"message": f"upstream error: {exc}"}})
return
try:
self._relay_response(resp)
finally:
conn.close()
def do_GET(self) -> None:
if self.path.split("?", 1)[0] == "/health":
self._json(200, {"status": "ok", "upstream": OLLAMA_BASE})
return
self._forward("GET", self.path, b"")
def do_HEAD(self) -> None:
self._forward("HEAD", self.path, b"")
def do_POST(self) -> None:
length = int(self.headers.get("content-length") or 0)
raw = self.rfile.read(length) if length else b""
if self.path.split("?", 1)[0] == "/v1/messages":
try:
body = json.loads(raw)
except json.JSONDecodeError:
body = None
if isinstance(body, dict):
normalize_system(body)
raw = json.dumps(body).encode()
self._forward("POST", self.path, raw)
def log_message(self, format: str, *args: object) -> None: # noqa: A002
if args:
format = format % args
_log.info("%s %s", self.command, format)
def main() -> None:
logging.basicConfig(level=logging.INFO)
server = ThreadingHTTPServer((DEFAULT_HOST, DEFAULT_PORT), _Handler)
server.daemon_threads = True
scheme, host, port = upstream_target()
print(
f"ollama-adapter: {listen_url()} -> {scheme}://{host}:{port}",
flush=True,
)
with contextlib.suppress(KeyboardInterrupt):
server.serve_forever()
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
"""Secure API key storage for backend switching.
Reads keys from two sources, in priority order:
1. ``~/.xiaohe/agent/config.json`` under ``keys.*`` — the primary store for
myagents/xiaohe secrets.
2. ``~/.mytoolkit/config.json`` under ``keys.*`` — for backward compatibility
with keys already managed by mytoolkit.
Writes always go to ``~/.xiaohe/agent/config.json`` so that xiaohe-managed
keys shadow mytoolkit keys without modifying them.
"""
from __future__ import annotations
import json
import os
import stat
import tempfile
from pathlib import Path
from typing import Any
XIAOHE_CONFIG_DIR = Path.home() / ".xiaohe" / "agent"
XIAOHE_CONFIG_PATH = XIAOHE_CONFIG_DIR / "config.json"
MYTOOLKIT_CONFIG_PATH = Path.home() / ".mytoolkit" / "config.json"
def _load_json(path: Path) -> dict[str, Any]:
"""Load JSON from path; return empty dict on missing/corrupt."""
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return {}
try:
data = json.loads(text)
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def _atomic_write(path: Path, data: dict[str, Any]) -> None:
"""Write JSON atomically and restrict permissions on Unix."""
path.parent.mkdir(parents=True, exist_ok=True)
serialized = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(serialized)
if os.name != "nt":
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _read_key_from_config(path: Path, key_name: str) -> str | None:
"""Read a single key from a config file's ``keys`` section."""
data = _load_json(path)
value = data.get("keys", {}).get(key_name)
return value if isinstance(value, str) and value else None
def load_xiaohe_config() -> dict[str, Any]:
"""Load the full ``~/.xiaohe/agent/config.json``."""
return _load_json(XIAOHE_CONFIG_PATH)
def save_xiaohe_config(data: dict[str, Any]) -> None:
"""Persist the full ``~/.xiaohe/agent/config.json``."""
_atomic_write(XIAOHE_CONFIG_PATH, data)
def get_key(key_name: str) -> str | None:
"""Return a key, preferring the xiaohe store over mytoolkit.
Returns ``None`` when no key is stored or the stored value is empty.
"""
value = _read_key_from_config(XIAOHE_CONFIG_PATH, key_name)
if value:
return value
return _read_key_from_config(MYTOOLKIT_CONFIG_PATH, key_name)
def set_key(key_name: str, value: str) -> None:
"""Store a key in ``~/.xiaohe/agent/config.json``.
Empty values are rejected.
"""
if not isinstance(value, str) or not value.strip():
raise ValueError("API key cannot be empty")
data = load_xiaohe_config()
if "keys" not in data or not isinstance(data["keys"], dict):
data["keys"] = {}
data["keys"][key_name] = value
save_xiaohe_config(data)
def remove_key(key_name: str) -> bool:
"""Remove a key from ``~/.xiaohe/agent/config.json``.
Returns ``True`` if the key existed and was removed.
"""
data = load_xiaohe_config()
keys = data.get("keys", {})
if not isinstance(keys, dict):
return False
if key_name not in keys:
return False
del keys[key_name]
if not keys:
data.pop("keys", None)
save_xiaohe_config(data)
return True
def has_key(key_name: str) -> bool:
"""Return whether a non-empty key exists in either store."""
return get_key(key_name) is not None
+111
View File
@@ -0,0 +1,111 @@
"""Read-only access to xiaohe hosted-session SQLite stores.
myagents runs against Claude Code CLI sessions (``~/.claude/projects``), but the
xiaohe client also drives Claude Code and keeps a hosted-session index for the
same jsonl transcripts. ``myclaude -r`` unifies both sources so client sessions
and plain CLI sessions appear in one resumable list.
Every accessor takes explicit path parameters and degrades to an empty result
when a store is missing or unreadable — a machine without xiaohe data behaves
exactly as before.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from pathlib import Path
_SESSIONS_DB = Path.home() / ".xiaohe" / "data" / "sessions-claude.db"
_WORKSPACES_DB = Path.home() / ".xiaohe" / "data" / "workspaces.db"
def _connect_readonly(path: Path) -> sqlite3.Connection | None:
"""Open a SQLite store read-only, or ``None`` when unavailable."""
try:
return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except (sqlite3.Error, OSError):
return None
def workspace_ids_for_cwd(
chat_cwd: Path, workspaces_db: Path = _WORKSPACES_DB
) -> list[str]:
"""Workspace ids whose bind path resolves to ``chat_cwd`` (e.g. ``primary``).
Empty when the store is missing or no workspace binds to the cwd.
"""
conn = _connect_readonly(workspaces_db)
if conn is None:
return []
try:
rows = conn.execute(
"SELECT workspace_id, workspace_path FROM workspaces "
"WHERE workspace_path IS NOT NULL"
).fetchall()
except sqlite3.Error:
return []
finally:
conn.close()
target = chat_cwd.resolve()
return [
str(rid)
for rid, raw_path in rows
if raw_path and Path(str(raw_path)).expanduser().resolve() == target
]
@dataclass(frozen=True)
class XiaoheSession:
"""Hosted-session index row for one cli_session_id (jsonl UUID)."""
cli_session_id: str
xiaohe_session_id: str
title: str | None
status: str | None
updated_at: float | None
def session_index(
workspace_ids: list[str], sessions_db: Path = _SESSIONS_DB
) -> dict[str, XiaoheSession]:
"""Map ``cli_session_id`` → hosted-session row for the given workspaces."""
if not workspace_ids:
return {}
conn = _connect_readonly(sessions_db)
if conn is None:
return {}
placeholders = ",".join("?" for _ in workspace_ids)
query = (
"SELECT cli_session_id, session_id, title, status, updated_at "
"FROM sessions "
f"WHERE workspace_id IN ({placeholders}) AND cli_session_id IS NOT NULL"
)
try:
rows = conn.execute(query, workspace_ids).fetchall()
except sqlite3.Error:
return {}
finally:
conn.close()
index: dict[str, XiaoheSession] = {}
for cli_id, xh_id, title, status, ts in rows:
index[str(cli_id)] = XiaoheSession(
cli_session_id=str(cli_id),
xiaohe_session_id=str(xh_id),
title=str(title) if title else None,
status=str(status) if status else None,
updated_at=float(ts) if isinstance(ts, (int, float)) else None,
)
return index
def default_sessions_db() -> Path:
"""Default hosted-sessions store path (kept importable for tests)."""
return _SESSIONS_DB
def default_workspaces_db() -> Path:
"""Default workspace store path (kept importable for tests)."""
return _WORKSPACES_DB
+3 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "myagents"
version = "0.1.0"
version = "0.2.1"
description = "Myagents CLI: unified launcher for AI coding agents"
requires-python = ">=3.10"
dependencies = [
@@ -14,7 +14,8 @@ myclaude = "myagents.entrypoints:claude_main"
mykimi = "myagents.entrypoints:kimi_main"
mycodex = "myagents.entrypoints:codex_main"
myhermes = "myagents.entrypoints:hermes_main"
xiaohe = "myagents.entrypoints:xiaohe_main"
mycursor = "myagents.entrypoints:cursor_main"
mydsh = "myagents.entrypoints:dsh_main"
[dependency-groups]
dev = ["pytest>=8.0"]
+1 -1
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import os
import sys
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes")
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes", "mycursor", "mydsh")
def _remove_link(link: str, want: str) -> bool:
-27
View File
@@ -1,27 +0,0 @@
# Temporary files
tmp/
*.tmp
*.bak
*.log
# macOS
.DS_Store
# Editor
*.swp
*.swo
*~
.vscode/
.idea/
# Download artifacts
*.zip
*.tar.gz
# Node
node_modules/
# Python
__pycache__/
*.pyc
.venv/
-80
View File
@@ -1,80 +0,0 @@
# Claude 个人助理 — 工作区配置
你是 Claude,用户的个人助理,协助日常办公、学术研究和软件研发工作。
---
## 1. 用户画像
- **姓名**: (请填写)
- **称呼**: (请填写)
- **身份**: (请填写)
- **专精领域**: (请填写)
- **经验水平**: (初级 / 中级 / 高级)
### 沟通偏好
- **风格**: 简洁直接,不说废话
- **解释**: 关键部分详细,熟悉内容简洁
- **代码**: PEP8 + Black,需要类型注解
- **测试**: 高度重视
---
## 2. 工作模式
切换命令: "切换到专家模式" / "用导师的方式" / "执行这个任务"
| 模式 | 特点 | 适用场景 |
|------|------|----------|
| **协作者** (默认) | 平等对话,共同探索 | 需求讨论、架构设计 |
| **专家** | 直接给最佳实践,减少解释 | 技术实现、代码审查 |
| **导师** | 引导思考,多问少答 | 学习、概念理解 |
| **执行者** | 最小化交互,快速执行 | 明确任务、批量操作 |
### 知识检索
查询资料时直接用 `Grep`/`Read` 工具。
---
## 3. Workspace 结构
```
workspace/
├── .agents/ # Agent 配置(同时提供 .claude 软链接)
├── .obsidian/ # Obsidian 配置(可选)
├── .git/ # git 仓库
├── AGENTS.md # 本文件(个人助理配置,同时提供 CLAUDE.md 软链接)
├── tmp/ # 临时文件
└── ... # 按需添加项目目录或符号链接
```
可按需创建符号链接指向常用目录(如文献库、项目目录等),方便 Claude 快速访问。
---
## 4. 临时文件管理
- **截图/下载** 默认写入 `tmp/`,避免污染根目录
- 定期清理 `tmp/` 中过期的临时文件
---
## 5. 沟通风格
- **简洁**: 不说废话
- **透明**: 复杂任务同步进度
- **主动**: 发现问题时提醒,识别更好方法时建议
### 回复结构
1. **快速总结** (1-2句): 确认理解
2. **核心回答**: 直接给答案或结果
3. **后续建议** (可选): 相关建议
---
## 6. 指令优先级
1. **明确指令** > 默认规则
2. **当前对话上下文** > 历史记忆
3. **本文件 (AGENTS.md)** > 全局设置
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+160
View File
@@ -0,0 +1,160 @@
"""Tests for myagents.commands.provider (xiaohe switch provider)."""
import json
from pathlib import Path
import pytest
from click.testing import CliRunner
from myagents import claude_settings as claude_settings_mod
from myagents import secrets as secrets_mod
from myagents.commands.provider import provider_cmd
@pytest.fixture()
def fake_config_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]:
xiaohe_dir = tmp_path / ".xiaohe" / "agent"
claude_dir = tmp_path / ".claude"
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_DIR", xiaohe_dir)
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_PATH", xiaohe_dir / "config.json")
monkeypatch.setattr(
claude_settings_mod, "_settings_path", lambda: claude_dir / "settings.json"
)
return {"xiaohe": xiaohe_dir, "claude": claude_dir}
class TestProviderList:
def test_list_shows_providers(self) -> None:
result = CliRunner().invoke(provider_cmd, ["list"])
assert result.exit_code == 0
assert "deepseek" in result.output
assert "kimi" in result.output
assert "kimi-code" in result.output
assert "claude" in result.output
class TestProviderCurrent:
def test_current_shows_default_when_unset(
self, fake_config_dirs: dict[str, Path]
) -> None:
result = CliRunner().invoke(provider_cmd, ["current"])
assert result.exit_code == 0
assert "Claude (default" in result.output or "Claude (official" in result.output
class TestProviderSwitch:
def test_bare_invocation_lists(self) -> None:
result = CliRunner().invoke(provider_cmd)
assert result.exit_code == 0
assert "Available providers" in result.output
assert "deepseek" in result.output
def test_switch_to_deepseek(self, fake_config_dirs: dict[str, Path]) -> None:
result = CliRunner().invoke(
provider_cmd, ["deepseek", "--key", "sk-test", "--yes"]
)
assert result.exit_code == 0, result.output
settings_path = fake_config_dirs["claude"] / "settings.json"
settings = json.loads(settings_path.read_text())
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.deepseek.com/anthropic"
def test_switch_to_kimi_code(self, fake_config_dirs: dict[str, Path]) -> None:
result = CliRunner().invoke(
provider_cmd, ["kimi-code", "--key", "sk-test", "--yes"]
)
assert result.exit_code == 0, result.output
settings_path = fake_config_dirs["claude"] / "settings.json"
settings = json.loads(settings_path.read_text())
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.kimi.com/coding"
assert settings["env"]["ANTHROPIC_MODEL"] == "kimi-for-coding"
def test_unknown_provider_errors(self) -> None:
result = CliRunner().invoke(provider_cmd, ["openai", "--yes"])
assert result.exit_code != 0
assert "Unknown provider" in result.output
def test_prompts_for_missing_key(self, fake_config_dirs: dict[str, Path]) -> None:
result = CliRunner().invoke(
provider_cmd, ["deepseek", "--yes"], input="sk-from-prompt\n"
)
assert result.exit_code == 0, result.output
settings_path = fake_config_dirs["claude"] / "settings.json"
settings = json.loads(settings_path.read_text())
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-from-prompt"
def test_cancelled_does_not_write(self, fake_config_dirs: dict[str, Path]) -> None:
result = CliRunner().invoke(
provider_cmd, ["deepseek", "--key", "sk-test"], input="n\n"
)
assert result.exit_code == 0
assert "Cancelled" in result.output
settings_path = fake_config_dirs["claude"] / "settings.json"
assert not settings_path.exists()
def test_model_override(self, fake_config_dirs: dict[str, Path]) -> None:
result = CliRunner().invoke(
provider_cmd, ["deepseek", "--key", "sk-test", "--model", "custom", "--yes"]
)
assert result.exit_code == 0, result.output
settings_path = fake_config_dirs["claude"] / "settings.json"
settings = json.loads(settings_path.read_text())
assert settings["env"]["ANTHROPIC_MODEL"] == "custom"
def test_claude_clears_third_party(self, fake_config_dirs: dict[str, Path]) -> None:
settings_path = fake_config_dirs["claude"] / "settings.json"
fake_config_dirs["claude"].mkdir(parents=True)
settings_path.write_text(
json.dumps(
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-test",
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
}
}
)
)
result = CliRunner().invoke(provider_cmd, ["claude", "--yes"])
assert result.exit_code == 0, result.output
settings = json.loads(settings_path.read_text())
assert "ANTHROPIC_AUTH_TOKEN" not in settings.get("env", {})
class TestProviderKeySet:
def test_key_set_stores_in_xiaohe_config(self, fake_config_dirs: dict[str, Path]) -> None:
result = CliRunner().invoke(
provider_cmd, ["key", "set", "deepseek", "--key", "sk-test"]
)
assert result.exit_code == 0, result.output
config_path = fake_config_dirs["xiaohe"] / "config.json"
data = json.loads(config_path.read_text())
assert data["keys"]["deepseek"] == "sk-test"
def test_key_set_for_claude_errors(self) -> None:
result = CliRunner().invoke(
provider_cmd, ["key", "set", "claude", "--key", "sk-test"]
)
assert result.exit_code != 0
assert "ANTHROPIC_API_KEY" in result.output
class TestProviderKeyRm:
def test_key_rm_removes_stored_key(self, fake_config_dirs: dict[str, Path]) -> None:
config_path = fake_config_dirs["xiaohe"] / "config.json"
fake_config_dirs["xiaohe"].mkdir(parents=True)
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
result = CliRunner().invoke(provider_cmd, ["key", "rm", "deepseek", "--yes"])
assert result.exit_code == 0, result.output
data = json.loads(config_path.read_text())
assert "deepseek" not in data.get("keys", {})
def test_key_rm_without_yes_confirms(self, fake_config_dirs: dict[str, Path]) -> None:
config_path = fake_config_dirs["xiaohe"] / "config.json"
fake_config_dirs["xiaohe"].mkdir(parents=True)
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
result = CliRunner().invoke(provider_cmd, ["key", "rm", "deepseek"], input="y\n")
assert result.exit_code == 0, result.output
data = json.loads(config_path.read_text())
assert "deepseek" not in data.get("keys", {})
+94
View File
@@ -0,0 +1,94 @@
"""Tests for myagents.backends."""
import pytest
from myagents.backends import (
build_provider_env,
detect_provider,
get_provider,
is_third_party,
list_providers,
normalize_provider_id,
)
class TestProviderRegistry:
def test_list_providers_includes_all(self) -> None:
ids = {p.id for p in list_providers()}
assert ids == {"deepseek", "kimi", "kimi-code", "claude"}
def test_get_provider_by_id(self) -> None:
provider = get_provider("kimi")
assert provider.id == "kimi"
assert provider.base_url == "https://api.moonshot.ai/anthropic"
def test_get_provider_normalizes_name(self) -> None:
assert get_provider("Kimi").id == "kimi"
assert get_provider("kimi_code").id == "kimi-code"
def test_unknown_provider_raises(self) -> None:
with pytest.raises(KeyError):
get_provider("openai")
class TestNormalizeProviderId:
def test_hyphenates_underscores(self) -> None:
assert normalize_provider_id("kimi_code") == "kimi-code"
def test_lowercases(self) -> None:
assert normalize_provider_id("DeepSeek") == "deepseek"
class TestBuildProviderEnv:
def test_deepseek_env(self) -> None:
provider = get_provider("deepseek")
env = build_provider_env(provider, "sk-test")
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
assert env["ANTHROPIC_BASE_URL"] == "https://api.deepseek.com/anthropic"
assert env["ANTHROPIC_MODEL"] == "deepseek-v4-pro"
assert env["ANTHROPIC_DEFAULT_FABLE_MODEL"] == "deepseek-v4-pro"
def test_kimi_code_env(self) -> None:
provider = get_provider("kimi-code")
env = build_provider_env(provider, "sk-test")
assert env["ANTHROPIC_BASE_URL"] == "https://api.kimi.com/coding"
assert env["ANTHROPIC_MODEL"] == "kimi-for-coding"
def test_model_override(self) -> None:
provider = get_provider("kimi")
env = build_provider_env(provider, "sk-test", model="kimi-k2.5")
assert env["ANTHROPIC_MODEL"] == "kimi-k2.5"
def test_official_claude_returns_empty(self) -> None:
provider = get_provider("claude")
assert build_provider_env(provider, "sk-test") == {}
class TestDetectProvider:
def test_detects_deepseek(self) -> None:
env = {"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic"}
assert detect_provider(env) == get_provider("deepseek")
def test_detects_kimi_code(self) -> None:
env = {"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding"}
assert detect_provider(env) == get_provider("kimi-code")
def test_no_base_url_with_api_key_is_claude(self) -> None:
env = {"ANTHROPIC_API_KEY": "sk-test"}
assert detect_provider(env) == get_provider("claude")
def test_empty_env_returns_none(self) -> None:
assert detect_provider({}) is None
def test_unknown_base_url_returns_none(self) -> None:
assert detect_provider({"ANTHROPIC_BASE_URL": "https://example.com"}) is None
class TestIsThirdParty:
def test_third_party_providers(self) -> None:
assert is_third_party(get_provider("deepseek"))
assert is_third_party(get_provider("kimi"))
assert is_third_party(get_provider("kimi-code"))
def test_claude_is_not_third_party(self) -> None:
assert not is_third_party(get_provider("claude"))
+139
View File
@@ -0,0 +1,139 @@
"""Tests for myagents.claude_settings."""
import json
from pathlib import Path
import pytest
from myagents import claude_settings as cs_mod
from myagents.backends import get_provider
@pytest.fixture()
def fake_settings_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / ".claude" / "settings.json"
monkeypatch.setattr(cs_mod, "_settings_path", lambda: path)
return path
class TestLoadSaveSettings:
def test_load_missing_returns_empty(self, fake_settings_path: Path) -> None:
assert cs_mod.load_settings() == {}
def test_save_and_load_roundtrip(self, fake_settings_path: Path) -> None:
cs_mod.save_settings({"effortLevel": "medium"})
assert cs_mod.load_settings()["effortLevel"] == "medium"
def test_load_corrupt_returns_empty(self, fake_settings_path: Path) -> None:
fake_settings_path.parent.mkdir(parents=True)
fake_settings_path.write_text("{not json")
assert cs_mod.load_settings() == {}
class TestApplyProvider:
def test_applies_deepseek_env(self, fake_settings_path: Path) -> None:
settings = cs_mod.apply_provider(get_provider("deepseek"), key="sk-test")
env = settings["env"]
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
assert env["ANTHROPIC_BASE_URL"] == "https://api.deepseek.com/anthropic"
assert env["ANTHROPIC_MODEL"] == "deepseek-v4-pro"
assert env["ANTHROPIC_DEFAULT_FABLE_MODEL"] == "deepseek-v4-pro"
def test_model_override(self, fake_settings_path: Path) -> None:
settings = cs_mod.apply_provider(
get_provider("kimi"), key="sk-test", model="kimi-k2.5"
)
assert settings["env"]["ANTHROPIC_MODEL"] == "kimi-k2.5"
def test_base_url_override(self, fake_settings_path: Path) -> None:
settings = cs_mod.apply_provider(
get_provider("kimi"), key="sk-test", base_url="https://api.moonshot.cn/anthropic"
)
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.moonshot.cn/anthropic"
def test_preserves_unrelated_env(self, fake_settings_path: Path) -> None:
fake_settings_path.parent.mkdir(parents=True)
fake_settings_path.write_text(
json.dumps({"env": {"DISABLE_AUTOUPDATER": "1", "CUSTOM_VAR": "keep"}})
)
settings = cs_mod.apply_provider(get_provider("deepseek"), key="sk-test")
env = settings["env"]
assert env["DISABLE_AUTOUPDATER"] == "1"
assert env["CUSTOM_VAR"] == "keep"
assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
def test_removes_stale_managed_vars(self, fake_settings_path: Path) -> None:
fake_settings_path.parent.mkdir(parents=True)
fake_settings_path.write_text(
json.dumps(
{
"env": {
"ANTHROPIC_API_KEY": "old-key",
"ANTHROPIC_BASE_URL": "https://old.example.com",
"ANTHROPIC_MODEL": "old-model",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "old-model",
}
}
)
)
settings = cs_mod.apply_provider(get_provider("kimi"), key="sk-test")
env = settings["env"]
assert "ANTHROPIC_API_KEY" not in env
assert env["ANTHROPIC_BASE_URL"] == "https://api.moonshot.ai/anthropic"
assert env["ANTHROPIC_MODEL"] == "kimi-k2.6"
def test_claude_provider_clears_third_party_env(self, fake_settings_path: Path) -> None:
fake_settings_path.parent.mkdir(parents=True)
fake_settings_path.write_text(
json.dumps(
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "sk-test",
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
"ANTHROPIC_MODEL": "deepseek-v4-pro",
}
}
)
)
settings = cs_mod.apply_provider(get_provider("claude"))
assert "env" not in settings
def test_claude_provider_preserves_user_api_key(self, fake_settings_path: Path) -> None:
fake_settings_path.parent.mkdir(parents=True)
fake_settings_path.write_text(json.dumps({"env": {"ANTHROPIC_API_KEY": "sk-ant"}}))
settings = cs_mod.apply_provider(get_provider("claude"))
assert settings["env"]["ANTHROPIC_API_KEY"] == "sk-ant"
def test_missing_key_raises(self, fake_settings_path: Path) -> None:
with pytest.raises(ValueError, match="API key required"):
cs_mod.apply_provider(get_provider("deepseek"))
class TestDetectActiveProvider:
def test_detects_from_global_env(self, fake_settings_path: Path) -> None:
fake_settings_path.parent.mkdir(parents=True)
fake_settings_path.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding"}})
)
provider = cs_mod.get_active_provider()
assert provider is not None
assert provider.id == "kimi-code"
class TestDescribeActiveBackend:
def test_describes_kimi_code(self, fake_settings_path: Path) -> None:
fake_settings_path.parent.mkdir(parents=True)
fake_settings_path.write_text(
json.dumps(
{
"env": {
"ANTHROPIC_BASE_URL": "https://api.kimi.com/coding",
"ANTHROPIC_MODEL": "kimi-for-coding",
}
}
)
)
assert "Kimi Code" in cs_mod.describe_active_backend()
def test_describes_default_when_unset(self, fake_settings_path: Path) -> None:
assert cs_mod.describe_active_backend() == "Claude (default / not configured)"
+359 -21
View File
@@ -5,17 +5,32 @@ import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
import myagents.launcher
from myagents.cli import cli
def _backend_which(*names: str):
"""Patch ``shutil.which`` to find backend binaries and nothing else.
The launcher also probes ``which("npm")`` while resolving npm-global
installs; returning None there keeps that probe from spawning a real
``subprocess.run`` in tests (which would break ``assert_called_once``).
"""
def _which(name: str) -> str | None:
return f"/usr/bin/{name}" if name in names else None
return _which
class TestMyagentsHelp:
"""Tests for top-level myagents command."""
def test_help_shows_agent_subcommands(self) -> None:
"""--help should list claude, kimi, codex and hermes subcommands."""
"""--help should list claude, kimi, codex, hermes, cursor and dsh."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
@@ -23,6 +38,8 @@ class TestMyagentsHelp:
assert "kimi" in result.output
assert "codex" in result.output
assert "hermes" in result.output
assert "cursor" in result.output
assert "dsh" in result.output
assert "update" in result.output
assert "upgrade" in result.output
@@ -40,6 +57,24 @@ class TestMyagentsHelp:
assert result.exit_code == 0
assert "Usage:" in result.output
def test_backends_lists_all(self) -> None:
"""``myagents backends`` lists all supported agent backends."""
runner = CliRunner()
result = runner.invoke(cli, ["backends"])
assert result.exit_code == 0
assert "claude" in result.output
assert "kimi" in result.output
assert "codex" in result.output
assert "hermes" in result.output
assert "cursor" in result.output
def test_help_lists_backends(self) -> None:
"""--help should list backends subcommand."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "backends" in result.output
class TestClaudeSubcommand:
"""Tests for ``myagents claude``."""
@@ -55,7 +90,7 @@ class TestClaudeSubcommand:
def test_runs_claude(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -80,7 +115,7 @@ class TestClaudeSubcommand:
test_dir.mkdir()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -102,7 +137,7 @@ class TestClaudePassthrough:
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -113,13 +148,17 @@ class TestClaudePassthrough:
mock_run.assert_called_once()
return mock_run.call_args[0][0]
def test_resume_flag_passes_through(self, tmp_path: Path) -> None:
cmd = self._invoke(["--resume"], tmp_path)
assert cmd == [
"/usr/bin/claude",
"--dangerously-skip-permissions",
"--resume",
]
def test_bare_resume_opens_picker_not_launch(self, tmp_path: Path) -> None:
"""Bare --resume opens the unified picker; non-tty lists and exits."""
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["claude", "--cwd", str(tmp_path), "--resume"])
assert result.exit_code == 0, result.output
mock_run.assert_not_called()
def test_resume_with_session_id(self, tmp_path: Path) -> None:
cmd = self._invoke(["-r", "abc123"], tmp_path)
@@ -156,7 +195,7 @@ class TestKimiSubcommand:
def test_runs_kimi(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("kimi")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -178,7 +217,7 @@ class TestKimiSubcommand:
test_dir.mkdir()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("kimi")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -193,7 +232,7 @@ class TestKimiPassthrough:
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("kimi")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -246,7 +285,7 @@ class TestCodexSubcommand:
def test_runs_codex(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -268,7 +307,7 @@ class TestCodexSubcommand:
test_dir.mkdir()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -283,7 +322,7 @@ class TestCodexPassthrough:
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -309,7 +348,7 @@ class TestCodexResumeOptions:
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
@@ -418,7 +457,7 @@ class TestHermesSubcommand:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
"myagents.launcher.shutil.which", side_effect=_backend_which("hermes")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
@@ -442,7 +481,7 @@ class TestHermesSubcommand:
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
"myagents.launcher.shutil.which", side_effect=_backend_which("hermes")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
@@ -459,7 +498,7 @@ class TestHermesPassthrough:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
"myagents.launcher.shutil.which", side_effect=_backend_which("hermes")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
@@ -581,3 +620,302 @@ class TestHermesListSessions:
assert result.exit_code == 0, result.output
assert "sess-abc123" in result.output
assert "hello hermes" in result.output
class TestCursorSubcommand:
"""Tests for ``myagents cursor``."""
def test_help_shows_options(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["cursor", "--help"])
assert result.exit_code == 0
assert "--cwd" in result.output
assert "--list" in result.output
def test_runs_agent(self) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", side_effect=_backend_which("agent")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["cursor"])
assert result.exit_code == 0
mock_run.assert_called_once()
assert mock_run.call_args[0][0] == ["/usr/bin/agent", "--force"]
def test_falls_back_to_cursor_agent(self) -> None:
runner = CliRunner()
def _which(name: str) -> str | None:
return "/usr/bin/cursor-agent" if name == "cursor-agent" else None
with (
patch("myagents.launcher.shutil.which", side_effect=_which),
patch("myagents.launcher.subprocess.run") as mock_run,
patch.dict("os.environ", {}, clear=False) as env,
):
env.pop("CURSOR_BIN", None)
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["cursor"])
assert result.exit_code == 0
assert mock_run.call_args[0][0] == [
"/usr/bin/cursor-agent",
"--force",
]
def test_missing_binary_error(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value=None),
patch.dict("os.environ", {}, clear=False) as env,
):
env.pop("CURSOR_BIN", None)
result = runner.invoke(cli, ["cursor"])
assert result.exit_code == 127
assert "not found" in result.output.lower()
def test_cwd_option_passed(self, tmp_path: Path) -> None:
runner = CliRunner()
test_dir = tmp_path / "test_cwd"
test_dir.mkdir()
with (
patch(
"myagents.launcher.shutil.which", side_effect=_backend_which("agent")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["cursor", "--cwd", str(test_dir)])
assert result.exit_code == 0
assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve())
class TestDshSubcommand:
"""Tests for ``myagents dsh``."""
def test_help_shows_options(self) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["dsh", "--help"])
assert result.exit_code == 0
assert "--cwd" in result.output
assert "--list" in result.output
def test_runs_dsh(self) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", side_effect=_backend_which("dsh")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["dsh"])
assert result.exit_code == 0
mock_run.assert_called_once()
# dsh is a profile launcher: no default args injected.
assert mock_run.call_args[0][0] == ["/usr/bin/dsh"]
def test_missing_binary_error(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value=None),
patch.dict("os.environ", {}, clear=False) as env,
):
env.pop("DSH_BIN", None)
result = runner.invoke(cli, ["dsh"])
assert result.exit_code == 127
assert "dsh CLI not found" in result.output
def test_cwd_option_passed(self, tmp_path: Path) -> None:
runner = CliRunner()
test_dir = tmp_path / "test_cwd"
test_dir.mkdir()
with (
patch(
"myagents.launcher.shutil.which", side_effect=_backend_which("dsh")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cli, ["dsh", "--cwd", str(test_dir)])
assert result.exit_code == 0
assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve())
class TestDshListSessions:
"""``mydsh -l`` reads zstd-compressed dsh session logs."""
def _write_session(self, dsh_home: Path, slug: str, sid: str, cwd: str) -> Path:
zstd = pytest.importorskip("zstandard")
log = dsh_home / "sessions" / slug / sid / "session.jsonl.zstd"
log.parent.mkdir(parents=True, exist_ok=True)
lines = [
json.dumps(
{"type": "session", "version": 0, "id": sid, "cwd": cwd}
),
json.dumps(
{
"type": "session/title",
"seq": 1,
"data": {"title": "My dsh test"},
}
),
]
with log.open("wb") as fh:
with zstd.ZstdCompressor().stream_writer(fh) as w:
w.write(("\n".join(lines) + "\n").encode("utf-8"))
return log
def test_list_shows_dsh_sessions(self, tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
dsh_home = tmp_path / "dshhome"
self._write_session(dsh_home, "slug", "sess-1", str(workspace))
monkeypatch.setenv("DSH_HOME", str(dsh_home))
runner = CliRunner()
with patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/dsh"
):
result = runner.invoke(
cli, ["dsh", "-l", "--cwd", str(workspace)]
)
assert result.exit_code == 0
assert "sess-1" in result.output
assert "My dsh test" in result.output
def test_first_prompt_reads_zstd(self, tmp_path: Path) -> None:
from myagents.launcher import _first_prompt_dsh
workspace = tmp_path / "ws"
workspace.mkdir()
log = self._write_session(tmp_path / "home", "slug", "sid-1", str(workspace))
assert _first_prompt_dsh(log) == "My dsh test"
class TestCursorPassthrough:
"""Map myagents-style resume flags to Cursor Agent CLI flags."""
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", side_effect=_backend_which("agent")
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
cli, ["cursor", "--cwd", str(tmp_path), *args]
)
assert result.exit_code == 0, result.output
mock_run.assert_called_once()
return mock_run.call_args[0][0]
def test_r_flag_maps_to_resume(self, tmp_path: Path) -> None:
cmd = self._invoke(["-r", "abc123"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--resume", "abc123"]
def test_bare_r_flag_maps_to_resume(self, tmp_path: Path) -> None:
cmd = self._invoke(["-r"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--resume"]
def test_resume_flag_passes_through(self, tmp_path: Path) -> None:
cmd = self._invoke(["--resume", "abc123"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--resume", "abc123"]
def test_c_flag_maps_to_continue(self, tmp_path: Path) -> None:
cmd = self._invoke(["-c"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
def test_continue_flag_passes_through(self, tmp_path: Path) -> None:
cmd = self._invoke(["--continue"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
def test_last_flag_maps_to_continue(self, tmp_path: Path) -> None:
cmd = self._invoke(["--last"], tmp_path)
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
class TestCursorListSessions:
"""``myagents cursor --list`` reads ~/.cursor/chats/<cwd-md5>/."""
def test_list_empty_directory(self, tmp_path: Path) -> None:
runner = CliRunner()
with patch(
"myagents.launcher._BACKENDS",
{
"cursor": {
**myagents.launcher._BACKENDS["cursor"],
"sessions_root": lambda: tmp_path / "chats",
}
},
):
result = runner.invoke(
cli, ["cursor", "--cwd", str(tmp_path), "--list"]
)
assert result.exit_code == 0
assert "no sessions" in result.output.lower()
def test_list_shows_session_id_and_name(self, tmp_path: Path) -> None:
import hashlib
import json
import sqlite3
cwd = tmp_path / "proj"
cwd.mkdir()
chats_root = tmp_path / "chats"
munged = hashlib.md5(str(cwd.resolve()).encode("utf-8")).hexdigest()
session_dir = chats_root / munged / "chat-abc123"
session_dir.mkdir(parents=True)
db_path = session_dir / "store.db"
conn = sqlite3.connect(db_path)
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)")
meta = {
"agentId": "chat-abc123",
"name": "Fix mycursor entrypoint",
"mode": "default",
"createdAt": 1,
}
conn.execute(
"INSERT INTO meta (key, value) VALUES ('0', ?)",
(json.dumps(meta).encode().hex(),),
)
conn.execute(
"INSERT INTO blobs (id, data) VALUES ('1', ?)",
(
json.dumps(
{
"role": "user",
"content": "<user_query>\nhello cursor\n</user_query>",
}
).encode(),
),
)
conn.commit()
conn.close()
runner = CliRunner()
with patch(
"myagents.launcher._BACKENDS",
{
"cursor": {
**myagents.launcher._BACKENDS["cursor"],
"sessions_root": lambda: chats_root,
}
},
):
result = runner.invoke(
cli, ["cursor", "--cwd", str(cwd), "--list"],
env={"COLUMNS": "120"},
)
assert result.exit_code == 0, result.output
assert "chat-abc123" in result.output
assert "Fix mycursor entrypoint" in result.output
assert "mycursor" in result.output
+99
View File
@@ -0,0 +1,99 @@
"""Tests for ``myagents ensure-agent`` multi-select."""
from __future__ import annotations
from unittest.mock import patch
from click.testing import CliRunner
from myagents.commands.ensure_agent import ensure_agent_cmd, run_ensure_agents
def test_ensure_agent_all_present() -> None:
with patch(
"myagents.commands.ensure_agent.backend_available", return_value=True
):
result = CliRunner().invoke(ensure_agent_cmd, [])
assert result.exit_code == 0, result.output
assert "ready" in result.output.lower()
def test_run_ensure_agents_noninteractive_installs_missing() -> None:
def available(name: str) -> bool:
return name != "claude"
with (
patch(
"myagents.commands.ensure_agent.backend_available",
side_effect=available,
),
patch(
"myagents.commands.ensure_agent._is_interactive", return_value=False
),
patch(
"myagents.commands.ensure_agent.ensure_backend", return_value=True
) as ensure,
):
still = run_ensure_agents()
assert still == []
assert any(c.args[0] == "claude" for c in ensure.call_args_list)
# Selected path uses yes=True (no second prompt).
assert ensure.call_args.kwargs.get("yes") is True
def test_run_ensure_agents_interactive_select() -> None:
def available(name: str) -> bool:
return name not in ("claude", "codex")
with (
patch(
"myagents.commands.ensure_agent.backend_available",
side_effect=available,
),
patch(
"myagents.commands.ensure_agent._is_interactive", return_value=True
),
patch(
"myagents.commands.ensure_agent.Prompt.ask", return_value="1"
),
patch(
"myagents.commands.ensure_agent.ensure_backend", return_value=True
) as ensure,
):
still = run_ensure_agents()
# Only first missing (claude) selected.
assert ensure.call_count == 1
assert ensure.call_args.args[0] == "claude"
assert "codex" in still
def test_run_ensure_agents_prefer_on_enter() -> None:
def available(name: str) -> bool:
return name not in ("claude", "codex")
with (
patch(
"myagents.commands.ensure_agent.backend_available",
side_effect=available,
),
patch(
"myagents.commands.ensure_agent._is_interactive", return_value=True
),
patch(
"myagents.commands.ensure_agent.Prompt.ask", return_value=""
),
patch(
"myagents.commands.ensure_agent.ensure_backend", return_value=True
) as ensure,
):
still = run_ensure_agents(prefer=["codex"])
assert ensure.call_count == 1
assert ensure.call_args.args[0] == "codex"
assert "claude" in still
def test_ensure_agent_help() -> None:
result = CliRunner().invoke(ensure_agent_cmd, ["--help"])
assert result.exit_code == 0
assert "multi-select" in result.output.lower() or "ensure-agent" in result.output
assert "--yes" not in result.output
+116
View File
@@ -0,0 +1,116 @@
"""Tests for myagents.launcher.ensure_backend / guided auto-install."""
from __future__ import annotations
import os
from pathlib import Path
from unittest.mock import patch
from myagents import launcher as launcher_mod
class TestEnsureBackend:
def test_already_available_skips_install(self) -> None:
with (
patch.object(
launcher_mod,
"_resolve_backend_binary",
return_value="/usr/bin/claude",
),
patch.object(launcher_mod, "_offer_install") as offer,
):
assert launcher_mod.ensure_backend("claude") is True
offer.assert_not_called()
def test_noninteractive_auto_installs(self) -> None:
with (
patch.object(
launcher_mod, "_resolve_backend_binary", return_value=None
),
patch.object(launcher_mod, "_is_interactive", return_value=False),
patch.object(
launcher_mod, "_run_install", return_value="/opt/bin/claude"
) as run_install,
patch.object(launcher_mod, "click") as click_mod,
):
assert launcher_mod.ensure_backend("claude") is True
run_install.assert_called_once()
click_mod.prompt.assert_not_called()
def test_yes_true_skips_prompt(self) -> None:
with (
patch.object(
launcher_mod, "_resolve_backend_binary", return_value=None
),
patch.object(
launcher_mod, "_run_install", return_value="/opt/bin/claude"
) as run_install,
patch.object(launcher_mod, "click") as click_mod,
):
assert launcher_mod.ensure_backend("claude", yes=True) is True
run_install.assert_called_once()
click_mod.prompt.assert_not_called()
def test_unknown_backend_false(self) -> None:
assert launcher_mod.ensure_backend("nope") is False
def test_hermes_cannot_auto_install(self) -> None:
with (
patch.object(
launcher_mod, "_resolve_backend_binary", return_value=None
),
patch.object(launcher_mod, "_is_interactive", return_value=False),
):
assert launcher_mod.ensure_backend("hermes") is False
class TestResolveBackendBinary:
def test_falls_back_to_npm_global_bin(self, tmp_path: Path, monkeypatch) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
claude = bin_dir / "claude"
claude.write_text("#!/bin/sh\n")
claude.chmod(0o755)
monkeypatch.delenv("CLAUDE_BIN", raising=False)
with (
patch.object(launcher_mod.shutil, "which", return_value=None),
patch.object(launcher_mod, "_npm_global_bin", return_value=bin_dir),
):
assert launcher_mod.resolve_backend_binary("claude") == str(claude)
def test_ensure_npm_bin_prepends_path(
self, tmp_path: Path, monkeypatch
) -> None:
bin_dir = tmp_path / "npm-bin"
bin_dir.mkdir()
monkeypatch.setenv("PATH", "/usr/bin")
with patch.object(launcher_mod, "_npm_global_bin", return_value=bin_dir):
got = launcher_mod.ensure_npm_bin_on_path(persist=False)
assert got == bin_dir
assert str(bin_dir) in os.environ["PATH"].split(os.pathsep)
assert os.environ["PATH"].split(os.pathsep)[0] == str(bin_dir)
class TestOfferInstallGuided:
def test_interactive_default_yes_when_npm_present(self) -> None:
cfg = launcher_mod._BACKENDS["claude"]
with (
patch.object(launcher_mod, "_is_interactive", return_value=True),
patch.object(
launcher_mod.shutil, "which", return_value="/usr/bin/npm"
),
patch.object(
launcher_mod, "_run_install", return_value="/opt/bin/claude"
) as run_install,
patch.object(
launcher_mod.click,
"prompt",
return_value="y",
) as prompt,
):
assert (
launcher_mod._offer_install(cfg, yes=None) == "/opt/bin/claude"
)
prompt.assert_called_once()
assert prompt.call_args.kwargs.get("default") == "y"
run_install.assert_called_once()
+107 -4
View File
@@ -1,12 +1,18 @@
"""Tests for standalone myclaude / mykimi / mycodex / myhermes entrypoints."""
"""Tests for standalone myclaude / mykimi / mycodex / myhermes / mycursor entrypoints."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from myagents.entrypoints import claude_cli, codex_cli, hermes_cli, kimi_cli
from myagents.entrypoints import (
claude_cli,
codex_cli,
cursor_cli,
dsh_cli,
hermes_cli,
kimi_cli,
)
class TestMyclaudeEntrypoint:
"""``myclaude`` standalone entrypoint."""
@@ -32,6 +38,22 @@ class TestMyclaudeEntrypoint:
assert "myclaude" in result.output
def test_passthrough(self, tmp_path: Path) -> None:
"""``--resume <id>`` still passes through to the native CLI."""
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
claude_cli, ["--cwd", str(tmp_path), "--resume", "abc123"]
)
assert result.exit_code == 0
args = mock_run.call_args[0][0]
assert args[-2:] == ["--resume", "abc123"]
def test_bare_resume_opens_picker(self, tmp_path: Path) -> None:
"""Bare ``--resume`` opens the unified picker instead of the CLI."""
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
@@ -40,7 +62,7 @@ class TestMyclaudeEntrypoint:
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"])
assert result.exit_code == 0
assert "--resume" in mock_run.call_args[0][0]
mock_run.assert_not_called()
class TestMykimiEntrypoint:
@@ -145,6 +167,87 @@ class TestMyhermesEntrypoint:
]
class TestMycursorEntrypoint:
"""``mycursor`` standalone entrypoint."""
def test_runs_agent(self) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(cursor_cli, [])
assert result.exit_code == 0
assert mock_run.call_args[0][0] == ["/usr/bin/agent", "--force"]
def test_version_shows_mycursor(self) -> None:
runner = CliRunner()
result = runner.invoke(cursor_cli, ["--version"])
assert result.exit_code == 0
assert "mycursor" in result.output
def test_passthrough(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch(
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
cursor_cli, ["--cwd", str(tmp_path), "-r", "abc123"]
)
assert result.exit_code == 0
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]
class TestMydshEntrypoint:
"""``mydsh`` standalone entrypoint."""
def test_runs_dsh(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/dsh"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(dsh_cli, [])
assert result.exit_code == 0
# dsh is a profile launcher; no default args are injected.
assert mock_run.call_args[0][0] == ["/usr/bin/dsh"]
def test_version_shows_mydsh(self) -> None:
runner = CliRunner()
result = runner.invoke(dsh_cli, ["--version"])
assert result.exit_code == 0
assert "mydsh" in result.output
def test_passthrough(self, tmp_path: Path) -> None:
"""``--resume <id>`` passes through to the native dsh CLI."""
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/dsh"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
dsh_cli, ["--cwd", str(tmp_path), "--resume", "abc123"]
)
assert result.exit_code == 0
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]
def test_missing_binary_exits_127(self) -> None:
runner = CliRunner()
with patch("myagents.launcher.shutil.which", return_value=None):
result = runner.invoke(dsh_cli, [])
assert result.exit_code == 127
assert "dsh CLI not found" in result.output
class TestTmuxOption:
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session."""
+357
View File
@@ -0,0 +1,357 @@
"""Tests for unified claude session listing/resume (jsonl xiaohe index)."""
import copy
import json
import os
import re
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import myagents.launcher as L
from myagents.xiaohe_sessions import XiaoheSession
def _slug(path: Path) -> str:
return re.sub(r"[^A-Za-z0-9]", "-", str(path.resolve()))
def _write_jsonl(
proj_dir: Path, cid: str, ts: str, *, ai_title: str | None = None
) -> Path:
"""Write a minimal Claude Code jsonl with a message timestamp + optional aiTitle."""
lines: list[dict[str, object]] = [
{"type": "mode", "mode": "normal", "sessionId": cid},
{
"type": "permission-mode",
"permissionMode": "bypassPermissions",
"sessionId": cid,
},
]
if ai_title:
lines.append(
{"type": "ai-title", "aiTitle": ai_title, "sessionId": cid}
)
lines.append(
{
"type": "user",
"sessionId": cid,
"message": {
"id": f"m-{cid}",
"role": "user",
"content": [{"type": "text", "text": f"first prompt {cid}"}],
"timestamp": ts,
},
}
)
path = proj_dir / f"{cid}.jsonl"
path.write_text(
"\n".join(json.dumps(line) for line in lines) + "\n", encoding="utf-8"
)
return path
@pytest.fixture
def claude_env(tmp_path: Path, monkeypatch):
"""Point the claude session root + xiaohe index at temp fixtures."""
projects = tmp_path / "projects"
cwd = tmp_path / "cwd"
cwd.mkdir()
proj_dir = projects / _slug(cwd)
proj_dir.mkdir(parents=True)
index: dict[str, XiaoheSession] = {}
def fake_workspace_ids(_chat_cwd):
return ["primary"]
def fake_session_index(workspace_ids):
return index
monkeypatch.setattr(L, "_BACKENDS", copy.deepcopy(L._BACKENDS))
L._BACKENDS["claude"]["sessions_root"] = lambda: projects
monkeypatch.setattr(L, "workspace_ids_for_cwd", fake_workspace_ids)
monkeypatch.setattr(L, "session_index", fake_session_index)
return SimpleNamespace(cwd=cwd, proj_dir=proj_dir, index=index)
class TestClaudeSessionRows:
def test_merges_jsonl_with_xiaohe_index(self, claude_env) -> None:
_write_jsonl(
claude_env.proj_dir,
"aaa",
"2026-08-15T04:00:00Z",
ai_title="Client A",
)
_write_jsonl(claude_env.proj_dir, "bbb", "2026-08-15T03:00:00Z")
claude_env.index["aaa"] = XiaoheSession(
cli_session_id="aaa",
xiaohe_session_id="x-1",
title="Client A",
status="ready",
updated_at=1000.0,
)
rows = L._claude_session_rows(claude_env.cwd)
by_id = {r.cli_session_id: r for r in rows}
assert by_id["aaa"].origin == "xiaohe"
assert by_id["aaa"].status == "ready"
assert by_id["aaa"].xiaohe_session_id == "x-1"
assert by_id["aaa"].title == "Client A"
assert by_id["bbb"].origin == "cli"
assert by_id["bbb"].status is None
def test_sorts_by_content_activity_desc(self, claude_env) -> None:
_write_jsonl(claude_env.proj_dir, "old", "2026-08-13T04:00:00Z")
_write_jsonl(claude_env.proj_dir, "mid", "2026-08-14T04:00:00Z")
_write_jsonl(claude_env.proj_dir, "new", "2026-08-15T04:00:00Z")
rows = L._claude_session_rows(claude_env.cwd)
assert [r.cli_session_id for r in rows] == ["new", "mid", "old"]
def test_db_only_session_is_client_only(self, claude_env) -> None:
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
claude_env.index["nojsonl"] = XiaoheSession(
cli_session_id="nojsonl",
xiaohe_session_id="x-2",
title="Lost",
status="ready",
updated_at=2000.0,
)
rows = L._claude_session_rows(claude_env.cwd)
by_id = {r.cli_session_id: r for r in rows}
assert by_id["nojsonl"].jsonl_path is None
assert by_id["nojsonl"].origin == "xiaohe"
def test_dedup_by_cli_session_id(self, claude_env) -> None:
_write_jsonl(claude_env.proj_dir, "dup", "2026-08-15T04:00:00Z")
claude_env.index["dup"] = XiaoheSession(
cli_session_id="dup",
xiaohe_session_id="x-3",
title="Dup",
status="ready",
updated_at=1.0,
)
rows = L._claude_session_rows(claude_env.cwd)
ids = [r.cli_session_id for r in rows]
assert ids.count("dup") == 1
class TestResumePickerClaude:
def test_non_tty_prints_and_exits(self, claude_env, capsys) -> None:
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
with pytest.raises(SystemExit) as exc:
L._resume_picker_claude(claude_env.cwd)
assert exc.value.code == 0
err = capsys.readouterr().err
assert "Not a terminal" in err
assert "resume directly with myclaude -r <id>" in err
def test_routing_select_launches(self, claude_env, monkeypatch) -> None:
"""Picking a session resumes via ``claude --resume <id>``."""
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
launched: list[list[str]] = []
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(L, "_pick_session", lambda rows, cwd: rows[0])
monkeypatch.setattr(
L, "_launch", lambda *_a, **_k: launched.append(_a[2])
)
L._resume_picker_claude(claude_env.cwd)
assert launched == [["--resume", "aaa"]]
def test_routing_quit_exits(self, claude_env, monkeypatch) -> None:
"""Quitting the picker exits 0 without launching."""
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
launched: list[list[str]] = []
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(L, "_pick_session", lambda rows, cwd: None)
monkeypatch.setattr(
L, "_launch", lambda *_a, **_k: launched.append(_a[2])
)
with pytest.raises(SystemExit) as exc:
L._resume_picker_claude(claude_env.cwd)
assert exc.value.code == 0
assert launched == []
def _make_rows(claude_env, n: int):
"""n sessions, s000 newest → rows[0] == s000."""
for i in range(n):
_write_jsonl(
claude_env.proj_dir,
f"s{i:03d}",
f"2026-08-15T{23 - i:02d}:00:00Z",
)
return L._claude_session_rows(claude_env.cwd)
def _ansi_strip(text: str) -> str:
return re.sub(r"\x1b\[[0-9;]*m", "", text)
class TestPickerBlock:
"""Fixed 80-col block rendering (header + rows + status), CJK-aware."""
def _rows(self, claude_env, n: int):
return _make_rows(claude_env, n)
def test_rows_are_single_line_within_80(self, claude_env) -> None:
rows = self._rows(claude_env, 3)
state = L._PickerState(rows=rows)
lines = L._picker_block(state, claude_env.cwd, None)
# header + 3 rows + status
assert len(lines) == 5
for line in lines[1:4]:
assert len(_ansi_strip(line)) <= 80
assert "Sessions in" in lines[0]
def test_selected_row_reversed(self, claude_env) -> None:
rows = self._rows(claude_env, 3)
state = L._PickerState(rows=rows, cursor=1)
lines = L._picker_block(state, claude_env.cwd, None)
assert "\x1b[7m" in lines[2]
assert "\x1b[7m" not in lines[1]
def test_title_truncated_with_ellipsis(self, claude_env) -> None:
# _make_rows writes title-less jsonl; inject a long CJK title directly.
row = L.ClaudeSessionRow(
cli_session_id="long",
title="很长的中文标题" * 10,
origin="cli",
status=None,
updated_at=1000.0,
jsonl_path=Path("x.jsonl"),
xiaohe_session_id=None,
)
state = L._PickerState(rows=[row])
lines = L._picker_block(state, claude_env.cwd, None)
plain = _ansi_strip(lines[1])
assert "" in plain
assert len(plain) <= 80
def test_message_line_replaces_hint(self, claude_env) -> None:
rows = self._rows(claude_env, 3)
state = L._PickerState(rows=rows)
lines = L._picker_block(state, claude_env.cwd, "client-only: no jsonl")
assert "client-only" in lines[-1]
assert "↑↓" not in lines[-1]
def test_cjk_display_width(self) -> None:
assert L._disp_width("ab") == 2
assert L._disp_width("中文") == 4
assert L._disp_width("a中") == 3
class TestPickerAdvance:
"""Windowed picker state machine (no tty needed)."""
def test_enter_selects_latest(self, claude_env) -> None:
rows = _make_rows(claude_env, 3)
state = L._PickerState(rows=rows)
state, action, payload = L._picker_advance(state, "enter")
assert action == "select"
assert payload == rows[0]
def test_number_selects_row(self, claude_env) -> None:
rows = _make_rows(claude_env, 3)
state = L._PickerState(rows=rows)
state, action, payload = L._picker_advance(state, "2")
assert action == "none"
state, action, payload = L._picker_advance(state, "enter")
assert action == "select"
assert payload == rows[1]
def test_number_out_of_range(self, claude_env) -> None:
rows = _make_rows(claude_env, 3)
state = L._PickerState(rows=rows)
state, _, _ = L._picker_advance(state, "9")
state, action, payload = L._picker_advance(state, "enter")
assert action == "none"
assert isinstance(payload, str) and "range" in payload
def test_scroll_window_advances(self, claude_env) -> None:
rows = _make_rows(claude_env, 15)
state = L._PickerState(rows=rows)
for _ in range(10):
state, action, payload = L._picker_advance(state, "down")
assert action == "none"
# cursor hits window bottom, then the window scrolls
assert state.offset == 1
assert state.cursor == 9
def test_quit(self, claude_env) -> None:
rows = _make_rows(claude_env, 3)
state = L._PickerState(rows=rows)
state, action, payload = L._picker_advance(state, "q")
assert action == "quit"
assert payload is None
def test_db_only_row_not_selectable(self, claude_env) -> None:
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
# hosted session with no jsonl on disk, dated newest (year 2033)
claude_env.index["nojsonl"] = XiaoheSession(
cli_session_id="nojsonl",
xiaohe_session_id="x",
title="Lost",
status="ready",
updated_at=2_000_000_000.0,
)
rows = L._claude_session_rows(claude_env.cwd)
assert rows[0].jsonl_path is None
state = L._PickerState(rows=rows)
state, action, payload = L._picker_advance(state, "enter")
assert action == "none"
assert isinstance(payload, str) and "client-only" in payload
def _read_key_from_bytes(data: bytes) -> str:
"""Feed bytes through a pipe into ``_read_key``."""
r, w = os.pipe()
try:
os.write(w, data)
return L._read_key(r)
finally:
os.close(r)
os.close(w)
class TestReadKey:
def test_enter(self) -> None:
assert _read_key_from_bytes(b"\r") == "enter"
def test_arrow_down(self) -> None:
assert _read_key_from_bytes(b"\x1b[B") == "down"
def test_arrow_up(self) -> None:
assert _read_key_from_bytes(b"\x1b[A") == "up"
def test_page_down(self) -> None:
assert _read_key_from_bytes(b"\x1b[6~") == "pgdown"
def test_q(self) -> None:
assert _read_key_from_bytes(b"q") == "q"
def test_digit(self) -> None:
assert _read_key_from_bytes(b"5") == "5"
def test_escape(self) -> None:
assert _read_key_from_bytes(b"\x1b") == "esc"
def test_rapid_arrows_not_merged(self) -> None:
"""Back-to-back arrow keys stay distinct (no bleed into one ESC)."""
r, w = os.pipe()
try:
# 4 rapid down presses back-to-back, exactly as the terminal sends them
os.write(w, b"\x1b[B" * 4)
keys = [L._read_key(r) for _ in range(4)]
assert keys == ["down"] * 4
# bare ESC still reads as its own key afterwards
os.write(w, b"\x1b")
assert L._read_key(r) == "esc"
finally:
os.close(r)
os.close(w)
+537
View File
@@ -0,0 +1,537 @@
"""Tests for myagents.commands.ollama and myagents.ollama_adapter."""
from __future__ import annotations
import json
import subprocess
import threading
from contextlib import contextmanager
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import click
import pytest
import myagents.ollama_adapter as adapter_mod
from myagents.commands import ollama
from myagents.ollama_adapter import (
_Handler,
is_adapter_endpoint,
listen_url,
normalize_system,
parse_num_ctx,
runtime_num_ctx,
upstream_target,
)
class TestNormalizeSystem:
def test_moves_system_message_to_top_level(self) -> None:
body = {
"system": [{"type": "text", "text": "SYS1"}],
"messages": [
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
{
"role": "system",
"content": [{"type": "text", "text": "SYS2"}],
},
],
}
out = normalize_system(body)
assert [m["role"] for m in out["messages"]] == ["user"]
assert [b["text"] for b in out["system"]] == ["SYS1", "SYS2"]
def test_keeps_non_system_order(self) -> None:
body = {
"messages": [
{"role": "user", "content": "a"},
{
"role": "system",
"content": [{"type": "text", "text": "s"}],
},
{"role": "user", "content": "b"},
]
}
out = normalize_system(body)
assert [m["role"] for m in out["messages"]] == ["user", "user"]
assert [m["content"] for m in out["messages"]] == ["a", "b"]
def test_string_system_becomes_block(self) -> None:
body = {"system": "top", "messages": [{"role": "user", "content": "a"}]}
out = normalize_system(body)
assert out["system"] == [{"type": "text", "text": "top"}]
def test_hoists_string_content_on_system_message(self) -> None:
body = {
"messages": [
{"role": "user", "content": "hi"},
{"role": "system", "content": "agent-types"},
{"role": "user", "content": "ping"},
]
}
out = normalize_system(body)
assert [m["role"] for m in out["messages"]] == ["user", "user"]
assert out["system"] == [{"type": "text", "text": "agent-types"}]
def test_preserves_cache_control_on_text_blocks(self) -> None:
body = {
"system": [
{
"type": "text",
"text": "SYS",
"cache_control": {"type": "ephemeral"},
}
],
"messages": [
{
"role": "system",
"content": [
{
"type": "text",
"text": "more",
"cache_control": {"type": "ephemeral"},
}
],
}
],
}
out = normalize_system(body)
assert out["system"][0]["cache_control"] == {"type": "ephemeral"}
assert out["system"][1] == {
"type": "text",
"text": "more",
"cache_control": {"type": "ephemeral"},
}
assert out["messages"] == []
def test_drops_empty_system_turns(self) -> None:
body = {
"messages": [
{"role": "system", "content": ""},
{"role": "user", "content": "hi"},
]
}
out = normalize_system(body)
assert out["messages"] == [{"role": "user", "content": "hi"}]
assert out["system"] == []
class TestListenAndUpstream:
def test_listen_url(self) -> None:
assert listen_url() == (
f"http://{adapter_mod.DEFAULT_HOST}:{adapter_mod.DEFAULT_PORT}"
)
def test_is_adapter_endpoint_loopback_aliases(self) -> None:
port = adapter_mod.DEFAULT_PORT
assert is_adapter_endpoint(f"http://127.0.0.1:{port}")
assert is_adapter_endpoint(f"http://127.0.0.1:{port}/")
assert is_adapter_endpoint(f"http://localhost:{port}")
assert not is_adapter_endpoint("http://127.0.0.1:11434")
assert not is_adapter_endpoint("")
assert not is_adapter_endpoint("https://api.deepseek.com/anthropic")
def test_upstream_target_defaults_to_ollama_port(self) -> None:
assert upstream_target("http://127.0.0.1") == (
"http",
"127.0.0.1",
11434,
)
assert upstream_target("127.0.0.1:11434") == (
"http",
"127.0.0.1",
11434,
)
assert upstream_target("https://example.com") == (
"https",
"example.com",
443,
)
assert upstream_target("https://example.com:8443") == (
"https",
"example.com",
8443,
)
class TestRuntimeNumCtx:
def test_parse_num_ctx(self) -> None:
assert parse_num_ctx("num_ctx 32768\n") == 32768
assert parse_num_ctx("temperature 1.0\nnum_ctx 65536") == 65536
assert parse_num_ctx(None) is None
assert parse_num_ctx("") is None
assert parse_num_ctx("temperature 1.0") is None
def test_runtime_num_ctx_reads_show(self, monkeypatch) -> None:
class _Resp:
def read(self) -> bytes:
return b'{"parameters":"num_ctx 65536\\n"}'
def __enter__(self):
return self
def __exit__(self, *args: object) -> None:
return None
monkeypatch.setattr(adapter_mod, "urlopen", lambda *a, **k: _Resp())
assert runtime_num_ctx("qwen3.5:4b-ctx64k") == 65536
def test_runtime_num_ctx_missing_model(self) -> None:
assert runtime_num_ctx("") is None
def _serve(handler: type[BaseHTTPRequestHandler]) -> ThreadingHTTPServer:
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return httpd
@contextmanager
def _running(handler: type[BaseHTTPRequestHandler]):
httpd = _serve(handler)
try:
yield httpd
finally:
httpd.shutdown()
httpd.server_close()
@contextmanager
def _adapter_against(upstream_handler, monkeypatch):
with _running(upstream_handler) as upstream:
monkeypatch.setattr(
adapter_mod,
"OLLAMA_BASE",
f"http://127.0.0.1:{upstream.server_address[1]}",
)
with _running(_Handler) as proxy:
yield proxy
def _recorder(
store: dict,
*,
status: int = 200,
body: bytes = b'{"ok":true}',
send_length: bool = True,
):
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _capture(self) -> None:
n = int(self.headers.get("content-length") or 0)
raw = self.rfile.read(n) if n else b""
store["method"] = self.command
store["path"] = self.path
store["body"] = raw
store["headers"] = {k.lower(): v for k, v in self.headers.items()}
def _reply(self, head: bool = False) -> None:
self.send_response(status)
self.send_header("content-type", "application/json")
if send_length:
self.send_header("content-length", str(len(body)))
else:
self.send_header("connection", "close")
self.close_connection = True
self.end_headers()
if not head:
self.wfile.write(body)
def do_GET(self) -> None:
self._capture()
self._reply()
def do_POST(self) -> None:
self._capture()
self._reply()
def do_HEAD(self) -> None:
self._capture()
self._reply(head=True)
def log_message(self, format: str, *args: object) -> None:
pass
return Handler
def _request(
proxy: ThreadingHTTPServer,
method: str,
path: str,
body: bytes = b"",
headers: dict[str, str] | None = None,
) -> tuple[int, bytes, str | None]:
host, port = proxy.server_address
conn = HTTPConnection(host, port, timeout=2)
hdrs = dict(headers or {})
if body and "content-type" not in {k.lower() for k in hdrs}:
hdrs["content-type"] = "application/json"
conn.request(method, path, body=body or None, headers=hdrs)
resp = conn.getresponse()
raw = resp.read()
conn.close()
return resp.status, raw, resp.getheader("content-length")
class TestProxy:
def test_health_is_local(self, monkeypatch) -> None:
store: dict = {}
with _adapter_against(_recorder(store), monkeypatch) as proxy:
status, raw, _length = _request(proxy, "GET", "/health")
assert status == 200
assert json.loads(raw)["status"] == "ok"
assert store == {}
def test_get_models_is_forwarded(self, monkeypatch) -> None:
store: dict = {}
payload = b'{"object":"list","data":[]}'
with _adapter_against(
_recorder(store, body=payload), monkeypatch
) as proxy:
status, raw, _length = _request(proxy, "GET", "/v1/models")
assert status == 200
assert raw == payload
assert store["method"] == "GET"
assert store["path"] == "/v1/models"
def test_post_messages_hoists_system_and_keeps_query(
self, monkeypatch
) -> None:
store: dict = {}
payload = {
"model": "x",
"max_tokens": 1,
"messages": [
{"role": "user", "content": "hi"},
{"role": "system", "content": "agent-types"},
{"role": "user", "content": "ping"},
],
}
with _adapter_against(_recorder(store), monkeypatch) as proxy:
status, _raw, _length = _request(
proxy,
"POST",
"/v1/messages?beta=true",
json.dumps(payload).encode(),
)
assert status == 200
assert store["path"] == "/v1/messages?beta=true"
forwarded = json.loads(store["body"])
assert [m["role"] for m in forwarded["messages"]] == ["user", "user"]
assert forwarded["system"] == [
{"type": "text", "text": "agent-types"}
]
def test_other_post_paths_are_forwarded_unnormalized(
self, monkeypatch
) -> None:
store: dict = {}
payload = b'{"model":"x"}'
with _adapter_against(_recorder(store), monkeypatch) as proxy:
status, _raw, _length = _request(
proxy,
"POST",
"/v1/messages/count_tokens?beta=true",
payload,
)
assert status == 200
assert store["path"] == "/v1/messages/count_tokens?beta=true"
assert store["body"] == payload
def test_forwards_client_api_key(self, monkeypatch) -> None:
store: dict = {}
with _adapter_against(_recorder(store), monkeypatch) as proxy:
_request(
proxy,
"POST",
"/v1/messages",
b'{"messages":[]}',
headers={"x-api-key": "from-client"},
)
assert store["headers"]["x-api-key"] == "from-client"
def test_upstream_error_is_502(self, monkeypatch) -> None:
monkeypatch.setattr(adapter_mod, "OLLAMA_BASE", "http://127.0.0.1:1")
with _running(_Handler) as proxy:
status, raw, _length = _request(
proxy, "POST", "/v1/messages", b"{}"
)
assert status == 502
assert "upstream error" in json.loads(raw)["error"]["message"]
class TestForwardTerminates:
def test_completes_when_upstream_sends_content_length(
self, monkeypatch
) -> None:
store: dict = {}
body = b'{"id":"msg_1","type":"message"}'
with _adapter_against(
_recorder(store, body=body), monkeypatch
) as proxy:
status, raw, length = _request(
proxy, "POST", "/v1/messages", b'{"messages":[]}'
)
assert status == 200
assert json.loads(raw)["id"] == "msg_1"
assert length == str(len(raw))
def test_completes_when_upstream_omits_content_length(
self, monkeypatch
) -> None:
store: dict = {}
body = b'data: {"type":"message_stop"}\n\n'
with _adapter_against(
_recorder(store, body=body, send_length=False), monkeypatch
) as proxy:
status, raw, _length = _request(
proxy, "POST", "/v1/messages?beta=true", b'{"messages":[]}'
)
assert status == 200
assert b"message_stop" in raw
def test_forwards_upstream_4xx_and_completes(self, monkeypatch) -> None:
store: dict = {}
body = b'{"error":{"message":"bad request"}}'
with _adapter_against(
_recorder(store, status=400, body=body), monkeypatch
) as proxy:
status, raw, length = _request(
proxy, "POST", "/v1/messages", b'{"messages":[]}'
)
assert status == 400
assert json.loads(raw)["error"]["message"] == "bad request"
assert length == str(len(raw))
class TestSystemdUnit:
def test_unit_text_smoke(self) -> None:
text = ollama._sysd_unit_text()
assert "ExecStart=" in text
assert "-m myagents.ollama_adapter" in text
assert "WantedBy=default.target" in text
def test_sysd_start_enables_and_reloads(
self, monkeypatch, tmp_path
) -> None:
calls: list[list[str]] = []
def fake_run(cmd, **kwargs):
kwargs.pop("capture_output", None)
kwargs.pop("text", None)
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, stdout="")
monkeypatch.setattr(ollama.subprocess, "run", fake_run)
monkeypatch.setattr(ollama, "UNIT_DIR", tmp_path)
monkeypatch.setattr(ollama, "UNIT_PATH", tmp_path / ollama.UNIT_NAME)
ollama._sysd_start()
assert ["systemctl", "--user", "daemon-reload"] in calls
assert [
"systemctl",
"--user",
"enable",
"--now",
ollama.UNIT_NAME,
] in calls
assert any(c[0] == "loginctl" and "enable-linger" in c for c in calls)
assert (tmp_path / ollama.UNIT_NAME).exists()
assert ["systemctl", "--user", "restart", ollama.UNIT_NAME] not in calls
def test_sysd_start_restarts_when_already_active(
self, monkeypatch, tmp_path
) -> None:
calls: list[list[str]] = []
def fake_run(cmd, **kwargs):
kwargs.pop("capture_output", None)
kwargs.pop("text", None)
calls.append(list(cmd))
if cmd[:3] == ["systemctl", "--user", "is-active"]:
return subprocess.CompletedProcess(
cmd, 0, stdout="active\n", stderr=""
)
if cmd[:3] == ["systemctl", "--user", "is-enabled"]:
return subprocess.CompletedProcess(
cmd, 0, stdout="enabled\n", stderr=""
)
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(ollama.subprocess, "run", fake_run)
monkeypatch.setattr(ollama, "UNIT_DIR", tmp_path)
monkeypatch.setattr(ollama, "UNIT_PATH", tmp_path / ollama.UNIT_NAME)
ollama._sysd_start()
assert ["systemctl", "--user", "restart", ollama.UNIT_NAME] in calls
def test_sysd_stop_disables(self, monkeypatch) -> None:
calls: list[list[str]] = []
def fake_run(cmd, **kwargs):
kwargs.pop("capture_output", None)
kwargs.pop("text", None)
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0)
monkeypatch.setattr(ollama.subprocess, "run", fake_run)
ollama._sysd_stop()
assert [
"systemctl",
"--user",
"disable",
"--now",
ollama.UNIT_NAME,
] in calls
assert ["systemctl", "--user", "daemon-reload"] in calls
class TestMacLaunchd:
def test_mac_start_bootstraps_when_not_loaded(self, monkeypatch) -> None:
calls: list[list[str]] = []
monkeypatch.setattr(ollama, "_mac_loaded", lambda: False)
def fake_run(cmd, **kwargs):
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(ollama.subprocess, "run", fake_run)
ollama._mac_start()
assert calls[0][:2] == ["launchctl", "bootstrap"]
assert not any(c[1] == "bootout" for c in calls)
def test_mac_start_rebounds_when_loaded(self, monkeypatch) -> None:
loaded = {"v": True}
calls: list[list[str]] = []
monkeypatch.setattr(ollama, "_mac_loaded", lambda: loaded["v"])
def fake_run(cmd, **kwargs):
if len(cmd) > 1 and cmd[1] == "bootout":
loaded["v"] = False
calls.append(list(cmd))
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
monkeypatch.setattr(ollama.subprocess, "run", fake_run)
ollama._mac_start()
assert calls[0][:2] == ["launchctl", "bootout"]
assert calls[1][:2] == ["launchctl", "bootstrap"]
class TestBackendDispatch:
def test_darwin_launchd(self) -> None:
if ollama._platform() == "darwin":
assert ollama._backend().name == "launchd"
def test_linux_systemd(self, monkeypatch) -> None:
monkeypatch.setattr(ollama, "_platform", lambda: "linux")
assert ollama._backend().name == "systemd"
def test_unsupported_platform_raises(self, monkeypatch) -> None:
monkeypatch.setattr(ollama, "_platform", lambda: "win32")
with pytest.raises(click.ClickException):
ollama._backend()
+103
View File
@@ -0,0 +1,103 @@
"""Tests for myagents.secrets."""
import json
import sys
from pathlib import Path
import pytest
from myagents import secrets as secrets_mod
@pytest.fixture()
def fake_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]:
xiaohe_dir = tmp_path / ".xiaohe" / "agent"
mytoolkit_dir = tmp_path / ".mytoolkit"
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_DIR", xiaohe_dir)
monkeypatch.setattr(secrets_mod, "XIAOHE_CONFIG_PATH", xiaohe_dir / "config.json")
monkeypatch.setattr(secrets_mod, "MYTOOLKIT_CONFIG_PATH", mytoolkit_dir / "config.json")
return {"xiaohe": xiaohe_dir, "mytoolkit": mytoolkit_dir}
class TestGetKey:
def test_returns_xiaohe_key_first(self, fake_dirs: dict[str, Path]) -> None:
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
mytoolkit_path = fake_dirs["mytoolkit"] / "config.json"
xiaohe_path.parent.mkdir(parents=True)
xiaohe_path.write_text(json.dumps({"keys": {"kimi": "xiaohe-key"}}))
mytoolkit_path.parent.mkdir(parents=True)
mytoolkit_path.write_text(json.dumps({"keys": {"kimi": "mtk-key"}}))
assert secrets_mod.get_key("kimi") == "xiaohe-key"
def test_falls_back_to_mytoolkit_key(self, fake_dirs: dict[str, Path]) -> None:
mytoolkit_path = fake_dirs["mytoolkit"] / "config.json"
mytoolkit_path.parent.mkdir(parents=True)
mytoolkit_path.write_text(json.dumps({"keys": {"kimi": "mtk-key"}}))
assert secrets_mod.get_key("kimi") == "mtk-key"
def test_returns_none_when_missing(self, fake_dirs: dict[str, Path]) -> None:
assert secrets_mod.get_key("missing") is None
def test_skips_empty_strings(self, fake_dirs: dict[str, Path]) -> None:
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
xiaohe_path.parent.mkdir(parents=True)
xiaohe_path.write_text(json.dumps({"keys": {"kimi": ""}}))
assert secrets_mod.get_key("kimi") is None
class TestSetKey:
def test_stores_key_in_xiaohe_config(self, fake_dirs: dict[str, Path]) -> None:
secrets_mod.set_key("deepseek", "sk-test")
data = json.loads((fake_dirs["xiaohe"] / "config.json").read_text())
assert data["keys"]["deepseek"] == "sk-test"
def test_rejects_empty_key(self, fake_dirs: dict[str, Path]) -> None:
with pytest.raises(ValueError, match="cannot be empty"):
secrets_mod.set_key("deepseek", "")
def test_preserves_other_keys(self, fake_dirs: dict[str, Path]) -> None:
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
xiaohe_path.parent.mkdir(parents=True)
xiaohe_path.write_text(json.dumps({"keys": {"other": "keep"}}))
secrets_mod.set_key("deepseek", "sk-test")
data = json.loads(xiaohe_path.read_text())
assert data["keys"]["other"] == "keep"
assert data["keys"]["deepseek"] == "sk-test"
class TestRemoveKey:
def test_removes_existing_key(self, fake_dirs: dict[str, Path]) -> None:
xiaohe_path = fake_dirs["xiaohe"] / "config.json"
xiaohe_path.parent.mkdir(parents=True)
xiaohe_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
assert secrets_mod.remove_key("deepseek") is True
data = json.loads(xiaohe_path.read_text())
assert "deepseek" not in data.get("keys", {})
def test_returns_false_when_missing(self, fake_dirs: dict[str, Path]) -> None:
assert secrets_mod.remove_key("deepseek") is False
class TestPermissions:
@pytest.mark.skipif(sys.platform == "win32", reason="Unix permissions only")
def test_config_file_is_user_readable_only(self, fake_dirs: dict[str, Path]) -> None:
secrets_mod.set_key("kimi", "sk-test")
path = fake_dirs["xiaohe"] / "config.json"
mode = path.stat().st_mode
assert mode & 0o777 == 0o600
class TestHasKey:
def test_true_when_key_exists(self, fake_dirs: dict[str, Path]) -> None:
secrets_mod.set_key("kimi", "sk-test")
assert secrets_mod.has_key("kimi") is True
def test_false_when_missing(self, fake_dirs: dict[str, Path]) -> None:
assert secrets_mod.has_key("missing") is False
+6 -73
View File
@@ -1,80 +1,13 @@
"""Tests for myagents.commands.switch."""
"""Tests for myagents.commands.switch (legacy shim)."""
from pathlib import Path
import pytest
from click.testing import CliRunner
from myagents.commands import switch as sw_mod
@pytest.fixture()
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
monkeypatch.setattr(sw_mod, "_install_tools", lambda runtime: [])
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.setattr(sw_mod, "get_workspace_root", lambda create=False: workspace)
monkeypatch.setattr(
sw_mod,
"sync_workspace",
lambda ws: {"added": [], "updated": [], "skipped": [], "removed": []},
)
return home
def _make_versions(home: Path, versions: tuple[str, ...], current: str) -> None:
root = home / ".xiaohe" / "runtime"
for name in versions:
(root / name).mkdir(parents=True)
(root / "current").symlink_to(root / current)
class TestSwitch:
def test_switches_current_and_reinstalls(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
installed: list[str] = []
monkeypatch.setattr(
sw_mod, "_install_tools", lambda rt: installed.append(rt.name) or []
)
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1", "--yes"])
assert result.exit_code == 0, result.output
assert "Switched: v2 -> v1" in result.output
current = fake_home / ".xiaohe" / "runtime" / "current"
assert current.resolve().name == "v1"
assert installed == ["v1"]
def test_unknown_version_errors(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, ["v9", "--yes"])
assert result.exit_code != 0
assert "not installed" in result.output
def test_already_current_is_noop(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, ["v2", "--yes"])
class TestSwitchHelp:
def test_group_mentions_upgrade(self) -> None:
result = CliRunner().invoke(sw_mod.switch_cmd, [])
assert result.exit_code == 0
assert "Already on v2" in result.output
def test_interactive_list_and_prompt(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, [], input="v1\ny\n")
assert result.exit_code == 0, result.output
assert "v2" in result.output and "(current)" in result.output
assert (fake_home / ".xiaohe" / "runtime" / "current").resolve().name == "v1"
def test_cancelled_keeps_current(self, fake_home: Path) -> None:
_make_versions(fake_home, ("v1", "v2"), "v2")
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1"], input="n\n")
assert result.exit_code == 0
assert "Cancelled" in result.output
assert (fake_home / ".xiaohe" / "runtime" / "current").resolve().name == "v2"
def test_no_versions_errors(self, fake_home: Path) -> None:
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1", "--yes"])
assert result.exit_code != 0
assert "No installed runtime" in result.output
assert "xiaohe upgrade" in result.output
assert "agent" in result.output
-210
View File
@@ -1,210 +0,0 @@
"""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"]
def test_symlinked_skill_dir_is_dereferenced(
self, runtime: Path, workspace: Path
) -> None:
# Skills living in contrib/ are linked into .agents/skills (relative
# symlink); the workspace must receive real self-contained files.
real = runtime / "contrib" / "mytoolkit" / ".claude" / "skills" / "mytoolkit"
real.mkdir(parents=True)
(real / "SKILL.md").write_text("toolkit skill v1")
import os
rel_target = os.path.relpath(real, runtime / ".agents" / "skills")
(runtime / ".agents" / "skills" / "mytoolkit").symlink_to(
rel_target, target_is_directory=True
)
report = sync_mod.sync_workspace(workspace)
copied = workspace / ".agents" / "skills" / "mytoolkit" / "SKILL.md"
assert copied.is_file() and not copied.is_symlink()
assert copied.read_text() == "toolkit skill v1"
assert ".agents/skills/mytoolkit/SKILL.md" in report["added"]
# Second run: content identical -> no spurious updates.
assert sync_mod.sync_workspace(workspace) == {
"added": [], "updated": [], "skipped": [], "removed": []
}
def test_existing_real_managed_symlink_target_is_kept(
self, runtime: Path, workspace: Path
) -> None:
# A pre-existing real CLAUDE.md must not be replaced by a symlink.
(workspace / "CLAUDE.md").write_text("my own claude rules")
sync_mod.sync_workspace(workspace)
claude_md = workspace / "CLAUDE.md"
assert not claude_md.is_symlink()
assert claude_md.read_text() == "my own claude rules"
assert (workspace / ".claude").is_symlink() # other links still made
class TestCreateLauncher:
@pytest.fixture()
def fake_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "home"
(home / "Desktop").mkdir(parents=True)
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
return home
def test_macos_app_bundle_with_icon(
self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(runtime / "assets" / "icon").mkdir(parents=True)
(runtime / "assets" / "icon" / "XiaoheAgent.icns").write_bytes(b"icns")
legacy_cmd = fake_home / "Desktop" / "XiaoheAgent.command"
legacy_cmd.write_text("#!/bin/sh\n")
legacy_app = fake_home / "Desktop" / "XiaoheAgent.app"
legacy_app.mkdir()
monkeypatch.setattr(
sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Darwin"})
)
sync_mod._create_launcher()
app = fake_home / "Desktop" / "Xiaohe Agent.app"
executable = app / "Contents" / "MacOS" / "XiaoheAgent"
plist = (app / "Contents" / "Info.plist").read_text()
assert "Xiaohe Agent" in plist
assert (app / "Contents" / "Resources" / "XiaoheAgent.icns").is_file()
assert executable.stat().st_mode & 0o111
assert "xiaohe" in executable.read_text()
assert not legacy_cmd.exists() # superseded .command removed
assert not legacy_app.exists() # renamed from XiaoheAgent.app
def test_linux_desktop_entry_with_icon(
self, runtime: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(runtime / "assets" / "icon").mkdir(parents=True)
(runtime / "assets" / "icon" / "xiaohe-icon-512.png").write_bytes(b"png")
monkeypatch.setattr(
sync_mod.os, "uname", lambda: type("U", (), {"sysname": "Linux"})
)
sync_mod._create_launcher()
entry = (
fake_home / ".local" / "share" / "applications" / "Xiaohe Agent.desktop"
)
text = entry.read_text()
assert "Name=Xiaohe Agent" in text
assert "Icon=xiaohe-agent" in text
class TestInitCommand:
@pytest.fixture()
def fake_home(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
monkeypatch.setattr(sync_mod, "get_setting", lambda *a, **kw: "")
monkeypatch.setattr(sync_mod, "set_setting", lambda *a, **kw: None)
return home
def test_agent_tasks_created_from_template(
self, runtime: Path, workspace: Path, fake_home: Path
) -> None:
from click.testing import CliRunner
template = runtime / "assistant" / "agent-tasks.template.md"
template.parent.mkdir(parents=True)
template.write_text("# task board template")
result = CliRunner().invoke(sync_mod.init_cmd, [str(workspace)])
assert result.exit_code == 0, result.output
live = workspace / "assistant" / "agent-tasks.md"
assert live.read_text() == "# task board template"
assert (workspace / "tmp").is_dir() # skeleton dirs created
def test_existing_agent_tasks_untouched(
self, runtime: Path, workspace: Path, fake_home: Path
) -> None:
from click.testing import CliRunner
template = runtime / "assistant" / "agent-tasks.template.md"
template.parent.mkdir(parents=True)
template.write_text("# task board template")
live = workspace / "assistant" / "agent-tasks.md"
live.parent.mkdir(parents=True)
live.write_text("# my live board")
result = CliRunner().invoke(sync_mod.init_cmd, [str(workspace)])
assert result.exit_code == 0, result.output
assert live.read_text() == "# my live board"
+2 -1
View File
@@ -53,6 +53,7 @@ class TestUninstall:
assert not (local_bin / "completions").exists() # empty dir cleaned
assert not (fake_home / "Desktop" / "Xiaohe Agent.app").exists()
assert (fake_home / ".xiaohe" / "agent" / "config.json").is_file()
# Legacy leftover dir kept unless --all
assert (fake_home / ".metabot" / "bots.json").is_file()
assert (fake_home / ".mytoolkit" / "config.json").is_file()
assert (fake_home / "workspace").is_dir()
@@ -64,7 +65,7 @@ class TestUninstall:
)
assert result.exit_code == 0
assert not (fake_home / ".xiaohe").exists()
assert not (fake_home / ".metabot").exists()
assert not (fake_home / ".metabot").exists() # legacy leftover
assert not (fake_home / ".mytoolkit").exists()
assert (fake_home / "workspace").is_dir() # workspace never touched
+25 -260
View File
@@ -1,270 +1,35 @@
"""Tests for myagents.commands.upgrade."""
"""Tests for myagents.commands.upgrade (forwards to xiaohe upgrade)."""
import io
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
from myagents.commands import upgrade as up_mod
@pytest.fixture()
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
return home
def _make_runtime(home: Path, version: str) -> Path:
target = home / ".xiaohe" / "runtime" / version
target.mkdir(parents=True)
current = target.parent / "current"
current.unlink(missing_ok=True)
current.symlink_to(target)
return target
class TestParseVersion:
def test_parses_quoted_version(self) -> None:
assert up_mod._parse_version('BASE_URL="x"\nVERSION="v1.2.3"\n') == "v1.2.3"
def test_returns_none_on_garbage(self) -> None:
assert up_mod._parse_version("no version here") is None
class TestPrune:
def test_keeps_two_newest(self, fake_home: Path) -> None:
root = fake_home / ".xiaohe" / "runtime"
for name in ("v1", "v2", "v3", "v4"):
d = root / name
d.mkdir(parents=True)
up_mod._prune()
remaining = sorted(p.name for p in root.iterdir())
assert remaining == ["v3", "v4"]
class TestUpgradeCommand:
def test_already_latest_shortcircuits(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_make_runtime(fake_home, "v1.0.0")
monkeypatch.setattr(
up_mod, "_fetch_text", lambda *a: 'VERSION="v1.0.0"\n'
)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
)
assert result.exit_code == 0
assert "Already up to date" in result.output
def test_forwards_to_xiaohe(self) -> None:
with (
patch.object(up_mod.shutil, "which", return_value="/bin/xiaohe"),
patch.object(up_mod.subprocess, "call", return_value=0) as call,
):
result = CliRunner().invoke(up_mod.upgrade_cmd, ["0.5.1", "--force"])
assert result.exit_code == 0
call.assert_called_once_with(
["/bin/xiaohe", "upgrade", "0.5.1", "--force"]
)
def test_missing_runtime_errors(self, fake_home: Path) -> None:
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
)
assert result.exit_code != 0
assert "install.sh" in result.output
def test_forwards_beta(self) -> None:
with (
patch.object(up_mod.shutil, "which", return_value="/bin/xiaohe"),
patch.object(up_mod.subprocess, "call", return_value=0) as call,
):
result = CliRunner().invoke(up_mod.upgrade_cmd, ["--beta"])
assert result.exit_code == 0
call.assert_called_once_with(["/bin/xiaohe", "upgrade", "--beta"])
def test_cancelled_by_user(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_make_runtime(fake_home, "v1.0.0")
monkeypatch.setattr(
up_mod, "_fetch_text", lambda *a: 'VERSION="v2.0.0"\n'
)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="n\n"
)
assert result.exit_code == 0
assert "Cancelled" in result.output
def _make_tarball_bytes() -> bytes:
import tarfile
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
data = b"#!/bin/sh\n"
info = tarfile.TarInfo("workspace/setup.sh")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
return buf.getvalue()
def _wire_upgrade(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
payload: bytes,
sha: str,
) -> None:
import hashlib
def fake_fetch(url: str, user: str, password: str) -> str:
if url.endswith("install.sh"):
return 'VERSION="v2.0.0"\n'
if url.endswith(".sha256"):
return f"{sha} xiaohe-agent-latest.tar.gz\n"
raise AssertionError(f"unexpected url {url}")
monkeypatch.setattr(up_mod, "_fetch_text", fake_fetch)
monkeypatch.setattr(
up_mod, "_download", lambda url, u, p, dest: dest.write_bytes(payload)
)
monkeypatch.setattr(up_mod, "_install_tools", lambda runtime: [])
workspace = tmp_path / "ws"
workspace.mkdir()
monkeypatch.setattr(up_mod, "get_workspace_root", lambda create=False: workspace)
monkeypatch.setattr(
up_mod,
"sync_workspace",
lambda ws: {"added": [], "updated": [], "skipped": [], "removed": []},
)
class TestUpgradeFlow:
def test_downloads_verifies_and_switches(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
import hashlib
_make_runtime(fake_home, "v1.0.0")
payload = _make_tarball_bytes()
sha = hashlib.sha256(payload).hexdigest()
_wire_upgrade(monkeypatch, tmp_path, payload, sha)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="y\n"
)
assert result.exit_code == 0, result.output
assert "Upgraded: v1.0.0 -> v2.0.0" in result.output
current = fake_home / ".xiaohe" / "runtime" / "current"
assert current.resolve().name == "v2.0.0"
assert (current / "setup.sh").is_file()
def test_checksum_mismatch_aborts_before_install(
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_make_runtime(fake_home, "v1.0.0")
payload = _make_tarball_bytes()
_wire_upgrade(monkeypatch, tmp_path, payload, "0" * 64)
result = CliRunner().invoke(
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="y\n"
)
assert result.exit_code != 0
assert "Checksum mismatch" in result.output
# Old runtime untouched, no new version dir.
current = fake_home / ".xiaohe" / "runtime" / "current"
assert current.resolve().name == "v1.0.0"
assert not (fake_home / ".xiaohe" / "runtime" / "v2.0.0").exists()
class TestVerifyChecksum:
def test_accepts_matching(self, tmp_path: Path) -> None:
import hashlib
blob = tmp_path / "f.tar.gz"
blob.write_bytes(b"data")
sha = hashlib.sha256(b"data").hexdigest()
up_mod._verify_checksum(blob, f"{sha} f.tar.gz\n", "http://x/")
def test_rejects_mismatch_and_garbage(self, tmp_path: Path) -> None:
import click
blob = tmp_path / "f.tar.gz"
blob.write_bytes(b"data")
with pytest.raises(click.ClickException, match="Checksum mismatch"):
up_mod._verify_checksum(blob, f"{'0' * 64} f.tar.gz\n", "http://x/")
with pytest.raises(click.ClickException, match="looks wrong"):
up_mod._verify_checksum(blob, "not-a-checksum\n", "http://x/")
class _FakeResponse:
def __init__(self, data: bytes) -> None:
self.headers = {"Content-Length": str(len(data))}
self._buf = io.BytesIO(data)
def read(self, n: int = -1) -> bytes:
return self._buf.read(n)
def __enter__(self) -> "_FakeResponse":
return self
def __exit__(self, *args: object) -> None:
return None
class TestFetch:
def test_fetch_text_sends_basic_auth(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
seen: dict[str, str] = {}
def fake_urlopen(req, timeout=0):
seen["auth"] = req.headers["Authorization"]
return _FakeResponse(b"hello")
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
assert up_mod._fetch_text("http://x/", "u", "p") == "hello"
assert seen["auth"].startswith("Basic ")
def test_401_maps_to_friendly_error(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
import urllib.error
def fake_urlopen(req, timeout=0):
raise urllib.error.HTTPError("http://x/", 401, "Unauthorized", {}, None)
monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)
with pytest.raises(Exception, match="401"):
up_mod._fetch_text("http://x/", "u", "p")
def test_download_writes_file(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
payload = b"x" * 100_000
monkeypatch.setattr(
"urllib.request.urlopen",
lambda req, timeout=0: _FakeResponse(payload),
)
dest = tmp_path / "out.tar.gz"
up_mod._download("http://x/f", "u", "p", dest)
assert dest.read_bytes() == payload
class TestPipInstallFlags:
def test_uses_break_system_packages_on_externally_managed(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import sysconfig
em = tmp_path / "EXTERNALLY-MANAGED"
em.write_text("[externally-managed]\n")
monkeypatch.setattr(
sysconfig, "get_path", lambda name: str(tmp_path) if name == "stdlib" else ""
)
seen: list[list[str]] = []
monkeypatch.setattr(
"subprocess.run",
lambda cmd, **kw: seen.append(cmd)
or type("R", (), {"returncode": 0, "stderr": ""})(),
)
up_mod._pip_install(tmp_path / "pkg")
assert "--break-system-packages" in seen[0]
def test_skips_flag_without_externally_managed(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import sysconfig
monkeypatch.setattr(
sysconfig, "get_path", lambda name: str(tmp_path) if name == "stdlib" else ""
)
seen: list[list[str]] = []
monkeypatch.setattr(
"subprocess.run",
lambda cmd, **kw: seen.append(cmd)
or type("R", (), {"returncode": 0, "stderr": ""})(),
)
up_mod._pip_install(tmp_path / "pkg")
assert "--break-system-packages" not in seen[0]
def test_missing_xiaohe(self) -> None:
with patch.object(up_mod.shutil, "which", return_value=None):
result = CliRunner().invoke(up_mod.upgrade_cmd, [])
assert result.exit_code != 0
assert "Install xiaohe" in result.output or "xiaohe upgrade" in result.output
+15 -19
View File
@@ -3,18 +3,10 @@
import subprocess
from pathlib import Path
from click.testing import CliRunner
from myagents.commands import version as ver_mod
class TestDescribeTree:
def test_installed_runtime_tree(self) -> None:
tree = Path("/home/u/.xiaohe/runtime/v1.2.3/contrib/myagents")
mode, detail = ver_mod.describe_tree(tree)
assert mode == "installed"
assert detail == "v1.2.3"
def test_development_checkout(self, tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
@@ -33,16 +25,20 @@ class TestGitDescribe:
repo.mkdir()
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-q", "--allow-empty", "-m", "init"],
cwd=repo, check=True,
[
"git",
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
],
cwd=repo,
check=True,
)
result = ver_mod._git_describe(repo)
assert "branch" in result
class TestVersionCommand:
def test_runs_and_reports(self) -> None:
result = CliRunner().invoke(ver_mod.version_cmd)
assert result.exit_code == 0
assert "myagents" in result.output
assert result != "unknown"
+90
View File
@@ -0,0 +1,90 @@
"""Tests for the read-only xiaohe hosted-session data layer."""
import sqlite3
from pathlib import Path
from myagents.xiaohe_sessions import session_index, workspace_ids_for_cwd
def _make_workspaces_db(path: Path, rows: list[tuple[str, str | None]]) -> None:
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE workspaces (workspace_id TEXT, workspace_path TEXT)"
)
conn.executemany("INSERT INTO workspaces VALUES (?, ?)", rows)
conn.commit()
conn.close()
def _make_sessions_db(path: Path, rows: list[tuple]) -> None:
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE sessions ("
"session_id TEXT PRIMARY KEY, workspace_id TEXT, cli_session_id TEXT, "
"title TEXT, status TEXT, updated_at REAL)"
)
conn.executemany("INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?)", rows)
conn.commit()
conn.close()
class TestWorkspaceIdsForCwd:
def test_returns_matching_bind_workspaces(self, tmp_path: Path) -> None:
"""Bind workspaces whose path resolves to the cwd are returned."""
db = tmp_path / "workspaces.db"
cwd = tmp_path / "ws"
cwd.mkdir()
_make_workspaces_db(
db,
[
("primary", str(cwd)),
("bot-f", str(cwd)),
("other", str(tmp_path / "elsewhere")),
("session-ws", None),
],
)
result = workspace_ids_for_cwd(cwd, workspaces_db=db)
assert result == ["primary", "bot-f"]
def test_no_match_returns_empty(self, tmp_path: Path) -> None:
db = tmp_path / "workspaces.db"
cwd = tmp_path / "ws"
_make_workspaces_db(db, [("primary", str(tmp_path / "other"))])
assert workspace_ids_for_cwd(cwd, workspaces_db=db) == []
def test_missing_store_returns_empty(self, tmp_path: Path) -> None:
assert workspace_ids_for_cwd(tmp_path / "ws") == []
class TestSessionIndex:
def test_maps_cli_ids_for_matching_workspaces(self, tmp_path: Path) -> None:
db = tmp_path / "sessions-claude.db"
_make_sessions_db(
db,
[
("s1", "primary", "c1", "Title A", "ready", 1000.0),
("s2", "primary", "c2", None, "error", 2000.0),
("s3", "other", "c3", "Title B", "ready", 3000.0),
],
)
index = session_index(["primary"], sessions_db=db)
assert set(index) == {"c1", "c2"}
assert index["c1"].xiaohe_session_id == "s1"
assert index["c1"].status == "ready"
assert index["c2"].title is None
assert index["c2"].updated_at == 2000.0
def test_skips_null_cli_session_id(self, tmp_path: Path) -> None:
db = tmp_path / "sessions-claude.db"
_make_sessions_db(db, [("s1", "primary", None, "T", "ready", 1.0)])
assert session_index(["primary"], sessions_db=db) == {}
def test_empty_workspace_ids_skips_query(self, tmp_path: Path) -> None:
db = tmp_path / "sessions-claude.db"
_make_sessions_db(db, [("s1", "primary", "c1", "T", "ready", 1.0)])
assert session_index([], sessions_db=db) == {}
def test_missing_store_returns_empty(self, tmp_path: Path) -> None:
assert (
session_index(["primary"], sessions_db=tmp_path / "nope.db") == {}
)