@agentrhq/webcmd 0.2.4 → 0.3.0
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/README.md +2 -2
- package/clis/_shared/site-auth.js +3 -3
- package/clis/_shared/site-auth.test.js +4 -4
- package/clis/slock/whoami.test.js +2 -2
- package/dist/src/browser/daemon-client.d.ts +2 -1
- package/dist/src/browser/runtime/local-cloak/actions.js +4 -1
- package/dist/src/browser/runtime/local-cloak/provider.test.js +36 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +3 -2
- package/dist/src/browser/runtime/local-cloak/session-manager.js +4 -2
- package/dist/src/cli.js +5 -4
- package/dist/src/cli.test.js +11 -5
- package/dist/src/commands/auth.js +3 -2
- package/dist/src/commands/auth.test.js +6 -6
- package/dist/src/generate-release-notes-cli.test.js +5 -0
- package/dist/src/hosted/args.d.ts +9 -0
- package/dist/src/hosted/args.js +101 -0
- package/dist/src/hosted/args.test.d.ts +1 -0
- package/dist/src/hosted/args.test.js +35 -0
- package/dist/src/hosted/client.d.ts +30 -0
- package/dist/src/hosted/client.js +122 -0
- package/dist/src/hosted/client.test.d.ts +1 -0
- package/dist/src/hosted/client.test.js +119 -0
- package/dist/src/hosted/config.d.ts +50 -0
- package/dist/src/hosted/config.js +90 -0
- package/dist/src/hosted/config.test.d.ts +1 -0
- package/dist/src/hosted/config.test.js +48 -0
- package/dist/src/hosted/manifest.d.ts +9 -0
- package/dist/src/hosted/manifest.js +92 -0
- package/dist/src/hosted/manifest.test.d.ts +1 -0
- package/dist/src/hosted/manifest.test.js +46 -0
- package/dist/src/hosted/runner.d.ts +12 -0
- package/dist/src/hosted/runner.js +404 -0
- package/dist/src/hosted/runner.test.d.ts +1 -0
- package/dist/src/hosted/runner.test.js +189 -0
- package/dist/src/hosted/setup.d.ts +9 -0
- package/dist/src/hosted/setup.js +49 -0
- package/dist/src/hosted/setup.test.d.ts +1 -0
- package/dist/src/hosted/setup.test.js +68 -0
- package/dist/src/hosted/types.d.ts +97 -0
- package/dist/src/hosted/types.js +1 -0
- package/dist/src/main.js +14 -0
- package/dist/src/release-notes.d.ts +6 -1
- package/dist/src/release-notes.js +184 -4
- package/dist/src/release-notes.test.js +128 -1
- package/package.json +1 -1
- package/scripts/generate-release-notes.ts +1 -1
- package/skills/smart-search/SKILL.md +14 -3
- package/skills/webcmd-adapter-author/SKILL.md +2 -2
- package/skills/webcmd-adapter-author/references/adapter-template.md +25 -3
- package/skills/webcmd-autofix/SKILL.md +1 -1
- package/skills/webcmd-usage/SKILL.md +22 -9
- package/clis/antigravity/SKILL.md +0 -38
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { getConfigPath } from './config.js';
|
|
6
|
+
import { runHostedSetup } from './setup.js';
|
|
7
|
+
let tempDir;
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
if (tempDir)
|
|
10
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
11
|
+
tempDir = undefined;
|
|
12
|
+
});
|
|
13
|
+
describe('webcmd setup', () => {
|
|
14
|
+
it('writes local mode from interactive answer', async () => {
|
|
15
|
+
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-'));
|
|
16
|
+
const answers = ['local'];
|
|
17
|
+
const messages = [];
|
|
18
|
+
const env = { WEBCMD_CONFIG_DIR: tempDir };
|
|
19
|
+
const code = await runHostedSetup({
|
|
20
|
+
env,
|
|
21
|
+
now: () => new Date('2026-07-08T00:00:00.000Z'),
|
|
22
|
+
question: async () => answers.shift() ?? '',
|
|
23
|
+
write: (message) => messages.push(message),
|
|
24
|
+
});
|
|
25
|
+
expect(code).toBe(0);
|
|
26
|
+
expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toEqual({
|
|
27
|
+
mode: 'local',
|
|
28
|
+
updatedAt: '2026-07-08T00:00:00.000Z',
|
|
29
|
+
});
|
|
30
|
+
expect(messages.join('')).toContain('local mode');
|
|
31
|
+
});
|
|
32
|
+
it('writes hosted mode and validates with /v1/me', async () => {
|
|
33
|
+
tempDir = await mkdtemp(join(tmpdir(), 'webcmd-setup-'));
|
|
34
|
+
const answers = ['hosted', 'wcmd_live_test'];
|
|
35
|
+
const env = { WEBCMD_CONFIG_DIR: tempDir };
|
|
36
|
+
const requests = [];
|
|
37
|
+
const prompts = [];
|
|
38
|
+
const code = await runHostedSetup({
|
|
39
|
+
env,
|
|
40
|
+
now: () => new Date('2026-07-08T00:00:00.000Z'),
|
|
41
|
+
question: async (prompt) => {
|
|
42
|
+
prompts.push(prompt);
|
|
43
|
+
return answers.shift() ?? '';
|
|
44
|
+
},
|
|
45
|
+
fetchImpl: async (url, init) => {
|
|
46
|
+
requests.push({
|
|
47
|
+
url: String(url),
|
|
48
|
+
authorization: new Headers(init?.headers).get('authorization'),
|
|
49
|
+
});
|
|
50
|
+
return new Response(JSON.stringify({ ok: true, user: { id: 'user_demo' } }), { status: 200 });
|
|
51
|
+
},
|
|
52
|
+
write: () => undefined,
|
|
53
|
+
});
|
|
54
|
+
expect(code).toBe(0);
|
|
55
|
+
expect(prompts).toEqual([
|
|
56
|
+
'Use hosted Webcmd Cloud or local Webcmd? [hosted/local] ',
|
|
57
|
+
'Webcmd API key: ',
|
|
58
|
+
]);
|
|
59
|
+
expect(requests).toEqual([{ url: 'https://api.webcmd.dev/v1/me', authorization: 'Bearer wcmd_live_test' }]);
|
|
60
|
+
expect(JSON.parse(await readFile(getConfigPath({ env }), 'utf8'))).toMatchObject({
|
|
61
|
+
mode: 'hosted',
|
|
62
|
+
hosted: {
|
|
63
|
+
apiBaseUrl: 'https://api.webcmd.dev',
|
|
64
|
+
apiKey: 'wcmd_live_test',
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export type HostedCommandStrategy = 'PUBLIC' | 'COOKIE' | 'INTERCEPT' | 'UI' | 'LOCAL' | string;
|
|
2
|
+
export interface HostedCommandArg {
|
|
3
|
+
name: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
required?: boolean;
|
|
6
|
+
valueRequired?: boolean;
|
|
7
|
+
positional?: boolean;
|
|
8
|
+
default?: unknown;
|
|
9
|
+
help?: string;
|
|
10
|
+
choices?: unknown[];
|
|
11
|
+
}
|
|
12
|
+
export interface HostedCommand {
|
|
13
|
+
site: string;
|
|
14
|
+
name: string;
|
|
15
|
+
aliases?: string[];
|
|
16
|
+
command: string;
|
|
17
|
+
description: string;
|
|
18
|
+
access: 'read' | 'write' | string;
|
|
19
|
+
strategy: HostedCommandStrategy;
|
|
20
|
+
browser: boolean;
|
|
21
|
+
args: HostedCommandArg[];
|
|
22
|
+
columns?: string[];
|
|
23
|
+
domain?: string | null;
|
|
24
|
+
defaultFormat?: string | null;
|
|
25
|
+
}
|
|
26
|
+
export interface HostedManifest {
|
|
27
|
+
userId: string;
|
|
28
|
+
generatedAt: string;
|
|
29
|
+
commands: HostedCommand[];
|
|
30
|
+
}
|
|
31
|
+
export interface HostedExecuteResponse {
|
|
32
|
+
ok: true;
|
|
33
|
+
result?: unknown;
|
|
34
|
+
data?: unknown;
|
|
35
|
+
rows?: unknown;
|
|
36
|
+
columns?: string[];
|
|
37
|
+
trace?: unknown;
|
|
38
|
+
}
|
|
39
|
+
export type HostedBrowserActionName = 'back' | 'click' | 'close-window' | 'console' | 'exec' | 'fill' | 'frames' | 'insert-text' | 'navigate' | 'network' | 'press-key' | 'screenshot' | 'scroll' | 'set-file-input' | 'snapshot' | 'tabs' | 'type' | 'wait';
|
|
40
|
+
export interface HostedBrowserRunRequest {
|
|
41
|
+
command: string;
|
|
42
|
+
args: Record<string, unknown>;
|
|
43
|
+
profile?: string;
|
|
44
|
+
windowMode?: 'foreground' | 'background';
|
|
45
|
+
trace?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface HostedBrowserRunResponse {
|
|
48
|
+
ok: true;
|
|
49
|
+
run: {
|
|
50
|
+
executionId: string;
|
|
51
|
+
session: string;
|
|
52
|
+
profile: {
|
|
53
|
+
id: string;
|
|
54
|
+
displayName: string;
|
|
55
|
+
};
|
|
56
|
+
liveViewUrl?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export interface HostedBrowserActionRequest {
|
|
60
|
+
action: HostedBrowserActionName;
|
|
61
|
+
args: Record<string, unknown>;
|
|
62
|
+
profile?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface HostedBrowserActionResponse {
|
|
65
|
+
ok: true;
|
|
66
|
+
result?: unknown;
|
|
67
|
+
columns?: string[];
|
|
68
|
+
trace?: unknown;
|
|
69
|
+
}
|
|
70
|
+
export interface HostedBrowserFinishRequest {
|
|
71
|
+
status: 'succeeded' | 'failed' | 'timed_out';
|
|
72
|
+
errorCode?: string;
|
|
73
|
+
profile?: string;
|
|
74
|
+
}
|
|
75
|
+
export interface HostedBrowserFinishResponse {
|
|
76
|
+
ok: true;
|
|
77
|
+
execution: {
|
|
78
|
+
id: string;
|
|
79
|
+
status: 'succeeded' | 'failed' | 'timed_out';
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export interface HostedBrowserRunActionInput extends HostedBrowserRunRequest, HostedBrowserActionRequest {
|
|
83
|
+
}
|
|
84
|
+
export interface HostedBrowserRunActionResponse extends HostedBrowserActionResponse {
|
|
85
|
+
run: HostedBrowserRunResponse['run'];
|
|
86
|
+
execution: HostedBrowserFinishResponse['execution'];
|
|
87
|
+
}
|
|
88
|
+
export interface HostedErrorResponse {
|
|
89
|
+
ok: false;
|
|
90
|
+
error: {
|
|
91
|
+
code?: string;
|
|
92
|
+
message?: string;
|
|
93
|
+
help?: string;
|
|
94
|
+
hint?: string;
|
|
95
|
+
exitCode?: number;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/main.js
CHANGED
|
@@ -53,6 +53,20 @@ if (argv[0] === 'completion' && argv.length >= 2) {
|
|
|
53
53
|
}
|
|
54
54
|
// Unknown shell — fall through to full path for proper error handling
|
|
55
55
|
}
|
|
56
|
+
// Hosted setup and hosted dispatch run before local adapter discovery. This is
|
|
57
|
+
// the mode boundary: hosted mode must not read ~/.webcmd/clis or local site
|
|
58
|
+
// memory just to decide what commands exist.
|
|
59
|
+
if (argv[0] === 'setup') {
|
|
60
|
+
const { runHostedSetup } = await import('./hosted/setup.js');
|
|
61
|
+
process.exit(await runHostedSetup());
|
|
62
|
+
}
|
|
63
|
+
const { shouldUseHostedMode } = await import('./hosted/config.js');
|
|
64
|
+
if (shouldUseHostedMode()) {
|
|
65
|
+
const { runHostedCli } = await import('./hosted/runner.js');
|
|
66
|
+
const result = await runHostedCli(argv);
|
|
67
|
+
if (result.handled)
|
|
68
|
+
process.exit(result.exitCode);
|
|
69
|
+
}
|
|
56
70
|
// Fast path: --get-completions — read from manifest, skip discovery
|
|
57
71
|
const getCompIdx = process.argv.indexOf('--get-completions');
|
|
58
72
|
if (getCompIdx !== -1) {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "Adapters", "Reverts"];
|
|
2
2
|
export type ReleaseNoteSection = typeof RELEASE_NOTE_SECTIONS[number];
|
|
3
|
+
export interface NormalizeReleaseNotesOptions {
|
|
4
|
+
context?: ReleaseContext;
|
|
5
|
+
}
|
|
3
6
|
export interface PullRequestLabel {
|
|
4
7
|
name: string;
|
|
5
8
|
}
|
|
@@ -33,8 +36,10 @@ export interface CompareCommit {
|
|
|
33
36
|
}
|
|
34
37
|
export type GitRunner = (args: readonly string[]) => Promise<string>;
|
|
35
38
|
export declare function releaseVersionFromTag(tag: string): string;
|
|
39
|
+
export declare function isMajorRelease(context: Pick<ReleaseContext, 'tag' | 'previousTag'>): boolean;
|
|
40
|
+
export declare function releaseContributorHandles(pullRequests: PullRequestDetails[]): string[];
|
|
36
41
|
export declare function replaceChangelogReleaseNotes(changelog: string, tag: string, notes: string): string;
|
|
37
42
|
export declare function extractPullRequestNumber(message: string): number | null;
|
|
38
43
|
export declare function filterReleasePullRequests(prs: PullRequestDetails[]): PullRequestDetails[];
|
|
39
|
-
export declare function normalizeReleaseNotes(raw: string): string;
|
|
44
|
+
export declare function normalizeReleaseNotes(raw: string, options?: NormalizeReleaseNotesOptions): string;
|
|
40
45
|
export declare function buildReleaseNotesPrompt(context: ReleaseContext): string;
|
|
@@ -8,6 +8,24 @@ export const RELEASE_NOTE_SECTIONS = [
|
|
|
8
8
|
const SQUASH_MERGE_PR_NUMBER_PATTERN = /\(#(?<number>\d+)\)\s*$/;
|
|
9
9
|
const MERGE_COMMIT_PR_NUMBER_PATTERN = /^Merge pull request #(?<number>\d+) /;
|
|
10
10
|
const RELEASE_PLEASE_TITLE_PATTERN = /^chore(?:\([^)]+\))?: release(?:\s|$)/;
|
|
11
|
+
const SERVICE_ACCOUNT_HANDLES = new Set([
|
|
12
|
+
'allcontributors',
|
|
13
|
+
'copilot-pull-request-reviewer',
|
|
14
|
+
'dependabot',
|
|
15
|
+
'github-actions',
|
|
16
|
+
'release-please',
|
|
17
|
+
'renovate',
|
|
18
|
+
'semantic-release-bot',
|
|
19
|
+
'snyk-bot',
|
|
20
|
+
'web-flow',
|
|
21
|
+
]);
|
|
22
|
+
function normalizeHandle(handle) {
|
|
23
|
+
const trimmed = handle.trim();
|
|
24
|
+
return trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
|
|
25
|
+
}
|
|
26
|
+
function uniqueSortedHandles(handles) {
|
|
27
|
+
return [...new Set(handles.map(normalizeHandle).filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
|
28
|
+
}
|
|
11
29
|
function isNoChangeContent(content) {
|
|
12
30
|
const lines = content
|
|
13
31
|
.split(/\r?\n/)
|
|
@@ -34,7 +52,11 @@ function formatReleaseNotesForChangelog(notes) {
|
|
|
34
52
|
const trimmed = notes.trim();
|
|
35
53
|
if (!trimmed)
|
|
36
54
|
return '';
|
|
37
|
-
|
|
55
|
+
const lines = trimmed.split(/\r?\n/);
|
|
56
|
+
if (lines[0]?.startsWith('# ')) {
|
|
57
|
+
lines[0] = `_${lines[0].replace(/^#\s+/, '').trim()}_`;
|
|
58
|
+
}
|
|
59
|
+
return stripContributorAvatarLines(lines.join('\n')).replace(/^##\s+/gm, '### ');
|
|
38
60
|
}
|
|
39
61
|
export function releaseVersionFromTag(tag) {
|
|
40
62
|
const value = tag.trim();
|
|
@@ -44,6 +66,143 @@ export function releaseVersionFromTag(tag) {
|
|
|
44
66
|
return value.slice(1);
|
|
45
67
|
return value;
|
|
46
68
|
}
|
|
69
|
+
function releaseDisplayVersionFromTag(tag) {
|
|
70
|
+
const version = releaseVersionFromTag(tag);
|
|
71
|
+
return version.startsWith('v') ? version : `v${version}`;
|
|
72
|
+
}
|
|
73
|
+
function parseReleaseSemver(tag) {
|
|
74
|
+
const match = releaseVersionFromTag(tag).match(/^v?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:[-+].*)?$/);
|
|
75
|
+
if (!match?.groups)
|
|
76
|
+
return null;
|
|
77
|
+
return {
|
|
78
|
+
major: Number(match.groups.major),
|
|
79
|
+
minor: Number(match.groups.minor),
|
|
80
|
+
patch: Number(match.groups.patch),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
export function isMajorRelease(context) {
|
|
84
|
+
const current = parseReleaseSemver(context.tag);
|
|
85
|
+
if (!current || current.major === 0)
|
|
86
|
+
return false;
|
|
87
|
+
const previous = parseReleaseSemver(context.previousTag);
|
|
88
|
+
if (!previous)
|
|
89
|
+
return current.minor === 0 && current.patch === 0;
|
|
90
|
+
return current.major > previous.major;
|
|
91
|
+
}
|
|
92
|
+
function isServiceAccount(handle) {
|
|
93
|
+
const normalized = normalizeHandle(handle).toLowerCase();
|
|
94
|
+
return SERVICE_ACCOUNT_HANDLES.has(normalized)
|
|
95
|
+
|| normalized.endsWith('[bot]')
|
|
96
|
+
|| normalized.endsWith('-bot');
|
|
97
|
+
}
|
|
98
|
+
export function releaseContributorHandles(pullRequests) {
|
|
99
|
+
return uniqueSortedHandles(pullRequests.flatMap((pr) => (pr.author?.login ? [pr.author.login] : []))).filter((handle) => !isServiceAccount(handle));
|
|
100
|
+
}
|
|
101
|
+
function escapeHtml(value) {
|
|
102
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
103
|
+
switch (character) {
|
|
104
|
+
case '&':
|
|
105
|
+
return '&';
|
|
106
|
+
case '<':
|
|
107
|
+
return '<';
|
|
108
|
+
case '>':
|
|
109
|
+
return '>';
|
|
110
|
+
case '"':
|
|
111
|
+
return '"';
|
|
112
|
+
case "'":
|
|
113
|
+
return ''';
|
|
114
|
+
default:
|
|
115
|
+
return character;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function githubHandleUrl(handle) {
|
|
120
|
+
return `https://github.com/${encodeURIComponent(handle)}`;
|
|
121
|
+
}
|
|
122
|
+
function formatContributorAvatar(handle) {
|
|
123
|
+
const escapedHandle = escapeHtml(handle);
|
|
124
|
+
const encodedHandle = encodeURIComponent(handle);
|
|
125
|
+
return `<a href="${githubHandleUrl(handle)}" title="@${escapedHandle}"><img src="https://github.com/${encodedHandle}.png?size=64" width="64" height="64" alt="@${escapedHandle}" /></a>`;
|
|
126
|
+
}
|
|
127
|
+
function formatContributorLink(handle) {
|
|
128
|
+
return `[@${handle}](${githubHandleUrl(handle)})`;
|
|
129
|
+
}
|
|
130
|
+
function formatContributorsSection(handles) {
|
|
131
|
+
if (handles.length === 0)
|
|
132
|
+
return null;
|
|
133
|
+
return [
|
|
134
|
+
'## Contributors',
|
|
135
|
+
handles.map(formatContributorAvatar).join('\n'),
|
|
136
|
+
'',
|
|
137
|
+
handles.map(formatContributorLink).join(' | '),
|
|
138
|
+
].join('\n');
|
|
139
|
+
}
|
|
140
|
+
function stripContributorAvatarLines(notes) {
|
|
141
|
+
return notes
|
|
142
|
+
.split(/\r?\n/)
|
|
143
|
+
.filter((line) => !(/<img\b/i.test(line) && /https:\/\/github\.com\/[^"'\s>]+\.png\?size=64/.test(line)))
|
|
144
|
+
.join('\n')
|
|
145
|
+
.replace(/(^|\n)(## Contributors)\n\n/g, '$1$2\n')
|
|
146
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
147
|
+
.trim();
|
|
148
|
+
}
|
|
149
|
+
function parseMajorReleaseTitle(raw) {
|
|
150
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
151
|
+
const titleMatch = line.match(/^#\s+(.+?)\s*$/);
|
|
152
|
+
if (titleMatch)
|
|
153
|
+
return titleMatch[1];
|
|
154
|
+
if (/^##\s+/.test(line))
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
function fallbackMajorReleaseTitle(context) {
|
|
160
|
+
const releaseText = context.pullRequests.map((pr) => [
|
|
161
|
+
pr.title,
|
|
162
|
+
...pr.labels.map((label) => label.name),
|
|
163
|
+
...pr.files.map((file) => file.path),
|
|
164
|
+
].join('\n')).join('\n').toLowerCase();
|
|
165
|
+
if (/\bclis\//.test(releaseText) || /\badapter\b/.test(releaseText) || /\bcli\b/.test(releaseText)) {
|
|
166
|
+
return 'The Command Surface Expands';
|
|
167
|
+
}
|
|
168
|
+
if (/\bbrowser\b|\bcloak\b|\bdaemon\b/.test(releaseText)) {
|
|
169
|
+
return 'The Browser Runtime Matures';
|
|
170
|
+
}
|
|
171
|
+
if (/\bskills?\b/.test(releaseText)) {
|
|
172
|
+
return 'The Agent Authoring Edition';
|
|
173
|
+
}
|
|
174
|
+
if (/\bdocs?\b|\breadme\b/.test(releaseText)) {
|
|
175
|
+
return 'The Documentation Edition';
|
|
176
|
+
}
|
|
177
|
+
if (/\bplugins?\b/.test(releaseText)) {
|
|
178
|
+
return 'The Plugin System Opens Up';
|
|
179
|
+
}
|
|
180
|
+
return 'A New Command Surface';
|
|
181
|
+
}
|
|
182
|
+
function normalizeMajorReleaseTitle(rawTitle, context) {
|
|
183
|
+
const title = (rawTitle ?? '')
|
|
184
|
+
.replace(/^webcmd[-\s]+v?\d+\.\d+\.\d+(?:[-+][^:\s]+)?\s*:\s*/i, '')
|
|
185
|
+
.replace(/^webcmd\s*:\s*/i, '')
|
|
186
|
+
.replace(/[`*_]/g, '')
|
|
187
|
+
.replace(/\s+/g, ' ')
|
|
188
|
+
.replace(/^[#:\-\s]+/, '')
|
|
189
|
+
.replace(/[.!]+$/, '')
|
|
190
|
+
.trim();
|
|
191
|
+
const lowerTitle = title.toLowerCase();
|
|
192
|
+
if (!title
|
|
193
|
+
|| title.length > 80
|
|
194
|
+
|| lowerTitle === 'none'
|
|
195
|
+
|| lowerTitle === 'n/a'
|
|
196
|
+
|| lowerTitle === 'release notes'
|
|
197
|
+
|| lowerTitle === 'webcmd'
|
|
198
|
+
|| lowerTitle === 'webcmd release') {
|
|
199
|
+
return fallbackMajorReleaseTitle(context);
|
|
200
|
+
}
|
|
201
|
+
return title;
|
|
202
|
+
}
|
|
203
|
+
function formatMajorReleaseHeading(raw, context) {
|
|
204
|
+
return `# webcmd ${releaseDisplayVersionFromTag(context.tag)}: ${normalizeMajorReleaseTitle(parseMajorReleaseTitle(raw), context)}`;
|
|
205
|
+
}
|
|
47
206
|
export function replaceChangelogReleaseNotes(changelog, tag, notes) {
|
|
48
207
|
const version = releaseVersionFromTag(tag);
|
|
49
208
|
const headingPattern = new RegExp(`^## \\[${escapeRegExp(version)}\\]\\([^\\n]+\\) \\([^\\n]+\\)\\s*$`, 'm');
|
|
@@ -104,12 +263,24 @@ function parseReleaseNoteSections(raw) {
|
|
|
104
263
|
}
|
|
105
264
|
return sections;
|
|
106
265
|
}
|
|
107
|
-
export function normalizeReleaseNotes(raw) {
|
|
266
|
+
export function normalizeReleaseNotes(raw, options = {}) {
|
|
108
267
|
const sections = parseReleaseNoteSections(raw);
|
|
109
|
-
|
|
268
|
+
const sectionBlocks = RELEASE_NOTE_SECTIONS.flatMap((section) => {
|
|
110
269
|
const content = normalizeSectionContent(sections[section]?.join('\n'));
|
|
111
270
|
return content ? [`## ${section}\n${content}`] : [];
|
|
112
|
-
})
|
|
271
|
+
});
|
|
272
|
+
if (sectionBlocks.length === 0)
|
|
273
|
+
return '';
|
|
274
|
+
const blocks = [];
|
|
275
|
+
if (options.context && isMajorRelease(options.context)) {
|
|
276
|
+
blocks.push(formatMajorReleaseHeading(raw, options.context));
|
|
277
|
+
}
|
|
278
|
+
blocks.push(...sectionBlocks);
|
|
279
|
+
const contributors = options.context ? releaseContributorHandles(options.context.pullRequests) : [];
|
|
280
|
+
const contributorsSection = formatContributorsSection(contributors);
|
|
281
|
+
if (contributorsSection)
|
|
282
|
+
blocks.push(contributorsSection);
|
|
283
|
+
return blocks.join('\n\n');
|
|
113
284
|
}
|
|
114
285
|
function formatPullRequest(pr) {
|
|
115
286
|
const author = pr.author?.login ?? 'unknown';
|
|
@@ -128,11 +299,20 @@ function formatPullRequest(pr) {
|
|
|
128
299
|
}
|
|
129
300
|
export function buildReleaseNotesPrompt(context) {
|
|
130
301
|
const prSummaries = context.pullRequests.map(formatPullRequest).join('\n\n');
|
|
302
|
+
const majorReleaseInstructions = isMajorRelease(context)
|
|
303
|
+
? [
|
|
304
|
+
`This is a major release. Start with exactly one H1: # webcmd ${releaseDisplayVersionFromTag(context.tag)}: <Elegant Release Title>.`,
|
|
305
|
+
'Make the title grand enough to feel memorable, but polished rather than loud. Keep it short, specific to the supplied PRs, and avoid hype words.',
|
|
306
|
+
]
|
|
307
|
+
: [
|
|
308
|
+
'Do not include a top-level release title.',
|
|
309
|
+
];
|
|
131
310
|
return [
|
|
132
311
|
`Write user-facing release notes for ${context.tag}.`,
|
|
133
312
|
`Release range: ${context.previousTag}...${context.currentRef}.`,
|
|
134
313
|
'Use only the supplied pull requests below. Do not invent changes or pull in information from elsewhere.',
|
|
135
314
|
`Allowed sections: ${RELEASE_NOTE_SECTIONS.map((section) => `## ${section}`).join(', ')}.`,
|
|
315
|
+
...majorReleaseInstructions,
|
|
136
316
|
'Include only sections that have user-visible changes. Omit empty sections entirely; do not write "None", "N/A", or similar placeholder text.',
|
|
137
317
|
'Do not include a Contributors section.',
|
|
138
318
|
'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".',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { RELEASE_NOTE_SECTIONS, buildReleaseNotesPrompt, extractPullRequestNumber, filterReleasePullRequests, normalizeReleaseNotes, replaceChangelogReleaseNotes, } from './release-notes.js';
|
|
2
|
+
import { RELEASE_NOTE_SECTIONS, buildReleaseNotesPrompt, extractPullRequestNumber, filterReleasePullRequests, isMajorRelease, normalizeReleaseNotes, releaseContributorHandles, 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);
|
|
@@ -17,6 +17,22 @@ describe('release notes helpers', () => {
|
|
|
17
17
|
];
|
|
18
18
|
expect(filterReleasePullRequests(prs).map((pr) => pr.number)).toEqual([1]);
|
|
19
19
|
});
|
|
20
|
+
it('detects major releases from semver tag ranges', () => {
|
|
21
|
+
expect(isMajorRelease({ tag: 'webcmd-v2.0.0', previousTag: 'webcmd-v1.9.9' })).toBe(true);
|
|
22
|
+
expect(isMajorRelease({ tag: 'v3.1.0', previousTag: 'v2.9.9' })).toBe(true);
|
|
23
|
+
expect(isMajorRelease({ tag: 'webcmd-v2.1.0', previousTag: 'webcmd-v2.0.0' })).toBe(false);
|
|
24
|
+
expect(isMajorRelease({ tag: 'webcmd-v0.3.0', previousTag: 'webcmd-v0.2.5' })).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
it('deduplicates PR author contributors and excludes service accounts', () => {
|
|
27
|
+
const prs = [
|
|
28
|
+
{ number: 1, title: 'feat: browser polish', author: { login: 'alice' }, labels: [], files: [], url: 'https://example.com/1' },
|
|
29
|
+
{ number: 2, title: 'feat: docs polish', author: { login: '@alice' }, labels: [], files: [], url: 'https://example.com/2' },
|
|
30
|
+
{ number: 3, title: 'chore: release', author: { login: 'github-actions[bot]' }, labels: [], files: [], url: 'https://example.com/3' },
|
|
31
|
+
{ number: 4, title: 'feat: adapter polish', author: { login: 'bob' }, labels: [], files: [], url: 'https://example.com/4' },
|
|
32
|
+
{ number: 5, title: 'chore: release notes', author: { login: 'release-please' }, labels: [], files: [], url: 'https://example.com/5' },
|
|
33
|
+
];
|
|
34
|
+
expect(releaseContributorHandles(prs)).toEqual(['alice', 'bob']);
|
|
35
|
+
});
|
|
20
36
|
it('normalizes only sections with real release-note content', () => {
|
|
21
37
|
const raw = [
|
|
22
38
|
'## Highlights',
|
|
@@ -51,6 +67,83 @@ describe('release notes helpers', () => {
|
|
|
51
67
|
expect(normalized).not.toContain('## Reverts');
|
|
52
68
|
expect(normalized).not.toContain('None.');
|
|
53
69
|
});
|
|
70
|
+
it('adds an elegant major release title and visual contributor credits', () => {
|
|
71
|
+
const context = {
|
|
72
|
+
tag: 'webcmd-v2.0.0',
|
|
73
|
+
previousTag: 'webcmd-v1.9.9',
|
|
74
|
+
currentRef: 'webcmd-v2.0.0',
|
|
75
|
+
pullRequests: [
|
|
76
|
+
{
|
|
77
|
+
number: 42,
|
|
78
|
+
title: 'feat: expand browser adapter coverage',
|
|
79
|
+
author: { login: 'alice' },
|
|
80
|
+
labels: [{ name: 'feature' }],
|
|
81
|
+
files: [{ path: 'clis/github/auth.js' }],
|
|
82
|
+
url: 'https://github.com/agentrhq/webcmd/pull/42',
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
number: 43,
|
|
86
|
+
title: 'chore: release automation',
|
|
87
|
+
author: { login: 'github-actions[bot]' },
|
|
88
|
+
labels: [],
|
|
89
|
+
files: [{ path: '.github/workflows/release.yml' }],
|
|
90
|
+
url: 'https://github.com/agentrhq/webcmd/pull/43',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
const raw = [
|
|
95
|
+
'# webcmd v2.0.0: The Command Surface Opens',
|
|
96
|
+
'',
|
|
97
|
+
'## Highlights',
|
|
98
|
+
'- Broader adapter authoring workflows.',
|
|
99
|
+
].join('\n');
|
|
100
|
+
const normalized = normalizeReleaseNotes(raw, { context });
|
|
101
|
+
expect(normalized).toContain('# webcmd v2.0.0: The Command Surface Opens');
|
|
102
|
+
expect(normalized).toContain('## Contributors');
|
|
103
|
+
expect(normalized).toContain('<img src="https://github.com/alice.png?size=64" width="64" height="64" alt="@alice" />');
|
|
104
|
+
expect(normalized).toContain('[@alice](https://github.com/alice)');
|
|
105
|
+
expect(normalized).not.toContain('github-actions');
|
|
106
|
+
});
|
|
107
|
+
it('uses a deterministic major release title fallback when the model omits one', () => {
|
|
108
|
+
const context = {
|
|
109
|
+
tag: 'webcmd-v2.0.0',
|
|
110
|
+
previousTag: 'webcmd-v1.9.9',
|
|
111
|
+
currentRef: 'webcmd-v2.0.0',
|
|
112
|
+
pullRequests: [
|
|
113
|
+
{
|
|
114
|
+
number: 42,
|
|
115
|
+
title: 'feat: add github adapter',
|
|
116
|
+
author: { login: 'alice' },
|
|
117
|
+
labels: [{ name: 'feature' }],
|
|
118
|
+
files: [{ path: 'clis/github/auth.js' }],
|
|
119
|
+
url: 'https://github.com/agentrhq/webcmd/pull/42',
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
const normalized = normalizeReleaseNotes('## Highlights\n- Added a GitHub adapter.', { context });
|
|
124
|
+
expect(normalized).toContain('# webcmd v2.0.0: The Command Surface Expands');
|
|
125
|
+
});
|
|
126
|
+
it('does not add major release title treatment to minor releases', () => {
|
|
127
|
+
const context = {
|
|
128
|
+
tag: 'webcmd-v2.1.0',
|
|
129
|
+
previousTag: 'webcmd-v2.0.0',
|
|
130
|
+
currentRef: 'webcmd-v2.1.0',
|
|
131
|
+
pullRequests: [
|
|
132
|
+
{
|
|
133
|
+
number: 42,
|
|
134
|
+
title: 'feat: improve release notes',
|
|
135
|
+
author: { login: 'alice' },
|
|
136
|
+
labels: [{ name: 'feature' }],
|
|
137
|
+
files: [{ path: 'src/release-notes.ts' }],
|
|
138
|
+
url: 'https://github.com/agentrhq/webcmd/pull/42',
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
const normalized = normalizeReleaseNotes('# The Polished Edition\n\n## Highlights\n- Better notes.', { context });
|
|
143
|
+
expect(normalized).not.toContain('# webcmd v2.1.0');
|
|
144
|
+
expect(normalized).toContain('## Highlights\n- Better notes.');
|
|
145
|
+
expect(normalized).toContain('## Contributors');
|
|
146
|
+
});
|
|
54
147
|
it('replaces a matching changelog release entry with generated notes', () => {
|
|
55
148
|
const changelog = [
|
|
56
149
|
'# Changelog',
|
|
@@ -70,16 +163,26 @@ describe('release notes helpers', () => {
|
|
|
70
163
|
'',
|
|
71
164
|
].join('\n');
|
|
72
165
|
const notes = [
|
|
166
|
+
'# webcmd v2.0.0: The Command Surface Expands',
|
|
167
|
+
'',
|
|
73
168
|
'## Highlights',
|
|
74
169
|
'- Better release notes.',
|
|
75
170
|
'',
|
|
76
171
|
'## Adapters',
|
|
77
172
|
'- Improved district checkout.',
|
|
173
|
+
'',
|
|
174
|
+
'## Contributors',
|
|
175
|
+
'<a href="https://github.com/alice" title="@alice"><img src="https://github.com/alice.png?size=64" width="64" height="64" alt="@alice" /></a>',
|
|
176
|
+
'',
|
|
177
|
+
'[@alice](https://github.com/alice)',
|
|
78
178
|
].join('\n');
|
|
79
179
|
const updated = replaceChangelogReleaseNotes(changelog, 'webcmd-v0.2.3', notes);
|
|
80
180
|
expect(updated).toContain('## [0.2.3]');
|
|
181
|
+
expect(updated).toContain('_webcmd v2.0.0: The Command Surface Expands_');
|
|
81
182
|
expect(updated).toContain('### Highlights\n- Better release notes.');
|
|
82
183
|
expect(updated).toContain('### Adapters\n- Improved district checkout.');
|
|
184
|
+
expect(updated).toContain('### Contributors\n[@alice](https://github.com/alice)');
|
|
185
|
+
expect(updated).not.toContain('<img');
|
|
83
186
|
expect(updated).not.toContain('release-please generated note');
|
|
84
187
|
expect(updated).toContain('## [0.2.2]');
|
|
85
188
|
expect(updated).toContain('* older note');
|
|
@@ -109,6 +212,7 @@ describe('release notes helpers', () => {
|
|
|
109
212
|
expect(prompt).toContain('## Highlights');
|
|
110
213
|
expect(prompt).toContain('## Adapters');
|
|
111
214
|
expect(prompt).not.toContain('## Contributors');
|
|
215
|
+
expect(prompt).toContain('Do not include a top-level release title');
|
|
112
216
|
expect(prompt).toContain('Omit empty sections entirely');
|
|
113
217
|
expect(prompt).toContain('Do not include a Contributors section');
|
|
114
218
|
expect(prompt).toContain('CLI commands and adapters are the same thing');
|
|
@@ -116,4 +220,27 @@ describe('release notes helpers', () => {
|
|
|
116
220
|
expect(prompt).toContain('Put new site adapters/CLIs, adapter promotions, adapter hardening');
|
|
117
221
|
expect(prompt).toContain('## Reverts');
|
|
118
222
|
});
|
|
223
|
+
it('asks for a tasteful H1 title only when building major release notes', () => {
|
|
224
|
+
const context = {
|
|
225
|
+
tag: 'webcmd-v2.0.0',
|
|
226
|
+
previousTag: 'webcmd-v1.9.9',
|
|
227
|
+
currentRef: 'webcmd-v2.0.0',
|
|
228
|
+
pullRequests: [
|
|
229
|
+
{
|
|
230
|
+
number: 42,
|
|
231
|
+
title: 'feat: expand adapter authoring',
|
|
232
|
+
body: 'Adds richer authoring workflows.',
|
|
233
|
+
author: { login: 'alice' },
|
|
234
|
+
labels: [{ name: 'feature' }],
|
|
235
|
+
files: [{ path: 'skills/webcmd-adapter-author/SKILL.md' }],
|
|
236
|
+
diff: 'diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md',
|
|
237
|
+
url: 'https://github.com/agentrhq/webcmd/pull/42',
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
const prompt = buildReleaseNotesPrompt(context);
|
|
242
|
+
expect(prompt).toContain('This is a major release');
|
|
243
|
+
expect(prompt).toContain('# webcmd v2.0.0: <Elegant Release Title>');
|
|
244
|
+
expect(prompt).toContain('grand enough to feel memorable, but polished rather than loud');
|
|
245
|
+
});
|
|
119
246
|
});
|
package/package.json
CHANGED
|
@@ -245,7 +245,7 @@ export async function runGenerateReleaseNotes(
|
|
|
245
245
|
const model = env.GEMINI_RELEASE_NOTES_MODEL || DEFAULT_MODEL;
|
|
246
246
|
const prompt = buildReleaseNotesPrompt(context);
|
|
247
247
|
const raw = await (deps.generateText ?? generateText)(prompt, model, apiKey);
|
|
248
|
-
const normalized = normalizeReleaseNotes(raw);
|
|
248
|
+
const normalized = normalizeReleaseNotes(raw, { context });
|
|
249
249
|
if (normalized) {
|
|
250
250
|
io.writeStdout(`${normalized}\n`);
|
|
251
251
|
}
|