@sokeai/cli 1.2.2 → 1.3.1

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 CHANGED
@@ -39,6 +39,7 @@
39
39
  4. **AI Agent Skills**
40
40
  - 提供 AI Agent 技能(Skills),使 AI 助手能够自动发现和调用 CLI 功能。
41
41
  - 支持自然语言交互,无需记忆复杂的命令参数。
42
+ - 支持维护者删除受管 Skill,并通过本地同步与升级安装自动清理废弃 Skill。
42
43
 
43
44
  ## 安装方法
44
45
 
@@ -164,6 +165,28 @@ soke-cli api GET /users/me
164
165
  soke-cli api POST /some/endpoint --data '{"key": "value"}'
165
166
  ```
166
167
 
168
+ ### 删除受管 Skill(维护者)
169
+
170
+ 如果你需要从当前仓库中下线一个受管 Skill,可以执行:
171
+
172
+ ```bash
173
+ soke-cli delete-skill soke-business-training-report
174
+ ```
175
+
176
+ 删除后再执行:
177
+
178
+ ```bash
179
+ bash ./scripts/local-test.sh
180
+ ```
181
+
182
+ 本地 Agent 中由 `soke-cli` 管理的过期 `soke-*` Skills 会被自动清理。客户后续执行:
183
+
184
+ ```bash
185
+ npm install -g @sokeai/cli@latest
186
+ ```
187
+
188
+ 也会按当前发布包中的 Skill 集合自动清理废弃的受管 Skill。
189
+
167
190
  ## 开发指南
168
191
 
169
192
  ### 从接口封装到发布的完整流程
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sokeai/cli",
3
- "version": "1.2.2",
3
+ "version": "1.3.1",
4
4
  "description": "授客AI官方CLI工具 - 支持AI Agent Skills",
5
5
  "bin": {
6
6
  "soke-cli": "scripts/run.js"
@@ -15,8 +15,37 @@ NC='\033[0m' # No Color
15
15
  VERSION=$(node -p "require('./package.json').version")
16
16
  echo -e "${GREEN}开始编译 soke-cli v${VERSION}${NC}"
17
17
 
18
+ # task 11.2 — OAuth 凭证通过 env 注入(替代原 sed 改 Go 源的方式)。
19
+ #
20
+ # 重要:vendor.yaml 的 ${SOKE_CLIENT_ID} / ${SOKE_CLIENT_SECRET} 由
21
+ # sokeai/provider.go 在 init() 阶段 Interpolate,**取的是产物运行时的 env**,
22
+ # 而不是 build 期。因此:
23
+ #
24
+ # * 本地开发:用户在 shell 里 export SOKE_CLIENT_ID / SOKE_CLIENT_SECRET,
25
+ # 然后 go run / 已 build 的 ./bin/soke-cli 启动时 init() 取到。
26
+ # * Release 二进制:client_id / client_secret 不在 build 阶段嵌入产物。
27
+ # 发版 CI 应当在制作 NPM 包时附带一个 wrapper(scripts/run.js)在启动
28
+ # soke-cli 前 setenv 注入(具体方式由 release-lead 在发版 CI 里定),
29
+ # 或要求最终用户自己 export(仅适用于内部分发场景)。
30
+ #
31
+ # 本脚本只做 sanity check + 把当前 shell env 透传到 go build(用于自动化
32
+ # 测试场景中,build 后立即跑 ./bin/soke-cli auth login 走流程)。
33
+ if [ -z "${SOKE_CLIENT_ID:-}" ] || [ -z "${SOKE_CLIENT_SECRET:-}" ]; then
34
+ if [ "${SOKE_CLI_RELEASE:-}" = "1" ]; then
35
+ echo -e "${RED}[FATAL] release 模式必须设置 SOKE_CLIENT_ID / SOKE_CLIENT_SECRET${NC}"
36
+ echo -e "${RED} 二进制本身不嵌入凭证,run.js wrapper 也需要相应的注入逻辑${NC}"
37
+ exit 1
38
+ fi
39
+ echo -e "${YELLOW}[警告] SOKE_CLIENT_ID / SOKE_CLIENT_SECRET 未设置;${NC}"
40
+ echo -e "${YELLOW} 本地 build 后直接 ./bin/soke-cli auth login 会 fail-fast。${NC}"
41
+ fi
42
+ export SOKE_CLIENT_ID SOKE_CLIENT_SECRET
43
+
18
44
  # 编译参数
19
- LDFLAGS="-s -w -X main.Version=${VERSION}"
45
+ # -X 注入路径必须匹配 internal/version.Version 所在包,
46
+ # 此前误写为 main.Version,导致 --version 始终落回 checker.go 里
47
+ # 的 hardcoded 默认值。
48
+ LDFLAGS="-s -w -X codeup.aliyun.com/5edbc121d1d1abe63b55f1c7/soke/soke-cli/internal/version.Version=${VERSION}"
20
49
 
21
50
  # 创建输出目录
22
51
  BIN_DIR="bin"
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # task 9.1 — 拦截误引入的 dws-cli upstream 旧常量。
3
+ #
4
+ # 故意提一个引入旧常量的 bad PR 时,CI 该步骤应当失败。
5
+ # 允许出现在 openspec/ 历史 doc 与 docs/dead-code-audit-*.md。
6
+ #
7
+ # 用法:bash scripts/ci/check-legacy-constants.sh
8
+
9
+ set -euo pipefail
10
+
11
+ PATTERN='OpenDevURL\|AuthorizeURL\|DefaultMCPBaseURL\|DefaultClientID\|DefaultClientSecret\|<YOUR_CLIENT_'
12
+
13
+ # 只扫源码目录;openspec/docs/scripts 历史档案允许出现。
14
+ hits=$(grep -rn -E "$PATTERN" cmd/ internal/ extension/ 2>/dev/null \
15
+ | grep -v vendorplugin/ \
16
+ | grep -v "_test.go" \
17
+ || true)
18
+
19
+ if [ -n "$hits" ]; then
20
+ echo "[FAIL] 检测到 dws-cli 旧常量残留:"
21
+ echo "$hits"
22
+ echo
23
+ echo "请改用 vendor.Active().Endpoints() / OAuth() 读取(见 cmd/user_auth/vendor.go)。"
24
+ exit 1
25
+ fi
26
+
27
+ echo "[OK] 无 dws-cli 旧常量残留。"
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # task 9.2 — 校验 extension/vendorplugin/builtin/**/vendor.yaml 内 ${VAR}
3
+ # 占位符全部符合白名单(^SOKE_[A-Z0-9_]+$)。
4
+ #
5
+ # 故意写一个 ${HOME} 进 vendor.yaml 时,本步骤应当 FAIL。
6
+ #
7
+ # 用法:bash scripts/ci/check-yaml-env-whitelist.sh
8
+
9
+ set -euo pipefail
10
+
11
+ WHITELIST_RE='^SOKE_[A-Z0-9_]+$'
12
+ fail=0
13
+
14
+ for yaml in $(find extension/vendorplugin/builtin -name vendor.yaml 2>/dev/null); do
15
+ # 抽出所有 ${...} 占位符内层内容(去掉默认值部分)
16
+ vars=$(grep -oE '\$\{[^{}]+\}' "$yaml" \
17
+ | sed -E 's/^\$\{([^:}]+)(:-[^}]*)?\}$/\1/' \
18
+ | sort -u)
19
+ for v in $vars; do
20
+ if ! echo "$v" | grep -qE "$WHITELIST_RE"; then
21
+ echo "[FAIL] $yaml 引用了非白名单 env: $v"
22
+ fail=1
23
+ fi
24
+ done
25
+ done
26
+
27
+ if [ "$fail" -ne 0 ]; then
28
+ echo
29
+ echo "白名单规则:^SOKE_[A-Z0-9_]+\$(见 extension/vendorplugin/env_whitelist.go)"
30
+ exit 1
31
+ fi
32
+
33
+ echo "[OK] 所有 vendor.yaml 内 \${VAR} 占位符均符合 SOKE_* 白名单。"
@@ -107,6 +107,46 @@ function detectSkillNames(packagedSkillsDir) {
107
107
  }
108
108
  }
109
109
 
110
+ function removeManagedSkillDir(targetDir, skillName) {
111
+ const dest = path.join(targetDir, skillName);
112
+ if (!fs.existsSync(dest)) return false;
113
+ try {
114
+ fs.rmSync(dest, { recursive: true, force: true });
115
+ return true;
116
+ } catch (_) {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function pruneManagedSkillDirs(targetDir, expectedSkillNames) {
122
+ if (!fs.existsSync(targetDir)) return [];
123
+ try {
124
+ const entries = fs.readdirSync(targetDir, { withFileTypes: true });
125
+ const removed = [];
126
+ for (const entry of entries) {
127
+ if (!entry.isDirectory() || !entry.name.startsWith('soke-')) continue;
128
+ if (expectedSkillNames.includes(entry.name)) continue;
129
+ if (removeManagedSkillDir(targetDir, entry.name)) {
130
+ removed.push(entry.name);
131
+ }
132
+ }
133
+ return removed;
134
+ } catch (_) {
135
+ return [];
136
+ }
137
+ }
138
+
139
+ function pruneRegistrySkills(registry, expectedSkillNames) {
140
+ if (!registry || !Array.isArray(registry.skills)) return 0;
141
+ const before = registry.skills.length;
142
+ registry.skills = registry.skills.filter((entry) => {
143
+ if (!entry || typeof entry.name !== 'string') return true;
144
+ if (!entry.name.startsWith('soke-')) return true;
145
+ return expectedSkillNames.includes(entry.name);
146
+ });
147
+ return before - registry.skills.length;
148
+ }
149
+
110
150
  function syncSkillsToSokeclawWorkspace() {
111
151
  const packageRoot = path.join(__dirname, '..');
112
152
  const packagedSkillsDir = path.join(packageRoot, 'skills');
@@ -118,6 +158,7 @@ function syncSkillsToSokeclawWorkspace() {
118
158
  for (const targetDir of targetDirs) {
119
159
  try {
120
160
  fs.mkdirSync(targetDir, { recursive: true });
161
+ pruneManagedSkillDirs(targetDir, skillNames);
121
162
  for (const skillName of skillNames) {
122
163
  const src = path.join(packagedSkillsDir, skillName);
123
164
  const dest = path.join(targetDir, skillName);
@@ -263,6 +304,9 @@ function syncSkillsToWorkclawRegistry() {
263
304
  if (fs.existsSync(src)) copyDirRecursive(src, dest);
264
305
  }
265
306
 
307
+ pruneManagedSkillDirs(workclawSkillInstallDir, skillNames);
308
+ pruneRegistrySkills(registry, skillNames);
309
+
266
310
  // 自动注册所有 skills
267
311
  for (const skillName of skillNames) {
268
312
  const skillDir = path.join(packagedSkillsDir, skillName);
@@ -418,29 +462,19 @@ const archMap = {
418
462
  const mappedPlatform = platformMap[platform];
419
463
  const mappedArch = archMap[arch];
420
464
 
421
- if (!mappedPlatform || !mappedArch) {
422
- console.error(`不支持的平台: ${platform}-${arch}`);
423
- console.error('支持的平台: darwin-x64, darwin-arm64, linux-x64, windows-x64');
424
- process.exit(1);
425
- }
426
-
427
465
  const binaryName = platform === 'win32' ? 'soke-cli.exe' : 'soke-cli';
428
- const binaryFileName = `soke-cli-${mappedPlatform}-${mappedArch}${platform === 'win32' ? '.exe' : ''}`;
466
+ const binaryFileName = mappedPlatform && mappedArch
467
+ ? `soke-cli-${mappedPlatform}-${mappedArch}${platform === 'win32' ? '.exe' : ''}`
468
+ : '';
429
469
 
430
470
  // GitHub Releases 下载地址
431
- const downloadURL = `https://github.com/liuchenlong1111/soke-cli/releases/download/v${version}/${binaryFileName}`;
471
+ const downloadURL = binaryFileName
472
+ ? `https://github.com/liuchenlong1111/soke-cli/releases/download/v${version}/${binaryFileName}`
473
+ : '';
432
474
 
433
475
  const binDir = path.join(__dirname, '..', 'bin');
434
476
  const binaryPath = path.join(binDir, binaryName);
435
477
 
436
- console.log(`正在为 ${mappedPlatform}-${mappedArch} 下载 soke-cli v${version}...`);
437
- console.log(`下载地址: ${downloadURL}`);
438
-
439
- // 创建 bin 目录
440
- if (!fs.existsSync(binDir)) {
441
- fs.mkdirSync(binDir, { recursive: true });
442
- }
443
-
444
478
  // 下载文件
445
479
  function downloadFile(url, dest) {
446
480
  return new Promise((resolve, reject) => {
@@ -492,6 +526,19 @@ function downloadFile(url, dest) {
492
526
 
493
527
  // 执行下载
494
528
  if (require.main === module) {
529
+ if (!mappedPlatform || !mappedArch) {
530
+ console.error(`不支持的平台: ${platform}-${arch}`);
531
+ console.error('支持的平台: darwin-x64, darwin-arm64, linux-x64, windows-x64');
532
+ process.exit(1);
533
+ }
534
+
535
+ console.log(`正在为 ${mappedPlatform}-${mappedArch} 下载 soke-cli v${version}...`);
536
+ console.log(`下载地址: ${downloadURL}`);
537
+
538
+ if (!fs.existsSync(binDir)) {
539
+ fs.mkdirSync(binDir, { recursive: true });
540
+ }
541
+
495
542
  downloadFile(downloadURL, binaryPath)
496
543
  .then(() => {
497
544
  // 设置可执行权限(非 Windows 平台)
@@ -535,6 +582,10 @@ if (require.main === module) {
535
582
  }
536
583
 
537
584
  module.exports = {
585
+ detectSkillNames,
586
+ parseSkillMetadata,
587
+ pruneManagedSkillDirs,
588
+ pruneRegistrySkills,
538
589
  syncSkillsToSokeclawWorkspace,
539
590
  syncSkillsToWorkclawRegistry
540
591
  };
@@ -104,6 +104,46 @@ function detectSkillNames(packagedSkillsDir) {
104
104
  }
105
105
  }
106
106
 
107
+ function removeManagedSkillDir(targetDir, skillName) {
108
+ const skillDir = path.join(targetDir, skillName);
109
+ if (!fs.existsSync(skillDir)) return false;
110
+ try {
111
+ fs.rmSync(skillDir, { recursive: true, force: true });
112
+ return true;
113
+ } catch (_) {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function pruneManagedSkillDirs(targetDir, expectedSkillNames) {
119
+ if (!fs.existsSync(targetDir)) return [];
120
+ try {
121
+ const entries = fs.readdirSync(targetDir, { withFileTypes: true });
122
+ const removed = [];
123
+ for (const entry of entries) {
124
+ if (!entry.isDirectory() || !entry.name.startsWith('soke-')) continue;
125
+ if (expectedSkillNames.includes(entry.name)) continue;
126
+ if (removeManagedSkillDir(targetDir, entry.name)) {
127
+ removed.push(entry.name);
128
+ }
129
+ }
130
+ return removed;
131
+ } catch (_) {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ function pruneRegistrySkills(registry, expectedSkillNames) {
137
+ if (!registry || !Array.isArray(registry.skills)) return 0;
138
+ const before = registry.skills.length;
139
+ registry.skills = registry.skills.filter((entry) => {
140
+ if (!entry || typeof entry.name !== 'string') return true;
141
+ if (!entry.name.startsWith('soke-')) return true;
142
+ return expectedSkillNames.includes(entry.name);
143
+ });
144
+ return before - registry.skills.length;
145
+ }
146
+
107
147
  /**
108
148
  * 从 SKILL.md 解析元数据
109
149
  */
@@ -226,6 +266,7 @@ function detectLocalAgentDirs() {
226
266
  * 更新 workclaw registry.json
227
267
  */
228
268
  function updateWorkclawRegistry(registryPath, skillName, metadata, skillInstallPath) {
269
+ const safeMetadata = metadata && typeof metadata === 'object' ? metadata : {};
229
270
  let registry;
230
271
 
231
272
  try {
@@ -238,11 +279,11 @@ function updateWorkclawRegistry(registryPath, skillName, metadata, skillInstallP
238
279
  if (!registry.migrations) registry.migrations = {};
239
280
  if (!Array.isArray(registry.skills)) registry.skills = [];
240
281
 
241
- const displayName = metadata.summary || metadata.name || skillName;
242
- const description = metadata.description || `${displayName} - 授客AI CLI工具`;
243
- const version = metadata.version || '1.0.0';
282
+ const displayName = safeMetadata.summary || safeMetadata.name || skillName;
283
+ const description = safeMetadata.description || `${displayName} - 授客AI CLI工具`;
284
+ const version = safeMetadata.version || '1.0.0';
244
285
  const emoji = inferSkillEmoji(skillName);
245
- const requires = metadata.bins ? { bins: metadata.bins } : {};
286
+ const requires = safeMetadata.bins ? { bins: safeMetadata.bins } : {};
246
287
 
247
288
  const skillEntry = {
248
289
  id: `skill:${skillName}`,
@@ -323,6 +364,26 @@ function distributeSkill(skillName, packagedSkillsDir, targetDirs) {
323
364
  return successCount > 0;
324
365
  }
325
366
 
367
+ function reconcileTarget(target, expectedSkillNames) {
368
+ const removedDirs = pruneManagedSkillDirs(target.path, expectedSkillNames);
369
+ if (removedDirs.length > 0) {
370
+ logInfo(` 清理过期 skills: ${removedDirs.join(', ')}`);
371
+ }
372
+
373
+ if (target.type === 'workclaw' && target.registryPath && fs.existsSync(target.registryPath)) {
374
+ try {
375
+ const registry = JSON.parse(fs.readFileSync(target.registryPath, 'utf8'));
376
+ const removedEntries = pruneRegistrySkills(registry, expectedSkillNames);
377
+ if (removedEntries > 0) {
378
+ fs.writeFileSync(target.registryPath, JSON.stringify(registry, null, 2));
379
+ logInfo(` 清理 registry 过期条目: ${removedEntries}`);
380
+ }
381
+ } catch (err) {
382
+ logError(` ✗ 清理 registry 失败: ${err.message}`);
383
+ }
384
+ }
385
+ }
386
+
326
387
  /**
327
388
  * 清理所有本地环境中的 skills
328
389
  */
@@ -455,6 +516,10 @@ function main() {
455
516
  }
456
517
  }
457
518
 
519
+ for (const target of targetDirs) {
520
+ reconcileTarget(target, allSkills);
521
+ }
522
+
458
523
  // 总结
459
524
  logSection('分发完成');
460
525
 
@@ -493,11 +558,21 @@ function main() {
493
558
  console.log('');
494
559
  }
495
560
 
561
+ module.exports = {
562
+ detectSkillNames,
563
+ parseSkillMetadata,
564
+ pruneManagedSkillDirs,
565
+ pruneRegistrySkills,
566
+ updateWorkclawRegistry
567
+ };
568
+
496
569
  // 运行
497
- try {
498
- main();
499
- } catch (err) {
500
- logError(`发生错误: ${err.message}`);
501
- console.error(err);
502
- process.exit(1);
570
+ if (require.main === module) {
571
+ try {
572
+ main();
573
+ } catch (err) {
574
+ logError(`发生错误: ${err.message}`);
575
+ console.error(err);
576
+ process.exit(1);
577
+ }
503
578
  }
@@ -109,34 +109,15 @@ echo ""
109
109
 
110
110
  # 步骤3.5: 分发 Skills 到本地 Agent
111
111
  echo -e "${YELLOW}[3.5/4] 分发 Skills 到本地 Agent...${NC}"
112
- if [ "$SKIP_GLOBAL" = false ]; then
113
- echo -e " 执行 scripts/install.js 同步 Skills..."
114
-
115
- # 使用 node 调用 install.js 中导出的同步函数
116
- cat > ./scripts/sync-skills-local.js << 'EOF'
117
- const install = require('./install.js');
118
-
119
- try {
120
- console.log(" 同步到 Sokeclaw Workspace...");
121
- install.syncSkillsToSokeclawWorkspace();
122
- console.log(" ✓ Sokeclaw Workspace 同步成功");
123
- } catch (e) {
124
- console.error(" ✗ Sokeclaw Workspace 同步失败:", e.message);
125
- }
126
-
127
- try {
128
- console.log(" 同步到 Workclaw Registry...");
129
- install.syncSkillsToWorkclawRegistry();
130
- console.log(" ✓ Workclaw Registry 同步成功");
131
- } catch (e) {
132
- console.error(" ✗ Workclaw Registry 同步失败:", e.message);
133
- }
134
- EOF
135
- node ./scripts/sync-skills-local.js
136
- rm ./scripts/sync-skills-local.js
112
+ echo -e " 执行 scripts/local-test.js 同步并清理本地 Agent Skills..."
113
+ if node ./scripts/local-test.js; then
137
114
  echo -e "${GREEN} ✓ Skills 同步完成${NC}"
115
+ if [ "$SKIP_GLOBAL" = true ]; then
116
+ echo -e "${YELLOW} 提示: 已跳过全局 CLI 覆盖,但本地 Agent Skills 已按当前仓库状态完成同步${NC}"
117
+ fi
138
118
  else
139
- echo -e "${YELLOW} 跳过全局安装,同时跳过 Skills 同步${NC}"
119
+ echo -e "${RED} Skills 同步失败${NC}"
120
+ exit 1
140
121
  fi
141
122
  echo ""
142
123
 
@@ -197,4 +178,3 @@ echo ""
197
178
  echo -e "${GREEN}========================================${NC}"
198
179
  echo -e "${GREEN} 本地测试完成! ✓${NC}"
199
180
  echo -e "${GREEN}========================================${NC}"
200
-
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # mcp-stdout-scan.sh — openspec change make-soke-cli-mcp-first §10.3
3
+ #
4
+ # 静态扫描 internal/mcp/ / extension/vendor/builtin/ / internal/courseimport/ 三个
5
+ # 与 mcp stdio 帧纯净性相关的代码树,拦下任何会污染 stdout JSON-RPC 帧的调用:
6
+ # - fmt.Println / fmt.Printf / fmt.Print
7
+ # - fmt.Fprintln / Fprintf / Fprint to os.Stdout
8
+ # - os.Stdout.Write*
9
+ # - 顶层 print( / println( 内建函数
10
+ #
11
+ # 排除 _test.go (测试代码允许写 stdout);
12
+ # 通过 sed 把 `// ...` 注释剥掉再 grep,避免文档/注释里提到 fmt.Println 触发 false positive。
13
+ #
14
+ # 用法: bash scripts/mcp-stdout-scan.sh
15
+ # 退出码: 0 = 干净;1 = 有命中(并打到 stderr)。
16
+
17
+ set -e
18
+
19
+ ROOTS="internal/mcp extension/vendor/builtin internal/courseimport"
20
+ PATTERN='fmt\.(Println|Printf|Print\()|fmt\.(Fprintln|Fprintf|Fprint)\(os\.Stdout|os\.Stdout\.Write|^[[:space:]]*(print|println)\('
21
+
22
+ have_hits=0
23
+ for d in $ROOTS; do
24
+ if [ ! -d "$d" ]; then
25
+ echo "skip 不存在的目录 $d"
26
+ continue
27
+ fi
28
+ files=$(find "$d" -name '*.go' ! -name '*_test.go' 2>/dev/null)
29
+ for f in $files; do
30
+ # 把行号前缀附在文件内容上, 再用 sed 剥 // 注释 (单行注释一律剥), 再 grep
31
+ hits=$(awk '{ printf "%s:%d:%s\n", FILENAME, NR, $0 }' "$f" \
32
+ | sed 's_//.*$__' \
33
+ | grep -E "$PATTERN" \
34
+ || true)
35
+ if [ -n "$hits" ]; then
36
+ echo "❌ $f 检测到 stdout-write 调用 (会破坏 mcp stdio JSON-RPC 帧):" >&2
37
+ echo "$hits" >&2
38
+ have_hits=1
39
+ fi
40
+ done
41
+ done
42
+
43
+ if [ "$have_hits" -eq 1 ]; then
44
+ exit 1
45
+ fi
46
+ echo "✓ stdout 纯净性扫描通过"
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env bash
2
+ # 回归 auth 命令体感 — task 8.1
3
+ #
4
+ # 串行跑:
5
+ # 1. auth status --json (预期未登录)
6
+ # 2. auth login (人工:浏览器跳转 + 真账号授权)
7
+ # 3. auth status --json (预期已登录)
8
+ # 4. auth logout
9
+ # 5. auth status --json (预期未登录)
10
+ #
11
+ # 用法:
12
+ # SOKE_CLIENT_ID=... SOKE_CLIENT_SECRET=... bash scripts/regress-auth.sh
13
+ # 首次运行前 build:make install 或 go build -o bin/soke-cli main.go
14
+ #
15
+ # 输出建议用 tee 保存,事后人工对比 baseline:
16
+ # bash scripts/regress-auth.sh 2>&1 | tee /tmp/regress-after.txt
17
+ # diff /tmp/regress-before.txt /tmp/regress-after.txt
18
+
19
+ set -u
20
+
21
+ BIN="${SOKE_CLI_BIN:-./bin/soke-cli}"
22
+ if [ ! -x "$BIN" ]; then
23
+ BIN="$(command -v soke-cli 2>/dev/null || echo "$BIN")"
24
+ fi
25
+
26
+ if [ ! -x "$BIN" ]; then
27
+ echo "[FATAL] 找不到 soke-cli 二进制(尝试 SOKE_CLI_BIN env 指定路径)" >&2
28
+ exit 2
29
+ fi
30
+
31
+ run_step() {
32
+ local label="$1"; shift
33
+ echo
34
+ echo "==== [$label] $* ===="
35
+ "$@"
36
+ local rc=$?
37
+ echo "==== [$label] exit=$rc ===="
38
+ return $rc
39
+ }
40
+
41
+ # baseline 时间戳 mask:让 diff 忽略 expires_at / iat / nbf / exp 等时间字段。
42
+ mask_timestamps() {
43
+ sed -E 's/("(expires_at|iat|nbf|exp|access_token|refresh_token)":\s*)"[^"]*"/\1"<masked>"/g'
44
+ }
45
+
46
+ run_step "1.before-status" "$BIN" auth status --json | mask_timestamps
47
+ echo
48
+ echo "请在浏览器完成授权后按回车继续…"
49
+ run_step "2.login" "$BIN" auth login
50
+ read -r _
51
+ run_step "3.after-login-status" "$BIN" auth status --json | mask_timestamps
52
+ run_step "4.logout" "$BIN" auth logout
53
+ run_step "5.final-status" "$BIN" auth status --json | mask_timestamps
54
+
55
+ echo
56
+ echo "[done] 完成。请人工对比 baseline diff(/tmp/regress-before.txt)。"
@@ -0,0 +1,84 @@
1
+ # auth 回归 baseline 流程(task 8.2)
2
+
3
+ > 配套脚本:`scripts/regress-auth.sh`
4
+ > 用途:在本 change 引入前后两次跑同一脚本,diff 验证零回归。
5
+
6
+ ## 1. baseline 采样(本 change 引入**前**)
7
+
8
+ 在 master 或上一个 release tag 下:
9
+
10
+ ```bash
11
+ git checkout <last-release-tag>
12
+ make install # 或:go build -o bin/soke-cli main.go
13
+ SOKE_CLIENT_ID=... SOKE_CLIENT_SECRET=... \
14
+ bash scripts/regress-auth.sh 2>&1 | tee /tmp/regress-before.txt
15
+ ```
16
+
17
+ `/tmp/regress-before.txt` 即 baseline,包含:
18
+ - 各步 stdout/stderr
19
+ - 各步 exit code(脚本内 `==== [label] exit=N ====` 行)
20
+ - timestamps 已 mask,仅保留结构性差异
21
+
22
+ ## 2. 引入后采样
23
+
24
+ 切到本 change 分支,重新 build 跑一次:
25
+
26
+ ```bash
27
+ git checkout worktree-generalize-vendor-plugin
28
+ make install
29
+ SOKE_CLIENT_ID=... SOKE_CLIENT_SECRET=... \
30
+ bash scripts/regress-auth.sh 2>&1 | tee /tmp/regress-after.txt
31
+ ```
32
+
33
+ ## 3. diff
34
+
35
+ ```bash
36
+ diff /tmp/regress-before.txt /tmp/regress-after.txt
37
+ ```
38
+
39
+ 期望:**无差异**(允许时间戳 mask 后的剩余 token 字段差异,因每次实际签发的
40
+ token bytes 不同 —— 但 mask 应已覆盖)。
41
+
42
+ 若 diff 显示行为变化,本 change 视为引入回归,**禁止 merge**,
43
+ 回到代码层检查 ClientID() / Endpoints() / CallbackPath 取值路径。
44
+
45
+ ## 4. keychain 字段验证(任选其一,平台相关)
46
+
47
+ macOS:
48
+ ```bash
49
+ security find-generic-password -s "soke-cli" -g 2>&1 | grep -E "acct|cdat"
50
+ ```
51
+
52
+ Linux(libsecret):
53
+ ```bash
54
+ secret-tool search service soke-cli
55
+ ```
56
+
57
+ 字段名(`access_token` / `refresh_token` 等)与 baseline 完全一致。
58
+
59
+ ## 5. 三平台 matrix(task 8.3)
60
+
61
+ 本 change 不接入 GH Actions yml(避免 PR 卡在未跑通的 CI 任务),由 ①
62
+ `make-soke-cli-mcp-first` 在 same release 时一并引入。matrix 草案:
63
+
64
+ ```yaml
65
+ # .github/workflows/regress-auth.yml(草案,在 ① 同 release 一起合)
66
+ strategy:
67
+ matrix:
68
+ os: [ubuntu-latest, macos-latest, windows-latest]
69
+ jobs:
70
+ regress:
71
+ runs-on: ${{ matrix.os }}
72
+ steps:
73
+ - uses: actions/checkout@v4
74
+ - uses: actions/setup-go@v5
75
+ with: { go-version: '1.25' }
76
+ - run: |
77
+ go build -o bin/soke-cli main.go
78
+ # auth login 需要真账号,CI 内只跑 status / logout 路径
79
+ ./bin/soke-cli auth status --json
80
+ ./bin/soke-cli auth logout
81
+ ./bin/soke-cli auth status --json
82
+ ```
83
+
84
+ 跑出 CI matrix 三个 job 全绿,本 change 才视为通过 task 8.3 AC。
@@ -5,66 +5,14 @@
5
5
  */
6
6
 
7
7
  const fs = require('fs');
8
+ const os = require('os');
8
9
  const path = require('path');
9
-
10
- // 复制 detectSkillNames 函数
11
- function detectSkillNames(packagedSkillsDir) {
12
- if (!fs.existsSync(packagedSkillsDir)) return [];
13
-
14
- try {
15
- const entries = fs.readdirSync(packagedSkillsDir, { withFileTypes: true });
16
- return entries
17
- .filter(entry => entry.isDirectory() && entry.name.startsWith('soke-'))
18
- .map(entry => entry.name)
19
- .sort();
20
- } catch (_) {
21
- return [];
22
- }
23
- }
24
-
25
- // 复制 parseSkillMetadata 函数
26
- function parseSkillMetadata(skillDir) {
27
- const skillMdPath = path.join(skillDir, 'SKILL.md');
28
- if (!fs.existsSync(skillMdPath)) {
29
- return null;
30
- }
31
-
32
- try {
33
- const content = fs.readFileSync(skillMdPath, 'utf8');
34
-
35
- const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
36
- if (!frontmatterMatch) return null;
37
-
38
- const frontmatter = frontmatterMatch[1];
39
- const metadata = {};
40
-
41
- const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
42
- if (nameMatch) metadata.name = nameMatch[1].trim();
43
-
44
- const summaryMatch = frontmatter.match(/^summary:\s*(.+)$/m);
45
- if (summaryMatch) metadata.summary = summaryMatch[1].trim();
46
-
47
- const descMatch = frontmatter.match(/^description:\s*["'](.+)["']$/m);
48
- if (descMatch) {
49
- metadata.description = descMatch[1].trim();
50
- } else {
51
- const descMatch2 = frontmatter.match(/^description:\s*(.+)$/m);
52
- if (descMatch2) metadata.description = descMatch2[1].trim();
53
- }
54
-
55
- const versionMatch = frontmatter.match(/^version:\s*(.+)$/m);
56
- if (versionMatch) metadata.version = versionMatch[1].trim();
57
-
58
- const binsMatch = frontmatter.match(/bins:\s*\[(.+?)\]/);
59
- if (binsMatch) {
60
- metadata.bins = binsMatch[1].split(',').map(b => b.trim().replace(/['"]/g, ''));
61
- }
62
-
63
- return metadata;
64
- } catch (_) {
65
- return null;
66
- }
67
- }
10
+ const {
11
+ detectSkillNames,
12
+ parseSkillMetadata,
13
+ pruneManagedSkillDirs,
14
+ pruneRegistrySkills
15
+ } = require('./install.js');
68
16
 
69
17
  // 测试
70
18
  const packageRoot = path.join(__dirname, '..');
@@ -102,19 +50,13 @@ for (const skillName of skillNames) {
102
50
  // 验证结果
103
51
  console.log('📊 验证结果:\n');
104
52
 
105
- const expectedSkills = ['soke-course', 'soke-exam', 'soke-shared'];
106
- const missingSkills = expectedSkills.filter(s => !skillNames.includes(s));
107
- const extraSkills = skillNames.filter(s => !expectedSkills.includes(s));
108
-
109
- if (missingSkills.length > 0) {
110
- console.log(`❌ 缺少的 skills: ${missingSkills.join(', ')}`);
111
- } else {
112
- console.log('✅ 所有预期的 skills 都已检测到');
113
- }
114
-
115
- if (extraSkills.length > 0) {
116
- console.log(`ℹ️ 额外的 skills: ${extraSkills.join(', ')}`);
53
+ const requiredSkills = ['soke-shared'];
54
+ const missingRequiredSkills = requiredSkills.filter(s => !skillNames.includes(s));
55
+ if (missingRequiredSkills.length > 0) {
56
+ console.log(`❌ 缺少基础 skills: ${missingRequiredSkills.join(', ')}`);
57
+ process.exit(1);
117
58
  }
59
+ console.log('✅ 基础 skills 检测通过');
118
60
 
119
61
  console.log('');
120
62
  console.log('🎉 测试完成!');
@@ -123,3 +65,38 @@ console.log('💡 提示:');
123
65
  console.log(' - 新增 skill 时,只需在 skills/ 目录下创建 soke-* 目录');
124
66
  console.log(' - 确保每个 skill 都有 SKILL.md 文件,包含完整的 frontmatter');
125
67
  console.log(' - install.js 会自动检测并注册所有 skills');
68
+
69
+ // 验证清理逻辑
70
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'soke-skill-prune-'));
71
+ const targetDir = path.join(tempRoot, 'skills');
72
+ fs.mkdirSync(targetDir, { recursive: true });
73
+ fs.mkdirSync(path.join(targetDir, 'soke-stale'));
74
+ fs.mkdirSync(path.join(targetDir, 'soke-course'));
75
+ fs.mkdirSync(path.join(targetDir, 'custom-skill'));
76
+
77
+ const removedDirs = pruneManagedSkillDirs(targetDir, ['soke-course']);
78
+ if (!removedDirs.includes('soke-stale')) {
79
+ console.error('❌ pruneManagedSkillDirs 未删除过期 managed skill');
80
+ process.exit(1);
81
+ }
82
+ if (!fs.existsSync(path.join(targetDir, 'custom-skill'))) {
83
+ console.error('❌ pruneManagedSkillDirs 错误删除了非受管 skill');
84
+ process.exit(1);
85
+ }
86
+
87
+ const registry = {
88
+ skills: [
89
+ { name: 'soke-stale' },
90
+ { name: 'soke-course' },
91
+ { name: 'custom-skill' }
92
+ ]
93
+ };
94
+ const removedEntries = pruneRegistrySkills(registry, ['soke-course']);
95
+ if (removedEntries !== 1) {
96
+ console.error('❌ pruneRegistrySkills 未正确移除过期条目');
97
+ process.exit(1);
98
+ }
99
+ if (!registry.skills.find((entry) => entry.name === 'custom-skill')) {
100
+ console.error('❌ pruneRegistrySkills 错误移除了非受管条目');
101
+ process.exit(1);
102
+ }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const {
7
+ pruneManagedSkillDirs,
8
+ pruneRegistrySkills,
9
+ updateWorkclawRegistry
10
+ } = require('./local-test.js');
11
+
12
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'soke-local-reconcile-'));
13
+ const targetDir = path.join(tempRoot, 'skills');
14
+ fs.mkdirSync(targetDir, { recursive: true });
15
+
16
+ fs.mkdirSync(path.join(targetDir, 'soke-stale'));
17
+ fs.mkdirSync(path.join(targetDir, 'soke-exam'));
18
+ fs.mkdirSync(path.join(targetDir, 'custom-skill'));
19
+
20
+ const removedDirs = pruneManagedSkillDirs(targetDir, ['soke-exam']);
21
+ if (!removedDirs.includes('soke-stale')) {
22
+ console.error('❌ stale managed skill was not removed');
23
+ process.exit(1);
24
+ }
25
+ if (!fs.existsSync(path.join(targetDir, 'custom-skill'))) {
26
+ console.error('❌ unmanaged local skill should remain untouched');
27
+ process.exit(1);
28
+ }
29
+
30
+ const registry = {
31
+ skills: [
32
+ { name: 'soke-stale' },
33
+ { name: 'soke-exam' },
34
+ { name: 'custom-skill' }
35
+ ]
36
+ };
37
+ const removedEntries = pruneRegistrySkills(registry, ['soke-exam']);
38
+ if (removedEntries !== 1) {
39
+ console.error('❌ registry stale entry removal failed');
40
+ process.exit(1);
41
+ }
42
+ if (!registry.skills.find((entry) => entry.name === 'custom-skill')) {
43
+ console.error('❌ unmanaged registry entry should remain untouched');
44
+ process.exit(1);
45
+ }
46
+
47
+ const registryPath = path.join(tempRoot, 'registry.json');
48
+ updateWorkclawRegistry(registryPath, 'soke-contact', null, '/tmp/soke-contact');
49
+ const updatedRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
50
+ if (!updatedRegistry.skills.find((entry) => entry.name === 'soke-contact')) {
51
+ console.error('❌ registry update should tolerate missing metadata');
52
+ process.exit(1);
53
+ }
54
+
55
+ console.log('✅ local reconciliation tests passed');
@@ -1,319 +0,0 @@
1
- ---
2
- name: soke-exam
3
- summary: 授客考试管理(考试列表/分类/考试用户成绩/详情),通过 soke-cli 查询
4
- version: 1.0.0
5
- description: "授客考试管理:查询考试、考试用户和成绩。查询考试列表、考试分类、考试用户成绩、考试详情。当用户需要查询考试成绩、查看考试列表、查询考试用户信息、查看考试分类时使用。"
6
- requiredAuthorizations: [{"platformId":"soke-ai"}]
7
- metadata:
8
- requires:
9
- bins: ["soke-cli"]
10
- cliHelp: "soke-cli exam --help"
11
- ---
12
-
13
- # 考试管理 (exam)
14
-
15
- **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../soke-shared/SKILL.md`](../soke-shared/SKILL.md),其中包含认证、配置、权限处理**
16
-
17
- ## 核心概念
18
-
19
- - **Exam(考试)**: 考试实体,包含标题、时间范围、状态等信息,通过 `uuid` 标识
20
- - **ExamUser(考试用户)**: 用户的考试记录,包含成绩、状态、答题时间等,通过 `target_id` 标识
21
- - **Category(考试分类)**: 考试分类,支持层级结构,通过 `uuid` 标识
22
- - **DeptUser(部门用户)**: 企业内的用户,通过 `dept_user_id` 标识
23
-
24
- ## 资源关系
25
-
26
- ```
27
- Exam (考试)
28
- ├── ExamUser (考试用户记录)
29
- │ ├── dept_user_id (用户ID)
30
- │ ├── score (成绩)
31
- │ ├── exam_status (考试状态)
32
- │ └── submit_time (提交时间)
33
- └── Category (考试分类)
34
- ```
35
-
36
- ## Shortcuts(推荐优先使用)
37
-
38
- Shortcut 是对常用操作的高级封装(`soke-cli exam +<verb> [flags]`)。有 Shortcut 的操作优先使用。
39
-
40
- | Shortcut | 说明 |
41
- |----------|------|
42
- | [`+list-exams`](#list-exams) | 列出考试列表,支持时间范围和状态筛选 |
43
- | [`+list-exam-users`](#list-exam-users) | 列出考试用户成绩列表,支持用户筛选和时间范围 |
44
- | [`+get-exam-user`](#get-exam-user) | 获取单个考试用户的详细成绩信息 |
45
- | [`+list-categories`](#list-categories) | 列出考试分类 |
46
-
47
- ## 命令详解
48
-
49
- ### +list-exams
50
-
51
- 列出考试列表,支持按时间范围和状态筛选。
52
-
53
- **命令格式**:
54
- ```bash
55
- soke-cli exam +list-exams \
56
- --start-time <timestamp> \
57
- --end-time <timestamp> \
58
- [--status <status>] \
59
- [--page <page>] \
60
- [--page-size <size>]
61
- ```
62
-
63
- **参数说明**:
64
- - `--start-time`: 开始时间(Unix时间戳,毫秒)**必需**
65
- - `--end-time`: 结束时间(Unix时间戳,毫秒)**必需**
66
- - `--status`: 考试状态(可选)
67
- - `--page`: 页码,从1开始(默认: 1)
68
- - `--page-size`: 每页数量,最大100(默认: 100)
69
-
70
- **返回字段**:
71
- - `uuid`: 考试ID
72
- - `title`: 考试标题
73
- - `start_time`: 开始时间
74
- - `end_time`: 结束时间
75
- - `status`: 考试状态
76
-
77
- **示例**:
78
- ```bash
79
- # 查询2023年的所有考试
80
- soke-cli exam +list-exams \
81
- --start-time 1672502400000 \
82
- --end-time 1704038400000
83
-
84
- # 查询进行中的考试
85
- soke-cli exam +list-exams \
86
- --start-time 1672502400000 \
87
- --end-time 1704038400000 \
88
- --status "进行中"
89
- ```
90
-
91
- **权限要求**: `exam:exam:readonly`
92
-
93
- ---
94
-
95
- ### +list-exam-users
96
-
97
- 列出考试用户成绩列表,支持按用户ID和完成时间筛选。
98
-
99
- **命令格式**:
100
- ```bash
101
- soke-cli exam +list-exam-users \
102
- --exam-id <exam_id> \
103
- [--userid-list <user_ids>] \
104
- [--finish-start-time <timestamp>] \
105
- [--finish-end-time <timestamp>] \
106
- [--page <page>] \
107
- [--page-size <size>]
108
- ```
109
-
110
- **参数说明**:
111
- - `--exam-id`: 考试ID **必需**
112
- - `--userid-list`: 用户ID列表,逗号分隔,最多100个(可选)
113
- - `--finish-start-time`: 完成开始时间(Unix时间戳,毫秒)(可选)
114
- - `--finish-end-time`: 完成结束时间(Unix时间戳,毫秒)(可选)
115
- - `--page`: 页码,从1开始(默认: 1)
116
- - `--page-size`: 每页数量,最大100(默认: 100)
117
-
118
- **返回字段**:
119
- - `target_id`: 考试用户记录ID
120
- - `dept_user_id`: 部门用户ID
121
- - `score`: 成绩
122
- - `exam_status`: 考试状态
123
- - `create_time`: 创建时间
124
-
125
- **示例**:
126
- ```bash
127
- # 查询某个考试的所有用户成绩
128
- soke-cli exam +list-exam-users --exam-id exam123
129
-
130
- # 查询特定用户的成绩
131
- soke-cli exam +list-exam-users \
132
- --exam-id exam123 \
133
- --userid-list "user1,user2,user3"
134
-
135
- # 查询某个时间段内完成的考试
136
- soke-cli exam +list-exam-users \
137
- --exam-id exam123 \
138
- --finish-start-time 1672502400000 \
139
- --finish-end-time 1704038400000
140
- ```
141
-
142
- **权限要求**: `exam:examUser:readonly`
143
-
144
- ---
145
-
146
- ### +get-exam-user
147
-
148
- 获取单个考试用户的详细成绩信息,包含答题详情。
149
-
150
- **命令格式**:
151
- ```bash
152
- soke-cli exam +get-exam-user \
153
- --exam-id <exam_id> \
154
- --dept-user-id <dept_user_id>
155
- ```
156
-
157
- **参数说明**:
158
- - `--exam-id`: 考试ID **必需**
159
- - `--dept-user-id`: 部门用户ID **必需**
160
-
161
- **返回字段**:
162
- - `target_id`: 考试用户记录ID
163
- - `target_title`: 考试标题
164
- - `dept_user_id`: 部门用户ID
165
- - `score`: 成绩
166
- - `exam_status`: 考试状态
167
- - `start_time`: 开始时间
168
- - `submit_time`: 提交时间
169
- - `question_count`: 题目数量
170
- - `create_time`: 创建时间
171
-
172
- **示例**:
173
- ```bash
174
- # 查询张三的考试成绩
175
- soke-cli exam +get-exam-user \
176
- --exam-id exam123 \
177
- --dept-user-id user456
178
- ```
179
-
180
- **权限要求**: `exam:examUser:readonly`
181
-
182
- **使用场景**:
183
- - 当用户询问"查询某人的考试成绩"时使用
184
- - 需要同时提供考试ID和用户ID
185
- - 如果只知道用户名,需要先通过 `soke-cli contact +search-user` 查询用户ID
186
-
187
- ---
188
-
189
- ### +list-categories
190
-
191
- 列出考试分类,支持分页。
192
-
193
- **命令格式**:
194
- ```bash
195
- soke-cli exam +list-categories \
196
- [--page <page>] \
197
- [--page-size <size>]
198
- ```
199
-
200
- **参数说明**:
201
- - `--page`: 页码,从1开始(默认: 1)
202
- - `--page-size`: 每页数量,最大100(默认: 100)
203
-
204
- **返回字段**:
205
- - `uuid`: 分类ID
206
- - `title`: 分类名称
207
- - `parent_id`: 父分类ID
208
- - `create_time`: 创建时间
209
-
210
- **示例**:
211
- ```bash
212
- # 查询所有考试分类
213
- soke-cli exam +list-categories
214
-
215
- # 分页查询
216
- soke-cli exam +list-categories --page 1 --page-size 20
217
- ```
218
-
219
- **权限要求**: `exam:category:readonly`
220
-
221
- ## 通用API调用
222
-
223
- 如果Shortcuts不满足需求,可以使用通用API调用:
224
-
225
- ```bash
226
- soke-cli api <METHOD> <path> [--params <json>]
227
- ```
228
-
229
- 示例:
230
- ```bash
231
- soke-cli api GET /exam/exam/list --params '{"start_time":"1672502400000","end_time":"1704038400000"}'
232
- ```
233
-
234
- ## 权限表
235
-
236
- | 操作 | 所需权限 |
237
- |------|---------|
238
- | `+list-exams` | `exam:exam:readonly` |
239
- | `+list-exam-users` | `exam:examUser:readonly` |
240
- | `+get-exam-user` | `exam:examUser:readonly` |
241
- | `+list-categories` | `exam:category:readonly` |
242
-
243
- ## 常见工作流
244
-
245
- ### 工作流1: 查询用户考试成绩
246
-
247
- 当用户询问"查询张三的考试成绩"时:
248
-
249
- **步骤1**: 如果只知道用户名,先查询用户ID
250
- ```bash
251
- soke-cli contact +search-user --name "张三"
252
- ```
253
-
254
- **步骤2**: 获取考试列表,找到目标考试ID
255
- ```bash
256
- soke-cli exam +list-exams \
257
- --start-time 1672502400000 \
258
- --end-time 1704038400000
259
- ```
260
-
261
- **步骤3**: 查询该用户的考试成绩
262
- ```bash
263
- soke-cli exam +get-exam-user \
264
- --exam-id <exam_id> \
265
- --dept-user-id <dept_user_id>
266
- ```
267
-
268
- ### 工作流2: 统计考试完成情况
269
-
270
- 当用户询问"统计某个考试的完成情况"时:
271
-
272
- **步骤1**: 获取考试用户列表
273
- ```bash
274
- soke-cli exam +list-exam-users --exam-id <exam_id>
275
- ```
276
-
277
- **步骤2**: 分析返回的数据
278
- - 统计 `exam_status` 字段的分布
279
- - 计算平均分(`score` 字段)
280
- - 统计完成人数
281
-
282
- ### 工作流3: 查询某个时间段的考试
283
-
284
- 当用户询问"查询本月的考试"时:
285
-
286
- **步骤1**: 计算时间范围(Unix时间戳,毫秒)
287
- ```bash
288
- # 例如:2024年1月1日 00:00:00 = 1704038400000
289
- # 2024年1月31日 23:59:59 = 1706716799000
290
- ```
291
-
292
- **步骤2**: 查询考试列表
293
- ```bash
294
- soke-cli exam +list-exams \
295
- --start-time 1704038400000 \
296
- --end-time 1706716799000
297
- ```
298
-
299
- ## 注意事项
300
-
301
- 1. **时间格式**: 所有时间参数使用Unix时间戳(毫秒),不是秒
302
- 2. **分页**: 默认每页100条,最大100条,超过需要分页查询
303
- 3. **用户ID**: `dept_user_id` 是企业内的用户ID,不是用户名
304
- 4. **考试ID**: `exam-id` 和 `uuid` 是同一个字段,都表示考试ID
305
- 5. **权限**: 所有操作都需要先完成认证(`soke-cli auth login`)
306
-
307
- ## 错误处理
308
-
309
- ### 权限不足
310
- 如果遇到权限错误,参考 [`../soke-shared/SKILL.md`](../soke-shared/SKILL.md) 中的权限处理章节。
311
-
312
- ### 参数错误
313
- 使用 `--help` 查看命令参数说明:
314
- ```bash
315
- soke-cli exam +get-exam-user --help
316
- ```
317
-
318
- ### 数据不存在
319
- 如果查询的考试或用户不存在,API会返回空数据或错误提示。
@@ -1,212 +0,0 @@
1
- # +get-exam-user - 获取考试用户详细成绩
2
-
3
- ## 概述
4
-
5
- 获取单个用户在特定考试中的详细成绩信息,包括分数、状态、答题时间等。
6
-
7
- ## 命令格式
8
-
9
- ```bash
10
- soke-cli exam +get-exam-user \
11
- --exam-id <exam_id> \
12
- --dept-user-id <dept_user_id>
13
- ```
14
-
15
- ## 参数说明
16
-
17
- ### 必需参数
18
-
19
- | 参数 | 类型 | 说明 |
20
- |------|------|------|
21
- | `--exam-id` | string | 考试ID(uuid) |
22
- | `--dept-user-id` | string | 部门用户ID |
23
-
24
- ### 可选参数
25
-
26
- | 参数 | 类型 | 默认值 | 说明 |
27
- |------|------|--------|------|
28
- | `--format` | string | json | 输出格式(json/table) |
29
-
30
- ## 返回数据
31
-
32
- ### JSON格式
33
-
34
- ```json
35
- {
36
- "code": 0,
37
- "msg": "success",
38
- "data": {
39
- "target_id": "exam_user_123",
40
- "target_title": "2024年度安全培训考试",
41
- "dept_user_id": "user456",
42
- "score": 85,
43
- "exam_status": "已完成",
44
- "start_time": 1704038400000,
45
- "submit_time": 1704042000000,
46
- "question_count": 20,
47
- "create_time": 1704038400000
48
- }
49
- }
50
- ```
51
-
52
- ### 表格格式
53
-
54
- ```
55
- target_id | target_title | dept_user_id | score | exam_status | start_time | submit_time | question_count | create_time
56
- exam_user_123 | 2024年度安全培训考试 | user456 | 85 | 已完成 | 1704038400000 | 1704042000000 | 20 | 1704038400000
57
- ```
58
-
59
- ## 字段说明
60
-
61
- | 字段 | 类型 | 说明 |
62
- |------|------|------|
63
- | `target_id` | string | 考试用户记录ID |
64
- | `target_title` | string | 考试标题 |
65
- | `dept_user_id` | string | 部门用户ID |
66
- | `score` | number | 考试成绩(分数) |
67
- | `exam_status` | string | 考试状态(如:已完成、进行中、未开始) |
68
- | `start_time` | number | 开始答题时间(Unix时间戳,毫秒) |
69
- | `submit_time` | number | 提交时间(Unix时间戳,毫秒) |
70
- | `question_count` | number | 题目总数 |
71
- | `create_time` | number | 记录创建时间(Unix时间戳,毫秒) |
72
-
73
- ## 使用示例
74
-
75
- ### 示例1: 查询单个用户成绩
76
-
77
- ```bash
78
- soke-cli exam +get-exam-user \
79
- --exam-id exam123 \
80
- --dept-user-id user456
81
- ```
82
-
83
- ### 示例2: 以表格格式输出
84
-
85
- ```bash
86
- soke-cli exam +get-exam-user \
87
- --exam-id exam123 \
88
- --dept-user-id user456 \
89
- --format table
90
- ```
91
-
92
- ## 常见场景
93
-
94
- ### 场景1: 用户询问自己的成绩
95
-
96
- **用户输入**: "我的考试成绩是多少?"
97
-
98
- **处理步骤**:
99
- 1. 获取当前用户的 `dept_user_id`(通过 `soke-cli api GET /users/me`)
100
- 2. 确认考试ID(可能需要先列出考试)
101
- 3. 执行查询命令
102
-
103
- ```bash
104
- # 步骤1: 获取当前用户信息
105
- soke-cli api GET /users/me
106
-
107
- # 步骤2: 查询成绩
108
- soke-cli exam +get-exam-user \
109
- --exam-id exam123 \
110
- --dept-user-id <从步骤1获取的user_id>
111
- ```
112
-
113
- ### 场景2: 管理员查询员工成绩
114
-
115
- **用户输入**: "查询张三的考试成绩"
116
-
117
- **处理步骤**:
118
- 1. 通过姓名查询用户ID(使用 `soke-cli contact +search-user`)
119
- 2. 确认考试ID
120
- 3. 执行查询命令
121
-
122
- ```bash
123
- # 步骤1: 查询用户ID
124
- soke-cli contact +search-user --name "张三"
125
-
126
- # 步骤2: 查询成绩
127
- soke-cli exam +get-exam-user \
128
- --exam-id exam123 \
129
- --dept-user-id <从步骤1获取的dept_user_id>
130
- ```
131
-
132
- ### 场景3: 批量查询多个用户成绩
133
-
134
- **用户输入**: "查询所有人的考试成绩"
135
-
136
- **处理步骤**:
137
- 使用 `+list-exam-users` 更合适,可以一次获取所有用户的成绩列表。
138
-
139
- ```bash
140
- soke-cli exam +list-exam-users --exam-id exam123
141
- ```
142
-
143
- ## 权限要求
144
-
145
- - **所需权限**: `exam:examUser:readonly`
146
- - **认证方式**: 需要先执行 `soke-cli auth login` 完成用户认证
147
-
148
- ## 错误处理
149
-
150
- ### 错误1: 考试不存在
151
-
152
- ```json
153
- {
154
- "code": 404,
155
- "msg": "考试不存在"
156
- }
157
- ```
158
-
159
- **解决方案**: 检查 `exam-id` 是否正确
160
-
161
- ### 错误2: 用户未参加考试
162
-
163
- ```json
164
- {
165
- "code": 404,
166
- "msg": "用户未参加该考试"
167
- }
168
- ```
169
-
170
- **解决方案**: 确认用户是否已参加该考试
171
-
172
- ### 错误3: 权限不足
173
-
174
- ```json
175
- {
176
- "code": 403,
177
- "msg": "权限不足"
178
- }
179
- ```
180
-
181
- **解决方案**:
182
- 1. 确认已执行 `soke-cli auth login`
183
- 2. 联系管理员开通 `exam:examUser:readonly` 权限
184
-
185
- ### 错误4: 参数缺失
186
-
187
- ```bash
188
- Error: required flag(s) "exam-id", "dept-user-id" not set
189
- ```
190
-
191
- **解决方案**: 检查是否提供了所有必需参数
192
-
193
- ## API详情
194
-
195
- - **HTTP方法**: GET
196
- - **API路径**: `/exam/user/info`
197
- - **请求参数**:
198
- - `exam_id`: 考试ID
199
- - `dept_user_id`: 部门用户ID
200
-
201
- ## 相关命令
202
-
203
- - `+list-exam-users`: 列出考试用户成绩列表
204
- - `+list-exams`: 列出考试列表
205
- - `soke-cli contact +search-user`: 查询用户信息
206
-
207
- ## 注意事项
208
-
209
- 1. **时间戳格式**: 所有时间字段都是Unix时间戳(毫秒),不是秒
210
- 2. **用户ID**: 必须使用 `dept_user_id`,不能使用用户名或其他标识
211
- 3. **考试状态**: 状态值可能因系统配置而异,常见值包括:已完成、进行中、未开始、已过期
212
- 4. **成绩计算**: 成绩字段可能为null(如果考试未完成或未提交)