Some checks failed
CI/CD / lint (pull_request) Successful in 1m6s
CI/CD / typecheck (pull_request) Successful in 2m10s
CI/CD / test (pull_request) Successful in 1m19s
CI/CD / smoke (pull_request) Failing after 1m53s
CI/CD / build (pull_request) Successful in 4m14s
CI/CD / publish (pull_request) Has been skipped
Mirror `mcpctl config claude` for prime-agent (which talks to the same mcpctl
proxy MCP gateway over HTTP instead of stdio):
`mcpctl config prime-agent --project X`:
- registers the proxy MCP gateway in ~/.prime/agent/settings.json as
mcpServers.X = { type: "http", url: <gateway>/projects/X/mcp }, merging with
any existing servers (e.g. the bundled `sre` project) and preserving all other
settings
- writes a .mcpctl-project marker so later syncs resolve the project
- syncs the project's skills into ~/.prime/agent/skills/<name>/ as markdown
skills (prime-agent auto-discovers them at session start)
New `mcpctl skills sync --agent prime-agent` target re-syncs the tree later.
- src/cli/src/config/prime-agent.ts: settings.json read/merge/write helpers
- src/cli/src/utils/prime-agent-skills.ts: prime-agent sync (reuses
installSkillAtomic + skills-state; skips Claude-only hooks/postInstall)
- completes config.ts/skills.ts wiring; regenerated shell completions
- tests: commands/prime-agent.test.ts + utils/prime-agent-skills.test.ts
243 lines
12 KiB
TypeScript
243 lines
12 KiB
TypeScript
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 <parent>` 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');
|
|
});
|
|
});
|