diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 3ff34e4..3f3b267 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c46283..52bd5ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/cli/package.json b/src/cli/package.json index f860d28..c99cd2c 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -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" } } diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts new file mode 100644 index 0000000..cd011f0 --- /dev/null +++ b/src/cli/src/commands/config.ts @@ -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; + log: (...args: string[]) => void; +} + +const defaultDeps: ConfigCommandDeps = { + configDeps: {}, + log: (...args) => console.log(...args), +}; + +export function createConfigCommand(deps?: Partial): 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 ', '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('', 'configuration key (e.g., daemonUrl, outputFormat)') + .argument('', 'value to set') + .action((key: string, value: string) => { + const updates: Record = {}; + + // 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, 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; +} diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts new file mode 100644 index 0000000..24324ec --- /dev/null +++ b/src/cli/src/commands/status.ts @@ -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; + log: (...args: string[]) => void; + checkDaemon: (url: string) => Promise; +} + +function defaultCheckDaemon(url: string): Promise { + 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): Command { + const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps }; + + return new Command('status') + .description('Show mcpctl status and connectivity') + .option('-o, --output ', '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}`); + } + }); +} diff --git a/src/cli/src/config/index.ts b/src/cli/src/config/index.ts new file mode 100644 index 0000000..8765cf8 --- /dev/null +++ b/src/cli/src/config/index.ts @@ -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'; diff --git a/src/cli/src/config/loader.ts b/src/cli/src/config/loader.ts new file mode 100644 index 0000000..fd79823 --- /dev/null +++ b/src/cli/src/config/loader.ts @@ -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): 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): 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, deps?: Partial): McpctlConfig { + const current = loadConfig(deps); + return McpctlConfigSchema.parse({ ...current, ...overrides }); +} diff --git a/src/cli/src/config/schema.ts b/src/cli/src/config/schema.ts new file mode 100644 index 0000000..5f4d78d --- /dev/null +++ b/src/cli/src/config/schema.ts @@ -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; + +export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({}); diff --git a/src/cli/src/formatters/index.ts b/src/cli/src/formatters/index.ts new file mode 100644 index 0000000..47e6f4a --- /dev/null +++ b/src/cli/src/formatters/index.ts @@ -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'; diff --git a/src/cli/src/formatters/output.ts b/src/cli/src/formatters/output.ts new file mode 100644 index 0000000..cc3e894 --- /dev/null +++ b/src/cli/src/formatters/output.ts @@ -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(); +} diff --git a/src/cli/src/formatters/table.ts b/src/cli/src/formatters/table.ts new file mode 100644 index 0000000..7a2b8d2 --- /dev/null +++ b/src/cli/src/formatters/table.ts @@ -0,0 +1,44 @@ +export interface Column { + header: string; + key: keyof T | ((row: T) => string); + width?: number; + align?: 'left' | 'right'; +} + +export function formatTable(rows: T[], columns: Column[]): string { + if (rows.length === 0) { + return 'No results found.'; + } + + const getValue = (row: T, col: Column): 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'); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 4c6ac3f..a8b2b4f 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -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 ', 'output format (table, json, yaml)', 'table') + .option('--daemon-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); +} diff --git a/src/cli/src/registry/types.ts b/src/cli/src/registry/types.ts index 952d371..613152b 100644 --- a/src/cli/src/registry/types.ts +++ b/src/cli/src/registry/types.ts @@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer; // ── 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, ''); diff --git a/src/cli/tests/cli.test.ts b/src/cli/tests/cli.test.ts new file mode 100644 index 0000000..2a82ff7 --- /dev/null +++ b/src/cli/tests/cli.test.ts @@ -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(); + }); +}); diff --git a/src/cli/tests/commands/config.test.ts b/src/cli/tests/commands/config.test.ts new file mode 100644 index 0000000..bc029c1 --- /dev/null +++ b/src/cli/tests/commands/config.test.ts @@ -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; + 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); + }); +}); diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts new file mode 100644 index 0000000..fefc48d --- /dev/null +++ b/src/cli/tests/commands/status.test.ts @@ -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; + 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'); + }); +}); diff --git a/src/cli/tests/config/loader.test.ts b/src/cli/tests/config/loader.test.ts new file mode 100644 index 0000000..ce091f5 --- /dev/null +++ b/src/cli/tests/config/loader.test.ts @@ -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'); + }); +}); diff --git a/src/cli/tests/config/schema.test.ts b/src/cli/tests/config/schema.test.ts new file mode 100644 index 0000000..785f48d --- /dev/null +++ b/src/cli/tests/config/schema.test.ts @@ -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({})); + }); +}); diff --git a/src/cli/tests/formatters/output.test.ts b/src/cli/tests/formatters/output.test.ts new file mode 100644 index 0000000..b3b09a2 --- /dev/null +++ b/src/cli/tests/formatters/output.test.ts @@ -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; + 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); + }); +}); diff --git a/src/cli/tests/formatters/table.test.ts b/src/cli/tests/formatters/table.test.ts new file mode 100644 index 0000000..67e0398 --- /dev/null +++ b/src/cli/tests/formatters/table.test.ts @@ -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[] = [ + { 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[] = [ + { 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[] = [ + { 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]+$/); + }); +}); diff --git a/src/cli/tsconfig.json b/src/cli/tsconfig.json index 1d1421c..be275fe 100644 --- a/src/cli/tsconfig.json +++ b/src/cli/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src/**/*.ts"], "references": [