@agentrhq/webcmd 0.2.5 → 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 (48) hide show
  1. package/clis/_shared/site-auth.js +3 -3
  2. package/clis/_shared/site-auth.test.js +4 -4
  3. package/clis/slock/whoami.test.js +2 -2
  4. package/dist/src/browser/daemon-client.d.ts +2 -1
  5. package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
  6. package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
  7. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +3 -2
  8. package/dist/src/browser/runtime/local-cloak/session-manager.js +4 -2
  9. package/dist/src/cli.js +5 -4
  10. package/dist/src/cli.test.js +11 -5
  11. package/dist/src/commands/auth.js +3 -2
  12. package/dist/src/commands/auth.test.js +6 -6
  13. package/dist/src/generate-release-notes-cli.test.js +5 -0
  14. package/dist/src/hosted/args.d.ts +9 -0
  15. package/dist/src/hosted/args.js +101 -0
  16. package/dist/src/hosted/args.test.d.ts +1 -0
  17. package/dist/src/hosted/args.test.js +35 -0
  18. package/dist/src/hosted/client.d.ts +30 -0
  19. package/dist/src/hosted/client.js +122 -0
  20. package/dist/src/hosted/client.test.d.ts +1 -0
  21. package/dist/src/hosted/client.test.js +119 -0
  22. package/dist/src/hosted/config.d.ts +50 -0
  23. package/dist/src/hosted/config.js +90 -0
  24. package/dist/src/hosted/config.test.d.ts +1 -0
  25. package/dist/src/hosted/config.test.js +48 -0
  26. package/dist/src/hosted/manifest.d.ts +9 -0
  27. package/dist/src/hosted/manifest.js +92 -0
  28. package/dist/src/hosted/manifest.test.d.ts +1 -0
  29. package/dist/src/hosted/manifest.test.js +46 -0
  30. package/dist/src/hosted/runner.d.ts +12 -0
  31. package/dist/src/hosted/runner.js +404 -0
  32. package/dist/src/hosted/runner.test.d.ts +1 -0
  33. package/dist/src/hosted/runner.test.js +189 -0
  34. package/dist/src/hosted/setup.d.ts +9 -0
  35. package/dist/src/hosted/setup.js +49 -0
  36. package/dist/src/hosted/setup.test.d.ts +1 -0
  37. package/dist/src/hosted/setup.test.js +68 -0
  38. package/dist/src/hosted/types.d.ts +97 -0
  39. package/dist/src/hosted/types.js +1 -0
  40. package/dist/src/main.js +14 -0
  41. package/dist/src/release-notes.d.ts +6 -1
  42. package/dist/src/release-notes.js +184 -4
  43. package/dist/src/release-notes.test.js +128 -1
  44. package/package.json +1 -1
  45. package/scripts/generate-release-notes.ts +1 -1
  46. package/skills/smart-search/SKILL.md +14 -3
  47. package/skills/webcmd-usage/SKILL.md +14 -7
  48. package/clis/antigravity/SKILL.md +0 -38
@@ -72,7 +72,7 @@ export function registerSiteAuthCommands(config) {
72
72
  ? { refresh: async (page, kwargs) => normalizeRefreshResult(await config.refresh(page, kwargs)) }
73
73
  : {}),
74
74
  },
75
- func: async (page) => tryProbe(config, page, 'identity'),
75
+ func: async (page) => [await tryProbe(config, page, 'identity')],
76
76
  });
77
77
 
78
78
  cli({
@@ -92,7 +92,7 @@ export function registerSiteAuthCommands(config) {
92
92
  columns: ['status', ...commandColumns(config)],
93
93
  func: async (page, kwargs) => {
94
94
  try {
95
- return { status: 'already_logged_in', ...await tryProbe(config, page, 'identity') };
95
+ return [{ status: 'already_logged_in', ...await tryProbe(config, page, 'identity') }];
96
96
  } catch (error) {
97
97
  if (!isAuthRequired(error)) throw error;
98
98
  }
@@ -106,7 +106,7 @@ export function registerSiteAuthCommands(config) {
106
106
  await page.wait(Math.min(POLL_INTERVAL_MS / 1000, Math.max(0.2, (deadline - Date.now()) / 1000)));
107
107
  try {
108
108
  const identity = await tryProbe(config, page, 'poll');
109
- return { status: 'login_complete', ...identity };
109
+ return [{ status: 'login_complete', ...identity }];
110
110
  } catch (error) {
111
111
  if (!isAuthRequired(error)) throw error;
112
112
  lastAuthMessage = getErrorMessage(error);
@@ -47,11 +47,11 @@ describe('site auth command helper', () => {
47
47
  const cmd = getRegistry().get('auth-helper-whoami/whoami');
48
48
  const page = pageMock();
49
49
 
50
- await expect(cmd.func(page, {})).resolves.toEqual({
50
+ await expect(cmd.func(page, {})).resolves.toEqual([{
51
51
  logged_in: true,
52
52
  site: 'auth-helper-whoami',
53
53
  username: 'alice',
54
- });
54
+ }]);
55
55
  expect(page.goto).not.toHaveBeenCalled();
56
56
  });
57
57
 
@@ -70,12 +70,12 @@ describe('site auth command helper', () => {
70
70
  const cmd = getRegistry().get('auth-helper-login/login');
71
71
  const page = pageMock();
72
72
 
73
- await expect(cmd.func(page, { timeout: 1 })).resolves.toEqual({
73
+ await expect(cmd.func(page, { timeout: 1 })).resolves.toEqual([{
74
74
  status: 'login_complete',
75
75
  logged_in: true,
76
76
  site: 'auth-helper-login',
77
77
  username: 'alice',
78
- });
78
+ }]);
79
79
  expect(page.goto).toHaveBeenCalledWith('https://example.com/login');
80
80
  expect(page.wait).toHaveBeenCalled();
81
81
  expect(poll).toHaveBeenCalledTimes(2);
@@ -14,10 +14,10 @@ function makePage(authMe, status = 200) {
14
14
  describe('slock whoami', () => {
15
15
  const command = getRegistry().get('slock/whoami');
16
16
 
17
- it('returns a normalized identity object from /auth/me on 200', async () => {
17
+ it('returns a normalized identity row from /auth/me on 200', async () => {
18
18
  const page = makePage({ id: 'u1', name: 'Alice', email: 'a@b.c' });
19
19
  const identity = await command.func(page, {});
20
- expect(identity).toEqual({ logged_in: true, site: 'slock', id: 'u1', name: 'Alice', email: 'a@b.c' });
20
+ expect(identity).toEqual([{ logged_in: true, site: 'slock', id: 'u1', name: 'Alice', email: 'a@b.c' }]);
21
21
  expect(page.goto).toHaveBeenCalled();
22
22
  });
23
23
  });
@@ -4,7 +4,7 @@
4
4
  * Provides a typed send() function that posts a Command and returns a Result.
5
5
  */
6
6
  import { fetchDaemonStatus, getDaemonHealth, requestDaemonShutdown, type BrowserProfileStatus, type DaemonHealth, type DaemonStatus } from './daemon-transport.js';
7
- import type { BrowserRuntimeCommand, BrowserRuntimeResult } from './protocol.js';
7
+ import type { BrowserRuntimeCommand, BrowserRuntimeResult, BrowserWindowMode } from './protocol.js';
8
8
  export declare function setDaemonCommandTimeoutSeconds(seconds: number | null): void;
9
9
  export type DaemonCommand = BrowserRuntimeCommand;
10
10
  export type DaemonResult = BrowserRuntimeResult;
@@ -31,4 +31,5 @@ export declare function bindTab(session: string, opts?: {
31
31
  preferredContextId?: string;
32
32
  page?: string;
33
33
  index?: number;
34
+ windowMode?: BrowserWindowMode;
34
35
  }): Promise<unknown>;
@@ -44,6 +44,7 @@ async function resolveLease(manager, command) {
44
44
  siteSession: command.siteSession,
45
45
  idleTimeout: command.idleTimeout,
46
46
  freshPage: command.freshPage,
47
+ windowMode: command.windowMode,
47
48
  });
48
49
  }
49
50
  function execTarget(page, frameIndex, pageId) {
@@ -134,11 +135,12 @@ export async function dispatchCloakAction(manager, command) {
134
135
  siteSession: command.siteSession,
135
136
  idleTimeout: command.idleTimeout,
136
137
  url: command.url,
138
+ windowMode: command.windowMode,
137
139
  });
138
140
  return { id: command.id, ok: true, data: { title: await lease.page.title(), url: lease.page.url() }, page: lease.pageId };
139
141
  }
140
142
  case 'select': {
141
- const lease = await manager.selectPage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index });
143
+ const lease = await manager.selectPage({ profileId: commandProfileId(manager, command), pageId: command.page, index: command.index, windowMode: command.windowMode });
142
144
  if (!lease)
143
145
  return { id: command.id, ok: false, errorCode: 'runtime_command_failed', error: 'Tab not found' };
144
146
  return { id: command.id, ok: true, data: { selected: true, url: lease.page.url() }, page: lease.pageId };
@@ -217,6 +219,7 @@ export async function dispatchCloakAction(manager, command) {
217
219
  surface: command.surface,
218
220
  siteSession: command.siteSession,
219
221
  idleTimeout: command.idleTimeout,
222
+ windowMode: command.windowMode,
220
223
  pageId: command.page,
221
224
  index: command.index,
222
225
  });
@@ -231,6 +231,42 @@ describe('LocalCloakRuntimeProvider', () => {
231
231
  .resolves.toMatchObject({ id: 'close', ok: true, data: { closed: created.page } });
232
232
  expect(pages[1].close).toHaveBeenCalled();
233
233
  });
234
+ it('does not bring selected tabs to front in background window mode', async () => {
235
+ const { provider, pages } = makeProviderWithFakePage();
236
+ const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'work', surface: 'browser', url: 'https://second.example/', profileId: 'default' });
237
+ await expect(provider.dispatch({
238
+ id: 'select',
239
+ action: 'tabs',
240
+ op: 'select',
241
+ session: 'work',
242
+ surface: 'browser',
243
+ page: created.page,
244
+ profileId: 'default',
245
+ windowMode: 'background',
246
+ })).resolves.toMatchObject({ id: 'select', ok: true });
247
+ expect(pages[1].bringToFront).not.toHaveBeenCalled();
248
+ });
249
+ it('does not bring bound tabs to front in background window mode', async () => {
250
+ const { provider, pages } = makeProviderWithFakePage();
251
+ const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' });
252
+ await expect(provider.dispatch({
253
+ id: 'bind',
254
+ action: 'bind',
255
+ session: 'target',
256
+ surface: 'browser',
257
+ page: created.page,
258
+ profileId: 'default',
259
+ windowMode: 'background',
260
+ })).resolves.toMatchObject({ id: 'bind', ok: true });
261
+ expect(pages[1].bringToFront).not.toHaveBeenCalled();
262
+ });
263
+ it('brings bound tabs to front by default', async () => {
264
+ const { provider, pages } = makeProviderWithFakePage();
265
+ const created = await provider.dispatch({ id: 'new', action: 'tabs', op: 'new', session: 'source', surface: 'browser', url: 'https://second.example/', profileId: 'default' });
266
+ await expect(provider.dispatch({ id: 'bind', action: 'bind', session: 'target', surface: 'browser', page: created.page, profileId: 'default' }))
267
+ .resolves.toMatchObject({ id: 'bind', ok: true });
268
+ expect(pages[1].bringToFront).toHaveBeenCalledOnce();
269
+ });
234
270
  it('closes a window by page identity when command.page is provided', async () => {
235
271
  const { provider, pages } = makeProviderWithFakePage();
236
272
  const first = await provider.dispatch({ id: 'first', action: 'navigate', session: 'first', surface: 'browser', url: 'https://first.example/', profileId: 'default' });
@@ -1,6 +1,6 @@
1
1
  import type { BrowserContext, Page as PlaywrightPage } from 'playwright-core';
2
2
  import { launchPersistentContext as cloakLaunchPersistentContext } from 'cloakbrowser';
3
- import type { BrowserSurface, SiteSessionMode } from '../../protocol.js';
3
+ import type { BrowserSurface, BrowserWindowMode, SiteSessionMode } from '../../protocol.js';
4
4
  import { CloakNetworkCapture } from './network.js';
5
5
  export type LaunchPersistentContext = typeof cloakLaunchPersistentContext;
6
6
  export type RecoverLockedProfile = (userDataDir: string) => Promise<boolean>;
@@ -10,6 +10,7 @@ export interface SessionKeyInput {
10
10
  surface?: BrowserSurface;
11
11
  siteSession?: SiteSessionMode;
12
12
  idleTimeout?: number;
13
+ windowMode?: BrowserWindowMode;
13
14
  /** Discard the existing leased page (if any) and create a new one under the same lease. */
14
15
  freshPage?: boolean;
15
16
  }
@@ -61,7 +62,7 @@ export declare class CloakSessionManager {
61
62
  newPage(input: SessionKeyInput & {
62
63
  url?: string;
63
64
  }): Promise<CloakPageLease>;
64
- selectPage(input: Pick<SessionKeyInput, 'profileId'> & {
65
+ selectPage(input: Pick<SessionKeyInput, 'profileId' | 'windowMode'> & {
65
66
  pageId?: string;
66
67
  index?: number;
67
68
  }): Promise<CloakPageLease | null>;
@@ -152,7 +152,8 @@ export class CloakSessionManager {
152
152
  if (!match)
153
153
  return null;
154
154
  const [leaseKey, entry] = match;
155
- await entry.page.bringToFront?.().catch(() => { });
155
+ if (input.windowMode !== 'background')
156
+ await entry.page.bringToFront?.().catch(() => { });
156
157
  runtime.selectedPageId = entry.pageId;
157
158
  runtime.lastSeenAt = Date.now();
158
159
  return { profileId, leaseKey, context: runtime.context, page: entry.page, pageId: entry.pageId };
@@ -183,7 +184,8 @@ export class CloakSessionManager {
183
184
  entry.siteSession = input.siteSession;
184
185
  entry.idleTimeout = input.idleTimeout;
185
186
  runtime.pages.set(canonicalKey, entry);
186
- await entry.page.bringToFront?.().catch(() => { });
187
+ if (input.windowMode !== 'background')
188
+ await entry.page.bringToFront?.().catch(() => { });
187
189
  this.refreshIdleTimer(runtime, canonicalKey, entry);
188
190
  runtime.selectedPageId = entry.pageId;
189
191
  runtime.lastSeenAt = Date.now();
package/dist/src/cli.js CHANGED
@@ -1022,11 +1022,12 @@ Examples:
1022
1022
  const profileSelection = getBrowserProfileSelection(command);
1023
1023
  const contextId = profileSelection?.contextId;
1024
1024
  const routing = profileRouteParams(profileSelection);
1025
+ const windowMode = getBrowserWindowMode(command, 'foreground');
1025
1026
  try {
1026
1027
  const { BrowserBridge } = await import('./browser/index.js');
1027
1028
  const bridge = new BrowserBridge();
1028
- await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...routing });
1029
- await fn({ session, contextId, routing }, opts);
1029
+ await bridge.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT, session, surface: 'browser', ...routing, windowMode });
1030
+ await fn({ session, contextId, routing, windowMode }, opts);
1030
1031
  }
1031
1032
  catch (err) {
1032
1033
  if (err instanceof BrowserCommandError) {
@@ -1044,7 +1045,7 @@ Examples:
1044
1045
  .description('Bind an existing Cloak runtime tab to the browser session named by <session>')
1045
1046
  .option('--page <id>', 'Cloak tab page id from `webcmd browser <session> tab list`')
1046
1047
  .option('--index <n>', 'Cloak tab index from `webcmd browser <session> tab list`')
1047
- .action(browserSessionCommandAction(async ({ session, contextId, routing }, opts) => {
1048
+ .action(browserSessionCommandAction(async ({ session, contextId, routing, windowMode }, opts) => {
1048
1049
  const page = typeof opts.page === 'string' && opts.page.trim() ? opts.page.trim() : undefined;
1049
1050
  const rawIndex = typeof opts.index === 'string' && opts.index.trim() ? opts.index.trim() : undefined;
1050
1051
  if ((page && rawIndex) || (!page && !rawIndex)) {
@@ -1054,7 +1055,7 @@ Examples:
1054
1055
  throw new BrowserCommandError('--index must be a non-negative integer.', 'invalid_request');
1055
1056
  }
1056
1057
  const index = rawIndex === undefined ? undefined : Number.parseInt(rawIndex, 10);
1057
- const data = await bindTab(session, { ...routing, ...(page && { page }), ...(index !== undefined && { index }) });
1058
+ const data = await bindTab(session, { ...routing, ...(page && { page }), ...(index !== undefined && { index }), windowMode });
1058
1059
  saveBrowserTargetState(undefined, getBrowserScope(session, contextId));
1059
1060
  console.log(JSON.stringify({ session, ...((data && typeof data === 'object') ? data : { data }) }, null, 2));
1060
1061
  }));
@@ -1109,8 +1109,8 @@ describe('browser tab targeting commands', () => {
1109
1109
  it('binds an existing Cloak tab by page id into a browser session', async () => {
1110
1110
  const program = createProgram('', '');
1111
1111
  await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--page', 'tab-2']);
1112
- expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser' });
1113
- expect(mockBindTab).toHaveBeenCalledWith('test', { page: 'tab-2' });
1112
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser', windowMode: 'foreground' });
1113
+ expect(mockBindTab).toHaveBeenCalledWith('test', { page: 'tab-2', windowMode: 'foreground' });
1114
1114
  const out = lastJsonLog();
1115
1115
  expect(out.session).toBe('test');
1116
1116
  expect(out.url).toBe('https://user.example/inbox');
@@ -1118,11 +1118,17 @@ describe('browser tab targeting commands', () => {
1118
1118
  it('binds an existing Cloak tab by index into a browser session', async () => {
1119
1119
  const program = createProgram('', '');
1120
1120
  await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind', '--index', '1']);
1121
- expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser' });
1122
- expect(mockBindTab).toHaveBeenCalledWith('test', { index: 1 });
1121
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser', windowMode: 'foreground' });
1122
+ expect(mockBindTab).toHaveBeenCalledWith('test', { index: 1, windowMode: 'foreground' });
1123
1123
  const out = lastJsonLog();
1124
1124
  expect(out.session).toBe('test');
1125
1125
  });
1126
+ it('passes background window mode when binding a Cloak tab', async () => {
1127
+ const program = createProgram('', '');
1128
+ await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', '--window', 'background', 'bind', '--page', 'tab-2']);
1129
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser', windowMode: 'background' });
1130
+ expect(mockBindTab).toHaveBeenCalledWith('test', { page: 'tab-2', windowMode: 'background' });
1131
+ });
1126
1132
  it('requires an explicit Cloak tab target for bind', async () => {
1127
1133
  const program = createProgram('', '');
1128
1134
  await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'bind']);
@@ -1237,7 +1243,7 @@ describe('browser tab targeting commands', () => {
1237
1243
  it('unbinds a session through the daemon close-window command', async () => {
1238
1244
  const program = createProgram('', '');
1239
1245
  await program.parseAsync(['node', 'webcmd', 'browser', '--session', 'test', 'unbind']);
1240
- expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser' });
1246
+ expect(mockBrowserConnect).toHaveBeenCalledWith({ timeout: 45, session: 'test', surface: 'browser', windowMode: 'foreground' });
1241
1247
  expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'test', surface: 'browser' });
1242
1248
  const out = lastJsonLog();
1243
1249
  expect(out).toEqual({ unbound: true, session: 'test' });
@@ -117,9 +117,10 @@ function safeIdentityValue(value) {
117
117
  return '';
118
118
  }
119
119
  function identitySummary(result) {
120
- if (!result || typeof result !== 'object' || Array.isArray(result))
120
+ const first = Array.isArray(result) ? result[0] : result;
121
+ if (!first || typeof first !== 'object' || Array.isArray(first))
121
122
  return '';
122
- const row = result;
123
+ const row = first;
123
124
  const blocked = /(?:email|phone|real.?name|first.?name|last.?name|cookie|token|session|secret|password|csrf|jwt|bearer|wt2)/i;
124
125
  for (const key of ['username', 'handle', 'user_id', 'id', 'name', 'nickname', 'user_type', 'url']) {
125
126
  if (blocked.test(key))
@@ -74,12 +74,12 @@ describe('auth status collection', () => {
74
74
  });
75
75
  it('runs full whoami with --full and returns a safe identity summary', async () => {
76
76
  registerWhoami('gamma', {
77
- identity: {
78
- logged_in: true,
79
- site: 'gamma',
80
- email: 'hidden@example.com',
81
- username: 'public-handle',
82
- },
77
+ identity: [{
78
+ logged_in: true,
79
+ site: 'gamma',
80
+ email: 'hidden@example.com',
81
+ username: 'public-handle',
82
+ }],
83
83
  });
84
84
  const rows = await collectAuthStatus({ sites: 'gamma', full: true });
85
85
  expect(rows).toEqual([
@@ -68,6 +68,11 @@ describe('runGenerateReleaseNotes', () => {
68
68
  '## Highlights',
69
69
  '- Better summaries.',
70
70
  '',
71
+ '## Contributors',
72
+ '<a href="https://github.com/alice" title="@alice"><img src="https://github.com/alice.png?size=64" width="64" height="64" alt="@alice" /></a>',
73
+ '',
74
+ '[@alice](https://github.com/alice)',
75
+ '',
71
76
  ].join('\n'),
72
77
  stderr: '',
73
78
  });
@@ -0,0 +1,9 @@
1
+ import type { HostedCommand } from './types.js';
2
+ export interface ParsedHostedInvocation {
3
+ args: Record<string, unknown>;
4
+ format: string;
5
+ trace: string;
6
+ profile?: string;
7
+ help: boolean;
8
+ }
9
+ export declare function parseHostedInvocation(command: HostedCommand, argv: string[]): ParsedHostedInvocation;
@@ -0,0 +1,101 @@
1
+ import { ArgumentError } from '../errors.js';
2
+ export function parseHostedInvocation(command, argv) {
3
+ const args = {};
4
+ const positional = command.args.filter((arg) => arg.positional);
5
+ const named = new Map(command.args.filter((arg) => !arg.positional).map((arg) => [arg.name, arg]));
6
+ let positionalIndex = 0;
7
+ let format = String(command.defaultFormat || 'table');
8
+ let trace = 'off';
9
+ let profile;
10
+ let help = false;
11
+ for (let i = 0; i < argv.length; i++) {
12
+ const token = argv[i];
13
+ if (token === '--') {
14
+ for (const rest of argv.slice(i + 1)) {
15
+ const arg = positional[positionalIndex++];
16
+ if (!arg)
17
+ throw new ArgumentError(`Unexpected positional argument: ${rest}`);
18
+ args[arg.name] = coerceValue(rest, arg.type);
19
+ }
20
+ break;
21
+ }
22
+ if (token === '-h' || token === '--help') {
23
+ help = true;
24
+ continue;
25
+ }
26
+ if (token === '-f' || token === '--format') {
27
+ format = readValue(argv, ++i, token);
28
+ continue;
29
+ }
30
+ if (token === '--trace') {
31
+ trace = readValue(argv, ++i, token);
32
+ continue;
33
+ }
34
+ if (token === '--profile') {
35
+ profile = readValue(argv, ++i, token);
36
+ continue;
37
+ }
38
+ if (token.startsWith('--')) {
39
+ const eqIndex = token.indexOf('=');
40
+ const rawName = token.slice(2, eqIndex === -1 ? undefined : eqIndex);
41
+ const arg = named.get(rawName);
42
+ if (!arg)
43
+ throw new ArgumentError(`Unknown option for hosted command ${command.command}: --${rawName}`);
44
+ const inlineValue = eqIndex === -1 ? undefined : token.slice(eqIndex + 1);
45
+ if (arg.type === 'bool' || arg.type === 'boolean') {
46
+ args[arg.name] = inlineValue === undefined ? true : coerceValue(inlineValue, arg.type);
47
+ }
48
+ else {
49
+ args[arg.name] = coerceValue(inlineValue ?? readValue(argv, ++i, token), arg.type);
50
+ }
51
+ continue;
52
+ }
53
+ const arg = positional[positionalIndex++];
54
+ if (!arg)
55
+ throw new ArgumentError(`Unexpected positional argument: ${token}`);
56
+ args[arg.name] = coerceValue(token, arg.type);
57
+ }
58
+ for (const arg of command.args) {
59
+ if (args[arg.name] === undefined && arg.default !== undefined)
60
+ args[arg.name] = arg.default;
61
+ if (arg.required && args[arg.name] === undefined) {
62
+ throw new ArgumentError(`Missing required argument for hosted command ${command.command}: ${arg.name}`);
63
+ }
64
+ }
65
+ return {
66
+ args,
67
+ format,
68
+ trace,
69
+ ...(profile ? { profile } : {}),
70
+ help,
71
+ };
72
+ }
73
+ function readValue(argv, index, flag) {
74
+ const value = argv[index];
75
+ if (value === undefined || value.startsWith('--')) {
76
+ throw new ArgumentError(`${flag} requires a value.`);
77
+ }
78
+ return value;
79
+ }
80
+ function coerceValue(value, type) {
81
+ if (type === 'int') {
82
+ const parsed = Number.parseInt(value, 10);
83
+ if (!Number.isFinite(parsed))
84
+ throw new ArgumentError(`Expected integer value, got "${value}".`);
85
+ return parsed;
86
+ }
87
+ if (type === 'number') {
88
+ const parsed = Number(value);
89
+ if (!Number.isFinite(parsed))
90
+ throw new ArgumentError(`Expected number value, got "${value}".`);
91
+ return parsed;
92
+ }
93
+ if (type === 'bool' || type === 'boolean') {
94
+ if (value === 'true' || value === '1')
95
+ return true;
96
+ if (value === 'false' || value === '0')
97
+ return false;
98
+ throw new ArgumentError(`Expected boolean value, got "${value}".`);
99
+ }
100
+ return value;
101
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseHostedInvocation } from './args.js';
3
+ const command = {
4
+ site: 'github',
5
+ name: 'search',
6
+ command: 'github/search',
7
+ description: 'Search GitHub',
8
+ access: 'read',
9
+ strategy: 'PUBLIC',
10
+ browser: false,
11
+ args: [
12
+ { name: 'query', positional: true, required: true, type: 'string' },
13
+ { name: 'limit', type: 'int', default: 10 },
14
+ { name: 'include-forks', type: 'boolean', default: false },
15
+ ],
16
+ columns: ['name'],
17
+ };
18
+ describe('parseHostedInvocation', () => {
19
+ it('parses positional args, value flags, boolean flags, and output options', () => {
20
+ expect(parseHostedInvocation(command, ['webcmd', '--limit', '5', '--include-forks', '-f', 'json', '--trace', 'on']))
21
+ .toEqual({
22
+ args: {
23
+ query: 'webcmd',
24
+ limit: 5,
25
+ 'include-forks': true,
26
+ },
27
+ format: 'json',
28
+ trace: 'on',
29
+ help: false,
30
+ });
31
+ });
32
+ it('rejects missing required positional args', () => {
33
+ expect(() => parseHostedInvocation(command, [])).toThrow('Missing required argument');
34
+ });
35
+ });
@@ -0,0 +1,30 @@
1
+ import { CliError, type ExitCode } from '../errors.js';
2
+ import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserRunRequest, HostedBrowserRunResponse, HostedExecuteResponse, HostedManifest } from './types.js';
3
+ export interface HostedClientOptions {
4
+ apiBaseUrl: string;
5
+ apiKey: string;
6
+ fetchImpl?: typeof fetch;
7
+ }
8
+ export declare class HostedClientError extends CliError {
9
+ constructor(code: string, message: string, help?: string, exitCode?: ExitCode);
10
+ }
11
+ export declare class HostedClient {
12
+ private readonly apiBaseUrl;
13
+ private readonly apiKey;
14
+ private readonly fetchImpl;
15
+ constructor(options: HostedClientOptions);
16
+ getMe(): Promise<unknown>;
17
+ getManifest(): Promise<HostedManifest>;
18
+ execute(input: {
19
+ command: string;
20
+ args: Record<string, unknown>;
21
+ format?: string;
22
+ trace?: string;
23
+ profile?: string;
24
+ }): Promise<HostedExecuteResponse>;
25
+ startBrowserRun(session: string, input: HostedBrowserRunRequest): Promise<HostedBrowserRunResponse>;
26
+ browserAction(session: string, executionId: string, input: HostedBrowserActionRequest): Promise<HostedBrowserActionResponse>;
27
+ finishBrowserRun(session: string, executionId: string, input: HostedBrowserFinishRequest): Promise<HostedBrowserFinishResponse>;
28
+ runBrowserAction(session: string, input: HostedBrowserRunActionInput): Promise<HostedBrowserRunActionResponse>;
29
+ private request;
30
+ }
@@ -0,0 +1,122 @@
1
+ import { CliError, EXIT_CODES } from '../errors.js';
2
+ export class HostedClientError extends CliError {
3
+ constructor(code, message, help, exitCode = EXIT_CODES.GENERIC_ERROR) {
4
+ super(code, message, help, exitCode);
5
+ }
6
+ }
7
+ export class HostedClient {
8
+ apiBaseUrl;
9
+ apiKey;
10
+ fetchImpl;
11
+ constructor(options) {
12
+ this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, '');
13
+ this.apiKey = options.apiKey;
14
+ this.fetchImpl = options.fetchImpl ?? fetch;
15
+ }
16
+ async getMe() {
17
+ return this.request('/v1/me');
18
+ }
19
+ async getManifest() {
20
+ const body = await this.request('/v1/manifest');
21
+ if (!body.manifest || !Array.isArray(body.manifest.commands)) {
22
+ throw new HostedClientError('HOSTED_PROTOCOL', 'Webcmd Cloud returned an invalid manifest.');
23
+ }
24
+ return body.manifest;
25
+ }
26
+ async execute(input) {
27
+ return this.request('/v1/execute', {
28
+ method: 'POST',
29
+ body: JSON.stringify(input),
30
+ });
31
+ }
32
+ async startBrowserRun(session, input) {
33
+ return this.request(`/v1/browser/${encodeURIComponent(session)}/runs`, {
34
+ method: 'POST',
35
+ body: JSON.stringify(input),
36
+ });
37
+ }
38
+ async browserAction(session, executionId, input) {
39
+ return this.request(`/v1/browser/${encodeURIComponent(session)}/runs/${encodeURIComponent(executionId)}/actions`, {
40
+ method: 'POST',
41
+ body: JSON.stringify(input),
42
+ });
43
+ }
44
+ async finishBrowserRun(session, executionId, input) {
45
+ return this.request(`/v1/browser/${encodeURIComponent(session)}/runs/${encodeURIComponent(executionId)}/finish`, {
46
+ method: 'POST',
47
+ body: JSON.stringify(input),
48
+ });
49
+ }
50
+ async runBrowserAction(session, input) {
51
+ const { command, trace, windowMode, action, profile, args } = input;
52
+ const run = await this.startBrowserRun(session, {
53
+ command,
54
+ args,
55
+ ...(profile !== undefined ? { profile } : {}),
56
+ ...(windowMode !== undefined ? { windowMode } : {}),
57
+ ...(trace !== undefined ? { trace } : {}),
58
+ });
59
+ try {
60
+ const actionResponse = await this.browserAction(session, run.run.executionId, {
61
+ action,
62
+ args,
63
+ ...(profile !== undefined ? { profile } : {}),
64
+ });
65
+ const finished = await this.finishBrowserRun(session, run.run.executionId, {
66
+ status: 'succeeded',
67
+ ...(profile !== undefined ? { profile } : {}),
68
+ });
69
+ return {
70
+ ...actionResponse,
71
+ run: run.run,
72
+ execution: finished.execution,
73
+ };
74
+ }
75
+ catch (error) {
76
+ await this.finishBrowserRun(session, run.run.executionId, {
77
+ status: 'failed',
78
+ errorCode: error instanceof HostedClientError ? error.code : 'HOSTED_BROWSER_ACTION_FAILED',
79
+ ...(profile !== undefined ? { profile } : {}),
80
+ }).catch(() => undefined);
81
+ throw error;
82
+ }
83
+ }
84
+ async request(path, init = {}) {
85
+ const response = await this.fetchImpl(`${this.apiBaseUrl}${path}`, {
86
+ ...init,
87
+ headers: {
88
+ accept: 'application/json',
89
+ ...(init.body ? { 'content-type': 'application/json' } : {}),
90
+ authorization: `Bearer ${this.apiKey}`,
91
+ ...(init.headers ?? {}),
92
+ },
93
+ });
94
+ const text = await response.text();
95
+ const body = text ? parseJson(text) : {};
96
+ if (!response.ok || isHostedError(body)) {
97
+ const error = isHostedError(body)
98
+ ? body.error
99
+ : { code: `HTTP_${response.status}`, message: `Webcmd Cloud request failed with HTTP ${response.status}.` };
100
+ throw new HostedClientError(error.code || `HTTP_${response.status}`, error.message || `Webcmd Cloud request failed with HTTP ${response.status}.`, error.help ?? error.hint, normalizeExitCode(error.exitCode, response.status === 401 ? EXIT_CODES.NOPERM : EXIT_CODES.GENERIC_ERROR));
101
+ }
102
+ return body;
103
+ }
104
+ }
105
+ function normalizeExitCode(value, fallback) {
106
+ const allowed = new Set(Object.values(EXIT_CODES));
107
+ return value !== undefined && allowed.has(value) ? value : fallback;
108
+ }
109
+ function parseJson(text) {
110
+ try {
111
+ return JSON.parse(text);
112
+ }
113
+ catch {
114
+ throw new HostedClientError('HOSTED_PROTOCOL', 'Webcmd Cloud returned non-JSON response.');
115
+ }
116
+ }
117
+ function isHostedError(value) {
118
+ return !!value
119
+ && typeof value === 'object'
120
+ && value.ok === false
121
+ && typeof value.error === 'object';
122
+ }
@@ -0,0 +1 @@
1
+ export {};