@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.
- package/dist/src/completion-shared.js +1 -1
- package/dist/src/docs-sync-review.js +13 -5
- package/dist/src/docs-sync-review.test.js +36 -0
- package/dist/src/generate-release-notes-cli.test.js +1 -1
- package/dist/src/hosted/client.d.ts +14 -2
- package/dist/src/hosted/client.js +46 -5
- package/dist/src/hosted/client.test.js +73 -20
- package/dist/src/hosted/manifest.test.js +2 -1
- package/dist/src/hosted/root-command-surface.test.js +7 -0
- package/dist/src/hosted/runner.js +93 -0
- package/dist/src/hosted/runner.test.js +126 -1
- package/dist/src/hosted/types.d.ts +9 -2
- package/hosted-contract.json +1 -1
- package/package.json +2 -2
|
@@ -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 =
|
|
120
|
-
?
|
|
121
|
-
: /^
|
|
122
|
-
?
|
|
123
|
-
:
|
|
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(
|
|
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
|
|
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, [
|
|
286
|
-
|
|
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('
|
|
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 () =>
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
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())
|
|
184
|
-
ok: true,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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;
|
package/hosted-contract.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentrhq/webcmd",
|
|
3
|
-
"version": "0.4.
|
|
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
|
+
}
|