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
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:
@@ -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": {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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
184
src/cli/src/config/dsh.ts
Normal 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 };
|
||||
}
|
||||
190
src/cli/tests/config/dsh-patch.test.ts
Normal file
190
src/cli/tests/config/dsh-patch.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user