@42ailab/42plugin 0.1.20 → 0.1.22
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 +1 -1
- package/src/commands/publish.ts +87 -0
- package/src/db.ts +100 -10
- package/src/services/publisher.ts +1 -1
- package/src/types.ts +1 -0
- package/src/validators/plugin-validator.ts +21 -51
package/package.json
CHANGED
package/src/commands/publish.ts
CHANGED
|
@@ -4,10 +4,73 @@
|
|
|
4
4
|
|
|
5
5
|
import { Command } from 'commander';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import { select } from '@inquirer/prompts';
|
|
7
10
|
import { api } from '../api';
|
|
8
11
|
import { Publisher } from '../services/publisher';
|
|
9
12
|
import { ValidationError, UploadError, AuthRequiredError } from '../errors';
|
|
10
13
|
import { getTypeIcon } from '../utils';
|
|
14
|
+
import type { PluginType } from '../types';
|
|
15
|
+
|
|
16
|
+
const VALID_TYPES: PluginType[] = ['skill', 'agent', 'command', 'hook', 'mcp'];
|
|
17
|
+
|
|
18
|
+
const TYPE_DESCRIPTIONS: Record<PluginType, string> = {
|
|
19
|
+
skill: 'skill(技能) - 包含 SKILL.md 的目录,为 Claude 提供特定能力',
|
|
20
|
+
agent: 'agent(代理) - 单个 .md 文件,定义 AI 角色和行为',
|
|
21
|
+
command: 'command(命令) - 单个 .md 文件,定义可执行的斜杠命令',
|
|
22
|
+
hook: 'hook(钩子) - 包含 hooks.json 的目录,响应事件触发',
|
|
23
|
+
mcp: 'mcp(MCP 服务) - 包含 mcp.json 的目录,Model Context Protocol 服务',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 从路径和文件特征推断建议类型(用于设置默认选项)
|
|
28
|
+
*/
|
|
29
|
+
async function suggestType(pluginPath: string): Promise<PluginType> {
|
|
30
|
+
const absPath = path.resolve(pluginPath);
|
|
31
|
+
const normalizedPath = absPath.replace(/\\/g, '/').toLowerCase();
|
|
32
|
+
|
|
33
|
+
// 1. 从路径推断(Claude Code 目录约定)
|
|
34
|
+
if (normalizedPath.includes('/.claude/agents/') || normalizedPath.includes('.claude/agents/')) {
|
|
35
|
+
return 'agent';
|
|
36
|
+
}
|
|
37
|
+
if (normalizedPath.includes('/.claude/commands/') || normalizedPath.includes('.claude/commands/')) {
|
|
38
|
+
return 'command';
|
|
39
|
+
}
|
|
40
|
+
if (normalizedPath.includes('/.claude/skills/') || normalizedPath.includes('.claude/skills/')) {
|
|
41
|
+
return 'skill';
|
|
42
|
+
}
|
|
43
|
+
if (normalizedPath.includes('/.claude/hooks/') || normalizedPath.includes('.claude/hooks/')) {
|
|
44
|
+
return 'hook';
|
|
45
|
+
}
|
|
46
|
+
if (normalizedPath.includes('/.claude/mcp/') || normalizedPath.includes('.claude/mcp/')) {
|
|
47
|
+
return 'mcp';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 2. 检查是否是目录,如果是则检查特征文件
|
|
51
|
+
try {
|
|
52
|
+
const stat = await fs.stat(absPath);
|
|
53
|
+
if (stat.isDirectory()) {
|
|
54
|
+
const files = await fs.readdir(absPath);
|
|
55
|
+
const lowerFiles = files.map((f) => f.toLowerCase());
|
|
56
|
+
|
|
57
|
+
if (lowerFiles.includes('mcp.json') || lowerFiles.includes('.mcp.json')) {
|
|
58
|
+
return 'mcp';
|
|
59
|
+
}
|
|
60
|
+
if (lowerFiles.includes('hooks.json') || lowerFiles.includes('hook.json')) {
|
|
61
|
+
return 'hook';
|
|
62
|
+
}
|
|
63
|
+
if (lowerFiles.includes('skill.md')) {
|
|
64
|
+
return 'skill';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// 路径不存在,忽略
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. 默认返回 skill
|
|
72
|
+
return 'skill';
|
|
73
|
+
}
|
|
11
74
|
|
|
12
75
|
export const publishCommand = new Command('publish')
|
|
13
76
|
.alias('pub')
|
|
@@ -17,8 +80,31 @@ export const publishCommand = new Command('publish')
|
|
|
17
80
|
.option('-f, --force', '强制发布(即使内容未变化)')
|
|
18
81
|
.option('--public', '公开发布(默认仅自己可见)')
|
|
19
82
|
.option('-n, --name <name>', '覆盖插件名称')
|
|
83
|
+
.option('-t, --type <type>', '指定插件类型 (skill|agent|command|hook|mcp)')
|
|
20
84
|
.action(async (pluginPath, options) => {
|
|
21
85
|
try {
|
|
86
|
+
// 验证 --type 参数
|
|
87
|
+
let selectedType: PluginType | undefined = options.type;
|
|
88
|
+
if (selectedType && !VALID_TYPES.includes(selectedType)) {
|
|
89
|
+
console.error(chalk.red(`无效的类型: ${selectedType}`));
|
|
90
|
+
console.log(chalk.gray(`有效类型: ${VALID_TYPES.join(', ')}`));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 如果没有指定类型,让用户交互式选择(智能默认)
|
|
95
|
+
if (!selectedType) {
|
|
96
|
+
const suggested = await suggestType(pluginPath);
|
|
97
|
+
selectedType = await select({
|
|
98
|
+
message: '请选择插件类型:',
|
|
99
|
+
choices: VALID_TYPES.map((t) => ({
|
|
100
|
+
name: TYPE_DESCRIPTIONS[t],
|
|
101
|
+
value: t,
|
|
102
|
+
})),
|
|
103
|
+
default: suggested,
|
|
104
|
+
});
|
|
105
|
+
console.log();
|
|
106
|
+
}
|
|
107
|
+
|
|
22
108
|
// 认证检查已在全局 hook 中完成
|
|
23
109
|
// 执行发布
|
|
24
110
|
const publisher = new Publisher();
|
|
@@ -28,6 +114,7 @@ export const publishCommand = new Command('publish')
|
|
|
28
114
|
force: options.force,
|
|
29
115
|
visibility: options.public ? 'public' : 'self',
|
|
30
116
|
name: options.name,
|
|
117
|
+
type: selectedType,
|
|
31
118
|
});
|
|
32
119
|
|
|
33
120
|
// 显示结果
|
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,15 +733,50 @@ export async function createLink(
|
|
|
678
733
|
// 不存在,继续
|
|
679
734
|
}
|
|
680
735
|
|
|
681
|
-
//
|
|
736
|
+
// 检测源是文件还是目录
|
|
682
737
|
const sourceStat = await fs.stat(sourcePath);
|
|
683
|
-
const
|
|
684
|
-
|
|
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> {
|
|
688
|
-
// 去掉结尾斜杠,否则 lstat
|
|
689
|
-
const normalizedPath = linkPath.replace(
|
|
778
|
+
// 去掉结尾斜杠,否则 lstat 会跟踪符号链接返回目标状态(兼容 Unix 和 Windows)
|
|
779
|
+
const normalizedPath = linkPath.replace(/[\/\\]+$/, '');
|
|
690
780
|
try {
|
|
691
781
|
const stat = await fs.lstat(normalizedPath);
|
|
692
782
|
if (stat.isSymbolicLink()) {
|
|
@@ -49,7 +49,7 @@ export class Publisher {
|
|
|
49
49
|
try {
|
|
50
50
|
// Phase 1: 完整验证
|
|
51
51
|
spinner.text = '验证插件...';
|
|
52
|
-
const validation = await this.validator.validateFull(options.path, options.name);
|
|
52
|
+
const validation = await this.validator.validateFull(options.path, options.name, options.type);
|
|
53
53
|
|
|
54
54
|
// 显示验证结果
|
|
55
55
|
spinner.stop();
|
package/src/types.ts
CHANGED
|
@@ -42,8 +42,15 @@ const VALID_TYPES: PluginType[] = ['skill', 'agent', 'command', 'hook', 'mcp'];
|
|
|
42
42
|
export class PluginValidator {
|
|
43
43
|
/**
|
|
44
44
|
* 完整验证(返回详细结果)
|
|
45
|
+
* @param pluginPath 插件路径
|
|
46
|
+
* @param overrideName 覆盖名称
|
|
47
|
+
* @param overrideType 覆盖类型(用户通过 -t 参数指定)
|
|
45
48
|
*/
|
|
46
|
-
async validateFull(
|
|
49
|
+
async validateFull(
|
|
50
|
+
pluginPath: string,
|
|
51
|
+
overrideName?: string,
|
|
52
|
+
overrideType?: PluginType
|
|
53
|
+
): Promise<ValidationResult> {
|
|
47
54
|
const result: ValidationResult = {
|
|
48
55
|
valid: true,
|
|
49
56
|
metadata: {
|
|
@@ -72,9 +79,9 @@ export class PluginValidator {
|
|
|
72
79
|
// Phase 2: 提取元信息并进行类型检测
|
|
73
80
|
try {
|
|
74
81
|
if (stat.isFile()) {
|
|
75
|
-
result.metadata = await this.extractFileMetadata(absPath, overrideName);
|
|
82
|
+
result.metadata = await this.extractFileMetadata(absPath, overrideName, overrideType);
|
|
76
83
|
} else if (stat.isDirectory()) {
|
|
77
|
-
result.metadata = await this.extractDirectoryMetadata(absPath, overrideName);
|
|
84
|
+
result.metadata = await this.extractDirectoryMetadata(absPath, overrideName, overrideType);
|
|
78
85
|
} else {
|
|
79
86
|
result.errors.push({
|
|
80
87
|
code: 'E000',
|
|
@@ -130,7 +137,8 @@ export class PluginValidator {
|
|
|
130
137
|
*/
|
|
131
138
|
private async extractFileMetadata(
|
|
132
139
|
filePath: string,
|
|
133
|
-
overrideName?: string
|
|
140
|
+
overrideName?: string,
|
|
141
|
+
overrideType?: PluginType
|
|
134
142
|
): Promise<PluginMetadata> {
|
|
135
143
|
const ext = path.extname(filePath).toLowerCase();
|
|
136
144
|
const basename = path.basename(filePath, ext);
|
|
@@ -146,8 +154,9 @@ export class PluginValidator {
|
|
|
146
154
|
const parsed = matter(content);
|
|
147
155
|
const data = parsed.data as Record<string, unknown>;
|
|
148
156
|
|
|
149
|
-
//
|
|
150
|
-
|
|
157
|
+
// 类型由用户通过 -t 参数或交互选择指定,必须提供
|
|
158
|
+
// 仅在未指定时使用 frontmatter 作为后备(向后兼容 validate 方法)
|
|
159
|
+
let type: PluginType = overrideType || (data.type as PluginType) || 'skill';
|
|
151
160
|
|
|
152
161
|
return {
|
|
153
162
|
name: overrideName || (data.name as string) || basename,
|
|
@@ -164,35 +173,23 @@ export class PluginValidator {
|
|
|
164
173
|
*/
|
|
165
174
|
private async extractDirectoryMetadata(
|
|
166
175
|
dirPath: string,
|
|
167
|
-
overrideName?: string
|
|
176
|
+
overrideName?: string,
|
|
177
|
+
overrideType?: PluginType
|
|
168
178
|
): Promise<PluginMetadata> {
|
|
169
179
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
170
180
|
const files = entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
171
|
-
const lowerFiles = files.map((f) => f.toLowerCase());
|
|
172
181
|
const basename = path.basename(dirPath);
|
|
173
182
|
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// MCP: 有 mcp.json 或 .mcp.json
|
|
179
|
-
if (lowerFiles.some((f) => f === 'mcp.json' || f === '.mcp.json')) {
|
|
180
|
-
type = 'mcp';
|
|
181
|
-
}
|
|
182
|
-
// Hook: 有 hooks.json 或 hook.json
|
|
183
|
-
else if (lowerFiles.some((f) => f === 'hooks.json' || f === 'hook.json')) {
|
|
184
|
-
type = 'hook';
|
|
185
|
-
}
|
|
186
|
-
// Skill: 有 SKILL.md 或 skill.md
|
|
187
|
-
else if (lowerFiles.some((f) => f === 'skill.md')) {
|
|
188
|
-
type = 'skill';
|
|
189
|
-
}
|
|
183
|
+
// 类型由用户通过 -t 参数或交互选择指定,必须提供
|
|
184
|
+
// 仅在未指定时使用 'skill' 作为后备(向后兼容 validate 方法)
|
|
185
|
+
const type: PluginType = overrideType || 'skill';
|
|
190
186
|
|
|
191
187
|
// 尝试读取主文件获取元信息
|
|
192
188
|
const mainFiles = ['SKILL.md', 'skill.md', 'README.md', 'readme.md'];
|
|
193
189
|
let description: string | undefined;
|
|
194
190
|
let title: string | undefined;
|
|
195
191
|
let tags: string[] | undefined;
|
|
192
|
+
let frontmatterData: Record<string, unknown> = {};
|
|
196
193
|
|
|
197
194
|
for (const file of mainFiles) {
|
|
198
195
|
if (files.includes(file)) {
|
|
@@ -204,9 +201,6 @@ export class PluginValidator {
|
|
|
204
201
|
if (frontmatterData.title) title = frontmatterData.title as string;
|
|
205
202
|
if (frontmatterData.description) description = frontmatterData.description as string;
|
|
206
203
|
if (frontmatterData.tags) tags = frontmatterData.tags as string[];
|
|
207
|
-
if (frontmatterData.type && VALID_TYPES.includes(frontmatterData.type as PluginType)) {
|
|
208
|
-
type = frontmatterData.type as PluginType;
|
|
209
|
-
}
|
|
210
204
|
|
|
211
205
|
// 如果没有 frontmatter 描述,取第一段文字
|
|
212
206
|
if (!description && parsed.content) {
|
|
@@ -233,30 +227,6 @@ export class PluginValidator {
|
|
|
233
227
|
};
|
|
234
228
|
}
|
|
235
229
|
|
|
236
|
-
/**
|
|
237
|
-
* 从内容推断插件类型
|
|
238
|
-
*/
|
|
239
|
-
private inferTypeFromContent(content: string, data: Record<string, unknown>): PluginType {
|
|
240
|
-
// 优先使用 frontmatter 中的 type
|
|
241
|
-
if (data.type && VALID_TYPES.includes(data.type as PluginType)) {
|
|
242
|
-
return data.type as PluginType;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// 从内容推断
|
|
246
|
-
const lowerContent = content.toLowerCase();
|
|
247
|
-
|
|
248
|
-
if (lowerContent.includes('you are') || lowerContent.includes('你是')) {
|
|
249
|
-
return 'agent';
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (lowerContent.includes('mcp') || lowerContent.includes('model context protocol')) {
|
|
253
|
-
return 'mcp';
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 默认为 skill
|
|
257
|
-
return 'skill';
|
|
258
|
-
}
|
|
259
|
-
|
|
260
230
|
// ==========================================================================
|
|
261
231
|
// Schema 验证
|
|
262
232
|
// ==========================================================================
|