Initial commit
This commit is contained in:
58
hooks/bun.lock
Normal file
58
hooks/bun.lock
Normal 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
130
hooks/claude-code-prompt.ts
Executable 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
28
hooks/hooks.json
Normal 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
13
hooks/package.json
Normal 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
709
hooks/sync-docs-on-skill-load.ts
Executable 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);
|
||||
});
|
||||
Reference in New Issue
Block a user