@jackwener/opencli 0.1.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/.github/workflows/ci.yml +26 -0
- package/.github/workflows/release.yml +40 -0
- package/README.md +67 -0
- package/SKILL.md +230 -0
- package/dist/bilibili.d.ts +13 -0
- package/dist/bilibili.js +93 -0
- package/dist/browser.d.ts +48 -0
- package/dist/browser.js +261 -0
- package/dist/clis/bilibili/favorite.d.ts +1 -0
- package/dist/clis/bilibili/favorite.js +39 -0
- package/dist/clis/bilibili/feed.d.ts +1 -0
- package/dist/clis/bilibili/feed.js +64 -0
- package/dist/clis/bilibili/history.d.ts +1 -0
- package/dist/clis/bilibili/history.js +44 -0
- package/dist/clis/bilibili/me.d.ts +1 -0
- package/dist/clis/bilibili/me.js +13 -0
- package/dist/clis/bilibili/search.d.ts +1 -0
- package/dist/clis/bilibili/search.js +24 -0
- package/dist/clis/bilibili/user-videos.d.ts +1 -0
- package/dist/clis/bilibili/user-videos.js +38 -0
- package/dist/clis/github/search.d.ts +1 -0
- package/dist/clis/github/search.js +20 -0
- package/dist/clis/index.d.ts +13 -0
- package/dist/clis/index.js +16 -0
- package/dist/clis/zhihu/search.d.ts +1 -0
- package/dist/clis/zhihu/search.js +58 -0
- package/dist/engine.d.ts +6 -0
- package/dist/engine.js +77 -0
- package/dist/explore.d.ts +17 -0
- package/dist/explore.js +603 -0
- package/dist/generate.d.ts +11 -0
- package/dist/generate.js +134 -0
- package/dist/main.d.ts +5 -0
- package/dist/main.js +117 -0
- package/dist/output.d.ts +11 -0
- package/dist/output.js +98 -0
- package/dist/pipeline.d.ts +9 -0
- package/dist/pipeline.js +315 -0
- package/dist/promote.d.ts +1 -0
- package/dist/promote.js +3 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +2 -0
- package/dist/registry.d.ts +50 -0
- package/dist/registry.js +42 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +27 -0
- package/dist/scaffold.d.ts +2 -0
- package/dist/scaffold.js +2 -0
- package/dist/smoke.d.ts +2 -0
- package/dist/smoke.js +2 -0
- package/dist/snapshotFormatter.d.ts +9 -0
- package/dist/snapshotFormatter.js +41 -0
- package/dist/synthesize.d.ts +10 -0
- package/dist/synthesize.js +191 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.js +73 -0
- package/dist/verify.d.ts +2 -0
- package/dist/verify.js +9 -0
- package/package.json +47 -0
- package/src/bilibili.ts +111 -0
- package/src/browser.ts +260 -0
- package/src/clis/bilibili/favorite.ts +42 -0
- package/src/clis/bilibili/feed.ts +71 -0
- package/src/clis/bilibili/history.ts +48 -0
- package/src/clis/bilibili/hot.yaml +38 -0
- package/src/clis/bilibili/me.ts +14 -0
- package/src/clis/bilibili/search.ts +25 -0
- package/src/clis/bilibili/user-videos.ts +42 -0
- package/src/clis/github/search.ts +21 -0
- package/src/clis/github/trending.yaml +58 -0
- package/src/clis/hackernews/top.yaml +36 -0
- package/src/clis/index.ts +19 -0
- package/src/clis/twitter/trending.yaml +40 -0
- package/src/clis/v2ex/hot.yaml +29 -0
- package/src/clis/v2ex/latest.yaml +28 -0
- package/src/clis/zhihu/hot.yaml +28 -0
- package/src/clis/zhihu/search.ts +65 -0
- package/src/engine.ts +86 -0
- package/src/explore.ts +648 -0
- package/src/generate.ts +145 -0
- package/src/main.ts +103 -0
- package/src/output.ts +96 -0
- package/src/pipeline.ts +295 -0
- package/src/promote.ts +3 -0
- package/src/register.ts +2 -0
- package/src/registry.ts +87 -0
- package/src/runtime.ts +36 -0
- package/src/scaffold.ts +2 -0
- package/src/smoke.ts +2 -0
- package/src/snapshotFormatter.ts +51 -0
- package/src/synthesize.ts +210 -0
- package/src/validate.ts +55 -0
- package/src/verify.ts +9 -0
- package/tsconfig.json +17 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core registry: Strategy enum, Arg/CliCommand interfaces, cli() registration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export enum Strategy {
|
|
6
|
+
PUBLIC = 'public',
|
|
7
|
+
COOKIE = 'cookie',
|
|
8
|
+
HEADER = 'header',
|
|
9
|
+
INTERCEPT = 'intercept',
|
|
10
|
+
UI = 'ui',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Arg {
|
|
14
|
+
name: string;
|
|
15
|
+
type?: string;
|
|
16
|
+
default?: any;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
help?: string;
|
|
19
|
+
choices?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface CliCommand {
|
|
23
|
+
site: string;
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
domain?: string;
|
|
27
|
+
strategy?: Strategy;
|
|
28
|
+
browser?: boolean;
|
|
29
|
+
args: Arg[];
|
|
30
|
+
columns?: string[];
|
|
31
|
+
func?: (page: any, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
|
32
|
+
pipeline?: any[];
|
|
33
|
+
timeoutSeconds?: number;
|
|
34
|
+
source?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CliOptions {
|
|
38
|
+
site: string;
|
|
39
|
+
name: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
domain?: string;
|
|
42
|
+
strategy?: Strategy;
|
|
43
|
+
browser?: boolean;
|
|
44
|
+
args?: Arg[];
|
|
45
|
+
columns?: string[];
|
|
46
|
+
func?: (page: any, kwargs: Record<string, any>, debug?: boolean) => Promise<any>;
|
|
47
|
+
pipeline?: any[];
|
|
48
|
+
timeoutSeconds?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const _registry = new Map<string, CliCommand>();
|
|
52
|
+
|
|
53
|
+
export function cli(opts: CliOptions): CliCommand {
|
|
54
|
+
const cmd: CliCommand = {
|
|
55
|
+
site: opts.site,
|
|
56
|
+
name: opts.name,
|
|
57
|
+
description: opts.description ?? '',
|
|
58
|
+
domain: opts.domain,
|
|
59
|
+
strategy: opts.strategy ?? (opts.browser === false ? Strategy.PUBLIC : Strategy.COOKIE),
|
|
60
|
+
browser: opts.browser ?? (opts.strategy === Strategy.PUBLIC ? false : true),
|
|
61
|
+
args: opts.args ?? [],
|
|
62
|
+
columns: opts.columns,
|
|
63
|
+
func: opts.func,
|
|
64
|
+
pipeline: opts.pipeline,
|
|
65
|
+
timeoutSeconds: opts.timeoutSeconds,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const key = fullName(cmd);
|
|
69
|
+
_registry.set(key, cmd);
|
|
70
|
+
return cmd;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getRegistry(): Map<string, CliCommand> {
|
|
74
|
+
return _registry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function fullName(cmd: CliCommand): string {
|
|
78
|
+
return `${cmd.site}/${cmd.name}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function strategyLabel(cmd: CliCommand): string {
|
|
82
|
+
return cmd.strategy ?? 'public';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function registerCommand(cmd: CliCommand): void {
|
|
86
|
+
_registry.set(fullName(cmd), cmd);
|
|
87
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime utilities: timeouts and browser session management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_BROWSER_CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10);
|
|
6
|
+
export const DEFAULT_BROWSER_COMMAND_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_COMMAND_TIMEOUT ?? '45', 10);
|
|
7
|
+
export const DEFAULT_BROWSER_EXPLORE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_EXPLORE_TIMEOUT ?? '120', 10);
|
|
8
|
+
export const DEFAULT_BROWSER_SMOKE_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_SMOKE_TIMEOUT ?? '60', 10);
|
|
9
|
+
|
|
10
|
+
export async function runWithTimeout<T>(
|
|
11
|
+
promise: Promise<T>,
|
|
12
|
+
opts: { timeout: number; label?: string },
|
|
13
|
+
): Promise<T> {
|
|
14
|
+
return new Promise<T>((resolve, reject) => {
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
reject(new Error(`${opts.label ?? 'Operation'} timed out after ${opts.timeout}s`));
|
|
17
|
+
}, opts.timeout * 1000);
|
|
18
|
+
|
|
19
|
+
promise
|
|
20
|
+
.then((result) => { clearTimeout(timer); resolve(result); })
|
|
21
|
+
.catch((err) => { clearTimeout(timer); reject(err); });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function browserSession<T>(
|
|
26
|
+
BrowserFactory: new () => any,
|
|
27
|
+
fn: (page: any) => Promise<T>,
|
|
28
|
+
): Promise<T> {
|
|
29
|
+
const mcp = new BrowserFactory();
|
|
30
|
+
try {
|
|
31
|
+
const page = await mcp.connect({ timeout: DEFAULT_BROWSER_CONNECT_TIMEOUT });
|
|
32
|
+
return await fn(page);
|
|
33
|
+
} finally {
|
|
34
|
+
await mcp.close().catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/scaffold.ts
ADDED
package/src/smoke.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aria snapshot formatter: parses Playwright MCP snapshot text into clean format.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface FormatOptions {
|
|
6
|
+
interactive?: boolean;
|
|
7
|
+
compact?: boolean;
|
|
8
|
+
maxDepth?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatSnapshot(raw: string, opts: FormatOptions = {}): string {
|
|
12
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
13
|
+
const lines = raw.split('\n');
|
|
14
|
+
const result: string[] = [];
|
|
15
|
+
let refCounter = 0;
|
|
16
|
+
|
|
17
|
+
for (const line of lines) {
|
|
18
|
+
if (!line.trim()) continue;
|
|
19
|
+
const indent = line.length - line.trimStart().length;
|
|
20
|
+
const depth = Math.floor(indent / 2);
|
|
21
|
+
if (opts.maxDepth && depth > opts.maxDepth) continue;
|
|
22
|
+
|
|
23
|
+
let content = line.trimStart();
|
|
24
|
+
|
|
25
|
+
// Skip non-interactive elements in interactive mode
|
|
26
|
+
if (opts.interactive) {
|
|
27
|
+
const interactiveRoles = ['button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'tab', 'menuitem', 'option'];
|
|
28
|
+
const role = content.split(/[\s[]/)[0]?.toLowerCase() ?? '';
|
|
29
|
+
if (!interactiveRoles.some(r => role.includes(r)) && depth > 1) continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Compact: strip verbose role descriptions
|
|
33
|
+
if (opts.compact) {
|
|
34
|
+
content = content
|
|
35
|
+
.replace(/\s*\[.*?\]\s*/g, ' ')
|
|
36
|
+
.replace(/\s+/g, ' ')
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Assign refs to interactive elements
|
|
41
|
+
const interactivePattern = /^(button|link|textbox|checkbox|radio|combobox|tab|menuitem|option)\b/i;
|
|
42
|
+
if (interactivePattern.test(content)) {
|
|
43
|
+
refCounter++;
|
|
44
|
+
content = `[@${refCounter}] ${content}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
result.push(' '.repeat(depth) + content);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result.join('\n');
|
|
51
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthesize: turn explore capabilities into ready-to-use CLI definitions.
|
|
3
|
+
*
|
|
4
|
+
* Takes the structured capabilities from Deep Explore and generates
|
|
5
|
+
* YAML pipeline files that can be directly registered as CLI commands.
|
|
6
|
+
*
|
|
7
|
+
* This is the bridge between discovery (explore) and usability (CLI).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import yaml from 'js-yaml';
|
|
13
|
+
|
|
14
|
+
export function synthesizeFromExplore(target: string, opts: any = {}): any {
|
|
15
|
+
const exploreDir = fs.existsSync(target) ? target : path.join('.opencli', 'explore', target);
|
|
16
|
+
if (!fs.existsSync(exploreDir)) throw new Error(`Explore dir not found: ${target}`);
|
|
17
|
+
|
|
18
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(exploreDir, 'manifest.json'), 'utf-8'));
|
|
19
|
+
const capabilities = JSON.parse(fs.readFileSync(path.join(exploreDir, 'capabilities.json'), 'utf-8'));
|
|
20
|
+
const endpoints = JSON.parse(fs.readFileSync(path.join(exploreDir, 'endpoints.json'), 'utf-8'));
|
|
21
|
+
const auth = JSON.parse(fs.readFileSync(path.join(exploreDir, 'auth.json'), 'utf-8'));
|
|
22
|
+
|
|
23
|
+
const targetDir = opts.outDir ?? path.join(exploreDir, 'candidates');
|
|
24
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
25
|
+
|
|
26
|
+
const site = manifest.site;
|
|
27
|
+
const topN = opts.top ?? 5;
|
|
28
|
+
const candidates: any[] = [];
|
|
29
|
+
|
|
30
|
+
// Sort capabilities by confidence
|
|
31
|
+
const sortedCaps = [...capabilities]
|
|
32
|
+
.sort((a: any, b: any) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
33
|
+
.slice(0, topN);
|
|
34
|
+
|
|
35
|
+
for (const cap of sortedCaps) {
|
|
36
|
+
// Find the matching endpoint for more detail
|
|
37
|
+
const endpoint = endpoints.find((ep: any) => ep.pattern === cap.endpoint) ??
|
|
38
|
+
endpoints[0];
|
|
39
|
+
|
|
40
|
+
const candidate = buildCandidateYaml(site, manifest, cap, endpoint);
|
|
41
|
+
const fileName = `${cap.name}.yaml`;
|
|
42
|
+
const filePath = path.join(targetDir, fileName);
|
|
43
|
+
fs.writeFileSync(filePath, yaml.dump(candidate.yaml, { sortKeys: false, lineWidth: 120 }));
|
|
44
|
+
|
|
45
|
+
candidates.push({
|
|
46
|
+
name: cap.name,
|
|
47
|
+
path: filePath,
|
|
48
|
+
strategy: cap.strategy,
|
|
49
|
+
endpoint: cap.endpoint,
|
|
50
|
+
confidence: cap.confidence,
|
|
51
|
+
columns: candidate.yaml.columns,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const index = {
|
|
56
|
+
site,
|
|
57
|
+
target_url: manifest.target_url,
|
|
58
|
+
generated_from: exploreDir,
|
|
59
|
+
candidate_count: candidates.length,
|
|
60
|
+
candidates,
|
|
61
|
+
};
|
|
62
|
+
fs.writeFileSync(path.join(targetDir, 'candidates.json'), JSON.stringify(index, null, 2));
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
site,
|
|
66
|
+
explore_dir: exploreDir,
|
|
67
|
+
out_dir: targetDir,
|
|
68
|
+
candidate_count: candidates.length,
|
|
69
|
+
candidates,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Volatile params to strip from generated URLs */
|
|
74
|
+
const VOLATILE_PARAMS = new Set(['w_rid', 'wts', 'callback', '_', 'timestamp', 't', 'nonce', 'sign']);
|
|
75
|
+
const SEARCH_PARAM_NAMES = new Set(['q', 'query', 'keyword', 'search', 'wd', 'kw', 'w', 'search_query']);
|
|
76
|
+
const LIMIT_PARAM_NAMES = new Set(['ps', 'page_size', 'limit', 'count', 'per_page', 'size', 'num']);
|
|
77
|
+
const PAGE_PARAM_NAMES = new Set(['pn', 'page', 'page_num', 'offset', 'cursor']);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a clean templated URL from a raw API URL.
|
|
81
|
+
* - Strips volatile params (w_rid, wts, etc.)
|
|
82
|
+
* - Templates search, limit, and pagination params
|
|
83
|
+
* - Builds URL string manually to avoid URL encoding of ${{ }} expressions
|
|
84
|
+
*/
|
|
85
|
+
function buildTemplatedUrl(rawUrl: string, cap: any, endpoint: any): string {
|
|
86
|
+
try {
|
|
87
|
+
const u = new URL(rawUrl);
|
|
88
|
+
const base = `${u.protocol}//${u.host}${u.pathname}`;
|
|
89
|
+
const params: Array<[string, string]> = [];
|
|
90
|
+
|
|
91
|
+
const hasKeyword = cap.recommendedArgs?.some((a: any) => a.name === 'keyword');
|
|
92
|
+
|
|
93
|
+
u.searchParams.forEach((v, k) => {
|
|
94
|
+
// Skip volatile params
|
|
95
|
+
if (VOLATILE_PARAMS.has(k)) return;
|
|
96
|
+
|
|
97
|
+
// Template known param types
|
|
98
|
+
if (hasKeyword && SEARCH_PARAM_NAMES.has(k)) {
|
|
99
|
+
params.push([k, '${{ args.keyword }}']);
|
|
100
|
+
} else if (LIMIT_PARAM_NAMES.has(k)) {
|
|
101
|
+
params.push([k, '${{ args.limit | default(20) }}']);
|
|
102
|
+
} else if (PAGE_PARAM_NAMES.has(k)) {
|
|
103
|
+
params.push([k, '${{ args.page | default(1) }}']);
|
|
104
|
+
} else {
|
|
105
|
+
params.push([k, v]);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (params.length === 0) return base;
|
|
110
|
+
return base + '?' + params.map(([k, v]) => `${k}=${v}`).join('&');
|
|
111
|
+
} catch {
|
|
112
|
+
return rawUrl;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build a YAML pipeline definition from a capability + endpoint.
|
|
118
|
+
*/
|
|
119
|
+
function buildCandidateYaml(site: string, manifest: any, cap: any, endpoint: any): { name: string; yaml: any } {
|
|
120
|
+
const needsBrowser = cap.strategy !== 'public';
|
|
121
|
+
const pipeline: any[] = [];
|
|
122
|
+
|
|
123
|
+
// Step 1: Navigate (if browser-based)
|
|
124
|
+
if (needsBrowser) {
|
|
125
|
+
pipeline.push({ navigate: manifest.target_url });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Step 2: Fetch the API — build a clean URL with templates
|
|
129
|
+
const rawUrl = endpoint?.url ?? manifest.target_url;
|
|
130
|
+
const fetchStep: any = { url: buildTemplatedUrl(rawUrl, cap, endpoint) };
|
|
131
|
+
|
|
132
|
+
pipeline.push({ fetch: fetchStep });
|
|
133
|
+
|
|
134
|
+
// Step 3: Select the item path
|
|
135
|
+
if (cap.itemPath) {
|
|
136
|
+
pipeline.push({ select: cap.itemPath });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Step 4: Map fields to columns
|
|
140
|
+
const mapStep: Record<string, string> = {};
|
|
141
|
+
const columns = cap.recommendedColumns ?? ['title', 'url'];
|
|
142
|
+
|
|
143
|
+
// Add a rank column if not doing search
|
|
144
|
+
if (!cap.recommendedArgs?.some((a: any) => a.name === 'keyword')) {
|
|
145
|
+
mapStep['rank'] = '${{ index + 1 }}';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Build field mappings from the endpoint's detected fields
|
|
149
|
+
const detectedFields = endpoint?.detectedFields ?? {};
|
|
150
|
+
for (const col of columns) {
|
|
151
|
+
const fieldPath = detectedFields[col];
|
|
152
|
+
if (fieldPath) {
|
|
153
|
+
mapStep[col] = `\${{ item.${fieldPath} }}`;
|
|
154
|
+
} else {
|
|
155
|
+
mapStep[col] = `\${{ item.${col} }}`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pipeline.push({ map: mapStep });
|
|
160
|
+
|
|
161
|
+
// Step 5: Limit
|
|
162
|
+
pipeline.push({ limit: '${{ args.limit | default(20) }}' });
|
|
163
|
+
|
|
164
|
+
// Build args definition
|
|
165
|
+
const argsDef: Record<string, any> = {};
|
|
166
|
+
for (const arg of cap.recommendedArgs ?? []) {
|
|
167
|
+
const def: any = { type: arg.type ?? 'str' };
|
|
168
|
+
if (arg.required) def.required = true;
|
|
169
|
+
if (arg.default != null) def.default = arg.default;
|
|
170
|
+
if (arg.name === 'keyword') def.description = 'Search keyword';
|
|
171
|
+
else if (arg.name === 'limit') def.description = 'Number of items to return';
|
|
172
|
+
else if (arg.name === 'page') def.description = 'Page number';
|
|
173
|
+
argsDef[arg.name] = def;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Ensure limit arg always exists
|
|
177
|
+
if (!argsDef['limit']) {
|
|
178
|
+
argsDef['limit'] = { type: 'int', default: 20, description: 'Number of items to return' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const allColumns = Object.keys(mapStep);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
name: cap.name,
|
|
185
|
+
yaml: {
|
|
186
|
+
site,
|
|
187
|
+
name: cap.name,
|
|
188
|
+
description: `${site} ${cap.name} (auto-generated)`,
|
|
189
|
+
domain: manifest.final_url ? new URL(manifest.final_url).hostname : undefined,
|
|
190
|
+
strategy: cap.strategy,
|
|
191
|
+
browser: needsBrowser,
|
|
192
|
+
args: argsDef,
|
|
193
|
+
pipeline,
|
|
194
|
+
columns: allColumns,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function renderSynthesizeSummary(r: any): string {
|
|
200
|
+
const lines = [
|
|
201
|
+
'opencli synthesize: OK',
|
|
202
|
+
`Site: ${r.site}`,
|
|
203
|
+
`Source: ${r.explore_dir}`,
|
|
204
|
+
`Candidates: ${r.candidate_count}`,
|
|
205
|
+
];
|
|
206
|
+
for (const c of r.candidates ?? []) {
|
|
207
|
+
lines.push(` • ${c.name} (${c.strategy}, ${(c.confidence * 100).toFixed(0)}% confidence) → ${c.path}`);
|
|
208
|
+
}
|
|
209
|
+
return lines.join('\n');
|
|
210
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Validate CLI definitions. */
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
|
|
6
|
+
export function validateClisWithTarget(dirs: string[], target?: string): any {
|
|
7
|
+
const results: any[] = [];
|
|
8
|
+
let errors = 0; let warnings = 0; let files = 0;
|
|
9
|
+
for (const dir of dirs) {
|
|
10
|
+
if (!fs.existsSync(dir)) continue;
|
|
11
|
+
for (const site of fs.readdirSync(dir)) {
|
|
12
|
+
if (target && site !== target && !target.startsWith(site + '/')) continue;
|
|
13
|
+
const siteDir = path.join(dir, site);
|
|
14
|
+
if (!fs.statSync(siteDir).isDirectory()) continue;
|
|
15
|
+
for (const file of fs.readdirSync(siteDir)) {
|
|
16
|
+
if (!file.endsWith('.yaml') && !file.endsWith('.yml')) continue;
|
|
17
|
+
if (target && target.includes('/') && !target.endsWith(file.replace(/\.(yaml|yml)$/, ''))) continue;
|
|
18
|
+
files++;
|
|
19
|
+
const filePath = path.join(siteDir, file);
|
|
20
|
+
const r = validateYamlFile(filePath);
|
|
21
|
+
results.push(r);
|
|
22
|
+
errors += r.errors.length;
|
|
23
|
+
warnings += r.warnings.length;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { ok: errors === 0, results, errors, warnings, files };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function validateYamlFile(filePath: string): any {
|
|
31
|
+
const errors: string[] = []; const warnings: string[] = [];
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
34
|
+
const def = yaml.load(raw) as any;
|
|
35
|
+
if (!def || typeof def !== 'object') { errors.push('Not a valid YAML object'); return { path: filePath, errors, warnings }; }
|
|
36
|
+
if (!def.site) errors.push('Missing "site"');
|
|
37
|
+
if (!def.name) errors.push('Missing "name"');
|
|
38
|
+
if (def.pipeline && !Array.isArray(def.pipeline)) errors.push('"pipeline" must be an array');
|
|
39
|
+
if (def.columns && !Array.isArray(def.columns)) errors.push('"columns" must be an array');
|
|
40
|
+
if (def.args && typeof def.args !== 'object') errors.push('"args" must be an object');
|
|
41
|
+
} catch (e: any) { errors.push(`YAML parse error: ${e.message}`); }
|
|
42
|
+
return { path: filePath, errors, warnings };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderValidationReport(report: any): string {
|
|
46
|
+
const lines = [`opencli validate: ${report.ok ? 'PASS' : 'FAIL'}`, `Checked ${report.results.length} CLI(s) in ${report.files} file(s)`, `Errors: ${report.errors} Warnings: ${report.warnings}`];
|
|
47
|
+
for (const r of report.results) {
|
|
48
|
+
if (r.errors.length > 0 || r.warnings.length > 0) {
|
|
49
|
+
lines.push(`\n${r.path}:`);
|
|
50
|
+
for (const e of r.errors) lines.push(` ❌ ${e}`);
|
|
51
|
+
for (const w of r.warnings) lines.push(` ⚠️ ${w}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Verification: validate + smoke. */
|
|
2
|
+
import { validateClisWithTarget, renderValidationReport } from './validate.js';
|
|
3
|
+
export async function verifyClis(opts: any): Promise<any> {
|
|
4
|
+
const report = validateClisWithTarget([opts.builtinClis, opts.userClis], opts.target);
|
|
5
|
+
return { ok: report.ok, validation: report, smoke: null };
|
|
6
|
+
}
|
|
7
|
+
export function renderVerifyReport(report: any): string {
|
|
8
|
+
return renderValidationReport(report.validation);
|
|
9
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": false,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"incremental": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|