skillcaddy 0.27.0

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.
Files changed (99) hide show
  1. package/CHANGELOG.md +497 -0
  2. package/CONTEXT.md +149 -0
  3. package/LICENSE +21 -0
  4. package/README.md +499 -0
  5. package/README_CN.md +482 -0
  6. package/collection-metadata/github/mattpocock.json +16 -0
  7. package/lib/canonicalJson.js +11 -0
  8. package/lib/claudeStore.js +135 -0
  9. package/lib/collectionEnablement.js +115 -0
  10. package/lib/collectionSetup.js +199 -0
  11. package/lib/enablePlan.js +116 -0
  12. package/lib/enablementScope.js +8 -0
  13. package/lib/hermesStore.js +21 -0
  14. package/lib/libraryImage.js +1785 -0
  15. package/lib/managerInstall.js +109 -0
  16. package/lib/managerSourceWorkflow.js +234 -0
  17. package/lib/projectActions.js +24 -0
  18. package/lib/projectPath.js +51 -0
  19. package/lib/roots.js +99 -0
  20. package/lib/skillLinkStore.js +68 -0
  21. package/lib/skillMetadata.js +191 -0
  22. package/lib/skillStore.js +541 -0
  23. package/lib/sourceAcquisition.js +475 -0
  24. package/lib/sourceAcquisitionAdapter.js +47 -0
  25. package/lib/sourceAcquisitionError.js +18 -0
  26. package/lib/sourceAcquisitionPolicy.js +139 -0
  27. package/lib/sourceArchive.js +363 -0
  28. package/lib/sourceArchiveWorkspace.js +36 -0
  29. package/lib/sourceGit.js +95 -0
  30. package/lib/sourceGitAcquisitionAdapter.js +145 -0
  31. package/lib/sourceGitBatchUpdate.js +168 -0
  32. package/lib/sourceGitRepair.js +227 -0
  33. package/lib/sourceGitStaging.js +139 -0
  34. package/lib/sourceGitUpgradeAdapter.js +583 -0
  35. package/lib/sourceGitUrl.js +170 -0
  36. package/lib/sourceHttp.js +302 -0
  37. package/lib/sourceLocal.js +200 -0
  38. package/lib/sourceLocalAcquisitionAdapter.js +107 -0
  39. package/lib/sourceManager.js +242 -0
  40. package/lib/sourceMigration.js +172 -0
  41. package/lib/sourceMigrationIssue.js +6 -0
  42. package/lib/sourcePath.js +6 -0
  43. package/lib/sourcePolicy.js +53 -0
  44. package/lib/sourceRecord.js +295 -0
  45. package/lib/sourceRegistry.js +155 -0
  46. package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
  47. package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
  48. package/lib/sourceRemoteFilePolicy.js +62 -0
  49. package/lib/sourceRemoteFileWorkspace.js +55 -0
  50. package/lib/sourceSkillDiscovery.js +117 -0
  51. package/lib/sourceTree.js +185 -0
  52. package/lib/sourceUpgrade.js +596 -0
  53. package/lib/sourceUpgradeAdapter.js +41 -0
  54. package/lib/sourceUpgradePolicy.js +170 -0
  55. package/lib/sourceUpgradeTransaction.js +202 -0
  56. package/lib/sourceValidation.js +120 -0
  57. package/lib/sourceWorkspace.js +37 -0
  58. package/lib/specGate.js +235 -0
  59. package/lib/tuiActions.js +169 -0
  60. package/lib/tuiInstall.js +133 -0
  61. package/lib/tuiLayout.js +94 -0
  62. package/lib/version.js +17 -0
  63. package/lib/webManager.js +370 -0
  64. package/package.json +74 -0
  65. package/public/agentsUi.js +138 -0
  66. package/public/app.js +1431 -0
  67. package/public/claude.css +2 -0
  68. package/public/claudeUi.js +119 -0
  69. package/public/emptyState.js +12 -0
  70. package/public/enabled.css +303 -0
  71. package/public/favicon.svg +5 -0
  72. package/public/index.html +199 -0
  73. package/public/skillcaddy_CN.png +0 -0
  74. package/public/skillcaddy_EN.png +0 -0
  75. package/public/styles.css +1611 -0
  76. package/scripts/cli.js +647 -0
  77. package/scripts/cron-sync-update-git.sh +128 -0
  78. package/scripts/pull-github.sh +22 -0
  79. package/scripts/source.js +638 -0
  80. package/scripts/tui.js +1058 -0
  81. package/server.js +196 -0
  82. package/skills/skillcaddy-manager/SKILL.md +93 -0
  83. package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
  84. package/skills/skillcaddy-manager/references/METADATA.md +75 -0
  85. package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
  86. package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
  87. package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
  88. package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
  89. package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
  90. package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
  91. package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
  92. package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
  93. package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
  94. package/skills/skillcaddy-manager/skillcaddy.json +9 -0
  95. package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
  96. package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
  97. package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
  98. package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
  99. package/skills/spec-pipeline/spec-role-contract/check-roles.py +108 -0
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Spec-pipeline gate decisions.
3
+ *
4
+ * These two functions are the single source of truth for whether a tech spec
5
+ * may enter implementation, and for whether the declared role contract holds.
6
+ *
7
+ * Why this lives in lib/ rather than inside the check scripts: the source
8
+ * directory `personal/` is git-ignored, so anything a CI job needs to verify
9
+ * must not depend on it. The Python scripts under personal/spec-pipeline/ are
10
+ * thin shells that call this module, so the gate logic cannot drift between
11
+ * what a developer runs locally and what CI enforces.
12
+ *
13
+ * Both functions are pure: they take file content and return a decision. They
14
+ * never read or write the filesystem themselves, which is what makes them
15
+ * testable from fixtures (see test/specGate.test.js).
16
+ */
17
+
18
+ /** Words that mean the direction line records a real decision. */
19
+ const PASS_WORDS = ['通过', 'approved', 'approve', 'pass'];
20
+ const REJECT_WORDS = ['拒绝', '驳回', 'rejected', 'reject', 'denied', 'deny'];
21
+
22
+ /**
23
+ * `通过 / 拒绝` left unfilled is a template, not a decision. Treating it as
24
+ * approved would let a copied-through approval block pass the gate.
25
+ */
26
+ const PLACEHOLDER_RE = /通过\s*\/\s*拒绝|approved\s*\/\s*rejected/i;
27
+
28
+ /** A real decision carries a when. The template's own <…> marker does not count. */
29
+ const TIME_RE = /\d{4}-\d{2}-\d{2}|\d{2}:\d{2}|20\d{2}/;
30
+
31
+ // JS has no named groups, so the verdict is captured as group 1.
32
+ const DIRECTION_RE = /^\s*[-*]?\s*(?:方向确认|方向|Direction)\s*[::]\s*(.*)$/i;
33
+ const APPROVAL_HEADING_RE = /^#{1,6}\s*Approval\b/i;
34
+ const HEADING_RE = /^#{1,6}\s+\S/;
35
+
36
+ const KNOWN_ROLES = ['author', 'implementer', 'verifier'];
37
+
38
+ /** Values that look filled but assert nothing. */
39
+ const PLACEHOLDERS = new Set([
40
+ '', '-', '--', '—', '——', 'n/a', 'na', 'tbd', 'todo',
41
+ '?', '??', '(无)', '(无)', '无', '待定', '未开始填写', 'x', 'xx'
42
+ ]);
43
+
44
+ const BOUNDARY_RE = /verifier\s*(?:不得|不能|不可)\s*(?:与|和)\s*author\s*(?:同|相同)/i;
45
+
46
+ function isFilled(value) {
47
+ return !PLACEHOLDERS.has(String(value ?? '').trim().replace(/^\*+|\*+$/g, '').trim().toLowerCase());
48
+ }
49
+
50
+ /**
51
+ * Locate the direction verdict inside `## Approval`.
52
+ *
53
+ * Scanning stops at the next heading on purpose: a document must not be able
54
+ * to pass by parking a "方向确认:通过" line in a later section.
55
+ *
56
+ * @returns {{verdict: string|null, line: string|null, sectionFound: boolean}}
57
+ */
58
+ export function readDirectionVerdict(text) {
59
+ const lines = text.split('\n');
60
+ let start = -1;
61
+ for (let i = 0; i < lines.length; i += 1) {
62
+ if (APPROVAL_HEADING_RE.test(lines[i].trim())) {
63
+ start = i + 1;
64
+ break;
65
+ }
66
+ }
67
+ if (start === -1) return { verdict: null, line: null, sectionFound: false };
68
+
69
+ for (const line of lines.slice(start)) {
70
+ if (HEADING_RE.test(line.trim())) break;
71
+ const match = DIRECTION_RE.exec(line);
72
+ if (match) {
73
+ return { verdict: match[1].trim(), line, sectionFound: true };
74
+ }
75
+ }
76
+ return { verdict: null, line: null, sectionFound: true };
77
+ }
78
+
79
+ /**
80
+ * Decide whether a TECH.md is approved to enter implementation.
81
+ *
82
+ * @param {string|null} text File content, or null when the file is absent.
83
+ * @returns {{code: 0|1, reason: string, kind: string}}
84
+ */
85
+ export function decideApproval(text) {
86
+ const blocked = (kind, reason) => ({ code: 1, reason: `BLOCKED: ${reason}`, kind });
87
+
88
+ if (text === null || text === undefined) {
89
+ return blocked('missing', 'TECH.md 不存在。方向尚未确认,不得进入实施。');
90
+ }
91
+
92
+ const { verdict, line, sectionFound } = readDirectionVerdict(text);
93
+
94
+ if (verdict === null) {
95
+ if (!sectionFound) {
96
+ return blocked('no-approval-section',
97
+ 'TECH.md 缺少 `## Approval` 段。批准状态是显式记录,没有它一律按未批准处理。请先运行 spec-direction-gate。');
98
+ }
99
+ return blocked('no-verdict',
100
+ '`## Approval` 段存在,但里面没有方向确认结论。\n'
101
+ + ' 需要形如「- 方向确认:通过(时间:…,确认人:…)」的一行。'
102
+ + '空的 Approval 段等同于未批准——这正是 spec-draft-intake 产出的状态。');
103
+ }
104
+
105
+ if (PLACEHOLDER_RE.test(verdict)) {
106
+ return blocked('template',
107
+ `方向确认仍是未填写的模板:${line.trim()}\n`
108
+ + ' `通过 / 拒绝` 两个候选都留着,说明结论没写。请补上实际决定、时间与确认人。');
109
+ }
110
+
111
+ if (!TIME_RE.test(verdict) && !TIME_RE.test(line ?? '')) {
112
+ return blocked('no-timestamp',
113
+ `方向确认缺少时间戳:${line.trim()}\n`
114
+ + ' 批准记录必须可核验到「谁在什么时候批准了方向」。');
115
+ }
116
+
117
+ const lower = verdict.toLowerCase();
118
+ const mentioned = (words) => words.some((w) => verdict.includes(w) || lower.includes(w));
119
+
120
+ if (mentioned(REJECT_WORDS)) {
121
+ return blocked('rejected',
122
+ `方向确认被拒绝:${line.trim()}\n`
123
+ + ' 按流程停止。修改草稿后须从方向确认重新开始,不得直接进入实施。');
124
+ }
125
+
126
+ if (!mentioned(PASS_WORDS)) {
127
+ return blocked('undecidable',
128
+ `无法判定方向确认结论:${line.trim()}\n 既没有「通过」也没有「拒绝」,按未批准处理。`);
129
+ }
130
+
131
+ return { code: 0, reason: 'APPROVED: 方向已确认,可以进入实施。', kind: 'approved' };
132
+ }
133
+
134
+ /**
135
+ * Parse the role table out of a ROLE.md body.
136
+ *
137
+ * @returns {{roles: Record<string, {who: string, session: string}>, order: string[]}}
138
+ */
139
+ export function extractRoles(text) {
140
+ const roles = {};
141
+ const order = [];
142
+ let inTable = false;
143
+
144
+ for (const raw of text.split('\n')) {
145
+ const line = raw.trim();
146
+ if (line.startsWith('|')) {
147
+ inTable = true;
148
+ const cells = line.replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
149
+ if (cells.every((c) => /^[-: ]*$/.test(c))) continue; // |---|---| separator
150
+ const key = cells[0].replace(/^\*+|\*+$/g, '').trim().toLowerCase();
151
+ for (const role of KNOWN_ROLES) {
152
+ if (key.includes(role)) {
153
+ if (!(role in roles)) order.push(role);
154
+ roles[role] = {
155
+ who: cells[1] ?? '',
156
+ session: cells[2] ?? ''
157
+ };
158
+ break;
159
+ }
160
+ }
161
+ } else if (inTable && line) {
162
+ break;
163
+ }
164
+ }
165
+
166
+ return { roles, order };
167
+ }
168
+
169
+ /**
170
+ * Decide whether a declared role contract holds.
171
+ *
172
+ * Scope limit, stated plainly because it is easy to overread: this checks the
173
+ * *declared* contract. A table filled in dishonestly passes. It catches the
174
+ * common accident — copying a role block and forgetting to change the session —
175
+ * not the absence of a real session boundary.
176
+ *
177
+ * @param {string|null} text File content, or null when the file is absent.
178
+ * @returns {{code: 0|1, reason: string, kind: string, problems: string[]}}
179
+ */
180
+ export function decideRoleContract(text) {
181
+ const violation = (kind, reason, problems = []) => ({
182
+ code: 1, reason: `VIOLATION: ${reason}`, kind, problems
183
+ });
184
+
185
+ if (text === null || text === undefined) {
186
+ return violation('missing', 'ROLE.md 不存在。角色边界无法核验,不得开始实施。');
187
+ }
188
+
189
+ const { roles } = extractRoles(text);
190
+
191
+ if (Object.keys(roles).length === 0) {
192
+ return violation('no-table',
193
+ 'ROLE.md 里没有角色表。\n 需要一张表,至少三行:author / implementer / verifier,每行含「谁 / 会话 / 状态」。');
194
+ }
195
+
196
+ const missing = KNOWN_ROLES.filter((role) => !(role in roles));
197
+ if (missing.length > 0) {
198
+ return violation('missing-role',
199
+ `角色表缺少位置:${missing.join(', ')}。\n 三个位置都要显式声明,不能靠默认。`);
200
+ }
201
+
202
+ const problems = [];
203
+ for (const role of KNOWN_ROLES) {
204
+ if (!isFilled(roles[role].who)) problems.push(` - ${role} 的人未填写`);
205
+ if (!isFilled(roles[role].session)) {
206
+ problems.push(` - ${role} 的会话标识未填写(占位符 \`${roles[role].session.trim()}\` 不算)`);
207
+ }
208
+ }
209
+
210
+ const authorSession = roles.author.session.trim();
211
+ const verifierSession = roles.verifier.session.trim();
212
+
213
+ if (isFilled(authorSession) && isFilled(verifierSession)
214
+ && authorSession.toLowerCase() === verifierSession.toLowerCase()) {
215
+ problems.push(
216
+ ` - verifier 与 author 同为会话 \`${authorSession}\`\n`
217
+ + ' author 带着自己的权衡上下文验收自己的实现,等于用出题人的答案判卷。验收必须换新会话。'
218
+ );
219
+ }
220
+
221
+ if (!BOUNDARY_RE.test(text)) {
222
+ problems.push(' - `## Boundaries` 里没有写明「verifier 不得与 author 同会话」');
223
+ }
224
+
225
+ if (problems.length > 0) {
226
+ return violation('boundary', `角色契约不满足边界要求:\n${problems.join('\n')}`, problems);
227
+ }
228
+
229
+ return {
230
+ code: 0,
231
+ reason: `OK: 三个位置均已声明,verifier 会话 \`${verifierSession}\` 与 author \`${authorSession}\` 不同。边界可核验。`,
232
+ kind: 'ok',
233
+ problems: []
234
+ };
235
+ }
@@ -0,0 +1,169 @@
1
+ import { syncClaudeSkills } from './claudeStore.js';
2
+ import { executeCollectionEnablement } from './collectionEnablement.js';
3
+ import { updateSkillMetadata } from './skillMetadata.js';
4
+ import { disableSkill, getState } from './skillStore.js';
5
+ import { enableProjectSkill } from './projectActions.js';
6
+
7
+ const DEFAULT_LIMIT = 20;
8
+
9
+ export async function loadTuiState(roots, projectPath, options = {}) {
10
+ return getState(roots, projectPath, options);
11
+ }
12
+
13
+ export function summarizeState(state) {
14
+ return {
15
+ projectPath: state.projectPath,
16
+ totalSkills: state.stats.total,
17
+ availableSkills: state.stats.available,
18
+ enabledAgents: state.enabled.length,
19
+ enabledClaude: state.claude?.skills?.length || 0,
20
+ advice: state.advice.length
21
+ };
22
+ }
23
+
24
+ export function listSkillChoices(state, options = {}) {
25
+ const query = normalizeSearch(options.query);
26
+ const source = options.source || '';
27
+ const tag = options.tag || '';
28
+ const limit = Number.isInteger(options.limit) ? options.limit : DEFAULT_LIMIT;
29
+ const enabledTargets = new Set(
30
+ enabledSkillsForScope(state, options.scope)
31
+ .map((skill) => skill.targetPath)
32
+ .filter(Boolean)
33
+ );
34
+
35
+ return state.skills
36
+ .filter((skill) => !source || skill.source === source)
37
+ .filter((skill) => !tag || (skill.tags || []).includes(tag))
38
+ .filter((skill) => !query || skillMatchesQuery(skill, query))
39
+ .slice(0, limit)
40
+ .map((skill, index) => ({
41
+ index: index + 1,
42
+ id: skill.id,
43
+ label: formatSkillLabel(skill),
44
+ enabled: enabledTargets.has(skill.path),
45
+ autoEnable: skill.autoEnable,
46
+ skill
47
+ }));
48
+ }
49
+
50
+ export function listEnabledAliases(state, scope = 'project') {
51
+ const enabled = enabledSkillsForScope(state, scope);
52
+ return enabled.map((skill, index) => ({
53
+ index: index + 1,
54
+ alias: skill.alias,
55
+ label: `${skill.alias} -> ${skill.targetPath || skill.linkPath}`,
56
+ canDisable: skill.canDisable ?? skill.isSymlink,
57
+ skill
58
+ }));
59
+ }
60
+
61
+ export function findSkillByChoice(state, value) {
62
+ const text = String(value || '').trim();
63
+ if (!text) throw new Error('skill 选择不能为空');
64
+
65
+ const byId = state.skills.find((skill) => skill.id === text);
66
+ if (byId) return byId;
67
+
68
+ const byName = state.skills.filter((skill) => skill.name === text);
69
+ if (byName.length === 1) return byName[0];
70
+ if (byName.length > 1) {
71
+ throw new Error(`存在多个同名 skill:${text},请使用完整 id`);
72
+ }
73
+
74
+ throw new Error(`找不到 skill:${text}`);
75
+ }
76
+
77
+ export function findSkillByQuery(state, query) {
78
+ const matches = listSkillChoices(state, { query, limit: 2 });
79
+ if (matches.length === 0) {
80
+ throw new Error(`找不到匹配的 skill:${query}`);
81
+ }
82
+ if (matches.length > 1) {
83
+ throw new Error(`多个 skill 匹配:${query},请从搜索结果序号中选择`);
84
+ }
85
+ return matches[0].skill;
86
+ }
87
+
88
+ export async function enableSkillChoice(roots, state, value, options = {}) {
89
+ const skill = findSkillByChoice(state, value);
90
+ return enableProjectSkill(roots, {
91
+ ...(options.scope === 'project' || !options.scope ? { projectPath: state.projectPath } : {}),
92
+ ...(options.scope ? { scope: options.scope } : {}),
93
+ ...(options.globalDir ? { globalDir: options.globalDir } : {}),
94
+ ...(options.hermesDir ? { hermesDir: options.hermesDir } : {}),
95
+ skillPath: skill.path,
96
+ alias: options.alias || skill.name
97
+ });
98
+ }
99
+
100
+ export async function enableCollectionChoice(roots, state, skillIds, options = {}) {
101
+ return executeCollectionEnablement(roots, {
102
+ ...(options.scope === 'project' || !options.scope ? { projectPath: state.projectPath } : {}),
103
+ ...(options.scope ? { scope: options.scope } : {}),
104
+ ...(options.globalDir ? { globalDir: options.globalDir } : {}),
105
+ ...(options.hermesDir ? { hermesDir: options.hermesDir } : {}),
106
+ skillIds
107
+ });
108
+ }
109
+
110
+ export async function disableAlias(state, alias, options = {}) {
111
+ return disableSkill({
112
+ ...(options.scope === 'project' || !options.scope ? { projectPath: state.projectPath } : {}),
113
+ ...(options.scope ? { scope: options.scope } : {}),
114
+ ...(options.rootDir ? { rootDir: options.rootDir } : {}),
115
+ ...(options.globalDir ? { globalDir: options.globalDir } : {}),
116
+ ...(options.hermesDir ? { hermesDir: options.hermesDir } : {}),
117
+ alias
118
+ });
119
+ }
120
+
121
+ function enabledSkillsForScope(state, scope = 'project') {
122
+ if (scope === 'global') return state.global || [];
123
+ if (scope === 'hermes') return state.hermes || [];
124
+ return state.enabled || [];
125
+ }
126
+
127
+ export async function syncClaude(state) {
128
+ return syncClaudeSkills(state.projectPath);
129
+ }
130
+
131
+ export async function saveSkillMetadata(roots, state, value, metadata) {
132
+ const skill = findSkillByChoice(state, value);
133
+ return updateSkillMetadata(roots, {
134
+ skillPath: skill.path,
135
+ note: metadata.note ?? skill.note,
136
+ tags: metadata.tags ?? skill.tags,
137
+ autoEnable: metadata.autoEnable ?? skill.autoEnable
138
+ });
139
+ }
140
+
141
+ export function formatAdvice(advice) {
142
+ return advice.map((item, index) => ({
143
+ index: index + 1,
144
+ severity: item.severity || 'info',
145
+ title: item.title,
146
+ detail: item.detail
147
+ }));
148
+ }
149
+
150
+ function normalizeSearch(value) {
151
+ return String(value || '').trim().toLowerCase();
152
+ }
153
+
154
+ function skillMatchesQuery(skill, query) {
155
+ return [
156
+ skill.id,
157
+ skill.name,
158
+ skill.source,
159
+ skill.collection,
160
+ skill.description,
161
+ skill.note,
162
+ ...(skill.tags || [])
163
+ ].some((value) => String(value || '').toLowerCase().includes(query));
164
+ }
165
+
166
+ function formatSkillLabel(skill) {
167
+ const disabled = skill.autoEnable === false ? ' skip-bulk' : '';
168
+ return `${skill.id}${disabled} - ${skill.note || skill.description || '无描述'}`;
169
+ }
@@ -0,0 +1,133 @@
1
+ import { access, lstat, readFile, realpath } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const PACKAGE_NAME = 'skillcaddy';
8
+ const COMMAND_ENTRY = 'scripts/tui.js';
9
+
10
+ export async function getSkillcaddyInstallPaths(rootDir, options = {}) {
11
+ const sourcePath = path.resolve(rootDir);
12
+ const globalRoot = options.globalRoot || await getNpmGlobalRoot(options);
13
+ return {
14
+ sourcePath,
15
+ targetPath: path.join(path.resolve(globalRoot), PACKAGE_NAME)
16
+ };
17
+ }
18
+
19
+ export async function checkSkillcaddyInstall(rootDir, options = {}) {
20
+ const paths = await getSkillcaddyInstallPaths(rootDir, options);
21
+ if (!await isSkillcaddyPackage(paths.sourcePath)) {
22
+ return {
23
+ ok: false,
24
+ status: 'source-invalid',
25
+ ...paths,
26
+ message: `Skillcaddy clone does not expose the CLI/TUI command: ${paths.sourcePath}`
27
+ };
28
+ }
29
+
30
+ const target = await safeLstat(paths.targetPath);
31
+ if (!target) {
32
+ return {
33
+ ok: false,
34
+ status: 'missing',
35
+ ...paths,
36
+ message: `Skillcaddy CLI/TUI command is not linked: ${paths.targetPath}`
37
+ };
38
+ }
39
+
40
+ let linkTarget;
41
+ try {
42
+ linkTarget = await realpath(paths.targetPath);
43
+ } catch {
44
+ return {
45
+ ok: false,
46
+ status: 'conflict',
47
+ ...paths,
48
+ message: `global Skillcaddy package link is broken: ${paths.targetPath}`
49
+ };
50
+ }
51
+
52
+ const sourceTarget = await realpath(paths.sourcePath);
53
+ if (linkTarget !== sourceTarget) {
54
+ return {
55
+ ok: false,
56
+ status: 'conflict',
57
+ ...paths,
58
+ linkTarget,
59
+ message: `global Skillcaddy package points elsewhere: ${linkTarget}`
60
+ };
61
+ }
62
+
63
+ return {
64
+ ok: true,
65
+ status: 'installed',
66
+ ...paths,
67
+ linkTarget,
68
+ command: 'skillcaddy',
69
+ message: 'Skillcaddy CLI/TUI command is linked to this clone'
70
+ };
71
+ }
72
+
73
+ export async function installTuiCommand(rootDir, options = {}) {
74
+ const current = await checkSkillcaddyInstall(rootDir, options);
75
+ if (current.status === 'installed') {
76
+ return { ...current, unchanged: true };
77
+ }
78
+ if (current.status !== 'missing') {
79
+ throw new Error(current.message);
80
+ }
81
+
82
+ const linkPackage = options.linkPackage || linkCurrentPackage;
83
+ await linkPackage(current.sourcePath, options);
84
+
85
+ const installed = await checkSkillcaddyInstall(rootDir, options);
86
+ if (!installed.ok) {
87
+ throw new Error(installed.message);
88
+ }
89
+ return { ...installed, unchanged: false };
90
+ }
91
+
92
+ export const getCliInstallPaths = getSkillcaddyInstallPaths;
93
+ export const checkCliInstall = checkSkillcaddyInstall;
94
+ export const installCliCommand = installTuiCommand;
95
+ export const getTuiInstallPaths = getSkillcaddyInstallPaths;
96
+ export const checkTuiInstall = checkSkillcaddyInstall;
97
+ export const installSkillcaddyCommand = installTuiCommand;
98
+
99
+ async function getNpmGlobalRoot(options) {
100
+ const npmCommand = options.npmCommand || (process.platform === 'win32' ? 'npm.cmd' : 'npm');
101
+ const { stdout } = await execFileAsync(npmCommand, ['root', '--global'], {
102
+ encoding: 'utf8'
103
+ });
104
+ return stdout.trim();
105
+ }
106
+
107
+ async function linkCurrentPackage(rootDir, options) {
108
+ const npmCommand = options.npmCommand || (process.platform === 'win32' ? 'npm.cmd' : 'npm');
109
+ await execFileAsync(npmCommand, ['link', '--ignore-scripts', '--no-audit', '--no-fund'], {
110
+ cwd: rootDir,
111
+ encoding: 'utf8'
112
+ });
113
+ }
114
+
115
+ async function isSkillcaddyPackage(rootDir) {
116
+ try {
117
+ const packageJson = JSON.parse(await readFile(path.join(rootDir, 'package.json'), 'utf8'));
118
+ if (packageJson.name !== PACKAGE_NAME || packageJson.bin?.skillcaddy !== COMMAND_ENTRY) return false;
119
+ await access(path.join(rootDir, COMMAND_ENTRY));
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ async function safeLstat(targetPath) {
127
+ try {
128
+ return await lstat(targetPath);
129
+ } catch (error) {
130
+ if (error?.code === 'ENOENT') return null;
131
+ throw error;
132
+ }
133
+ }
@@ -0,0 +1,94 @@
1
+ const DEFAULT_PAGE_SIZE = 10;
2
+ const TABLE_DIVIDER = ' | ';
3
+
4
+ export function getSkillPage(choices, page = 1, pageSize = DEFAULT_PAGE_SIZE) {
5
+ const totalPages = Math.max(1, Math.ceil(choices.length / pageSize));
6
+ const currentPage = Math.min(Math.max(1, page), totalPages);
7
+ const start = (currentPage - 1) * pageSize;
8
+
9
+ return {
10
+ currentPage,
11
+ totalPages,
12
+ total: choices.length,
13
+ start: choices.length === 0 ? 0 : start + 1,
14
+ end: Math.min(start + pageSize, choices.length),
15
+ choices: choices.slice(start, start + pageSize)
16
+ };
17
+ }
18
+
19
+ export function getSkillTableLayout(columns) {
20
+ const terminalWidth = Number.isFinite(columns) && columns > 0
21
+ ? Math.floor(columns)
22
+ : 100;
23
+ const singleColumn = terminalWidth < 40;
24
+ const leftWidth = singleColumn
25
+ ? terminalWidth
26
+ : Math.min(38, Math.max(20, Math.floor(terminalWidth * 0.36)));
27
+
28
+ return {
29
+ leftWidth,
30
+ introductionWidth: singleColumn
31
+ ? 0
32
+ : terminalWidth - leftWidth - getDisplayWidth(TABLE_DIVIDER)
33
+ };
34
+ }
35
+
36
+ export function formatSkillTableHeader(layout) {
37
+ if (layout.introductionWidth === 0) return 'Skill / 状态';
38
+ return `${padDisplayWidth('Skill / 状态', layout.leftWidth)}${TABLE_DIVIDER}介绍`;
39
+ }
40
+
41
+ export function formatSkillTableDivider(layout) {
42
+ if (layout.introductionWidth === 0) return '-'.repeat(layout.leftWidth);
43
+ return `${'-'.repeat(layout.leftWidth)}+${'-'.repeat(layout.introductionWidth + 2)}`;
44
+ }
45
+
46
+ export function formatCompactSkillChoice(choice, indexWidth = 1, layout) {
47
+ const status = choice.enabled
48
+ ? '已启用'
49
+ : choice.skill.autoEnable === false
50
+ ? '手动添加'
51
+ : '可添加';
52
+ const prefix = `${String(choice.index).padStart(indexWidth)}. `;
53
+ const suffix = ` ${status}`;
54
+ const label = `${prefix}${choice.skill.name}${suffix}`;
55
+
56
+ if (!layout) return label;
57
+
58
+ const nameWidth = Math.max(1, layout.leftWidth - getDisplayWidth(prefix) - getDisplayWidth(suffix));
59
+ const compactLabel = `${prefix}${truncateDisplayWidth(choice.skill.name, nameWidth)}${suffix}`;
60
+ if (layout.introductionWidth === 0) return compactLabel;
61
+ return `${padDisplayWidth(compactLabel, layout.leftWidth)}${TABLE_DIVIDER}${truncateDisplayWidth(getSkillIntroduction(choice.skill), layout.introductionWidth)}`;
62
+ }
63
+
64
+ export function getSkillIntroduction(skill) {
65
+ return skill.note || skill.description || skill.path;
66
+ }
67
+
68
+ export function getDisplayWidth(value) {
69
+ return [...String(value)].reduce((width, character) => width + (isWideCharacter(character) ? 2 : 1), 0);
70
+ }
71
+
72
+ export function truncateDisplayWidth(value, maxWidth) {
73
+ const text = String(value);
74
+ if (getDisplayWidth(text) <= maxWidth) return text;
75
+
76
+ let result = '';
77
+ let width = 0;
78
+ for (const character of text) {
79
+ const characterWidth = getDisplayWidth(character);
80
+ if (width + characterWidth + 1 > maxWidth) break;
81
+ result += character;
82
+ width += characterWidth;
83
+ }
84
+ return `${result}…`;
85
+ }
86
+
87
+ function padDisplayWidth(value, width) {
88
+ return `${value}${' '.repeat(Math.max(0, width - getDisplayWidth(value)))}`;
89
+ }
90
+
91
+ function isWideCharacter(character) {
92
+ if (character === '…') return false;
93
+ return character.codePointAt(0) > 0xff;
94
+ }
package/lib/version.js ADDED
@@ -0,0 +1,17 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { normalizeRoots } from './roots.js';
4
+
5
+ /**
6
+ * Reads name and version from the project's package.json.
7
+ * @param {string|{ libraryRoot?: string, codeRoot?: string }} roots code root, or a legacy single root
8
+ * @returns {Promise<{ name: string, version: string }>}
9
+ */
10
+ export async function readVersion(roots) {
11
+ const { codeRoot } = normalizeRoots(roots);
12
+ const pkgPath = path.join(codeRoot, 'package.json');
13
+ const raw = await readFile(pkgPath, 'utf8');
14
+ const pkg = JSON.parse(raw);
15
+
16
+ return { name: pkg.name, version: pkg.version };
17
+ }