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.
@@ -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)