@finch.app/minitools 0.1.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 ADDED
@@ -0,0 +1,72 @@
1
+ # @finch.app/minitools
2
+
3
+ CLI shim for installing Finch extensions to the correct location.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ # Install from npm
9
+ npx @finch.app/minitools add @scope/finch-extension-example
10
+
11
+ # Install a local extension directory
12
+ npx @finch.app/minitools add ./my-extension
13
+
14
+ # Install from a zip file (local or URL)
15
+ npx @finch.app/minitools add ./my-extension.zip
16
+ npx @finch.app/minitools add https://github.com/user/repo/archive/refs/heads/main.zip
17
+
18
+ # Install to the current project
19
+ npx @finch.app/minitools add @finch/extension-mcp --cwd
20
+
21
+ # Install to a specific project path
22
+ npx @finch.app/minitools add @finch/extension-mcp --cwd /path/to/project
23
+
24
+ # Install globally (~/.finch/extensions/)
25
+ npx @finch.app/minitools add @finch/extension-mcp --global
26
+
27
+ # List installed extensions
28
+ npx @finch.app/minitools list
29
+ npx @finch.app/minitools list --global
30
+
31
+ # Remove an extension
32
+ npx @finch.app/minitools remove mcp
33
+
34
+ # Show install paths
35
+ npx @finch.app/minitools where
36
+
37
+ # Validate an extension package
38
+ npx @finch.app/minitools doctor ./my-extension
39
+ ```
40
+
41
+ ## Install locations
42
+
43
+ | Flag | Path | Scope |
44
+ |---|---|---|
45
+ | *(default)* | `<workspace.json#finchHomeDir>/.finch/extensions/<id>/` | Current Finch workspace |
46
+ | `--cwd` | `<process.cwd()>/.finch/extensions/<id>/` | Current project |
47
+ | `--cwd path` | `<path>/.finch/extensions/<id>/` | Specific project |
48
+ | `--global` | `~/.finch/extensions/<id>/` | All Finch sessions |
49
+
50
+ The default workspace path is read from `~/.finch/workspace.json#finchHomeDir`.
51
+
52
+ ## Extension package
53
+
54
+ A Finch extension is an npm-style package with `package.json#finch`:
55
+
56
+ ```json
57
+ {
58
+ "name": "my-extension",
59
+ "version": "1.0.0",
60
+ "type": "module",
61
+ "main": "dist/index.js",
62
+ "finch": {
63
+ "manifestVersion": 1,
64
+ "id": "my-extension",
65
+ "displayName": "My Extension",
66
+ "main": "dist/index.js",
67
+ "activationEvents": ["onStartup"]
68
+ }
69
+ }
70
+ ```
71
+
72
+ `add` installs the extension but does not enable or grant permissions. Open Finch → Toolcase → Extensions to review permissions and enable it.
@@ -0,0 +1,594 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @finch.app/extensions — install Finch extensions to the correct location.
4
+ *
5
+ * Zero npm dependencies. npm sources are fetched with `npm install --ignore-scripts`
6
+ * so third-party install scripts never run during CLI install.
7
+ */
8
+ import {
9
+ existsSync, mkdirSync, readdirSync, cpSync, rmSync,
10
+ readFileSync, writeFileSync, statSync,
11
+ } from 'node:fs';
12
+ import { join, resolve, basename } from 'node:path';
13
+ import { homedir, tmpdir } from 'node:os';
14
+ import { spawnSync } from 'node:child_process';
15
+ import { randomUUID } from 'node:crypto';
16
+
17
+ const LOCK_FILE = '.plugins-lock.json';
18
+
19
+ function finchRuntimeHome() {
20
+ return process.env.FINCH_RUNTIME_HOME ?? join(homedir(), '.finch');
21
+ }
22
+ function expandHomePath(path) {
23
+ return path.replace(/^~(?=\/|$)/, homedir());
24
+ }
25
+ function workspaceStatePath() {
26
+ return join(finchRuntimeHome(), 'workspace.json');
27
+ }
28
+ function configuredAgentHome() {
29
+ const state = readJson(workspaceStatePath(), {});
30
+ const configured = typeof state.finchHomeDir === 'string' && state.finchHomeDir.trim()
31
+ ? state.finchHomeDir.trim()
32
+ : join(homedir(), 'finchnest');
33
+ return resolve(expandHomePath(configured));
34
+ }
35
+ function globalPluginsDir() {
36
+ return join(homedir(), '.finch', 'extensions');
37
+ }
38
+ function personalPluginsDir() {
39
+ return join(configuredAgentHome(), '.finch', 'extensions');
40
+ }
41
+ // Extensions only ever install to the personal (default) or global tier —
42
+ // there is intentionally no project/--cwd scope. (Skills still support a
43
+ // project tier via the separate @finch.app/skills CLI; that's unrelated.)
44
+ function targetDir(opts) {
45
+ return opts.global ? globalPluginsDir() : personalPluginsDir();
46
+ }
47
+ function pluginsStatePath() {
48
+ return join(finchRuntimeHome(), 'extensions.json');
49
+ }
50
+ function lockPath(dir) {
51
+ return join(dir, LOCK_FILE);
52
+ }
53
+ function readJson(path, fallback) {
54
+ try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return fallback; }
55
+ }
56
+ function writeJson(path, data) {
57
+ mkdirSync(join(path, '..'), { recursive: true });
58
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf-8');
59
+ }
60
+ function readLock(dir) {
61
+ return readJson(lockPath(dir), {});
62
+ }
63
+ function writeLock(dir, lock) {
64
+ mkdirSync(dir, { recursive: true });
65
+ writeFileSync(lockPath(dir), JSON.stringify(lock, null, 2) + '\n', 'utf-8');
66
+ }
67
+ function recordInstall(dir, id, source) {
68
+ const lock = readLock(dir);
69
+ lock[id] = { ...source, installedAt: new Date().toISOString() };
70
+ writeLock(dir, lock);
71
+ }
72
+ function deleteRecord(dir, id) {
73
+ const lock = readLock(dir);
74
+ delete lock[id];
75
+ writeLock(dir, lock);
76
+ }
77
+
78
+ function readPackageJson(dir) {
79
+ const file = join(dir, 'package.json');
80
+ if (!existsSync(file)) return null;
81
+ try { return JSON.parse(readFileSync(file, 'utf-8')); } catch { return null; }
82
+ }
83
+
84
+ function pluginInfo(dir) {
85
+ const pkg = readPackageJson(dir);
86
+ const manifest = pkg?.finch;
87
+ if (!pkg || !manifest || typeof manifest !== 'object') return null;
88
+ const id = String(manifest.id ?? pkg.name ?? '').trim();
89
+ if (!id) return { error: 'package.json#finch 缺少 id' };
90
+ const main = String(manifest.main ?? pkg.main ?? 'dist/index.js');
91
+ if (!existsSync(join(dir, main))) return { error: `入口文件不存在: ${main}(请先构建插件)`, id };
92
+ // `name` is the current preferred manifest field; `displayName` is kept only
93
+ // for backward compatibility with older extensions.
94
+ const nameField = manifest.name ?? manifest.displayName;
95
+ return {
96
+ id,
97
+ name: pkg.name ?? id,
98
+ version: pkg.version ?? '0.0.0',
99
+ displayName: typeof nameField === 'string'
100
+ ? nameField
101
+ : nameField?.default ?? nameField?.['en-US'] ?? nameField?.['zh-CN'] ?? id,
102
+ main,
103
+ };
104
+ }
105
+
106
+ function findPluginDirs(root, maxDepth = 4) {
107
+ const found = [];
108
+ const seen = new Set();
109
+ function visit(dir, depth) {
110
+ const key = dir.toLowerCase();
111
+ if (seen.has(key)) return;
112
+ seen.add(key);
113
+ const info = pluginInfo(dir);
114
+ if (info && !info.error) found.push(dir);
115
+ if (depth <= 0) return;
116
+ let entries = [];
117
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
118
+ for (const e of entries) {
119
+ if (!e.isDirectory()) continue;
120
+ if (e.name === '.git' || e.name === '.cache') continue;
121
+ visit(join(dir, e.name), depth - 1);
122
+ }
123
+ }
124
+ visit(root, maxDepth);
125
+ return found;
126
+ }
127
+
128
+ function installExtensionDir(srcDir, destRoot, lockSource) {
129
+ const info = pluginInfo(srcDir);
130
+ if (!info) throw new Error(`不是 Finch 扩展: ${srcDir}`);
131
+ if (info.error) throw new Error(info.error);
132
+ mkdirSync(destRoot, { recursive: true });
133
+ const dest = join(destRoot, info.id);
134
+ cpSync(srcDir, dest, { recursive: true, force: true, dereference: false });
135
+ recordInstall(destRoot, info.id, lockSource);
136
+ console.log(`✓ Added "${info.displayName}" (${info.id}) → ${dest}`);
137
+ console.log(' Installed only. Open Finch → Toolcase → Extensions to review permissions and enable.');
138
+ return info.id;
139
+ }
140
+
141
+ /**
142
+ * Look for an executable on PATH (via `which`/`where`), falling back to a
143
+ * handful of common install locations that don't always make it onto the
144
+ * PATH inherited by a GUI-launched app (Homebrew, nvm, Volta, Windows
145
+ * installer). Returns the resolved path, or null if not found anywhere.
146
+ */
147
+ function findExecutable(name) {
148
+ const finder = process.platform === 'win32' ? 'where' : 'which';
149
+ const found = spawnSync(finder, [name], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
150
+ if (found.status === 0) {
151
+ const first = found.stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
152
+ if (first) return first;
153
+ }
154
+
155
+ const candidateDirs = process.platform === 'win32'
156
+ ? [
157
+ join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs'),
158
+ join(process.env.APPDATA ?? '', 'npm'),
159
+ ]
160
+ : (() => {
161
+ const dirs = ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.volta', 'bin')];
162
+ const nvmRoot = join(homedir(), '.nvm', 'versions', 'node');
163
+ try {
164
+ for (const version of readdirSync(nvmRoot)) dirs.push(join(nvmRoot, version, 'bin'));
165
+ } catch { /* nvm not installed */ }
166
+ return dirs;
167
+ })();
168
+ const exeName = process.platform === 'win32' ? `${name}.cmd` : name;
169
+ for (const dir of candidateDirs) {
170
+ const candidate = join(dir, exeName);
171
+ if (existsSync(candidate)) return candidate;
172
+ }
173
+ return null;
174
+ }
175
+
176
+ const NODEJS_INSTALL_HINT = 'Install Node.js (which bundles npm) from https://nodejs.org, then try again.';
177
+
178
+ function npmInstallToTemp(spec, tmp) {
179
+ const npmPath = findExecutable('npm');
180
+ if (!npmPath) {
181
+ throw new Error(`This extension is an npm package, but no "npm" executable was found on this machine.\n${NODEJS_INSTALL_HINT}`);
182
+ }
183
+ mkdirSync(tmp, { recursive: true });
184
+ const r = spawnSync(npmPath, ['install', '--ignore-scripts', '--omit=dev', '--prefix', tmp, spec], {
185
+ stdio: ['ignore', 'pipe', 'pipe'],
186
+ encoding: 'utf-8',
187
+ });
188
+ if (r.error) {
189
+ throw new Error(`Failed to run npm (${npmPath}): ${r.error.message}\n${NODEJS_INSTALL_HINT}`);
190
+ }
191
+ if (r.status !== 0) {
192
+ throw new Error(`npm install failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
193
+ }
194
+ }
195
+
196
+ function isLocalSource(src) {
197
+ return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~');
198
+ }
199
+ function isZipUrl(src) {
200
+ return /^https?:\/\/.+\.zip(\?.*)?$/i.test(src);
201
+ }
202
+ function isZipFile(src) {
203
+ return src.toLowerCase().endsWith('.zip');
204
+ }
205
+ function expandHome(path) {
206
+ return path.replace(/^~(?=\/|$)/, homedir());
207
+ }
208
+
209
+ /**
210
+ * Download a URL to a local file path using Node's built-in fetch (Node 18+).
211
+ * Falls back to curl/wget if fetch is unavailable.
212
+ */
213
+ async function downloadFile(url, destPath) {
214
+ if (typeof fetch === 'function') {
215
+ const res = await fetch(url, { redirect: 'follow' });
216
+ if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText} — ${url}`);
217
+ const buf = Buffer.from(await res.arrayBuffer());
218
+ writeFileSync(destPath, buf);
219
+ } else {
220
+ // Fallback: curl (macOS / Linux always has it)
221
+ const r = spawnSync('curl', ['-fsSL', '-o', destPath, url], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
222
+ if (r.status !== 0) throw new Error(`curl failed:\n${r.stderr || r.stdout}`);
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Extract a zip archive to destDir using the system `unzip` command (macOS / Linux built-in).
228
+ */
229
+ function extractZip(zipPath, destDir) {
230
+ mkdirSync(destDir, { recursive: true });
231
+ const r = spawnSync('unzip', ['-q', '-o', zipPath, '-d', destDir], {
232
+ stdio: ['ignore', 'pipe', 'pipe'],
233
+ encoding: 'utf-8',
234
+ });
235
+ if (r.error) {
236
+ throw new Error(`No "unzip" command found on this machine: ${r.error.message}`);
237
+ }
238
+ if (r.status !== 0) throw new Error(`unzip failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
239
+ }
240
+
241
+ /**
242
+ * Install extensions from a zip file (local path or remote URL).
243
+ * The zip may contain one or more extensions at any nesting level.
244
+ */
245
+ async function installFromZip(src, dest, isUrl) {
246
+ const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
247
+ const zipPath = join(tmp, 'extension.zip');
248
+ const extractDir = join(tmp, 'extracted');
249
+ mkdirSync(tmp, { recursive: true });
250
+ try {
251
+ if (isUrl) {
252
+ console.log(` Downloading ${src} …`);
253
+ await downloadFile(src, zipPath);
254
+ } else {
255
+ // Local zip — copy to tmp so we have a consistent path
256
+ const abs = resolve(expandHome(src));
257
+ if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
258
+ cpSync(abs, zipPath);
259
+ }
260
+ console.log(' Extracting …');
261
+ extractZip(zipPath, extractDir);
262
+ const found = findPluginDirs(extractDir, 5);
263
+ if (found.length === 0) throw new Error('No Finch extension found inside the zip archive.');
264
+ const lockSource = isUrl
265
+ ? { type: 'zip', url: src }
266
+ : { type: 'zip', localPath: resolve(expandHome(src)) };
267
+ for (const dir of found) installExtensionDir(dir, dest, lockSource);
268
+ } finally {
269
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
270
+ }
271
+ }
272
+
273
+ async function cmdAdd(src, opts) {
274
+ const dest = targetDir(opts);
275
+
276
+ // --- zip URL (e.g. https://github.com/.../archive/main.zip) ---
277
+ if (isZipUrl(src)) {
278
+ await installFromZip(src, dest, true);
279
+ return;
280
+ }
281
+
282
+ // --- local path ---
283
+ if (isLocalSource(src)) {
284
+ const abs = resolve(expandHome(src));
285
+ if (!existsSync(abs)) throw new Error(`path not found: ${abs}`);
286
+
287
+ // Local zip file
288
+ if (isZipFile(src)) {
289
+ await installFromZip(src, dest, false);
290
+ return;
291
+ }
292
+
293
+ // Local directory
294
+ const direct = pluginInfo(abs);
295
+ if (direct && !direct.error) {
296
+ installExtensionDir(abs, dest, { type: 'local', localPath: abs });
297
+ return;
298
+ }
299
+ const found = findPluginDirs(abs, 3);
300
+ if (found.length === 0) throw new Error('No Finch extension found in the given directory.');
301
+ for (const dir of found) installExtensionDir(dir, dest, { type: 'local', localPath: dir });
302
+ return;
303
+ }
304
+
305
+ // --- npm package ---
306
+ const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
307
+ try {
308
+ npmInstallToTemp(src, tmp);
309
+ const found = findPluginDirs(join(tmp, 'node_modules'), 5);
310
+ if (found.length === 0) throw new Error('No package with package.json#finch found in the npm package.');
311
+ // Prefer the top-level package matching the requested spec when possible.
312
+ const first = found[0];
313
+ installExtensionDir(first, dest, { type: 'npm', package: src });
314
+ } finally {
315
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
316
+ }
317
+ }
318
+
319
+ function listInstalled(dir) {
320
+ if (!existsSync(dir)) return [];
321
+ return readdirSync(dir, { withFileTypes: true })
322
+ .filter((e) => e.isDirectory())
323
+ .map((e) => ({ dir: e.name, path: join(dir, e.name), info: pluginInfo(join(dir, e.name)) }))
324
+ .filter((x) => x.info && !x.info.error);
325
+ }
326
+
327
+ function cmdList(opts) {
328
+ const dir = targetDir(opts);
329
+ const plugins = listInstalled(dir);
330
+ if (plugins.length === 0) {
331
+ console.log(`No extensions installed in ${dir}`);
332
+ return;
333
+ }
334
+ for (const p of plugins) {
335
+ console.log(`${p.info.id}\t${p.info.version}\t${p.info.displayName}\t${p.path}`);
336
+ }
337
+ }
338
+
339
+ function cmdRemove(id, opts) {
340
+ const dir = targetDir(opts);
341
+ const target = join(dir, id);
342
+ if (!existsSync(target)) throw new Error(`extension not found: ${id}`);
343
+ rmSync(target, { recursive: true, force: true });
344
+ deleteRecord(dir, id);
345
+ setEnabled(id, false);
346
+ console.log(`✓ Removed ${id}`);
347
+ }
348
+
349
+ function normalizePluginState(raw) {
350
+ const plugins = {};
351
+ if (raw?.plugins && typeof raw.plugins === 'object') {
352
+ for (const [id, record] of Object.entries(raw.plugins)) {
353
+ if (!record || typeof record !== 'object') continue;
354
+ plugins[id] = { ...record, enabled: record.enabled === true };
355
+ }
356
+ }
357
+ if (Array.isArray(raw?.enabled)) {
358
+ for (const id of raw.enabled) {
359
+ if (typeof id === 'string') plugins[id] = { ...(plugins[id] ?? {}), enabled: true };
360
+ }
361
+ }
362
+ return plugins;
363
+ }
364
+
365
+ function setEnabled(id, enabled) {
366
+ const path = pluginsStatePath();
367
+ const plugins = normalizePluginState(readJson(path, {}));
368
+ plugins[id] = { ...(plugins[id] ?? {}), enabled };
369
+ const enabledIds = Object.entries(plugins)
370
+ .filter(([, record]) => record.enabled)
371
+ .map(([extensionId]) => extensionId)
372
+ .sort();
373
+ writeJson(path, { enabled: enabledIds, plugins });
374
+ }
375
+
376
+ function cmdEnable(id, enabled) {
377
+ setEnabled(id, enabled);
378
+ console.log(`✓ ${enabled ? 'Enabled' : 'Disabled'} ${id}`);
379
+ if (enabled) {
380
+ console.log(' Note: CLI enable does not grant fine-grained permissions yet. Review extension permissions in Finch when available.');
381
+ }
382
+ }
383
+
384
+ function cmdWhere() {
385
+ console.log(`Personal: ${personalPluginsDir()}`);
386
+ console.log(`Global: ${globalPluginsDir()}`);
387
+ console.log(`State: ${pluginsStatePath()}`);
388
+ }
389
+
390
+ /** Collect JS/MJS/TS source files under a plugin dir (excludes node_modules/.git). */
391
+ function collectSourceFiles(root, maxDepth = 4) {
392
+ const files = [];
393
+ function visit(dir, depth) {
394
+ let entries = [];
395
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
396
+ for (const e of entries) {
397
+ if (e.name === 'node_modules' || e.name === '.git' || e.name === '.cache') continue;
398
+ const full = join(dir, e.name);
399
+ if (e.isDirectory()) {
400
+ if (depth > 0) visit(full, depth - 1);
401
+ } else if (/\.(mjs|cjs|js|ts)$/.test(e.name)) {
402
+ files.push(full);
403
+ }
404
+ }
405
+ }
406
+ visit(root, maxDepth);
407
+ return files;
408
+ }
409
+
410
+ /**
411
+ * Static lint of an extension's source for patterns that won't work or break the
412
+ * sandboxing contract. Returns arrays of warning strings.
413
+ */
414
+ function lintExtensionSource(root) {
415
+ const warnings = [];
416
+ const files = collectSourceFiles(root);
417
+ for (const file of files) {
418
+ let text = '';
419
+ try { text = readFileSync(file, 'utf-8'); } catch { continue; }
420
+ const rel = file.slice(root.length + 1);
421
+
422
+ // Runtime `import ... from 'finch'` fails — `finch` is a types-only module.
423
+ if (/^\s*import\s+(?!type\b)[^;]*\bfrom\s+['"]finch['"]/m.test(text)) {
424
+ warnings.push(`${rel}: 用了运行时 import from 'finch';应为 \`import type * as finch from 'finch'\`(finch 仅提供类型,运行时通过 activate(ctx) 注入)。`);
425
+ }
426
+ // Legacy API surface that no longer exists.
427
+ if (/\bFinchPluginAPI\b/.test(text)) {
428
+ warnings.push(`${rel}: 引用了已移除的 FinchPluginAPI;请改用 activate(ctx) + ctx.*。`);
429
+ }
430
+ // Importing Electron or Finch internals breaks the host isolation boundary.
431
+ if (/\bfrom\s+['"]electron['"]/.test(text) || /require\(\s*['"]electron['"]\s*\)/.test(text)) {
432
+ warnings.push(`${rel}: 直接 import 'electron';插件运行在隔离 host 中,无法访问 Electron API。`);
433
+ }
434
+ if (/from\s+['"][^'"]*\/src\/(main|renderer|shared)\//.test(text)) {
435
+ warnings.push(`${rel}: 引用了 Finch 内部源码(src/main|renderer|shared);只能通过 ctx.* 使用能力。`);
436
+ }
437
+ }
438
+ return warnings;
439
+ }
440
+
441
+ function cmdDoctor(src = '.') {
442
+ const abs = resolve(expandHome(src));
443
+ const info = pluginInfo(abs);
444
+ if (!info) throw new Error('Not a Finch extension package (missing package.json#finch).');
445
+ if (info.error) throw new Error(info.error);
446
+ console.log(`✓ Finch extension: ${info.displayName}`);
447
+ console.log(` id: ${info.id}`);
448
+ console.log(` version: ${info.version}`);
449
+ console.log(` main: ${info.main}`);
450
+
451
+ const pkg = readPackageJson(abs);
452
+ const manifest = pkg?.finch ?? {};
453
+ // Surface recommended manifest metadata that's missing (non-fatal).
454
+ const recommended = ['name', 'description', 'extensionType'];
455
+ const missing = recommended.filter((k) => manifest[k] == null);
456
+ if (missing.length) console.log(` hint: manifest 建议补充字段: ${missing.join(', ')}`);
457
+ if (manifest.permissions) {
458
+ const p = manifest.permissions;
459
+ const decl = [
460
+ p.filesystem && p.filesystem !== 'none' ? `filesystem=${p.filesystem}` : null,
461
+ p.network ? 'network' : null,
462
+ p.shell ? 'shell' : null,
463
+ ].filter(Boolean);
464
+ if (decl.length) console.log(` permissions: ${decl.join(', ')}(启用时会向用户展示)`);
465
+ }
466
+
467
+ const warnings = lintExtensionSource(abs);
468
+ if (warnings.length === 0) {
469
+ console.log('✓ No issues found.');
470
+ return;
471
+ }
472
+ console.log(`\n⚠ ${warnings.length} warning(s):`);
473
+ for (const w of warnings) console.log(` - ${w}`);
474
+ }
475
+
476
+ async function cmdUpdate(id, opts) {
477
+ const dir = targetDir(opts);
478
+ const target = join(dir, id);
479
+ if (!existsSync(target)) throw new Error(`extension not found: ${id}`);
480
+ let source = readLock(dir)[id];
481
+ if (!source) {
482
+ // No install record — this happens for bundled first-party extensions that
483
+ // were deployed by copying (e.g. the MCP bridge), not via `add`. Fall back to
484
+ // the installed package.json's npm name so they can still be updated from the
485
+ // registry. recordInstall below then writes a proper lock entry.
486
+ const pkg = readPackageJson(target);
487
+ const pkgName = typeof pkg?.name === 'string' ? pkg.name.trim() : '';
488
+ if (pkgName) {
489
+ source = { type: 'npm', package: pkgName };
490
+ } else {
491
+ throw new Error(`no install record for "${id}"; reinstall it with \`add\` to enable updates.`);
492
+ }
493
+ }
494
+
495
+ if (source.type === 'local') {
496
+ const localPath = source.localPath ? expandHome(source.localPath) : '';
497
+ if (!localPath || !existsSync(localPath)) {
498
+ throw new Error(`local source no longer exists: ${source.localPath ?? '(unknown)'}`);
499
+ }
500
+ const info = pluginInfo(localPath);
501
+ if (!info || info.error) throw new Error(info?.error ?? `not a Finch extension: ${localPath}`);
502
+ cpSync(localPath, target, { recursive: true, force: true, dereference: false });
503
+ recordInstall(dir, id, source);
504
+ console.log(`✓ Updated "${info.displayName}" (${id}) from local path`);
505
+ return;
506
+ }
507
+
508
+ // zip source: re-download / re-extract from the recorded URL or local path.
509
+ if (source.type === 'zip') {
510
+ if (source.url) {
511
+ await installFromZip(source.url, dir, true);
512
+ } else if (source.localPath) {
513
+ await installFromZip(source.localPath, dir, false);
514
+ } else {
515
+ throw new Error(`zip install record for "${id}" has no url or localPath; reinstall with \`add\`.`);
516
+ }
517
+ return;
518
+ }
519
+
520
+ // npm source: reinstall the latest published version.
521
+ const spec = source.package ?? id;
522
+ const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
523
+ try {
524
+ npmInstallToTemp(spec, tmp);
525
+ const found = findPluginDirs(join(tmp, 'node_modules'), 5).filter((d) => pluginInfo(d)?.id === id);
526
+ const fresh = found[0] ?? findPluginDirs(join(tmp, 'node_modules'), 5)[0];
527
+ if (!fresh) throw new Error('No matching Finch extension found in npm package.');
528
+ const info = pluginInfo(fresh);
529
+ rmSync(target, { recursive: true, force: true });
530
+ cpSync(fresh, target, { recursive: true, force: true, dereference: false });
531
+ recordInstall(dir, id, source);
532
+ console.log(`✓ Updated "${info.displayName}" (${id}) → v${info.version}`);
533
+ } finally {
534
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
535
+ }
536
+ }
537
+
538
+ function parseArgs(argv) {
539
+ const args = [...argv];
540
+ const cmd = args.shift();
541
+ const opts = { global: false };
542
+ const rest = [];
543
+ for (let i = 0; i < args.length; i += 1) {
544
+ const a = args[i];
545
+ if (a === '--global' || a === '-g') opts.global = true;
546
+ else if (a === '--cwd') {
547
+ // Removed: extensions no longer support project/--cwd scope. Consume any
548
+ // following path argument so old invocations fail loudly instead of
549
+ // silently being parsed as the install source.
550
+ throw new Error('--cwd is no longer supported for extensions — install to the personal (default) or --global tier only.');
551
+ } else rest.push(a);
552
+ }
553
+ return { cmd, rest, opts };
554
+ }
555
+
556
+ function help() {
557
+ console.log(`npx @finch.app/extensions\n\nUsage:\n add <npm-package|local-path|url.zip> [--global]\n update <id> [--global]\n list [--global]\n remove <id> [--global]\n enable <id>\n disable <id>\n where\n doctor [path]\n\nInstall locations:\n default workspace.json#finchHomeDir/.finch/extensions/ (personal — default)\n --global ~/.finch/extensions/ (global)\n\nThere is no project/--cwd scope — extensions only install to personal or global.\n`);
558
+ }
559
+
560
+ (async () => {
561
+ try {
562
+ const { cmd, rest, opts } = parseArgs(process.argv.slice(2));
563
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return help();
564
+ if (cmd === 'add') {
565
+ if (!rest[0]) throw new Error('missing source');
566
+ await cmdAdd(rest[0], opts);
567
+ return;
568
+ }
569
+ if (cmd === 'update' || cmd === 'up') {
570
+ if (!rest[0]) throw new Error('missing plugin id');
571
+ await cmdUpdate(rest[0], opts);
572
+ return;
573
+ }
574
+ if (cmd === 'list' || cmd === 'ls') return cmdList(opts);
575
+ if (cmd === 'remove' || cmd === 'rm') {
576
+ if (!rest[0]) throw new Error('missing plugin id');
577
+ return cmdRemove(rest[0], opts);
578
+ }
579
+ if (cmd === 'enable') {
580
+ if (!rest[0]) throw new Error('missing plugin id');
581
+ return cmdEnable(rest[0], true);
582
+ }
583
+ if (cmd === 'disable') {
584
+ if (!rest[0]) throw new Error('missing plugin id');
585
+ return cmdEnable(rest[0], false);
586
+ }
587
+ if (cmd === 'where') return cmdWhere();
588
+ if (cmd === 'doctor') return cmdDoctor(rest[0] ?? '.');
589
+ throw new Error(`unknown command: ${cmd}`);
590
+ } catch (err) {
591
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
592
+ process.exit(1);
593
+ }
594
+ })();
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@finch.app/minitools",
3
+ "version": "0.1.5",
4
+ "description": "CLI shim for installing Finch extensions to the correct location.",
5
+ "type": "module",
6
+ "bin": {
7
+ "finch-minitools": "bin/minitools.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "registry": "https://registry.npmjs.org"
19
+ },
20
+ "license": "MIT"
21
+ }