OverviewFilesCommitsIssuesDocsReleasesPeers

hypercore-kb / scripts / check-skill-updates.js

334 lines · javascript ref:
#!/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)
})