diff --git a/docs/en/docs/integrations/dsh.md b/docs/en/docs/integrations/dsh.md index 6ba2400f31..32f859803b 100644 --- a/docs/en/docs/integrations/dsh.md +++ b/docs/en/docs/integrations/dsh.md @@ -10,7 +10,7 @@ description: Install the PowerContext DeepSeek Harness plugin and control its lo ## Install matching Server and plugin versions -Install DeepSeek Harness and make sure its Web profile is available. The real-host acceptance suite pins DSH +Install DeepSeek Harness Web or Desktop. The real-host acceptance suite pins DSH 0.1.2-rc.1. Choose one PowerContext installation path and keep the Server and plugin together. For this guided-setup build: @@ -36,7 +36,38 @@ commands. A local source must contain the checked-in built `lib/index.js`. `setup dsh --source oceanbase/powercontext --ref master` reuses a valid cached checkout without fetching: repeating that command does not update a moving branch. A broken checkout is replaced. -`setup dsh` calls `dsh plugin --profile web add`; it does not start the Server. Restart DSH after installation. +`powercontext setup dsh` asks for `web` or `desktop` in an interactive terminal. Without a terminal, or with +`--json`, an omitted profile defaults to `web`. Explicit selection never prompts: + +```bash +powercontext setup dsh --profile web +powercontext setup dsh --profile desktop +powercontext doctor dsh --profile desktop +``` + +Web installs under `$DSH_HOME/profiles/web`; Desktop installs under `$DSH_HOME/profiles/desktop` +(`DSH_HOME` defaults to `~/.dsh`). Setup calls `dsh plugin --profile add`, which installs the package +and enables its bundle in that profile's `package.json`. The bundle supplies its own patch. Setup preserves +both profiles' user `cordis.patch.yml` files and does not enable the plugin in the other profile. + +Desktop requires its **Desktop-installed command**, not npm/pnpm dsh. Open Desktop once to initialize its +profile, use **Manage dsh Command…** to install the command, open a new terminal, then fully quit Desktop +before setup. Closing the window may only hide it. Setup searches PATH for the Desktop carrier even when +an npm shim comes first. If it is not registered on PATH, pass its installed launcher explicitly: + +```powershell +powercontext setup dsh --profile desktop --dsh-command "D:\Apps\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" +``` + +On macOS the launcher is `DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh`. +`--dsh-command` also works with `doctor dsh`. Packaged Windows/macOS Desktop layouts are supported; +unpackaged development launchers are not. Desktop inspection uses the application's Electron runtime and +configuration APIs without booting plugins. Registration success does not establish running-host readiness: +reopen Desktop and run `/pc doctor`. Setup does not start the Server or quit Desktop for you. + +Saved `hosts.dsh` connection settings and `$DSH_HOME/powercontext/credentials.json` are shared between Web +and Desktop. Use the same Server for both; changing the shared URL affects either profile that has no native +override. Credentials remain bound to their saved Server URL. Install into each profile separately if you use both. Existing UI and model patches can remain in place. Setup checks the composed PowerContext connection settings; see [remote connection configuration](../operate/connect-remote-server.md) for conflicts and dynamic configuration limits. diff --git a/docs/zh/docs/integrations/dsh.md b/docs/zh/docs/integrations/dsh.md index 4000faed29..9070fc3c1f 100644 --- a/docs/zh/docs/integrations/dsh.md +++ b/docs/zh/docs/integrations/dsh.md @@ -10,7 +10,7 @@ description: 安装 PowerContext DeepSeek Harness 插件并控制其本地行为 ## 安装匹配的 Server 和插件 -先安装 DeepSeek Harness,并确保 Web profile 可用。真实宿主验收固定使用 DSH 0.1.2-rc.1。 +先安装 DeepSeek Harness Web 或 Desktop。真实宿主验收固定使用 DSH 0.1.2-rc.1。 选择以下一种 PowerContext 安装方式,让 Server 和插件保持匹配。 使用本站对应的配置向导版本: @@ -36,7 +36,37 @@ powercontext setup dsh --source ./powercontext-dsh-dev `setup dsh --source oceanbase/powercontext --ref master` 会直接复用有效缓存 checkout,不会 fetch; 重复运行不代表更新了移动分支。只有残缺 checkout 会被替换。 -`setup dsh` 调用 `dsh plugin --profile web add`,不会启动 Server。安装完成后重启 DSH。 +在交互终端中运行 `powercontext setup dsh` 会询问安装到 `web` 还是 `desktop`。 +非交互模式或使用 `--json` 时,未指定 profile 默认安装到 `web`。显式指定目标不会询问: + +```bash +powercontext setup dsh --profile web +powercontext setup dsh --profile desktop +powercontext doctor dsh --profile desktop +``` + +Web 安装目录为 `$DSH_HOME/profiles/web`,Desktop 为 `$DSH_HOME/profiles/desktop`; +`DSH_HOME` 默认是 `~/.dsh`。Setup 调用 `dsh plugin --profile add`,在对应 profile 的 +`package.json` 中安装依赖并启用 bundle,再加载插件包自带的 patch。两个 profile 的用户 +`cordis.patch.yml` 均会保留,安装不会改变另一个 profile 的插件启用状态。 + +Desktop 必须使用**桌面版自带的 dsh 命令**,npm/pnpm 版 dsh 无权管理 desktop profile。 +请先启动一次 Desktop 初始化配置,通过 **Manage dsh Command…** 菜单安装命令并打开新终端, +然后完全退出 Desktop 再安装插件;关闭窗口可能只是隐藏。即使 npm 命令排在前面,Setup 也会继续 +在 PATH 中查找 Desktop 启动器。未注册 PATH 时可以显式指定: + +```powershell +powercontext setup dsh --profile desktop --dsh-command "D:\Apps\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd" +``` + +macOS 启动器位于 `DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh`。 +`doctor dsh` 同样支持 `--dsh-command`。支持 Windows/macOS 的打包桌面版布局,不支持未打包的开发启动器。 +配置检查使用桌面版自带的 Electron 和配置解析器,不启动插件。注册成功不代表运行中的宿主和 Server +已经就绪;安装后重新打开 Desktop,在会话中执行 `/pc doctor`。Setup 不会替你启动 Server 或退出 Desktop。 + +Web 和 Desktop 共用已保存的 `hosts.dsh` 连接设置及 `$DSH_HOME/powercontext/credentials.json`。 +建议两者连接同一个 Server;修改共享 URL 会影响没有原生覆盖配置的 profile,凭据仍绑定原来的 Server URL。 +同时使用 Web 和 Desktop 时,分别执行两个 profile 的安装命令。 已有界面和模型 patch 可以保留。setup 检查合成后的 PowerContext 连接设置; 冲突处理及动态配置限制见[远程连接配置](../operate/connect-remote-server.md)。 diff --git a/integrations/dsh/plugins/powercontext/README.md b/integrations/dsh/plugins/powercontext/README.md index fa0d76ebab..bc90c40f65 100644 --- a/integrations/dsh/plugins/powercontext/README.md +++ b/integrations/dsh/plugins/powercontext/README.md @@ -79,3 +79,13 @@ Its displayed text is the same untrusted, request-specific context sent to the m See [runtime acceptance tests](tests/runtime/README.md) for the pinned real DSH host, deterministic CI scenarios, and the separate real-model and Web acceptance procedure. + +## Web and Desktop setup targets + +`powercontext setup dsh` prompts for Web or Desktop on a TTY; `--json` and non-interactive invocations default to Web. +Use `--profile web` or `--profile desktop` explicitly in scripts. Each profile has independent package dependencies +and bundle activation. Desktop requires its installed dsh launcher, an initialized profile, and a fully quit application. +Use Desktop's **Manage dsh Command…** menu or pass `--dsh-command` with its installed launcher path. +`powercontext doctor dsh --profile desktop` checks registration without launching the reserved profile; reopen +Desktop and run `/pc doctor` to inspect the active session. Connection settings and URL-bound credentials remain shared. +See [the installation guide](../../../../docs/en/docs/integrations/dsh.md) for paths and complete examples. diff --git a/src/powercontext/cli/dsh.py b/src/powercontext/cli/dsh.py index 0bd1fb5df0..6039440327 100644 --- a/src/powercontext/cli/dsh.py +++ b/src/powercontext/cli/dsh.py @@ -24,6 +24,7 @@ from pathlib import Path from shutil import which +from powercontext.cli.dsh_runtime import DshProfile, DshTarget, dsh_home from powercontext.cli.git_source import ( InvalidGitHubSourceError, clone_github_source, @@ -49,14 +50,22 @@ class DshSetupResult: plugin_path: str data_dir: str authorization_state: str = "not_attempted" + profile: str = "web" + profile_dir: str = "" def install_dsh_plugin( - *, source: str, ref: str, server_url: str = "http://127.0.0.1:8000", allow_insecure_http: bool = False + *, + source: str, + ref: str, + server_url: str = "http://127.0.0.1:8000", + allow_insecure_http: bool = False, + target: DshTarget | None = None, ) -> DshSetupResult: """Install the plugin from a PowerContext checkout or Git source.""" - dsh_executable() + profile = target.profile if target else DshProfile.WEB + executable = target.command if target and target.command else dsh_executable() data_dir = powercontext_data_dir() try: data_dir.mkdir(parents=True, exist_ok=True) @@ -67,13 +76,13 @@ def install_dsh_plugin( from powercontext.cli.dsh_transport import read_dsh_settings, validate_dsh_setup_transport try: - settings = read_dsh_settings(profile=DSH_PROFILE, candidate=plugin_dir, prospective=True) + settings = read_dsh_settings(profile=profile, candidate=plugin_dir, prospective=True, executable=executable) validate_dsh_setup_transport(settings, server_url, allow_insecure_http) except ValueError as error: raise SetupError(str(error)) from error - _run_dsh("plugin", "--profile", DSH_PROFILE, "add", str(plugin_dir)) + _run_dsh("plugin", "--profile", profile, "add", str(plugin_dir), **({"executable": executable} if target else {})) try: - settings = read_dsh_settings(profile=DSH_PROFILE, require_installed=True) + settings = read_dsh_settings(profile=profile, executable=executable, require_installed=True) validate_dsh_setup_transport(settings, server_url, allow_insecure_http) except ValueError as error: raise SetupError( # noqa: TRY003 - actionable host-specific readback failure. @@ -89,6 +98,8 @@ def install_dsh_plugin( plugin=DSH_PLUGIN_NAME, plugin_path=str(plugin_dir), data_dir=str(data_dir), + profile=profile, + profile_dir=str(dsh_home() / "profiles" / profile), authorization_state=configure_stored_authorization( "dsh", server_url=server_url, @@ -123,11 +134,11 @@ def require_built_plugin(path: Path) -> None: raise SetupError.unbuilt_dsh_plugin(path) -def run_dsh_diagnostics() -> dict[str, Diagnostic]: +def run_dsh_diagnostics(*, target: DshTarget | None = None) -> dict[str, Diagnostic]: """Collect diagnostics for the optional DeepSeek Harness integration.""" try: - executable = dsh_executable() + executable = target.command if target and target.command else dsh_executable() except SetupError: return { "dsh": Diagnostic( @@ -139,8 +150,10 @@ def run_dsh_diagnostics() -> dict[str, Diagnostic]: detail="not checked because DeepSeek Harness CLI is unavailable", ), } + if target and target.profile == DshProfile.DESKTOP: + return _desktop_diagnostics(target) try: - output = _run_dsh("--profile", DSH_PROFILE, "--dump-config") + output = _run_dsh("--profile", DSH_PROFILE, "--dump-config", **({"executable": executable} if target else {})) except SetupError as error: cause = error.__cause__ checks = {"dump_config": "failed"} @@ -188,6 +201,34 @@ def run_dsh_diagnostics() -> dict[str, Diagnostic]: } +def _desktop_diagnostics(target: DshTarget) -> dict[str, Diagnostic]: + """Compose registration with Desktop's parser without booting its reserved profile.""" + from powercontext.cli.dsh_transport import read_dsh_settings + + try: + read_dsh_settings(profile=target.profile, executable=target.command, require_installed=True) + except ValueError as error: + plugin = Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) + else: + plugin = Diagnostic( + status=DiagnosticStatus.OK, + detail="powercontext-dsh is registered in the Desktop profile. Reopen Desktop and run /pc doctor " + "inside that session to check the running host and Server.", + checks={ + "registration": "present", + "running_host_configuration": "not_observed", + "server_liveness": "not_checked", + "server_readiness": "not_checked", + "route_compatibility": "not_checked", + "session_scope": "not_checked", + }, + ) + return { + "dsh": Diagnostic(status=DiagnosticStatus.OK, detail=target.command or "Desktop-installed dsh"), + "plugin": plugin, + } + + def dsh_executable() -> str: """Return a subprocess-launchable DeepSeek Harness CLI path.""" @@ -270,8 +311,8 @@ def _clone_github_source(source: str, ref: str, target: Path) -> None: raise SetupError.invalid_dsh_source() from None -def _run_dsh(*arguments: str) -> str: - command = [dsh_executable(), *arguments] +def _run_dsh(*arguments: str, executable: str | None = None) -> str: + command = [executable or dsh_executable(), *arguments] try: completed = subprocess.run( # noqa: S603 - arguments are passed directly to the fixed dsh executable. command, diff --git a/src/powercontext/cli/dsh_config.mjs b/src/powercontext/cli/dsh_config.mjs index 98804e09ac..a39de3976d 100644 --- a/src/powercontext/cli/dsh_config.mjs +++ b/src/powercontext/cli/dsh_config.mjs @@ -64,8 +64,12 @@ function installation(executable) { fail('Cannot locate the installed DSH package from its CLI; use a supported npm/pnpm DSH installation') } -async function inspect(executable, home, profile, candidate, prospective, requireInstalled) { - const anchor = installation(executable) +async function inspect(executable, home, profile, candidate, prospective, desktopAnchor, requireInstalled) { + const anchor = desktopAnchor || installation(executable) + if (desktopAnchor) { + const manifest = read(anchor, () => JSON.parse(readFileSync(anchor, 'utf8'))) + if (manifest.name !== '@deepseek-ai/dsh') fail('Invalid Desktop runtime manifest', anchor) + } const require = createRequire(anchor) // These are the selected host's APIs, not dependencies of the plugin being installed. // Resolving a different copy from PowerContext would inspect a different host contract. @@ -288,8 +292,8 @@ async function inspect(executable, home, profile, candidate, prospective, requir } try { - const [executable, home, profile, candidate, prospective, requireInstalled] = process.argv.slice(2) - const result = await inspect(resolve(executable), resolve(home), profile, candidate || undefined, prospective === 'true', requireInstalled === 'true') + const [executable, home, profile, candidate, prospective, desktopAnchor, requireInstalled] = process.argv.slice(2) + const result = await inspect(resolve(executable), resolve(home), profile, candidate || undefined, prospective === 'true', desktopAnchor, requireInstalled === 'true') process.stdout.write(JSON.stringify(result)) } catch (error) { // Native parser messages and stacks can include credentials or whole YAML rows. diff --git a/src/powercontext/cli/dsh_runtime.py b/src/powercontext/cli/dsh_runtime.py index e68da4c3b5..a467025a94 100644 --- a/src/powercontext/cli/dsh_runtime.py +++ b/src/powercontext/cli/dsh_runtime.py @@ -12,15 +12,96 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Locate DSH's shared configuration and credential home.""" +"""Select a DSH profile and its installation-owned command/runtime.""" from __future__ import annotations import os +from dataclasses import dataclass +from enum import StrEnum from pathlib import Path +class DshProfile(StrEnum): + WEB = "web" + DESKTOP = "desktop" + + def dsh_home() -> Path: """Use the same home for profile configuration and URL-bound credentials.""" configured = os.environ.get("DSH_HOME", "") return Path(configured if configured.strip() else Path.home() / ".dsh").expanduser() + + +@dataclass(frozen=True) +class DesktopRuntime: + """The signed application supplies both Electron and the ASAR package tree.""" + + electron: Path + anchor: Path + + +def desktop_runtime(command: str) -> DesktopRuntime | None: + """Recognize installed Windows/macOS launchers, including command symlinks. + + Do not execute launcher contents or substitute an npm runtime for Desktop. + Python cannot stat files inside ASAR; the Electron inspection helper validates + the manifest before importing the installed configuration APIs. + """ + path = Path(command).expanduser().resolve() + if not path.is_file() or path.name not in {"dsh", "dsh.cmd"}: + return None + if tuple(part.lower() for part in path.parts[-5:-1]) != ("resources", "runtime", "cli", "bin"): + return None + resources = path.parents[3] + electron = ( + resources.parent / "DeepSeek Harness.exe" + if path.suffix == ".cmd" + else resources.parent / "MacOS" / "DeepSeek Harness" + ) + archive = resources / "app.asar" + if not electron.is_file() or not archive.is_file(): + return None + return DesktopRuntime(electron, archive / "dsh/node_modules/@deepseek-ai/dsh/package.json") + + +@dataclass(frozen=True) +class DshTarget: + profile: DshProfile + command: str | None = None + + @property + def directory(self) -> Path: + return dsh_home() / "profiles" / self.profile + + +def resolve_dsh_target(profile: DshProfile, command: Path | None = None) -> DshTarget: + """Resolve an explicit command or find the selected carrier on PATH.""" + from powercontext.cli.system import SetupError + + if command is not None: + selected = str(command.expanduser().resolve()) + if not Path(selected).is_file(): + raise SetupError( # noqa: TRY003 - actionable host-specific setup failures. + f"DSH command does not exist: {selected}" + ) + elif profile == DshProfile.WEB: + # Preserve Web's transport preflight before requiring a local CLI. + selected = None + else: + filename = "dsh.cmd" if os.name == "nt" else "dsh" + candidates = [str(Path(directory) / filename) for directory in os.get_exec_path() if directory] + selected = next((candidate for candidate in candidates if desktop_runtime(candidate)), "") + if profile == DshProfile.DESKTOP and not desktop_runtime(selected or ""): + raise SetupError( # noqa: TRY003 - actionable host-specific setup failures. + "Desktop setup requires the Desktop-installed dsh command; npm/pnpm dsh cannot manage its profile. " + "Use Desktop's Manage dsh Command menu, open a new terminal, or pass " + "--dsh-command /path/to/Desktop/resources/runtime/cli/bin/dsh (dsh.cmd on Windows)." + ) + target = DshTarget(profile, selected) + if profile == DshProfile.DESKTOP and not (target.directory / "package.json").is_file(): + raise SetupError( # noqa: TRY003 - actionable host-specific setup failures. + f"Desktop profile is not initialized: {target.directory}. " + "Open Desktop once, then fully quit it before rerunning setup." + ) + return target diff --git a/src/powercontext/cli/dsh_transport.py b/src/powercontext/cli/dsh_transport.py index f164b70d9e..9571d8d630 100644 --- a/src/powercontext/cli/dsh_transport.py +++ b/src/powercontext/cli/dsh_transport.py @@ -30,7 +30,12 @@ def read_dsh_settings( - *, profile: str = "web", candidate: Path | None = None, prospective: bool = False, require_installed: bool = False + *, + profile: str = "web", + candidate: Path | None = None, + prospective: bool = False, + executable: str | None = None, + require_installed: bool = False, ) -> dict[str, Any]: """Compose the installed host's layers; optionally substitute the installation candidate.""" from powercontext.cli.dsh import dsh_executable @@ -55,26 +60,35 @@ def read_dsh_settings( ): return {} try: - executable = dsh_executable() + if executable is None and profile == "desktop": + from powercontext.cli.dsh_runtime import DshProfile, resolve_dsh_target + + executable = resolve_dsh_target(DshProfile.DESKTOP).command + executable = executable or dsh_executable() except SetupError: raise ValueError("DSH CLI is required to inspect its configuration") from None # noqa: TRY003 - sibling_node = Path(executable).parent / ("node.exe" if os.name == "nt" else "node") - node = str(sibling_node) if sibling_node.is_file() else which("node") - if not node: - raise ValueError("Node.js is required to inspect DSH configuration") # noqa: TRY003 + prefix, environment, anchor = _inspection_runtime(executable) command = [ - node, + *prefix, str(Path(__file__).with_name("dsh_config.mjs")), executable, str(home), profile, str(candidate) if candidate else "", str(prospective).lower(), + anchor, str(require_installed).lower(), ] try: result = subprocess.run( # noqa: S603 - fixed helper and separate arguments, no shell. - command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30, check=False + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + check=False, + env=environment, ) payload = json.loads(result.stdout) except (OSError, subprocess.SubprocessError, ValueError): @@ -91,6 +105,24 @@ def read_dsh_settings( return settings +def _inspection_runtime(executable: str) -> tuple[list[str], dict[str, str] | None, str]: + """Run packaged configuration reads under Electron so ASAR resolution matches Desktop.""" + from powercontext.cli.dsh_runtime import desktop_runtime + + desktop = desktop_runtime(executable) + environment = None + if desktop: + prefix = [str(desktop.electron), "--expose-internals"] + environment = dict(os.environ, ELECTRON_RUN_AS_NODE="1") + else: + sibling_node = Path(executable).parent / ("node.exe" if os.name == "nt" else "node") + node = str(sibling_node) if sibling_node.is_file() else which("node") + if not node: + raise ValueError("Node.js is required to inspect DSH configuration") # noqa: TRY003 + prefix = [node] + return prefix, environment, str(desktop.anchor) if desktop else "" + + def matching_dsh_consent(settings: dict[str, Any], endpoint: str) -> bool | None: """Native refusal is unconditional; native permission belongs to its endpoint.""" consent = settings.get("allowInsecureHttp") diff --git a/src/powercontext/cli/hosts.py b/src/powercontext/cli/hosts.py index c565abf2cb..0b2531db25 100644 --- a/src/powercontext/cli/hosts.py +++ b/src/powercontext/cli/hosts.py @@ -25,6 +25,7 @@ import typer if TYPE_CHECKING: + from powercontext.cli.dsh_runtime import DshTarget from powercontext.cli.system import Diagnostic from powercontext.cli.transport import SetupTransport @@ -245,6 +246,7 @@ def setup_host( capture_prompts: bool = True, allow_insecure_http: bool | None = None, json_output: bool = False, + dsh_target: DshTarget | None = None, ) -> HostInstallation: """Resolve, install, and persist one Agent using the common connection policy. @@ -254,7 +256,11 @@ def setup_host( from powercontext.cli.transport import prepare_setup_transport, save_setup_transport transport = prepare_setup_transport( - name, server_url=server_url, allow_insecure_http=allow_insecure_http, json_output=json_output + name, + server_url=server_url, + allow_insecure_http=allow_insecure_http, + json_output=json_output, + **({"dsh_target": dsh_target} if dsh_target else {}), ) result = install_host( name, @@ -263,6 +269,7 @@ def setup_host( server_url=transport.server_url, capture_prompts=capture_prompts, allow_insecure_http=transport.allow_insecure_http, + **({"dsh_target": dsh_target} if dsh_target else {}), ) save_setup_transport(transport) return HostInstallation(result, transport) @@ -314,6 +321,7 @@ def install_host( server_url: str | None, capture_prompts: bool, allow_insecure_http: bool = False, + dsh_target: DshTarget | None = None, ) -> object: """Call the existing installer for one first-class host.""" @@ -339,6 +347,7 @@ def install_host( ref=ref, server_url=server_url or "http://127.0.0.1:8000", allow_insecure_http=allow_insecure_http, + **({"target": dsh_target} if dsh_target else {}), ) if name == "openclaw": from powercontext.cli.openclaw import install_openclaw_plugin diff --git a/src/powercontext/cli/native_transport.py b/src/powercontext/cli/native_transport.py index 4539a047e6..1a7e9e4723 100644 --- a/src/powercontext/cli/native_transport.py +++ b/src/powercontext/cli/native_transport.py @@ -22,6 +22,7 @@ from pathlib import Path from typing import Any, cast +from powercontext.cli.dsh_runtime import DshTarget from powercontext.client.transport_policy import ( load_client_settings, normalize_client_url, @@ -89,12 +90,15 @@ def _codex_url() -> str | None: return next(iter(urls), None) -def _native_settings(host: str) -> tuple[dict[str, Any], str, str]: +def _native_settings(host: str, dsh_target: DshTarget | None = None) -> tuple[dict[str, Any], str, str]: if host == "dsh": from powercontext.cli.dsh_transport import read_dsh_settings return ( - read_dsh_settings(profile=os.environ.get("DSH_PROFILE", "").strip() or "web"), + read_dsh_settings( + profile=dsh_target.profile if dsh_target else os.environ.get("DSH_PROFILE", "").strip() or "web", + executable=dsh_target.command if dsh_target else None, + ), "baseUrl", "allowInsecureHttp", ) @@ -172,7 +176,7 @@ def configured_native_endpoint(host: str) -> str | None: return _url(native[url_key]) if url_key in native else None -def resolve_host_transport(host: str) -> tuple[str, bool]: +def resolve_host_transport(host: str, *, dsh_target: DshTarget | None = None) -> tuple[str, bool]: """Resolve the effective endpoint and consent, or report an unknown native configuration. Native HTTP consent is endpoint-bound. This is a read-only diagnostic, not @@ -194,7 +198,7 @@ def resolve_host_transport(host: str) -> tuple[str, bool]: if native_consent is not _MISSING: allowed = native_consent and native_url == endpoint return endpoint, allowed - native, url_key, consent_key = _native_settings(host) + native, url_key, consent_key = _native_settings(host, dsh_target) native_url = _url(native[url_key]) if url_key in native else None prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) endpoint = _url(_selected_url(host, prefix, native_url)) diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 7274818232..6928219c47 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -36,10 +36,12 @@ from urllib.parse import urlsplit, urlunsplit from urllib.request import Request, urlopen +import click import typer from pydantic import ValidationError -from powercontext.cli.hosts import setup_host +from powercontext.cli.dsh_runtime import DshProfile, resolve_dsh_target +from powercontext.cli.hosts import setup_host, stdin_is_tty from powercontext.cli.transport import add_transport_diagnostic, is_remote_http from powercontext.client.settings import normalize_server_url from powercontext.client.transport_policy import resolve_client_transport @@ -569,6 +571,12 @@ def setup_claude_code( @setup_app.command("dsh") def setup_dsh( + profile: Annotated[ + DshProfile | None, typer.Option(help="DSH profile; prompts on a TTY, otherwise defaults to web.") + ] = None, + dsh_command: Annotated[ + Path | None, typer.Option(help="Path to the DSH launcher; Desktop requires its installed command.") + ] = None, source: Annotated[ str, typer.Option(help="PowerContext Git source or local checkout path."), @@ -597,6 +605,17 @@ def setup_dsh( from powercontext.cli.dsh import run_dsh_diagnostics try: + if profile is None: + profile = ( + DshProfile(typer.prompt("DSH profile", default="web", type=click.Choice(["web", "desktop"]))) + if not json_output and stdin_is_tty() + else DshProfile.WEB + ) + target = resolve_dsh_target(profile, dsh_command) + if not json_output: + typer.echo(f"DSH target: {profile} ({target.directory})") + if profile == DshProfile.DESKTOP: + typer.echo("Desktop must be fully quit before installation (closing its window only hides it).", err=True) result = setup_host( "dsh", source=source, @@ -604,12 +623,13 @@ def setup_dsh( server_url=server_url, allow_insecure_http=allow_insecure_http, json_output=json_output, + dsh_target=target, ).result except SetupError as error: typer.echo(str(error), err=True) raise typer.Exit(code=1) from error - diagnostics = run_dsh_diagnostics() + diagnostics = run_dsh_diagnostics(target=target) if not _diagnostics_ok(diagnostics): _write_diagnostics(diagnostics, json_output=json_output) raise typer.Exit(code=1) @@ -620,7 +640,11 @@ def setup_dsh( typer.echo("PowerContext DeepSeek Harness setup complete.") typer.echo(f"Plugin: {result.plugin} ({result.plugin_path})") typer.echo(f"Data directory: {result.data_dir}") - typer.echo("Next: run `powercontext server run`, then start `dsh web`.") + typer.echo( + "Next: run `powercontext server run`, then reopen DeepSeek Harness Desktop." + if profile == DshProfile.DESKTOP + else "Next: run `powercontext server run`, then start `dsh web`." + ) @setup_app.command("zcode") @@ -1072,6 +1096,8 @@ def doctor_claude_code( @doctor_app.command("dsh") def doctor_dsh( + profile: Annotated[DshProfile, typer.Option(help="DSH profile to inspect.")] = DshProfile.WEB, + dsh_command: Annotated[Path | None, typer.Option(help="Path to the DSH launcher.")] = None, json_output: Annotated[ bool, typer.Option("--json", help="Write the result as JSON."), @@ -1081,8 +1107,19 @@ def doctor_dsh( from powercontext.cli.dsh import run_dsh_diagnostics - diagnostics = run_dsh_diagnostics() - add_transport_diagnostic(diagnostics, "dsh") + try: + target = resolve_dsh_target(profile, dsh_command) + except SetupError as error: + diagnostics = { + "dsh": Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)), + "plugin": Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because the selected DeepSeek Harness profile is unavailable", + ), + } + else: + diagnostics = run_dsh_diagnostics(target=target) + add_transport_diagnostic(diagnostics, "dsh", dsh_target=target) _write_diagnostics(diagnostics, json_output=json_output) if not _diagnostics_ok(diagnostics): raise typer.Exit(code=1) diff --git a/src/powercontext/cli/transport.py b/src/powercontext/cli/transport.py index 3660ed1e26..bd4807cf85 100644 --- a/src/powercontext/cli/transport.py +++ b/src/powercontext/cli/transport.py @@ -38,6 +38,7 @@ from powercontext.transport import is_loopback_host if TYPE_CHECKING: + from powercontext.cli.dsh_runtime import DshTarget from powercontext.cli.system import Diagnostic @@ -64,6 +65,7 @@ def prepare_setup_transport( server_url: str | None = None, allow_insecure_http: bool | None = None, json_output: bool = False, + dsh_target: DshTarget | None = None, ) -> SetupTransport: """Resolve and confirm the endpoint before any installation side effects.""" @@ -74,7 +76,11 @@ def prepare_setup_transport( if host == "dsh": from powercontext.cli.dsh_transport import read_dsh_settings - native = read_dsh_settings(profile="web", prospective=True) + native = read_dsh_settings( + profile=dsh_target.profile if dsh_target else "web", + prospective=True, + executable=dsh_target.command if dsh_target else None, + ) prefix = "POWERCONTEXT_" + ("CLAUDE" if host == "claude-code" else host.upper().replace("-", "_")) + "_" server_url = resolve_setup_endpoint(host, server_url=server_url, native_endpoint=native.get("baseUrl")) allow_insecure_http = _setup_consent(host, server_url, allow_insecure_http, native) @@ -301,14 +307,14 @@ def hermes_config_file() -> Path: return Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))) / "powercontext" / "config.json" -def transport_diagnostic(host: str) -> Diagnostic: +def transport_diagnostic(host: str, *, dsh_target: DshTarget | None = None) -> Diagnostic: """Report explicitly insecure transport as degraded rather than silently green.""" from powercontext.cli.native_transport import resolve_host_transport from powercontext.cli.system import Diagnostic, DiagnosticStatus try: - endpoint, allowed = resolve_host_transport(host) + endpoint, allowed = resolve_host_transport(host, **({"dsh_target": dsh_target} if dsh_target else {})) except ValueError as error: return Diagnostic(status=DiagnosticStatus.FAILED, detail=str(error)) if is_remote_http(endpoint): @@ -323,9 +329,11 @@ def transport_diagnostic(host: str) -> Diagnostic: return Diagnostic(status=DiagnosticStatus.OK, detail=endpoint) -def add_transport_diagnostic(diagnostics: dict[str, Diagnostic], host: str) -> None: +def add_transport_diagnostic( + diagnostics: dict[str, Diagnostic], host: str, *, dsh_target: DshTarget | None = None +) -> None: """Append policy problems without changing healthy installation reports.""" - diagnostic = transport_diagnostic(host) + diagnostic = transport_diagnostic(host, **({"dsh_target": dsh_target} if dsh_target else {})) if not diagnostic.ok: diagnostics["transport"] = diagnostic diff --git a/tests/test_dsh_cli.py b/tests/test_dsh_cli.py index 1130c742b2..f315c8bb36 100644 --- a/tests/test_dsh_cli.py +++ b/tests/test_dsh_cli.py @@ -120,7 +120,7 @@ def test_doctor_dsh_requires_the_plugin_id_field(monkeypatch) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") - monkeypatch.setattr(dsh_cli, "_run_dsh", lambda *_args: "name: powercontext-dsh\n") + monkeypatch.setattr(dsh_cli, "_run_dsh", lambda *_args, **_kwargs: "name: powercontext-dsh\n") result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh"]) @@ -135,7 +135,7 @@ def test_doctor_dsh_reports_an_installed_plugin(monkeypatch) -> None: monkeypatch.setattr( dsh_cli, "_run_dsh", - lambda *_args: "- id: powercontext-dsh\n name: powercontext-dsh\n", + lambda *_args, **_kwargs: "- id: powercontext-dsh\n name: powercontext-dsh\n", ) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--json"]) @@ -159,7 +159,7 @@ def test_doctor_dsh_does_not_expose_host_config_or_claim_to_check_its_server(mon monkeypatch.setattr( dsh_cli, "_run_dsh", - lambda *_args: f"- id: powercontext-dsh\n config:\n baseUrl: http://unreachable/{marker}\n", + lambda *_args, **_kwargs: f"- id: powercontext-dsh\n config:\n baseUrl: http://unreachable/{marker}\n", ) result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--json"]) assert result.exit_code == 0 diff --git a/tests/test_dsh_profiles.py b/tests/test_dsh_profiles.py new file mode 100644 index 0000000000..3c5e34d0fe --- /dev/null +++ b/tests/test_dsh_profiles.py @@ -0,0 +1,207 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Profile selection and Desktop prerequisites through the public CLI.""" + +import json +import os +from pathlib import Path +from unittest.mock import Mock + +import pytest +from typer.testing import CliRunner + +from powercontext.cli.app import create_cli +from powercontext.cli.dsh_runtime import DshProfile, desktop_runtime, resolve_dsh_target +from powercontext.cli.system import doctor_app, setup_app + + +@pytest.fixture +def desktop_command(tmp_path): + resources = tmp_path / "DeepSeek Harness" / "resources" + command = resources / "runtime/cli/bin/dsh.cmd" + command.parent.mkdir(parents=True) + command.write_text("@echo off\n") + (resources / "app.asar").touch() + (resources.parent / "DeepSeek Harness.exe").touch() + return command + + +@pytest.fixture +def setup_boundary(tmp_path, monkeypatch): + import powercontext.cli.dsh as dsh + import powercontext.cli.dsh_transport as transport + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) + plugin = tmp_path / "plugin" + (plugin / "lib").mkdir(parents=True) + (plugin / "package.json").write_text('{"name":"powercontext-dsh"}') + (plugin / "lib/index.js").write_text("export const name = 'powercontext-dsh'\n") + monkeypatch.setattr(dsh, "dsh_executable", lambda: "npm-dsh") + inspect = Mock(return_value={}) + monkeypatch.setattr(transport, "read_dsh_settings", inspect) + run = Mock(return_value="id: powercontext-dsh\n") + monkeypatch.setattr(dsh, "_run_dsh", run) + return plugin, run, inspect + + +@pytest.mark.parametrize("mode", ["interactive", "explicit", "json", "noninteractive"]) +def test_setup_selects_profile_and_reports_destination(tmp_path, monkeypatch, desktop_command, setup_boundary, mode): + import powercontext.cli.system as system + + plugin, run, inspect = setup_boundary + profile = "web" if mode in {"json", "noninteractive"} else "desktop" + desktop_dir = Path(os.environ["DSH_HOME"]) / "profiles/desktop" + desktop_dir.mkdir(parents=True) + (desktop_dir / "package.json").write_text("{}") + monkeypatch.setattr(system, "stdin_is_tty", lambda: mode in {"interactive", "json"}) + args = ["setup", "dsh", "--source", str(plugin), "--dsh-command", str(desktop_command)] + if mode == "explicit": + args += ["--profile", "desktop"] + if mode == "json": + args += ["--json"] + result = CliRunner().invoke(create_cli([setup_app]), args, input="desktop\n") + assert result.exit_code == 0, result.output + assert run.call_args_list[0].args[2] == profile + assert run.call_args_list[0].kwargs["executable"] == str(desktop_command.resolve()) + assert {call.kwargs["profile"] for call in inspect.call_args_list} == {profile} + assert str(Path(os.environ["DSH_HOME"]) / "profiles" / profile) in ( + json.loads(result.stdout)["profile_dir"] if mode == "json" else result.output + ) + if mode == "json": + assert json.loads(result.stdout)["profile"] == "web" + assert "DSH profile" not in result.output + if profile == "desktop": + assert "reopen DeepSeek Harness Desktop" in result.output + assert "fully quit" in result.output + assert all("--dump-config" not in call.args for call in run.call_args_list) + + +def test_uninitialized_desktop_fails_before_installation(tmp_path, desktop_command, setup_boundary): + plugin, run, inspect = setup_boundary + result = CliRunner().invoke( + create_cli([setup_app]), + [ + "setup", + "dsh", + "--profile", + "desktop", + "--dsh-command", + str(desktop_command), + "--source", + str(plugin), + ], + ) + assert result.exit_code == 1 + assert "Open Desktop once" in result.output + assert not (Path(os.environ["DSH_HOME"]) / "profiles/desktop").exists() + assert not (tmp_path / "data").exists() + run.assert_not_called() + inspect.assert_not_called() + + +@pytest.mark.parametrize( + ("profile", "available_command", "error"), + [ + ("web", False, "does not exist"), + ("desktop", False, "does not exist"), + ("desktop", True, "Open Desktop once"), + ], +) +def test_doctor_keeps_plugin_check_when_target_is_unavailable( + tmp_path, desktop_command, profile, available_command, error +): + command = desktop_command if available_command else tmp_path / "missing-dsh.cmd" + result = CliRunner().invoke( + create_cli([doctor_app]), + ["doctor", "dsh", "--profile", profile, "--dsh-command", str(command), "--json"], + ) + assert result.exit_code == 1 + report = json.loads(result.stdout) + assert report["status"] == "failed" + assert set(report["checks"]) == {"dsh", "plugin"} + assert report["checks"]["dsh"]["status"] == "failed" + assert error in report["checks"]["dsh"]["detail"] + assert report["checks"]["plugin"]["status"] == "skipped" + assert "not checked" in report["checks"]["plugin"]["detail"] + assert not (Path(os.environ["DSH_HOME"]) / "profiles").exists() + + +def test_desktop_rejects_npm_command_without_writes(tmp_path, setup_boundary): + plugin, run, _ = setup_boundary + command = tmp_path / "dsh.cmd" + command.write_text("@echo off\n") + result = CliRunner().invoke( + create_cli([setup_app]), + [ + "setup", + "dsh", + "--profile", + "desktop", + "--dsh-command", + str(command), + "--source", + str(plugin), + ], + ) + assert result.exit_code == 1 + assert "Desktop-installed dsh" in result.output + assert "Manage dsh Command" in result.output + run.assert_not_called() + + +def test_invalid_profile_never_installs(setup_boundary): + _, run, _ = setup_boundary + result = CliRunner().invoke(create_cli([setup_app]), ["setup", "dsh", "--profile", "../other"]) + assert result.exit_code == 2 + run.assert_not_called() + + +def test_cancelled_selection_has_no_installation_effects(monkeypatch, setup_boundary): + import powercontext.cli.system as system + + _, run, inspect = setup_boundary + monkeypatch.setattr(system, "stdin_is_tty", lambda: True) + result = CliRunner().invoke(create_cli([setup_app]), ["setup", "dsh"], input="\x03") + assert result.exit_code == 1 + run.assert_not_called() + inspect.assert_not_called() + + +@pytest.mark.skipif(os.name != "nt", reason="Windows installed command layout") +def test_desktop_searches_path_past_npm_shim(tmp_path, monkeypatch, desktop_command): + npm = tmp_path / "npm" + npm.mkdir() + (npm / "dsh.cmd").write_text("@echo off\n") + monkeypatch.setenv("PATH", os.pathsep.join([str(npm), str(desktop_command.parent)])) + profile = Path(os.environ["DSH_HOME"]) / "profiles/desktop" + profile.mkdir(parents=True) + (profile / "package.json").write_text("{}") + assert resolve_dsh_target(DshProfile.DESKTOP).command == str(desktop_command) + + +def test_macos_launcher_resolves_its_signed_runtime(tmp_path): + resources = tmp_path / "DeepSeek Harness.app/Contents/Resources" + command = resources / "runtime/cli/bin/dsh" + command.parent.mkdir(parents=True) + command.touch() + electron = resources.parent / "MacOS/DeepSeek Harness" + electron.parent.mkdir() + electron.touch() + (resources / "app.asar").touch() + runtime = desktop_runtime(str(command)) + assert runtime is not None + assert runtime.electron == electron + assert str(runtime.anchor).endswith(str(Path("app.asar/dsh/node_modules/@deepseek-ai/dsh/package.json"))) diff --git a/tests/test_dsh_transport.py b/tests/test_dsh_transport.py index 3431ac0596..22e8605aaa 100644 --- a/tests/test_dsh_transport.py +++ b/tests/test_dsh_transport.py @@ -684,7 +684,7 @@ def test_postinstall_include_drift_preserves_saved_connection_and_credentials( monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) monkeypatch.setenv("POWERCONTEXT_DSH_AUTHORIZATION", "Bearer new-secret-token") - def install(*_args): + def install(*_args, **_kwargs): shutil.copytree(source, profile / "node_modules/powercontext-dsh") (profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": ["powercontext-dsh"]}}})) (profile / "included.json").write_text( @@ -844,7 +844,7 @@ def test_setup_checks_actual_installed_transport_before_saving_connection( monkeypatch.setenv("POWERCONTEXT_HOME", str(tmp_path / "data")) monkeypatch.setenv("POWERCONTEXT_DSH_AUTHORIZATION", "Bearer new-token") - def install(*_args): + def install(*_args, **_kwargs): shutil.copytree(source, profile / "node_modules/powercontext-dsh") (profile / "package.json").write_text(json.dumps({"dsh": {"profile": {"bundles": ["powercontext-dsh"]}}})) write_patch(profile, patch) @@ -1043,3 +1043,63 @@ def test_unavailable_host_configuration_apis_give_recovery_guidance(dsh_native_a entry.write_text("export {}\n") with pytest.raises(ValueError, match="upgrade or reinstall DSH"): read_dsh_settings() + + +def test_selected_profile_controls_setup_transport_and_preserves_other_profile(dsh_profile, tmp_path): + from powercontext.cli.dsh import dsh_executable + from powercontext.cli.dsh_runtime import DshProfile, DshTarget + + # Exercise real DSH composition with disposable manifests, not Electron installation. + desktop = dsh_profile.parent / "desktop" + desktop.mkdir() + (desktop / "package.json").write_bytes((dsh_profile / "package.json").read_bytes()) + write_patch(dsh_profile, "- id: powercontext-dsh\n config:\n baseUrl: https://web.example\n") + write_patch(desktop, "- id: powercontext-dsh\n config:\n baseUrl: https://desktop.example\n") + original = {p: p.read_bytes() for p in dsh_profile.parent.rglob("*") if p.is_file()} + target = DshTarget(DshProfile.DESKTOP, dsh_executable()) + assert prepare_setup_transport("dsh", dsh_target=target).server_url == "https://desktop.example" + assert prepare_setup_transport("dsh").server_url == "https://web.example" + with pytest.raises(RuntimeError, match="baseUrl conflicts"): + prepare_setup_transport("dsh", dsh_target=target, server_url="https://web.example") + assert {p: p.read_bytes() for p in dsh_profile.parent.rglob("*") if p.is_file()} == original + + +def test_desktop_registration_does_not_boot_and_requires_an_enabled_bundle(dsh_profile, tmp_path): + from powercontext.cli.dsh import dsh_executable, run_dsh_diagnostics + from powercontext.cli.dsh_runtime import DshProfile, DshTarget + + desktop = dsh_profile.parent / "desktop" + desktop.mkdir() + manifest = desktop / "package.json" + manifest.write_text('{"dsh":{"profile":{"bundles":[]}}}') + target = DshTarget(DshProfile.DESKTOP, dsh_executable()) + assert not run_dsh_diagnostics(target=target)["plugin"].ok + + source = plugin_source(tmp_path) + installed = desktop / "node_modules/powercontext-dsh" + installed.mkdir(parents=True) + for name in ("package.json", "cordis.patch.yml"): + (installed / name).write_bytes((source / name).read_bytes()) + manifest.write_text('{"dsh":{"profile":{"bundles":["powercontext-dsh"]}}}') + assert run_dsh_diagnostics(target=target)["plugin"].ok + write_patch(desktop, "- id: powercontext-dsh\n disabled: true\n") + assert not run_dsh_diagnostics(target=target)["plugin"].ok + + +def test_explicit_web_doctor_does_not_read_desktop_environment_profile(dsh_profile, monkeypatch): + import powercontext.cli.dsh as dsh + from powercontext.cli.system import doctor_app + + desktop = dsh_profile.parent / "desktop" + desktop.mkdir() + (desktop / "package.json").write_bytes((dsh_profile / "package.json").read_bytes()) + write_patch( + desktop, + "- insert:\n - id: powercontext-dsh\n name: powercontext-dsh\n" + " config:\n baseUrl: http://desktop.example\n", + ) + monkeypatch.setenv("DSH_PROFILE", "desktop") + monkeypatch.setattr(dsh, "_run_dsh", lambda *_args, **_kwargs: "id: powercontext-dsh\n") + result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh", "--profile", "web", "--json"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["ok"] is True diff --git a/tests/test_setup_transport.py b/tests/test_setup_transport.py index bb360d19f0..c66131103a 100644 --- a/tests/test_setup_transport.py +++ b/tests/test_setup_transport.py @@ -39,7 +39,9 @@ def isolate_client_config(tmp_path, monkeypatch): @pytest.mark.parametrize("host", ["codex", "claude-code", "dsh", "openclaw", "opencode", "pi", "hermes", "workbuddy"]) -def test_every_setup_rejects_remote_http_without_consent_before_install(host): +def test_every_setup_rejects_remote_http_without_consent_before_install(host, monkeypatch): + if host == "dsh": + monkeypatch.setattr("powercontext.cli.dsh.which", lambda _name: None) result = CliRunner().invoke( create_cli([setup_app]), ["setup", host, "--server-url", "http://192.0.2.10:8000", "--json"] ) @@ -178,6 +180,8 @@ def test_all_setup_routes_persist_adapter_endpoint(host, bulk, endpoint, tmp_pat from powercontext.cli import hosts, system from powercontext.cli.transport import client_config_file + if host == "dsh": + monkeypatch.setattr("powercontext.cli.dsh.which", lambda _name: None) path = client_config_file() path.write_text(json.dumps({"version": 1, "hosts": {host: {"custom": "keep"}, "other": {"custom": True}}})) monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) @@ -193,7 +197,7 @@ def install(name, **options): monkeypatch.setattr(hosts, "verify_host", lambda _host: None) if host not in {"claude-code", "openclaw"}: module = system if host == "codex" else importlib.import_module(f"powercontext.cli.{host}") - monkeypatch.setattr(module, f"run_{host}_diagnostics", lambda: {}) + monkeypatch.setattr(module, f"run_{host}_diagnostics", lambda **_options: {}) arguments = ["setup", "select", "--host", host, "--json"] if bulk else ["setup", host, "--json"] if endpoint.startswith("https:"): arguments.extend(["--server-url", endpoint]) diff --git a/tests/test_system_cli.py b/tests/test_system_cli.py index be516522c7..ca0f147eb3 100644 --- a/tests/test_system_cli.py +++ b/tests/test_system_cli.py @@ -1860,6 +1860,8 @@ def test_setup_dsh_adds_plugin_from_a_local_checkout(tmp_path: Path, monkeypatch "plugin_path": str(plugin), "data_dir": str(tmp_path / "data"), "authorization_state": "not_configured", + "profile": "web", + "profile_dir": str(tmp_path / "dsh-home/profiles/web"), } assert run_dsh.call_args_list[0].args == ( "plugin", @@ -1914,7 +1916,7 @@ def test_doctor_dsh_requires_the_installed_plugin(monkeypatch) -> None: import powercontext.cli.dsh as dsh_cli monkeypatch.setattr(dsh_cli, "which", lambda _name: "/usr/bin/dsh") - monkeypatch.setattr(dsh_cli, "_run_dsh", lambda *_args: "id: other-plugin\n") + monkeypatch.setattr(dsh_cli, "_run_dsh", lambda *_args, **_kwargs: "id: other-plugin\n") result = CliRunner().invoke(create_cli([doctor_app]), ["doctor", "dsh"])