import { describe, it, expect } from 'vitest'; import { readFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execSync } from 'node:child_process'; const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const fishFile = readFileSync(join(root, 'completions', 'mcpctl.fish'), 'utf-8'); const bashFile = readFileSync(join(root, 'completions', 'mcpctl.bash'), 'utf-8'); describe('freshness', () => { it('committed completions match generator output', () => { const generatorPath = join(root, 'scripts', 'generate-completions.ts'); expect(existsSync(generatorPath), 'generator script must exist').toBe(true); // Run the generator in --check mode; exit 0 means files are up to date execSync(`npx tsx ${generatorPath} --check`, { cwd: root, stdio: 'pipe' }); }); }); describe('fish completions', () => { it('erases stale completions at the top', () => { const lines = fishFile.split('\n'); const firstComplete = lines.findIndex((l) => l.startsWith('complete ')); expect(lines[firstComplete]).toContain('-e'); }); it('does not offer resource types without __mcpctl_needs_resource_type guard', () => { const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests']; const lines = fishFile.split('\n').filter((l) => l.startsWith('complete ')); for (const line of lines) { // Find lines that offer resource types as positional args const offersResourceType = resourceTypes.some((r) => { // Match `-a "...servers..."` or `-a 'servers projects'` const aMatch = line.match(/-a\s+['"]([^'"]+)['"]/); if (!aMatch) return false; return aMatch[1].split(/\s+/).includes(r); }); if (!offersResourceType) continue; // Skip the help completions line and the -e line if (line.includes('__fish_seen_subcommand_from help')) continue; // Skip project-scoped command offerings (those offer commands, not resource types) if (line.includes('attach-server') || line.includes('detach-server')) continue; // Skip lines that offer commands (not resource types) if (line.includes("-d 'Show") || line.includes("-d 'Manage") || line.includes("-d 'Authenticate") || line.includes("-d 'Log out'") || line.includes("-d 'Get instance") || line.includes("-d 'Create a resource'") || line.includes("-d 'Edit a resource'") || line.includes("-d 'Apply") || line.includes("-d 'Backup") || line.includes("-d 'Restore") || line.includes("-d 'List resources") || line.includes("-d 'Delete a resource'")) continue; // Lines offering resource types MUST have __mcpctl_needs_resource_type in their condition expect(line, `Resource type completion missing guard: ${line}`).toContain('__mcpctl_needs_resource_type'); } }); it('resource name completions require resource type to be selected', () => { const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('__mcpctl_resource_names')); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('not __mcpctl_needs_resource_type'); } }); it('defines --project option with -p shorthand', () => { expect(fishFile).toContain("-s p -l project"); }); it('attach-server command only shows with --project', () => { // Only check lines that OFFER attach-server as a command (via -a attach-server), not argument completions const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes("-a attach-server")); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('__mcpctl_has_project'); } }); it('detach-server command only shows with --project', () => { const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes("-a detach-server")); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(line).toContain('__mcpctl_has_project'); } }); it('resource name functions use jq to extract names and avoid nested matches', () => { const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? ''; const projectNamesFn = fishFile.match(/function __mcpctl_project_names[\s\S]*?^end/m)?.[0] ?? ''; // Resource names: uses .[].name for most resources, .[][].server.name for instances expect(resourceNamesFn, '__mcpctl_resource_names must use jq for name extraction').toContain("jq -r"); expect(resourceNamesFn, '__mcpctl_resource_names must not use string match on name').not.toMatch(/string match.*"name"/); expect(projectNamesFn, '__mcpctl_project_names must use jq for name extraction').toContain("jq -r"); expect(projectNamesFn, '__mcpctl_project_names must not use string match on name').not.toMatch(/string match.*"name"/); }); it('instances use server.name instead of name', () => { const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? ''; expect(resourceNamesFn, 'must handle instances via server.name').toContain('.server.name'); }); it('attach-server completes with available (unattached) servers and guards against repeat', () => { const attachLine = fishFile.split('\n').find((l) => l.startsWith('complete') && l.includes('__fish_seen_subcommand_from attach-server')); expect(attachLine, 'attach-server argument completion must exist').toBeDefined(); expect(attachLine, 'attach-server must use __mcpctl_available_servers').toContain('__mcpctl_available_servers'); expect(attachLine, 'attach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg'); }); it('detach-server completes with project servers and guards against repeat', () => { const detachLine = fishFile.split('\n').find((l) => l.startsWith('complete') && l.includes('__fish_seen_subcommand_from detach-server')); expect(detachLine, 'detach-server argument completion must exist').toBeDefined(); expect(detachLine, 'detach-server must use __mcpctl_project_servers').toContain('__mcpctl_project_servers'); expect(detachLine, 'detach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg'); }); it('non-project commands do not show with --project', () => { const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup']; // Only check top-level command lines — those are the ones whose // visibility is gated on `__mcpctl_has_project`. Lines scoped to a // sub-command (e.g. `provider status`) live under a different // `__fish_seen_subcommand_from ` predicate and don't need // the project guard. const topLevelMarkers = ['$commands', '$project_commands']; const lines = fishFile.split('\n').filter((l) => { if (!l.startsWith('complete') || !l.includes('-a ')) return false; return topLevelMarkers.some((m) => l.includes(m)); }); for (const cmd of nonProjectCmds) { const cmdLines = lines.filter((l) => { const aMatch = l.match(/-a\s+(\S+)/); return aMatch && aMatch[1].replace(/['"]/g, '') === cmd; }); for (const line of cmdLines) { expect(line, `${cmd} should require 'not __mcpctl_has_project'`).toContain('not __mcpctl_has_project'); } } }); }); describe('bash completions', () => { it('separates project commands from regular commands', () => { expect(bashFile).toContain('project_commands='); expect(bashFile).toContain('attach-server detach-server'); }); it('checks has_project before offering project commands', () => { expect(bashFile).toContain('if $has_project'); expect(bashFile).toContain('$project_commands'); }); it('fetches resource names dynamically after resource type', () => { expect(bashFile).toContain('_mcpctl_resource_names'); // get, describe, and delete should each use resource_names when resource_type is set for (const cmd of ['get', 'describe', 'delete']) { const block = bashFile.match(new RegExp(`${cmd}\\)[\\s\\S]*?return ;;`))?.[0] ?? ''; expect(block, `${cmd} case must use _mcpctl_resource_names`).toContain('_mcpctl_resource_names'); } }); it('attach-server filters out already-attached servers and guards against repeat', () => { const attachBlock = bashFile.match(/attach-server\)[\s\S]*?return ;;/)?.[0] ?? ''; expect(attachBlock, 'attach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value'); expect(attachBlock, 'attach-server must query project servers to exclude').toContain('--project'); expect(attachBlock, 'attach-server must check position to prevent repeat').toContain('cword - subcmd_pos'); }); it('detach-server shows only project servers and guards against repeat', () => { const detachBlock = bashFile.match(/detach-server\)[\s\S]*?return ;;/)?.[0] ?? ''; expect(detachBlock, 'detach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value'); expect(detachBlock, 'detach-server must query project servers').toContain('--project'); expect(detachBlock, 'detach-server must check position to prevent repeat').toContain('cword - subcmd_pos'); }); it('instances use server.name instead of name', () => { const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? ''; expect(fnMatch, 'must handle instances via .server.name').toContain('.server.name'); }); it('defines --project option', () => { expect(bashFile).toContain('--project'); }); it('resource name function uses jq to extract names and avoid nested matches', () => { const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? ''; expect(fnMatch, '_mcpctl_resource_names must use jq for name extraction').toContain("jq -r"); expect(fnMatch, '_mcpctl_resource_names must not use grep on name').not.toMatch(/grep.*"name"/); }); }); describe('agent + chat completions', () => { it('fish lists agents as a resource type', () => { expect(fishFile).toMatch(/set -l resources [^\n]*\bagents\b/); }); it('fish accepts both `agent` and `agents` aliases', () => { const aliasLine = fishFile.split('\n').find((l) => l.startsWith(' set -l resource_aliases')); expect(aliasLine).toMatch(/\bagent\b/); expect(aliasLine).toMatch(/\bagents\b/); }); it('fish offers `chat` as a top-level command', () => { expect(fishFile).toMatch(/set -l commands [^\n]*\bchat\b/); }); it('fish offers `agent` under `mcpctl create`', () => { expect(fishFile).toMatch(/-a agent\b[^\n]*Create an Agent/); }); it('fish wires --llm flag for create agent', () => { expect(fishFile).toMatch(/__mcpctl_subcmd_active create agent[^\n]*-l llm\b/); }); it('bash lists agents in resources and resource_aliases', () => { expect(bashFile).toMatch(/local resources="[^"]*\bagents\b[^"]*"/); expect(bashFile).toMatch(/local resource_aliases="[^"]*\bagent\b[^"]*"/); }); it('bash includes `chat` in the commands list', () => { expect(bashFile).toMatch(/local commands="[^"]*\bchat\b[^"]*"/); }); it('bash dispatches a `chat)` case that completes with agent names + LiteLLM-style flags', () => { const chatBlock = bashFile.match(/chat\)[\s\S]*?return ;;/)?.[0] ?? ''; expect(chatBlock, 'chat must call _mcpctl_resource_names with "agents"').toContain('"agents"'); expect(chatBlock, 'chat must offer --temperature').toContain('--temperature'); expect(chatBlock, 'chat must offer --thread').toContain('--thread'); expect(chatBlock, 'chat must offer --no-stream').toContain('--no-stream'); }); it('bash dispatches `create agent` with the correct flags', () => { const createBlock = bashFile.match(/^\s*agent\)[\s\S]*?;;/m)?.[0] ?? ''; expect(createBlock).toContain('--llm'); expect(createBlock).toContain('--system-prompt'); expect(createBlock).toContain('--default-temperature'); }); });