diff --git a/cli-manifest.json b/cli-manifest.json index c3a91a9b..12537b68 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -2,7 +2,7 @@ { "site": "web", "name": "fetch", - "description": "Fetch a URL with local HTTP clients", + "description": "Fetch a URL with local HTTP clients. Use after a blocked, 403, or Cloudflare response; never opens a browser.", "access": "read", "strategy": "public", "browser": false, diff --git a/skill-src/webcmd-browser/SKILL.src.md b/skill-src/webcmd-browser/SKILL.src.md index 60de9aba..a1ee6d44 100644 --- a/skill-src/webcmd-browser/SKILL.src.md +++ b/skill-src/webcmd-browser/SKILL.src.md @@ -31,6 +31,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover ## Session lifecycle - Create an opaque browser session before raw browser work: `webcmd --profile session create`. +- Create a named profile first: `webcmd profile create `. If an explicit profile returns `PROFILE_NOT_FOUND`, create it, then retry session creation. - Raw browser commands require that ID at the root: `webcmd --session browser ...`; the old positional session form is retired. - Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. - `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. Close is blocked while that Session has a live handoff. @@ -42,6 +43,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. ```bash +webcmd profile create work webcmd --profile work session create # Copy the returned full ID: # session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 diff --git a/skill-src/webcmd-usage/SKILL.src.md b/skill-src/webcmd-usage/SKILL.src.md index 2654e3c7..9dd881ce 100644 --- a/skill-src/webcmd-usage/SKILL.src.md +++ b/skill-src/webcmd-usage/SKILL.src.md @@ -63,12 +63,17 @@ Profiles are cookie jars and authentication scope. Sessions are browser workspac ```bash webcmd session create -f json +webcmd profile create work +webcmd --profile work session create -f json webcmd --session session_abc browser snapshot --snapshot-mode act webcmd --session session_abc browser run --stdin webcmd session list webcmd session close session_abc ``` +Create a named profile before using it. If `--profile session create` returns +`PROFILE_NOT_FOUND`, run `webcmd profile create ` and retry. + `webcmd session close ` is blocked while that Session has a live human handoff. Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid. diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index f972f5b7..c9bcfa2c 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -31,6 +31,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover ## Session lifecycle - Create an opaque browser session before raw browser work: `webcmd --profile session create`. +- Create a named profile first: `webcmd profile create `. If an explicit profile returns `PROFILE_NOT_FOUND`, create it, then retry session creation. - Raw browser commands require that ID at the root: `webcmd --session browser ...`; the old positional session form is retired. - Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. - `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. Close is blocked while that Session has a live handoff. @@ -42,6 +43,7 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. ```bash +webcmd profile create work webcmd --profile work session create # Copy the returned full ID: # session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index 56173691..67962b4f 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -63,12 +63,17 @@ Profiles are cookie jars and authentication scope. Sessions are browser workspac ```bash webcmd session create -f json +webcmd profile create work +webcmd --profile work session create -f json webcmd --session session_abc browser snapshot --snapshot-mode act webcmd --session session_abc browser run --stdin webcmd session list webcmd session close session_abc ``` +Create a named profile before using it. If `--profile session create` returns +`PROFILE_NOT_FOUND`, run `webcmd profile create ` and retry. + `webcmd session close ` is blocked while that Session has a live human handoff. Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid. diff --git a/src/browser/command-catalog.ts b/src/browser/command-catalog.ts index acb34c29..e63655c7 100644 --- a/src/browser/command-catalog.ts +++ b/src/browser/command-catalog.ts @@ -158,7 +158,7 @@ export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [ option('trace', 'Trace capture: off, on, or retain-on-failure', { default: 'off' }), option('maxTopLevelKeys', 'Maximum allowed top-level keys', { default: 12 }), ], 'create-or-reuse'), - command('run', 'Run JavaScript with Playwright', 'run', [], [ + command('run', 'Run JavaScript with Playwright. A second overlapping run returns SESSION_BUSY; wait and retry.', 'run', [], [ flag('stdin', 'Read the program from stdin'), option('file', 'Read the program from a file'), option('timeout', 'Execution timeout in seconds'), diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts index 9bf2e9a0..52020797 100644 --- a/src/browser/profile.test.ts +++ b/src/browser/profile.test.ts @@ -4,7 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { ENV_PREFIX } from '../brand.js'; import { ArgumentError } from '../errors.js'; -import { loadProfileConfig, profileListRows, profileRouteParams, resolveProfileSelection, setDefaultProfile } from './profile.js'; +import { createProfile, loadProfileConfig, profileListRows, profileRouteParams, resolveProfileSelection, setDefaultProfile } from './profile.js'; describe('profile selection', () => { let configDir: string; @@ -99,6 +99,31 @@ describe('profileListRows', () => { }); }); +describe('createProfile', () => { + let configDir: string; + + beforeEach(() => { + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-profile-create-')); + vi.stubEnv(`${ENV_PREFIX}_CONFIG_DIR`, configDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + it('creates an alias and is idempotent', () => { + expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: true }); + expect(createProfile('eval-a')).toEqual({ contextId: 'eval-a', alias: 'eval-a', created: false }); + expect(loadProfileConfig().aliases['eval-a']).toBe('eval-a'); + expect(fs.existsSync(path.join(configDir, 'cloak', 'profiles', 'eval-a'))).toBe(true); + }); + + it('rejects an invalid alias', () => { + expect(() => createProfile('../nope')).toThrow(ArgumentError); + }); +}); + describe('setDefaultProfile membership', () => { let configDir: string; diff --git a/src/browser/profile.ts b/src/browser/profile.ts index 661f9fc6..857a3dec 100644 --- a/src/browser/profile.ts +++ b/src/browser/profile.ts @@ -2,7 +2,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; -import { ArgumentError } from '../errors.js'; +import { ArgumentError, CliError, EXIT_CODES } from '../errors.js'; +import { normalizeProfileId, resolveCloakProfileDir } from './runtime/local-cloak/profiles.js'; export const DEFAULT_CONTEXT_ID = 'default'; @@ -89,6 +90,41 @@ export function aliasForContextId(config: ProfileConfig, contextId: string): str return undefined; } +export class ProfileNotFoundError extends CliError { + constructor(name: string, rows: ProfileListRow[]) { + const labels = knownProfileLabels(rows); + const valid = labels.length > 0 ? `Valid profiles: ${labels.join(', ')}` : 'No profiles exist yet.'; + super( + 'PROFILE_NOT_FOUND', + `No profile matches "${name}". ${valid}`, + `usage: ${CLI_COMMAND} --profile session create\nCreate one: ${CLI_COMMAND} profile create ${name}\nList profiles: ${CLI_COMMAND} profile list`, + EXIT_CODES.EMPTY_RESULT, + ); + } +} + +export function createProfile(alias: string): { contextId: string; alias: string; created: boolean } { + const name = normalizeContextId(alias); + if (!name) throw new ArgumentError('profile alias is required', `usage: ${CLI_COMMAND} profile create `); + let contextId: string; + try { + contextId = normalizeProfileId(name); + } catch { + throw new ArgumentError( + `Invalid profile alias "${name}". Use letters, numbers, ".", "_" or "-".`, + `usage: ${CLI_COMMAND} profile create \nexample: ${CLI_COMMAND} profile create work`, + ); + } + const config = loadProfileConfig(); + if (config.aliases[name]) { + return { contextId: config.aliases[name], alias: name, created: false }; + } + config.aliases[name] = contextId; + saveProfileConfig(config); + fs.mkdirSync(resolveCloakProfileDir(contextId), { recursive: true }); + return { contextId, alias: name, created: true }; +} + export function renameProfile(contextId: string, alias: string): ProfileConfig { const normalizedContextId = normalizeContextId(contextId); const normalizedAlias = normalizeContextId(alias); diff --git a/src/browser/run/runner.ts b/src/browser/run/runner.ts index 6a5cc08a..f92de30b 100644 --- a/src/browser/run/runner.ts +++ b/src/browser/run/runner.ts @@ -102,6 +102,13 @@ function normalizeExecutionError(error: unknown): Error { sanitize(unsupported[1] ?? message), ); } + if (/Storage is disabled inside data:/i.test(message) || /Access is denied for this document/i.test(message)) { + return new BrowserRunError( + 'BROWSER_RUN_INVALID_INPUT', + 'localStorage is disabled on data: and about:blank documents.', + 'Navigate to an http(s) URL, or use page.evaluate memory. data: pages cannot use localStorage.', + ); + } if (/interrupted|execution timeout|timed out/i.test(message)) { return new BrowserRunError( 'BROWSER_RUN_TIMEOUT', diff --git a/src/builtin-command-surface.ts b/src/builtin-command-surface.ts index 40c0d993..b827b33a 100644 --- a/src/builtin-command-surface.ts +++ b/src/builtin-command-surface.ts @@ -1,4 +1,4 @@ -import { OUTPUT_FORMAT_HELP } from './command-surface.js'; +import { addOutputFormatOption, OUTPUT_FORMAT_HELP } from './command-surface.js'; import type { Command } from 'commander'; export const LIST_COMMAND_DESCRIPTION = 'List all available CLI commands'; @@ -8,10 +8,9 @@ export const COMPLETION_SHELL_DESCRIPTION = 'Shell type: bash, zsh, or fish'; /** Configure built-in grammar shared by the local and hosted runtimes. */ export function configureListCommandSurface(command: Command): Command { - return command + return addOutputFormatOption(command .description(LIST_COMMAND_DESCRIPTION) - .option('-f, --format ', LIST_FORMAT_DESCRIPTION, 'table') - .option('--tag ', 'Filter commands by exact tag'); + .option('--tag ', 'Filter commands by exact tag')); } /** Configure completion grammar shared by the local and hosted runtimes. */ @@ -23,24 +22,22 @@ export function configureCompletionCommandSurface(command: Command): Command { /** Configure plugin marketplace search grammar shared by local and hosted runtimes. */ export function configurePluginSearchSurface(command: Command): Command { - return command + return addOutputFormatOption(command .description('Search installable marketplace plugins') - .argument('[query]', 'Search query matched against plugin name and description') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .argument('[query]', 'Search query matched against plugin name and description')); } /** Configure plugin installation grammar shared by local and hosted runtimes. */ export function configurePluginInstallSurface(command: Command): Command { return command .description('Install a plugin from a git repository') - .argument('', 'Plugin source (e.g. github:user/repo)'); + .argument('', 'Plugin source (e.g. github:user/repo/)') + .option('--all', 'Install every plugin from a monorepo root'); } /** Configure installed-plugin listing grammar shared by local and hosted runtimes. */ export function configurePluginListSurface(command: Command): Command { - return command - .description('List installed plugins') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + return addOutputFormatOption(command.description('List installed plugins')); } /** Configure plugin uninstall grammar shared by local and hosted runtimes. */ diff --git a/src/cli-argv-preprocess.ts b/src/cli-argv-preprocess.ts index d62bb2c1..ad432250 100644 --- a/src/cli-argv-preprocess.ts +++ b/src/cli-argv-preprocess.ts @@ -156,6 +156,7 @@ function knownCommandOptions(cmd: DashPositionalManifestEntry): Map { } }); + it('lets explicit --format win over --json for early errors', () => { + const previous = process.argv; + process.argv = ['node', 'webcmd', 'validate', 'nope', '--format', 'yaml', '--json']; + try { + expect(capture(new CliError('ARGUMENT', 'No command matches "nope".', undefined, EXIT_CODES.USAGE_ERROR))) + .toMatch(/^ok: false\n/); + } finally { + process.argv = previous; + } + }); + it('omits the stack unless WEBCMD_DEBUG is set', () => { expect(yaml.load(capture(new Error('boom'))) as any).not.toHaveProperty('error.stack'); vi.stubEnv('WEBCMD_DEBUG', '1'); diff --git a/src/cli.test.ts b/src/cli.test.ts index 59a839b5..aed096a6 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -534,7 +534,7 @@ describe('createProgram root help descriptions', () => { expect(descriptionFor(program, 'auth')).toBe('refresh, status'); expect(descriptionFor(program, 'plugin')).toBe('catalog, create, install, list, search, uninstall, update'); expect(descriptionFor(program, 'adapter')).toBe('override, path, reset, source, status'); - expect(descriptionFor(program, 'profile')).toBe('list, rename, use'); + expect(descriptionFor(program, 'profile')).toBe('create, list, rename, use'); expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop'); expect(descriptionFor(program, 'external')).toBe('install, list, register'); }); @@ -1123,7 +1123,7 @@ name: 'search', expect(data.domain).toBe('www.youtube.com'); expect(data.positionals).toMatchObject([{ name: 'bvid', positional: true, required: true }]); expect(data.command_options).toMatchObject([{ name: 'with-comments', default: false }]); - expect(data.common_options.map((option: any) => option.name)).toEqual(['format', 'trace', 'verbose', 'help']); + expect(data.common_options.map((option: any) => option.name)).toEqual(['format', 'json', 'trace', 'verbose', 'help']); expect(data.columns).toEqual(['title', 'url']); expect(data).not.toHaveProperty('args'); } finally { @@ -1268,6 +1268,8 @@ name: 'search', expect(search.options.map(option => option.flags)).toContain('-f, --format '); expect(install.usage()).toBe('[options] '); expect(install.description()).toBe('Install a plugin from a git repository'); + expect(install.registeredArguments[0]?.description).toContain('github:user/repo/'); + expect(install.options.map(option => option.long)).toContain('--all'); }); it('renders adapter namespace structured help preserving original description after applyRootSubcommandSummaries', () => { @@ -1308,9 +1310,9 @@ name: 'search', expect(data).toMatchObject({ namespace: 'profile', description: 'Manage webcmd browser runtime profiles', - command_count: 3, + command_count: 4, }); - expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['list', 'rename', 'use']); + expect(data.commands.map((cmd: any) => cmd.name)).toEqual(['create', 'list', 'rename', 'use']); const list = data.commands.find((cmd: any) => cmd.name === 'list'); expect(list).toMatchObject({ description: 'List Chrome and Chromium profiles available through the Cloak runtime', @@ -2310,6 +2312,28 @@ describe('browser Session lifecycle commands', () => { expect(output).not.toContain('profileId'); }); + it('creates a session under a newly created profile', async () => { + mockSendCommand.mockResolvedValue({ + id: 'session_eval', + kind: 'explicit', + profileId: 'eval-a', + runtimeState: 'idle', + }); + await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'create', 'eval-a']); + await createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'eval-a', 'session', 'create']); + expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'eval-a' }); + }); + + it('rejects an unknown --profile on session create with PROFILE_NOT_FOUND', async () => { + await expect(createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'does-not-exist', 'session', 'create'])) + .rejects.toMatchObject({ + code: 'PROFILE_NOT_FOUND', + message: expect.stringContaining('does-not-exist'), + hint: expect.stringMatching(/profile create[\s\S]*profile list/), + }); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + it('lists persisted Sessions without creating the adapter default when daemon is absent', async () => { const baseDir = path.join(isolatedCliTestHome, '.webcmd'); fs.mkdirSync(baseDir, { recursive: true }); diff --git a/src/cli.ts b/src/cli.ts index ac826330..ecd68d43 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,7 +18,7 @@ import { type CliCommand, getRegistry } from './registry.js'; import './fetch/command.js'; import { commandListPresentation, filterCommandsByTag, toPresentableCommand } from './command-presentation.js'; import { configureCompletionCommandSurface, configureListCommandSurface, configurePluginInstallSurface, configurePluginListSurface, configurePluginSearchSurface } from './builtin-command-surface.js'; -import { applyUnknownOptionContract, CommanderStructuralError, OUTPUT_FORMAT_HELP, resolveOutputFormat } from './command-surface.js'; +import { addOutputFormatOption, applyUnknownOptionContract, CommanderStructuralError, outputFormatIsExplicit, resolveCommandOutputFormat } from './command-surface.js'; import { render as renderOutput, formatErrorEnvelope, errorEnvelopeFormat, requestedFormatFromArgv } from './output.js'; import { PKG_VERSION } from './version.js'; import { printCompletionScript } from './completion.js'; @@ -44,7 +44,7 @@ import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js'; import { enableVerbose, isVerbose, log } from './logger.js'; import { BrowserCommandError, listExistingBrowserTabs, releaseSiteSessionLease, sendCommand } from './browser/daemon-client.js'; import { fetchDaemonStatus } from './browser/daemon-transport.js'; -import { aliasForContextId, loadProfileConfig, profileListRows, profileRouteParams, renameProfile, resolveProfileSelection, setDefaultProfile, type ProfileSelection } from './browser/profile.js'; +import { aliasForContextId, createProfile, loadProfileConfig, normalizeContextId, ProfileNotFoundError, profileListRows, profileRouteParams, renameProfile, resolveKnownProfile, resolveProfileSelection, setDefaultProfile, type ProfileSelection } from './browser/profile.js'; import { formatDaemonVersion, isDaemonStale } from './browser/daemon-version.js'; import { DEFAULT_BROWSER_CONNECT_TIMEOUT } from './browser/config.js'; import { CLI_COMMAND, PACKAGE_NAME } from './brand.js'; @@ -568,6 +568,27 @@ function getSelectedProfileId(command?: Command): string { return getBrowserProfileSelection(command)?.contextId ?? 'default'; } +function explicitProfileName(command?: Command): string | undefined { + const flag = getCommandOption(command, 'profile'); + if (typeof flag === 'string' && flag.trim()) return flag.trim(); + return normalizeContextId(process.env.WEBCMD_PROFILE); +} + +async function requireKnownProfileId(command?: Command): Promise { + const profileId = getSelectedProfileId(command); + const requested = explicitProfileName(command); + if (!requested) return profileId; + const status = await fetchDaemonStatus(); + const connected = status && !isDaemonStale(status, PKG_VERSION) && Array.isArray(status.profiles) + ? status.profiles + : []; + const rows = profileListRows(loadProfileConfig(), connected); + if (!resolveKnownProfile(requested, rows) && !resolveKnownProfile(profileId, rows)) { + throw new ProfileNotFoundError(requested, rows); + } + return profileId; +} + function formatHandoff(row: BrowserSessionListRow): string { return row.handoff ? `${row.handoff.site} until ${row.handoff.expiresAt}` : ''; } @@ -640,7 +661,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi const listCmd = configureListCommandSurface(program.command('list')) .action((opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(listCmd, opts.format); if (fmt === null) return; const externalClis = fmt === 'table' ? loadExternalClis() : []; const overrides = readOverrideRecords(); @@ -671,7 +692,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi } renderOutput(presentation.rows, { fmt, - fmtExplicit: listCmd.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(listCmd), columns: presentation.columns, title: 'webcmd/list', source: 'webcmd list', @@ -680,15 +701,14 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi // ── Built-in: validate / verify ─────────────────────────────────────────── - const validateCmd = program + const validateCmd = addOutputFormatOption(program .command('validate') .description('Validate CLI definitions') - .argument('[target]', 'site or site/name') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .argument('[target]', 'site or site/name')); validateCmd.action(async (target, opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(validateCmd, opts.format); if (fmt === null) return; - const fmtExplicit = validateCmd.getOptionValueSource('format') === 'cli'; + const fmtExplicit = outputFormatIsExplicit(validateCmd); const { validateClisWithTarget, renderValidationReport } = await import('./validate.js'); const report = validateClisWithTarget([BUILTIN_CLIS, USER_CLIS], target); if (fmt === 'table') console.log(renderValidationReport(report)); @@ -696,16 +716,15 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi process.exitCode = report.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR; }); - const verifyCmd = program + const verifyCmd = addOutputFormatOption(program .command('verify') .description('Validate + smoke test') .argument('[target]') - .option('--smoke', 'Run smoke tests', false) - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .option('--smoke', 'Run smoke tests', false)); verifyCmd.action(async (target, opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(verifyCmd, opts.format); if (fmt === null) return; - const fmtExplicit = verifyCmd.getOptionValueSource('format') === 'cli'; + const fmtExplicit = outputFormatIsExplicit(verifyCmd); const { verifyClis, renderVerifyReport } = await import('./verify.js'); const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke }); if (fmt === 'table') console.log(renderVerifyReport(r)); @@ -724,24 +743,22 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi source, }); - const skillsCmd = program + const skillsCmd = addOutputFormatOption(program .command('skills') - .description('List, add, update, and remove bundled Webcmd skills') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .description('List, add, update, and remove bundled Webcmd skills')); skillsCmd.action(async (opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(skillsCmd, opts.format); if (fmt === null) return; - await renderSkillsList(fmt, skillsCmd.getOptionValueSource('format') === 'cli', 'webcmd skills'); + await renderSkillsList(fmt, outputFormatIsExplicit(skillsCmd), 'webcmd skills'); }); - const skillsListCmd = skillsCmd + const skillsListCmd = addOutputFormatOption(skillsCmd .command('list') - .description('List bundled Webcmd skills') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .description('List bundled Webcmd skills')); skillsListCmd.action(async (opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(skillsListCmd, opts.format); if (fmt === null) return; - await renderSkillsList(fmt, skillsListCmd.getOptionValueSource('format') === 'cli', 'webcmd skills list'); + await renderSkillsList(fmt, outputFormatIsExplicit(skillsListCmd), 'webcmd skills list'); }); skillsCmd @@ -825,10 +842,10 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi .description('Scan adapters for agent-native convention violations') .argument('[target]', 'site or site/name') .option('--site ', 'Limit audit to one site') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') .option('--strict', 'Exit non-zero when violations are found', false); + addOutputFormatOption(conventionAuditCmd); conventionAuditCmd.action(async (target, opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(conventionAuditCmd, opts.format); if (fmt === null) return; const { runConventionAudit, renderConventionAuditText } = await import('./convention-audit.js'); const report = runConventionAudit({ @@ -843,25 +860,23 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi const sessionCmd = program.command('session').description('Create, list, and close browser Sessions'); - sessionCmd + const sessionCreateCmd = addOutputFormatOption(sessionCmd .command('create') - .description('Create a new opaque browser Session ID for the selected Profile') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'yaml') - .action(async (opts, command) => { - const fmt = resolveOutputFormat(opts.format); + .description('Create a new opaque browser Session ID for the selected Profile'), 'yaml'); + sessionCreateCmd.action(async (opts, command) => { + const fmt = resolveCommandOutputFormat(command, opts.format); if (fmt === null) return; - const profileId = getSelectedProfileId(command); + const profileId = await requireKnownProfileId(command); const data = await sendCommand('session-create', { contextId: profileId }); - await renderOutput(sessionCreateOutput(data), { fmt, fmtExplicit: command.getOptionValueSource('format') === 'cli', columns: ['id', 'kind', 'runtimeState'] }); + await renderOutput(sessionCreateOutput(data), { fmt, fmtExplicit: outputFormatIsExplicit(command), columns: ['id', 'kind', 'runtimeState'] }); }); - sessionCmd + const sessionListCmd = addOutputFormatOption(sessionCmd .command('list') .description('List browser Sessions for the selected Profile') - .option('--limit ', 'Maximum Sessions to return (1-100)', parseSessionListLimit, 20) - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') - .action(async (opts, command) => { - const fmt = resolveOutputFormat(opts.format); + .option('--limit ', 'Maximum Sessions to return (1-100)', parseSessionListLimit, 20)); + sessionListCmd.action(async (opts, command) => { + const fmt = resolveCommandOutputFormat(command, opts.format); if (fmt === null) return; const profileId = getSelectedProfileId(command); let rows: BrowserSessionListRow[]; @@ -872,21 +887,20 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi rows = new LocalBrowserSessionStore().list(profileId, opts.limit); } const output = rows.map((row) => ({ ...row, handoff: formatHandoff(row) })); - if (output.length === 0 && fmt === 'table' && command.getOptionValueSource('format') !== 'cli') { + if (output.length === 0 && fmt === 'table' && !outputFormatIsExplicit(command)) { console.log(`No browser Sessions found for Profile ${profileId}.`); return; } - await renderOutput(output, { fmt, fmtExplicit: command.getOptionValueSource('format') === 'cli', columns: ['id', 'kind', 'runtimeState', 'handoff'] }); + await renderOutput(output, { fmt, fmtExplicit: outputFormatIsExplicit(command), columns: ['id', 'kind', 'runtimeState', 'handoff'] }); }); - sessionCmd + const sessionCloseCmd = addOutputFormatOption(sessionCmd .command('close') .description('Close a browser Session runtime without deleting its durable record') .argument('', 'Existing opaque Session ID from `webcmd session create`') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'yaml') - .option('--force', 'Close even while the Session is busy or paused for handoff') - .action(async (sessionId: string, opts: { format?: string; force?: boolean }, command) => { - const fmt = resolveOutputFormat(opts.format); + .option('--force', 'Close even while the Session is busy or paused for handoff'), 'yaml'); + sessionCloseCmd.action(async (sessionId: string, opts: { format?: string; force?: boolean }, command) => { + const fmt = resolveCommandOutputFormat(command, opts.format); if (fmt === null) return; const profileId = getSelectedProfileId(command); requireSessionIdShape(sessionId); @@ -898,7 +912,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi session: sessionId, force: opts.force === true, }); - await renderOutput(data, { fmt, fmtExplicit: command.getOptionValueSource('format') === 'cli' }); + await renderOutput(data, { fmt, fmtExplicit: outputFormatIsExplicit(command) }); return; } catch (error) { if (status || opts.force === true) throw error; @@ -906,11 +920,11 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi } if (opts.force === true) { const data = await sendCommand('session-close', { contextId: profileId, session: sessionId, force: true }); - await renderOutput(data, { fmt, fmtExplicit: command.getOptionValueSource('format') === 'cli' }); + await renderOutput(data, { fmt, fmtExplicit: outputFormatIsExplicit(command) }); return; } new LocalBrowserSessionStore().require(profileId, sessionId); - await renderOutput({ closed: false, alreadyIdle: true, session: sessionId }, { fmt, fmtExplicit: command.getOptionValueSource('format') === 'cli' }); + await renderOutput({ closed: false, alreadyIdle: true, session: sessionId }, { fmt, fmtExplicit: outputFormatIsExplicit(command) }); }); // ── Built-in: browser (browser control for Claude Code skill) ─────────────── @@ -1016,12 +1030,12 @@ cli({ .option('--seed-args ', 'Seed args when no fixture exists; use JSON array/object for multiple args or flags') .option('--trace ', 'Trace capture for the adapter subprocess: off, on, retain-on-failure', 'off') .option('--max-top-level-keys ', 'Override the row-shape top-level key cap (default: 12) for adapters whose rows are wide by design') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') .description('Execute an adapter and validate output; uses fixture at ~/.webcmd/sites//verify/.json when present'); + addOutputFormatOption(browserVerifyCmd); browserVerifyCmd.action(async (name: string, opts: { fixture?: boolean; writeFixture?: boolean; updateFixture?: boolean; strictMemory?: boolean; seedArgs?: string; trace?: string; maxTopLevelKeys?: string; format?: string } = {}) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(browserVerifyCmd, opts.format); if (fmt === null) return; - const fmtExplicit = browserVerifyCmd.getOptionValueSource('format') === 'cli'; + const fmtExplicit = outputFormatIsExplicit(browserVerifyCmd); const asTable = fmt === 'table'; // Prose-only progress/detail lines. The structured report below carries the // same facts as data; -f json/yaml callers get the report, not this text. @@ -1263,6 +1277,13 @@ cli({ ...(error.details !== undefined ? { details: error.details } : {}), }, }, null, 2)); + } else if (error instanceof CliError) { + const payload = { error: { code: error.code, message: error.message, ...(error.hint ? { hint: error.hint } : {}) } }; + console.log(JSON.stringify(payload, null, 2)); + process.stderr.write(`${error.code}: ${error.message}\n`); + if (error.hint) process.stderr.write(`${error.hint}\n`); + process.exitCode = error.exitCode; + return; } log.error(error instanceof CliError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error)); if (error instanceof CliError && error.hint) log.error(error.hint); @@ -1290,7 +1311,7 @@ cli({ })))); const runCommand = withBrowserVerbose(new Command('run') - .description('Run JavaScript with Playwright') + .description('Run JavaScript with Playwright. A second overlapping run returns SESSION_BUSY; wait and retry.') .option('--stdin', 'Read the program from stdin') .option('--file ', 'Read the program from a file') .addOption(new Option('--timeout ', 'Execution timeout in seconds').argParser(browserOptionValueParser('run', 'timeout')!)) @@ -1346,13 +1367,13 @@ cli({ const doctorCmd = program .command('doctor') .description('Diagnose webcmd browser bridge connectivity') - .option('-v, --verbose', 'Debug output') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .option('-v, --verbose', 'Debug output'); + addOutputFormatOption(doctorCmd); doctorCmd.action(async (opts) => { applyVerbose(opts); - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(doctorCmd, opts.format); if (fmt === null) return; - const fmtExplicit = doctorCmd.getOptionValueSource('format') === 'cli'; + const fmtExplicit = outputFormatIsExplicit(doctorCmd); const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js'); const report = await runBrowserDoctor({ cliVersion: PKG_VERSION }); if (fmt === 'table') console.log(renderBrowserDoctorReport(report)); @@ -1390,11 +1411,11 @@ cli({ const originalPluginDescription = pluginCmd.description(); configurePluginInstallSurface(pluginCmd.command('install')) - .action(async (source: string) => { + .action(async (source: string, opts: { all?: boolean }) => { const { installPlugin } = await import('./plugin.js'); const { discoverPlugins } = await import('./discovery.js'); try { - const result = installPlugin(source); + const result = installPlugin(source, { all: opts.all === true }); await discoverPlugins(); if (Array.isArray(result)) { if (result.length === 0) { @@ -1431,7 +1452,7 @@ cli({ .description('Update a plugin (or all plugins) to the latest version') .argument('[name]', 'Plugin name (required unless --all is passed)') .option('--all', 'Update all installed plugins') - .option('--force', 'Discard uncommitted changes in the plugin directory') + .option('--force', 'Discard uncommitted changes in this plugin\'s files') .action(async (name: string | undefined, opts: { all?: boolean; force?: boolean }) => { if (!name && !opts.all) { console.error('Error: Please specify a plugin name or use the --all flag.'); @@ -1496,14 +1517,14 @@ cli({ const pluginListCmd = configurePluginListSurface(pluginCmd.command('list')); pluginListCmd.action(async (opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(pluginListCmd, opts.format); if (fmt === null) return; const { listPlugins } = await import('./plugin.js'); const plugins = listPlugins(); if (fmt !== 'table') { renderOutput(plugins, { fmt, - fmtExplicit: pluginListCmd.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(pluginListCmd), columns: ['name', 'commands', 'source', 'overrides', 'updateAvailable'], title: `${CLI_COMMAND}/plugins`, source: `${CLI_COMMAND} plugin list`, @@ -1512,7 +1533,7 @@ cli({ } if (plugins.length === 0) { console.log(' No plugins installed.'); - console.log(` Install one with: ${CLI_COMMAND} plugin install github:user/repo`); + console.log(` Install one with: ${CLI_COMMAND} plugin install github:user/repo/`); return; } console.log(); @@ -1564,12 +1585,11 @@ cli({ .command('catalog') .description('Manage plugin marketplace sources'); - const catalogListCmd = catalogCmd + const catalogListCmd = addOutputFormatOption(catalogCmd .command('list') - .description('List configured plugin marketplace sources') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .description('List configured plugin marketplace sources')); catalogListCmd.action(async (opts: { format?: string }) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(catalogListCmd, opts.format); if (fmt === null) return; const { readCatalog } = await import('./plugin-catalog.js'); try { @@ -1580,7 +1600,7 @@ cli({ } renderOutput(catalog.sources, { fmt, - fmtExplicit: catalogListCmd.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(catalogListCmd), columns: ['id', 'source', 'manifestUrl'], title: `${CLI_COMMAND}/plugin-catalog`, source: `${CLI_COMMAND} plugin catalog list`, @@ -1591,20 +1611,19 @@ cli({ } }); - const catalogAddCmd = catalogCmd + const catalogAddCmd = addOutputFormatOption(catalogCmd .command('add') .description('Add a plugin marketplace source') - .argument('', 'Marketplace source, e.g. github:owner/repo') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .argument('', 'Marketplace source, e.g. github:owner/repo')); catalogAddCmd.action(async (source: string, opts: { format?: string }) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(catalogAddCmd, opts.format); if (fmt === null) return; const { addCatalogSource } = await import('./plugin-catalog.js'); try { const added = await addCatalogSource(source); renderOutput(fmt === 'json' ? added : [added], { fmt, - fmtExplicit: catalogAddCmd.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(catalogAddCmd), columns: ['id', 'source', 'manifestUrl'], title: `${CLI_COMMAND}/plugin-catalog`, source: `${CLI_COMMAND} plugin catalog add`, @@ -1632,13 +1651,13 @@ cli({ const pluginSearchCmd = configurePluginSearchSurface(pluginCmd.command('search')); pluginSearchCmd.action(async (query: string | undefined, opts: { format?: string }) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(pluginSearchCmd, opts.format); if (fmt === null) return; const { readCatalog, searchCatalogPlugins } = await import('./plugin-catalog.js'); try { const catalog = readCatalog(); const result = await searchCatalogPlugins(catalog, { query }); - const fmtExplicit = pluginSearchCmd.getOptionValueSource('format') === 'cli'; + const fmtExplicit = outputFormatIsExplicit(pluginSearchCmd); if (fmt === 'json') { renderOutput(result, { fmt }); } else { @@ -1646,7 +1665,7 @@ cli({ renderOutput(result.plugins, { fmt, fmtExplicit, - columns: ['name', 'description', 'version', 'sourceId', 'installSource', 'webcmd'], + columns: ['installSource', 'name', 'description', 'version', 'sourceId', 'webcmd'], title: `${CLI_COMMAND}/plugin-search`, source: `${CLI_COMMAND} plugin search`, }); @@ -1718,12 +1737,11 @@ cli({ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing. const originalAdapterDescription = adapterCmd.description(); - const adapterStatusCmd = adapterCmd + const adapterStatusCmd = addOutputFormatOption(adapterCmd .command('status') - .description('List local adapters in ~/.webcmd/clis/') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .description('List local adapters in ~/.webcmd/clis/')); adapterStatusCmd.action(async (opts: { format?: string }) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(adapterStatusCmd, opts.format); if (fmt === null) return; let userClisListed = false; try { @@ -1732,7 +1750,7 @@ cli({ const userSites = userEntries.filter(e => e.isDirectory() && e.name !== '.base').map(e => e.name).sort(); if (userSites.length === 0) { if (fmt !== 'table') { - renderOutput([], { fmt, fmtExplicit: adapterStatusCmd.getOptionValueSource('format') === 'cli' }); + renderOutput([], { fmt, fmtExplicit: outputFormatIsExplicit(adapterStatusCmd) }); return; } console.log('No local adapters installed.'); @@ -1767,7 +1785,7 @@ cli({ if (fmt !== 'table') { renderOutput(adapters, { fmt, - fmtExplicit: adapterStatusCmd.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(adapterStatusCmd), columns: ['command', 'kind', 'plugin', 'reconciliationNeeded', 'orphaned'], title: `${CLI_COMMAND}/adapter-status`, source: `${CLI_COMMAND} adapter status`, @@ -1789,7 +1807,7 @@ cli({ } } catch (err) { if (!userClisListed && (err as NodeJS.ErrnoException).code === 'ENOENT') { - if (fmt !== 'table') renderOutput([], { fmt, fmtExplicit: adapterStatusCmd.getOptionValueSource('format') === 'cli' }); + if (fmt !== 'table') renderOutput([], { fmt, fmtExplicit: outputFormatIsExplicit(adapterStatusCmd) }); else console.log('No local adapters installed.'); return; } @@ -1884,12 +1902,11 @@ cli({ // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing. const originalProfileDescription = profileCmd.description(); - const profileListCmd = profileCmd + const profileListCmd = addOutputFormatOption(profileCmd .command('list') - .description('List Chrome and Chromium profiles available through the Cloak runtime') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') - .action(async (opts: { format?: string }, command: Command) => { - const fmt = resolveOutputFormat(opts.format); + .description('List Chrome and Chromium profiles available through the Cloak runtime')); + profileListCmd.action(async (opts: { format?: string }, command: Command) => { + const fmt = resolveCommandOutputFormat(command, opts.format); if (fmt === null) return; const status = await fetchDaemonStatus(); const config = loadProfileConfig(); @@ -1917,7 +1934,7 @@ cli({ // Saved-but-disconnected profiles are included: they exist, they are just not live. await renderOutput(profileListRows(config, profiles), { fmt, - fmtExplicit: command.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(command), columns: ['contextId', 'alias', 'default', 'connected', 'runtimeVersion'], }); return; @@ -1964,6 +1981,17 @@ cli({ } }); + profileCmd + .command('create') + .description('Create a Cloak profile alias') + .argument('', 'Local alias, e.g. work or personal') + .action((alias: string) => { + const result = createProfile(alias); + console.log(result.created + ? `Profile ${result.alias} created (contextId: ${result.contextId}).` + : `Profile ${result.alias} already exists (contextId: ${result.contextId}).`); + }); + profileCmd .command('rename') .description('Assign a local alias to an available Cloak profile') @@ -1997,14 +2025,13 @@ cli({ const daemonCmd = program.command('daemon').description('Manage the webcmd daemon'); // Snapshot before applyRootSubcommandSummaries() rewrites .description() to a child-name listing. const originalDaemonDescription = daemonCmd.description(); - const daemonStatusCmd = daemonCmd + const daemonStatusCmd = addOutputFormatOption(daemonCmd .command('status') - .description('Show daemon status') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .description('Show daemon status')); daemonStatusCmd.action(async (opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(daemonStatusCmd, opts.format); if (fmt === null) return; - await daemonStatus({ fmt, fmtExplicit: daemonStatusCmd.getOptionValueSource('format') === 'cli' }); + await daemonStatus({ fmt, fmtExplicit: outputFormatIsExplicit(daemonStatusCmd) }); }); daemonCmd .command('stop') @@ -2048,12 +2075,11 @@ cli({ registerExternalCli(name, { binary: opts.binary, install: opts.install, description: opts.desc }); }); - const externalListCmd = externalCmd + const externalListCmd = addOutputFormatOption(externalCmd .command('list') - .description('List registered external CLIs') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + .description('List registered external CLIs')); externalListCmd.action((opts) => { - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(externalListCmd, opts.format); if (fmt === null) return; const rows = loadExternalClis().map((ext) => ({ name: ext.name, @@ -2066,7 +2092,7 @@ cli({ })); renderOutput(rows, { fmt, - fmtExplicit: externalListCmd.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(externalListCmd), columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'], title: 'webcmd/external/list', source: 'webcmd external list', diff --git a/src/command-presentation.ts b/src/command-presentation.ts index 11a9dafa..b0c0b17c 100644 --- a/src/command-presentation.ts +++ b/src/command-presentation.ts @@ -1,5 +1,5 @@ import { CLI_COMMAND } from './brand.js'; -import { OUTPUT_FORMAT_HELP, OUTPUT_FORMATS } from './command-surface.js'; +import { JSON_FORMAT_ALIAS_HELP, OUTPUT_FORMAT_HELP, OUTPUT_FORMATS } from './command-surface.js'; import type { Arg } from './registry.js'; export interface PresentableCommand { @@ -94,6 +94,12 @@ const COMMON_OPTIONS = [ default: 'table', choices: [...OUTPUT_FORMATS], }, + { + flags: '--json', + name: 'json', + help: JSON_FORMAT_ALIAS_HELP, + default: false, + }, { flags: '--trace ', name: 'trace', diff --git a/src/command-surface.test.ts b/src/command-surface.test.ts index 3a0142ef..51f33735 100644 --- a/src/command-surface.test.ts +++ b/src/command-surface.test.ts @@ -89,6 +89,23 @@ describe('parseCommandSurface', () => { }); }); + it('treats --json as an explicit --format json alias', () => { + expect(parseCommandSurface(metadata, ['needle', '--json'])).toMatchObject({ + format: 'json', + formatExplicit: true, + }); + }); + + it.each([ + ['needle', '--json', '--format', 'yaml'], + ['needle', '--format', 'yaml', '--json'], + ])('lets an explicit --format win over --json for %j', (...argv) => { + expect(parseCommandSurface(metadata, argv)).toMatchObject({ + format: 'yaml', + formatExplicit: true, + }); + }); + it.each<{ input: string; normalized: OutputFormat }>([ { input: 'table', normalized: 'table' }, { input: 'plain', normalized: 'plain' }, @@ -196,6 +213,7 @@ describe('configureCommandSurface', () => { '--enabled', '--label', '--format', + '--json', '--trace', '--verbose', ])); @@ -215,6 +233,7 @@ describe('unknown option contract', () => { expect(error.output).toContain("error: unknown option '--unknown'"); expect(error.output).toContain('help: valid flags for `webcmd demo search`:'); expect(error.output).toContain('--format'); + expect(error.output).toContain('--json'); expect(error.output).toContain('--trace'); } }); diff --git a/src/command-surface.ts b/src/command-surface.ts index 94e4998d..ace98c29 100644 --- a/src/command-surface.ts +++ b/src/command-surface.ts @@ -8,6 +8,8 @@ export const OUTPUT_FORMATS = ['table', 'plain', 'json', 'yaml', 'md', 'csv'] as export const OUTPUT_FORMAT_ALIASES: Readonly> = { yml: 'yaml', markdown: 'md' }; /** Shared option description so every `-f/--format` flag advertises the same formats. */ export const OUTPUT_FORMAT_HELP = `Output format: ${OUTPUT_FORMATS.join(', ')}`; +/** Shared `--json` description so the flag is listed wherever `--format` is. */ +export const JSON_FORMAT_ALIAS_HELP = 'Alias of --format json'; export const TRACE_MODES = ['off', 'on', 'retain-on-failure'] as const; const BROWSER_WINDOW_MODES = ['foreground', 'background'] as const; @@ -137,8 +139,7 @@ export function configureCommandSurface(command: Command, metadata: CommandSurfa else command.option(flag, arg.help ?? ''); } - command - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') + addOutputFormatOption(command) .option('--trace ', `Trace capture: ${TRACE_MODES.join(', ')}`, 'off') .option('-v, --verbose', 'Debug output', false); @@ -228,8 +229,8 @@ export function parseCommandSurface( // format validation, and trace validation occurs inside executeCommand after // both. Commander has already enforced required positionals/options. const args = coerceCommandArguments(metadata.args, input); - const formatExplicit = command.getOptionValueSource('format') === 'cli'; - const format = parseOutputFormat(formatExplicit ? parsedOptions.format : defaultFormat); + const formatExplicit = outputFormatIsExplicit(command); + const format = parseOutputFormat(formatExplicit ? requestedOutputFormat(command, parsedOptions.format) : defaultFormat); const trace = parseTraceMode(parsedOptions.trace ?? 'off'); const verbose = parsedOptions.verbose === true; @@ -354,6 +355,29 @@ export function resolveOutputFormat(raw: string | undefined): OutputFormat | nul } } +/** Register `-f/--format` plus the `--json` alias on one command. */ +export function addOutputFormatOption(command: Command, defaultFormat = 'table'): Command { + return command + .option('-f, --format ', OUTPUT_FORMAT_HELP, defaultFormat) + .option('--json', JSON_FORMAT_ALIAS_HELP, false); +} + +export function outputFormatIsExplicit(command: Command): boolean { + return command.getOptionValueSource('format') === 'cli' || command.getOptionValueSource('json') === 'cli'; +} + +/** Resolve `--json` onto `--format json` unless `--format` was also passed. */ +export function requestedOutputFormat(command: Command, format: unknown): unknown { + return command.getOptionValueSource('json') === 'cli' && command.getOptionValueSource('format') !== 'cli' + ? 'json' + : format; +} + +export function resolveCommandOutputFormat(command: Command, format: unknown): OutputFormat | null { + const raw = requestedOutputFormat(command, format); + return resolveOutputFormat(raw === undefined ? undefined : String(raw)); +} + function parseTraceMode(value: unknown): TraceMode { if (TRACE_MODES.includes(value as TraceMode)) return value as TraceMode; throw new ArgumentError(`--trace must be one of: ${TRACE_MODES.join(', ')}. Received: "${String(value)}"`); diff --git a/src/commanderAdapter.test.ts b/src/commanderAdapter.test.ts index 84dc894a..ba28ecb8 100644 --- a/src/commanderAdapter.test.ts +++ b/src/commanderAdapter.test.ts @@ -313,6 +313,19 @@ describe('commanderAdapter default formats', () => { ); }); + it('treats --json as an explicit json format', async () => { + const program = new Command(); + const siteCmd = program.command('gemini'); + registerCommandToProgram(siteCmd, cmd); + + await program.parseAsync(['node', 'webcmd', 'gemini', 'ask', '--json']); + + expect(mockRenderOutput).toHaveBeenCalledWith( + [{ response: 'hello' }], + expect.objectContaining({ fmt: 'json', fmtExplicit: true }), + ); + }); + it('respects an explicit user format over the command defaultFormat', async () => { const program = new Command(); const siteCmd = program.command('gemini'); diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 6624fd62..d2e252d4 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -14,7 +14,7 @@ import { Command } from 'commander'; import { log } from './logger.js'; import { type CliCommand, fullName, getRegistry } from './registry.js'; import { errorEnvelopeFormat, formatErrorEnvelope, render as renderOutput } from './output.js'; -import { configureCommandSurface, parseOutputFormat, prepareCommandArgs } from './command-surface.js'; +import { configureCommandSurface, outputFormatIsExplicit, parseOutputFormat, prepareCommandArgs, requestedOutputFormat } from './command-surface.js'; import { commandHelpData, formatCommandHelpText, @@ -96,8 +96,8 @@ export function registerCommandToProgram( const kwargs = prepareCommandArgs(cmd, rawKwargs); const verbose = optionsRecord.verbose === true; - let format = parseOutputFormat(optionsRecord.format ?? 'table'); - const formatExplicit = subCmd.getOptionValueSource('format') === 'cli'; + let format = parseOutputFormat(requestedOutputFormat(subCmd, optionsRecord.format ?? 'table')); + const formatExplicit = outputFormatIsExplicit(subCmd); if (verbose) process.env.WEBCMD_VERBOSE = '1'; const globals = typeof subCmd.optsWithGlobals === 'function' ? subCmd.optsWithGlobals() as Record : {}; const result = cmd.clientOwned && cmd.browser === false diff --git a/src/commands/auth.ts b/src/commands/auth.ts index d881b725..d22a8b53 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { Command, InvalidArgumentError, Option } from 'commander'; -import { OUTPUT_FORMAT_HELP, resolveOutputFormat } from '../command-surface.js'; +import { addOutputFormatOption, outputFormatIsExplicit, resolveCommandOutputFormat } from '../command-surface.js'; import { AuthRequiredError, CliError, getErrorMessage } from '../errors.js'; import { executeCommand } from '../execution.js'; import { enableVerbose } from '../logger.js'; @@ -467,13 +467,13 @@ export function registerAuthCommands(program: Command): Command { .option('--concurrency ', 'Maximum sites to check at once') .option('--timeout ', 'Per-site timeout in seconds') .addOption(new Option('--only ', 'Filter rows by status').choices(['all', 'logged-in', 'not-logged-in', 'unknown', 'error']).default('all')) - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') - .option('-v, --verbose', 'Debug output', false) - .action(async (opts) => { + .option('-v, --verbose', 'Debug output', false); + addOutputFormatOption(status); + status.action(async (opts) => { // Both auth probes drive the browser/daemon stack, so verbose mode surfaces // the CDP diagnostics those layers already gate on `isVerbose()` (#174). enableVerbose(opts.verbose === true); - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(status, opts.format); if (fmt === null) return; const globals = typeof status.optsWithGlobals === 'function' ? status.optsWithGlobals() as Record : {}; const rows = await collectAuthStatus({ @@ -486,7 +486,7 @@ export function registerAuthCommands(program: Command): Command { }); renderOutput(rows, { fmt, - fmtExplicit: status.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(status), columns: ['site', 'status', 'identity', 'checked', 'error'], title: 'webcmd/auth status', source: opts.full ? 'full whoami probe' : 'quick auth check', @@ -500,11 +500,11 @@ export function registerAuthCommands(program: Command): Command { .option('--all', 'Ignore the 24h refresh throttle and force every selected site', false) .option('--concurrency ', 'Maximum sites to refresh at once') .option('--timeout ', 'Per-site timeout in seconds') - .option('-f, --format ', OUTPUT_FORMAT_HELP, 'table') - .option('-v, --verbose', 'Debug output', false) - .action(async (opts) => { + .option('-v, --verbose', 'Debug output', false); + addOutputFormatOption(refresh); + refresh.action(async (opts) => { enableVerbose(opts.verbose === true); - const fmt = resolveOutputFormat(opts.format); + const fmt = resolveCommandOutputFormat(refresh, opts.format); if (fmt === null) return; const globals = typeof refresh.optsWithGlobals === 'function' ? refresh.optsWithGlobals() as Record : {}; const rows = await collectAuthRefresh({ @@ -516,7 +516,7 @@ export function registerAuthCommands(program: Command): Command { }); renderOutput(rows, { fmt, - fmtExplicit: refresh.getOptionValueSource('format') === 'cli', + fmtExplicit: outputFormatIsExplicit(refresh), columns: ['site', 'status', 'last_touched_at', 'next_refresh_at', 'error'], title: 'webcmd/auth refresh', source: opts.all ? 'forced persistent touch' : 'persistent touch with 24h throttle', diff --git a/src/completion-shared.ts b/src/completion-shared.ts index 9d51ccdc..7c8b781d 100644 --- a/src/completion-shared.ts +++ b/src/completion-shared.ts @@ -45,7 +45,7 @@ export const HOSTED_ROOT_HELP: RootHelpPresentation = { { name: 'list', description: 'List all available hosted CLI commands' }, { name: 'profile', description: 'Manage hosted browser profiles' }, { name: 'setup', description: 'Configure local or hosted mode' }, - { name: 'web', description: 'Fetch URLs locally without launching a browser' }, + { name: 'web', description: 'Fetch URLs locally without launching a browser. Use after a blocked, 403, or Cloudflare response.' }, ], localOnlyCommands: [ { name: 'adapter', description: 'Manage adapters installed on this computer' }, diff --git a/src/errors.test.ts b/src/errors.test.ts index fba9ada6..81a770f4 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -188,6 +188,7 @@ describe('SessionBusyError platform hints', () => { it('uses PowerShell process guidance on Windows when the holder pid is known', () => { const err = new SessionBusyError(holder, 'win32', () => true); + expect(err.hint).toContain('run the same command again'); expect(err.hint).toContain('Stop-Process -Id 4242'); expect(err.hint).not.toContain('kill 4242'); }); @@ -195,6 +196,7 @@ describe('SessionBusyError platform hints', () => { it('uses Task Manager guidance on Windows when the holder pid is unavailable', () => { const err = new SessionBusyError({ ...holder, pid: undefined }, 'win32'); expect(err.hint).toMatch(/wait/i); + expect(err.hint).toContain('run the same command again'); expect(err.hint).toContain('Task Manager'); expect(err.hint).not.toContain('Stop-Process'); }); @@ -217,6 +219,7 @@ describe('SessionBusyError platform hints', () => { expect(err.hint).toMatch(/wait/i); expect(err.hint).not.toContain('kill 4242'); expect(err.hint).toContain('webcmd session close --force session_a'); + expect(err.hint).not.toContain('Do not force-close'); }); it.each([ diff --git a/src/errors.ts b/src/errors.ts index 58ac6bc7..2dda16be 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -157,14 +157,15 @@ function formatBusyHint(holder: SessionLeaseHolder, platform: string, pidAlive: const forceClose = !hasLivePid && holder.sessionId ? ` Last resort: run \`webcmd session close --force ${holder.sessionId}\`.` : ''; + const retry = 'Wait, then run the same command again.'; if (platform === 'win32') { return scope + (!hasLivePid - ? `Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.${forceClose}` - : `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`); + ? `${retry} If it does not finish, use Task Manager to stop the owning process.${forceClose}` + : `${retry} Do not force-close. If it is stuck, run \`Stop-Process -Id ${holder.pid}\` in PowerShell.`); } return scope + (!hasLivePid - ? `Wait for it to finish, or stop the owning process if it is stuck.${forceClose}` - : `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`); + ? `${retry}${forceClose}` + : `${retry} Do not force-close. If it is stuck, run \`kill ${holder.pid}\`.`); } /** A persistent write session is temporarily owned by another logical run. */ diff --git a/src/fetch/command.test.ts b/src/fetch/command.test.ts index d50da849..831e0c55 100644 --- a/src/fetch/command.test.ts +++ b/src/fetch/command.test.ts @@ -37,6 +37,12 @@ describe('web fetch command', () => { expect(webFetchCommand).toMatchObject({ site: 'web', name: 'fetch', browser: false, clientOwned: true, defaultFormat: 'md' }); }); + it('is findable from blocked, 403, and Cloudflare', () => { + expect(webFetchCommand.description).toMatch(/blocked/i); + expect(webFetchCommand.description).toMatch(/403/); + expect(webFetchCommand.description).toMatch(/Cloudflare/i); + }); + it('accepts hosted root options without importing execution', async () => { await runWebFetchCommand(['--profile', 'work', '--workspace', 'test', 'web', 'fetch', '--url', 'https://example.com']); diff --git a/src/fetch/command.ts b/src/fetch/command.ts index dbb0e808..50865fea 100644 --- a/src/fetch/command.ts +++ b/src/fetch/command.ts @@ -8,7 +8,7 @@ import type { WebFetchOptions, WebFetchResult } from './client.js'; export const webFetchCommand = cli({ site: 'web', name: 'fetch', access: 'read', strategy: Strategy.PUBLIC, browser: false, clientOwned: true, - description: 'Fetch a URL with local HTTP clients', defaultFormat: 'md', + description: 'Fetch a URL with local HTTP clients. Use after a blocked, 403, or Cloudflare response; never opens a browser.', defaultFormat: 'md', renderMarkdown: data => (isWebFetchResult(data) ? formatWebFetchMarkdown(data) : undefined), args: [ { name: 'url', type: 'string', required: true, help: 'HTTP or HTTPS URL to fetch' }, diff --git a/src/help.test.ts b/src/help.test.ts index f4258c64..8de46521 100644 --- a/src/help.test.ts +++ b/src/help.test.ts @@ -5,6 +5,7 @@ import { formatCommandHelpText, formatRootAdapterHelpText, formatSiteHelpText, + getRequestedHelpFormat, siteHelpData, } from './help.js'; import { @@ -61,6 +62,10 @@ describe('classifyAdapter', () => { }); }); +it('lets explicit --format win over --json in structured help', () => { + expect(getRequestedHelpFormat(['webcmd', '--help', '--format', 'yaml', '--json'])).toBe('yaml'); +}); + describe('formatRootAdapterHelpText', () => { it('renders all three sections in External / App / Site order when populated', () => { const text = formatRootAdapterHelpText({ diff --git a/src/help.ts b/src/help.ts index 11084f28..38012b85 100644 --- a/src/help.ts +++ b/src/help.ts @@ -48,19 +48,30 @@ function normalizeStructuredHelpFormat(value: string | undefined): StructuredHel } export function getRequestedHelpFormat(argv: readonly string[] = process.argv): StructuredHelpFormat | undefined { + let format: StructuredHelpFormat | undefined; + let explicitFormat = false; for (let i = 0; i < argv.length; i++) { const token = argv[i]; + if (token === '--json') { + if (!explicitFormat) format = 'json'; + continue; + } if (token === '-f' || token === '--format') { - return normalizeStructuredHelpFormat(argv[i + 1]); + explicitFormat = true; + format = normalizeStructuredHelpFormat(argv[i + 1]); + continue; } if (token.startsWith('--format=')) { - return normalizeStructuredHelpFormat(token.slice('--format='.length)); + explicitFormat = true; + format = normalizeStructuredHelpFormat(token.slice('--format='.length)); + continue; } if (token.startsWith('-f') && token.length > 2) { - return normalizeStructuredHelpFormat(token.slice(2)); + explicitFormat = true; + format = normalizeStructuredHelpFormat(token.slice(2)); } } - return undefined; + return format; } export function renderStructuredHelp(data: unknown, format: StructuredHelpFormat): string { diff --git a/src/hosted/contract.test.ts b/src/hosted/contract.test.ts index 55ab565c..ee3648b9 100644 --- a/src/hosted/contract.test.ts +++ b/src/hosted/contract.test.ts @@ -150,6 +150,16 @@ describe('buildHostedContract', () => { default: 'table', choices: ['table', 'plain', 'json', 'yaml', 'md', 'csv'], }, + { + name: 'json', + flags: '--json', + type: 'boolean', + description: 'Alias of --format json', + positional: false, + required: false, + variadic: false, + default: false, + }, { name: 'trace', flags: '--trace ', diff --git a/src/hosted/root-command-surface.test.ts b/src/hosted/root-command-surface.test.ts index 5c0e8f27..d5e71721 100644 --- a/src/hosted/root-command-surface.test.ts +++ b/src/hosted/root-command-surface.test.ts @@ -201,7 +201,7 @@ describe('hosted root command surface', () => { it('advertises client-owned web fetch at the hosted root', () => { expect(HOSTED_ROOT_HELP.commands).toContainEqual({ name: 'web', - description: 'Fetch URLs locally without launching a browser', + description: 'Fetch URLs locally without launching a browser. Use after a blocked, 403, or Cloudflare response.', }); }); diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 156c3368..632dbb58 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -493,6 +493,28 @@ describe('runHostedCli', () => { expect(requests).toEqual(['https://api.example.com/v1/marketplace/installations']); }); + it('rejects plugin install --all in hosted mode before a marketplace request', async () => { + const stderr = sink(); + const fetchImpl = vi.fn(); + const result = await runHostedCli(['plugin', 'install', 'github:agentrhq/webcmd', '--all'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 78 }); + expect(yaml.load(stderr.text())).toMatchObject({ + error: { + code: 'CONFIG', + message: 'plugin install --all is not available in hosted mode.', + help: expect.stringContaining('github:user/repo/'), + exitCode: 78, + }, + }); + expect((yaml.load(stderr.text()) as { error: { help: string } }).error.help).toContain('local mode'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('renders Cloud local-only marketplace install guidance', async () => { const stdout = sink(); const stderr = sink(); @@ -948,7 +970,7 @@ describe('runHostedCli', () => { }); expect(result.handled).toBe(true); - expect(stderr.text()).toMatch(/unknown command|not supported/i); + expect(stderr.text()).toMatch(/unknown command|not supported|not available/i); expect(fetchImpl).not.toHaveBeenCalled(); }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index c8adca7f..c3f44b71 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -13,7 +13,7 @@ import { configurePluginUpdateSurface, } from '../builtin-command-surface.js'; import { BrowserSessionArgvError, rejectMisplacedSessionSelectorArgv, rejectPositionalBrowserSessionArgv } from '../cli-argv-preprocess.js'; -import { CommanderStructuralError, MissingRequiredPositionalError, OUTPUT_FORMAT_HELP, parseOutputFormat, resolveCommandFromArgv, structuralErrorFromCommander } from '../command-surface.js'; +import { addOutputFormatOption, CommanderStructuralError, MissingRequiredPositionalError, outputFormatIsExplicit, parseOutputFormat, requestedOutputFormat, resolveCommandFromArgv, structuralErrorFromCommander } from '../command-surface.js'; import { filterCommandsByTag, formatRootHelp, getCommandCompletionCandidates } from '../command-presentation.js'; import { HOSTED_BUILTIN_COMMANDS, @@ -257,7 +257,7 @@ async function dispatchHosted( } if (args[0] === 'profile') { - if (args[1] === 'rename' || args[1] === 'use') { + if (args[1] === 'rename' || args[1] === 'use' || args[1] === 'create') { throw new ConfigError( `webcmd profile ${args[1]} is not available in hosted mode.`, 'Hosted mode supports: webcmd profile list and delete.', @@ -298,7 +298,7 @@ async function dispatchHosted( })), { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, - columns: ['name', 'description', 'version', 'sourceId', 'installSource', 'webcmd', 'availability', 'excludedCommands'], + columns: ['installSource', 'name', 'description', 'version', 'sourceId', 'webcmd', 'availability', 'excludedCommands'], title: `${CLI_COMMAND}/plugin-search`, source: `${CLI_COMMAND} plugin search`, stdout, @@ -307,6 +307,12 @@ async function dispatchHosted( return; } if (parsed.command === 'install') { + if (parsed.all) { + throw new ConfigError( + 'plugin install --all is not available in hosted mode.', + 'Install one plugin with `webcmd plugin install github:user/repo/`, or run `webcmd setup` and choose local mode for --all.', + ); + } const installed = await client.installMarketplacePlugin(parsed.source); await writeToStream(stdout, `✅ Plugin "${installed.name}" installed successfully. Commands are ready to use.\n`); return; @@ -645,9 +651,9 @@ function parseHostedSessionSurface(argv: readonly string[], literal: boolean): P }; root.exitOverride().configureOutput(output); session.exitOverride().configureOutput(output); - const configure = (command: Command, format: string): Command => command.option('-f, --format ', OUTPUT_FORMAT_HELP, format); + const configure = (command: Command, format: string): Command => addOutputFormatOption(command, format); const setParsed = (command: 'create' | 'list' | 'close', surface: Command, format: string, extras: Omit, 'kind' | 'command' | 'format' | 'formatExplicit'> = {}): void => { - parsed = { kind: 'run', command, format: validateHostedFormat(format), formatExplicit: surface.getOptionValueSource('format') === 'cli', ...extras }; + parsed = { kind: 'run', command, format: validateHostedFormat(String(requestedOutputFormat(surface, format))), formatExplicit: outputFormatIsExplicit(surface), ...extras }; }; const create = configure(session.command('create'), 'yaml'); create.action((options: { format: string }) => setParsed('create', create, options.format)); @@ -1127,9 +1133,9 @@ function parseHostedListSurface(argv: readonly string[], literal: boolean): Pars root.exitOverride().configureOutput(output); list.exitOverride().configureOutput(output).action((options: { format: string; tag?: string }) => { actionRan = true; - parsedFormat = validateHostedFormat(options.format); + parsedFormat = validateHostedFormat(String(requestedOutputFormat(list, options.format))); parsedTag = options.tag; - formatExplicit = list.getOptionValueSource('format') === 'cli'; + formatExplicit = outputFormatIsExplicit(list); }); try { @@ -1171,8 +1177,7 @@ function parseHostedProfileSurface( root.exitOverride().configureOutput(output); profile.exitOverride().configureOutput(output); - const configureFormat = (command: Command): Command => - command.option('-f, --format ', OUTPUT_FORMAT_HELP, 'table'); + const configureFormat = (command: Command): Command => addOutputFormatOption(command); const setParsed = ( command: HostedProfileCommand, surface: Command, @@ -1182,8 +1187,8 @@ function parseHostedProfileSurface( parsed = { kind: 'run', command, - format: validateHostedFormat(options.format), - formatExplicit: surface.getOptionValueSource('format') === 'cli', + format: validateHostedFormat(String(requestedOutputFormat(surface, options.format))), + formatExplicit: outputFormatIsExplicit(surface), ...(value !== undefined ? { value } : {}), }; }; @@ -1224,7 +1229,7 @@ async function dispatchHostedProfile( type ParsedHostedPluginSurface = | { kind: 'help'; output: string } | { kind: 'run'; command: 'search'; query?: string; format: string; formatExplicit: boolean } - | { kind: 'run'; command: 'install'; source: string } + | { kind: 'run'; command: 'install'; source: string; all: boolean } | { kind: 'run'; command: 'list'; format: string; formatExplicit: boolean } | { kind: 'run'; command: 'uninstall'; name: string } | { kind: 'run'; command: 'update'; name?: string; all: boolean } @@ -1248,15 +1253,15 @@ function parseHostedPluginSurface( const search = configurePluginSearchSurface(plugin.command('search')); search.exitOverride().configureOutput(output).action((query: string | undefined, options: { format: string }) => { - parsed = { kind: 'run', command: 'search', ...(query !== undefined ? { query } : {}), format: validateHostedFormat(options.format), formatExplicit: search.getOptionValueSource('format') === 'cli' }; + parsed = { kind: 'run', command: 'search', ...(query !== undefined ? { query } : {}), format: validateHostedFormat(String(requestedOutputFormat(search, options.format))), formatExplicit: outputFormatIsExplicit(search) }; }); const install = configurePluginInstallSurface(plugin.command('install')); - install.exitOverride().configureOutput(output).action((source: string) => { - parsed = { kind: 'run', command: 'install', source }; + install.exitOverride().configureOutput(output).action((source: string, options: { all?: boolean }) => { + parsed = { kind: 'run', command: 'install', source, all: options.all === true }; }); const list = configurePluginListSurface(plugin.command('list')); list.exitOverride().configureOutput(output).action((options: { format: string }) => { - parsed = { kind: 'run', command: 'list', format: validateHostedFormat(options.format), formatExplicit: list.getOptionValueSource('format') === 'cli' }; + parsed = { kind: 'run', command: 'list', format: validateHostedFormat(String(requestedOutputFormat(list, options.format))), formatExplicit: outputFormatIsExplicit(list) }; }); const uninstall = configurePluginUninstallSurface(plugin.command('uninstall')); uninstall.exitOverride().configureOutput(output).action((name: string) => { diff --git a/src/output.test.ts b/src/output.test.ts index 5d4f6b20..67c4c4b9 100644 --- a/src/output.test.ts +++ b/src/output.test.ts @@ -185,6 +185,9 @@ describe('requestedFormatFromArgv', () => { { argv: ['list', '-f', 'json'], format: 'json' }, { argv: ['list', '--format', 'JSON'], format: 'JSON' }, { argv: ['list', '--format=yaml'], format: 'yaml' }, + { argv: ['list', '-fyaml', '--json'], format: 'yaml' }, + { argv: ['list', '--json'], format: 'json' }, + { argv: ['list', '--json', '--format', 'yaml'], format: 'yaml' }, { argv: ['list'], format: undefined }, { argv: ['list', '--', '-f', 'json'], format: undefined }, ])('$argv → $format', ({ argv, format }) => { diff --git a/src/output.ts b/src/output.ts index 8b4bd2ab..354b6773 100644 --- a/src/output.ts +++ b/src/output.ts @@ -82,16 +82,32 @@ export function errorEnvelopeFormat(fmt?: unknown): 'json' | 'yaml' { } export function requestedFormatFromArgv(argv: readonly string[]): string | undefined { + let format: string | undefined; + let explicitFormat = false; for (let i = 0; i < argv.length; i++) { const token = argv[i]!; if (token === '--') break; + if (token === '--json') { + if (!explicitFormat) format = 'json'; + continue; + } if (token === '-f' || token === '--format') { + explicitFormat = true; const value = argv[i + 1]; - return value && !value.startsWith('-') ? value : undefined; + format = value && !value.startsWith('-') ? value : undefined; + continue; + } + if (token.startsWith('-f') && token.length > 2) { + explicitFormat = true; + format = token.slice(2); + continue; + } + if (token.startsWith('--format=')) { + explicitFormat = true; + format = token.slice('--format='.length) || undefined; } - if (token.startsWith('--format=')) return token.slice('--format='.length) || undefined; } - return undefined; + return format; } /** Serialize the local error envelope without writing to process-global stderr. */ diff --git a/src/plugin-catalog.ts b/src/plugin-catalog.ts index 64296349..ed9a2a4f 100644 --- a/src/plugin-catalog.ts +++ b/src/plugin-catalog.ts @@ -20,11 +20,11 @@ export interface PluginCatalog { } export interface PluginSearchRow { + installSource: string; name: string; description?: string; version?: string; sourceId: string; - installSource: string; webcmd?: string; } @@ -114,21 +114,21 @@ export function removeCatalogSource(id: string, options: CatalogOptions = {}): P export function flattenPluginManifest(source: PluginCatalogSource, manifest: PluginManifest): PluginSearchRow[] { if (isMonorepo(manifest)) { return getEnabledPlugins(manifest).map(({ name, entry }) => ({ + installSource: `${source.source}/${name}`, name, description: entry.description, version: entry.version, sourceId: source.id, - installSource: `${source.source}/${name}`, webcmd: entry.webcmd ?? manifest.webcmd, })); } if (!manifest.name) return []; return [{ + installSource: source.source, name: manifest.name, description: manifest.description, version: manifest.version, sourceId: source.id, - installSource: source.source, webcmd: manifest.webcmd, }]; } diff --git a/src/plugin.test.ts b/src/plugin.test.ts index 756ae81a..0af9cf3c 100644 --- a/src/plugin.test.ts +++ b/src/plugin.test.ts @@ -1202,7 +1202,34 @@ describe('installPlugin transactional staging', () => { return ''; }); - expect(() => installPlugin(monorepoSource)).toThrow(`npm install failed`); + expect(() => installPlugin(monorepoSource, { all: true })).toThrow(`npm install failed`); + expect(fs.existsSync(monorepoRepoDir)).toBe(false); + expect(fs.existsSync(monorepoLink)).toBe(false); + expect(_readLockFile().alpha).toBeUndefined(); + }); + + it('refuses a monorepo root without --all and installs nothing', () => { + mockExecFileSync.mockImplementation((cmd, args) => { + if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { + const cloneDir = String(args[args.length - 1]); + const alphaDir = path.join(cloneDir, 'packages', 'alpha'); + const betaDir = path.join(cloneDir, 'packages', 'beta'); + fs.mkdirSync(alphaDir, { recursive: true }); + fs.mkdirSync(betaDir, { recursive: true }); + fs.writeFileSync(path.join(cloneDir, 'webcmd-plugin.json'), JSON.stringify({ + plugins: { + alpha: { path: 'packages/alpha' }, + beta: { path: 'packages/beta' }, + }, + })); + fs.writeFileSync(path.join(alphaDir, 'hello.js'), 'cli({ site: "test", name: "hello", access: "read" })'); + fs.writeFileSync(path.join(betaDir, 'hello.js'), 'cli({ site: "test", name: "hello", access: "read" })'); + return ''; + } + return ''; + }); + + expect(() => installPlugin(monorepoSource)).toThrow(/This source has 2 plugins/); expect(fs.existsSync(monorepoRepoDir)).toBe(false); expect(fs.existsSync(monorepoLink)).toBe(false); expect(_readLockFile().alpha).toBeUndefined(); @@ -1429,6 +1456,143 @@ describe('updatePlugin transactional staging', () => { expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe('oldmonooldmonooldmonooldmonooldmonoold'); }); + it('keeps each published monorepo commit available as the next update baseline', () => { + const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, 'old.js'), 'cli({ site: "old", name: "old", access: "read" })'); + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + fs.symlinkSync(subDir, monorepoLink, 'dir'); + + const oldHash = '0'.repeat(40); + const updateHashes = ['1'.repeat(40), '2'.repeat(40)]; + const availableBaselines = new Set([oldHash]); + const retainedRefs: string[] = []; + const cloneHashes = new Map(); + let cloneCount = 0; + const lock = _readLockFile(); + lock[monorepoPluginName] = { + source: { + kind: 'monorepo', + url: 'https://github.com/user/webcmd-plugins-__test-transactional-mono-update__.git', + repoName: monorepoName, + subPath: `packages/${monorepoPluginName}`, + }, + commitHash: oldHash, + installedAt: '2025-01-01T00:00:00.000Z', + }; + _writeLockFile(lock); + + mockExecFileSync.mockImplementation((cmd, args, opts) => { + if (cmd === 'git' && Array.isArray(args) && args[0] === 'diff') { + const baseline = String(args[2]); + if (!availableBaselines.has(baseline)) throw new Error(`fatal: bad object ${baseline}`); + return ''; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { + const cloneDir = String(args[4]); + const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName); + fs.mkdirSync(alphaDir, { recursive: true }); + fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ + name: 'webcmd-plugins-__test-transactional-mono-update__', + private: true, + })); + fs.writeFileSync(path.join(cloneDir, 'webcmd-plugin.json'), JSON.stringify({ + plugins: { + [monorepoPluginName]: { path: `packages/${monorepoPluginName}` }, + }, + })); + fs.writeFileSync(path.join(alphaDir, 'hello.js'), `// update ${cloneCount + 1}\ncli({ site: "test", name: "hello", access: "read" })`); + cloneHashes.set(cloneDir, updateHashes[cloneCount++]!); + return ''; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'fetch') { + const refspec = String(args[3]); + retainedRefs.push(refspec); + availableBaselines.add(refspec.split(':')[0]!); + return ''; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { + return `${cloneHashes.get(String(opts?.cwd)) ?? oldHash}\n`; + } + return ''; + }); + + expect(updatePlugin(monorepoPluginName)).toEqual([monorepoPluginName]); + expect(updatePlugin(monorepoPluginName)).toEqual([monorepoPluginName]); + expect(_readLockFile()[monorepoPluginName]?.commitHash).toBe(updateHashes[1]); + expect(retainedRefs).toEqual(updateHashes.map((hash) => `${hash}:refs/webcmd/baselines/${hash}`)); + }); + + it('publishes hoisted dependencies without replacing dirty sibling source', () => { + const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName); + const siblingDir = path.join(monorepoRepoDir, 'packages', 'sibling'); + fs.mkdirSync(subDir, { recursive: true }); + fs.mkdirSync(siblingDir, { recursive: true }); + fs.mkdirSync(path.join(monorepoRepoDir, 'node_modules', 'old-dependency'), { recursive: true }); + fs.writeFileSync(path.join(subDir, 'old.js'), 'cli({ site: "old", name: "old", access: "read" })'); + fs.writeFileSync(path.join(siblingDir, 'source.js'), 'dirty sibling source'); + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + fs.symlinkSync(subDir, monorepoLink, 'dir'); + + const oldHash = '0'.repeat(40); + const updateHash = '1'.repeat(40); + const lock = _readLockFile(); + lock[monorepoPluginName] = { + source: { + kind: 'monorepo', + url: 'https://github.com/user/webcmd-plugins-__test-transactional-mono-update__.git', + repoName: monorepoName, + subPath: `packages/${monorepoPluginName}`, + }, + commitHash: oldHash, + installedAt: '2025-01-01T00:00:00.000Z', + }; + _writeLockFile(lock); + + mockExecFileSync.mockImplementation((cmd, args, opts) => { + if (cmd === 'git' && Array.isArray(args) && args[0] === 'diff') return ''; + if (cmd === 'git' && Array.isArray(args) && args[0] === 'status') { + return opts?.cwd === monorepoRepoDir ? ' M packages/sibling/source.js\n' : ''; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { + const cloneDir = String(args[4]); + const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName); + const siblingCloneDir = path.join(cloneDir, 'packages', 'sibling'); + fs.mkdirSync(alphaDir, { recursive: true }); + fs.mkdirSync(siblingCloneDir, { recursive: true }); + fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ + name: 'webcmd-plugins-__test-transactional-mono-update__', + private: true, + workspaces: ['packages/*'], + })); + fs.writeFileSync(path.join(cloneDir, 'webcmd-plugin.json'), JSON.stringify({ + plugins: { + [monorepoPluginName]: { path: `packages/${monorepoPluginName}` }, + sibling: { path: 'packages/sibling' }, + }, + })); + fs.writeFileSync(path.join(alphaDir, 'hello.js'), 'cli({ site: "test", name: "hello", access: "read" })'); + fs.writeFileSync(path.join(siblingCloneDir, 'source.js'), 'remote sibling source'); + return ''; + } + if (cmd === 'npm' && Array.isArray(args) && args[0] === 'install') { + const dependencyDir = path.join(String(opts?.cwd), 'node_modules', 'shared-dependency'); + fs.mkdirSync(dependencyDir, { recursive: true }); + fs.writeFileSync(path.join(dependencyDir, 'index.js'), 'module.exports = "updated";'); + return ''; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { + return `${updateHash}\n`; + } + return ''; + }); + + expect(updatePlugin(monorepoPluginName)).toEqual([monorepoPluginName]); + expect(fs.readFileSync(path.join(monorepoRepoDir, 'node_modules', 'shared-dependency', 'index.js'), 'utf-8')) + .toContain('updated'); + expect(fs.readFileSync(path.join(siblingDir, 'source.js'), 'utf-8')).toBe('dirty sibling source'); + }); + it('relinks monorepo plugins when the updated manifest moves their subPath', () => { const oldSubDir = path.join(monorepoRepoDir, 'packages', 'old-alpha'); fs.mkdirSync(oldSubDir, { recursive: true }); @@ -1783,6 +1947,9 @@ describe('updatePlugin dirty-checkout guard', () => { _writeLockFile(lock); mockExecFileSync.mockImplementation((cmd, args, opts) => { + if (cmd === 'git' && Array.isArray(args) && args[0] === 'diff') { + return opts?.cwd === monorepoRepoDir ? 'M\tpackages/alpha-dirty/old.js\n' : ''; + } if (cmd === 'git' && Array.isArray(args) && args[0] === 'status') { return opts?.cwd === monorepoRepoDir ? ' M packages/alpha-dirty/old.js\n' : ''; } @@ -1815,6 +1982,9 @@ describe('updatePlugin dirty-checkout guard', () => { _writeLockFile(lock); mockExecFileSync.mockImplementation((cmd, args, opts) => { + if (cmd === 'git' && Array.isArray(args) && args[0] === 'diff') { + return opts?.cwd === monorepoRepoDir ? 'M\tpackages/alpha-dirty/old.js\n' : ''; + } if (cmd === 'git' && Array.isArray(args) && args[0] === 'status') { return opts?.cwd === monorepoRepoDir ? ' M packages/alpha-dirty/old.js\n' : ''; } @@ -1843,6 +2013,86 @@ describe('updatePlugin dirty-checkout guard', () => { expect(() => updatePlugin(monorepoPluginName, { force: true })).not.toThrow(); }); + it('updates one monorepo plugin when only a sibling path is dirty', () => { + const subDir = path.join(monorepoRepoDir, 'packages', monorepoPluginName); + const siblingDir = path.join(monorepoRepoDir, 'packages', 'rest-countries'); + fs.mkdirSync(subDir, { recursive: true }); + fs.mkdirSync(siblingDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, 'old.js'), 'cli({ site: "old", name: "old", access: "read" })'); + fs.writeFileSync(path.join(siblingDir, 'top.js'), 'cli({ site: "rest-countries", name: "top", access: "read" })'); + fs.mkdirSync(PLUGINS_DIR, { recursive: true }); + fs.symlinkSync(subDir, monorepoLink, 'dir'); + const siblingLink = path.join(PLUGINS_DIR, 'rest-countries'); + fs.symlinkSync(siblingDir, siblingLink, 'dir'); + + const lock = _readLockFile(); + lock[monorepoPluginName] = { + source: { + kind: 'monorepo', + url: 'https://github.com/user/webcmd-plugins-__test-dirty-mono__.git', + repoName: monorepoName, + subPath: `packages/${monorepoPluginName}`, + }, + commitHash: 'oldmonooldmonooldmonooldmonooldmonoold', + installedAt: '2025-01-01T00:00:00.000Z', + }; + lock['rest-countries'] = { + source: { + kind: 'monorepo', + url: 'https://github.com/user/webcmd-plugins-__test-dirty-mono__.git', + repoName: monorepoName, + subPath: 'packages/rest-countries', + }, + commitHash: 'oldmonooldmonooldmonooldmonooldmonoold', + installedAt: '2025-01-01T00:00:00.000Z', + }; + _writeLockFile(lock); + + mockExecFileSync.mockImplementation((cmd, args, opts) => { + if (cmd === 'git' && Array.isArray(args) && args[0] === 'diff') { + const pathspec = Array.isArray(args) ? args[args.length - 1] : ''; + if (pathspec === `packages/${monorepoPluginName}`) return ''; + return 'M\tpackages/rest-countries/top.js\n'; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'status') { + return opts?.cwd === monorepoRepoDir ? ' M packages/rest-countries/top.js\n' : ''; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'clone') { + const cloneDir = String(args[4]); + const alphaDir = path.join(cloneDir, 'packages', monorepoPluginName); + const siblingClone = path.join(cloneDir, 'packages', 'rest-countries'); + fs.mkdirSync(alphaDir, { recursive: true }); + fs.mkdirSync(siblingClone, { recursive: true }); + fs.writeFileSync(path.join(cloneDir, 'package.json'), JSON.stringify({ + name: 'webcmd-plugins-__test-dirty-mono__', + private: true, + })); + fs.writeFileSync(path.join(cloneDir, 'webcmd-plugin.json'), JSON.stringify({ + plugins: { + [monorepoPluginName]: { path: `packages/${monorepoPluginName}` }, + 'rest-countries': { path: 'packages/rest-countries' }, + }, + })); + fs.writeFileSync(path.join(alphaDir, 'hello.js'), 'cli({ site: "test", name: "hello", access: "read" })'); + fs.writeFileSync(path.join(siblingClone, 'top.js'), 'SHOULD_NOT_BE_COPIED'); + return ''; + } + if (cmd === 'git' && Array.isArray(args) && args[0] === 'rev-parse' && args[1] === 'HEAD') { + return '1234567890abcdef1234567890abcdef12345678\n'; + } + return ''; + }); + + expect(updatePlugin(monorepoPluginName)).toEqual([monorepoPluginName]); + expect(fs.readFileSync(path.join(siblingDir, 'top.js'), 'utf-8')).toContain('rest-countries'); + expect(fs.existsSync(path.join(subDir, 'hello.js'))).toBe(true); + + try { fs.unlinkSync(siblingLink); } catch {} + const finalLock = _readLockFile(); + delete finalLock['rest-countries']; + _writeLockFile(finalLock); + }); + it('local (symlinked) plugin updates are not blocked by the dirty-checkout guard', () => { const localTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-local-dirty-')); const linkPath = path.join(PLUGINS_DIR, '__test-local-dirty__'); diff --git a/src/plugin.ts b/src/plugin.ts index 746b2a26..22fd84e9 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -514,6 +514,18 @@ export function getCommitHash(dir: string): string | undefined { } } +function retainMonorepoBaseline(repoDir: string, cloneDir: string, commitHash: string): void { + execFileSync( + 'git', + ['fetch', '--no-tags', cloneDir, `${commitHash}:refs/webcmd/baselines/${commitHash}`], + { + cwd: repoDir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); +} + /** True only for git's "this directory has no repository at all" failure. */ function isNotAGitRepositoryError(error: unknown): boolean { const stderr = typeof (error as { stderr?: unknown })?.stderr === 'string' @@ -551,7 +563,8 @@ function describeGitError(error: unknown): string { * must fail closed — this guard exists to prevent silent data loss, so an * inconclusive check must refuse the update rather than proceed as if clean. */ -export function getDirtyFiles(dir: string): string[] { +export function getDirtyFiles(dir: string, options: { pathspec?: string; against?: string } = {}): string[] { + const pathspec = options.pathspec ?? '.'; try { execFileSync('git', ['rev-parse', '--git-dir'], { cwd: dir, @@ -566,7 +579,23 @@ export function getDirtyFiles(dir: string): string[] { ); } try { - const out = execFileSync('git', ['status', '--porcelain', '--', '.'], { + if (options.against) { + const diff = execFileSync('git', ['diff', '--name-status', options.against, '--', pathspec], { + cwd: dir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + const untracked = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '--', pathspec], { + cwd: dir, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + return [ + ...diff.split('\n').filter((line) => line.trim()).map(fromNameStatus), + ...untracked.split('\n').filter((line) => line.trim()).map((file) => `?? ${file.trim()}`), + ].filter((line) => !isInstallArtifact(line)); + } + const out = execFileSync('git', ['status', '--porcelain', '--', pathspec], { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], @@ -613,17 +642,43 @@ function describeDirtyEntry(entry: string): string { return entry.startsWith('??') ? `${file} (new, unstaged)` : `${file} (modified)`; } -function assertPluginNotDirty(name: string, dir: string, force: boolean): void { - if (force) return; - const dirty = getDirtyFiles(dir); - if (dirty.length === 0) return; - const described = dirty.slice(0, 10).map(describeDirtyEntry); +function fromNameStatus(line: string): string { + const match = line.trim().match(/^([A-Z])\t?(.*)$/); + if (!match) return `M ${line.trim()}`; + const file = match[2]!.trim(); + return match[1] === 'A' ? `?? ${file}` : `M ${file}`; +} + +function dirtyPathIsInside(entry: string, subPath: string): boolean { + const file = dirtyEntryPath(entry).replace(/\\/g, '/'); + const prefix = subPath.replace(/\\/g, '/').replace(/\/+$/, ''); + return file === prefix || file.startsWith(`${prefix}/`); +} + +function formatDirtyPaths(dirty: string[]): string { + return dirty.slice(0, 10).map(describeDirtyEntry).join('\n '); +} + +function assertPluginNotDirty(name: string, dirty: string[], force: boolean): string[] { + if (dirty.length === 0) return []; + if (force) return dirty; throw new PluginError( - `Plugin "${name}" has uncommitted changes that updating would destroy:\n ${described.join('\n ')}`, + `Plugin "${name}" has uncommitted changes that updating would destroy:\n ${formatDirtyPaths(dirty)}`, 'Commit or stash them, re-run with --force to discard them, or develop against a symlinked checkout with "webcmd plugin install file:///path".', ); } +function inspectPluginDirtiness(name: string, force: boolean, readDirty: () => string[]): string[] { + if (force) { + try { + return readDirty(); + } catch { + return []; + } + } + return assertPluginNotDirty(name, readDirty(), false); +} + /** * Validate that a downloaded plugin directory is a structurally valid plugin. * Checks for at least one command file (.ts, .js) and a valid @@ -772,11 +827,21 @@ function publishMonorepoPlugins( repoDir: string, pluginsDir: string, plugins: MonorepoPublishPlugin[], - publishRepo?: { stagingDir: string; parentDir: string }, + publishRepo?: { + stagingDir: string; + parentDir: string; + replaceSubPath?: string; + sharedNodeModulesDir?: string; + }, writeLock?: (commitHash: string | undefined) => void, ): void { runTransaction((tx) => { - if (publishRepo) { + if (publishRepo?.replaceSubPath) { + tx.track(beginReplaceDir(publishRepo.stagingDir, resolveRepoContainedPath(repoDir, publishRepo.replaceSubPath))); + if (publishRepo.sharedNodeModulesDir && fs.existsSync(publishRepo.sharedNodeModulesDir)) { + tx.track(beginReplaceDir(publishRepo.sharedNodeModulesDir, path.join(repoDir, 'node_modules'))); + } + } else if (publishRepo) { fs.mkdirSync(publishRepo.parentDir, { recursive: true }); tx.track(beginReplaceDir(publishRepo.stagingDir, repoDir)); } @@ -803,7 +868,7 @@ function publishMonorepoPlugins( * * Returns the installed plugin name(s). */ -export function installPlugin(source: string): string | string[] { +export function installPlugin(source: string, options: { all?: boolean } = {}): string | string[] { const parsed = parseSource(source); if (!parsed) { throw new Error( @@ -837,7 +902,7 @@ export function installPlugin(source: string): string | string[] { } if (manifest && isMonorepo(manifest)) { - return installMonorepo(tmpCloneDir, parsed.cloneUrl!, repoName, manifest, subPlugin); + return installMonorepo(tmpCloneDir, parsed.cloneUrl!, repoName, manifest, subPlugin, options.all === true); } // Single plugin mode @@ -961,6 +1026,7 @@ function installMonorepo( repoName: string, manifest: PluginManifest, subPlugin?: string, + installAll = false, ): string[] { const monoreposDir = getMonoreposDir(); const repoDir = path.join(monoreposDir, repoName); @@ -986,6 +1052,11 @@ function installMonorepo( } let pluginsToInstall = getEnabledPlugins(effectiveManifest); + if (!subPlugin && !installAll) { + throw new PluginError( + `This source has ${pluginsToInstall.length} plugins; install one with github:user/repo/, or pass --all to install every plugin.`, + ); + } // If a specific sub-plugin was requested, filter to just that one if (subPlugin) { @@ -1086,6 +1157,7 @@ function collectUpdatedMonorepoPlugins( manifest: PluginManifest, cloneUrl: string, tmpCloneDir: string, + only?: string, ): Array<{ name: string; lockEntry: LockEntry; @@ -1098,6 +1170,7 @@ function collectUpdatedMonorepoPlugins( }> = []; for (const [pluginName, entry] of Object.entries(lock)) { + if (only && pluginName !== only) continue; if (entry.source.kind !== 'monorepo' || entry.source.repoName !== monoName) continue; const manifestEntry = manifest.plugins?.[pluginName]; if (!manifestEntry || manifestEntry.disabled) { @@ -1215,8 +1288,7 @@ function isSymlinkSync(p: string): boolean { /** * Update a plugin by name (git pull + re-install lifecycle). - * For monorepo sub-plugins: pulls the monorepo root and re-runs lifecycle - * for all sub-plugins from the same monorepo. + * For monorepo sub-plugins: updates only the named plugin's subdirectory. */ export function updatePlugin(name: string, options: { force?: boolean } = {}): string[] { const targetDir = path.join(PLUGINS_DIR, name); @@ -1238,7 +1310,18 @@ export function updatePlugin(name: string, options: { force?: boolean } = {}): s const monoDir = path.join(getMonoreposDir(), source.repoName); const monoName = source.repoName; const cloneUrl = source.url; - assertPluginNotDirty(monoName, monoDir, options.force === true); + const discarded = inspectPluginDirtiness(name, options.force === true, () => getDirtyFiles(monoDir, { + pathspec: source.subPath, + ...(lockEntry?.commitHash ? { against: lockEntry.commitHash } : {}), + })); + if (options.force === true && discarded.length > 0) { + console.error(`--force will discard uncommitted changes in "${name}":\n ${formatDirtyPaths(discarded)}`); + } + const siblingDirty = inspectPluginDirtiness(name, true, () => getDirtyFiles(monoDir)) + .filter((entry) => !dirtyPathIsInside(entry, source.subPath)); + if (siblingDirty.length > 0) { + console.error(`Shared monorepo has uncommitted files outside "${name}"; leaving them in place:\n ${formatDirtyPaths(siblingDirty)}`); + } return withTempClone(cloneUrl, (tmpCloneDir) => { const manifest = readPluginManifest(tmpCloneDir); if (!manifest || !isMonorepo(manifest)) { @@ -1257,6 +1340,7 @@ export function updatePlugin(name: string, options: { force?: boolean } = {}): s manifest, cloneUrl, tmpCloneDir, + name, ); if (updatedPlugins.length > 0) { @@ -1266,21 +1350,33 @@ export function updatePlugin(name: string, options: { force?: boolean } = {}): s ); } + const plugin = updatedPlugins[0]; + if (!plugin) return []; + const commitHash = getCommitHash(tmpCloneDir); + if (commitHash) retainMonorepoBaseline(monoDir, tmpCloneDir, commitHash); publishMonorepoPlugins( monoDir, PLUGINS_DIR, - updatedPlugins.map((plugin) => ({ name: plugin.name, subPath: plugin.manifestEntry.path })), - { stagingDir: tmpCloneDir, parentDir: path.dirname(monoDir) }, - (commitHash) => { + [{ name: plugin.name, subPath: plugin.manifestEntry.path }], + { + stagingDir: resolveRepoContainedPath(tmpCloneDir, plugin.manifestEntry.path), + parentDir: path.dirname(monoDir), + replaceSubPath: plugin.manifestEntry.path, + sharedNodeModulesDir: path.join(tmpCloneDir, 'node_modules'), + }, + () => { updateMonorepoLockEntries(lock, updatedPlugins, cloneUrl, monoName, commitHash); writeLockFile(lock); }, ); - return updatedPlugins.map((plugin) => plugin.name); + return updatedPlugins.map((item) => item.name); }); } - assertPluginNotDirty(name, targetDir, options.force === true); + const discarded = inspectPluginDirtiness(name, options.force === true, () => getDirtyFiles(targetDir)); + if (options.force === true && discarded.length > 0) { + console.error(`--force will discard uncommitted changes in "${name}":\n ${formatDirtyPaths(discarded)}`); + } const cloneUrl = resolveRemotePluginSource(lockEntry, targetDir); withTempClone(cloneUrl, (tmpCloneDir) => { diff --git a/src/site-memory/commands.test.ts b/src/site-memory/commands.test.ts new file mode 100644 index 00000000..df7e0bf5 --- /dev/null +++ b/src/site-memory/commands.test.ts @@ -0,0 +1,82 @@ +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; +import { applyUnknownOptionContract, CommanderStructuralError } from '../command-surface.js'; +import { ArgumentError } from '../errors.js'; +import { readSitePutSource, registerSiteCommands, type SiteMemoryBackend } from './commands.js'; + +function backend(overrides: Partial = {}): SiteMemoryBackend { + return { + show: vi.fn(async () => [{ path: 'notes.md', body: '# notes' }]), + list: vi.fn(async () => [{ path: 'notes.md', byteSize: 7, updatedAt: '2026-01-01T00:00:00.000Z', sha256: 'abc' }]), + note: vi.fn(async () => undefined), + endpoint: vi.fn(async () => undefined), + stale: vi.fn(async () => undefined), + fieldMap: vi.fn(async () => undefined), + fixture: vi.fn(async () => '{"args":{}}'), + putFixture: vi.fn(async () => undefined), + sample: vi.fn(async () => undefined), + ...overrides, + }; +} + +function program(store: SiteMemoryBackend, io?: { readStdin?: () => Promise }): Command { + const root = new Command('webcmd').exitOverride(); + registerSiteCommands(root, store, undefined, io); + applyUnknownOptionContract(root); + return root; +} + +describe('site memory format flags', () => { + it('accepts -f json on site fixture get', async () => { + const store = backend(); + await program(store).parseAsync(['site', 'fixture', 'get', 'quotes-toscrape/list', '-f', 'json'], { from: 'user' }); + expect(store.fixture).toHaveBeenCalledWith('quotes-toscrape', 'list'); + }); + + it.each([ + ['site', 'memory', 'show', 'quotes-toscrape', '--kind', 'endpoints', '-f', 'json'], + ['site', 'memory', 'list', 'quotes-toscrape', '-f', 'json'], + ['site', 'note', 'list', 'quotes-toscrape', '--json'], + ['site', 'endpoint', 'list', 'quotes-toscrape', '-f', 'json'], + ])('accepts format flags on %s %s %s', async (...argv) => { + const store = backend(); + await program(store).parseAsync(argv, { from: 'user' }); + expect(vi.mocked(store.show).mock.calls.length + vi.mocked(store.list).mock.calls.length).toBeGreaterThan(0); + }); + + it('rejects unknown flags with the valid set including --format and --json', async () => { + try { + await program(backend()).parseAsync(['site', 'memory', 'show', 'quotes-toscrape', '--nope'], { from: 'user' }); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(CommanderStructuralError); + const output = (error as CommanderStructuralError).output; + expect(output).toContain("unknown option '--nope'"); + expect(output).toContain('--format'); + expect(output).toContain('--json'); + } + }); +}); + +describe('site fixture put --stdin', () => { + it('reads stdin when --stdin is set', async () => { + const store = backend(); + await program(store, { readStdin: async () => '{"args":{}}' }) + .parseAsync(['site', 'fixture', 'put', 'quotes-toscrape/list', '--stdin'], { from: 'user' }); + expect(store.putFixture).toHaveBeenCalledWith('quotes-toscrape', 'list', '{"args":{}}'); + }); + + it('reads stdin when the path is -', async () => { + const store = backend(); + await program(store, { readStdin: async () => '{"ok":true}' }) + .parseAsync(['site', 'sample', 'add', 'quotes-toscrape/list', '-'], { from: 'user' }); + expect(store.sample).toHaveBeenCalledWith('quotes-toscrape', 'list', '{"ok":true}'); + }); +}); + +describe('readSitePutSource', () => { + it('enumerates the valid input shape when neither path nor --stdin is given', async () => { + await expect(readSitePutSource({})).rejects.toBeInstanceOf(ArgumentError); + await expect(readSitePutSource({})).rejects.toThrow(/--stdin/); + }); +}); diff --git a/src/site-memory/commands.ts b/src/site-memory/commands.ts index bda01617..ff65f1a4 100644 --- a/src/site-memory/commands.ts +++ b/src/site-memory/commands.ts @@ -1,5 +1,6 @@ import { readFile, writeFile } from 'node:fs/promises'; import type { Command } from 'commander'; +import { addOutputFormatOption, outputFormatIsExplicit, resolveCommandOutputFormat } from '../command-surface.js'; import { ArgumentError, CliError, EXIT_CODES } from '../errors.js'; import { render as renderOutput } from '../output.js'; import { writeToStream } from '../stream-write.js'; @@ -33,21 +34,38 @@ export interface SiteMemoryBackend { sample(site: string, command: string, body: string): Promise; } -export function registerSiteCommands(root: Command, backend: SiteMemoryBackend, stdout?: NodeJS.WritableStream): void { +export interface SiteCommandIo { + readStdin?(): Promise; +} + +export interface SitePutSourceInput { + path?: string; + stdin?: boolean; +} + +export function registerSiteCommands( + root: Command, + backend: SiteMemoryBackend, + stdout?: NodeJS.WritableStream, + io: SiteCommandIo = {}, +): void { const site = root.command('site').description('Read and write site memory'); const memory = site.command('memory').description('Inspect site memory'); - memory.command('show').argument('').option('--kind ').option('-o, --output ').action(async (name, opts: { kind?: string; output?: string }) => { - const result = await backend.show(name, parseKind(opts.kind)); - if (opts.output) return writeFile(opts.output, `${JSON.stringify(result, null, 2)}\n`); - await renderOutput(result, { fmt: 'json', stdout }); + const show = addOutputFormatOption(memory.command('show').argument('').option('--kind ').option('-o, --output '), 'json'); + show.action(async (name, opts: { kind?: string; output?: string; format?: string }) => { + await emitListing(show, await backend.show(name, parseKind(opts.kind)), opts, stdout); }); - memory.command('list').argument('').option('-o, --output ').action(async (name, opts: { output?: string }) => { - const result = await backend.list(name); - if (opts.output) return writeFile(opts.output, `${JSON.stringify(result, null, 2)}\n`); - await renderOutput(result, { fmt: 'table', fmtExplicit: true, columns: ['path', 'updatedAt', 'byteSize', 'sha256'], stdout }); + const list = addOutputFormatOption(memory.command('list').argument('').option('-o, --output ')); + list.action(async (name, opts: { output?: string; format?: string }) => { + await emitListing(list, await backend.list(name), opts, stdout, ['path', 'updatedAt', 'byteSize', 'sha256']); }); - site.command('note').command('add').argument('').requiredOption('--text ').option('--author ') + const note = site.command('note').description('Read and write site notes'); + note.command('add').argument('').requiredOption('--text ').option('--author ') .action((name, opts: { text: string; author?: string }) => backend.note(name, opts.text, opts.author)); + const noteList = addOutputFormatOption(note.command('list').argument('').option('-o, --output '), 'json'); + noteList.action(async (name, opts: { output?: string; format?: string }) => { + await emitListing(noteList, await backend.show(name, 'notes'), opts, stdout); + }); const endpoint = site.command('endpoint').description('Maintain verified endpoints'); endpoint.command('set').argument('').argument('').requiredOption('--url ').requiredOption('--method ') .option('--params ').option('--rows-path ').option('--fields ').option('--notes ') @@ -59,25 +77,47 @@ export function registerSiteCommands(root: Command, backend: SiteMemoryBackend, ...(opts.notes ? { notes: opts.notes } : {}), })); endpoint.command('stale').argument('').argument('').action((siteName, name) => backend.stale(siteName, name)); + const endpointList = addOutputFormatOption(endpoint.command('list').argument('').option('-o, --output '), 'json'); + endpointList.action(async (name, opts: { output?: string; format?: string }) => { + await emitListing(endpointList, await backend.show(name, 'endpoints'), opts, stdout); + }); site.command('field-map').command('add').argument('').argument('').requiredOption('--meaning ').requiredOption('--source ').option('--force') .action((siteName, key, opts: { meaning: string; source: string; force?: boolean }) => backend.fieldMap(siteName, key, opts.meaning, opts.source, opts.force === true)); const fixture = site.command('fixture').description('Read and write verify fixtures'); - fixture.command('get').argument('').option('--output ').action(async (key, opts: { output?: string }) => { + const get = addOutputFormatOption(fixture.command('get').argument('').option('--output '), 'json'); + get.action(async (key, opts: { output?: string; format?: string }) => { const { site: siteName, command } = parseSiteCommand(key); const body = await backend.fixture(siteName, command); if (body === null) throw new CliError('SITE_MEMORY_NOT_FOUND', `Verify fixture ${key} was not found.`, undefined, EXIT_CODES.EMPTY_RESULT); - if (opts.output) await writeFile(opts.output, body); - else if (stdout) await writeToStream(stdout, body); - else process.stdout.write(body); - }); - fixture.command('put').argument('').argument('').action(async (key, file) => { - const { site: siteName, command } = parseSiteCommand(key); - await backend.putFixture(siteName, command, await readFile(file, 'utf8')); - }); - site.command('sample').command('add').argument('').argument('').action(async (key, file) => { - const { site: siteName, command } = parseSiteCommand(key); - await backend.sample(siteName, command, await readFile(file, 'utf8')); + if (opts.output) { + await writeFile(opts.output, body); + return; + } + if (!outputFormatIsExplicit(get)) { + if (stdout) await writeToStream(stdout, body); + else process.stdout.write(body); + return; + } + const fmt = resolveCommandOutputFormat(get, opts.format); + if (fmt === null) return; + await renderOutput(parseFixtureBody(body), { fmt, fmtExplicit: true, stdout }); }); + fixture.command('put').argument('').argument('[path]').option('--stdin', 'Read the fixture from stdin') + .action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { + const { site: siteName, command } = parseSiteCommand(key); + await backend.putFixture(siteName, command, await readSitePutSource( + { path: file, stdin: opts.stdin === true }, + { readStdin: io.readStdin, usage: 'webcmd site fixture put ' }, + )); + }); + site.command('sample').command('add').argument('').argument('[path]').option('--stdin', 'Read the sample from stdin') + .action(async (key, file: string | undefined, opts: { stdin?: boolean }) => { + const { site: siteName, command } = parseSiteCommand(key); + await backend.sample(siteName, command, await readSitePutSource( + { path: file, stdin: opts.stdin === true }, + { readStdin: io.readStdin, usage: 'webcmd site sample add ' }, + )); + }); } export function createLocalSiteMemoryBackend(options: LocalStoreOptions = {}): SiteMemoryBackend { @@ -94,6 +134,28 @@ export function createLocalSiteMemoryBackend(options: LocalStoreOptions = {}): S }; } +export async function readSitePutSource( + input: SitePutSourceInput, + io: { readStdin?: () => Promise; readPath?: (file: string) => Promise; usage?: string } = {}, +): Promise { + const usage = io.usage ?? 'webcmd site fixture put '; + const path = typeof input.path === 'string' && input.path !== '-' ? input.path : undefined; + const fromStdin = input.stdin === true || input.path === '-'; + if (fromStdin && path) { + throw new ArgumentError( + 'Choose exactly one source: --stdin or .', + `Use: ${usage} or printf '{}' | ${usage} --stdin`, + ); + } + if (!fromStdin && !path) { + throw new ArgumentError( + 'Body requires a file path, --stdin, or -.', + `Use: ${usage} \nexample: printf '{}' | ${usage} --stdin`, + ); + } + return fromStdin ? (io.readStdin ?? readProcessStdin)() : (io.readPath ?? ((file: string) => readFile(file, 'utf8')))(path!); +} + function parseKind(value: string | undefined): MemoryKind | undefined { if (value === undefined) return undefined; if (value === 'notes' || value === 'endpoints' || value === 'field-map' || value === 'verify' || value === 'fixture') return value; @@ -124,3 +186,35 @@ function kindForPath(path: string): MemoryKind | undefined { if (path.startsWith('fixtures/')) return 'fixture'; return undefined; } + +function parseFixtureBody(body: string): unknown { + try { + return JSON.parse(body); + } catch { + return body; + } +} + +async function emitListing( + command: Command, + data: unknown, + opts: { format?: string; output?: string }, + stdout?: NodeJS.WritableStream, + columns?: string[], +): Promise { + if (opts.output) { + await writeFile(opts.output, `${JSON.stringify(data, null, 2)}\n`); + return; + } + const fmt = resolveCommandOutputFormat(command, opts.format); + if (fmt === null) return; + await renderOutput(data, { fmt, fmtExplicit: outputFormatIsExplicit(command), ...(columns ? { columns } : {}), stdout }); +} + +async function readProcessStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/src/skills.test.ts b/src/skills.test.ts index bfe8b5c8..08d3b165 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -280,11 +280,13 @@ describe('webcmd skills content', () => { const autofix = bundledSkill('webcmd-autofix'); expect(usage).toContain('webcmd session create -f json'); + expect(usage).toContain('webcmd profile create work'); expect(usage).toContain('webcmd --session session_abc browser'); expect(usage).toContain('SESSION_BUSY'); expect(usage).toContain('SESSION_REQUIRED'); expect(usage).toMatch(/Adapter commands may omit `--session`[\s\S]{0,200}adapter-default session/i); expect(usage).toMatch(/retired positional session form is invalid/i); + expect(browser).toContain('webcmd profile create work'); expect(browser).toMatch(/Profiles are cookie jars[\s\S]{0,180}sessions are browser workspaces\/windows/i); expect(browser).toMatch(/Parallel agents use separate sessions/i); expect(browser).toContain('SESSION_BUSY');