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:
41
src/cli/tests/formatters/output.test.ts
Normal file
41
src/cli/tests/formatters/output.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatJson, formatYaml } from '../../src/formatters/output.js';
|
||||
|
||||
describe('formatJson', () => {
|
||||
it('formats object as indented JSON', () => {
|
||||
const result = formatJson({ key: 'value', num: 42 });
|
||||
expect(JSON.parse(result)).toEqual({ key: 'value', num: 42 });
|
||||
expect(result).toContain('\n'); // indented
|
||||
});
|
||||
|
||||
it('formats arrays', () => {
|
||||
const result = formatJson([1, 2, 3]);
|
||||
expect(JSON.parse(result)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('handles null and undefined values', () => {
|
||||
const result = formatJson({ a: null, b: undefined });
|
||||
const parsed = JSON.parse(result) as Record<string, unknown>;
|
||||
expect(parsed['a']).toBeNull();
|
||||
expect('b' in parsed).toBe(false); // undefined stripped by JSON
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatYaml', () => {
|
||||
it('formats object as YAML', () => {
|
||||
const result = formatYaml({ key: 'value', num: 42 });
|
||||
expect(result).toContain('key: value');
|
||||
expect(result).toContain('num: 42');
|
||||
});
|
||||
|
||||
it('formats arrays', () => {
|
||||
const result = formatYaml(['a', 'b']);
|
||||
expect(result).toContain('- a');
|
||||
expect(result).toContain('- b');
|
||||
});
|
||||
|
||||
it('does not end with trailing newline', () => {
|
||||
const result = formatYaml({ x: 1 });
|
||||
expect(result.endsWith('\n')).toBe(false);
|
||||
});
|
||||
});
|
||||
87
src/cli/tests/formatters/table.test.ts
Normal file
87
src/cli/tests/formatters/table.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatTable } from '../../src/formatters/table.js';
|
||||
import type { Column } from '../../src/formatters/table.js';
|
||||
|
||||
interface TestRow {
|
||||
name: string;
|
||||
age: number;
|
||||
city: string;
|
||||
}
|
||||
|
||||
const columns: Column<TestRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'AGE', key: 'age', align: 'right' },
|
||||
{ header: 'CITY', key: 'city' },
|
||||
];
|
||||
|
||||
describe('formatTable', () => {
|
||||
it('returns empty message for no rows', () => {
|
||||
expect(formatTable([], columns)).toBe('No results found.');
|
||||
});
|
||||
|
||||
it('formats a single row', () => {
|
||||
const rows = [{ name: 'Alice', age: 30, city: 'NYC' }];
|
||||
const result = formatTable(rows, columns);
|
||||
const lines = result.split('\n');
|
||||
expect(lines).toHaveLength(3); // header, separator, data
|
||||
expect(lines[0]).toContain('NAME');
|
||||
expect(lines[0]).toContain('AGE');
|
||||
expect(lines[0]).toContain('CITY');
|
||||
expect(lines[2]).toContain('Alice');
|
||||
expect(lines[2]).toContain('NYC');
|
||||
});
|
||||
|
||||
it('right-aligns numeric columns', () => {
|
||||
const rows = [{ name: 'Bob', age: 5, city: 'LA' }];
|
||||
const result = formatTable(rows, columns);
|
||||
const lines = result.split('\n');
|
||||
// AGE column should be right-aligned: " 5" or "5" padded
|
||||
const ageLine = lines[2];
|
||||
// The age value should have leading space(s) for right alignment
|
||||
expect(ageLine).toMatch(/\s+5/);
|
||||
});
|
||||
|
||||
it('auto-sizes columns to content', () => {
|
||||
const rows = [
|
||||
{ name: 'A', age: 1, city: 'X' },
|
||||
{ name: 'LongName', age: 100, city: 'LongCityName' },
|
||||
];
|
||||
const result = formatTable(rows, columns);
|
||||
const lines = result.split('\n');
|
||||
// Header should be at least as wide as longest data
|
||||
expect(lines[0]).toContain('NAME');
|
||||
expect(lines[2]).toContain('A');
|
||||
expect(lines[3]).toContain('LongName');
|
||||
expect(lines[3]).toContain('LongCityName');
|
||||
});
|
||||
|
||||
it('truncates long values when width is fixed', () => {
|
||||
const narrowCols: Column<TestRow>[] = [
|
||||
{ header: 'NAME', key: 'name', width: 5 },
|
||||
];
|
||||
const rows = [{ name: 'VeryLongName', age: 0, city: '' }];
|
||||
const result = formatTable(rows, narrowCols);
|
||||
const lines = result.split('\n');
|
||||
// Should be truncated with ellipsis
|
||||
expect(lines[2].trim().length).toBeLessThanOrEqual(5);
|
||||
expect(lines[2]).toContain('\u2026');
|
||||
});
|
||||
|
||||
it('supports function-based column keys', () => {
|
||||
const fnCols: Column<TestRow>[] = [
|
||||
{ header: 'INFO', key: (row) => `${row.name} (${row.age})` },
|
||||
];
|
||||
const rows = [{ name: 'Eve', age: 25, city: 'SF' }];
|
||||
const result = formatTable(rows, fnCols);
|
||||
expect(result).toContain('Eve (25)');
|
||||
});
|
||||
|
||||
it('handles separator line matching column widths', () => {
|
||||
const rows = [{ name: 'Test', age: 1, city: 'Here' }];
|
||||
const result = formatTable(rows, columns);
|
||||
const lines = result.split('\n');
|
||||
const separator = lines[1];
|
||||
// Separator should consist of dashes and spaces
|
||||
expect(separator).toMatch(/^[-\s]+$/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user