@orchagent/cli 0.3.22 → 0.3.23

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.
@@ -10,6 +10,7 @@ const config_1 = require("../lib/config");
10
10
  const api_1 = require("../lib/api");
11
11
  const errors_1 = require("../lib/errors");
12
12
  const output_1 = require("../lib/output");
13
+ const spinner_1 = require("../lib/spinner");
13
14
  const llm_1 = require("../lib/llm");
14
15
  const analytics_1 = require("../lib/analytics");
15
16
  const DEFAULT_VERSION = 'latest';
@@ -311,11 +312,21 @@ argument or --file option instead.
311
312
  sourceLabel = multipart.sourceLabel;
312
313
  }
313
314
  const url = `${resolved.apiUrl.replace(/\/$/, '')}/${org}/${parsed.agent}/${parsed.version}/${endpoint}`;
314
- const response = await (0, api_1.safeFetchWithRetryForCalls)(url, {
315
- method: 'POST',
316
- headers,
317
- body,
318
- });
315
+ // Make the API call with a spinner
316
+ const spinner = (0, spinner_1.createSpinner)(`Calling ${org}/${parsed.agent}@${parsed.version}...`);
317
+ spinner.start();
318
+ let response;
319
+ try {
320
+ response = await (0, api_1.safeFetchWithRetryForCalls)(url, {
321
+ method: 'POST',
322
+ headers,
323
+ body,
324
+ });
325
+ }
326
+ catch (err) {
327
+ spinner.fail(`Call failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
328
+ throw err;
329
+ }
319
330
  if (!response.ok) {
320
331
  const text = await response.text();
321
332
  let payload;
@@ -330,6 +341,7 @@ argument or --file option instead.
330
341
  ? payload.error?.code
331
342
  : undefined;
332
343
  if (errorCode === 'LLM_KEY_REQUIRED') {
344
+ spinner.fail('LLM key required');
333
345
  throw new errors_1.CliError('This public agent requires you to provide an LLM key.\n' +
334
346
  'Use --key <key> --provider <provider> or set OPENAI_API_KEY/ANTHROPIC_API_KEY env var.');
335
347
  }
@@ -339,8 +351,10 @@ argument or --file option instead.
339
351
  payload.message ||
340
352
  response.statusText
341
353
  : response.statusText;
354
+ spinner.fail(`Call failed: ${message}`);
342
355
  throw new errors_1.CliError(message);
343
356
  }
357
+ spinner.succeed(`Called ${org}/${parsed.agent}@${parsed.version}`);
344
358
  // Track successful call
345
359
  const inputType = filePaths.length > 0
346
360
  ? 'file'
@@ -27,6 +27,7 @@ const formats_1 = require("./formats");
27
27
  const update_1 = require("./update");
28
28
  const env_1 = require("./env");
29
29
  const list_1 = require("./list");
30
+ const test_1 = require("./test");
30
31
  function registerCommands(program) {
31
32
  (0, login_1.registerLoginCommand)(program);
32
33
  (0, whoami_1.registerWhoamiCommand)(program);
@@ -54,4 +55,5 @@ function registerCommands(program) {
54
55
  (0, update_1.registerUpdateCommand)(program);
55
56
  (0, env_1.registerEnvCommand)(program);
56
57
  (0, list_1.registerListCommand)(program);
58
+ (0, test_1.registerTestCommand)(program);
57
59
  }
@@ -45,6 +45,7 @@ const config_1 = require("../lib/config");
45
45
  const api_1 = require("../lib/api");
46
46
  const errors_1 = require("../lib/errors");
47
47
  const output_1 = require("../lib/output");
48
+ const spinner_1 = require("../lib/spinner");
48
49
  const llm_1 = require("../lib/llm");
49
50
  const DEFAULT_VERSION = 'latest';
50
51
  const AGENTS_DIR = path_1.default.join(os_1.default.homedir(), '.orchagent', 'agents');
@@ -210,17 +211,18 @@ async function downloadDependenciesRecursively(config, depStatuses, visited = ne
210
211
  continue;
211
212
  visited.add(depRef);
212
213
  const [org, agent] = status.dep.id.split('/');
213
- process.stderr.write(`\nDownloading dependency: ${depRef}...\n`);
214
- // Save the dependency metadata locally
215
- await saveAgentLocally(org, agent, status.agentData);
216
- // For bundle-based agents, also extract the bundle
217
- if (status.agentData.has_bundle) {
218
- await saveBundleLocally(config, org, agent, status.dep.version, status.agentData.id);
219
- }
220
- // Install if it's a pip/source code agent
221
- if (status.agentData.type === 'code' && (status.agentData.source_url || status.agentData.pip_package)) {
222
- await installCodeAgent(status.agentData);
223
- }
214
+ await (0, spinner_1.withSpinner)(`Downloading dependency: ${depRef}...`, async () => {
215
+ // Save the dependency metadata locally
216
+ await saveAgentLocally(org, agent, status.agentData);
217
+ // For bundle-based agents, also extract the bundle
218
+ if (status.agentData.has_bundle) {
219
+ await saveBundleLocally(config, org, agent, status.dep.version, status.agentData.id);
220
+ }
221
+ // Install if it's a pip/source code agent
222
+ if (status.agentData.type === 'code' && (status.agentData.source_url || status.agentData.pip_package)) {
223
+ await installCodeAgent(status.agentData);
224
+ }
225
+ }, { successText: `Downloaded ${depRef}` });
224
226
  // Download default skills
225
227
  const defaultSkills = status.agentData.default_skills || [];
226
228
  for (const skillRef of defaultSkills) {
@@ -327,20 +329,18 @@ async function executePromptLocally(agentData, inputData, skillPrompts = [], con
327
329
  }));
328
330
  // Show which provider is being used (primary)
329
331
  const primary = providersWithModels[0];
330
- if (providersWithModels.length > 1) {
331
- process.stderr.write(`Running with ${primary.provider} (${primary.model}), ` +
332
- `${providersWithModels.length - 1} fallback(s) available...\n`);
333
- }
334
- else {
335
- process.stderr.write(`Running with ${primary.provider} (${primary.model})...\n`);
336
- }
332
+ const spinnerText = providersWithModels.length > 1
333
+ ? `Running with ${primary.provider} (${primary.model}), ${providersWithModels.length - 1} fallback(s) available...`
334
+ : `Running with ${primary.provider} (${primary.model})...`;
337
335
  // Use fallback if multiple providers, otherwise single call
338
- if (providersWithModels.length > 1) {
339
- return await (0, llm_1.callLlmWithFallback)(providersWithModels, prompt, agentData.output_schema);
340
- }
341
- else {
342
- return await (0, llm_1.callLlm)(primary.provider, primary.apiKey, primary.model, prompt, agentData.output_schema);
343
- }
336
+ return await (0, spinner_1.withSpinner)(spinnerText, async () => {
337
+ if (providersWithModels.length > 1) {
338
+ return await (0, llm_1.callLlmWithFallback)(providersWithModels, prompt, agentData.output_schema);
339
+ }
340
+ else {
341
+ return await (0, llm_1.callLlm)(primary.provider, primary.apiKey, primary.model, prompt, agentData.output_schema);
342
+ }
343
+ }, { successText: `Completed with ${primary.provider}` });
344
344
  }
345
345
  // Provider override: use single provider (existing behavior)
346
346
  const detected = await (0, llm_1.detectLlmKey)(providersToCheck, config);
@@ -352,11 +352,10 @@ async function executePromptLocally(agentData, inputData, skillPrompts = [], con
352
352
  const { provider, key, model: serverModel } = detected;
353
353
  // Priority: CLI override > server config model > agent default model > hardcoded default
354
354
  const model = modelOverride || serverModel || agentData.default_models?.[provider] || (0, llm_1.getDefaultModel)(provider);
355
- // Show which provider is being used (helpful for debugging rate limits)
356
- process.stderr.write(`Running with ${provider} (${model})...\n`);
357
- // Call the LLM directly
358
- const response = await (0, llm_1.callLlm)(provider, key, model, prompt, agentData.output_schema);
359
- return response;
355
+ // Call the LLM with spinner
356
+ return await (0, spinner_1.withSpinner)(`Running with ${provider} (${model})...`, async () => {
357
+ return await (0, llm_1.callLlm)(provider, key, model, prompt, agentData.output_schema);
358
+ }, { successText: `Completed with ${provider}` });
360
359
  }
361
360
  function parseSkillRef(value) {
362
361
  const [ref, versionPart] = value.split('@');
@@ -436,21 +435,22 @@ async function installCodeAgent(agentData) {
436
435
  if (agentData.pip_package) {
437
436
  const installed = await checkPackageInstalled(agentData.pip_package);
438
437
  if (installed) {
439
- process.stderr.write(`Package ${agentData.pip_package} already installed.\n`);
438
+ const spinner = (0, spinner_1.createSpinner)(`Package ${agentData.pip_package}`);
439
+ spinner.succeed(`Package ${agentData.pip_package} already installed`);
440
440
  return;
441
441
  }
442
442
  }
443
- process.stderr.write(`Installing ${installSource}...\n`);
444
- const { code } = await runCommand('python3', ['-m', 'pip', 'install', '--quiet', '--disable-pip-version-check', installSource]);
445
- if (code !== 0) {
446
- throw new errors_1.CliError(`Failed to install agent (exit code ${code}).\n\n` +
447
- 'Troubleshooting:\n' +
448
- ' - Check Python is installed: python3 --version\n' +
449
- ' - Check pip is available: pip --version\n' +
450
- ' - Check network connectivity\n' +
451
- ' - Try installing manually: pip install <package>');
452
- }
453
- process.stderr.write(`Installation complete.\n`);
443
+ await (0, spinner_1.withSpinner)(`Installing ${installSource}...`, async () => {
444
+ const { code } = await runCommand('python3', ['-m', 'pip', 'install', '--quiet', '--disable-pip-version-check', installSource]);
445
+ if (code !== 0) {
446
+ throw new errors_1.CliError(`Failed to install agent (exit code ${code}).\n\n` +
447
+ 'Troubleshooting:\n' +
448
+ ' - Check Python is installed: python3 --version\n' +
449
+ ' - Check pip is available: pip --version\n' +
450
+ ' - Check network connectivity\n' +
451
+ ' - Try installing manually: pip install <package>');
452
+ }
453
+ }, { successText: 'Installation complete' });
454
454
  }
455
455
  async function executeCodeAgent(agentData, args) {
456
456
  if (!agentData.run_command) {
@@ -502,24 +502,27 @@ async function executeBundleAgent(config, org, agentName, version, agentData, ar
502
502
  const bundleZip = path_1.default.join(tempDir, 'bundle.zip');
503
503
  const extractDir = path_1.default.join(tempDir, 'agent');
504
504
  try {
505
- // Download the bundle
506
- process.stderr.write(`Downloading bundle...\n`);
507
- const bundleBuffer = await downloadBundleWithFallback(config, org, agentName, version, agentData.id);
508
- await promises_1.default.writeFile(bundleZip, bundleBuffer);
509
- process.stderr.write(`Bundle downloaded (${bundleBuffer.length} bytes)\n`);
510
- // Extract the bundle
505
+ // Download the bundle with spinner
506
+ const bundleBuffer = await (0, spinner_1.withSpinner)(`Downloading ${org}/${agentName}@${version} bundle...`, async () => {
507
+ const buffer = await downloadBundleWithFallback(config, org, agentName, version, agentData.id);
508
+ await promises_1.default.writeFile(bundleZip, buffer);
509
+ return buffer;
510
+ }, { successText: (buf) => `Downloaded bundle (${buf.length} bytes)` });
511
+ // Extract the bundle with spinner
511
512
  await promises_1.default.mkdir(extractDir, { recursive: true });
512
- process.stderr.write(`Extracting bundle...\n`);
513
- await unzipBundle(bundleZip, extractDir);
513
+ await (0, spinner_1.withSpinner)('Extracting bundle...', async () => {
514
+ await unzipBundle(bundleZip, extractDir);
515
+ }, { successText: 'Bundle extracted' });
514
516
  // Check if requirements.txt exists and install dependencies
515
517
  const requirementsPath = path_1.default.join(extractDir, 'requirements.txt');
516
518
  try {
517
519
  await promises_1.default.access(requirementsPath);
518
- process.stderr.write(`Installing dependencies from requirements.txt...\n`);
519
- const { code } = await runCommand('python3', ['-m', 'pip', 'install', '-q', '--disable-pip-version-check', '-r', requirementsPath]);
520
- if (code !== 0) {
521
- throw new errors_1.CliError('Failed to install dependencies from requirements.txt');
522
- }
520
+ await (0, spinner_1.withSpinner)('Installing dependencies...', async () => {
521
+ const { code } = await runCommand('python3', ['-m', 'pip', 'install', '-q', '--disable-pip-version-check', '-r', requirementsPath]);
522
+ if (code !== 0) {
523
+ throw new errors_1.CliError('Failed to install dependencies from requirements.txt');
524
+ }
525
+ }, { successText: 'Dependencies installed' });
523
526
  }
524
527
  catch (err) {
525
528
  if (err.code !== 'ENOENT') {
@@ -739,8 +742,7 @@ async function saveBundleLocally(config, org, agent, version, agentId) {
739
742
  // Metadata doesn't exist, need to download
740
743
  }
741
744
  // Download and extract bundle
742
- process.stderr.write(`Downloading bundle for ${org}/${agent}@${version}...\n`);
743
- const bundleBuffer = await downloadBundleWithFallback(config, org, agent, version, agentId);
745
+ const bundleBuffer = await (0, spinner_1.withSpinner)(`Downloading bundle for ${org}/${agent}@${version}...`, async () => downloadBundleWithFallback(config, org, agent, version, agentId), { successText: `Downloaded bundle for ${org}/${agent}@${version}` });
744
746
  const tempZip = path_1.default.join(os_1.default.tmpdir(), `bundle-${Date.now()}.zip`);
745
747
  await promises_1.default.writeFile(tempZip, bundleBuffer);
746
748
  // Clean and recreate bundle directory
@@ -818,23 +820,23 @@ Note: Use 'run' for local execution, 'call' for server-side execution.
818
820
  if (!org) {
819
821
  throw new errors_1.CliError('Missing org. Use org/agent format.');
820
822
  }
821
- // Download agent definition
822
- process.stderr.write(`Downloading ${org}/${parsed.agent}@${parsed.version}...\n`);
823
- let agentData;
824
- try {
825
- agentData = await downloadAgent(resolved, org, parsed.agent, parsed.version);
826
- }
827
- catch (err) {
828
- // Fall back to getting public agent info if download endpoint not available
829
- const agentMeta = await (0, api_1.getPublicAgent)(resolved, org, parsed.agent, parsed.version);
830
- agentData = {
831
- type: agentMeta.type || 'code',
832
- name: agentMeta.name,
833
- version: agentMeta.version,
834
- description: agentMeta.description || undefined,
835
- supported_providers: agentMeta.supported_providers || ['any'],
836
- };
837
- }
823
+ // Download agent definition with spinner
824
+ const agentData = await (0, spinner_1.withSpinner)(`Downloading ${org}/${parsed.agent}@${parsed.version}...`, async () => {
825
+ try {
826
+ return await downloadAgent(resolved, org, parsed.agent, parsed.version);
827
+ }
828
+ catch (err) {
829
+ // Fall back to getting public agent info if download endpoint not available
830
+ const agentMeta = await (0, api_1.getPublicAgent)(resolved, org, parsed.agent, parsed.version);
831
+ return {
832
+ type: agentMeta.type || 'code',
833
+ name: agentMeta.name,
834
+ version: agentMeta.version,
835
+ description: agentMeta.description || undefined,
836
+ supported_providers: agentMeta.supported_providers || ['any'],
837
+ };
838
+ }
839
+ }, { successText: `Downloaded ${org}/${parsed.agent}@${parsed.version}` });
838
840
  // Skills cannot be run directly - they're instructions to inject into agents
839
841
  if (agentData.type === 'skill') {
840
842
  throw new errors_1.CliError('Skills cannot be run directly.\n\n' +
@@ -845,8 +847,7 @@ Note: Use 'run' for local execution, 'call' for server-side execution.
845
847
  }
846
848
  // Check for dependencies (orchestrator agents)
847
849
  if (agentData.dependencies && agentData.dependencies.length > 0) {
848
- process.stderr.write(`\nChecking dependencies...\n`);
849
- const depStatuses = await checkDependencies(resolved, agentData.dependencies);
850
+ const depStatuses = await (0, spinner_1.withSpinner)('Checking dependencies...', async () => checkDependencies(resolved, agentData.dependencies), { successText: `Found ${agentData.dependencies.length} dependencies` });
850
851
  let choice;
851
852
  if (options.withDeps) {
852
853
  // Auto-download deps without prompting
@@ -865,9 +866,7 @@ Note: Use 'run' for local execution, 'call' for server-side execution.
865
866
  process.exit(0);
866
867
  }
867
868
  // choice === 'local' - download dependencies
868
- process.stderr.write(`\nDownloading dependencies...\n`);
869
869
  await downloadDependenciesRecursively(resolved, depStatuses);
870
- process.stderr.write(`\nAll dependencies downloaded.\n`);
871
870
  }
872
871
  // Check if user is overriding locked skills
873
872
  const agentSkillsLocked = agentData.skills_locked;
@@ -954,12 +953,10 @@ Note: Use 'run' for local execution, 'call' for server-side execution.
954
953
  }
955
954
  }
956
955
  if (skillRefs.length > 0) {
957
- process.stderr.write(`Loading ${skillRefs.length} skill(s)...\n`);
958
- skillPrompts = await loadSkillPrompts(resolved, skillRefs, org);
956
+ skillPrompts = await (0, spinner_1.withSpinner)(`Loading ${skillRefs.length} skill(s)...`, async () => loadSkillPrompts(resolved, skillRefs, org), { successText: `Loaded ${skillRefs.length} skill(s)` });
959
957
  }
960
958
  }
961
- // Execute locally
962
- process.stderr.write(`Executing locally...\n\n`);
959
+ // Execute locally (the spinner is inside executePromptLocally)
963
960
  const result = await executePromptLocally(agentData, inputData, skillPrompts, resolved, options.provider, options.model);
964
961
  if (options.json) {
965
962
  (0, output_1.printJson)(result);
@@ -0,0 +1,536 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.registerTestCommand = registerTestCommand;
7
+ const promises_1 = __importDefault(require("fs/promises"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const child_process_1 = require("child_process");
10
+ const chalk_1 = __importDefault(require("chalk"));
11
+ const yaml_1 = __importDefault(require("yaml"));
12
+ const errors_1 = require("../lib/errors");
13
+ const config_1 = require("../lib/config");
14
+ const llm_1 = require("../lib/llm");
15
+ /**
16
+ * Parse SKILL.md frontmatter
17
+ */
18
+ async function parseSkillMd(filePath) {
19
+ try {
20
+ const content = await promises_1.default.readFile(filePath, 'utf-8');
21
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
22
+ if (!match)
23
+ return null;
24
+ const frontmatter = yaml_1.default.parse(match[1]);
25
+ const body = match[2].trim();
26
+ if (!frontmatter.name || !frontmatter.description)
27
+ return null;
28
+ return { frontmatter, body };
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Run a command and return the result
36
+ */
37
+ function runCommand(command, args, cwd, verbose) {
38
+ return new Promise((resolve) => {
39
+ const proc = (0, child_process_1.spawn)(command, args, {
40
+ cwd,
41
+ stdio: ['inherit', 'pipe', 'pipe'],
42
+ shell: true,
43
+ });
44
+ let stdout = '';
45
+ let stderr = '';
46
+ proc.stdout?.on('data', (data) => {
47
+ const text = data.toString();
48
+ stdout += text;
49
+ if (verbose) {
50
+ process.stdout.write(text);
51
+ }
52
+ });
53
+ proc.stderr?.on('data', (data) => {
54
+ const text = data.toString();
55
+ stderr += text;
56
+ // Always show stderr for test output
57
+ process.stderr.write(text);
58
+ });
59
+ proc.on('close', (code) => {
60
+ resolve({ code: code ?? 1, stdout, stderr });
61
+ });
62
+ proc.on('error', (err) => {
63
+ resolve({ code: 1, stdout, stderr: err.message });
64
+ });
65
+ });
66
+ }
67
+ /**
68
+ * Check if a command exists
69
+ */
70
+ async function commandExists(command) {
71
+ try {
72
+ const proc = (0, child_process_1.spawn)('which', [command], { shell: true });
73
+ return new Promise((resolve) => {
74
+ proc.on('close', (code) => resolve(code === 0));
75
+ proc.on('error', () => resolve(false));
76
+ });
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Check if a file exists
84
+ */
85
+ async function fileExists(filePath) {
86
+ try {
87
+ await promises_1.default.access(filePath);
88
+ return true;
89
+ }
90
+ catch {
91
+ return false;
92
+ }
93
+ }
94
+ /**
95
+ * Detect the agent type from the directory structure
96
+ */
97
+ async function detectAgentType(agentDir) {
98
+ // Check for SKILL.md first
99
+ if (await fileExists(path_1.default.join(agentDir, 'SKILL.md'))) {
100
+ return 'skill';
101
+ }
102
+ // Check for prompt.md (prompt agent)
103
+ if (await fileExists(path_1.default.join(agentDir, 'prompt.md'))) {
104
+ return 'prompt';
105
+ }
106
+ // Check for orchagent.json
107
+ const manifestPath = path_1.default.join(agentDir, 'orchagent.json');
108
+ if (await fileExists(manifestPath)) {
109
+ try {
110
+ const raw = await promises_1.default.readFile(manifestPath, 'utf-8');
111
+ const manifest = JSON.parse(raw);
112
+ if (manifest.type === 'prompt')
113
+ return 'prompt';
114
+ if (manifest.type === 'skill')
115
+ return 'skill';
116
+ if (manifest.type === 'code') {
117
+ // Detect language
118
+ if (await fileExists(path_1.default.join(agentDir, 'requirements.txt')))
119
+ return 'code-python';
120
+ if (await fileExists(path_1.default.join(agentDir, 'pyproject.toml')))
121
+ return 'code-python';
122
+ if (await fileExists(path_1.default.join(agentDir, 'package.json')))
123
+ return 'code-js';
124
+ // Default to Python for code agents
125
+ return 'code-python';
126
+ }
127
+ }
128
+ catch {
129
+ // Invalid manifest, continue detection
130
+ }
131
+ }
132
+ // Fallback: detect by file presence
133
+ if (await fileExists(path_1.default.join(agentDir, 'requirements.txt')))
134
+ return 'code-python';
135
+ if (await fileExists(path_1.default.join(agentDir, 'pyproject.toml')))
136
+ return 'code-python';
137
+ if (await fileExists(path_1.default.join(agentDir, 'package.json')))
138
+ return 'code-js';
139
+ return 'unknown';
140
+ }
141
+ /**
142
+ * Recursively walk a directory and return all files
143
+ */
144
+ async function walkDir(dir, files = []) {
145
+ try {
146
+ const entries = await promises_1.default.readdir(dir, { withFileTypes: true });
147
+ for (const entry of entries) {
148
+ const fullPath = path_1.default.join(dir, entry.name);
149
+ // Skip common non-source directories
150
+ if (entry.isDirectory()) {
151
+ if (['node_modules', '__pycache__', '.git', 'dist', 'build', '.venv', 'venv'].includes(entry.name)) {
152
+ continue;
153
+ }
154
+ await walkDir(fullPath, files);
155
+ }
156
+ else if (entry.isFile()) {
157
+ files.push(fullPath);
158
+ }
159
+ }
160
+ }
161
+ catch {
162
+ // Directory doesn't exist or not readable
163
+ }
164
+ return files;
165
+ }
166
+ /**
167
+ * Discover test files in the agent directory
168
+ */
169
+ async function discoverTests(agentDir) {
170
+ const result = {
171
+ python: [],
172
+ javascript: [],
173
+ fixtures: [],
174
+ };
175
+ // Get all files recursively
176
+ const allFiles = await walkDir(agentDir);
177
+ for (const file of allFiles) {
178
+ const basename = path_1.default.basename(file);
179
+ const relPath = path_1.default.relative(agentDir, file);
180
+ // Python test patterns: test_*.py, *_test.py
181
+ if (basename.endsWith('.py')) {
182
+ if (basename.startsWith('test_') || basename.endsWith('_test.py')) {
183
+ result.python.push(file);
184
+ }
185
+ }
186
+ // JS/TS test patterns: *.test.ts, *.test.js, *.spec.ts, *.spec.js
187
+ if (basename.endsWith('.test.ts') || basename.endsWith('.test.js') ||
188
+ basename.endsWith('.spec.ts') || basename.endsWith('.spec.js')) {
189
+ result.javascript.push(file);
190
+ }
191
+ // Fixture patterns: tests/fixture*.json or fixture*.json in tests/ subdirs
192
+ if (basename.endsWith('.json') && basename.startsWith('fixture')) {
193
+ if (relPath.includes('tests' + path_1.default.sep) || relPath.startsWith('tests' + path_1.default.sep)) {
194
+ result.fixtures.push(file);
195
+ }
196
+ }
197
+ }
198
+ return result;
199
+ }
200
+ /**
201
+ * Run Python tests using pytest
202
+ */
203
+ async function runPythonTests(agentDir, verbose) {
204
+ process.stderr.write(chalk_1.default.blue('\nRunning Python tests...\n\n'));
205
+ // Check if pytest is available
206
+ const hasPytest = await commandExists('pytest');
207
+ let command;
208
+ let args;
209
+ if (hasPytest) {
210
+ command = 'pytest';
211
+ args = verbose ? ['-v'] : [];
212
+ }
213
+ else {
214
+ command = 'python3';
215
+ args = ['-m', 'pytest'];
216
+ if (verbose)
217
+ args.push('-v');
218
+ }
219
+ const { code } = await runCommand(command, args, agentDir, verbose);
220
+ return code;
221
+ }
222
+ /**
223
+ * Run JavaScript/TypeScript tests
224
+ */
225
+ async function runJsTests(agentDir, verbose) {
226
+ process.stderr.write(chalk_1.default.blue('\nRunning JavaScript/TypeScript tests...\n\n'));
227
+ // Check for vitest first
228
+ const hasVitest = await fileExists(path_1.default.join(agentDir, 'node_modules', '.bin', 'vitest'));
229
+ if (hasVitest) {
230
+ const args = ['run'];
231
+ if (verbose)
232
+ args.push('--reporter=verbose');
233
+ const { code } = await runCommand('npx', ['vitest', ...args], agentDir, verbose);
234
+ return code;
235
+ }
236
+ // Fall back to npm test
237
+ const packageJsonPath = path_1.default.join(agentDir, 'package.json');
238
+ if (await fileExists(packageJsonPath)) {
239
+ try {
240
+ const raw = await promises_1.default.readFile(packageJsonPath, 'utf-8');
241
+ const pkg = JSON.parse(raw);
242
+ if (pkg.scripts?.test) {
243
+ const { code } = await runCommand('npm', ['test'], agentDir, verbose);
244
+ return code;
245
+ }
246
+ }
247
+ catch {
248
+ // Invalid package.json
249
+ }
250
+ }
251
+ process.stderr.write(chalk_1.default.yellow('No JavaScript test runner found. Install vitest or add a test script to package.json.\n'));
252
+ return 1;
253
+ }
254
+ /**
255
+ * Run fixture-based tests for prompt agents
256
+ */
257
+ async function runFixtureTests(agentDir, fixtures, verbose, config) {
258
+ process.stderr.write(chalk_1.default.blue('\nRunning fixture tests...\n\n'));
259
+ // Read prompt
260
+ let prompt;
261
+ const promptPath = path_1.default.join(agentDir, 'prompt.md');
262
+ const skillPath = path_1.default.join(agentDir, 'SKILL.md');
263
+ // Check if this is a skill
264
+ const skillData = await parseSkillMd(skillPath);
265
+ if (skillData) {
266
+ prompt = skillData.body;
267
+ }
268
+ else {
269
+ try {
270
+ prompt = await promises_1.default.readFile(promptPath, 'utf-8');
271
+ }
272
+ catch {
273
+ throw new errors_1.CliError('No prompt.md or SKILL.md found for fixture tests');
274
+ }
275
+ }
276
+ // Read output schema if available
277
+ let outputSchema;
278
+ const schemaPath = path_1.default.join(agentDir, 'schema.json');
279
+ try {
280
+ const raw = await promises_1.default.readFile(schemaPath, 'utf-8');
281
+ const schemas = JSON.parse(raw);
282
+ outputSchema = schemas.output;
283
+ }
284
+ catch {
285
+ // Schema is optional
286
+ }
287
+ // Detect LLM key
288
+ const detected = await (0, llm_1.detectLlmKey)(['any'], config);
289
+ if (!detected) {
290
+ throw new errors_1.CliError('No LLM key found for fixture tests.\n' +
291
+ 'Set an environment variable (e.g., OPENAI_API_KEY) or run `orchagent keys add <provider>`');
292
+ }
293
+ const { provider, key } = detected;
294
+ const model = (0, llm_1.getDefaultModel)(provider);
295
+ let passed = 0;
296
+ let failed = 0;
297
+ for (const fixturePath of fixtures) {
298
+ const fixtureName = path_1.default.basename(fixturePath);
299
+ process.stderr.write(` ${fixtureName}: `);
300
+ try {
301
+ const raw = await promises_1.default.readFile(fixturePath, 'utf-8');
302
+ const fixture = JSON.parse(raw);
303
+ // Build and call LLM
304
+ const fullPrompt = (0, llm_1.buildPrompt)(prompt, fixture.input);
305
+ const result = await (0, llm_1.callLlm)(provider, key, model, fullPrompt, outputSchema);
306
+ // Validate result
307
+ let testPassed = true;
308
+ const failures = [];
309
+ if (fixture.expected_output) {
310
+ // Exact match comparison
311
+ const resultStr = JSON.stringify(result, Object.keys(result).sort());
312
+ const expectedStr = JSON.stringify(fixture.expected_output, Object.keys(fixture.expected_output).sort());
313
+ if (resultStr !== expectedStr) {
314
+ testPassed = false;
315
+ failures.push(`Expected: ${expectedStr}\nGot: ${resultStr}`);
316
+ }
317
+ }
318
+ if (fixture.expected_contains) {
319
+ // Check if output contains expected strings
320
+ const resultStr = JSON.stringify(result);
321
+ for (const expected of fixture.expected_contains) {
322
+ if (!resultStr.includes(expected)) {
323
+ testPassed = false;
324
+ failures.push(`Expected output to contain: "${expected}"`);
325
+ }
326
+ }
327
+ }
328
+ if (testPassed) {
329
+ process.stderr.write(chalk_1.default.green('PASS\n'));
330
+ passed++;
331
+ if (verbose) {
332
+ process.stderr.write(chalk_1.default.gray(` Input: ${JSON.stringify(fixture.input)}\n`));
333
+ process.stderr.write(chalk_1.default.gray(` Output: ${JSON.stringify(result)}\n`));
334
+ }
335
+ }
336
+ else {
337
+ process.stderr.write(chalk_1.default.red('FAIL\n'));
338
+ failed++;
339
+ for (const failure of failures) {
340
+ process.stderr.write(chalk_1.default.red(` ${failure}\n`));
341
+ }
342
+ }
343
+ }
344
+ catch (err) {
345
+ process.stderr.write(chalk_1.default.red('ERROR\n'));
346
+ failed++;
347
+ const message = err instanceof Error ? err.message : String(err);
348
+ process.stderr.write(chalk_1.default.red(` ${message}\n`));
349
+ }
350
+ }
351
+ process.stderr.write('\n');
352
+ process.stderr.write(`Fixtures: ${passed} passed, ${failed} failed\n`);
353
+ return failed > 0 ? 1 : 0;
354
+ }
355
+ /**
356
+ * Watch mode: re-run tests on file changes
357
+ */
358
+ async function watchTests(agentDir, agentType, testFiles, verbose, config) {
359
+ process.stderr.write(chalk_1.default.cyan('\nWatching for file changes... (press Ctrl+C to exit)\n\n'));
360
+ // Collect all files to watch
361
+ const watchPaths = [agentDir];
362
+ const runTests = async () => {
363
+ process.stderr.write(chalk_1.default.dim(`\n[${new Date().toLocaleTimeString()}] Running tests...\n`));
364
+ await executeTests(agentDir, agentType, testFiles, verbose, config);
365
+ };
366
+ // Initial run
367
+ await runTests();
368
+ // Set up file watcher using fs.watch (basic implementation)
369
+ const watchers = [];
370
+ let debounceTimer = null;
371
+ const onChange = () => {
372
+ if (debounceTimer)
373
+ clearTimeout(debounceTimer);
374
+ debounceTimer = setTimeout(runTests, 500);
375
+ };
376
+ for (const watchPath of watchPaths) {
377
+ try {
378
+ // Use recursive watch on the directory
379
+ const ac = new AbortController();
380
+ (async () => {
381
+ try {
382
+ const watcher = promises_1.default.watch(watchPath, { recursive: true, signal: ac.signal });
383
+ for await (const _event of watcher) {
384
+ onChange();
385
+ }
386
+ }
387
+ catch (err) {
388
+ if (err.name !== 'AbortError') {
389
+ // Watcher error, ignore
390
+ }
391
+ }
392
+ })();
393
+ }
394
+ catch {
395
+ // Watch not supported, fall back to polling
396
+ }
397
+ }
398
+ // Keep process alive
399
+ await new Promise(() => { });
400
+ }
401
+ /**
402
+ * Execute tests based on agent type and discovered test files
403
+ */
404
+ async function executeTests(agentDir, agentType, testFiles, verbose, config) {
405
+ let exitCode = 0;
406
+ // Run tests based on what's available
407
+ const hasTests = testFiles.python.length > 0 ||
408
+ testFiles.javascript.length > 0 ||
409
+ testFiles.fixtures.length > 0;
410
+ if (!hasTests) {
411
+ // For prompt agents/skills, suggest creating fixtures
412
+ if (agentType === 'prompt' || agentType === 'skill') {
413
+ process.stderr.write(chalk_1.default.yellow('No test files found.\n\n'));
414
+ process.stderr.write('For prompt agents, create fixture files in tests/:\n');
415
+ process.stderr.write(chalk_1.default.gray(' tests/fixture-1.json:\n'));
416
+ process.stderr.write(chalk_1.default.gray(' {\n'));
417
+ process.stderr.write(chalk_1.default.gray(' "input": {"text": "Hello world"},\n'));
418
+ process.stderr.write(chalk_1.default.gray(' "expected_contains": ["response"]\n'));
419
+ process.stderr.write(chalk_1.default.gray(' }\n\n'));
420
+ }
421
+ else {
422
+ process.stderr.write(chalk_1.default.yellow('No test files found.\n\n'));
423
+ process.stderr.write('Supported test file patterns:\n');
424
+ process.stderr.write(chalk_1.default.gray(' Python: test_*.py, *_test.py, tests/test_*.py\n'));
425
+ process.stderr.write(chalk_1.default.gray(' JS/TS: *.test.ts, *.spec.ts, tests/*.test.ts\n'));
426
+ process.stderr.write(chalk_1.default.gray(' Fixtures: tests/fixture-*.json\n\n'));
427
+ }
428
+ return 1;
429
+ }
430
+ // Run Python tests if found
431
+ if (testFiles.python.length > 0) {
432
+ if (verbose) {
433
+ process.stderr.write(chalk_1.default.gray(`Found ${testFiles.python.length} Python test file(s)\n`));
434
+ }
435
+ const code = await runPythonTests(agentDir, verbose);
436
+ if (code !== 0)
437
+ exitCode = 1;
438
+ }
439
+ // Run JS/TS tests if found
440
+ if (testFiles.javascript.length > 0) {
441
+ if (verbose) {
442
+ process.stderr.write(chalk_1.default.gray(`Found ${testFiles.javascript.length} JavaScript/TypeScript test file(s)\n`));
443
+ }
444
+ const code = await runJsTests(agentDir, verbose);
445
+ if (code !== 0)
446
+ exitCode = 1;
447
+ }
448
+ // Run fixture tests if found (for prompt agents)
449
+ if (testFiles.fixtures.length > 0) {
450
+ if (verbose) {
451
+ process.stderr.write(chalk_1.default.gray(`Found ${testFiles.fixtures.length} fixture file(s)\n`));
452
+ }
453
+ const code = await runFixtureTests(agentDir, testFiles.fixtures, verbose, config);
454
+ if (code !== 0)
455
+ exitCode = 1;
456
+ }
457
+ return exitCode;
458
+ }
459
+ function registerTestCommand(program) {
460
+ program
461
+ .command('test [path]')
462
+ .description('Run agent test suite locally')
463
+ .option('-v, --verbose', 'Show detailed test output')
464
+ .option('-w, --watch', 'Watch for file changes and re-run tests')
465
+ .addHelpText('after', `
466
+ Examples:
467
+ orch test Run tests in current directory
468
+ orch test ./my-agent Run tests in specified directory
469
+ orch test --verbose Show detailed test output
470
+ orch test --watch Watch mode - re-run on file changes
471
+
472
+ Test Discovery:
473
+ Python: test_*.py, *_test.py, tests/test_*.py, tests/*_test.py
474
+ JS/TS: *.test.ts, *.test.js, *.spec.ts, *.spec.js, tests/*.test.*
475
+ Fixtures: tests/fixture-*.json (for prompt agents)
476
+
477
+ Fixture Format (tests/fixture-1.json):
478
+ {
479
+ "input": {"key": "value"},
480
+ "expected_output": {"result": "expected"},
481
+ "expected_contains": ["substring"],
482
+ "description": "Test description"
483
+ }
484
+ `)
485
+ .action(async (agentPath, options) => {
486
+ const agentDir = agentPath
487
+ ? path_1.default.resolve(process.cwd(), agentPath)
488
+ : process.cwd();
489
+ // Verify directory exists
490
+ try {
491
+ const stat = await promises_1.default.stat(agentDir);
492
+ if (!stat.isDirectory()) {
493
+ throw new errors_1.CliError(`Not a directory: ${agentDir}`);
494
+ }
495
+ }
496
+ catch (err) {
497
+ if (err.code === 'ENOENT') {
498
+ throw new errors_1.CliError(`Directory not found: ${agentDir}`);
499
+ }
500
+ throw err;
501
+ }
502
+ // Detect agent type
503
+ const agentType = await detectAgentType(agentDir);
504
+ if (options.verbose) {
505
+ process.stderr.write(chalk_1.default.gray(`Detected agent type: ${agentType}\n`));
506
+ }
507
+ // Discover test files
508
+ const testFiles = await discoverTests(agentDir);
509
+ if (options.verbose) {
510
+ const totalTests = testFiles.python.length + testFiles.javascript.length + testFiles.fixtures.length;
511
+ process.stderr.write(chalk_1.default.gray(`Discovered ${totalTests} test file(s)\n`));
512
+ }
513
+ // Get config for LLM access (needed for fixture tests)
514
+ let config;
515
+ try {
516
+ config = await (0, config_1.getResolvedConfig)();
517
+ }
518
+ catch {
519
+ // Config not available, fixture tests will use env vars only
520
+ }
521
+ // Watch mode
522
+ if (options.watch) {
523
+ await watchTests(agentDir, agentType, testFiles, !!options.verbose, config);
524
+ return;
525
+ }
526
+ // Run tests
527
+ const exitCode = await executeTests(agentDir, agentType, testFiles, !!options.verbose, config);
528
+ if (exitCode === 0) {
529
+ process.stderr.write(chalk_1.default.green('\nAll tests passed.\n'));
530
+ }
531
+ else {
532
+ process.stderr.write(chalk_1.default.red('\nSome tests failed.\n'));
533
+ }
534
+ process.exit(exitCode);
535
+ });
536
+ }
package/dist/index.js CHANGED
@@ -45,6 +45,8 @@ const commander_1 = require("commander");
45
45
  const commands_1 = require("./commands");
46
46
  const errors_1 = require("./lib/errors");
47
47
  const analytics_1 = require("./lib/analytics");
48
+ const config_1 = require("./lib/config");
49
+ const spinner_1 = require("./lib/spinner");
48
50
  const package_json_1 = __importDefault(require("../package.json"));
49
51
  (0, analytics_1.initPostHog)();
50
52
  const program = new commander_1.Command();
@@ -52,6 +54,7 @@ program
52
54
  .name('orchagent')
53
55
  .description('orchagent CLI')
54
56
  .version(package_json_1.default.version)
57
+ .option('--no-progress', 'Disable progress spinners (useful for CI/scripts)')
55
58
  .addHelpText('after', `
56
59
  Quick Reference:
57
60
  run Download and run an agent locally (your machine)
@@ -64,7 +67,22 @@ Documentation: https://docs.orchagent.io
64
67
  orchagent docs agents Building agents guide
65
68
  `);
66
69
  (0, commands_1.registerCommands)(program);
67
- program
68
- .parseAsync(process.argv)
70
+ // Initialize progress setting before parsing
71
+ async function main() {
72
+ // Check config for no_progress setting
73
+ const config = await (0, config_1.loadConfig)();
74
+ if (config.no_progress) {
75
+ (0, spinner_1.setProgressEnabled)(false);
76
+ }
77
+ // Parse args - hook will set noProgress if --no-progress flag is passed
78
+ program.hook('preAction', () => {
79
+ const opts = program.opts();
80
+ if (opts.progress === false) {
81
+ (0, spinner_1.setProgressEnabled)(false);
82
+ }
83
+ });
84
+ await program.parseAsync(process.argv);
85
+ }
86
+ main()
69
87
  .catch(errors_1.exitWithError)
70
88
  .finally(() => (0, analytics_1.shutdownPostHog)());
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setProgressEnabled = setProgressEnabled;
7
+ exports.isProgressEnabled = isProgressEnabled;
8
+ exports.createSpinner = createSpinner;
9
+ exports.withSpinner = withSpinner;
10
+ exports.createProgressSpinner = createProgressSpinner;
11
+ const ora_1 = __importDefault(require("ora"));
12
+ // Global flag to control spinner visibility (set via --no-progress)
13
+ let progressEnabled = true;
14
+ /**
15
+ * Set whether progress spinners are enabled.
16
+ * Disable for CI/scripts with --no-progress flag.
17
+ */
18
+ function setProgressEnabled(enabled) {
19
+ progressEnabled = enabled;
20
+ }
21
+ /**
22
+ * Check if progress spinners are enabled.
23
+ */
24
+ function isProgressEnabled() {
25
+ return progressEnabled;
26
+ }
27
+ /**
28
+ * Create a spinner with the given text.
29
+ * Returns a spinner that auto-disables in non-TTY environments.
30
+ * If progress is disabled (--no-progress), returns a no-op spinner.
31
+ */
32
+ function createSpinner(text) {
33
+ if (!progressEnabled) {
34
+ // Return a no-op spinner that writes to stderr instead
35
+ const noopSpinner = {
36
+ text,
37
+ isSpinning: false,
38
+ start() {
39
+ process.stderr.write(`${text}\n`);
40
+ return noopSpinner;
41
+ },
42
+ stop() {
43
+ return noopSpinner;
44
+ },
45
+ succeed(msg) {
46
+ if (msg)
47
+ process.stderr.write(`${msg}\n`);
48
+ return noopSpinner;
49
+ },
50
+ fail(msg) {
51
+ if (msg)
52
+ process.stderr.write(`${msg}\n`);
53
+ return noopSpinner;
54
+ },
55
+ warn(msg) {
56
+ if (msg)
57
+ process.stderr.write(`${msg}\n`);
58
+ return noopSpinner;
59
+ },
60
+ info(msg) {
61
+ if (msg)
62
+ process.stderr.write(`${msg}\n`);
63
+ return noopSpinner;
64
+ },
65
+ };
66
+ return noopSpinner;
67
+ }
68
+ // ora automatically handles non-TTY by falling back to text output
69
+ return (0, ora_1.default)({
70
+ text,
71
+ stream: process.stderr, // Use stderr so it doesn't interfere with JSON output
72
+ });
73
+ }
74
+ /**
75
+ * Execute an async function with a spinner.
76
+ * Automatically handles success/failure states.
77
+ *
78
+ * @param text - Text to show while operation is running
79
+ * @param fn - Async function to execute
80
+ * @param options - Optional success/fail message overrides
81
+ * @returns Result of the async function
82
+ */
83
+ async function withSpinner(text, fn, options) {
84
+ const spinner = createSpinner(text);
85
+ spinner.start();
86
+ try {
87
+ const result = await fn();
88
+ const successMsg = typeof options?.successText === 'function'
89
+ ? options.successText(result)
90
+ : options?.successText;
91
+ spinner.succeed(successMsg);
92
+ return result;
93
+ }
94
+ catch (err) {
95
+ const failMsg = typeof options?.failText === 'function'
96
+ ? options.failText(err)
97
+ : options?.failText || (err instanceof Error ? err.message : 'Failed');
98
+ spinner.fail(failMsg);
99
+ throw err;
100
+ }
101
+ }
102
+ /**
103
+ * Create a spinner that can be updated with progress info.
104
+ * Useful for operations like downloads where you want to show progress.
105
+ */
106
+ function createProgressSpinner(initialText) {
107
+ const spinner = createSpinner(initialText);
108
+ const updateProgress = (current, total, unit = 'bytes') => {
109
+ if (progressEnabled && spinner.isSpinning) {
110
+ const percent = total > 0 ? Math.round((current / total) * 100) : 0;
111
+ spinner.text = `${initialText} (${percent}% - ${current}/${total} ${unit})`;
112
+ }
113
+ };
114
+ return { spinner, updateProgress };
115
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchagent/cli",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "Command-line interface for the orchagent AI agent marketplace",
5
5
  "license": "MIT",
6
6
  "author": "orchagent <hello@orchagent.io>",
@@ -49,6 +49,7 @@
49
49
  "cli-table3": "^0.6.3",
50
50
  "commander": "^11.1.0",
51
51
  "open": "^8.4.2",
52
+ "ora": "^9.1.0",
52
53
  "posthog-node": "^4.0.0",
53
54
  "yaml": "^2.8.2"
54
55
  },