From 2ed6d3878426ecad627d17f4b992e38b768bdcf6 Mon Sep 17 00:00:00 2001 From: Michal Date: Tue, 13 Oct 2026 22:58:39 +0100 Subject: [PATCH] =?UTF-8?q?feat(cli):=20dsh=20integration=20=E2=80=94=20co?= =?UTF-8?q?nfig=20dsh=20+=20skills=20sync=20--agent=20dsh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dsh (the DeepSeek harness) joins claude, pi, prime-agent and opencode as a first-class host. `mcpctl config dsh --project X`: - merges ONE dsh-mcp-client entry (id mcp-mcpctl, mount name mcpctl, stdio bridge `mcpctl mcp -p X`) into the profile's user patch layer (~/.dsh/profiles//cordis.patch.yml). The stdio bridge keeps auth in ~/.mcpctl/credentials — the patch file is 0644 and commonly committed to dotfiles, so the HTTP mount's bearer header is not an option there. - edits with the comment-preserving `yaml` Document API and touches only its own entry: dsh appends runtime UI state to the same file, bundles and users own the rest, and a corrupt or non-array file aborts instead of being overwritten. Re-runs re-point the bridge args in place and keep user-added keys; a same-mount entry under another id is adopted, a hand-configured server with its own serverName is never touched. - writes the .mcpctl-project marker with the prime-agent semantics and syncs skills into ~/.dsh/skills — the user root dsh-skill-filesystem scans AND watches, so synced skills appear without a restart. `dsh` becomes a skills sync target with the shared-tree semantics, state in ~/.mcpctl/skills-state-dsh.json. Verified against dsh 0.2.0-rc.2: the web profile boots, spawns the bridge as its child within a second, and serves with the mount active; patch-file comments and dsh's own appended entries survive re-runs. Completions regenerated for the new subcommand. Note: tests/commands/prime-agent.test.ts ("does not write a marker when run from $HOME") fails on main too on this machine and leaks a real ~/.mcpctl-project — pre-existing test-isolation issue, untouched here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01V6HNx6cyr4J4CiMGCy1wkF --- README.md | 31 ++++ completions/mcpctl.bash | 5 +- completions/mcpctl.fish | 15 +- docs/dsh-integration.md | 65 +++++++++ pnpm-lock.yaml | 3 + src/cli/package.json | 1 + src/cli/src/commands/config.ts | 121 ++++++++++++++++ src/cli/src/commands/skills.ts | 11 +- src/cli/src/config/dsh.ts | 184 ++++++++++++++++++++++++ src/cli/tests/config/dsh-patch.test.ts | 190 +++++++++++++++++++++++++ 10 files changed, 619 insertions(+), 7 deletions(-) create mode 100644 docs/dsh-integration.md create mode 100644 src/cli/src/config/dsh.ts create mode 100644 src/cli/tests/config/dsh-patch.test.ts diff --git a/README.md b/README.md index fe68342..12bd664 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,37 @@ Re-sync skills on their own with See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. +### Connect dsh (DeepSeek harness) + +dsh has a native MCP client, configured through a profile's user patch layer +(`~/.dsh/profiles//cordis.patch.yml`). mcpctl mounts the same stdio +bridge there that `config claude` registers — auth stays in +`~/.mcpctl/credentials`, nothing secret lands in the patch file: + +```bash +mcpctl config dsh --project monitoring # the `web` profile +mcpctl config dsh --project monitoring --profile cli +``` + +This merges a single `dsh-mcp-client` entry (id `mcp-mcpctl`, mount name +`mcpctl`, bridge `mcpctl mcp -p monitoring`) into the patch file with a +comment-preserving editor — every other entry, comment and runtime-appended +block survives — writes a `.mcpctl-project` marker, and syncs skills into +`~/.dsh/skills/` (dsh watches that root, so skills appear without a restart; +the MCP mount itself is picked up on the next dsh start). Re-running with a +different project re-points the existing entry in place. + +```bash +mcpctl config dsh --project monitoring --skip-skills # don't sync skills +mcpctl config dsh --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config dsh --project monitoring --dry-run # print the plan, write nothing +``` + +Re-sync skills on their own with +`mcpctl skills sync --agent dsh --project monitoring`. + +See [docs/dsh-integration.md](docs/dsh-integration.md) for full details. + ### Run Claude Code on the homelab LLM The homelab gateway (LiteLLM in front of vLLM) already serves the Anthropic diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index e4debf6..9715e0c 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -106,7 +106,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode dsh setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -139,6 +139,9 @@ _mcpctl() { opencode) COMPREPLY=($(compgen -W "-p --project --gateway-url --token --opencode-dir --skip-skills --skip-plugin --skip-marker --dry-run -h --help" -- "$cur")) ;; + dsh) + COMPREPLY=($(compgen -W "-p --project --profile -o --output --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index acbc043..5f56711 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -325,7 +325,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate +set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode dsh setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' @@ -336,6 +336,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a opencode -d 'Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a dsh -d 'Register the mcpctl stdio bridge in a dsh profile patch layer + sync skills (~/.dsh)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -404,6 +405,14 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-plugin -d complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l dry-run -d 'Print what would change without writing or syncing' +# config dsh options +complete -c mcpctl -n "__mcpctl_subcmd_active config dsh" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config dsh" -l profile -d 'dsh profile to wire' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config dsh" -s o -l output -d 'cordis.patch.yml path (default: ~/.dsh/profiles//cordis.patch.yml)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config dsh" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config dsh" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' +complete -c mcpctl -n "__mcpctl_subcmd_active config dsh" -l dry-run -d 'Print what would change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -697,11 +706,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Revie # skills subcommands set -l skills_cmds sync -complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)' +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, ~/.dsh, or opencode skill roots)' # skills sync options complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, pi, or opencode' -x +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, pi, opencode, or dsh' -x complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by session-start hooks)' diff --git a/docs/dsh-integration.md b/docs/dsh-integration.md new file mode 100644 index 0000000..bdd3d25 --- /dev/null +++ b/docs/dsh-integration.md @@ -0,0 +1,65 @@ +# mcpctl × dsh (DeepSeek harness) + +## What `mcpctl config dsh --project X` wires up + +| Piece | Where | Purpose | +|-------|-------|---------| +| MCP entry | `~/.dsh/profiles//cordis.patch.yml` | one `@deepseek-ai/dsh-mcp-client` mount named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | +| Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | +| Skills | `~/.dsh/skills/` | the project's `SKILL.md` bundles, in the user root `dsh-skill-filesystem` scans and watches | + +`--profile` picks the dsh profile (default `web`); `-o` points at a patch file +directly. + +## Why the stdio bridge, not the HTTP gateway + +dsh's MCP client speaks both stdio and Streamable HTTP, but its config is ONE +user-owned YAML patch file — `cordis.patch.yml` — which is 0644 and commonly +kept in a dotfiles repo. The HTTP mount would need the project bearer token in +a header there; the stdio bridge (`mcpctl mcp -p X`) authenticates from +`~/.mcpctl/credentials` instead, exactly like the Claude Code integration, so +nothing secret ever lands in the patch file. The trade-off is the same as +Claude's: switching projects means re-running `config dsh` and restarting dsh, +where opencode/prime-agent re-point their HTTP mounts live. + +## One entry, merged in place + +The patch file is dsh's own: bundles compose it, the user hand-edits it, and +dsh itself appends UI state to it at runtime. So `config dsh` parses it with a +comment-preserving YAML editor and touches ONLY its own entry: + +- Ours is the entry with id `mcp-mcpctl` — or, adopted, any `dsh-mcp-client` + entry whose `serverName` is `mcpctl` (that mount name is this CLI's; dsh + requires unique serverNames, so a hand-configured second gateway with its own + name never matches). +- A project switch rewrites the bridge args of the existing entry in place and + keeps any keys the user added to it (an `env:` block, say). +- Everything else — other entries, comments, formatting — survives + byte-for-byte. A corrupt or non-array file aborts the run instead of being + overwritten. +- Tools surface to the model as `mcp__mcpctl__`, the same namespace + Claude Code sees. + +## Skills + +`~/.dsh/skills` is the "user" root of dsh's filesystem skill provider: a skill +is a `/SKILL.md` bundle with `name` + `description` frontmatter — the +same format every other target uses, so the shared sync writes it unchanged. +dsh watches the root, so synced skills appear without a restart. The tree has +the same shared-tree semantics as prime-agent/pi/opencode: per-project +ownership tracking, untracked hand-authored skills are preserved, state in +`~/.mcpctl/skills-state-dsh.json`. + +Re-sync later with: + +```bash +mcpctl skills sync --agent dsh --project X +``` + +## Skipping steps + +```bash +mcpctl config dsh --project X --skip-skills # don't sync skills +mcpctl config dsh --project X --skip-marker # don't touch .mcpctl-project here +mcpctl config dsh --project X --dry-run # print what would change +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86a79db..4101476 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: react: specifier: ^19.2.4 version: 19.2.4 + yaml: + specifier: ^2.8.2 + version: 2.9.0 zod: specifier: ^3.24.0 version: 3.25.76 diff --git a/src/cli/package.json b/src/cli/package.json index 7cbc03d..bb4966f 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -26,6 +26,7 @@ "inquirer": "^12.0.0", "js-yaml": "^4.1.0", "react": "^19.2.4", + "yaml": "^2.8.2", "zod": "^3.24.0" }, "devDependencies": { diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 917d551..5c66ad6 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -63,6 +63,15 @@ import { storedToken, } from '../utils/opencode-settings.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; +import { + registerDshMcp, + dshPatchPath, + dshSkillsRoot, + DSH_MCP_ENTRY_ID, + DSH_MCP_PLUGIN, + DSH_MCP_SERVER_NAME, + DEFAULT_DSH_PROFILE, +} from '../config/dsh.js'; /** * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint @@ -1005,6 +1014,118 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } }); + // ── dsh: mount the stdio bridge in a profile's patch layer + sync skills ── + config + .command('dsh') + .description('Register the mcpctl stdio bridge in a dsh profile patch layer + sync skills (~/.dsh)') + .option('-p, --project ', 'Project name') + .option('--profile ', 'dsh profile to wire', DEFAULT_DSH_PROFILE) + .option('-o, --output ', 'cordis.patch.yml path (default: ~/.dsh/profiles//cordis.patch.yml)') + .option('--skip-skills', 'Skip the skills sync step') + .option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { + project?: string; + profile: string; + output?: string; + skipSkills?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required'); + process.exitCode = 1; + return; + } + + const patchPath = resolve(opts.output ?? dshPatchPath(opts.profile)); + + if (opts.dryRun === true) { + log(JSON.stringify({ + dsh: { + patchPath, + entry: { + id: DSH_MCP_ENTRY_ID, + name: DSH_MCP_PLUGIN, + config: { serverName: DSH_MCP_SERVER_NAME, transport: 'stdio', command: 'mcpctl', args: ['mcp', '-p', opts.project] }, + }, + skillsRoot: dshSkillsRoot(), + marker: opts.skipMarker === true ? '' : join(cwd(), '.mcpctl-project'), + }, + action: 'merge the bridge entry into the patch layer + write .mcpctl-project marker + sync skills to ~/.dsh/skills/', + }, null, 2)); + return; + } + + // 1. Mount (or re-point) the bridge entry. No credential step: the stdio + // bridge authenticates from ~/.mcpctl/credentials, exactly like the + // Claude integration — nothing secret touches the patch file. + try { + const reg = await registerDshMcp(opts.project, patchPath); + if (reg.created) { + log(`Created ${patchPath} and mounted '${DSH_MCP_SERVER_NAME}' (mcpctl mcp -p ${reg.project})`); + } else if (reg.newEntry) { + log(`Mounted '${DSH_MCP_SERVER_NAME}' in ${patchPath} (mcpctl mcp -p ${reg.project})`); + } else { + log(reg.previousProject !== null && reg.previousProject !== reg.project + ? `Re-pointed '${DSH_MCP_SERVER_NAME}' in ${patchPath} ('${reg.previousProject}' → '${reg.project}')` + : `'${DSH_MCP_SERVER_NAME}' already mounted for '${reg.project}' in ${patchPath}`); + } + } catch (err: unknown) { + log(`Error: failed to write ${patchPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // 2. Project marker — the same semantics as `config prime-agent`: an + // explicit -p is authoritative, $HOME is never scoped, --skip-marker + // opts out for callers running from arbitrary directories. + try { + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else if (cwd() !== homedir()) { + const existing = await findProjectMarker(cwd(), homedir()); + if (existing !== null && existing.project === opts.project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(cwd(), opts.project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.project}')` + : `Wrote ${markerPath}`); + } + } else { + log('Skipped .mcpctl-project marker (running from $HOME)'); + } + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // 3. Sync skills into ~/.dsh/skills (skippable; best-effort — the mount + // in step 1 decides success, a skills error must not report a broken + // switch when MCP access is already wired). + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runSkillsSync( + { project: opts.project, target: 'dsh' }, + { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0 || result.errors.length > 0) { + log(`dsh skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`); + } + } catch (err: unknown) { + log(`Warning: dsh skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent dsh` separately)'); + } + } + + log(''); + log(`Next: restart dsh (the ${opts.profile} profile picks the entry up at boot).`); + log(`Switch projects with: mcpctl config dsh -p --profile ${opts.profile}`); + }); config.addCommand(createConfigSetupCommand({ configDeps })); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 33708a9..b43e3e5 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -42,7 +42,7 @@ import { opencodeSkillsDir } from '../utils/opencode-settings.js'; * postInstall, mcpServers auto-attach); every other target is a shared flat * tree with per-project ownership. */ -export const SYNC_TARGETS = ['claude', 'prime-agent', 'pi', 'opencode'] as const; +export const SYNC_TARGETS = ['claude', 'prime-agent', 'pi', 'opencode', 'dsh'] as const; export type SyncTarget = (typeof SYNC_TARGETS)[number]; export function isSyncTarget(value: string): value is SyncTarget { @@ -120,6 +120,8 @@ export interface SyncOpts { * (untracked) skill dirs are preserved. * 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in * ~/.pi/agent/skills and opencode's `skill` dir. + * 'dsh' — same again, in ~/.dsh/skills (the user root + * dsh-skill-filesystem scans and watches). */ target?: SyncTarget; } @@ -575,6 +577,7 @@ export interface SkillsCommandDeps { * "prime-agent" → ~/.prime/agent/skills * "pi" → ~/.pi/agent/skills * "opencode" → ~/.config/opencode/skill (XDG-aware) + * "dsh" → ~/.dsh/skills * Kept here so `mcpctl skills sync --agent` and `mcpctl config ` agree. */ export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string { @@ -585,6 +588,8 @@ export function agentInstallRoot(agent: string | undefined, homeDir: string = ho return join(homeDir, '.pi', 'agent', 'skills'); case 'opencode': return opencodeSkillsDir(process.env, homeDir); + case 'dsh': + return join(homeDir, '.dsh', 'skills'); case 'claude': default: return join(homeDir, '.claude', 'skills'); @@ -600,9 +605,9 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)') + .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, ~/.dsh, or opencode skill roots)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') - .option('--agent ', 'Sync target: claude (default), prime-agent, pi, or opencode', 'claude') + .option('--agent ', 'Sync target: claude (default), prime-agent, pi, opencode, or dsh', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') diff --git a/src/cli/src/config/dsh.ts b/src/cli/src/config/dsh.ts new file mode 100644 index 0000000..3e37fd7 --- /dev/null +++ b/src/cli/src/config/dsh.ts @@ -0,0 +1,184 @@ +/** + * Read/merge/write helpers for the dsh (DeepSeek harness) user patch layer, + * used by `mcpctl config dsh`. + * + * dsh has no mcpServers JSON: a profile is assembled from bundle layers plus + * ONE user-owned patch file, `~/.dsh/profiles//cordis.patch.yml` — a + * top-level YAML array of loader patch entries. We mount mcpctl there as a + * `@deepseek-ai/dsh-mcp-client` entry running the same stdio bridge the Claude + * integration registers (`mcpctl mcp -p `), so auth stays implicit in + * `~/.mcpctl/credentials` and no bearer token ever lands in the patch file + * (which is 0644 and commonly kept in dotfile repos). + * + * Safety invariants, mirroring `claude-mcp.ts` / `prime-agent.ts`: + * - The file is parsed with the comment-preserving `yaml` Document API and + * only OUR entry is touched — every other entry, comment and blank line + * survives a re-run byte-for-byte. dsh itself appends entries to this + * file at runtime, so wholesale rewrites would fight the host. + * - A corrupt or non-array file throws instead of being overwritten. + * - Ours is the entry with id `mcp-mcpctl`, or (adoption) any + * `dsh-mcp-client` entry whose `serverName` is `mcpctl` — that mount name + * is this CLI's, and dsh requires serverNames to be unique per scope, so a + * hand-configured second gateway never matches. + * - A project switch edits the bridge args of the existing entry in place; + * user-added keys on the entry (an `env` block, say) are kept. + */ +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import { parseDocument, Document, YAMLSeq, YAMLMap, isSeq, isMap } from 'yaml'; + +/** Entry id we own inside the patch file. */ +export const DSH_MCP_ENTRY_ID = 'mcp-mcpctl'; + +/** The dsh plugin that provides MCP client mounts. */ +export const DSH_MCP_PLUGIN = '@deepseek-ai/dsh-mcp-client'; + +/** MCP mount name — tools surface as `mcp__mcpctl__`, as in Claude Code. */ +export const DSH_MCP_SERVER_NAME = 'mcpctl'; + +/** The profile `mcpctl config dsh` wires when none is named. */ +export const DEFAULT_DSH_PROFILE = 'web'; + +/** Resolve a profile's user patch file path. */ +export function dshPatchPath(profile: string = DEFAULT_DSH_PROFILE, homeDir: string = homedir()): string { + return join(homeDir, '.dsh', 'profiles', profile, 'cordis.patch.yml'); +} + +/** Root of dsh's user skills tree (`dsh-skill-filesystem`'s user root). */ +export function dshSkillsRoot(homeDir: string = homedir()): string { + return join(homeDir, '.dsh', 'skills'); +} + +/** dsh keeps its own skills state file so it never collides with the others. */ +export function dshSkillsStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'skills-state-dsh.json'); +} + +/** + * The project a bridge-args list points at, or null when the args are not our + * stdio bridge. Same recognition rules as `projectOfEntry` in claude-mcp.ts. + */ +export function bridgeProjectOf(command: unknown, args: unknown): string | null { + if (command !== 'mcpctl' || !Array.isArray(args) || args[0] !== 'mcp') return null; + for (let i = 1; i < args.length - 1; i++) { + if (args[i] === '-p' || args[i] === '--project') { + const v: unknown = args[i + 1]; + return typeof v === 'string' && v.length > 0 ? v : null; + } + } + return null; +} + +/** Is this YAML map node an entry this CLI owns (see module doc)? */ +function isOurEntry(node: YAMLMap): boolean { + if (node.get('id', false) === DSH_MCP_ENTRY_ID) return true; + if (node.get('name', false) !== DSH_MCP_PLUGIN) return false; + const config = node.get('config'); + return isMap(config) && config.get('serverName', false) === DSH_MCP_SERVER_NAME; +} + +/** + * Walk the patch document's top-level items — both plain entries and the + * members of `insert:` lists — and return the first entry map that is ours. + */ +function findOurEntry(root: YAMLSeq): YAMLMap | null { + for (const item of root.items) { + if (!isMap(item)) continue; + if (isOurEntry(item)) return item; + const insert = item.get('insert'); + if (!isSeq(insert)) continue; + for (const member of insert.items) { + if (isMap(member) && isOurEntry(member)) return member; + } + } + return null; +} + +export interface RegisterDshResult { + patchPath: string; + created: boolean; // true if the patch file did not previously exist + newEntry: boolean; // true if our entry was not already present + previousProject: string | null; // what the existing entry bridged to, if any + project: string; +} + +/** + * Ensure the patch file mounts the mcpctl stdio bridge for `project`. + * Creates the file when missing, updates our entry's bridge args in place when + * present, appends an `insert:` item otherwise. Everything else in the file — + * entries, comments, formatting — is preserved. + */ +export async function registerDshMcp(project: string, patchPath: string): Promise { + let raw: string | null; + try { + raw = await readFile(patchPath, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code !== 'ENOENT') { + throw new Error(`failed to read ${patchPath}: ${err instanceof Error ? err.message : String(err)}`); + } + raw = null; + } + + const created = raw === null; + const doc = raw === null ? new Document([]) : parseDocument(raw); + if (doc.errors.length > 0) { + throw new Error(`patch file ${patchPath} is not valid YAML — refusing to overwrite it. Fix it and re-run (${doc.errors[0]?.message ?? 'parse error'})`); + } + if (doc.contents === null || (raw !== null && raw.trim().length === 0)) { + doc.contents = doc.createNode([]) as unknown as typeof doc.contents; + } + const root = doc.contents; + if (!isSeq(root)) { + throw new Error(`patch file ${patchPath} is not a top-level YAML array — refusing to overwrite it`); + } + + const existing = findOurEntry(root); + const args = ['mcp', '-p', project]; + let newEntry: boolean; + let previousProject: string | null = null; + + if (existing !== null) { + newEntry = false; + const config = existing.get('config'); + if (isMap(config)) { + previousProject = bridgeProjectOf(config.get('command', false), (config.get('args') as YAMLSeq | undefined)?.toJSON()); + } + // Merge: pin the fields that make the mount ours, keep user-added keys + // (env, cwd, …) and the entry's surrounding comments. + existing.set('id', DSH_MCP_ENTRY_ID); + existing.set('name', DSH_MCP_PLUGIN); + existing.setIn(['config', 'serverName'], DSH_MCP_SERVER_NAME); + existing.setIn(['config', 'transport'], 'stdio'); + existing.setIn(['config', 'command'], 'mcpctl'); + const argsNode = doc.createNode(args); + (argsNode as YAMLSeq).flow = true; + existing.setIn(['config', 'args'], argsNode); + } else { + newEntry = true; + const item = doc.createNode({ + insert: [{ + id: DSH_MCP_ENTRY_ID, + name: DSH_MCP_PLUGIN, + config: { + serverName: DSH_MCP_SERVER_NAME, + transport: 'stdio', + command: 'mcpctl', + args, + }, + }], + }) as YAMLMap; + const argsNode = item.getIn(['insert', 0, 'config', 'args']); + if (isSeq(argsNode)) argsNode.flow = true; + item.commentBefore = + ' mcpctl tools via the same stdio bridge `mcpctl config claude` registers.\n' + + ' Auth is implicit (~/.mcpctl/credentials); switch projects with\n' + + ' `mcpctl config dsh -p `. Written by `mcpctl config dsh`.'; + root.add(item); + } + + await mkdir(dirname(patchPath), { recursive: true }); + await writeFile(patchPath, doc.toString(), 'utf-8'); + + return { patchPath, created, newEntry, previousProject, project }; +} diff --git a/src/cli/tests/config/dsh-patch.test.ts b/src/cli/tests/config/dsh-patch.test.ts new file mode 100644 index 0000000..8ce5751 --- /dev/null +++ b/src/cli/tests/config/dsh-patch.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { parse } from 'yaml'; + +import { + registerDshMcp, + bridgeProjectOf, + dshPatchPath, + dshSkillsRoot, + DSH_MCP_ENTRY_ID, + DSH_MCP_PLUGIN, + DSH_MCP_SERVER_NAME, +} from '../../src/config/dsh.js'; + +describe('bridgeProjectOf', () => { + it('reads the project out of the bridge args', () => { + expect(bridgeProjectOf('mcpctl', ['mcp', '-p', 'sre'])).toBe('sre'); + expect(bridgeProjectOf('mcpctl', ['mcp', '--project', 'docmost'])).toBe('docmost'); + }); + + it('ignores anything that is not our stdio bridge', () => { + expect(bridgeProjectOf('echo', ['mcp', '-p', 'x'])).toBeNull(); + expect(bridgeProjectOf('mcpctl', ['console', '-p', 'x'])).toBeNull(); + expect(bridgeProjectOf('mcpctl', ['mcp', '-p'])).toBeNull(); + expect(bridgeProjectOf('mcpctl', undefined)).toBeNull(); + }); +}); + +describe('path helpers', () => { + it('derive from the profile and home directory', () => { + expect(dshPatchPath('web', '/home/u')).toBe('/home/u/.dsh/profiles/web/cordis.patch.yml'); + expect(dshPatchPath(undefined, '/home/u')).toBe('/home/u/.dsh/profiles/web/cordis.patch.yml'); + expect(dshSkillsRoot('/home/u')).toBe('/home/u/.dsh/skills'); + }); +}); + +describe('registerDshMcp', () => { + let tmpDir: string; + let patchPath: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-dsh-')); + patchPath = join(tmpDir, 'cordis.patch.yml'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + /** The parsed entry map our id owns, wherever it sits in the document. */ + function ourEntry(path: string): Record | null { + const doc = parse(readFileSync(path, 'utf-8')) as unknown[]; + for (const item of doc) { + const rec = item as Record; + if (rec['id'] === DSH_MCP_ENTRY_ID) return rec; + const insert = rec['insert']; + if (!Array.isArray(insert)) continue; + for (const member of insert) { + if ((member as Record)['id'] === DSH_MCP_ENTRY_ID) return member as Record; + } + } + return null; + } + + it('creates the patch file with the bridge entry when missing', async () => { + const res = await registerDshMcp('sre', patchPath); + expect(res).toMatchObject({ created: true, newEntry: true, previousProject: null, project: 'sre' }); + expect(existsSync(patchPath)).toBe(true); + + const entry = ourEntry(patchPath); + expect(entry).not.toBeNull(); + expect(entry!['name']).toBe(DSH_MCP_PLUGIN); + const config = entry!['config'] as Record; + expect(config['serverName']).toBe(DSH_MCP_SERVER_NAME); + expect(config['transport']).toBe('stdio'); + expect(bridgeProjectOf(config['command'], config['args'])).toBe('sre'); + }); + + it('appends to an existing file, preserving entries and comments', async () => { + const original = [ + '# LLM: route every model through the gateway.', + '- id: llm-pi-ai', + ' config:', + ' providers:', + ' itaz:', + ' baseURL: https://llm.example/v1 # inline comment', + '', + '# Default model for new sessions.', + '- id: agent-default-model', + ' config:', + ' provider: itaz', + '', + ].join('\n'); + writeFileSync(patchPath, original, 'utf-8'); + + const res = await registerDshMcp('sre', patchPath); + expect(res).toMatchObject({ created: false, newEntry: true }); + + const after = readFileSync(patchPath, 'utf-8'); + expect(after).toContain('# LLM: route every model through the gateway.'); + expect(after).toContain('# Default model for new sessions.'); + expect(after).toContain('# inline comment'); + expect(after).toContain('mcpctl config dsh'); + + const doc = parse(after) as Array>; + expect(doc[0]!['id']).toBe('llm-pi-ai'); + expect(doc[1]!['id']).toBe('agent-default-model'); + expect(ourEntry(patchPath)).not.toBeNull(); + }); + + it('re-points an existing entry in place and keeps user-added keys', async () => { + await registerDshMcp('sre', patchPath); + // The user hand-adds an env block to our entry. + const doc = parse(readFileSync(patchPath, 'utf-8')) as Array<{ insert: Array> }>; + const idx = doc.findIndex((i) => Array.isArray(i.insert)); + const withEnv = readFileSync(patchPath, 'utf-8').replace('command: mcpctl', 'command: mcpctl\n env:\n FOO: bar'); + writeFileSync(patchPath, withEnv, 'utf-8'); + expect(idx).toBeGreaterThanOrEqual(0); + + const res = await registerDshMcp('docmost', patchPath); + expect(res).toMatchObject({ created: false, newEntry: false, previousProject: 'sre', project: 'docmost' }); + + const entry = ourEntry(patchPath)!; + const config = entry['config'] as Record; + expect(bridgeProjectOf(config['command'], config['args'])).toBe('docmost'); + expect((config['env'] as Record)['FOO']).toBe('bar'); + // Still exactly one mount. + expect(readFileSync(patchPath, 'utf-8').match(/dsh-mcp-client/g)).toHaveLength(1); + }); + + it('adopts a same-mount entry under a different id instead of double-mounting', async () => { + writeFileSync(patchPath, [ + '- insert:', + ' - id: my-old-mcpctl', + ` name: '${DSH_MCP_PLUGIN}'`, + ' config:', + ` serverName: ${DSH_MCP_SERVER_NAME}`, + ' transport: stdio', + ' command: mcpctl', + ' args: [mcp, -p, sre]', + '', + ].join('\n'), 'utf-8'); + + const res = await registerDshMcp('docmost', patchPath); + expect(res).toMatchObject({ newEntry: false, previousProject: 'sre' }); + const after = readFileSync(patchPath, 'utf-8'); + expect(after.match(/dsh-mcp-client/g)).toHaveLength(1); + expect(ourEntry(patchPath)).not.toBeNull(); // id was pinned to ours + }); + + it('leaves a hand-configured second MCP server alone', async () => { + writeFileSync(patchPath, [ + '- insert:', + ' - id: mcp-github', + ` name: '${DSH_MCP_PLUGIN}'`, + ' config:', + ' serverName: github', + ' transport: stdio', + ' command: npx', + ' args: [-y, server-github]', + '', + ].join('\n'), 'utf-8'); + + const res = await registerDshMcp('sre', patchPath); + expect(res).toMatchObject({ newEntry: true }); + const after = readFileSync(patchPath, 'utf-8'); + expect(after).toContain('serverName: github'); + expect(after.match(/dsh-mcp-client/g)).toHaveLength(2); + }); + + it('refuses to overwrite a corrupt file', async () => { + writeFileSync(patchPath, '- id: broken\n config: [unclosed', 'utf-8'); + await expect(registerDshMcp('sre', patchPath)).rejects.toThrow(/refusing to overwrite/); + expect(readFileSync(patchPath, 'utf-8')).toContain('unclosed'); + }); + + it('refuses a file that is not a top-level array', async () => { + writeFileSync(patchPath, 'id: not-a-list\n', 'utf-8'); + await expect(registerDshMcp('sre', patchPath)).rejects.toThrow(/top-level YAML array/); + }); + + it('treats an empty or comment-only file as a fresh list', async () => { + writeFileSync(patchPath, '', 'utf-8'); + const res = await registerDshMcp('sre', patchPath); + expect(res.newEntry).toBe(true); + expect(ourEntry(patchPath)).not.toBeNull(); + }); +});