@agentrhq/webcmd 0.4.0 → 0.4.2

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.
@@ -0,0 +1,98 @@
1
+ import {
2
+ CommandExecutionError,
3
+ EmptyResultError,
4
+ TimeoutError,
5
+ } from '@agentrhq/webcmd/errors';
6
+ import { cli, Strategy } from '@agentrhq/webcmd/registry';
7
+ import { normalizeWishlistRows } from './parsers.js';
8
+ import { gotoAmazon, SITE, WISHLIST_URL } from './shared.js';
9
+
10
+ cli({
11
+ site: SITE,
12
+ name: 'wishlist',
13
+ access: 'read',
14
+ description: 'Fetch current prices for products in the default Amazon.in wishlist',
15
+ domain: 'amazon.in',
16
+ strategy: Strategy.UI,
17
+ browser: true,
18
+ navigateBefore: false,
19
+ siteSession: 'persistent',
20
+ args: [
21
+ {
22
+ name: 'filter',
23
+ default: 'unpurchased',
24
+ choices: ['unpurchased', 'all'],
25
+ help: 'Wishlist items to include',
26
+ },
27
+ ],
28
+ columns: [
29
+ 'list_name', 'item_id', 'asin', 'title', 'price', 'mrp',
30
+ 'availability', 'size', 'colour', 'image_url', 'product_url',
31
+ ],
32
+ func: async (page, args) => {
33
+ const url = new URL(WISHLIST_URL);
34
+ url.searchParams.set('type', 'wishlist');
35
+ url.searchParams.set('filter', args.filter ?? 'unpurchased');
36
+ url.searchParams.set('sort', 'date-added');
37
+ url.searchParams.set('viewType', 'list');
38
+ await gotoAmazon(page, url.href, 'wishlist');
39
+
40
+ let reachedEnd = false;
41
+ for (let step = 0; step < 100; step += 1) {
42
+ reachedEnd = await page.evaluate(`
43
+ (() => Boolean(document.querySelector('#endOfListMarker')))()
44
+ `);
45
+ if (reachedEnd) break;
46
+ await page.scroll('down', 700);
47
+ await page.sleep(0.25);
48
+ }
49
+ if (!reachedEnd) {
50
+ throw new TimeoutError(
51
+ 'Amazon.in wishlist loading',
52
+ 25,
53
+ 'Open the wishlist in the Webcmd browser and check whether Amazon is still loading items.',
54
+ );
55
+ }
56
+
57
+ const payload = await page.evaluate(`
58
+ (() => {
59
+ const text = (node) => (node?.textContent || '').replace(/\\s+/g, ' ').trim();
60
+ const cards = [...document.querySelectorAll('#g-items li.g-item-sortable')].map((card) => {
61
+ const productLinks = [...card.querySelectorAll('a[href*="/dp/"]')];
62
+ const productLink = productLinks.find((link) => link.title) || productLinks[0];
63
+ const variants = [...card.querySelectorAll('#twisterText')].map((node) => text(node));
64
+ return {
65
+ cardItemId: card.getAttribute('data-itemid') || '',
66
+ cardHref: productLink?.href || '',
67
+ cardTitle: productLink?.title || text(productLink),
68
+ cardPriceText: text(card.querySelector('.price-section .a-price .a-offscreen, .a-price .a-offscreen')),
69
+ cardMrpText: text(card.querySelector('.wl-deal-price.a-text-strike, .a-price.a-text-price .a-offscreen')),
70
+ cardAvailabilityText: text(card.querySelector('[id^="availability-"], .itemAvailability, .a-color-price')),
71
+ cardSizeText: variants.find((value) => /^size\\s*:/i.test(value))?.replace(/^size\\s*:\\s*/i, '') || '',
72
+ cardColourText: variants.find((value) => /^colou?r\\s*:/i.test(value))?.replace(/^colou?r\\s*:\\s*/i, '') || '',
73
+ cardImageUrl: card.querySelector('img[alt]')?.currentSrc || card.querySelector('img[alt]')?.src || '',
74
+ };
75
+ });
76
+ return {
77
+ listName: text(document.querySelector('#profile-list-name')),
78
+ cards,
79
+ empty: /no items|this list is empty/i.test(document.body?.innerText || ''),
80
+ };
81
+ })()
82
+ `);
83
+ if (!payload?.listName) {
84
+ throw new CommandExecutionError('Amazon.in wishlist name could not be read');
85
+ }
86
+ if (!payload.cards?.length) {
87
+ if (payload.empty) throw new EmptyResultError('amazon-in wishlist');
88
+ throw new CommandExecutionError('Amazon.in wishlist exposed no item cards');
89
+ }
90
+ try {
91
+ return normalizeWishlistRows(payload.listName, payload.cards);
92
+ } catch (error) {
93
+ throw new CommandExecutionError(
94
+ `Amazon.in wishlist details could not be normalized: ${error.message}`,
95
+ );
96
+ }
97
+ },
98
+ });
@@ -38,6 +38,7 @@ export const HOSTED_ROOT_HELP = {
38
38
  { name: 'browser', description: 'Browser control through a hosted browser session' },
39
39
  { name: 'completion <shell>', description: 'Output a shell completion script' },
40
40
  { name: 'list', description: 'List all available hosted CLI commands' },
41
+ { name: 'profile', description: 'Manage hosted browser profiles' },
41
42
  { name: 'setup', description: 'Configure local or hosted mode' },
42
43
  ],
43
44
  localOnlyCommands: [
@@ -49,7 +50,6 @@ export const HOSTED_ROOT_HELP = {
49
50
  { name: 'doctor', description: 'Diagnose local browser bridge connectivity' },
50
51
  { name: 'external', description: 'Manage local CLI passthrough commands' },
51
52
  { name: 'plugin', description: 'Manage plugins installed on this computer' },
52
- { name: 'profile', description: 'Manage profiles in the local browser runtime' },
53
53
  { name: 'skills', description: 'Manage bundled skills on this computer' },
54
54
  { name: 'validate', description: 'Validate local CLI definitions' },
55
55
  { name: 'verify', description: 'Validate and smoke-test local adapters' },
@@ -105,6 +105,12 @@ const BROWSER_DOCUMENTATION = [
105
105
  'skills/webcmd-browser/SKILL.md',
106
106
  'skills/webcmd-usage/SKILL.md',
107
107
  ];
108
+ const HOSTED_DOCUMENTATION = [
109
+ ...BROWSER_DOCUMENTATION,
110
+ 'docs/authentication-and-profiles.mdx',
111
+ 'docs/local-or-cloud.mdx',
112
+ ];
113
+ const HOSTED_PROFILE_PATHS = /^(?:src\/completion-shared|src\/hosted\/(?:browser-args|client|runner|types))\.ts$/;
108
114
  const ADAPTER_DOCUMENTATION = [
109
115
  'README.md',
110
116
  'docs/authoring.mdx',
@@ -116,11 +122,13 @@ const ADAPTER_DOCUMENTATION = [
116
122
  export function selectDocumentationPaths(files) {
117
123
  const selected = new Set();
118
124
  for (const file of files) {
119
- const paths = /^(?:src\/browser\/|src\/hosted\/)/.test(file.path)
120
- ? BROWSER_DOCUMENTATION
121
- : /^(?:clis\/|plugins\/|src\/plugin)/.test(file.path)
122
- ? ADAPTER_DOCUMENTATION
123
- : GENERAL_DOCUMENTATION;
125
+ const paths = HOSTED_PROFILE_PATHS.test(file.path)
126
+ ? HOSTED_DOCUMENTATION
127
+ : /^src\/browser\//.test(file.path)
128
+ ? BROWSER_DOCUMENTATION
129
+ : /^(?:clis\/|plugins\/|src\/plugin)/.test(file.path)
130
+ ? ADAPTER_DOCUMENTATION
131
+ : GENERAL_DOCUMENTATION;
124
132
  paths.forEach((path) => selected.add(path));
125
133
  }
126
134
  return [...selected].sort();
@@ -95,6 +95,42 @@ describe('review context', () => {
95
95
  'skills/webcmd-usage/SKILL.md',
96
96
  ]);
97
97
  });
98
+ it('selects profile documentation for hosted profile changes', () => {
99
+ expect(selectDocumentationPaths([changed('src/hosted/runner.ts')])).toEqual([
100
+ 'README.md',
101
+ 'docs/agent-prompts.mdx',
102
+ 'docs/authentication-and-profiles.mdx',
103
+ 'docs/browser-and-sitemap-memory.mdx',
104
+ 'docs/cli-reference.mdx',
105
+ 'docs/concepts.mdx',
106
+ 'docs/local-or-cloud.mdx',
107
+ 'skills/webcmd-browser-sitemap/SKILL.md',
108
+ 'skills/webcmd-browser/SKILL.md',
109
+ 'skills/webcmd-usage/SKILL.md',
110
+ ]);
111
+ });
112
+ it('selects profile documentation for hosted root command changes', () => {
113
+ expect(selectDocumentationPaths([changed('src/completion-shared.ts')])).toEqual([
114
+ 'README.md',
115
+ 'docs/agent-prompts.mdx',
116
+ 'docs/authentication-and-profiles.mdx',
117
+ 'docs/browser-and-sitemap-memory.mdx',
118
+ 'docs/cli-reference.mdx',
119
+ 'docs/concepts.mdx',
120
+ 'docs/local-or-cloud.mdx',
121
+ 'skills/webcmd-browser-sitemap/SKILL.md',
122
+ 'skills/webcmd-browser/SKILL.md',
123
+ 'skills/webcmd-usage/SKILL.md',
124
+ ]);
125
+ });
126
+ it('does not select profile documentation for unrelated hosted changes', () => {
127
+ expect(selectDocumentationPaths([changed('src/hosted/files.ts')])).toEqual([
128
+ 'README.md',
129
+ 'docs/cli-reference.mdx',
130
+ 'docs/concepts.mdx',
131
+ 'skills/webcmd-usage/SKILL.md',
132
+ ]);
133
+ });
98
134
  it('selects adapter and plugin documentation', () => {
99
135
  expect(selectDocumentationPaths([changed('clis/reddit/search.js')])).toEqual([
100
136
  'README.md',
@@ -218,7 +218,7 @@ describe('runGenerateReleaseNotes', () => {
218
218
  expect(workflow).toContain('release_body_file="$RUNNER_TEMP/release-body.md"');
219
219
  expect(workflow).toContain('release_edit_args+=(--title "$release_title")');
220
220
  expect(workflow).toContain('release_edit_args+=(--notes-file "$release_body_file")');
221
- expect(workflow).toContain('if gh release edit "${{ steps.release.outputs.tag_name }}" "${release_edit_args[@]}"; then');
221
+ expect(workflow).toContain('if gh release edit "${{ steps.release.outputs.tag_name || inputs.publish_tag }}" "${release_edit_args[@]}"; then');
222
222
  expect(workflow).toMatch(/Enhanced release notes could not be applied; keeping release-please notes\./);
223
223
  });
224
224
  });
@@ -3,8 +3,13 @@ import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBro
3
3
  export interface HostedClientOptions {
4
4
  apiBaseUrl: string;
5
5
  apiKey: string;
6
+ workspace?: string;
6
7
  fetchImpl?: typeof fetch;
7
8
  }
9
+ /**
10
+ * Resolves the active workspace from CLI flags/env, precedence: --workspace flag > WEBCMD_WORKSPACE env > undefined.
11
+ */
12
+ export declare function resolveWorkspace(argv: readonly string[], env: NodeJS.ProcessEnv): string | undefined;
8
13
  export declare class HostedClientError extends CliError {
9
14
  readonly execution?: HostedExecution;
10
15
  readonly trace?: HostedTraceReceipt;
@@ -16,11 +21,16 @@ export declare class HostedClientError extends CliError {
16
21
  export declare class HostedClient {
17
22
  private readonly apiBaseUrl;
18
23
  private readonly apiKey;
24
+ private readonly workspace;
19
25
  private readonly fetchImpl;
20
26
  constructor(options: HostedClientOptions);
21
27
  getMe(): Promise<unknown>;
22
28
  getManifest(): Promise<HostedManifest>;
23
29
  listProfiles(): Promise<HostedProfilesResponse>;
30
+ deleteProfile(profileId: string): Promise<{
31
+ ok: true;
32
+ deleted: true;
33
+ }>;
24
34
  execute(input: {
25
35
  command: string;
26
36
  args: Record<string, unknown>;
@@ -1,4 +1,20 @@
1
1
  import { attachTraceReceipt, CliError, EXIT_CODES } from '../errors.js';
2
+ /**
3
+ * Resolves the active workspace from CLI flags/env, precedence: --workspace flag > WEBCMD_WORKSPACE env > undefined.
4
+ */
5
+ export function resolveWorkspace(argv, env) {
6
+ const idx = argv.indexOf('--workspace');
7
+ if (idx >= 0 && argv[idx + 1])
8
+ return argv[idx + 1];
9
+ const equalsForm = argv.find(arg => arg.startsWith('--workspace='));
10
+ if (equalsForm !== undefined) {
11
+ const value = equalsForm.slice('--workspace='.length).trim();
12
+ if (value)
13
+ return value;
14
+ }
15
+ const fromEnv = env.WEBCMD_WORKSPACE?.trim();
16
+ return fromEnv ? fromEnv : undefined;
17
+ }
2
18
  export class HostedClientError extends CliError {
3
19
  execution;
4
20
  trace;
@@ -13,10 +29,12 @@ export class HostedClientError extends CliError {
13
29
  export class HostedClient {
14
30
  apiBaseUrl;
15
31
  apiKey;
32
+ workspace;
16
33
  fetchImpl;
17
34
  constructor(options) {
18
35
  this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, '');
19
36
  this.apiKey = options.apiKey;
37
+ this.workspace = options.workspace;
20
38
  this.fetchImpl = options.fetchImpl ?? fetch;
21
39
  }
22
40
  async getMe() {
@@ -36,6 +54,13 @@ export class HostedClient {
36
54
  }
37
55
  return body;
38
56
  }
57
+ async deleteProfile(profileId) {
58
+ const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE' });
59
+ if (!hasExactKeys(body, ['ok', 'deleted']) || body.ok !== true || body.deleted !== true) {
60
+ throw protocolError('Webcmd Cloud returned an invalid profile deletion response.');
61
+ }
62
+ return { ok: true, deleted: true };
63
+ }
39
64
  async execute(input) {
40
65
  const traceMode = normalizeTraceMode(input.trace);
41
66
  const body = await this.request('/v1/execute', {
@@ -94,6 +119,7 @@ export class HostedClient {
94
119
  headers: {
95
120
  accept: 'application/octet-stream',
96
121
  authorization: `Bearer ${this.apiKey}`,
122
+ ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}),
97
123
  },
98
124
  });
99
125
  if (!response.ok) {
@@ -159,6 +185,7 @@ export class HostedClient {
159
185
  accept: 'application/json',
160
186
  ...(init.body ? { 'content-type': 'application/json' } : {}),
161
187
  authorization: `Bearer ${this.apiKey}`,
188
+ ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}),
162
189
  ...(init.headers ?? {}),
163
190
  },
164
191
  });
@@ -282,11 +309,17 @@ function isHostedProfilesResponse(value) {
282
309
  && value.profiles.every(isHostedPublicProfile);
283
310
  }
284
311
  function isHostedPublicProfile(value) {
285
- return hasExactKeys(value, ['name', 'default', 'status', 'createdAt', 'lastUsedAt'])
286
- && typeof value.name === 'string'
312
+ return hasExactKeys(value, [
313
+ 'id', 'name', 'workspace', 'default', 'status',
314
+ 'createdAt', 'updatedAt', 'lastUsedAt',
315
+ ])
316
+ && typeof value.id === 'string'
317
+ && (value.name === null || typeof value.name === 'string')
318
+ && (value.workspace === null || typeof value.workspace === 'string')
287
319
  && typeof value.default === 'boolean'
288
- && value.status === 'available'
320
+ && (value.status === 'pending' || value.status === 'available')
289
321
  && typeof value.createdAt === 'string'
322
+ && typeof value.updatedAt === 'string'
290
323
  && typeof value.lastUsedAt === 'string';
291
324
  }
292
325
  function isHostedManifestCommand(value) {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { HostedClient } from './client.js';
2
+ import { HostedClient, resolveWorkspace } from './client.js';
3
3
  const invalidTraceUrlCases = [
4
4
  {
5
5
  name: 'raw absolute Kernel URL with token',
@@ -165,31 +165,66 @@ describe('HostedClient', () => {
165
165
  });
166
166
  await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
167
167
  });
168
- it('parses hosted profile rows without provider identifiers', async () => {
168
+ it('manages public hosted profiles over the authenticated HTTP API', async () => {
169
+ const requests = [];
170
+ const profile = {
171
+ id: 'profile_work',
172
+ name: 'Work',
173
+ workspace: 'user_64256',
174
+ default: false,
175
+ status: 'available',
176
+ createdAt: '2026-07-08T00:00:00.000Z',
177
+ updatedAt: '2026-07-08T00:00:00.000Z',
178
+ lastUsedAt: '2026-07-08T00:00:00.000Z',
179
+ };
169
180
  const client = new HostedClient({
170
181
  apiBaseUrl: 'https://api.example.com',
171
182
  apiKey: 'wcmd_live_test',
172
- fetchImpl: async () => new Response(JSON.stringify({
173
- ok: true,
174
- profiles: [{
175
- name: 'default',
176
- default: true,
177
- status: 'available',
178
- createdAt: '2026-07-08T00:00:00.000Z',
179
- lastUsedAt: '2026-07-08T00:00:00.000Z',
180
- }],
181
- }), { status: 200 }),
183
+ fetchImpl: async (url, init) => {
184
+ requests.push({
185
+ url: String(url),
186
+ method: init?.method ?? 'GET',
187
+ ...(init?.body ? { body: String(init.body) } : {}),
188
+ });
189
+ const path = new URL(String(url)).pathname;
190
+ if (path.startsWith('/v1/profiles/') && init?.method === 'DELETE') {
191
+ return new Response(JSON.stringify({ ok: true, deleted: true }));
192
+ }
193
+ return new Response(JSON.stringify({ ok: true, profiles: [profile] }));
194
+ },
182
195
  });
183
- await expect(client.listProfiles()).resolves.toEqual({
184
- ok: true,
185
- profiles: [{
186
- name: 'default',
187
- default: true,
188
- status: 'available',
189
- createdAt: '2026-07-08T00:00:00.000Z',
190
- lastUsedAt: '2026-07-08T00:00:00.000Z',
191
- }],
196
+ await expect(client.listProfiles()).resolves.toEqual({ ok: true, profiles: [profile] });
197
+ await expect(client.deleteProfile('profile/work')).resolves.toEqual({ ok: true, deleted: true });
198
+ expect(requests).toEqual([
199
+ { url: 'https://api.example.com/v1/profiles', method: 'GET' },
200
+ { url: 'https://api.example.com/v1/profiles/profile%2Fwork', method: 'DELETE' },
201
+ ]);
202
+ });
203
+ it.each([
204
+ { name: 'private provider field', change: { kernelProfileId: 'private' } },
205
+ { name: 'missing updatedAt', change: { updatedAt: undefined } },
206
+ { name: 'non-nullable name shape', change: { name: 7 } },
207
+ { name: 'non-nullable workspace shape', change: { workspace: false } },
208
+ ])('rejects a hosted profile with $name', async ({ change }) => {
209
+ const profile = {
210
+ id: 'profile_work',
211
+ name: null,
212
+ workspace: null,
213
+ default: false,
214
+ status: 'pending',
215
+ createdAt: '2026-07-08T00:00:00.000Z',
216
+ updatedAt: '2026-07-08T00:00:00.000Z',
217
+ lastUsedAt: '2026-07-08T00:00:00.000Z',
218
+ ...change,
219
+ };
220
+ if (change.updatedAt === undefined)
221
+ delete profile.updatedAt;
222
+ const client = new HostedClient({
223
+ apiBaseUrl: 'https://api.example.com',
224
+ apiKey: 'key',
225
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true, profiles: [profile] })),
192
226
  });
227
+ await expect(client.listProfiles()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
193
228
  });
194
229
  it('maps hosted error envelopes to CliError-compatible errors', async () => {
195
230
  const client = new HostedClient({
@@ -797,4 +832,77 @@ describe('HostedClient', () => {
797
832
  },
798
833
  ]);
799
834
  });
835
+ it('attaches the workspace header when a workspace is configured', async () => {
836
+ const requests = [];
837
+ const client = new HostedClient({
838
+ apiBaseUrl: 'https://api.example.com',
839
+ apiKey: 'key',
840
+ workspace: resolveWorkspace([], { WEBCMD_WORKSPACE: 'user_64256' }),
841
+ fetchImpl: async (_url, init) => {
842
+ requests.push({ workspace: new Headers(init?.headers).get('x-webcmd-workspace') });
843
+ return new Response(JSON.stringify({
844
+ ok: true,
845
+ result: [],
846
+ execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' },
847
+ }), { status: 200 });
848
+ },
849
+ });
850
+ await client.execute({ command: 'github/whoami', args: {} });
851
+ expect(requests).toEqual([{ workspace: 'user_64256' }]);
852
+ });
853
+ it('attaches the workspace header for the --workspace=<value> equals form', async () => {
854
+ const requests = [];
855
+ const client = new HostedClient({
856
+ apiBaseUrl: 'https://api.example.com',
857
+ apiKey: 'key',
858
+ workspace: resolveWorkspace(['--workspace=user_64256'], {}),
859
+ fetchImpl: async (_url, init) => {
860
+ requests.push({ workspace: new Headers(init?.headers).get('x-webcmd-workspace') });
861
+ return new Response(JSON.stringify({
862
+ ok: true,
863
+ result: [],
864
+ execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' },
865
+ }), { status: 200 });
866
+ },
867
+ });
868
+ await client.execute({ command: 'github/whoami', args: {} });
869
+ expect(requests).toEqual([{ workspace: 'user_64256' }]);
870
+ });
871
+ it('omits the workspace header when no workspace is configured', async () => {
872
+ const requests = [];
873
+ const client = new HostedClient({
874
+ apiBaseUrl: 'https://api.example.com',
875
+ apiKey: 'key',
876
+ fetchImpl: async (_url, init) => {
877
+ requests.push({ workspace: new Headers(init?.headers).get('x-webcmd-workspace') });
878
+ return new Response(JSON.stringify({
879
+ ok: true,
880
+ result: [],
881
+ execution: { id: 'exec_1', command: 'github/whoami', status: 'succeeded' },
882
+ }), { status: 200 });
883
+ },
884
+ });
885
+ await client.execute({ command: 'github/whoami', args: {} });
886
+ expect(requests).toEqual([{ workspace: null }]);
887
+ });
888
+ });
889
+ describe('resolveWorkspace', () => {
890
+ it('returns the --workspace flag value when present', () => {
891
+ expect(resolveWorkspace(['--workspace', 'flagws'], {})).toBe('flagws');
892
+ });
893
+ it('returns the --workspace=<value> flag value when present', () => {
894
+ expect(resolveWorkspace(['--workspace=user_64256'], {})).toBe('user_64256');
895
+ });
896
+ it('--workspace overrides the env var', () => {
897
+ expect(resolveWorkspace(['--workspace', 'flagws'], { WEBCMD_WORKSPACE: 'envws' })).toBe('flagws');
898
+ });
899
+ it('falls back to WEBCMD_WORKSPACE when no flag is present', () => {
900
+ expect(resolveWorkspace([], { WEBCMD_WORKSPACE: 'envws' })).toBe('envws');
901
+ });
902
+ it('trims WEBCMD_WORKSPACE', () => {
903
+ expect(resolveWorkspace([], { WEBCMD_WORKSPACE: ' envws ' })).toBe('envws');
904
+ });
905
+ it('returns undefined when neither --workspace nor WEBCMD_WORKSPACE is set', () => {
906
+ expect(resolveWorkspace([], {})).toBeUndefined();
907
+ });
800
908
  });
@@ -98,6 +98,7 @@ describe('hosted manifest helpers', () => {
98
98
  expect(result).toEqual({ handled: true, exitCode: 0 });
99
99
  expect(stdout.text()).toBe(formatRootHelp(HOSTED_ROOT_HELP));
100
100
  expect(stdout.text()).toContain('completion');
101
+ expect(stdout.text()).toMatch(/profile\s+Manage hosted browser profiles/);
101
102
  expect(stdout.text()).toContain('--profile <name>');
102
103
  expect(stdout.text()).toContain('Local-only commands:');
103
104
  expect(stdout.text()).toContain('Run `webcmd setup` and choose local mode to use local-only commands.');
@@ -160,6 +161,6 @@ describe('hosted manifest helpers', () => {
160
161
  stdout: stdout.stream,
161
162
  fetchImpl: async () => new Response(JSON.stringify({ ok: true, manifest }), { status: 200 }),
162
163
  });
163
- expect(stdout.text().trim().split('\n')).toEqual(['browser', 'completion', 'github', 'list', 'setup']);
164
+ expect(stdout.text().trim().split('\n')).toEqual(['browser', 'completion', 'github', 'list', 'profile', 'setup']);
164
165
  });
165
166
  });
@@ -156,6 +156,13 @@ const generatedTerminalCorpus = profileForms.flatMap(profile => dispatchForms.fl
156
156
  { name: `${profile.name}/clustered-version/${dispatch.name}`, argv: [...profile.tokens, '-Vx', ...dispatch.tokens], kind: 'version' },
157
157
  ]));
158
158
  describe('hosted root command surface', () => {
159
+ it('advertises hosted profile management as a root command, not a local-only namespace', () => {
160
+ expect(HOSTED_ROOT_HELP.commands).toContainEqual({
161
+ name: 'profile',
162
+ description: 'Manage hosted browser profiles',
163
+ });
164
+ expect(HOSTED_ROOT_HELP.localOnlyCommands?.some(command => command.name === 'profile')).toBe(false);
165
+ });
159
166
  it.each([
160
167
  { name: 'no args', argv: [], expected: { kind: 'help', exitCode: 1 } },
161
168
  { name: 'profile only', argv: ['--profile', 'work'], expected: { kind: 'help', exitCode: 1 } },
@@ -15,7 +15,7 @@ import { StreamWriteError, writeToStream } from '../stream-write.js';
15
15
  import { PKG_VERSION } from '../version.js';
16
16
  import { getCompletionScriptFast } from '../completion-fast.js';
17
17
  import { browserCommandCatalog } from '../browser/command-catalog.js';
18
- import { HostedClient, HostedClientError } from './client.js';
18
+ import { HostedClient, HostedClientError, resolveWorkspace } from './client.js';
19
19
  import { parseHostedInvocation } from './args.js';
20
20
  import { HostedBrowserHelp, parseHostedBrowserStructure } from './browser-args.js';
21
21
  import { materializeHostedOutputs, prepareHostedFiles, rewriteHostedOutputResultPaths } from './files.js';
@@ -53,6 +53,7 @@ export async function runHostedCli(argv, opts = {}) {
53
53
  const client = new HostedClient({
54
54
  apiBaseUrl: config.hosted.apiBaseUrl,
55
55
  apiKey: credential.apiKey,
56
+ workspace: resolveWorkspace(argv, opts.env ?? process.env),
56
57
  fetchImpl: opts.fetchImpl,
57
58
  });
58
59
  await dispatchHosted(argv, client, stdout, stderr, opts.now ?? Date.now);
@@ -146,6 +147,18 @@ async function dispatchHosted(argv, client, stdout, stderr, now) {
146
147
  await renderHostedList(manifest, parsed.format, parsed.formatExplicit, stdout);
147
148
  return;
148
149
  }
150
+ if (args[0] === 'profile') {
151
+ if (args[1] === 'rename' || args[1] === 'use') {
152
+ throw new ConfigError(`webcmd profile ${args[1]} is not available in hosted mode.`, 'Hosted mode supports: webcmd profile list and delete.');
153
+ }
154
+ const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal);
155
+ if (parsed.kind === 'help') {
156
+ await writeToStream(stdout, parsed.output);
157
+ return;
158
+ }
159
+ await dispatchHostedProfile(parsed, client, stdout);
160
+ return;
161
+ }
149
162
  const manifest = await client.getManifest();
150
163
  validateManifestContractIdentity(manifest);
151
164
  const site = args[0];
@@ -552,6 +565,58 @@ function parseHostedListSurface(argv, literal) {
552
565
  throw new CommanderStructuralError("error: command 'list' did not run\n", 1);
553
566
  return { kind: 'run', format: parsedFormat, formatExplicit };
554
567
  }
568
+ function parseHostedProfileSurface(argv, literal) {
569
+ let stdout = '';
570
+ let stderr = '';
571
+ let parsed;
572
+ const root = new Command('webcmd');
573
+ const profile = root.command('profile').description('Manage hosted browser profiles');
574
+ const output = {
575
+ writeOut: (value) => { stdout += value; },
576
+ writeErr: (value) => { stderr += value; },
577
+ };
578
+ root.exitOverride().configureOutput(output);
579
+ profile.exitOverride().configureOutput(output);
580
+ const configureFormat = (command) => command.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table');
581
+ const setParsed = (command, surface, value) => {
582
+ const options = surface.opts();
583
+ parsed = {
584
+ kind: 'run',
585
+ command,
586
+ format: options.format,
587
+ formatExplicit: surface.getOptionValueSource('format') === 'cli',
588
+ ...(value !== undefined ? { value } : {}),
589
+ };
590
+ };
591
+ const list = configureFormat(profile.command('list'));
592
+ list.exitOverride().configureOutput(output).action(() => setParsed('list', list));
593
+ const remove = configureFormat(profile.command('delete').argument('<profile-id>'));
594
+ remove.exitOverride().configureOutput(output).action((profileId) => setParsed('delete', remove, profileId));
595
+ try {
596
+ root.parse(literal ? ['--', 'profile', ...argv] : ['profile', ...argv], { from: 'user' });
597
+ }
598
+ catch (error) {
599
+ if (!(error instanceof CommanderError))
600
+ throw error;
601
+ if (error.code === 'commander.helpDisplayed')
602
+ return { kind: 'help', output: stdout };
603
+ throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode);
604
+ }
605
+ if (!parsed) {
606
+ throw new CommanderStructuralError("error: command 'profile' did not run\n", 1);
607
+ }
608
+ return parsed;
609
+ }
610
+ async function dispatchHostedProfile(parsed, client, stdout) {
611
+ const result = parsed.command === 'list'
612
+ ? (await client.listProfiles()).profiles
613
+ : await client.deleteProfile(parsed.value);
614
+ await renderOutput(result, {
615
+ fmt: parsed.format,
616
+ fmtExplicit: parsed.formatExplicit,
617
+ stdout,
618
+ });
619
+ }
555
620
  function parseHostedCompletionSurface(argv, literal) {
556
621
  let stdout = '';
557
622
  let stderr = '';