@agentrhq/webcmd 0.4.0 → 0.4.1

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.
@@ -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
  });
@@ -1,5 +1,5 @@
1
1
  import { CliError, type ExitCode } from '../errors.js';
2
- import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserRunRequest, HostedBrowserRunResponse, HostedExecution, HostedExecuteResponse, HostedPrepareExecutionResponse, HostedProfilesResponse, HostedUploadArtifactResponse, HostedManifest, HostedTraceReceipt } from './types.js';
2
+ import type { HostedBrowserActionRequest, HostedBrowserActionResponse, HostedBrowserFinishRequest, HostedBrowserFinishResponse, HostedBrowserRunActionInput, HostedBrowserRunActionResponse, HostedBrowserRunRequest, HostedBrowserRunResponse, HostedExecution, HostedExecuteResponse, HostedPrepareExecutionResponse, HostedProfileResponse, HostedProfilesResponse, HostedUploadArtifactResponse, HostedManifest, HostedTraceReceipt } from './types.js';
3
3
  export interface HostedClientOptions {
4
4
  apiBaseUrl: string;
5
5
  apiKey: string;
@@ -20,7 +20,19 @@ export declare class HostedClient {
20
20
  constructor(options: HostedClientOptions);
21
21
  getMe(): Promise<unknown>;
22
22
  getManifest(): Promise<HostedManifest>;
23
- listProfiles(): Promise<HostedProfilesResponse>;
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>;
32
+ deleteProfile(profileId: string): Promise<{
33
+ ok: true;
34
+ deleted: true;
35
+ }>;
24
36
  execute(input: {
25
37
  command: string;
26
38
  args: Record<string, unknown>;
@@ -29,13 +29,43 @@ export class HostedClient {
29
29
  }
30
30
  return body.manifest;
31
31
  }
32
- async listProfiles() {
33
- const body = await this.request('/v1/profiles');
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}` : ''}`);
34
40
  if (!isHostedProfilesResponse(body)) {
35
41
  throw protocolError('Webcmd Cloud returned an invalid profiles response.');
36
42
  }
37
43
  return body;
38
44
  }
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
+ async deleteProfile(profileId) {
63
+ const body = await this.request(`/v1/profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE' });
64
+ if (!hasExactKeys(body, ['ok', 'deleted']) || body.ok !== true || body.deleted !== true) {
65
+ throw protocolError('Webcmd Cloud returned an invalid profile deletion response.');
66
+ }
67
+ return { ok: true, deleted: true };
68
+ }
39
69
  async execute(input) {
40
70
  const traceMode = normalizeTraceMode(input.trace);
41
71
  const body = await this.request('/v1/execute', {
@@ -281,12 +311,23 @@ function isHostedProfilesResponse(value) {
281
311
  && Array.isArray(value.profiles)
282
312
  && value.profiles.every(isHostedPublicProfile);
283
313
  }
314
+ function isHostedProfileResponse(value) {
315
+ return hasExactKeys(value, ['ok', 'profile'])
316
+ && value.ok === true
317
+ && isHostedPublicProfile(value.profile);
318
+ }
284
319
  function isHostedPublicProfile(value) {
285
- return hasExactKeys(value, ['name', 'default', 'status', 'createdAt', 'lastUsedAt'])
286
- && typeof value.name === 'string'
320
+ return hasExactKeys(value, [
321
+ 'id', 'name', 'userId', 'default', 'status',
322
+ 'createdAt', 'updatedAt', 'lastUsedAt',
323
+ ])
324
+ && typeof value.id === 'string'
325
+ && (value.name === null || typeof value.name === 'string')
326
+ && (value.userId === null || typeof value.userId === 'string')
287
327
  && typeof value.default === 'boolean'
288
- && value.status === 'available'
328
+ && (value.status === 'pending' || value.status === 'available')
289
329
  && typeof value.createdAt === 'string'
330
+ && typeof value.updatedAt === 'string'
290
331
  && typeof value.lastUsedAt === 'string';
291
332
  }
292
333
  function isHostedManifestCommand(value) {
@@ -165,31 +165,84 @@ 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
+ userId: '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 === '/v1/profiles' && init?.method === 'POST') {
191
+ return new Response(JSON.stringify({ ok: true, profile }), { status: 201 });
192
+ }
193
+ if (path.startsWith('/v1/profiles/') && init?.method === 'DELETE') {
194
+ return new Response(JSON.stringify({ ok: true, deleted: true }));
195
+ }
196
+ if (path.startsWith('/v1/profiles/')) {
197
+ return new Response(JSON.stringify({ ok: true, profile }));
198
+ }
199
+ return new Response(JSON.stringify({ ok: true, profiles: [profile] }));
200
+ },
182
201
  });
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
- }],
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
+ });
209
+ await expect(client.deleteProfile('profile/work')).resolves.toEqual({ ok: true, deleted: true });
210
+ 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' },
218
+ { url: 'https://api.example.com/v1/profiles/profile%2Fwork', method: 'DELETE' },
219
+ ]);
220
+ });
221
+ it.each([
222
+ { name: 'private provider field', change: { kernelProfileId: 'private' } },
223
+ { name: 'missing updatedAt', change: { updatedAt: undefined } },
224
+ { name: 'non-nullable name shape', change: { name: 7 } },
225
+ { name: 'non-nullable user ID shape', change: { userId: false } },
226
+ ])('rejects a hosted profile with $name', async ({ change }) => {
227
+ const profile = {
228
+ id: 'profile_work',
229
+ name: null,
230
+ userId: null,
231
+ default: false,
232
+ status: 'pending',
233
+ createdAt: '2026-07-08T00:00:00.000Z',
234
+ updatedAt: '2026-07-08T00:00:00.000Z',
235
+ lastUsedAt: '2026-07-08T00:00:00.000Z',
236
+ ...change,
237
+ };
238
+ if (change.updatedAt === undefined)
239
+ delete profile.updatedAt;
240
+ const client = new HostedClient({
241
+ apiBaseUrl: 'https://api.example.com',
242
+ apiKey: 'key',
243
+ fetchImpl: async () => new Response(JSON.stringify({ ok: true, profiles: [profile] })),
192
244
  });
245
+ await expect(client.listProfiles()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
193
246
  });
194
247
  it('maps hosted error envelopes to CliError-compatible errors', async () => {
195
248
  const client = new HostedClient({
@@ -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 } },
@@ -146,6 +146,18 @@ async function dispatchHosted(argv, client, stdout, stderr, now) {
146
146
  await renderHostedList(manifest, parsed.format, parsed.formatExplicit, stdout);
147
147
  return;
148
148
  }
149
+ if (args[0] === 'profile') {
150
+ 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
+ }
153
+ const parsed = parseHostedProfileSurface(args.slice(1), normalized.literal);
154
+ if (parsed.kind === 'help') {
155
+ await writeToStream(stdout, parsed.output);
156
+ return;
157
+ }
158
+ await dispatchHostedProfile(parsed, client, stdout);
159
+ return;
160
+ }
149
161
  const manifest = await client.getManifest();
150
162
  validateManifestContractIdentity(manifest);
151
163
  const site = args[0];
@@ -552,6 +564,87 @@ function parseHostedListSurface(argv, literal) {
552
564
  throw new CommanderStructuralError("error: command 'list' did not run\n", 1);
553
565
  return { kind: 'run', format: parsedFormat, formatExplicit };
554
566
  }
567
+ function parseHostedProfileSurface(argv, literal) {
568
+ let stdout = '';
569
+ let stderr = '';
570
+ let parsed;
571
+ const root = new Command('webcmd');
572
+ const profile = root.command('profile').description('Manage hosted browser profiles');
573
+ const output = {
574
+ writeOut: (value) => { stdout += value; },
575
+ writeErr: (value) => { stderr += value; },
576
+ };
577
+ root.exitOverride().configureOutput(output);
578
+ profile.exitOverride().configureOutput(output);
579
+ 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 options = surface.opts();
582
+ parsed = {
583
+ kind: 'run',
584
+ command,
585
+ format: options.format,
586
+ formatExplicit: surface.getOptionValueSource('format') === 'cli',
587
+ ...(value !== undefined ? { value } : {}),
588
+ ...(userId !== undefined ? { userId } : {}),
589
+ };
590
+ };
591
+ const list = configureFormat(profile.command('list'));
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
+ const remove = configureFormat(profile.command('delete').argument('<profile-id>'));
598
+ remove.exitOverride().configureOutput(output).action((profileId) => setParsed('delete', remove, profileId));
599
+ try {
600
+ root.parse(literal ? ['--', 'profile', ...argv] : ['profile', ...argv], { from: 'user' });
601
+ }
602
+ catch (error) {
603
+ if (!(error instanceof CommanderError))
604
+ throw error;
605
+ if (error.code === 'commander.helpDisplayed')
606
+ return { kind: 'help', output: stdout };
607
+ throw new CommanderStructuralError(stderr || `${error.message}\n`, error.exitCode);
608
+ }
609
+ if (!parsed) {
610
+ throw new CommanderStructuralError("error: command 'profile' did not run\n", 1);
611
+ }
612
+ return parsed;
613
+ }
614
+ 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
+ }
631
+ await renderOutput(result, {
632
+ fmt: parsed.format,
633
+ fmtExplicit: parsed.formatExplicit,
634
+ stdout,
635
+ });
636
+ }
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
+ }
555
648
  function parseHostedCompletionSurface(argv, literal) {
556
649
  let stdout = '';
557
650
  let stderr = '';
@@ -10,7 +10,7 @@ import { createProgram } from '../cli.js';
10
10
  import { formatRootHelp } from '../command-presentation.js';
11
11
  import { HOSTED_ROOT_HELP } from '../completion-shared.js';
12
12
  import { PKG_VERSION } from '../version.js';
13
- import { makeHostedConfig } from './config.js';
13
+ import { makeHostedConfig, makeLocalConfig } from './config.js';
14
14
  import { runHostedCli } from './runner.js';
15
15
  const [packageMajor, packageMinor] = PKG_VERSION.split('.');
16
16
  const compatiblePatchVersion = `${packageMajor}.${packageMinor}.99`;
@@ -223,6 +223,131 @@ function captureLocalBrowserStructure(argv) {
223
223
  }
224
224
  }
225
225
  describe('runHostedCli', () => {
226
+ const publicProfile = {
227
+ id: 'profile_work',
228
+ name: 'Work',
229
+ userId: 'user_64256',
230
+ default: false,
231
+ status: 'available',
232
+ createdAt: '2026-07-24T00:00:00.000Z',
233
+ updatedAt: '2026-07-24T00:00:00.000Z',
234
+ lastUsedAt: '2026-07-24T00:00:00.000Z',
235
+ };
236
+ it('lists, creates, resolves, gets, and deletes hosted profiles without fetching the manifest', async () => {
237
+ const requests = [];
238
+ const fetchImpl = vi.fn(async (url, init) => {
239
+ const request = {
240
+ url: String(url),
241
+ method: init?.method ?? 'GET',
242
+ ...(init?.body ? { body: JSON.parse(String(init.body)) } : {}),
243
+ };
244
+ requests.push(request);
245
+ if (request.method === 'POST') {
246
+ return new Response(JSON.stringify({ ok: true, profile: publicProfile }), { status: 201 });
247
+ }
248
+ if (request.method === 'DELETE') {
249
+ return new Response(JSON.stringify({ ok: true, deleted: true }));
250
+ }
251
+ return new Response(JSON.stringify({ ok: true, profiles: [publicProfile] }));
252
+ });
253
+ for (const argv of [
254
+ ['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
+ ['profile', 'delete', 'profile_work', '-f', 'json'],
260
+ ]) {
261
+ const stdout = sink();
262
+ const stderr = sink();
263
+ const result = await runHostedCli(argv, {
264
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
265
+ stdout: stdout.stream,
266
+ stderr: stderr.stream,
267
+ fetchImpl,
268
+ });
269
+ expect(result).toEqual({ handled: true, exitCode: 0 });
270
+ expect(stderr.text()).toBe('');
271
+ expect(stdout.text()).toContain(argv[1] === 'delete' ? '"deleted": true' : '"id": "profile_work"');
272
+ }
273
+ 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
+ { url: 'https://api.example.com/v1/profiles', method: 'GET' },
283
+ { url: 'https://api.example.com/v1/profiles/profile_work', method: 'DELETE' },
284
+ ]);
285
+ expect(requests.some(request => request.url.endsWith('/v1/manifest'))).toBe(false);
286
+ });
287
+ it('deduplicates universal profile matches and rejects missing or ambiguous selectors', async () => {
288
+ 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
+ },
313
+ ];
314
+ for (const testCase of cases) {
315
+ const stdout = sink();
316
+ 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'], {
319
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
320
+ stdout: stdout.stream,
321
+ stderr: stderr.stream,
322
+ fetchImpl,
323
+ });
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);
331
+ }
332
+ });
333
+ it.each(['rename', 'use'])('rejects local-only profile %s in hosted mode without an API call', async (command) => {
334
+ const stderr = sink();
335
+ const fetchImpl = vi.fn();
336
+ const result = await runHostedCli(['profile', command, 'value'], {
337
+ config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }),
338
+ stderr: stderr.stream,
339
+ fetchImpl,
340
+ });
341
+ expect(result).toEqual({ handled: true, exitCode: 78 });
342
+ expect(stderr.text()).toContain(`webcmd profile ${command} is not available in hosted mode.`);
343
+ expect(fetchImpl).not.toHaveBeenCalled();
344
+ });
345
+ it.each(['list', 'rename', 'use'])('leaves profile %s to the existing local command surface', async (command) => {
346
+ const result = await runHostedCli(['profile', command, 'value'], {
347
+ config: makeLocalConfig(),
348
+ });
349
+ expect(result).toEqual({ handled: false, exitCode: 0 });
350
+ });
226
351
  it.each([
227
352
  ['missing-site'],
228
353
  ['missing-site', 'child'],
@@ -40,16 +40,23 @@ export interface HostedManifest {
40
40
  commands: HostedCommand[];
41
41
  }
42
42
  export interface HostedPublicProfile {
43
- name: string;
43
+ id: string;
44
+ name: string | null;
45
+ userId: string | null;
44
46
  default: boolean;
45
- status: 'available';
47
+ status: 'pending' | 'available';
46
48
  createdAt: string;
49
+ updatedAt: string;
47
50
  lastUsedAt: string;
48
51
  }
49
52
  export interface HostedProfilesResponse {
50
53
  ok: true;
51
54
  profiles: HostedPublicProfile[];
52
55
  }
56
+ export interface HostedProfileResponse {
57
+ ok: true;
58
+ profile: HostedPublicProfile;
59
+ }
53
60
  export interface HostedExecution {
54
61
  id: string;
55
62
  command: string;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "webcmdVersion": "0.4.0",
3
+ "webcmdVersion": "0.4.1",
4
4
  "outputFormats": [
5
5
  "table",
6
6
  "plain",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentrhq/webcmd",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0"
@@ -114,4 +114,4 @@
114
114
  "overrides": {
115
115
  "postcss": "^8.5.10"
116
116
  }
117
- }
117
+ }