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.
- package/CHANGELOG.md +497 -0
- package/CONTEXT.md +149 -0
- package/LICENSE +21 -0
- package/README.md +499 -0
- package/README_CN.md +482 -0
- package/collection-metadata/github/mattpocock.json +16 -0
- package/lib/canonicalJson.js +11 -0
- package/lib/claudeStore.js +135 -0
- package/lib/collectionEnablement.js +115 -0
- package/lib/collectionSetup.js +199 -0
- package/lib/enablePlan.js +116 -0
- package/lib/enablementScope.js +8 -0
- package/lib/hermesStore.js +21 -0
- package/lib/libraryImage.js +1785 -0
- package/lib/managerInstall.js +109 -0
- package/lib/managerSourceWorkflow.js +234 -0
- package/lib/projectActions.js +24 -0
- package/lib/projectPath.js +51 -0
- package/lib/roots.js +99 -0
- package/lib/skillLinkStore.js +68 -0
- package/lib/skillMetadata.js +191 -0
- package/lib/skillStore.js +541 -0
- package/lib/sourceAcquisition.js +475 -0
- package/lib/sourceAcquisitionAdapter.js +47 -0
- package/lib/sourceAcquisitionError.js +18 -0
- package/lib/sourceAcquisitionPolicy.js +139 -0
- package/lib/sourceArchive.js +363 -0
- package/lib/sourceArchiveWorkspace.js +36 -0
- package/lib/sourceGit.js +95 -0
- package/lib/sourceGitAcquisitionAdapter.js +145 -0
- package/lib/sourceGitBatchUpdate.js +168 -0
- package/lib/sourceGitRepair.js +227 -0
- package/lib/sourceGitStaging.js +139 -0
- package/lib/sourceGitUpgradeAdapter.js +583 -0
- package/lib/sourceGitUrl.js +170 -0
- package/lib/sourceHttp.js +302 -0
- package/lib/sourceLocal.js +200 -0
- package/lib/sourceLocalAcquisitionAdapter.js +107 -0
- package/lib/sourceManager.js +242 -0
- package/lib/sourceMigration.js +172 -0
- package/lib/sourceMigrationIssue.js +6 -0
- package/lib/sourcePath.js +6 -0
- package/lib/sourcePolicy.js +53 -0
- package/lib/sourceRecord.js +295 -0
- package/lib/sourceRegistry.js +155 -0
- package/lib/sourceRemoteArchiveAcquisitionAdapter.js +147 -0
- package/lib/sourceRemoteFileAcquisitionAdapter.js +95 -0
- package/lib/sourceRemoteFilePolicy.js +62 -0
- package/lib/sourceRemoteFileWorkspace.js +55 -0
- package/lib/sourceSkillDiscovery.js +117 -0
- package/lib/sourceTree.js +185 -0
- package/lib/sourceUpgrade.js +596 -0
- package/lib/sourceUpgradeAdapter.js +41 -0
- package/lib/sourceUpgradePolicy.js +170 -0
- package/lib/sourceUpgradeTransaction.js +202 -0
- package/lib/sourceValidation.js +120 -0
- package/lib/sourceWorkspace.js +37 -0
- package/lib/specGate.js +235 -0
- package/lib/tuiActions.js +169 -0
- package/lib/tuiInstall.js +133 -0
- package/lib/tuiLayout.js +94 -0
- package/lib/version.js +17 -0
- package/lib/webManager.js +370 -0
- package/package.json +74 -0
- package/public/agentsUi.js +138 -0
- package/public/app.js +1431 -0
- package/public/claude.css +2 -0
- package/public/claudeUi.js +119 -0
- package/public/emptyState.js +12 -0
- package/public/enabled.css +303 -0
- package/public/favicon.svg +5 -0
- package/public/index.html +199 -0
- package/public/skillcaddy_CN.png +0 -0
- package/public/skillcaddy_EN.png +0 -0
- package/public/styles.css +1611 -0
- package/scripts/cli.js +647 -0
- package/scripts/cron-sync-update-git.sh +128 -0
- package/scripts/pull-github.sh +22 -0
- package/scripts/source.js +638 -0
- package/scripts/tui.js +1058 -0
- package/server.js +196 -0
- package/skills/skillcaddy-manager/SKILL.md +93 -0
- package/skills/skillcaddy-manager/agents/openai.yaml +7 -0
- package/skills/skillcaddy-manager/references/METADATA.md +75 -0
- package/skills/skillcaddy-manager/references/OPERATIONS.md +315 -0
- package/skills/skillcaddy-manager/references/RECOMMENDATION_GUIDE.md +51 -0
- package/skills/skillcaddy-manager/references/featured-skills.json +564 -0
- package/skills/skillcaddy-manager/references/skill-platforms.json +48 -0
- package/skills/skillcaddy-manager/scripts/check-conflicts.cjs +117 -0
- package/skills/skillcaddy-manager/scripts/check-global-skills.cjs +169 -0
- package/skills/skillcaddy-manager/scripts/translate-skill-notes.cjs +297 -0
- package/skills/skillcaddy-manager/scripts/version-manager.cjs +146 -0
- package/skills/skillcaddy-manager/scripts/view-recommendations.cjs +279 -0
- package/skills/skillcaddy-manager/skillcaddy.json +9 -0
- package/skills/spec-pipeline/spec-direction-gate/SKILL.md +119 -0
- package/skills/spec-pipeline/spec-direction-gate/check-approval.py +110 -0
- package/skills/spec-pipeline/spec-draft-intake/SKILL.md +116 -0
- package/skills/spec-pipeline/spec-role-contract/SKILL.md +113 -0
- 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,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
|
+
}
|