From f14c39d7c0a478b307e822895bc8238aac1b4100 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Mon, 17 Aug 2026 19:54:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(agents):=20=E6=96=B0=E5=A2=9E=20mydsh=20?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=99=A8=20=E2=80=94=E2=80=94=20DeepSeek=20H?= =?UTF-8?q?arness=20CLI=20=E5=8C=85=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launcher _BACKENDS 加 dsh:profile 透传、zstd 会话列表(session/title 或首条用户消息)、 --resume 透传、DSH_HOME 环境变量与 expanduser - entrypoints/cli/completion/打包/卸载全触点 + TestDsh 用例(裸跑/版本/透传/列表/缺 bin) --- Makefile | 2 +- myagents/cli.py | 6 +- myagents/commands/completion.py | 4 + myagents/entrypoints.py | 12 +++ myagents/launcher.py | 129 +++++++++++++++++++++++++++++- pyproject.toml | 1 + scripts/rm_user_local_myagents.py | 2 +- tests/test_cli.py | 108 ++++++++++++++++++++++++- tests/test_entrypoints.py | 44 ++++++++++ 9 files changed, 301 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index e931a95..5a13f4a 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/myagents/cli.py b/myagents/cli.py index 4befde1..e39db15 100644 --- a/myagents/cli.py +++ b/myagents/cli.py @@ -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") diff --git a/myagents/commands/completion.py b/myagents/commands/completion.py index 3fdd4db..f7a0466 100644 --- a/myagents/commands/completion.py +++ b/myagents/commands/completion.py @@ -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}") diff --git a/myagents/entrypoints.py b/myagents/entrypoints.py index 517b7fb..68c6fb2 100644 --- a/myagents/entrypoints.py +++ b/myagents/entrypoints.py @@ -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() diff --git a/myagents/launcher.py b/myagents/launcher.py index 8680cad..4466040 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -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 before the task; a bare + # --resume 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 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"\s*(.*?)\s*", 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 //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 (bare -r opens the session picker)" if backend == "cursor": return "-r, --resume " + if backend == "dsh": + return "--resume, -r " return "--resume, -r " @@ -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 (//). 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, diff --git a/pyproject.toml b/pyproject.toml index 466360f..489cc8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/rm_user_local_myagents.py b/scripts/rm_user_local_myagents.py index 4ba6ae2..20f1d1c 100644 --- a/scripts/rm_user_local_myagents.py +++ b/scripts/rm_user_local_myagents.py @@ -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: diff --git a/tests/test_cli.py b/tests/test_cli.py index 73cee89..57e26f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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.""" diff --git a/tests/test_entrypoints.py b/tests/test_entrypoints.py index 3ea1f8d..7e39b38 100644 --- a/tests/test_entrypoints.py +++ b/tests/test_entrypoints.py @@ -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 `` 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."""