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,170 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { canonicalJson } from './canonicalJson.js';
|
|
3
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
4
|
+
import {
|
|
5
|
+
checksumDirectory,
|
|
6
|
+
sourcePathExists
|
|
7
|
+
} from './sourceTree.js';
|
|
8
|
+
import { scanEnabledSkills, scanGlobalSkills } from './skillStore.js';
|
|
9
|
+
import { scanHermesSkills } from './hermesStore.js';
|
|
10
|
+
|
|
11
|
+
export function fingerprintSourceUpgradePlan(plan) {
|
|
12
|
+
return canonicalJson({
|
|
13
|
+
operation: plan.operation,
|
|
14
|
+
status: plan.status,
|
|
15
|
+
sourceId: plan.sourceId,
|
|
16
|
+
installPath: plan.installPath,
|
|
17
|
+
input: plan.input,
|
|
18
|
+
origin: plan.origin,
|
|
19
|
+
integrity: plan.integrity,
|
|
20
|
+
currentCommit: plan.currentCommit,
|
|
21
|
+
incomingCommit: plan.incomingCommit,
|
|
22
|
+
skills: plan.skills,
|
|
23
|
+
warnings: plan.warnings,
|
|
24
|
+
skillChanges: plan.skillChanges,
|
|
25
|
+
changes: plan.changes,
|
|
26
|
+
affectedProjectLinks: plan.affectedProjectLinks,
|
|
27
|
+
affectedGlobalLinks: plan.affectedGlobalLinks,
|
|
28
|
+
affectedHermesLinks: plan.affectedHermesLinks
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function assertSameSourceUpgradePlan(
|
|
33
|
+
originalFingerprint,
|
|
34
|
+
plan,
|
|
35
|
+
message
|
|
36
|
+
) {
|
|
37
|
+
if (originalFingerprint !== fingerprintSourceUpgradePlan(plan)) {
|
|
38
|
+
throw new SourceAcquisitionError('stale-plan', message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertNoAffectedProjectLinks(affectedProjectLinks) {
|
|
43
|
+
return assertNoAffectedLinks(affectedProjectLinks, []);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function assertNoAffectedLinks(
|
|
47
|
+
affectedProjectLinks,
|
|
48
|
+
affectedGlobalLinks = [],
|
|
49
|
+
affectedHermesLinks = []
|
|
50
|
+
) {
|
|
51
|
+
if (
|
|
52
|
+
affectedProjectLinks.length === 0 &&
|
|
53
|
+
affectedGlobalLinks.length === 0 &&
|
|
54
|
+
affectedHermesLinks.length === 0
|
|
55
|
+
) return;
|
|
56
|
+
const projectAliases = affectedProjectLinks.map((link) => link.alias).join(', ');
|
|
57
|
+
const globalAliases = affectedGlobalLinks.map((link) => link.alias).join(', ');
|
|
58
|
+
const hermesAliases = affectedHermesLinks.map((link) => link.alias).join(', ');
|
|
59
|
+
const details = [];
|
|
60
|
+
if (affectedProjectLinks.length > 0) details.push(`current-project links: ${projectAliases}`);
|
|
61
|
+
if (affectedGlobalLinks.length > 0) details.push(`global links: ${globalAliases}`);
|
|
62
|
+
if (affectedHermesLinks.length > 0) details.push(`Hermes links: ${hermesAliases}`);
|
|
63
|
+
const error = new SourceAcquisitionError(
|
|
64
|
+
'breaking-replacement',
|
|
65
|
+
`Source replacement would break ${details.join('; ')}. Re-run with --allow-breaking to authorize it.`,
|
|
66
|
+
4
|
|
67
|
+
);
|
|
68
|
+
error.affectedProjectLinks = affectedProjectLinks.map((link) => ({
|
|
69
|
+
alias: link.alias,
|
|
70
|
+
skillPath: link.skillPath
|
|
71
|
+
}));
|
|
72
|
+
error.affectedGlobalLinks = affectedGlobalLinks.map((link) => ({
|
|
73
|
+
alias: link.alias,
|
|
74
|
+
skillPath: link.skillPath
|
|
75
|
+
}));
|
|
76
|
+
error.affectedHermesLinks = affectedHermesLinks.map((link) => ({
|
|
77
|
+
alias: link.alias,
|
|
78
|
+
skillPath: link.skillPath
|
|
79
|
+
}));
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function findAffectedProjectLinks({
|
|
84
|
+
rootDir,
|
|
85
|
+
projectPath,
|
|
86
|
+
installPath,
|
|
87
|
+
removedSkillPaths
|
|
88
|
+
}) {
|
|
89
|
+
if (!projectPath || removedSkillPaths.length === 0) return [];
|
|
90
|
+
const removedByTarget = new Map(removedSkillPaths.map((skillPath) => [
|
|
91
|
+
path.resolve(rootDir, installPath, skillPath),
|
|
92
|
+
skillPath
|
|
93
|
+
]));
|
|
94
|
+
const links = await scanEnabledSkills(projectPath);
|
|
95
|
+
return links
|
|
96
|
+
.filter((link) => link.isSymlink && removedByTarget.has(link.targetPath))
|
|
97
|
+
.map((link) => ({
|
|
98
|
+
alias: link.alias,
|
|
99
|
+
skillPath: removedByTarget.get(link.targetPath)
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function findAffectedGlobalLinks({
|
|
104
|
+
rootDir,
|
|
105
|
+
globalDir,
|
|
106
|
+
installPath,
|
|
107
|
+
removedSkillPaths
|
|
108
|
+
}) {
|
|
109
|
+
if (removedSkillPaths.length === 0) return [];
|
|
110
|
+
const removedByTarget = new Map(removedSkillPaths.map((skillPath) => [
|
|
111
|
+
path.resolve(rootDir, installPath, skillPath),
|
|
112
|
+
skillPath
|
|
113
|
+
]));
|
|
114
|
+
const links = await scanGlobalSkills({ globalDir });
|
|
115
|
+
return links
|
|
116
|
+
.filter((link) => link.isSymlink && removedByTarget.has(link.targetPath))
|
|
117
|
+
.map((link) => ({
|
|
118
|
+
alias: link.alias,
|
|
119
|
+
skillPath: removedByTarget.get(link.targetPath)
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function findAffectedHermesLinks({
|
|
124
|
+
rootDir,
|
|
125
|
+
hermesDir,
|
|
126
|
+
installPath,
|
|
127
|
+
removedSkillPaths
|
|
128
|
+
}) {
|
|
129
|
+
if (removedSkillPaths.length === 0) return [];
|
|
130
|
+
const removedByTarget = new Map(removedSkillPaths.map((skillPath) => [
|
|
131
|
+
path.resolve(rootDir, installPath, skillPath),
|
|
132
|
+
skillPath
|
|
133
|
+
]));
|
|
134
|
+
const links = await scanHermesSkills({ hermesDir });
|
|
135
|
+
return links
|
|
136
|
+
.filter((link) => link.isSymlink && removedByTarget.has(link.targetPath))
|
|
137
|
+
.map((link) => ({
|
|
138
|
+
alias: link.alias,
|
|
139
|
+
skillPath: removedByTarget.get(link.targetPath)
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function assertActiveSourceMatchesRecord(destination, record) {
|
|
144
|
+
if (!(await sourcePathExists(destination))) {
|
|
145
|
+
throw new SourceAcquisitionError(
|
|
146
|
+
'source-collision',
|
|
147
|
+
`Registered source content is missing: ${record.sourceId}`,
|
|
148
|
+
3
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (await checksumDirectory(destination) !== record.integrity?.value) {
|
|
152
|
+
throw new SourceAcquisitionError(
|
|
153
|
+
'source-collision',
|
|
154
|
+
`Registered source content no longer matches its integrity: ${record.sourceId}`,
|
|
155
|
+
3
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function classifySkillChanges(previousSkills, nextSkills) {
|
|
161
|
+
const previous = new Set(previousSkills);
|
|
162
|
+
const next = new Set(nextSkills);
|
|
163
|
+
return {
|
|
164
|
+
unchanged: previousSkills.filter((skillPath) => next.has(skillPath)),
|
|
165
|
+
added: nextSkills.filter((skillPath) => !previous.has(skillPath)),
|
|
166
|
+
removedOrRelocated: previousSkills.filter(
|
|
167
|
+
(skillPath) => !next.has(skillPath)
|
|
168
|
+
)
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source upgrade transactions
|
|
3
|
+
*
|
|
4
|
+
* Two strategies for atomic source updates:
|
|
5
|
+
* - DIRECTORY_REPLACEMENT: For local, archive, and remote-file sources
|
|
6
|
+
* - GIT_FAST_FORWARD: For Git sources
|
|
7
|
+
*
|
|
8
|
+
* Both follow the same phase structure:
|
|
9
|
+
* - PREPARE: Stage content and registry entries
|
|
10
|
+
* - COMMIT: Make changes active
|
|
11
|
+
* - CLEANUP: Remove temporary artifacts (directory only)
|
|
12
|
+
* - ROLLBACK: Undo on failure
|
|
13
|
+
* - DISCARD: Clean up staging state
|
|
14
|
+
*
|
|
15
|
+
* See sourceUpgrade.js for the lifecycle orchestration.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile as execFileCallback } from 'node:child_process';
|
|
18
|
+
import {
|
|
19
|
+
lstat,
|
|
20
|
+
rename,
|
|
21
|
+
rm
|
|
22
|
+
} from 'node:fs/promises';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { promisify } from 'node:util';
|
|
25
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
26
|
+
import {
|
|
27
|
+
discardStagedSourceRecord,
|
|
28
|
+
publishStagedSourceRecord,
|
|
29
|
+
stageSourceRecord,
|
|
30
|
+
writeSourceRecord
|
|
31
|
+
} from './sourceRegistry.js';
|
|
32
|
+
|
|
33
|
+
const execFile = promisify(execFileCallback);
|
|
34
|
+
|
|
35
|
+
// === Directory Replacement Strategy ===
|
|
36
|
+
// For local, archive, and remote-file sources
|
|
37
|
+
|
|
38
|
+
export const defaultSourceUpdateFileOperations = { rename, rm };
|
|
39
|
+
|
|
40
|
+
export async function prepareDirectorySourceReplacement({
|
|
41
|
+
rootDir,
|
|
42
|
+
destination,
|
|
43
|
+
stagingRoot,
|
|
44
|
+
preparedContentRoot,
|
|
45
|
+
nextRecord,
|
|
46
|
+
fileOperations
|
|
47
|
+
}) {
|
|
48
|
+
const previousSource = path.join(stagingRoot, 'previous-source');
|
|
49
|
+
const previousRecord = path.join(stagingRoot, 'previous-record.json');
|
|
50
|
+
const stagedRecord = await stageSourceRecord(rootDir, nextRecord);
|
|
51
|
+
return {
|
|
52
|
+
destination,
|
|
53
|
+
fileOperations,
|
|
54
|
+
preparedContentRoot,
|
|
55
|
+
previousRecord,
|
|
56
|
+
previousSource,
|
|
57
|
+
stagedRecord
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function commitDirectorySourceReplacement(state) {
|
|
62
|
+
await state.fileOperations.rename(state.destination, state.previousSource);
|
|
63
|
+
await state.fileOperations.rename(
|
|
64
|
+
state.preparedContentRoot,
|
|
65
|
+
state.destination
|
|
66
|
+
);
|
|
67
|
+
await state.fileOperations.rename(
|
|
68
|
+
state.stagedRecord.targetPath,
|
|
69
|
+
state.previousRecord
|
|
70
|
+
);
|
|
71
|
+
await state.fileOperations.rename(
|
|
72
|
+
state.stagedRecord.temporaryPath,
|
|
73
|
+
state.stagedRecord.targetPath
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function cleanupDirectorySourceReplacement(state) {
|
|
78
|
+
await state.fileOperations.rm(
|
|
79
|
+
state.previousSource,
|
|
80
|
+
{ recursive: true, force: true }
|
|
81
|
+
);
|
|
82
|
+
await state.fileOperations.rm(state.previousRecord, { force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function rollbackDirectorySourceReplacement(state) {
|
|
86
|
+
if (await pathExists(state.previousRecord)) {
|
|
87
|
+
await restorePublishedPath({
|
|
88
|
+
fileOperations: state.fileOperations,
|
|
89
|
+
backup: state.previousRecord,
|
|
90
|
+
target: state.stagedRecord.targetPath,
|
|
91
|
+
removeOptions: { force: true }
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (await pathExists(state.previousSource)) {
|
|
95
|
+
await restorePublishedPath({
|
|
96
|
+
fileOperations: state.fileOperations,
|
|
97
|
+
backup: state.previousSource,
|
|
98
|
+
target: state.destination,
|
|
99
|
+
removeOptions: { recursive: true, force: true }
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function discardDirectorySourceReplacement(state) {
|
|
105
|
+
await discardStagedSourceRecord(state.stagedRecord);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// === Git Fast-Forward Strategy ===
|
|
109
|
+
// For Git sources
|
|
110
|
+
|
|
111
|
+
export async function prepareGitSourceFastForward({
|
|
112
|
+
rootDir,
|
|
113
|
+
record,
|
|
114
|
+
nextRecord,
|
|
115
|
+
incomingCommit,
|
|
116
|
+
publishRecord = publishStagedSourceRecord
|
|
117
|
+
}) {
|
|
118
|
+
return {
|
|
119
|
+
destination: path.resolve(rootDir, record.installPath),
|
|
120
|
+
incomingCommit,
|
|
121
|
+
previousRecord: record,
|
|
122
|
+
publishRecord,
|
|
123
|
+
recordPublished: false,
|
|
124
|
+
rootDir,
|
|
125
|
+
sourceId: record.sourceId,
|
|
126
|
+
stagedRecord: await stageSourceRecord(rootDir, nextRecord)
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function commitGitSourceFastForward(state) {
|
|
131
|
+
await state.publishRecord(state.stagedRecord);
|
|
132
|
+
state.recordPublished = true;
|
|
133
|
+
await execGit(state.destination, [
|
|
134
|
+
'merge',
|
|
135
|
+
'--ff-only',
|
|
136
|
+
'--no-edit',
|
|
137
|
+
state.incomingCommit
|
|
138
|
+
], state.sourceId, 'Could not fast-forward Git source');
|
|
139
|
+
const installedCommit = await readGitOutput(
|
|
140
|
+
state.destination,
|
|
141
|
+
['rev-parse', 'HEAD']
|
|
142
|
+
);
|
|
143
|
+
if (installedCommit !== state.incomingCommit) {
|
|
144
|
+
throw gitUpdateError(
|
|
145
|
+
'git-update',
|
|
146
|
+
`Git source did not advance to the planned commit: ${state.sourceId}`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function rollbackGitSourceFastForward(state) {
|
|
152
|
+
if (!state.recordPublished) return;
|
|
153
|
+
await writeSourceRecord(state.rootDir, state.previousRecord);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function discardGitSourceFastForward(state) {
|
|
157
|
+
await discardStagedSourceRecord(state.stagedRecord);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// === Private helpers ===
|
|
161
|
+
|
|
162
|
+
async function pathExists(targetPath) {
|
|
163
|
+
try {
|
|
164
|
+
await lstat(targetPath);
|
|
165
|
+
return true;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (error.code === 'ENOENT') return false;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function restorePublishedPath({
|
|
173
|
+
fileOperations,
|
|
174
|
+
backup,
|
|
175
|
+
target,
|
|
176
|
+
removeOptions
|
|
177
|
+
}) {
|
|
178
|
+
await fileOperations.rm(target, removeOptions);
|
|
179
|
+
await fileOperations.rename(backup, target);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function readGitOutput(directory, args) {
|
|
183
|
+
const result = await execGit(
|
|
184
|
+
directory,
|
|
185
|
+
args,
|
|
186
|
+
'source',
|
|
187
|
+
'Could not inspect Git source'
|
|
188
|
+
);
|
|
189
|
+
return result.stdout.trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function execGit(directory, args, sourceId, action) {
|
|
193
|
+
try {
|
|
194
|
+
return await execFile('git', ['-C', directory, ...args]);
|
|
195
|
+
} catch {
|
|
196
|
+
throw gitUpdateError('git-update', `${action}: ${sourceId}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function gitUpdateError(category, message, exitCode = 1) {
|
|
201
|
+
return new SourceAcquisitionError(category, message, exitCode);
|
|
202
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { access, lstat, readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
5
|
+
import { discoverSourceSkillDirectories } from './sourceSkillDiscovery.js';
|
|
6
|
+
|
|
7
|
+
const KNOWN_FRONTMATTER_FIELDS = new Set([
|
|
8
|
+
'allowed-tools',
|
|
9
|
+
'compatibility',
|
|
10
|
+
'description',
|
|
11
|
+
'disable-model-invocation',
|
|
12
|
+
'license',
|
|
13
|
+
'metadata',
|
|
14
|
+
'name'
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export async function validateStagedSource(sourceRoot) {
|
|
18
|
+
const skillDirectories = await discoverSourceSkillDirectories(sourceRoot);
|
|
19
|
+
if (skillDirectories.length === 0) {
|
|
20
|
+
throw validationError('No scanner-visible SKILL.md was found');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const skills = [];
|
|
24
|
+
const warnings = [];
|
|
25
|
+
for (const skillDirectory of skillDirectories) {
|
|
26
|
+
const skillPath = path.join(skillDirectory, 'SKILL.md');
|
|
27
|
+
const relativeDirectory = toRelativeSkillPath(sourceRoot, skillDirectory);
|
|
28
|
+
const content = await readRegularSkillFile(skillPath, relativeDirectory);
|
|
29
|
+
skills.push(relativeDirectory);
|
|
30
|
+
warnings.push(...qualityWarnings(relativeDirectory, content));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { skills: skills.sort(), warnings };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readRegularSkillFile(skillPath, relativeDirectory) {
|
|
37
|
+
const displayPath = relativeDirectory === '.' ? 'SKILL.md' : `${relativeDirectory}/SKILL.md`;
|
|
38
|
+
const stat = await lstat(skillPath);
|
|
39
|
+
if (!stat.isFile()) {
|
|
40
|
+
throw validationError(`${displayPath} must be a regular file`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await access(skillPath, constants.R_OK);
|
|
45
|
+
const content = await readFile(skillPath, 'utf8');
|
|
46
|
+
if (!content.trim()) throw validationError(`${displayPath} must not be empty`);
|
|
47
|
+
return content;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof SourceAcquisitionError) throw error;
|
|
50
|
+
throw validationError(`${displayPath} must be readable`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function qualityWarnings(skillPath, content) {
|
|
55
|
+
const frontmatter = parseFrontmatter(content);
|
|
56
|
+
if (!frontmatter) {
|
|
57
|
+
return [
|
|
58
|
+
warning('missing-frontmatter', skillPath, 'SKILL.md has no YAML frontmatter'),
|
|
59
|
+
warning('missing-description', skillPath, 'SKILL.md frontmatter has no description')
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
if (frontmatter.malformed) {
|
|
63
|
+
return [
|
|
64
|
+
warning('malformed-frontmatter', skillPath, 'SKILL.md frontmatter is not closed'),
|
|
65
|
+
warning('missing-description', skillPath, 'SKILL.md frontmatter has no description')
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const warnings = [];
|
|
70
|
+
if (!hasDescription(frontmatter.fields)) {
|
|
71
|
+
warnings.push(warning(
|
|
72
|
+
'missing-description',
|
|
73
|
+
skillPath,
|
|
74
|
+
'SKILL.md frontmatter has no description'
|
|
75
|
+
));
|
|
76
|
+
}
|
|
77
|
+
for (const field of frontmatter.fields.keys()) {
|
|
78
|
+
if (!KNOWN_FRONTMATTER_FIELDS.has(field)) {
|
|
79
|
+
warnings.push(warning(
|
|
80
|
+
'unknown-frontmatter-field',
|
|
81
|
+
skillPath,
|
|
82
|
+
`SKILL.md frontmatter contains unknown field: ${field}`
|
|
83
|
+
));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return warnings;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseFrontmatter(content) {
|
|
90
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
91
|
+
if (lines[0] !== '---') return null;
|
|
92
|
+
const closingIndex = lines.indexOf('---', 1);
|
|
93
|
+
if (closingIndex < 0) return { malformed: true, fields: new Map() };
|
|
94
|
+
|
|
95
|
+
const fields = new Map();
|
|
96
|
+
for (const line of lines.slice(1, closingIndex)) {
|
|
97
|
+
const match = line.match(/^([A-Za-z0-9_-]+)\s*:(.*)$/);
|
|
98
|
+
if (match) fields.set(match[1], match[2].trim());
|
|
99
|
+
}
|
|
100
|
+
return { malformed: false, fields };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasDescription(fields) {
|
|
104
|
+
if (!fields.has('description')) return false;
|
|
105
|
+
const value = fields.get('description');
|
|
106
|
+
return Boolean(value && !['""', "''", 'null', '~'].includes(value));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function warning(category, skillPath, message) {
|
|
110
|
+
return { category, skillPath, message };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validationError(message) {
|
|
114
|
+
return new SourceAcquisitionError('source-validation', message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toRelativeSkillPath(sourceRoot, skillDirectory) {
|
|
118
|
+
const relative = path.relative(sourceRoot, skillDirectory);
|
|
119
|
+
return relative ? relative.split(path.sep).join('/') : '.';
|
|
120
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdtemp,
|
|
3
|
+
rm,
|
|
4
|
+
rmdir
|
|
5
|
+
} from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { SourceAcquisitionError } from './sourceAcquisitionError.js';
|
|
8
|
+
import { ensureSourceDirectory } from './sourceTree.js';
|
|
9
|
+
|
|
10
|
+
export async function createManagedSourceWorkspace(rootDir, prefix) {
|
|
11
|
+
const parent = await requireSafeDirectory(
|
|
12
|
+
rootDir,
|
|
13
|
+
'.skillcaddy',
|
|
14
|
+
'staging'
|
|
15
|
+
);
|
|
16
|
+
return {
|
|
17
|
+
parent,
|
|
18
|
+
root: await mkdtemp(path.join(parent, prefix))
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function removeManagedSourceWorkspace(workspace) {
|
|
23
|
+
await rm(workspace.root, { recursive: true, force: true });
|
|
24
|
+
try {
|
|
25
|
+
await rmdir(workspace.parent);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function requireSafeDirectory(rootDir, ...segments) {
|
|
32
|
+
try {
|
|
33
|
+
return await ensureSourceDirectory(rootDir, ...segments);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
throw new SourceAcquisitionError('source-safety', error.message);
|
|
36
|
+
}
|
|
37
|
+
}
|