@boolesai/tspec-cli 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { Command } from "commander";
2
+ import { existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { isAbsolute, resolve as resolve$1, basename, relative, dirname, join } from "path";
2
5
  import ora from "ora";
3
- import { getTypeFromFilePath, validateTestCase, clearTemplateCache, parseTestCases, scheduler, registry as registry$1 } from "@boolesai/tspec";
6
+ import { getTypeFromFilePath, isSuiteFile, getSuiteProtocolType, validateTestCase, clearTemplateCache, getPluginManager, version as version$1, registry as registry$1, executeSuite, parseTestCases, scheduler, PluginManager } from "@boolesai/tspec";
4
7
  import { glob } from "glob";
5
- import { isAbsolute, resolve as resolve$1, basename, relative } from "path";
6
- import { existsSync, statSync } from "fs";
7
8
  import chalk from "chalk";
9
+ import { findConfigFile, findLocalConfigFile, findGlobalConfigFile, PLUGINS_DIR, isPluginInstalled, installPlugin, GLOBAL_CONFIG_PATH } from "@boolesai/tspec/plugin";
8
10
  import process$2 from "node:process";
9
11
  async function discoverTSpecFiles(patterns, cwd) {
10
12
  const workingDir = process.cwd();
@@ -15,14 +17,14 @@ async function discoverTSpecFiles(patterns, cwd) {
15
17
  if (existsSync(absolutePath)) {
16
18
  const stat = statSync(absolutePath);
17
19
  if (stat.isFile()) {
18
- if (absolutePath.endsWith(".tspec")) {
20
+ if (absolutePath.endsWith(".tcase")) {
19
21
  filePaths.push(absolutePath);
20
22
  }
21
23
  continue;
22
24
  } else if (stat.isDirectory()) {
23
- const dirFiles = await glob("**/*.tspec", { cwd: absolutePath, absolute: true });
25
+ const dirFiles = await glob("**/*.tcase", { cwd: absolutePath, absolute: true });
24
26
  if (dirFiles.length === 0) {
25
- errors2.push(`No .tspec files found in directory: ${pattern2}`);
27
+ errors2.push(`No .tcase files found in directory: ${pattern2}`);
26
28
  } else {
27
29
  filePaths.push(...dirFiles);
28
30
  }
@@ -30,9 +32,9 @@ async function discoverTSpecFiles(patterns, cwd) {
30
32
  }
31
33
  }
32
34
  const matches = await glob(pattern2, { cwd: workingDir, absolute: true });
33
- const tspecMatches = matches.filter((f) => f.endsWith(".tspec"));
35
+ const tspecMatches = matches.filter((f) => f.endsWith(".tcase"));
34
36
  if (tspecMatches.length === 0) {
35
- errors2.push(`No .tspec files matched pattern: ${pattern2}`);
37
+ errors2.push(`No .tcase files matched pattern: ${pattern2}`);
36
38
  } else {
37
39
  filePaths.push(...tspecMatches);
38
40
  }
@@ -46,6 +48,61 @@ async function discoverTSpecFiles(patterns, cwd) {
46
48
  }));
47
49
  return { files, errors: errors2 };
48
50
  }
51
+ async function discoverAllTestFiles(patterns, cwd) {
52
+ const workingDir = process.cwd();
53
+ const tspecPaths = [];
54
+ const suitePaths = [];
55
+ const errors2 = [];
56
+ for (const pattern2 of patterns) {
57
+ const absolutePath = isAbsolute(pattern2) ? pattern2 : resolve$1(workingDir, pattern2);
58
+ if (existsSync(absolutePath)) {
59
+ const stat = statSync(absolutePath);
60
+ if (stat.isFile()) {
61
+ if (absolutePath.endsWith(".tcase")) {
62
+ tspecPaths.push(absolutePath);
63
+ } else if (isSuiteFile(absolutePath)) {
64
+ suitePaths.push(absolutePath);
65
+ }
66
+ continue;
67
+ } else if (stat.isDirectory()) {
68
+ const tspecFiles2 = await glob("**/*.tcase", { cwd: absolutePath, absolute: true });
69
+ const suiteFiles2 = await glob("**/*.tsuite", { cwd: absolutePath, absolute: true });
70
+ if (tspecFiles2.length === 0 && suiteFiles2.length === 0) {
71
+ errors2.push(`No .tcase or .tsuite files found in directory: ${pattern2}`);
72
+ } else {
73
+ tspecPaths.push(...tspecFiles2);
74
+ suitePaths.push(...suiteFiles2);
75
+ }
76
+ continue;
77
+ }
78
+ }
79
+ const matches = await glob(pattern2, { cwd: workingDir, absolute: true });
80
+ const tspecMatches = matches.filter((f) => f.endsWith(".tcase"));
81
+ const suiteMatches = matches.filter((f) => isSuiteFile(f));
82
+ if (tspecMatches.length === 0 && suiteMatches.length === 0) {
83
+ errors2.push(`No .tcase or .tsuite files matched pattern: ${pattern2}`);
84
+ } else {
85
+ tspecPaths.push(...tspecMatches);
86
+ suitePaths.push(...suiteMatches);
87
+ }
88
+ }
89
+ const uniqueTspecPaths = [...new Set(tspecPaths)];
90
+ const uniqueSuitePaths = [...new Set(suitePaths)];
91
+ const tspecFiles = uniqueTspecPaths.map((filePath) => ({
92
+ path: filePath,
93
+ relativePath: relative(workingDir, filePath),
94
+ fileName: basename(filePath),
95
+ protocol: getTypeFromFilePath(filePath)
96
+ }));
97
+ const suiteFiles = uniqueSuitePaths.map((filePath) => ({
98
+ path: filePath,
99
+ relativePath: relative(workingDir, filePath),
100
+ fileName: basename(filePath),
101
+ protocol: getSuiteProtocolType(filePath),
102
+ isTemplate: filePath.endsWith(".tsuite.yaml")
103
+ }));
104
+ return { tspecFiles, suiteFiles, errors: errors2 };
105
+ }
49
106
  function formatJson(data) {
50
107
  return JSON.stringify(data, null, 2);
51
108
  }
@@ -199,7 +256,7 @@ async function executeValidate(params) {
199
256
  if (fileDescriptors.length === 0) {
200
257
  return {
201
258
  success: false,
202
- output: "No .tspec files found",
259
+ output: "No .tcase files found",
203
260
  data: { results: [] }
204
261
  };
205
262
  }
@@ -222,7 +279,7 @@ async function executeValidate(params) {
222
279
  data: { results }
223
280
  };
224
281
  }
225
- const validateCommand = new Command("validate").description("Validate .tspec files for schema correctness").argument("<files...>", "Files or glob patterns to validate").option("-o, --output <format>", "Output format: json, text", "text").option("-q, --quiet", "Only output errors").action(async (files, options) => {
282
+ const validateCommand = new Command("validate").description("Validate .tcase files for schema correctness").argument("<files...>", "Files or glob patterns to validate").option("-o, --output <format>", "Output format: json, text", "text").option("-q, --quiet", "Only output errors").action(async (files, options) => {
226
283
  setLoggerOptions({ quiet: options.quiet });
227
284
  const spinner = options.quiet ? null : ora("Validating...").start();
228
285
  try {
@@ -297,6 +354,14 @@ async function runFileTestCasesInternal(descriptor, env, params, concurrency, fa
297
354
  }
298
355
  async function executeRun(params) {
299
356
  clearTemplateCache();
357
+ const configPath = params.config || findConfigFile();
358
+ if (configPath) {
359
+ const pluginManager = getPluginManager(version$1);
360
+ await pluginManager.initialize(configPath, {
361
+ skipAutoInstall: params.noAutoInstall
362
+ });
363
+ registry$1.enablePluginManager();
364
+ }
300
365
  const concurrency = params.concurrency ?? 5;
301
366
  const env = params.env ?? {};
302
367
  const paramValues = params.params ?? {};
@@ -304,11 +369,11 @@ async function executeRun(params) {
304
369
  const output = params.output ?? "text";
305
370
  const verbose = params.verbose ?? false;
306
371
  const quiet = params.quiet ?? false;
307
- const { files: fileDescriptors, errors: resolveErrors } = await discoverTSpecFiles(params.files);
308
- if (fileDescriptors.length === 0) {
372
+ const { tspecFiles, suiteFiles, errors: resolveErrors } = await discoverAllTestFiles(params.files);
373
+ if (tspecFiles.length === 0 && suiteFiles.length === 0) {
309
374
  return {
310
375
  success: false,
311
- output: "No .tspec files found",
376
+ output: "No .tcase or .tsuite files found",
312
377
  data: {
313
378
  results: [],
314
379
  summary: { total: 0, passed: 0, failed: 0, passRate: 0, duration: 0 },
@@ -316,6 +381,29 @@ async function executeRun(params) {
316
381
  }
317
382
  };
318
383
  }
384
+ if (suiteFiles.length > 0) {
385
+ return executeSuiteRun(suiteFiles, tspecFiles, {
386
+ env,
387
+ params: paramValues,
388
+ concurrency,
389
+ failFast,
390
+ output,
391
+ verbose,
392
+ quiet
393
+ });
394
+ }
395
+ return executeTspecRun(tspecFiles, {
396
+ env,
397
+ params: paramValues,
398
+ concurrency,
399
+ failFast,
400
+ output,
401
+ verbose,
402
+ quiet
403
+ });
404
+ }
405
+ async function executeTspecRun(fileDescriptors, options) {
406
+ const { env, params: paramValues, concurrency, failFast, output, verbose, quiet } = options;
319
407
  const allResults = [];
320
408
  const parseErrors = [];
321
409
  let totalTestCases = 0;
@@ -384,7 +472,117 @@ ${parseErrors.length} file(s) failed to parse:`);
384
472
  data: { results: formattedResults, summary, parseErrors }
385
473
  };
386
474
  }
387
- const runCommand = new Command("run").description("Execute test cases and report results").argument("<files...>", "Files or glob patterns to run").option("-o, --output <format>", "Output format: json, text", "text").option("-c, --concurrency <number>", "Max concurrent tests", "5").option("-e, --env <key=value>", "Environment variables", parseKeyValue$1, {}).option("-p, --params <key=value>", "Parameters", parseKeyValue$1, {}).option("-v, --verbose", "Verbose output").option("-q, --quiet", "Only output summary").option("--fail-fast", "Stop on first failure").action(async (files, options) => {
475
+ async function executeSuiteRun(suiteFiles, additionalTspecFiles, options) {
476
+ const { env, params: paramValues, failFast, output, verbose, quiet } = options;
477
+ const allResults = [];
478
+ const parseErrors = [];
479
+ let totalTests = 0;
480
+ let totalPassed = 0;
481
+ let totalFailed = 0;
482
+ let stopped = false;
483
+ for (const suiteDescriptor of suiteFiles) {
484
+ if (stopped) break;
485
+ if (suiteDescriptor.isTemplate) continue;
486
+ try {
487
+ const suiteResult = await executeSuite(suiteDescriptor.path, {
488
+ env,
489
+ params: paramValues,
490
+ onSuiteStart: (name) => {
491
+ if (!quiet) logger.log(chalk.blue(`
492
+ Suite: ${name}`));
493
+ },
494
+ onTestStart: (file) => {
495
+ if (verbose) logger.log(chalk.gray(` Running: ${file}`));
496
+ },
497
+ onTestComplete: (file, result) => {
498
+ const statusIcon = result.status === "passed" ? chalk.green("✓") : chalk.red("✗");
499
+ if (!quiet) logger.log(` ${statusIcon} ${result.name} (${result.duration}ms)`);
500
+ }
501
+ });
502
+ totalTests += suiteResult.stats.total;
503
+ totalPassed += suiteResult.stats.passed;
504
+ totalFailed += suiteResult.stats.failed + suiteResult.stats.error;
505
+ for (const testResult of suiteResult.tests) {
506
+ allResults.push({
507
+ testCaseId: testResult.name,
508
+ passed: testResult.status === "passed",
509
+ duration: testResult.duration,
510
+ assertions: (testResult.assertions || []).map((a) => ({
511
+ passed: a.passed,
512
+ type: a.type,
513
+ message: a.message || ""
514
+ }))
515
+ });
516
+ }
517
+ if (suiteResult.suites) {
518
+ for (const nestedSuite of suiteResult.suites) {
519
+ totalTests += nestedSuite.stats.total;
520
+ totalPassed += nestedSuite.stats.passed;
521
+ totalFailed += nestedSuite.stats.failed + nestedSuite.stats.error;
522
+ for (const testResult of nestedSuite.tests) {
523
+ allResults.push({
524
+ testCaseId: `${nestedSuite.name}/${testResult.name}`,
525
+ passed: testResult.status === "passed",
526
+ duration: testResult.duration,
527
+ assertions: (testResult.assertions || []).map((a) => ({
528
+ passed: a.passed,
529
+ type: a.type,
530
+ message: a.message || ""
531
+ }))
532
+ });
533
+ }
534
+ }
535
+ }
536
+ if (failFast && (suiteResult.status === "failed" || suiteResult.status === "error")) {
537
+ stopped = true;
538
+ }
539
+ } catch (err) {
540
+ parseErrors.push({
541
+ file: suiteDescriptor.path,
542
+ error: err instanceof Error ? err.message : String(err)
543
+ });
544
+ }
545
+ }
546
+ if (additionalTspecFiles.length > 0 && !stopped) {
547
+ const tspecResult = await executeTspecRun(additionalTspecFiles, options);
548
+ allResults.push(...tspecResult.data.results);
549
+ parseErrors.push(...tspecResult.data.parseErrors);
550
+ totalTests += tspecResult.data.summary.total;
551
+ totalPassed += tspecResult.data.summary.passed;
552
+ totalFailed += tspecResult.data.summary.failed;
553
+ }
554
+ const summary = {
555
+ total: totalTests,
556
+ passed: totalPassed,
557
+ failed: totalFailed,
558
+ passRate: totalTests > 0 ? totalPassed / totalTests * 100 : 0,
559
+ duration: 0
560
+ };
561
+ let outputStr;
562
+ if (output === "json") {
563
+ outputStr = formatJson({ results: allResults, summary, parseErrors });
564
+ } else {
565
+ const parts = [];
566
+ if (!quiet) {
567
+ parts.push("\n" + chalk.bold("Results:"));
568
+ parts.push(formatTestResults(allResults, summary, { format: output, verbose }));
569
+ } else {
570
+ parts.push(`${summary.passed}/${summary.total} tests passed (${summary.passRate.toFixed(1)}%)`);
571
+ }
572
+ if (parseErrors.length > 0) {
573
+ parts.push(`
574
+ ${parseErrors.length} file(s) failed to parse:`);
575
+ parseErrors.forEach(({ file, error: error2 }) => parts.push(` ${file}: ${error2}`));
576
+ }
577
+ outputStr = parts.join("\n");
578
+ }
579
+ return {
580
+ success: totalFailed === 0 && parseErrors.length === 0,
581
+ output: outputStr,
582
+ data: { results: allResults, summary, parseErrors }
583
+ };
584
+ }
585
+ const runCommand = new Command("run").description("Execute test cases and report results").argument("<files...>", "Files or glob patterns to run").option("-o, --output <format>", "Output format: json, text", "text").option("-c, --concurrency <number>", "Max concurrent tests", "5").option("-e, --env <key=value>", "Environment variables", parseKeyValue$1, {}).option("-p, --params <key=value>", "Parameters", parseKeyValue$1, {}).option("-v, --verbose", "Verbose output").option("-q, --quiet", "Only output summary").option("--fail-fast", "Stop on first failure").option("--config <path>", "Path to tspec.config.json for plugin loading").option("--no-auto-install", "Skip automatic plugin installation").action(async (files, options) => {
388
586
  setLoggerOptions({ verbose: options.verbose, quiet: options.quiet });
389
587
  const spinner = options.quiet ? null : ora("Running tests...").start();
390
588
  try {
@@ -395,6 +593,8 @@ const runCommand = new Command("run").description("Execute test cases and report
395
593
  verbose: options.verbose,
396
594
  quiet: options.quiet,
397
595
  failFast: options.failFast,
596
+ config: options.config,
597
+ noAutoInstall: options.noAutoInstall,
398
598
  env: options.env,
399
599
  params: options.params
400
600
  });
@@ -415,9 +615,20 @@ const runCommand = new Command("run").description("Execute test cases and report
415
615
  }
416
616
  process.exit(result.success ? 0 : 1);
417
617
  } catch (err) {
418
- spinner?.fail("Execution failed");
618
+ spinner?.stop();
419
619
  const message = err instanceof Error ? err.message : String(err);
420
- logger.error(message);
620
+ if (options.output === "json") {
621
+ const errorOutput = formatJson({
622
+ results: [],
623
+ summary: { total: 0, passed: 0, failed: 0, passRate: 0, duration: 0 },
624
+ parseErrors: [],
625
+ error: message
626
+ });
627
+ logger.log(errorOutput);
628
+ } else {
629
+ spinner?.fail("Execution failed");
630
+ logger.error(message);
631
+ }
421
632
  process.exit(2);
422
633
  }
423
634
  });
@@ -438,7 +649,7 @@ async function executeParse(params) {
438
649
  if (fileDescriptors.length === 0) {
439
650
  return {
440
651
  success: false,
441
- output: "No .tspec files found",
652
+ output: "No .tcase files found",
442
653
  data: {
443
654
  testCases: [],
444
655
  parseErrors: [],
@@ -14756,7 +14967,7 @@ const TOOL_DEFINITIONS = [
14756
14967
  files: {
14757
14968
  type: "array",
14758
14969
  items: { type: "string" },
14759
- description: 'Files or glob patterns to run (e.g., ["tests/*.tspec"])'
14970
+ description: 'Files or glob patterns to run (e.g., ["tests/*.tcase"])'
14760
14971
  },
14761
14972
  concurrency: {
14762
14973
  type: "number",
@@ -14787,7 +14998,7 @@ const TOOL_DEFINITIONS = [
14787
14998
  },
14788
14999
  {
14789
15000
  name: "tspec_validate",
14790
- description: "Validate .tspec files for schema correctness",
15001
+ description: "Validate .tcase files for schema correctness",
14791
15002
  inputSchema: {
14792
15003
  type: "object",
14793
15004
  properties: {
@@ -14949,12 +15160,273 @@ const mcpCommand = new Command("mcp").description("Start MCP server for tool int
14949
15160
  setLoggerOptions({ quiet: true });
14950
15161
  await startMcpServer();
14951
15162
  });
15163
+ async function executePluginList(params) {
15164
+ const output = params.output ?? "text";
15165
+ const pluginManager = new PluginManager(version$1);
15166
+ const localConfigPath = findLocalConfigFile();
15167
+ const globalConfigPath = findGlobalConfigFile();
15168
+ const configPath = params.config || localConfigPath || globalConfigPath;
15169
+ let loadSummary;
15170
+ if (configPath) {
15171
+ loadSummary = await pluginManager.initialize(configPath);
15172
+ }
15173
+ const plugins = pluginManager.list();
15174
+ const protocols = pluginManager.listProtocols();
15175
+ let healthReports;
15176
+ if (params.health) {
15177
+ healthReports = await pluginManager.healthCheck();
15178
+ }
15179
+ const data = {
15180
+ plugins: plugins.map((p) => ({
15181
+ name: p.name,
15182
+ version: p.version,
15183
+ description: p.description,
15184
+ protocols: p.protocols,
15185
+ author: p.author,
15186
+ homepage: p.homepage
15187
+ })),
15188
+ protocols,
15189
+ configPath: configPath || void 0,
15190
+ configSources: {
15191
+ local: localConfigPath || void 0,
15192
+ global: globalConfigPath || void 0
15193
+ },
15194
+ pluginsDir: PLUGINS_DIR,
15195
+ health: healthReports
15196
+ };
15197
+ let outputStr;
15198
+ if (output === "json") {
15199
+ outputStr = JSON.stringify(data, null, 2);
15200
+ } else {
15201
+ outputStr = formatPluginListText(data, params.verbose ?? false, loadSummary);
15202
+ }
15203
+ return {
15204
+ success: true,
15205
+ output: outputStr,
15206
+ data
15207
+ };
15208
+ }
15209
+ function formatPluginListText(data, verbose, loadSummary) {
15210
+ const lines = [];
15211
+ lines.push(chalk.bold("\nTSpec Plugins\n"));
15212
+ lines.push(chalk.bold("Config:"));
15213
+ if (data.configSources?.local) {
15214
+ lines.push(chalk.gray(` Local: ${data.configSources.local}`));
15215
+ } else {
15216
+ lines.push(chalk.gray(" Local: (none)"));
15217
+ }
15218
+ if (data.configSources?.global) {
15219
+ lines.push(chalk.gray(` Global: ${data.configSources.global}`));
15220
+ } else {
15221
+ lines.push(chalk.gray(" Global: (none)"));
15222
+ }
15223
+ lines.push(chalk.gray(` Plugins dir: ${data.pluginsDir}`));
15224
+ if (loadSummary) {
15225
+ lines.push("");
15226
+ lines.push(chalk.gray(`Discovered: ${loadSummary.total}, Loaded: ${loadSummary.loaded}`));
15227
+ if (loadSummary.installed && loadSummary.installed > 0) {
15228
+ lines.push(chalk.green(`Installed: ${loadSummary.installed} plugin(s)`));
15229
+ }
15230
+ if (loadSummary.failed > 0) {
15231
+ lines.push(chalk.red(`Failed: ${loadSummary.failed}`));
15232
+ for (const error2 of loadSummary.errors) {
15233
+ lines.push(chalk.red(` ${error2.plugin}: ${error2.error}`));
15234
+ }
15235
+ }
15236
+ if (loadSummary.installErrors && loadSummary.installErrors.length > 0) {
15237
+ lines.push(chalk.red(`Install failures:`));
15238
+ for (const error2 of loadSummary.installErrors) {
15239
+ lines.push(chalk.red(` ${error2.plugin}: ${error2.error}`));
15240
+ }
15241
+ }
15242
+ }
15243
+ lines.push("");
15244
+ if (data.plugins.length === 0) {
15245
+ lines.push(chalk.yellow("No plugins loaded."));
15246
+ lines.push(chalk.gray("Add plugins to your tspec.config.json:"));
15247
+ lines.push(chalk.gray(" {"));
15248
+ lines.push(chalk.gray(' "plugins": ["@tspec/http", "@tspec/web"]'));
15249
+ lines.push(chalk.gray(" }"));
15250
+ } else {
15251
+ for (const plugin of data.plugins) {
15252
+ lines.push(`${chalk.cyan(plugin.name)} ${chalk.gray(`v${plugin.version}`)}`);
15253
+ if (verbose && plugin.description) {
15254
+ lines.push(` ${plugin.description}`);
15255
+ }
15256
+ lines.push(` Protocols: ${plugin.protocols.join(", ")}`);
15257
+ if (verbose) {
15258
+ if (plugin.author) {
15259
+ lines.push(` Author: ${plugin.author}`);
15260
+ }
15261
+ if (plugin.homepage) {
15262
+ lines.push(` Homepage: ${plugin.homepage}`);
15263
+ }
15264
+ }
15265
+ lines.push("");
15266
+ }
15267
+ }
15268
+ if (data.health) {
15269
+ lines.push(chalk.bold("Health Check\n"));
15270
+ for (const report of data.health) {
15271
+ const status = report.healthy ? chalk.green("✓ Healthy") : chalk.red("✗ Unhealthy");
15272
+ lines.push(`${chalk.cyan(report.plugin)}: ${status}`);
15273
+ if (report.message) {
15274
+ lines.push(` ${report.message}`);
15275
+ }
15276
+ }
15277
+ lines.push("");
15278
+ }
15279
+ if (data.protocols.length > 0) {
15280
+ lines.push(chalk.bold("Supported Protocols: ") + data.protocols.join(", "));
15281
+ }
15282
+ return lines.join("\n");
15283
+ }
15284
+ const pluginListCommand = new Command("plugin:list").alias("plugins").description("List all installed TSpec plugins").option("-o, --output <format>", "Output format: json, text", "text").option("-v, --verbose", "Show detailed plugin information").option("--health", "Run health checks on all plugins").option("-c, --config <path>", "Path to tspec.config.json").action(async (options) => {
15285
+ try {
15286
+ const result = await executePluginList({
15287
+ output: options.output,
15288
+ verbose: options.verbose,
15289
+ health: options.health,
15290
+ config: options.config
15291
+ });
15292
+ logger.log(result.output);
15293
+ } catch (err) {
15294
+ const message = err instanceof Error ? err.message : String(err);
15295
+ logger.error(`Failed to list plugins: ${message}`);
15296
+ process.exit(2);
15297
+ }
15298
+ });
15299
+ function loadConfigFile(configPath) {
15300
+ if (!existsSync(configPath)) {
15301
+ return { plugins: [], pluginOptions: {} };
15302
+ }
15303
+ try {
15304
+ const content = readFileSync(configPath, "utf-8");
15305
+ return JSON.parse(content);
15306
+ } catch {
15307
+ return { plugins: [], pluginOptions: {} };
15308
+ }
15309
+ }
15310
+ function saveConfigFile(configPath, config2) {
15311
+ const dir = configPath.substring(0, configPath.lastIndexOf("/"));
15312
+ if (!existsSync(dir)) {
15313
+ mkdirSync(dir, { recursive: true });
15314
+ }
15315
+ writeFileSync(configPath, JSON.stringify(config2, null, 2) + "\n");
15316
+ }
15317
+ function addPluginToConfig(config2, pluginName) {
15318
+ if (!config2.plugins) {
15319
+ config2.plugins = [];
15320
+ }
15321
+ if (config2.plugins.includes(pluginName)) {
15322
+ return false;
15323
+ }
15324
+ config2.plugins.push(pluginName);
15325
+ return true;
15326
+ }
15327
+ async function executePluginInstall(params) {
15328
+ const { pluginName, output = "text", global: useGlobal = false, config: customConfig } = params;
15329
+ let configPath;
15330
+ if (customConfig) {
15331
+ configPath = customConfig;
15332
+ } else if (useGlobal) {
15333
+ configPath = GLOBAL_CONFIG_PATH;
15334
+ } else {
15335
+ const localConfig = findLocalConfigFile();
15336
+ configPath = localConfig || GLOBAL_CONFIG_PATH;
15337
+ }
15338
+ const alreadyInstalled = isPluginInstalled(pluginName);
15339
+ let installed = false;
15340
+ let installError;
15341
+ if (!alreadyInstalled) {
15342
+ const result = await installPlugin(pluginName);
15343
+ installed = result.success;
15344
+ if (!result.success) {
15345
+ installError = result.error;
15346
+ }
15347
+ } else {
15348
+ installed = true;
15349
+ }
15350
+ let configUpdated = false;
15351
+ if (installed) {
15352
+ const config2 = loadConfigFile(configPath);
15353
+ configUpdated = addPluginToConfig(config2, pluginName);
15354
+ if (configUpdated) {
15355
+ saveConfigFile(configPath, config2);
15356
+ }
15357
+ }
15358
+ const data = {
15359
+ plugin: pluginName,
15360
+ installed,
15361
+ configUpdated,
15362
+ configPath: installed ? configPath : void 0,
15363
+ error: installError
15364
+ };
15365
+ let outputStr;
15366
+ if (output === "json") {
15367
+ outputStr = JSON.stringify(data, null, 2);
15368
+ } else {
15369
+ if (!installed) {
15370
+ outputStr = chalk.red(`Failed to install ${pluginName}: ${installError || "Unknown error"}`);
15371
+ } else if (alreadyInstalled && !configUpdated) {
15372
+ outputStr = chalk.yellow(`Plugin ${pluginName} is already installed and configured.`);
15373
+ } else if (alreadyInstalled && configUpdated) {
15374
+ outputStr = [
15375
+ chalk.green(`Plugin ${pluginName} is already installed.`),
15376
+ chalk.green(`Added to config: ${configPath}`)
15377
+ ].join("\n");
15378
+ } else if (configUpdated) {
15379
+ outputStr = [
15380
+ chalk.green(`Successfully installed ${pluginName}`),
15381
+ chalk.green(`Added to config: ${configPath}`)
15382
+ ].join("\n");
15383
+ } else {
15384
+ outputStr = [
15385
+ chalk.green(`Successfully installed ${pluginName}`),
15386
+ chalk.yellow(`Plugin already in config: ${configPath}`)
15387
+ ].join("\n");
15388
+ }
15389
+ }
15390
+ return {
15391
+ success: installed,
15392
+ output: outputStr,
15393
+ data
15394
+ };
15395
+ }
15396
+ const pluginInstallCommand = new Command("plugin:install").alias("install").description("Install a TSpec plugin and add it to config").argument("<plugin>", "Plugin name (npm package name, e.g., @tspec/http)").option("-o, --output <format>", "Output format: json, text", "text").option("-g, --global", "Add plugin to global config (~/.tspec/tspec.config.json)").option("-c, --config <path>", "Path to config file to update").action(async (plugin, options) => {
15397
+ const spinner = ora(`Installing ${plugin}...`).start();
15398
+ try {
15399
+ const result = await executePluginInstall({
15400
+ pluginName: plugin,
15401
+ output: options.output,
15402
+ global: options.global,
15403
+ config: options.config
15404
+ });
15405
+ spinner.stop();
15406
+ logger.log(result.output);
15407
+ process.exit(result.success ? 0 : 1);
15408
+ } catch (err) {
15409
+ spinner.stop();
15410
+ const message = err instanceof Error ? err.message : String(err);
15411
+ if (options.output === "json") {
15412
+ logger.log(JSON.stringify({ success: false, error: message }, null, 2));
15413
+ } else {
15414
+ logger.error(`Failed to install plugin: ${message}`);
15415
+ }
15416
+ process.exit(2);
15417
+ }
15418
+ });
15419
+ const __filename$1 = fileURLToPath(import.meta.url);
15420
+ const __dirname$1 = dirname(__filename$1);
15421
+ const packageJson = JSON.parse(readFileSync(join(__dirname$1, "../package.json"), "utf-8"));
14952
15422
  const program = new Command();
14953
- program.name("tspec").description("CLI for @boolesai/tspec testing framework").version("1.0.0");
15423
+ program.name("tspec").description("CLI for @boolesai/tspec testing framework").version(packageJson.version);
14954
15424
  program.addCommand(validateCommand);
14955
15425
  program.addCommand(runCommand);
14956
15426
  program.addCommand(parseCommand);
14957
15427
  program.addCommand(listCommand);
14958
15428
  program.addCommand(mcpCommand);
15429
+ program.addCommand(pluginListCommand);
15430
+ program.addCommand(pluginInstallCommand);
14959
15431
  await program.parseAsync();
14960
15432
  //# sourceMappingURL=index.js.map