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`。
|
||||
- 用户想恢复之前的会话:`myagents claude -r <session-id>`、`myagents kimi -S <session-id>`。
|
||||
- 用户想查看可恢复会话:`myagents claude -l`。
|
||||
- 开发 myagents CLI 本身:在 `contrib/myagents` 目录运行 `myagents claude --cwd .`。
|
||||
- 开发 myagents CLI 本身:在 `packages/myagents` 目录运行 `myagents claude --cwd .`。
|
||||
|
||||
## 与其他 Skill 的分工
|
||||
|
||||
@@ -74,7 +74,7 @@ myagents completion install
|
||||
## 开发维护
|
||||
|
||||
```bash
|
||||
cd ~/workspace/contrib/myagents
|
||||
cd ~/workspace/packages/myagents
|
||||
make install # 可编辑安装 + 创建 ~/.local/bin 入口链接 + 安装 shell 补全
|
||||
make uninstall # 移除链接与补全
|
||||
python3 -m pytest tests/ -q
|
||||
@@ -90,5 +90,5 @@ python3 -m ruff check myagents tests scripts
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 设计约束与开发规范见 `contrib/myagents/AGENTS.md`
|
||||
- 安装与完整命令表见 `contrib/myagents/README.md`
|
||||
- 设计约束与开发规范见 `packages/myagents/AGENTS.md`
|
||||
- 安装与完整命令表见 `packages/myagents/README.md`
|
||||
|
||||
@@ -48,8 +48,6 @@ myagents/
|
||||
│ ├── entrypoints.py # myclaude / mykimi / mycodex / myhermes 独立入口
|
||||
│ ├── commands/ # 子命令
|
||||
│ └── ...
|
||||
├── templates/ # 模板目录
|
||||
│ └── workspace/ # workspace 模板 (AGENTS.md, CLAUDE.md symlink, .gitignore)
|
||||
└── .agents/ # Agent 运行时配置(含向后兼容的 .claude 软链接)
|
||||
|
||||
workspace/ # 主工作环境 (默认 --cwd 目标, 独立目录)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ROOT_DIR := $(shell pwd)
|
||||
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
|
||||
USER_BIN_DIR := $(HOME)/.local/bin
|
||||
COMMANDS := myagents myclaude mykimi mycodex myhermes
|
||||
COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor mydsh
|
||||
|
||||
.PHONY: help install uninstall _symlink-commands
|
||||
|
||||
@@ -11,7 +11,15 @@ help:
|
||||
@echo " install Sync venv, symlink bin, install shell completions"
|
||||
@echo " uninstall Remove bin, completions and user symlinks"
|
||||
|
||||
# Working tree: xiaohe-agent/packages/myagents (submodule of apaam/myagents).
|
||||
# Refuse ephemeral GHA runner checkouts (_work) so ~/.local/bin cannot point there.
|
||||
install:
|
||||
@case "$(ROOT_DIR)" in \
|
||||
*/_work/*|*/.xiaohe/actions-runner/*) \
|
||||
echo "ERROR: refuse install from GHA runner worktree: $(ROOT_DIR)" >&2; \
|
||||
echo "Use: cd <xiaohe-agent>/packages/myagents && make install" >&2; \
|
||||
exit 1 ;; \
|
||||
esac
|
||||
@cd "$(ROOT_DIR)" && \
|
||||
if command -v uv >/dev/null 2>&1; then \
|
||||
uv sync; \
|
||||
@@ -22,6 +30,10 @@ install:
|
||||
@$(MAKE) _symlink-commands
|
||||
@echo "Installing shell completions…"
|
||||
@myagents completion install || echo "Warning: completion install failed"
|
||||
@case "$(ROOT_DIR)" in \
|
||||
*/_work/*|*/.xiaohe/actions-runner/*) \
|
||||
echo "ERROR: install root still a runner tree: $(ROOT_DIR)" >&2; exit 1 ;; \
|
||||
esac
|
||||
|
||||
_symlink-commands:
|
||||
@mkdir -p "$(USER_BIN_DIR)"
|
||||
|
||||
@@ -21,7 +21,6 @@ myagents/
|
||||
│ ├── project_root.py # 项目/工作区根目录解析
|
||||
│ └── commands/ # update / upgrade 子命令
|
||||
├── tests/
|
||||
├── templates/ # workspace 模板
|
||||
├── pyproject.toml
|
||||
├── Makefile
|
||||
└── README.md
|
||||
@@ -136,7 +135,7 @@ python3 -m ruff check myagents tests scripts
|
||||
|
||||
## 完整部署指南
|
||||
|
||||
如果你要部署的是**小荷助理**完整环境(含 Claude Code、cc-switch、Metabot、飞书机器人等),请参见 [xiaohe-agent 部署文档](../../README.md)。
|
||||
如果你要部署的是**小荷助理**完整环境(Session API、Desktop、Skills Hub 等),请参见 [xiaohe-agent 仓库 README](../../README.md)(产品包名 **xiaohe**)。
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -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.completion import build_completion_group
|
||||
from myagents.commands.ensure_agent import ensure_agent_cmd
|
||||
from myagents.commands.ollama import ollama_cmd
|
||||
from myagents.launcher import build_cli
|
||||
|
||||
|
||||
@@ -14,6 +16,8 @@ def _progs():
|
||||
from myagents.entrypoints import (
|
||||
claude_cli,
|
||||
codex_cli,
|
||||
cursor_cli,
|
||||
dsh_cli,
|
||||
hermes_cli,
|
||||
kimi_cli,
|
||||
)
|
||||
@@ -24,6 +28,8 @@ def _progs():
|
||||
("mykimi", lambda: kimi_cli),
|
||||
("mycodex", lambda: codex_cli),
|
||||
("myhermes", lambda: hermes_cli),
|
||||
("mycursor", lambda: cursor_cli),
|
||||
("mydsh", lambda: dsh_cli),
|
||||
]
|
||||
|
||||
|
||||
@@ -40,20 +46,34 @@ def _package_version() -> str:
|
||||
def cli(ctx: click.Context) -> None:
|
||||
"""Myagents: unified launcher for AI coding agents.
|
||||
|
||||
Use ``myagents claude``, ``myagents kimi``, ``myagents codex`` or
|
||||
``myagents hermes`` to start an agent in workspace/.
|
||||
Use ``myagents claude``, ``myagents kimi``, ``myagents codex``,
|
||||
``myagents hermes``, ``myagents cursor`` or ``myagents dsh`` to start an
|
||||
agent in workspace/.
|
||||
"""
|
||||
if ctx.invoked_subcommand is None:
|
||||
click.echo(ctx.get_help())
|
||||
ctx.exit(0)
|
||||
|
||||
|
||||
@cli.command("backends")
|
||||
def backends_cmd() -> None:
|
||||
"""List available agent backends (one per line, for machine consumption)."""
|
||||
from myagents.launcher import _BACKENDS
|
||||
|
||||
for name in _BACKENDS:
|
||||
click.echo(name)
|
||||
|
||||
|
||||
cli.add_command(build_cli("claude"), name="claude")
|
||||
cli.add_command(build_cli("kimi"), name="kimi")
|
||||
cli.add_command(build_cli("codex"), name="codex")
|
||||
cli.add_command(build_cli("hermes"), name="hermes")
|
||||
cli.add_command(build_cli("cursor"), name="cursor")
|
||||
cli.add_command(build_cli("dsh"), name="dsh")
|
||||
cli.add_command(ensure_agent_cmd)
|
||||
cli.add_command(update_cmd)
|
||||
cli.add_command(upgrade_cmd, name="upgrade")
|
||||
cli.add_command(ollama_cmd)
|
||||
cli.add_command(build_completion_group(_progs))
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ def _print_script(prog_name: str, shell: str) -> None:
|
||||
from myagents.entrypoints import hermes_cli
|
||||
|
||||
cli_obj = hermes_cli
|
||||
elif prog_name == "mycursor":
|
||||
from myagents.entrypoints import cursor_cli
|
||||
|
||||
cli_obj = cursor_cli
|
||||
elif prog_name == "mydsh":
|
||||
from myagents.entrypoints import dsh_cli
|
||||
|
||||
cli_obj = dsh_cli
|
||||
else:
|
||||
raise click.ClickException(f"Unknown command: {prog_name}")
|
||||
|
||||
|
||||
@@ -132,16 +132,18 @@ def completions_installed(
|
||||
*,
|
||||
install_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Check whether the zsh completion for the first prog is current."""
|
||||
if not progs:
|
||||
return True
|
||||
prog_name, _ = progs[0]
|
||||
"""Check whether the zsh completion for every prog is current."""
|
||||
for prog_name, _ in progs:
|
||||
ok = False
|
||||
for target in completion_targets(prog_name, install_root=install_root):
|
||||
if target.shell == "zsh" and target.installed:
|
||||
marker = f"_{prog_name.upper()}_COMPLETE"
|
||||
if marker in target.path.read_text(encoding="utf-8"):
|
||||
return True
|
||||
ok = True
|
||||
break
|
||||
if not ok:
|
||||
return False
|
||||
return bool(progs)
|
||||
|
||||
|
||||
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]] = {
|
||||
"myacademia": ["path_myacademia"],
|
||||
"myslides": ["path_myslides", "MYSLIDES_ROOT"],
|
||||
"metabot": ["METABOT_HOME"],
|
||||
"mytoolkit": ["path_mytoolkit", "MYTOOLKIT_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
|
||||
``current`` symlink, reinstalls the CLI tools from that runtime, and re-syncs
|
||||
the workspace. Run without an argument to list installed versions and pick
|
||||
one interactively.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
|
||||
from myagents.commands.sync_workspace import _print_report, sync_workspace
|
||||
from myagents.commands.upgrade import _current_version, _install_tools, _runtime_root
|
||||
from myagents.project_root import get_workspace_root
|
||||
from myagents.launcher import _BACKENDS
|
||||
from myagents.settings import get_setting, set_setting
|
||||
|
||||
stderr_console = Console(stderr=True)
|
||||
console = Console()
|
||||
|
||||
|
||||
def _installed_versions() -> list[str]:
|
||||
root = _runtime_root()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
d.name for d in root.iterdir() if d.is_dir() and not d.is_symlink()
|
||||
)
|
||||
_KNOWN_BACKENDS = tuple(_BACKENDS)
|
||||
|
||||
|
||||
@click.command("switch")
|
||||
@click.argument("version", required=False)
|
||||
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt")
|
||||
def switch_cmd(version: str | None, yes: bool) -> None:
|
||||
"""Switch the active runtime to another installed version (rollback)."""
|
||||
versions = _installed_versions()
|
||||
current = _current_version()
|
||||
if not versions:
|
||||
raise click.ClickException(
|
||||
"No installed runtime versions found (~/.xiaohe/runtime)."
|
||||
)
|
||||
|
||||
if not version:
|
||||
console.print("[bold]Installed versions:[/bold]")
|
||||
for name in versions:
|
||||
marker = " [green](current)[/green]" if name == current else ""
|
||||
def agent_list_table() -> None:
|
||||
"""Print the agent list (reusable from info command)."""
|
||||
default = _resolve_agent()
|
||||
console.print("[bold]Available agents:[/bold]")
|
||||
for name in _KNOWN_BACKENDS:
|
||||
marker = " [green](current)[/green]" if name == default else ""
|
||||
console.print(f" {name}{marker}")
|
||||
version = str(click.prompt("Switch to", err=True))
|
||||
|
||||
if version not in versions:
|
||||
|
||||
def _resolve_agent() -> str:
|
||||
import re
|
||||
|
||||
agent = str(get_setting("default_agent", "") or "")
|
||||
agent = re.sub(r"^my", "", agent.strip().lower())
|
||||
return agent if agent in _KNOWN_BACKENDS else "claude"
|
||||
|
||||
|
||||
@click.command("agent")
|
||||
@click.argument("agent", required=False)
|
||||
def switch_agent(agent: str | None) -> None:
|
||||
"""Switch the default agent CLI."""
|
||||
known = list(_KNOWN_BACKENDS)
|
||||
default = _resolve_agent()
|
||||
|
||||
if not agent:
|
||||
console.print("[bold]Available agents:[/bold]")
|
||||
for name in known:
|
||||
marker = " [green](current)[/green]" if name == default else ""
|
||||
console.print(f" {name}{marker}")
|
||||
agent = str(click.prompt("Switch to", err=True))
|
||||
|
||||
agent = agent.strip().lower()
|
||||
if agent.startswith("my"):
|
||||
agent = agent[2:]
|
||||
if agent not in _KNOWN_BACKENDS:
|
||||
raise click.ClickException(
|
||||
f"Version {version!r} is not installed. Installed: {', '.join(versions)}"
|
||||
f"Unknown agent '{agent}'. Choose from: {', '.join(known)}"
|
||||
)
|
||||
if version == current:
|
||||
console.print(f"Already on {version}.")
|
||||
return
|
||||
if not yes and not click.confirm(
|
||||
f"Switch runtime {current} -> {version} and reinstall tools?",
|
||||
default=True,
|
||||
err=True,
|
||||
):
|
||||
console.print("Cancelled.")
|
||||
if agent == default:
|
||||
console.print(f"Already on {agent}.")
|
||||
return
|
||||
|
||||
target = _runtime_root() / version
|
||||
current_link = _runtime_root() / "current"
|
||||
current_link.unlink(missing_ok=True)
|
||||
current_link.symlink_to(target)
|
||||
set_setting("default_agent", agent)
|
||||
console.print(f"[bold green]Switched default agent to {agent}.[/bold green]")
|
||||
console.print(f"[dim]Use 'xiaohe' (or 'my{agent}') to launch.[/dim]")
|
||||
|
||||
warnings = _install_tools(target)
|
||||
|
||||
workspace = get_workspace_root(create=False)
|
||||
if workspace.is_dir():
|
||||
try:
|
||||
report = sync_workspace(workspace)
|
||||
except click.ClickException as exc:
|
||||
stderr_console.print(f"[yellow]workspace sync skipped: {exc.message}[/yellow]")
|
||||
else:
|
||||
_print_report(workspace, report)
|
||||
|
||||
for warning in warnings:
|
||||
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
|
||||
@click.group("switch", invoke_without_command=True)
|
||||
@click.pass_context
|
||||
def switch_cmd(ctx: click.Context) -> None:
|
||||
"""Switch agent or provider."""
|
||||
if ctx.invoked_subcommand is None:
|
||||
console.print("[bold]Use one of:[/bold]")
|
||||
console.print(" xiaohe switch agent — switch default agent CLI")
|
||||
console.print(" xiaohe switch provider — switch LLM provider")
|
||||
console.print()
|
||||
console.print(
|
||||
f"\n[bold green]Switched: {current} -> {version}[/bold green] "
|
||||
"(open a new terminal to pick up the change)"
|
||||
"[dim]Package version: xiaohe upgrade [<version>][/dim]"
|
||||
)
|
||||
|
||||
|
||||
switch_cmd.add_command(switch_agent)
|
||||
|
||||
@@ -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
|
||||
CLI, shell completions, and the desktop launcher. Keeps ~/.xiaohe (runtime,
|
||||
settings, keys), ~/.metabot, ~/.mytoolkit and the workspace. ``--all`` also
|
||||
deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit (every saved credential);
|
||||
the workspace is never touched.
|
||||
Also removes leftover legacy stubs (metabot / mb / mm / doubao-tts) if present.
|
||||
``--all`` deletes ~/.xiaohe, ~/.mytoolkit and leftover ~/.metabot;
|
||||
workspace is never touched.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -20,12 +18,30 @@ stderr_console = Console(stderr=True)
|
||||
console = Console()
|
||||
|
||||
BIN_NAMES = (
|
||||
"xiaohe", "myagents", "myclaude", "mykimi", "mycodex", "myhermes",
|
||||
"mytoolkit", "metabot", "mb", "mm", "doubao-tts",
|
||||
"xiaohe",
|
||||
"myagents",
|
||||
"myclaude",
|
||||
"mykimi",
|
||||
"mycodex",
|
||||
"myhermes",
|
||||
"mycursor",
|
||||
"mytoolkit",
|
||||
)
|
||||
METABOT_COMPLETIONS = (
|
||||
"mb", "mm", "metabot", "doubao-tts",
|
||||
"_mb", "_mm", "_metabot", "_doubao-tts",
|
||||
LEGACY_BIN_NAMES = (
|
||||
"metabot",
|
||||
"mb",
|
||||
"mm",
|
||||
"doubao-tts",
|
||||
)
|
||||
LEGACY_COMPLETIONS = (
|
||||
"mb",
|
||||
"mm",
|
||||
"metabot",
|
||||
"doubao-tts",
|
||||
"_mb",
|
||||
"_mm",
|
||||
"_metabot",
|
||||
"_doubao-tts",
|
||||
)
|
||||
PY_PACKAGES = ("mytoolkit", "myagents")
|
||||
|
||||
@@ -34,7 +50,7 @@ def _launcher_paths(home: Path) -> list[Path]:
|
||||
apps = home / ".local" / "share" / "applications"
|
||||
icon = (
|
||||
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
|
||||
/ "apps" / "xiaohe-agent.png"
|
||||
/ "apps" / "xiaohe.png"
|
||||
)
|
||||
return [
|
||||
home / "Desktop" / "Xiaohe Agent.app",
|
||||
@@ -43,6 +59,8 @@ def _launcher_paths(home: Path) -> list[Path]:
|
||||
apps / "Xiaohe Agent.desktop",
|
||||
apps / "XiaoheAgent.desktop",
|
||||
icon,
|
||||
home / ".local" / "share" / "icons" / "hicolor" / "512x512"
|
||||
/ "apps" / "xiaohe.png",
|
||||
]
|
||||
|
||||
|
||||
@@ -78,7 +96,7 @@ def _pip_uninstall() -> list[str]:
|
||||
|
||||
@click.command("uninstall")
|
||||
@click.option("--all", "remove_all", is_flag=True,
|
||||
help="Also remove ~/.xiaohe (runtime, settings, API keys)")
|
||||
help="Also remove ~/.xiaohe (settings, API keys)")
|
||||
@click.option("--yes", is_flag=True,
|
||||
help="Skip the first confirmation (--all still requires the full chain)")
|
||||
def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
||||
@@ -89,19 +107,26 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
||||
from myagents.project_root import get_workspace_root
|
||||
|
||||
console.print("[bold]Will remove:[/bold]")
|
||||
console.print(" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,")
|
||||
console.print(" myagents, mytoolkit, metabot, mb, mm, doubao-tts")
|
||||
console.print(
|
||||
" - CLI entry points: xiaohe, myclaude, mykimi, mycodex, myhermes,"
|
||||
)
|
||||
console.print(" mycursor, myagents, mytoolkit")
|
||||
console.print(
|
||||
" - legacy leftovers if present: metabot, mb, mm, doubao-tts"
|
||||
)
|
||||
console.print(" - shell completions for the above")
|
||||
console.print(" - desktop launcher (Xiaohe Agent.app / Xiaohe Agent.desktop)")
|
||||
console.print(" - pip packages: mytoolkit, myagents")
|
||||
if remove_all:
|
||||
console.print(" - [red]~/.xiaohe (runtime, settings.json, config.json keys)[/red]")
|
||||
console.print(" - [red]~/.metabot (Feishu bot config)[/red]")
|
||||
console.print(" - [red]~/.xiaohe (settings, secrets, keys)[/red]")
|
||||
console.print(" - [red]~/.mytoolkit (mytoolkit config, incl. keys)[/red]")
|
||||
console.print(
|
||||
" - [red]~/.metabot (legacy leftover, if present)[/red]"
|
||||
)
|
||||
console.print("[bold]Will keep:[/bold]")
|
||||
if not remove_all:
|
||||
console.print(" - ~/.xiaohe (runtime, settings, keys)")
|
||||
console.print(" - ~/.metabot and ~/.mytoolkit (bot/tool configs)")
|
||||
console.print(" - ~/.xiaohe (settings, keys)")
|
||||
console.print(" - ~/.mytoolkit (tool configs)")
|
||||
console.print(f" - workspace: {get_workspace_root(create=False)}")
|
||||
console.print(" - rc-file edits (PATH line, ANTHROPIC_* exports) and the claude CLI")
|
||||
|
||||
@@ -112,8 +137,8 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
||||
# Deleting these dirs destroys settings and every saved credential —
|
||||
# always require the full confirmation chain, even with --yes.
|
||||
if not click.confirm(
|
||||
"This also deletes ~/.xiaohe, ~/.metabot and ~/.mytoolkit — runtime, "
|
||||
"bot config and ALL saved API keys. Continue?",
|
||||
"This also deletes ~/.xiaohe, ~/.mytoolkit and any leftover "
|
||||
"~/.metabot — config and ALL saved API keys. Continue?",
|
||||
default=False,
|
||||
err=True,
|
||||
):
|
||||
@@ -133,9 +158,9 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
||||
|
||||
# Entry-point scripts in ~/.local/bin (defensive: pip removes its own).
|
||||
local_bin = home / ".local" / "bin"
|
||||
for name in BIN_NAMES:
|
||||
for name in (*BIN_NAMES, *LEGACY_BIN_NAMES):
|
||||
_remove_path(local_bin / name, removed)
|
||||
for name in METABOT_COMPLETIONS:
|
||||
for name in LEGACY_COMPLETIONS:
|
||||
_remove_path(local_bin / "completions" / name, removed)
|
||||
comp_dir = local_bin / "completions"
|
||||
if comp_dir.is_dir() and not any(comp_dir.iterdir()):
|
||||
@@ -148,8 +173,8 @@ def uninstall_cmd(remove_all: bool, yes: bool) -> None:
|
||||
|
||||
if remove_all:
|
||||
_remove_path(home / ".xiaohe", removed)
|
||||
_remove_path(home / ".metabot", removed)
|
||||
_remove_path(home / ".mytoolkit", removed)
|
||||
_remove_path(home / ".metabot", removed)
|
||||
|
||||
for warning in warnings:
|
||||
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
|
||||
|
||||
+31
-266
@@ -1,277 +1,42 @@
|
||||
"""``xiaohe upgrade`` — download the latest runtime package and switch to it.
|
||||
"""``upgrade`` — forward to ``xiaohe upgrade`` (xiaohe wheel)."""
|
||||
|
||||
Flow: query version (from the published install.sh) -> confirm -> download
|
||||
tarball with a progress bar -> install to ~/.xiaohe/runtime/<version> and
|
||||
repoint ``current`` (keep the two newest) -> reinstall CLI tools -> sync
|
||||
workspace content. Credentials come from XIAOHE_USER/XIAOHE_PASS, --user/
|
||||
--password, or an interactive prompt (never stored).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.progress import (
|
||||
BarColumn,
|
||||
DownloadColumn,
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TextColumn,
|
||||
TimeRemainingColumn,
|
||||
TransferSpeedColumn,
|
||||
)
|
||||
|
||||
from myagents.commands.sync_workspace import (
|
||||
_print_report,
|
||||
sync_workspace,
|
||||
)
|
||||
from myagents.project_root import get_workspace_root
|
||||
from myagents.settings import get_setting
|
||||
|
||||
stderr_console = Console(stderr=True)
|
||||
console = Console()
|
||||
|
||||
DEFAULT_BASE_URL = "http://1.14.226.205:8088/xiaohe-agent"
|
||||
KEEP_VERSIONS = 2
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
return str(get_setting("vps_baseurl", "") or DEFAULT_BASE_URL).rstrip("/")
|
||||
|
||||
|
||||
def _runtime_root() -> Path:
|
||||
return Path.home() / ".xiaohe" / "runtime"
|
||||
|
||||
|
||||
def _current_version() -> str:
|
||||
current = _runtime_root() / "current"
|
||||
if not current.is_symlink():
|
||||
return ""
|
||||
try:
|
||||
return current.resolve(strict=True).name
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _auth_header(user: str, password: str) -> dict[str, str]:
|
||||
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
def _map_url_error(exc: Exception, url: str) -> click.ClickException:
|
||||
if isinstance(exc, urllib.error.HTTPError) and exc.code in (401, 403):
|
||||
return click.ClickException("Wrong account or password (server returned 401).")
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
return click.ClickException(f"Download failed: HTTP {exc.code} — {url}")
|
||||
reason = getattr(exc, "reason", exc)
|
||||
return click.ClickException(f"Cannot reach download server: {reason} — {url}")
|
||||
|
||||
|
||||
def _fetch_text(url: str, user: str, password: str) -> str:
|
||||
req = urllib.request.Request(url, headers=_auth_header(user, password))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8", errors="replace")
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise _map_url_error(exc, url) from exc
|
||||
|
||||
|
||||
def _download(url: str, user: str, password: str, dest: Path) -> None:
|
||||
req = urllib.request.Request(url, headers=_auth_header(user, password))
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
total = int(resp.headers.get("Content-Length") or 0) or None
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
DownloadColumn(),
|
||||
TransferSpeedColumn(),
|
||||
TimeRemainingColumn(),
|
||||
console=console,
|
||||
) as progress:
|
||||
task = progress.add_task("Downloading runtime", total=total)
|
||||
with dest.open("wb") as fh:
|
||||
while chunk := resp.read(1 << 16):
|
||||
fh.write(chunk)
|
||||
progress.update(task, advance=len(chunk))
|
||||
except (urllib.error.URLError, OSError) as exc:
|
||||
raise _map_url_error(exc, url) from exc
|
||||
|
||||
|
||||
def _parse_version(install_sh: str) -> str | None:
|
||||
match = re.search(r'^VERSION="([^"]+)"', install_sh, re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 16), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _verify_checksum(
|
||||
tarball: Path, sha256_text: str, url: str
|
||||
) -> None:
|
||||
"""Verify tarball against the published "<sha256> <name>" line."""
|
||||
expected = sha256_text.split()[0].strip().lower() if sha256_text.split() else ""
|
||||
if not expected or not all(c in "0123456789abcdef" for c in expected):
|
||||
raise click.ClickException(f"Checksum file looks wrong — {url}")
|
||||
actual = _sha256(tarball)
|
||||
if actual != expected:
|
||||
raise click.ClickException(
|
||||
"Checksum mismatch — the download is corrupted or tampered with. "
|
||||
"Aborting; nothing was installed."
|
||||
)
|
||||
|
||||
|
||||
def _extract(tarball: Path, dest: Path) -> None:
|
||||
with tarfile.open(tarball) as tf:
|
||||
try:
|
||||
tf.extractall(dest, filter="data")
|
||||
except TypeError: # Python < 3.11.4 lacks the filter argument
|
||||
tf.extractall(dest)
|
||||
|
||||
|
||||
def _pip_install(pkg_dir: Path) -> subprocess.CompletedProcess:
|
||||
import os
|
||||
import sysconfig
|
||||
|
||||
if os.environ.get("VIRTUAL_ENV") and shutil.which("uv"):
|
||||
cmd = ["uv", "pip", "install", "-e", str(pkg_dir), "--quiet"]
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "pip", "install"]
|
||||
# Ubuntu 24.04+ marks the system Python externally-managed (PEP 668);
|
||||
# without the flag pip refuses to install there at all.
|
||||
stdlib = Path(sysconfig.get_path("stdlib"))
|
||||
if (stdlib / "EXTERNALLY-MANAGED").exists():
|
||||
cmd.append("--break-system-packages")
|
||||
cmd += ["-e", str(pkg_dir), "--quiet"]
|
||||
return subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
|
||||
def _install_tools(runtime: Path) -> list[str]:
|
||||
"""Reinstall pip/npm tools from the new runtime. Returns warnings."""
|
||||
warnings: list[str] = []
|
||||
for pkg in ("mytoolkit", "myagents"):
|
||||
with console.status(f"Installing {pkg} ..."):
|
||||
result = _pip_install(runtime / "contrib" / pkg)
|
||||
if result.returncode != 0:
|
||||
tail = (result.stderr or "").strip().splitlines()[-1:]
|
||||
warnings.append(f"{pkg} install failed: {tail[0] if tail else 'unknown error'}")
|
||||
metabot = runtime / "contrib" / "metabot"
|
||||
if shutil.which("npm") and (metabot / "package.json").is_file():
|
||||
steps = (["npm", "install", "--silent"], ["npm", "run", "build", "--silent"],
|
||||
["npm", "run", "update-cli", "--silent"])
|
||||
for cmd in steps:
|
||||
with console.status(f"metabot: {' '.join(cmd[:2])} ..."):
|
||||
result = subprocess.run(cmd, cwd=metabot, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
warnings.append(f"metabot `{' '.join(cmd[:2])}` failed — rerun manually later")
|
||||
break
|
||||
elif not shutil.which("npm"):
|
||||
warnings.append("npm not found — skipping metabot (xiaohe itself is unaffected)")
|
||||
return warnings
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
versions = sorted(
|
||||
(d for d in _runtime_root().iterdir() if d.is_dir() and not d.is_symlink()),
|
||||
key=lambda d: d.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for old in versions[KEEP_VERSIONS:]:
|
||||
shutil.rmtree(old)
|
||||
|
||||
|
||||
@click.command("upgrade")
|
||||
@click.option("--user", envvar="XIAOHE_USER", help="Download-site account (or set XIAOHE_USER)")
|
||||
@click.option("--password", envvar="XIAOHE_PASS", help="Download-site password (or set XIAOHE_PASS)")
|
||||
@click.option("--force", is_flag=True, help="Reinstall even if already up to date")
|
||||
def upgrade_cmd(user: str | None, password: str | None, force: bool) -> None:
|
||||
"""Upgrade xiaohe: fetch the latest runtime, reinstall tools, sync workspace."""
|
||||
base = _base_url()
|
||||
current = _current_version()
|
||||
if not current:
|
||||
@click.argument("version", required=False)
|
||||
@click.option("--force", is_flag=True, help="Reinstall even if already on that version")
|
||||
@click.option(
|
||||
"--beta",
|
||||
is_flag=True,
|
||||
help="Forward to xiaohe upgrade --beta",
|
||||
)
|
||||
@click.option("--user", "user", default=None, hidden=True)
|
||||
@click.option("--password", default=None, hidden=True)
|
||||
def upgrade_cmd(
|
||||
version: str | None,
|
||||
force: bool,
|
||||
beta: bool,
|
||||
user: str | None,
|
||||
password: str | None,
|
||||
) -> None:
|
||||
"""Forward to ``xiaohe upgrade`` (xiaohe wheel)."""
|
||||
del user, password
|
||||
xiaohe = shutil.which("xiaohe")
|
||||
if not xiaohe:
|
||||
raise click.ClickException(
|
||||
"No installed runtime found (~/.xiaohe/runtime) — run install.sh first."
|
||||
)
|
||||
user = str(user or click.prompt("Download account", err=True))
|
||||
password = str(password or click.prompt("Password", hide_input=True, err=True))
|
||||
|
||||
console.print(f"[dim]Current version: {current}[/dim]")
|
||||
with console.status("Checking latest version ..."):
|
||||
install_sh = _fetch_text(f"{base}/install.sh", user, password)
|
||||
latest = _parse_version(install_sh)
|
||||
if not latest:
|
||||
raise click.ClickException(
|
||||
"Could not parse a version from install.sh — server content looks wrong."
|
||||
)
|
||||
|
||||
if latest == current and not force:
|
||||
console.print(f"[green]Already up to date ({latest}).[/green] Use --force to reinstall.")
|
||||
return
|
||||
if not force and not click.confirm(
|
||||
f"New version {latest} (current: {current}). Upgrade now?", default=True, err=True
|
||||
):
|
||||
console.print("Cancelled.")
|
||||
return
|
||||
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="xiaohe-upgrade-"))
|
||||
try:
|
||||
tarball = tmpdir / "xiaohe-agent-latest.tar.gz"
|
||||
_download(f"{base}/xiaohe-agent-latest.tar.gz", user, password, tarball)
|
||||
with console.status("Verifying checksum ..."):
|
||||
sha_url = f"{base}/xiaohe-agent-latest.tar.gz.sha256"
|
||||
_verify_checksum(tarball, _fetch_text(sha_url, user, password), sha_url)
|
||||
|
||||
target = _runtime_root() / latest
|
||||
with console.status(f"Installing to {target} ..."):
|
||||
extract_dir = tmpdir / "extract"
|
||||
_extract(tarball, extract_dir)
|
||||
inner = extract_dir / "workspace"
|
||||
if not (inner / "setup.sh").is_file():
|
||||
raise click.ClickException("Tarball looks wrong (setup.sh missing).")
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
_runtime_root().mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(inner), str(target))
|
||||
current_link = _runtime_root() / "current"
|
||||
current_link.unlink(missing_ok=True)
|
||||
current_link.symlink_to(target)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
warnings = _install_tools(target)
|
||||
_prune()
|
||||
|
||||
workspace = get_workspace_root(create=False)
|
||||
if workspace.is_dir():
|
||||
try:
|
||||
report = sync_workspace(workspace)
|
||||
except click.ClickException as exc:
|
||||
# e.g. dev machine: workspace is a git checkout — not a failure.
|
||||
stderr_console.print(f"[yellow]workspace sync skipped: {exc.message}[/yellow]")
|
||||
else:
|
||||
_print_report(workspace, report)
|
||||
else:
|
||||
console.print(f"[yellow]workspace {workspace} missing — run 'xiaohe init' first.[/yellow]")
|
||||
|
||||
for warning in warnings:
|
||||
stderr_console.print(f"[yellow]warning: {warning}[/yellow]")
|
||||
console.print(
|
||||
f"\n[bold green]Upgraded: {current} -> {latest}[/bold green] "
|
||||
"(open a new terminal; roll back anytime with 'xiaohe switch')"
|
||||
"Install xiaohe, then run: xiaohe upgrade"
|
||||
)
|
||||
cmd = [xiaohe, "upgrade"]
|
||||
if version:
|
||||
cmd.append(version)
|
||||
if force:
|
||||
cmd.append("--force")
|
||||
if beta:
|
||||
cmd.append("--beta")
|
||||
raise SystemExit(subprocess.call(cmd))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""``xiaohe version`` — show whether the CLI runs a dev checkout or an installed runtime."""
|
||||
"""``version`` — show package install location."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
@@ -14,11 +15,15 @@ def _git_describe(tree: Path) -> str:
|
||||
try:
|
||||
describe = subprocess.run(
|
||||
["git", "-C", str(tree), "describe", "--tags", "--dirty", "--always"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
branch = subprocess.run(
|
||||
["git", "-C", str(tree), "branch", "--show-current"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return "unknown"
|
||||
@@ -30,57 +35,38 @@ def _git_describe(tree: Path) -> str:
|
||||
|
||||
|
||||
def describe_tree(tree: Path) -> tuple[str, str]:
|
||||
"""Classify a package source tree: ('installed'|'development'|'unknown', detail)."""
|
||||
parts = tree.parts
|
||||
if ".xiaohe" in parts and "runtime" in parts:
|
||||
try:
|
||||
idx = parts.index("runtime")
|
||||
return "installed", parts[idx + 1]
|
||||
except (ValueError, IndexError):
|
||||
return "installed", "unknown"
|
||||
if (tree / ".git").exists():
|
||||
"""Classify a package source tree."""
|
||||
if (tree / ".git").exists() or (tree.parent / ".git").exists():
|
||||
# packages/myagents → monorepo root may hold .git
|
||||
probe = tree
|
||||
for _ in range(3):
|
||||
if (probe / ".git").exists():
|
||||
return "development", _git_describe(probe)
|
||||
probe = probe.parent
|
||||
return "development", _git_describe(tree)
|
||||
return "unknown", ""
|
||||
|
||||
|
||||
def _pkg_tree(module_file: str) -> Path:
|
||||
# <repo>/myagents/__init__.py -> <repo>
|
||||
return Path(module_file).resolve().parents[1]
|
||||
|
||||
|
||||
def _report_pkg(label: str, module_file: str) -> str:
|
||||
tree = _pkg_tree(module_file)
|
||||
mode, detail = describe_tree(tree)
|
||||
line = {
|
||||
"installed": f"installed runtime [cyan]{detail}[/cyan]",
|
||||
"development": f"[green]development checkout[/green] — {detail}",
|
||||
}.get(mode, "unknown")
|
||||
console.print(f" {label}: {line}")
|
||||
console.print(f" [dim]{tree}[/dim]")
|
||||
return mode
|
||||
|
||||
|
||||
@click.command("version")
|
||||
def version_cmd() -> None:
|
||||
"""Show which tree the CLI runs from: dev checkout or installed runtime."""
|
||||
"""Show which tree the CLI runs from."""
|
||||
import myagents
|
||||
|
||||
bin_path = shutil.which("xiaohe") or "?"
|
||||
console.print(f"[bold]xiaohe[/bold] (bin: {bin_path})")
|
||||
mode = _report_pkg("myagents", myagents.__file__)
|
||||
bin_path = shutil.which("myagents") or "?"
|
||||
console.print(f"[bold]myagents[/bold] (bin: {bin_path})")
|
||||
|
||||
try:
|
||||
import mytoolkit
|
||||
console.print(f" version: [cyan]{version('myagents')}[/cyan]")
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
|
||||
_report_pkg("mytoolkit", mytoolkit.__file__)
|
||||
except ImportError:
|
||||
console.print(" mytoolkit: [yellow]not importable[/yellow]")
|
||||
|
||||
current = Path.home() / ".xiaohe" / "runtime" / "current"
|
||||
if current.is_symlink():
|
||||
console.print(f"runtime snapshot: [cyan]{current.resolve().name}[/cyan] [dim]({current.parent})[/dim]")
|
||||
|
||||
if mode == "installed":
|
||||
console.print(
|
||||
"\n[dim]Dev machine? Point the bins at your repo instead:[/dim]\n"
|
||||
" pip install -e <repo>/contrib/mytoolkit -e <repo>/contrib/myagents"
|
||||
)
|
||||
tree = _pkg_tree(myagents.__file__)
|
||||
mode, detail = describe_tree(tree)
|
||||
if mode == "development":
|
||||
console.print(f" source: [green]development[/green] — {detail}")
|
||||
else:
|
||||
console.print(f" source: installed [dim]{tree}[/dim]")
|
||||
|
||||
+25
-48
@@ -1,46 +1,16 @@
|
||||
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, xiaohe."""
|
||||
|
||||
import re
|
||||
"""Standalone entrypoints for myclaude, mykimi, mycodex, myhermes, mycursor."""
|
||||
|
||||
from myagents.launcher import build_cli
|
||||
|
||||
claude_cli = build_cli("claude", prog_name="myclaude")
|
||||
kimi_cli = build_cli("kimi", prog_name="mykimi")
|
||||
codex_cli = build_cli("codex", prog_name="mycodex")
|
||||
hermes_cli = build_cli("hermes", prog_name="myhermes")
|
||||
|
||||
_KNOWN_BACKENDS = ("claude", "kimi", "codex", "hermes")
|
||||
|
||||
|
||||
def default_agent() -> str:
|
||||
"""Default backend for the ``xiaohe`` alias.
|
||||
|
||||
Reads ``default_agent`` from xiaohe settings (accepts both "claude" and
|
||||
"myclaude" spellings); falls back to claude.
|
||||
"""
|
||||
from myagents.settings import get_setting
|
||||
|
||||
agent = str(get_setting("default_agent", "") or "")
|
||||
agent = re.sub(r"^my", "", agent.strip().lower())
|
||||
return agent if agent in _KNOWN_BACKENDS else "claude"
|
||||
|
||||
|
||||
def build_xiaohe_cli():
|
||||
"""The ``xiaohe`` command group: agent forwarding + init/sync/upgrade/switch/uninstall/version."""
|
||||
from myagents.commands.switch import switch_cmd
|
||||
from myagents.commands.sync_workspace import init_cmd, sync_cmd
|
||||
from myagents.commands.uninstall import uninstall_cmd
|
||||
from myagents.commands.upgrade import upgrade_cmd
|
||||
from myagents.commands.version import version_cmd
|
||||
|
||||
xiaohe_cli = build_cli(default_agent(), prog_name="xiaohe", offer_install=True)
|
||||
xiaohe_cli.add_command(init_cmd)
|
||||
xiaohe_cli.add_command(sync_cmd)
|
||||
xiaohe_cli.add_command(upgrade_cmd)
|
||||
xiaohe_cli.add_command(switch_cmd)
|
||||
xiaohe_cli.add_command(uninstall_cmd)
|
||||
xiaohe_cli.add_command(version_cmd)
|
||||
return xiaohe_cli
|
||||
# offer_install=True so a missing backend CLI (e.g. `claude`) triggers the
|
||||
# interactive "Install now? [y/N]" prompt instead of a bare not-found error.
|
||||
# Backends without an install_cmd (hermes/cursor) fall back to the manual msg.
|
||||
claude_cli = build_cli("claude", prog_name="myclaude", offer_install=True)
|
||||
kimi_cli = build_cli("kimi", prog_name="mykimi", offer_install=True)
|
||||
codex_cli = build_cli("codex", prog_name="mycodex", offer_install=True)
|
||||
hermes_cli = build_cli("hermes", prog_name="myhermes", offer_install=True)
|
||||
cursor_cli = build_cli("cursor", prog_name="mycursor", offer_install=True)
|
||||
dsh_cli = build_cli("dsh", prog_name="mydsh", offer_install=True)
|
||||
|
||||
|
||||
def _progs():
|
||||
@@ -52,7 +22,8 @@ def _progs():
|
||||
("mykimi", lambda: kimi_cli),
|
||||
("mycodex", lambda: codex_cli),
|
||||
("myhermes", lambda: hermes_cli),
|
||||
("xiaohe", build_xiaohe_cli),
|
||||
("mycursor", lambda: cursor_cli),
|
||||
("mydsh", lambda: dsh_cli),
|
||||
]
|
||||
|
||||
|
||||
@@ -96,15 +67,21 @@ def hermes_main() -> None:
|
||||
hermes_cli()
|
||||
|
||||
|
||||
def xiaohe_main() -> None:
|
||||
"""Run ``xiaohe``: default-agent launcher plus init/sync subcommands.
|
||||
|
||||
Same options as the corresponding ``my<agent>`` command; when the
|
||||
underlying CLI is missing, offers to install it interactively.
|
||||
"""
|
||||
def cursor_main() -> None:
|
||||
"""Run ``mycursor``."""
|
||||
from myagents.commands.completion_install import (
|
||||
ensure_completions_installed,
|
||||
)
|
||||
|
||||
ensure_completions_installed(_progs())
|
||||
build_xiaohe_cli()()
|
||||
cursor_cli()
|
||||
|
||||
|
||||
def dsh_main() -> None:
|
||||
"""Run ``mydsh``."""
|
||||
from myagents.commands.completion_install import (
|
||||
ensure_completions_installed,
|
||||
)
|
||||
|
||||
ensure_completions_installed(_progs())
|
||||
dsh_cli()
|
||||
|
||||
+1332
-53
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]
|
||||
name = "myagents"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
description = "Myagents CLI: unified launcher for AI coding agents"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
@@ -14,7 +14,8 @@ myclaude = "myagents.entrypoints:claude_main"
|
||||
mykimi = "myagents.entrypoints:kimi_main"
|
||||
mycodex = "myagents.entrypoints:codex_main"
|
||||
myhermes = "myagents.entrypoints:hermes_main"
|
||||
xiaohe = "myagents.entrypoints:xiaohe_main"
|
||||
mycursor = "myagents.entrypoints:cursor_main"
|
||||
mydsh = "myagents.entrypoints:dsh_main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pytest>=8.0"]
|
||||
|
||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
|
||||
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes")
|
||||
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes", "mycursor", "mydsh")
|
||||
|
||||
|
||||
def _remove_link(link: str, want: str) -> bool:
|
||||
|
||||
@@ -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 unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import myagents.launcher
|
||||
from myagents.cli import cli
|
||||
|
||||
|
||||
def _backend_which(*names: str):
|
||||
"""Patch ``shutil.which`` to find backend binaries and nothing else.
|
||||
|
||||
The launcher also probes ``which("npm")`` while resolving npm-global
|
||||
installs; returning None there keeps that probe from spawning a real
|
||||
``subprocess.run`` in tests (which would break ``assert_called_once``).
|
||||
"""
|
||||
|
||||
def _which(name: str) -> str | None:
|
||||
return f"/usr/bin/{name}" if name in names else None
|
||||
|
||||
return _which
|
||||
|
||||
|
||||
class TestMyagentsHelp:
|
||||
"""Tests for top-level myagents command."""
|
||||
|
||||
def test_help_shows_agent_subcommands(self) -> None:
|
||||
"""--help should list claude, kimi, codex and hermes subcommands."""
|
||||
"""--help should list claude, kimi, codex, hermes, cursor and dsh."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
@@ -23,6 +38,8 @@ class TestMyagentsHelp:
|
||||
assert "kimi" in result.output
|
||||
assert "codex" in result.output
|
||||
assert "hermes" in result.output
|
||||
assert "cursor" in result.output
|
||||
assert "dsh" in result.output
|
||||
assert "update" in result.output
|
||||
assert "upgrade" in result.output
|
||||
|
||||
@@ -40,6 +57,24 @@ class TestMyagentsHelp:
|
||||
assert result.exit_code == 0
|
||||
assert "Usage:" in result.output
|
||||
|
||||
def test_backends_lists_all(self) -> None:
|
||||
"""``myagents backends`` lists all supported agent backends."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["backends"])
|
||||
assert result.exit_code == 0
|
||||
assert "claude" in result.output
|
||||
assert "kimi" in result.output
|
||||
assert "codex" in result.output
|
||||
assert "hermes" in result.output
|
||||
assert "cursor" in result.output
|
||||
|
||||
def test_help_lists_backends(self) -> None:
|
||||
"""--help should list backends subcommand."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "backends" in result.output
|
||||
|
||||
|
||||
class TestClaudeSubcommand:
|
||||
"""Tests for ``myagents claude``."""
|
||||
@@ -55,7 +90,7 @@ class TestClaudeSubcommand:
|
||||
def test_runs_claude(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -80,7 +115,7 @@ class TestClaudeSubcommand:
|
||||
test_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -102,7 +137,7 @@ class TestClaudePassthrough:
|
||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -113,13 +148,17 @@ class TestClaudePassthrough:
|
||||
mock_run.assert_called_once()
|
||||
return mock_run.call_args[0][0]
|
||||
|
||||
def test_resume_flag_passes_through(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["--resume"], tmp_path)
|
||||
assert cmd == [
|
||||
"/usr/bin/claude",
|
||||
"--dangerously-skip-permissions",
|
||||
"--resume",
|
||||
]
|
||||
def test_bare_resume_opens_picker_not_launch(self, tmp_path: Path) -> None:
|
||||
"""Bare --resume opens the unified picker; non-tty lists and exits."""
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("claude")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["claude", "--cwd", str(tmp_path), "--resume"])
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_resume_with_session_id(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["-r", "abc123"], tmp_path)
|
||||
@@ -156,7 +195,7 @@ class TestKimiSubcommand:
|
||||
def test_runs_kimi(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("kimi")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -178,7 +217,7 @@ class TestKimiSubcommand:
|
||||
test_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("kimi")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -193,7 +232,7 @@ class TestKimiPassthrough:
|
||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/kimi"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("kimi")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -246,7 +285,7 @@ class TestCodexSubcommand:
|
||||
def test_runs_codex(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -268,7 +307,7 @@ class TestCodexSubcommand:
|
||||
test_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -283,7 +322,7 @@ class TestCodexPassthrough:
|
||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -309,7 +348,7 @@ class TestCodexResumeOptions:
|
||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/codex"),
|
||||
patch("myagents.launcher.shutil.which", side_effect=_backend_which("codex")),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
@@ -418,7 +457,7 @@ class TestHermesSubcommand:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("hermes")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
@@ -442,7 +481,7 @@ class TestHermesSubcommand:
|
||||
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("hermes")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
@@ -459,7 +498,7 @@ class TestHermesPassthrough:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", return_value="/usr/bin/hermes"
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("hermes")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
@@ -581,3 +620,302 @@ class TestHermesListSessions:
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "sess-abc123" in result.output
|
||||
assert "hello hermes" in result.output
|
||||
|
||||
|
||||
class TestCursorSubcommand:
|
||||
"""Tests for ``myagents cursor``."""
|
||||
|
||||
def test_help_shows_options(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["cursor", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--cwd" in result.output
|
||||
assert "--list" in result.output
|
||||
|
||||
def test_runs_agent(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("agent")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["cursor"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once()
|
||||
assert mock_run.call_args[0][0] == ["/usr/bin/agent", "--force"]
|
||||
|
||||
def test_falls_back_to_cursor_agent(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
def _which(name: str) -> str | None:
|
||||
return "/usr/bin/cursor-agent" if name == "cursor-agent" else None
|
||||
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", side_effect=_which),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
patch.dict("os.environ", {}, clear=False) as env,
|
||||
):
|
||||
env.pop("CURSOR_BIN", None)
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["cursor"])
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args[0][0] == [
|
||||
"/usr/bin/cursor-agent",
|
||||
"--force",
|
||||
]
|
||||
|
||||
def test_missing_binary_error(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value=None),
|
||||
patch.dict("os.environ", {}, clear=False) as env,
|
||||
):
|
||||
env.pop("CURSOR_BIN", None)
|
||||
result = runner.invoke(cli, ["cursor"])
|
||||
assert result.exit_code == 127
|
||||
assert "not found" in result.output.lower()
|
||||
|
||||
def test_cwd_option_passed(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
test_dir = tmp_path / "test_cwd"
|
||||
test_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("agent")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["cursor", "--cwd", str(test_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve())
|
||||
|
||||
|
||||
class TestDshSubcommand:
|
||||
"""Tests for ``myagents dsh``."""
|
||||
|
||||
def test_help_shows_options(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["dsh", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--cwd" in result.output
|
||||
assert "--list" in result.output
|
||||
|
||||
def test_runs_dsh(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("dsh")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["dsh"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once()
|
||||
# dsh is a profile launcher: no default args injected.
|
||||
assert mock_run.call_args[0][0] == ["/usr/bin/dsh"]
|
||||
|
||||
def test_missing_binary_error(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value=None),
|
||||
patch.dict("os.environ", {}, clear=False) as env,
|
||||
):
|
||||
env.pop("DSH_BIN", None)
|
||||
result = runner.invoke(cli, ["dsh"])
|
||||
assert result.exit_code == 127
|
||||
assert "dsh CLI not found" in result.output
|
||||
|
||||
def test_cwd_option_passed(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
test_dir = tmp_path / "test_cwd"
|
||||
test_dir.mkdir()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("dsh")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cli, ["dsh", "--cwd", str(test_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve())
|
||||
|
||||
|
||||
class TestDshListSessions:
|
||||
"""``mydsh -l`` reads zstd-compressed dsh session logs."""
|
||||
|
||||
def _write_session(self, dsh_home: Path, slug: str, sid: str, cwd: str) -> Path:
|
||||
zstd = pytest.importorskip("zstandard")
|
||||
log = dsh_home / "sessions" / slug / sid / "session.jsonl.zstd"
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
json.dumps(
|
||||
{"type": "session", "version": 0, "id": sid, "cwd": cwd}
|
||||
),
|
||||
json.dumps(
|
||||
{
|
||||
"type": "session/title",
|
||||
"seq": 1,
|
||||
"data": {"title": "My dsh test"},
|
||||
}
|
||||
),
|
||||
]
|
||||
with log.open("wb") as fh:
|
||||
with zstd.ZstdCompressor().stream_writer(fh) as w:
|
||||
w.write(("\n".join(lines) + "\n").encode("utf-8"))
|
||||
return log
|
||||
|
||||
def test_list_shows_dsh_sessions(self, tmp_path: Path, monkeypatch) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
dsh_home = tmp_path / "dshhome"
|
||||
self._write_session(dsh_home, "slug", "sess-1", str(workspace))
|
||||
monkeypatch.setenv("DSH_HOME", str(dsh_home))
|
||||
|
||||
runner = CliRunner()
|
||||
with patch(
|
||||
"myagents.launcher.shutil.which", return_value="/usr/bin/dsh"
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli, ["dsh", "-l", "--cwd", str(workspace)]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "sess-1" in result.output
|
||||
assert "My dsh test" in result.output
|
||||
|
||||
def test_first_prompt_reads_zstd(self, tmp_path: Path) -> None:
|
||||
from myagents.launcher import _first_prompt_dsh
|
||||
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
log = self._write_session(tmp_path / "home", "slug", "sid-1", str(workspace))
|
||||
assert _first_prompt_dsh(log) == "My dsh test"
|
||||
|
||||
|
||||
class TestCursorPassthrough:
|
||||
"""Map myagents-style resume flags to Cursor Agent CLI flags."""
|
||||
|
||||
def _invoke(self, args: list[str], tmp_path: Path) -> list[str]:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", side_effect=_backend_which("agent")
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(
|
||||
cli, ["cursor", "--cwd", str(tmp_path), *args]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_run.assert_called_once()
|
||||
return mock_run.call_args[0][0]
|
||||
|
||||
def test_r_flag_maps_to_resume(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["-r", "abc123"], tmp_path)
|
||||
assert cmd == ["/usr/bin/agent", "--force", "--resume", "abc123"]
|
||||
|
||||
def test_bare_r_flag_maps_to_resume(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["-r"], tmp_path)
|
||||
assert cmd == ["/usr/bin/agent", "--force", "--resume"]
|
||||
|
||||
def test_resume_flag_passes_through(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["--resume", "abc123"], tmp_path)
|
||||
assert cmd == ["/usr/bin/agent", "--force", "--resume", "abc123"]
|
||||
|
||||
def test_c_flag_maps_to_continue(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["-c"], tmp_path)
|
||||
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
|
||||
|
||||
def test_continue_flag_passes_through(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["--continue"], tmp_path)
|
||||
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
|
||||
|
||||
def test_last_flag_maps_to_continue(self, tmp_path: Path) -> None:
|
||||
cmd = self._invoke(["--last"], tmp_path)
|
||||
assert cmd == ["/usr/bin/agent", "--force", "--continue"]
|
||||
|
||||
|
||||
class TestCursorListSessions:
|
||||
"""``myagents cursor --list`` reads ~/.cursor/chats/<cwd-md5>/."""
|
||||
|
||||
def test_list_empty_directory(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with patch(
|
||||
"myagents.launcher._BACKENDS",
|
||||
{
|
||||
"cursor": {
|
||||
**myagents.launcher._BACKENDS["cursor"],
|
||||
"sessions_root": lambda: tmp_path / "chats",
|
||||
}
|
||||
},
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli, ["cursor", "--cwd", str(tmp_path), "--list"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "no sessions" in result.output.lower()
|
||||
|
||||
def test_list_shows_session_id_and_name(self, tmp_path: Path) -> None:
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
cwd = tmp_path / "proj"
|
||||
cwd.mkdir()
|
||||
chats_root = tmp_path / "chats"
|
||||
munged = hashlib.md5(str(cwd.resolve()).encode("utf-8")).hexdigest()
|
||||
session_dir = chats_root / munged / "chat-abc123"
|
||||
session_dir.mkdir(parents=True)
|
||||
db_path = session_dir / "store.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)")
|
||||
conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)")
|
||||
meta = {
|
||||
"agentId": "chat-abc123",
|
||||
"name": "Fix mycursor entrypoint",
|
||||
"mode": "default",
|
||||
"createdAt": 1,
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('0', ?)",
|
||||
(json.dumps(meta).encode().hex(),),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO blobs (id, data) VALUES ('1', ?)",
|
||||
(
|
||||
json.dumps(
|
||||
{
|
||||
"role": "user",
|
||||
"content": "<user_query>\nhello cursor\n</user_query>",
|
||||
}
|
||||
).encode(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch(
|
||||
"myagents.launcher._BACKENDS",
|
||||
{
|
||||
"cursor": {
|
||||
**myagents.launcher._BACKENDS["cursor"],
|
||||
"sessions_root": lambda: chats_root,
|
||||
}
|
||||
},
|
||||
):
|
||||
result = runner.invoke(
|
||||
cli, ["cursor", "--cwd", str(cwd), "--list"],
|
||||
env={"COLUMNS": "120"},
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "chat-abc123" in result.output
|
||||
assert "Fix mycursor entrypoint" in result.output
|
||||
assert "mycursor" in result.output
|
||||
|
||||
@@ -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 unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from myagents.entrypoints import claude_cli, codex_cli, hermes_cli, kimi_cli
|
||||
|
||||
from myagents.entrypoints import (
|
||||
claude_cli,
|
||||
codex_cli,
|
||||
cursor_cli,
|
||||
dsh_cli,
|
||||
hermes_cli,
|
||||
kimi_cli,
|
||||
)
|
||||
|
||||
class TestMyclaudeEntrypoint:
|
||||
"""``myclaude`` standalone entrypoint."""
|
||||
@@ -32,6 +38,22 @@ class TestMyclaudeEntrypoint:
|
||||
assert "myclaude" in result.output
|
||||
|
||||
def test_passthrough(self, tmp_path: Path) -> None:
|
||||
"""``--resume <id>`` still passes through to the native CLI."""
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(
|
||||
claude_cli, ["--cwd", str(tmp_path), "--resume", "abc123"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
args = mock_run.call_args[0][0]
|
||||
assert args[-2:] == ["--resume", "abc123"]
|
||||
|
||||
def test_bare_resume_opens_picker(self, tmp_path: Path) -> None:
|
||||
"""Bare ``--resume`` opens the unified picker instead of the CLI."""
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
||||
@@ -40,7 +62,7 @@ class TestMyclaudeEntrypoint:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"])
|
||||
assert result.exit_code == 0
|
||||
assert "--resume" in mock_run.call_args[0][0]
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestMykimiEntrypoint:
|
||||
@@ -145,6 +167,87 @@ class TestMyhermesEntrypoint:
|
||||
]
|
||||
|
||||
|
||||
class TestMycursorEntrypoint:
|
||||
"""``mycursor`` standalone entrypoint."""
|
||||
|
||||
def test_runs_agent(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(cursor_cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args[0][0] == ["/usr/bin/agent", "--force"]
|
||||
|
||||
def test_version_shows_mycursor(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cursor_cli, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "mycursor" in result.output
|
||||
|
||||
def test_passthrough(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"myagents.launcher.shutil.which", return_value="/usr/bin/agent"
|
||||
),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(
|
||||
cursor_cli, ["--cwd", str(tmp_path), "-r", "abc123"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]
|
||||
|
||||
|
||||
class TestMydshEntrypoint:
|
||||
"""``mydsh`` standalone entrypoint."""
|
||||
|
||||
def test_runs_dsh(self) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/dsh"),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(dsh_cli, [])
|
||||
assert result.exit_code == 0
|
||||
# dsh is a profile launcher; no default args are injected.
|
||||
assert mock_run.call_args[0][0] == ["/usr/bin/dsh"]
|
||||
|
||||
def test_version_shows_mydsh(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(dsh_cli, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "mydsh" in result.output
|
||||
|
||||
def test_passthrough(self, tmp_path: Path) -> None:
|
||||
"""``--resume <id>`` passes through to the native dsh CLI."""
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/dsh"),
|
||||
patch("myagents.launcher.subprocess.run") as mock_run,
|
||||
):
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(
|
||||
dsh_cli, ["--cwd", str(tmp_path), "--resume", "abc123"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]
|
||||
|
||||
def test_missing_binary_exits_127(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch("myagents.launcher.shutil.which", return_value=None):
|
||||
result = runner.invoke(dsh_cli, [])
|
||||
assert result.exit_code == 127
|
||||
assert "dsh CLI not found" in result.output
|
||||
|
||||
|
||||
class TestTmuxOption:
|
||||
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session."""
|
||||
|
||||
|
||||
@@ -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 myagents.commands import switch as sw_mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
monkeypatch.setattr(sw_mod, "_install_tools", lambda runtime: [])
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
monkeypatch.setattr(sw_mod, "get_workspace_root", lambda create=False: workspace)
|
||||
monkeypatch.setattr(
|
||||
sw_mod,
|
||||
"sync_workspace",
|
||||
lambda ws: {"added": [], "updated": [], "skipped": [], "removed": []},
|
||||
)
|
||||
return home
|
||||
|
||||
|
||||
def _make_versions(home: Path, versions: tuple[str, ...], current: str) -> None:
|
||||
root = home / ".xiaohe" / "runtime"
|
||||
for name in versions:
|
||||
(root / name).mkdir(parents=True)
|
||||
(root / "current").symlink_to(root / current)
|
||||
|
||||
|
||||
class TestSwitch:
|
||||
def test_switches_current_and_reinstalls(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
installed: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
sw_mod, "_install_tools", lambda rt: installed.append(rt.name) or []
|
||||
)
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1", "--yes"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Switched: v2 -> v1" in result.output
|
||||
current = fake_home / ".xiaohe" / "runtime" / "current"
|
||||
assert current.resolve().name == "v1"
|
||||
assert installed == ["v1"]
|
||||
|
||||
def test_unknown_version_errors(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, ["v9", "--yes"])
|
||||
assert result.exit_code != 0
|
||||
assert "not installed" in result.output
|
||||
|
||||
def test_already_current_is_noop(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, ["v2", "--yes"])
|
||||
class TestSwitchHelp:
|
||||
def test_group_mentions_upgrade(self) -> None:
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Already on v2" in result.output
|
||||
|
||||
def test_interactive_list_and_prompt(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, [], input="v1\ny\n")
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "v2" in result.output and "(current)" in result.output
|
||||
assert (fake_home / ".xiaohe" / "runtime" / "current").resolve().name == "v1"
|
||||
|
||||
def test_cancelled_keeps_current(self, fake_home: Path) -> None:
|
||||
_make_versions(fake_home, ("v1", "v2"), "v2")
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1"], input="n\n")
|
||||
assert result.exit_code == 0
|
||||
assert "Cancelled" in result.output
|
||||
assert (fake_home / ".xiaohe" / "runtime" / "current").resolve().name == "v2"
|
||||
|
||||
def test_no_versions_errors(self, fake_home: Path) -> None:
|
||||
result = CliRunner().invoke(sw_mod.switch_cmd, ["v1", "--yes"])
|
||||
assert result.exit_code != 0
|
||||
assert "No installed runtime" in result.output
|
||||
assert "xiaohe upgrade" in result.output
|
||||
assert "agent" in result.output
|
||||
|
||||
@@ -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 (fake_home / "Desktop" / "Xiaohe Agent.app").exists()
|
||||
assert (fake_home / ".xiaohe" / "agent" / "config.json").is_file()
|
||||
# Legacy leftover dir kept unless --all
|
||||
assert (fake_home / ".metabot" / "bots.json").is_file()
|
||||
assert (fake_home / ".mytoolkit" / "config.json").is_file()
|
||||
assert (fake_home / "workspace").is_dir()
|
||||
@@ -64,7 +65,7 @@ class TestUninstall:
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert not (fake_home / ".xiaohe").exists()
|
||||
assert not (fake_home / ".metabot").exists()
|
||||
assert not (fake_home / ".metabot").exists() # legacy leftover
|
||||
assert not (fake_home / ".mytoolkit").exists()
|
||||
assert (fake_home / "workspace").is_dir() # workspace never touched
|
||||
|
||||
|
||||
+22
-257
@@ -1,270 +1,35 @@
|
||||
"""Tests for myagents.commands.upgrade."""
|
||||
"""Tests for myagents.commands.upgrade (forwards to xiaohe upgrade)."""
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from myagents.commands import upgrade as up_mod
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
return home
|
||||
|
||||
|
||||
def _make_runtime(home: Path, version: str) -> Path:
|
||||
target = home / ".xiaohe" / "runtime" / version
|
||||
target.mkdir(parents=True)
|
||||
current = target.parent / "current"
|
||||
current.unlink(missing_ok=True)
|
||||
current.symlink_to(target)
|
||||
return target
|
||||
|
||||
|
||||
class TestParseVersion:
|
||||
def test_parses_quoted_version(self) -> None:
|
||||
assert up_mod._parse_version('BASE_URL="x"\nVERSION="v1.2.3"\n') == "v1.2.3"
|
||||
|
||||
def test_returns_none_on_garbage(self) -> None:
|
||||
assert up_mod._parse_version("no version here") is None
|
||||
|
||||
|
||||
class TestPrune:
|
||||
def test_keeps_two_newest(self, fake_home: Path) -> None:
|
||||
root = fake_home / ".xiaohe" / "runtime"
|
||||
for name in ("v1", "v2", "v3", "v4"):
|
||||
d = root / name
|
||||
d.mkdir(parents=True)
|
||||
up_mod._prune()
|
||||
remaining = sorted(p.name for p in root.iterdir())
|
||||
assert remaining == ["v3", "v4"]
|
||||
|
||||
|
||||
class TestUpgradeCommand:
|
||||
def test_already_latest_shortcircuits(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_make_runtime(fake_home, "v1.0.0")
|
||||
monkeypatch.setattr(
|
||||
up_mod, "_fetch_text", lambda *a: 'VERSION="v1.0.0"\n'
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
|
||||
)
|
||||
def test_forwards_to_xiaohe(self) -> None:
|
||||
with (
|
||||
patch.object(up_mod.shutil, "which", return_value="/bin/xiaohe"),
|
||||
patch.object(up_mod.subprocess, "call", return_value=0) as call,
|
||||
):
|
||||
result = CliRunner().invoke(up_mod.upgrade_cmd, ["0.5.1", "--force"])
|
||||
assert result.exit_code == 0
|
||||
assert "Already up to date" in result.output
|
||||
call.assert_called_once_with(
|
||||
["/bin/xiaohe", "upgrade", "0.5.1", "--force"]
|
||||
)
|
||||
|
||||
def test_missing_runtime_errors(self, fake_home: Path) -> None:
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"]
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "install.sh" in result.output
|
||||
|
||||
def test_cancelled_by_user(
|
||||
self, fake_home: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_make_runtime(fake_home, "v1.0.0")
|
||||
monkeypatch.setattr(
|
||||
up_mod, "_fetch_text", lambda *a: 'VERSION="v2.0.0"\n'
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
up_mod.upgrade_cmd, ["--user", "u", "--password", "p"], input="n\n"
|
||||
)
|
||||
def test_forwards_beta(self) -> None:
|
||||
with (
|
||||
patch.object(up_mod.shutil, "which", return_value="/bin/xiaohe"),
|
||||
patch.object(up_mod.subprocess, "call", return_value=0) as call,
|
||||
):
|
||||
result = CliRunner().invoke(up_mod.upgrade_cmd, ["--beta"])
|
||||
assert result.exit_code == 0
|
||||
assert "Cancelled" in result.output
|
||||
call.assert_called_once_with(["/bin/xiaohe", "upgrade", "--beta"])
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
def test_missing_xiaohe(self) -> None:
|
||||
with patch.object(up_mod.shutil, "which", return_value=None):
|
||||
result = CliRunner().invoke(up_mod.upgrade_cmd, [])
|
||||
assert result.exit_code != 0
|
||||
assert "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]
|
||||
assert "Install xiaohe" in result.output or "xiaohe upgrade" in result.output
|
||||
|
||||
+15
-19
@@ -3,18 +3,10 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from myagents.commands import version as ver_mod
|
||||
|
||||
|
||||
class TestDescribeTree:
|
||||
def test_installed_runtime_tree(self) -> None:
|
||||
tree = Path("/home/u/.xiaohe/runtime/v1.2.3/contrib/myagents")
|
||||
mode, detail = ver_mod.describe_tree(tree)
|
||||
assert mode == "installed"
|
||||
assert detail == "v1.2.3"
|
||||
|
||||
def test_development_checkout(self, tmp_path: Path) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
@@ -33,16 +25,20 @@ class TestGitDescribe:
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t",
|
||||
"commit", "-q", "--allow-empty", "-m", "init"],
|
||||
cwd=repo, check=True,
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"user.email=t@t",
|
||||
"-c",
|
||||
"user.name=t",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init",
|
||||
],
|
||||
cwd=repo,
|
||||
check=True,
|
||||
)
|
||||
result = ver_mod._git_describe(repo)
|
||||
assert "branch" in result
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
def test_runs_and_reports(self) -> None:
|
||||
result = CliRunner().invoke(ver_mod.version_cmd)
|
||||
assert result.exit_code == 0
|
||||
assert "myagents" in result.output
|
||||
assert result != "unknown"
|
||||
|
||||
@@ -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