@agentrhq/webcmd 0.4.1 → 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
+ });
@@ -1,10 +1,15 @@
1
1
  import { CliError, type ExitCode } from '../errors.js';
2
- import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserRunRequest, HostedBrowserRunResponse, HostedExecution, HostedExecuteResponse, HostedPrepareExecutionResponse, HostedProfileResponse, HostedProfilesResponse, HostedUploadArtifactResponse, HostedManifest, HostedTraceReceipt } from './types.js';
2
+ import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserRunRequest, HostedBrowserRunResponse, HostedExecution, HostedExecuteResponse, HostedPrepareExecutionResponse, HostedProfilesResponse, HostedUploadArtifactResponse, HostedManifest, HostedTraceReceipt } from './types.js';
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,19 +21,12 @@ 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
- listProfiles(filters?: {
24
- name?: string;
25
- userId?: string;
26
- }): Promise<HostedProfilesResponse>;
27
- createProfile(input: {
28
- name: string;
29
- userId?: string;
30
- }): Promise<HostedProfileResponse>;
31
- getProfile(profileId: string): Promise<HostedProfileResponse>;
29
+ listProfiles(): Promise<HostedProfilesResponse>;
32
30
  deleteProfile(profileId: string): Promise<{
33
31
  ok: true;
34
32
  deleted: true;
@@ -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() {
@@ -29,36 +47,13 @@ export class HostedClient {
29
47
  }
30
48
  return body.manifest;
31
49
  }
32
- async listProfiles(filters = {}) {
33
- const params = new URLSearchParams();
34
- if (filters.name !== undefined)
35
- params.set('name', filters.name);
36
- if (filters.userId !== undefined)
37
- params.set('userId', filters.userId);
38
- const query = params.toString();
39
- const body = await this.request(`/v1/profiles${query ? `?${query}` : ''}`);
50
+ async listProfiles() {
51
+ const body = await this.request('/v1/profiles');
40
52
  if (!isHostedProfilesResponse(body)) {
41
53
  throw protocolError('Webcmd Cloud returned an invalid profiles response.');
42
54
  }
43
55
  return body;
44
56
  }
45
- async createProfile(input) {
46
- const body = await this.request('/v1/profiles', {
47
- method: 'POST',
48
- body: JSON.stringify(input),
49
- });
50
- if (!isHostedProfileResponse(body)) {
51
- throw protocolError('Webcmd Cloud returned an invalid profile response.');
52
- }
53
- return body;
54
- }
55
- async getProfile(profileId) {
56
- const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`);
57
- if (!isHostedProfileResponse(body)) {
58
- throw protocolError('Webcmd Cloud returned an invalid profile response.');
59
- }
60
- return body;
61
- }
62
57
  async deleteProfile(profileId) {
63
58
  const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE' });
64
59
  if (!hasExactKeys(body, ['ok', 'deleted']) || body.ok !== true || body.deleted !== true) {
@@ -124,6 +119,7 @@ export class HostedClient {
124
119
  headers: {
125
120
  accept: 'application/octet-stream',
126
121
  authorization: `Bearer ${this.apiKey}`,
122
+ ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}),
127
123
  },
128
124
  });
129
125
  if (!response.ok) {
@@ -189,6 +185,7 @@ export class HostedClient {
189
185
  accept: 'application/json',
190
186
  ...(init.body ? { 'content-type': 'application/json' } : {}),
191
187
  authorization: `Bearer ${this.apiKey}`,
188
+ ...(this.workspace ? { 'x-webcmd-workspace': this.workspace } : {}),
192
189
  ...(init.headers ?? {}),
193
190
  },
194
191
  });
@@ -311,19 +308,14 @@ function isHostedProfilesResponse(value) {
311
308
  && Array.isArray(value.profiles)
312
309
  && value.profiles.every(isHostedPublicProfile);
313
310
  }
314
- function isHostedProfileResponse(value) {
315
- return hasExactKeys(value, ['ok', 'profile'])
316
- && value.ok === true
317
- && isHostedPublicProfile(value.profile);
318
- }
319
311
  function isHostedPublicProfile(value) {
320
312
  return hasExactKeys(value, [
321
- 'id', 'name', 'userId', 'default', 'status',
313
+ 'id', 'name', 'workspace', 'default', 'status',
322
314
  'createdAt', 'updatedAt', 'lastUsedAt',
323
315
  ])
324
316
  && typeof value.id === 'string'
325
317
  && (value.name === null || typeof value.name === 'string')
326
- && (value.userId === null || typeof value.userId === 'string')
318
+ && (value.workspace === null || typeof value.workspace === 'string')
327
319
  && typeof value.default === 'boolean'
328
320
  && (value.status === 'pending' || value.status === 'available')
329
321
  && typeof value.createdAt === 'string'
@@ -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',
@@ -170,7 +170,7 @@ describe('HostedClient', () => {
170
170
  const profile = {
171
171
  id: 'profile_work',
172
172
  name: 'Work',
173
- userId: 'user_64256',
173
+ workspace: 'user_64256',
174
174
  default: false,
175
175
  status: 'available',
176
176
  createdAt: '2026-07-08T00:00:00.000Z',
@@ -187,34 +187,16 @@ describe('HostedClient', () => {
187
187
  ...(init?.body ? { body: String(init.body) } : {}),
188
188
  });
189
189
  const path = new URL(String(url)).pathname;
190
- if (path === '/v1/profiles' && init?.method === 'POST') {
191
- return new Response(JSON.stringify({ ok: true, profile }), { status: 201 });
192
- }
193
190
  if (path.startsWith('/v1/profiles/') && init?.method === 'DELETE') {
194
191
  return new Response(JSON.stringify({ ok: true, deleted: true }));
195
192
  }
196
- if (path.startsWith('/v1/profiles/')) {
197
- return new Response(JSON.stringify({ ok: true, profile }));
198
- }
199
193
  return new Response(JSON.stringify({ ok: true, profiles: [profile] }));
200
194
  },
201
195
  });
202
- await expect(client.listProfiles({ name: 'Work', userId: 'user_64256' }))
203
- .resolves.toEqual({ ok: true, profiles: [profile] });
204
- await expect(client.createProfile({ name: 'Work', userId: 'user_64256' }))
205
- .resolves.toMatchObject({ profile: { id: 'profile_work', status: 'available' } });
206
- await expect(client.getProfile('profile_work')).resolves.toMatchObject({
207
- profile: { id: 'profile_work' },
208
- });
196
+ await expect(client.listProfiles()).resolves.toEqual({ ok: true, profiles: [profile] });
209
197
  await expect(client.deleteProfile('profile/work')).resolves.toEqual({ ok: true, deleted: true });
210
198
  expect(requests).toEqual([
211
- { url: 'https://api.example.com/v1/profiles?name=Work&userId=user_64256', method: 'GET' },
212
- {
213
- url: 'https://api.example.com/v1/profiles',
214
- method: 'POST',
215
- body: JSON.stringify({ name: 'Work', userId: 'user_64256' }),
216
- },
217
- { url: 'https://api.example.com/v1/profiles/profile_work', method: 'GET' },
199
+ { url: 'https://api.example.com/v1/profiles', method: 'GET' },
218
200
  { url: 'https://api.example.com/v1/profiles/profile%2Fwork', method: 'DELETE' },
219
201
  ]);
220
202
  });
@@ -222,12 +204,12 @@ describe('HostedClient', () => {
222
204
  { name: 'private provider field', change: { kernelProfileId: 'private' } },
223
205
  { name: 'missing updatedAt', change: { updatedAt: undefined } },
224
206
  { name: 'non-nullable name shape', change: { name: 7 } },
225
- { name: 'non-nullable user ID shape', change: { userId: false } },
207
+ { name: 'non-nullable workspace shape', change: { workspace: false } },
226
208
  ])('rejects a hosted profile with $name', async ({ change }) => {
227
209
  const profile = {
228
210
  id: 'profile_work',
229
211
  name: null,
230
- userId: null,
212
+ workspace: null,
231
213
  default: false,
232
214
  status: 'pending',
233
215
  createdAt: '2026-07-08T00:00:00.000Z',
@@ -850,4 +832,77 @@ describe('HostedClient', () => {
850
832
  },
851
833
  ]);
852
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
+ });
853
908
  });
@@ -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);
@@ -148,7 +149,7 @@ async function dispatchHosted(argv, client, stdout, stderr, now) {
148
149
  }
149
150
  if (args[0] === 'profile') {
150
151
  if (args[1] === 'rename' || args[1] === 'use') {
151
- throw new ConfigError(`webcmd profile ${args[1]} is not available in hosted mode.`, 'Hosted mode supports: webcmd profile list, create, get, and delete.');
152
+ throw new ConfigError(`webcmd profile ${args[1]} is not available in hosted mode.`, 'Hosted mode supports: webcmd profile list and delete.');
152
153
  }
153
154
  const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal);
154
155
  if (parsed.kind === 'help') {
@@ -577,7 +578,7 @@ function parseHostedProfileSurface(argv, literal) {
577
578
  root.exitOverride().configureOutput(output);
578
579
  profile.exitOverride().configureOutput(output);
579
580
  const configureFormat = (command) => command.option('-f, --format <fmt>', 'Output format: table, json, yaml, md, csv', 'table');
580
- const setParsed = (command, surface, value, userId) => {
581
+ const setParsed = (command, surface, value) => {
581
582
  const options = surface.opts();
582
583
  parsed = {
583
584
  kind: 'run',
@@ -585,15 +586,10 @@ function parseHostedProfileSurface(argv, literal) {
585
586
  format: options.format,
586
587
  formatExplicit: surface.getOptionValueSource('format') === 'cli',
587
588
  ...(value !== undefined ? { value } : {}),
588
- ...(userId !== undefined ? { userId } : {}),
589
589
  };
590
590
  };
591
591
  const list = configureFormat(profile.command('list'));
592
592
  list.exitOverride().configureOutput(output).action(() => setParsed('list', list));
593
- const create = configureFormat(profile.command('create').argument('<name>').option('--user-id <id>'));
594
- create.exitOverride().configureOutput(output).action((name, options) => setParsed('create', create, name, options.userId));
595
- const get = configureFormat(profile.command('get').argument('<profile>'));
596
- get.exitOverride().configureOutput(output).action((selector) => setParsed('get', get, selector));
597
593
  const remove = configureFormat(profile.command('delete').argument('<profile-id>'));
598
594
  remove.exitOverride().configureOutput(output).action((profileId) => setParsed('delete', remove, profileId));
599
595
  try {
@@ -612,39 +608,15 @@ function parseHostedProfileSurface(argv, literal) {
612
608
  return parsed;
613
609
  }
614
610
  async function dispatchHostedProfile(parsed, client, stdout) {
615
- let result;
616
- if (parsed.command === 'list') {
617
- result = (await client.listProfiles()).profiles;
618
- }
619
- else if (parsed.command === 'create') {
620
- result = (await client.createProfile({
621
- name: parsed.value,
622
- ...(parsed.userId !== undefined ? { userId: parsed.userId } : {}),
623
- })).profile;
624
- }
625
- else if (parsed.command === 'get') {
626
- result = resolveHostedProfile((await client.listProfiles()).profiles, parsed.value);
627
- }
628
- else {
629
- result = await client.deleteProfile(parsed.value);
630
- }
611
+ const result = parsed.command === 'list'
612
+ ? (await client.listProfiles()).profiles
613
+ : await client.deleteProfile(parsed.value);
631
614
  await renderOutput(result, {
632
615
  fmt: parsed.format,
633
616
  fmtExplicit: parsed.formatExplicit,
634
617
  stdout,
635
618
  });
636
619
  }
637
- function resolveHostedProfile(profiles, selector) {
638
- const matches = profiles.filter(profile => profile.id === selector || profile.name === selector || profile.userId === selector);
639
- const distinct = [...new Map(matches.map(profile => [profile.id, profile])).values()];
640
- if (distinct.length === 0) {
641
- throw new CliError('PROFILE_NOT_FOUND', 'Profile was not found.', undefined, EXIT_CODES.EMPTY_RESULT);
642
- }
643
- if (distinct.length > 1) {
644
- throw new CliError('AMBIGUOUS_PROFILE', 'The profile selector matched multiple profiles.', 'Use the immutable profile ID returned by webcmd profile list.', EXIT_CODES.TEMPFAIL);
645
- }
646
- return distinct[0];
647
- }
648
620
  function parseHostedCompletionSurface(argv, literal) {
649
621
  let stdout = '';
650
622
  let stderr = '';
@@ -226,14 +226,14 @@ describe('runHostedCli', () => {
226
226
  const publicProfile = {
227
227
  id: 'profile_work',
228
228
  name: 'Work',
229
- userId: 'user_64256',
229
+ workspace: 'ws_demo',
230
230
  default: false,
231
231
  status: 'available',
232
232
  createdAt: '2026-07-24T00:00:00.000Z',
233
233
  updatedAt: '2026-07-24T00:00:00.000Z',
234
234
  lastUsedAt: '2026-07-24T00:00:00.000Z',
235
235
  };
236
- it('lists, creates, resolves, gets, and deletes hosted profiles without fetching the manifest', async () => {
236
+ it('lists and deletes hosted profiles without fetching the manifest', async () => {
237
237
  const requests = [];
238
238
  const fetchImpl = vi.fn(async (url, init) => {
239
239
  const request = {
@@ -242,9 +242,6 @@ describe('runHostedCli', () => {
242
242
  ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}),
243
243
  };
244
244
  requests.push(request);
245
- if (request.method === 'POST') {
246
- return new Response(JSON.stringify({ ok: true, profile: publicProfile }), { status: 201 });
247
- }
248
245
  if (request.method === 'DELETE') {
249
246
  return new Response(JSON.stringify({ ok: true, deleted: true }));
250
247
  }
@@ -252,10 +249,6 @@ describe('runHostedCli', () => {
252
249
  });
253
250
  for (const argv of [
254
251
  ['profile', 'list', '-f', 'json'],
255
- ['profile', 'create', 'Work', '--user-id', 'user_64256', '-f', 'json'],
256
- ['profile', 'get', 'Work', '-f', 'json'],
257
- ['profile', 'get', 'user_64256', '-f', 'json'],
258
- ['profile', 'get', 'profile_work', '-f', 'json'],
259
252
  ['profile', 'delete', 'profile_work', '-f', 'json'],
260
253
  ]) {
261
254
  const stdout = sink();
@@ -271,63 +264,46 @@ describe('runHostedCli', () => {
271
264
  expect(stdout.text()).toContain(argv[1] === 'delete' ? '"deleted": true' : '"id": "profile_work"');
272
265
  }
273
266
  expect(requests).toEqual([
274
- { url: 'https://api.example.com/v1/profiles', method: 'GET' },
275
- {
276
- url: 'https://api.example.com/v1/profiles',
277
- method: 'POST',
278
- body: { name: 'Work', userId: 'user_64256' },
279
- },
280
- { url: 'https://api.example.com/v1/profiles', method: 'GET' },
281
- { url: 'https://api.example.com/v1/profiles', method: 'GET' },
282
267
  { url: 'https://api.example.com/v1/profiles', method: 'GET' },
283
268
  { url: 'https://api.example.com/v1/profiles/profile_work', method: 'DELETE' },
284
269
  ]);
285
270
  expect(requests.some(request => request.url.endsWith('/v1/manifest'))).toBe(false);
286
271
  });
287
- it('deduplicates universal profile matches and rejects missing or ambiguous selectors', async () => {
272
+ it.each(['create', 'get'])('rejects the removed profile %s subcommand', async (command) => {
273
+ const stderr = sink();
274
+ const fetchImpl = vi.fn();
275
+ const result = await runHostedCli(['profile', command, 'Work'], {
276
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
277
+ stderr: stderr.stream,
278
+ fetchImpl,
279
+ });
280
+ expect(result.handled).toBe(true);
281
+ expect(stderr.text()).toMatch(/unknown command|not supported/i);
282
+ expect(fetchImpl).not.toHaveBeenCalled();
283
+ });
284
+ it('threads the ambient workspace onto hosted profile requests', async () => {
288
285
  const cases = [
289
- {
290
- selector: 'same',
291
- profiles: [{ ...publicProfile, id: 'profile_same', name: 'same', userId: 'same' }],
292
- exitCode: 0,
293
- stdout: '"id": "profile_same"',
294
- stderr: '',
295
- },
296
- {
297
- selector: 'missing',
298
- profiles: [publicProfile],
299
- exitCode: 66,
300
- stdout: '',
301
- stderr: 'code: PROFILE_NOT_FOUND',
302
- },
303
- {
304
- selector: 'shared',
305
- profiles: [
306
- { ...publicProfile, id: 'profile_one', name: 'shared' },
307
- { ...publicProfile, id: 'profile_two', name: 'Other', userId: 'shared' },
308
- ],
309
- exitCode: 75,
310
- stdout: '',
311
- stderr: 'code: AMBIGUOUS_PROFILE',
312
- },
286
+ { name: 'from WEBCMD_WORKSPACE env', argv: ['profile', 'list', '-f', 'json'], env: { WEBCMD_WORKSPACE: 'ws1' }, expected: 'ws1' },
287
+ { name: 'from --workspace flag', argv: ['--workspace', 'ws2', 'profile', 'list', '-f', 'json'], env: {}, expected: 'ws2' },
313
288
  ];
314
289
  for (const testCase of cases) {
290
+ let capturedHeader = null;
291
+ const fetchImpl = vi.fn(async (_url, init) => {
292
+ capturedHeader = new Headers(init?.headers).get('x-webcmd-workspace');
293
+ return new Response(JSON.stringify({ ok: true, profiles: [publicProfile] }));
294
+ });
315
295
  const stdout = sink();
316
296
  const stderr = sink();
317
- const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ ok: true, profiles: testCase.profiles })));
318
- const result = await runHostedCli(['profile', 'get', testCase.selector, '-f', 'json'], {
297
+ const result = await runHostedCli(testCase.argv, {
319
298
  config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
320
299
  stdout: stdout.stream,
321
300
  stderr: stderr.stream,
322
301
  fetchImpl,
302
+ env: testCase.env,
323
303
  });
324
- expect(result.exitCode).toBe(testCase.exitCode);
325
- expect(stdout.text()).toContain(testCase.stdout);
326
- expect(stderr.text()).toContain(testCase.stderr);
327
- if (testCase.selector === 'shared') {
328
- expect(stderr.text()).toContain('Use the immutable profile ID returned by webcmd profile list.');
329
- }
330
- expect(fetchImpl).toHaveBeenCalledTimes(1);
304
+ expect(result).toEqual({ handled: true, exitCode: 0 });
305
+ expect(stderr.text()).toBe('');
306
+ expect(capturedHeader).toBe(testCase.expected);
331
307
  }
332
308
  });
333
309
  it.each(['rename', 'use'])('rejects local-only profile %s in hosted mode without an API call', async (command) => {
@@ -42,7 +42,7 @@ export interface HostedManifest {
42
42
  export interface HostedPublicProfile {
43
43
  id: string;
44
44
  name: string | null;
45
- userId: string | null;
45
+ workspace: string | null;
46
46
  default: boolean;
47
47
  status: 'pending' | 'available';
48
48
  createdAt: string;
@@ -53,10 +53,6 @@ export interface HostedProfilesResponse {
53
53
  ok: true;
54
54
  profiles: HostedPublicProfile[];
55
55
  }
56
- export interface HostedProfileResponse {
57
- ok: true;
58
- profile: HostedPublicProfile;
59
- }
60
56
  export interface HostedExecution {
61
57
  id: string;
62
58
  command: string;
@@ -35,6 +35,11 @@ export function parseHostedRootCommandSurface(argv) {
35
35
  let stderr = '';
36
36
  const boundary = findRootCommandBoundary(input);
37
37
  const root = configureRootCommandSurface(new Command('webcmd'))
38
+ // Hosted-only: registered here (not in the shared configureRootCommandSurface)
39
+ // so the local CLI surface is unaffected. Lets Commander's structural parse
40
+ // consume `--workspace <id>` before the site/command token instead of
41
+ // throwing an unknown-option error.
42
+ .option('--workspace <id>', 'Hosted workspace id/slug for the request')
38
43
  .exitOverride()
39
44
  .configureOutput({
40
45
  writeOut: value => { stdout += value; },
@@ -89,13 +94,16 @@ export function parseHostedRootCommandSurface(argv) {
89
94
  function findRootCommandBoundary(argv) {
90
95
  for (let index = 0; index < argv.length; index += 1) {
91
96
  const token = argv[index];
92
- if (token === '--profile') {
97
+ if (token === '--profile' || token === '--workspace') {
93
98
  // Commander requires and consumes the next token even when it is `--` or
94
99
  // starts with a dash. Structural failures have already been reported.
100
+ // `--workspace` is hosted-only (not a registered Commander option here)
101
+ // but must still be skipped as a value pair so it and its value are not
102
+ // mistaken for the site/command token and forwarded to the server.
95
103
  index += 1;
96
104
  continue;
97
105
  }
98
- if (token.startsWith('--profile='))
106
+ if (token.startsWith('--profile=') || token.startsWith('--workspace='))
99
107
  continue;
100
108
  if (token === '--')
101
109
  return { separatorIndex: index };