@agentrhq/webcmd 0.2.1 → 0.2.3

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 (100) hide show
  1. package/README.md +39 -24
  2. package/cli-manifest.json +2308 -650
  3. package/clis/_shared/site-auth.js +6 -1
  4. package/clis/bigbasket/add-to-cart.js +82 -0
  5. package/clis/bigbasket/bigbasket.test.js +255 -0
  6. package/clis/bigbasket/cart.js +81 -0
  7. package/clis/bigbasket/category.js +30 -0
  8. package/clis/bigbasket/checkout.js +71 -0
  9. package/clis/bigbasket/location.js +30 -0
  10. package/clis/bigbasket/product.js +79 -0
  11. package/clis/bigbasket/search.js +30 -0
  12. package/clis/bigbasket/utils.js +207 -0
  13. package/clis/blinkit/add-to-cart.js +123 -0
  14. package/clis/blinkit/auth.js +99 -0
  15. package/clis/blinkit/blinkit.test.js +168 -0
  16. package/clis/blinkit/cart.js +34 -0
  17. package/clis/blinkit/checkout.js +32 -0
  18. package/clis/blinkit/location.js +29 -0
  19. package/clis/blinkit/place-order.js +78 -0
  20. package/clis/blinkit/product.js +63 -0
  21. package/clis/blinkit/search.js +89 -0
  22. package/clis/blinkit/utils.js +223 -0
  23. package/clis/district/_lib.js +566 -0
  24. package/clis/district/auth.js +49 -0
  25. package/clis/district/checkout.js +348 -0
  26. package/clis/district/listings.js +158 -0
  27. package/clis/district/locations.js +211 -0
  28. package/clis/district/search.js +218 -0
  29. package/clis/district/seats.js +233 -0
  30. package/clis/district/set-location.js +82 -0
  31. package/clis/district/showtimes.js +433 -0
  32. package/clis/practo/appointment.js +21 -0
  33. package/clis/practo/appointments.js +27 -0
  34. package/clis/practo/book-confirm.js +35 -0
  35. package/clis/practo/book-preview.js +24 -0
  36. package/clis/practo/booking-link.js +24 -0
  37. package/clis/practo/cancel.js +29 -0
  38. package/clis/practo/contact.js +21 -0
  39. package/clis/practo/login.js +21 -0
  40. package/clis/practo/practo.test.js +136 -0
  41. package/clis/practo/profile.js +31 -0
  42. package/clis/practo/search.js +30 -0
  43. package/clis/practo/slots.js +19 -0
  44. package/clis/practo/utils.js +374 -0
  45. package/clis/practo/whoami.js +28 -0
  46. package/clis/reddit/popular.js +12 -1
  47. package/clis/reddit/popular.test.js +12 -3
  48. package/clis/zepto/add-to-cart.js +53 -0
  49. package/clis/zepto/auth.js +59 -0
  50. package/clis/zepto/cart.js +23 -0
  51. package/clis/zepto/checkout.js +60 -0
  52. package/clis/zepto/location.js +20 -0
  53. package/clis/zepto/place-order.js +47 -0
  54. package/clis/zepto/product.js +52 -0
  55. package/clis/zepto/search.js +30 -0
  56. package/clis/zepto/utils.js +228 -0
  57. package/clis/zepto/zepto.test.js +238 -0
  58. package/dist/src/browser/bridge.d.ts +1 -0
  59. package/dist/src/browser/bridge.js +1 -1
  60. package/dist/src/browser/page.d.ts +4 -1
  61. package/dist/src/browser/page.js +12 -1
  62. package/dist/src/browser/protocol.d.ts +2 -0
  63. package/dist/src/browser/runtime/local-cloak/actions.js +1 -0
  64. package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +2 -0
  65. package/dist/src/browser/runtime/local-cloak/session-manager.js +12 -2
  66. package/dist/src/browser/runtime/local-cloak/session-manager.test.js +58 -0
  67. package/dist/src/build-manifest.js +1 -0
  68. package/dist/src/build-manifest.test.js +34 -0
  69. package/dist/src/cli.js +111 -28
  70. package/dist/src/discovery.js +1 -0
  71. package/dist/src/engine.test.js +62 -0
  72. package/dist/src/execution.js +1 -1
  73. package/dist/src/generate-release-notes-cli.test.js +55 -1
  74. package/dist/src/manifest-types.d.ts +2 -0
  75. package/dist/src/registry.d.ts +10 -0
  76. package/dist/src/registry.js +5 -3
  77. package/dist/src/registry.test.js +25 -0
  78. package/dist/src/release-notes.d.ts +3 -1
  79. package/dist/src/release-notes.js +44 -1
  80. package/dist/src/release-notes.test.js +39 -1
  81. package/dist/src/runtime.d.ts +2 -0
  82. package/dist/src/runtime.js +1 -0
  83. package/dist/src/skills.d.ts +23 -5
  84. package/dist/src/skills.js +87 -45
  85. package/dist/src/skills.test.js +80 -23
  86. package/package.json +2 -2
  87. package/scripts/generate-release-notes.ts +31 -0
  88. package/skills/smart-search/SKILL.md +156 -0
  89. package/skills/smart-search/references/sources-ai.md +74 -0
  90. package/skills/smart-search/references/sources-info.md +43 -0
  91. package/skills/smart-search/references/sources-media.md +40 -0
  92. package/skills/smart-search/references/sources-other.md +32 -0
  93. package/skills/smart-search/references/sources-shopping.md +21 -0
  94. package/skills/smart-search/references/sources-social.md +36 -0
  95. package/skills/smart-search/references/sources-tech.md +38 -0
  96. package/skills/smart-search/references/sources-travel.md +26 -0
  97. package/skills/webcmd-adapter-author/SKILL.md +10 -0
  98. package/skills/webcmd-adapter-author/references/adapter-template.md +2 -0
  99. package/skills/webcmd-autofix/SKILL.md +8 -0
  100. package/skills/webcmd-sitemap-author/SKILL.md +1 -1
@@ -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,
@@ -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');
@@ -334,7 +334,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
334
334
  await page.closeWindow?.().catch(() => { });
335
335
  throw err;
336
336
  }
337
- }, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession });
337
+ }, { session, cdpEndpoint, ...profileRouting, windowMode, surface: 'adapter', siteSession, freshPage: cmd.freshPage === true && siteSession === 'persistent' });
338
338
  }
339
339
  else {
340
340
  // Non-browser commands: enforce a timeout only when the command exposes
@@ -1,4 +1,6 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import { fileURLToPath } from 'node:url';
3
5
  import { describe, expect, it, vi } from 'vitest';
4
6
  import { loadReleaseContext, runGenerateReleaseNotes } from '../scripts/generate-release-notes.js';
@@ -72,6 +74,9 @@ describe('runGenerateReleaseNotes', () => {
72
74
  '## Fixes',
73
75
  'None.',
74
76
  '',
77
+ '## Adapters',
78
+ 'None.',
79
+ '',
75
80
  '## Contributors',
76
81
  '- @alice',
77
82
  '',
@@ -94,6 +99,52 @@ describe('runGenerateReleaseNotes', () => {
94
99
  stderr: 'Gemini release notes failed: gh timed out\n',
95
100
  });
96
101
  });
102
+ it('updates the matching changelog entry from an existing notes file', async () => {
103
+ const { io, read } = createIo();
104
+ const tempDir = mkdtempSync(join(tmpdir(), 'webcmd-release-notes-'));
105
+ const notesPath = join(tempDir, 'release-notes.md');
106
+ const changelogPath = join(tempDir, 'CHANGELOG.md');
107
+ try {
108
+ writeFileSync(notesPath, [
109
+ '## Highlights',
110
+ '- Better generated notes.',
111
+ '',
112
+ '## Adapters',
113
+ '- Added checkout adapter polish.',
114
+ '',
115
+ ].join('\n'));
116
+ writeFileSync(changelogPath, [
117
+ '# Changelog',
118
+ '',
119
+ '## [0.2.3](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.2...webcmd-v0.2.3) (2026-07-09)',
120
+ '',
121
+ '### Features',
122
+ '',
123
+ '* release-please generated note',
124
+ '',
125
+ '## [0.2.2](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.1...webcmd-v0.2.2) (2026-07-08)',
126
+ '',
127
+ '### Bug Fixes',
128
+ '',
129
+ '* older note',
130
+ '',
131
+ ].join('\n'));
132
+ const exitCode = await runGenerateReleaseNotes(['node', 'script', '--update-changelog', 'webcmd-v0.2.3', notesPath, changelogPath], {}, {}, io);
133
+ expect(exitCode).toBe(0);
134
+ expect(read()).toEqual({
135
+ stdout: `Updated ${changelogPath} for webcmd-v0.2.3\n`,
136
+ stderr: '',
137
+ });
138
+ const changelog = readFileSync(changelogPath, 'utf8');
139
+ expect(changelog).toContain('### Highlights\n- Better generated notes.');
140
+ expect(changelog).toContain('### Adapters\n- Added checkout adapter polish.');
141
+ expect(changelog).not.toContain('release-please generated note');
142
+ expect(changelog).toContain('* older note');
143
+ }
144
+ finally {
145
+ rmSync(tempDir, { recursive: true, force: true });
146
+ }
147
+ });
97
148
  it('loads bounded PR diffs into release context', async () => {
98
149
  const gh = vi.fn((args) => {
99
150
  const key = args.join(' ');
@@ -134,6 +185,9 @@ describe('runGenerateReleaseNotes', () => {
134
185
  it('keeps npm publish unblocked when enhanced release-note editing fails', () => {
135
186
  const workflowPath = fileURLToPath(new URL('../.github/workflows/release.yml', import.meta.url));
136
187
  const workflow = readFileSync(workflowPath, 'utf8');
188
+ expect(workflow.indexOf('- name: Publish to npm')).toBeLessThan(workflow.indexOf('- name: Update changelog with enhanced release notes'));
189
+ expect(workflow).toContain('npm --silent run generate-release-notes -- --update-changelog');
190
+ expect(workflow).toContain('git push origin "HEAD:${{ github.ref_name }}"');
137
191
  expect(workflow).toMatch(/if gh release edit "\$\{\{ steps\.release\.outputs\.tag_name \}\}" --notes-file "\$RUNNER_TEMP\/release-notes\.md"; then/);
138
192
  expect(workflow).toMatch(/Enhanced release notes could not be applied; keeping release-please notes\./);
139
193
  });
@@ -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
  }
@@ -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,4 +1,4 @@
1
- export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "Contributors", "Reverts"];
1
+ export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "Adapters", "Contributors", "Reverts"];
2
2
  export type ReleaseNoteSection = typeof RELEASE_NOTE_SECTIONS[number];
3
3
  export interface PullRequestLabel {
4
4
  name: string;
@@ -32,6 +32,8 @@ export interface CompareCommit {
32
32
  author?: string | null;
33
33
  }
34
34
  export type GitRunner = (args: readonly string[]) => Promise<string>;
35
+ export declare function releaseVersionFromTag(tag: string): string;
36
+ export declare function replaceChangelogReleaseNotes(changelog: string, tag: string, notes: string): string;
35
37
  export declare function extractPullRequestNumber(message: string): number | null;
36
38
  export declare function filterReleasePullRequests(prs: PullRequestDetails[]): PullRequestDetails[];
37
39
  export declare function normalizeReleaseNotes(raw: string, contributors: string[]): string;
@@ -1,4 +1,11 @@
1
- export const RELEASE_NOTE_SECTIONS = ['Highlights', 'Improvements', 'Fixes', 'Contributors', 'Reverts'];
1
+ export const RELEASE_NOTE_SECTIONS = [
2
+ 'Highlights',
3
+ 'Improvements',
4
+ 'Fixes',
5
+ 'Adapters',
6
+ 'Contributors',
7
+ 'Reverts',
8
+ ];
2
9
  const SQUASH_MERGE_PR_NUMBER_PATTERN = /\(#(?<number>\d+)\)\s*$/;
3
10
  const MERGE_COMMIT_PR_NUMBER_PATTERN = /^Merge pull request #(?<number>\d+) /;
4
11
  const RELEASE_PLEASE_TITLE_PATTERN = /^chore(?:\([^)]+\))?: release(?:\s|$)/;
@@ -13,6 +20,39 @@ function formatSectionContent(content) {
13
20
  const trimmed = content?.trim();
14
21
  return trimmed && trimmed.length > 0 ? trimmed : 'None.';
15
22
  }
23
+ function escapeRegExp(value) {
24
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
25
+ }
26
+ function formatReleaseNotesForChangelog(notes) {
27
+ const trimmed = notes.trim();
28
+ if (!trimmed)
29
+ return '### Highlights\nNone.';
30
+ return trimmed.replace(/^##\s+/gm, '### ');
31
+ }
32
+ export function releaseVersionFromTag(tag) {
33
+ const value = tag.trim();
34
+ if (value.startsWith('webcmd-v'))
35
+ return value.slice('webcmd-v'.length);
36
+ if (value.startsWith('v'))
37
+ return value.slice(1);
38
+ return value;
39
+ }
40
+ export function replaceChangelogReleaseNotes(changelog, tag, notes) {
41
+ const version = releaseVersionFromTag(tag);
42
+ const headingPattern = new RegExp(`^## \\[${escapeRegExp(version)}\\]\\([^\\n]+\\) \\([^\\n]+\\)\\s*$`, 'm');
43
+ const headingMatch = headingPattern.exec(changelog);
44
+ if (!headingMatch) {
45
+ throw new Error(`Could not find CHANGELOG.md entry for ${version}`);
46
+ }
47
+ const headingEnd = headingMatch.index + headingMatch[0].length;
48
+ const remaining = changelog.slice(headingEnd);
49
+ const nextReleaseMatch = /\n## \[/.exec(remaining);
50
+ const releaseEnd = nextReleaseMatch ? headingEnd + nextReleaseMatch.index : changelog.length;
51
+ const before = changelog.slice(0, headingEnd).trimEnd();
52
+ const after = changelog.slice(releaseEnd);
53
+ const suffix = after ? after.replace(/^\n+/, '\n\n') : '\n';
54
+ return `${before}\n\n${formatReleaseNotesForChangelog(notes)}${suffix}`;
55
+ }
16
56
  export function extractPullRequestNumber(message) {
17
57
  const firstLine = message.split(/\r?\n/, 1)[0] ?? message;
18
58
  const squashMatch = firstLine.match(SQUASH_MERGE_PR_NUMBER_PATTERN);
@@ -92,6 +132,9 @@ export function buildReleaseNotesPrompt(context) {
92
132
  'Use only the supplied pull requests below. Do not invent changes or pull in information from elsewhere.',
93
133
  `Required sections: ${RELEASE_NOTE_SECTIONS.map((section) => `## ${section}`).join(', ')}.`,
94
134
  'Each section must be present in the final notes.',
135
+ 'In this project, CLI commands and adapters are the same thing. Treat any PR that adds, removes, or changes files under clis/** as an adapter change, even if the PR title says "CLI" instead of "adapter".',
136
+ 'Put new site adapters/CLIs, adapter promotions, adapter hardening, adapter output changes, selector/API updates, and site-specific workflow improvements in ## Adapters.',
137
+ 'Use ## Improvements for non-adapter product, runtime, CLI, docs, or workflow improvements.',
95
138
  '',
96
139
  `Pull requests included for this release:`,
97
140
  prSummaries,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { RELEASE_NOTE_SECTIONS, buildReleaseNotesPrompt, extractPullRequestNumber, filterReleasePullRequests, normalizeReleaseNotes, } from './release-notes.js';
2
+ import { RELEASE_NOTE_SECTIONS, buildReleaseNotesPrompt, extractPullRequestNumber, filterReleasePullRequests, normalizeReleaseNotes, replaceChangelogReleaseNotes, } from './release-notes.js';
3
3
  describe('release notes helpers', () => {
4
4
  it('extracts PR numbers from squash and merge commit messages', () => {
5
5
  expect(extractPullRequestNumber('feat: add release notes (#123)')).toBe(123);
@@ -29,10 +29,44 @@ describe('release notes helpers', () => {
29
29
  for (const section of RELEASE_NOTE_SECTIONS) {
30
30
  expect(normalized).toContain(`## ${section}`);
31
31
  }
32
+ expect(normalized).toContain('## Adapters\nNone.');
32
33
  expect(normalized).toContain('## Improvements\nNone.');
33
34
  expect(normalized).toContain('## Contributors\n- @alice\n- @bob');
34
35
  expect(normalized).toContain('## Reverts\nNone.');
35
36
  });
37
+ it('replaces a matching changelog release entry with generated notes', () => {
38
+ const changelog = [
39
+ '# Changelog',
40
+ '',
41
+ '## [0.2.3](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.2...webcmd-v0.2.3) (2026-07-09)',
42
+ '',
43
+ '',
44
+ '### Features',
45
+ '',
46
+ '* release-please generated note',
47
+ '',
48
+ '## [0.2.2](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.1...webcmd-v0.2.2) (2026-07-08)',
49
+ '',
50
+ '### Bug Fixes',
51
+ '',
52
+ '* older note',
53
+ '',
54
+ ].join('\n');
55
+ const notes = [
56
+ '## Highlights',
57
+ '- Better release notes.',
58
+ '',
59
+ '## Adapters',
60
+ '- Improved district checkout.',
61
+ ].join('\n');
62
+ const updated = replaceChangelogReleaseNotes(changelog, 'webcmd-v0.2.3', notes);
63
+ expect(updated).toContain('## [0.2.3]');
64
+ expect(updated).toContain('### Highlights\n- Better release notes.');
65
+ expect(updated).toContain('### Adapters\n- Improved district checkout.');
66
+ expect(updated).not.toContain('release-please generated note');
67
+ expect(updated).toContain('## [0.2.2]');
68
+ expect(updated).toContain('* older note');
69
+ });
36
70
  it('builds a prompt grounded in the exact release range and PR list', () => {
37
71
  const context = {
38
72
  tag: 'v0.2.0',
@@ -56,6 +90,10 @@ describe('release notes helpers', () => {
56
90
  expect(prompt).toContain('PR #42: feat: add docs scaffold');
57
91
  expect(prompt).toContain('docs/docs.json');
58
92
  expect(prompt).toContain('## Highlights');
93
+ expect(prompt).toContain('## Adapters');
94
+ expect(prompt).toContain('CLI commands and adapters are the same thing');
95
+ expect(prompt).toContain('files under clis/** as an adapter change');
96
+ expect(prompt).toContain('Put new site adapters/CLIs, adapter promotions, adapter hardening');
59
97
  expect(prompt).toContain('## Reverts');
60
98
  });
61
99
  });
@@ -34,6 +34,7 @@ export interface IBrowserFactory {
34
34
  windowMode?: BrowserWindowMode;
35
35
  surface?: BrowserSurface;
36
36
  siteSession?: 'ephemeral' | 'persistent';
37
+ freshPage?: boolean;
37
38
  }): Promise<IPage>;
38
39
  close(): Promise<void>;
39
40
  }
@@ -46,4 +47,5 @@ export declare function browserSession<T>(BrowserFactory: new () => IBrowserFact
46
47
  windowMode?: BrowserWindowMode;
47
48
  surface?: BrowserSurface;
48
49
  siteSession?: 'ephemeral' | 'persistent';
50
+ freshPage?: boolean;
49
51
  }): Promise<T>;
@@ -46,6 +46,7 @@ export async function browserSession(BrowserFactory, fn, opts = {}) {
46
46
  windowMode: opts.windowMode,
47
47
  surface: opts.surface,
48
48
  siteSession: opts.siteSession,
49
+ freshPage: opts.freshPage,
49
50
  });
50
51
  return await fn(page);
51
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 {};
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
2
3
  import * as path from 'node:path';
3
4
  import { fileURLToPath } from 'node:url';
4
5
  import yaml from 'js-yaml';
@@ -13,35 +14,100 @@ export function listWebcmdSkills(packageRoot) {
13
14
  if (!fs.existsSync(skillsRoot))
14
15
  return [];
15
16
  return fs.readdirSync(skillsRoot, { withFileTypes: true })
16
- .filter((entry) => entry.isDirectory() && entry.name.startsWith('webcmd-'))
17
+ .filter((entry) => entry.isDirectory())
17
18
  .map((entry) => readSkillInfo(skillsRoot, entry.name))
18
19
  .filter((entry) => entry !== null)
19
20
  .sort((a, b) => a.name.localeCompare(b.name));
20
21
  }
21
- export function readWebcmdSkill(target, relpath = '', packageRoot) {
22
- const { name, pathInSkill } = parseSkillTarget(target, relpath);
23
- if (!name.startsWith('webcmd-')) {
24
- throw new ArgumentError(`Unknown Webcmd skill: ${name}`, 'Run "webcmd skills list" to see available Webcmd skills.');
22
+ export function installWebcmdSkill(options = {}) {
23
+ const provider = options.customPath === undefined ? normalizeProvider(options.provider) : undefined;
24
+ const scope = normalizeScope(options.scope);
25
+ const skills = updateStableSkillLinks(options)
26
+ .map((skill) => {
27
+ const destination = destinationFor(skill.name, provider, scope, options);
28
+ replaceDirectorySymlink(skill.stableLink, destination);
29
+ return { ...skill, destination };
30
+ });
31
+ return { provider, scope, skills };
32
+ }
33
+ export function updateWebcmdSkill(options = {}) {
34
+ const skills = updateStableSkillLinks(options);
35
+ if (options.provider === undefined && options.scope === undefined && options.customPath === undefined)
36
+ return { skills };
37
+ const provider = options.customPath === undefined ? normalizeProvider(options.provider) : undefined;
38
+ const scope = normalizeScope(options.scope);
39
+ return {
40
+ provider,
41
+ scope,
42
+ skills: skills.map((skill) => {
43
+ const destination = destinationFor(skill.name, provider, scope, options);
44
+ replaceDirectorySymlink(skill.stableLink, destination);
45
+ return { ...skill, destination };
46
+ }),
47
+ };
48
+ }
49
+ function updateStableSkillLinks(options) {
50
+ const skillsRoot = getSkillsRoot(options.packageRoot);
51
+ const skills = listWebcmdSkills(options.packageRoot);
52
+ if (skills.length === 0) {
53
+ throw new ArgumentError(`No Webcmd skills found: ${skillsRoot}`, 'Install a package that includes skills/*/SKILL.md.');
25
54
  }
26
- const skillsRoot = getSkillsRoot(packageRoot);
27
- const skillRoot = path.join(skillsRoot, name);
28
- if (!isDirectory(skillRoot) || !fs.existsSync(path.join(skillRoot, 'SKILL.md'))) {
29
- throw new ArgumentError(`Unknown Webcmd skill: ${name}`, 'Run "webcmd skills list" to see available Webcmd skills.');
55
+ return skills.map((skill) => {
56
+ const source = path.join(skillsRoot, skill.name);
57
+ const stableLink = path.join(options.homeDir ?? os.homedir(), '.webcmd', 'skills', skill.name);
58
+ replaceDirectorySymlink(source, stableLink);
59
+ return { name: skill.name, source, stableLink };
60
+ });
61
+ }
62
+ function destinationFor(name, provider, scope, options) {
63
+ if (options.customPath !== undefined)
64
+ return path.join(expandHomePath(options.customPath), name);
65
+ const base = scope === 'project' ? options.cwd ?? process.cwd() : options.homeDir ?? os.homedir();
66
+ const agentDir = provider === 'claude' ? '.claude' : provider === 'codex' ? '.codex' : '.agents';
67
+ return path.join(base, agentDir, 'skills', name);
68
+ }
69
+ function expandHomePath(raw) {
70
+ const value = raw.trim();
71
+ if (!value)
72
+ throw new ArgumentError('Custom skills path must be non-empty.');
73
+ return path.resolve(value === '~' ? os.homedir() : value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value);
74
+ }
75
+ function normalizeProvider(raw = 'agents') {
76
+ const value = raw.trim().toLowerCase();
77
+ if (value === 'agents' || value === 'codex')
78
+ return value;
79
+ if (value === 'claude' || value === 'claude-code' || value === 'claude_code')
80
+ return 'claude';
81
+ throw new ArgumentError(`Unsupported skill provider: ${raw}`, 'Use one of: agents, codex, claude.');
82
+ }
83
+ function normalizeScope(raw = 'user') {
84
+ const value = raw.trim().toLowerCase();
85
+ if (value === 'user' || value === 'global')
86
+ return 'user';
87
+ if (value === 'project' || value === 'local')
88
+ return 'project';
89
+ throw new ArgumentError(`Unsupported skill scope: ${raw}`, 'Use one of: user, global, project, local.');
90
+ }
91
+ function replaceDirectorySymlink(target, linkPath) {
92
+ const current = safeLstat(linkPath);
93
+ if (current) {
94
+ if (!current.isSymbolicLink()) {
95
+ throw new ArgumentError(`Refusing to replace non-symlink path: ${linkPath}`, 'Remove it manually or choose a different scope/provider.');
96
+ }
97
+ fs.unlinkSync(linkPath);
30
98
  }
31
- const relativePath = normalizeSkillPath(pathInSkill || 'SKILL.md');
32
- const absolutePath = path.resolve(skillRoot, relativePath);
33
- const relativeToRoot = path.relative(skillRoot, absolutePath);
34
- if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) {
35
- throw new ArgumentError(`Invalid skill path: ${relativePath}`, 'Skill paths must stay inside the selected Webcmd skill.');
99
+ fs.mkdirSync(path.dirname(linkPath), { recursive: true });
100
+ fs.symlinkSync(target, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
101
+ }
102
+ function safeLstat(filePath) {
103
+ try {
104
+ return fs.lstatSync(filePath);
36
105
  }
37
- if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
38
- throw new ArgumentError(`Skill file not found: ${name}/${relativePath}`, 'Run "webcmd skills list <skill>" is not supported yet; read SKILL.md or a known references/... file.');
106
+ catch (err) {
107
+ if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT')
108
+ return null;
109
+ throw err;
39
110
  }
40
- return {
41
- skill: name,
42
- path: relativePath,
43
- content: fs.readFileSync(absolutePath, 'utf8'),
44
- };
45
111
  }
46
112
  function readSkillInfo(skillsRoot, name) {
47
113
  const skillMdPath = path.join(skillsRoot, name, 'SKILL.md');
@@ -56,30 +122,6 @@ function readSkillInfo(skillsRoot, name) {
56
122
  path: `${name}/SKILL.md`,
57
123
  };
58
124
  }
59
- function parseSkillTarget(target, relpath) {
60
- const normalizedTarget = normalizeSkillPath(target);
61
- if (relpath) {
62
- return { name: normalizedTarget, pathInSkill: relpath };
63
- }
64
- const slash = normalizedTarget.indexOf('/');
65
- if (slash === -1) {
66
- return { name: normalizedTarget, pathInSkill: '' };
67
- }
68
- return {
69
- name: normalizedTarget.slice(0, slash),
70
- pathInSkill: normalizedTarget.slice(slash + 1),
71
- };
72
- }
73
- function normalizeSkillPath(raw) {
74
- const normalized = raw.trim().replace(/\\/g, '/');
75
- if (!normalized || normalized.includes('\0')) {
76
- throw new ArgumentError('Skill path must be non-empty.');
77
- }
78
- if (normalized.startsWith('/') || normalized.split('/').some((part) => part === '..')) {
79
- throw new ArgumentError(`Invalid skill path: ${raw}`, 'Use a path relative to a Webcmd skill directory.');
80
- }
81
- return path.posix.normalize(normalized);
82
- }
83
125
  function parseFrontmatter(content) {
84
126
  if (!content.startsWith('---\n'))
85
127
  return {};