@42ailab/42plugin 0.1.0-beta.0 → 0.1.2

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/src/api.ts ADDED
@@ -0,0 +1,447 @@
1
+ /**
2
+ * API 客户端
3
+ *
4
+ * 单文件封装所有 API 调用
5
+ */
6
+
7
+ import { config } from './config';
8
+ import type {
9
+ PluginDownload,
10
+ KitDownload,
11
+ KitApiResponse,
12
+ SearchResponse,
13
+ SearchApiResponse,
14
+ DeviceCodeResponse,
15
+ DeviceTokenResponse,
16
+ Session,
17
+ PluginStatusResponse,
18
+ UploadUrlResponse,
19
+ PublishConfirmRequest,
20
+ PublishConfirmResponse,
21
+ PluginType,
22
+ } from './types';
23
+
24
+ // ============================================================================
25
+ // 错误类型
26
+ // ============================================================================
27
+
28
+ export class ApiError extends Error {
29
+ constructor(
30
+ public statusCode: number,
31
+ public endpoint: string,
32
+ message: string,
33
+ public code?: string
34
+ ) {
35
+ super(message);
36
+ this.name = 'ApiError';
37
+ }
38
+ }
39
+
40
+ export class QuotaExceededError extends ApiError {
41
+ constructor(
42
+ public quotaType: string,
43
+ public limit: number,
44
+ public role: string,
45
+ message: string
46
+ ) {
47
+ super(429, 'quota', message, 'QUOTA_EXCEEDED');
48
+ this.name = 'QuotaExceededError';
49
+ }
50
+
51
+ isGuestQuota(): boolean {
52
+ return this.role === 'guest';
53
+ }
54
+ }
55
+
56
+ // ============================================================================
57
+ // API 客户端
58
+ // ============================================================================
59
+
60
+ class ApiClient {
61
+ private baseUrl: string;
62
+ private sessionToken: string | null = null;
63
+
64
+ constructor() {
65
+ this.baseUrl = config.apiBaseUrl;
66
+ }
67
+
68
+ setSessionToken(token: string | null): void {
69
+ this.sessionToken = token;
70
+ }
71
+
72
+ isAuthenticated(): boolean {
73
+ return !!this.sessionToken;
74
+ }
75
+
76
+ private async request<T>(
77
+ path: string,
78
+ options: {
79
+ method?: string;
80
+ body?: unknown;
81
+ auth?: boolean;
82
+ } = {}
83
+ ): Promise<T> {
84
+ const { method = 'GET', body, auth = true } = options;
85
+
86
+ const headers: Record<string, string> = {
87
+ 'Content-Type': 'application/json',
88
+ 'User-Agent': '42plugin-cli/1.0.0',
89
+ 'X-Download-Source': 'cli',
90
+ };
91
+
92
+ if (auth && this.sessionToken) {
93
+ headers['Authorization'] = `Bearer ${this.sessionToken}`;
94
+ }
95
+
96
+ const response = await fetch(`${this.baseUrl}${path}`, {
97
+ method,
98
+ headers,
99
+ body: body ? JSON.stringify(body) : undefined,
100
+ });
101
+
102
+ const data = await response.json().catch(() => ({}));
103
+
104
+ if (!response.ok) {
105
+ const error = (data as { error?: { code?: string; message?: string; quotaType?: string; limit?: number; role?: string } }).error;
106
+ const message = error?.message || `HTTP ${response.status}`;
107
+
108
+ if (response.status === 429 && error?.code === 'QUOTA_EXCEEDED') {
109
+ throw new QuotaExceededError(
110
+ error.quotaType || 'unknown',
111
+ error.limit || 0,
112
+ error.role || 'guest',
113
+ message
114
+ );
115
+ }
116
+
117
+ throw new ApiError(response.status, path, message, error?.code);
118
+ }
119
+
120
+ return ((data as { data?: T }).data ?? data) as T;
121
+ }
122
+
123
+ // ==========================================================================
124
+ // 插件 API
125
+ // ==========================================================================
126
+
127
+ async getPluginDownload(author: string, name: string): Promise<PluginDownload> {
128
+ return this.request<PluginDownload>(`/v1/plugins/${author}/${name}/download`);
129
+ }
130
+
131
+ // ==========================================================================
132
+ // 套包 API
133
+ // ==========================================================================
134
+
135
+ async getKitDownload(username: string, slugOrId: string): Promise<KitDownload> {
136
+ const raw = await this.request<KitApiResponse['data']>(
137
+ `/v1/kits/${username}/kit/${slugOrId}/download`
138
+ );
139
+
140
+ // 转换 snake_case -> camelCase
141
+ return {
142
+ kit: {
143
+ fullName: raw.kit.full_name,
144
+ name: raw.kit.name,
145
+ description: raw.kit.description,
146
+ priceTier: raw.kit.price_tier,
147
+ effectivePriceTier: raw.kit.effective_price_tier,
148
+ },
149
+ plugins: raw.plugins.map((p) => ({
150
+ plugin: {
151
+ fullName: p.plugin.full_name,
152
+ name: p.plugin.name,
153
+ title: p.plugin.title,
154
+ author: p.plugin.author,
155
+ type: p.plugin.type,
156
+ },
157
+ download: {
158
+ fullName: p.download.full_name,
159
+ name: p.download.name,
160
+ type: p.download.type,
161
+ version: p.download.version,
162
+ downloadUrl: p.download.download_url,
163
+ checksum: p.download.checksum,
164
+ sizeBytes: p.download.size_bytes,
165
+ installPath: p.download.install_path,
166
+ },
167
+ required: p.required,
168
+ reason: p.reason,
169
+ })),
170
+ summary: {
171
+ totalPlugins: raw.summary.total_plugins,
172
+ requiredPlugins: raw.summary.required_plugins,
173
+ optionalPlugins: raw.summary.optional_plugins,
174
+ totalSizeBytes: raw.summary.total_size_bytes,
175
+ expiresAt: raw.summary.expires_at,
176
+ },
177
+ };
178
+ }
179
+
180
+ // ==========================================================================
181
+ // 搜索 API
182
+ // ==========================================================================
183
+
184
+ async search(params: {
185
+ q: string;
186
+ pluginType?: string;
187
+ page?: number;
188
+ perPage?: number;
189
+ }): Promise<SearchResponse> {
190
+ const searchParams = new URLSearchParams();
191
+ searchParams.set('q', params.q);
192
+ if (params.pluginType) searchParams.set('plugin_type', params.pluginType);
193
+ if (params.page) searchParams.set('page', String(params.page));
194
+ if (params.perPage) searchParams.set('per_page', String(params.perPage));
195
+
196
+ // 搜索 API 直接返回 {data, pagination},不需要解包
197
+ const headers: Record<string, string> = {
198
+ 'Content-Type': 'application/json',
199
+ 'User-Agent': '42plugin-cli/1.0.0',
200
+ 'X-Download-Source': 'cli',
201
+ };
202
+ if (this.sessionToken) {
203
+ headers['Authorization'] = `Bearer ${this.sessionToken}`;
204
+ }
205
+
206
+ const response = await fetch(`${this.baseUrl}/v1/search?${searchParams}`, {
207
+ method: 'GET',
208
+ headers,
209
+ });
210
+
211
+ const raw = await response.json();
212
+
213
+ if (!response.ok) {
214
+ const error = raw?.error;
215
+ const message = error?.message || `HTTP ${response.status}`;
216
+ throw new ApiError(response.status, '/v1/search', message, error?.code);
217
+ }
218
+
219
+ // 转换 API 响应格式为内部格式 (snake_case -> camelCase)
220
+ const apiData = raw as SearchApiResponse;
221
+ const plugins = apiData.data?.plugins || [];
222
+
223
+ return {
224
+ data: plugins.map((p) => ({
225
+ fullName: p.full_name,
226
+ name: p.name,
227
+ title: p.title,
228
+ slogan: p.slogan || null,
229
+ description: p.description,
230
+ type: p.type,
231
+ version: p.version || '0.0.0',
232
+ author: p.author,
233
+ tags: p.tags || [],
234
+ downloads: p.downloads,
235
+ icon: p.icon || null,
236
+ })),
237
+ pagination: {
238
+ page: apiData.pagination?.page || 1,
239
+ perPage: apiData.pagination?.per_page || 20,
240
+ total: apiData.pagination?.total || 0,
241
+ totalPages: Math.ceil((apiData.pagination?.total || 0) / (apiData.pagination?.per_page || 20)),
242
+ },
243
+ };
244
+ }
245
+
246
+ // ==========================================================================
247
+ // 认证 API
248
+ // ==========================================================================
249
+
250
+ async requestDeviceCode(): Promise<DeviceCodeResponse> {
251
+ const response = await fetch(`${this.baseUrl}/auth/device/code`, {
252
+ method: 'POST',
253
+ headers: {
254
+ 'Content-Type': 'application/json',
255
+ 'User-Agent': '42plugin-cli/1.0.0',
256
+ 'X-Download-Source': 'cli',
257
+ },
258
+ body: JSON.stringify({
259
+ client_id: '42plugin-cli',
260
+ scope: 'openid profile email',
261
+ }),
262
+ });
263
+
264
+ const data = await response.json();
265
+
266
+ return {
267
+ deviceCode: data.device_code,
268
+ userCode: data.user_code,
269
+ verificationUri: data.verification_uri,
270
+ verificationUriComplete: data.verification_uri_complete,
271
+ expiresIn: data.expires_in,
272
+ interval: data.interval,
273
+ };
274
+ }
275
+
276
+ async pollDeviceToken(deviceCode: string): Promise<DeviceTokenResponse> {
277
+ const response = await fetch(`${this.baseUrl}/auth/device/token`, {
278
+ method: 'POST',
279
+ headers: {
280
+ 'Content-Type': 'application/json',
281
+ 'User-Agent': '42plugin-cli/1.0.0',
282
+ 'X-Download-Source': 'cli',
283
+ },
284
+ body: JSON.stringify({
285
+ client_id: '42plugin-cli',
286
+ device_code: deviceCode,
287
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
288
+ }),
289
+ });
290
+
291
+ const data = await response.json();
292
+
293
+ return {
294
+ accessToken: data.access_token,
295
+ user: data.user
296
+ ? {
297
+ id: data.user.id,
298
+ name: data.user.name,
299
+ username: data.user.username,
300
+ email: data.user.email,
301
+ }
302
+ : undefined,
303
+ error: data.error,
304
+ errorDescription: data.error_description,
305
+ };
306
+ }
307
+
308
+ async getSession(): Promise<Session> {
309
+ // 开发模式跳过认证 - 返回 mock session
310
+ if (config.devSkipAuth) {
311
+ return {
312
+ user: {
313
+ id: 'dev-user-id',
314
+ name: 'DevUser',
315
+ username: 'devuser',
316
+ email: 'dev@localhost',
317
+ },
318
+ session: {
319
+ id: 'dev-session-id',
320
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
321
+ },
322
+ };
323
+ }
324
+ return this.request<Session>('/session');
325
+ }
326
+
327
+ // ==========================================================================
328
+ // 用户 API
329
+ // ==========================================================================
330
+
331
+ async recordInstall(pluginFullName: string): Promise<void> {
332
+ await this.request('/user/installed', {
333
+ method: 'POST',
334
+ body: { plugin_full_name: pluginFullName },
335
+ });
336
+ }
337
+
338
+ async removeInstallRecord(author: string, name: string): Promise<void> {
339
+ await this.request(`/user/installed/${author}/${name}`, {
340
+ method: 'DELETE',
341
+ });
342
+ }
343
+
344
+ // ==========================================================================
345
+ // 发布 API
346
+ // ==========================================================================
347
+
348
+ /**
349
+ * 获取插件状态(用于版本决策)
350
+ */
351
+ async getPluginStatus(name: string): Promise<PluginStatusResponse> {
352
+ try {
353
+ const result = await this.request<{
354
+ exists: boolean;
355
+ version?: string;
356
+ content_hash?: string;
357
+ visibility?: 'self' | 'public';
358
+ }>(`/v1/plugins/me/${name}/status`);
359
+
360
+ return {
361
+ exists: result.exists,
362
+ version: result.version,
363
+ contentHash: result.content_hash,
364
+ visibility: result.visibility,
365
+ };
366
+ } catch (error) {
367
+ // 404 表示插件不存在
368
+ if (error instanceof ApiError && error.statusCode === 404) {
369
+ return { exists: false };
370
+ }
371
+ throw error;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * 获取上传签名 URL
377
+ */
378
+ async getUploadUrl(params: {
379
+ name: string;
380
+ type: PluginType;
381
+ version: string;
382
+ contentHash: string;
383
+ sizeBytes: number;
384
+ }): Promise<UploadUrlResponse> {
385
+ const result = await this.request<{
386
+ upload_url: string;
387
+ storage_key: string;
388
+ expires_at: string;
389
+ }>('/v1/plugins/upload-url', {
390
+ method: 'POST',
391
+ body: {
392
+ name: params.name,
393
+ type: params.type,
394
+ version: params.version,
395
+ content_hash: params.contentHash,
396
+ size_bytes: params.sizeBytes,
397
+ },
398
+ });
399
+
400
+ return {
401
+ uploadUrl: result.upload_url,
402
+ storageKey: result.storage_key,
403
+ expiresAt: result.expires_at,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * 确认发布
409
+ */
410
+ async confirmPublish(params: PublishConfirmRequest): Promise<PublishConfirmResponse> {
411
+ const result = await this.request<{
412
+ plugin: {
413
+ id: string;
414
+ full_name: string;
415
+ version: string;
416
+ };
417
+ action: 'created' | 'updated';
418
+ }>('/v1/plugins', {
419
+ method: 'POST',
420
+ body: {
421
+ name: params.name,
422
+ type: params.type,
423
+ version: params.version,
424
+ content_hash: params.contentHash,
425
+ package_hash: params.packageHash,
426
+ storage_key: params.storageKey,
427
+ size_bytes: params.sizeBytes,
428
+ title: params.title,
429
+ description: params.description,
430
+ tags: params.tags,
431
+ visibility: params.visibility,
432
+ },
433
+ });
434
+
435
+ return {
436
+ plugin: {
437
+ id: result.plugin.id,
438
+ fullName: result.plugin.full_name,
439
+ version: result.plugin.version,
440
+ },
441
+ action: result.action,
442
+ };
443
+ }
444
+ }
445
+
446
+ // 单例导出
447
+ export const api = new ApiClient();
package/src/cli.ts CHANGED
@@ -1,27 +1,44 @@
1
+ /**
2
+ * CLI 入口
3
+ */
4
+
1
5
  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';
6
+ import {
7
+ authCommand,
8
+ installCommand,
9
+ searchCommand,
10
+ listCommand,
11
+ uninstallCommand,
12
+ completionCommand,
13
+ setupCommand,
14
+ publishCommand,
15
+ checkCommand,
16
+ } from './commands';
8
17
 
9
- export const program = new Command();
18
+ const program = new Command();
10
19
 
11
20
  program
12
21
  .name('42plugin')
13
- .description('Claude Code 插件管理器')
14
- .version('0.1.0', '-v, --version', '显示版本号')
15
- .option('--verbose', '详细输出')
16
- .option('--no-color', '禁用颜色输出');
22
+ .description('活水插件 - AI 插件管理工具')
23
+ .version('0.1.0');
17
24
 
18
- // Register commands
25
+ // 注册命令
19
26
  program.addCommand(authCommand);
20
- program.addCommand(installCommand);
21
27
  program.addCommand(searchCommand);
28
+ program.addCommand(installCommand);
22
29
  program.addCommand(listCommand);
23
30
  program.addCommand(uninstallCommand);
24
- program.addCommand(versionCommand);
31
+ program.addCommand(publishCommand);
32
+ program.addCommand(checkCommand);
33
+ program.addCommand(completionCommand);
34
+ program.addCommand(setupCommand);
35
+
36
+ // 版本命令
37
+ program
38
+ .command('version')
39
+ .description('显示版本信息')
40
+ .action(() => {
41
+ console.log('42plugin v0.1.0');
42
+ });
25
43
 
26
- // Default help
27
- program.showHelpAfterError();
44
+ export { program };
@@ -1,114 +1,128 @@
1
+ /**
2
+ * auth 命令 - 认证管理
3
+ */
4
+
1
5
  import { Command } from 'commander';
2
- import open from 'open';
3
- import ora from 'ora';
4
6
  import chalk from 'chalk';
5
- import { api } from '../services/api';
6
- import { saveSecrets, loadSecrets, clearSecrets } from '../services/auth';
7
+ import ora from 'ora';
8
+ import open from 'open';
9
+ import { api } from '../api';
10
+ import { getSessionToken, saveSessionToken, clearSessionToken } from '../db';
7
11
 
8
12
  export const authCommand = new Command('auth')
9
- .description('登录 / 授权账户')
10
- .option('--status', '查看当前登录状态')
11
- .option('--logout', '登出并清除本地凭证')
13
+ .description('登录/登出 42plugin')
14
+ .option('--status', '查看登录状态')
15
+ .option('--logout', '登出')
12
16
  .action(async (options) => {
13
- if (options.status) {
14
- await showStatus();
15
- return;
16
- }
17
-
18
17
  if (options.logout) {
19
18
  await logout();
20
- return;
19
+ } else if (options.status) {
20
+ await status();
21
+ } else {
22
+ await login();
21
23
  }
22
-
23
- await login();
24
24
  });
25
25
 
26
- async function login() {
27
- const spinner = ora('正在初始化登录...').start();
26
+ async function login(): Promise<void> {
27
+ // 检查是否已登录
28
+ const existingToken = await getSessionToken();
29
+ if (existingToken) {
30
+ api.setSessionToken(existingToken);
31
+ try {
32
+ const session = await api.getSession();
33
+ console.log(chalk.yellow(`已登录为 ${session.user.name || session.user.email}`));
34
+ console.log(chalk.gray('如需切换账号,请先执行 42plugin auth --logout'));
35
+ return;
36
+ } catch {
37
+ // Token 无效,继续登录流程
38
+ }
39
+ }
40
+
41
+ const spinner = ora('正在请求设备授权码...').start();
28
42
 
29
43
  try {
30
- // 1. Get authorization code
31
- const { code, auth_url, expires_at } = await api.startAuth();
44
+ const deviceCode = await api.requestDeviceCode();
32
45
  spinner.stop();
33
46
 
34
47
  console.log();
35
- console.log(chalk.cyan('正在打开浏览器进行授权...'));
48
+ console.log(chalk.cyan('请在浏览器中完成登录:'));
36
49
  console.log();
37
- console.log('如果浏览器没有自动打开,请访问:');
38
- console.log(chalk.underline(auth_url));
50
+ console.log(` ${chalk.bold('验证码')}: ${chalk.yellow.bold(deviceCode.userCode)}`);
51
+ console.log(` ${chalk.bold('链接')}: ${deviceCode.verificationUri}`);
39
52
  console.log();
40
53
 
41
- // 2. Open browser
42
- await open(auth_url);
54
+ // 尝试打开浏览器
55
+ if (deviceCode.verificationUriComplete) {
56
+ await open(deviceCode.verificationUriComplete).catch(() => {});
57
+ }
43
58
 
44
- // 3. Poll for authorization completion
45
- spinner.start('等待浏览器授权...');
59
+ // 轮询等待授权
60
+ const pollSpinner = ora('等待授权...').start();
61
+ const interval = (deviceCode.interval || 5) * 1000;
62
+ const expiresAt = Date.now() + deviceCode.expiresIn * 1000;
46
63
 
47
- const pollInterval = 2000; // 2 seconds
48
- const expiresTime = new Date(expires_at).getTime();
64
+ while (Date.now() < expiresAt) {
65
+ await new Promise((r) => setTimeout(r, interval));
49
66
 
50
- while (Date.now() < expiresTime) {
51
- const result = await api.pollAuth(code);
67
+ const tokenResponse = await api.pollDeviceToken(deviceCode.deviceCode);
52
68
 
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
- });
69
+ if (tokenResponse.accessToken && tokenResponse.user) {
70
+ pollSpinner.succeed('授权成功!');
60
71
 
61
- // Update API client token
62
- api.setToken(result.access_token!);
72
+ // 保存 token
73
+ await saveSessionToken(tokenResponse.accessToken);
74
+ api.setSessionToken(tokenResponse.accessToken);
63
75
 
64
- spinner.succeed(chalk.green('登录成功!'));
65
76
  console.log();
66
- console.log(`欢迎,${chalk.cyan(result.user!.display_name || result.user!.username)}!`);
77
+ console.log(chalk.green(`欢迎,${tokenResponse.user.name || tokenResponse.user.username || tokenResponse.user.email}!`));
67
78
  return;
68
79
  }
69
80
 
70
- await sleep(pollInterval);
81
+ if (tokenResponse.error === 'expired_token') {
82
+ pollSpinner.fail('授权码已过期,请重新执行 42plugin auth');
83
+ process.exit(1);
84
+ }
85
+
86
+ if (tokenResponse.error === 'access_denied') {
87
+ pollSpinner.fail('授权被拒绝');
88
+ process.exit(1);
89
+ }
90
+
91
+ // authorization_pending 继续等待
71
92
  }
72
93
 
73
- spinner.fail('授权超时,请重试');
94
+ pollSpinner.fail('授权超时,请重试');
95
+ process.exit(1);
74
96
  } catch (error) {
75
- spinner.fail(`登录失败: ${(error as Error).message}`);
97
+ spinner.fail('登录失败');
98
+ console.error(chalk.red((error as Error).message));
76
99
  process.exit(1);
77
100
  }
78
101
  }
79
102
 
80
- async function showStatus() {
81
- const secrets = await loadSecrets();
103
+ async function logout(): Promise<void> {
104
+ await clearSessionToken();
105
+ api.setSessionToken(null);
106
+ console.log(chalk.green('已登出'));
107
+ }
82
108
 
83
- if (!secrets) {
109
+ async function status(): Promise<void> {
110
+ const token = await getSessionToken();
111
+
112
+ if (!token) {
84
113
  console.log(chalk.yellow('未登录'));
85
- console.log('使用 42plugin auth 登录');
114
+ console.log(chalk.gray('执行 42plugin auth 登录'));
86
115
  return;
87
116
  }
88
117
 
118
+ api.setSessionToken(token);
119
+
89
120
  try {
90
- const user = await api.getMe();
121
+ const session = await api.getSession();
91
122
  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
- }
123
+ console.log(` 用户: ${session.user.name || session.user.email}`);
100
124
  } catch {
101
- console.log(chalk.yellow('Token 已过期,请重新登录'));
102
- console.log('使用 42plugin auth 重新登录');
125
+ console.log(chalk.yellow('登录已过期'));
126
+ console.log(chalk.gray('请执行 42plugin auth 重新登录'));
103
127
  }
104
128
  }
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
- }