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) ROOT_DIR := $(shell pwd)
VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin VENV_BIN_DIR := $(ROOT_DIR)/.venv/bin
USER_BIN_DIR := $(HOME)/.local/bin USER_BIN_DIR := $(HOME)/.local/bin
COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor COMMANDS := myagents myclaude mykimi mycodex myhermes mycursor mydsh
.PHONY: help install uninstall _symlink-commands .PHONY: help install uninstall _symlink-commands
+5 -1
View File
@@ -17,6 +17,7 @@ def _progs():
claude_cli, claude_cli,
codex_cli, codex_cli,
cursor_cli, cursor_cli,
dsh_cli,
hermes_cli, hermes_cli,
kimi_cli, kimi_cli,
) )
@@ -28,6 +29,7 @@ def _progs():
("mycodex", lambda: codex_cli), ("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli), ("myhermes", lambda: hermes_cli),
("mycursor", lambda: cursor_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. """Myagents: unified launcher for AI coding agents.
Use ``myagents claude``, ``myagents kimi``, ``myagents codex``, 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: if ctx.invoked_subcommand is None:
click.echo(ctx.get_help()) 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("codex"), name="codex")
cli.add_command(build_cli("hermes"), name="hermes") cli.add_command(build_cli("hermes"), name="hermes")
cli.add_command(build_cli("cursor"), name="cursor") cli.add_command(build_cli("cursor"), name="cursor")
cli.add_command(build_cli("dsh"), name="dsh")
cli.add_command(ensure_agent_cmd) cli.add_command(ensure_agent_cmd)
cli.add_command(update_cmd) cli.add_command(update_cmd)
cli.add_command(upgrade_cmd, name="upgrade") cli.add_command(upgrade_cmd, name="upgrade")
+4
View File
@@ -47,6 +47,10 @@ def _print_script(prog_name: str, shell: str) -> None:
from myagents.entrypoints import cursor_cli from myagents.entrypoints import cursor_cli
cli_obj = cursor_cli cli_obj = cursor_cli
elif prog_name == "mydsh":
from myagents.entrypoints import dsh_cli
cli_obj = dsh_cli
else: else:
raise click.ClickException(f"Unknown command: {prog_name}") raise click.ClickException(f"Unknown command: {prog_name}")
+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) codex_cli = build_cli("codex", prog_name="mycodex", offer_install=True)
hermes_cli = build_cli("hermes", prog_name="myhermes", 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) cursor_cli = build_cli("cursor", prog_name="mycursor", offer_install=True)
dsh_cli = build_cli("dsh", prog_name="mydsh", offer_install=True)
def _progs(): def _progs():
@@ -22,6 +23,7 @@ def _progs():
("mycodex", lambda: codex_cli), ("mycodex", lambda: codex_cli),
("myhermes", lambda: hermes_cli), ("myhermes", lambda: hermes_cli),
("mycursor", lambda: cursor_cli), ("mycursor", lambda: cursor_cli),
("mydsh", lambda: dsh_cli),
] ]
@@ -73,3 +75,13 @@ def cursor_main() -> None:
ensure_completions_installed(_progs()) ensure_completions_installed(_progs())
cursor_cli() 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]." "[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) extra = _translate_hermes_extra(extra)
elif backend == "cursor": elif backend == "cursor":
extra = _translate_cursor_extra(extra) 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] cmd = [binary, *config["default_args"], *extra]
if use_tmux: if use_tmux:
@@ -557,8 +579,9 @@ def _sessions_dir(backend: str, chat_cwd: Path) -> Path:
if backend in ("kimi", "cursor"): if backend in ("kimi", "cursor"):
# Kimi / Cursor Agent hash the cwd with md5. # Kimi / Cursor Agent hash the cwd with md5.
munged = hashlib.md5(cwd_str.encode("utf-8")).hexdigest() # noqa: S324 munged = hashlib.md5(cwd_str.encode("utf-8")).hexdigest() # noqa: S324
elif backend == "codex": elif backend in ("codex", "dsh"):
# Codex stores all sessions under a single dated tree; cwd is in metadata. # 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"]() return config["sessions_root"]()
else: else:
munged = re.sub(r"[^A-Za-z0-9]", "-", cwd_str) munged = re.sub(r"[^A-Za-z0-9]", "-", cwd_str)
@@ -1188,6 +1211,88 @@ def _first_prompt_codex(session_file: Path) -> str:
return "" 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( _USER_QUERY_RE = re.compile(
r"<user_query>\s*(.*?)\s*</user_query>", re.DOTALL | re.IGNORECASE 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) files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return files 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": if backend == "claude":
files = sorted( files = sorted(
sessions_dir.glob(config["session_pattern"]), 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) return _first_prompt_codex(session_file)
if backend == "cursor": if backend == "cursor":
return _first_prompt_cursor(session_file) return _first_prompt_cursor(session_file)
if backend == "dsh":
return _first_prompt_dsh(session_file)
return _first_prompt_kimi(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)" return "-r, --resume <id> (bare -r opens the session picker)"
if backend == "cursor": if backend == "cursor":
return "-r, --resume <id>" return "-r, --resume <id>"
if backend == "dsh":
return "--resume, -r <id>"
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: for session_file in files:
mtime = session_file.stat().st_mtime mtime = session_file.stat().st_mtime
title = _session_title(backend, session_file) 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 session_id = session_file.parent.name
elif backend == "codex": elif backend == "codex":
session_id = _codex_session_id(session_file) session_id = _codex_session_id(session_file)
@@ -1477,6 +1598,8 @@ def build_cli(
backend_title = "OpenAI Codex" backend_title = "OpenAI Codex"
elif backend == "cursor": elif backend == "cursor":
backend_title = "Cursor Agent" backend_title = "Cursor Agent"
elif backend == "dsh":
backend_title = "DeepSeek Harness"
@click.group( @click.group(
cls=LaunchGroup, cls=LaunchGroup,
+1
View File
@@ -15,6 +15,7 @@ mykimi = "myagents.entrypoints:kimi_main"
mycodex = "myagents.entrypoints:codex_main" mycodex = "myagents.entrypoints:codex_main"
myhermes = "myagents.entrypoints:hermes_main" myhermes = "myagents.entrypoints:hermes_main"
mycursor = "myagents.entrypoints:cursor_main" mycursor = "myagents.entrypoints:cursor_main"
mydsh = "myagents.entrypoints:dsh_main"
[dependency-groups] [dependency-groups]
dev = ["pytest>=8.0"] dev = ["pytest>=8.0"]
+1 -1
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
import os import os
import sys import sys
_COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes") _COMMANDS = ("myagents", "myclaude", "mykimi", "mycodex", "myhermes", "mycursor", "mydsh")
def _remove_link(link: str, want: str) -> bool: def _remove_link(link: str, want: str) -> bool:
+107 -1
View File
@@ -5,6 +5,7 @@ import sqlite3
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner from click.testing import CliRunner
import myagents.launcher import myagents.launcher
@@ -29,7 +30,7 @@ class TestMyagentsHelp:
"""Tests for top-level myagents command.""" """Tests for top-level myagents command."""
def test_help_shows_agent_subcommands(self) -> None: def test_help_shows_agent_subcommands(self) -> None:
"""--help should list claude, kimi, codex, hermes and cursor subcommands.""" """--help should list claude, kimi, codex, hermes, cursor and dsh."""
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--help"]) result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0 assert result.exit_code == 0
@@ -38,6 +39,7 @@ class TestMyagentsHelp:
assert "codex" in result.output assert "codex" in result.output
assert "hermes" in result.output assert "hermes" in result.output
assert "cursor" in result.output assert "cursor" in result.output
assert "dsh" in result.output
assert "update" in result.output assert "update" in result.output
assert "upgrade" in result.output assert "upgrade" in result.output
@@ -692,6 +694,110 @@ class TestCursorSubcommand:
assert mock_run.call_args.kwargs.get("cwd") == str(test_dir.resolve()) 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: class TestCursorPassthrough:
"""Map myagents-style resume flags to Cursor Agent CLI flags.""" """Map myagents-style resume flags to Cursor Agent CLI flags."""
+44
View File
@@ -9,6 +9,7 @@ from myagents.entrypoints import (
claude_cli, claude_cli,
codex_cli, codex_cli,
cursor_cli, cursor_cli,
dsh_cli,
hermes_cli, hermes_cli,
kimi_cli, kimi_cli,
) )
@@ -204,6 +205,49 @@ class TestMycursorEntrypoint:
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"] assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]
class TestMydshEntrypoint:
"""``mydsh`` standalone entrypoint."""
def test_runs_dsh(self) -> None:
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/dsh"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(dsh_cli, [])
assert result.exit_code == 0
# dsh is a profile launcher; no default args are injected.
assert mock_run.call_args[0][0] == ["/usr/bin/dsh"]
def test_version_shows_mydsh(self) -> None:
runner = CliRunner()
result = runner.invoke(dsh_cli, ["--version"])
assert result.exit_code == 0
assert "mydsh" in result.output
def test_passthrough(self, tmp_path: Path) -> None:
"""``--resume <id>`` passes through to the native dsh CLI."""
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/dsh"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
dsh_cli, ["--cwd", str(tmp_path), "--resume", "abc123"]
)
assert result.exit_code == 0
assert mock_run.call_args[0][0][-2:] == ["--resume", "abc123"]
def test_missing_binary_exits_127(self) -> None:
runner = CliRunner()
with patch("myagents.launcher.shutil.which", return_value=None):
result = runner.invoke(dsh_cli, [])
assert result.exit_code == 127
assert "dsh CLI not found" in result.output
class TestTmuxOption: class TestTmuxOption:
"""``--tmux`` / ``-t`` wraps the backend in an attachable tmux session.""" """``--tmux`` / ``-t`` wraps the backend in an attachable tmux session."""