844 lines
22 KiB
JavaScript
844 lines
22 KiB
JavaScript
/**
|
||
* Cross-platform Hook shared utility library
|
||
* Provides cross-platform compatible shared functions for Claude Code Hooks
|
||
*
|
||
* @module hook-common
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { execSync } = require('child_process');
|
||
|
||
/**
|
||
* Get Git status information
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {Object} Git info object
|
||
*/
|
||
function getGitInfo(cwd) {
|
||
try {
|
||
// Check if this is a Git repository
|
||
execSync('git rev-parse --git-dir', {
|
||
cwd,
|
||
stdio: 'pipe'
|
||
});
|
||
|
||
// Get branch name
|
||
let branch = 'unknown';
|
||
try {
|
||
branch = execSync('git branch --show-current', {
|
||
cwd,
|
||
encoding: 'utf8',
|
||
stdio: 'pipe'
|
||
}).trim();
|
||
} catch {
|
||
branch = 'unknown';
|
||
}
|
||
|
||
// Get changed files
|
||
let changes = '';
|
||
try {
|
||
changes = execSync('git status --porcelain', {
|
||
cwd,
|
||
encoding: 'utf8',
|
||
stdio: 'pipe'
|
||
});
|
||
} catch {
|
||
changes = '';
|
||
}
|
||
|
||
const changeList = changes.trim().split('\n').filter(Boolean);
|
||
const hasChanges = changeList.length > 0;
|
||
|
||
return {
|
||
is_repo: true,
|
||
branch,
|
||
changes_count: changeList.length,
|
||
has_changes: hasChanges,
|
||
changes: changeList
|
||
};
|
||
} catch {
|
||
return {
|
||
is_repo: false,
|
||
branch: 'unknown',
|
||
changes_count: 0,
|
||
has_changes: false,
|
||
changes: []
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get todo item information
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {Object} Todo item information
|
||
*/
|
||
function getTodoInfo(cwd) {
|
||
const todoFiles = [
|
||
path.join(cwd, 'docs', 'todo.md'),
|
||
path.join(cwd, 'TODO.md'),
|
||
path.join(cwd, '.claude', 'todos.md'),
|
||
path.join(cwd, 'TODO'),
|
||
path.join(cwd, 'notes', 'todo.md')
|
||
];
|
||
|
||
for (const file of todoFiles) {
|
||
if (fs.existsSync(file)) {
|
||
try {
|
||
const content = fs.readFileSync(file, 'utf8');
|
||
const totalMatches = content.match(/^[\-\*] \[[ x]\]/gi) || [];
|
||
const total = totalMatches.length;
|
||
|
||
const doneMatches = content.match(/^[\-\*] \[x\]/gi) || [];
|
||
const done = doneMatches.length;
|
||
|
||
const pending = total - done;
|
||
|
||
return {
|
||
found: true,
|
||
file: path.basename(file),
|
||
path: file,
|
||
total,
|
||
done,
|
||
pending
|
||
};
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
found: false,
|
||
file: null,
|
||
path: null,
|
||
total: 0,
|
||
done: 0,
|
||
pending: 0
|
||
};
|
||
}
|
||
|
||
|
||
/**
|
||
* Resolve repository root when available
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {string} Repository root or cwd fallback
|
||
*/
|
||
function getRepoRoot(cwd) {
|
||
try {
|
||
return execSync('git rev-parse --show-toplevel', {
|
||
cwd,
|
||
encoding: 'utf8',
|
||
stdio: 'pipe'
|
||
}).trim();
|
||
} catch {
|
||
return cwd;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse simple YAML frontmatter from a Markdown file.
|
||
* Supports the project-memory binding metadata shape.
|
||
* @param {string} filePath
|
||
* @returns {Object|null}
|
||
*/
|
||
function parseFrontmatterFile(filePath) {
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||
if (!match) return null;
|
||
|
||
const data = {};
|
||
for (const line of match[1].split('\n')) {
|
||
const parts = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
||
if (!parts) continue;
|
||
const key = parts[1];
|
||
let value = parts[2].trim();
|
||
|
||
if (value === 'true') value = true;
|
||
else if (value === 'false') value = false;
|
||
else value = value.replace(/^['"]|['"]$/g, '');
|
||
|
||
data[key] = value;
|
||
}
|
||
return data;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Detect whether the current repository is bound to Obsidian project memory
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {Object} Binding info
|
||
*/
|
||
function getProjectMemoryBinding(cwd) {
|
||
const repoRoot = getRepoRoot(cwd);
|
||
const projectMemoryDir = path.join(repoRoot, '.claude', 'project-memory');
|
||
const registryPath = path.join(projectMemoryDir, 'registry.yaml');
|
||
|
||
if (!fs.existsSync(registryPath)) {
|
||
return {
|
||
bound: false,
|
||
repoRoot,
|
||
registryPath,
|
||
projectId: null,
|
||
status: null,
|
||
autoSync: false,
|
||
vaultRoot: null,
|
||
hubNote: null,
|
||
memoryPath: null
|
||
};
|
||
}
|
||
|
||
let project = null;
|
||
let memoryPath = null;
|
||
|
||
if (fs.existsSync(projectMemoryDir)) {
|
||
const memoryFiles = fs.readdirSync(projectMemoryDir)
|
||
.filter(name => name.endsWith('.md'))
|
||
.map(name => path.join(projectMemoryDir, name));
|
||
|
||
for (const candidate of memoryFiles) {
|
||
const frontmatter = parseFrontmatterFile(candidate);
|
||
if (!frontmatter) continue;
|
||
if (frontmatter.repo_root && (frontmatter.repo_root === repoRoot || cwd.startsWith(frontmatter.repo_root))) {
|
||
project = frontmatter;
|
||
memoryPath = candidate;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!memoryPath && memoryFiles.length > 0) {
|
||
memoryPath = memoryFiles[0];
|
||
project = parseFrontmatterFile(memoryPath) || null;
|
||
}
|
||
}
|
||
|
||
return {
|
||
bound: true,
|
||
repoRoot,
|
||
registryPath,
|
||
projectId: project ? project.project_id || null : null,
|
||
status: project ? project.status || null : null,
|
||
autoSync: Boolean(project && project.auto_sync),
|
||
vaultRoot: project ? project.vault_root || null : null,
|
||
hubNote: project ? project.hub_note || null : null,
|
||
memoryPath
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Heuristically detect whether a directory looks like a research repo
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {Object} Detection result
|
||
*/
|
||
function detectResearchProject(cwd) {
|
||
const repoRoot = getRepoRoot(cwd);
|
||
const markers = [
|
||
'.git',
|
||
'README.md',
|
||
'docs',
|
||
'notes',
|
||
'plan',
|
||
'results',
|
||
'outputs',
|
||
'src',
|
||
'scripts'
|
||
];
|
||
|
||
const hits = markers.filter(marker => fs.existsSync(path.join(repoRoot, marker)));
|
||
const candidate = hits.length >= 3 || (hits.includes('.git') && hits.includes('README.md') && hits.length >= 2);
|
||
|
||
return {
|
||
repoRoot,
|
||
candidate,
|
||
markers: hits
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Detect whether a user prompt looks research-related
|
||
* @param {string} prompt - User prompt
|
||
* @returns {boolean} Whether the prompt looks research-related
|
||
*/
|
||
function promptLooksResearchRelated(prompt) {
|
||
return /\b(obsidian|zotero|paper|papers|literature|review|experiment|results?|finding|analysis|research|plan|todo|daily|meeting|writing|draft|proposal|rebuttal|claim|method)\b|文献|实验|结果|研究|计划|待办|知识库|论文|综述|笔记|项目记忆|obsidian|zotero/i.test(prompt);
|
||
}
|
||
|
||
/**
|
||
* Get Git change details
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {Object} Change statistics
|
||
*/
|
||
function getChangesDetails(cwd) {
|
||
try {
|
||
let added = 0;
|
||
let modified = 0;
|
||
let deleted = 0;
|
||
|
||
// 解析 name-status 输出,每行格式: "A\tfilename" 或 "M\tfilename"
|
||
const parseNameStatus = (output) => {
|
||
for (const line of output.trim().split('\n').filter(Boolean)) {
|
||
const status = line.charAt(0);
|
||
if (status === 'A') added++;
|
||
else if (status === 'M') modified++;
|
||
else if (status === 'D') deleted++;
|
||
}
|
||
};
|
||
|
||
// 工作区变更(unstaged)
|
||
const unstaged = execSync('git diff --name-status', {
|
||
cwd,
|
||
encoding: 'utf8',
|
||
stdio: 'pipe'
|
||
});
|
||
parseNameStatus(unstaged);
|
||
|
||
// 暂存区变更(staged)
|
||
const staged = execSync('git diff --cached --name-status', {
|
||
cwd,
|
||
encoding: 'utf8',
|
||
stdio: 'pipe'
|
||
});
|
||
parseNameStatus(staged);
|
||
|
||
// 未跟踪文件算作 added
|
||
const untracked = execSync('git status --porcelain', {
|
||
cwd,
|
||
encoding: 'utf8',
|
||
stdio: 'pipe'
|
||
});
|
||
for (const line of untracked.trim().split('\n').filter(Boolean)) {
|
||
if (line.startsWith('??')) added++;
|
||
}
|
||
|
||
return { added, modified, deleted };
|
||
} catch {
|
||
return { added: 0, modified: 0, deleted: 0 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Analyze changes by file type
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {Object} File type statistics
|
||
*/
|
||
function analyzeChangesByType(cwd) {
|
||
const gitInfo = getGitInfo(cwd);
|
||
|
||
if (!gitInfo.is_repo || !gitInfo.has_changes) {
|
||
return {
|
||
test_files: 0,
|
||
docs_files: 0,
|
||
sql_files: 0,
|
||
config_files: 0,
|
||
service_files: 0
|
||
};
|
||
}
|
||
|
||
// 逐行匹配文件路径(git status --porcelain 格式: "XY filename")
|
||
const files = gitInfo.changes.map(line => line.substring(3).trim());
|
||
|
||
const testRegex = /(?:^|[\/\\])tests?[\/\\]|[\/\\._]test[_.]|\.test\.|_test\./i;
|
||
const docsRegex = /\.(md|txt|rst)$/i;
|
||
const sqlRegex = /\.sql$/i;
|
||
const configRegex = /\.(json|yaml|yml|toml|ini|conf)$/i;
|
||
const serviceRegex = /(service|controller)/i;
|
||
|
||
let testFiles = 0;
|
||
let docsFiles = 0;
|
||
let sqlFiles = 0;
|
||
let configFiles = 0;
|
||
let serviceFiles = 0;
|
||
|
||
for (const file of files) {
|
||
if (testRegex.test(file)) testFiles++;
|
||
if (docsRegex.test(file)) docsFiles++;
|
||
if (sqlRegex.test(file)) sqlFiles++;
|
||
if (configRegex.test(file)) configFiles++;
|
||
if (serviceRegex.test(file)) serviceFiles++;
|
||
}
|
||
|
||
return {
|
||
test_files: testFiles,
|
||
docs_files: docsFiles,
|
||
sql_files: sqlFiles,
|
||
config_files: configFiles,
|
||
service_files: serviceFiles
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Detect temporary files
|
||
* @param {string} cwd - Current working directory
|
||
* @returns {Object} Temporary file information
|
||
*/
|
||
function detectTempFiles(cwd) {
|
||
const tempFiles = new Set();
|
||
const gitInfo = getGitInfo(cwd);
|
||
|
||
// Find temp files from Git untracked files
|
||
if (gitInfo.is_repo) {
|
||
for (const change of gitInfo.changes) {
|
||
if (change.startsWith('??')) {
|
||
const file = change.substring(3).trim();
|
||
if (/plan|draft|tmp|temp|scratch/i.test(file)) {
|
||
tempFiles.add(file);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check known temporary directories
|
||
const tempDirs = ['plan', 'docs/plans', '.claude/temp', 'tmp', 'temp'];
|
||
for (const dir of tempDirs) {
|
||
const dirPath = path.join(cwd, dir);
|
||
if (fs.existsSync(dirPath)) {
|
||
try {
|
||
const files = getAllFiles(dirPath);
|
||
for (const file of files) {
|
||
tempFiles.add(path.relative(cwd, file));
|
||
}
|
||
} catch {
|
||
// Ignore errors
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
files: Array.from(tempFiles),
|
||
count: tempFiles.size
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Recursively get all files in a directory
|
||
* @param {string} dirPath - Directory path
|
||
* @returns {Array<string>} List of file paths
|
||
*/
|
||
function getAllFiles(dirPath) {
|
||
const files = [];
|
||
const items = fs.readdirSync(dirPath);
|
||
|
||
for (const item of items) {
|
||
const fullPath = path.join(dirPath, item);
|
||
const stat = fs.statSync(fullPath);
|
||
|
||
if (stat.isDirectory()) {
|
||
files.push(...getAllFiles(fullPath));
|
||
} else {
|
||
files.push(fullPath);
|
||
}
|
||
}
|
||
|
||
return files;
|
||
}
|
||
|
||
/**
|
||
* Generate smart recommendations
|
||
* @param {string} cwd - Current working directory
|
||
* @param {Object} gitInfo - Git information
|
||
* @returns {Array<string>} List of recommendations
|
||
*/
|
||
function generateRecommendations(cwd, gitInfo) {
|
||
const recommendations = [];
|
||
|
||
if (gitInfo.is_repo && gitInfo.has_changes) {
|
||
const changesDetails = getChangesDetails(cwd);
|
||
const typeAnalysis = analyzeChangesByType(cwd);
|
||
|
||
if (changesDetails.added > 0 || changesDetails.modified > 0) {
|
||
recommendations.push('git add . && git commit -m "feat: xxx"');
|
||
}
|
||
|
||
if (typeAnalysis.test_files > 0) {
|
||
recommendations.push('Run test suite to verify changes');
|
||
}
|
||
if (typeAnalysis.docs_files > 0) {
|
||
recommendations.push('Check documentation is in sync with code');
|
||
}
|
||
if (typeAnalysis.sql_files > 0) {
|
||
recommendations.push('Update all related database scripts');
|
||
}
|
||
if (typeAnalysis.config_files > 0) {
|
||
recommendations.push('Check if environment variables need updating');
|
||
}
|
||
if (typeAnalysis.service_files > 0) {
|
||
recommendations.push('Update API documentation');
|
||
}
|
||
}
|
||
|
||
// Todo reminder
|
||
const todoInfo = getTodoInfo(cwd);
|
||
if (todoInfo.found && todoInfo.pending > 0) {
|
||
recommendations.push(`Check todos: ${todoInfo.file} (${todoInfo.pending} items remaining)`);
|
||
}
|
||
|
||
// Non-repo environment reminder
|
||
if (!gitInfo.is_repo) {
|
||
recommendations.push('Remember to back up important files to a git repo or cloud storage');
|
||
}
|
||
|
||
return recommendations;
|
||
}
|
||
|
||
/**
|
||
* Get list of enabled plugins
|
||
* @param {string} homeDir - User home directory
|
||
* @returns {Array<Object>} Plugin list
|
||
*/
|
||
function getEnabledPlugins(homeDir) {
|
||
const settingsFile = path.join(homeDir, '.claude', 'settings.json');
|
||
|
||
if (!fs.existsSync(settingsFile)) {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
||
const enabledPlugins = settings.enabledPlugins || {};
|
||
|
||
const plugins = [];
|
||
for (const [pluginId, enabled] of Object.entries(enabledPlugins)) {
|
||
if (enabled) {
|
||
const pluginName = pluginId.split('@')[0];
|
||
plugins.push({
|
||
id: pluginId,
|
||
name: pluginName
|
||
});
|
||
}
|
||
}
|
||
|
||
return plugins;
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get list of available commands
|
||
* @param {string} homeDir - User home directory
|
||
* @returns {Array<Object>} Command list
|
||
*/
|
||
function getAvailableCommands(homeDir) {
|
||
const commands = [];
|
||
|
||
// Only collect local commands, not plugin commands
|
||
const localCommandsDir = path.join(homeDir, '.claude', 'commands');
|
||
if (fs.existsSync(localCommandsDir)) {
|
||
const commandFiles = fs.readdirSync(localCommandsDir)
|
||
.filter(f => f.endsWith('.md'));
|
||
|
||
for (const cmdFile of commandFiles) {
|
||
const cmdName = cmdFile.replace('.md', '');
|
||
commands.push({
|
||
plugin: 'local',
|
||
name: cmdName,
|
||
path: path.join(localCommandsDir, cmdFile)
|
||
});
|
||
}
|
||
}
|
||
|
||
return commands;
|
||
}
|
||
|
||
/**
|
||
* Get command description
|
||
* @param {string} cmdPath - Command file path
|
||
* @returns {string} Command description
|
||
*/
|
||
function getCommandDescription(cmdPath) {
|
||
try {
|
||
const content = fs.readFileSync(cmdPath, 'utf8');
|
||
const lines = content.split('\n');
|
||
|
||
// Try to get description from frontmatter
|
||
let inFrontmatter = false;
|
||
for (const line of lines) {
|
||
if (line.trim() === '---') {
|
||
if (!inFrontmatter) {
|
||
inFrontmatter = true;
|
||
} else {
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (inFrontmatter && line.trim().startsWith('description:')) {
|
||
const match = line.match(/description:\s*["']?(.+?)["']?$/);
|
||
if (match) {
|
||
return match[1].trim();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Try to get from heading
|
||
for (const line of lines) {
|
||
const match = line.match(/^#+\s*(.+)$/);
|
||
if (match) {
|
||
return match[1].trim().substring(0, 50);
|
||
}
|
||
}
|
||
|
||
return '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Collect local skills
|
||
* @param {string} homeDir - User home directory
|
||
* @returns {Array<Object>} Skill list
|
||
*/
|
||
function collectLocalSkills(homeDir) {
|
||
const skills = [];
|
||
const skillsDir = path.join(homeDir, '.claude', 'skills');
|
||
|
||
if (!fs.existsSync(skillsDir)) {
|
||
return skills;
|
||
}
|
||
|
||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||
.filter(d => d.isDirectory())
|
||
.map(d => d.name);
|
||
|
||
for (const skillName of skillDirs) {
|
||
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
||
let description = '';
|
||
|
||
if (fs.existsSync(skillFile)) {
|
||
try {
|
||
const content = fs.readFileSync(skillFile, 'utf8');
|
||
const match = content.match(/description:\s*(.+)$/im);
|
||
if (match) {
|
||
description = match[1].trim();
|
||
}
|
||
} catch {
|
||
// Ignore
|
||
}
|
||
}
|
||
|
||
skills.push({
|
||
name: skillName,
|
||
description,
|
||
type: 'local'
|
||
});
|
||
}
|
||
|
||
return skills;
|
||
}
|
||
|
||
/**
|
||
* Collect plugin skills
|
||
* @param {string} homeDir - User home directory
|
||
* @returns {Array<Object>} Skill list
|
||
*/
|
||
function collectPluginSkills(homeDir) {
|
||
const skills = [];
|
||
const pluginsCache = path.join(homeDir, '.claude', 'plugins', 'cache');
|
||
|
||
if (!fs.existsSync(pluginsCache)) {
|
||
return skills;
|
||
}
|
||
|
||
const marketplaces = fs.readdirSync(pluginsCache, { withFileTypes: true })
|
||
.filter(d => d.isDirectory())
|
||
.map(d => d.name);
|
||
|
||
for (const marketplace of marketplaces) {
|
||
// Skip ai-research-skills
|
||
if (marketplace === 'ai-research-skills') continue;
|
||
|
||
const marketplacePath = path.join(pluginsCache, marketplace);
|
||
const plugins = fs.readdirSync(marketplacePath, { withFileTypes: true })
|
||
.filter(d => d.isDirectory() && !d.name.startsWith('.'))
|
||
.map(d => d.name);
|
||
|
||
for (const plugin of plugins) {
|
||
const pluginPath = path.join(marketplacePath, plugin);
|
||
const versions = fs.readdirSync(pluginPath, { withFileTypes: true })
|
||
.filter(d => d.isDirectory())
|
||
.map(d => d.name)
|
||
.sort()
|
||
.reverse();
|
||
|
||
if (versions.length === 0) continue;
|
||
|
||
const latestVersion = versions[0];
|
||
const pluginRoot = path.join(pluginPath, latestVersion);
|
||
const skillsDir = path.join(pluginRoot, 'skills');
|
||
|
||
if (fs.existsSync(skillsDir)) {
|
||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||
.filter(d => d.isDirectory())
|
||
.map(d => d.name);
|
||
|
||
for (const skillName of skillDirs) {
|
||
skills.push({
|
||
name: `${plugin}:${skillName}`,
|
||
plugin,
|
||
skill: skillName,
|
||
type: 'plugin'
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return skills;
|
||
}
|
||
|
||
/**
|
||
* Format date and time
|
||
* @param {Date} date - Date object
|
||
* @returns {string} Formatted date-time string
|
||
*/
|
||
function formatDateTime(date = new Date()) {
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
const hours = String(date.getHours()).padStart(2, '0');
|
||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||
|
||
return `${year}/${month}/${day} ${hours}:${minutes}:${seconds}`;
|
||
}
|
||
|
||
/**
|
||
* Check if CLAUDE.md needs updating
|
||
* @param {string} homeDir - User home directory
|
||
* @returns {Object} Check result
|
||
*/
|
||
function checkClaudeMdUpdate(homeDir) {
|
||
const claudeMdPath = path.join(homeDir, '.claude', 'CLAUDE.md');
|
||
const lastSyncPath = path.join(homeDir, '.claude', '.last-memory-sync');
|
||
|
||
// If CLAUDE.md does not exist, return immediately
|
||
if (!fs.existsSync(claudeMdPath)) {
|
||
return { needsUpdate: false, reason: 'CLAUDE.md not found' };
|
||
}
|
||
|
||
// Get CLAUDE.md modification time
|
||
const claudeMdMtime = fs.statSync(claudeMdPath).mtimeMs;
|
||
|
||
// Get last sync time (use CLAUDE.md creation time if not available)
|
||
let lastSyncMtime = claudeMdMtime;
|
||
if (fs.existsSync(lastSyncPath)) {
|
||
try {
|
||
lastSyncMtime = parseInt(fs.readFileSync(lastSyncPath, 'utf8').trim(), 10);
|
||
} catch {
|
||
lastSyncMtime = claudeMdMtime;
|
||
}
|
||
}
|
||
|
||
const referenceTime = Math.max(claudeMdMtime, lastSyncMtime);
|
||
|
||
// Define source file directories to monitor
|
||
const sourceDirs = [
|
||
{ dir: path.join(homeDir, '.claude', 'skills'), pattern: /SKILL\.md$/, type: 'skill' },
|
||
{ dir: path.join(homeDir, '.claude', 'commands'), pattern: /\.md$/, type: 'command' },
|
||
{ dir: path.join(homeDir, '.claude', 'agents'), pattern: /\.md$/, type: 'agent' },
|
||
{ dir: path.join(homeDir, '.claude', 'hooks'), pattern: /\.(js|json)$/, type: 'hook' }
|
||
];
|
||
|
||
const changedFiles = [];
|
||
let totalSkills = 0;
|
||
let totalCommands = 0;
|
||
let totalAgents = 0;
|
||
let totalHooks = 0;
|
||
|
||
// Scan each source directory
|
||
for (const { dir, pattern, type } of sourceDirs) {
|
||
if (!fs.existsSync(dir)) continue;
|
||
|
||
const files = getAllFiles(dir).filter(f => pattern.test(f));
|
||
|
||
for (const file of files) {
|
||
try {
|
||
const mtime = fs.statSync(file).mtimeMs;
|
||
|
||
// Count totals
|
||
if (type === 'skill') totalSkills++;
|
||
else if (type === 'command') totalCommands++;
|
||
else if (type === 'agent') totalAgents++;
|
||
else if (type === 'hook') totalHooks++;
|
||
|
||
// Check if newer than reference time
|
||
if (mtime > referenceTime) {
|
||
changedFiles.push({
|
||
path: file,
|
||
type,
|
||
relativePath: path.relative(homeDir, file),
|
||
mtime: new Date(mtime).toLocaleString('en-US')
|
||
});
|
||
}
|
||
} catch {
|
||
// Ignore inaccessible files
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
needsUpdate: changedFiles.length > 0,
|
||
changedFiles,
|
||
stats: {
|
||
skills: totalSkills,
|
||
commands: totalCommands,
|
||
agents: totalAgents,
|
||
hooks: totalHooks
|
||
},
|
||
claudeMdMtime: new Date(claudeMdMtime).toLocaleString('en-US'),
|
||
lastSyncMtime: new Date(lastSyncMtime).toLocaleString('en-US')
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update sync timestamp
|
||
* @param {string} homeDir - User home directory
|
||
*/
|
||
function updateSyncTimestamp(homeDir) {
|
||
const lastSyncPath = path.join(homeDir, '.claude', '.last-memory-sync');
|
||
try {
|
||
fs.writeFileSync(lastSyncPath, Date.now().toString(), 'utf8');
|
||
} catch {
|
||
// Ignore write errors
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a temporary file
|
||
* @param {string} prefix - Filename prefix
|
||
* @returns {string} Temporary file path
|
||
*/
|
||
function createTempFile(prefix = 'claude-temp') {
|
||
const os = require('os');
|
||
const tmpDir = os.tmpdir();
|
||
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
||
return path.join(tmpDir, `${prefix}-${randomSuffix}.tmp`);
|
||
}
|
||
|
||
// Export all functions
|
||
module.exports = {
|
||
getGitInfo,
|
||
getTodoInfo,
|
||
getRepoRoot,
|
||
getProjectMemoryBinding,
|
||
detectResearchProject,
|
||
promptLooksResearchRelated,
|
||
getChangesDetails,
|
||
analyzeChangesByType,
|
||
detectTempFiles,
|
||
generateRecommendations,
|
||
getEnabledPlugins,
|
||
getAvailableCommands,
|
||
getCommandDescription,
|
||
collectLocalSkills,
|
||
collectPluginSkills,
|
||
formatDateTime,
|
||
createTempFile,
|
||
getAllFiles,
|
||
checkClaudeMdUpdate,
|
||
updateSyncTimestamp
|
||
};
|