@42ailab/42plugin 0.1.19 → 0.1.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@42ailab/42plugin",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "活水插件",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/api.ts CHANGED
@@ -261,18 +261,20 @@ class ApiClient {
261
261
  // ==========================================================================
262
262
 
263
263
  /**
264
- * 搜索插件
264
+ * 搜索插件和套包
265
265
  * 使用 CLI 专用端点 /cli/v1/search
266
- * 响应直接返回 camelCase,只返回插件不返回套包
266
+ * 响应直接返回 camelCase
267
267
  */
268
268
  async search(params: {
269
269
  q: string;
270
+ searchType?: 'plugin' | 'kit' | 'all';
270
271
  pluginType?: string;
271
272
  page?: number;
272
273
  perPage?: number;
273
274
  }): Promise<SearchResponse> {
274
275
  const searchParams = new URLSearchParams();
275
276
  searchParams.set('q', params.q);
277
+ if (params.searchType) searchParams.set('search_type', params.searchType);
276
278
  if (params.pluginType) searchParams.set('type', params.pluginType);
277
279
  if (params.page) searchParams.set('page', String(params.page));
278
280
  if (params.perPage) searchParams.set('per_page', String(params.perPage));
@@ -302,7 +304,7 @@ class ApiClient {
302
304
 
303
305
  // CLI 专用端点直接返回 camelCase 格式
304
306
  interface CliSearchResponse {
305
- data: Array<{
307
+ plugins: Array<{
306
308
  fullName: string;
307
309
  name: string;
308
310
  title: string | null;
@@ -312,9 +314,19 @@ class ApiClient {
312
314
  downloads: number;
313
315
  description: string | null;
314
316
  }>;
317
+ kits: Array<{
318
+ fullName: string;
319
+ name: string;
320
+ author: string;
321
+ pluginCount: number;
322
+ downloads: number;
323
+ description: string | null;
324
+ }>;
315
325
  pagination: {
316
326
  page: number;
317
327
  perPage: number;
328
+ totalPlugins: number;
329
+ totalKits: number;
318
330
  total: number;
319
331
  totalPages: number;
320
332
  };
@@ -323,7 +335,7 @@ class ApiClient {
323
335
  const apiData = raw as CliSearchResponse;
324
336
 
325
337
  return {
326
- data: apiData.data.map((p) => ({
338
+ plugins: apiData.plugins.map((p) => ({
327
339
  fullName: p.fullName,
328
340
  name: p.name,
329
341
  title: p.title,
@@ -336,9 +348,19 @@ class ApiClient {
336
348
  downloads: p.downloads,
337
349
  icon: null,
338
350
  })),
351
+ kits: apiData.kits.map((k) => ({
352
+ fullName: k.fullName,
353
+ name: k.name,
354
+ author: k.author,
355
+ pluginCount: k.pluginCount,
356
+ downloads: k.downloads,
357
+ description: k.description,
358
+ })),
339
359
  pagination: {
340
360
  page: apiData.pagination.page,
341
361
  perPage: apiData.pagination.perPage,
362
+ totalPlugins: apiData.pagination.totalPlugins,
363
+ totalKits: apiData.pagination.totalKits,
342
364
  total: apiData.pagination.total,
343
365
  totalPages: apiData.pagination.totalPages,
344
366
  },
package/src/cli.ts CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  searchCommand,
10
10
  listCommand,
11
11
  uninstallCommand,
12
- completionCommand,
13
12
  setupCommand,
14
13
  publishCommand,
15
14
  checkCommand,
@@ -53,7 +52,6 @@ program.addCommand(listCommand);
53
52
  program.addCommand(uninstallCommand);
54
53
  program.addCommand(publishCommand);
55
54
  program.addCommand(checkCommand);
56
- program.addCommand(completionCommand);
57
55
  program.addCommand(setupCommand);
58
56
 
59
57
  // 版本命令
@@ -7,7 +7,6 @@ export { installCommand } from './install';
7
7
  export { searchCommand } from './search';
8
8
  export { listCommand } from './list';
9
9
  export { uninstallCommand } from './uninstall';
10
- export { completionCommand } from './completion';
11
10
  export { setupCommand } from './setup';
12
11
  export { publishCommand } from './publish';
13
12
  export { checkCommand } from './check';
@@ -192,43 +192,74 @@ async function installKit(
192
192
  }
193
193
 
194
194
  spinner.succeed(`${kitDownload.kit.name} - ${plugins.length} 个插件`);
195
+
196
+ // 超过 42 个插件时提示确认
197
+ if (plugins.length > 42) {
198
+ console.log();
199
+ console.log(chalk.yellow(`⚠ 该套包包含 ${plugins.length} 个插件,数量较多`));
200
+ const shouldContinue = await confirm({
201
+ message: '确定要继续安装吗?',
202
+ default: true,
203
+ });
204
+ if (!shouldContinue) {
205
+ console.log(chalk.gray('已取消安装'));
206
+ return;
207
+ }
208
+ }
209
+
195
210
  console.log();
196
211
 
197
212
  // 获取项目
198
213
  const projectPath = options.global ? config.globalDir : process.cwd();
199
214
  const project = await getOrCreateProject(projectPath);
200
215
 
201
- // 安装每个插件
202
- let installed = 0;
203
- let failed = 0;
204
-
205
- for (const item of plugins) {
206
- const pluginSpinner = ora(` ${item.plugin.fullName}...`).start();
207
-
208
- try {
216
+ // 第一阶段:检查所有冲突(并行)
217
+ const conflictCheckSpinner = ora('检查安装冲突...').start();
218
+ const conflictResults = await Promise.all(
219
+ plugins.map(async (item) => {
209
220
  const downloadInfo = item.download;
210
221
  const linkPath = path.join(projectPath, downloadInfo.installPath);
211
-
212
- // 检查是否有同名冲突
213
222
  const conflictPlugin = await checkInstallConflict(projectPath, linkPath, downloadInfo.fullName);
214
- if (conflictPlugin) {
215
- pluginSpinner.stop();
216
- console.log(chalk.yellow(` ⚠ 路径冲突: ${downloadInfo.installPath}`));
217
- console.log(chalk.gray(` 已安装: ${conflictPlugin}`));
218
-
219
- const shouldReplace = await confirm({
220
- message: `是否替换 ${conflictPlugin}?`,
221
- default: false,
222
- });
223
-
224
- if (!shouldReplace) {
225
- console.log(chalk.gray(' 已跳过'));
226
- continue;
227
- }
228
- // 删除冲突的旧安装记录
229
- await removeInstallation(projectPath, conflictPlugin);
230
- pluginSpinner.start(` ${item.plugin.fullName}...`);
223
+ return { item, linkPath, conflictPlugin };
224
+ })
225
+ );
226
+ conflictCheckSpinner.stop();
227
+
228
+ // 第二阶段:处理冲突(需要用户交互,只能串行)
229
+ const toInstall: typeof conflictResults = [];
230
+ for (const result of conflictResults) {
231
+ if (result.conflictPlugin) {
232
+ console.log(chalk.yellow(`⚠ 路径冲突: ${result.item.download.installPath}`));
233
+ console.log(chalk.gray(` 已安装: ${result.conflictPlugin}`));
234
+ console.log(chalk.gray(` 新插件: ${result.item.plugin.fullName}`));
235
+
236
+ const shouldReplace = await confirm({
237
+ message: `是否替换 ${result.conflictPlugin}?`,
238
+ default: false,
239
+ });
240
+
241
+ if (shouldReplace) {
242
+ await removeInstallation(projectPath, result.conflictPlugin);
243
+ toInstall.push(result);
244
+ } else {
245
+ console.log(chalk.gray(' 已跳过'));
231
246
  }
247
+ } else {
248
+ toInstall.push(result);
249
+ }
250
+ }
251
+
252
+ if (toInstall.length === 0) {
253
+ console.log(chalk.yellow('没有需要安装的插件'));
254
+ return;
255
+ }
256
+
257
+ // 第三阶段:并行安装
258
+ const installSpinner = ora(`安装 ${toInstall.length} 个插件...`).start();
259
+
260
+ const results = await Promise.allSettled(
261
+ toInstall.map(async ({ item, linkPath }) => {
262
+ const downloadInfo = item.download;
232
263
 
233
264
  const { cachePath, fromCache } = await resolveCachePath(
234
265
  downloadInfo,
@@ -253,13 +284,30 @@ async function installKit(
253
284
  api.recordInstall(downloadInfo.fullName).catch(() => {});
254
285
  }
255
286
 
256
- pluginSpinner.succeed(
257
- ` ${getTypeIcon(downloadInfo.type)} ${downloadInfo.fullName}` +
287
+ return { downloadInfo, fromCache };
288
+ })
289
+ );
290
+
291
+ installSpinner.stop();
292
+
293
+ // 显示结果
294
+ let installed = 0;
295
+ let failed = 0;
296
+
297
+ for (let i = 0; i < results.length; i++) {
298
+ const result = results[i];
299
+ const item = toInstall[i].item;
300
+
301
+ if (result.status === 'fulfilled') {
302
+ const { downloadInfo, fromCache } = result.value;
303
+ console.log(
304
+ chalk.green(' ✓ ') +
305
+ `${getTypeIcon(downloadInfo.type)} ${downloadInfo.fullName}` +
258
306
  (fromCache ? chalk.gray(' (cached)') : '')
259
307
  );
260
308
  installed++;
261
- } catch (error) {
262
- pluginSpinner.fail(` ${item.plugin.fullName}: ${(error as Error).message}`);
309
+ } else {
310
+ console.log(chalk.red(` ${item.plugin.fullName}: ${result.reason?.message || '未知错误'}`));
263
311
  failed++;
264
312
  }
265
313
  }
@@ -10,6 +10,7 @@ import { ValidationError, UploadError, AuthRequiredError } from '../errors';
10
10
  import { getTypeIcon } from '../utils';
11
11
 
12
12
  export const publishCommand = new Command('publish')
13
+ .alias('pub')
13
14
  .description('发布插件到 42plugin')
14
15
  .argument('[path]', '插件路径(文件或目录)', '.')
15
16
  .option('--dry-run', '仅验证,不实际发布')
@@ -1,5 +1,5 @@
1
1
  /**
2
- * search 命令 - 搜索插件
2
+ * search 命令 - 搜索插件和套包
3
3
  */
4
4
 
5
5
  import { Command } from 'commander';
@@ -8,11 +8,11 @@ import ora from 'ora';
8
8
  import { checkbox, confirm } from '@inquirer/prompts';
9
9
  import { api } from '../api';
10
10
  import { getTypeIcon, getTypeLabel } from '../utils';
11
- import type { SearchResult } from '../types';
11
+ import type { SearchResult, KitSearchResult } from '../types';
12
12
 
13
13
  const DEFAULT_PAGE_SIZE = 7;
14
14
 
15
- function displayResults(items: SearchResult[], startIndex: number = 0): void {
15
+ function displayPluginResults(items: SearchResult[]): void {
16
16
  for (const item of items) {
17
17
  const icon = getTypeIcon(item.type);
18
18
  const typeLabel = getTypeLabel(item.type);
@@ -35,10 +35,28 @@ function displayResults(items: SearchResult[], startIndex: number = 0): void {
35
35
  }
36
36
  }
37
37
 
38
+ function displayKitResults(items: KitSearchResult[]): void {
39
+ for (const item of items) {
40
+ console.log(`📦 ${chalk.magenta.bold(item.fullName)} ${chalk.gray('[套包]')}`);
41
+
42
+ if (item.description) {
43
+ const desc = item.description.length > 80
44
+ ? item.description.slice(0, 80) + '...'
45
+ : item.description;
46
+ console.log(chalk.gray(` ${desc}`));
47
+ }
48
+
49
+ console.log(chalk.gray(` ${item.pluginCount} 个插件 · ${item.downloads} 下载`));
50
+ console.log();
51
+ }
52
+ }
53
+
38
54
  export const searchCommand = new Command('search')
39
- .description('搜索插件')
55
+ .description('搜索插件和套包')
40
56
  .argument('<keyword>', '搜索关键词')
41
- .option('-t, --type <type>', '筛选类型 (skill, agent, command, hook, mcp)')
57
+ .option('-t, --type <type>', '筛选插件类型 (skill, agent, command, hook, mcp)')
58
+ .option('-k, --kit', '只搜索套包')
59
+ .option('-p, --plugin', '只搜索插件')
42
60
  .option('-l, --limit <n>', '每页数量', String(DEFAULT_PAGE_SIZE))
43
61
  .option('-a, --all', '显示所有结果')
44
62
  .option('--json', '输出 JSON 格式')
@@ -49,14 +67,19 @@ export const searchCommand = new Command('search')
49
67
 
50
68
  try {
51
69
  const pageSize = parseInt(options.limit) || DEFAULT_PAGE_SIZE;
52
- let allResults: SearchResult[] = [];
70
+ let allPlugins: SearchResult[] = [];
71
+ let allKits: KitSearchResult[] = [];
53
72
  let currentPage = 1;
54
- let totalResults = 0;
55
- let displayedCount = 0;
73
+
74
+ // 确定搜索类型
75
+ let searchType: 'plugin' | 'kit' | 'all' = 'all';
76
+ if (options.kit) searchType = 'kit';
77
+ else if (options.plugin || options.type) searchType = 'plugin';
56
78
 
57
79
  // 首次搜索
58
80
  const result = await api.search({
59
81
  q: keyword,
82
+ searchType,
60
83
  pluginType: options.type,
61
84
  perPage: pageSize,
62
85
  page: currentPage,
@@ -69,25 +92,41 @@ export const searchCommand = new Command('search')
69
92
  return;
70
93
  }
71
94
 
72
- if (result.data.length === 0) {
73
- console.log(chalk.yellow('未找到匹配的插件'));
95
+ const totalPlugins = result.pagination.totalPlugins;
96
+ const totalKits = result.pagination.totalKits;
97
+ const total = result.pagination.total;
98
+
99
+ if (total === 0) {
100
+ console.log(chalk.yellow('未找到匹配的结果'));
74
101
  return;
75
102
  }
76
103
 
77
- totalResults = result.pagination.total;
78
- allResults = result.data;
104
+ // 显示统计
105
+ const stats: string[] = [];
106
+ if (totalPlugins > 0) stats.push(`${totalPlugins} 个插件`);
107
+ if (totalKits > 0) stats.push(`${totalKits} 个套包`);
108
+ console.log(chalk.gray(`找到 ${stats.join(',')}:\n`));
109
+
110
+ allPlugins = result.plugins;
111
+ allKits = result.kits;
112
+
113
+ // 先显示套包
114
+ if (allKits.length > 0) {
115
+ displayKitResults(allKits);
116
+ }
79
117
 
80
- console.log(chalk.gray(`找到 ${totalResults} 个结果:\n`));
118
+ // 再显示插件
119
+ if (allPlugins.length > 0) {
120
+ displayPluginResults(allPlugins);
121
+ }
81
122
 
82
- // 显示首批结果
83
- displayResults(allResults);
84
- displayedCount = allResults.length;
123
+ let displayedCount = allPlugins.length + allKits.length;
85
124
 
86
125
  // 如果有更多结果,根据模式决定是否继续
87
- while (displayedCount < totalResults) {
126
+ while (displayedCount < total) {
88
127
  // 非 --all 模式下询问用户
89
128
  if (!options.all) {
90
- const remaining = totalResults - displayedCount;
129
+ const remaining = total - displayedCount;
91
130
 
92
131
  const loadMore = await confirm({
93
132
  message: `还有 ${remaining} 个结果,是否继续查看?`,
@@ -104,6 +143,7 @@ export const searchCommand = new Command('search')
104
143
 
105
144
  const moreResult = await api.search({
106
145
  q: keyword,
146
+ searchType,
107
147
  pluginType: options.type,
108
148
  perPage: pageSize,
109
149
  page: currentPage,
@@ -111,7 +151,7 @@ export const searchCommand = new Command('search')
111
151
 
112
152
  moreSpinner.stop();
113
153
 
114
- if (moreResult.data.length === 0) {
154
+ if (moreResult.plugins.length === 0 && moreResult.kits.length === 0) {
115
155
  break;
116
156
  }
117
157
 
@@ -119,21 +159,37 @@ export const searchCommand = new Command('search')
119
159
  console.log();
120
160
  }
121
161
 
122
- displayResults(moreResult.data);
123
- allResults = [...allResults, ...moreResult.data];
124
- displayedCount += moreResult.data.length;
162
+ if (moreResult.kits.length > 0) {
163
+ displayKitResults(moreResult.kits);
164
+ allKits = [...allKits, ...moreResult.kits];
165
+ }
166
+
167
+ if (moreResult.plugins.length > 0) {
168
+ displayPluginResults(moreResult.plugins);
169
+ allPlugins = [...allPlugins, ...moreResult.plugins];
170
+ }
171
+
172
+ displayedCount += moreResult.plugins.length + moreResult.kits.length;
125
173
  }
126
174
 
127
175
  // 交互式选择安装
128
- if (options.interactive && allResults.length > 0) {
176
+ if (options.interactive && (allPlugins.length > 0 || allKits.length > 0)) {
129
177
  console.log();
130
- const choices = allResults.map((item) => ({
131
- name: `${getTypeIcon(item.type)} ${item.fullName} - ${item.title || item.slogan || item.description?.slice(0, 30) || ''}`,
132
- value: item.fullName,
133
- }));
178
+
179
+ // 构建选项列表(套包在前,插件在后)
180
+ const choices = [
181
+ ...allKits.map((item) => ({
182
+ name: `📦 ${item.fullName} - ${item.description?.slice(0, 30) || `${item.pluginCount} 个插件`}`,
183
+ value: item.fullName,
184
+ })),
185
+ ...allPlugins.map((item) => ({
186
+ name: `${getTypeIcon(item.type)} ${item.fullName} - ${item.title || item.slogan || item.description?.slice(0, 30) || ''}`,
187
+ value: item.fullName,
188
+ })),
189
+ ];
134
190
 
135
191
  const selected = await checkbox({
136
- message: '选择要安装的插件 (空格选择,回车确认):',
192
+ message: '选择要安装的插件/套包 (空格选择,回车确认):',
137
193
  choices,
138
194
  });
139
195
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * uninstall 命令 - 卸载插件
2
+ * uninstall 命令 - 卸载插件或套包
3
3
  */
4
4
 
5
5
  import { Command } from 'commander';
@@ -8,55 +8,140 @@ import ora from 'ora';
8
8
  import { api } from '../api';
9
9
  import { getInstallations, removeInstallation, removeLink, removeCache } from '../db';
10
10
  import { parseTarget } from '../utils';
11
+ import { TargetType } from '../types';
11
12
 
12
13
  export const uninstallCommand = new Command('uninstall')
13
14
  .alias('rm')
14
- .description('卸载插件')
15
- .argument('<target>', '插件名 (author/name)')
15
+ .description('卸载插件或套包')
16
+ .argument('<target>', '插件名 (author/name) 或套包名 (author/kit/slug)')
16
17
  .option('--purge', '同时清除缓存')
17
18
  .action(async (target, options) => {
18
- // token 已在全局 hook 中注入
19
- const spinner = ora(`卸载 ${target}...`).start();
20
-
21
19
  try {
22
20
  const parsed = parseTarget(target);
23
21
  const projectPath = process.cwd();
24
22
 
25
- // 查找安装记录
26
- const installations = await getInstallations(projectPath);
27
- const installation = installations.find((i) => i.fullName === parsed.fullName);
28
-
29
- if (!installation) {
30
- spinner.fail(`未找到已安装的插件: ${target}`);
31
- process.exit(1);
23
+ if (parsed.type === TargetType.Kit) {
24
+ // 卸载套包:批量卸载所有来自该套包的插件
25
+ await uninstallKit(projectPath, parsed.fullName, options.purge);
26
+ } else {
27
+ // 卸载单个插件
28
+ await uninstallPlugin(projectPath, parsed.fullName, options.purge);
32
29
  }
30
+ } catch (error) {
31
+ console.error(chalk.red((error as Error).message));
32
+ process.exit(1);
33
+ }
34
+ });
35
+
36
+ /**
37
+ * 卸载单个插件
38
+ */
39
+ async function uninstallPlugin(
40
+ projectPath: string,
41
+ fullName: string,
42
+ purge: boolean
43
+ ): Promise<void> {
44
+ const spinner = ora(`卸载 ${fullName}...`).start();
45
+
46
+ // 查找安装记录
47
+ const installations = await getInstallations(projectPath);
48
+ const installation = installations.find((i) => i.fullName === fullName);
49
+
50
+ if (!installation) {
51
+ spinner.fail(`未找到已安装的插件: ${fullName}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ // 移除链接
56
+ await removeLink(installation.linkPath);
57
+
58
+ // 移除安装记录
59
+ await removeInstallation(projectPath, fullName);
60
+
61
+ // 同步卸载记录(静默)
62
+ if (api.isAuthenticated()) {
63
+ const parts = fullName.split('/');
64
+ const author = parts[0];
65
+ const name = parts.slice(1).join('/');
66
+ api.removeInstallRecord(author, name).catch(() => {});
67
+ }
68
+
69
+ // 清除缓存
70
+ if (purge) {
71
+ const removed = await removeCache(fullName, installation.version);
72
+ if (removed) {
73
+ spinner.succeed(`已卸载 ${fullName}(含缓存)`);
74
+ } else {
75
+ spinner.succeed(`已卸载 ${fullName}`);
76
+ console.log(chalk.gray(' 缓存已不存在'));
77
+ }
78
+ } else {
79
+ spinner.succeed(`已卸载 ${fullName}`);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * 卸载套包(批量卸载所有来自该套包的插件)
85
+ */
86
+ async function uninstallKit(
87
+ projectPath: string,
88
+ kitFullName: string,
89
+ purge: boolean
90
+ ): Promise<void> {
91
+ const spinner = ora(`查找套包 ${kitFullName} 的插件...`).start();
92
+
93
+ // 查找所有来自该套包的安装记录
94
+ const installations = await getInstallations(projectPath);
95
+ const kitInstallations = installations.filter((i) => i.sourceKit === kitFullName);
96
+
97
+ if (kitInstallations.length === 0) {
98
+ spinner.fail(`未找到来自套包 ${kitFullName} 的已安装插件`);
99
+ process.exit(1);
100
+ }
101
+
102
+ spinner.text = `卸载 ${kitInstallations.length} 个插件...`;
33
103
 
104
+ // 批量卸载
105
+ let successCount = 0;
106
+ let failCount = 0;
107
+
108
+ for (const installation of kitInstallations) {
109
+ try {
34
110
  // 移除链接
35
111
  await removeLink(installation.linkPath);
36
112
 
37
113
  // 移除安装记录
38
- await removeInstallation(projectPath, parsed.fullName);
39
-
40
- // 同步卸载记录(静默)
41
- if (api.isAuthenticated()) {
42
- api.removeInstallRecord(parsed.author, parsed.name).catch(() => {});
43
- }
114
+ await removeInstallation(projectPath, installation.fullName);
44
115
 
45
116
  // 清除缓存
46
- if (options.purge) {
47
- const removed = await removeCache(parsed.fullName, installation.version);
48
- if (removed) {
49
- spinner.succeed(`已卸载 ${target}(含缓存)`);
50
- } else {
51
- spinner.succeed(`已卸载 ${target}`);
52
- console.log(chalk.gray(' 缓存已不存在'));
53
- }
54
- } else {
55
- spinner.succeed(`已卸载 ${target}`);
117
+ if (purge) {
118
+ await removeCache(installation.fullName, installation.version);
56
119
  }
57
- } catch (error) {
58
- spinner.fail('卸载失败');
59
- console.error(chalk.red((error as Error).message));
60
- process.exit(1);
120
+
121
+ successCount++;
122
+ } catch {
123
+ failCount++;
61
124
  }
62
- });
125
+ }
126
+
127
+ // 同步卸载记录(静默)
128
+ if (api.isAuthenticated()) {
129
+ const parts = kitFullName.split('/');
130
+ const author = parts[0];
131
+ const slug = parts[2];
132
+ api.removeInstallRecord(author, `kit/${slug}`).catch(() => {});
133
+ }
134
+
135
+ spinner.stop();
136
+
137
+ if (failCount === 0) {
138
+ console.log(chalk.green(`✓ 已卸载套包 ${kitFullName}`));
139
+ console.log(chalk.gray(` 共 ${successCount} 个插件`));
140
+ if (purge) {
141
+ console.log(chalk.gray(' 缓存已清除'));
142
+ }
143
+ } else {
144
+ console.log(chalk.yellow(`⚠ 部分卸载完成`));
145
+ console.log(chalk.gray(` 成功: ${successCount}, 失败: ${failCount}`));
146
+ }
147
+ }
package/src/db.ts CHANGED
@@ -8,13 +8,18 @@
8
8
  import { Database } from 'bun:sqlite';
9
9
  import fs from 'fs/promises';
10
10
  import path from 'path';
11
+ import os from 'os';
11
12
  import crypto from 'crypto';
12
13
  import * as tar from 'tar';
13
14
  import cliProgress from 'cli-progress';
15
+ import { spawn } from 'child_process';
14
16
  import { config } from './config';
15
17
  import { formatBytes } from './utils';
16
18
  import type { LocalProject, LocalCache, LocalInstallation, PluginType, PluginDownloadInfo } from './types';
17
19
 
20
+ // Windows 平台检测
21
+ const isWindows = process.platform === 'win32';
22
+
18
23
  // 文件大小超过 1MB 时显示下载进度条
19
24
  const PROGRESS_THRESHOLD = 1024 * 1024; // 1MB
20
25
 
@@ -386,8 +391,8 @@ export async function checkInstallConflict(
386
391
  ): Promise<string | null> {
387
392
  const client = await getDb();
388
393
  const absPath = path.resolve(projectPath);
389
- // 规范化路径:去掉尾部斜杠以便比较
390
- const normalizedLinkPath = path.resolve(linkPath).replace(/\/+$/, '');
394
+ // 规范化路径:去掉尾部斜杠以便比较(兼容 Unix 和 Windows)
395
+ const normalizedLinkPath = path.resolve(linkPath).replace(/[\/\\]+$/, '');
391
396
 
392
397
  // 查询该项目下所有安装记录,在应用层比较路径
393
398
  const rows = client.prepare(`
@@ -397,7 +402,7 @@ export async function checkInstallConflict(
397
402
  `).all(absPath, newFullName) as { full_name: string; link_path: string }[];
398
403
 
399
404
  for (const row of rows) {
400
- const existingPath = row.link_path.replace(/\/+$/, '');
405
+ const existingPath = row.link_path.replace(/[\/\\]+$/, '');
401
406
  if (existingPath === normalizedLinkPath) {
402
407
  return row.full_name;
403
408
  }
@@ -659,12 +664,62 @@ export async function getDirectorySize(pathOrDir: string): Promise<number> {
659
664
  return totalSize;
660
665
  }
661
666
 
667
+ /**
668
+ * 递归复制目录或文件
669
+ */
670
+ async function copyRecursive(src: string, dest: string): Promise<void> {
671
+ const stat = await fs.stat(src);
672
+ if (stat.isDirectory()) {
673
+ await fs.mkdir(dest, { recursive: true });
674
+ const entries = await fs.readdir(src, { withFileTypes: true });
675
+ for (const entry of entries) {
676
+ const srcPath = path.join(src, entry.name);
677
+ const destPath = path.join(dest, entry.name);
678
+ if (entry.isDirectory()) {
679
+ await copyRecursive(srcPath, destPath);
680
+ } else {
681
+ await fs.copyFile(srcPath, destPath);
682
+ }
683
+ }
684
+ } else {
685
+ await fs.copyFile(src, dest);
686
+ }
687
+ }
688
+
689
+ /**
690
+ * Windows 上使用 mklink /J 创建 Junction(目录链接)
691
+ * Junction 不需要管理员权限
692
+ */
693
+ async function createJunction(sourcePath: string, targetPath: string): Promise<void> {
694
+ return new Promise((resolve, reject) => {
695
+ // mklink /J 创建目录 junction
696
+ const cmd = spawn('cmd', ['/c', 'mklink', '/J', targetPath, sourcePath], {
697
+ windowsHide: true,
698
+ });
699
+
700
+ let stderr = '';
701
+ cmd.stderr.on('data', (data) => {
702
+ stderr += data.toString();
703
+ });
704
+
705
+ cmd.on('close', (code) => {
706
+ if (code === 0) {
707
+ resolve();
708
+ } else {
709
+ reject(new Error(`创建 Junction 失败: ${stderr}`));
710
+ }
711
+ });
712
+
713
+ cmd.on('error', reject);
714
+ });
715
+ }
716
+
662
717
  export async function createLink(
663
718
  sourcePath: string,
664
719
  targetPath: string
665
720
  ): Promise<void> {
666
- // 移除目标路径末尾的斜杠
667
- const normalizedTarget = targetPath.replace(/\/+$/, '');
721
+ // 移除目标路径末尾的斜杠(兼容 Unix 和 Windows)
722
+ const normalizedTarget = targetPath.replace(/[\/\\]+$/, '');
668
723
  const targetDir = path.dirname(normalizedTarget);
669
724
  await fs.mkdir(targetDir, { recursive: true });
670
725
 
@@ -678,19 +733,56 @@ export async function createLink(
678
733
  // 不存在,继续
679
734
  }
680
735
 
681
- // 检测源是文件还是目录,创建对应类型的符号链接
736
+ // 检测源是文件还是目录
682
737
  const sourceStat = await fs.stat(sourcePath);
683
- const linkType = sourceStat.isDirectory() ? 'dir' : 'file';
684
- await fs.symlink(sourcePath, normalizedTarget, linkType);
738
+ const isDirectory = sourceStat.isDirectory();
739
+
740
+ // 尝试创建符号链接
741
+ try {
742
+ const linkType = isDirectory ? 'dir' : 'file';
743
+ await fs.symlink(sourcePath, normalizedTarget, linkType);
744
+ return;
745
+ } catch (error) {
746
+ // 如果不是 Windows,直接抛出错误
747
+ if (!isWindows) {
748
+ throw error;
749
+ }
750
+
751
+ // Windows 上符号链接失败(EPERM),尝试回退方案
752
+ const isEperm = (error as NodeJS.ErrnoException).code === 'EPERM';
753
+ if (!isEperm) {
754
+ throw error;
755
+ }
756
+
757
+ // 回退方案 1: 对于目录,尝试使用 Junction
758
+ if (isDirectory) {
759
+ try {
760
+ await createJunction(sourcePath, normalizedTarget);
761
+ return;
762
+ } catch {
763
+ // Junction 也失败,继续尝试复制
764
+ }
765
+ }
766
+
767
+ // 回退方案 2: 直接复制文件/目录
768
+ // 这是最后的保底方案,虽然会占用更多磁盘空间
769
+ console.warn(
770
+ `[Windows] 符号链接创建失败,回退到文件复制模式\n` +
771
+ ` 提示: 启用 Windows 开发者模式可使用符号链接,节省磁盘空间`
772
+ );
773
+ await copyRecursive(sourcePath, normalizedTarget);
774
+ }
685
775
  }
686
776
 
687
777
  export async function removeLink(linkPath: string): Promise<void> {
778
+ // 去掉结尾斜杠,否则 lstat 会跟踪符号链接返回目标状态(兼容 Unix 和 Windows)
779
+ const normalizedPath = linkPath.replace(/[\/\\]+$/, '');
688
780
  try {
689
- const stat = await fs.lstat(linkPath);
781
+ const stat = await fs.lstat(normalizedPath);
690
782
  if (stat.isSymbolicLink()) {
691
- await fs.unlink(linkPath);
783
+ await fs.unlink(normalizedPath);
692
784
  } else if (stat.isDirectory()) {
693
- await fs.rm(linkPath, { recursive: true, force: true });
785
+ await fs.rm(normalizedPath, { recursive: true, force: true });
694
786
  }
695
787
  } catch {
696
788
  // 不存在,忽略
package/src/types.ts CHANGED
@@ -95,11 +95,23 @@ export interface SearchResult {
95
95
  icon: string | null;
96
96
  }
97
97
 
98
+ export interface KitSearchResult {
99
+ fullName: string;
100
+ name: string;
101
+ author: string;
102
+ pluginCount: number;
103
+ downloads: number;
104
+ description: string | null;
105
+ }
106
+
98
107
  export interface SearchResponse {
99
- data: SearchResult[];
108
+ plugins: SearchResult[];
109
+ kits: KitSearchResult[];
100
110
  pagination: {
101
111
  page: number;
102
112
  perPage: number;
113
+ totalPlugins: number;
114
+ totalKits: number;
103
115
  total: number;
104
116
  totalPages: number;
105
117
  };
@@ -1,210 +0,0 @@
1
- /**
2
- * completion 命令 - 生成 shell 自动补全脚本
3
- */
4
-
5
- import { Command } from 'commander';
6
- import chalk from 'chalk';
7
-
8
- export const completionCommand = new Command('completion')
9
- .description('生成 shell 自动补全脚本')
10
- .argument('<shell>', 'shell 类型 (bash, zsh, fish)')
11
- .action((shell: string) => {
12
- switch (shell.toLowerCase()) {
13
- case 'bash':
14
- console.log(generateBashCompletion());
15
- break;
16
- case 'zsh':
17
- console.log(generateZshCompletion());
18
- break;
19
- case 'fish':
20
- console.log(generateFishCompletion());
21
- break;
22
- default:
23
- console.error(chalk.red(`不支持的 shell: ${shell}`));
24
- console.log(chalk.gray('支持的 shell: bash, zsh, fish'));
25
- process.exit(1);
26
- }
27
- });
28
-
29
- function generateBashCompletion(): string {
30
- return `# 42plugin bash completion
31
- # 添加到 ~/.bashrc:
32
- # eval "$(42plugin completion bash)"
33
- # 或:
34
- # 42plugin completion bash >> ~/.bashrc
35
- #
36
- # 注意: 动态补全插件名需要安装 jq (可选)
37
- # 未安装 jq 时仍可使用命令和选项补全
38
-
39
- _42plugin_completions() {
40
- local cur="\${COMP_WORDS[COMP_CWORD]}"
41
- local prev="\${COMP_WORDS[COMP_CWORD-1]}"
42
-
43
- case "\${prev}" in
44
- 42plugin)
45
- COMPREPLY=( $(compgen -W "auth search install list uninstall version completion" -- "\${cur}") )
46
- return 0
47
- ;;
48
- install|uninstall)
49
- # 动态补全插件名 (需要 jq)
50
- if command -v jq &>/dev/null; then
51
- if [[ "\${cur}" == */* ]]; then
52
- local plugins=$(42plugin list --json 2>/dev/null | jq -r '.[].fullName' 2>/dev/null)
53
- COMPREPLY=( $(compgen -W "\${plugins}" -- "\${cur}") )
54
- else
55
- local authors=$(42plugin list --json 2>/dev/null | jq -r '.[].fullName' 2>/dev/null | cut -d'/' -f1 | sort -u)
56
- COMPREPLY=( $(compgen -W "\${authors}" -- "\${cur}") )
57
- fi
58
- fi
59
- return 0
60
- ;;
61
- search)
62
- # 类型补全
63
- if [[ "\${cur}" == -* ]]; then
64
- COMPREPLY=( $(compgen -W "-t --type -l --limit --json -i --interactive" -- "\${cur}") )
65
- fi
66
- return 0
67
- ;;
68
- -t|--type)
69
- COMPREPLY=( $(compgen -W "skill agent command hook mcp" -- "\${cur}") )
70
- return 0
71
- ;;
72
- auth)
73
- COMPREPLY=( $(compgen -W "--status --logout" -- "\${cur}") )
74
- return 0
75
- ;;
76
- list|ls)
77
- if [[ "\${cur}" == -* ]]; then
78
- COMPREPLY=( $(compgen -W "-t --type --json" -- "\${cur}") )
79
- fi
80
- return 0
81
- ;;
82
- completion)
83
- COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
84
- return 0
85
- ;;
86
- esac
87
- }
88
-
89
- complete -F _42plugin_completions 42plugin
90
- `;
91
- }
92
-
93
- function generateZshCompletion(): string {
94
- return `#compdef 42plugin
95
- # 42plugin zsh completion
96
- # 添加到 ~/.zshrc:
97
- # eval "$(42plugin completion zsh)"
98
- # 或:
99
- # 42plugin completion zsh >> ~/.zshrc
100
- #
101
- # 注意: 动态补全插件名需要安装 jq (可选)
102
-
103
- _42plugin() {
104
- local -a commands
105
- commands=(
106
- 'auth:登录/登出/查看状态'
107
- 'search:搜索插件'
108
- 'install:安装插件或套包'
109
- 'list:查看已安装插件'
110
- 'uninstall:卸载插件'
111
- 'version:显示版本'
112
- 'completion:生成补全脚本'
113
- )
114
-
115
- _arguments -C \\
116
- '1: :->command' \\
117
- '*: :->args'
118
-
119
- case $state in
120
- command)
121
- _describe 'command' commands
122
- ;;
123
- args)
124
- case $words[2] in
125
- install|uninstall)
126
- # 插件名补全 (需要 jq)
127
- if (( $+commands[jq] )); then
128
- local plugins
129
- plugins=(\${(f)"$(42plugin list --json 2>/dev/null | jq -r '.[].fullName' 2>/dev/null)"})
130
- _describe 'plugin' plugins
131
- fi
132
- ;;
133
- search)
134
- _arguments \\
135
- '-t[筛选类型]:type:(skill agent command hook mcp)' \\
136
- '--type[筛选类型]:type:(skill agent command hook mcp)' \\
137
- '-l[结果数量]:limit:' \\
138
- '--limit[结果数量]:limit:' \\
139
- '--json[JSON 输出]' \\
140
- '-i[交互式安装]' \\
141
- '--interactive[交互式安装]'
142
- ;;
143
- auth)
144
- _arguments \\
145
- '--status[查看登录状态]' \\
146
- '--logout[登出]'
147
- ;;
148
- list)
149
- _arguments \\
150
- '-t[筛选类型]:type:(skill agent command hook mcp)' \\
151
- '--type[筛选类型]:type:(skill agent command hook mcp)' \\
152
- '--json[JSON 输出]'
153
- ;;
154
- completion)
155
- _describe 'shell' '(bash zsh fish)'
156
- ;;
157
- esac
158
- ;;
159
- esac
160
- }
161
-
162
- _42plugin
163
- `;
164
- }
165
-
166
- function generateFishCompletion(): string {
167
- return `# 42plugin fish completion
168
- # 添加到 ~/.config/fish/config.fish:
169
- # 42plugin completion fish | source
170
- # 或:
171
- # 42plugin completion fish > ~/.config/fish/completions/42plugin.fish
172
-
173
- complete -c 42plugin -f
174
-
175
- # Commands
176
- complete -c 42plugin -n '__fish_use_subcommand' -a auth -d '登录/登出/查看状态'
177
- complete -c 42plugin -n '__fish_use_subcommand' -a search -d '搜索插件'
178
- complete -c 42plugin -n '__fish_use_subcommand' -a install -d '安装插件或套包'
179
- complete -c 42plugin -n '__fish_use_subcommand' -a list -d '查看已安装插件'
180
- complete -c 42plugin -n '__fish_use_subcommand' -a uninstall -d '卸载插件'
181
- complete -c 42plugin -n '__fish_use_subcommand' -a version -d '显示版本'
182
- complete -c 42plugin -n '__fish_use_subcommand' -a completion -d '生成补全脚本'
183
-
184
- # auth options
185
- complete -c 42plugin -n '__fish_seen_subcommand_from auth' -l status -d '查看登录状态'
186
- complete -c 42plugin -n '__fish_seen_subcommand_from auth' -l logout -d '登出'
187
-
188
- # search options
189
- complete -c 42plugin -n '__fish_seen_subcommand_from search' -s t -l type -d '筛选类型' -a 'skill agent command hook mcp'
190
- complete -c 42plugin -n '__fish_seen_subcommand_from search' -s l -l limit -d '结果数量'
191
- complete -c 42plugin -n '__fish_seen_subcommand_from search' -l json -d 'JSON 输出'
192
- complete -c 42plugin -n '__fish_seen_subcommand_from search' -s i -l interactive -d '交互式安装'
193
-
194
- # list options
195
- complete -c 42plugin -n '__fish_seen_subcommand_from list' -s t -l type -d '筛选类型' -a 'skill agent command hook mcp'
196
- complete -c 42plugin -n '__fish_seen_subcommand_from list' -l json -d 'JSON 输出'
197
-
198
- # install options
199
- complete -c 42plugin -n '__fish_seen_subcommand_from install' -s g -l global -d '安装到全局目录'
200
- complete -c 42plugin -n '__fish_seen_subcommand_from install' -s f -l force -d '强制重新下载'
201
- complete -c 42plugin -n '__fish_seen_subcommand_from install' -l no-cache -d '跳过缓存检查'
202
- complete -c 42plugin -n '__fish_seen_subcommand_from install' -l optional -d '安装套包时包含可选插件'
203
-
204
- # uninstall options
205
- complete -c 42plugin -n '__fish_seen_subcommand_from uninstall' -l purge -d '同时清除缓存'
206
-
207
- # completion argument
208
- complete -c 42plugin -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish' -d 'Shell 类型'
209
- `;
210
- }