@sokeai/cli 1.0.21 → 1.0.25
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 +470 -14
- package/package.json +1 -1
- package/scripts/e2e-test.sh +38 -0
- package/scripts/link-skills.sh +203 -0
- package/scripts/local-test.js +503 -0
- package/scripts/local-test.sh +225 -0
- package/scripts/push.sh +0 -10
- package/skills/soke-learning-profile/SKILL.md +223 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# soke-cli Skills 本地测试脚本
|
|
4
|
+
# 将本地 skills 链接到 Claude 的 skills 目录进行测试
|
|
5
|
+
|
|
6
|
+
# 颜色输出
|
|
7
|
+
RED='\033[0;31m'
|
|
8
|
+
GREEN='\033[0;32m'
|
|
9
|
+
YELLOW='\033[1;33m'
|
|
10
|
+
BLUE='\033[0;34m'
|
|
11
|
+
NC='\033[0m'
|
|
12
|
+
|
|
13
|
+
echo -e "${BLUE}========================================${NC}"
|
|
14
|
+
echo -e "${BLUE} soke-cli Skills 本地测试${NC}"
|
|
15
|
+
echo -e "${BLUE}========================================${NC}"
|
|
16
|
+
echo ""
|
|
17
|
+
|
|
18
|
+
# 检查 skills 目录是否存在
|
|
19
|
+
if [ ! -d "./skills" ]; then
|
|
20
|
+
echo -e "${RED}错误: 未找到 ./skills 目录${NC}"
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
# 查找所有可能的 skills 目录
|
|
25
|
+
echo -e "${YELLOW}查找 Claude skills 目录...${NC}"
|
|
26
|
+
SKILLS_DIRS=(
|
|
27
|
+
"$HOME/.codex/skills"
|
|
28
|
+
"$HOME/.openclaw/skills"
|
|
29
|
+
"$HOME/.agents/skills"
|
|
30
|
+
"$HOME/.workclaw/skills"
|
|
31
|
+
"$HOME/.skills"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
FOUND_DIRS=()
|
|
35
|
+
for dir in "${SKILLS_DIRS[@]}"; do
|
|
36
|
+
if [ -d "$dir" ]; then
|
|
37
|
+
FOUND_DIRS+=("$dir")
|
|
38
|
+
echo -e " ${GREEN}✓${NC} 找到: $dir"
|
|
39
|
+
fi
|
|
40
|
+
done
|
|
41
|
+
|
|
42
|
+
if [ ${#FOUND_DIRS[@]} -eq 0 ]; then
|
|
43
|
+
echo -e "${RED}错误: 未找到任何 Claude skills 目录${NC}"
|
|
44
|
+
echo -e "${YELLOW}提示: 请先运行 'npx skills add' 安装任意一个 skill 来初始化目录${NC}"
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
echo ""
|
|
48
|
+
|
|
49
|
+
# 选择要链接的目录
|
|
50
|
+
if [ ${#FOUND_DIRS[@]} -eq 1 ]; then
|
|
51
|
+
TARGET_DIR="${FOUND_DIRS[0]}"
|
|
52
|
+
echo -e "${BLUE}将链接到: ${TARGET_DIR}${NC}"
|
|
53
|
+
else
|
|
54
|
+
echo -e "${YELLOW}找到多个 skills 目录,请选择:${NC}"
|
|
55
|
+
for i in "${!FOUND_DIRS[@]}"; do
|
|
56
|
+
echo -e " $((i+1)). ${FOUND_DIRS[$i]}"
|
|
57
|
+
done
|
|
58
|
+
echo ""
|
|
59
|
+
|
|
60
|
+
# 如果有参数,使用参数作为选择
|
|
61
|
+
if [ -n "$1" ]; then
|
|
62
|
+
choice=$1
|
|
63
|
+
else
|
|
64
|
+
echo -n "请输入序号 (1-${#FOUND_DIRS[@]}) 或 'all' 链接到所有目录: "
|
|
65
|
+
read -r choice
|
|
66
|
+
fi
|
|
67
|
+
|
|
68
|
+
if [ "$choice" = "all" ]; then
|
|
69
|
+
echo -e "${BLUE}将链接到所有目录${NC}"
|
|
70
|
+
LINK_ALL=true
|
|
71
|
+
elif [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#FOUND_DIRS[@]} ]; then
|
|
72
|
+
TARGET_DIR="${FOUND_DIRS[$((choice-1))]}"
|
|
73
|
+
echo -e "${BLUE}已选择: ${TARGET_DIR}${NC}"
|
|
74
|
+
LINK_ALL=false
|
|
75
|
+
else
|
|
76
|
+
echo -e "${RED}无效的选择${NC}"
|
|
77
|
+
exit 1
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
echo ""
|
|
81
|
+
|
|
82
|
+
# 获取当前项目的绝对路径
|
|
83
|
+
PROJECT_DIR=$(pwd)
|
|
84
|
+
SOURCE_SKILLS_DIR="${PROJECT_DIR}/skills"
|
|
85
|
+
|
|
86
|
+
# 列出要链接的 skills
|
|
87
|
+
echo -e "${YELLOW}准备链接以下 skills:${NC}"
|
|
88
|
+
for skill_dir in "$SOURCE_SKILLS_DIR"/*; do
|
|
89
|
+
if [ -d "$skill_dir" ]; then
|
|
90
|
+
skill_name=$(basename "$skill_dir")
|
|
91
|
+
echo -e " - ${BLUE}${skill_name}${NC}"
|
|
92
|
+
fi
|
|
93
|
+
done
|
|
94
|
+
echo ""
|
|
95
|
+
|
|
96
|
+
# 确认操作
|
|
97
|
+
echo -e "${YELLOW}是否继续? (y/n)${NC}"
|
|
98
|
+
read -r CONFIRM
|
|
99
|
+
if [ "$CONFIRM" != "y" ] && [ "$CONFIRM" != "Y" ]; then
|
|
100
|
+
echo -e "${YELLOW}取消操作${NC}"
|
|
101
|
+
exit 0
|
|
102
|
+
fi
|
|
103
|
+
echo ""
|
|
104
|
+
|
|
105
|
+
# 创建符号链接
|
|
106
|
+
echo -e "${YELLOW}创建符号链接...${NC}"
|
|
107
|
+
SUCCESS_COUNT=0
|
|
108
|
+
SKIP_COUNT=0
|
|
109
|
+
|
|
110
|
+
# 如果选择链接到所有目录
|
|
111
|
+
if [ "$LINK_ALL" = true ]; then
|
|
112
|
+
for target_dir in "${FOUND_DIRS[@]}"; do
|
|
113
|
+
echo -e "${BLUE}链接到: ${target_dir}${NC}"
|
|
114
|
+
for skill_dir in "$SOURCE_SKILLS_DIR"/*; do
|
|
115
|
+
if [ -d "$skill_dir" ]; then
|
|
116
|
+
skill_name=$(basename "$skill_dir")
|
|
117
|
+
target_link="${target_dir}/${skill_name}"
|
|
118
|
+
|
|
119
|
+
if [ -L "$target_link" ]; then
|
|
120
|
+
current_target=$(readlink "$target_link")
|
|
121
|
+
if [ "$current_target" = "$skill_dir" ]; then
|
|
122
|
+
echo -e " ${GREEN}✓${NC} ${skill_name} (已存在)"
|
|
123
|
+
SKIP_COUNT=$((SKIP_COUNT + 1))
|
|
124
|
+
else
|
|
125
|
+
rm "$target_link"
|
|
126
|
+
ln -s "$skill_dir" "$target_link"
|
|
127
|
+
echo -e " ${GREEN}✓${NC} ${skill_name} (已更新)"
|
|
128
|
+
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
|
129
|
+
fi
|
|
130
|
+
elif [ -e "$target_link" ]; then
|
|
131
|
+
echo -e " ${RED}✗${NC} ${skill_name} (存在同名文件)"
|
|
132
|
+
else
|
|
133
|
+
ln -s "$skill_dir" "$target_link"
|
|
134
|
+
echo -e " ${GREEN}✓${NC} ${skill_name} (已链接)"
|
|
135
|
+
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
|
136
|
+
fi
|
|
137
|
+
fi
|
|
138
|
+
done
|
|
139
|
+
echo ""
|
|
140
|
+
done
|
|
141
|
+
else
|
|
142
|
+
# 链接到单个目录
|
|
143
|
+
for skill_dir in "$SOURCE_SKILLS_DIR"/*; do
|
|
144
|
+
if [ -d "$skill_dir" ]; then
|
|
145
|
+
skill_name=$(basename "$skill_dir")
|
|
146
|
+
target_link="${TARGET_DIR}/${skill_name}"
|
|
147
|
+
|
|
148
|
+
# 检查是否已存在
|
|
149
|
+
if [ -L "$target_link" ]; then
|
|
150
|
+
# 已存在符号链接,检查是否指向正确位置
|
|
151
|
+
current_target=$(readlink "$target_link")
|
|
152
|
+
if [ "$current_target" = "$skill_dir" ]; then
|
|
153
|
+
echo -e " ${GREEN}✓${NC} ${skill_name} (已存在,指向正确)"
|
|
154
|
+
SKIP_COUNT=$((SKIP_COUNT + 1))
|
|
155
|
+
else
|
|
156
|
+
echo -e " ${YELLOW}!${NC} ${skill_name} (已存在,但指向: ${current_target})"
|
|
157
|
+
echo -n " 是否覆盖? (y/n): "
|
|
158
|
+
read -r overwrite
|
|
159
|
+
if [ "$overwrite" = "y" ] || [ "$overwrite" = "Y" ]; then
|
|
160
|
+
rm "$target_link"
|
|
161
|
+
ln -s "$skill_dir" "$target_link"
|
|
162
|
+
echo -e " ${GREEN}✓${NC} 已覆盖"
|
|
163
|
+
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
|
164
|
+
else
|
|
165
|
+
echo -e " ${YELLOW}跳过${NC}"
|
|
166
|
+
SKIP_COUNT=$((SKIP_COUNT + 1))
|
|
167
|
+
fi
|
|
168
|
+
fi
|
|
169
|
+
elif [ -e "$target_link" ]; then
|
|
170
|
+
# 存在同名文件/目录
|
|
171
|
+
echo -e " ${RED}✗${NC} ${skill_name} (存在同名文件/目录)"
|
|
172
|
+
echo -e " 请手动删除: rm -rf ${target_link}"
|
|
173
|
+
else
|
|
174
|
+
# 创建新链接
|
|
175
|
+
ln -s "$skill_dir" "$target_link"
|
|
176
|
+
echo -e " ${GREEN}✓${NC} ${skill_name} (已链接)"
|
|
177
|
+
SUCCESS_COUNT=$((SUCCESS_COUNT + 1))
|
|
178
|
+
fi
|
|
179
|
+
fi
|
|
180
|
+
done
|
|
181
|
+
fi
|
|
182
|
+
echo ""
|
|
183
|
+
|
|
184
|
+
# 完成
|
|
185
|
+
echo -e "${GREEN}========================================${NC}"
|
|
186
|
+
echo -e "${GREEN} Skills 链接完成! ✓${NC}"
|
|
187
|
+
echo -e "${GREEN}========================================${NC}"
|
|
188
|
+
echo ""
|
|
189
|
+
echo -e "${BLUE}统计:${NC}"
|
|
190
|
+
echo -e " 新建链接: ${GREEN}${SUCCESS_COUNT}${NC}"
|
|
191
|
+
echo -e " 已存在: ${YELLOW}${SKIP_COUNT}${NC}"
|
|
192
|
+
echo ""
|
|
193
|
+
echo -e "${BLUE}下一步:${NC}"
|
|
194
|
+
echo -e " 1. 确保全局 CLI 已更新: ${BLUE}bash ./scripts/local-test.sh${NC}"
|
|
195
|
+
echo -e " 2. 在 Claude Code 中测试:"
|
|
196
|
+
echo -e " ${BLUE}\"查询张三的学习档案\"${NC}"
|
|
197
|
+
echo -e " ${BLUE}\"查询技术部的学员学习情况\"${NC}"
|
|
198
|
+
echo ""
|
|
199
|
+
echo -e "${YELLOW}提示:${NC}"
|
|
200
|
+
echo -e " - Skills 已链接到: ${TARGET_DIR}"
|
|
201
|
+
echo -e " - 修改本地 skills 文件会立即生效"
|
|
202
|
+
echo -e " - 删除链接: ${BLUE}rm ${TARGET_DIR}/soke-*${NC}"
|
|
203
|
+
echo ""
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 本地测试脚本 - 将 skills 分发到本地 AI Agent 环境
|
|
5
|
+
* 用于开发完 skill 后在本地测试,无需发布到 npm
|
|
6
|
+
*
|
|
7
|
+
* 使用方法:
|
|
8
|
+
* node scripts/local-test.js
|
|
9
|
+
* node scripts/local-test.js --skill soke-course
|
|
10
|
+
* node scripts/local-test.js --clean
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
|
|
17
|
+
// 颜色输出
|
|
18
|
+
const colors = {
|
|
19
|
+
reset: '\x1b[0m',
|
|
20
|
+
bright: '\x1b[1m',
|
|
21
|
+
red: '\x1b[31m',
|
|
22
|
+
green: '\x1b[32m',
|
|
23
|
+
yellow: '\x1b[33m',
|
|
24
|
+
blue: '\x1b[34m',
|
|
25
|
+
cyan: '\x1b[36m'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function log(message, color = 'reset') {
|
|
29
|
+
console.log(`${colors[color]}${message}${colors.reset}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function logSection(title) {
|
|
33
|
+
console.log('');
|
|
34
|
+
log(`${'='.repeat(60)}`, 'blue');
|
|
35
|
+
log(` ${title}`, 'bright');
|
|
36
|
+
log(`${'='.repeat(60)}`, 'blue');
|
|
37
|
+
console.log('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function logSuccess(message) {
|
|
41
|
+
log(`✅ ${message}`, 'green');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function logError(message) {
|
|
45
|
+
log(`❌ ${message}`, 'red');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function logWarning(message) {
|
|
49
|
+
log(`⚠️ ${message}`, 'yellow');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function logInfo(message) {
|
|
53
|
+
log(`ℹ️ ${message}`, 'cyan');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 递归复制目录
|
|
58
|
+
*/
|
|
59
|
+
function copyDirRecursive(srcDir, destDir) {
|
|
60
|
+
if (!fs.existsSync(srcDir)) return false;
|
|
61
|
+
|
|
62
|
+
if (!fs.existsSync(destDir)) {
|
|
63
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
70
|
+
const destPath = path.join(destDir, entry.name);
|
|
71
|
+
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
copyDirRecursive(srcPath, destPath);
|
|
74
|
+
} else if (entry.isSymbolicLink()) {
|
|
75
|
+
try {
|
|
76
|
+
const linkTarget = fs.readlinkSync(srcPath);
|
|
77
|
+
try {
|
|
78
|
+
fs.unlinkSync(destPath);
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
fs.symlinkSync(linkTarget, destPath);
|
|
81
|
+
} catch (_) {}
|
|
82
|
+
} else {
|
|
83
|
+
fs.copyFileSync(srcPath, destPath);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 检测所有 soke-* skills
|
|
92
|
+
*/
|
|
93
|
+
function detectSkillNames(packagedSkillsDir) {
|
|
94
|
+
if (!fs.existsSync(packagedSkillsDir)) return [];
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const entries = fs.readdirSync(packagedSkillsDir, { withFileTypes: true });
|
|
98
|
+
return entries
|
|
99
|
+
.filter(entry => entry.isDirectory() && entry.name.startsWith('soke-'))
|
|
100
|
+
.map(entry => entry.name)
|
|
101
|
+
.sort();
|
|
102
|
+
} catch (_) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 从 SKILL.md 解析元数据
|
|
109
|
+
*/
|
|
110
|
+
function parseSkillMetadata(skillDir) {
|
|
111
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
112
|
+
if (!fs.existsSync(skillMdPath)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(skillMdPath, 'utf8');
|
|
118
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
119
|
+
if (!frontmatterMatch) return null;
|
|
120
|
+
|
|
121
|
+
const frontmatter = frontmatterMatch[1];
|
|
122
|
+
const metadata = {};
|
|
123
|
+
|
|
124
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
125
|
+
if (nameMatch) metadata.name = nameMatch[1].trim();
|
|
126
|
+
|
|
127
|
+
const summaryMatch = frontmatter.match(/^summary:\s*(.+)$/m);
|
|
128
|
+
if (summaryMatch) metadata.summary = summaryMatch[1].trim();
|
|
129
|
+
|
|
130
|
+
const descMatch = frontmatter.match(/^description:\s*["'](.+)["']$/m);
|
|
131
|
+
if (descMatch) {
|
|
132
|
+
metadata.description = descMatch[1].trim();
|
|
133
|
+
} else {
|
|
134
|
+
const descMatch2 = frontmatter.match(/^description:\s*(.+)$/m);
|
|
135
|
+
if (descMatch2) metadata.description = descMatch2[1].trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const versionMatch = frontmatter.match(/^version:\s*(.+)$/m);
|
|
139
|
+
if (versionMatch) metadata.version = versionMatch[1].trim();
|
|
140
|
+
|
|
141
|
+
const binsMatch = frontmatter.match(/bins:\s*\[(.+?)\]/);
|
|
142
|
+
if (binsMatch) {
|
|
143
|
+
metadata.bins = binsMatch[1].split(',').map(b => b.trim().replace(/['"]/g, ''));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return metadata;
|
|
147
|
+
} catch (_) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 推断 skill emoji
|
|
154
|
+
*/
|
|
155
|
+
function inferSkillEmoji(skillName) {
|
|
156
|
+
const emojiMap = {
|
|
157
|
+
'soke-exam': '📝',
|
|
158
|
+
'soke-course': '📚',
|
|
159
|
+
'soke-shared': '🔧',
|
|
160
|
+
'soke-user': '👤',
|
|
161
|
+
'soke-contact': '📇',
|
|
162
|
+
'soke-department': '🏢',
|
|
163
|
+
'soke-approval': '✅',
|
|
164
|
+
'soke-attendance': '📅',
|
|
165
|
+
'soke-report': '📊'
|
|
166
|
+
};
|
|
167
|
+
return emojiMap[skillName] || '📦';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 检测本地 AI Agent 环境
|
|
172
|
+
* 只检测 agent 相关的目录,不包括全局安装目录
|
|
173
|
+
*/
|
|
174
|
+
function detectLocalAgentDirs() {
|
|
175
|
+
const homeDir = os.homedir();
|
|
176
|
+
const dirs = [];
|
|
177
|
+
|
|
178
|
+
// 1. workclaw (Claude Code) - Agent skills 目录
|
|
179
|
+
const workclawDir = path.join(homeDir, '.workclaw', 'skills');
|
|
180
|
+
if (fs.existsSync(path.join(homeDir, '.workclaw'))) {
|
|
181
|
+
dirs.push({
|
|
182
|
+
name: 'workclaw (Claude Code)',
|
|
183
|
+
path: workclawDir,
|
|
184
|
+
registryPath: path.join(workclawDir, 'registry.json'),
|
|
185
|
+
type: 'workclaw'
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 2. claude (Claude Desktop) - Agent skills 目录
|
|
190
|
+
const claudeDir = path.join(homeDir, '.claude', 'skills');
|
|
191
|
+
if (fs.existsSync(path.join(homeDir, '.claude'))) {
|
|
192
|
+
dirs.push({
|
|
193
|
+
name: 'claude (Claude Desktop)',
|
|
194
|
+
path: claudeDir,
|
|
195
|
+
registryPath: null,
|
|
196
|
+
type: 'claude'
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 3. sokeclaw - Agent skills 目录
|
|
201
|
+
const sokeclawDir = path.join(homeDir, '.sokeclaw', 'openai-agents', 'workspaces', 'main', 'skills');
|
|
202
|
+
if (fs.existsSync(path.join(homeDir, '.sokeclaw'))) {
|
|
203
|
+
dirs.push({
|
|
204
|
+
name: 'sokeclaw',
|
|
205
|
+
path: sokeclawDir,
|
|
206
|
+
registryPath: null,
|
|
207
|
+
type: 'sokeclaw'
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 4. zev - Agent skills 目录
|
|
212
|
+
const zevDir = path.join(homeDir, '.zev', 'openai-agents', 'workspaces', 'main', 'skills');
|
|
213
|
+
if (fs.existsSync(path.join(homeDir, '.zev'))) {
|
|
214
|
+
dirs.push({
|
|
215
|
+
name: 'zev',
|
|
216
|
+
path: zevDir,
|
|
217
|
+
registryPath: null,
|
|
218
|
+
type: 'zev'
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return dirs;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 更新 workclaw registry.json
|
|
227
|
+
*/
|
|
228
|
+
function updateWorkclawRegistry(registryPath, skillName, metadata, skillInstallPath) {
|
|
229
|
+
let registry;
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
|
|
233
|
+
} catch (_) {
|
|
234
|
+
registry = { version: 1, migrations: {}, skills: [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (registry.version == null) registry.version = 1;
|
|
238
|
+
if (!registry.migrations) registry.migrations = {};
|
|
239
|
+
if (!Array.isArray(registry.skills)) registry.skills = [];
|
|
240
|
+
|
|
241
|
+
const displayName = metadata.summary || metadata.name || skillName;
|
|
242
|
+
const description = metadata.description || `${displayName} - 授客AI CLI工具`;
|
|
243
|
+
const version = metadata.version || '1.0.0';
|
|
244
|
+
const emoji = inferSkillEmoji(skillName);
|
|
245
|
+
const requires = metadata.bins ? { bins: metadata.bins } : {};
|
|
246
|
+
|
|
247
|
+
const skillEntry = {
|
|
248
|
+
id: `skill:${skillName}`,
|
|
249
|
+
name: skillName,
|
|
250
|
+
displayName: displayName,
|
|
251
|
+
description: description,
|
|
252
|
+
source: { type: 'local', slug: '', url: '' },
|
|
253
|
+
install: {
|
|
254
|
+
path: skillInstallPath,
|
|
255
|
+
installedAt: new Date().toISOString(),
|
|
256
|
+
updatedAt: new Date().toISOString(),
|
|
257
|
+
version: version
|
|
258
|
+
},
|
|
259
|
+
state: { enabled: true, health: 'ok', lastError: '' },
|
|
260
|
+
runtime: { supported: ['openclaw'], enabled: ['openclaw'], primary: 'openclaw' },
|
|
261
|
+
security: { riskLevel: 'normal', requiresApproval: false },
|
|
262
|
+
metadata: { emoji: emoji, homepage: '', requires: requires }
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const idx = registry.skills.findIndex(s => s && s.id === skillEntry.id);
|
|
266
|
+
if (idx >= 0) {
|
|
267
|
+
registry.skills[idx] = { ...registry.skills[idx], ...skillEntry };
|
|
268
|
+
} else {
|
|
269
|
+
registry.skills.push(skillEntry);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 分发单个 skill 到所有本地环境
|
|
277
|
+
*/
|
|
278
|
+
function distributeSkill(skillName, packagedSkillsDir, targetDirs) {
|
|
279
|
+
const srcDir = path.join(packagedSkillsDir, skillName);
|
|
280
|
+
|
|
281
|
+
if (!fs.existsSync(srcDir)) {
|
|
282
|
+
logError(`Skill 目录不存在: ${srcDir}`);
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const metadata = parseSkillMetadata(srcDir);
|
|
287
|
+
if (!metadata || !metadata.name) {
|
|
288
|
+
logWarning(`无法解析 ${skillName} 的元数据,将使用默认值`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
logInfo(`分发 ${skillName}...`);
|
|
292
|
+
console.log('');
|
|
293
|
+
|
|
294
|
+
let successCount = 0;
|
|
295
|
+
|
|
296
|
+
for (const target of targetDirs) {
|
|
297
|
+
try {
|
|
298
|
+
fs.mkdirSync(target.path, { recursive: true });
|
|
299
|
+
|
|
300
|
+
const destDir = path.join(target.path, skillName);
|
|
301
|
+
const success = copyDirRecursive(srcDir, destDir);
|
|
302
|
+
|
|
303
|
+
if (success) {
|
|
304
|
+
logSuccess(` → ${target.name}`);
|
|
305
|
+
logInfo(` ${destDir}`);
|
|
306
|
+
|
|
307
|
+
// 更新 workclaw registry
|
|
308
|
+
if (target.type === 'workclaw' && target.registryPath) {
|
|
309
|
+
updateWorkclawRegistry(target.registryPath, skillName, metadata, destDir);
|
|
310
|
+
logInfo(` 已更新 registry.json`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
successCount++;
|
|
314
|
+
} else {
|
|
315
|
+
logError(` → ${target.name} (复制失败)`);
|
|
316
|
+
}
|
|
317
|
+
} catch (err) {
|
|
318
|
+
logError(` → ${target.name} (错误: ${err.message})`);
|
|
319
|
+
}
|
|
320
|
+
console.log('');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return successCount > 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 清理所有本地环境中的 skills
|
|
328
|
+
*/
|
|
329
|
+
function cleanSkills(targetDirs, skillNames) {
|
|
330
|
+
logSection('清理本地 Skills');
|
|
331
|
+
|
|
332
|
+
for (const target of targetDirs) {
|
|
333
|
+
logInfo(`清理 ${target.name}...`);
|
|
334
|
+
|
|
335
|
+
for (const skillName of skillNames) {
|
|
336
|
+
const skillDir = path.join(target.path, skillName);
|
|
337
|
+
|
|
338
|
+
if (fs.existsSync(skillDir)) {
|
|
339
|
+
try {
|
|
340
|
+
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
341
|
+
logSuccess(` ✓ 删除 ${skillName}`);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
logError(` ✗ 删除 ${skillName} 失败: ${err.message}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// 清理 workclaw registry
|
|
349
|
+
if (target.type === 'workclaw' && target.registryPath && fs.existsSync(target.registryPath)) {
|
|
350
|
+
try {
|
|
351
|
+
const registry = JSON.parse(fs.readFileSync(target.registryPath, 'utf8'));
|
|
352
|
+
if (Array.isArray(registry.skills)) {
|
|
353
|
+
const before = registry.skills.length;
|
|
354
|
+
registry.skills = registry.skills.filter(s => {
|
|
355
|
+
return !s || !s.name || !skillNames.includes(s.name);
|
|
356
|
+
});
|
|
357
|
+
const after = registry.skills.length;
|
|
358
|
+
|
|
359
|
+
if (before !== after) {
|
|
360
|
+
fs.writeFileSync(target.registryPath, JSON.stringify(registry, null, 2));
|
|
361
|
+
logSuccess(` ✓ 清理 registry.json (删除 ${before - after} 个条目)`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
} catch (err) {
|
|
365
|
+
logError(` ✗ 清理 registry.json 失败: ${err.message}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
console.log('');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 主函数
|
|
375
|
+
*/
|
|
376
|
+
function main() {
|
|
377
|
+
const args = process.argv.slice(2);
|
|
378
|
+
const isClean = args.includes('--clean');
|
|
379
|
+
const skillArg = args.find(arg => arg.startsWith('--skill='));
|
|
380
|
+
const specificSkill = skillArg ? skillArg.split('=')[1] : null;
|
|
381
|
+
|
|
382
|
+
logSection('本地 Skill 测试工具');
|
|
383
|
+
|
|
384
|
+
// 检测项目目录
|
|
385
|
+
const packageRoot = path.join(__dirname, '..');
|
|
386
|
+
const packagedSkillsDir = path.join(packageRoot, 'skills');
|
|
387
|
+
|
|
388
|
+
if (!fs.existsSync(packagedSkillsDir)) {
|
|
389
|
+
logError(`Skills 目录不存在: ${packagedSkillsDir}`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// 检测所有 skills
|
|
394
|
+
const allSkills = detectSkillNames(packagedSkillsDir);
|
|
395
|
+
|
|
396
|
+
if (allSkills.length === 0) {
|
|
397
|
+
logError('未检测到任何 soke-* skills');
|
|
398
|
+
process.exit(1);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
logInfo(`检测到 ${allSkills.length} 个 skills: ${allSkills.join(', ')}`);
|
|
402
|
+
console.log('');
|
|
403
|
+
|
|
404
|
+
// 检测本地 AI Agent 环境
|
|
405
|
+
const targetDirs = detectLocalAgentDirs();
|
|
406
|
+
|
|
407
|
+
if (targetDirs.length === 0) {
|
|
408
|
+
logError('未检测到任何本地 AI Agent 环境');
|
|
409
|
+
logInfo('支持的环境:');
|
|
410
|
+
logInfo(' • workclaw (~/.workclaw/skills/)');
|
|
411
|
+
logInfo(' • claude (~/.claude/skills/)');
|
|
412
|
+
logInfo(' • sokeclaw (~/.sokeclaw/openai-agents/workspaces/main/skills/)');
|
|
413
|
+
logInfo(' • zev (~/.zev/openai-agents/workspaces/main/skills/)');
|
|
414
|
+
console.log('');
|
|
415
|
+
logWarning('注意: 此脚本只分发到 Agent skills 目录,不包括全局安装目录');
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
logInfo(`检测到 ${targetDirs.length} 个本地环境:`);
|
|
420
|
+
for (const target of targetDirs) {
|
|
421
|
+
logInfo(` • ${target.name}`);
|
|
422
|
+
logInfo(` ${target.path}`);
|
|
423
|
+
}
|
|
424
|
+
console.log('');
|
|
425
|
+
|
|
426
|
+
// 清理模式
|
|
427
|
+
if (isClean) {
|
|
428
|
+
cleanSkills(targetDirs, allSkills);
|
|
429
|
+
logSuccess('清理完成!');
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 确定要分发的 skills
|
|
434
|
+
const skillsToDistribute = specificSkill
|
|
435
|
+
? (allSkills.includes(specificSkill) ? [specificSkill] : [])
|
|
436
|
+
: allSkills;
|
|
437
|
+
|
|
438
|
+
if (skillsToDistribute.length === 0) {
|
|
439
|
+
logError(`Skill 不存在: ${specificSkill}`);
|
|
440
|
+
process.exit(1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 分发 skills
|
|
444
|
+
logSection('分发 Skills 到本地环境');
|
|
445
|
+
|
|
446
|
+
let totalSuccess = 0;
|
|
447
|
+
let totalFailed = 0;
|
|
448
|
+
|
|
449
|
+
for (const skillName of skillsToDistribute) {
|
|
450
|
+
const success = distributeSkill(skillName, packagedSkillsDir, targetDirs);
|
|
451
|
+
if (success) {
|
|
452
|
+
totalSuccess++;
|
|
453
|
+
} else {
|
|
454
|
+
totalFailed++;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 总结
|
|
459
|
+
logSection('分发完成');
|
|
460
|
+
|
|
461
|
+
logInfo(`总计: ${skillsToDistribute.length} 个 skills`);
|
|
462
|
+
logSuccess(`成功: ${totalSuccess} 个`);
|
|
463
|
+
if (totalFailed > 0) {
|
|
464
|
+
logError(`失败: ${totalFailed} 个`);
|
|
465
|
+
}
|
|
466
|
+
console.log('');
|
|
467
|
+
|
|
468
|
+
// 下一步提示
|
|
469
|
+
logSection('下一步');
|
|
470
|
+
console.log('');
|
|
471
|
+
log('1. 重启你的 AI Agent', 'cyan');
|
|
472
|
+
log(' • Claude Code: 重启 VS Code 或重新打开 Claude Code 窗口', 'cyan');
|
|
473
|
+
log(' • Claude Desktop: 重启 Claude Desktop 应用', 'cyan');
|
|
474
|
+
log(' • Sokeclaw: 重启 sokeclaw 进程', 'cyan');
|
|
475
|
+
log(' • Zev: 重启 zev 进程', 'cyan');
|
|
476
|
+
console.log('');
|
|
477
|
+
log('2. 在对话中测试 skill 功能', 'cyan');
|
|
478
|
+
log(' 例如: "查询课程列表" 或 "查询考试成绩"', 'cyan');
|
|
479
|
+
console.log('');
|
|
480
|
+
log('3. 验证命令是否可用:', 'cyan');
|
|
481
|
+
console.log('');
|
|
482
|
+
for (const skillName of skillsToDistribute) {
|
|
483
|
+
log(` soke-cli ${skillName.replace('soke-', '')} --help`, 'yellow');
|
|
484
|
+
}
|
|
485
|
+
console.log('');
|
|
486
|
+
log('4. 测试完成后,可以清理:', 'cyan');
|
|
487
|
+
log(' node scripts/local-test.js --clean', 'yellow');
|
|
488
|
+
console.log('');
|
|
489
|
+
log('💡 提示:', 'cyan');
|
|
490
|
+
log(' • 此脚本只分发到 Agent skills 目录', 'cyan');
|
|
491
|
+
log(' • 全局安装 (npm install -g) 需要单独处理', 'cyan');
|
|
492
|
+
log(' • 修改后重新运行此脚本即可更新', 'cyan');
|
|
493
|
+
console.log('');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 运行
|
|
497
|
+
try {
|
|
498
|
+
main();
|
|
499
|
+
} catch (err) {
|
|
500
|
+
logError(`发生错误: ${err.message}`);
|
|
501
|
+
console.error(err);
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|