@jackwener/opencli 1.5.3 → 1.5.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/.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 +1199 -1106
- package/dist/daemon.js +14 -3
- package/dist/external-clis.yaml +16 -0
- package/dist/serialization.js +6 -1
- package/dist/serialization.test.d.ts +1 -0
- package/dist/serialization.test.js +23 -0
- package/extension/dist/background.js +11 -4
- package/extension/manifest.json +2 -2
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +19 -5
- 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/daemon.ts +16 -4
- package/src/external-clis.yaml +16 -0
- package/src/serialization.test.ts +26 -0
- package/src/serialization.ts +6 -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/daemon.ts
CHANGED
|
@@ -102,7 +102,22 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
|
|
102
102
|
return;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
const url = req.url ?? '/';
|
|
106
|
+
const pathname = url.split('?')[0];
|
|
107
|
+
|
|
108
|
+
// Health-check endpoint — no X-OpenCLI header required.
|
|
109
|
+
// Used by the extension to silently probe daemon reachability before
|
|
110
|
+
// attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED).
|
|
111
|
+
// Security note: this endpoint is reachable by any client that passes the
|
|
112
|
+
// origin check above (chrome-extension:// or no Origin header, e.g. curl).
|
|
113
|
+
// Timing side-channels can reveal daemon presence to local processes, which
|
|
114
|
+
// is an accepted risk given the daemon is loopback-only and short-lived.
|
|
115
|
+
if (req.method === 'GET' && pathname === '/ping') {
|
|
116
|
+
jsonResponse(res, 200, { ok: true });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Require custom header on all other HTTP requests. Browsers cannot attach
|
|
106
121
|
// custom headers in "simple" requests, and our preflight returns no
|
|
107
122
|
// Access-Control-Allow-Headers, so scripted fetch() from web pages is
|
|
108
123
|
// blocked even if Origin check is somehow bypassed.
|
|
@@ -111,9 +126,6 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
|
|
111
126
|
return;
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
const url = req.url ?? '/';
|
|
115
|
-
const pathname = url.split('?')[0];
|
|
116
|
-
|
|
117
129
|
if (req.method === 'GET' && pathname === '/status') {
|
|
118
130
|
jsonResponse(res, 200, {
|
|
119
131
|
ok: true,
|
package/src/external-clis.yaml
CHANGED
|
@@ -21,3 +21,19 @@
|
|
|
21
21
|
tags: [docker, containers, devops]
|
|
22
22
|
install:
|
|
23
23
|
mac: "brew install --cask docker"
|
|
24
|
+
|
|
25
|
+
- name: lark-cli
|
|
26
|
+
binary: lark-cli
|
|
27
|
+
description: "Lark/Feishu CLI — messages, documents, spreadsheets, calendar, tasks and 200+ commands for AI agents"
|
|
28
|
+
homepage: "https://github.com/larksuite/cli"
|
|
29
|
+
tags: [lark, feishu, collaboration, productivity, ai-agent]
|
|
30
|
+
install:
|
|
31
|
+
default: "npm install -g @larksuite/cli"
|
|
32
|
+
|
|
33
|
+
- name: vercel
|
|
34
|
+
binary: vercel
|
|
35
|
+
description: "Vercel CLI — deploy projects, manage domains, env vars, logs and serverless functions"
|
|
36
|
+
homepage: "https://vercel.com/docs/cli"
|
|
37
|
+
tags: [vercel, deployment, serverless, frontend, devops]
|
|
38
|
+
install:
|
|
39
|
+
default: "npm install -g vercel"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CliCommand } from './registry.js';
|
|
3
|
+
import { Strategy } from './registry.js';
|
|
4
|
+
import { formatRegistryHelpText } from './serialization.js';
|
|
5
|
+
|
|
6
|
+
describe('formatRegistryHelpText', () => {
|
|
7
|
+
it('summarizes long choices lists so help text stays readable', () => {
|
|
8
|
+
const cmd: CliCommand = {
|
|
9
|
+
site: 'demo',
|
|
10
|
+
name: 'dynamic',
|
|
11
|
+
description: 'Demo command',
|
|
12
|
+
strategy: Strategy.PUBLIC,
|
|
13
|
+
browser: false,
|
|
14
|
+
args: [
|
|
15
|
+
{
|
|
16
|
+
name: 'field',
|
|
17
|
+
help: 'Field to use',
|
|
18
|
+
choices: ['all-fields', 'topic', 'title', 'author', 'publication-titles', 'year-published', 'doi'],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
columns: ['field'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
expect(formatRegistryHelpText(cmd)).toContain('--field: all-fields, topic, title, author, ... (+3 more)');
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/serialization.ts
CHANGED
|
@@ -62,6 +62,11 @@ export function formatArgSummary(args: Arg[]): string {
|
|
|
62
62
|
.join(' ');
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function summarizeChoices(choices: string[]): string {
|
|
66
|
+
if (choices.length <= 4) return choices.join(', ');
|
|
67
|
+
return `${choices.slice(0, 4).join(', ')}, ... (+${choices.length - 4} more)`;
|
|
68
|
+
}
|
|
69
|
+
|
|
65
70
|
/** Generate the --help appendix showing registry metadata not exposed by Commander. */
|
|
66
71
|
export function formatRegistryHelpText(cmd: CliCommand): string {
|
|
67
72
|
const lines: string[] = [];
|
|
@@ -69,7 +74,7 @@ export function formatRegistryHelpText(cmd: CliCommand): string {
|
|
|
69
74
|
for (const a of choicesArgs) {
|
|
70
75
|
const prefix = a.positional ? `<${a.name}>` : `--${a.name}`;
|
|
71
76
|
const def = a.default != null ? ` (default: ${a.default})` : '';
|
|
72
|
-
lines.push(` ${prefix}: ${a.choices
|
|
77
|
+
lines.push(` ${prefix}: ${summarizeChoices(a.choices!)}${def}`);
|
|
73
78
|
}
|
|
74
79
|
const meta: string[] = [];
|
|
75
80
|
meta.push(`Strategy: ${strategyLabel(cmd)}`);
|