Files
mcpctl/src/cli/tests/completions.test.ts

234 lines
12 KiB
TypeScript
Raw Normal View History

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'];
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('-a '));
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"/);
});
});
feat(agents): mcpctl chat REPL + agent CRUD + completions (Stage 5) This is the moment the user can actually talk to an agent end-to-end: mcpctl create llm qwen3-thinking --type openai --model qwen3-thinking \ --url http://litellm.nvidia-nim.svc.cluster.local:4000/v1 \ --api-key-ref litellm-key/API_KEY mcpctl create agent reviewer --llm qwen3-thinking --project mcpctl-dev \ --description "I review security design — ask me after each major change." mcpctl chat reviewer Pieces: * src/cli/src/commands/chat.ts (new) — REPL + one-shot. Streams the SSE endpoint and prints text deltas to stdout as they arrive; tool_call / tool_result events go to stderr in dim-style brackets so the chat output stays clean. LiteLLM-style flags (--temperature / --top-p / --top-k / --max-tokens / --seed / --stop / --allow-tool / --extra) layer over agent.defaultParams. In-REPL slash-commands: /set KEY VAL, /system <text>, /tools (list project's MCP servers), /clear (new thread), /save (PATCH agent.defaultParams = current overrides), /quit. * src/cli/src/commands/create.ts — `create agent` mirroring the llm pattern. Every yaml-applyable field has a corresponding flag (memory rule); --default-temperature / --default-top-p / --default-top-k / --default-max-tokens / --default-seed / --default-stop / --default-extra / --default-params-file all populate agent.defaultParams. * src/cli/src/commands/apply.ts — AgentSpecSchema accepts both `llm: qwen3-thinking` shorthand and `llm: { name: ... }` long form; runs after llms in the apply order so apiKey/llm references resolve. Round- trips with `get agent foo -o yaml | apply -f -` (memory rule). * src/cli/src/commands/get.ts — agentColumns (NAME, LLM, PROJECT, DESCRIPTION, ID); RESOURCE_KIND mapping for yaml export. * src/cli/src/commands/shared.ts — `agent`/`agents`/`thread`/`threads` added to RESOURCE_ALIASES. * src/cli/src/index.ts — wires createChatCommand into the program; passes the resolved baseUrl + token so chat can stream SSE without going through ApiClient (which only does buffered request/response). * completions/mcpctl.{fish,bash} regenerated. scripts/generate-completions.ts knows about agents (canonical + aliases) and emits a special-case `chat)` block that completes the first arg with `mcpctl get agents` names. tests/completions.test.ts: +9 new assertions covering agents in the resource list, chat in the commands list, --llm flag for create agent, agent-name completion for chat, etc. CLI suite: 430/430 (was 421). Completions --check is clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:02:38 +01:00
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(/agent\)[\s\S]*?;;/)?.[0] ?? '';
expect(createBlock).toContain('--llm');
expect(createBlock).toContain('--system-prompt');
expect(createBlock).toContain('--default-temperature');
});
});