@agentrhq/webcmd 0.2.2 → 0.2.4
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/cli-manifest.json +1278 -115
- package/clis/bigbasket/add-to-cart.js +82 -0
- package/clis/bigbasket/bigbasket.test.js +255 -0
- package/clis/bigbasket/cart.js +81 -0
- package/clis/bigbasket/category.js +30 -0
- package/clis/bigbasket/checkout.js +71 -0
- package/clis/bigbasket/location.js +30 -0
- package/clis/bigbasket/product.js +79 -0
- package/clis/bigbasket/search.js +30 -0
- package/clis/bigbasket/utils.js +207 -0
- package/clis/blinkit/add-to-cart.js +123 -0
- package/clis/blinkit/auth.js +99 -0
- package/clis/blinkit/blinkit.test.js +168 -0
- package/clis/blinkit/cart.js +34 -0
- package/clis/blinkit/checkout.js +32 -0
- package/clis/blinkit/location.js +29 -0
- package/clis/blinkit/place-order.js +78 -0
- package/clis/blinkit/product.js +63 -0
- package/clis/blinkit/search.js +89 -0
- package/clis/blinkit/utils.js +223 -0
- package/clis/district/checkout.js +71 -1
- package/clis/practo/appointment.js +21 -0
- package/clis/practo/appointments.js +27 -0
- package/clis/practo/book-confirm.js +35 -0
- package/clis/practo/book-preview.js +24 -0
- package/clis/practo/booking-link.js +24 -0
- package/clis/practo/cancel.js +29 -0
- package/clis/practo/contact.js +21 -0
- package/clis/practo/login.js +48 -0
- package/clis/practo/practo.test.js +154 -0
- package/clis/practo/profile.js +31 -0
- package/clis/practo/search.js +30 -0
- package/clis/practo/slots.js +19 -0
- package/clis/practo/utils.js +374 -0
- package/clis/practo/whoami.js +28 -0
- package/clis/zepto/add-to-cart.js +53 -0
- package/clis/zepto/auth.js +59 -0
- package/clis/zepto/cart.js +23 -0
- package/clis/zepto/checkout.js +60 -0
- package/clis/zepto/location.js +20 -0
- package/clis/zepto/place-order.js +47 -0
- package/clis/zepto/product.js +52 -0
- package/clis/zepto/search.js +30 -0
- package/clis/zepto/utils.js +228 -0
- package/clis/zepto/zepto.test.js +238 -0
- package/dist/src/browser/daemon-lifecycle.d.ts +2 -0
- package/dist/src/browser/daemon-lifecycle.js +28 -6
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +116 -2
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +36 -0
- package/dist/src/browser.test.js +8 -4
- package/dist/src/cli.js +96 -0
- package/dist/src/cli.test.js +2 -2
- package/dist/src/generate-release-notes-cli.test.js +87 -13
- package/dist/src/plugin-catalog.d.ts +46 -0
- package/dist/src/plugin-catalog.js +178 -0
- package/dist/src/plugin-catalog.test.d.ts +1 -0
- package/dist/src/plugin-catalog.test.js +95 -0
- package/dist/src/release-notes.d.ts +4 -2
- package/dist/src/release-notes.js +67 -20
- package/dist/src/release-notes.test.js +67 -9
- package/package.json +2 -1
- package/plugin-catalog.json +10 -0
- package/scripts/generate-release-notes.ts +33 -4
- package/skills/webcmd-adapter-author/SKILL.md +9 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getEnabledPlugins, isMonorepo } from './plugin-manifest.js';
|
|
6
|
+
import { findPackageRoot } from './package-paths.js';
|
|
7
|
+
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
8
|
+
const CATALOG_FILENAME = 'plugin-catalog.json';
|
|
9
|
+
export function getUserPluginCatalogPath(homeDir = os.homedir()) {
|
|
10
|
+
return path.join(homeDir, '.webcmd', CATALOG_FILENAME);
|
|
11
|
+
}
|
|
12
|
+
export function getPackagedPluginCatalogPath(packageRoot = findPackageRoot(MODULE_FILE)) {
|
|
13
|
+
return path.join(packageRoot, CATALOG_FILENAME);
|
|
14
|
+
}
|
|
15
|
+
export function readCatalog(options = {}) {
|
|
16
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
17
|
+
const userPath = getUserPluginCatalogPath(homeDir);
|
|
18
|
+
if (!fs.existsSync(userPath))
|
|
19
|
+
seedUserCatalog(options.packageRoot, homeDir);
|
|
20
|
+
let parsed;
|
|
21
|
+
try {
|
|
22
|
+
parsed = JSON.parse(fs.readFileSync(userPath, 'utf-8'));
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
throw new Error(`Malformed plugin catalog at ${userPath}: ${errorMessage(err)}`);
|
|
26
|
+
}
|
|
27
|
+
return normalizeCatalog(parsed, userPath);
|
|
28
|
+
}
|
|
29
|
+
export function writeCatalog(catalog, options = {}) {
|
|
30
|
+
const userPath = getUserPluginCatalogPath(options.homeDir ?? os.homedir());
|
|
31
|
+
fs.mkdirSync(path.dirname(userPath), { recursive: true });
|
|
32
|
+
fs.writeFileSync(userPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
33
|
+
}
|
|
34
|
+
export function deriveGithubCatalogSource(source) {
|
|
35
|
+
const parsed = parseGithubSource(source);
|
|
36
|
+
if (!parsed)
|
|
37
|
+
throw new Error(`Unsupported catalog source "${source}". Use github:owner/repo.`);
|
|
38
|
+
const { owner, repo } = parsed;
|
|
39
|
+
return {
|
|
40
|
+
id: `${owner}/${repo}`,
|
|
41
|
+
source,
|
|
42
|
+
manifestUrl: `https://raw.githubusercontent.com/${owner}/${repo}/main/webcmd-plugin.json`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export async function addCatalogSource(source, options = {}) {
|
|
46
|
+
const entry = deriveGithubCatalogSource(source);
|
|
47
|
+
const catalog = readCatalog(options);
|
|
48
|
+
const duplicate = catalog.sources.find((existing) => (existing.id === entry.id || existing.source === entry.source || existing.manifestUrl === entry.manifestUrl));
|
|
49
|
+
if (duplicate)
|
|
50
|
+
throw new Error(`Catalog source already exists: ${duplicate.id}`);
|
|
51
|
+
const manifest = await fetchManifest(entry, options.fetchJson);
|
|
52
|
+
validateMarketplaceManifest(manifest, entry.manifestUrl);
|
|
53
|
+
catalog.sources.push(entry);
|
|
54
|
+
writeCatalog(catalog, options);
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
export function removeCatalogSource(id, options = {}) {
|
|
58
|
+
const catalog = readCatalog(options);
|
|
59
|
+
const index = catalog.sources.findIndex((source) => source.id === id);
|
|
60
|
+
if (index < 0)
|
|
61
|
+
throw new Error(`Catalog source not found: ${id}`);
|
|
62
|
+
const [removed] = catalog.sources.splice(index, 1);
|
|
63
|
+
writeCatalog(catalog, options);
|
|
64
|
+
return removed;
|
|
65
|
+
}
|
|
66
|
+
export function flattenPluginManifest(source, manifest) {
|
|
67
|
+
if (isMonorepo(manifest)) {
|
|
68
|
+
return getEnabledPlugins(manifest).map(({ name, entry }) => ({
|
|
69
|
+
name,
|
|
70
|
+
description: entry.description,
|
|
71
|
+
version: entry.version,
|
|
72
|
+
sourceId: source.id,
|
|
73
|
+
installSource: `${source.source}/${name}`,
|
|
74
|
+
webcmd: entry.webcmd ?? manifest.webcmd,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
if (!manifest.name)
|
|
78
|
+
return [];
|
|
79
|
+
return [{
|
|
80
|
+
name: manifest.name,
|
|
81
|
+
description: manifest.description,
|
|
82
|
+
version: manifest.version,
|
|
83
|
+
sourceId: source.id,
|
|
84
|
+
installSource: source.source,
|
|
85
|
+
webcmd: manifest.webcmd,
|
|
86
|
+
}];
|
|
87
|
+
}
|
|
88
|
+
export async function searchCatalogPlugins(catalog, options = {}) {
|
|
89
|
+
const plugins = [];
|
|
90
|
+
const errors = [];
|
|
91
|
+
await Promise.all(catalog.sources.map(async (source) => {
|
|
92
|
+
try {
|
|
93
|
+
const manifest = await fetchManifest(source, options.fetchJson);
|
|
94
|
+
validateMarketplaceManifest(manifest, source.manifestUrl);
|
|
95
|
+
plugins.push(...flattenPluginManifest(source, manifest));
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
errors.push({ sourceId: source.id, manifestUrl: source.manifestUrl, message: errorMessage(err) });
|
|
99
|
+
}
|
|
100
|
+
}));
|
|
101
|
+
const query = options.query?.trim().toLowerCase();
|
|
102
|
+
const filtered = query
|
|
103
|
+
? plugins.filter((plugin) => `${plugin.name} ${plugin.description ?? ''}`.toLowerCase().includes(query))
|
|
104
|
+
: plugins;
|
|
105
|
+
filtered.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
errors.sort((a, b) => a.sourceId.localeCompare(b.sourceId));
|
|
107
|
+
return { plugins: filtered, errors };
|
|
108
|
+
}
|
|
109
|
+
function seedUserCatalog(packageRoot, homeDir) {
|
|
110
|
+
const packagedPath = getPackagedPluginCatalogPath(packageRoot);
|
|
111
|
+
const userPath = getUserPluginCatalogPath(homeDir);
|
|
112
|
+
if (!fs.existsSync(packagedPath))
|
|
113
|
+
throw new Error(`Packaged plugin catalog not found: ${packagedPath}`);
|
|
114
|
+
fs.mkdirSync(path.dirname(userPath), { recursive: true });
|
|
115
|
+
fs.copyFileSync(packagedPath, userPath);
|
|
116
|
+
}
|
|
117
|
+
function normalizeCatalog(value, filePath) {
|
|
118
|
+
if (!isRecord(value))
|
|
119
|
+
throw new Error(`Malformed plugin catalog at ${filePath}: expected object`);
|
|
120
|
+
if (value.version !== 1)
|
|
121
|
+
throw new Error(`Malformed plugin catalog at ${filePath}: expected version 1`);
|
|
122
|
+
if (!Array.isArray(value.sources))
|
|
123
|
+
throw new Error(`Malformed plugin catalog at ${filePath}: expected sources array`);
|
|
124
|
+
return {
|
|
125
|
+
version: 1,
|
|
126
|
+
sources: value.sources.map((source, index) => normalizeCatalogSource(source, `${filePath} sources[${index}]`)),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function normalizeCatalogSource(value, label) {
|
|
130
|
+
if (!isRecord(value))
|
|
131
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected object`);
|
|
132
|
+
if (typeof value.id !== 'string' || !value.id)
|
|
133
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected id`);
|
|
134
|
+
if (typeof value.source !== 'string' || !value.source)
|
|
135
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected source`);
|
|
136
|
+
if (typeof value.manifestUrl !== 'string' || !value.manifestUrl)
|
|
137
|
+
throw new Error(`Malformed plugin catalog at ${label}: expected manifestUrl`);
|
|
138
|
+
const github = parseGithubSource(value.source);
|
|
139
|
+
return {
|
|
140
|
+
id: github ? `${github.owner}/${github.repo}` : value.id,
|
|
141
|
+
source: value.source,
|
|
142
|
+
manifestUrl: value.manifestUrl,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function parseGithubSource(source) {
|
|
146
|
+
const match = /^github:([\w.-]+)\/([\w.-]+)$/.exec(source);
|
|
147
|
+
if (!match)
|
|
148
|
+
return null;
|
|
149
|
+
return { owner: match[1], repo: match[2] };
|
|
150
|
+
}
|
|
151
|
+
async function fetchManifest(source, fetchJson = defaultFetchJson) {
|
|
152
|
+
const value = await fetchJson(source.manifestUrl);
|
|
153
|
+
if (!isRecord(value))
|
|
154
|
+
throw new Error(`Invalid plugin manifest at ${source.manifestUrl}: expected object`);
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
function validateMarketplaceManifest(manifest, manifestUrl) {
|
|
158
|
+
if (isMonorepo(manifest)) {
|
|
159
|
+
if (getEnabledPlugins(manifest).length === 0) {
|
|
160
|
+
throw new Error(`Invalid plugin manifest at ${manifestUrl}: no enabled plugins`);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!manifest.name)
|
|
165
|
+
throw new Error(`Invalid plugin manifest at ${manifestUrl}: missing name or plugins`);
|
|
166
|
+
}
|
|
167
|
+
async function defaultFetchJson(url) {
|
|
168
|
+
const response = await fetch(url);
|
|
169
|
+
if (!response.ok)
|
|
170
|
+
throw new Error(`HTTP ${response.status} ${response.statusText}`.trim());
|
|
171
|
+
return response.json();
|
|
172
|
+
}
|
|
173
|
+
function isRecord(value) {
|
|
174
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
175
|
+
}
|
|
176
|
+
function errorMessage(err) {
|
|
177
|
+
return err instanceof Error ? err.message : String(err);
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { addCatalogSource, deriveGithubCatalogSource, flattenPluginManifest, getUserPluginCatalogPath, readCatalog, removeCatalogSource, searchCatalogPlugins, } from './plugin-catalog.js';
|
|
6
|
+
describe('plugin catalog', () => {
|
|
7
|
+
let tmpDir;
|
|
8
|
+
let packageRoot;
|
|
9
|
+
let homeDir;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-plugin-catalog-'));
|
|
12
|
+
packageRoot = path.join(tmpDir, 'package');
|
|
13
|
+
homeDir = path.join(tmpDir, 'home');
|
|
14
|
+
fs.mkdirSync(packageRoot, { recursive: true });
|
|
15
|
+
fs.mkdirSync(homeDir, { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(packageRoot, 'plugin-catalog.json'), JSON.stringify({
|
|
17
|
+
version: 1,
|
|
18
|
+
sources: [{
|
|
19
|
+
id: 'agentrhq/webcmd',
|
|
20
|
+
source: 'github:agentrhq/webcmd',
|
|
21
|
+
manifestUrl: 'https://raw.githubusercontent.com/agentrhq/webcmd/main/webcmd-plugin.json',
|
|
22
|
+
}],
|
|
23
|
+
}));
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
it('seeds the user catalog from the packaged default', () => {
|
|
29
|
+
const catalog = readCatalog({ packageRoot, homeDir });
|
|
30
|
+
expect(catalog.sources).toEqual([{ id: 'agentrhq/webcmd', source: 'github:agentrhq/webcmd', manifestUrl: 'https://raw.githubusercontent.com/agentrhq/webcmd/main/webcmd-plugin.json' }]);
|
|
31
|
+
expect(fs.existsSync(getUserPluginCatalogPath(homeDir))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('derives catalog metadata from github shorthand', () => {
|
|
34
|
+
expect(deriveGithubCatalogSource('github:other-org/webcmd-travel-plugins')).toEqual({
|
|
35
|
+
id: 'other-org/webcmd-travel-plugins',
|
|
36
|
+
source: 'github:other-org/webcmd-travel-plugins',
|
|
37
|
+
manifestUrl: 'https://raw.githubusercontent.com/other-org/webcmd-travel-plugins/main/webcmd-plugin.json',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
it('adds and removes catalog sources in the user catalog', async () => {
|
|
41
|
+
const fetchJson = async () => ({ name: 'travel', description: 'Travel tools' });
|
|
42
|
+
const added = await addCatalogSource('github:other-org/webcmd-travel-plugins', { packageRoot, homeDir, fetchJson });
|
|
43
|
+
expect(added.id).toBe('other-org/webcmd-travel-plugins');
|
|
44
|
+
expect(readCatalog({ packageRoot, homeDir }).sources.map((source) => source.id)).toEqual([
|
|
45
|
+
'agentrhq/webcmd',
|
|
46
|
+
'other-org/webcmd-travel-plugins',
|
|
47
|
+
]);
|
|
48
|
+
removeCatalogSource('other-org/webcmd-travel-plugins', { packageRoot, homeDir });
|
|
49
|
+
expect(readCatalog({ packageRoot, homeDir }).sources.map((source) => source.id)).toEqual(['agentrhq/webcmd']);
|
|
50
|
+
});
|
|
51
|
+
it('rejects duplicate catalog sources', async () => {
|
|
52
|
+
const fetchJson = async () => ({ name: 'travel', description: 'Travel tools' });
|
|
53
|
+
await addCatalogSource('github:other-org/webcmd-travel-plugins', { packageRoot, homeDir, fetchJson });
|
|
54
|
+
await expect(addCatalogSource('github:other-org/webcmd-travel-plugins', { packageRoot, homeDir, fetchJson })).rejects.toThrow('already exists');
|
|
55
|
+
});
|
|
56
|
+
it('flattens monorepo manifests into installable rows', () => {
|
|
57
|
+
const rows = flattenPluginManifest({
|
|
58
|
+
id: 'agentrhq/webcmd',
|
|
59
|
+
source: 'github:agentrhq/webcmd',
|
|
60
|
+
manifestUrl: 'https://example.com/webcmd-plugin.json',
|
|
61
|
+
}, {
|
|
62
|
+
webcmd: '>=0.2.0',
|
|
63
|
+
plugins: {
|
|
64
|
+
skyscanner: { path: 'plugins/skyscanner', version: '0.1.0', description: 'Flights', webcmd: '>=0.2.1' },
|
|
65
|
+
disabled: { path: 'plugins/disabled', disabled: true },
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
expect(rows).toEqual([{ name: 'skyscanner', description: 'Flights', version: '0.1.0', sourceId: 'agentrhq/webcmd', installSource: 'github:agentrhq/webcmd/skyscanner', webcmd: '>=0.2.1' }]);
|
|
69
|
+
});
|
|
70
|
+
it('flattens single-plugin manifests into installable rows', () => {
|
|
71
|
+
const rows = flattenPluginManifest({
|
|
72
|
+
id: 'research-tools',
|
|
73
|
+
source: 'github:someone/research-tools',
|
|
74
|
+
manifestUrl: 'https://example.com/webcmd-plugin.json',
|
|
75
|
+
}, { name: 'research-tools', version: '0.1.0', description: 'Research helpers', webcmd: '>=0.2.0' });
|
|
76
|
+
expect(rows).toEqual([{ name: 'research-tools', description: 'Research helpers', version: '0.1.0', sourceId: 'research-tools', installSource: 'github:someone/research-tools', webcmd: '>=0.2.0' }]);
|
|
77
|
+
});
|
|
78
|
+
it('fetches manifests live and preserves remote errors in search output', async () => {
|
|
79
|
+
const catalog = {
|
|
80
|
+
version: 1,
|
|
81
|
+
sources: [
|
|
82
|
+
{ id: 'ok', source: 'github:ok/repo', manifestUrl: 'https://ok.test/webcmd-plugin.json' },
|
|
83
|
+
{ id: 'bad', source: 'github:bad/repo', manifestUrl: 'https://bad.test/webcmd-plugin.json' },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
const fetchJson = async (url) => {
|
|
87
|
+
if (url.includes('bad'))
|
|
88
|
+
throw new Error('network failed');
|
|
89
|
+
return { plugins: { flights: { path: 'plugins/flights', description: 'Flight search' } } };
|
|
90
|
+
};
|
|
91
|
+
const result = await searchCatalogPlugins(catalog, { query: 'flight', fetchJson });
|
|
92
|
+
expect(result.plugins.map((plugin) => plugin.name)).toEqual(['flights']);
|
|
93
|
+
expect(result.errors).toEqual([{ sourceId: 'bad', manifestUrl: 'https://bad.test/webcmd-plugin.json', message: 'network failed' }]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "
|
|
1
|
+
export declare const RELEASE_NOTE_SECTIONS: readonly ["Highlights", "Improvements", "Fixes", "Adapters", "Reverts"];
|
|
2
2
|
export type ReleaseNoteSection = typeof RELEASE_NOTE_SECTIONS[number];
|
|
3
3
|
export interface PullRequestLabel {
|
|
4
4
|
name: string;
|
|
@@ -32,7 +32,9 @@ 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
|
-
export declare function normalizeReleaseNotes(raw: string
|
|
39
|
+
export declare function normalizeReleaseNotes(raw: string): string;
|
|
38
40
|
export declare function buildReleaseNotesPrompt(context: ReleaseContext): string;
|
|
@@ -1,17 +1,64 @@
|
|
|
1
|
-
export const RELEASE_NOTE_SECTIONS = [
|
|
1
|
+
export const RELEASE_NOTE_SECTIONS = [
|
|
2
|
+
'Highlights',
|
|
3
|
+
'Improvements',
|
|
4
|
+
'Fixes',
|
|
5
|
+
'Adapters',
|
|
6
|
+
'Reverts',
|
|
7
|
+
];
|
|
2
8
|
const SQUASH_MERGE_PR_NUMBER_PATTERN = /\(#(?<number>\d+)\)\s*$/;
|
|
3
9
|
const MERGE_COMMIT_PR_NUMBER_PATTERN = /^Merge pull request #(?<number>\d+) /;
|
|
4
10
|
const RELEASE_PLEASE_TITLE_PATTERN = /^chore(?:\([^)]+\))?: release(?:\s|$)/;
|
|
5
|
-
function
|
|
6
|
-
const
|
|
7
|
-
|
|
11
|
+
function isNoChangeContent(content) {
|
|
12
|
+
const lines = content
|
|
13
|
+
.split(/\r?\n/)
|
|
14
|
+
.map((line) => line.trim().replace(/^[-*]\s+/, '').replace(/[.。]+$/, '').trim().toLowerCase())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
if (lines.length === 0)
|
|
17
|
+
return true;
|
|
18
|
+
return lines.every((line) => (line === 'none'
|
|
19
|
+
|| line === 'n/a'
|
|
20
|
+
|| line === 'not applicable'
|
|
21
|
+
|| /^no .*?(?:changes|updates|reverts|fixes|improvements|adapters|highlights)(?: in this release)?$/.test(line)
|
|
22
|
+
|| /^there (?:are|were) no .*?(?:changes|updates|reverts|fixes|improvements|adapters|highlights)(?: in this release)?$/.test(line)));
|
|
8
23
|
}
|
|
9
|
-
function
|
|
10
|
-
return [...new Set(handles.map(normalizeHandle).filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
|
11
|
-
}
|
|
12
|
-
function formatSectionContent(content) {
|
|
24
|
+
function normalizeSectionContent(content) {
|
|
13
25
|
const trimmed = content?.trim();
|
|
14
|
-
|
|
26
|
+
if (!trimmed || isNoChangeContent(trimmed))
|
|
27
|
+
return null;
|
|
28
|
+
return trimmed;
|
|
29
|
+
}
|
|
30
|
+
function escapeRegExp(value) {
|
|
31
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
32
|
+
}
|
|
33
|
+
function formatReleaseNotesForChangelog(notes) {
|
|
34
|
+
const trimmed = notes.trim();
|
|
35
|
+
if (!trimmed)
|
|
36
|
+
return '';
|
|
37
|
+
return trimmed.replace(/^##\s+/gm, '### ');
|
|
38
|
+
}
|
|
39
|
+
export function releaseVersionFromTag(tag) {
|
|
40
|
+
const value = tag.trim();
|
|
41
|
+
if (value.startsWith('webcmd-v'))
|
|
42
|
+
return value.slice('webcmd-v'.length);
|
|
43
|
+
if (value.startsWith('v'))
|
|
44
|
+
return value.slice(1);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
export function replaceChangelogReleaseNotes(changelog, tag, notes) {
|
|
48
|
+
const version = releaseVersionFromTag(tag);
|
|
49
|
+
const headingPattern = new RegExp(`^## \\[${escapeRegExp(version)}\\]\\([^\\n]+\\) \\([^\\n]+\\)\\s*$`, 'm');
|
|
50
|
+
const headingMatch = headingPattern.exec(changelog);
|
|
51
|
+
if (!headingMatch) {
|
|
52
|
+
throw new Error(`Could not find CHANGELOG.md entry for ${version}`);
|
|
53
|
+
}
|
|
54
|
+
const headingEnd = headingMatch.index + headingMatch[0].length;
|
|
55
|
+
const remaining = changelog.slice(headingEnd);
|
|
56
|
+
const nextReleaseMatch = /\n## \[/.exec(remaining);
|
|
57
|
+
const releaseEnd = nextReleaseMatch ? headingEnd + nextReleaseMatch.index : changelog.length;
|
|
58
|
+
const before = changelog.slice(0, headingEnd).trimEnd();
|
|
59
|
+
const after = changelog.slice(releaseEnd);
|
|
60
|
+
const suffix = after ? after.replace(/^\n+/, '\n\n') : '\n';
|
|
61
|
+
return `${before}\n\n${formatReleaseNotesForChangelog(notes)}${suffix}`;
|
|
15
62
|
}
|
|
16
63
|
export function extractPullRequestNumber(message) {
|
|
17
64
|
const firstLine = message.split(/\r?\n/, 1)[0] ?? message;
|
|
@@ -57,16 +104,11 @@ function parseReleaseNoteSections(raw) {
|
|
|
57
104
|
}
|
|
58
105
|
return sections;
|
|
59
106
|
}
|
|
60
|
-
export function normalizeReleaseNotes(raw
|
|
107
|
+
export function normalizeReleaseNotes(raw) {
|
|
61
108
|
const sections = parseReleaseNoteSections(raw);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const content = normalizedContributors.length > 0 ? normalizedContributors.join('\n') : 'None.';
|
|
66
|
-
return `## ${section}\n${content}`;
|
|
67
|
-
}
|
|
68
|
-
const content = formatSectionContent(sections[section]?.join('\n'));
|
|
69
|
-
return `## ${section}\n${content}`;
|
|
109
|
+
return RELEASE_NOTE_SECTIONS.flatMap((section) => {
|
|
110
|
+
const content = normalizeSectionContent(sections[section]?.join('\n'));
|
|
111
|
+
return content ? [`## ${section}\n${content}`] : [];
|
|
70
112
|
}).join('\n\n');
|
|
71
113
|
}
|
|
72
114
|
function formatPullRequest(pr) {
|
|
@@ -90,8 +132,13 @@ export function buildReleaseNotesPrompt(context) {
|
|
|
90
132
|
`Write user-facing release notes for ${context.tag}.`,
|
|
91
133
|
`Release range: ${context.previousTag}...${context.currentRef}.`,
|
|
92
134
|
'Use only the supplied pull requests below. Do not invent changes or pull in information from elsewhere.',
|
|
93
|
-
`
|
|
94
|
-
'
|
|
135
|
+
`Allowed sections: ${RELEASE_NOTE_SECTIONS.map((section) => `## ${section}`).join(', ')}.`,
|
|
136
|
+
'Include only sections that have user-visible changes. Omit empty sections entirely; do not write "None", "N/A", or similar placeholder text.',
|
|
137
|
+
'Do not include a Contributors section.',
|
|
138
|
+
'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".',
|
|
139
|
+
'Put new site adapters/CLIs, adapter promotions, adapter hardening, adapter output changes, selector/API updates, and site-specific workflow improvements in ## Adapters.',
|
|
140
|
+
'Use ## Improvements for non-adapter product, runtime, CLI, docs, or workflow improvements.',
|
|
141
|
+
'Use ## Reverts only when the release includes actual reverted changes.',
|
|
95
142
|
'',
|
|
96
143
|
`Pull requests included for this release:`,
|
|
97
144
|
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);
|
|
@@ -17,21 +17,72 @@ describe('release notes helpers', () => {
|
|
|
17
17
|
];
|
|
18
18
|
expect(filterReleasePullRequests(prs).map((pr) => pr.number)).toEqual([1]);
|
|
19
19
|
});
|
|
20
|
-
it('normalizes
|
|
20
|
+
it('normalizes only sections with real release-note content', () => {
|
|
21
21
|
const raw = [
|
|
22
|
+
'## Highlights',
|
|
23
|
+
'- Better release notes.',
|
|
24
|
+
'',
|
|
25
|
+
'## Improvements',
|
|
26
|
+
'No improvements.',
|
|
27
|
+
'',
|
|
28
|
+
'## Adapters',
|
|
29
|
+
'No adapter changes.',
|
|
30
|
+
'',
|
|
31
|
+
'## Fixes',
|
|
32
|
+
'- Fixed release fallback.',
|
|
33
|
+
'',
|
|
34
|
+
'## Contributors',
|
|
35
|
+
'- @alice',
|
|
36
|
+
'',
|
|
37
|
+
'## Reverts',
|
|
38
|
+
'There are no reverts in this release.',
|
|
39
|
+
].join('\n');
|
|
40
|
+
const normalized = normalizeReleaseNotes(raw);
|
|
41
|
+
expect(RELEASE_NOTE_SECTIONS).toEqual(['Highlights', 'Improvements', 'Fixes', 'Adapters', 'Reverts']);
|
|
42
|
+
expect(normalized).toBe([
|
|
22
43
|
'## Highlights',
|
|
23
44
|
'- Better release notes.',
|
|
24
45
|
'',
|
|
25
46
|
'## Fixes',
|
|
26
47
|
'- Fixed release fallback.',
|
|
48
|
+
].join('\n'));
|
|
49
|
+
expect(normalized).not.toContain('## Improvements');
|
|
50
|
+
expect(normalized).not.toContain('## Contributors');
|
|
51
|
+
expect(normalized).not.toContain('## Reverts');
|
|
52
|
+
expect(normalized).not.toContain('None.');
|
|
53
|
+
});
|
|
54
|
+
it('replaces a matching changelog release entry with generated notes', () => {
|
|
55
|
+
const changelog = [
|
|
56
|
+
'# Changelog',
|
|
57
|
+
'',
|
|
58
|
+
'## [0.2.3](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.2...webcmd-v0.2.3) (2026-07-09)',
|
|
59
|
+
'',
|
|
60
|
+
'',
|
|
61
|
+
'### Features',
|
|
62
|
+
'',
|
|
63
|
+
'* release-please generated note',
|
|
64
|
+
'',
|
|
65
|
+
'## [0.2.2](https://github.com/agentrhq/webcmd/compare/webcmd-v0.2.1...webcmd-v0.2.2) (2026-07-08)',
|
|
66
|
+
'',
|
|
67
|
+
'### Bug Fixes',
|
|
68
|
+
'',
|
|
69
|
+
'* older note',
|
|
70
|
+
'',
|
|
71
|
+
].join('\n');
|
|
72
|
+
const notes = [
|
|
73
|
+
'## Highlights',
|
|
74
|
+
'- Better release notes.',
|
|
75
|
+
'',
|
|
76
|
+
'## Adapters',
|
|
77
|
+
'- Improved district checkout.',
|
|
27
78
|
].join('\n');
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
expect(
|
|
33
|
-
expect(
|
|
34
|
-
expect(
|
|
79
|
+
const updated = replaceChangelogReleaseNotes(changelog, 'webcmd-v0.2.3', notes);
|
|
80
|
+
expect(updated).toContain('## [0.2.3]');
|
|
81
|
+
expect(updated).toContain('### Highlights\n- Better release notes.');
|
|
82
|
+
expect(updated).toContain('### Adapters\n- Improved district checkout.');
|
|
83
|
+
expect(updated).not.toContain('release-please generated note');
|
|
84
|
+
expect(updated).toContain('## [0.2.2]');
|
|
85
|
+
expect(updated).toContain('* older note');
|
|
35
86
|
});
|
|
36
87
|
it('builds a prompt grounded in the exact release range and PR list', () => {
|
|
37
88
|
const context = {
|
|
@@ -56,6 +107,13 @@ describe('release notes helpers', () => {
|
|
|
56
107
|
expect(prompt).toContain('PR #42: feat: add docs scaffold');
|
|
57
108
|
expect(prompt).toContain('docs/docs.json');
|
|
58
109
|
expect(prompt).toContain('## Highlights');
|
|
110
|
+
expect(prompt).toContain('## Adapters');
|
|
111
|
+
expect(prompt).not.toContain('## Contributors');
|
|
112
|
+
expect(prompt).toContain('Omit empty sections entirely');
|
|
113
|
+
expect(prompt).toContain('Do not include a Contributors section');
|
|
114
|
+
expect(prompt).toContain('CLI commands and adapters are the same thing');
|
|
115
|
+
expect(prompt).toContain('files under clis/** as an adapter change');
|
|
116
|
+
expect(prompt).toContain('Put new site adapters/CLIs, adapter promotions, adapter hardening');
|
|
59
117
|
expect(prompt).toContain('## Reverts');
|
|
60
118
|
});
|
|
61
119
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentrhq/webcmd",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Turn websites, browser sessions, desktop apps, and local tools into deterministic CLI surfaces for humans and AI agents.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0"
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"clis/",
|
|
33
33
|
"skills/**",
|
|
34
34
|
"cli-manifest.json",
|
|
35
|
+
"plugin-catalog.json",
|
|
35
36
|
"scripts/",
|
|
36
37
|
"README.md",
|
|
37
38
|
"LICENSE",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
3
|
import { pathToFileURL } from 'node:url';
|
|
3
4
|
import { GoogleGenAI } from '@google/genai';
|
|
4
5
|
import {
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
extractPullRequestNumber,
|
|
7
8
|
filterReleasePullRequests,
|
|
8
9
|
normalizeReleaseNotes,
|
|
10
|
+
replaceChangelogReleaseNotes,
|
|
9
11
|
type PullRequestDetails,
|
|
10
12
|
type ReleaseContext,
|
|
11
13
|
} from '../src/release-notes.js';
|
|
@@ -191,8 +193,29 @@ async function generateText(prompt: string, model: string, apiKey: string): Prom
|
|
|
191
193
|
return text;
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
function
|
|
195
|
-
|
|
196
|
+
function updateChangelog(tag: string | undefined, notesPath: string | undefined, changelogPath: string | undefined, io: Io): number {
|
|
197
|
+
if (!tag || !notesPath) {
|
|
198
|
+
io.writeStderr('Usage: generate-release-notes --update-changelog <tag> <notes-file> [changelog-file]\n');
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const targetChangelogPath = changelogPath ?? 'CHANGELOG.md';
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const notes = readFileSync(notesPath, 'utf8');
|
|
206
|
+
const changelog = readFileSync(targetChangelogPath, 'utf8');
|
|
207
|
+
const updated = replaceChangelogReleaseNotes(changelog, tag, notes);
|
|
208
|
+
if (updated !== changelog) {
|
|
209
|
+
writeFileSync(targetChangelogPath, updated);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
io.writeStdout(`Updated ${targetChangelogPath} for ${tag}\n`);
|
|
213
|
+
return 0;
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
216
|
+
io.writeStderr(`CHANGELOG.md update failed: ${message}\n`);
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
196
219
|
}
|
|
197
220
|
|
|
198
221
|
export async function runGenerateReleaseNotes(
|
|
@@ -201,6 +224,10 @@ export async function runGenerateReleaseNotes(
|
|
|
201
224
|
deps: RunDependencies = {},
|
|
202
225
|
io: Io = DEFAULT_IO,
|
|
203
226
|
): Promise<number> {
|
|
227
|
+
if (argv[2] === '--update-changelog') {
|
|
228
|
+
return updateChangelog(argv[3], argv[4], argv[5], io);
|
|
229
|
+
}
|
|
230
|
+
|
|
204
231
|
const tag = argv[2];
|
|
205
232
|
if (!tag) {
|
|
206
233
|
io.writeStderr('Usage: generate-release-notes <tag>\n');
|
|
@@ -218,8 +245,10 @@ export async function runGenerateReleaseNotes(
|
|
|
218
245
|
const model = env.GEMINI_RELEASE_NOTES_MODEL || DEFAULT_MODEL;
|
|
219
246
|
const prompt = buildReleaseNotesPrompt(context);
|
|
220
247
|
const raw = await (deps.generateText ?? generateText)(prompt, model, apiKey);
|
|
221
|
-
const normalized = normalizeReleaseNotes(raw
|
|
222
|
-
|
|
248
|
+
const normalized = normalizeReleaseNotes(raw);
|
|
249
|
+
if (normalized) {
|
|
250
|
+
io.writeStdout(`${normalized}\n`);
|
|
251
|
+
}
|
|
223
252
|
return 0;
|
|
224
253
|
} catch (error) {
|
|
225
254
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -194,6 +194,14 @@ Check these off step by step:
|
|
|
194
194
|
[ ] `fixtures/<cmd>-<YYYYMMDDHHMM>.json`: save one complete endpoint response sample after removing cookies, tokens, and private user fields. Use it for later field comparison and offline replay.
|
|
195
195
|
[ ] If debugging dumped temporary files in the repo or adapter directory, such as `.dbg-*.html`, `raw-*.json`, or similar, **delete them before commit**. Those belong in `~/.webcmd/sites/<site>/fixtures/` or `/tmp/`.
|
|
196
196
|
|
|
197
|
+
[ ] 13. **First command for this site? Stop and ask before building more.**
|
|
198
|
+
[ ] If this was the site's first command, do not silently keep scaffolding more commands. Ask the user what use cases they have in mind for this site — who the persona is, what they're trying to accomplish end to end.
|
|
199
|
+
[ ] From the use cases, propose the full set of commands you'd recommend adding, not just the obvious next one. Cover the whole journey the use cases imply (discovery, single-item detail, comparison, account/write actions, etc.), not only what's cheapest to build.
|
|
200
|
+
[ ] If that set is small (roughly ≤6-8 commands), list it flat and ask the user to confirm or trim it.
|
|
201
|
+
[ ] If it's large, bucket the commands into named groups (e.g. "Discovery", "Single-item evaluation", "Account actions requiring login") and ask the user which bucket(s) to build first — do not dump an unbucketed wall of commands.
|
|
202
|
+
[ ] Flag any bucket that needs a capability not yet solved (login/OTP, write access, payment) as its own decision point — e.g. "these need login — how do you want to handle auth?" — separate from the command list itself.
|
|
203
|
+
[ ] Do not scaffold additional commands until the user has confirmed which ones to build.
|
|
204
|
+
|
|
197
205
|
---
|
|
198
206
|
|
|
199
207
|
## Fallback Paths
|
|
@@ -247,6 +255,7 @@ Check these off step by step:
|
|
|
247
255
|
- **Persistent sessions keep stale DOM between commands.** `siteSession: 'persistent'` shares one tab per site; leftover modals/drawers from the previous command leak into the next one. State-sensitive write commands (checkout flows) should add `freshPage: true` (new tab, same lease — cookies/login/location survive). Verify session-scoped context (login, selected city/date) *before* side effects, and embed such context in URLs/IDs your command emits for sibling commands. See `references/adapter-template.md` and "Persistent Sessions and State Hygiene" in `docs/authoring.mdx`.
|
|
248
256
|
- For private adapters, write `~/.webcmd/clis/<site>/<name>.js` to avoid a build. Copy to `clis/<site>/<name>.js` only when preparing a PR.
|
|
249
257
|
- Write site memory every round: no memory -> use skill -> produce memory -> next time becomes a five-minute task.
|
|
258
|
+
- **After a site's first command passes verify, stop and ask the user for their use cases before recommending next set of commands.** See Runbook Step 13.
|
|
250
259
|
- **Raw dumps, packet captures, and HTML samples from debugging may only be written to `~/.webcmd/sites/<site>/fixtures/` or `/tmp/`. Never leave `.dbg-*.html`, `raw-*.json`, `sample.*`, or similar temporary files in the repo root, `clis/<site>/`, or the current working directory.**
|
|
251
260
|
- **JSDOM unit-test fixtures (`clis/<site>/__fixtures__/<command>.html`) are the exception.** They are intentional review artifacts committed to the repo, not temporary dumps. Because of that, the quality bar is higher: complete the five steps in `references/jsdom-fixture-pattern.md`, including the mandatory `awk 'NF>0'` blank-line tightening, and reverse-validate once to prove the regression guard can fail.
|
|
252
261
|
|