Files
mcpctl/src/cli/tests/config/opencode-extension-embed.test.ts
Michal cbd3b95d97 fix(opencode): guard state parsing, lint the .tsx, correct an overstated doc claim
Three findings from a cross-branch review of the competing opencode
implementations, all of which are fair.

1. `readState` type-guards the parsed JSON now. A bare try/catch does not
   cover it: `JSON.parse('null')` succeeds and returns null, so the catch never
   fires and the next `state.project` throws a TypeError that takes the plugin
   down. Verified the crash before fixing; a test pins the guard in the embedded
   copies. Credit to the competing 'opencode-mine' branch, which had this right.

2. eslint now covers `src/opencode-ext/*.tsx`. The glob was `*.ts` only, so the
   300-line TUI plugin — the largest file in the addon — was linted by nothing.
   It was typechecked, which is why this went unnoticed. Confirmed the rules
   actually fire on it rather than the file being silently skipped. The
   'abhishek' branch was the only entry that got this right.

3. docs/opencode-extension.md overstated the security argument. "The token would
   sit in a 0644 opencode.json" is not a point against a `type: local` stdio
   bridge, which needs no token at all because it reads your own credentials.
   That reason is a consequence of having picked the HTTP gateway, not a
   justification for it. The docs now lead with the real reason — live
   re-pointing without a restart — and state the trade honestly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 20:47:45 +01:00

104 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 opencodes own tree, never Claudes', () => {
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'");
});
});
describe('embedded opencode plugins — state parsing', () => {
it('type-guard the parsed state, not just try/catch', () => {
// `JSON.parse('null')` succeeds and returns null, so a bare try/catch lets
// it through and the next `state.project` throws a TypeError that takes the
// plugin down. A hand-edited or truncated state file must degrade to "no
// project", never to a broken opencode.
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).toContain("typeof parsed === 'object' && parsed !== null");
expect(src).not.toMatch(/return JSON\.parse\(await readFile\([^)]*\)\) as OpencodeState;/);
}
});
});