feat(agents): 新增 mydsh 启动器 —— DeepSeek Harness CLI 包装

- launcher _BACKENDS 加 dsh:profile 透传、zstd 会话列表(session/title 或首条用户消息)、
  --resume 透传、DSH_HOME 环境变量与 expanduser
- entrypoints/cli/completion/打包/卸载全触点 + TestDsh 用例(裸跑/版本/透传/列表/缺 bin)
This commit is contained in:
Zhengshou Lai
2026-08-17 19:54:13 +08:00
parent f12cd5fcf1
commit f14c39d7c0
9 changed files with 301 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
ROOT_DIR := $(shell pwd)
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
USER_BIN_DIR := $(HOME)/.local/bin
COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor
COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor mydsh
.PHONY: help install uninstall _symlink-commands
+5 -1
View File
@@ -17,6 +17,7 @@ def _progs():
claude_cli,
codex_cli,
cursor_cli,
dsh_cli,
hermes_cli,
kimi_cli,
)
@@ -28,6 +29,7 @@ def _progs():
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
("mycursor", lambda: cursor_cli),
("mydsh", lambda: dsh_cli),
]
@@ -45,7 +47,8 @@ def cli(ctx: click.Context) -> None:
"""Myagents: unified launcher for AI coding agents.
Use ``myagents claude``, ``myagents kimi``, ``myagents codex``,
``myagents hermes`` or ``myagents cursor`` to start an agent in workspace/.
``myagents hermes``, ``myagents cursor`` or ``myagents dsh`` to start an
agent in workspace/.
"""
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
@@ -66,6 +69,7 @@ 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")
+4
View File
@@ -47,6 +47,10 @@ def _print_script(prog_name: str, shell: str) -> None:
from myagents.entrypoints import cursor_cli
cli_obj = cursor_cli
elif prog_name == "mydsh":
from myagents.entrypoints import dsh_cli
cli_obj = dsh_cli
else:
raise click.ClickException(f"Unknown command: {prog_name}")
+12
View File
@@ -10,6 +10,7 @@ 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():
@@ -22,6 +23,7 @@ def _progs():
("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli),
("mycursor", lambda: cursor_cli),
("mydsh", lambda: dsh_cli),
]
@@ -73,3 +75,13 @@ def cursor_main() -> None:
ensure_completions_installed(_progs())
cursor_cli()
def dsh_main() -> None:
"""Run ``mydsh``."""
from myagents.commands.completion_install import (
ensure_completions_installed,
)
ensure_completions_installed(_progs())
dsh_cli()
+126 -3
View File
@@ -91,6 +91,24 @@ _BACKENDS: dict[str, dict] = {
"[cyan]CURSOR_BIN[/cyan]."
),
},
"dsh": {
# DeepSeek Harness is a dev-preview profile launcher; pass flags through
# unchanged and let the CLI own resume syntax (--profile / --resume).
"binary": "dsh",
"env_bin": "DSH_BIN",
"sessions_root": lambda: Path(
os.environ.get("DSH_HOME") or (Path.home() / ".local/share/dsh")
).expanduser()
/ "sessions",
"session_pattern": "*/*/session.jsonl.zstd",
"default_args": [],
"install_cmd": ["npm", "install", "-g", "@deepseek-ai/dsh"],
"not_found_msg": (
"[red]dsh CLI not found in PATH.[/red] Install DeepSeek Harness "
"with [cyan]npm install -g @deepseek-ai/dsh[/cyan] or set "
"[cyan]DSH_BIN[/cyan]."
),
},
}
@@ -536,6 +554,10 @@ def _launch(
extra = _translate_hermes_extra(extra)
elif backend == "cursor":
extra = _translate_cursor_extra(extra)
elif backend == "dsh":
# DeepSeek Harness takes --profile <name> before the task; a bare
# --resume <id> passes through as-is (profile launcher owns it).
pass
cmd = [binary, *config["default_args"], *extra]
if use_tmux:
@@ -557,8 +579,9 @@ def _sessions_dir(backend: str, chat_cwd: Path) -> Path:
if backend in ("kimi", "cursor"):
# Kimi / Cursor Agent hash the cwd with md5.
munged = hashlib.md5(cwd_str.encode("utf-8")).hexdigest() # noqa: S324
elif backend == "codex":
# Codex stores all sessions under a single dated tree; cwd is in metadata.
elif backend in ("codex", "dsh"):
# Codex stores all sessions under a single dated tree; dsh stores them
# under <cwd-slug>/<session-id>/ (cwd is in each session header).
return config["sessions_root"]()
else:
munged = re.sub(r"[^A-Za-z0-9]", "-", cwd_str)
@@ -1188,6 +1211,88 @@ def _first_prompt_codex(session_file: Path) -> str:
return ""
def _dsh_matches_cwd(session_file: Path, chat_cwd: Path) -> bool:
"""True when a dsh session's header cwd matches ``chat_cwd``."""
stored = _dsh_session_cwd(session_file)
if stored is None:
return False
return stored == str(chat_cwd) or Path(stored).resolve() == chat_cwd
def _dsh_session_cwd(session_file: Path) -> str | None:
"""Read the ``cwd`` from a dsh ``session.jsonl.zstd`` header line."""
try:
import io
import zstandard as zstd
except ImportError:
return None
try:
with session_file.open("rb") as fh:
with zstd.ZstdDecompressor().stream_reader(fh) as reader:
text = io.TextIOWrapper(reader, encoding="utf-8", errors="ignore")
for line in text:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict) or entry.get("type") != "session":
return None
cwd = entry.get("cwd")
return cwd if isinstance(cwd, str) else None
except (OSError, zstd.ZstdError):
return None
return None
def _first_prompt_dsh(session_file: Path) -> str:
"""Best-effort label from a dsh ``session.jsonl.zstd``.
Prefers the ``session/title`` event, falling back to the first user text.
"""
try:
import io
import zstandard as zstd
except ImportError:
return ""
try:
with session_file.open("rb") as fh:
with zstd.ZstdDecompressor().stream_reader(fh) as reader:
text = io.TextIOWrapper(reader, encoding="utf-8", errors="ignore")
for line in text:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if not isinstance(entry, dict):
continue
etype = entry.get("type")
data = entry.get("data")
if etype == "session/title" and isinstance(data, dict):
title = data.get("title")
if isinstance(title, str) and title.strip():
return " ".join(title.split())[:80]
if etype == "user/message" and isinstance(data, dict):
content = data.get("content")
if isinstance(content, list):
for block in content:
if (
isinstance(block, dict)
and block.get("type") == "text"
):
t = block.get("text")
if isinstance(t, str) and t.strip():
return " ".join(t.split())[:80]
except (OSError, zstd.ZstdError):
return ""
return ""
_USER_QUERY_RE = re.compile(
r"<user_query>\s*(.*?)\s*</user_query>", re.DOTALL | re.IGNORECASE
)
@@ -1277,6 +1382,17 @@ def _session_files(backend: str, chat_cwd: Path) -> list[Path]:
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files
if backend == "dsh":
# Sessions live under <cwd-slug>/<session-id>/session.jsonl.zstd;
# filter by the cwd recorded in each session header.
files = [
p
for p in sessions_dir.glob(config["session_pattern"])
if p.is_file() and _dsh_matches_cwd(p, chat_cwd)
]
files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files
if backend == "claude":
files = sorted(
sessions_dir.glob(config["session_pattern"]),
@@ -1318,6 +1434,8 @@ def _session_title(backend: str, session_file: Path) -> str:
return _first_prompt_codex(session_file)
if backend == "cursor":
return _first_prompt_cursor(session_file)
if backend == "dsh":
return _first_prompt_dsh(session_file)
return _first_prompt_kimi(session_file)
@@ -1331,6 +1449,8 @@ def _resume_syntax(backend: str) -> str:
return "-r, --resume <id> (bare -r opens the session picker)"
if backend == "cursor":
return "-r, --resume <id>"
if backend == "dsh":
return "--resume, -r <id>"
return "--resume, -r <id>"
@@ -1423,7 +1543,8 @@ def _list_sessions(backend: str, chat_cwd: Path) -> None:
for session_file in files:
mtime = session_file.stat().st_mtime
title = _session_title(backend, session_file)
if backend in ("kimi", "cursor"):
if backend in ("kimi", "cursor", "dsh"):
# Session id is the parent directory name (<cwd-slug>/<session-id>/).
session_id = session_file.parent.name
elif backend == "codex":
session_id = _codex_session_id(session_file)
@@ -1477,6 +1598,8 @@ def build_cli(
backend_title = "OpenAI Codex"
elif backend == "cursor":
backend_title = "Cursor Agent"
elif backend == "dsh":
backend_title = "DeepSeek Harness"
@click.group(
cls=LaunchGroup,
+1
View File
@@ -15,6 +15,7 @@ mykimi = "myagents.entrypoints:kimi_main"
mycodex = "myagents.entrypoints:codex_main"
myhermes = "myagents.entrypoints:hermes_main"
mycursor = "myagents.entrypoints:cursor_main"
mydsh = "myagents.entrypoints:dsh_main"
[dependency-groups]
dev = ["pytest>=8.0"]
+1 -1
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import os
import sys
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes")
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes", "mycursor", "mydsh")
def _remove_link(link: str, want: str) -> bool:
+107 -1
View File
@@ -5,6 +5,7 @@ import sqlite3
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
import myagents.launcher
@@ -29,7 +30,7 @@ class TestMyagentsHelp:
"""Tests for top-level myagents command."""
def test_help_shows_agent_subcommands(self) -> None:
"""--help should list claude, kimi, codex, hermes and cursor subcommands."""
"""--help should list claude, kimi, codex, hermes, cursor and dsh."""
runner = CliRunner()
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
@@ -38,6 +39,7 @@ class TestMyagentsHelp:
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
@@ -692,6 +694,110 @@ class TestCursorSubcommand:
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."""
+44
View File
@@ -9,6 +9,7 @@ from myagents.entrypoints import (
claude_cli,
codex_cli,
cursor_cli,
dsh_cli,
hermes_cli,
kimi_cli,
)
@@ -204,6 +205,49 @@ class TestMycursorEntrypoint:
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."""