backup materials and knowledge-base docs
This commit is contained in:
843
文档润色流和知识库构建流/claude-scholar-upstream/hooks/hook-common.js
Normal file
843
文档润色流和知识库构建流/claude-scholar-upstream/hooks/hook-common.js
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
Reference in New Issue
Block a user