@agentrhq/webcmd 0.2.3 → 0.2.5
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/cli-manifest.json +12 -3
- package/clis/practo/login.js +34 -7
- package/clis/practo/practo.test.js +19 -1
- 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 +35 -15
- 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 +2 -2
- package/dist/src/release-notes.js +25 -21
- package/dist/src/release-notes.test.js +29 -9
- package/package.json +2 -1
- package/plugin-catalog.json +10 -0
- package/scripts/generate-release-notes.ts +4 -6
- 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 +10 -4
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type PluginManifest } from './plugin-manifest.js';
|
|
2
|
+
export interface PluginCatalogSource {
|
|
3
|
+
id: string;
|
|
4
|
+
source: string;
|
|
5
|
+
manifestUrl: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PluginCatalog {
|
|
8
|
+
version: 1;
|
|
9
|
+
sources: PluginCatalogSource[];
|
|
10
|
+
}
|
|
11
|
+
export interface PluginSearchRow {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
sourceId: string;
|
|
16
|
+
installSource: string;
|
|
17
|
+
webcmd?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PluginSearchError {
|
|
20
|
+
sourceId: string;
|
|
21
|
+
manifestUrl: string;
|
|
22
|
+
message: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PluginSearchResult {
|
|
25
|
+
plugins: PluginSearchRow[];
|
|
26
|
+
errors: PluginSearchError[];
|
|
27
|
+
}
|
|
28
|
+
type FetchJson = (url: string) => Promise<unknown>;
|
|
29
|
+
interface CatalogOptions {
|
|
30
|
+
packageRoot?: string;
|
|
31
|
+
homeDir?: string;
|
|
32
|
+
fetchJson?: FetchJson;
|
|
33
|
+
}
|
|
34
|
+
export declare function getUserPluginCatalogPath(homeDir?: string): string;
|
|
35
|
+
export declare function getPackagedPluginCatalogPath(packageRoot?: string): string;
|
|
36
|
+
export declare function readCatalog(options?: CatalogOptions): PluginCatalog;
|
|
37
|
+
export declare function writeCatalog(catalog: PluginCatalog, options?: CatalogOptions): void;
|
|
38
|
+
export declare function deriveGithubCatalogSource(source: string): PluginCatalogSource;
|
|
39
|
+
export declare function addCatalogSource(source: string, options?: CatalogOptions): Promise<PluginCatalogSource>;
|
|
40
|
+
export declare function removeCatalogSource(id: string, options?: CatalogOptions): PluginCatalogSource;
|
|
41
|
+
export declare function flattenPluginManifest(source: PluginCatalogSource, manifest: PluginManifest): PluginSearchRow[];
|
|
42
|
+
export declare function searchCatalogPlugins(catalog: PluginCatalog, options?: {
|
|
43
|
+
query?: string;
|
|
44
|
+
fetchJson?: FetchJson;
|
|
45
|
+
}): Promise<PluginSearchResult>;
|
|
46
|
+
export {};
|
|
@@ -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", "Adapters", "
|
|
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;
|
|
@@ -36,5 +36,5 @@ export declare function releaseVersionFromTag(tag: string): string;
|
|
|
36
36
|
export declare function replaceChangelogReleaseNotes(changelog: string, tag: string, notes: string): string;
|
|
37
37
|
export declare function extractPullRequestNumber(message: string): number | null;
|
|
38
38
|
export declare function filterReleasePullRequests(prs: PullRequestDetails[]): PullRequestDetails[];
|
|
39
|
-
export declare function normalizeReleaseNotes(raw: string
|
|
39
|
+
export declare function normalizeReleaseNotes(raw: string): string;
|
|
40
40
|
export declare function buildReleaseNotesPrompt(context: ReleaseContext): string;
|
|
@@ -3,22 +3,29 @@ export const RELEASE_NOTE_SECTIONS = [
|
|
|
3
3
|
'Improvements',
|
|
4
4
|
'Fixes',
|
|
5
5
|
'Adapters',
|
|
6
|
-
'Contributors',
|
|
7
6
|
'Reverts',
|
|
8
7
|
];
|
|
9
8
|
const SQUASH_MERGE_PR_NUMBER_PATTERN = /\(#(?<number>\d+)\)\s*$/;
|
|
10
9
|
const MERGE_COMMIT_PR_NUMBER_PATTERN = /^Merge pull request #(?<number>\d+) /;
|
|
11
10
|
const RELEASE_PLEASE_TITLE_PATTERN = /^chore(?:\([^)]+\))?: release(?:\s|$)/;
|
|
12
|
-
function
|
|
13
|
-
const
|
|
14
|
-
|
|
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)));
|
|
15
23
|
}
|
|
16
|
-
function
|
|
17
|
-
return [...new Set(handles.map(normalizeHandle).filter(Boolean))].sort((left, right) => left.localeCompare(right));
|
|
18
|
-
}
|
|
19
|
-
function formatSectionContent(content) {
|
|
24
|
+
function normalizeSectionContent(content) {
|
|
20
25
|
const trimmed = content?.trim();
|
|
21
|
-
|
|
26
|
+
if (!trimmed || isNoChangeContent(trimmed))
|
|
27
|
+
return null;
|
|
28
|
+
return trimmed;
|
|
22
29
|
}
|
|
23
30
|
function escapeRegExp(value) {
|
|
24
31
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -26,7 +33,7 @@ function escapeRegExp(value) {
|
|
|
26
33
|
function formatReleaseNotesForChangelog(notes) {
|
|
27
34
|
const trimmed = notes.trim();
|
|
28
35
|
if (!trimmed)
|
|
29
|
-
return '
|
|
36
|
+
return '';
|
|
30
37
|
return trimmed.replace(/^##\s+/gm, '### ');
|
|
31
38
|
}
|
|
32
39
|
export function releaseVersionFromTag(tag) {
|
|
@@ -97,16 +104,11 @@ function parseReleaseNoteSections(raw) {
|
|
|
97
104
|
}
|
|
98
105
|
return sections;
|
|
99
106
|
}
|
|
100
|
-
export function normalizeReleaseNotes(raw
|
|
107
|
+
export function normalizeReleaseNotes(raw) {
|
|
101
108
|
const sections = parseReleaseNoteSections(raw);
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const content = normalizedContributors.length > 0 ? normalizedContributors.join('\n') : 'None.';
|
|
106
|
-
return `## ${section}\n${content}`;
|
|
107
|
-
}
|
|
108
|
-
const content = formatSectionContent(sections[section]?.join('\n'));
|
|
109
|
-
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}`] : [];
|
|
110
112
|
}).join('\n\n');
|
|
111
113
|
}
|
|
112
114
|
function formatPullRequest(pr) {
|
|
@@ -130,11 +132,13 @@ export function buildReleaseNotesPrompt(context) {
|
|
|
130
132
|
`Write user-facing release notes for ${context.tag}.`,
|
|
131
133
|
`Release range: ${context.previousTag}...${context.currentRef}.`,
|
|
132
134
|
'Use only the supplied pull requests below. Do not invent changes or pull in information from elsewhere.',
|
|
133
|
-
`
|
|
134
|
-
'
|
|
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.',
|
|
135
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".',
|
|
136
139
|
'Put new site adapters/CLIs, adapter promotions, adapter hardening, adapter output changes, selector/API updates, and site-specific workflow improvements in ## Adapters.',
|
|
137
140
|
'Use ## Improvements for non-adapter product, runtime, CLI, docs, or workflow improvements.',
|
|
141
|
+
'Use ## Reverts only when the release includes actual reverted changes.',
|
|
138
142
|
'',
|
|
139
143
|
`Pull requests included for this release:`,
|
|
140
144
|
prSummaries,
|
|
@@ -17,22 +17,39 @@ 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
22
|
'## Highlights',
|
|
23
23
|
'- Better release notes.',
|
|
24
24
|
'',
|
|
25
|
+
'## Improvements',
|
|
26
|
+
'No improvements.',
|
|
27
|
+
'',
|
|
28
|
+
'## Adapters',
|
|
29
|
+
'No adapter changes.',
|
|
30
|
+
'',
|
|
25
31
|
'## Fixes',
|
|
26
32
|
'- Fixed release fallback.',
|
|
33
|
+
'',
|
|
34
|
+
'## Contributors',
|
|
35
|
+
'- @alice',
|
|
36
|
+
'',
|
|
37
|
+
'## Reverts',
|
|
38
|
+
'There are no reverts in this release.',
|
|
27
39
|
].join('\n');
|
|
28
|
-
const normalized = normalizeReleaseNotes(raw
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
const normalized = normalizeReleaseNotes(raw);
|
|
41
|
+
expect(RELEASE_NOTE_SECTIONS).toEqual(['Highlights', 'Improvements', 'Fixes', 'Adapters', 'Reverts']);
|
|
42
|
+
expect(normalized).toBe([
|
|
43
|
+
'## Highlights',
|
|
44
|
+
'- Better release notes.',
|
|
45
|
+
'',
|
|
46
|
+
'## Fixes',
|
|
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.');
|
|
36
53
|
});
|
|
37
54
|
it('replaces a matching changelog release entry with generated notes', () => {
|
|
38
55
|
const changelog = [
|
|
@@ -91,6 +108,9 @@ describe('release notes helpers', () => {
|
|
|
91
108
|
expect(prompt).toContain('docs/docs.json');
|
|
92
109
|
expect(prompt).toContain('## Highlights');
|
|
93
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');
|
|
94
114
|
expect(prompt).toContain('CLI commands and adapters are the same thing');
|
|
95
115
|
expect(prompt).toContain('files under clis/** as an adapter change');
|
|
96
116
|
expect(prompt).toContain('Put new site adapters/CLIs, adapter promotions, adapter hardening');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentrhq/webcmd",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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",
|
|
@@ -193,10 +193,6 @@ async function generateText(prompt: string, model: string, apiKey: string): Prom
|
|
|
193
193
|
return text;
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
function contributorHandles(pullRequests: PullRequestDetails[]): string[] {
|
|
197
|
-
return pullRequests.flatMap((pr) => (pr.author?.login ? [pr.author.login] : []));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
196
|
function updateChangelog(tag: string | undefined, notesPath: string | undefined, changelogPath: string | undefined, io: Io): number {
|
|
201
197
|
if (!tag || !notesPath) {
|
|
202
198
|
io.writeStderr('Usage: generate-release-notes --update-changelog <tag> <notes-file> [changelog-file]\n');
|
|
@@ -249,8 +245,10 @@ export async function runGenerateReleaseNotes(
|
|
|
249
245
|
const model = env.GEMINI_RELEASE_NOTES_MODEL || DEFAULT_MODEL;
|
|
250
246
|
const prompt = buildReleaseNotesPrompt(context);
|
|
251
247
|
const raw = await (deps.generateText ?? generateText)(prompt, model, apiKey);
|
|
252
|
-
const normalized = normalizeReleaseNotes(raw
|
|
253
|
-
|
|
248
|
+
const normalized = normalizeReleaseNotes(raw);
|
|
249
|
+
if (normalized) {
|
|
250
|
+
io.writeStdout(`${normalized}\n`);
|
|
251
|
+
}
|
|
254
252
|
return 0;
|
|
255
253
|
} catch (error) {
|
|
256
254
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -28,7 +28,7 @@ Continue only when all three answers are yes.
|
|
|
28
28
|
|
|
29
29
|
## Top-Level Decision Tree
|
|
30
30
|
|
|
31
|
-
**Choose the strategy before writing the adapter.** Every time you reach Step 3 or Step 4, and before writing code, produce a strategy note. Without that note, do not start
|
|
31
|
+
**Choose the strategy before writing the adapter.** Every time you reach Step 3 or Step 4, and before writing code, produce a strategy note. Without that note, do not start an adapter file.
|
|
32
32
|
|
|
33
33
|
The core question is not whether an API is more elegant than DOM work. The core question is whether the data source has an external contract. Public or official interfaces are usually the most stable. UI/DOM semantics often have a user-visible contract too. Undocumented in-site XHR, GraphQL, or signature endpoints drift the most. Do not move a stable UI/DOM implementation to an uncontracted internal endpoint just to be "API-first."
|
|
34
34
|
|
|
@@ -253,7 +253,7 @@ Check these off step by step:
|
|
|
253
253
|
- **The `browser:` field determines the `func` signature:** `browser:false -> (args)`, `browser:true -> (page, args)`. If this is reversed, `args` may actually be a debug flag and all external parameters can silently fall back to defaults.
|
|
254
254
|
- Throw the correct typed error for known failures according to [`references/typed-errors.md`](./references/typed-errors.md). **Do not** silently `return []`, **do not** silently `return [{sentinel}]`, and **do not** silently clamp external parameters with `Math.max/min`.
|
|
255
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`.
|
|
256
|
-
- For private
|
|
256
|
+
- For private iteration, write `~/.webcmd/clis/<site>/<name>.js` to avoid a build. When the user says to promote a CLI, create a main-repo plugin with `webcmd plugin create <site> --dir plugins/<site>`, copy the real command files into it, delete scaffold sample commands, register it in root `webcmd-plugin.json`, remove the local `~/.webcmd/clis/<site>` shadow, install the plugin, then run `webcmd validate <site>` and smoke commands. See `references/adapter-template.md` for details.
|
|
257
257
|
- Write site memory every round: no memory -> use skill -> produce memory -> next time becomes a five-minute task.
|
|
258
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.
|
|
259
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.**
|
|
@@ -16,10 +16,32 @@ Write the working file at:
|
|
|
16
16
|
~/.webcmd/clis/<site>/<name>.js
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Promote a community CLI to the main repo as a plugin:
|
|
20
20
|
|
|
21
|
-
```
|
|
22
|
-
|
|
21
|
+
```bash
|
|
22
|
+
webcmd plugin create <site> --dir plugins/<site> --description "<site> commands for Webcmd"
|
|
23
|
+
cp ~/.webcmd/clis/<site>/*.js plugins/<site>/
|
|
24
|
+
rm plugins/<site>/hello.ts plugins/<site>/greet.ts 2>/dev/null || true
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then add `<site>` to the root `webcmd-plugin.json` `plugins` map:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
"<site>": {
|
|
31
|
+
"path": "plugins/<site>",
|
|
32
|
+
"version": "0.1.0",
|
|
33
|
+
"description": "<site> commands for Webcmd",
|
|
34
|
+
"webcmd": ">=0.2.0"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Before handing off, remove the private shadow and prove the plugin path works:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
rm -rf ~/.webcmd/clis/<site>
|
|
42
|
+
webcmd plugin install file://$PWD/plugins/<site>
|
|
43
|
+
webcmd validate <site>
|
|
44
|
+
webcmd <site> <command> --help
|
|
23
45
|
```
|
|
24
46
|
|
|
25
47
|
## Minimal Registry Shape
|
|
@@ -18,7 +18,7 @@ Hard stops before any code change:
|
|
|
18
18
|
|
|
19
19
|
Scope constraint:
|
|
20
20
|
|
|
21
|
-
- Modify only the file at `adapterSourcePath` in the trace `summary.md` front matter. That path is authoritative and may be `clis/<site>/...` in the repo or `~/.webcmd/clis/<site>/...` for user-local installs.
|
|
21
|
+
- Modify only the file at `adapterSourcePath` in the trace `summary.md` front matter. That path is authoritative and may be `clis/<site>/...` in the repo or `plugins/<site>/...` in a plugin repo or `~/.webcmd/clis/<site>/...` for user-local installs.
|
|
22
22
|
- Never modify `src/`, `extension/`, `tests/`, `package.json`, or `tsconfig.json` during autofix.
|
|
23
23
|
|
|
24
24
|
Retry budget: maximum **3 repair rounds** per failure. A round is diagnose -> patch -> retry. If 3 rounds do not resolve it, stop and report what was tried.
|
|
@@ -10,7 +10,7 @@ Webcmd turns websites, Electron desktop apps, and external CLIs into a uniform `
|
|
|
10
10
|
|
|
11
11
|
## The Three Pillars
|
|
12
12
|
|
|
13
|
-
- **Adapter commands:** `webcmd <site> <command> [...]`. Built-in adapters live in `clis/`;
|
|
13
|
+
- **Adapter commands:** `webcmd <site> <command> [...]`. Built-in adapters live in `clis/`; community adapters promoted to the main repo live as plugins under `plugins/`; private iteration adapters live in `~/.webcmd/clis/`. Each command has a strategy such as `PUBLIC`, `COOKIE`, `INTERCEPT`, `UI`, or `LOCAL`.
|
|
14
14
|
- **Browser driving:** `webcmd browser *` subcommands (`open`, `state`, `click`, `type`, `select`, `find`, `extract`, `network`) for ad-hoc interaction when no adapter covers the task. See `webcmd-browser`.
|
|
15
15
|
- **External CLI passthrough:** `webcmd gh`, `webcmd docker`, `webcmd vercel`, and similar wrappers. Manage them with `webcmd external install <name>` or `webcmd external register <name>`.
|
|
16
16
|
|
|
@@ -102,10 +102,13 @@ The error envelope includes a `trace` block pointing at `summary.md`. Patch only
|
|
|
102
102
|
|
|
103
103
|
## Writing An Adapter
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
Storage paths:
|
|
106
106
|
|
|
107
107
|
- Private: `~/.webcmd/clis/<site>/<command>.js`
|
|
108
|
-
- Public
|
|
108
|
+
- Public (official bundle): `clis/<site>/<command>.js`
|
|
109
|
+
- Public (community PRs): `plugins/<site>/` plus root `webcmd-plugin.json` registration
|
|
110
|
+
|
|
111
|
+
The main Webcmd repo is itself a plugin monorepo: promoted community CLIs belong under `plugins/<site>/` and must be registered in the root `webcmd-plugin.json`.
|
|
109
112
|
|
|
110
113
|
Scaffolding and checks:
|
|
111
114
|
|
|
@@ -126,9 +129,12 @@ webcmd plugin list [-f json]
|
|
|
126
129
|
webcmd plugin update [name] | --all
|
|
127
130
|
webcmd plugin uninstall <name>
|
|
128
131
|
webcmd plugin create <name>
|
|
132
|
+
webcmd plugin search [query]
|
|
129
133
|
```
|
|
130
134
|
|
|
131
|
-
Plugins are
|
|
135
|
+
Plugins are installable extensions pulled from git or local paths. Main-repo community CLIs are exposed through the root plugin catalog manifest, not bundled into npm's `clis/` set.
|
|
136
|
+
|
|
137
|
+
> **Note:** The repository's `plugins/` directory is not shipped in the npm package. Find the required plugin with `webcmd plugin search`, then install its `installSource` with `webcmd plugin install <installSource>`.
|
|
132
138
|
|
|
133
139
|
## External CLI Passthrough
|
|
134
140
|
|