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,107 @@
1
+ import path from 'node:path';
2
+ import {
3
+ defineSourceAcquisitionAdapter
4
+ } from './sourceAcquisitionAdapter.js';
5
+ import { SourceAcquisitionError } from './sourceAcquisitionError.js';
6
+ import { inspectLocalInput, stageLocalInput } from './sourceLocal.js';
7
+ import {
8
+ assertDirectorySourceMatchesRecord,
9
+ sameJson
10
+ } from './sourceAcquisitionPolicy.js';
11
+ import { validateSourceId } from './sourceRegistry.js';
12
+
13
+ export function createLocalAcquisitionAdapter({ archiveLimits }, request) {
14
+ const inputPath = requireLocalInput(request);
15
+ const requestIdentity = {
16
+ name: request.name,
17
+ namespace: request.namespace
18
+ };
19
+
20
+ return defineSourceAcquisitionAdapter({
21
+ stalePlanMessage: 'Local source content changed since the add plan',
22
+ inspect: async () => buildLocalCandidate(
23
+ await inspectLocalInput(inputPath, { archiveLimits })
24
+ ),
25
+ prepare: async (workspaceRoot) => buildLocalCandidate(
26
+ await stageLocalInput(inputPath, workspaceRoot, { archiveLimits })
27
+ ),
28
+ resolveIdentity(candidate) {
29
+ return resolveLocalIdentity(requestIdentity, candidate.defaultName);
30
+ },
31
+ buildRecord(plan) {
32
+ return {
33
+ schemaVersion: 1,
34
+ sourceId: plan.sourceId,
35
+ bucket: 'personal',
36
+ type: 'local',
37
+ installPath: plan.installPath,
38
+ origin: {
39
+ kind: 'local',
40
+ name: plan.input.name
41
+ },
42
+ integrity: plan.integrity,
43
+ skills: plan.skills
44
+ };
45
+ },
46
+ matchesIdentity(record, plan) {
47
+ return record.type === 'local' &&
48
+ record.installPath === plan.installPath;
49
+ },
50
+ matchesContent(record, plan) {
51
+ return record.integrity?.value === plan.integrity.value &&
52
+ sameJson(record.skills, plan.skills);
53
+ },
54
+ assertInstalled: assertDirectorySourceMatchesRecord
55
+ });
56
+ }
57
+
58
+ function requireLocalInput(request) {
59
+ if (!request || typeof request.input !== 'string' || !request.input.trim()) {
60
+ throw new Error('Source add requires request.input');
61
+ }
62
+ return path.resolve(request.input);
63
+ }
64
+
65
+ function resolveLocalIdentity(request, defaultName) {
66
+ const name = request.name === undefined ? defaultName : request.name;
67
+ const namespace = request.namespace;
68
+ if (typeof name !== 'string' || !name) {
69
+ throw identityError('Local source name must be a non-empty identity segment');
70
+ }
71
+ if (namespace !== undefined && (typeof namespace !== 'string' || !namespace)) {
72
+ throw identityError('Local source namespace must be a non-empty identity segment');
73
+ }
74
+
75
+ const sourceId = namespace
76
+ ? `personal/${namespace}/${name}`
77
+ : `personal/${name}`;
78
+ try {
79
+ validateSourceId(sourceId);
80
+ } catch {
81
+ throw identityError(
82
+ `Invalid local source identity: ${sourceId}. Use --name and --namespace with safe identity segments.`
83
+ );
84
+ }
85
+ return {
86
+ sourceId,
87
+ installPath: `personal/${namespace ? `${namespace}--` : ''}${name}`
88
+ };
89
+ }
90
+
91
+ function buildLocalCandidate(prepared) {
92
+ return {
93
+ defaultName: prepared.defaultName,
94
+ input: {
95
+ type: prepared.type,
96
+ name: prepared.basename
97
+ },
98
+ integrity: prepared.integrity,
99
+ skills: prepared.skills,
100
+ warnings: prepared.warnings,
101
+ ...(prepared.contentRoot ? { contentRoot: prepared.contentRoot } : {})
102
+ };
103
+ }
104
+
105
+ function identityError(message) {
106
+ return new SourceAcquisitionError('unresolved-identity', message, 3);
107
+ }
@@ -0,0 +1,242 @@
1
+ import {
2
+ exportLibraryImage as exportLibraryImageWorkflow,
3
+ importLibraryImage as importLibraryImageWorkflow
4
+ } from './libraryImage.js';
5
+ import { lstat } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import {
8
+ applySourceAcquisition,
9
+ planSourceAcquisition
10
+ } from './sourceAcquisition.js';
11
+ import {
12
+ updateRegisteredGitSources
13
+ } from './sourceGitBatchUpdate.js';
14
+ import {
15
+ applyGitSourceRepair,
16
+ planGitSourceRepair
17
+ } from './sourceGitRepair.js';
18
+ import {
19
+ applySourceUpgrade,
20
+ planSourceUpgrade
21
+ } from './sourceUpgrade.js';
22
+ import { readSourceRecords, validateSourceId } from './sourceRegistry.js';
23
+ import {
24
+ applySourceMigrationPlan,
25
+ buildSourceMigrationPlan
26
+ } from './sourceMigration.js';
27
+ import { SOURCE_FOLDERS } from './sourcePolicy.js';
28
+ import { readSourceDirectory } from './sourceTree.js';
29
+
30
+ export async function listSources(context) {
31
+ const rootDir = requireRootDir(context);
32
+ const registered = await readSourceRecords(rootDir);
33
+ const registeredPaths = new Set(registered.map((record) => record.installPath));
34
+ const unmanaged = await findUnmanagedSourceEntries(rootDir, registeredPaths);
35
+
36
+ const sources = [
37
+ ...await Promise.all(registered.map(async (record) => ({
38
+ inventoryId: record.sourceId,
39
+ status: 'registered',
40
+ sourceId: record.sourceId,
41
+ installPath: record.installPath,
42
+ exists: await pathExists(path.join(rootDir, record.installPath))
43
+ }))),
44
+ ...unmanaged
45
+ ];
46
+
47
+ return {
48
+ sources: sources.sort(compareInventoryEntries)
49
+ };
50
+ }
51
+
52
+ export async function inspectSource(context, sourceId) {
53
+ const rootDir = requireRootDir(context);
54
+ validateSourceId(sourceId);
55
+ const records = await readSourceRecords(rootDir);
56
+ const record = records.find((candidate) => candidate.sourceId === sourceId);
57
+ if (!record) throw new Error(`Source is not registered: ${sourceId}`);
58
+ return record;
59
+ }
60
+
61
+ export async function planSourceMigration(context) {
62
+ return buildSourceMigrationPlan(requireRootDir(context));
63
+ }
64
+
65
+ export async function applySourceMigration(context, plan) {
66
+ return applySourceMigrationPlan(requireRootDir(context), plan);
67
+ }
68
+
69
+ export async function planAddSource(context, request) {
70
+ return planSourceAcquisition({
71
+ rootDir: requireRootDir(context),
72
+ archiveLimits: context?.archiveLimits,
73
+ httpLimits: context?.httpLimits,
74
+ httpLookup: context?.httpLookup
75
+ }, request);
76
+ }
77
+
78
+ export async function applyAddSource(context, plan) {
79
+ return applySourceAcquisition({
80
+ rootDir: requireRootDir(context),
81
+ archiveLimits: context?.archiveLimits,
82
+ httpLimits: context?.httpLimits,
83
+ httpLookup: context?.httpLookup
84
+ }, plan);
85
+ }
86
+
87
+ export async function planUpdateSource(context, request) {
88
+ return planSourceUpgrade({
89
+ rootDir: requireRootDir(context),
90
+ archiveLimits: context?.archiveLimits,
91
+ httpLimits: context?.httpLimits,
92
+ httpLookup: context?.httpLookup,
93
+ projectPath: context?.projectPath,
94
+ globalDir: context?.globalDir,
95
+ hermesDir: context?.hermesDir,
96
+ gitFetchRetryDelaysMs: context?.gitFetchRetryDelaysMs
97
+ }, {
98
+ request,
99
+ authorization: { kind: 'ordinary' }
100
+ });
101
+ }
102
+
103
+ export async function planBreakingUpdateSource(context, request) {
104
+ return planSourceUpgrade({
105
+ rootDir: requireRootDir(context),
106
+ archiveLimits: context?.archiveLimits,
107
+ httpLimits: context?.httpLimits,
108
+ httpLookup: context?.httpLookup,
109
+ projectPath: context?.projectPath,
110
+ globalDir: context?.globalDir,
111
+ hermesDir: context?.hermesDir,
112
+ gitFetchRetryDelaysMs: context?.gitFetchRetryDelaysMs
113
+ }, {
114
+ request,
115
+ authorization: { kind: 'breaking' }
116
+ });
117
+ }
118
+
119
+ export async function applyUpdateSource(context, plan) {
120
+ const lifecycleApplication = await applySourceUpgrade({
121
+ rootDir: requireRootDir(context),
122
+ archiveLimits: context?.archiveLimits,
123
+ httpLimits: context?.httpLimits,
124
+ httpLookup: context?.httpLookup,
125
+ projectPath: context?.projectPath,
126
+ fileOperations: context?.fileOperations,
127
+ publishRecord: context?.publishRecord,
128
+ globalDir: context?.globalDir,
129
+ hermesDir: context?.hermesDir,
130
+ gitFetchRetryDelaysMs: context?.gitFetchRetryDelaysMs
131
+ }, plan);
132
+ if (lifecycleApplication.status === 'applied') {
133
+ return lifecycleApplication.result;
134
+ }
135
+ }
136
+
137
+ export async function updateGitSources(context, options) {
138
+ const projectPath = requireProjectPath(context);
139
+ return updateRegisteredGitSources({
140
+ rootDir: requireRootDir(context),
141
+ projectPath,
142
+ globalDir: context?.globalDir,
143
+ hermesDir: context?.hermesDir,
144
+ publishRecord: context?.publishRecord,
145
+ gitFetchRetryDelaysMs: context?.gitFetchRetryDelaysMs,
146
+ gitNetworkFailureLimit: context?.gitNetworkFailureLimit
147
+ }, options);
148
+ }
149
+
150
+ export async function planRepairSource(context, request) {
151
+ return planGitSourceRepair({
152
+ rootDir: requireRootDir(context),
153
+ projectPath: requireProjectPath(context),
154
+ globalDir: context?.globalDir,
155
+ hermesDir: context?.hermesDir,
156
+ gitFetchRetryDelaysMs: context?.gitFetchRetryDelaysMs
157
+ }, {
158
+ request,
159
+ authorization: { kind: 'ordinary' }
160
+ });
161
+ }
162
+
163
+ export async function planBreakingRepairSource(context, request) {
164
+ return planGitSourceRepair({
165
+ rootDir: requireRootDir(context),
166
+ projectPath: requireProjectPath(context),
167
+ globalDir: context?.globalDir,
168
+ hermesDir: context?.hermesDir,
169
+ gitFetchRetryDelaysMs: context?.gitFetchRetryDelaysMs
170
+ }, {
171
+ request,
172
+ authorization: { kind: 'breaking' }
173
+ });
174
+ }
175
+
176
+ export async function applyRepairSource(context, plan) {
177
+ return applyGitSourceRepair({
178
+ rootDir: requireRootDir(context),
179
+ projectPath: requireProjectPath(context),
180
+ globalDir: context?.globalDir,
181
+ hermesDir: context?.hermesDir,
182
+ gitFetchRetryDelaysMs: context?.gitFetchRetryDelaysMs
183
+ }, plan);
184
+ }
185
+
186
+ async function findUnmanagedSourceEntries(rootDir, registeredPaths) {
187
+ const sources = [];
188
+ for (const bucket of SOURCE_FOLDERS) {
189
+ const entries = await readSourceDirectory(path.join(rootDir, bucket));
190
+ for (const entry of entries) {
191
+ if (!entry.isDirectory()) continue;
192
+ const installPath = `${bucket}/${entry.name}`;
193
+ if (registeredPaths.has(installPath)) continue;
194
+ sources.push({
195
+ inventoryId: `unmanaged:${installPath}`,
196
+ status: 'unmanaged',
197
+ sourceId: null,
198
+ installPath,
199
+ exists: true
200
+ });
201
+ }
202
+ }
203
+ return sources;
204
+ }
205
+
206
+ function requireRootDir(context) {
207
+ if (!context || typeof context.rootDir !== 'string' || !context.rootDir.trim()) {
208
+ throw new Error('Source management requires context.rootDir');
209
+ }
210
+ return path.resolve(context.rootDir);
211
+ }
212
+
213
+ function requireProjectPath(context) {
214
+ if (!context || typeof context.projectPath !== 'string' || !context.projectPath.trim()) {
215
+ throw new Error('Git source updates require context.projectPath');
216
+ }
217
+ return path.resolve(context.projectPath);
218
+ }
219
+
220
+ async function pathExists(targetPath) {
221
+ try {
222
+ await lstat(targetPath);
223
+ return true;
224
+ } catch (error) {
225
+ if (error.code === 'ENOENT') return false;
226
+ throw error;
227
+ }
228
+ }
229
+
230
+ function compareInventoryEntries(left, right) {
231
+ const statusOrder = { registered: 0, unmanaged: 1 };
232
+ return statusOrder[left.status] - statusOrder[right.status] ||
233
+ left.inventoryId.localeCompare(right.inventoryId);
234
+ }
235
+
236
+ export async function exportLibraryImage(context, filePath) {
237
+ return exportLibraryImageWorkflow({ ...context, rootDir: requireRootDir(context) }, filePath);
238
+ }
239
+
240
+ export async function importLibraryImage(context, filePath, options) {
241
+ return importLibraryImageWorkflow({ ...context, rootDir: requireRootDir(context) }, filePath, options);
242
+ }
@@ -0,0 +1,172 @@
1
+ import { access } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { inspectGitSource } from './sourceGit.js';
4
+ import { SourceMigrationIssue } from './sourceMigrationIssue.js';
5
+ import { readSourceRecords, writeSourceRecord } from './sourceRegistry.js';
6
+ import { validateSourceRecord } from './sourceRecord.js';
7
+ import { scanSkills } from './skillStore.js';
8
+ import {
9
+ checksumDirectory,
10
+ findSourceEntries,
11
+ validateSourceTree
12
+ } from './sourceTree.js';
13
+
14
+ export async function buildSourceMigrationPlan(rootDir) {
15
+ const [registered, skills] = await Promise.all([
16
+ readSourceRecords(rootDir),
17
+ scanSkills(rootDir)
18
+ ]);
19
+ const registeredPaths = new Set(registered.map((record) => record.installPath));
20
+ const records = [];
21
+ const unresolved = [];
22
+
23
+ for (const entry of await findSourceEntries(rootDir)) {
24
+ if (registeredPaths.has(entry.installPath)) continue;
25
+ try {
26
+ const record = await buildMigrationRecord(rootDir, entry, skills);
27
+ records.push(validateSourceRecord(rootDir, record));
28
+ } catch (error) {
29
+ unresolved.push({
30
+ installPath: entry.installPath,
31
+ reason: resolveIssueReason(error),
32
+ detail: error.message
33
+ });
34
+ }
35
+ }
36
+
37
+ removeDuplicateIdentities(records, unresolved, registered);
38
+ return {
39
+ dryRun: true,
40
+ records: records.sort((left, right) => left.sourceId.localeCompare(right.sourceId)),
41
+ unresolved: unresolved.sort((left, right) => left.installPath.localeCompare(right.installPath))
42
+ };
43
+ }
44
+
45
+ export async function applySourceMigrationPlan(rootDir, plan) {
46
+ assertMigrationPlan(plan);
47
+ const registered = await readSourceRecords(rootDir);
48
+ const registeredById = new Map(registered.map((record) => [record.sourceId, record]));
49
+ const currentPlan = await buildSourceMigrationPlan(rootDir);
50
+ const currentById = new Map(currentPlan.records.map((record) => [record.sourceId, record]));
51
+ const pending = [];
52
+ const unchanged = [];
53
+
54
+ for (const requestedRecord of plan.records) {
55
+ const validated = validateSourceRecord(rootDir, requestedRecord);
56
+ const existing = registeredById.get(validated.sourceId);
57
+ if (existing) {
58
+ if (!recordsEqual(existing, validated)) {
59
+ throw new Error(`Source migration plan conflicts with registered source: ${validated.sourceId}`);
60
+ }
61
+ unchanged.push(validated.sourceId);
62
+ continue;
63
+ }
64
+
65
+ const current = currentById.get(validated.sourceId);
66
+ if (!current || !recordsEqual(current, validated)) {
67
+ throw new Error(`Source migration plan is stale for: ${validated.sourceId}`);
68
+ }
69
+ pending.push(validated);
70
+ }
71
+
72
+ for (const record of pending) await writeSourceRecord(rootDir, record);
73
+ return {
74
+ dryRun: false,
75
+ written: pending.map((record) => record.sourceId),
76
+ unchanged,
77
+ unresolved: plan.unresolved
78
+ };
79
+ }
80
+
81
+ async function buildMigrationRecord(rootDir, entry, skills) {
82
+ if (entry.unsafe) {
83
+ throw new SourceMigrationIssue('unsafe-path', 'Source entry is a symbolic link');
84
+ }
85
+ await validateSourceTree(rootDir, entry.sourcePath);
86
+ const discoveredSkills = discoverEntrySkills(rootDir, entry.installPath, skills);
87
+ if (discoveredSkills.length === 0) {
88
+ throw new Error('No skills discovered by the existing state scanner');
89
+ }
90
+
91
+ try {
92
+ return await buildGitRecord(entry, discoveredSkills);
93
+ } catch (error) {
94
+ if (error.code !== 'ENOENT') throw error;
95
+ }
96
+ return buildLegacyLocalRecord(entry, discoveredSkills);
97
+ }
98
+
99
+ async function buildGitRecord(entry, discoveredSkills) {
100
+ await access(path.join(entry.sourcePath, '.git'));
101
+ const git = await inspectGitSource(entry.sourcePath);
102
+ return {
103
+ schemaVersion: 1,
104
+ sourceId: git.sourceId,
105
+ bucket: 'github',
106
+ type: 'git',
107
+ installPath: entry.installPath,
108
+ origin: git.origin,
109
+ skills: discoveredSkills
110
+ };
111
+ }
112
+
113
+ async function buildLegacyLocalRecord(entry, discoveredSkills) {
114
+ return {
115
+ schemaVersion: 1,
116
+ sourceId: entry.installPath,
117
+ bucket: entry.bucket,
118
+ type: 'legacy-local',
119
+ installPath: entry.installPath,
120
+ origin: { kind: 'unknown' },
121
+ integrity: {
122
+ algorithm: 'sha256',
123
+ value: await checksumDirectory(entry.sourcePath)
124
+ },
125
+ skills: discoveredSkills
126
+ };
127
+ }
128
+
129
+ function discoverEntrySkills(rootDir, installPath, skills) {
130
+ const sourcePath = path.join(rootDir, installPath);
131
+ return skills
132
+ .map((skill) => path.relative(sourcePath, skill.path))
133
+ .filter((relative) => !relative.startsWith('..') && !path.isAbsolute(relative))
134
+ .map((relative) => relative ? relative.split(path.sep).join('/') : '.')
135
+ .sort();
136
+ }
137
+
138
+ function removeDuplicateIdentities(records, unresolved, registered) {
139
+ const pathsById = new Map();
140
+ for (const record of [...registered, ...records]) {
141
+ const paths = pathsById.get(record.sourceId) || [];
142
+ paths.push(record.installPath);
143
+ pathsById.set(record.sourceId, paths);
144
+ }
145
+
146
+ for (let index = records.length - 1; index >= 0; index -= 1) {
147
+ const record = records[index];
148
+ const paths = [...new Set(pathsById.get(record.sourceId))];
149
+ if (paths.length < 2) continue;
150
+ records.splice(index, 1);
151
+ unresolved.push({
152
+ installPath: record.installPath,
153
+ reason: 'duplicate-identity',
154
+ detail: `Source identity ${record.sourceId} also resolves from: ${paths.join(', ')}`
155
+ });
156
+ }
157
+ }
158
+
159
+ function resolveIssueReason(error) {
160
+ if (error.reason) return error.reason;
161
+ return /Invalid sourceId/.test(error.message) ? 'invalid-identity' : 'unresolved-source';
162
+ }
163
+
164
+ function assertMigrationPlan(plan) {
165
+ if (!plan || plan.dryRun !== true || !Array.isArray(plan.records) || !Array.isArray(plan.unresolved)) {
166
+ throw new Error('A source migration plan is required');
167
+ }
168
+ }
169
+
170
+ function recordsEqual(left, right) {
171
+ return JSON.stringify(left) === JSON.stringify(right);
172
+ }
@@ -0,0 +1,6 @@
1
+ export class SourceMigrationIssue extends Error {
2
+ constructor(reason, message) {
3
+ super(message);
4
+ this.reason = reason;
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ import path from 'node:path';
2
+
3
+ export function isPathInsideOrEqual(rootDir, targetPath) {
4
+ const relative = path.relative(rootDir, targetPath);
5
+ return !relative.startsWith('..') && !path.isAbsolute(relative);
6
+ }
@@ -0,0 +1,53 @@
1
+ import path from 'node:path';
2
+ import { normalizeRoots } from './roots.js';
3
+
4
+ export const SOURCE_FOLDERS = ['official', 'github', 'personal', 'archived'];
5
+ export const SOURCE_INSTALLING_MARKER = '.skillcaddy-installing';
6
+ export const SOURCE_INSTALLING_MARKER_CONTENT = 'skillcaddy-source-publication-v1\n';
7
+ export const REPOSITORY_SKILLS_SOURCE = 'local';
8
+ export const REPOSITORY_SKILLS_FOLDER = 'skills';
9
+
10
+ /**
11
+ * 允许的 skill 来源目录。
12
+ *
13
+ * 四个 source bucket 位于原件库根;仓库自带 skills 桶位于代码根。
14
+ * 两者解耦之后,`npx skillcaddy` 形态下仓库自带 skills 仍然可被识别、可被启用。
15
+ */
16
+ function allowedSourcePaths(roots) {
17
+ const { libraryRoot, codeRoot } = normalizeRoots(roots);
18
+
19
+ return [
20
+ ...SOURCE_FOLDERS.map((sourceFolder) => ({
21
+ sourceFolder,
22
+ sourcePath: path.join(libraryRoot, sourceFolder)
23
+ })),
24
+ {
25
+ sourceFolder: REPOSITORY_SKILLS_FOLDER,
26
+ sourcePath: path.join(codeRoot, REPOSITORY_SKILLS_FOLDER)
27
+ }
28
+ ];
29
+ }
30
+
31
+ export function assertInsideAllowedSkillSources(roots, skillPath) {
32
+ if (isInsideAllowedSkillSources(roots, skillPath)) return;
33
+ throw new Error('skillPath 必须位于当前 AISkills 的来源目录或仓库 skills 目录内');
34
+ }
35
+
36
+ export function isInsideAllowedSkillSources(roots, skillPath) {
37
+ return Boolean(resolveAllowedSkillSource(roots, skillPath));
38
+ }
39
+
40
+ export function resolveAllowedSkillSource(roots, skillPath) {
41
+ for (const { sourceFolder, sourcePath } of allowedSourcePaths(roots)) {
42
+ const relative = path.relative(sourcePath, skillPath);
43
+ if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
44
+ return {
45
+ sourceFolder,
46
+ sourcePath,
47
+ relativePath: relative
48
+ };
49
+ }
50
+ }
51
+
52
+ return null;
53
+ }