feat(cli): dsh integration — config dsh + skills sync --agent dsh
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m20s
CI/CD / test (pull_request) Failing after 28s
CI/CD / lint (pull_request) Successful in 2m51s
CI/CD / smoke (pull_request) Has been skipped
CI/CD / build (pull_request) Has been skipped
CI/CD / publish (pull_request) Has been skipped

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/<profile>/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V6HNx6cyr4J4CiMGCy1wkF
This commit is contained in:
Michal
2026-10-13 22:58:39 +01:00
parent ecd86fa357
commit 5f6d054ffa
10 changed files with 619 additions and 7 deletions

View File

@@ -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": {

View File

@@ -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<ConfigCommandDeps>, 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 <name>', 'Project name')
.option('--profile <name>', 'dsh profile to wire', DEFAULT_DSH_PROFILE)
.option('-o, --output <path>', 'cordis.patch.yml path (default: ~/.dsh/profiles/<profile>/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 ? '<skipped>' : 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<typeof console.error>)) },
);
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 <project> --profile ${opts.profile}`);
});
config.addCommand(createConfigSetupCommand({ configDeps }));

View File

@@ -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 <agent>` 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 <name>', 'Project to sync (overrides .mcpctl-project marker)')
.option('--agent <name>', 'Sync target: claude (default), prime-agent, pi, or opencode', 'claude')
.option('--agent <name>', '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)')

184
src/cli/src/config/dsh.ts Normal file
View File

@@ -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/<profile>/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 <project>`), 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__<tool>`, 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<RegisterDshResult> {
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 <project>`. 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 };
}

View File

@@ -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<string, unknown> | null {
const doc = parse(readFileSync(path, 'utf-8')) as unknown[];
for (const item of doc) {
const rec = item as Record<string, unknown>;
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<string, unknown>)['id'] === DSH_MCP_ENTRY_ID) return member as Record<string, unknown>;
}
}
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<string, unknown>;
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<Record<string, unknown>>;
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<Record<string, unknown>> }>;
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<string, unknown>;
expect(bridgeProjectOf(config['command'], config['args'])).toBe('docmost');
expect((config['env'] as Record<string, unknown>)['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();
});
});