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:
@@ -475,7 +475,7 @@
|
||||
"dependencies": [
|
||||
"1"
|
||||
],
|
||||
"status": "pending",
|
||||
"status": "done",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -499,7 +499,8 @@
|
||||
"testStrategy": "TDD tests for config loading, saving, validation, and credential encryption.",
|
||||
"parentId": "undefined"
|
||||
}
|
||||
]
|
||||
],
|
||||
"updatedAt": "2026-02-21T04:17:17.744Z"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
@@ -729,9 +730,9 @@
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-02-21T04:10:25.433Z",
|
||||
"lastModified": "2026-02-21T04:17:17.744Z",
|
||||
"taskCount": 24,
|
||||
"completedCount": 2,
|
||||
"completedCount": 3,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
182
pnpm-lock.yaml
generated
182
pnpm-lock.yaml
generated
@@ -16,7 +16,7 @@ importers:
|
||||
version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0))
|
||||
version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))
|
||||
eslint:
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1(jiti@2.6.1)
|
||||
@@ -34,7 +34,7 @@ importers:
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(jiti@2.6.1)(tsx@4.21.0)
|
||||
version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||
|
||||
src/cli:
|
||||
dependencies:
|
||||
@@ -52,10 +52,20 @@ importers:
|
||||
version: 13.1.0
|
||||
inquirer:
|
||||
specifier: ^12.0.0
|
||||
version: 12.11.1
|
||||
version: 12.11.1(@types/node@25.3.0)
|
||||
js-yaml:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.1
|
||||
zod:
|
||||
specifier: ^3.24.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/js-yaml':
|
||||
specifier: ^4.0.9
|
||||
version: 4.0.9
|
||||
'@types/node':
|
||||
specifier: ^25.3.0
|
||||
version: 25.3.0
|
||||
|
||||
src/db:
|
||||
dependencies:
|
||||
@@ -698,9 +708,15 @@ packages:
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/node@25.3.0':
|
||||
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.56.0':
|
||||
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1795,6 +1811,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
unpipe@1.0.0:
|
||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -2098,100 +2117,128 @@ snapshots:
|
||||
|
||||
'@inquirer/ansi@1.0.2': {}
|
||||
|
||||
'@inquirer/checkbox@4.3.2':
|
||||
'@inquirer/checkbox@4.3.2(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 1.0.2
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/figures': 1.0.15
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
yoctocolors-cjs: 2.1.3
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/confirm@5.1.21':
|
||||
'@inquirer/confirm@5.1.21(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/core@10.3.2':
|
||||
'@inquirer/core@10.3.2(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 1.0.2
|
||||
'@inquirer/figures': 1.0.15
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
cli-width: 4.1.0
|
||||
mute-stream: 2.0.0
|
||||
signal-exit: 4.1.0
|
||||
wrap-ansi: 6.2.0
|
||||
yoctocolors-cjs: 2.1.3
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/editor@4.2.23':
|
||||
'@inquirer/editor@4.2.23(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/external-editor': 1.0.3
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/external-editor': 1.0.3(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/expand@4.0.23':
|
||||
'@inquirer/expand@4.0.23(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
yoctocolors-cjs: 2.1.3
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/external-editor@1.0.3':
|
||||
'@inquirer/external-editor@1.0.3(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
chardet: 2.1.1
|
||||
iconv-lite: 0.7.2
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/figures@1.0.15': {}
|
||||
|
||||
'@inquirer/input@4.3.1':
|
||||
'@inquirer/input@4.3.1(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/number@3.0.23':
|
||||
'@inquirer/number@3.0.23(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/password@4.0.23':
|
||||
'@inquirer/password@4.0.23(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 1.0.2
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/prompts@7.10.1':
|
||||
'@inquirer/prompts@7.10.1(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/checkbox': 4.3.2
|
||||
'@inquirer/confirm': 5.1.21
|
||||
'@inquirer/editor': 4.2.23
|
||||
'@inquirer/expand': 4.0.23
|
||||
'@inquirer/input': 4.3.1
|
||||
'@inquirer/number': 3.0.23
|
||||
'@inquirer/password': 4.0.23
|
||||
'@inquirer/rawlist': 4.1.11
|
||||
'@inquirer/search': 3.2.2
|
||||
'@inquirer/select': 4.4.2
|
||||
'@inquirer/checkbox': 4.3.2(@types/node@25.3.0)
|
||||
'@inquirer/confirm': 5.1.21(@types/node@25.3.0)
|
||||
'@inquirer/editor': 4.2.23(@types/node@25.3.0)
|
||||
'@inquirer/expand': 4.0.23(@types/node@25.3.0)
|
||||
'@inquirer/input': 4.3.1(@types/node@25.3.0)
|
||||
'@inquirer/number': 3.0.23(@types/node@25.3.0)
|
||||
'@inquirer/password': 4.0.23(@types/node@25.3.0)
|
||||
'@inquirer/rawlist': 4.1.11(@types/node@25.3.0)
|
||||
'@inquirer/search': 3.2.2(@types/node@25.3.0)
|
||||
'@inquirer/select': 4.4.2(@types/node@25.3.0)
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/rawlist@4.1.11':
|
||||
'@inquirer/rawlist@4.1.11(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
yoctocolors-cjs: 2.1.3
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/search@3.2.2':
|
||||
'@inquirer/search@3.2.2(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/figures': 1.0.15
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
yoctocolors-cjs: 2.1.3
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/select@4.4.2':
|
||||
'@inquirer/select@4.4.2(@types/node@25.3.0)':
|
||||
dependencies:
|
||||
'@inquirer/ansi': 1.0.2
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/figures': 1.0.15
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
yoctocolors-cjs: 2.1.3
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@inquirer/type@3.0.10': {}
|
||||
'@inquirer/type@3.0.10(@types/node@25.3.0)':
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2': {}
|
||||
|
||||
@@ -2351,8 +2398,14 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/node@25.3.0':
|
||||
dependencies:
|
||||
undici-types: 7.18.2
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -2444,7 +2497,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.56.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0))':
|
||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
@@ -2456,7 +2509,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.18(jiti@2.6.1)(tsx@4.21.0)
|
||||
vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
dependencies:
|
||||
@@ -2467,13 +2520,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))':
|
||||
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0)
|
||||
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
dependencies:
|
||||
@@ -3033,15 +3086,17 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
inquirer@12.11.1:
|
||||
inquirer@12.11.1(@types/node@25.3.0):
|
||||
dependencies:
|
||||
'@inquirer/ansi': 1.0.2
|
||||
'@inquirer/core': 10.3.2
|
||||
'@inquirer/prompts': 7.10.1
|
||||
'@inquirer/type': 3.0.10
|
||||
'@inquirer/core': 10.3.2(@types/node@25.3.0)
|
||||
'@inquirer/prompts': 7.10.1(@types/node@25.3.0)
|
||||
'@inquirer/type': 3.0.10(@types/node@25.3.0)
|
||||
mute-stream: 2.0.0
|
||||
run-async: 4.0.6
|
||||
rxjs: 7.8.2
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
ip-address@10.0.1: {}
|
||||
|
||||
@@ -3532,6 +3587,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
uri-js@4.4.1:
|
||||
@@ -3540,7 +3597,7 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@7.3.1(jiti@2.6.1)(tsx@4.21.0):
|
||||
vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -3549,14 +3606,15 @@ snapshots:
|
||||
rollup: 4.58.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
tsx: 4.21.0
|
||||
|
||||
vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0):
|
||||
vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))
|
||||
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
@@ -3573,8 +3631,10 @@ snapshots:
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0)
|
||||
vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
|
||||
@@ -16,11 +16,16 @@
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^13.0.0",
|
||||
"@mcpctl/db": "workspace:*",
|
||||
"@mcpctl/shared": "workspace:*",
|
||||
"chalk": "^5.4.0",
|
||||
"commander": "^13.0.0",
|
||||
"inquirer": "^12.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"@mcpctl/shared": "workspace:*",
|
||||
"@mcpctl/db": "workspace:*"
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
69
src/cli/src/commands/config.ts
Normal file
69
src/cli/src/commands/config.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Command } from 'commander';
|
||||
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
|
||||
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
|
||||
import { formatJson, formatYaml } from '../formatters/index.js';
|
||||
|
||||
export interface ConfigCommandDeps {
|
||||
configDeps: Partial<ConfigLoaderDeps>;
|
||||
log: (...args: string[]) => void;
|
||||
}
|
||||
|
||||
const defaultDeps: ConfigCommandDeps = {
|
||||
configDeps: {},
|
||||
log: (...args) => console.log(...args),
|
||||
};
|
||||
|
||||
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command {
|
||||
const { configDeps, log } = { ...defaultDeps, ...deps };
|
||||
|
||||
const config = new Command('config').description('Manage mcpctl configuration');
|
||||
|
||||
config
|
||||
.command('view')
|
||||
.description('Show current configuration')
|
||||
.option('-o, --output <format>', 'output format (json, yaml)', 'json')
|
||||
.action((opts: { output: string }) => {
|
||||
const cfg = loadConfig(configDeps);
|
||||
const out = opts.output === 'yaml' ? formatYaml(cfg) : formatJson(cfg);
|
||||
log(out);
|
||||
});
|
||||
|
||||
config
|
||||
.command('set')
|
||||
.description('Set a configuration value')
|
||||
.argument('<key>', 'configuration key (e.g., daemonUrl, outputFormat)')
|
||||
.argument('<value>', 'value to set')
|
||||
.action((key: string, value: string) => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
// Handle typed conversions
|
||||
if (key === 'cacheTTLMs') {
|
||||
updates[key] = parseInt(value, 10);
|
||||
} else if (key === 'registries') {
|
||||
updates[key] = value.split(',').map((s) => s.trim());
|
||||
} else {
|
||||
updates[key] = value;
|
||||
}
|
||||
|
||||
const updated = mergeConfig(updates as Partial<McpctlConfig>, configDeps);
|
||||
saveConfig(updated, configDeps);
|
||||
log(`Set ${key} = ${value}`);
|
||||
});
|
||||
|
||||
config
|
||||
.command('path')
|
||||
.description('Show configuration file path')
|
||||
.action(() => {
|
||||
log(getConfigPath(configDeps?.configDir));
|
||||
});
|
||||
|
||||
config
|
||||
.command('reset')
|
||||
.description('Reset configuration to defaults')
|
||||
.action(() => {
|
||||
saveConfig(DEFAULT_CONFIG, configDeps);
|
||||
log('Configuration reset to defaults');
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
63
src/cli/src/commands/status.ts
Normal file
63
src/cli/src/commands/status.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Command } from 'commander';
|
||||
import http from 'node:http';
|
||||
import { loadConfig } from '../config/index.js';
|
||||
import type { ConfigLoaderDeps } from '../config/index.js';
|
||||
import { formatJson, formatYaml } from '../formatters/index.js';
|
||||
import { APP_VERSION } from '@mcpctl/shared';
|
||||
|
||||
export interface StatusCommandDeps {
|
||||
configDeps: Partial<ConfigLoaderDeps>;
|
||||
log: (...args: string[]) => void;
|
||||
checkDaemon: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function defaultCheckDaemon(url: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`${url}/health`, { timeout: 3000 }, (res) => {
|
||||
resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400);
|
||||
res.resume();
|
||||
});
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const defaultDeps: StatusCommandDeps = {
|
||||
configDeps: {},
|
||||
log: (...args) => console.log(...args),
|
||||
checkDaemon: defaultCheckDaemon,
|
||||
};
|
||||
|
||||
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
|
||||
const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps };
|
||||
|
||||
return new Command('status')
|
||||
.description('Show mcpctl status and connectivity')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.action(async (opts: { output: string }) => {
|
||||
const config = loadConfig(configDeps);
|
||||
const daemonReachable = await checkDaemon(config.daemonUrl);
|
||||
|
||||
const status = {
|
||||
version: APP_VERSION,
|
||||
daemonUrl: config.daemonUrl,
|
||||
daemonReachable,
|
||||
registries: config.registries,
|
||||
outputFormat: config.outputFormat,
|
||||
};
|
||||
|
||||
if (opts.output === 'json') {
|
||||
log(formatJson(status));
|
||||
} else if (opts.output === 'yaml') {
|
||||
log(formatYaml(status));
|
||||
} else {
|
||||
log(`mcpctl v${status.version}`);
|
||||
log(`Daemon: ${status.daemonUrl} (${daemonReachable ? 'connected' : 'unreachable'})`);
|
||||
log(`Registries: ${status.registries.join(', ')}`);
|
||||
log(`Output: ${status.outputFormat}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
4
src/cli/src/config/index.ts
Normal file
4
src/cli/src/config/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
|
||||
export type { McpctlConfig } from './schema.js';
|
||||
export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js';
|
||||
export type { ConfigLoaderDeps } from './loader.js';
|
||||
45
src/cli/src/config/loader.ts
Normal file
45
src/cli/src/config/loader.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
|
||||
import type { McpctlConfig } from './schema.js';
|
||||
|
||||
export interface ConfigLoaderDeps {
|
||||
configDir: string;
|
||||
}
|
||||
|
||||
function defaultConfigDir(): string {
|
||||
return join(homedir(), '.mcpctl');
|
||||
}
|
||||
|
||||
export function getConfigPath(configDir?: string): string {
|
||||
return join(configDir ?? defaultConfigDir(), 'config.json');
|
||||
}
|
||||
|
||||
export function loadConfig(deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
|
||||
const configPath = getConfigPath(deps?.configDir);
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return McpctlConfigSchema.parse(parsed);
|
||||
}
|
||||
|
||||
export function saveConfig(config: McpctlConfig, deps?: Partial<ConfigLoaderDeps>): void {
|
||||
const dir = deps?.configDir ?? defaultConfigDir();
|
||||
const configPath = getConfigPath(dir);
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
export function mergeConfig(overrides: Partial<McpctlConfig>, deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
|
||||
const current = loadConfig(deps);
|
||||
return McpctlConfigSchema.parse({ ...current, ...overrides });
|
||||
}
|
||||
22
src/cli/src/config/schema.ts
Normal file
22
src/cli/src/config/schema.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const McpctlConfigSchema = z.object({
|
||||
/** mcpd daemon endpoint */
|
||||
daemonUrl: z.string().default('http://localhost:3000'),
|
||||
/** Active registries for search */
|
||||
registries: z.array(z.enum(['official', 'glama', 'smithery'])).default(['official', 'glama', 'smithery']),
|
||||
/** Cache TTL in milliseconds */
|
||||
cacheTTLMs: z.number().int().positive().default(3_600_000),
|
||||
/** HTTP proxy URL */
|
||||
httpProxy: z.string().optional(),
|
||||
/** HTTPS proxy URL */
|
||||
httpsProxy: z.string().optional(),
|
||||
/** Default output format */
|
||||
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
|
||||
/** Smithery API key */
|
||||
smitheryApiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type McpctlConfig = z.infer<typeof McpctlConfigSchema>;
|
||||
|
||||
export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({});
|
||||
4
src/cli/src/formatters/index.ts
Normal file
4
src/cli/src/formatters/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { formatTable } from './table.js';
|
||||
export type { Column } from './table.js';
|
||||
export { formatJson, formatYaml } from './output.js';
|
||||
export type { OutputFormat } from './output.js';
|
||||
11
src/cli/src/formatters/output.ts
Normal file
11
src/cli/src/formatters/output.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
export type OutputFormat = 'table' | 'json' | 'yaml';
|
||||
|
||||
export function formatJson(data: unknown): string {
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function formatYaml(data: unknown): string {
|
||||
return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd();
|
||||
}
|
||||
44
src/cli/src/formatters/table.ts
Normal file
44
src/cli/src/formatters/table.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export interface Column<T> {
|
||||
header: string;
|
||||
key: keyof T | ((row: T) => string);
|
||||
width?: number;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function formatTable<T>(rows: T[], columns: Column<T>[]): string {
|
||||
if (rows.length === 0) {
|
||||
return 'No results found.';
|
||||
}
|
||||
|
||||
const getValue = (row: T, col: Column<T>): string => {
|
||||
if (typeof col.key === 'function') {
|
||||
return col.key(row);
|
||||
}
|
||||
const val = row[col.key];
|
||||
return val == null ? '' : String(val);
|
||||
};
|
||||
|
||||
// Calculate column widths
|
||||
const widths = columns.map((col) => {
|
||||
if (col.width !== undefined) return col.width;
|
||||
const headerLen = col.header.length;
|
||||
const maxDataLen = rows.reduce((max, row) => {
|
||||
const val = getValue(row, col);
|
||||
return Math.max(max, val.length);
|
||||
}, 0);
|
||||
return Math.max(headerLen, maxDataLen);
|
||||
});
|
||||
|
||||
const pad = (text: string, width: number, align: 'left' | 'right' = 'left'): string => {
|
||||
const truncated = text.length > width ? text.slice(0, width - 1) + '\u2026' : text;
|
||||
return align === 'right' ? truncated.padStart(width) : truncated.padEnd(width);
|
||||
};
|
||||
|
||||
const headerLine = columns.map((col, i) => pad(col.header, widths[i] ?? 0, col.align ?? 'left')).join(' ');
|
||||
const separator = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map((row) =>
|
||||
columns.map((col, i) => pad(getValue(row, col), widths[i] ?? 0, col.align ?? 'left')).join(' '),
|
||||
);
|
||||
|
||||
return [headerLine, separator, ...dataLines].join('\n');
|
||||
}
|
||||
@@ -1,2 +1,29 @@
|
||||
// mcpctl CLI entry point
|
||||
// Will be implemented in Task 7
|
||||
#!/usr/bin/env node
|
||||
import { Command } from 'commander';
|
||||
import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
|
||||
import { createConfigCommand } from './commands/config.js';
|
||||
import { createStatusCommand } from './commands/status.js';
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command()
|
||||
.name(APP_NAME)
|
||||
.description('Manage MCP servers like kubectl manages containers')
|
||||
.version(APP_VERSION, '-v, --version')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.option('--daemon-url <url>', 'mcpd daemon URL');
|
||||
|
||||
program.addCommand(createConfigCommand());
|
||||
program.addCommand(createStatusCommand());
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
// Run when invoked directly
|
||||
const isDirectRun =
|
||||
typeof process !== 'undefined' &&
|
||||
process.argv[1] !== undefined &&
|
||||
import.meta.url === `file://${process.argv[1]}`;
|
||||
|
||||
if (isDirectRun) {
|
||||
createProgram().parseAsync(process.argv);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer<typeof SmitheryServerSchema>;
|
||||
|
||||
// ── Security utilities ──
|
||||
|
||||
const ANSI_ESCAPE_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]|\x1b\[[0-9;]*[a-zA-Z]|\033\[[0-9;]*[a-zA-Z]/g;
|
||||
const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
|
||||
|
||||
export function sanitizeString(text: string): string {
|
||||
return text.replace(ANSI_ESCAPE_RE, '');
|
||||
|
||||
38
src/cli/tests/cli.test.ts
Normal file
38
src/cli/tests/cli.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createProgram } from '../src/index.js';
|
||||
|
||||
describe('createProgram', () => {
|
||||
it('creates a Commander program', () => {
|
||||
const program = createProgram();
|
||||
expect(program.name()).toBe('mcpctl');
|
||||
});
|
||||
|
||||
it('has version flag', () => {
|
||||
const program = createProgram();
|
||||
expect(program.version()).toBe('0.1.0');
|
||||
});
|
||||
|
||||
it('has config subcommand', () => {
|
||||
const program = createProgram();
|
||||
const config = program.commands.find((c) => c.name() === 'config');
|
||||
expect(config).toBeDefined();
|
||||
});
|
||||
|
||||
it('has status subcommand', () => {
|
||||
const program = createProgram();
|
||||
const status = program.commands.find((c) => c.name() === 'status');
|
||||
expect(status).toBeDefined();
|
||||
});
|
||||
|
||||
it('has output option', () => {
|
||||
const program = createProgram();
|
||||
const opt = program.options.find((o) => o.long === '--output');
|
||||
expect(opt).toBeDefined();
|
||||
});
|
||||
|
||||
it('has daemon-url option', () => {
|
||||
const program = createProgram();
|
||||
const opt = program.options.find((o) => o.long === '--daemon-url');
|
||||
expect(opt).toBeDefined();
|
||||
});
|
||||
});
|
||||
99
src/cli/tests/commands/config.test.ts
Normal file
99
src/cli/tests/commands/config.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
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 { createConfigCommand } from '../../src/commands/config.js';
|
||||
import { loadConfig, 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-config-test-'));
|
||||
output = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeCommand() {
|
||||
return createConfigCommand({
|
||||
configDeps: { configDir: tempDir },
|
||||
log,
|
||||
});
|
||||
}
|
||||
|
||||
describe('config view', () => {
|
||||
it('outputs default config as JSON', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['view'], { from: 'user' });
|
||||
expect(output).toHaveLength(1);
|
||||
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
|
||||
expect(parsed['daemonUrl']).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('outputs config as YAML with --output yaml', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['view', '-o', 'yaml'], { from: 'user' });
|
||||
expect(output[0]).toContain('daemonUrl:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config set', () => {
|
||||
it('sets a string value', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['set', 'daemonUrl', 'http://new:9000'], { from: 'user' });
|
||||
expect(output[0]).toContain('daemonUrl');
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.daemonUrl).toBe('http://new:9000');
|
||||
});
|
||||
|
||||
it('sets cacheTTLMs as integer', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['set', 'cacheTTLMs', '60000'], { from: 'user' });
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.cacheTTLMs).toBe(60000);
|
||||
});
|
||||
|
||||
it('sets registries as comma-separated list', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['set', 'registries', 'official,glama'], { from: 'user' });
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.registries).toEqual(['official', 'glama']);
|
||||
});
|
||||
|
||||
it('sets outputFormat', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['set', 'outputFormat', 'json'], { from: 'user' });
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.outputFormat).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config path', () => {
|
||||
it('shows config file path', async () => {
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['path'], { from: 'user' });
|
||||
expect(output[0]).toContain(tempDir);
|
||||
expect(output[0]).toContain('config.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config reset', () => {
|
||||
it('resets to defaults', async () => {
|
||||
// First set a custom value
|
||||
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom' }, { configDir: tempDir });
|
||||
|
||||
const cmd = makeCommand();
|
||||
await cmd.parseAsync(['reset'], { from: 'user' });
|
||||
expect(output[0]).toContain('reset');
|
||||
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.daemonUrl).toBe(DEFAULT_CONFIG.daemonUrl);
|
||||
});
|
||||
});
|
||||
94
src/cli/tests/commands/status.test.ts
Normal file
94
src/cli/tests/commands/status.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
83
src/cli/tests/config/loader.test.ts
Normal file
83
src/cli/tests/config/loader.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../../src/config/index.js';
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('getConfigPath', () => {
|
||||
it('returns path within config dir', () => {
|
||||
const path = getConfigPath('/tmp/mcpctl');
|
||||
expect(path).toBe('/tmp/mcpctl/config.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('returns defaults when no config file exists', () => {
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it('loads config from file', () => {
|
||||
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5000' }, { configDir: tempDir });
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.daemonUrl).toBe('http://custom:5000');
|
||||
});
|
||||
|
||||
it('applies defaults for missing fields', () => {
|
||||
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
|
||||
writeFileSync(join(tempDir, 'config.json'), '{"daemonUrl":"http://x:1"}');
|
||||
const config = loadConfig({ configDir: tempDir });
|
||||
expect(config.daemonUrl).toBe('http://x:1');
|
||||
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveConfig', () => {
|
||||
it('creates config file', () => {
|
||||
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
|
||||
expect(existsSync(join(tempDir, 'config.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('creates config directory if missing', () => {
|
||||
const nested = join(tempDir, 'nested', 'dir');
|
||||
saveConfig(DEFAULT_CONFIG, { configDir: nested });
|
||||
expect(existsSync(join(nested, 'config.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips configuration', () => {
|
||||
const custom = {
|
||||
...DEFAULT_CONFIG,
|
||||
daemonUrl: 'http://custom:9000',
|
||||
registries: ['official' as const],
|
||||
outputFormat: 'json' as const,
|
||||
};
|
||||
saveConfig(custom, { configDir: tempDir });
|
||||
const loaded = loadConfig({ configDir: tempDir });
|
||||
expect(loaded).toEqual(custom);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeConfig', () => {
|
||||
it('merges overrides into existing config', () => {
|
||||
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
|
||||
const merged = mergeConfig({ daemonUrl: 'http://new:1234' }, { configDir: tempDir });
|
||||
expect(merged.daemonUrl).toBe('http://new:1234');
|
||||
expect(merged.registries).toEqual(DEFAULT_CONFIG.registries);
|
||||
});
|
||||
|
||||
it('works when no config file exists', () => {
|
||||
const merged = mergeConfig({ outputFormat: 'yaml' }, { configDir: tempDir });
|
||||
expect(merged.outputFormat).toBe('yaml');
|
||||
expect(merged.daemonUrl).toBe('http://localhost:3000');
|
||||
});
|
||||
});
|
||||
52
src/cli/tests/config/schema.test.ts
Normal file
52
src/cli/tests/config/schema.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { McpctlConfigSchema, DEFAULT_CONFIG } from '../../src/config/schema.js';
|
||||
|
||||
describe('McpctlConfigSchema', () => {
|
||||
it('provides sensible defaults from empty object', () => {
|
||||
const config = McpctlConfigSchema.parse({});
|
||||
expect(config.daemonUrl).toBe('http://localhost:3000');
|
||||
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
|
||||
expect(config.cacheTTLMs).toBe(3_600_000);
|
||||
expect(config.outputFormat).toBe('table');
|
||||
expect(config.httpProxy).toBeUndefined();
|
||||
expect(config.httpsProxy).toBeUndefined();
|
||||
expect(config.smitheryApiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates a full config', () => {
|
||||
const config = McpctlConfigSchema.parse({
|
||||
daemonUrl: 'http://custom:4000',
|
||||
registries: ['official'],
|
||||
cacheTTLMs: 60_000,
|
||||
httpProxy: 'http://proxy:8080',
|
||||
httpsProxy: 'http://proxy:8443',
|
||||
outputFormat: 'json',
|
||||
smitheryApiKey: 'sk-test',
|
||||
});
|
||||
expect(config.daemonUrl).toBe('http://custom:4000');
|
||||
expect(config.registries).toEqual(['official']);
|
||||
expect(config.outputFormat).toBe('json');
|
||||
});
|
||||
|
||||
it('rejects invalid registry names', () => {
|
||||
expect(() => McpctlConfigSchema.parse({ registries: ['invalid'] })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid output format', () => {
|
||||
expect(() => McpctlConfigSchema.parse({ outputFormat: 'xml' })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects negative cacheTTLMs', () => {
|
||||
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-integer cacheTTLMs', () => {
|
||||
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: 1.5 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_CONFIG', () => {
|
||||
it('matches schema defaults', () => {
|
||||
expect(DEFAULT_CONFIG).toEqual(McpctlConfigSchema.parse({}));
|
||||
});
|
||||
});
|
||||
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]+$/);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
|
||||
Reference in New Issue
Block a user