@finch.app/minitools 0.1.6 → 0.1.9

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.
Files changed (3) hide show
  1. package/README.md +14 -13
  2. package/bin/minitools.mjs +486 -138
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -15,26 +15,21 @@ npx @finch.app/minitools add ./my-tool
15
15
  npx @finch.app/minitools add ./my-tool.zip
16
16
  npx @finch.app/minitools add https://github.com/user/repo/archive/refs/heads/main.zip
17
17
 
18
- # Install to the current project
19
- npx @finch.app/minitools add @finch.app/mcp-client --cwd
20
-
21
- # Install to a specific project path
22
- npx @finch.app/minitools add @finch.app/mcp-client --cwd /path/to/project
23
-
24
18
  # Install globally (~/.finch/extensions/)
19
+ # Default installs go to the personal Finch workspace extension directory.
25
20
  npx @finch.app/minitools add @finch.app/mcp-client --global
26
21
 
27
22
  # List installed mini tools (id, version, enabled/disabled, name, path)
28
23
  npx @finch.app/minitools list
29
24
  npx @finch.app/minitools list --global
30
25
 
31
- # Remove an mini tool
26
+ # Remove a mini tool
32
27
  npx @finch.app/minitools remove mcp-client
33
28
 
34
29
  # Show install paths
35
30
  npx @finch.app/minitools where
36
31
 
37
- # Validate an mini tool package
32
+ # Validate a mini tool package
38
33
  npx @finch.app/minitools doctor ./my-tool
39
34
  ```
40
35
 
@@ -42,12 +37,14 @@ npx @finch.app/minitools doctor ./my-tool
42
37
 
43
38
  | Flag | Path | Scope |
44
39
  |---|---|---|
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 |
40
+ | *(default)* | `<workspace.json#finchHomeDir>/.finch/extensions/<id>/` | Personal Finch workspace |
48
41
  | `--global` | `~/.finch/extensions/<id>/` | All Finch sessions |
49
42
 
50
- The default workspace path is read from `~/.finch/workspace.json#finchHomeDir`.
43
+ The default workspace path is read from `~/.finch/workspace.json#finchHomeDir`. There is no project/`--cwd` scope for mini tools; use the personal default or `--global`.
44
+
45
+ ## Registry and downloads
46
+
47
+ Pinned npm specs such as `@scope/tool@1.2.3` skip npm registry metadata and download directly from `https://community.finchwork.app/download/minitool/<package>/<version>`. Unpinned specs such as `@scope/tool` or `@scope/tool@latest` still use npm registry metadata to resolve the concrete version first; `--registry <url>` only affects that metadata lookup, not the final package download.
51
48
 
52
49
  ## Mini tool package
53
50
 
@@ -62,11 +59,15 @@ A Finch mini tool is an npm-style package with `package.json#finch`:
62
59
  "finch": {
63
60
  "manifestVersion": 1,
64
61
  "id": "my-tool",
65
- "displayName": "My Tool",
62
+ "name": "My Tool",
66
63
  "main": "dist/index.js",
67
64
  "activationEvents": ["onStartup"]
68
65
  }
69
66
  }
70
67
  ```
71
68
 
69
+ `doctor` reports fatal issues and warnings. `add` / `update` block packages with fatal validation issues such as a missing `package.json#finch`, invalid `id`, unsupported `manifestVersion`, malformed contribution declarations, or a missing entry file.
70
+
71
+ Downloads served by `https://community.finchwork.app/download/minitool/<package>/<version>` are cached under `~/.finch/cache/minitools/` by package and version, so reinstalling or updating the same version avoids another network download.
72
+
72
73
  `add` installs the mini tool but does not enable or grant permissions. Open Finch → Toolcase → Tools to review permissions and enable it.
package/bin/minitools.mjs CHANGED
@@ -2,19 +2,22 @@
2
2
  /**
3
3
  * @finch.app/minitools — install Finch extensions to the correct location.
4
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.
5
+ * Zero npm dependencies. npm sources are installed by downloading the registry
6
+ * dist.tarball (.tgz) directly, so third-party install scripts never run.
7
7
  */
8
8
  import {
9
9
  existsSync, mkdirSync, readdirSync, cpSync, rmSync,
10
- readFileSync, writeFileSync, statSync,
10
+ readFileSync, writeFileSync,
11
11
  } from 'node:fs';
12
- import { join, resolve, basename } from 'node:path';
12
+ import { isAbsolute, join, resolve, basename } from 'node:path';
13
13
  import { homedir, tmpdir } from 'node:os';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import { randomUUID } from 'node:crypto';
16
16
 
17
17
  const LOCK_FILE = '.plugins-lock.json';
18
+ const SUPPORTED_MANIFEST_VERSION = 1;
19
+ const EXTENSION_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
20
+ const KNOWN_EXTENSION_TYPES = new Set(['official', 'community', 'local']);
18
21
  const LEGACY_NPM_PACKAGE_RENAMES = new Map([
19
22
  ['@finch.app/mcp-bridge', '@finch.app/mcp-client'],
20
23
  ]);
@@ -56,6 +59,9 @@ function targetDir(opts) {
56
59
  function pluginsStatePath() {
57
60
  return join(finchRuntimeHome(), 'extensions.json');
58
61
  }
62
+ function miniToolCacheDir() {
63
+ return join(finchRuntimeHome(), 'cache', 'minitools');
64
+ }
59
65
  function lockPath(dir) {
60
66
  return join(dir, LOCK_FILE);
61
67
  }
@@ -90,30 +96,221 @@ function readPackageJson(dir) {
90
96
  try { return JSON.parse(readFileSync(file, 'utf-8')); } catch { return null; }
91
97
  }
92
98
 
93
- function pluginInfo(dir) {
99
+ function localizedValue(value, fallback = '') {
100
+ if (typeof value === 'string') return value;
101
+ if (value && typeof value === 'object') {
102
+ return value.default ?? value['en-US'] ?? value['zh-CN'] ?? fallback;
103
+ }
104
+ return fallback;
105
+ }
106
+
107
+ function isLocalizedString(value) {
108
+ if (value == null) return true;
109
+ if (typeof value === 'string') return true;
110
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
111
+ return Object.entries(value).every(([locale, text]) => (
112
+ typeof locale === 'string' && typeof text === 'string'
113
+ ));
114
+ }
115
+
116
+ function validateStringField(value, field, diagnostics, { required = false, localized = false } = {}) {
117
+ if (value == null) {
118
+ if (required) diagnostics.fatal.push(`${field} 缺失`);
119
+ return;
120
+ }
121
+ const ok = localized ? isLocalizedString(value) : typeof value === 'string';
122
+ if (!ok) diagnostics.fatal.push(`${field} 必须是${localized ? '字符串或本地化字符串对象' : '字符串'}`);
123
+ }
124
+
125
+ function validateStringArray(value, field, diagnostics) {
126
+ if (value == null) return;
127
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !item.trim())) {
128
+ diagnostics.warning.push(`${field} 应为非空字符串数组`);
129
+ }
130
+ }
131
+
132
+ function validateObject(value, field, diagnostics) {
133
+ if (value == null) return true;
134
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
135
+ diagnostics.warning.push(`${field} 应为对象`);
136
+ return false;
137
+ }
138
+ return true;
139
+ }
140
+
141
+ function validateContributes(contributes, diagnostics) {
142
+ if (contributes == null) return;
143
+ if (!validateObject(contributes, 'finch.contributes', diagnostics)) return;
144
+
145
+ if (contributes.tools != null && typeof contributes.tools !== 'boolean') {
146
+ diagnostics.warning.push('finch.contributes.tools 应为 boolean');
147
+ }
148
+ if (contributes.skills != null && typeof contributes.skills !== 'boolean') {
149
+ diagnostics.warning.push('finch.contributes.skills 应为 boolean');
150
+ }
151
+ if (contributes.composerActions != null) {
152
+ if (!Array.isArray(contributes.composerActions)) {
153
+ diagnostics.fatal.push('finch.contributes.composerActions 必须是数组');
154
+ } else {
155
+ for (const [index, action] of contributes.composerActions.entries()) {
156
+ const prefix = `finch.contributes.composerActions[${index}]`;
157
+ if (!action || typeof action !== 'object' || Array.isArray(action)) {
158
+ diagnostics.fatal.push(`${prefix} 必须是对象`);
159
+ continue;
160
+ }
161
+ if (typeof action.id !== 'string' || !action.id.trim()) {
162
+ diagnostics.fatal.push(`${prefix}.id 缺失或不是字符串`);
163
+ }
164
+ validateStringField(action.icon, `${prefix}.icon`, diagnostics);
165
+ validateStringField(action.tooltip, `${prefix}.tooltip`, diagnostics, { localized: true });
166
+ }
167
+ }
168
+ }
169
+ if (contributes.iconPacks != null) {
170
+ if (!Array.isArray(contributes.iconPacks)) {
171
+ diagnostics.fatal.push('finch.contributes.iconPacks 必须是数组');
172
+ } else {
173
+ for (const [index, pack] of contributes.iconPacks.entries()) {
174
+ const prefix = `finch.contributes.iconPacks[${index}]`;
175
+ if (!pack || typeof pack !== 'object' || Array.isArray(pack)) {
176
+ diagnostics.fatal.push(`${prefix} 必须是对象`);
177
+ continue;
178
+ }
179
+ if (typeof pack.id !== 'string' || !pack.id.trim()) diagnostics.fatal.push(`${prefix}.id 缺失或不是字符串`);
180
+ validateStringField(pack.label, `${prefix}.label`, diagnostics, { localized: true });
181
+ validateStringField(pack.description, `${prefix}.description`, diagnostics, { localized: true });
182
+ }
183
+ }
184
+ }
185
+ if (contributes.icons != null && validateObject(contributes.icons, 'finch.contributes.icons', diagnostics)) {
186
+ for (const [iconId, icon] of Object.entries(contributes.icons)) {
187
+ if (!icon || typeof icon !== 'object' || Array.isArray(icon)) {
188
+ diagnostics.fatal.push(`finch.contributes.icons.${iconId} 必须是对象`);
189
+ continue;
190
+ }
191
+ if (typeof icon.svg !== 'string' || !icon.svg.trim()) diagnostics.fatal.push(`finch.contributes.icons.${iconId}.svg 缺失或不是字符串`);
192
+ }
193
+ }
194
+ if (contributes.mcpServers != null && !Array.isArray(contributes.mcpServers)) {
195
+ diagnostics.fatal.push('finch.contributes.mcpServers 必须是数组');
196
+ }
197
+ }
198
+
199
+ function validatePermissions(permissions, diagnostics) {
200
+ if (permissions == null) return;
201
+ if (!validateObject(permissions, 'finch.permissions', diagnostics)) return;
202
+ if (permissions.filesystem != null && !['none', 'read', 'write'].includes(permissions.filesystem)) {
203
+ diagnostics.warning.push('finch.permissions.filesystem 建议使用 none/read/write');
204
+ }
205
+ if (permissions.network != null && typeof permissions.network !== 'boolean') {
206
+ diagnostics.warning.push('finch.permissions.network 应为 boolean');
207
+ }
208
+ if (permissions.shell != null && typeof permissions.shell !== 'boolean') {
209
+ diagnostics.warning.push('finch.permissions.shell 应为 boolean');
210
+ }
211
+ }
212
+
213
+ function validateCapabilitySpec(spec, field, diagnostics) {
214
+ if (spec == null) return;
215
+ if (!validateObject(spec, field, diagnostics)) return;
216
+ validateStringArray(spec.capabilities, `${field}.capabilities`, diagnostics);
217
+ }
218
+
219
+ function validateMiniToolPackage(dir, { lintSource = false } = {}) {
220
+ const diagnostics = { fatal: [], warning: [] };
94
221
  const pkg = readPackageJson(dir);
95
- const manifest = pkg?.finch;
96
- if (!pkg || !manifest || typeof manifest !== 'object') return null;
222
+ if (!pkg) {
223
+ diagnostics.fatal.push('缺少 package.json,或 package.json 不是合法 JSON');
224
+ return { diagnostics, info: null };
225
+ }
226
+
227
+ const manifest = pkg.finch;
228
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
229
+ diagnostics.fatal.push('缺少 package.json#finch,或 finch manifest 不是对象');
230
+ return { diagnostics, info: null };
231
+ }
232
+
97
233
  const id = String(manifest.id ?? pkg.name ?? '').trim();
98
- if (!id) return { error: 'package.json#finch 缺少 id' };
99
- const main = String(manifest.main ?? pkg.main ?? 'dist/index.js');
100
- if (!existsSync(join(dir, main))) return { error: `入口文件不存在: ${main}(请先构建插件)`, id };
101
- // `name` is the current preferred manifest field; `displayName` is kept only
102
- // for backward compatibility with older extensions.
234
+ if (!id) {
235
+ diagnostics.fatal.push('package.json#finch 缺少 id');
236
+ } else if (!EXTENSION_ID_RE.test(id) || id === '.' || id === '..') {
237
+ diagnostics.fatal.push(`package.json#finch.id 不合法: ${id}(只能使用字母、数字、点、下划线、短横线,且不能包含路径分隔符)`);
238
+ }
239
+
240
+ if (manifest.manifestVersion !== undefined && manifest.manifestVersion !== SUPPORTED_MANIFEST_VERSION) {
241
+ diagnostics.fatal.push(`不支持的 manifestVersion: ${manifest.manifestVersion}(当前 Finch 支持 ${SUPPORTED_MANIFEST_VERSION})`);
242
+ }
243
+
244
+ validateStringField(manifest.name, 'finch.name', diagnostics, { localized: true });
245
+ validateStringField(manifest.displayName, 'finch.displayName', diagnostics, { localized: true });
246
+ validateStringField(manifest.description, 'finch.description', diagnostics, { localized: true });
247
+ validateStringField(manifest.systemPrompt, 'finch.systemPrompt', diagnostics, { localized: true });
248
+ validateStringArray(manifest.categories, 'finch.categories', diagnostics);
249
+
250
+ const extensionType = manifest.miniToolType ?? manifest.extensionType;
251
+ if (extensionType != null && (typeof extensionType !== 'string' || !extensionType.trim())) {
252
+ diagnostics.warning.push('finch.miniToolType/extensionType 应为字符串');
253
+ } else if (typeof extensionType === 'string' && !KNOWN_EXTENSION_TYPES.has(extensionType)) {
254
+ diagnostics.warning.push(`未知 extensionType: ${extensionType}(常见值为 official/community/local)`);
255
+ }
256
+
257
+ if (manifest.install != null && validateObject(manifest.install, 'finch.install', diagnostics)) {
258
+ const scope = manifest.install.preferredScope;
259
+ if (scope != null && scope !== 'global' && scope !== 'personal') {
260
+ diagnostics.warning.push('finch.install.preferredScope 应为 global 或 personal');
261
+ }
262
+ }
263
+
264
+ validatePermissions(manifest.permissions, diagnostics);
265
+ validateCapabilitySpec(manifest.provides, 'finch.provides', diagnostics);
266
+ validateCapabilitySpec(manifest.requires, 'finch.requires', diagnostics);
267
+ validateContributes(manifest.contributes, diagnostics);
268
+
269
+ const mainValue = manifest.main ?? pkg.main ?? 'dist/index.js';
270
+ if (typeof mainValue !== 'string' || !mainValue.trim()) {
271
+ diagnostics.fatal.push('入口 main 必须是非空字符串(finch.main 或 package.json#main)');
272
+ }
273
+ const main = typeof mainValue === 'string' && mainValue.trim() ? mainValue.trim() : 'dist/index.js';
274
+ const entry = isAbsolute(main) ? main : join(dir, main);
275
+ if (!existsSync(entry)) diagnostics.fatal.push(`入口文件不存在: ${main}(请先构建小工具)`);
276
+
277
+ const recommended = [];
278
+ if (manifest.name == null && manifest.displayName == null) recommended.push('name');
279
+ if (manifest.description == null) recommended.push('description');
280
+ if (manifest.miniToolType == null && manifest.extensionType == null) recommended.push('miniToolType');
281
+ if (recommended.length) diagnostics.warning.push(`manifest 建议补充字段: ${recommended.join(', ')}`);
282
+
283
+ if (lintSource) diagnostics.warning.push(...lintExtensionSource(dir));
284
+
103
285
  const nameField = manifest.name ?? manifest.displayName;
286
+ const displayName = localizedValue(nameField, pkg.name ?? id).trim() || id;
104
287
  return {
105
- id,
106
- name: pkg.name ?? id,
107
- version: pkg.version ?? '0.0.0',
108
- displayName: typeof nameField === 'string'
109
- ? nameField
110
- : nameField?.default ?? nameField?.['en-US'] ?? nameField?.['zh-CN'] ?? id,
111
- main,
288
+ diagnostics,
289
+ info: {
290
+ id,
291
+ name: pkg.name ?? id,
292
+ version: pkg.version ?? '0.0.0',
293
+ displayName,
294
+ main,
295
+ },
112
296
  };
113
297
  }
114
298
 
299
+ function pluginInfo(dir) {
300
+ const result = validateMiniToolPackage(dir);
301
+ if (!result.info && result.diagnostics.fatal.length > 0) {
302
+ const pkg = readPackageJson(dir);
303
+ return pkg && Object.prototype.hasOwnProperty.call(pkg, 'finch')
304
+ ? { error: result.diagnostics.fatal[0] }
305
+ : null;
306
+ }
307
+ if (result.diagnostics.fatal.length > 0) return { error: result.diagnostics.fatal[0], id: result.info?.id };
308
+ return result.info;
309
+ }
310
+
115
311
  function findPluginDirs(root, maxDepth = 4) {
116
312
  const found = [];
313
+ const invalid = [];
117
314
  const seen = new Set();
118
315
  function visit(dir, depth) {
119
316
  const key = dir.toLowerCase();
@@ -121,6 +318,7 @@ function findPluginDirs(root, maxDepth = 4) {
121
318
  seen.add(key);
122
319
  const info = pluginInfo(dir);
123
320
  if (info && !info.error) found.push(dir);
321
+ else if (info?.error) invalid.push({ dir, error: info.error });
124
322
  if (depth <= 0) return;
125
323
  let entries = [];
126
324
  try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
@@ -131,6 +329,7 @@ function findPluginDirs(root, maxDepth = 4) {
131
329
  }
132
330
  }
133
331
  visit(root, maxDepth);
332
+ found.invalid = invalid;
134
333
  return found;
135
334
  }
136
335
 
@@ -143,67 +342,12 @@ function installExtensionDir(srcDir, destRoot, lockSource) {
143
342
  cpSync(srcDir, dest, { recursive: true, force: true, dereference: false });
144
343
  recordInstall(destRoot, info.id, lockSource);
145
344
  console.log(`✓ Added "${info.displayName}" (${info.id}) → ${dest}`);
146
- console.log(' Installed only. Open Finch → Toolcase → Extensions to review permissions and enable.');
345
+ console.log(' Installed only. Open Finch → Toolcase → Mini Tool to review permissions and enable.');
147
346
  return info.id;
148
347
  }
149
348
 
150
- /**
151
- * Look for an executable on PATH (via `which`/`where`), falling back to a
152
- * handful of common install locations that don't always make it onto the
153
- * PATH inherited by a GUI-launched app (Homebrew, nvm, Volta, Windows
154
- * installer). Returns the resolved path, or null if not found anywhere.
155
- */
156
- function findExecutable(name) {
157
- const finder = process.platform === 'win32' ? 'where' : 'which';
158
- const found = spawnSync(finder, [name], { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' });
159
- if (found.status === 0) {
160
- const first = found.stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
161
- if (first) return first;
162
- }
163
-
164
- const candidateDirs = process.platform === 'win32'
165
- ? [
166
- join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs'),
167
- join(process.env.APPDATA ?? '', 'npm'),
168
- ]
169
- : (() => {
170
- const dirs = ['/opt/homebrew/bin', '/usr/local/bin', join(homedir(), '.volta', 'bin')];
171
- const nvmRoot = join(homedir(), '.nvm', 'versions', 'node');
172
- try {
173
- for (const version of readdirSync(nvmRoot)) dirs.push(join(nvmRoot, version, 'bin'));
174
- } catch { /* nvm not installed */ }
175
- return dirs;
176
- })();
177
- const exeName = process.platform === 'win32' ? `${name}.cmd` : name;
178
- for (const dir of candidateDirs) {
179
- const candidate = join(dir, exeName);
180
- if (existsSync(candidate)) return candidate;
181
- }
182
- return null;
183
- }
184
-
185
- const NODEJS_INSTALL_HINT = 'Install Node.js (which bundles npm) from https://nodejs.org, then try again.';
186
-
187
- function npmInstallToTemp(spec, tmp) {
188
- const npmPath = findExecutable('npm');
189
- if (!npmPath) {
190
- throw new Error(`This extension is an npm package, but no "npm" executable was found on this machine.\n${NODEJS_INSTALL_HINT}`);
191
- }
192
- mkdirSync(tmp, { recursive: true });
193
- const r = spawnSync(npmPath, ['install', '--ignore-scripts', '--omit=dev', '--prefix', tmp, spec], {
194
- stdio: ['ignore', 'pipe', 'pipe'],
195
- encoding: 'utf-8',
196
- });
197
- if (r.error) {
198
- throw new Error(`Failed to run npm (${npmPath}): ${r.error.message}\n${NODEJS_INSTALL_HINT}`);
199
- }
200
- if (r.status !== 0) {
201
- throw new Error(`npm install failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
202
- }
203
- }
204
-
205
349
  function isLocalSource(src) {
206
- return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~');
350
+ return src.startsWith('./') || src.startsWith('../') || src.startsWith('/') || src.startsWith('~') || /^[a-zA-Z]:[\\/]/.test(src);
207
351
  }
208
352
  function isZipUrl(src) {
209
353
  return /^https?:\/\/.+\.zip(\?.*)?$/i.test(src);
@@ -211,6 +355,13 @@ function isZipUrl(src) {
211
355
  function isZipFile(src) {
212
356
  return src.toLowerCase().endsWith('.zip');
213
357
  }
358
+ function isTgzUrl(src) {
359
+ return /^https?:\/\/.+\.(?:tgz|tar\.gz)(\?.*)?$/i.test(src);
360
+ }
361
+ function isTgzFile(src) {
362
+ const lower = src.toLowerCase();
363
+ return lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
364
+ }
214
365
  function expandHome(path) {
215
366
  return path.replace(/^~(?=\/|$)/, homedir());
216
367
  }
@@ -219,17 +370,73 @@ function expandHome(path) {
219
370
  * Download a URL to a local file path using Node's built-in fetch (Node 18+).
220
371
  * Falls back to curl/wget if fetch is unavailable.
221
372
  */
373
+ function downloadWithSystemTool(url, destPath, fetchError) {
374
+ const curlExe = process.platform === 'win32' ? 'curl.exe' : 'curl';
375
+ const curl = spawnSync(curlExe, ['-fL', '--retry', '2', '--connect-timeout', '20', '-o', destPath, url], {
376
+ stdio: ['ignore', 'pipe', 'pipe'],
377
+ encoding: 'utf-8',
378
+ });
379
+ if (curl.status === 0 && !curl.error) return;
380
+ if (process.platform === 'win32') {
381
+ const ps = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'Invoke-WebRequest -Uri $args[0] -OutFile $args[1]', url, destPath], {
382
+ stdio: ['ignore', 'pipe', 'pipe'],
383
+ encoding: 'utf-8',
384
+ });
385
+ if (ps.status === 0 && !ps.error) return;
386
+ throw new Error(`Download failed: ${fetchError?.message ?? fetchError}\n${curl.stderr || curl.stdout || curl.error?.message || 'curl failed'}\n${ps.stderr || ps.stdout || ps.error?.message || 'powershell failed'}`);
387
+ }
388
+ throw new Error(`Download failed: ${fetchError?.message ?? fetchError}\n${curl.stderr || curl.stdout || curl.error?.message || 'curl failed'}`);
389
+ }
390
+
222
391
  async function downloadFile(url, destPath) {
223
- if (typeof fetch === 'function') {
392
+ try {
224
393
  const res = await fetch(url, { redirect: 'follow' });
225
- if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText} — ${url}`);
394
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
226
395
  const buf = Buffer.from(await res.arrayBuffer());
227
396
  writeFileSync(destPath, buf);
228
- } else {
229
- // Fallback: curl (macOS / Linux always has it)
230
- const r = spawnSync('curl', ['-fsSL', '-o', destPath, url], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
231
- if (r.status !== 0) throw new Error(`curl failed:\n${r.stderr || r.stdout}`);
397
+ } catch (err) {
398
+ downloadWithSystemTool(url, destPath, err);
399
+ }
400
+ }
401
+
402
+ function communityMiniToolFromUrl(url) {
403
+ let parsed;
404
+ try { parsed = new URL(url); } catch { return null; }
405
+ if (parsed.protocol !== 'https:' || parsed.hostname !== 'community.finchwork.app') return null;
406
+ const parts = parsed.pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
407
+ if (parts[0] !== 'download' || parts[1] !== 'minitool' || parts.length < 4) return null;
408
+ const version = parts.at(-1);
409
+ const packageName = parts.slice(2, -1).join('/');
410
+ if (!packageName || !version || version === 'latest') return null;
411
+ return { packageName, version };
412
+ }
413
+
414
+ function safeCacheSegment(value) {
415
+ return encodeURIComponent(value).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
416
+ }
417
+
418
+ function communityMiniToolCachePath(meta) {
419
+ return join(miniToolCacheDir(), `${safeCacheSegment(meta.packageName)}-${safeCacheSegment(meta.version)}.tgz`);
420
+ }
421
+
422
+ async function downloadFileWithCache(url, destPath) {
423
+ const communityMeta = communityMiniToolFromUrl(url);
424
+ if (!communityMeta) {
425
+ await downloadFile(url, destPath);
426
+ return;
427
+ }
428
+
429
+ const cachePath = communityMiniToolCachePath(communityMeta);
430
+ if (existsSync(cachePath)) {
431
+ cpSync(cachePath, destPath);
432
+ console.log(` Using cached package ${communityMeta.packageName}@${communityMeta.version}`);
433
+ return;
232
434
  }
435
+
436
+ await downloadFile(url, destPath);
437
+ mkdirSync(miniToolCacheDir(), { recursive: true });
438
+ cpSync(destPath, cachePath);
439
+ console.log(` Cached package ${communityMeta.packageName}@${communityMeta.version}`);
233
440
  }
234
441
 
235
442
  /**
@@ -237,6 +444,21 @@ async function downloadFile(url, destPath) {
237
444
  */
238
445
  function extractZip(zipPath, destDir) {
239
446
  mkdirSync(destDir, { recursive: true });
447
+ if (process.platform === 'win32') {
448
+ const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', 'Expand-Archive -LiteralPath $args[0] -DestinationPath $args[1] -Force', zipPath, destDir], {
449
+ stdio: ['ignore', 'pipe', 'pipe'],
450
+ encoding: 'utf-8',
451
+ });
452
+ if (r.status === 0 && !r.error) return;
453
+ const tar = spawnSync('tar.exe', ['-xf', zipPath, '-C', destDir], {
454
+ stdio: ['ignore', 'pipe', 'pipe'],
455
+ encoding: 'utf-8',
456
+ });
457
+ if (tar.status !== 0 || tar.error) {
458
+ throw new Error(`zip extract failed:\n${r.stderr || tar.stderr || r.stdout || tar.stdout || tar.error?.message || 'unknown error'}`);
459
+ }
460
+ return;
461
+ }
240
462
  const r = spawnSync('unzip', ['-q', '-o', zipPath, '-d', destDir], {
241
463
  stdio: ['ignore', 'pipe', 'pipe'],
242
464
  encoding: 'utf-8',
@@ -247,6 +469,83 @@ function extractZip(zipPath, destDir) {
247
469
  if (r.status !== 0) throw new Error(`unzip failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
248
470
  }
249
471
 
472
+ /**
473
+ * Extract a .tgz/.tar.gz archive using the system tar command. macOS/Linux ship
474
+ * it by default; modern Windows includes bsdtar as tar.exe.
475
+ */
476
+ function extractTgz(tgzPath, destDir) {
477
+ mkdirSync(destDir, { recursive: true });
478
+ const tarExe = process.platform === 'win32' ? 'tar.exe' : 'tar';
479
+ const r = spawnSync(tarExe, ['-xzf', tgzPath, '-C', destDir], {
480
+ stdio: ['ignore', 'pipe', 'pipe'],
481
+ encoding: 'utf-8',
482
+ });
483
+ if (r.error) {
484
+ throw new Error(`No "tar" command found on this machine: ${r.error.message}`);
485
+ }
486
+ if (r.status !== 0) throw new Error(`tar extract failed:\n${r.stderr || r.stdout || `exit code ${r.status}`}`);
487
+ }
488
+
489
+ function parseNpmSpec(spec) {
490
+ const trimmed = String(spec ?? '').trim();
491
+ if (!trimmed) throw new Error('missing npm package name');
492
+ if (trimmed.startsWith('@')) {
493
+ const versionSep = trimmed.indexOf('@', 1);
494
+ return versionSep > 0
495
+ ? { name: trimmed.slice(0, versionSep), version: trimmed.slice(versionSep + 1) || 'latest' }
496
+ : { name: trimmed, version: 'latest' };
497
+ }
498
+ const versionSep = trimmed.lastIndexOf('@');
499
+ return versionSep > 0
500
+ ? { name: trimmed.slice(0, versionSep), version: trimmed.slice(versionSep + 1) || 'latest' }
501
+ : { name: trimmed, version: 'latest' };
502
+ }
503
+
504
+ let registryOverride = null;
505
+
506
+ function configuredRegistryUrl() {
507
+ return (registryOverride || process.env.npm_config_registry || 'https://registry.npmjs.org').replace(/\/+$/, '');
508
+ }
509
+
510
+ function npmMetadataUrl(packageName, version) {
511
+ return `${configuredRegistryUrl()}/${encodeURIComponent(packageName)}/${encodeURIComponent(version || 'latest')}`;
512
+ }
513
+
514
+ function officialMiniToolDownloadUrl(packageName, version) {
515
+ return `https://community.finchwork.app/download/minitool/${packageName}/${encodeURIComponent(version)}`;
516
+ }
517
+
518
+ async function resolveNpmTarball(spec) {
519
+ const { name, version } = parseNpmSpec(spec);
520
+ if (version && version !== 'latest') {
521
+ return {
522
+ package: name,
523
+ version,
524
+ tarball: officialMiniToolDownloadUrl(name, version),
525
+ };
526
+ }
527
+
528
+ const url = npmMetadataUrl(name, version);
529
+ let meta;
530
+ try {
531
+ const res = await fetch(url, { redirect: 'follow' });
532
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
533
+ meta = await res.json();
534
+ } catch (err) {
535
+ throw new Error(`npm metadata fetch failed: ${err instanceof Error ? err.message : String(err)} — ${url}`);
536
+ }
537
+ const resolvedVersion = String(meta.version ?? version);
538
+ const tarball = meta?.dist?.tarball;
539
+ if (typeof tarball !== 'string' || !tarball) {
540
+ throw new Error(`No dist.tarball found for ${name}@${version}`);
541
+ }
542
+ return {
543
+ package: name,
544
+ version: resolvedVersion,
545
+ tarball: officialMiniToolDownloadUrl(name, resolvedVersion),
546
+ };
547
+ }
548
+
250
549
  /**
251
550
  * Install extensions from a zip file (local path or remote URL).
252
551
  * The zip may contain one or more extensions at any nesting level.
@@ -259,17 +558,20 @@ async function installFromZip(src, dest, isUrl) {
259
558
  try {
260
559
  if (isUrl) {
261
560
  console.log(` Downloading ${src} …`);
262
- await downloadFile(src, zipPath);
561
+ await downloadFileWithCache(src, zipPath);
263
562
  } else {
264
563
  // Local zip — copy to tmp so we have a consistent path
265
564
  const abs = resolve(expandHome(src));
266
565
  if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
267
566
  cpSync(abs, zipPath);
268
567
  }
269
- console.log(' Extracting …');
568
+ // console.log(' Extracting …');
270
569
  extractZip(zipPath, extractDir);
271
570
  const found = findPluginDirs(extractDir, 5);
272
- if (found.length === 0) throw new Error('No Finch extension found inside the zip archive.');
571
+ if (found.length === 0) {
572
+ if (found.invalid?.length) throw new Error(`Invalid Finch extension inside the zip archive: ${found.invalid[0].error}`);
573
+ throw new Error('No Finch extension found inside the zip archive.');
574
+ }
273
575
  const lockSource = isUrl
274
576
  ? { type: 'zip', url: src }
275
577
  : { type: 'zip', localPath: resolve(expandHome(src)) };
@@ -279,25 +581,63 @@ async function installFromZip(src, dest, isUrl) {
279
581
  }
280
582
  }
281
583
 
584
+ async function installFromTgz(src, dest, { isUrl, lockSource }) {
585
+ const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
586
+ const tgzPath = join(tmp, 'extension.tgz');
587
+ const extractDir = join(tmp, 'extracted');
588
+ mkdirSync(tmp, { recursive: true });
589
+ try {
590
+ if (isUrl) {
591
+ console.log(` Downloading ${src} …`);
592
+ await downloadFileWithCache(src, tgzPath);
593
+ } else {
594
+ const abs = resolve(expandHome(src));
595
+ if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
596
+ cpSync(abs, tgzPath);
597
+ }
598
+ // console.log(' Extracting …');
599
+ extractTgz(tgzPath, extractDir);
600
+ const found = findPluginDirs(extractDir, 5);
601
+ if (found.length === 0) {
602
+ if (found.invalid?.length) throw new Error(`Invalid Finch extension inside the tgz archive: ${found.invalid[0].error}`);
603
+ throw new Error('No Finch extension found inside the tgz archive.');
604
+ }
605
+ const source = lockSource ?? (isUrl
606
+ ? { type: 'tgz', url: src }
607
+ : { type: 'tgz', localPath: resolve(expandHome(src)) });
608
+ for (const dir of found) installExtensionDir(dir, dest, source);
609
+ } finally {
610
+ try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
611
+ }
612
+ }
613
+
282
614
  async function cmdAdd(src, opts) {
283
615
  const dest = targetDir(opts);
284
616
 
285
- // --- zip URL (e.g. https://github.com/.../archive/main.zip) ---
617
+ // --- archive URL (e.g. https://github.com/.../archive/main.zip or npm tarball .tgz) ---
286
618
  if (isZipUrl(src)) {
287
619
  await installFromZip(src, dest, true);
288
620
  return;
289
621
  }
622
+ if (isTgzUrl(src)) {
623
+ await installFromTgz(src, dest, { isUrl: true });
624
+ return;
625
+ }
290
626
 
291
627
  // --- local path ---
292
628
  if (isLocalSource(src)) {
293
629
  const abs = resolve(expandHome(src));
294
630
  if (!existsSync(abs)) throw new Error(`path not found: ${abs}`);
295
631
 
296
- // Local zip file
632
+ // Local archive file
297
633
  if (isZipFile(src)) {
298
634
  await installFromZip(src, dest, false);
299
635
  return;
300
636
  }
637
+ if (isTgzFile(src)) {
638
+ await installFromTgz(src, dest, { isUrl: false });
639
+ return;
640
+ }
301
641
 
302
642
  // Local directory
303
643
  const direct = pluginInfo(abs);
@@ -306,23 +646,20 @@ async function cmdAdd(src, opts) {
306
646
  return;
307
647
  }
308
648
  const found = findPluginDirs(abs, 3);
309
- if (found.length === 0) throw new Error('No Finch extension found in the given directory.');
649
+ if (found.length === 0) {
650
+ if (found.invalid?.length) throw new Error(`Invalid Finch extension in the given directory: ${found.invalid[0].error}`);
651
+ throw new Error('No Finch extension found in the given directory.');
652
+ }
310
653
  for (const dir of found) installExtensionDir(dir, dest, { type: 'local', localPath: dir });
311
654
  return;
312
655
  }
313
656
 
314
- // --- npm package ---
315
- const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
316
- try {
317
- npmInstallToTemp(src, tmp);
318
- const found = findPluginDirs(join(tmp, 'node_modules'), 5);
319
- if (found.length === 0) throw new Error('No package with package.json#finch found in the npm package.');
320
- // Prefer the top-level package matching the requested spec when possible.
321
- const first = found[0];
322
- installExtensionDir(first, dest, { type: 'npm', package: src });
323
- } finally {
324
- try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
325
- }
657
+ // --- npm package: fetch registry metadata, download dist.tarball, then extract locally. ---
658
+ const resolved = await resolveNpmTarball(src);
659
+ await installFromTgz(resolved.tarball, dest, {
660
+ isUrl: true,
661
+ lockSource: { type: 'npm', package: resolved.package, version: resolved.version },
662
+ });
326
663
  }
327
664
 
328
665
  function listInstalled(dir) {
@@ -451,22 +788,20 @@ function lintExtensionSource(root) {
451
788
 
452
789
  function cmdDoctor(src = '.') {
453
790
  const abs = resolve(expandHome(src));
454
- const info = pluginInfo(abs);
455
- if (!info) throw new Error('Not a Finch extension package (missing package.json#finch).');
456
- if (info.error) throw new Error(info.error);
457
- console.log(`✓ Finch extension: ${info.displayName}`);
458
- console.log(` id: ${info.id}`);
459
- console.log(` version: ${info.version}`);
460
- console.log(` main: ${info.main}`);
791
+ const { diagnostics, info } = validateMiniToolPackage(abs, { lintSource: true });
792
+
793
+ if (info) {
794
+ console.log(`Finch mini tool: ${info.displayName}`);
795
+ console.log(` id: ${info.id || '(missing)'}`);
796
+ console.log(` version: ${info.version}`);
797
+ console.log(` main: ${info.main}`);
798
+ } else {
799
+ console.log(`Finch mini tool: ${abs}`);
800
+ }
461
801
 
462
802
  const pkg = readPackageJson(abs);
463
803
  const manifest = pkg?.finch ?? {};
464
- // Surface recommended manifest metadata that's missing (non-fatal).
465
- const recommended = ['name', 'description'];
466
- const missing = recommended.filter((k) => manifest[k] == null);
467
- if (manifest.miniToolType == null && manifest.extensionType == null) missing.push('miniToolType');
468
- if (missing.length) console.log(` hint: manifest 建议补充字段: ${missing.join(', ')}`);
469
- if (manifest.permissions) {
804
+ if (manifest.permissions && typeof manifest.permissions === 'object') {
470
805
  const p = manifest.permissions;
471
806
  const decl = [
472
807
  p.filesystem && p.filesystem !== 'none' ? `filesystem=${p.filesystem}` : null,
@@ -476,13 +811,20 @@ function cmdDoctor(src = '.') {
476
811
  if (decl.length) console.log(` permissions: ${decl.join(', ')}(启用时会向用户展示)`);
477
812
  }
478
813
 
479
- const warnings = lintExtensionSource(abs);
480
- if (warnings.length === 0) {
814
+ if (diagnostics.fatal.length > 0) {
815
+ console.log(`\n✖ ${diagnostics.fatal.length} fatal issue(s):`);
816
+ for (const issue of diagnostics.fatal) console.log(` - ${issue}`);
817
+ }
818
+ if (diagnostics.warning.length > 0) {
819
+ console.log(`\n⚠ ${diagnostics.warning.length} warning(s):`);
820
+ for (const issue of diagnostics.warning) console.log(` - ${issue}`);
821
+ }
822
+ if (diagnostics.fatal.length === 0 && diagnostics.warning.length === 0) {
481
823
  console.log('✓ No issues found.');
482
- return;
483
824
  }
484
- console.log(`\n⚠ ${warnings.length} warning(s):`);
485
- for (const w of warnings) console.log(` - ${w}`);
825
+ if (diagnostics.fatal.length > 0) {
826
+ throw new Error('mini tool package validation failed');
827
+ }
486
828
  }
487
829
 
488
830
  async function cmdUpdate(id, opts) {
@@ -517,7 +859,7 @@ async function cmdUpdate(id, opts) {
517
859
  return;
518
860
  }
519
861
 
520
- // zip source: re-download / re-extract from the recorded URL or local path.
862
+ // zip/tgz source: re-download / re-extract from the recorded URL or local path.
521
863
  if (source.type === 'zip') {
522
864
  if (source.url) {
523
865
  await installFromZip(source.url, dir, true);
@@ -528,34 +870,39 @@ async function cmdUpdate(id, opts) {
528
870
  }
529
871
  return;
530
872
  }
531
-
532
- // npm source: reinstall the latest published version.
533
- const spec = source.package ?? id;
534
- const tmp = join(tmpdir(), `finch-ext-${randomUUID()}`);
535
- try {
536
- npmInstallToTemp(spec, tmp);
537
- const found = findPluginDirs(join(tmp, 'node_modules'), 5).filter((d) => pluginInfo(d)?.id === id);
538
- const fresh = found[0] ?? findPluginDirs(join(tmp, 'node_modules'), 5)[0];
539
- if (!fresh) throw new Error('No matching Finch extension found in npm package.');
540
- const info = pluginInfo(fresh);
541
- rmSync(target, { recursive: true, force: true });
542
- cpSync(fresh, target, { recursive: true, force: true, dereference: false });
543
- recordInstall(dir, id, source);
544
- console.log(`✓ Updated "${info.displayName}" (${id}) → v${info.version}`);
545
- } finally {
546
- try { rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
873
+ if (source.type === 'tgz') {
874
+ if (source.url) {
875
+ await installFromTgz(source.url, dir, { isUrl: true, lockSource: source });
876
+ } else if (source.localPath) {
877
+ await installFromTgz(source.localPath, dir, { isUrl: false, lockSource: source });
878
+ } else {
879
+ throw new Error(`tgz install record for "${id}" has no url or localPath; reinstall with \`add\`.`);
880
+ }
881
+ return;
547
882
  }
883
+
884
+ // npm source: reinstall the latest published version by downloading dist.tarball directly.
885
+ const resolved = await resolveNpmTarball(source.package ?? id);
886
+ await installFromTgz(resolved.tarball, dir, {
887
+ isUrl: true,
888
+ lockSource: { type: 'npm', package: resolved.package, version: resolved.version },
889
+ });
548
890
  }
549
891
 
550
892
  function parseArgs(argv) {
551
893
  const args = [...argv];
552
894
  const cmd = args.shift();
553
- const opts = { global: false };
895
+ const opts = { global: false, registry: null };
554
896
  const rest = [];
555
897
  for (let i = 0; i < args.length; i += 1) {
556
898
  const a = args[i];
557
899
  if (a === '--global' || a === '-g') opts.global = true;
558
- else if (a === '--cwd') {
900
+ else if (a === '--registry') {
901
+ const value = args[i + 1];
902
+ if (!value || value.startsWith('--')) throw new Error('--registry requires a URL');
903
+ opts.registry = value;
904
+ i += 1;
905
+ } else if (a === '--cwd') {
559
906
  // Removed: extensions no longer support project/--cwd scope. Consume any
560
907
  // following path argument so old invocations fail loudly instead of
561
908
  // silently being parsed as the install source.
@@ -566,12 +913,13 @@ function parseArgs(argv) {
566
913
  }
567
914
 
568
915
  function help() {
569
- console.log(`npx @finch.app/minitools\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`);
916
+ console.log(`npx @finch.app/minitools\n\nUsage:\n add <npm-package|local-path|url.zip|url.tgz> [--global] [--registry <url>]\n update <id> [--global] [--registry <url>]\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\nRegistry:\n --registry <url> overrides npm registry for npm package metadata/tarball downloads.\n If omitted, npm_config_registry is used, then https://registry.npmjs.org.\n\nThere is no project/--cwd scope — extensions only install to personal or global.\n`);
570
917
  }
571
918
 
572
919
  (async () => {
573
920
  try {
574
921
  const { cmd, rest, opts } = parseArgs(process.argv.slice(2));
922
+ registryOverride = opts.registry;
575
923
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') return help();
576
924
  if (cmd === 'add') {
577
925
  if (!rest[0]) throw new Error('missing source');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finch.app/minitools",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "CLI shim for installing Finch mini tools to the correct location.",
5
5
  "type": "module",
6
6
  "bin": {