Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcd780b045 | ||
|
|
3d8887bba1 | ||
|
|
f14c39d7c0 | ||
|
|
f12cd5fcf1 | ||
|
|
fa792674d3 | ||
|
|
4a37d8d218 | ||
|
|
0b0de97363 | ||
|
|
75a72a8b24 | ||
|
|
c342c4e94a | ||
|
|
969fc9ac27 | ||
|
|
88170bdf57 | ||
|
|
d6a0f455b0 | ||
|
|
c2d8e1c014 | ||
|
|
3d40e27a93 | ||
|
|
18154020a3 | ||
|
|
5efc8380fb | ||
|
|
3f79301eb3 | ||
|
|
9344b376da | ||
|
|
6702f67462 | ||
|
|
c3e119e3c9 | ||
|
|
a95c920d8a | ||
|
|
1bce85cc19 | ||
|
|
ffac0ba37d | ||
|
|
6ec2a3ebe6 | ||
|
|
d99c5534cb | ||
|
|
37961feb21 | ||
|
|
7b6da50c93 | ||
|
|
8be2a2dcd0 | ||
|
|
12d746e0f8 | ||
|
|
3d840a1ca3 | ||
|
|
8a36245b5c | ||
|
|
d6344fef75 | ||
|
|
46a41eb82a | ||
|
|
dd2011ae2d | ||
|
|
e021aa9dbf | ||
|
|
3ec60aa6b3 |
@@ -26,7 +26,7 @@ triggers:
|
|||||||
- 用户想启动某个底层 agent:`myagents claude`、`myagents kimi`、`myagents codex`。
|
- 用户想启动某个底层 agent:`myagents claude`、`myagents kimi`、`myagents codex`。
|
||||||
- 用户想恢复之前的会话:`myagents claude -r <session-id>`、`myagents kimi -S <session-id>`。
|
- 用户想恢复之前的会话:`myagents claude -r <session-id>`、`myagents kimi -S <session-id>`。
|
||||||
- 用户想查看可恢复会话:`myagents claude -l`。
|
- 用户想查看可恢复会话:`myagents claude -l`。
|
||||||
- 开发 myagents CLI 本身:在 `contrib/myagents` 目录运行 `myagents claude --cwd .`。
|
- 开发 myagents CLI 本身:在 `packages/myagents` 目录运行 `myagents claude --cwd .`。
|
||||||
|
|
||||||
## 与其他 Skill 的分工
|
## 与其他 Skill 的分工
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ myagents completion install
|
|||||||
## 开发维护
|
## 开发维护
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/workspace/contrib/myagents
|
cd ~/workspace/packages/myagents
|
||||||
make install # 可编辑安装 + 创建 ~/.local/bin 入口链接 + 安装 shell 补全
|
make install # 可编辑安装 + 创建 ~/.local/bin 入口链接 + 安装 shell 补全
|
||||||
make uninstall # 移除链接与补全
|
make uninstall # 移除链接与补全
|
||||||
python3 -m pytest tests/ -q
|
python3 -m pytest tests/ -q
|
||||||
@@ -90,5 +90,5 @@ python3 -m ruff check myagents tests scripts
|
|||||||
|
|
||||||
## 参考资料
|
## 参考资料
|
||||||
|
|
||||||
- 设计约束与开发规范见 `contrib/myagents/AGENTS.md`
|
- 设计约束与开发规范见 `packages/myagents/AGENTS.md`
|
||||||
- 安装与完整命令表见 `contrib/myagents/README.md`
|
- 安装与完整命令表见 `packages/myagents/README.md`
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ myagents/
|
|||||||
│ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
|
│ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
|
||||||
│ ├── commands/ # 子命令
|
│ ├── commands/ # 子命令
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── templates/ # 模板目录
|
|
||||||
│ └── workspace/ # workspace 模板 (AGENTS.md, CLAUDE.md symlink, .gitignore)
|
|
||||||
└── .agents/ # Agent 运行时配置(含向后兼容的 .claude 软链接)
|
└── .agents/ # Agent 运行时配置(含向后兼容的 .claude 软链接)
|
||||||
|
|
||||||
workspace/ # 主工作环境 (默认 --cwd 目标, 独立目录)
|
workspace/ # 主工作环境 (默认 --cwd 目标, 独立目录)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
ROOT_DIR := $(shell pwd)
|
ROOT_DIR := $(shell pwd)
|
||||||
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
|
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
|
||||||
USER_BIN_DIR := $(HOME)/.local/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
|
.PHONY: help install uninstall _symlink-commands
|
||||||
|
|
||||||
@@ -11,7 +11,15 @@ help:
|
|||||||
@echo " install Sync venv, symlink bin, install shell completions"
|
@echo " install Sync venv, symlink bin, install shell completions"
|
||||||
@echo " uninstall Remove bin, completions and user symlinks"
|
@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:
|
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)" && \
|
@cd "$(ROOT_DIR)" && \
|
||||||
if command -v uv >/dev/null 2>&1; then \
|
if command -v uv >/dev/null 2>&1; then \
|
||||||
uv sync; \
|
uv sync; \
|
||||||
@@ -22,6 +30,10 @@ install:
|
|||||||
@$(MAKE) _symlink-commands
|
@$(MAKE) _symlink-commands
|
||||||
@echo "Installing shell completions…"
|
@echo "Installing shell completions…"
|
||||||
@myagents completion install || echo "Warning: completion install failed"
|
@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:
|
_symlink-commands:
|
||||||
@mkdir -p "$(USER_BIN_DIR)"
|
@mkdir -p "$(USER_BIN_DIR)"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ myagents/
|
|||||||
│ ├── project_root.py # 项目/工作区根目录解析
|
│ ├── project_root.py # 项目/工作区根目录解析
|
||||||
│ └── commands/ # update / upgrade 子命令
|
│ └── commands/ # update / upgrade 子命令
|
||||||
├── tests/
|
├── tests/
|
||||||
├── templates/ # workspace 模板
|
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── Makefile
|
├── Makefile
|
||||||
└── README.md
|
└── 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -6,6 +6,8 @@ import click
|
|||||||
|
|
||||||
from myagents.commands import update_cmd, upgrade_cmd
|
from myagents.commands import update_cmd, upgrade_cmd
|
||||||
from myagents.commands.completion import build_completion_group
|
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
|
from myagents.launcher import build_cli
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ def _progs():
|
|||||||
from myagents.entrypoints import (
|
from myagents.entrypoints import (
|
||||||
claude_cli,
|
claude_cli,
|
||||||
codex_cli,
|
codex_cli,
|
||||||
|
cursor_cli,
|
||||||
|
dsh_cli,
|
||||||
hermes_cli,
|
hermes_cli,
|
||||||
kimi_cli,
|
kimi_cli,
|
||||||
)
|
)
|
||||||
@@ -24,6 +28,8 @@ def _progs():
|
|||||||
("mykimi", lambda: kimi_cli),
|
("mykimi", lambda: kimi_cli),
|
||||||
("mycodex", lambda: codex_cli),
|
("mycodex", lambda: codex_cli),
|
||||||
("myhermes", lambda: hermes_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:
|
def cli(ctx: click.Context) -> None:
|
||||||
"""Myagents: unified launcher for AI coding agents.
|
"""Myagents: unified launcher for AI coding agents.
|
||||||
|
|
||||||
Use ``myagents claude``, ``myagents kimi``, ``myagents codex`` or
|
Use ``myagents claude``, ``myagents kimi``, ``myagents codex``,
|
||||||
``myagents hermes`` to start an agent in workspace/.
|
``myagents hermes``, ``myagents cursor`` or ``myagents dsh`` to start an
|
||||||
|
agent in workspace/.
|
||||||
"""
|
"""
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
click.echo(ctx.get_help())
|
click.echo(ctx.get_help())
|
||||||
ctx.exit(0)
|
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("claude"), name="claude")
|
||||||
cli.add_command(build_cli("kimi"), name="kimi")
|
cli.add_command(build_cli("kimi"), name="kimi")
|
||||||
cli.add_command(build_cli("codex"), name="codex")
|
cli.add_command(build_cli("codex"), name="codex")
|
||||||
cli.add_command(build_cli("hermes"), name="hermes")
|
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(update_cmd)
|
||||||
cli.add_command(upgrade_cmd, name="upgrade")
|
cli.add_command(upgrade_cmd, name="upgrade")
|
||||||
|
cli.add_command(ollama_cmd)
|
||||||
cli.add_command(build_completion_group(_progs))
|
cli.add_command(build_completion_group(_progs))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ def _print_script(prog_name: str, shell: str) -> None:
|
|||||||
from myagents.entrypoints import hermes_cli
|
from myagents.entrypoints import hermes_cli
|
||||||
|
|
||||||
cli_obj = 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:
|
else:
|
||||||
raise click.ClickException(f"Unknown command: {prog_name}")
|
raise click.ClickException(f"Unknown command: {prog_name}")
|
||||||
|
|
||||||
|
|||||||
@@ -132,16 +132,18 @@ def completions_installed(
|
|||||||
*,
|
*,
|
||||||
install_root: Path | None = None,
|
install_root: Path | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check whether the zsh completion for the first prog is current."""
|
"""Check whether the zsh completion for every prog is current."""
|
||||||
if not progs:
|
for prog_name, _ in progs:
|
||||||
return True
|
ok = False
|
||||||
prog_name, _ = progs[0]
|
for target in completion_targets(prog_name, install_root=install_root):
|
||||||
for target in completion_targets(prog_name, install_root=install_root):
|
if target.shell == "zsh" and target.installed:
|
||||||
if target.shell == "zsh" and target.installed:
|
marker = f"_{prog_name.upper()}_COMPLETE"
|
||||||
marker = f"_{prog_name.upper()}_COMPLETE"
|
if marker in target.path.read_text(encoding="utf-8"):
|
||||||
if marker in target.path.read_text(encoding="utf-8"):
|
ok = True
|
||||||
return True
|
break
|
||||||
return False
|
if not ok:
|
||||||
|
return False
|
||||||
|
return bool(progs)
|
||||||
|
|
||||||
|
|
||||||
def ensure_completions_installed(
|
def ensure_completions_installed(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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]"
|
||||||
|
)
|
||||||
@@ -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>")
|
||||||
@@ -19,7 +19,6 @@ stderr_console = Console(stderr=True)
|
|||||||
_LINK_MAP: dict[str, list[str]] = {
|
_LINK_MAP: dict[str, list[str]] = {
|
||||||
"myacademia": ["path_myacademia"],
|
"myacademia": ["path_myacademia"],
|
||||||
"myslides": ["path_myslides", "MYSLIDES_ROOT"],
|
"myslides": ["path_myslides", "MYSLIDES_ROOT"],
|
||||||
"metabot": ["METABOT_HOME"],
|
|
||||||
"mytoolkit": ["path_mytoolkit", "MYTOOLKIT_ROOT"],
|
"mytoolkit": ["path_mytoolkit", "MYTOOLKIT_ROOT"],
|
||||||
"mywebpage": ["path_mywebpage", "MYWEBPAGE_ROOT"],
|
"mywebpage": ["path_mywebpage", "MYWEBPAGE_ROOT"],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]")
|
||||||
@@ -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
@@ -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
|
from __future__ import annotations
|
||||||
``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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from myagents.commands.sync_workspace import _print_report, sync_workspace
|
from myagents.launcher import _BACKENDS
|
||||||
from myagents.commands.upgrade import _current_version, _install_tools, _runtime_root
|
from myagents.settings import get_setting, set_setting
|
||||||
from myagents.project_root import get_workspace_root
|
|
||||||
|
|
||||||
stderr_console = Console(stderr=True)
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
|
_KNOWN_BACKENDS = tuple(_BACKENDS)
|
||||||
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()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("switch")
|
def agent_list_table() -> None:
|
||||||
@click.argument("version", required=False)
|
"""Print the agent list (reusable from info command)."""
|
||||||
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt")
|
default = _resolve_agent()
|
||||||
def switch_cmd(version: str | None, yes: bool) -> None:
|
console.print("[bold]Available agents:[/bold]")
|
||||||
"""Switch the active runtime to another installed version (rollback)."""
|
for name in _KNOWN_BACKENDS:
|
||||||
versions = _installed_versions()
|
marker = " [green](current)[/green]" if name == default else ""
|
||||||
current = _current_version()
|
console.print(f" {name}{marker}")
|
||||||
if not versions:
|
|
||||||
raise click.ClickException(
|
|
||||||
"No installed runtime versions found (~/.xiaohe/runtime)."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not version:
|
|
||||||
console.print("[bold]Installed versions:[/bold]")
|
def _resolve_agent() -> str:
|
||||||
for name in versions:
|
import re
|
||||||
marker = " [green](current)[/green]" if name == current else ""
|
|
||||||
|
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}")
|
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(
|
raise click.ClickException(
|
||||||
f"Version {version!r} is not installed. Installed: {', '.join(versions)}"
|
f"Unknown agent '{agent}'. Choose from: {', '.join(known)}"
|
||||||
)
|
)
|
||||||
if version == current:
|
if agent == default:
|
||||||
console.print(f"Already on {version}.")
|
console.print(f"Already on {agent}.")
|
||||||
return
|
|
||||||
if not yes and not click.confirm(
|
|
||||||
f"Switch runtime {current} -> {version} and reinstall tools?",
|
|
||||||
default=True,
|
|
||||||
err=True,
|
|
||||||
):
|
|
||||||
console.print("Cancelled.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
target = _runtime_root() / version
|
set_setting("default_agent", agent)
|
||||||
current_link = _runtime_root() / "current"
|
console.print(f"[bold green]Switched default agent to {agent}.[/bold green]")
|
||||||
current_link.unlink(missing_ok=True)
|
console.print(f"[dim]Use 'xiaohe' (or 'my{agent}') to launch.[/dim]")
|
||||||
current_link.symlink_to(target)
|
|
||||||
|
|
||||||
warnings = _install_tools(target)
|
|
||||||
|
|
||||||
workspace = get_workspace_root(create=False)
|
@click.group("switch", invoke_without_command=True)
|
||||||
if workspace.is_dir():
|
@click.pass_context
|
||||||
try:
|
def switch_cmd(ctx: click.Context) -> None:
|
||||||
report = sync_workspace(workspace)
|
"""Switch agent or provider."""
|
||||||
except click.ClickException as exc:
|
if ctx.invoked_subcommand is None:
|
||||||
stderr_console.print(f"[yellow]workspace sync skipped: {exc.message}[/yellow]")
|
console.print("[bold]Use one of:[/bold]")
|
||||||
else:
|
console.print(" xiaohe switch agent — switch default agent CLI")
|
||||||
_print_report(workspace, report)
|
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]")
|
switch_cmd.add_command(switch_agent)
|
||||||
console.print(
|
|
||||||
f"\n[bold green]Switched: {current} -> {version}[/bold green] "
|
|
||||||
"(open a new terminal to pick up the change)"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
Also removes leftover legacy stubs (metabot / mb / mm / doubao-tts) if present.
|
||||||
CLI, shell completions, and the desktop launcher. Keeps ~/.xiaohe (runtime,
|
``--all`` deletes ~/.xiaohe, ~/.mytoolkit and leftover ~/.metabot;
|
||||||
settings, keys), ~/.metabot, ~/.mytoolkit and the workspace. ``--all`` also
|
workspace is never touched.
|
||||||
deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit (every saved credential);
|
|
||||||
the workspace is never touched.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -20,12 +18,30 @@ stderr_console = Console(stderr=True)
|
|||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
BIN_NAMES = (
|
BIN_NAMES = (
|
||||||
"xiaohe", "myagents", "myclaude", "mykimi", "mycodex", "myhermes",
|
"xiaohe",
|
||||||
"mytoolkit", "metabot", "mb", "mm", "doubao-tts",
|
"myagents",
|
||||||
|
"myclaude",
|
||||||
|
"mykimi",
|
||||||
|
"mycodex",
|
||||||
|
"myhermes",
|
||||||
|
"mycursor",
|
||||||
|
"mytoolkit",
|
||||||
)
|
)
|
||||||
METABOT_COMPLETIONS = (
|
LEGACY_BIN_NAMES = (
|
||||||
"mb", "mm", "metabot", "doubao-tts",
|
"metabot",
|
||||||
"_mb", "_mm", "_metabot", "_doubao-tts",
|
"mb",
|
||||||
|
"mm",
|
||||||
|
"doubao-tts",
|
||||||
|
)
|
||||||
|
LEGACY_COMPLETIONS = (
|
||||||
|
"mb",
|
||||||
|
"mm",
|
||||||
|
"metabot",
|
||||||
|
"doubao-tts",
|
||||||
|
"_mb",
|
||||||
|
"_mm",
|
||||||
|
"_metabot",
|
||||||
|
"_doubao-tts",
|
||||||
)
|
)
|
||||||
PY_PACKAGES = ("mytoolkit", "myagents")
|
PY_PACKAGES = ("mytoolkit", "myagents")
|
||||||
|
|
||||||
@@ -34,7 +50,7 @@ def _launcher_paths(home: Path) -> list[Path]:
|
|||||||
apps = home / ".local" / "share" / "applications"
|
apps = home / ".local" / "share" / "applications"
|
||||||
icon = (
|
icon = (
|
||||||
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
|
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
|
||||||
/ "apps" / "xiaohe-agent.png"
|
/ "apps" / "xiaohe.png"
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
home / "Desktop" / "Xiaohe Agent.app",
|
home / "Desktop" / "Xiaohe Agent.app",
|
||||||
@@ -43,6 +59,8 @@ def _launcher_paths(home: Path) -> list[Path]:
|
|||||||
apps / "Xiaohe Agent.desktop",
|
apps / "Xiaohe Agent.desktop",
|
||||||
apps / "XiaoheAgent.desktop",
|
apps / "XiaoheAgent.desktop",
|
||||||
icon,
|
icon,
|
||||||
|
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
|
||||||
|
/ "apps" / "xiaohe.png",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -78,7 +96,7 @@ def _pip_uninstall() -> list[str]:
|
|||||||
|
|
||||||
@click.command("uninstall")
|
@click.command("uninstall")
|
||||||
@click.option("--all", "remove_all", is_flag=True,
|
@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,
|
@click.option("--yes", is_flag=True,
|
||||||
help="Skip the first confirmation (--all still requires the full chain)")
|
help="Skip the first confirmation (--all still requires the full chain)")
|
||||||
def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
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
|
from myagents.project_root import get_workspace_root
|
||||||
|
|
||||||
console.print("[bold]Will remove:[/bold]")
|
console.print("[bold]Will remove:[/bold]")
|
||||||
console.print(" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,")
|
console.print(
|
||||||
console.print(" myagents, mytoolkit, metabot, mb, mm, doubao-tts")
|
" - 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(" - shell completions for the above")
|
||||||
console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)")
|
console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)")
|
||||||
console.print(" - pip packages: mytoolkit, myagents")
|
console.print(" - pip packages: mytoolkit, myagents")
|
||||||
if remove_all:
|
if remove_all:
|
||||||
console.print(" - [red]~/.xiaohe (runtime, settings.json, config.json keys)[/red]")
|
console.print(" - [red]~/.xiaohe (settings, secrets, keys)[/red]")
|
||||||
console.print(" - [red]~/.metabot (Feishu bot config)[/red]")
|
|
||||||
console.print(" - [red]~/.mytoolkit (mytoolkit config, incl. 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]")
|
console.print("[bold]Will keep:[/bold]")
|
||||||
if not remove_all:
|
if not remove_all:
|
||||||
console.print(" - ~/.xiaohe (runtime, settings, keys)")
|
console.print(" - ~/.xiaohe (settings, keys)")
|
||||||
console.print(" - ~/.metabot and ~/.mytoolkit (bot/tool configs)")
|
console.print(" - ~/.mytoolkit (tool configs)")
|
||||||
console.print(f" - workspace: {get_workspace_root(create=False)}")
|
console.print(f" - workspace: {get_workspace_root(create=False)}")
|
||||||
console.print(" - rc-file edits (PATH line, ANTHROPIC_* exports) and the claude CLI")
|
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 —
|
# Deleting these dirs destroys settings and every saved credential —
|
||||||
# always require the full confirmation chain, even with --yes.
|
# always require the full confirmation chain, even with --yes.
|
||||||
if not click.confirm(
|
if not click.confirm(
|
||||||
"This also deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit — runtime, "
|
"This also deletes ~/.xiaohe, ~/.mytoolkit and any leftover "
|
||||||
"bot config and ALL saved API keys. Continue?",
|
"~/.metabot — config and ALL saved API keys. Continue?",
|
||||||
default=False,
|
default=False,
|
||||||
err=True,
|
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).
|
# Entry-point scripts in ~/.local/bin (defensive: pip removes its own).
|
||||||
local_bin = home / ".local" / "bin"
|
local_bin = home / ".local" / "bin"
|
||||||
for name in BIN_NAMES:
|
for name in (*BIN_NAMES, *LEGACY_BIN_NAMES):
|
||||||
_remove_path(local_bin / name, removed)
|
_remove_path(local_bin / name, removed)
|
||||||
for name in METABOT_COMPLETIONS:
|
for name in LEGACY_COMPLETIONS:
|
||||||
_remove_path(local_bin / "completions" / name, removed)
|
_remove_path(local_bin / "completions" / name, removed)
|
||||||
comp_dir = local_bin / "completions"
|
comp_dir = local_bin / "completions"
|
||||||
if comp_dir.is_dir() and not any(comp_dir.iterdir()):
|
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:
|
if remove_all:
|
||||||
_remove_path(home / ".xiaohe", removed)
|
_remove_path(home / ".xiaohe", removed)
|
||||||
_remove_path(home / ".metabot", removed)
|
|
||||||
_remove_path(home / ".mytoolkit", removed)
|
_remove_path(home / ".mytoolkit", removed)
|
||||||
|
_remove_path(home / ".metabot", removed)
|
||||||
|
|
||||||
for warning in warnings:
|
for warning in warnings:
|
||||||
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
|
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
|
||||||
|
|||||||
+31
-266
@@ -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
|
from __future__ import annotations
|
||||||
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).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
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.command("upgrade")
|
||||||
@click.option("--user", envvar="XIAOHE_USER", help="Download-site account (or set XIAOHE_USER)")
|
@click.argument("version", required=False)
|
||||||
@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 on that version")
|
||||||
@click.option("--force", is_flag=True, help="Reinstall even if already up to date")
|
@click.option(
|
||||||
def upgrade_cmd(user: str | None, password: str | None, force: bool) -> None:
|
"--beta",
|
||||||
"""Upgrade xiaohe: fetch the latest runtime, reinstall tools, sync workspace."""
|
is_flag=True,
|
||||||
base = _base_url()
|
help="Forward to xiaohe upgrade --beta",
|
||||||
current = _current_version()
|
)
|
||||||
if not current:
|
@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(
|
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))
|
cmd = [xiaohe, "upgrade"]
|
||||||
password = str(password or click.prompt("Password", hide_input=True, err=True))
|
if version:
|
||||||
|
cmd.append(version)
|
||||||
console.print(f"[dim]Current version: {current}[/dim]")
|
if force:
|
||||||
with console.status("Checking latest version ..."):
|
cmd.append("--force")
|
||||||
install_sh = _fetch_text(f"{base}/install.sh", user, password)
|
if beta:
|
||||||
latest = _parse_version(install_sh)
|
cmd.append("--beta")
|
||||||
if not latest:
|
raise SystemExit(subprocess.call(cmd))
|
||||||
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')"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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 shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -14,11 +15,15 @@ def _git_describe(tree: Path) -> str:
|
|||||||
try:
|
try:
|
||||||
describe = subprocess.run(
|
describe = subprocess.run(
|
||||||
["git", "-C", str(tree), "describe", "--tags", "--dirty", "--always"],
|
["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(
|
branch = subprocess.run(
|
||||||
["git", "-C", str(tree), "branch", "--show-current"],
|
["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):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -30,57 +35,38 @@ def _git_describe(tree: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def describe_tree(tree: Path) -> tuple[str, str]:
|
def describe_tree(tree: Path) -> tuple[str, str]:
|
||||||
"""Classify a package source tree: ('installed'|'development'|'unknown', detail)."""
|
"""Classify a package source tree."""
|
||||||
parts = tree.parts
|
if (tree / ".git").exists() or (tree.parent / ".git").exists():
|
||||||
if ".xiaohe" in parts and "runtime" in parts:
|
# packages/myagents → monorepo root may hold .git
|
||||||
try:
|
probe = tree
|
||||||
idx = parts.index("runtime")
|
for _ in range(3):
|
||||||
return "installed", parts[idx + 1]
|
if (probe / ".git").exists():
|
||||||
except (ValueError, IndexError):
|
return "development", _git_describe(probe)
|
||||||
return "installed", "unknown"
|
probe = probe.parent
|
||||||
if (tree / ".git").exists():
|
|
||||||
return "development", _git_describe(tree)
|
return "development", _git_describe(tree)
|
||||||
return "unknown", ""
|
return "unknown", ""
|
||||||
|
|
||||||
|
|
||||||
def _pkg_tree(module_file: str) -> Path:
|
def _pkg_tree(module_file: str) -> Path:
|
||||||
# <repo>/myagents/__init__.py -> <repo>
|
|
||||||
return Path(module_file).resolve().parents[1]
|
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")
|
@click.command("version")
|
||||||
def version_cmd() -> None:
|
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
|
import myagents
|
||||||
|
|
||||||
bin_path = shutil.which("xiaohe") or "?"
|
bin_path = shutil.which("myagents") or "?"
|
||||||
console.print(f"[bold]xiaohe[/bold] (bin: {bin_path})")
|
console.print(f"[bold]myagents[/bold] (bin: {bin_path})")
|
||||||
mode = _report_pkg("myagents", myagents.__file__)
|
|
||||||
try:
|
try:
|
||||||
import mytoolkit
|
console.print(f" version: [cyan]{version('myagents')}[/cyan]")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
_report_pkg("mytoolkit", mytoolkit.__file__)
|
tree = _pkg_tree(myagents.__file__)
|
||||||
except ImportError:
|
mode, detail = describe_tree(tree)
|
||||||
console.print(" mytoolkit: [yellow]not importable[/yellow]")
|
if mode == "development":
|
||||||
|
console.print(f" source: [green]development[/green] — {detail}")
|
||||||
current = Path.home() / ".xiaohe" / "runtime" / "current"
|
else:
|
||||||
if current.is_symlink():
|
console.print(f" source: installed [dim]{tree}[/dim]")
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|||||||
+25
-48
@@ -1,46 +1,16 @@
|
|||||||
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, xiaohe."""
|
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, mycursor."""
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from myagents.launcher import build_cli
|
from myagents.launcher import build_cli
|
||||||
|
|
||||||
claude_cli = build_cli("claude", prog_name="myclaude")
|
# offer_install=True so a missing backend CLI (e.g. `claude`) triggers the
|
||||||
kimi_cli = build_cli("kimi", prog_name="mykimi")
|
# interactive "Install now? [y/N]" prompt instead of a bare not-found error.
|
||||||
codex_cli = build_cli("codex", prog_name="mycodex")
|
# Backends without an install_cmd (hermes/cursor) fall back to the manual msg.
|
||||||
hermes_cli = build_cli("hermes", prog_name="myhermes")
|
claude_cli = build_cli("claude", prog_name="myclaude", offer_install=True)
|
||||||
|
kimi_cli = build_cli("kimi", prog_name="mykimi", offer_install=True)
|
||||||
_KNOWN_BACKENDS = ("claude", "kimi", "codex", "hermes")
|
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)
|
||||||
def default_agent() -> str:
|
dsh_cli = build_cli("dsh", prog_name="mydsh", offer_install=True)
|
||||||
"""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
|
|
||||||
|
|
||||||
|
|
||||||
def _progs():
|
def _progs():
|
||||||
@@ -52,7 +22,8 @@ def _progs():
|
|||||||
("mykimi", lambda: kimi_cli),
|
("mykimi", lambda: kimi_cli),
|
||||||
("mycodex", lambda: codex_cli),
|
("mycodex", lambda: codex_cli),
|
||||||
("myhermes", lambda: hermes_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()
|
hermes_cli()
|
||||||
|
|
||||||
|
|
||||||
def xiaohe_main() -> None:
|
def cursor_main() -> None:
|
||||||
"""Run ``xiaohe``: default-agent launcher plus init/sync subcommands.
|
"""Run ``mycursor``."""
|
||||||
|
|
||||||
Same options as the corresponding ``my<agent>`` command; when the
|
|
||||||
underlying CLI is missing, offers to install it interactively.
|
|
||||||
"""
|
|
||||||
from myagents.commands.completion_install import (
|
from myagents.commands.completion_install import (
|
||||||
ensure_completions_installed,
|
ensure_completions_installed,
|
||||||
)
|
)
|
||||||
|
|
||||||
ensure_completions_installed(_progs())
|
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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "myagents"
|
name = "myagents"
|
||||||
version = "0.1.0"
|
version = "0.2.1"
|
||||||
description = "Myagents CLI: unified launcher for AI coding agents"
|
description = "Myagents CLI: unified launcher for AI coding agents"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
@@ -14,7 +14,8 @@ myclaude = "myagents.entrypoints:claude_main"
|
|||||||
mykimi = "myagents.entrypoints:kimi_main"
|
mykimi = "myagents.entrypoints:kimi_main"
|
||||||
mycodex = "myagents.entrypoints:codex_main"
|
mycodex = "myagents.entrypoints:codex_main"
|
||||||
myhermes = "myagents.entrypoints:hermes_main"
|
myhermes = "myagents.entrypoints:hermes_main"
|
||||||
xiaohe = "myagents.entrypoints:xiaohe_main"
|
mycursor = "myagents.entrypoints:cursor_main"
|
||||||
|
mydsh = "myagents.entrypoints:dsh_main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["pytest>=8.0"]
|
dev = ["pytest>=8.0"]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes")
|
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes", "mycursor", "mydsh")
|
||||||
|
|
||||||
|
|
||||||
def _remove_link(link: str, want: str) -> bool:
|
def _remove_link(link: str, want: str) -> bool:
|
||||||
|
|||||||
@@ -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/
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
AGENTS.md
|
|
||||||
@@ -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", {})
|
||||||
@@ -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"))
|
||||||
@@ -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
@@ -5,17 +5,32 @@ import sqlite3
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
import myagents.launcher
|
import myagents.launcher
|
||||||
from myagents.cli import cli
|
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:
|
class TestMyagentsHelp:
|
||||||
"""Tests for top-level myagents command."""
|
"""Tests for top-level myagents command."""
|
||||||
|
|
||||||
def test_help_shows_agent_subcommands(self) -> None:
|
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()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["--help"])
|
result = runner.invoke(cli, ["--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -23,6 +38,8 @@ class TestMyagentsHelp:
|
|||||||
assert "kimi" in result.output
|
assert "kimi" in result.output
|
||||||
assert "codex" in result.output
|
assert "codex" in result.output
|
||||||
assert "hermes" in result.output
|
assert "hermes" in result.output
|
||||||
|
assert "cursor" in result.output
|
||||||
|
assert "dsh" in result.output
|
||||||
assert "update" in result.output
|
assert "update" in result.output
|
||||||
assert "upgrade" in result.output
|
assert "upgrade" in result.output
|
||||||
|
|
||||||
@@ -40,6 +57,24 @@ class TestMyagentsHelp:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Usage:" in result.output
|
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:
|
class TestClaudeSubcommand:
|
||||||
"""Tests for ``myagents claude``."""
|
"""Tests for ``myagents claude``."""
|
||||||
@@ -55,7 +90,7 @@ class TestClaudeSubcommand:
|
|||||||
def test_runs_claude(self) -> None:
|
def test_runs_claude(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -80,7 +115,7 @@ class TestClaudeSubcommand:
|
|||||||
test_dir.mkdir()
|
test_dir.mkdir()
|
||||||
|
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -102,7 +137,7 @@ class TestClaudePassthrough:
|
|||||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -113,13 +148,17 @@ class TestClaudePassthrough:
|
|||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
return mock_run.call_args[0][0]
|
return mock_run.call_args[0][0]
|
||||||
|
|
||||||
def test_resume_flag_passes_through(self, tmp_path: Path) -> None:
|
def test_bare_resume_opens_picker_not_launch(self, tmp_path: Path) -> None:
|
||||||
cmd = self._invoke(["--resume"], tmp_path)
|
"""Bare --resume opens the unified picker; non-tty lists and exits."""
|
||||||
assert cmd == [
|
runner = CliRunner()
|
||||||
"/usr/bin/claude",
|
with (
|
||||||
"--dangerously-skip-permissions",
|
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
|
||||||
"--resume",
|
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:
|
def test_resume_with_session_id(self, tmp_path: Path) -> None:
|
||||||
cmd = self._invoke(["-r", "abc123"], tmp_path)
|
cmd = self._invoke(["-r", "abc123"], tmp_path)
|
||||||
@@ -156,7 +195,7 @@ class TestKimiSubcommand:
|
|||||||
def test_runs_kimi(self) -> None:
|
def test_runs_kimi(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -178,7 +217,7 @@ class TestKimiSubcommand:
|
|||||||
test_dir.mkdir()
|
test_dir.mkdir()
|
||||||
|
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -193,7 +232,7 @@ class TestKimiPassthrough:
|
|||||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -246,7 +285,7 @@ class TestCodexSubcommand:
|
|||||||
def test_runs_codex(self) -> None:
|
def test_runs_codex(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -268,7 +307,7 @@ class TestCodexSubcommand:
|
|||||||
test_dir.mkdir()
|
test_dir.mkdir()
|
||||||
|
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -283,7 +322,7 @@ class TestCodexPassthrough:
|
|||||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -309,7 +348,7 @@ class TestCodexResumeOptions:
|
|||||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
@@ -418,7 +457,7 @@ class TestHermesSubcommand:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
with (
|
||||||
patch(
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
@@ -442,7 +481,7 @@ class TestHermesSubcommand:
|
|||||||
|
|
||||||
with (
|
with (
|
||||||
patch(
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
@@ -459,7 +498,7 @@ class TestHermesPassthrough:
|
|||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
with (
|
with (
|
||||||
patch(
|
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,
|
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||||
):
|
):
|
||||||
@@ -581,3 +620,302 @@ class TestHermesListSessions:
|
|||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
assert "sess-abc123" in result.output
|
assert "sess-abc123" in result.output
|
||||||
assert "hello hermes" 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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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 pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from click.testing import CliRunner
|
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:
|
class TestMyclaudeEntrypoint:
|
||||||
"""``myclaude`` standalone entrypoint."""
|
"""``myclaude`` standalone entrypoint."""
|
||||||
@@ -32,6 +38,22 @@ class TestMyclaudeEntrypoint:
|
|||||||
assert "myclaude" in result.output
|
assert "myclaude" in result.output
|
||||||
|
|
||||||
def test_passthrough(self, tmp_path: Path) -> None:
|
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()
|
runner = CliRunner()
|
||||||
with (
|
with (
|
||||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
||||||
@@ -40,7 +62,7 @@ class TestMyclaudeEntrypoint:
|
|||||||
mock_run.return_value = MagicMock(returncode=0)
|
mock_run.return_value = MagicMock(returncode=0)
|
||||||
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"])
|
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "--resume" in mock_run.call_args[0][0]
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
class TestMykimiEntrypoint:
|
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:
|
class TestTmuxOption:
|
||||||
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session."""
|
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session."""
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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 click.testing import CliRunner
|
||||||
|
|
||||||
from myagents.commands import switch as sw_mod
|
from myagents.commands import switch as sw_mod
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
class TestSwitchHelp:
|
||||||
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
def test_group_mentions_upgrade(self) -> None:
|
||||||
home = tmp_path / "home"
|
result = CliRunner().invoke(sw_mod.switch_cmd, [])
|
||||||
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"])
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Already on v2" in result.output
|
assert "xiaohe upgrade" in result.output
|
||||||
|
assert "agent" 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
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -53,6 +53,7 @@ class TestUninstall:
|
|||||||
assert not (local_bin / "completions").exists() # empty dir cleaned
|
assert not (local_bin / "completions").exists() # empty dir cleaned
|
||||||
assert not (fake_home / "Desktop" / "Xiaohe Agent.app").exists()
|
assert not (fake_home / "Desktop" / "Xiaohe Agent.app").exists()
|
||||||
assert (fake_home / ".xiaohe" / "agent" / "config.json").is_file()
|
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 / ".metabot" / "bots.json").is_file()
|
||||||
assert (fake_home / ".mytoolkit" / "config.json").is_file()
|
assert (fake_home / ".mytoolkit" / "config.json").is_file()
|
||||||
assert (fake_home / "workspace").is_dir()
|
assert (fake_home / "workspace").is_dir()
|
||||||
@@ -64,7 +65,7 @@ class TestUninstall:
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert not (fake_home / ".xiaohe").exists()
|
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 not (fake_home / ".mytoolkit").exists()
|
||||||
assert (fake_home / "workspace").is_dir() # workspace never touched
|
assert (fake_home / "workspace").is_dir() # workspace never touched
|
||||||
|
|
||||||
|
|||||||
+25
-260
@@ -1,270 +1,35 @@
|
|||||||
"""Tests for myagents.commands.upgrade."""
|
"""Tests for myagents.commands.upgrade (forwards to xiaohe upgrade)."""
|
||||||
|
|
||||||
import io
|
from unittest.mock import patch
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from myagents.commands import upgrade as up_mod
|
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:
|
class TestUpgradeCommand:
|
||||||
def test_already_latest_shortcircuits(
|
def test_forwards_to_xiaohe(self) -> None:
|
||||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
with (
|
||||||
) -> None:
|
patch.object(up_mod.shutil, "which", return_value="/bin/xiaohe"),
|
||||||
_make_runtime(fake_home, "v1.0.0")
|
patch.object(up_mod.subprocess, "call", return_value=0) as call,
|
||||||
monkeypatch.setattr(
|
):
|
||||||
up_mod, "_fetch_text", lambda *a: 'VERSION="v1.0.0"\n'
|
result = CliRunner().invoke(up_mod.upgrade_cmd, ["0.5.1", "--force"])
|
||||||
)
|
assert result.exit_code == 0
|
||||||
result = CliRunner().invoke(
|
call.assert_called_once_with(
|
||||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
|
["/bin/xiaohe", "upgrade", "0.5.1", "--force"]
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Already up to date" in result.output
|
|
||||||
|
|
||||||
def test_missing_runtime_errors(self, fake_home: Path) -> None:
|
def test_forwards_beta(self) -> None:
|
||||||
result = CliRunner().invoke(
|
with (
|
||||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
|
patch.object(up_mod.shutil, "which", return_value="/bin/xiaohe"),
|
||||||
)
|
patch.object(up_mod.subprocess, "call", return_value=0) as call,
|
||||||
assert result.exit_code != 0
|
):
|
||||||
assert "install.sh" in result.output
|
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(
|
def test_missing_xiaohe(self) -> None:
|
||||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
with patch.object(up_mod.shutil, "which", return_value=None):
|
||||||
) -> None:
|
result = CliRunner().invoke(up_mod.upgrade_cmd, [])
|
||||||
_make_runtime(fake_home, "v1.0.0")
|
assert result.exit_code != 0
|
||||||
monkeypatch.setattr(
|
assert "Install xiaohe" in result.output or "xiaohe upgrade" in result.output
|
||||||
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]
|
|
||||||
|
|||||||
+15
-19
@@ -3,18 +3,10 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from myagents.commands import version as ver_mod
|
from myagents.commands import version as ver_mod
|
||||||
|
|
||||||
|
|
||||||
class TestDescribeTree:
|
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:
|
def test_development_checkout(self, tmp_path: Path) -> None:
|
||||||
repo = tmp_path / "repo"
|
repo = tmp_path / "repo"
|
||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
@@ -33,16 +25,20 @@ class TestGitDescribe:
|
|||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
[
|
||||||
"commit", "-q", "--allow-empty", "-m", "init"],
|
"git",
|
||||||
cwd=repo, check=True,
|
"-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)
|
result = ver_mod._git_describe(repo)
|
||||||
assert "branch" in result
|
assert result != "unknown"
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -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") == {}
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user