@agentrhq/webcmd 0.2.0 → 0.2.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.
Files changed (78) hide show
  1. package/README.md +58 -31
  2. package/cli-manifest.json +506 -0
  3. package/clis/_shared/site-auth.js +6 -1
  4. package/clis/district/_lib.js +566 -0
  5. package/clis/district/auth.js +49 -0
  6. package/clis/district/checkout.js +278 -0
  7. package/clis/district/listings.js +158 -0
  8. package/clis/district/locations.js +211 -0
  9. package/clis/district/search.js +218 -0
  10. package/clis/district/seats.js +233 -0
  11. package/clis/district/set-location.js +82 -0
  12. package/clis/district/showtimes.js +433 -0
  13. package/clis/reddit/popular.js +12 -1
  14. package/clis/reddit/popular.test.js +12 -3
  15. package/clis/twitter/delete.js +14 -6
  16. package/clis/twitter/delete.test.js +74 -1
  17. package/clis/twitter/timeline.js +3 -1
  18. package/clis/twitter/timeline.test.js +4 -0
  19. package/dist/src/browser/bridge-readiness.test.js +1 -1
  20. package/dist/src/browser/bridge.d.ts +2 -0
  21. package/dist/src/browser/bridge.js +6 -4
  22. package/dist/src/browser/cdp.d.ts +1 -0
  23. package/dist/src/browser/daemon-client.d.ts +1 -0
  24. package/dist/src/browser/daemon-client.js +7 -2
  25. package/dist/src/browser/daemon-client.test.js +33 -30
  26. package/dist/src/browser/daemon-transport.js +3 -19
  27. package/dist/src/browser/page.d.ts +5 -1
  28. package/dist/src/browser/page.js +16 -1
  29. package/dist/src/browser/profile.d.ts +9 -0
  30. package/dist/src/browser/profile.js +18 -7
  31. package/dist/src/browser/profile.test.d.ts +1 -0
  32. package/dist/src/browser/profile.test.js +44 -0
  33. package/dist/src/browser/protocol.d.ts +3 -0
  34. package/dist/src/browser/runtime/local-cloak/actions.js +25 -10
  35. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +3 -0
  36. package/dist/src/browser/runtime/local-cloak/session-manager.js +15 -2
  37. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +145 -0
  38. package/dist/src/build-manifest.js +1 -0
  39. package/dist/src/build-manifest.test.js +34 -0
  40. package/dist/src/cli.js +133 -45
  41. package/dist/src/cli.test.js +2 -2
  42. package/dist/src/commands/daemon.test.js +13 -13
  43. package/dist/src/constants.d.ts +2 -3
  44. package/dist/src/constants.js +3 -10
  45. package/dist/src/daemon.js +1 -5
  46. package/dist/src/discovery.js +1 -0
  47. package/dist/src/doctor.js +12 -0
  48. package/dist/src/doctor.test.js +30 -3
  49. package/dist/src/engine.test.js +62 -0
  50. package/dist/src/execution.js +5 -3
  51. package/dist/src/external.js +19 -1
  52. package/dist/src/external.test.js +37 -1
  53. package/dist/src/main.js +0 -5
  54. package/dist/src/manifest-types.d.ts +2 -0
  55. package/dist/src/node-network.test.js +3 -3
  56. package/dist/src/registry.d.ts +10 -0
  57. package/dist/src/registry.js +5 -3
  58. package/dist/src/registry.test.js +25 -0
  59. package/dist/src/runtime-identity.test.js +0 -8
  60. package/dist/src/runtime.d.ts +4 -0
  61. package/dist/src/runtime.js +2 -0
  62. package/dist/src/skills.d.ts +23 -5
  63. package/dist/src/skills.js +87 -45
  64. package/dist/src/skills.test.js +80 -23
  65. package/package.json +3 -3
  66. package/skills/smart-search/SKILL.md +156 -0
  67. package/skills/smart-search/references/sources-ai.md +74 -0
  68. package/skills/smart-search/references/sources-info.md +43 -0
  69. package/skills/smart-search/references/sources-media.md +40 -0
  70. package/skills/smart-search/references/sources-other.md +32 -0
  71. package/skills/smart-search/references/sources-shopping.md +21 -0
  72. package/skills/smart-search/references/sources-social.md +36 -0
  73. package/skills/smart-search/references/sources-tech.md +38 -0
  74. package/skills/smart-search/references/sources-travel.md +26 -0
  75. package/skills/webcmd-adapter-author/SKILL.md +1 -0
  76. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  77. package/skills/webcmd-autofix/SKILL.md +8 -0
  78. package/skills/webcmd-sitemap-author/SKILL.md +1 -1
@@ -1,16 +1,9 @@
1
1
  /**
2
2
  * Shared constants used across explore, synthesize, and pipeline modules.
3
3
  */
4
- import { CLI_COMMAND, DAEMON_HEADER_NAME, ENV_PREFIX, PRODUCT_DISPLAY_NAME } from './brand.js';
5
- /** Default daemon port for HTTP/WebSocket communication with browser extension */
6
- export const DEFAULT_DAEMON_PORT = 19825;
7
- export function unsupportedDaemonPortEnvMessage(value) {
8
- const envName = `${ENV_PREFIX}_DAEMON_PORT`;
9
- const suffix = value ? ` (received ${value})` : '';
10
- return `${envName} is no longer supported${suffix}. ` +
11
- `The ${PRODUCT_DISPLAY_NAME} Chrome extension can only connect to localhost:${DEFAULT_DAEMON_PORT}. ` +
12
- `Unset ${envName} and rerun ${CLI_COMMAND}.`;
13
- }
4
+ import { DAEMON_HEADER_NAME } from './brand.js';
5
+ /** Default daemon port for HTTP communication with the browser runtime. */
6
+ export const DEFAULT_DAEMON_PORT = 9777;
14
7
  export { DAEMON_HEADER_NAME };
15
8
  /** URL query params that are volatile/ephemeral and should be stripped from patterns */
16
9
  export const VOLATILE_PARAMS = new Set([
@@ -1,13 +1,9 @@
1
- import { DEFAULT_DAEMON_PORT, unsupportedDaemonPortEnvMessage } from './constants.js';
1
+ import { DEFAULT_DAEMON_PORT } from './constants.js';
2
2
  import { EXIT_CODES } from './errors.js';
3
3
  import { log } from './logger.js';
4
4
  import { PKG_VERSION } from './version.js';
5
5
  import { createDaemonServer } from './daemon/server.js';
6
6
  import { LocalCloakRuntimeProvider } from './browser/runtime/local-cloak/provider.js';
7
- if (process.env.WEBCMD_DAEMON_PORT) {
8
- log.error(unsupportedDaemonPortEnvMessage(process.env.WEBCMD_DAEMON_PORT));
9
- process.exit(EXIT_CODES.USAGE_ERROR);
10
- }
11
7
  const provider = new LocalCloakRuntimeProvider();
12
8
  const daemon = createDaemonServer(provider, { port: DEFAULT_DAEMON_PORT, host: '127.0.0.1', version: PKG_VERSION });
13
9
  daemon.listen().then(() => {
@@ -211,6 +211,7 @@ async function loadFromManifest(manifestPath, clisDir) {
211
211
  source: entry.sourceFile ? path.resolve(clisDir, entry.sourceFile) : modulePath,
212
212
  navigateBefore: entry.navigateBefore,
213
213
  siteSession: entry.siteSession,
214
+ freshPage: entry.freshPage,
214
215
  defaultWindowMode: entry.defaultWindowMode,
215
216
  _lazy: true,
216
217
  _modulePath: modulePath,
@@ -93,6 +93,18 @@ export async function runBrowserDoctor(opts = {}) {
93
93
  if (!connectivity.ok) {
94
94
  issues.push(`Browser connectivity test failed: ${connectivity.error ?? 'unknown'}`);
95
95
  }
96
+ const profileConfig = loadProfileConfig();
97
+ const staleDefault = profileConfig.defaultContextId;
98
+ if (staleDefault && profiles?.length && !profiles.some((p) => p.contextId === staleDefault)) {
99
+ const alias = aliasForContextId(profileConfig, staleDefault);
100
+ const label = alias ? `${alias} (${staleDefault})` : staleDefault;
101
+ const fallbackNote = profiles.length === 1
102
+ ? `Commands currently fall back to the only active profile: ${profiles[0].contextId}.`
103
+ : 'Multiple profiles are active, so commands will ask you to choose.';
104
+ issues.push(`Default Cloak profile is not active: ${label}.\n` +
105
+ ` ${fallbackNote}\n` +
106
+ ' Refresh it with: webcmd profile list, then webcmd profile use <name>.');
107
+ }
96
108
  if (adapterShadows.length > 0) {
97
109
  issues.push(formatAdapterShadowIssue(adapterShadows));
98
110
  }
@@ -49,7 +49,7 @@ describe('doctor report rendering', () => {
49
49
  runtimeVersion: '1.6.8',
50
50
  issues: [],
51
51
  }));
52
- expect(text).toContain('[OK] Daemon: running on port 19825');
52
+ expect(text).toContain('[OK] Daemon: running on port 9777');
53
53
  expect(text).toContain('(v1.7.9)');
54
54
  expect(text).toContain('[OK] Runtime: Cloak connected (v1.6.8)');
55
55
  expect(text).toContain('Everything looks good!');
@@ -66,7 +66,7 @@ describe('doctor report rendering', () => {
66
66
  runtimeVersion: '1.0.3',
67
67
  issues: ['Stale daemon detected: daemon v1.7.6 != CLI v1.7.9.\n Run: webcmd daemon restart'],
68
68
  }));
69
- expect(text).toContain('[WARN] Daemon: running on port 19825 (v1.7.6, stale; CLI v1.7.9)');
69
+ expect(text).toContain('[WARN] Daemon: running on port 9777 (v1.7.6, stale; CLI v1.7.9)');
70
70
  expect(text).toContain('Run: webcmd daemon restart');
71
71
  expect(text).not.toContain('Everything looks good!');
72
72
  });
@@ -86,7 +86,7 @@ describe('doctor report rendering', () => {
86
86
  runtimeConnected: false,
87
87
  issues: ['Daemon is running but the Cloak runtime is not connected.'],
88
88
  }));
89
- expect(text).toContain('[OK] Daemon: running on port 19825');
89
+ expect(text).toContain('[OK] Daemon: running on port 9777');
90
90
  expect(text).toContain('[MISSING] Runtime: Cloak not connected');
91
91
  });
92
92
  it('renders OK when the connected Cloak runtime version is unknown', () => {
@@ -157,6 +157,33 @@ describe('doctor report rendering', () => {
157
157
  expect.stringContaining('Daemon is not running'),
158
158
  ]));
159
159
  });
160
+ it('reports a stale default Cloak profile when it is not active', async () => {
161
+ const fs = await import('node:fs');
162
+ const os = await import('node:os');
163
+ const path = await import('node:path');
164
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-doctor-profile-'));
165
+ fs.writeFileSync(path.join(configDir, 'browser-profiles.json'), JSON.stringify({ version: 1, aliases: { work: 'profile-default' }, defaultContextId: 'profile-default' }));
166
+ vi.stubEnv('WEBCMD_CONFIG_DIR', configDir);
167
+ try {
168
+ mockGetDaemonHealth.mockResolvedValueOnce({
169
+ state: 'ready',
170
+ status: {
171
+ runtimeConnected: true,
172
+ runtimeName: 'Cloak',
173
+ profiles: [{ contextId: 'active-profile', runtimeConnected: true, pending: 0 }],
174
+ },
175
+ });
176
+ const report = await runBrowserDoctor();
177
+ expect(report.issues).toEqual(expect.arrayContaining([
178
+ expect.stringContaining('Default Cloak profile is not active: work (profile-default)'),
179
+ ]));
180
+ expect(report.issues.join('\n')).toContain('fall back to the only active profile: active-profile');
181
+ }
182
+ finally {
183
+ vi.unstubAllEnvs();
184
+ fs.rmSync(configDir, { recursive: true, force: true });
185
+ }
186
+ });
160
187
  it('reports flapping when live check succeeds but final status shows runtime disconnected', async () => {
161
188
  mockGetDaemonHealth.mockResolvedValueOnce({ state: 'no-runtime', status: { runtimeConnected: false, runtimeName: 'Cloak' } });
162
189
  const report = await runBrowserDoctor();
@@ -3,6 +3,7 @@ import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdap
3
3
  import { executeCommand } from './execution.js';
4
4
  import { getRegistry, cli, Strategy } from './registry.js';
5
5
  import { clearAllHooks, onAfterExecute } from './hooks.js';
6
+ import * as runtime from './runtime.js';
6
7
  import * as fs from 'node:fs';
7
8
  import * as os from 'node:os';
8
9
  import * as path from 'node:path';
@@ -71,6 +72,67 @@ cli({
71
72
  await fs.promises.rm(tempBuildRoot, { recursive: true, force: true });
72
73
  }
73
74
  });
75
+ it('preserves freshPage from manifest before lazy command import', async () => {
76
+ const site = `manifest-fresh-${Date.now()}`;
77
+ const tempBuildRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-manifest-fresh-'));
78
+ const distDir = path.join(tempBuildRoot, 'dist');
79
+ const siteDir = path.join(distDir, site);
80
+ const commandPath = path.join(siteDir, 'checkout.js');
81
+ const manifestPath = path.join(tempBuildRoot, 'cli-manifest.json');
82
+ const sessionOpts = [];
83
+ const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
84
+ try {
85
+ await fs.promises.mkdir(siteDir, { recursive: true });
86
+ await fs.promises.writeFile(manifestPath, `${JSON.stringify([
87
+ {
88
+ site,
89
+ name: 'checkout',
90
+ description: 'fresh checkout',
91
+ access: 'write',
92
+ strategy: 'public',
93
+ browser: true,
94
+ args: [],
95
+ type: 'js',
96
+ modulePath: `${site}/checkout.js`,
97
+ sourceFile: `${site}/checkout.js`,
98
+ siteSession: 'persistent',
99
+ freshPage: true,
100
+ },
101
+ ], null, 2)}\n`);
102
+ await fs.promises.writeFile(commandPath, `
103
+ import { cli, Strategy } from '${pathToFileURL(path.join(process.cwd(), 'src', 'registry.ts')).href}';
104
+ cli({
105
+ site: '${site}',
106
+ name: 'checkout', access: 'write',
107
+ description: 'fresh checkout',
108
+ browser: true,
109
+ strategy: Strategy.PUBLIC,
110
+ siteSession: 'persistent',
111
+ freshPage: true,
112
+ func: async () => [{ ok: true }],
113
+ });
114
+ `);
115
+ await discoverClis(distDir);
116
+ const cmd = getRegistry().get(`${site}/checkout`);
117
+ expect(cmd?.freshPage).toBe(true);
118
+ vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
119
+ sessionOpts.push(opts ?? {});
120
+ return fn(mockPage);
121
+ });
122
+ await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true }]);
123
+ expect(sessionOpts).toHaveLength(1);
124
+ expect(sessionOpts[0]).toMatchObject({
125
+ session: `site:${site}`,
126
+ siteSession: 'persistent',
127
+ freshPage: true,
128
+ });
129
+ }
130
+ finally {
131
+ vi.restoreAllMocks();
132
+ getRegistry().delete(`${site}/checkout`);
133
+ await fs.promises.rm(tempBuildRoot, { recursive: true, force: true });
134
+ }
135
+ });
74
136
  it('loads user CLI modules via package exports symlink', async () => {
75
137
  const tempWebcmdRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'webcmd-user-clis-'));
76
138
  const userClisDir = path.join(tempWebcmdRoot, 'clis');
@@ -18,7 +18,7 @@ import { executePipeline } from './pipeline/index.js';
18
18
  import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
19
19
  import { shouldUseBrowserSession } from './capabilityRouting.js';
20
20
  import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
21
- import { resolveProfileContextId } from './browser/profile.js';
21
+ import { profileRouteParams, resolveProfileSelection } from './browser/profile.js';
22
22
  import { setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js';
23
23
  import { emitHook } from './hooks.js';
24
24
  import { log } from './logger.js';
@@ -211,7 +211,9 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
211
211
  }
212
212
  }
213
213
  const BrowserFactory = getBrowserFactory(cmd.site);
214
- const contextId = resolveProfileContextId(opts.profile);
214
+ const profileSelection = resolveProfileSelection(opts.profile);
215
+ const profileRouting = profileRouteParams(profileSelection);
216
+ const contextId = profileSelection?.contextId;
215
217
  const internal = cmd;
216
218
  const siteSession = resolveSiteSession(cmd, opts.siteSession);
217
219
  const session = resolveAdapterBrowserSession(cmd, siteSession);
@@ -332,7 +334,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
332
334
  await page.closeWindow?.().catch(() => { });
333
335
  throw err;
334
336
  }
335
- }, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession });
337
+ }, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession, freshPage: cmd.freshPage === true && siteSession === 'persistent' });
336
338
  }
337
339
  else {
338
340
  // Non-browser commands: enforce a timeout only when the command exposes
@@ -161,16 +161,34 @@ export function executeExternalCli(name, args, preloaded) {
161
161
  }
162
162
  }
163
163
  // 3. Passthrough execution with stdio inherited
164
- const result = spawnSync(cli.binary, args, { stdio: 'inherit' });
164
+ const result = spawnPassthrough(cli.binary, args);
165
165
  if (result.error) {
166
166
  log.error(`Failed to execute '${cli.binary}': ${result.error.message}`);
167
167
  process.exitCode = EXIT_CODES.GENERIC_ERROR;
168
168
  return;
169
169
  }
170
+ if (result.signal) {
171
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
172
+ return;
173
+ }
170
174
  if (result.status !== null) {
171
175
  process.exitCode = result.status;
172
176
  }
173
177
  }
178
+ function quoteForCmdShell(token) {
179
+ if (token !== '' && !/[\s"^&|<>%()]/.test(token))
180
+ return token;
181
+ return `"${token.replace(/"/g, '""')}"`;
182
+ }
183
+ function spawnPassthrough(binary, args) {
184
+ const direct = spawnSync(binary, args, { stdio: 'inherit' });
185
+ const errorCode = direct.error?.code;
186
+ if (os.platform() === 'win32' && (errorCode === 'EINVAL' || errorCode === 'ENOENT')) {
187
+ const command = [binary, ...args].map(quoteForCmdShell).join(' ');
188
+ return spawnSync(command, { stdio: 'inherit', shell: true });
189
+ }
190
+ return direct;
191
+ }
174
192
  export function registerExternalCli(name, opts) {
175
193
  const userPath = getUserRegistryPath();
176
194
  const configDir = path.dirname(userPath);
@@ -18,7 +18,8 @@ vi.mock('node:os', async () => {
18
18
  platform: mockPlatform,
19
19
  };
20
20
  });
21
- import { formatExternalCliLabel, installExternalCli, parseCommand } from './external.js';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { executeExternalCli, formatExternalCliLabel, installExternalCli, parseCommand } from './external.js';
22
23
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
24
  describe('parseCommand', () => {
24
25
  it('splits binaries and quoted arguments without invoking a shell', () => {
@@ -93,3 +94,38 @@ describe('installExternalCli', () => {
93
94
  expect(mockExecFileSync).toHaveBeenCalledTimes(1);
94
95
  });
95
96
  });
97
+ describe('executeExternalCli passthrough', () => {
98
+ const spawnMock = vi.mocked(spawnSync);
99
+ const cli = { name: 'tg', binary: 'tg', description: '' };
100
+ beforeEach(() => {
101
+ spawnMock.mockReset();
102
+ mockExecFileSync.mockReset();
103
+ mockExecFileSync.mockReturnValue(Buffer.from(''));
104
+ mockPlatform.mockReturnValue('darwin');
105
+ process.exitCode = undefined;
106
+ });
107
+ it('retries through the shell on Windows when the binary is a .cmd shim', () => {
108
+ mockPlatform.mockReturnValue('win32');
109
+ const einval = Object.assign(new Error('spawnSync tg EINVAL'), { code: 'EINVAL' });
110
+ spawnMock
111
+ .mockReturnValueOnce({ error: einval, status: null, signal: null })
112
+ .mockReturnValueOnce({ status: 0, signal: null });
113
+ executeExternalCli('tg', ['send', 'hello world', '--to', 'a"b'], [cli]);
114
+ expect(spawnMock).toHaveBeenCalledTimes(2);
115
+ expect(spawnMock).toHaveBeenNthCalledWith(1, 'tg', ['send', 'hello world', '--to', 'a"b'], { stdio: 'inherit' });
116
+ expect(spawnMock).toHaveBeenNthCalledWith(2, 'tg send "hello world" --to "a""b"', { stdio: 'inherit', shell: true });
117
+ expect(process.exitCode).toBe(0);
118
+ });
119
+ it('does not retry through the shell on non-Windows platforms', () => {
120
+ const einval = Object.assign(new Error('spawnSync tg EINVAL'), { code: 'EINVAL' });
121
+ spawnMock.mockReturnValueOnce({ error: einval, status: null, signal: null });
122
+ executeExternalCli('tg', [], [cli]);
123
+ expect(spawnMock).toHaveBeenCalledTimes(1);
124
+ expect(process.exitCode).toBe(1);
125
+ });
126
+ it('reports a non-zero exit code when the child dies from a signal', () => {
127
+ spawnMock.mockReturnValueOnce({ status: null, signal: 'SIGKILL' });
128
+ executeExternalCli('tg', [], [cli]);
129
+ expect(process.exitCode).toBe(1);
130
+ });
131
+ });
package/dist/src/main.js CHANGED
@@ -21,7 +21,6 @@ import { findPackageRoot, getCliManifestPath } from './package-paths.js';
21
21
  import { PKG_VERSION } from './version.js';
22
22
  import { EXIT_CODES } from './errors.js';
23
23
  import { isSupportedNodeVersion, MIN_SUPPORTED_NODE_MAJOR } from './runtime-detect.js';
24
- import { unsupportedDaemonPortEnvMessage } from './constants.js';
25
24
  import { CONFIG_DIR_NAME } from './brand.js';
26
25
  const __filename = fileURLToPath(import.meta.url);
27
26
  const __dirname = path.dirname(__filename);
@@ -41,10 +40,6 @@ if (typeof globalThis.Bun === 'undefined' && !isSupportedNodeVersion(process.ver
41
40
  ].join('\n'));
42
41
  process.exit(EXIT_CODES.CONFIG_ERROR);
43
42
  }
44
- if (process.env.WEBCMD_DAEMON_PORT) {
45
- process.stderr.write(`error: ${unsupportedDaemonPortEnvMessage(process.env.WEBCMD_DAEMON_PORT)}\n`);
46
- process.exit(EXIT_CODES.CONFIG_ERROR);
47
- }
48
43
  // Fast path: --version (only when it's the top-level intent, not passed to a subcommand)
49
44
  // e.g. `webcmd --version` or `webcmd -V`, but NOT `webcmd gh --version`
50
45
  if (argv[0] === '--version' || argv[0] === '-V') {
@@ -38,6 +38,8 @@ export interface ManifestEntry {
38
38
  navigateBefore?: boolean | string;
39
39
  /** Site session lifecycle defaults — see CliCommand.siteSession */
40
40
  siteSession?: 'ephemeral' | 'persistent';
41
+ /** Fresh page behavior for persistent site sessions — see CliCommand.freshPage */
42
+ freshPage?: boolean;
41
43
  /** Default browser window visibility — see CliCommand.defaultWindowMode */
42
44
  defaultWindowMode?: 'foreground' | 'background';
43
45
  }
@@ -22,9 +22,9 @@ describe('node network proxy decisions', () => {
22
22
  });
23
23
  it('bypasses proxies for loopback addresses', () => {
24
24
  const env = { https_proxy: 'http://127.0.0.1:7897', http_proxy: 'http://127.0.0.1:7897' };
25
- expect(decideProxy(new URL('http://127.0.0.1:19825/status'), env)).toEqual({ mode: 'direct' });
26
- expect(decideProxy(new URL('http://localhost:19825/status'), env)).toEqual({ mode: 'direct' });
27
- expect(decideProxy(new URL('http://[::1]:19825/status'), env)).toEqual({ mode: 'direct' });
25
+ expect(decideProxy(new URL('http://127.0.0.1:9777/status'), env)).toEqual({ mode: 'direct' });
26
+ expect(decideProxy(new URL('http://localhost:9777/status'), env)).toEqual({ mode: 'direct' });
27
+ expect(decideProxy(new URL('http://[::1]:9777/status'), env)).toEqual({ mode: 'direct' });
28
28
  });
29
29
  it('honors NO_PROXY domain matches', () => {
30
30
  const decision = decideProxy(new URL('https://api.example.com/v1/items'), {
@@ -63,6 +63,16 @@ interface BaseCliCommand {
63
63
  navigateBefore?: boolean | string;
64
64
  /** Site session lifecycle for adapter commands. */
65
65
  siteSession?: SiteSessionMode;
66
+ /**
67
+ * Start this command on a freshly created page even when the persistent
68
+ * site session already has a leased tab. The old tab is closed and a new
69
+ * one opened under the same lease, so profile state (cookies, localStorage,
70
+ * login, location) survives while stale DOM (leftover modals, drawers,
71
+ * half-finished flows from earlier commands) is discarded. Requires
72
+ * `siteSession: 'persistent'` — ephemeral sessions always start fresh, so
73
+ * combining them is a contradiction and fails at registration.
74
+ */
75
+ freshPage?: boolean;
66
76
  /** Default browser window mode for commands whose UX requires visibility. */
67
77
  defaultWindowMode?: 'foreground' | 'background';
68
78
  /** Override the default CLI output format when the user does not pass -f/--format. */
@@ -29,6 +29,7 @@ export function cli(opts) {
29
29
  validateArgs: opts.validateArgs,
30
30
  navigateBefore: opts.navigateBefore,
31
31
  siteSession: opts.siteSession,
32
+ freshPage: opts.freshPage,
32
33
  defaultWindowMode: opts.defaultWindowMode,
33
34
  defaultFormat: opts.defaultFormat,
34
35
  authStatus: opts.authStatus,
@@ -86,12 +87,13 @@ function assertCommandAccess(cmd) {
86
87
  throw new Error(`Command ${key} must declare access: 'read' | 'write'`);
87
88
  }
88
89
  function assertSiteSession(cmd) {
89
- if (cmd.siteSession === undefined)
90
- return;
91
90
  const key = `${cmd.site}/${cmd.name}`;
92
- if (cmd.siteSession !== 'ephemeral' && cmd.siteSession !== 'persistent') {
91
+ if (cmd.siteSession !== undefined && cmd.siteSession !== 'ephemeral' && cmd.siteSession !== 'persistent') {
93
92
  throw new Error(`Command ${key} siteSession must be one of: ephemeral, persistent`);
94
93
  }
94
+ if (cmd.freshPage === true && cmd.siteSession !== 'persistent') {
95
+ throw new Error(`Command ${key} freshPage requires siteSession: 'persistent' — ephemeral sessions already start on a fresh page`);
96
+ }
95
97
  }
96
98
  export function registerCommand(cmd) {
97
99
  const normalized = normalizeCommand(cmd);
@@ -19,6 +19,31 @@ describe('cli() registration', () => {
19
19
  expect(cmd.browser).toBe(false);
20
20
  expect(cmd.args).toEqual([]);
21
21
  });
22
+ it('accepts freshPage with a persistent site session', () => {
23
+ const cmd = cli({
24
+ site: 'test-registry',
25
+ name: 'fresh-ok', access: 'write',
26
+ description: 'test',
27
+ siteSession: 'persistent',
28
+ freshPage: true,
29
+ });
30
+ expect(cmd.freshPage).toBe(true);
31
+ });
32
+ it('rejects freshPage without a persistent site session', () => {
33
+ expect(() => cli({
34
+ site: 'test-registry',
35
+ name: 'fresh-bad', access: 'write',
36
+ description: 'test',
37
+ freshPage: true,
38
+ })).toThrow(/freshPage requires siteSession: 'persistent'/);
39
+ expect(() => cli({
40
+ site: 'test-registry',
41
+ name: 'fresh-bad-ephemeral', access: 'write',
42
+ description: 'test',
43
+ siteSession: 'ephemeral',
44
+ freshPage: true,
45
+ })).toThrow(/freshPage requires siteSession: 'persistent'/);
46
+ });
22
47
  it('puts registered command in the registry', () => {
23
48
  cli({
24
49
  site: 'test-registry',
@@ -1,6 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import * as path from 'node:path';
3
- import { unsupportedDaemonPortEnvMessage } from './constants.js';
4
3
  import { getUserWebcmdDir, getUserClisDir, getPluginsDir } from './discovery.js';
5
4
  describe('webcmd runtime identity', () => {
6
5
  it('uses webcmd runtime directories', async () => {
@@ -8,11 +7,4 @@ describe('webcmd runtime identity', () => {
8
7
  expect(getUserClisDir('/home/tester')).toBe(path.join('/home/tester', '.webcmd', 'clis'));
9
8
  expect(getPluginsDir('/home/tester')).toBe(path.join('/home/tester', '.webcmd', 'plugins'));
10
9
  });
11
- it('reports unsupported daemon port with WEBCMD env names', () => {
12
- const legacyEnvName = ['OPEN', 'CLI_DAEMON_PORT'].join('');
13
- expect(unsupportedDaemonPortEnvMessage('1234')).toContain('WEBCMD_DAEMON_PORT');
14
- expect(unsupportedDaemonPortEnvMessage('1234')).toContain('Webcmd');
15
- expect(unsupportedDaemonPortEnvMessage('1234')).toContain('rerun webcmd');
16
- expect(unsupportedDaemonPortEnvMessage('1234')).not.toContain(legacyEnvName);
17
- });
18
10
  });
@@ -29,10 +29,12 @@ export interface IBrowserFactory {
29
29
  session?: string;
30
30
  cdpEndpoint?: string;
31
31
  contextId?: string;
32
+ preferredContextId?: string;
32
33
  idleTimeout?: number;
33
34
  windowMode?: BrowserWindowMode;
34
35
  surface?: BrowserSurface;
35
36
  siteSession?: 'ephemeral' | 'persistent';
37
+ freshPage?: boolean;
36
38
  }): Promise<IPage>;
37
39
  close(): Promise<void>;
38
40
  }
@@ -40,8 +42,10 @@ export declare function browserSession<T>(BrowserFactory: new () => IBrowserFact
40
42
  session?: string;
41
43
  cdpEndpoint?: string;
42
44
  contextId?: string;
45
+ preferredContextId?: string;
43
46
  idleTimeout?: number;
44
47
  windowMode?: BrowserWindowMode;
45
48
  surface?: BrowserSurface;
46
49
  siteSession?: 'ephemeral' | 'persistent';
50
+ freshPage?: boolean;
47
51
  }): Promise<T>;
@@ -41,10 +41,12 @@ export async function browserSession(BrowserFactory, fn, opts = {}) {
41
41
  session: opts.session,
42
42
  cdpEndpoint: opts.cdpEndpoint,
43
43
  contextId: opts.contextId,
44
+ preferredContextId: opts.preferredContextId,
44
45
  idleTimeout: opts.idleTimeout,
45
46
  windowMode: opts.windowMode,
46
47
  surface: opts.surface,
47
48
  siteSession: opts.siteSession,
49
+ freshPage: opts.freshPage,
48
50
  });
49
51
  return await fn(page);
50
52
  }
@@ -4,11 +4,29 @@ export interface WebcmdSkillInfo {
4
4
  version: string;
5
5
  path: string;
6
6
  }
7
- export interface WebcmdSkillReadResult {
8
- skill: string;
9
- path: string;
10
- content: string;
7
+ export interface WebcmdSkillInstallOptions {
8
+ provider?: string;
9
+ scope?: string;
10
+ customPath?: string;
11
+ packageRoot?: string;
12
+ homeDir?: string;
13
+ cwd?: string;
14
+ }
15
+ export interface WebcmdSkillLink {
16
+ name: string;
17
+ source: string;
18
+ stableLink: string;
19
+ destination?: string;
20
+ }
21
+ export interface WebcmdSkillInstallResult {
22
+ provider?: SkillProvider;
23
+ scope?: SkillScope;
24
+ skills: WebcmdSkillLink[];
11
25
  }
26
+ type SkillProvider = 'agents' | 'codex' | 'claude';
27
+ type SkillScope = 'user' | 'project';
12
28
  export declare function getSkillsRoot(packageRoot?: string): string;
13
29
  export declare function listWebcmdSkills(packageRoot?: string): WebcmdSkillInfo[];
14
- export declare function readWebcmdSkill(target: string, relpath?: string, packageRoot?: string): WebcmdSkillReadResult;
30
+ export declare function installWebcmdSkill(options?: WebcmdSkillInstallOptions): WebcmdSkillInstallResult;
31
+ export declare function updateWebcmdSkill(options?: WebcmdSkillInstallOptions): WebcmdSkillInstallResult;
32
+ export {};