cyber-skills 0.0.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/bin/cyber-skills.mts +102 -0
- package/hooks/definitions/commit-discipline.mts +15 -0
- package/hooks/definitions/init.mts +22 -0
- package/hooks/inject-commit-discipline.mts +66 -0
- package/hooks/lib/commit-discipline-content.mts +53 -0
- package/hooks/lib/hook-command.mts +31 -0
- package/hooks/lib/package-root.mts +7 -0
- package/hooks/register-agent-hooks.mts +290 -0
- package/hooks/runtime/commit-discipline.mts +39 -0
- package/package.json +57 -0
- package/readme.md +76 -0
- package/skills/audit-skill/SKILL.md +271 -0
- package/skills/audit-skill/scripts/validate-skills.mts +495 -0
- package/skills/commit/SKILL.md +34 -0
- package/skills/configure-awesome-sources/SKILL.md +73 -0
- package/skills/configure-awesome-sources/scripts/configure-awesome-sources.mts +252 -0
- package/skills/create-skill/SKILL.md +126 -0
- package/skills/find-awesome-skill/SKILL.md +55 -0
- package/skills/find-awesome-skill/scripts/awesome-lib.mts +476 -0
- package/skills/find-awesome-skill/scripts/find-awesome-skill.mts +39 -0
- package/skills/init/SKILL.md +83 -0
- package/skills/init-commit-discipline/SKILL.md +75 -0
- package/skills/init-commit-discipline/scripts/resolve-commit-skill.mts +76 -0
- package/skills/patch-skill/SKILL.md +229 -0
- package/skills/skillify/SKILL.md +110 -0
- package/skills/update-awesome-list/SKILL.md +65 -0
- package/skills/update-awesome-list/scripts/inspect-skills-repo.mts +112 -0
- package/skills/update-awesome-list/scripts/render-awesome-list.mts +91 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
|
|
5
|
+
type EntryType = 'repo' | 'skill'
|
|
6
|
+
type RepoKind = 'targeted' | 'broad-catalog'
|
|
7
|
+
type TrustLevel = 'authored' | 'recommended'
|
|
8
|
+
type HighlightType = 'skill' | 'bundle' | 'workflow' | 'plugin' | 'mcp-server' | 'cli' | 'app' | 'doc'
|
|
9
|
+
type SourceClass = 'local-private' | 'repo-shared' | 'global-user' | 'default'
|
|
10
|
+
|
|
11
|
+
interface Highlight {
|
|
12
|
+
type: HighlightType
|
|
13
|
+
key: string
|
|
14
|
+
summary: string
|
|
15
|
+
why_recommended: string
|
|
16
|
+
tags: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RepoEntry {
|
|
20
|
+
type: 'repo'
|
|
21
|
+
repo: string
|
|
22
|
+
kind: RepoKind
|
|
23
|
+
trust: TrustLevel
|
|
24
|
+
summary: string
|
|
25
|
+
why_recommended: string
|
|
26
|
+
tags: string[]
|
|
27
|
+
highlights?: Highlight[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SkillEntry {
|
|
31
|
+
type: 'skill'
|
|
32
|
+
repo: string
|
|
33
|
+
skill: string
|
|
34
|
+
kind: RepoKind
|
|
35
|
+
trust: TrustLevel
|
|
36
|
+
summary: string
|
|
37
|
+
why_recommended: string
|
|
38
|
+
tags: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type AwesomeEntry = RepoEntry | SkillEntry
|
|
42
|
+
|
|
43
|
+
interface AwesomeListFile {
|
|
44
|
+
version: 1
|
|
45
|
+
repos: Record<string, Omit<RepoEntry, 'type'>>
|
|
46
|
+
skills: Record<string, Omit<SkillEntry, 'type'>>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SourceRef {
|
|
50
|
+
repo: string
|
|
51
|
+
path: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SourceConfigFile {
|
|
55
|
+
version: 1
|
|
56
|
+
sources?: SourceRef[]
|
|
57
|
+
disabled_sources?: SourceRef[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ResolvedSource extends SourceRef {
|
|
61
|
+
sourceClass: SourceClass
|
|
62
|
+
origin: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface AggregatedNote {
|
|
66
|
+
source: string
|
|
67
|
+
sourceClass: SourceClass
|
|
68
|
+
why_recommended: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface AggregatedEntry {
|
|
72
|
+
id: string
|
|
73
|
+
type: EntryType
|
|
74
|
+
repo: string
|
|
75
|
+
skill?: string
|
|
76
|
+
kind: RepoKind
|
|
77
|
+
trust: TrustLevel
|
|
78
|
+
summary: string
|
|
79
|
+
why_recommended: string
|
|
80
|
+
notes: AggregatedNote[]
|
|
81
|
+
tags: string[]
|
|
82
|
+
highlights?: Highlight[]
|
|
83
|
+
corroborationCount: number
|
|
84
|
+
sourceClasses: SourceClass[]
|
|
85
|
+
installCommand: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface SearchResult extends AggregatedEntry {
|
|
89
|
+
score: number
|
|
90
|
+
reasons: string[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface RemoteContentResponse {
|
|
94
|
+
content?: string
|
|
95
|
+
encoding?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const SOURCE_CLASS_RANK: Record<SourceClass, number> = {
|
|
99
|
+
default: 0,
|
|
100
|
+
'global-user': 1,
|
|
101
|
+
'repo-shared': 2,
|
|
102
|
+
'local-private': 3,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeRepo(repo: string): string {
|
|
106
|
+
return repo
|
|
107
|
+
.trim()
|
|
108
|
+
.replace(/^https?:\/\/github\.com\//, '')
|
|
109
|
+
.replace(/\.git$/, '')
|
|
110
|
+
.replace(/^\/+|\/+$/g, '')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizePath(filePath: string): string {
|
|
114
|
+
return filePath.trim().replace(/^\/+/, '') || 'awesome-skills.json'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeTag(tag: string): string {
|
|
118
|
+
return tag
|
|
119
|
+
.trim()
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
122
|
+
.replace(/^-+|-+$/g, '')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeTags(value: unknown): string[] {
|
|
126
|
+
if (!Array.isArray(value)) return []
|
|
127
|
+
return Array.from(new Set(value.map((item) => normalizeTag(String(item))).filter(Boolean))).sort()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function entryId(entry: AwesomeEntry): string {
|
|
131
|
+
return entry.type === 'repo' ? entry.repo : `${entry.repo}::${entry.skill}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function skillEntryId(repo: string, skill: string): string {
|
|
135
|
+
return `${repo}::${skill}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function highlightId(repo: string, highlight: Highlight): string {
|
|
139
|
+
return `${repo}::${highlight.type}::${highlight.key}`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sourceKey(source: SourceRef): string {
|
|
143
|
+
return `${normalizeRepo(source.repo)}::${normalizePath(source.path)}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function deriveInstallCommand(entry: AwesomeEntry): string {
|
|
147
|
+
return entry.type === 'repo' ? `npx skills add ${entry.repo}` : `npx skills add ${entry.repo} --skill ${entry.skill}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getLayerFilePath(cwd: string, sourceClass: Exclude<SourceClass, 'default'>): string {
|
|
151
|
+
switch (sourceClass) {
|
|
152
|
+
case 'local-private':
|
|
153
|
+
return path.join(cwd, '.agents', 'awesome-skill-sources.local.json')
|
|
154
|
+
case 'repo-shared':
|
|
155
|
+
return path.join(cwd, '.agents', 'awesome-skill-sources.json')
|
|
156
|
+
case 'global-user':
|
|
157
|
+
return path.join(os.homedir(), '.agents', 'awesome-skill-sources.json')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseRepositoryFromPackage(cwd: string): string | null {
|
|
162
|
+
const manifestPath = path.join(cwd, 'package.json')
|
|
163
|
+
if (!fs.existsSync(manifestPath)) return null
|
|
164
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { repository?: { url?: string } | string }
|
|
165
|
+
const repoUrl = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url
|
|
166
|
+
if (!repoUrl) return null
|
|
167
|
+
const match = repoUrl.match(/github\.com[:/](.+?)(?:\.git)?$/)
|
|
168
|
+
return match ? normalizeRepo(match[1]) : null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function validateHighlight(value: unknown, context: string): Highlight {
|
|
172
|
+
const highlight = value as Partial<Highlight>
|
|
173
|
+
if (!highlight || typeof highlight !== 'object') throw new Error(`${context} must be an object`)
|
|
174
|
+
if (!highlight.type || !highlight.key || !highlight.summary || !highlight.why_recommended) {
|
|
175
|
+
throw new Error(`${context} must include type, key, summary, and why_recommended`)
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
type: highlight.type,
|
|
179
|
+
key: String(highlight.key),
|
|
180
|
+
summary: String(highlight.summary),
|
|
181
|
+
why_recommended: String(highlight.why_recommended),
|
|
182
|
+
tags: normalizeTags(highlight.tags),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function validateAwesomeList(data: unknown, origin: string): AwesomeListFile {
|
|
187
|
+
const file = data as Partial<AwesomeListFile>
|
|
188
|
+
if (
|
|
189
|
+
!file ||
|
|
190
|
+
typeof file !== 'object' ||
|
|
191
|
+
file.version !== 1 ||
|
|
192
|
+
typeof file.repos !== 'object' ||
|
|
193
|
+
file.repos === null ||
|
|
194
|
+
typeof file.skills !== 'object' ||
|
|
195
|
+
file.skills === null
|
|
196
|
+
) {
|
|
197
|
+
throw new Error(`${origin} must contain version: 1 plus repos and skills objects`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const repos = Object.fromEntries(
|
|
201
|
+
Object.entries(file.repos).map(([key, raw], index) => {
|
|
202
|
+
const entry = raw as Partial<Omit<RepoEntry, 'type'>>
|
|
203
|
+
const context = `${origin} repo ${index + 1}`
|
|
204
|
+
if (!entry || typeof entry !== 'object') throw new Error(`${context} must be an object`)
|
|
205
|
+
if (!entry.repo || !entry.summary || !entry.why_recommended || !entry.kind || !entry.trust) {
|
|
206
|
+
throw new Error(`${context} is missing required fields`)
|
|
207
|
+
}
|
|
208
|
+
const normalizedRepo = normalizeRepo(entry.repo)
|
|
209
|
+
if (key !== normalizedRepo) throw new Error(`${context} key must match normalized repo ${normalizedRepo}`)
|
|
210
|
+
return [
|
|
211
|
+
key,
|
|
212
|
+
{
|
|
213
|
+
repo: normalizedRepo,
|
|
214
|
+
kind: entry.kind,
|
|
215
|
+
trust: entry.trust,
|
|
216
|
+
summary: String(entry.summary),
|
|
217
|
+
why_recommended: String(entry.why_recommended),
|
|
218
|
+
tags: normalizeTags(entry.tags),
|
|
219
|
+
highlights: Array.isArray(entry.highlights)
|
|
220
|
+
? entry.highlights.map((item, i) => validateHighlight(item, `${context} highlight ${i + 1}`))
|
|
221
|
+
: [],
|
|
222
|
+
} satisfies Omit<RepoEntry, 'type'>,
|
|
223
|
+
]
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
const skills = Object.fromEntries(
|
|
228
|
+
Object.entries(file.skills).map(([key, raw], index) => {
|
|
229
|
+
const entry = raw as Partial<Omit<SkillEntry, 'type'>>
|
|
230
|
+
const context = `${origin} skill ${index + 1}`
|
|
231
|
+
if (!entry || typeof entry !== 'object') throw new Error(`${context} must be an object`)
|
|
232
|
+
if (!entry.repo || !entry.skill || !entry.summary || !entry.why_recommended || !entry.kind || !entry.trust) {
|
|
233
|
+
throw new Error(`${context} is missing required fields`)
|
|
234
|
+
}
|
|
235
|
+
const normalizedRepo = normalizeRepo(entry.repo)
|
|
236
|
+
const normalizedSkill = String(entry.skill)
|
|
237
|
+
const normalizedId = skillEntryId(normalizedRepo, normalizedSkill)
|
|
238
|
+
if (key !== normalizedId) throw new Error(`${context} key must match normalized skill id ${normalizedId}`)
|
|
239
|
+
return [
|
|
240
|
+
key,
|
|
241
|
+
{
|
|
242
|
+
repo: normalizedRepo,
|
|
243
|
+
skill: normalizedSkill,
|
|
244
|
+
kind: entry.kind,
|
|
245
|
+
trust: entry.trust,
|
|
246
|
+
summary: String(entry.summary),
|
|
247
|
+
why_recommended: String(entry.why_recommended),
|
|
248
|
+
tags: normalizeTags(entry.tags),
|
|
249
|
+
} satisfies Omit<SkillEntry, 'type'>,
|
|
250
|
+
]
|
|
251
|
+
}),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return { version: 1, repos, skills }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function flattenAwesomeEntries(file: AwesomeListFile): AwesomeEntry[] {
|
|
258
|
+
const repoEntries = Object.values(file.repos).map((entry) => ({
|
|
259
|
+
type: 'repo' as const,
|
|
260
|
+
...entry,
|
|
261
|
+
}))
|
|
262
|
+
const skillEntries = Object.values(file.skills).map((entry) => ({
|
|
263
|
+
type: 'skill' as const,
|
|
264
|
+
...entry,
|
|
265
|
+
}))
|
|
266
|
+
return [...repoEntries, ...skillEntries]
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function loadSourceConfigFile(filePath: string): SourceConfigFile {
|
|
270
|
+
if (!fs.existsSync(filePath)) return { version: 1, sources: [], disabled_sources: [] }
|
|
271
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as SourceConfigFile
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function getResolvedSources(cwd: string): ResolvedSource[] {
|
|
275
|
+
const layers: Array<{ sourceClass: Exclude<SourceClass, 'default'>; path: string }> = [
|
|
276
|
+
{ sourceClass: 'local-private', path: getLayerFilePath(cwd, 'local-private') },
|
|
277
|
+
{ sourceClass: 'repo-shared', path: getLayerFilePath(cwd, 'repo-shared') },
|
|
278
|
+
{ sourceClass: 'global-user', path: getLayerFilePath(cwd, 'global-user') },
|
|
279
|
+
]
|
|
280
|
+
const disabled = new Set<string>()
|
|
281
|
+
const kept = new Map<string, ResolvedSource>()
|
|
282
|
+
|
|
283
|
+
for (const layer of layers) {
|
|
284
|
+
const config = loadSourceConfigFile(layer.path)
|
|
285
|
+
for (const ref of config.disabled_sources ?? []) disabled.add(sourceKey(ref))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const currentRepo = parseRepositoryFromPackage(cwd)
|
|
289
|
+
if (currentRepo) {
|
|
290
|
+
const ref = { repo: currentRepo, path: 'awesome-skills.json' }
|
|
291
|
+
const key = sourceKey(ref)
|
|
292
|
+
if (!disabled.has(key) && fs.existsSync(path.join(cwd, ref.path))) {
|
|
293
|
+
kept.set(key, { ...ref, sourceClass: 'default', origin: path.join(cwd, ref.path) })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const layer of layers.slice().reverse()) {
|
|
298
|
+
const config = loadSourceConfigFile(layer.path)
|
|
299
|
+
for (const ref of config.sources ?? []) {
|
|
300
|
+
const key = sourceKey(ref)
|
|
301
|
+
if (disabled.has(key)) continue
|
|
302
|
+
const existing = kept.get(key)
|
|
303
|
+
if (!existing || SOURCE_CLASS_RANK[layer.sourceClass] > SOURCE_CLASS_RANK[existing.sourceClass]) {
|
|
304
|
+
kept.set(key, { ...ref, sourceClass: layer.sourceClass, origin: layer.path })
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return Array.from(kept.values()).sort((a, b) => SOURCE_CLASS_RANK[b.sourceClass] - SOURCE_CLASS_RANK[a.sourceClass])
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function loadAwesomeListFromSource(source: ResolvedSource, cwd: string): Promise<AwesomeListFile> {
|
|
313
|
+
const currentRepo = parseRepositoryFromPackage(cwd)
|
|
314
|
+
if (currentRepo && source.repo === currentRepo) {
|
|
315
|
+
return validateAwesomeList(JSON.parse(fs.readFileSync(path.join(cwd, source.path), 'utf8')), source.path)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const response = await fetch(`https://api.github.com/repos/${source.repo}/contents/${source.path}`, {
|
|
319
|
+
headers: {
|
|
320
|
+
Accept: 'application/vnd.github+json',
|
|
321
|
+
'User-Agent': 'cyber-skills-awesome-skills',
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
if (!response.ok)
|
|
325
|
+
throw new Error(`Failed to fetch ${source.repo}/${source.path}: ${response.status} ${response.statusText}`)
|
|
326
|
+
const body = (await response.json()) as RemoteContentResponse
|
|
327
|
+
if (!body.content || body.encoding !== 'base64') {
|
|
328
|
+
throw new Error(`GitHub contents API did not return base64 content for ${source.repo}/${source.path}`)
|
|
329
|
+
}
|
|
330
|
+
return validateAwesomeList(
|
|
331
|
+
JSON.parse(Buffer.from(body.content, 'base64').toString('utf8')),
|
|
332
|
+
`${source.repo}/${source.path}`,
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function loadAllAwesomeLists(cwd: string): Promise<Array<{ source: ResolvedSource; file: AwesomeListFile }>> {
|
|
337
|
+
const loaded: Array<{ source: ResolvedSource; file: AwesomeListFile }> = []
|
|
338
|
+
for (const source of getResolvedSources(cwd)) {
|
|
339
|
+
loaded.push({ source, file: await loadAwesomeListFromSource(source, cwd) })
|
|
340
|
+
}
|
|
341
|
+
return loaded
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function mergeAwesomeEntries(loaded: Array<{ source: ResolvedSource; file: AwesomeListFile }>): AggregatedEntry[] {
|
|
345
|
+
const byId = new Map<
|
|
346
|
+
string,
|
|
347
|
+
{
|
|
348
|
+
canonical: AwesomeEntry
|
|
349
|
+
canonicalSourceClass: SourceClass
|
|
350
|
+
tags: Set<string>
|
|
351
|
+
notes: AggregatedNote[]
|
|
352
|
+
highlights: Map<string, Highlight>
|
|
353
|
+
sourceClasses: Set<SourceClass>
|
|
354
|
+
}
|
|
355
|
+
>()
|
|
356
|
+
|
|
357
|
+
for (const { source, file } of loaded) {
|
|
358
|
+
for (const entry of flattenAwesomeEntries(file)) {
|
|
359
|
+
const id = entryId(entry)
|
|
360
|
+
const note = {
|
|
361
|
+
source: `${source.repo}/${source.path}`,
|
|
362
|
+
sourceClass: source.sourceClass,
|
|
363
|
+
why_recommended: entry.why_recommended,
|
|
364
|
+
}
|
|
365
|
+
const existing = byId.get(id)
|
|
366
|
+
if (!existing) {
|
|
367
|
+
byId.set(id, {
|
|
368
|
+
canonical: entry,
|
|
369
|
+
canonicalSourceClass: source.sourceClass,
|
|
370
|
+
tags: new Set(entry.tags),
|
|
371
|
+
notes: [note],
|
|
372
|
+
highlights: new Map(
|
|
373
|
+
(entry.type === 'repo' ? (entry.highlights ?? []) : []).map((item) => [
|
|
374
|
+
highlightId(entry.repo, item),
|
|
375
|
+
item,
|
|
376
|
+
]),
|
|
377
|
+
),
|
|
378
|
+
sourceClasses: new Set([source.sourceClass]),
|
|
379
|
+
})
|
|
380
|
+
continue
|
|
381
|
+
}
|
|
382
|
+
existing.sourceClasses.add(source.sourceClass)
|
|
383
|
+
for (const tag of entry.tags) existing.tags.add(tag)
|
|
384
|
+
if (
|
|
385
|
+
!existing.notes.some((item) => item.source === note.source && item.why_recommended === note.why_recommended)
|
|
386
|
+
) {
|
|
387
|
+
existing.notes.push(note)
|
|
388
|
+
}
|
|
389
|
+
if (entry.type === 'repo') {
|
|
390
|
+
for (const highlight of entry.highlights ?? []) {
|
|
391
|
+
const key = highlightId(entry.repo, highlight)
|
|
392
|
+
if (!existing.highlights.has(key)) existing.highlights.set(key, highlight)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (SOURCE_CLASS_RANK[source.sourceClass] > SOURCE_CLASS_RANK[existing.canonicalSourceClass]) {
|
|
396
|
+
existing.canonical = entry
|
|
397
|
+
existing.canonicalSourceClass = source.sourceClass
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return Array.from(byId.entries()).map(([id, state]) => ({
|
|
403
|
+
id,
|
|
404
|
+
type: state.canonical.type,
|
|
405
|
+
repo: state.canonical.repo,
|
|
406
|
+
skill: state.canonical.type === 'skill' ? state.canonical.skill : undefined,
|
|
407
|
+
kind: state.canonical.kind,
|
|
408
|
+
trust: state.canonical.trust,
|
|
409
|
+
summary: state.canonical.summary,
|
|
410
|
+
why_recommended: state.notes[0]?.why_recommended ?? state.canonical.why_recommended,
|
|
411
|
+
notes: state.notes,
|
|
412
|
+
tags: Array.from(state.tags).sort(),
|
|
413
|
+
highlights:
|
|
414
|
+
state.canonical.type === 'repo'
|
|
415
|
+
? Array.from(state.highlights.values()).sort((a, b) => a.key.localeCompare(b.key))
|
|
416
|
+
: undefined,
|
|
417
|
+
corroborationCount: state.notes.length,
|
|
418
|
+
sourceClasses: Array.from(state.sourceClasses).sort((a, b) => SOURCE_CLASS_RANK[b] - SOURCE_CLASS_RANK[a]),
|
|
419
|
+
installCommand: deriveInstallCommand(state.canonical),
|
|
420
|
+
}))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function tokenize(value: string): string[] {
|
|
424
|
+
return value
|
|
425
|
+
.toLowerCase()
|
|
426
|
+
.split(/[^a-z0-9]+/)
|
|
427
|
+
.filter(Boolean)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function findAwesomeSkills(cwd: string, query: string): Promise<SearchResult[]> {
|
|
431
|
+
const loaded = await loadAllAwesomeLists(cwd)
|
|
432
|
+
const entries = mergeAwesomeEntries(loaded)
|
|
433
|
+
const q = query.trim().toLowerCase()
|
|
434
|
+
const tokens = tokenize(q)
|
|
435
|
+
|
|
436
|
+
return entries
|
|
437
|
+
.map((entry) => {
|
|
438
|
+
let score = 0
|
|
439
|
+
const reasons: string[] = []
|
|
440
|
+
const repoText = entry.repo.toLowerCase()
|
|
441
|
+
const skillText = entry.skill?.toLowerCase() ?? ''
|
|
442
|
+
const summaryText = entry.summary.toLowerCase()
|
|
443
|
+
const highlightText = (entry.highlights ?? [])
|
|
444
|
+
.map((item) => `${item.type} ${item.key} ${item.summary} ${item.tags.join(' ')}`)
|
|
445
|
+
.join(' ')
|
|
446
|
+
.toLowerCase()
|
|
447
|
+
|
|
448
|
+
if (!q) {
|
|
449
|
+
score += entry.trust === 'authored' ? 10 : 0
|
|
450
|
+
score += entry.corroborationCount
|
|
451
|
+
}
|
|
452
|
+
if (q && (repoText === q || skillText === q)) {
|
|
453
|
+
score += 100
|
|
454
|
+
reasons.push('exact name match')
|
|
455
|
+
}
|
|
456
|
+
if (q && (repoText.includes(q) || skillText.includes(q) || highlightText.includes(q))) {
|
|
457
|
+
score += 50
|
|
458
|
+
reasons.push('name contains query')
|
|
459
|
+
}
|
|
460
|
+
for (const token of tokens) {
|
|
461
|
+
if (summaryText.includes(token)) score += 10
|
|
462
|
+
if (entry.tags.some((tag) => tag.includes(token))) score += 12
|
|
463
|
+
if (highlightText.includes(token)) score += 8
|
|
464
|
+
}
|
|
465
|
+
const corroborationBonus = Math.max(0, entry.corroborationCount - 1) * 6
|
|
466
|
+
score += corroborationBonus
|
|
467
|
+
if (corroborationBonus > 0) reasons.push(`recommended by ${entry.corroborationCount} sources`)
|
|
468
|
+
if (entry.sourceClasses.includes('local-private')) score += 6
|
|
469
|
+
else if (entry.sourceClasses.includes('repo-shared')) score += 4
|
|
470
|
+
else if (entry.sourceClasses.includes('global-user')) score += 2
|
|
471
|
+
if (score > 0 && tokens.some((token) => entry.tags.includes(token))) reasons.push('tag match')
|
|
472
|
+
return { ...entry, score, reasons: Array.from(new Set(reasons)) }
|
|
473
|
+
})
|
|
474
|
+
.filter((entry) => entry.score > 0 || !q)
|
|
475
|
+
.sort((a, b) => b.score - a.score || a.repo.localeCompare(b.repo) || (a.skill ?? '').localeCompare(b.skill ?? ''))
|
|
476
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { findAwesomeSkills } from './awesome-lib.mts'
|
|
3
|
+
|
|
4
|
+
function parseArgs(argv: string[]): { query: string; limit: number; json: boolean } {
|
|
5
|
+
const args = argv.slice(2)
|
|
6
|
+
const json = args.includes('--json')
|
|
7
|
+
const limitIdx = args.indexOf('--limit')
|
|
8
|
+
const limit = limitIdx !== -1 ? Number(args[limitIdx + 1]) : 8
|
|
9
|
+
const query = args
|
|
10
|
+
.filter((arg, index) => arg !== '--json' && index !== limitIdx && index !== limitIdx + 1)
|
|
11
|
+
.join(' ')
|
|
12
|
+
.trim()
|
|
13
|
+
return { query, limit: Number.isFinite(limit) && limit > 0 ? limit : 8, json }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { query, limit, json } = parseArgs(process.argv)
|
|
17
|
+
const results = (await findAwesomeSkills(process.cwd(), query)).slice(0, limit)
|
|
18
|
+
|
|
19
|
+
if (json) {
|
|
20
|
+
console.log(JSON.stringify(results, null, 2))
|
|
21
|
+
process.exit(0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (results.length === 0) {
|
|
25
|
+
console.log(query ? `No awesome skill matches for "${query}".` : 'No awesome skill entries found.')
|
|
26
|
+
process.exit(0)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(query ? `Awesome skill matches for "${query}":` : 'Awesome skill recommendations:')
|
|
30
|
+
for (const result of results) {
|
|
31
|
+
const title = result.type === 'repo' ? result.repo : `${result.repo}#${result.skill}`
|
|
32
|
+
console.log(`\n- ${title} (${result.kind}, ${result.trust})`)
|
|
33
|
+
console.log(` ${result.summary}`)
|
|
34
|
+
console.log(` Why recommended: ${result.why_recommended}`)
|
|
35
|
+
if (result.reasons.length > 0) console.log(` Match: ${result.reasons.join('; ')}`)
|
|
36
|
+
if (result.corroborationCount > 1)
|
|
37
|
+
console.log(` Also recommended by ${result.corroborationCount - 1} other source(s).`)
|
|
38
|
+
console.log(` Install: ${result.installCommand}`)
|
|
39
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: init
|
|
3
|
+
description: Use this skill when initializing or improving an AGENTS.md file with codebase documentation for AI coding assistants.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Analyze this codebase and create or improve an AGENTS.md file, then symlink CLAUDE.md to it.
|
|
7
|
+
|
|
8
|
+
What to include:
|
|
9
|
+
1. Commands commonly used for building, linting, and running tests — including how to run a single test.
|
|
10
|
+
2. High-level architecture and code structure that requires reading multiple files to understand. Focus on the big picture, not file listings.
|
|
11
|
+
3. A "Skill Augmentations" section with this exact content:
|
|
12
|
+
|
|
13
|
+
```markdown
|
|
14
|
+
## Skill Augmentations
|
|
15
|
+
|
|
16
|
+
When reading any `SKILL.md` file, always check whether a `SKILL.local.md` exists in the same directory. If it does, treat its contents as additional instructions that extend the base skill. Local augmentations take precedence over the base skill where they conflict.
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Usage notes:
|
|
20
|
+
- If there's already an AGENTS.md, suggest improvements to it.
|
|
21
|
+
- Avoid listing every component or file structure that can be easily discovered.
|
|
22
|
+
- Do not make up sections like "Common Development Tasks" or "Tips for Development" unless that content appears in existing project files.
|
|
23
|
+
- If there are Cursor rules (in `.cursor/rules/` or `.cursorrules`) or Copilot rules (in `.github/copilot-instructions.md`), include the important parts.
|
|
24
|
+
- If there is a README.md, include the important parts.
|
|
25
|
+
- Prefix the file with:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
# AGENTS.md
|
|
29
|
+
|
|
30
|
+
This file provides guidance to AI coding assistants when working with code in this repository.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
After writing AGENTS.md, scan for repo-internal skills and mark them. For each `SKILL.md` found under `.agents/skills/`, ensure the frontmatter includes:
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
metadata:
|
|
37
|
+
internal: true
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Add it if missing. This prevents these skills from being accidentally surfaced as public or globally available.
|
|
41
|
+
|
|
42
|
+
Then register hooks so these behaviors apply automatically going forward, not just at init time.
|
|
43
|
+
|
|
44
|
+
### Hook registration
|
|
45
|
+
|
|
46
|
+
Invoke the `cyber-skills` CLI from the repo root. Do **not** add `cyber-skills` as a devDependency by default — it is bin-only tooling and will trigger unused-dependency warnings (for example from knip) in repos that never import it.
|
|
47
|
+
|
|
48
|
+
**Default (global init skill):** pinned npx with an explicit version (never `@latest`):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx cyber-skills@<version> register-hooks --set init
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Optional devDependency:** only when the user needs offline CLI access *and* the AI agent runs locally against that repo (`pnpm add -D cyber-skills`, then `pnpm exec cyber-skills …`).
|
|
55
|
+
|
|
56
|
+
The command detects which agents are present (`.claude/`, `.cursor/`, `.codex-plugin/`), deep-merges the required hook entries for each without clobbering other settings, and exits quietly. It is idempotent — safe to re-run. Pass `--verbose` for a human-readable summary on stderr.
|
|
57
|
+
|
|
58
|
+
The two hook scripts it registers live in `.agents/hooks/`:
|
|
59
|
+
|
|
60
|
+
- **`mark-internal.sh`** — PostToolUse/afterFileEdit: patches `metadata: internal: true` into any SKILL.md written under `.agents/skills/`
|
|
61
|
+
- **`inject-local-augmentations.sh`** — SessionStart: surfaces `SKILL.local.md` contents as session context at the start of every session
|
|
62
|
+
|
|
63
|
+
Registration logic lives in the `cyber-skills` npm package (`hooks/register-agent-hooks.mts`).
|
|
64
|
+
|
|
65
|
+
For **commit discipline** (AGENTS.md section + SessionStart hook), invoke the `init-commit-discipline` skill after init.
|
|
66
|
+
|
|
67
|
+
For **other agents** (OpenCode, etc.): if they expose a documented repo-level hook system, register the equivalent hooks. Otherwise skip hook registration and rely on AGENTS.md for the `SKILL.local.md` behaviour.
|
|
68
|
+
|
|
69
|
+
> Note: `npx skills` does not yet manage runtime hook registration automatically. Follow https://github.com/vercel-labs/skills/issues/1231 — once resolved, the manual steps above should become `npx skills add` side-effects.
|
|
70
|
+
|
|
71
|
+
Then create the CLAUDE.md symlink. Detect the platform first:
|
|
72
|
+
|
|
73
|
+
**Unix / macOS / Linux:**
|
|
74
|
+
```bash
|
|
75
|
+
[ -f CLAUDE.md ] && ! [ -L CLAUDE.md ] && rm CLAUDE.md
|
|
76
|
+
ln -sf AGENTS.md CLAUDE.md
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Windows (PowerShell):**
|
|
80
|
+
```powershell
|
|
81
|
+
if (Test-Path CLAUDE.md -PathType Leaf) { Remove-Item CLAUDE.md }
|
|
82
|
+
New-Item -ItemType SymbolicLink -Name CLAUDE.md -Target AGENTS.md
|
|
83
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: init-commit-discipline
|
|
3
|
+
description: "Use this skill when initializing commit discipline — AGENTS.md rules and SessionStart hooks where agents support them."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Init Commit Discipline
|
|
7
|
+
|
|
8
|
+
Inject always-on commit discipline into the repo: an AGENTS.md section for every agent, plus SessionStart hooks on agents that support them.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
- `AGENTS.md` should exist (run the `init` skill first if missing).
|
|
13
|
+
- The `cyber-skills` npm package must be invokable (see below).
|
|
14
|
+
|
|
15
|
+
## Commit helper skill
|
|
16
|
+
|
|
17
|
+
Commit discipline references a **commit helper skill** for staging, splitting, and message writing. Resolve one before injecting AGENTS.md.
|
|
18
|
+
|
|
19
|
+
Run from the repo root:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx tsx skills/init-commit-discipline/scripts/resolve-commit-skill.mts --check
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If none are detected, ask the user to choose:
|
|
26
|
+
|
|
27
|
+
| Option | Action |
|
|
28
|
+
|--------|--------|
|
|
29
|
+
| **A — Recommended** | Install [`softaworks/agent-toolkit@commit-work`](https://github.com/softaworks/agent-toolkit): `npx skills add softaworks/agent-toolkit --skill commit-work -g` |
|
|
30
|
+
| **B — User override** | User names another commit skill to install or reference |
|
|
31
|
+
| **C — Bundled fallback** | Install cyber-skills' minimal `commit` skill: `npx skills add cyberuni/cyber-skills --skill commit -g` |
|
|
32
|
+
|
|
33
|
+
Do not proceed until a commit helper skill name is chosen.
|
|
34
|
+
|
|
35
|
+
## Ensure cyber-skills package
|
|
36
|
+
|
|
37
|
+
Do **not** add `cyber-skills` as a devDependency by default — it is bin-only tooling and will trigger unused-dependency warnings (for example from knip) in repos that never import it.
|
|
38
|
+
|
|
39
|
+
Check in order:
|
|
40
|
+
|
|
41
|
+
1. **Pinned npx (default)** — `npx cyber-skills@<version> <subcommand>` with an explicit version (never `@latest`). No `package.json` change; use when init skills are installed globally.
|
|
42
|
+
2. **Existing devDependency** — if `cyber-skills` is already in `package.json`, use `pnpm exec cyber-skills` or the local bin.
|
|
43
|
+
3. **Optional devDependency** — only when the user needs offline CLI access *and* the AI agent runs locally against that repo: `pnpm add -D cyber-skills`.
|
|
44
|
+
4. If neither npx nor a local install works, ask the user to confirm a pinned npx version or opt in to the devDependency above.
|
|
45
|
+
|
|
46
|
+
## Workflow
|
|
47
|
+
|
|
48
|
+
1. Resolve commit helper skill (above).
|
|
49
|
+
2. Inject AGENTS.md section:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx cyber-skills@<version> inject-commit-discipline --commit-skill <name>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
3. Register SessionStart hook:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npx cyber-skills@<version> register-hooks --set commit-discipline
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Pass `--verbose` on either command for a human-readable summary. Pass `--dry-run` to preview without writing.
|
|
62
|
+
|
|
63
|
+
## What gets applied
|
|
64
|
+
|
|
65
|
+
**AGENTS.md** (all agents): `## Commit Discipline` with Conventional Commits rules and a pointer to the chosen commit helper skill.
|
|
66
|
+
|
|
67
|
+
**Runtime hook** (Claude Code, Codex): SessionStart injection of the same discipline so the agent commits each self-contained unit of work before moving on.
|
|
68
|
+
|
|
69
|
+
For agents without hook support, AGENTS.md alone applies the rules.
|
|
70
|
+
|
|
71
|
+
## Related skills
|
|
72
|
+
|
|
73
|
+
- **`init`** — create AGENTS.md and register skill-augmentation hooks
|
|
74
|
+
- **`commit`** — bundled minimal commit helper (cyber-asana-style)
|
|
75
|
+
- **`commit-work`** — full staging/splitting workflow from softaworks/agent-toolkit (recommended)
|