Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions skill-src/webcmd-browser/SKILL.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <profile> session create`.
- Create a named profile first: `webcmd profile create <profile>`. 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 <session-id> 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 <session-id>`. Close is blocked while that Session has a live handoff.
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions skill-src/webcmd-usage/SKILL.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> session create` returns
`PROFILE_NOT_FOUND`, run `webcmd profile create <name>` and retry.

`webcmd session close <session-id>` 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 <session-id>` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid.

Expand Down
2 changes: 2 additions & 0 deletions skills/webcmd-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <profile> session create`.
- Create a named profile first: `webcmd profile create <profile>`. 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 <session-id> 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 <session-id>`. Close is blocked while that Session has a live handoff.
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions skills/webcmd-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> session create` returns
`PROFILE_NOT_FOUND`, run `webcmd profile create <name>` and retry.

`webcmd session close <session-id>` 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 <session-id>` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid.

Expand Down
2 changes: 1 addition & 1 deletion src/browser/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
27 changes: 26 additions & 1 deletion src/browser/profile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
38 changes: 37 additions & 1 deletion src/browser/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 <alias|contextId> 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 <alias>`);
let contextId: string;
try {
contextId = normalizeProfileId(name);
} catch {
throw new ArgumentError(
`Invalid profile alias "${name}". Use letters, numbers, ".", "_" or "-".`,
`usage: ${CLI_COMMAND} profile create <alias>\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);
Expand Down
7 changes: 7 additions & 0 deletions src/browser/run/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
19 changes: 8 additions & 11 deletions src/builtin-command-surface.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 <fmt>', LIST_FORMAT_DESCRIPTION, 'table')
.option('--tag <tag>', 'Filter commands by exact tag');
.option('--tag <tag>', 'Filter commands by exact tag'));
}

/** Configure completion grammar shared by the local and hosted runtimes. */
Expand All @@ -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 <fmt>', 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('<source>', 'Plugin source (e.g. github:user/repo)');
.argument('<source>', 'Plugin source (e.g. github:user/repo/<plugin>)')
.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 <fmt>', OUTPUT_FORMAT_HELP, 'table');
return addOutputFormatOption(command.description('List installed plugins'));
}

/** Configure plugin uninstall grammar shared by local and hosted runtimes. */
Expand Down
1 change: 1 addition & 0 deletions src/cli-argv-preprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ function knownCommandOptions(cmd: DashPositionalManifestEntry): Map<string, Opti
['--verbose', 'none'],
['-f', 'required'],
['--format', 'required'],
['--json', 'none'],
['--trace', 'required'],
]);
if (cmd.browser) {
Expand Down
11 changes: 11 additions & 0 deletions src/cli-error-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ describe('reportCliError', () => {
}
});

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');
Expand Down
32 changes: 28 additions & 4 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -1268,6 +1268,8 @@ name: 'search',
expect(search.options.map(option => option.flags)).toContain('-f, --format <fmt>');
expect(install.usage()).toBe('[options] <source>');
expect(install.description()).toBe('Install a plugin from a git repository');
expect(install.registeredArguments[0]?.description).toContain('github:user/repo/<plugin>');
expect(install.options.map(option => option.long)).toContain('--all');
});

it('renders adapter namespace structured help preserving original description after applyRootSubcommandSummaries', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
Expand Down
Loading
Loading