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:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user