Three improvements taken from reading the sibling opencode branches (feat/opencode-extension-abhishek in particular): - `<leader>m` opens the project picker. Switching is the repeated action and typing `/mcpctl` every time is friction; the other two commands stay palette-only. - A switch disconnects before re-adding. `mcp.add` under the same name does re-point the tools on its own, but leaves it to opencode whether the previous client is closed — and an abandoned one keeps its `mcp-session-id` alive on mcplocal, which is exactly what holds a gated project open. Best-effort, so a first mount still works. - The footer label renders `wrapMode="none" truncate`. The home prompt row is narrow enough that the default wrap broke `mcpctl:homeautomation` across two lines mid-word; clipping the tail of a long name reads far better. Verified against opencode 1.18.15: ctrl-x m opens the picker, the home footer is now one line, and a disconnect-then-add switch still lands — the model called `mcpctl_begin_session` and listed the new project's tools. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
91 lines
4.0 KiB
TypeScript
91 lines
4.0 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import {
|
||
OPENCODE_SERVER_PLUGIN_SOURCE,
|
||
OPENCODE_TUI_PLUGIN_SOURCE,
|
||
OPENCODE_SERVER_PLUGIN_FILENAME,
|
||
OPENCODE_TUI_PLUGIN_FILENAME,
|
||
} from '../../src/config/opencode-extension.js';
|
||
|
||
/**
|
||
* `mcpctl config opencode` installs the *embedded* copy of the plugins, not the
|
||
* files in src/opencode-ext/. Editing the sources without re-running the
|
||
* generator therefore ships stale code to users while the repo looks correct —
|
||
* and the embedded copy is the one thing no typecheck covers. Same guarantee
|
||
* the completions check gives.
|
||
*/
|
||
const repoRoot = join(import.meta.dirname, '..', '..', '..', '..');
|
||
const extDir = join(repoRoot, 'src', 'opencode-ext');
|
||
|
||
describe('embedded opencode plugins', () => {
|
||
it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
|
||
expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed')
|
||
.toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'));
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed')
|
||
.toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'));
|
||
});
|
||
|
||
it('install under names opencode can actually load', () => {
|
||
// The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is
|
||
// referenced by path from tui.json and must stay .tsx for its JSX to be
|
||
// transpiled.
|
||
expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts');
|
||
expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx');
|
||
});
|
||
|
||
it('are self-contained — the installed files have no mcpctl imports to resolve', () => {
|
||
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
|
||
expect(src).not.toMatch(/from '@mcpctl\//);
|
||
expect(src).not.toMatch(/from '\.\.\//);
|
||
}
|
||
});
|
||
|
||
it('keep the JSX pragma the TUI plugin needs to render its indicator', () => {
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE.startsWith('/** @jsxImportSource @opentui/solid */')).toBe(true);
|
||
});
|
||
|
||
it('agree on the MCP server name, so a switch re-points one mount instead of stacking two', () => {
|
||
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
|
||
expect(src).toContain("const SERVER_NAME = 'mcpctl'");
|
||
}
|
||
});
|
||
|
||
it('agree on the state file both read', () => {
|
||
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
|
||
expect(src).toContain("join(homedir(), '.mcpctl', 'opencode-state.json')");
|
||
}
|
||
});
|
||
|
||
it('spawn the CLI without a shell, so a project name is never interpolated into a command string', () => {
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("execFile('mcpctl', args");
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexecSync\s*\(/);
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexec\(`/);
|
||
});
|
||
|
||
it('sync skills into opencode’s own tree, never Claude’s', () => {
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'");
|
||
});
|
||
|
||
it('bind the switcher to a chord as well as a slash command', () => {
|
||
// Switching is the repeated action; typing /mcpctl every time is friction.
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'");
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: '<leader>m'");
|
||
});
|
||
|
||
it('tear the outgoing mount down before re-pointing it', () => {
|
||
// An abandoned client keeps its mcp-session-id — and a gated project's
|
||
// unlocked state — alive on mcplocal.
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })');
|
||
});
|
||
|
||
it('clip rather than wrap the footer label on the narrow home prompt', () => {
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"');
|
||
});
|
||
|
||
it('switch without rewriting the plugin file opencode has already loaded', () => {
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'");
|
||
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'");
|
||
});
|
||
});
|