feat: build CLI core framework with Commander.js

Add CLI entry point with Commander.js, config management (~/.mcpctl/config.json
with Zod validation), output formatters (table/json/yaml), config and status
commands with dependency injection for testing. Fix sanitizeString regex ordering.
67 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 04:17:31 +00:00
parent dc45f5981b
commit 247b4967e5
20 changed files with 808 additions and 7 deletions

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createStatusCommand } from '../../src/commands/status.js';
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
let output: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
output = [];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('status command', () => {
it('shows status in table format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('mcpctl v');
expect(output.join('\n')).toContain('connected');
});
it('shows unreachable when daemon is down', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('unreachable');
});
it('shows status in JSON format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['version']).toBe('0.1.0');
expect(parsed['daemonReachable']).toBe(true);
});
it('shows status in YAML format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('daemonReachable: false');
});
it('uses custom daemon URL from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5555' }, { configDir: tempDir });
let checkedUrl = '';
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async (url) => {
checkedUrl = url;
return false;
},
});
await cmd.parseAsync([], { from: 'user' });
expect(checkedUrl).toBe('http://custom:5555');
});
it('shows registries from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('official');
expect(output.join('\n')).not.toContain('glama');
});
});