@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.
@@ -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
- function extractBalancedBlock(
57
- source: string,
58
- startIndex: number,
59
- openChar: string,
60
- closeChar: string,
61
- ): string | null {
62
- let depth = 0;
63
- let quote: string | null = null;
64
- let escaped = false;
65
-
66
- for (let i = startIndex; i < source.length; i++) {
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 extractTsArgsBlock(source: string): string | null {
101
- const argsMatch = source.match(/args\s*:/);
102
- if (!argsMatch || argsMatch.index === undefined) return null;
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 parseInlineChoices(body: string): string[] | undefined {
111
- const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
112
- if (!choicesMatch) return undefined;
113
-
114
- const values = choicesMatch[1]
115
- .split(',')
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
- export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
123
- const args: ManifestEntry['args'] = [];
124
- let cursor = 0;
125
-
126
- while (cursor < argsBlock.length) {
127
- const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]/);
128
- if (!nameMatch || nameMatch.index === undefined) break;
129
-
130
- const objectStart = cursor + nameMatch.index;
131
- const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
132
- if (body == null) break;
133
-
134
- const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
135
- const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
136
- const requiredMatch = body.match(/required\s*:\s*(true|false)/);
137
- const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
138
- const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
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 scanTs(filePath: string, site: string): ManifestEntry | null {
203
- // TS adapters self-register via cli() at import time.
204
- // We statically parse the source to extract metadata for the manifest stub.
205
- const baseName = path.basename(filePath, path.extname(filePath));
206
- const relativePath = `${site}/${baseName}.js`;
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 (!/\bcli\s*\(/.test(src)) return null;
213
-
214
- const entry: ManifestEntry = {
215
- site,
216
- name: baseName,
217
- description: '',
218
- strategy: 'cookie',
219
- browser: true,
220
- args: [],
221
- type: 'ts',
222
- modulePath: relativePath,
223
- };
224
-
225
- // Extract description
226
- const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
227
- if (descMatch) entry.description = descMatch[1];
228
-
229
- // Extract domain
230
- const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
231
- if (domainMatch) entry.domain = domainMatch[1];
232
-
233
- // Extract strategy
234
- const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
235
- if (stratMatch) entry.strategy = stratMatch[1].toLowerCase();
236
-
237
- // Extract browser: false (some adapters bypass browser entirely)
238
- const browserMatch = src.match(/browser\s*:\s*(true|false)/);
239
- if (browserMatch) entry.browser = browserMatch[1] === 'true';
240
- else entry.browser = entry.strategy !== 'public';
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 null;
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 entry = scanTs(filePath, site);
317
- if (entry) {
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
- // Require custom header on all HTTP requests. Browsers cannot attach
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,
@@ -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
+ });
@@ -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!.join(', ')}${def}`);
77
+ lines.push(` ${prefix}: ${summarizeChoices(a.choices!)}${def}`);
73
78
  }
74
79
  const meta: string[] = [];
75
80
  meta.push(`Strategy: ${strategyLabel(cmd)}`);