Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:14:51 +08:00
commit e5cbabbd94
17 changed files with 2012 additions and 0 deletions

58
hooks/bun.lock Normal file
View File

@@ -0,0 +1,58 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "claude-code-knowledge-hooks",
"dependencies": {
"fast-xml-parser": "^4.3.2",
},
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.37",
"@types/bun": "latest",
},
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.1.37", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.33.5", "@img/sharp-darwin-x64": "^0.33.5", "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" }, "peerDependencies": { "zod": "^3.24.1" } }, "sha512-LMfqMIPLTz0vRhpcO7hpPJ5L6Bg24y5/PaqZvwAUNZ/GR3OAl7xmJR7IryIR6m8Pyd/6Hs2yBU8j86Os+wHFQQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/react": ["@types/react@19.2.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A=="],
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="],
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
}
}

130
hooks/claude-code-prompt.ts Executable file
View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bun
import { readFileSync } from 'node:fs';
import type { SyncHookJSONOutput, UserPromptSubmitHookInput } from '@anthropic-ai/claude-agent-sdk';
import { readSessionCache, writeSessionCache } from '../../../utils/session-cache';
interface SessionCache {
knowledge_suggested: boolean;
first_triggered: string;
match_reason: string;
}
async function main() {
try {
// Read input from stdin
const input = readFileSync(0, 'utf-8');
const data: UserPromptSubmitHookInput = JSON.parse(input);
const prompt = data.prompt.toLowerCase();
// Check for Claude Code related keywords (case insensitive)
const claudeCodeKeywords = ['claude code', 'claudecode', 'claude-code'];
// Check for standalone "claude" but be more selective to avoid false positives
const standaloneClaudePatterns = [
/\bclaude\b(?!\s+sonnet|\s+opus|\s+haiku)/i, // "claude" not followed by model names
/how\s+(?:do|does|can)\s+claude/i, // "how do/does/can claude..."
/can\s+claude/i, // "can claude..."
/does\s+claude/i, // "does claude..."
/is\s+claude/i, // "is claude..."
/tell\s+claude/i, // "tell claude..."
/ask\s+claude/i, // "ask claude..."
];
// Additional Claude Code related patterns
const claudeCodePatterns = [
/\bhook/i,
/\bmcp\s+server/i,
/\bskill/i,
/slash\s+command/i,
/claude.*setting/i,
/claude.*config/i,
/claude.*feature/i,
/claude.*capability/i,
/how\s+(?:do|does)\s+(?:i|claude).*(hook|mcp|skill|command)/i,
];
let isMatch = false;
let matchReason = '';
// Check for exact keyword matches
for (const keyword of claudeCodeKeywords) {
if (prompt.includes(keyword)) {
isMatch = true;
matchReason = `Detected "${keyword}"`;
break;
}
}
// Check for standalone Claude mentions
if (!isMatch) {
for (const pattern of standaloneClaudePatterns) {
if (pattern.test(prompt)) {
isMatch = true;
matchReason = 'Detected Claude Code question';
break;
}
}
}
// Check for Claude Code feature patterns
if (!isMatch) {
for (const pattern of claudeCodePatterns) {
if (pattern.test(prompt)) {
isMatch = true;
matchReason = 'Detected Claude Code feature mention';
break;
}
}
}
// Generate context injection if match found
if (isMatch) {
// Check session cache - only suggest once per session
const cache = readSessionCache<SessionCache>(
'claude-code-knowledge',
data.cwd,
data.session_id
);
// If already suggested this session, exit silently
if (cache?.knowledge_suggested) {
process.exit(0);
}
// Use JSON output method for explicit control
let context = '<plugin-claude-code-knowledge-suggestion>\n';
context += `Detected Claude Code question: ${matchReason}\n\n`;
context += 'ESSENTIAL SKILL:\n';
context += ' → claude-code-knowledge:claude-code-knowledge\n';
context += '</plugin-claude-code-knowledge-suggestion>';
// Return JSON with hookSpecificOutput for UserPromptSubmit
const output: SyncHookJSONOutput = {
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: context,
},
};
console.log(JSON.stringify(output));
// Mark as suggested in session cache
writeSessionCache<SessionCache>('claude-code-knowledge', data.cwd, data.session_id, {
knowledge_suggested: true,
first_triggered: new Date().toISOString(),
match_reason: matchReason,
});
}
// Exit 0 = success, additionalContext is added to context
process.exit(0);
} catch (err) {
console.error('Error in claude-code-prompt hook:', err);
process.exit(1);
}
}
main().catch((err) => {
console.error('Uncaught error:', err);
process.exit(1);
});

28
hooks/hooks.json Normal file
View File

@@ -0,0 +1,28 @@
{
"description": "Auto-suggests skill and syncs docs transparently when skill loads",
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claude-code-prompt.ts",
"timeout": 30
}
]
}
],
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/sync-docs-on-skill-load.ts",
"timeout": 30
}
]
}
]
}
}

13
hooks/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "claude-code-knowledge-hooks",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"fast-xml-parser": "^4.3.2"
},
"devDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.37",
"@types/bun": "latest"
}
}

709
hooks/sync-docs-on-skill-load.ts Executable file
View File

@@ -0,0 +1,709 @@
#!/usr/bin/env bun
/**
* PreToolUse Hook: Auto-sync claude-code-knowledge documentation when skill loads
*
* This hook combines all documentation fetching and syncing logic in one place.
* It runs transparently before the Skill tool executes, checking if docs need
* updating and fetching from docs.anthropic.com if necessary.
*
* Features:
* - Smart caching (3-hour threshold)
* - Automatic sitemap discovery
* - Retry logic with exponential backoff
* - Silent failures (never blocks skill loading)
* - Complete manifest tracking
*/
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import type { PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
import { XMLParser } from 'fast-xml-parser';
// ============================================================================
// Constants
// ============================================================================
const SITEMAP_URLS = [
'https://docs.anthropic.com/sitemap.xml',
'https://docs.claude.com/sitemap.xml',
];
const MANIFEST_FILE = 'docs_manifest.json';
const HEADERS = {
'User-Agent': 'Claude-Code-Knowledge-Skill/1.0',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
};
const MAX_RETRIES = 3;
const RETRY_DELAY = 2000; // ms
const MAX_RETRY_DELAY = 30000; // ms
const MAX_CONCURRENT_FETCHES = 10; // parallel fetch limit
// ============================================================================
// Types
// ============================================================================
interface ManifestFile {
original_url?: string;
original_md_url?: string;
original_raw_url?: string;
hash: string;
last_updated: string;
source?: string;
}
interface Manifest {
files: Record<string, ManifestFile>;
last_updated?: string;
source?: string;
skill?: string;
fetch_metadata?: {
last_fetch_completed: string;
fetch_duration_seconds: number;
total_pages_discovered: number;
pages_fetched_successfully: number;
pages_failed: number;
failed_pages: string[];
sitemap_url: string;
base_url: string;
total_files: number;
};
}
interface SitemapUrlEntry {
loc?: string;
[key: string]: unknown;
}
interface FetchResult {
success: boolean;
filename?: string;
pagePath?: string;
manifestEntry?: ManifestFile;
}
// ============================================================================
// Path Resolution
// ============================================================================
const SCRIPT_DIR = dirname(new URL(import.meta.url).pathname);
const PLUGIN_ROOT = join(SCRIPT_DIR, '..');
const DOCS_DIR = join(PLUGIN_ROOT, 'skills/claude-code-knowledge/docs');
const MANIFEST_PATH = join(DOCS_DIR, MANIFEST_FILE);
// ============================================================================
// Hook Input Handling
// ============================================================================
async function readHookInput(): Promise<PreToolUseHookInput | null> {
try {
const input = await Bun.stdin.text();
return JSON.parse(input);
} catch {
return null;
}
}
function shouldSync(hookInput: PreToolUseHookInput | null): boolean {
if (!hookInput) return false;
if (hookInput.tool_name !== 'Skill') return false;
const toolInput = hookInput.tool_input as Record<string, unknown>;
const skillName = (toolInput.skill as string) || '';
return (
skillName === 'claude-code-knowledge' ||
skillName === 'claude-code-knowledge:claude-code-knowledge'
);
}
// ============================================================================
// Sync Need Detection
// ============================================================================
async function checkIfSyncNeeded(): Promise<boolean> {
if (!existsSync(MANIFEST_PATH)) {
return true; // First time, need to fetch
}
try {
const manifestContent = await readFile(MANIFEST_PATH, 'utf-8');
const manifest = JSON.parse(manifestContent);
const lastUpdate = manifest.last_updated || 'unknown';
if (lastUpdate === 'unknown') return true;
const lastUpdateDate = new Date(lastUpdate.slice(0, 19));
const currentDate = new Date();
const hoursSinceUpdate = Math.floor(
(currentDate.getTime() - lastUpdateDate.getTime()) / (1000 * 60 * 60)
);
return hoursSinceUpdate >= 3;
} catch {
return true; // Error reading manifest, re-fetch
}
}
// ============================================================================
// Manifest Management
// ============================================================================
async function loadManifest(): Promise<Manifest> {
if (existsSync(MANIFEST_PATH)) {
try {
const content = await readFile(MANIFEST_PATH, 'utf-8');
const manifest = JSON.parse(content);
if (!manifest.files) manifest.files = {};
return manifest;
} catch {
// Ignore error, return empty manifest
}
}
return { files: {}, last_updated: undefined };
}
async function saveManifest(manifest: Manifest): Promise<void> {
manifest.last_updated = new Date().toISOString();
manifest.source = 'https://docs.anthropic.com/en/docs/claude-code/';
manifest.skill = 'claude-code-knowledge';
await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
}
// ============================================================================
// Utility Functions
// ============================================================================
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function urlToSafeFilename(urlPath: string): string {
const prefixes = ['/en/docs/claude-code/', '/docs/claude-code/', '/claude-code/'];
let path = urlPath;
for (const prefix of prefixes) {
if (urlPath.includes(prefix)) {
path = urlPath.split(prefix).pop() || urlPath;
break;
}
}
if (path === urlPath && urlPath.includes('claude-code/')) {
path = urlPath.split('claude-code/').pop() || urlPath;
}
if (!path.includes('/')) {
return path.endsWith('.md') ? path : `${path}.md`;
}
let safeName = path.replace(/\//g, '__');
if (!safeName.endsWith('.md')) {
safeName += '.md';
}
return safeName;
}
function contentHasChanged(content: string, oldHash: string): boolean {
const newHash = createHash('sha256').update(content, 'utf-8').digest('hex');
return newHash !== oldHash;
}
// ============================================================================
// Sitemap and Page Discovery
// ============================================================================
async function discoverSitemapAndBaseUrl(): Promise<{ sitemapUrl: string; baseUrl: string }> {
for (const sitemapUrl of SITEMAP_URLS) {
try {
const response = await fetch(sitemapUrl, { headers: HEADERS });
if (response.status === 200) {
const content = await response.text();
const parser = new XMLParser();
const result = parser.parse(content);
let firstUrl: string | null = null;
if (result.urlset?.url) {
const urls = Array.isArray(result.urlset.url) ? result.urlset.url : [result.urlset.url];
if (urls[0]?.loc) {
firstUrl = urls[0].loc;
}
}
if (firstUrl) {
const url = new URL(firstUrl);
const baseUrl = `${url.protocol}//${url.hostname}`;
return { sitemapUrl, baseUrl };
}
}
} catch {}
}
throw new Error('Could not find a valid sitemap');
}
function getFallbackPages(): string[] {
return [
'/en/docs/claude-code/overview',
'/en/docs/claude-code/quickstart',
'/en/docs/claude-code/setup',
'/en/docs/claude-code/cli-reference',
'/en/docs/claude-code/common-workflows',
'/en/docs/claude-code/interactive-mode',
'/en/docs/claude-code/settings',
'/en/docs/claude-code/model-config',
'/en/docs/claude-code/network-config',
'/en/docs/claude-code/terminal-config',
'/en/docs/claude-code/output-styles',
'/en/docs/claude-code/statusline',
'/en/docs/claude-code/hooks',
'/en/docs/claude-code/hooks-guide',
'/en/docs/claude-code/mcp',
'/en/docs/claude-code/skills',
'/en/docs/claude-code/slash-commands',
'/en/docs/claude-code/plugins',
'/en/docs/claude-code/plugins-reference',
'/en/docs/claude-code/plugin-marketplaces',
'/en/docs/claude-code/sub-agents',
'/en/docs/claude-code/memory',
'/en/docs/claude-code/checkpointing',
'/en/docs/claude-code/analytics',
'/en/docs/claude-code/monitoring-usage',
'/en/docs/claude-code/costs',
'/en/docs/claude-code/github-actions',
'/en/docs/claude-code/gitlab-ci-cd',
'/en/docs/claude-code/vs-code',
'/en/docs/claude-code/jetbrains',
'/en/docs/claude-code/devcontainer',
'/en/docs/claude-code/claude-code-on-the-web',
'/en/docs/claude-code/third-party-integrations',
'/en/docs/claude-code/amazon-bedrock',
'/en/docs/claude-code/google-vertex-ai',
'/en/docs/claude-code/llm-gateway',
'/en/docs/claude-code/iam',
'/en/docs/claude-code/security',
'/en/docs/claude-code/sandboxing',
'/en/docs/claude-code/data-usage',
'/en/docs/claude-code/legal-and-compliance',
'/en/docs/claude-code/headless',
'/en/docs/claude-code/troubleshooting',
'/en/docs/claude-code/sdk/migration-guide',
];
}
async function discoverClaudeCodePages(sitemapUrl: string): Promise<string[]> {
try {
const response = await fetch(sitemapUrl, { headers: HEADERS });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const content = await response.text();
const parser = new XMLParser();
const result = parser.parse(content);
let urls: string[] = [];
if (result.urlset?.url) {
const urlEntries = Array.isArray(result.urlset.url) ? result.urlset.url : [result.urlset.url];
urls = urlEntries
.map((entry: SitemapUrlEntry) => entry.loc)
.filter((loc: unknown): loc is string => typeof loc === 'string');
}
const claudeCodePages: string[] = [];
const englishPatterns = ['/en/docs/claude-code/'];
for (const url of urls) {
if (englishPatterns.some((pattern) => url.includes(pattern))) {
const urlObj = new URL(url);
let path = urlObj.pathname;
if (path.endsWith('.html')) {
path = path.slice(0, -5);
} else if (path.endsWith('/')) {
path = path.slice(0, -1);
}
const skipPatterns = ['/tool-use/', '/examples/', '/legacy/', '/api/', '/reference/'];
if (!skipPatterns.some((skip) => path.includes(skip))) {
claudeCodePages.push(path);
}
}
}
const uniquePages = [...new Set(claudeCodePages)].sort();
if (uniquePages.length === 0) {
return getFallbackPages();
}
return uniquePages;
} catch {
return getFallbackPages();
}
}
// ============================================================================
// Content Fetching
// ============================================================================
function validateMarkdownContent(content: string, _filename: string): void {
if (!content || content.startsWith('<!DOCTYPE') || content.slice(0, 100).includes('<html')) {
throw new Error('Received HTML instead of markdown');
}
if (content.trim().length < 50) {
throw new Error(`Content too short (${content.length} bytes)`);
}
const lines = content.split('\n');
const markdownIndicators = ['# ', '## ', '### ', '```', '- ', '* ', '1. ', '[', '**', '_', '> '];
const indicatorCount = lines.slice(0, 50).reduce((count, line) => {
return (
count +
markdownIndicators.filter(
(indicator) => line.trim().startsWith(indicator) || line.includes(indicator)
).length
);
}, 0);
if (indicatorCount < 3) {
throw new Error(`Content doesn't appear to be markdown (${indicatorCount} indicators found)`);
}
}
async function fetchMarkdownContent(
path: string,
baseUrl: string
): Promise<{ filename: string; content: string }> {
const markdownUrl = `${baseUrl}${path}.md`;
const filename = urlToSafeFilename(path);
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const response = await fetch(markdownUrl, {
headers: HEADERS,
redirect: 'follow',
});
if (response.status === 429) {
const retryAfter = Number.parseInt(response.headers.get('Retry-After') || '60', 10);
await sleep(retryAfter * 1000);
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const content = await response.text();
validateMarkdownContent(content, filename);
return { filename, content };
} catch (e) {
if (attempt < MAX_RETRIES - 1) {
const delay = Math.min(RETRY_DELAY * 2 ** attempt, MAX_RETRY_DELAY);
const jitteredDelay = delay * (0.5 + Math.random() * 0.5);
await sleep(jitteredDelay);
} else {
throw new Error(`Failed to fetch ${filename} after ${MAX_RETRIES} attempts: ${e}`);
}
}
}
throw new Error(`Failed to fetch ${path}`);
}
async function fetchChangelog(): Promise<{ filename: string; content: string }> {
const changelogUrl = 'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md';
const filename = 'changelog.md';
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const response = await fetch(changelogUrl, {
headers: HEADERS,
redirect: 'follow',
});
if (response.status === 429) {
const retryAfter = Number.parseInt(response.headers.get('Retry-After') || '60', 10);
await sleep(retryAfter * 1000);
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const content = await response.text();
const header = `# Claude Code Changelog
> **Source**: https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md
>
> This is the official Claude Code release changelog from the Claude Code repository.
---
`;
const fullContent = header + content;
if (fullContent.trim().length < 100) {
throw new Error(`Changelog content too short (${fullContent.length} bytes)`);
}
return { filename, content: fullContent };
} catch (e) {
if (attempt < MAX_RETRIES - 1) {
const delay = Math.min(RETRY_DELAY * 2 ** attempt, MAX_RETRY_DELAY);
const jitteredDelay = delay * (0.5 + Math.random() * 0.5);
await sleep(jitteredDelay);
} else {
throw new Error(`Failed to fetch changelog after ${MAX_RETRIES} attempts: ${e}`);
}
}
}
throw new Error('Failed to fetch changelog');
}
async function saveMarkdownFile(filename: string, content: string): Promise<string> {
const filePath = join(DOCS_DIR, filename);
await writeFile(filePath, content, 'utf-8');
const contentHash = createHash('sha256').update(content, 'utf-8').digest('hex');
return contentHash;
}
// ============================================================================
// Parallel Fetch Helpers
// ============================================================================
async function fetchAndSavePage(
pagePath: string,
baseUrl: string,
manifest: Manifest
): Promise<FetchResult> {
try {
const { filename, content } = await fetchMarkdownContent(pagePath, baseUrl);
const oldHash = manifest.files[filename]?.hash || '';
const oldEntry = manifest.files[filename] || {};
const filePath = join(DOCS_DIR, filename);
const fileExists = existsSync(filePath);
let contentHash: string;
let lastUpdated: string;
if (!fileExists || contentHasChanged(content, oldHash)) {
contentHash = await saveMarkdownFile(filename, content);
lastUpdated = new Date().toISOString();
} else {
contentHash = oldHash;
lastUpdated = oldEntry.last_updated || new Date().toISOString();
}
return {
success: true,
filename,
pagePath,
manifestEntry: {
original_url: `${baseUrl}${pagePath}`,
original_md_url: `${baseUrl}${pagePath}.md`,
hash: contentHash,
last_updated: lastUpdated,
},
};
} catch (e) {
return { success: false, pagePath };
}
}
async function fetchAndSaveChangelog(manifest: Manifest): Promise<FetchResult> {
try {
const { filename, content } = await fetchChangelog();
const oldHash = manifest.files[filename]?.hash || '';
const oldEntry = manifest.files[filename] || {};
const filePath = join(DOCS_DIR, filename);
const fileExists = existsSync(filePath);
let contentHash: string;
let lastUpdated: string;
if (!fileExists || contentHasChanged(content, oldHash)) {
contentHash = await saveMarkdownFile(filename, content);
lastUpdated = new Date().toISOString();
} else {
contentHash = oldHash;
lastUpdated = oldEntry.last_updated || new Date().toISOString();
}
return {
success: true,
filename,
pagePath: 'changelog',
manifestEntry: {
original_url: 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md',
original_raw_url:
'https://raw.githubusercontent.com/anthropics/claude-code/main/CHANGELOG.md',
hash: contentHash,
last_updated: lastUpdated,
source: 'claude-code-repository',
},
};
} catch (e) {
return { success: false, pagePath: 'changelog' };
}
}
async function fetchAllPagesInParallel(
documentationPages: string[],
baseUrl: string,
manifest: Manifest
): Promise<FetchResult[]> {
// Create all fetch tasks (documentation pages + changelog)
const pageTasks = documentationPages.map(
(pagePath) => () => fetchAndSavePage(pagePath, baseUrl, manifest)
);
const allTasks = [...pageTasks, () => fetchAndSaveChangelog(manifest)];
const results: FetchResult[] = [];
// Process in batches to limit concurrency
for (let i = 0; i < allTasks.length; i += MAX_CONCURRENT_FETCHES) {
const batch = allTasks.slice(i, i + MAX_CONCURRENT_FETCHES);
const batchResults = await Promise.allSettled(batch.map((task) => task()));
for (const result of batchResults) {
if (result.status === 'fulfilled') {
results.push(result.value);
} else {
results.push({ success: false, pagePath: 'unknown' });
}
}
// Small delay between batches to be respectful to the server
if (i + MAX_CONCURRENT_FETCHES < allTasks.length) {
await sleep(500);
}
}
return results;
}
// ============================================================================
// Main Fetch Logic
// ============================================================================
async function runFetch(): Promise<void> {
const startTime = Date.now();
// Create docs directory if needed
if (!existsSync(DOCS_DIR)) {
await mkdir(DOCS_DIR, { recursive: true });
}
// Load existing manifest
const manifest = await loadManifest();
// Statistics
let successful = 0;
let failed = 0;
const failedPages: string[] = [];
const newManifest: Manifest = { files: {} };
// Discover sitemap and base URL
let sitemapUrl: string;
let baseUrl: string;
try {
const result = await discoverSitemapAndBaseUrl();
sitemapUrl = result.sitemapUrl;
baseUrl = result.baseUrl;
} catch (e) {
throw new Error(`Failed to discover sitemap: ${e}`);
}
// Discover documentation pages
const documentationPages = await discoverClaudeCodePages(sitemapUrl);
if (documentationPages.length === 0) {
throw new Error('No documentation pages discovered!');
}
// Fetch all pages in parallel (including changelog)
const results = await fetchAllPagesInParallel(documentationPages, baseUrl, manifest);
// Process results
for (const result of results) {
if (result.success && result.filename && result.manifestEntry) {
newManifest.files[result.filename] = result.manifestEntry;
successful++;
} else {
failed++;
failedPages.push(result.pagePath || 'unknown');
}
}
// Add metadata
const duration = (Date.now() - startTime) / 1000;
newManifest.fetch_metadata = {
last_fetch_completed: new Date().toISOString(),
fetch_duration_seconds: duration,
total_pages_discovered: documentationPages.length,
pages_fetched_successfully: successful,
pages_failed: failed,
failed_pages: failedPages,
sitemap_url: sitemapUrl,
base_url: baseUrl,
total_files: successful,
};
// Save manifest
await saveManifest(newManifest);
// If no pages were fetched successfully, throw error
if (successful === 0) {
throw new Error('No pages were fetched successfully!');
}
}
// ============================================================================
// Main Hook Logic
// ============================================================================
async function main() {
// Read hook input
const hookInput = await readHookInput();
// Check if we should sync
if (!shouldSync(hookInput)) {
process.exit(0);
}
// Check if sync is needed
const syncNeeded = await checkIfSyncNeeded();
if (!syncNeeded) {
process.exit(0);
}
// Run fetch (blocks, but with timeout handled by hook system)
try {
await runFetch();
} catch {
// Silent failure - don't block skill loading
}
// Always exit 0 (non-blocking, silent)
process.exit(0);
}
// Run main function
main().catch(() => {
// Silent failure - always exit 0
process.exit(0);
});