@42ailab/42plugin 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # 42plugin CLI
2
+
3
+ Claude Code 插件管理器,支持搜索、安装、管理来自 42plugin 平台的能力、插件与插件列表。
4
+
5
+ ## 前提条件
6
+ - 需要安装 [Bun](https://bun.sh)(用于运行与构建)。
7
+ - Node.js/npm 非必需;依赖通过 `bun install` 处理。
8
+
9
+ ## 安装依赖
10
+ ```bash
11
+ bun install
12
+ ```
13
+
14
+ ## 本地开发与运行
15
+ - 直接运行入口(便于调试):
16
+ ```bash
17
+ bun run src/index.ts <command> [options]
18
+ ```
19
+ - 或使用脚本(等同于上方):
20
+ ```bash
21
+ bun run dev -- <command> [options]
22
+ ```
23
+ - 构建跨平台可执行文件:
24
+ ```bash
25
+ bun run build
26
+ # 输出位于 dist/ 下,如 dist/42plugin-darwin-arm64、dist/42plugin-linux-x64 等
27
+ ```
28
+ 构建后二进制即可直接执行,例如 `./dist/42plugin-darwin-arm64 search claude`。
29
+
30
+ ## CLI 使用
31
+ ### 登录
32
+ - 浏览器授权登录:`42plugin auth`
33
+ - 查看状态:`42plugin auth --status`
34
+ - 登出并清理凭证:`42plugin auth --logout`
35
+
36
+ ### 搜索插件/能力/列表
37
+ ```bash
38
+ 42plugin search <关键词> [--type skill|agent|command|hook|list] [--limit 50] [--json]
39
+ ```
40
+
41
+ ### 安装
42
+ 支持的安装目标格式:
43
+ - 能力:`owner/repo:plugin:type:name`
44
+ - 插件(带插件名):`owner/repo:plugin`
45
+ - 简化插件:`owner/plugin`
46
+ - 列表:`owner/list/<slug>`
47
+
48
+ 常用示例:
49
+ ```bash
50
+ 42plugin install user/repo:cool-plugin
51
+ 42plugin install user/repo:cool-plugin:command:deploy
52
+ 42plugin install user/list/tools # 安装列表(默认只装必选项)
53
+ 42plugin install user/list/tools --optional # 包含列表中的可选项
54
+ ```
55
+
56
+ 选项:
57
+ - `-g, --global`:仅下载到缓存,不链接到当前项目。
58
+ - `--force`:忽略缓存,强制重新下载。
59
+ - `--no-cache`:跳过缓存命中检查。
60
+ - `--optional`:安装列表时包含可选能力(默认只安装必选)。
61
+
62
+ 安装会将包解压到本地缓存,再在当前工作目录创建链接到具体安装路径(例如 `.claude/` 下),同时在本地数据库记录。请在项目根目录执行。
63
+
64
+ ### 查看已安装
65
+ ```bash
66
+ 42plugin list [--type <type>] [--json]
67
+ ```
68
+
69
+ ### 卸载
70
+ ```bash
71
+ 42plugin uninstall <full_name> [--purge]
72
+ ```
73
+ `--purge` 会同时删除缓存内容。
74
+
75
+ ### 查看版本
76
+ ```bash
77
+ 42plugin version
78
+ ```
79
+
80
+ ## 数据存储位置
81
+ - 配置与数据库:`~/.42plugin/local.db`(Windows 为 `%APPDATA%/42plugin/local.db`)
82
+ - 缓存:`~/.42plugin/cache`
83
+ - 登录凭证:`~/.42plugin/secrets.json`(权限 600)
84
+
85
+ ## 环境变量
86
+ - `API_BASE`:替换默认的 API 地址(默认 `https://api.42plugin.com`)。
87
+ - `CDN_BASE`:替换默认的 CDN 地址(默认 `https://cdn.42plugin.com`)。
88
+ - `DEBUG=true`:输出调试信息与原始错误。
89
+ - `CLI_DB_DRIVER=postgres`:让 CLI 使用 Postgres 而非默认的本地 SQLite(`~/.42plugin/local.db`)。配合 `CLI_DATABASE_URL` 或 `LOCAL_DATABASE_URL` 指定连接串,例如 `postgresql://postgres:password@localhost:5432/42plugin_cli_dev`。
90
+
91
+ ## 项目结构速览
92
+ - `src/cli.ts`:命令定义与统一入口。
93
+ - `src/commands/*`:各子命令实现(auth、install、search、list、uninstall、version)。
94
+ - `src/services/*`:API、缓存、下载、项目记录、链接创建等核心逻辑。
95
+ - `src/db/client.ts`:本地 SQLite 初始化与连接(基于 @libsql/client)。
96
+ - `tests/`:命令与服务的测试用例。
97
+
98
+ ## 常用开发命令
99
+ ```bash
100
+ bun test # 运行测试
101
+ bun run lint # ESLint
102
+ bun run format # Prettier
103
+ bun run typecheck # TypeScript 类型检查
104
+ ```
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@42ailab/42plugin",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Claude Code 插件管理器",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "bin": {
8
+ "42plugin": "./src/index.ts"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "dev": "bun run src/index.ts",
16
+ "build": "bun run scripts/build.ts",
17
+ "test": "bun test",
18
+ "lint": "eslint src/",
19
+ "format": "prettier --write src/",
20
+ "typecheck": "tsc --noEmit"
21
+ },
22
+ "keywords": [
23
+ "claude",
24
+ "claude-code",
25
+ "plugin",
26
+ "cli",
27
+ "ai",
28
+ "anthropic"
29
+ ],
30
+ "author": "42ailab <y@42ailab.com>",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/42ailab/42plugin.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/42ailab/42plugin/issues"
38
+ },
39
+ "homepage": "https://github.com/42ailab/42plugin#readme",
40
+ "engines": {
41
+ "node": ">=18.0.0"
42
+ },
43
+ "dependencies": {
44
+ "@libsql/client": "^0.14.0",
45
+ "chalk": "^5.4.1",
46
+ "commander": "^13.0.0",
47
+ "nanoid": "^5.0.9",
48
+ "open": "^10.1.0",
49
+ "ora": "^8.1.1",
50
+ "pg": "^8.12.0",
51
+ "tar": "^7.4.3"
52
+ },
53
+ "devDependencies": {
54
+ "@types/bun": "latest",
55
+ "@types/tar": "^6.1.13",
56
+ "typescript": "^5.7.0"
57
+ }
58
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { Command } from 'commander';
2
+ import { authCommand } from './commands/auth';
3
+ import { installCommand } from './commands/install';
4
+ import { searchCommand } from './commands/search';
5
+ import { listCommand } from './commands/list';
6
+ import { uninstallCommand } from './commands/uninstall';
7
+ import { versionCommand } from './commands/version';
8
+
9
+ export const program = new Command();
10
+
11
+ program
12
+ .name('42plugin')
13
+ .description('Claude Code 插件管理器')
14
+ .version('0.1.0', '-v, --version', '显示版本号')
15
+ .option('--verbose', '详细输出')
16
+ .option('--no-color', '禁用颜色输出');
17
+
18
+ // Register commands
19
+ program.addCommand(authCommand);
20
+ program.addCommand(installCommand);
21
+ program.addCommand(searchCommand);
22
+ program.addCommand(listCommand);
23
+ program.addCommand(uninstallCommand);
24
+ program.addCommand(versionCommand);
25
+
26
+ // Default help
27
+ program.showHelpAfterError();
@@ -0,0 +1,114 @@
1
+ import { Command } from 'commander';
2
+ import open from 'open';
3
+ import ora from 'ora';
4
+ import chalk from 'chalk';
5
+ import { api } from '../services/api';
6
+ import { saveSecrets, loadSecrets, clearSecrets } from '../services/auth';
7
+
8
+ export const authCommand = new Command('auth')
9
+ .description('登录 / 授权账户')
10
+ .option('--status', '查看当前登录状态')
11
+ .option('--logout', '登出并清除本地凭证')
12
+ .action(async (options) => {
13
+ if (options.status) {
14
+ await showStatus();
15
+ return;
16
+ }
17
+
18
+ if (options.logout) {
19
+ await logout();
20
+ return;
21
+ }
22
+
23
+ await login();
24
+ });
25
+
26
+ async function login() {
27
+ const spinner = ora('正在初始化登录...').start();
28
+
29
+ try {
30
+ // 1. Get authorization code
31
+ const { code, auth_url, expires_at } = await api.startAuth();
32
+ spinner.stop();
33
+
34
+ console.log();
35
+ console.log(chalk.cyan('正在打开浏览器进行授权...'));
36
+ console.log();
37
+ console.log('如果浏览器没有自动打开,请访问:');
38
+ console.log(chalk.underline(auth_url));
39
+ console.log();
40
+
41
+ // 2. Open browser
42
+ await open(auth_url);
43
+
44
+ // 3. Poll for authorization completion
45
+ spinner.start('等待浏览器授权...');
46
+
47
+ const pollInterval = 2000; // 2 seconds
48
+ const expiresTime = new Date(expires_at).getTime();
49
+
50
+ while (Date.now() < expiresTime) {
51
+ const result = await api.pollAuth(code);
52
+
53
+ if (result.status === 'completed') {
54
+ // 4. Save credentials
55
+ await saveSecrets({
56
+ access_token: result.access_token!,
57
+ refresh_token: result.refresh_token!,
58
+ created_at: new Date().toISOString(),
59
+ });
60
+
61
+ // Update API client token
62
+ api.setToken(result.access_token!);
63
+
64
+ spinner.succeed(chalk.green('登录成功!'));
65
+ console.log();
66
+ console.log(`欢迎,${chalk.cyan(result.user!.display_name || result.user!.username)}!`);
67
+ return;
68
+ }
69
+
70
+ await sleep(pollInterval);
71
+ }
72
+
73
+ spinner.fail('授权超时,请重试');
74
+ } catch (error) {
75
+ spinner.fail(`登录失败: ${(error as Error).message}`);
76
+ process.exit(1);
77
+ }
78
+ }
79
+
80
+ async function showStatus() {
81
+ const secrets = await loadSecrets();
82
+
83
+ if (!secrets) {
84
+ console.log(chalk.yellow('未登录'));
85
+ console.log('使用 42plugin auth 登录');
86
+ return;
87
+ }
88
+
89
+ try {
90
+ const user = await api.getMe();
91
+ console.log(chalk.green('已登录'));
92
+ console.log();
93
+ console.log(`用户名: ${chalk.cyan(user.username)}`);
94
+ console.log(`显示名: ${user.display_name || '-'}`);
95
+ console.log(`角色: ${user.role}`);
96
+
97
+ if (user.role === 'vip' && user.vip_expires_at) {
98
+ console.log(`VIP 到期: ${new Date(user.vip_expires_at).toLocaleDateString()}`);
99
+ }
100
+ } catch {
101
+ console.log(chalk.yellow('Token 已过期,请重新登录'));
102
+ console.log('使用 42plugin auth 重新登录');
103
+ }
104
+ }
105
+
106
+ async function logout() {
107
+ await clearSecrets();
108
+ api.resetToken();
109
+ console.log(chalk.green('已登出'));
110
+ }
111
+
112
+ function sleep(ms: number) {
113
+ return new Promise(resolve => setTimeout(resolve, ms));
114
+ }
@@ -0,0 +1,323 @@
1
+ import { Command } from 'commander';
2
+ import path from 'path';
3
+ import ora, { type Ora } from 'ora';
4
+ import chalk from 'chalk';
5
+ import { api } from '../services/api';
6
+ import { downloadAndExtract } from '../services/download';
7
+ import { getOrCreateProject, addProjectPlugin } from '../services/project';
8
+ import { getCachedPlugin, cachePlugin } from '../services/cache';
9
+ import { createLink } from '../services/link';
10
+ import { parseTarget, TargetType } from '../utils/target';
11
+
12
+ interface DownloadInfo {
13
+ type: string;
14
+ install_path: string;
15
+ full_name: string;
16
+ version: string;
17
+ }
18
+
19
+ interface InstallOptions {
20
+ global?: boolean;
21
+ optional?: boolean;
22
+ force?: boolean;
23
+ noCache?: boolean;
24
+ }
25
+
26
+ export const installCommand = new Command('install')
27
+ .description('安装插件或列表')
28
+ .argument('<target>', '插件或列表标识符')
29
+ .option('-g, --global', '全局安装')
30
+ .option('--optional', '仅安装列表中的可选项')
31
+ .option('--force', '强制重新安装')
32
+ .option('--no-cache', '不使用缓存')
33
+ .action(async (target: string, options: InstallOptions) => {
34
+ await install(target, options);
35
+ });
36
+
37
+ async function install(target: string, options: InstallOptions) {
38
+ const spinner = ora('解析安装目标...').start();
39
+
40
+ try {
41
+ // 1. Parse target
42
+ const parsed = parseTarget(target);
43
+
44
+ // 2. Handle based on type
45
+ switch (parsed.type) {
46
+ case TargetType.Capability:
47
+ await installCapability(parsed.fullName, options, spinner);
48
+ break;
49
+
50
+ case TargetType.CapabilitySlug:
51
+ await installCapabilityBySlug(parsed.author!, parsed.slug!, options, spinner);
52
+ break;
53
+
54
+ case TargetType.Plugin:
55
+ await installPlugin(parsed.fullName, options, spinner);
56
+ break;
57
+
58
+ case TargetType.List:
59
+ await installList(parsed.fullName, options, spinner);
60
+ break;
61
+
62
+ default:
63
+ spinner.fail(`无法识别的安装目标: ${target}`);
64
+ process.exit(1);
65
+ }
66
+ } catch (error) {
67
+ spinner.fail(`安装失败: ${(error as Error).message}`);
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ async function installCapability(
73
+ fullName: string,
74
+ options: InstallOptions,
75
+ spinner: Ora
76
+ ) {
77
+ spinner.text = `获取 ${fullName} 的安装信息...`;
78
+
79
+ // Get download info
80
+ const downloadInfo = await api.getCapabilityDownload(fullName);
81
+
82
+ // Check permissions
83
+ if (downloadInfo.requires_auth && !api.isAuthenticated()) {
84
+ spinner.fail('此插件需要登录才能安装');
85
+ console.log('使用 42plugin auth 登录');
86
+ process.exit(1);
87
+ }
88
+
89
+ // Check cache
90
+ if (!options.force && options.noCache !== true) {
91
+ const cached = await getCachedPlugin(fullName, downloadInfo.version);
92
+ if (cached && cached.checksum === downloadInfo.checksum) {
93
+ spinner.text = '使用缓存...';
94
+ await linkToProject(cached.cache_path, downloadInfo, options);
95
+ spinner.succeed(`${chalk.green('已安装')} ${fullName}`);
96
+ return;
97
+ }
98
+ }
99
+
100
+ // Download
101
+ spinner.text = `下载 ${fullName}...`;
102
+ const cachePath = await downloadAndExtract(
103
+ downloadInfo.download_url,
104
+ downloadInfo.checksum,
105
+ fullName,
106
+ downloadInfo.version
107
+ );
108
+
109
+ // Update cache record
110
+ await cachePlugin({
111
+ full_name: fullName,
112
+ type: downloadInfo.type,
113
+ version: downloadInfo.version,
114
+ cache_path: cachePath,
115
+ checksum: downloadInfo.checksum,
116
+ });
117
+
118
+ // Link to project
119
+ await linkToProject(cachePath, downloadInfo, options);
120
+
121
+ spinner.succeed(`${chalk.green('已安装')} ${fullName}`);
122
+ }
123
+
124
+ async function installCapabilityBySlug(
125
+ author: string,
126
+ slug: string,
127
+ options: InstallOptions,
128
+ spinner: Ora
129
+ ) {
130
+ const displayName = `${author}/${slug}`;
131
+ spinner.text = `获取 ${displayName} 的安装信息...`;
132
+
133
+ // Get download info by slug
134
+ const downloadInfo = await api.getCapabilityDownloadBySlug(author, slug);
135
+
136
+ // Check permissions
137
+ if (downloadInfo.requires_auth && !api.isAuthenticated()) {
138
+ spinner.fail('此插件需要登录才能安装');
139
+ console.log('使用 42plugin auth 登录');
140
+ process.exit(1);
141
+ }
142
+
143
+ // Check cache
144
+ if (!options.force && options.noCache !== true) {
145
+ const cached = await getCachedPlugin(downloadInfo.full_name, downloadInfo.version);
146
+ if (cached && cached.checksum === downloadInfo.checksum) {
147
+ spinner.text = '使用缓存...';
148
+ await linkToProject(cached.cache_path, downloadInfo, options);
149
+ spinner.succeed(`${chalk.green('已安装')} ${displayName}`);
150
+ return;
151
+ }
152
+ }
153
+
154
+ // Download
155
+ spinner.text = `下载 ${displayName}...`;
156
+ const cachePath = await downloadAndExtract(
157
+ downloadInfo.download_url,
158
+ downloadInfo.checksum,
159
+ downloadInfo.full_name,
160
+ downloadInfo.version
161
+ );
162
+
163
+ // Update cache record
164
+ await cachePlugin({
165
+ full_name: downloadInfo.full_name,
166
+ type: downloadInfo.type,
167
+ version: downloadInfo.version,
168
+ cache_path: cachePath,
169
+ checksum: downloadInfo.checksum,
170
+ });
171
+
172
+ // Link to project
173
+ await linkToProject(cachePath, downloadInfo, options);
174
+
175
+ spinner.succeed(`${chalk.green('已安装')} ${displayName}`);
176
+ }
177
+
178
+ async function installPlugin(
179
+ fullName: string,
180
+ options: InstallOptions,
181
+ spinner: Ora
182
+ ) {
183
+ spinner.text = `获取插件 ${fullName} 的信息...`;
184
+
185
+ // Get plugin details - for simple plugins, use the download endpoint
186
+ const [owner, plugin] = fullName.split('/');
187
+
188
+ // Try to get as a simple plugin first
189
+ const downloadInfo = await api.getPluginDownload(owner, plugin);
190
+
191
+ // Check permissions
192
+ if (downloadInfo.requires_auth && !api.isAuthenticated()) {
193
+ spinner.fail('此插件需要登录才能安装');
194
+ console.log('使用 42plugin auth 登录');
195
+ process.exit(1);
196
+ }
197
+
198
+ // Check cache
199
+ if (!options.force && options.noCache !== true) {
200
+ const cached = await getCachedPlugin(fullName, downloadInfo.version);
201
+ if (cached && cached.checksum === downloadInfo.checksum) {
202
+ spinner.text = '使用缓存...';
203
+ await linkToProject(cached.cache_path, downloadInfo, options);
204
+ spinner.succeed(`${chalk.green('已安装')} ${fullName}`);
205
+ return;
206
+ }
207
+ }
208
+
209
+ // Download
210
+ spinner.text = `下载 ${fullName}...`;
211
+ const cachePath = await downloadAndExtract(
212
+ downloadInfo.download_url,
213
+ downloadInfo.checksum,
214
+ fullName,
215
+ downloadInfo.version
216
+ );
217
+
218
+ // Update cache record
219
+ await cachePlugin({
220
+ full_name: fullName,
221
+ type: downloadInfo.type,
222
+ version: downloadInfo.version,
223
+ cache_path: cachePath,
224
+ checksum: downloadInfo.checksum,
225
+ });
226
+
227
+ // Link to project
228
+ await linkToProject(cachePath, downloadInfo, options);
229
+
230
+ spinner.succeed(`${chalk.green('已安装')} ${fullName}`);
231
+ }
232
+
233
+ async function installList(
234
+ fullName: string,
235
+ options: InstallOptions,
236
+ spinner: Ora
237
+ ) {
238
+ // Parse list identifier
239
+ const [username, , slugOrId] = fullName.split('/');
240
+ spinner.text = `获取列表 ${fullName}...`;
241
+
242
+ // Get list download info
243
+ const listDownload = await api.getListDownload(username, slugOrId);
244
+
245
+ console.log();
246
+ console.log(`列表: ${chalk.cyan(listDownload.list.name)}`);
247
+ console.log(`能力: ${listDownload.capabilities.length} 个`);
248
+ console.log();
249
+
250
+ // Filter optional items
251
+ let capabilities = listDownload.capabilities;
252
+ if (!options.optional) {
253
+ capabilities = capabilities.filter(c => c.required);
254
+ }
255
+
256
+ // Install each capability
257
+ let installed = 0;
258
+ for (const cap of capabilities) {
259
+ try {
260
+ spinner.text = `安装 ${cap.name}...`;
261
+
262
+ const cachePath = await downloadAndExtract(
263
+ cap.download_url,
264
+ cap.checksum,
265
+ cap.full_name,
266
+ cap.version
267
+ );
268
+
269
+ await cachePlugin({
270
+ full_name: cap.full_name,
271
+ type: cap.type,
272
+ version: cap.version,
273
+ cache_path: cachePath,
274
+ checksum: cap.checksum,
275
+ });
276
+
277
+ await linkToProject(cachePath, cap as DownloadInfo, options, {
278
+ source: 'list',
279
+ source_list: fullName,
280
+ });
281
+
282
+ installed++;
283
+ } catch (error) {
284
+ console.log(chalk.yellow(` 跳过 ${cap.name}: ${(error as Error).message}`));
285
+ }
286
+ }
287
+
288
+ spinner.succeed(`${chalk.green('已安装')} ${installed}/${capabilities.length} 个能力`);
289
+ }
290
+
291
+ async function linkToProject(
292
+ cachePath: string,
293
+ info: { type: string; install_path: string; full_name: string; version: string },
294
+ options: InstallOptions,
295
+ extra?: { source?: string; source_list?: string }
296
+ ) {
297
+ if (options.global) {
298
+ // Global install doesn't link to project
299
+ return;
300
+ }
301
+
302
+ // Get or create project
303
+ const projectPath = process.cwd();
304
+ const project = await getOrCreateProject(projectPath);
305
+
306
+ // Target path
307
+ const targetPath = path.join(projectPath, info.install_path);
308
+
309
+ // Create link
310
+ await createLink(cachePath, targetPath);
311
+
312
+ // Record in database
313
+ await addProjectPlugin({
314
+ project_id: project.id,
315
+ full_name: info.full_name,
316
+ type: info.type,
317
+ version: info.version,
318
+ cache_path: cachePath,
319
+ link_path: targetPath,
320
+ source: extra?.source || 'direct',
321
+ source_list: extra?.source_list,
322
+ });
323
+ }
@@ -0,0 +1,80 @@
1
+ import { Command } from 'commander';
2
+ import path from 'path';
3
+ import chalk from 'chalk';
4
+ import { getProject, getProjectPlugins } from '../services/project';
5
+ import type { ProjectPlugin } from '../types/db';
6
+
7
+ interface ListOptions {
8
+ type?: string;
9
+ json?: boolean;
10
+ }
11
+
12
+ export const listCommand = new Command('list')
13
+ .description('查看当前项目已安装的插件')
14
+ .option('--type <type>', '筛选类型')
15
+ .option('--json', 'JSON 格式输出')
16
+ .action(async (options: ListOptions) => {
17
+ await list(options);
18
+ });
19
+
20
+ async function list(options: ListOptions) {
21
+ const projectPath = process.cwd();
22
+ const project = await getProject(projectPath);
23
+
24
+ if (!project) {
25
+ console.log(chalk.yellow('当前目录不是 42plugin 项目'));
26
+ console.log('使用 42plugin install <plugin> 安装第一个插件');
27
+ return;
28
+ }
29
+
30
+ let plugins = await getProjectPlugins(project.id);
31
+
32
+ if (options.type) {
33
+ plugins = plugins.filter(p => p.type === options.type);
34
+ }
35
+
36
+ if (options.json) {
37
+ console.log(JSON.stringify(plugins, null, 2));
38
+ return;
39
+ }
40
+
41
+ if (plugins.length === 0) {
42
+ console.log(chalk.yellow('当前项目没有安装任何插件'));
43
+ return;
44
+ }
45
+
46
+ console.log();
47
+ console.log(`项目: ${chalk.cyan(path.basename(projectPath))}`);
48
+ console.log(`路径: ${projectPath}`);
49
+ console.log();
50
+ console.log(`已安装 ${plugins.length} 个插件:`);
51
+ console.log();
52
+
53
+ // Group by type
54
+ const grouped = groupBy(plugins, 'type');
55
+
56
+ for (const [type, items] of Object.entries(grouped)) {
57
+ console.log(chalk.bold(`${type}:`));
58
+
59
+ for (const plugin of items) {
60
+ const relativePath = path.relative(projectPath, plugin.link_path);
61
+ console.log(` ${chalk.cyan(plugin.full_name)}`);
62
+ console.log(` 版本: ${plugin.version}`);
63
+ console.log(` 路径: ${chalk.gray(relativePath)}`);
64
+
65
+ if (plugin.source === 'list') {
66
+ console.log(` 来源: ${chalk.gray(plugin.source_list)}`);
67
+ }
68
+
69
+ console.log();
70
+ }
71
+ }
72
+ }
73
+
74
+ function groupBy<T>(arr: T[], key: keyof T): Record<string, T[]> {
75
+ return arr.reduce((acc, item) => {
76
+ const k = String(item[key]);
77
+ (acc[k] = acc[k] || []).push(item);
78
+ return acc;
79
+ }, {} as Record<string, T[]>);
80
+ }