@wang121ye/skillmanager 0.0.4 → 0.0.6
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 +24 -0
- package/package.json +3 -2
- package/src/cli.js +3 -0
- package/src/commands/bootstrap.js +15 -41
- package/src/commands/uninstall.js +27 -9
- package/src/commands/update.js +1 -1
- package/src/commands/webui.js +1 -1
- package/src/lib/cli-select.js +200 -0
- package/src/lib/git.js +69 -5
package/README.md
CHANGED
|
@@ -30,6 +30,21 @@ skillmanager webui
|
|
|
30
30
|
skillmanager webui --concurrency 1
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
### 缓存自动刷新(避免“只看到旧的 skills”)
|
|
34
|
+
|
|
35
|
+
为避免第三方用户忘记更新缓存,`skillmanager` 在扫描来源仓库时会检查本地缓存是否落后于远端:
|
|
36
|
+
|
|
37
|
+
- 如果检测到缓存落后,默认会**自动重新拉取**最新仓库。
|
|
38
|
+
- 如需手动控制,可使用 `--force-refresh` 强制刷新,或设置 `SKILLMANAGER_AUTO_REFRESH=0` 关闭自动刷新。
|
|
39
|
+
|
|
40
|
+
示例:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
skillmanager webui --force-refresh
|
|
44
|
+
skillmanager install --force-refresh
|
|
45
|
+
skillmanager update --force-refresh
|
|
46
|
+
```
|
|
47
|
+
|
|
33
48
|
## 安装与使用
|
|
34
49
|
|
|
35
50
|
### 全局安装(推荐)
|
|
@@ -56,6 +71,15 @@ npx @wang121ye/skillmanager install
|
|
|
56
71
|
skillmanager install
|
|
57
72
|
```
|
|
58
73
|
|
|
74
|
+
### 命令行交互式选择(类似 openskills)
|
|
75
|
+
|
|
76
|
+
默认 `skillmanager install` 会进入交互式选择(按来源分组)。支持:
|
|
77
|
+
- 空格选择/取消
|
|
78
|
+
- a 全选,i 反选
|
|
79
|
+
- h 顶部,e 底部
|
|
80
|
+
- [ / ] 切换分组
|
|
81
|
+
- Esc 退出,Enter 确认
|
|
82
|
+
|
|
59
83
|
### 2) 安装时启用 Web UI 选择(默认全选,可批量全选/全不选/反选/搜索)
|
|
60
84
|
|
|
61
85
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wang121ye/skillmanager",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Cross-platform agent skill manager (bootstrap + optional Web UI selection) built on top of openskills.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"skillmanager",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"gray-matter": "^4.0.3",
|
|
46
46
|
"openskills": "^1.5.0",
|
|
47
47
|
"simple-git": "^3.27.0",
|
|
48
|
-
"undici": "^6.23.0"
|
|
48
|
+
"undici": "^6.23.0",
|
|
49
|
+
"yoctocolors-cjs": "^2.1.3"
|
|
49
50
|
}
|
|
50
51
|
}
|
package/src/cli.js
CHANGED
|
@@ -30,6 +30,7 @@ async function main() {
|
|
|
30
30
|
.option('--dry-run', '仅打印将执行的内容,不实际安装', false)
|
|
31
31
|
.option('--concurrency <n>', '选择模式下的并发扫描数(默认:3)', '3')
|
|
32
32
|
.option('--profile <name>', '选择配置名(默认:来自 config 或 SKILLMANAGER_PROFILE 环境变量)')
|
|
33
|
+
.option('--force-refresh', '强制刷新来源仓库缓存(重新拉取)', false)
|
|
33
34
|
.action(async (opts) => {
|
|
34
35
|
await bootstrap(opts);
|
|
35
36
|
});
|
|
@@ -44,6 +45,7 @@ async function main() {
|
|
|
44
45
|
.option('--output <path>', '作用:sync 输出文件路径(默认:AGENTS.md)')
|
|
45
46
|
.option('--no-sync', '作用:不执行 openskills sync(仅安装/卸载,不更新 AGENTS.md)', false)
|
|
46
47
|
.option('--concurrency <n>', '作用:install 模式下并发拉取/扫描来源仓库,提高速度(默认:3)', '3')
|
|
48
|
+
.option('--force-refresh', '强制刷新来源仓库缓存(重新拉取)', false)
|
|
47
49
|
.action(async (opts) => {
|
|
48
50
|
await webui(opts);
|
|
49
51
|
});
|
|
@@ -64,6 +66,7 @@ async function main() {
|
|
|
64
66
|
.option('--no-sync', '跳过 openskills sync', false)
|
|
65
67
|
.option('--profile <name>', '按 profile 选择集更新(会重新安装选中的 skills)')
|
|
66
68
|
.option('--concurrency <n>', '选择模式下的并发扫描数(默认:3)', '3')
|
|
69
|
+
.option('--force-refresh', '强制刷新来源仓库缓存(重新拉取)', false)
|
|
67
70
|
.action(async (opts) => {
|
|
68
71
|
await update(opts);
|
|
69
72
|
});
|
|
@@ -3,7 +3,7 @@ const { ensureDir } = require('../lib/fs');
|
|
|
3
3
|
const { loadSourcesManifest } = require('../lib/manifest');
|
|
4
4
|
const { ensureRepo } = require('../lib/git');
|
|
5
5
|
const { scanSkillsInRepo } = require('../lib/scan');
|
|
6
|
-
const { loadProfile } = require('../lib/profiles');
|
|
6
|
+
const { loadProfile, saveProfile } = require('../lib/profiles');
|
|
7
7
|
const { mapWithConcurrency } = require('../lib/concurrency');
|
|
8
8
|
const { getEffectiveDefaultProfile } = require('../lib/config');
|
|
9
9
|
const path = require('path');
|
|
@@ -11,6 +11,7 @@ const os = require('os');
|
|
|
11
11
|
const { installSourceRef, syncAgents } = require('../lib/openskills');
|
|
12
12
|
const { installFromLocalSkillDir } = require('../lib/local-install');
|
|
13
13
|
const { warnPrereqs } = require('../lib/prereqs');
|
|
14
|
+
const { promptSkillSelection } = require('../lib/cli-select');
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
function uniq(arr) {
|
|
@@ -28,47 +29,9 @@ async function bootstrap(opts) {
|
|
|
28
29
|
|
|
29
30
|
const profileName = opts?.profile || (await getEffectiveDefaultProfile());
|
|
30
31
|
const existing = await loadProfile({ profilesDir: paths.profilesDir, profileName });
|
|
31
|
-
|
|
32
|
-
const wantsSelection = existing?.selectedSkillIds && Array.isArray(existing.selectedSkillIds);
|
|
33
32
|
const globalInstall = !!opts?.global;
|
|
34
33
|
const universal = !!opts?.universal;
|
|
35
34
|
|
|
36
|
-
if (!wantsSelection) {
|
|
37
|
-
// Default path: install everything from each enabled source via openskills directly.
|
|
38
|
-
// This avoids repo scanning and matches “bootstrap 安装所有 skills”的默认体验。
|
|
39
|
-
// eslint-disable-next-line no-console
|
|
40
|
-
console.log(`将从 ${enabledSources.length} 个来源安装(global=${globalInstall}, universal=${universal})…`);
|
|
41
|
-
|
|
42
|
-
if (opts?.dryRun) {
|
|
43
|
-
// eslint-disable-next-line no-console
|
|
44
|
-
console.log('\n--dry-run 已启用:将安装以下来源(不执行安装)');
|
|
45
|
-
for (const s of enabledSources) {
|
|
46
|
-
const ref = s.openskillsRef || s.repo;
|
|
47
|
-
// eslint-disable-next-line no-console
|
|
48
|
-
console.log(`- ${s.name || s.id} (${ref || 'missing-ref'})`);
|
|
49
|
-
}
|
|
50
|
-
// eslint-disable-next-line no-console
|
|
51
|
-
console.log('\n完成(dry-run)。');
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
for (const s of enabledSources) {
|
|
56
|
-
const ref = s.openskillsRef || s.repo;
|
|
57
|
-
if (!ref) continue;
|
|
58
|
-
// eslint-disable-next-line no-console
|
|
59
|
-
console.log(`\n==> Installing source: ${s.name || s.id} (${ref})`);
|
|
60
|
-
await installSourceRef({ ref, globalInstall, universal });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (opts?.sync !== false) {
|
|
64
|
-
await syncAgents({ output: opts?.output, cwd: process.cwd() });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// eslint-disable-next-line no-console
|
|
68
|
-
console.log('\n完成。');
|
|
69
|
-
return;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
35
|
// Selection path: clone repos + scan SKILL.md, then install selected skill dirs.
|
|
73
36
|
const concurrency = Number(opts?.concurrency || process.env.SKILLMANAGER_CONCURRENCY || 3);
|
|
74
37
|
// eslint-disable-next-line no-console
|
|
@@ -77,7 +40,7 @@ async function bootstrap(opts) {
|
|
|
77
40
|
const skillsById = new Map();
|
|
78
41
|
const perSource = await mapWithConcurrency(enabledSources, concurrency, async (s) => {
|
|
79
42
|
try {
|
|
80
|
-
const repoDir = await ensureRepo({ reposDir: paths.reposDir, source: s });
|
|
43
|
+
const repoDir = await ensureRepo({ reposDir: paths.reposDir, source: s, forceRefresh: !!opts?.forceRefresh });
|
|
81
44
|
const skills = await scanSkillsInRepo({
|
|
82
45
|
sourceId: s.id,
|
|
83
46
|
sourceName: s.name || s.id,
|
|
@@ -103,7 +66,18 @@ async function bootstrap(opts) {
|
|
|
103
66
|
? existing.selectedSkillIds
|
|
104
67
|
: allSkills.map((s) => s.id);
|
|
105
68
|
|
|
106
|
-
|
|
69
|
+
const chosen = await promptSkillSelection({
|
|
70
|
+
title: `skillmanager install · profile=${profileName}`,
|
|
71
|
+
skills: allSkills,
|
|
72
|
+
initialSelectedIds: selectedIds
|
|
73
|
+
});
|
|
74
|
+
if (chosen == null) {
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.log('已取消(未执行安装)。');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
selectedIds = chosen;
|
|
80
|
+
await saveProfile({ profilesDir: paths.profilesDir, profileName, selectedSkillIds: selectedIds });
|
|
107
81
|
|
|
108
82
|
selectedIds = uniq(selectedIds).filter((id) => skillsById.has(id));
|
|
109
83
|
|
|
@@ -4,6 +4,7 @@ const fsp = require('fs/promises');
|
|
|
4
4
|
|
|
5
5
|
const { listInstalledSkills } = require('../lib/installed');
|
|
6
6
|
const { syncAgents } = require('../lib/openskills');
|
|
7
|
+
const { promptSkillSelection } = require('../lib/cli-select');
|
|
7
8
|
|
|
8
9
|
function resolveTargetDir({ globalInstall, universal }) {
|
|
9
10
|
const folder = universal ? '.agent/skills' : '.claude/skills';
|
|
@@ -18,10 +19,35 @@ async function uninstall(opts, skillNames) {
|
|
|
18
19
|
const installed = await listInstalledSkills(targetDir);
|
|
19
20
|
const installedByName = new Map(installed.map((s) => [s.name, s]));
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
if (installed.length === 0) {
|
|
23
|
+
// eslint-disable-next-line no-console
|
|
24
|
+
console.log(`未检测到可卸载的 skill。目标目录:${targetDir}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
22
27
|
|
|
28
|
+
let toRemove = [];
|
|
23
29
|
if (opts?.all) {
|
|
24
30
|
toRemove = installed.map((s) => s.name);
|
|
31
|
+
} else {
|
|
32
|
+
const initialSelected = Array.isArray(skillNames) ? skillNames.filter(Boolean) : [];
|
|
33
|
+
const chosen = await promptSkillSelection({
|
|
34
|
+
title: 'skillmanager uninstall',
|
|
35
|
+
skills: installed.map((s) => ({
|
|
36
|
+
id: s.name,
|
|
37
|
+
sourceId: 'installed',
|
|
38
|
+
sourceName: 'Installed',
|
|
39
|
+
name: s.name,
|
|
40
|
+
description: s.description,
|
|
41
|
+
skillDir: s.skillDir
|
|
42
|
+
})),
|
|
43
|
+
initialSelectedIds: initialSelected
|
|
44
|
+
});
|
|
45
|
+
if (chosen == null) {
|
|
46
|
+
// eslint-disable-next-line no-console
|
|
47
|
+
console.log('已取消(未执行卸载)。');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
toRemove = chosen;
|
|
25
51
|
}
|
|
26
52
|
|
|
27
53
|
toRemove = Array.from(new Set(toRemove)).filter((n) => installedByName.has(n));
|
|
@@ -29,14 +55,6 @@ async function uninstall(opts, skillNames) {
|
|
|
29
55
|
if (toRemove.length === 0) {
|
|
30
56
|
// eslint-disable-next-line no-console
|
|
31
57
|
console.log(`未选择任何可卸载的 skill。目标目录:${targetDir}`);
|
|
32
|
-
// eslint-disable-next-line no-console
|
|
33
|
-
console.log('用法:');
|
|
34
|
-
// eslint-disable-next-line no-console
|
|
35
|
-
console.log(' - skillmanager webui --mode uninstall');
|
|
36
|
-
// eslint-disable-next-line no-console
|
|
37
|
-
console.log(' - skillmanager uninstall <skill1> <skill2>');
|
|
38
|
-
// eslint-disable-next-line no-console
|
|
39
|
-
console.log(' - skillmanager uninstall --all');
|
|
40
58
|
return;
|
|
41
59
|
}
|
|
42
60
|
|
package/src/commands/update.js
CHANGED
|
@@ -53,7 +53,7 @@ async function update(opts) {
|
|
|
53
53
|
const skillsById = new Map();
|
|
54
54
|
const perSource = await mapWithConcurrency(enabledSources, concurrency, async (s) => {
|
|
55
55
|
try {
|
|
56
|
-
const repoDir = await ensureRepo({ reposDir: paths.reposDir, source: s });
|
|
56
|
+
const repoDir = await ensureRepo({ reposDir: paths.reposDir, source: s, forceRefresh: !!opts?.forceRefresh });
|
|
57
57
|
const skills = await scanSkillsInRepo({
|
|
58
58
|
sourceId: s.id,
|
|
59
59
|
sourceName: s.name || s.id,
|
package/src/commands/webui.js
CHANGED
|
@@ -91,7 +91,7 @@ async function webui(opts) {
|
|
|
91
91
|
const skillsById = new Map();
|
|
92
92
|
const perSource = await mapWithConcurrency(enabledSources, concurrency, async (s) => {
|
|
93
93
|
try {
|
|
94
|
-
const repoDir = await ensureRepo({ reposDir: paths.reposDir, source: s });
|
|
94
|
+
const repoDir = await ensureRepo({ reposDir: paths.reposDir, source: s, forceRefresh: !!opts?.forceRefresh });
|
|
95
95
|
const skills = await scanSkillsInRepo({
|
|
96
96
|
sourceId: s.id,
|
|
97
97
|
sourceName: s.name || s.id,
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createPrompt,
|
|
3
|
+
useState,
|
|
4
|
+
useKeypress,
|
|
5
|
+
usePagination,
|
|
6
|
+
usePrefix,
|
|
7
|
+
isUpKey,
|
|
8
|
+
isDownKey,
|
|
9
|
+
isSpaceKey,
|
|
10
|
+
isEnterKey
|
|
11
|
+
} = require('@inquirer/core');
|
|
12
|
+
const colors = require('yoctocolors-cjs');
|
|
13
|
+
|
|
14
|
+
function groupSkills(skills) {
|
|
15
|
+
const bySource = new Map();
|
|
16
|
+
for (const skill of skills) {
|
|
17
|
+
const key = skill.sourceName || skill.sourceId || 'unknown';
|
|
18
|
+
if (!bySource.has(key)) bySource.set(key, []);
|
|
19
|
+
bySource.get(key).push(skill);
|
|
20
|
+
}
|
|
21
|
+
for (const [key, list] of bySource.entries()) {
|
|
22
|
+
list.sort((a, b) => a.name.localeCompare(b.name));
|
|
23
|
+
bySource.set(key, list);
|
|
24
|
+
}
|
|
25
|
+
return Array.from(bySource.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildItems(skills) {
|
|
29
|
+
const grouped = groupSkills(skills);
|
|
30
|
+
const items = [];
|
|
31
|
+
let groupIndex = 0;
|
|
32
|
+
for (const [sourceName, list] of grouped) {
|
|
33
|
+
items.push({ type: 'separator', label: `-- ${sourceName} --`, groupIndex });
|
|
34
|
+
for (const skill of list) {
|
|
35
|
+
items.push({
|
|
36
|
+
type: 'choice',
|
|
37
|
+
id: skill.id,
|
|
38
|
+
name: skill.name,
|
|
39
|
+
description: skill.description || '',
|
|
40
|
+
groupIndex
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
groupIndex += 1;
|
|
44
|
+
}
|
|
45
|
+
return items;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getSelectableIndexes(items) {
|
|
49
|
+
const indexes = [];
|
|
50
|
+
for (let i = 0; i < items.length; i++) {
|
|
51
|
+
if (items[i].type === 'choice') indexes.push(i);
|
|
52
|
+
}
|
|
53
|
+
return indexes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findNextSelectable(items, startIndex, direction) {
|
|
57
|
+
const step = direction >= 0 ? 1 : -1;
|
|
58
|
+
for (let i = startIndex + step; i >= 0 && i < items.length; i += step) {
|
|
59
|
+
if (items[i].type === 'choice') return i;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function findGroupStart(items, groupIndex) {
|
|
65
|
+
for (let i = 0; i < items.length; i++) {
|
|
66
|
+
if (items[i].type === 'choice' && items[i].groupIndex === groupIndex) return i;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const promptSkillSelection = createPrompt((config, done) => {
|
|
72
|
+
const items = buildItems(config.skills || []);
|
|
73
|
+
const selectableIndexes = getSelectableIndexes(items);
|
|
74
|
+
const firstSelectable = selectableIndexes[0] ?? 0;
|
|
75
|
+
const lastSelectable = selectableIndexes[selectableIndexes.length - 1] ?? 0;
|
|
76
|
+
const [activeIndex, setActiveIndex] = useState(firstSelectable);
|
|
77
|
+
const [status, setStatus] = useState('idle');
|
|
78
|
+
const [selectedSet, setSelectedSet] = useState(
|
|
79
|
+
new Set(Array.isArray(config.initialSelectedIds) ? config.initialSelectedIds : [])
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const moveToIndex = (idx) => {
|
|
83
|
+
if (idx == null) return;
|
|
84
|
+
if (items[idx]?.type !== 'choice') return;
|
|
85
|
+
setActiveIndex(idx);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const moveBy = (dir) => {
|
|
89
|
+
const next = findNextSelectable(items, activeIndex, dir);
|
|
90
|
+
if (next != null) {
|
|
91
|
+
setActiveIndex(next);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// loop to start/end
|
|
95
|
+
setActiveIndex(dir > 0 ? firstSelectable : lastSelectable);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const moveGroup = (dir) => {
|
|
99
|
+
const currentGroup = items[activeIndex]?.groupIndex ?? 0;
|
|
100
|
+
const maxGroup = Math.max(0, ...items.map((i) => i.groupIndex ?? 0));
|
|
101
|
+
const nextGroup = dir > 0 ? (currentGroup + 1 > maxGroup ? 0 : currentGroup + 1) : currentGroup - 1 < 0 ? maxGroup : currentGroup - 1;
|
|
102
|
+
moveToIndex(findGroupStart(items, nextGroup));
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const toggleActive = () => {
|
|
106
|
+
const item = items[activeIndex];
|
|
107
|
+
if (!item || item.type !== 'choice') return;
|
|
108
|
+
const next = new Set(selectedSet);
|
|
109
|
+
if (next.has(item.id)) next.delete(item.id);
|
|
110
|
+
else next.add(item.id);
|
|
111
|
+
setSelectedSet(next);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const selectAll = () => {
|
|
115
|
+
const next = new Set(selectedSet);
|
|
116
|
+
for (const idx of selectableIndexes) next.add(items[idx].id);
|
|
117
|
+
setSelectedSet(next);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const invertAll = () => {
|
|
121
|
+
const next = new Set();
|
|
122
|
+
for (const idx of selectableIndexes) {
|
|
123
|
+
const id = items[idx].id;
|
|
124
|
+
if (!selectedSet.has(id)) next.add(id);
|
|
125
|
+
}
|
|
126
|
+
setSelectedSet(next);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
useKeypress((key) => {
|
|
130
|
+
const keyName = key?.name || key?.sequence;
|
|
131
|
+
if (isUpKey(key)) return moveBy(-1);
|
|
132
|
+
if (isDownKey(key)) return moveBy(1);
|
|
133
|
+
if (isSpaceKey(key)) return toggleActive();
|
|
134
|
+
if (isEnterKey(key)) {
|
|
135
|
+
setStatus('done');
|
|
136
|
+
return done(Array.from(selectedSet));
|
|
137
|
+
}
|
|
138
|
+
if (keyName === 'escape') {
|
|
139
|
+
setStatus('done');
|
|
140
|
+
return done(null);
|
|
141
|
+
}
|
|
142
|
+
if (keyName === 'a') return selectAll();
|
|
143
|
+
if (keyName === 'i') return invertAll();
|
|
144
|
+
if (keyName === 'h') return moveToIndex(firstSelectable);
|
|
145
|
+
if (keyName === 'e') return moveToIndex(lastSelectable);
|
|
146
|
+
if (keyName === '[') return moveGroup(-1);
|
|
147
|
+
if (keyName === ']') return moveGroup(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const prefix = usePrefix({ status });
|
|
151
|
+
const titleText = config.title || config.message || 'Select skills to install';
|
|
152
|
+
const total = selectableIndexes.length;
|
|
153
|
+
const selected = selectedSet.size;
|
|
154
|
+
const counter = colors.dim(`${selected} / ${total} selected`);
|
|
155
|
+
const message = `${colors.bold(titleText)} ${counter}`;
|
|
156
|
+
const activeItem = items[activeIndex];
|
|
157
|
+
const desc =
|
|
158
|
+
activeItem?.type === 'choice' && activeItem.description
|
|
159
|
+
? `\n${colors.yellow(activeItem.description.trim())}`
|
|
160
|
+
: '';
|
|
161
|
+
|
|
162
|
+
const page = usePagination({
|
|
163
|
+
items,
|
|
164
|
+
active: activeIndex,
|
|
165
|
+
pageSize: config.pageSize || 18,
|
|
166
|
+
loop: false,
|
|
167
|
+
renderItem: ({ item, index, isActive }) => {
|
|
168
|
+
if (item.type === 'separator') {
|
|
169
|
+
return `\n ${colors.bold(colors.cyan(item.label))}`;
|
|
170
|
+
}
|
|
171
|
+
const cursor = isActive ? colors.cyan('>') : ' ';
|
|
172
|
+
const isChecked = selectedSet.has(item.id);
|
|
173
|
+
const dot = isChecked ? colors.green('●') : colors.dim('○');
|
|
174
|
+
const label = isChecked ? colors.green(item.name) : isActive ? colors.white(item.name) : colors.dim(item.name);
|
|
175
|
+
return `${cursor} ${dot} ${label}`;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const help =
|
|
180
|
+
'\n' +
|
|
181
|
+
colors.dim('↑↓ 选择 · 空格 勾选 · a 全选 · i 反选 · h 顶部 · e 底部 · [ ] 切组 · Esc 退出 · ⏎ 确认');
|
|
182
|
+
|
|
183
|
+
if (status === 'done') {
|
|
184
|
+
return `${prefix} ${colors.bold(titleText)} ${colors.green(`✔ ${selected} skills selected`)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return `${prefix} ${message}\n${page}${desc}${help}`;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
async function promptSkillSelectionWrapper({ title, skills, initialSelectedIds }) {
|
|
191
|
+
const chosen = await promptSkillSelection({
|
|
192
|
+
title,
|
|
193
|
+
skills,
|
|
194
|
+
initialSelectedIds
|
|
195
|
+
});
|
|
196
|
+
if (chosen == null) return null;
|
|
197
|
+
return Array.isArray(chosen) ? chosen : [];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { promptSkillSelection: promptSkillSelectionWrapper };
|
package/src/lib/git.js
CHANGED
|
@@ -10,7 +10,53 @@ function sleep(ms) {
|
|
|
10
10
|
return new Promise((r) => setTimeout(r, ms));
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
function isTruthy(value) {
|
|
14
|
+
const v = String(value || '').trim().toLowerCase();
|
|
15
|
+
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function shouldAutoRefreshCache() {
|
|
19
|
+
// 默认开启自动刷新(发现远端更新时自动重新拉取)
|
|
20
|
+
// 如需关闭:SKILLMANAGER_AUTO_REFRESH=0
|
|
21
|
+
if (process.env.SKILLMANAGER_AUTO_REFRESH != null) {
|
|
22
|
+
return isTruthy(process.env.SKILLMANAGER_AUTO_REFRESH);
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function getLocalHead(repoGit) {
|
|
28
|
+
try {
|
|
29
|
+
const out = await repoGit.revparse(['HEAD']);
|
|
30
|
+
return String(out || '').trim() || null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function getRemoteHead(repoGit) {
|
|
37
|
+
try {
|
|
38
|
+
const out = await repoGit.raw(['ls-remote', 'origin', 'HEAD']);
|
|
39
|
+
const first = String(out || '').trim().split(/\s+/)[0];
|
|
40
|
+
return first || null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function isRepoStale(repoGit) {
|
|
47
|
+
const [localHead, remoteHead] = await Promise.all([getLocalHead(repoGit), getRemoteHead(repoGit)]);
|
|
48
|
+
if (!localHead || !remoteHead) return false;
|
|
49
|
+
return localHead !== remoteHead;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function cloneRepo({ git, repoUrl, repoDir, cloneArgs }) {
|
|
53
|
+
await rmDir(repoDir);
|
|
54
|
+
await ensureDir(repoDir);
|
|
55
|
+
await git.clone(repoUrl, repoDir, cloneArgs);
|
|
56
|
+
return repoDir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function ensureRepo({ reposDir, source, forceRefresh = false }) {
|
|
14
60
|
if (!source?.repo) {
|
|
15
61
|
throw new Error(`Invalid source repo for ${source?.id || '<unknown>'}`);
|
|
16
62
|
}
|
|
@@ -20,14 +66,12 @@ async function ensureRepo({ reposDir, source }) {
|
|
|
20
66
|
|
|
21
67
|
const gitDir = path.join(repoDir, '.git');
|
|
22
68
|
const git = simpleGit();
|
|
69
|
+
const cloneArgs = ['--depth', '1', '--single-branch', '--filter=blob:none'];
|
|
23
70
|
|
|
24
71
|
if (!(await fileExists(gitDir))) {
|
|
25
|
-
const cloneArgs = ['--depth', '1', '--single-branch', '--filter=blob:none'];
|
|
26
72
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
27
73
|
try {
|
|
28
|
-
await
|
|
29
|
-
await ensureDir(repoDir);
|
|
30
|
-
await git.clone(source.repo, repoDir, cloneArgs);
|
|
74
|
+
await cloneRepo({ git, repoUrl: source.repo, repoDir, cloneArgs });
|
|
31
75
|
return repoDir;
|
|
32
76
|
} catch (err) {
|
|
33
77
|
await rmDir(repoDir);
|
|
@@ -50,6 +94,26 @@ async function ensureRepo({ reposDir, source }) {
|
|
|
50
94
|
} catch {
|
|
51
95
|
// ignore ff-only failures (e.g. detached HEAD) — user can clean manually
|
|
52
96
|
}
|
|
97
|
+
|
|
98
|
+
// 如果缓存仓库落后于远端,自动重新拉取(可用 env 关闭)
|
|
99
|
+
try {
|
|
100
|
+
const stale = await isRepoStale(repoGit);
|
|
101
|
+
if (stale) {
|
|
102
|
+
const shouldRefresh = forceRefresh || shouldAutoRefreshCache();
|
|
103
|
+
if (shouldRefresh) {
|
|
104
|
+
// eslint-disable-next-line no-console
|
|
105
|
+
console.warn(`检测到缓存仓库落后于远端:${source.id},正在重新拉取最新版本…`);
|
|
106
|
+
return await cloneRepo({ git, repoUrl: source.repo, repoDir, cloneArgs });
|
|
107
|
+
}
|
|
108
|
+
// eslint-disable-next-line no-console
|
|
109
|
+
console.warn(`检测到缓存仓库落后于远端:${source.id}。可使用 --force-refresh 或设置 SKILLMANAGER_AUTO_REFRESH=1。`);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// eslint-disable-next-line no-console
|
|
113
|
+
console.warn(
|
|
114
|
+
`提示:无法检查远端版本(可能网络/权限问题),缓存可能过期。可使用 --force-refresh 强制刷新,或删除 ${repoDir} 后重试。`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
53
117
|
return repoDir;
|
|
54
118
|
}
|
|
55
119
|
|