#!/usr/bin/env node
/**
* Skill Update Detection Mechanism
*
* Tracks when skills were last updated and detects if underlying repos have changed.
*
* Usage:
* node scripts/check-skill-updates.js # Check all skills
* node scripts/check-skill-updates.js hypercore # Check specific skill
* node scripts/check-skill-updates.js --report # Generate detailed report
* node scripts/check-skill-updates.js --stale # Show only stale skills
*/
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { execSync } from 'child_process'
const __dirname = dirname(fileURLToPath(import.meta.url))
const KB_ROOT = join(__dirname, '..')
const SKILLS_DIR = join(KB_ROOT, '.claude', 'skills')
const METADATA_FILE = join(SKILLS_DIR, '.skill-metadata.json')
const HOLEPUNCHTO_REPOS = join(KB_ROOT, 'holepunchto', 'repos')
const HYPER_FLEEKY_ROOT = join(KB_ROOT, 'hyper-fleeky')
// Load or create metadata
function loadMetadata() {
if (!existsSync(METADATA_FILE)) {
return {}
}
return JSON.parse(readFileSync(METADATA_FILE, 'utf-8'))
}
function saveMetadata(metadata) {
writeFileSync(METADATA_FILE, JSON.stringify(metadata, null, 2))
}
// Get all skills
function getAllSkills() {
const skills = []
const entries = readdirSync(SKILLS_DIR, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const skillFile = join(SKILLS_DIR, entry.name, 'SKILL.md')
if (existsSync(skillFile)) {
skills.push({
name: entry.name,
path: skillFile,
category: detectCategory(entry.name)
})
}
}
}
return skills
}
// Detect skill category
function detectCategory(skillName) {
if (['hyper-wallet', 'hyper-lab', 'wirecore', 'hyperident', 'hyperident-gateway', 'fedicore'].includes(skillName)) {
return 'hyper-fleeky'
}
if (skillName.startsWith('bare-') || skillName === 'bare' || skillName === 'pear' || skillName === 'pear-interface') {
return 'pear'
}
if (skillName.startsWith('wdk-')) {
return 'tetherto'
}
return 'holepunchto'
}
// Get repo path for a skill
function getRepoPath(skill) {
const { name, category } = skill
if (category === 'hyper-fleeky') {
// Check hyper-fleeky projects
const projectPath = join(HYPER_FLEEKY_ROOT, name)
if (existsSync(projectPath)) return projectPath
// Check in repos subdirectory
const reposPath = join(HYPER_FLEEKY_ROOT, 'repos', name)
if (existsSync(reposPath)) return reposPath
}
if (category === 'holepunchto' || category === 'pear') {
const repoPath = join(HOLEPUNCHTO_REPOS, name)
if (existsSync(repoPath)) return repoPath
}
return null
}
// Get last commit date for a repo
function getLastCommitDate(repoPath) {
try {
const result = execSync('git log -1 --format=%ct', {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim()
return parseInt(result) * 1000 // Convert to milliseconds
} catch (err) {
return null
}
}
// Get commits since a date
function getCommitsSince(repoPath, sinceDate) {
try {
const since = Math.floor(sinceDate / 1000) // Convert to seconds
const result = execSync(`git log --since=${since} --oneline`, {
cwd: repoPath,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim()
if (!result) return []
return result.split('\n').map(line => {
const [hash, ...message] = line.split(' ')
return { hash, message: message.join(' ') }
})
} catch (err) {
return []
}
}
// Parse skill frontmatter to get creation date
async function getSkillCreationDate(skillPath) {
try {
const content = readFileSync(skillPath, 'utf-8')
const match = content.match(/^---\n([\s\S]*?)\n---/)
if (match) {
const frontmatter = match[1]
// Check for created or updated field
const createdMatch = frontmatter.match(/(?:created|updated):\s*(.+)/)
if (createdMatch) {
return new Date(createdMatch[1]).getTime()
}
}
// Fallback to file modification time
const { stat } = await import('fs/promises')
const stats = await stat(skillPath)
return stats.mtime.getTime()
} catch (err) {
return Date.now()
}
}
// Check if skill is stale
async function checkSkillStatus(skill, metadata) {
const repoPath = getRepoPath(skill)
if (!repoPath) {
return {
...skill,
status: 'no-repo',
message: 'Repository not found',
needsUpdate: false
}
}
if (!existsSync(join(repoPath, '.git'))) {
return {
...skill,
status: 'not-git',
message: 'Not a git repository',
needsUpdate: false
}
}
// Get skill last updated date from metadata
let skillLastUpdated = metadata[skill.name]?.lastUpdated
if (!skillLastUpdated) {
// Try to get from skill file
skillLastUpdated = await getSkillCreationDate(skill.path)
}
// Get repo last commit date
const repoLastCommit = getLastCommitDate(repoPath)
if (!repoLastCommit) {
return {
...skill,
status: 'error',
message: 'Could not get git history',
needsUpdate: false
}
}
// Check if repo has commits since skill was updated
const isStale = repoLastCommit > skillLastUpdated
let commits = []
if (isStale) {
commits = getCommitsSince(repoPath, skillLastUpdated)
}
return {
...skill,
status: isStale ? 'stale' : 'up-to-date',
skillLastUpdated,
repoLastCommit,
repoPath,
commitsSince: commits.length,
commits: commits.slice(0, 5), // Only show first 5
needsUpdate: isStale && commits.length > 0
}
}
// Generate report
function generateReport(results, options = {}) {
const { staleOnly = false, verbose = false } = options
const stale = results.filter(r => r.needsUpdate)
const upToDate = results.filter(r => r.status === 'up-to-date')
const other = results.filter(r => !r.needsUpdate && r.status !== 'up-to-date')
console.log('\n=== Skill Update Status ===\n')
console.log(`Total Skills: ${results.length}`)
console.log(`Up to Date: ${upToDate.length}`)
console.log(`Stale (need update): ${stale.length}`)
console.log(`Other: ${other.length}`)
if (stale.length > 0) {
console.log('\n--- Stale Skills (Need Update) ---\n')
for (const skill of stale) {
console.log(`\n📦 ${skill.name} (${skill.category})`)
console.log(` Last Updated: ${new Date(skill.skillLastUpdated).toISOString().split('T')[0]}`)
console.log(` Repo Updated: ${new Date(skill.repoLastCommit).toISOString().split('T')[0]}`)
console.log(` Commits Since: ${skill.commitsSince}`)
if (verbose && skill.commits.length > 0) {
console.log(` Recent commits:`)
for (const commit of skill.commits) {
console.log(` - ${commit.hash.substring(0, 7)}: ${commit.message}`)
}
}
}
}
if (!staleOnly && other.length > 0) {
console.log('\n--- Other Skills ---\n')
for (const skill of other) {
console.log(`${skill.name}: ${skill.status} - ${skill.message}`)
}
}
if (!staleOnly && verbose && upToDate.length > 0) {
console.log('\n--- Up to Date Skills ---\n')
for (const skill of upToDate) {
const daysSince = Math.floor((Date.now() - skill.skillLastUpdated) / (1000 * 60 * 60 * 24))
console.log(`✅ ${skill.name} (last checked ${daysSince} days ago)`)
}
}
console.log('\n')
}
// Mark skill as updated (use this after regenerating a skill)
function markSkillUpdated(skillName) {
const metadata = loadMetadata()
metadata[skillName] = {
lastUpdated: Date.now(),
lastChecked: Date.now()
}
saveMetadata(metadata)
console.log(`✅ Marked ${skillName} as updated`)
}
// Main
async function main() {
const args = process.argv.slice(2)
// Check for flags
const showReport = args.includes('--report')
const staleOnly = args.includes('--stale')
const verbose = args.includes('--verbose') || args.includes('-v')
const markUpdated = args.find(a => a.startsWith('--mark-updated='))
// Handle mark-updated
if (markUpdated) {
const skillName = markUpdated.split('=')[1]
markSkillUpdated(skillName)
return
}
// Get specific skill or all skills
const specificSkill = args.find(a => !a.startsWith('--'))
const metadata = loadMetadata()
let skills = getAllSkills()
if (specificSkill) {
skills = skills.filter(s => s.name === specificSkill)
if (skills.length === 0) {
console.error(`Skill not found: ${specificSkill}`)
process.exit(1)
}
}
console.log(`Checking ${skills.length} skills...`)
const results = []
for (const skill of skills) {
const status = await checkSkillStatus(skill, metadata)
results.push(status)
}
generateReport(results, { staleOnly, verbose })
// Exit with error code if there are stale skills (useful for CI)
const staleCount = results.filter(r => r.needsUpdate).length
if (staleCount > 0) {
process.exit(1)
}
}
main().catch(err => {
console.error('Error:', err.message)
process.exit(1)
})