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