diff --git a/src/cli/commands/plugin.ts b/src/cli/commands/plugin.ts index 96b84221..25a929b1 100644 --- a/src/cli/commands/plugin.ts +++ b/src/cli/commands/plugin.ts @@ -7,6 +7,7 @@ import { listMarketplacePlugins, getWellKnownMarketplaces, } from '../../core/marketplace.js'; +import { isJsonMode, jsonOutput } from '../json-output.js'; // ============================================================================= // plugin marketplace list @@ -20,6 +21,15 @@ const marketplaceListCmd = command({ try { const marketplaces = await listMarketplaces(); + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'plugin marketplace list', + data: { marketplaces }, + }); + return; + } + if (marketplaces.length === 0) { console.log('No marketplaces registered.\n'); console.log('Add a marketplace with:'); @@ -53,6 +63,10 @@ const marketplaceListCmd = command({ console.log(`Total: ${marketplaces.length} marketplace(s)`); } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace list', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -74,19 +88,43 @@ const marketplaceAddCmd = command({ }, handler: async ({ source, name }) => { try { - console.log(`Adding marketplace: ${source}...`); + if (!isJsonMode()) { + console.log(`Adding marketplace: ${source}...`); + } const result = await addMarketplace(source, name); if (!result.success) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace add', error: result.error ?? 'Unknown error' }); + process.exit(1); + } console.error(`\nError: ${result.error}`); process.exit(1); } + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'plugin marketplace add', + data: { + marketplace: { + name: result.marketplace?.name, + path: result.marketplace?.path, + }, + }, + }); + return; + } + console.log(`\u2713 Marketplace '${result.marketplace?.name}' added`); console.log(` Path: ${result.marketplace?.path}`); } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace add', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -110,14 +148,34 @@ const marketplaceRemoveCmd = command({ const result = await removeMarketplace(name); if (!result.success) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace remove', error: result.error ?? 'Unknown error' }); + process.exit(1); + } console.error(`Error: ${result.error}`); process.exit(1); } + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'plugin marketplace remove', + data: { + name, + path: result.marketplace?.path, + }, + }); + return; + } + console.log(`\u2713 Marketplace '${name}' removed from registry`); console.log(` Note: Files at ${result.marketplace?.path} were not deleted`); } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace remove', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -138,15 +196,32 @@ const marketplaceUpdateCmd = command({ }, handler: async ({ name }) => { try { - console.log( - name - ? `Updating marketplace: ${name}...` - : 'Updating all marketplaces...', - ); - console.log(); + if (!isJsonMode()) { + console.log( + name + ? `Updating marketplace: ${name}...` + : 'Updating all marketplaces...', + ); + console.log(); + } const results = await updateMarketplace(name); + if (isJsonMode()) { + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + jsonOutput({ + success: failed === 0, + command: 'plugin marketplace update', + data: { results, succeeded, failed }, + ...(failed > 0 && { error: `${failed} marketplace(s) failed to update` }), + }); + if (failed > 0) { + process.exit(1); + } + return; + } + if (results.length === 0) { console.log('No marketplaces to update.'); return; @@ -173,6 +248,10 @@ const marketplaceUpdateCmd = command({ } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin marketplace update', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -211,6 +290,14 @@ const pluginListCmd = command({ const marketplaces = await listMarketplaces(); if (marketplaces.length === 0) { + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'plugin list', + data: { plugins: [], total: 0 }, + }); + return; + } console.log('No marketplaces registered.\n'); console.log('Add a marketplace first:'); console.log(' allagents plugin marketplace add '); @@ -223,10 +310,30 @@ const pluginListCmd = command({ : marketplaces; if (marketplace && toList.length === 0) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin list', error: `Marketplace '${marketplace}' not found` }); + process.exit(1); + } console.error(`Marketplace '${marketplace}' not found`); process.exit(1); } + if (isJsonMode()) { + const allPlugins: Array<{ name: string; marketplace: string }> = []; + for (const mp of toList) { + const plugins = await listMarketplacePlugins(mp.name); + for (const plugin of plugins) { + allPlugins.push({ name: plugin.name, marketplace: mp.name }); + } + } + jsonOutput({ + success: true, + command: 'plugin list', + data: { plugins: allPlugins, total: allPlugins.length }, + }); + return; + } + let totalPlugins = 0; for (const mp of toList) { @@ -252,6 +359,10 @@ const pluginListCmd = command({ } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'plugin list', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -271,6 +382,14 @@ const pluginValidateCmd = command({ path: positional({ type: string, displayName: 'path' }), }, handler: async ({ path }) => { + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'plugin validate', + data: { path, valid: false, message: 'not yet implemented' }, + }); + return; + } // TODO: Implement plugin validation console.log(`Validating plugin at: ${path}`); console.log('(validation not yet implemented)'); diff --git a/src/cli/commands/self.ts b/src/cli/commands/self.ts index 1b76fbcd..b28bca8a 100644 --- a/src/cli/commands/self.ts +++ b/src/cli/commands/self.ts @@ -3,6 +3,7 @@ import { execa } from 'execa'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isJsonMode, jsonOutput } from '../json-output.js'; /** * Detect package manager from a script path @@ -54,6 +55,10 @@ const updateCmd = command({ let packageManager: 'bun' | 'npm'; if (npm && bun) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'self update', error: 'Cannot specify both --npm and --bun' }); + process.exit(1); + } console.error('Error: Cannot specify both --npm and --bun'); process.exit(1); } @@ -67,8 +72,11 @@ const updateCmd = command({ } const currentVersion = getCurrentVersion(); - console.log(`Current version: ${currentVersion}`); - console.log(`Updating allagents using ${packageManager}...\n`); + + if (!isJsonMode()) { + console.log(`Current version: ${currentVersion}`); + console.log(`Updating allagents using ${packageManager}...\n`); + } // Build the update command const args = @@ -76,24 +84,60 @@ const updateCmd = command({ ? ['install', '-g', 'allagents@latest'] : ['add', '-g', 'allagents@latest']; - // Execute the update + // In JSON mode, capture output instead of inheriting stdio const result = await execa(packageManager, args, { - stdio: 'inherit', + stdio: isJsonMode() ? 'pipe' : 'inherit', }); if (result.exitCode === 0) { // Get the new version by spawning allagents --version + let newVersion: string | undefined; try { const versionResult = await execa('allagents', ['--version']); - const newVersion = versionResult.stdout.trim(); - console.log(`\nUpdate complete: ${currentVersion} \u2192 ${newVersion}`); + newVersion = versionResult.stdout.trim(); } catch { // Fallback if we can't get new version + } + + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'self update', + data: { + previousVersion: currentVersion, + newVersion: newVersion ?? 'unknown', + packageManager, + }, + }); + return; + } + + if (newVersion) { + console.log(`\nUpdate complete: ${currentVersion} \u2192 ${newVersion}`); + } else { console.log('\nUpdate complete.'); } } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + // Check if package manager is not available + if ( + error.message.includes('ENOENT') || + error.message.includes('not found') + ) { + const detected = detectPackageManager(); + const alternative = detected === 'npm' ? 'bun' : 'npm'; + jsonOutput({ + success: false, + command: 'self update', + error: `${detected} not found. Try using --${alternative} flag.`, + }); + } else { + jsonOutput({ success: false, command: 'self update', error: error.message }); + } + process.exit(1); + } // Check if package manager is not available if ( error.message.includes('ENOENT') || diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index aeaa3bde..f951764e 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -3,64 +3,95 @@ import { initWorkspace } from '../../core/workspace.js'; import { syncWorkspace } from '../../core/sync.js'; import { getWorkspaceStatus } from '../../core/status.js'; import { addPlugin, removePlugin } from '../../core/workspace-modify.js'; +import { isJsonMode, jsonOutput } from '../json-output.js'; + +/** + * Build a JSON-friendly sync data object from a sync result. + */ +function buildSyncData(result: Awaited>) { + return { + copied: result.totalCopied, + generated: result.totalGenerated, + failed: result.totalFailed, + skipped: result.totalSkipped, + plugins: result.pluginResults.map((pr) => ({ + plugin: pr.plugin, + success: pr.success, + error: pr.error, + copied: pr.copyResults.filter((r) => r.action === 'copied').length, + generated: pr.copyResults.filter((r) => r.action === 'generated').length, + failed: pr.copyResults.filter((r) => r.action === 'failed').length, + copyResults: pr.copyResults, + })), + purgedPaths: result.purgedPaths ?? [], + }; +} /** * Run sync and print results. Returns true if sync succeeded. */ -async function runSyncAndPrint(): Promise { - console.log('\nSyncing workspace...\n'); +async function runSyncAndPrint(): Promise<{ ok: boolean; syncData: ReturnType | null }> { + if (!isJsonMode()) { + console.log('\nSyncing workspace...\n'); + } const result = await syncWorkspace(); if (!result.success && result.error) { - console.error(`Sync error: ${result.error}`); - return false; + if (!isJsonMode()) { + console.error(`Sync error: ${result.error}`); + } + return { ok: false, syncData: null }; } - for (const pluginResult of result.pluginResults) { - const status = pluginResult.success ? '\u2713' : '\u2717'; - console.log(`${status} Plugin: ${pluginResult.plugin}`); + const syncData = buildSyncData(result); - if (pluginResult.error) { - console.log(` Error: ${pluginResult.error}`); - } + if (!isJsonMode()) { + for (const pluginResult of result.pluginResults) { + const status = pluginResult.success ? '\u2713' : '\u2717'; + console.log(`${status} Plugin: ${pluginResult.plugin}`); + + if (pluginResult.error) { + console.log(` Error: ${pluginResult.error}`); + } - const copied = pluginResult.copyResults.filter( - (r) => r.action === 'copied', - ).length; - const generated = pluginResult.copyResults.filter( - (r) => r.action === 'generated', - ).length; - const failed = pluginResult.copyResults.filter( - (r) => r.action === 'failed', - ).length; - - if (copied > 0) console.log(` Copied: ${copied} files`); - if (generated > 0) console.log(` Generated: ${generated} files`); - if (failed > 0) { - console.log(` Failed: ${failed} files`); - for (const failedResult of pluginResult.copyResults.filter( + const copied = pluginResult.copyResults.filter( + (r) => r.action === 'copied', + ).length; + const generated = pluginResult.copyResults.filter( + (r) => r.action === 'generated', + ).length; + const failed = pluginResult.copyResults.filter( (r) => r.action === 'failed', - )) { - console.log( - ` - ${failedResult.destination}: ${failedResult.error}`, - ); + ).length; + + if (copied > 0) console.log(` Copied: ${copied} files`); + if (generated > 0) console.log(` Generated: ${generated} files`); + if (failed > 0) { + console.log(` Failed: ${failed} files`); + for (const failedResult of pluginResult.copyResults.filter( + (r) => r.action === 'failed', + )) { + console.log( + ` - ${failedResult.destination}: ${failedResult.error}`, + ); + } } } - } - console.log('\nSync complete:'); - console.log(` Total copied: ${result.totalCopied}`); - if (result.totalGenerated > 0) { - console.log(` Total generated: ${result.totalGenerated}`); - } - if (result.totalFailed > 0) { - console.log(` Total failed: ${result.totalFailed}`); - } - if (result.totalSkipped > 0) { - console.log(` Total skipped: ${result.totalSkipped}`); + console.log('\nSync complete:'); + console.log(` Total copied: ${result.totalCopied}`); + if (result.totalGenerated > 0) { + console.log(` Total generated: ${result.totalGenerated}`); + } + if (result.totalFailed > 0) { + console.log(` Total failed: ${result.totalFailed}`); + } + if (result.totalSkipped > 0) { + console.log(` Total skipped: ${result.totalSkipped}`); + } } - return result.success && result.totalFailed === 0; + return { ok: result.success && result.totalFailed === 0, syncData }; } // ============================================================================= @@ -79,6 +110,16 @@ const initCmd = command({ const targetPath = path ?? '.'; const result = await initWorkspace(targetPath, from ? { from } : {}); + if (isJsonMode()) { + const syncData = result.syncResult ? buildSyncData(result.syncResult) : null; + jsonOutput({ + success: true, + command: 'workspace init', + data: { path: targetPath, syncResult: syncData }, + }); + return; + } + // Print sync results if sync was performed if (result.syncResult) { const syncResult = result.syncResult; @@ -101,6 +142,10 @@ const initCmd = command({ } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace init', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -123,13 +168,15 @@ const syncCmd = command({ }, handler: async ({ offline, dryRun, client }) => { try { - if (dryRun) { - console.log('Dry run mode - no changes will be made\n'); - } - if (client) { - console.log(`Syncing client: ${client}\n`); + if (!isJsonMode()) { + if (dryRun) { + console.log('Dry run mode - no changes will be made\n'); + } + if (client) { + console.log(`Syncing client: ${client}\n`); + } + console.log('Syncing workspace...\n'); } - console.log('Syncing workspace...\n'); const result = await syncWorkspace(process.cwd(), { offline, dryRun, @@ -139,10 +186,29 @@ const syncCmd = command({ // Early exit only for top-level errors (e.g., missing .allagents/workspace.yaml) // Plugin-level errors are handled in the loop below if (!result.success && result.error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace sync', error: result.error }); + process.exit(1); + } console.error(`Error: ${result.error}`); process.exit(1); } + if (isJsonMode()) { + const syncData = buildSyncData(result); + const success = result.success && result.totalFailed === 0; + jsonOutput({ + success, + command: 'workspace sync', + data: syncData, + ...(!success && { error: 'Sync completed with failures' }), + }); + if (!success) { + process.exit(1); + } + return; + } + // Show purge plan in dry-run mode if (dryRun && result.purgedPaths && result.purgedPaths.length > 0) { console.log('Would purge managed directories:'); @@ -215,6 +281,10 @@ const syncCmd = command({ } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace sync', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -236,10 +306,23 @@ const statusCmd = command({ const result = await getWorkspaceStatus(); if (!result.success) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace status', error: result.error ?? 'Unknown error' }); + process.exit(1); + } console.error(`Error: ${result.error}`); process.exit(1); } + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'workspace status', + data: { plugins: result.plugins, clients: result.clients }, + }); + return; + } + // Display plugins console.log(`Plugins (${result.plugins.length}):`); if (result.plugins.length === 0) { @@ -268,6 +351,10 @@ const statusCmd = command({ } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace status', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -291,21 +378,47 @@ const pluginInstallCmd = command({ const result = await addPlugin(plugin); if (!result.success) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace plugin install', error: result.error ?? 'Unknown error' }); + process.exit(1); + } console.error(`Error: ${result.error}`); process.exit(1); } + if (isJsonMode()) { + const { ok, syncData } = await runSyncAndPrint(); + jsonOutput({ + success: ok, + command: 'workspace plugin install', + data: { + plugin, + autoRegistered: result.autoRegistered ?? null, + syncResult: syncData, + }, + ...(!ok && { error: 'Sync completed with failures' }), + }); + if (!ok) { + process.exit(1); + } + return; + } + if (result.autoRegistered) { console.log(`\u2713 Auto-registered marketplace: ${result.autoRegistered}`); } console.log(`\u2713 Installed plugin: ${plugin}`); - const syncOk = await runSyncAndPrint(); + const { ok: syncOk } = await runSyncAndPrint(); if (!syncOk) { process.exit(1); } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace plugin install', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } @@ -330,18 +443,43 @@ const pluginUninstallCmd = command({ const result = await removePlugin(plugin); if (!result.success) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace plugin uninstall', error: result.error ?? 'Unknown error' }); + process.exit(1); + } console.error(`Error: ${result.error}`); process.exit(1); } + if (isJsonMode()) { + const { ok, syncData } = await runSyncAndPrint(); + jsonOutput({ + success: ok, + command: 'workspace plugin uninstall', + data: { + plugin, + syncResult: syncData, + }, + ...(!ok && { error: 'Sync completed with failures' }), + }); + if (!ok) { + process.exit(1); + } + return; + } + console.log(`\u2713 Uninstalled plugin: ${plugin}`); - const syncOk = await runSyncAndPrint(); + const { ok: syncOk } = await runSyncAndPrint(); if (!syncOk) { process.exit(1); } } catch (error) { if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ success: false, command: 'workspace plugin uninstall', error: error.message }); + process.exit(1); + } console.error(`Error: ${error.message}`); process.exit(1); } diff --git a/src/cli/index.ts b/src/cli/index.ts index 82498eb7..e09cc57f 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { workspaceCmd } from './commands/workspace.js'; import { pluginCmd } from './commands/plugin.js'; import { selfCmd } from './commands/self.js'; +import { extractJsonFlag, setJsonMode } from './json-output.js'; // Read version from package.json const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -24,4 +25,8 @@ const app = subcommands({ }, }); -run(app, process.argv.slice(2)); +const rawArgs = process.argv.slice(2); +const { args, json } = extractJsonFlag(rawArgs); +setJsonMode(json); + +run(app, args); diff --git a/src/cli/json-output.ts b/src/cli/json-output.ts new file mode 100644 index 00000000..59644057 --- /dev/null +++ b/src/cli/json-output.ts @@ -0,0 +1,29 @@ +let jsonMode = false; + +export function isJsonMode(): boolean { + return jsonMode; +} + +export function setJsonMode(value: boolean): void { + jsonMode = value; +} + +export interface JsonEnvelope { + success: boolean; + command: string; + data?: unknown; + error?: string; +} + +export function jsonOutput(envelope: JsonEnvelope): void { + console.log(JSON.stringify(envelope, null, 2)); +} + +/** + * Strip --json from args so cmd-ts doesn't see it. + */ +export function extractJsonFlag(args: string[]): { args: string[]; json: boolean } { + const idx = args.indexOf('--json'); + if (idx === -1) return { args, json: false }; + return { args: [...args.slice(0, idx), ...args.slice(idx + 1)], json: true }; +} diff --git a/tests/e2e/cli-json-output.test.ts b/tests/e2e/cli-json-output.test.ts new file mode 100644 index 00000000..c959fd96 --- /dev/null +++ b/tests/e2e/cli-json-output.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'bun:test'; +import { execa } from 'execa'; +import { resolve } from 'node:path'; + +const CLI = resolve(import.meta.dir, '../../dist/index.js'); + +async function runCli(args: string[]) { + try { + const result = await execa('node', [CLI, ...args]); + return { stdout: result.stdout, stderr: result.stderr, exitCode: 0 }; + } catch (error: any) { + return { stdout: error.stdout || '', stderr: error.stderr || '', exitCode: error.exitCode || 1 }; + } +} + +function parseJson(stdout: string) { + return JSON.parse(stdout); +} + +// ============================================================================= +// JSON envelope structure +// ============================================================================= + +describe('CLI --json output envelope', () => { + it('plugin validate --json returns valid JSON with success and command fields', async () => { + const { stdout, exitCode } = await runCli(['plugin', 'validate', '/tmp/test', '--json']); + expect(exitCode).toBe(0); + const json = parseJson(stdout); + expect(json.success).toBe(true); + expect(json.command).toBe('plugin validate'); + expect(json.data).toBeDefined(); + }); + + it('plugin marketplace list --json returns valid JSON with success and command fields', async () => { + const { stdout, exitCode } = await runCli(['plugin', 'marketplace', 'list', '--json']); + expect(exitCode).toBe(0); + const json = parseJson(stdout); + expect(json.success).toBe(true); + expect(json.command).toBe('plugin marketplace list'); + expect(json.data).toBeDefined(); + expect(json.data.marketplaces).toBeInstanceOf(Array); + }); + + it('plugin list --json returns valid JSON with plugins array and total', async () => { + const { stdout, exitCode } = await runCli(['plugin', 'list', '--json']); + expect(exitCode).toBe(0); + const json = parseJson(stdout); + expect(json.success).toBe(true); + expect(json.command).toBe('plugin list'); + expect(json.data.plugins).toBeInstanceOf(Array); + expect(typeof json.data.total).toBe('number'); + }); + + it('plugin marketplace update --json returns valid JSON with results array', async () => { + const { stdout, exitCode } = await runCli(['plugin', 'marketplace', 'update', '--json']); + // May succeed or fail depending on network, but should always be valid JSON + const json = parseJson(stdout); + expect(typeof json.success).toBe('boolean'); + expect(json.command).toBe('plugin marketplace update'); + expect(json.data).toBeDefined(); + expect(json.data.results).toBeInstanceOf(Array); + expect(typeof json.data.succeeded).toBe('number'); + expect(typeof json.data.failed).toBe('number'); + }); +}); + +// ============================================================================= +// Error cases with --json +// ============================================================================= + +describe('CLI --json error cases', () => { + it('workspace sync --json in non-workspace dir returns error JSON with exit code 1', async () => { + const { stdout, exitCode } = await runCli(['workspace', 'sync', '--json']); + expect(exitCode).toBe(1); + const json = parseJson(stdout); + expect(json.success).toBe(false); + expect(json.command).toBe('workspace sync'); + expect(typeof json.error).toBe('string'); + expect(json.error.length).toBeGreaterThan(0); + }); + + it('workspace status --json in non-workspace dir returns error JSON with exit code 1', async () => { + const { stdout, exitCode } = await runCli(['workspace', 'status', '--json']); + expect(exitCode).toBe(1); + const json = parseJson(stdout); + expect(json.success).toBe(false); + expect(json.command).toBe('workspace status'); + expect(typeof json.error).toBe('string'); + }); + + it('workspace plugin install --json with bad plugin returns error JSON', async () => { + const { stdout, exitCode } = await runCli(['workspace', 'plugin', 'install', 'nonexistent-plugin-xyz', '--json']); + expect(exitCode).toBe(1); + const json = parseJson(stdout); + expect(json.success).toBe(false); + expect(json.command).toBe('workspace plugin install'); + expect(typeof json.error).toBe('string'); + }); + + it('workspace plugin uninstall --json with bad plugin returns error JSON', async () => { + const { stdout, exitCode } = await runCli(['workspace', 'plugin', 'uninstall', 'nonexistent-plugin-xyz', '--json']); + expect(exitCode).toBe(1); + const json = parseJson(stdout); + expect(json.success).toBe(false); + expect(json.command).toBe('workspace plugin uninstall'); + expect(typeof json.error).toBe('string'); + }); + + it('plugin marketplace remove --json with nonexistent marketplace returns error JSON', async () => { + const { stdout, exitCode } = await runCli(['plugin', 'marketplace', 'remove', 'nonexistent-mp', '--json']); + expect(exitCode).toBe(1); + const json = parseJson(stdout); + expect(json.success).toBe(false); + expect(json.command).toBe('plugin marketplace remove'); + expect(typeof json.error).toBe('string'); + }); +}); + +// ============================================================================= +// --json flag position +// ============================================================================= + +describe('CLI --json flag position', () => { + it('--json can appear before the subcommand', async () => { + const { stdout, exitCode } = await runCli(['--json', 'plugin', 'validate', '/tmp/test']); + expect(exitCode).toBe(0); + const json = parseJson(stdout); + expect(json.success).toBe(true); + expect(json.command).toBe('plugin validate'); + }); + + it('--json can appear between subcommands', async () => { + const { stdout, exitCode } = await runCli(['plugin', '--json', 'validate', '/tmp/test']); + expect(exitCode).toBe(0); + const json = parseJson(stdout); + expect(json.success).toBe(true); + expect(json.command).toBe('plugin validate'); + }); +}); + +// ============================================================================= +// Human output is unchanged without --json +// ============================================================================= + +describe('CLI output without --json is unchanged', () => { + it('plugin validate without --json outputs human text', async () => { + const { stdout, exitCode } = await runCli(['plugin', 'validate', '/tmp/test']); + expect(exitCode).toBe(0); + expect(stdout).toContain('Validating plugin at: /tmp/test'); + expect(stdout).toContain('(validation not yet implemented)'); + // Ensure it's not JSON + expect(() => JSON.parse(stdout)).toThrow(); + }); + + it('workspace sync without --json in non-workspace dir outputs human error', async () => { + const { stderr, exitCode } = await runCli(['workspace', 'sync']); + expect(exitCode).toBe(1); + expect(stderr).toContain('Error'); + }); + + it('plugin marketplace list without --json outputs human text', async () => { + const { stdout, exitCode } = await runCli(['plugin', 'marketplace', 'list']); + expect(exitCode).toBe(0); + // Should contain human-readable text, not JSON + expect(() => JSON.parse(stdout)).toThrow(); + }); +});