@jackwener/opencli 1.5.3 → 1.5.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/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
- package/README.md +213 -18
- package/dist/build-manifest.d.ts +2 -3
- package/dist/build-manifest.js +75 -170
- package/dist/build-manifest.test.js +113 -88
- package/dist/cli-manifest.json +1253 -1105
- package/dist/cli.js +14 -14
- package/dist/clis/antigravity/serve.js +2 -2
- package/dist/clis/sinafinance/rolling-news.d.ts +4 -0
- package/dist/clis/sinafinance/rolling-news.js +40 -0
- package/dist/clis/sinafinance/stock.d.ts +8 -0
- package/dist/clis/sinafinance/stock.js +117 -0
- package/dist/commanderAdapter.js +26 -3
- package/dist/daemon.js +19 -7
- package/dist/errors.d.ts +29 -1
- package/dist/errors.js +49 -11
- package/dist/external-clis.yaml +16 -0
- package/dist/external.js +3 -3
- package/dist/main.js +2 -1
- package/dist/serialization.js +6 -1
- package/dist/serialization.test.d.ts +1 -0
- package/dist/serialization.test.js +23 -0
- package/dist/tui.js +2 -1
- package/docs/adapters/browser/sinafinance.md +56 -6
- package/extension/dist/background.js +12 -6
- package/extension/manifest.json +2 -2
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +21 -6
- package/extension/src/protocol.ts +2 -1
- package/package.json +1 -1
- package/src/build-manifest.test.ts +117 -88
- package/src/build-manifest.ts +81 -180
- package/src/cli.ts +14 -14
- package/src/clis/antigravity/serve.ts +2 -2
- package/src/clis/sinafinance/rolling-news.ts +42 -0
- package/src/clis/sinafinance/stock.ts +127 -0
- package/src/commanderAdapter.ts +25 -2
- package/src/daemon.ts +21 -8
- package/src/errors.ts +71 -10
- package/src/external-clis.yaml +16 -0
- package/src/external.ts +3 -3
- package/src/main.ts +2 -1
- package/src/serialization.test.ts +26 -0
- package/src/serialization.ts +6 -1
- package/src/tui.ts +2 -1
package/src/build-manifest.ts
CHANGED
|
@@ -14,6 +14,7 @@ import * as path from 'node:path';
|
|
|
14
14
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
15
15
|
import yaml from 'js-yaml';
|
|
16
16
|
import { getErrorMessage } from './errors.js';
|
|
17
|
+
import { fullName, getRegistry, type CliCommand } from './registry.js';
|
|
17
18
|
|
|
18
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const CLIS_DIR = path.resolve(__dirname, 'clis');
|
|
@@ -52,116 +53,50 @@ import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
|
|
|
52
53
|
|
|
53
54
|
import { isRecord } from './utils.js';
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const ch = source[i];
|
|
68
|
-
|
|
69
|
-
if (quote) {
|
|
70
|
-
if (escaped) {
|
|
71
|
-
escaped = false;
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
if (ch === '\\') {
|
|
75
|
-
escaped = true;
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
if (ch === quote) quote = null;
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (ch === '"' || ch === '\'' || ch === '`') {
|
|
83
|
-
quote = ch;
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (ch === openChar) {
|
|
88
|
-
depth++;
|
|
89
|
-
} else if (ch === closeChar) {
|
|
90
|
-
depth--;
|
|
91
|
-
if (depth === 0) {
|
|
92
|
-
return source.slice(startIndex + 1, i);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return null;
|
|
56
|
+
const CLI_MODULE_PATTERN = /\bcli\s*\(/;
|
|
57
|
+
|
|
58
|
+
function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
|
|
59
|
+
return args.map(arg => ({
|
|
60
|
+
name: arg.name,
|
|
61
|
+
type: arg.type ?? 'str',
|
|
62
|
+
default: arg.default,
|
|
63
|
+
required: !!arg.required,
|
|
64
|
+
positional: arg.positional || undefined,
|
|
65
|
+
help: arg.help ?? '',
|
|
66
|
+
choices: arg.choices,
|
|
67
|
+
}));
|
|
98
68
|
}
|
|
99
69
|
|
|
100
|
-
function
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const bracketIndex = source.indexOf('[', argsMatch.index);
|
|
105
|
-
if (bracketIndex === -1) return null;
|
|
106
|
-
|
|
107
|
-
return extractBalancedBlock(source, bracketIndex, '[', ']');
|
|
70
|
+
function toTsModulePath(filePath: string, site: string): string {
|
|
71
|
+
const baseName = path.basename(filePath, path.extname(filePath));
|
|
72
|
+
return `${site}/${baseName}.js`;
|
|
108
73
|
}
|
|
109
74
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.
|
|
116
|
-
.map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
|
|
117
|
-
.filter(Boolean);
|
|
118
|
-
|
|
119
|
-
return values.length > 0 ? values : undefined;
|
|
75
|
+
function isCliCommandValue(value: unknown, site: string): value is CliCommand {
|
|
76
|
+
return isRecord(value)
|
|
77
|
+
&& typeof value.site === 'string'
|
|
78
|
+
&& value.site === site
|
|
79
|
+
&& typeof value.name === 'string'
|
|
80
|
+
&& Array.isArray(value.args);
|
|
120
81
|
}
|
|
121
82
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
let defaultVal: unknown = undefined;
|
|
141
|
-
if (defaultMatch) {
|
|
142
|
-
const raw = defaultMatch[1].trim();
|
|
143
|
-
if (raw === 'true') defaultVal = true;
|
|
144
|
-
else if (raw === 'false') defaultVal = false;
|
|
145
|
-
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
|
|
146
|
-
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
|
|
147
|
-
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
args.push({
|
|
151
|
-
name: nameMatch[1],
|
|
152
|
-
type: typeMatch?.[1] ?? 'str',
|
|
153
|
-
default: defaultVal,
|
|
154
|
-
required: requiredMatch?.[1] === 'true',
|
|
155
|
-
positional: positionalMatch?.[1] === 'true' || undefined,
|
|
156
|
-
help: helpMatch?.[1] ?? '',
|
|
157
|
-
choices: parseInlineChoices(body),
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
cursor = objectStart + body.length;
|
|
161
|
-
if (cursor <= objectStart) break; // safety: prevent infinite loop
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return args;
|
|
83
|
+
function toManifestEntry(cmd: CliCommand, modulePath: string): ManifestEntry {
|
|
84
|
+
return {
|
|
85
|
+
site: cmd.site,
|
|
86
|
+
name: cmd.name,
|
|
87
|
+
description: cmd.description ?? '',
|
|
88
|
+
domain: cmd.domain,
|
|
89
|
+
strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
|
|
90
|
+
browser: cmd.browser ?? true,
|
|
91
|
+
args: toManifestArgs(cmd.args),
|
|
92
|
+
columns: cmd.columns,
|
|
93
|
+
timeout: cmd.timeoutSeconds,
|
|
94
|
+
deprecated: cmd.deprecated,
|
|
95
|
+
replacedBy: cmd.replacedBy,
|
|
96
|
+
type: 'ts',
|
|
97
|
+
modulePath,
|
|
98
|
+
navigateBefore: cmd.navigateBefore,
|
|
99
|
+
};
|
|
165
100
|
}
|
|
166
101
|
|
|
167
102
|
function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
|
@@ -199,83 +134,49 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
|
|
|
199
134
|
}
|
|
200
135
|
}
|
|
201
136
|
|
|
202
|
-
export function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
137
|
+
export async function loadTsManifestEntries(
|
|
138
|
+
filePath: string,
|
|
139
|
+
site: string,
|
|
140
|
+
importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref),
|
|
141
|
+
): Promise<ManifestEntry[]> {
|
|
208
142
|
try {
|
|
209
143
|
const src = fs.readFileSync(filePath, 'utf-8');
|
|
210
144
|
|
|
211
145
|
// Helper/test modules should not appear as CLI commands in the manifest.
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// Extract columns
|
|
243
|
-
const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
|
|
244
|
-
if (colMatch) {
|
|
245
|
-
entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Extract args array items: { name: '...', ... }
|
|
249
|
-
const argsBlock = extractTsArgsBlock(src);
|
|
250
|
-
if (argsBlock) {
|
|
251
|
-
entry.args = parseTsArgsBlock(argsBlock);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Extract navigateBefore: false / true / 'https://...'
|
|
255
|
-
const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
|
|
256
|
-
if (navBoolMatch) {
|
|
257
|
-
entry.navigateBefore = navBoolMatch[1] === 'true';
|
|
258
|
-
} else {
|
|
259
|
-
const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
260
|
-
if (navStringMatch) entry.navigateBefore = navStringMatch[1];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
|
|
264
|
-
if (deprecatedBoolMatch) {
|
|
265
|
-
entry.deprecated = deprecatedBoolMatch[1] === 'true';
|
|
266
|
-
} else {
|
|
267
|
-
const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
268
|
-
if (deprecatedStringMatch) entry.deprecated = deprecatedStringMatch[1];
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
|
|
272
|
-
if (replacedByMatch) entry.replacedBy = replacedByMatch[1];
|
|
273
|
-
|
|
274
|
-
return entry;
|
|
146
|
+
if (!CLI_MODULE_PATTERN.test(src)) return [];
|
|
147
|
+
|
|
148
|
+
const modulePath = toTsModulePath(filePath, site);
|
|
149
|
+
const registry = getRegistry();
|
|
150
|
+
const before = new Map(registry.entries());
|
|
151
|
+
const mod = await importer(pathToFileURL(filePath).href);
|
|
152
|
+
|
|
153
|
+
const exportedCommands = Object.values(isRecord(mod) ? mod : {})
|
|
154
|
+
.filter(value => isCliCommandValue(value, site));
|
|
155
|
+
|
|
156
|
+
const runtimeCommands = exportedCommands.length > 0
|
|
157
|
+
? exportedCommands
|
|
158
|
+
: [...registry.entries()]
|
|
159
|
+
.filter(([key, cmd]) => {
|
|
160
|
+
if (cmd.site !== site) return false;
|
|
161
|
+
const previous = before.get(key);
|
|
162
|
+
return !previous || previous !== cmd;
|
|
163
|
+
})
|
|
164
|
+
.map(([, cmd]) => cmd);
|
|
165
|
+
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
return runtimeCommands
|
|
168
|
+
.filter((cmd) => {
|
|
169
|
+
const key = fullName(cmd);
|
|
170
|
+
if (seen.has(key)) return false;
|
|
171
|
+
seen.add(key);
|
|
172
|
+
return true;
|
|
173
|
+
})
|
|
174
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
175
|
+
.map(cmd => toManifestEntry(cmd, modulePath));
|
|
275
176
|
} catch (err) {
|
|
276
177
|
// If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
|
|
277
178
|
process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
|
|
278
|
-
return
|
|
179
|
+
return [];
|
|
279
180
|
}
|
|
280
181
|
}
|
|
281
182
|
|
|
@@ -288,7 +189,7 @@ export function shouldReplaceManifestEntry(current: ManifestEntry, next: Manifes
|
|
|
288
189
|
return current.type === 'yaml' && next.type === 'ts';
|
|
289
190
|
}
|
|
290
191
|
|
|
291
|
-
export function buildManifest(): ManifestEntry[] {
|
|
192
|
+
export async function buildManifest(): Promise<ManifestEntry[]> {
|
|
292
193
|
const manifest = new Map<string, ManifestEntry>();
|
|
293
194
|
|
|
294
195
|
if (fs.existsSync(CLIS_DIR)) {
|
|
@@ -313,8 +214,8 @@ export function buildManifest(): ManifestEntry[] {
|
|
|
313
214
|
(file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
|
|
314
215
|
(file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')
|
|
315
216
|
) {
|
|
316
|
-
const
|
|
317
|
-
|
|
217
|
+
const entries = await loadTsManifestEntries(filePath, site);
|
|
218
|
+
for (const entry of entries) {
|
|
318
219
|
const key = `${entry.site}/${entry.name}`;
|
|
319
220
|
const existing = manifest.get(key);
|
|
320
221
|
if (!existing || shouldReplaceManifestEntry(existing, entry)) {
|
|
@@ -332,8 +233,8 @@ export function buildManifest(): ManifestEntry[] {
|
|
|
332
233
|
return [...manifest.values()];
|
|
333
234
|
}
|
|
334
235
|
|
|
335
|
-
function main(): void {
|
|
336
|
-
const manifest = buildManifest();
|
|
236
|
+
async function main(): Promise<void> {
|
|
237
|
+
const manifest = await buildManifest();
|
|
337
238
|
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
|
|
338
239
|
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
|
|
339
240
|
|
|
@@ -367,5 +268,5 @@ function main(): void {
|
|
|
367
268
|
|
|
368
269
|
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
|
369
270
|
if (entrypoint === import.meta.url) {
|
|
370
|
-
main();
|
|
271
|
+
void main();
|
|
371
272
|
}
|
package/src/cli.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { PKG_VERSION } from './version.js';
|
|
|
15
15
|
import { printCompletionScript } from './completion.js';
|
|
16
16
|
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
|
17
17
|
import { registerAllCommands } from './commanderAdapter.js';
|
|
18
|
-
import { getErrorMessage } from './errors.js';
|
|
18
|
+
import { EXIT_CODES, getErrorMessage } from './errors.js';
|
|
19
19
|
|
|
20
20
|
export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
21
21
|
const program = new Command();
|
|
@@ -120,7 +120,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
120
120
|
const { verifyClis, renderVerifyReport } = await import('./verify.js');
|
|
121
121
|
const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
|
|
122
122
|
console.log(renderVerifyReport(r));
|
|
123
|
-
process.exitCode = r.ok ?
|
|
123
|
+
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
// ── Built-in: explore / synthesize / generate / cascade ───────────────────
|
|
@@ -180,7 +180,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
180
180
|
workspace,
|
|
181
181
|
});
|
|
182
182
|
console.log(renderGenerateSummary(r));
|
|
183
|
-
process.exitCode = r.ok ?
|
|
183
|
+
process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
// ── Built-in: record ─────────────────────────────────────────────────────
|
|
@@ -204,7 +204,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
204
204
|
timeoutMs: parseInt(opts.timeout, 10),
|
|
205
205
|
});
|
|
206
206
|
console.log(renderRecordSummary(result));
|
|
207
|
-
process.exitCode = result.candidateCount > 0 ?
|
|
207
|
+
process.exitCode = result.candidateCount > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.EMPTY_RESULT;
|
|
208
208
|
});
|
|
209
209
|
|
|
210
210
|
program
|
|
@@ -272,7 +272,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
272
272
|
}
|
|
273
273
|
} catch (err) {
|
|
274
274
|
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
275
|
-
process.exitCode =
|
|
275
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
276
276
|
}
|
|
277
277
|
});
|
|
278
278
|
|
|
@@ -287,7 +287,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
287
287
|
console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
|
|
288
288
|
} catch (err) {
|
|
289
289
|
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
290
|
-
process.exitCode =
|
|
290
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
291
291
|
}
|
|
292
292
|
});
|
|
293
293
|
|
|
@@ -299,12 +299,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
299
299
|
.action(async (name: string | undefined, opts: { all?: boolean }) => {
|
|
300
300
|
if (!name && !opts.all) {
|
|
301
301
|
console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.'));
|
|
302
|
-
process.exitCode =
|
|
302
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
303
303
|
return;
|
|
304
304
|
}
|
|
305
305
|
if (name && opts.all) {
|
|
306
306
|
console.error(chalk.red('Error: Cannot specify both a plugin name and --all.'));
|
|
307
|
-
process.exitCode =
|
|
307
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
308
308
|
return;
|
|
309
309
|
}
|
|
310
310
|
|
|
@@ -335,7 +335,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
335
335
|
console.log();
|
|
336
336
|
if (hasErrors) {
|
|
337
337
|
console.error(chalk.red('Completed with some errors.'));
|
|
338
|
-
process.exitCode =
|
|
338
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
339
339
|
} else {
|
|
340
340
|
console.log(chalk.green('✅ All plugins updated successfully.'));
|
|
341
341
|
}
|
|
@@ -348,7 +348,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
348
348
|
console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
|
|
349
349
|
} catch (err) {
|
|
350
350
|
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
351
|
-
process.exitCode =
|
|
351
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
352
352
|
}
|
|
353
353
|
});
|
|
354
354
|
|
|
@@ -438,7 +438,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
438
438
|
console.log(chalk.dim(` opencli ${name} hello`));
|
|
439
439
|
} catch (err) {
|
|
440
440
|
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
441
|
-
process.exitCode =
|
|
441
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
442
442
|
}
|
|
443
443
|
});
|
|
444
444
|
|
|
@@ -454,7 +454,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
454
454
|
const ext = externalClis.find(e => e.name === name);
|
|
455
455
|
if (!ext) {
|
|
456
456
|
console.error(chalk.red(`External CLI '${name}' not found in registry.`));
|
|
457
|
-
process.exitCode =
|
|
457
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
458
458
|
return;
|
|
459
459
|
}
|
|
460
460
|
installExternalCli(ext);
|
|
@@ -480,7 +480,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
480
480
|
executeExternalCli(name, args, externalClis);
|
|
481
481
|
} catch (err) {
|
|
482
482
|
console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
|
|
483
|
-
process.exitCode =
|
|
483
|
+
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
|
|
@@ -525,7 +525,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
|
|
|
525
525
|
console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
|
|
526
526
|
}
|
|
527
527
|
program.outputHelp();
|
|
528
|
-
process.exitCode =
|
|
528
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
529
529
|
});
|
|
530
530
|
|
|
531
531
|
program.parse();
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
14
14
|
import { CDPBridge } from '../../browser/cdp.js';
|
|
15
15
|
import type { IPage } from '../../types.js';
|
|
16
|
-
import { getErrorMessage } from '../../errors.js';
|
|
16
|
+
import { EXIT_CODES, getErrorMessage } from '../../errors.js';
|
|
17
17
|
|
|
18
18
|
// ─── Types ───────────────────────────────────────────────────────────
|
|
19
19
|
|
|
@@ -594,7 +594,7 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
|
|
|
594
594
|
console.error('\n[serve] Shutting down...');
|
|
595
595
|
cdp?.close().catch(() => {});
|
|
596
596
|
server.close();
|
|
597
|
-
process.exit(
|
|
597
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
598
598
|
};
|
|
599
599
|
process.on('SIGTERM', shutdown);
|
|
600
600
|
process.on('SIGINT', shutdown);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sinafinance rolling news feed
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { cli, Strategy } from '../../registry.js';
|
|
6
|
+
|
|
7
|
+
cli({
|
|
8
|
+
site: 'sinafinance',
|
|
9
|
+
name: 'rolling-news',
|
|
10
|
+
description: '新浪财经滚动新闻',
|
|
11
|
+
domain: 'finance.sina.com.cn/roll',
|
|
12
|
+
strategy: Strategy.COOKIE,
|
|
13
|
+
args: [],
|
|
14
|
+
columns: ['column', 'title', 'date', 'url'],
|
|
15
|
+
func: async (page, _args) => {
|
|
16
|
+
await page.goto(`https://finance.sina.com.cn/roll/#pageid=384&lid=2519`);
|
|
17
|
+
await page.wait({ selector: '.d_list_txt li', timeout: 10000 });
|
|
18
|
+
|
|
19
|
+
const payload = await page.evaluate(`
|
|
20
|
+
(() => {
|
|
21
|
+
const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
22
|
+
const results = [];
|
|
23
|
+
document.querySelectorAll('.d_list_txt li').forEach(el => {
|
|
24
|
+
const titleEl = el.querySelector('.c_tit a');
|
|
25
|
+
const columnEl = el.querySelector('.c_chl');
|
|
26
|
+
const dateEl = el.querySelector('.c_time');
|
|
27
|
+
const url = titleEl?.getAttribute('href') || '';
|
|
28
|
+
if (!url) return;
|
|
29
|
+
results.push({
|
|
30
|
+
title: cleanText(titleEl?.textContent || ''),
|
|
31
|
+
column: cleanText(columnEl?.textContent || ''),
|
|
32
|
+
date: cleanText(dateEl?.textContent || ''),
|
|
33
|
+
url: url,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
return results;
|
|
37
|
+
})()
|
|
38
|
+
`);
|
|
39
|
+
if (!Array.isArray(payload)) return [];
|
|
40
|
+
return payload;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sinafinance stock quote — A股 / 港股 / 美股
|
|
3
|
+
*
|
|
4
|
+
* Uses two public Sina APIs (no browser required):
|
|
5
|
+
* suggest3.sinajs.cn — symbol search
|
|
6
|
+
* hq.sinajs.cn — real-time quote
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { cli, Strategy } from '../../registry.js';
|
|
10
|
+
import { CliError } from '../../errors.js';
|
|
11
|
+
|
|
12
|
+
const MARKET_CN = '11';
|
|
13
|
+
const MARKET_HK = '31';
|
|
14
|
+
const MARKET_US = '41';
|
|
15
|
+
|
|
16
|
+
async function fetchGBK(url: string): Promise<string> {
|
|
17
|
+
const res = await fetch(url, { headers: { Referer: 'https://finance.sina.com.cn' } });
|
|
18
|
+
if (!res.ok) throw new CliError('FETCH_ERROR', `Sina API HTTP ${res.status}`, 'Check your network');
|
|
19
|
+
const buf = await res.arrayBuffer();
|
|
20
|
+
return new TextDecoder('gbk').decode(buf);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface SuggestEntry { name: string; market: string; symbol: string; }
|
|
24
|
+
|
|
25
|
+
function parseSuggest(raw: string, markets: string[]): SuggestEntry[] {
|
|
26
|
+
const m = raw.match(/suggestvalue="(.*)"/s);
|
|
27
|
+
if (!m) return [];
|
|
28
|
+
return m[1].split(';').filter(Boolean).map(s => {
|
|
29
|
+
const p = s.split(',');
|
|
30
|
+
return { name: p[4] || p[0] || '', market: p[1] || '', symbol: p[3] || '' };
|
|
31
|
+
}).filter(e => markets.includes(e.market));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hqSymbol(e: SuggestEntry): string {
|
|
35
|
+
if (e.market === MARKET_HK) return `hk${e.symbol}`;
|
|
36
|
+
if (e.market === MARKET_US) return `gb_${e.symbol}`;
|
|
37
|
+
return e.symbol; // A股: already "sh600519" / "sz300XXX"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseHq(raw: string, sym: string): string[] {
|
|
41
|
+
const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
42
|
+
const m = raw.match(new RegExp(`hq_str_${escaped}="([^"]*)"`));
|
|
43
|
+
return m ? m[1].split(',') : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fmtMktCap(val: string): string {
|
|
47
|
+
const n = parseFloat(val);
|
|
48
|
+
if (!n) return '';
|
|
49
|
+
if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T';
|
|
50
|
+
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
51
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
|
52
|
+
return String(n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
cli({
|
|
56
|
+
site: 'sinafinance',
|
|
57
|
+
name: 'stock',
|
|
58
|
+
description: '新浪财经行情(A股/港股/美股)',
|
|
59
|
+
domain: 'suggest3.sinajs.cn,hq.sinajs.cn',
|
|
60
|
+
strategy: Strategy.PUBLIC,
|
|
61
|
+
browser: false,
|
|
62
|
+
args: [
|
|
63
|
+
{ name: 'key', type: 'string', required: true, positional: true, help: 'Stock name or code (e.g. 贵州茅台, 腾讯控股, AAPL)' },
|
|
64
|
+
{ name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto (default: auto searches cn → hk → us)' },
|
|
65
|
+
],
|
|
66
|
+
columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'],
|
|
67
|
+
func: async (_page, args) => {
|
|
68
|
+
const key = String(args.key);
|
|
69
|
+
const market = String(args.market);
|
|
70
|
+
|
|
71
|
+
const marketMap: Record<string, string[]> = {
|
|
72
|
+
cn: [MARKET_CN], hk: [MARKET_HK], us: [MARKET_US],
|
|
73
|
+
auto: [MARKET_CN, MARKET_HK, MARKET_US],
|
|
74
|
+
};
|
|
75
|
+
const targetMarkets = marketMap[market];
|
|
76
|
+
if (!targetMarkets) {
|
|
77
|
+
throw new CliError('INPUT_ERROR', `Invalid market: "${market}"`, 'Expected cn, hk, us, or auto');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 1. Search symbol — only request the markets we care about
|
|
81
|
+
const suggestRaw = await fetchGBK(
|
|
82
|
+
`https://suggest3.sinajs.cn/suggest/type=${targetMarkets.join(',')}&key=${encodeURIComponent(key)}`
|
|
83
|
+
);
|
|
84
|
+
const entries = parseSuggest(suggestRaw, targetMarkets);
|
|
85
|
+
if (!entries.length) {
|
|
86
|
+
throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Pick best match: score by name similarity, tiebreak by market priority
|
|
90
|
+
const needle = key.toLowerCase();
|
|
91
|
+
const score = (e: SuggestEntry): number => {
|
|
92
|
+
const n = e.name.toLowerCase();
|
|
93
|
+
if (n === needle) return 1;
|
|
94
|
+
if (n.includes(needle)) return needle.length / n.length;
|
|
95
|
+
return 0;
|
|
96
|
+
};
|
|
97
|
+
const best = entries.sort((a, b) => {
|
|
98
|
+
const d = score(b) - score(a);
|
|
99
|
+
return d !== 0 ? d : targetMarkets.indexOf(a.market) - targetMarkets.indexOf(b.market);
|
|
100
|
+
})[0];
|
|
101
|
+
|
|
102
|
+
// 2. Fetch quote
|
|
103
|
+
const sym = hqSymbol(best);
|
|
104
|
+
const hqRaw = await fetchGBK(`https://hq.sinajs.cn/list=${sym}`);
|
|
105
|
+
const f = parseHq(hqRaw, sym);
|
|
106
|
+
|
|
107
|
+
if (f.length < 2 || !f[0]) {
|
|
108
|
+
throw new CliError('NOT_FOUND', `No quote data for "${key}"`, 'Market may be closed or data unavailable');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (best.market === MARKET_CN) {
|
|
112
|
+
const price = parseFloat(f[3]);
|
|
113
|
+
const prev = parseFloat(f[2]);
|
|
114
|
+
const chg = (price - prev).toFixed(2);
|
|
115
|
+
const chgPct = ((price - prev) / prev * 100).toFixed(2) + '%';
|
|
116
|
+
return [{ Symbol: sym.toUpperCase(), Name: f[0], Price: f[3], Change: chg, ChangePercent: chgPct, Open: f[1], High: f[4], Low: f[5], Volume: f[8], MarketCap: '' }];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (best.market === MARKET_HK) {
|
|
120
|
+
// [2]=price [4]=high [5]=low [6]=open [7]=change [8]=change% [11]=volume
|
|
121
|
+
return [{ Symbol: best.symbol, Name: f[1], Price: f[2], Change: f[7], ChangePercent: f[8] + '%', Open: f[6], High: f[4], Low: f[5], Volume: f[11], MarketCap: '' }];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// MARKET_US: [1]=price [2]=change% [4]=change [6]=open [7]=today_low [8]=52wH [9]=52wL [10]=volume [12]=mktcap
|
|
125
|
+
return [{ Symbol: best.symbol.toUpperCase(), Name: f[0], Price: f[1], Change: f[4], ChangePercent: f[2] + '%', Open: f[6], High: f[8], Low: f[9], Volume: f[10], MarketCap: fmtMktCap(f[12]) }];
|
|
126
|
+
},
|
|
127
|
+
});
|