@finch.app/minitools 0.1.8 → 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 +303 -42
  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
@@ -9,12 +9,15 @@ import {
9
9
  existsSync, mkdirSync, readdirSync, cpSync, rmSync,
10
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,7 +342,7 @@ 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
 
@@ -200,6 +399,46 @@ async function downloadFile(url, destPath) {
200
399
  }
201
400
  }
202
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;
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}`);
440
+ }
441
+
203
442
  /**
204
443
  * Extract a zip archive to destDir using the system `unzip` command (macOS / Linux built-in).
205
444
  */
@@ -278,6 +517,14 @@ function officialMiniToolDownloadUrl(packageName, version) {
278
517
 
279
518
  async function resolveNpmTarball(spec) {
280
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
+
281
528
  const url = npmMetadataUrl(name, version);
282
529
  let meta;
283
530
  try {
@@ -311,17 +558,20 @@ async function installFromZip(src, dest, isUrl) {
311
558
  try {
312
559
  if (isUrl) {
313
560
  console.log(` Downloading ${src} …`);
314
- await downloadFile(src, zipPath);
561
+ await downloadFileWithCache(src, zipPath);
315
562
  } else {
316
563
  // Local zip — copy to tmp so we have a consistent path
317
564
  const abs = resolve(expandHome(src));
318
565
  if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
319
566
  cpSync(abs, zipPath);
320
567
  }
321
- console.log(' Extracting …');
568
+ // console.log(' Extracting …');
322
569
  extractZip(zipPath, extractDir);
323
570
  const found = findPluginDirs(extractDir, 5);
324
- 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
+ }
325
575
  const lockSource = isUrl
326
576
  ? { type: 'zip', url: src }
327
577
  : { type: 'zip', localPath: resolve(expandHome(src)) };
@@ -339,16 +589,19 @@ async function installFromTgz(src, dest, { isUrl, lockSource }) {
339
589
  try {
340
590
  if (isUrl) {
341
591
  console.log(` Downloading ${src} …`);
342
- await downloadFile(src, tgzPath);
592
+ await downloadFileWithCache(src, tgzPath);
343
593
  } else {
344
594
  const abs = resolve(expandHome(src));
345
595
  if (!existsSync(abs)) throw new Error(`file not found: ${abs}`);
346
596
  cpSync(abs, tgzPath);
347
597
  }
348
- console.log(' Extracting …');
598
+ // console.log(' Extracting …');
349
599
  extractTgz(tgzPath, extractDir);
350
600
  const found = findPluginDirs(extractDir, 5);
351
- if (found.length === 0) throw new Error('No Finch extension found inside the tgz archive.');
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
+ }
352
605
  const source = lockSource ?? (isUrl
353
606
  ? { type: 'tgz', url: src }
354
607
  : { type: 'tgz', localPath: resolve(expandHome(src)) });
@@ -393,7 +646,10 @@ async function cmdAdd(src, opts) {
393
646
  return;
394
647
  }
395
648
  const found = findPluginDirs(abs, 3);
396
- 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
+ }
397
653
  for (const dir of found) installExtensionDir(dir, dest, { type: 'local', localPath: dir });
398
654
  return;
399
655
  }
@@ -532,22 +788,20 @@ function lintExtensionSource(root) {
532
788
 
533
789
  function cmdDoctor(src = '.') {
534
790
  const abs = resolve(expandHome(src));
535
- const info = pluginInfo(abs);
536
- if (!info) throw new Error('Not a Finch extension package (missing package.json#finch).');
537
- if (info.error) throw new Error(info.error);
538
- console.log(`✓ Finch extension: ${info.displayName}`);
539
- console.log(` id: ${info.id}`);
540
- console.log(` version: ${info.version}`);
541
- 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
+ }
542
801
 
543
802
  const pkg = readPackageJson(abs);
544
803
  const manifest = pkg?.finch ?? {};
545
- // Surface recommended manifest metadata that's missing (non-fatal).
546
- const recommended = ['name', 'description'];
547
- const missing = recommended.filter((k) => manifest[k] == null);
548
- if (manifest.miniToolType == null && manifest.extensionType == null) missing.push('miniToolType');
549
- if (missing.length) console.log(` hint: manifest 建议补充字段: ${missing.join(', ')}`);
550
- if (manifest.permissions) {
804
+ if (manifest.permissions && typeof manifest.permissions === 'object') {
551
805
  const p = manifest.permissions;
552
806
  const decl = [
553
807
  p.filesystem && p.filesystem !== 'none' ? `filesystem=${p.filesystem}` : null,
@@ -557,13 +811,20 @@ function cmdDoctor(src = '.') {
557
811
  if (decl.length) console.log(` permissions: ${decl.join(', ')}(启用时会向用户展示)`);
558
812
  }
559
813
 
560
- const warnings = lintExtensionSource(abs);
561
- 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) {
562
823
  console.log('✓ No issues found.');
563
- return;
564
824
  }
565
- console.log(`\n⚠ ${warnings.length} warning(s):`);
566
- for (const w of warnings) console.log(` - ${w}`);
825
+ if (diagnostics.fatal.length > 0) {
826
+ throw new Error('mini tool package validation failed');
827
+ }
567
828
  }
568
829
 
569
830
  async function cmdUpdate(id, opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@finch.app/minitools",
3
- "version": "0.1.8",
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": {