@agentrhq/webcmd 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -2
  2. package/clis/_shared/site-auth.js +3 -3
  3. package/clis/_shared/site-auth.test.js +4 -4
  4. package/clis/slock/whoami.test.js +2 -2
  5. package/dist/src/browser/daemon-client.d.ts +2 -1
  6. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  7. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  8. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +3 -2
  9. package/dist/src/browser/runtime/local-cloak/session-manager.js +4 -2
  10. package/dist/src/cli.js +5 -4
  11. package/dist/src/cli.test.js +11 -5
  12. package/dist/src/commands/auth.js +3 -2
  13. package/dist/src/commands/auth.test.js +6 -6
  14. package/dist/src/generate-release-notes-cli.test.js +5 -0
  15. package/dist/src/hosted/args.d.ts +9 -0
  16. package/dist/src/hosted/args.js +101 -0
  17. package/dist/src/hosted/args.test.d.ts +1 -0
  18. package/dist/src/hosted/args.test.js +35 -0
  19. package/dist/src/hosted/client.d.ts +30 -0
  20. package/dist/src/hosted/client.js +122 -0
  21. package/dist/src/hosted/client.test.d.ts +1 -0
  22. package/dist/src/hosted/client.test.js +119 -0
  23. package/dist/src/hosted/config.d.ts +50 -0
  24. package/dist/src/hosted/config.js +90 -0
  25. package/dist/src/hosted/config.test.d.ts +1 -0
  26. package/dist/src/hosted/config.test.js +48 -0
  27. package/dist/src/hosted/manifest.d.ts +9 -0
  28. package/dist/src/hosted/manifest.js +92 -0
  29. package/dist/src/hosted/manifest.test.d.ts +1 -0
  30. package/dist/src/hosted/manifest.test.js +46 -0
  31. package/dist/src/hosted/runner.d.ts +12 -0
  32. package/dist/src/hosted/runner.js +404 -0
  33. package/dist/src/hosted/runner.test.d.ts +1 -0
  34. package/dist/src/hosted/runner.test.js +189 -0
  35. package/dist/src/hosted/setup.d.ts +9 -0
  36. package/dist/src/hosted/setup.js +49 -0
  37. package/dist/src/hosted/setup.test.d.ts +1 -0
  38. package/dist/src/hosted/setup.test.js +68 -0
  39. package/dist/src/hosted/types.d.ts +97 -0
  40. package/dist/src/hosted/types.js +1 -0
  41. package/dist/src/main.js +14 -0
  42. package/dist/src/release-notes.d.ts +6 -1
  43. package/dist/src/release-notes.js +184 -4
  44. package/dist/src/release-notes.test.js +128 -1
  45. package/package.json +1 -1
  46. package/scripts/generate-release-notes.ts +1 -1
  47. package/skills/smart-search/SKILL.md +14 -3
  48. package/skills/webcmd-adapter-author/SKILL.md +2 -2
  49. package/skills/webcmd-adapter-author/references/adapter-template.md +25 -3
  50. package/skills/webcmd-autofix/SKILL.md +1 -1
  51. package/skills/webcmd-usage/SKILL.md +22 -9
  52. package/clis/antigravity/SKILL.md +0 -38
@@ -0,0 +1,404 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import yaml from 'js-yaml';
3
+ import { BrowserSessionArgvError, rewriteBrowserArgv } from '../cli-argv-preprocess.js';
4
+ import { ConfigError, EXIT_CODES, toEnvelope } from '../errors.js';
5
+ import { render as renderOutput } from '../output.js';
6
+ import { HostedClient } from './client.js';
7
+ import { parseHostedInvocation } from './args.js';
8
+ import { findHostedCommand, hostedListRows, isLocalOnlyHostedCommand, renderHostedCommandHelp, renderHostedSiteHelp, siteNames, commandNamesForSite, } from './manifest.js';
9
+ import { isHostedConfig, loadWebcmdConfig } from './config.js';
10
+ export async function runHostedCli(argv, opts = {}) {
11
+ const config = opts.config ?? loadWebcmdConfig();
12
+ if (!isHostedConfig(config))
13
+ return { handled: false, exitCode: EXIT_CODES.SUCCESS };
14
+ const stdout = opts.stdout ?? process.stdout;
15
+ const stderr = opts.stderr ?? process.stderr;
16
+ const client = new HostedClient({
17
+ apiBaseUrl: config.hosted.apiBaseUrl,
18
+ apiKey: config.hosted.apiKey,
19
+ fetchImpl: opts.fetchImpl,
20
+ });
21
+ try {
22
+ await dispatchHosted(argv, client, stdout);
23
+ return { handled: true, exitCode: EXIT_CODES.SUCCESS };
24
+ }
25
+ catch (err) {
26
+ stderr.write(yaml.dump(toEnvelope(err), { sortKeys: false, lineWidth: 120, noRefs: true }));
27
+ return {
28
+ handled: true,
29
+ exitCode: errorExitCode(err),
30
+ };
31
+ }
32
+ }
33
+ async function dispatchHosted(argv, client, stdout) {
34
+ const normalized = stripGlobalOptions(argv);
35
+ const args = normalized.argv;
36
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
37
+ stdout.write('Usage: webcmd <site> <command> [options]\n webcmd list\n webcmd setup\n');
38
+ return;
39
+ }
40
+ if (args[0] === 'daemon') {
41
+ throw new ConfigError('webcmd daemon is local-only. Hosted mode has no local daemon.', 'Run `webcmd setup` and choose local mode to manage the local daemon.');
42
+ }
43
+ if (args[0] === 'browser') {
44
+ await dispatchHostedBrowser(args, normalized.profile, client, stdout);
45
+ return;
46
+ }
47
+ const manifest = await client.getManifest();
48
+ if (isCompletionRequest(args)) {
49
+ stdout.write(hostedCompletions(manifest, args).join('\n') + '\n');
50
+ return;
51
+ }
52
+ if (args[0] === 'list') {
53
+ renderHostedList(manifest, args.slice(1));
54
+ return;
55
+ }
56
+ const site = args[0];
57
+ const commandName = args[1];
58
+ if (!commandName || commandName === '--help' || commandName === '-h') {
59
+ stdout.write(renderHostedSiteHelp(manifest, site));
60
+ return;
61
+ }
62
+ const command = findHostedCommand(manifest, site, commandName);
63
+ if (!command) {
64
+ throw new ConfigError(`Unknown hosted Webcmd command: ${site}/${commandName}`);
65
+ }
66
+ if (isLocalOnlyHostedCommand(command)) {
67
+ throw new ConfigError(`Command ${command.command} is local-only and is not available in hosted mode.`, 'Run `webcmd setup` and choose local mode to use local-only commands.');
68
+ }
69
+ const parsed = parseHostedInvocation(command, [...args.slice(2), ...normalized.trailingCommandOptions]);
70
+ if (parsed.help) {
71
+ stdout.write(renderHostedCommandHelp(command));
72
+ return;
73
+ }
74
+ const response = await client.execute({
75
+ command: command.command,
76
+ args: parsed.args,
77
+ format: parsed.format,
78
+ trace: parsed.trace,
79
+ profile: parsed.profile ?? normalized.profile,
80
+ });
81
+ const result = response.result ?? response.rows ?? response.data ?? null;
82
+ renderOutput(result, {
83
+ fmt: parsed.format,
84
+ fmtExplicit: true,
85
+ columns: response.columns ?? command.columns,
86
+ title: command.command,
87
+ source: 'webcmd cloud',
88
+ });
89
+ }
90
+ async function dispatchHostedBrowser(argv, profile, client, stdout) {
91
+ const invocation = parseHostedBrowserInvocation(argv, profile);
92
+ const response = await client.runBrowserAction(invocation.session, {
93
+ command: invocation.command,
94
+ action: invocation.action,
95
+ args: invocation.args,
96
+ ...(invocation.profile !== undefined ? { profile: invocation.profile } : {}),
97
+ ...(invocation.windowMode !== undefined ? { windowMode: invocation.windowMode } : {}),
98
+ trace: 'off',
99
+ });
100
+ renderHostedBrowserResponse(stdout, invocation, response);
101
+ }
102
+ function parseHostedBrowserInvocation(argv, profile) {
103
+ let rewritten;
104
+ try {
105
+ rewritten = rewriteBrowserArgv(argv);
106
+ }
107
+ catch (error) {
108
+ if (error instanceof BrowserSessionArgvError) {
109
+ throw new ConfigError(error.message, 'Use: webcmd browser <session> <command>');
110
+ }
111
+ throw error;
112
+ }
113
+ if (rewritten[0] !== 'browser') {
114
+ throw new ConfigError('Hosted browser invocation must start with browser.');
115
+ }
116
+ if (rewritten[1] !== '--session' || !rewritten[2]) {
117
+ throw new ConfigError('<session> is required for hosted browser commands.', 'Use: webcmd browser <session> <command>');
118
+ }
119
+ const session = rewritten[2];
120
+ let index = 3;
121
+ let windowMode;
122
+ while (index < rewritten.length) {
123
+ const token = rewritten[index];
124
+ if (token === '--window') {
125
+ windowMode = parseWindowMode(rewritten[index + 1]);
126
+ index += 2;
127
+ continue;
128
+ }
129
+ if (token?.startsWith('--window=')) {
130
+ windowMode = parseWindowMode(token.slice('--window='.length));
131
+ index += 1;
132
+ continue;
133
+ }
134
+ break;
135
+ }
136
+ const leaf = rewritten[index];
137
+ if (!leaf || leaf === '--help' || leaf === '-h') {
138
+ throw new ConfigError('Hosted browser command is required.', 'Use: webcmd browser <session> open <url>, state, screenshot, tab list, or eval <js>.');
139
+ }
140
+ const rest = rewritten.slice(index + 1);
141
+ const parsed = parseBrowserLeaf(leaf, rest);
142
+ return {
143
+ session,
144
+ command: `browser/${parsed.commandName}`,
145
+ action: parsed.action,
146
+ args: parsed.args,
147
+ ...(parsed.localPath !== undefined ? { localPath: parsed.localPath } : {}),
148
+ ...(profile !== undefined ? { profile } : {}),
149
+ ...(windowMode !== undefined ? { windowMode } : {}),
150
+ };
151
+ }
152
+ function parseWindowMode(value) {
153
+ if (value === 'foreground' || value === 'background')
154
+ return value;
155
+ throw new ConfigError('--window must be one of: foreground, background.');
156
+ }
157
+ function parseBrowserLeaf(leaf, argv) {
158
+ const parsed = splitOptions(argv);
159
+ switch (leaf) {
160
+ case 'bind':
161
+ throw new ConfigError('Browser bind is not supported in hosted mode.', 'Use browser state or browser tabs to inspect the active hosted page.');
162
+ case 'unbind':
163
+ case 'close':
164
+ return { commandName: leaf, action: 'close-window', args: {} };
165
+ case 'open':
166
+ return { commandName: 'open', action: 'navigate', args: { url: requiredPositional(parsed.positionals, 0, 'url') } };
167
+ case 'back':
168
+ return { commandName: 'back', action: 'back', args: {} };
169
+ case 'state':
170
+ return { commandName: 'state', action: 'snapshot', args: { source: parsed.options.source ?? 'dom' } };
171
+ case 'frames':
172
+ return { commandName: 'frames', action: 'frames', args: {} };
173
+ case 'screenshot': {
174
+ const localPath = parsed.positionals[0];
175
+ return {
176
+ commandName: 'screenshot',
177
+ action: 'screenshot',
178
+ args: {
179
+ fullPage: parsed.options.fullPage === true,
180
+ ...(parsed.options.width !== undefined ? { width: parsed.options.width } : {}),
181
+ ...(parsed.options.height !== undefined ? { height: parsed.options.height } : {}),
182
+ },
183
+ ...(localPath !== undefined ? { localPath } : {}),
184
+ };
185
+ }
186
+ case 'tab':
187
+ return parseBrowserTab(parsed.positionals);
188
+ case 'eval':
189
+ return {
190
+ commandName: 'eval',
191
+ action: 'exec',
192
+ args: {
193
+ js: requiredPositional(parsed.positionals, 0, 'js'),
194
+ ...(parsed.options.frame !== undefined ? { frame: parsed.options.frame } : {}),
195
+ },
196
+ };
197
+ case 'scroll':
198
+ return {
199
+ commandName: 'scroll',
200
+ action: 'scroll',
201
+ args: {
202
+ direction: requiredPositional(parsed.positionals, 0, 'direction'),
203
+ amount: parsed.options.amount ?? 500,
204
+ },
205
+ };
206
+ case 'keys':
207
+ return { commandName: 'keys', action: 'press-key', args: { key: requiredPositional(parsed.positionals, 0, 'key') } };
208
+ case 'wait':
209
+ return {
210
+ commandName: 'wait',
211
+ action: 'wait',
212
+ args: {
213
+ type: requiredPositional(parsed.positionals, 0, 'type'),
214
+ ...(parsed.positionals[1] !== undefined ? { value: parsed.positionals[1] } : {}),
215
+ ...(parsed.options.timeout !== undefined ? { timeout: parsed.options.timeout } : {}),
216
+ },
217
+ };
218
+ case 'click':
219
+ return { commandName: 'click', action: 'click', args: { target: requiredPositional(parsed.positionals, 0, 'target') } };
220
+ case 'type':
221
+ return {
222
+ commandName: 'type',
223
+ action: 'type',
224
+ args: {
225
+ target: requiredPositional(parsed.positionals, 0, 'target'),
226
+ text: requiredPositional(parsed.positionals, 1, 'text'),
227
+ },
228
+ };
229
+ case 'fill':
230
+ return {
231
+ commandName: 'fill',
232
+ action: 'fill',
233
+ args: {
234
+ target: requiredPositional(parsed.positionals, 0, 'target'),
235
+ text: requiredPositional(parsed.positionals, 1, 'text'),
236
+ },
237
+ };
238
+ case 'upload':
239
+ return {
240
+ commandName: 'upload',
241
+ action: 'set-file-input',
242
+ args: {
243
+ selector: parsed.positionals[0] ?? 'input[type="file"]',
244
+ files: parsed.positionals.slice(1),
245
+ },
246
+ };
247
+ case 'console':
248
+ return { commandName: 'console', action: 'console', args: parsed.options };
249
+ case 'network':
250
+ return { commandName: 'network', action: 'network', args: parsed.options };
251
+ default:
252
+ throw new ConfigError(`Hosted browser command is not supported yet: ${leaf}`);
253
+ }
254
+ }
255
+ function parseBrowserTab(positionals) {
256
+ const op = positionals[0] ?? 'list';
257
+ if (op === 'list')
258
+ return { commandName: 'tab/list', action: 'tabs', args: { op: 'list' } };
259
+ if (op === 'new')
260
+ return { commandName: 'tab/new', action: 'tabs', args: { op: 'new', ...(positionals[1] ? { url: positionals[1] } : {}) } };
261
+ if (op === 'select')
262
+ return { commandName: 'tab/select', action: 'tabs', args: { op: 'select', target: requiredPositional(positionals, 1, 'targetId') } };
263
+ if (op === 'close')
264
+ return { commandName: 'tab/close', action: 'tabs', args: { op: 'close', target: requiredPositional(positionals, 1, 'targetId') } };
265
+ throw new ConfigError(`Hosted browser tab command is not supported yet: ${op}`);
266
+ }
267
+ function splitOptions(argv) {
268
+ const positionals = [];
269
+ const options = {};
270
+ let literal = false;
271
+ for (let i = 0; i < argv.length; i++) {
272
+ const token = argv[i];
273
+ if (literal) {
274
+ positionals.push(token);
275
+ continue;
276
+ }
277
+ if (token === '--') {
278
+ literal = true;
279
+ continue;
280
+ }
281
+ if (!token.startsWith('-') || token === '-') {
282
+ positionals.push(token);
283
+ continue;
284
+ }
285
+ if (token.startsWith('--') && token.includes('=')) {
286
+ const [rawKey, ...rawValue] = token.slice(2).split('=');
287
+ options[toCamelCase(rawKey)] = coerceOptionValue(rawValue.join('='));
288
+ continue;
289
+ }
290
+ const key = token.replace(/^-+/, '');
291
+ const next = argv[i + 1];
292
+ if (next !== undefined && !next.startsWith('-')) {
293
+ options[toCamelCase(key)] = coerceOptionValue(next);
294
+ i += 1;
295
+ }
296
+ else {
297
+ options[toCamelCase(key)] = true;
298
+ }
299
+ }
300
+ return { positionals, options };
301
+ }
302
+ function requiredPositional(values, index, label) {
303
+ const value = values[index];
304
+ if (value === undefined || value === '') {
305
+ throw new ConfigError(`Missing required browser argument: ${label}`);
306
+ }
307
+ return value;
308
+ }
309
+ function toCamelCase(value) {
310
+ return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
311
+ }
312
+ function coerceOptionValue(value) {
313
+ if (value === 'true')
314
+ return true;
315
+ if (value === 'false')
316
+ return false;
317
+ if (/^-?\d+$/.test(value))
318
+ return Number.parseInt(value, 10);
319
+ return value;
320
+ }
321
+ function renderHostedBrowserResponse(stdout, invocation, response) {
322
+ const result = response.result;
323
+ if (invocation.action === 'snapshot' && result && typeof result === 'object') {
324
+ const record = result;
325
+ stdout.write(`URL: ${typeof record.url === 'string' ? record.url : ''}\n\n`);
326
+ stdout.write(`${typeof record.snapshot === 'string' ? record.snapshot : JSON.stringify(record.snapshot, null, 2)}\n`);
327
+ return;
328
+ }
329
+ if (invocation.action === 'screenshot' && result && typeof result === 'object') {
330
+ const base64 = result.base64;
331
+ if (typeof base64 === 'string' && invocation.localPath) {
332
+ writeFileSync(invocation.localPath, Buffer.from(base64, 'base64'));
333
+ stdout.write(`Screenshot saved to: ${invocation.localPath}\n`);
334
+ return;
335
+ }
336
+ if (typeof base64 === 'string') {
337
+ stdout.write(`${base64}\n`);
338
+ return;
339
+ }
340
+ }
341
+ if (typeof result === 'string') {
342
+ stdout.write(`${result}\n`);
343
+ return;
344
+ }
345
+ stdout.write(`${JSON.stringify(result ?? response, null, 2)}\n`);
346
+ }
347
+ function renderHostedList(manifest, argv) {
348
+ const fmt = readFormat(argv);
349
+ const structured = fmt === 'json' || fmt === 'yaml' || fmt === 'yml';
350
+ renderOutput(hostedListRows(manifest, structured), {
351
+ fmt,
352
+ fmtExplicit: true,
353
+ columns: ['command', 'site', 'name', 'aliases', 'description', 'access', 'strategy', 'browser', 'args',
354
+ ...(structured ? ['columns', 'domain'] : [])],
355
+ title: 'webcmd/list',
356
+ source: 'webcmd cloud',
357
+ });
358
+ }
359
+ function readFormat(argv) {
360
+ for (let i = 0; i < argv.length; i++) {
361
+ if (argv[i] === '-f' || argv[i] === '--format')
362
+ return argv[i + 1] ?? 'table';
363
+ if (argv[i]?.startsWith('--format='))
364
+ return argv[i].slice('--format='.length);
365
+ }
366
+ return 'table';
367
+ }
368
+ function stripGlobalOptions(argv) {
369
+ const out = [];
370
+ const trailingCommandOptions = [];
371
+ let profile;
372
+ for (let i = 0; i < argv.length; i++) {
373
+ const token = argv[i];
374
+ if (token === '--profile') {
375
+ profile = argv[++i];
376
+ continue;
377
+ }
378
+ if (token.startsWith('--profile=')) {
379
+ profile = token.slice('--profile='.length);
380
+ continue;
381
+ }
382
+ out.push(token);
383
+ }
384
+ return { argv: out, trailingCommandOptions, ...(profile ? { profile } : {}) };
385
+ }
386
+ function isCompletionRequest(argv) {
387
+ return argv.includes('--get-completions');
388
+ }
389
+ function hostedCompletions(manifest, argv) {
390
+ const index = argv.indexOf('--get-completions');
391
+ const words = index === -1 ? argv : argv.slice(index + 1).filter((word) => word !== '--cursor');
392
+ const meaningful = words.filter((word) => !/^\d+$/.test(word));
393
+ if (meaningful.length <= 1)
394
+ return ['list', 'setup', ...siteNames(manifest)];
395
+ return commandNamesForSite(manifest, meaningful[0]);
396
+ }
397
+ function errorExitCode(err) {
398
+ if (err instanceof ConfigError)
399
+ return err.exitCode;
400
+ if (err && typeof err === 'object' && 'exitCode' in err && typeof err.exitCode === 'number') {
401
+ return err.exitCode;
402
+ }
403
+ return EXIT_CODES.GENERIC_ERROR;
404
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,189 @@
1
+ import { Writable } from 'node:stream';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { makeHostedConfig } from './config.js';
4
+ import { runHostedCli } from './runner.js';
5
+ const manifest = {
6
+ userId: 'user_demo',
7
+ generatedAt: '2026-07-08T00:00:00.000Z',
8
+ commands: [
9
+ {
10
+ site: 'github',
11
+ name: 'whoami',
12
+ command: 'github/whoami',
13
+ description: 'Show GitHub identity',
14
+ access: 'read',
15
+ strategy: 'COOKIE',
16
+ browser: true,
17
+ args: [],
18
+ columns: ['username'],
19
+ domain: 'github.com',
20
+ },
21
+ {
22
+ site: 'docker',
23
+ name: 'ps',
24
+ command: 'docker/ps',
25
+ description: 'Local Docker containers',
26
+ access: 'read',
27
+ strategy: 'LOCAL',
28
+ browser: false,
29
+ args: [],
30
+ columns: ['id'],
31
+ },
32
+ ],
33
+ };
34
+ function sink() {
35
+ let data = '';
36
+ return {
37
+ stream: new Writable({
38
+ write(chunk, _encoding, callback) {
39
+ data += String(chunk);
40
+ callback();
41
+ },
42
+ }),
43
+ text: () => data,
44
+ };
45
+ }
46
+ afterEach(() => {
47
+ vi.restoreAllMocks();
48
+ });
49
+ describe('runHostedCli', () => {
50
+ it('renders hosted list without LOCAL commands', async () => {
51
+ const logs = [];
52
+ vi.spyOn(console, 'log').mockImplementation((message = '') => {
53
+ logs.push(String(message));
54
+ });
55
+ const result = await runHostedCli(['list', '-f', 'json'], {
56
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
57
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }),
58
+ });
59
+ expect(result).toEqual({ handled: true, exitCode: 0 });
60
+ expect(logs.join('\n')).toContain('github/whoami');
61
+ expect(logs.join('\n')).not.toContain('docker/ps');
62
+ });
63
+ it('dispatches hosted commands to /v1/execute', async () => {
64
+ const requests = [];
65
+ vi.spyOn(console, 'log').mockImplementation(() => undefined);
66
+ const result = await runHostedCli(['github', 'whoami', '-f', 'json'], {
67
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
68
+ fetchImpl: async (url, init) => {
69
+ requests.push({
70
+ url: String(url),
71
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
72
+ });
73
+ if (String(url).endsWith('/v1/manifest')) {
74
+ return new Response(JSON.stringify({ ok: true, manifest }), { status: 200 });
75
+ }
76
+ return new Response(JSON.stringify({
77
+ ok: true,
78
+ result: [{ username: 'octocat' }],
79
+ columns: ['username'],
80
+ }), { status: 200 });
81
+ },
82
+ });
83
+ expect(result).toEqual({ handled: true, exitCode: 0 });
84
+ expect(requests.at(-1)).toEqual({
85
+ url: 'https://api.example.com/v1/execute',
86
+ body: {
87
+ command: 'github/whoami',
88
+ args: {},
89
+ format: 'json',
90
+ trace: 'off',
91
+ },
92
+ });
93
+ });
94
+ it('rejects daemon commands in hosted mode', async () => {
95
+ const stderr = sink();
96
+ const result = await runHostedCli(['daemon', 'status'], {
97
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
98
+ stderr: stderr.stream,
99
+ });
100
+ expect(result.exitCode).toBe(78);
101
+ expect(stderr.text()).toMatch(/hosted mode has no local daemon/i);
102
+ });
103
+ it('routes hosted browser positional commands through the cloud lifecycle', async () => {
104
+ const requests = [];
105
+ const stdout = sink();
106
+ const result = await runHostedCli(['--profile', 'default', 'browser', 'work', 'open', 'https://example.com', '--window', 'background'], {
107
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
108
+ stdout: stdout.stream,
109
+ fetchImpl: async (url, init) => {
110
+ requests.push({
111
+ url: String(url),
112
+ body: init?.body ? JSON.parse(String(init.body)) : undefined,
113
+ });
114
+ if (String(url).endsWith('/runs')) {
115
+ return new Response(JSON.stringify({
116
+ ok: true,
117
+ run: {
118
+ executionId: 'exec_browser',
119
+ session: 'work',
120
+ profile: { id: 'profile_default', displayName: 'default' },
121
+ },
122
+ }), { status: 201 });
123
+ }
124
+ if (String(url).endsWith('/actions')) {
125
+ return new Response(JSON.stringify({
126
+ ok: true,
127
+ result: { url: 'https://example.com' },
128
+ columns: ['url'],
129
+ trace: null,
130
+ }), { status: 200 });
131
+ }
132
+ return new Response(JSON.stringify({
133
+ ok: true,
134
+ execution: { id: 'exec_browser', status: 'succeeded' },
135
+ }), { status: 200 });
136
+ },
137
+ });
138
+ expect(result).toEqual({ handled: true, exitCode: 0 });
139
+ expect(stdout.text()).toContain('https://example.com');
140
+ expect(requests).toEqual([
141
+ {
142
+ url: 'https://api.example.com/v1/browser/work/runs',
143
+ body: {
144
+ command: 'browser/open',
145
+ args: { url: 'https://example.com' },
146
+ profile: 'default',
147
+ windowMode: 'background',
148
+ trace: 'off',
149
+ },
150
+ },
151
+ {
152
+ url: 'https://api.example.com/v1/browser/work/runs/exec_browser/actions',
153
+ body: {
154
+ action: 'navigate',
155
+ args: { url: 'https://example.com' },
156
+ profile: 'default',
157
+ },
158
+ },
159
+ {
160
+ url: 'https://api.example.com/v1/browser/work/runs/exec_browser/finish',
161
+ body: {
162
+ status: 'succeeded',
163
+ profile: 'default',
164
+ },
165
+ },
166
+ ]);
167
+ });
168
+ it('rejects the retired hosted browser --session flag', async () => {
169
+ const stderr = sink();
170
+ const result = await runHostedCli(['browser', '--session', 'work', 'state'], {
171
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
172
+ stderr: stderr.stream,
173
+ });
174
+ expect(result.exitCode).toBe(78);
175
+ expect(stderr.text()).toMatch(/session.*no longer a public option/i);
176
+ });
177
+ it('rejects browser bind before making a hosted request', async () => {
178
+ const stderr = sink();
179
+ const fetchImpl = vi.fn();
180
+ const result = await runHostedCli(['browser', 'work', 'bind'], {
181
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
182
+ stderr: stderr.stream,
183
+ fetchImpl,
184
+ });
185
+ expect(result).toEqual({ handled: true, exitCode: 78 });
186
+ expect(stderr.text()).toMatch(/browser bind is not supported in hosted mode/i);
187
+ expect(fetchImpl).not.toHaveBeenCalled();
188
+ });
189
+ });
@@ -0,0 +1,9 @@
1
+ import { type ConfigIo } from './config.js';
2
+ export interface SetupIo extends ConfigIo {
3
+ input?: NodeJS.ReadableStream;
4
+ output?: NodeJS.WritableStream;
5
+ fetchImpl?: typeof fetch;
6
+ question?: (prompt: string) => Promise<string>;
7
+ write?: (message: string) => void;
8
+ }
9
+ export declare function runHostedSetup(io?: SetupIo): Promise<number>;
@@ -0,0 +1,49 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { stdin as defaultInput, stdout as defaultOutput } from 'node:process';
3
+ import { HostedClient } from './client.js';
4
+ import { defaultHostedApiBaseUrl, makeHostedConfig, makeLocalConfig, saveWebcmdConfig, } from './config.js';
5
+ export async function runHostedSetup(io = {}) {
6
+ const write = io.write ?? ((message) => (io.output ?? defaultOutput).write(message));
7
+ const ownedReadline = io.question ? undefined : createInterface({
8
+ input: io.input ?? defaultInput,
9
+ output: io.output ?? defaultOutput,
10
+ });
11
+ const ask = io.question ?? ((prompt) => ownedReadline.question(prompt));
12
+ try {
13
+ write('Webcmd setup\n');
14
+ const mode = await ask('Use hosted Webcmd Cloud or local Webcmd? [hosted/local] ');
15
+ if (mode.trim().toLowerCase().startsWith('l')) {
16
+ saveWebcmdConfig(makeLocalConfig(io.now?.() ?? new Date()), io);
17
+ write('Webcmd is now configured for local mode.\n');
18
+ return 0;
19
+ }
20
+ const apiBaseUrl = defaultHostedApiBaseUrl(io.env ?? process.env);
21
+ const apiKey = (await ask('Webcmd API key: ')).trim();
22
+ if (!apiKey) {
23
+ write('A Webcmd API key is required for hosted mode.\n');
24
+ return 2;
25
+ }
26
+ const config = makeHostedConfig({
27
+ apiBaseUrl,
28
+ apiKey,
29
+ now: io.now?.() ?? new Date(),
30
+ });
31
+ try {
32
+ await new HostedClient({
33
+ apiBaseUrl: config.hosted.apiBaseUrl,
34
+ apiKey: config.hosted.apiKey,
35
+ fetchImpl: io.fetchImpl,
36
+ }).getMe();
37
+ }
38
+ catch (err) {
39
+ const message = err instanceof Error ? err.message : String(err);
40
+ write(`Warning: could not verify API key yet: ${message}\n`);
41
+ }
42
+ saveWebcmdConfig(config, io);
43
+ write('Webcmd is now configured for hosted mode.\n');
44
+ return 0;
45
+ }
46
+ finally {
47
+ ownedReadline?.close();
48
+ }
49
+ }
@@ -0,0 +1 @@
1
+ export {};