first commit

This commit is contained in:
2026-06-11 03:33:14 +08:00
commit 5f555bf342
599 changed files with 142347 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
/**
* 包管理器检测系统
* 智能检测并管理 npm、pnpm、yarn、bun 等包管理器
*
* @module package-manager
*/
const fs = require('fs');
const path = require('path');
const { commandExists, readJSON, getProjectRoot, getClaudeConfigDir, isWindows } = require('./utils');
// 包管理器配置
const PACKAGE_MANAGERS = {
npm: {
name: 'npm',
lockFile: 'package-lock.json',
installCmd: 'npm install',
runCmd: 'npm run',
execCmd: 'npx',
globalFlag: '--global'
},
pnpm: {
name: 'pnpm',
lockFile: 'pnpm-lock.yaml',
installCmd: 'pnpm install',
runCmd: 'pnpm',
execCmd: 'pnpm dlx',
globalFlag: '--global'
},
yarn: {
name: 'yarn',
lockFile: 'yarn.lock',
installCmd: 'yarn install',
runCmd: 'yarn',
execCmd: 'yarn dlx',
globalFlag: 'global'
},
bun: {
name: 'bun',
lockFile: 'bun.lockb',
installCmd: 'bun install',
runCmd: 'bun run',
execCmd: 'bun x',
globalFlag: '--global'
}
};
// 检测优先级
const DETECTION_PRIORITY = ['pnpm', 'bun', 'yarn', 'npm'];
/**
* 获取项目配置文件路径
* @returns {string} 配置文件路径
*/
function getProjectConfigPath() {
const projectRoot = getProjectRoot();
if (projectRoot) {
return path.join(projectRoot, '.claude', 'package-manager.json');
}
return null;
}
/**
* 获取全局配置文件路径
* @returns {string} 配置文件路径
*/
function getGlobalConfigPath() {
return path.join(getClaudeConfigDir(), 'package-manager.json');
}
/**
* 从环境变量检测包管理器
* @returns {string|null} 包管理器名称或 null
*/
function detectFromEnvironment() {
const envPm = process.env.CLAUDE_PACKAGE_MANAGER;
if (envPm && PACKAGE_MANAGERS[envPm]) {
return envPm;
}
return null;
}
/**
* 从项目配置检测包管理器
* @returns {string|null} 包管理器名称或 null
*/
function detectFromProjectConfig() {
const configPath = getProjectConfigPath();
if (configPath && fs.existsSync(configPath)) {
const config = readJSON(configPath);
if (config && config.packageManager && PACKAGE_MANAGERS[config.packageManager]) {
return config.packageManager;
}
}
return null;
}
/**
* 从全局配置检测包管理器
* @returns {string|null} 包管理器名称或 null
*/
function detectFromGlobalConfig() {
const configPath = getGlobalConfigPath();
if (configPath && fs.existsSync(configPath)) {
const config = readJSON(configPath);
if (config && config.packageManager && PACKAGE_MANAGERS[config.packageManager]) {
return config.packageManager;
}
}
return null;
}
/**
* 从 package.json 检测包管理器
* @returns {string|null} 包管理器名称或 null
*/
function detectFromPackageJson() {
const projectRoot = getProjectRoot();
if (!projectRoot) {
return null;
}
const packageJsonPath = path.join(projectRoot, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
return null;
}
const packageJson = readJSON(packageJsonPath);
if (!packageJson) {
return null;
}
// 检查 packageManager 字段
if (packageJson.packageManager) {
// 格式: "npm@8.0.0" 或 "pnpm@7.0.0"
const match = packageJson.packageManager.match(/^([a-zA-Z]+)@/);
if (match && PACKAGE_MANAGERS[match[1]]) {
return match[1];
}
}
return null;
}
/**
* 从锁文件检测包管理器
* @returns {string|null} 包管理器名称或 null
*/
function detectFromLockFile() {
const projectRoot = getProjectRoot();
if (!projectRoot) {
return null;
}
// 按优先级检查锁文件
for (const pm of DETECTION_PRIORITY) {
const lockFile = path.join(projectRoot, PACKAGE_MANAGERS[pm].lockFile);
if (fs.existsSync(lockFile)) {
return pm;
}
}
return null;
}
/**
* 从可用命令检测包管理器
* @returns {string|null} 包管理器名称或 null
*/
function detectFromAvailableCommands() {
for (const pm of DETECTION_PRIORITY) {
if (commandExists(pm)) {
return pm;
}
}
// npm 始终可用Node.js 自带)
return 'npm';
}
/**
* 智能检测包管理器
* @param {Object} options - 检测选项
* @returns {{name: string, source: string, config: Object}} 检测结果
*/
function getPackageManager(options = {}) {
const {
skipEnvironment = false,
skipProjectConfig = false,
skipGlobalConfig = false,
skipPackageJson = false,
skipLockFile = false,
skipAvailable = false
} = options;
// 按优先级检测
const detectors = [
!skipEnvironment && { detector: detectFromEnvironment, source: 'environment' },
!skipProjectConfig && { detector: detectFromProjectConfig, source: 'project-config' },
!skipPackageJson && { detector: detectFromPackageJson, source: 'package.json' },
!skipLockFile && { detector: detectFromLockFile, source: 'lock-file' },
!skipGlobalConfig && { detector: detectFromGlobalConfig, source: 'global-config' },
!skipAvailable && { detector: detectFromAvailableCommands, source: 'available' }
].filter(Boolean);
for (const { detector, source } of detectors) {
const pm = detector();
if (pm && PACKAGE_MANAGERS[pm]) {
return {
name: pm,
source,
config: PACKAGE_MANAGERS[pm]
};
}
}
// 默认回退到 npm
return {
name: 'npm',
source: 'default',
config: PACKAGE_MANAGERS.npm
};
}
/**
* 设置项目包管理器
* @param {string} packageManager - 包管理器名称
* @returns {boolean} 是否成功
*/
function setProjectPackageManager(packageManager) {
if (!PACKAGE_MANAGERS[packageManager]) {
console.error(`不支持的包管理器: ${packageManager}`);
return false;
}
const configPath = getProjectConfigPath();
if (!configPath) {
console.error('无法找到项目根目录');
return false;
}
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
try {
fs.writeFileSync(
configPath,
JSON.stringify({ packageManager }, null, 2),
'utf8'
);
return true;
} catch (err) {
console.error(`写入配置失败: ${err.message}`);
return false;
}
}
/**
* 设置全局包管理器
* @param {string} packageManager - 包管理器名称
* @returns {boolean} 是否成功
*/
function setGlobalPackageManager(packageManager) {
if (!PACKAGE_MANAGERS[packageManager]) {
console.error(`不支持的包管理器: ${packageManager}`);
return false;
}
const configPath = getGlobalConfigPath();
const configDir = path.dirname(configPath);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
try {
fs.writeFileSync(
configPath,
JSON.stringify({ packageManager }, null, 2),
'utf8'
);
return true;
} catch (err) {
console.error(`写入配置失败: ${err.message}`);
return false;
}
}
/**
* 构建包管理器命令
* @param {string} commandType - 命令类型 (install, run, exec)
* @param {Object} options - 选项
* @returns {string} 完整命令
*/
function buildCommand(commandType, options = {}) {
const pm = getPackageManager();
const config = pm.config;
switch (commandType) {
case 'install':
return config.installCmd;
case 'run':
return `${config.runCmd} ${options.script || ''}`;
case 'exec':
return `${config.execCmd} ${options.package || ''}`;
default:
return config.installCmd;
}
}
/**
* 获取所有可用的包管理器
* @returns {Array<{name: string, available: boolean}>} 可用包管理器列表
*/
function getAvailablePackageManagers() {
return Object.keys(PACKAGE_MANAGERS).map(name => ({
name,
available: commandExists(name)
}));
}
/**
* 打印包管理器信息
*/
function printPackageManagerInfo() {
const pm = getPackageManager();
console.log(`当前包管理器: ${pm.name} (来源: ${pm.source})`);
console.log(`配置:`);
console.log(` 安装命令: ${pm.config.installCmd}`);
console.log(` 运行命令: ${pm.config.runCmd}`);
console.log(` 执行命令: ${pm.config.execCmd}`);
}
// 导出所有函数
module.exports = {
PACKAGE_MANAGERS,
DETECTION_PRIORITY,
getPackageManager,
setProjectPackageManager,
setGlobalPackageManager,
buildCommand,
getAvailablePackageManagers,
printPackageManagerInfo,
getProjectConfigPath,
getGlobalConfigPath,
// 为 setup-package-manager.js 添加的导出
setPreferredPackageManager: setGlobalPackageManager,
detectFromLockFile,
detectFromPackageJson,
getSelectionPrompt: () => {
return '\n💡 运行 /setup-pm 配置首选包管理器\n';
}
};

View File

@@ -0,0 +1,233 @@
/**
* 跨平台工具函数库
* 为 Claude Code 插件提供跨平台兼容性支持
*
* @module utils
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, spawnSync } = require('child_process');
// 平台检测常量
const isWindows = process.platform === 'win32';
const isMacOS = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
/**
* 获取用户主目录(跨平台)
* @returns {string} 用户主目录路径
*/
function getHomeDir() {
return os.homedir();
}
/**
* 确保目录存在,如不存在则创建(跨平台)
* @param {string} dirPath - 目录路径
* @returns {string} 目录路径
*/
function ensureDir(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
return dirPath;
}
/**
* 检查命令是否存在(跨平台)
* @param {string} cmd - 命令名称
* @returns {boolean} 命令是否存在
*/
function commandExists(cmd) {
// 验证命令名称安全性
if (!/^[a-zA-Z0-9_.-]+$/.test(cmd)) {
return false;
}
try {
if (isWindows) {
// Windows 使用 where 命令
const result = spawnSync('where', [cmd], { stdio: 'pipe' });
return result.status === 0;
} else {
// Unix-like 系统使用 which 命令
const result = spawnSync('which', [cmd], { stdio: 'pipe' });
return result.status === 0;
}
} catch {
return false;
}
}
/**
* 安全执行命令(跨平台)
* @param {string} cmd - 要执行的命令
* @param {Object} options - 执行选项
* @returns {{success: boolean, output: string, error?: string}} 执行结果
*/
function runCommand(cmd, options = {}) {
try {
const result = execSync(cmd, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
...options
});
return { success: true, output: result.trim() };
} catch (err) {
return {
success: false,
output: err.stdout || '',
error: err.stderr || err.message
};
}
}
/**
* 获取 Claude 配置目录(跨平台)
* @returns {string} Claude 配置目录路径
*/
function getClaudeConfigDir() {
const homeDir = getHomeDir();
return path.join(homeDir, '.claude');
}
/**
* 获取项目根目录(跨平台)
* @param {string} startDir - 起始目录
* @returns {string|null} 项目根目录或 null
*/
function getProjectRoot(startDir = process.cwd()) {
let currentDir = startDir;
while (currentDir !== path.parse(currentDir).root) {
// 检查是否存在 .claude-plugin 目录
const pluginDir = path.join(currentDir, '.claude-plugin');
if (fs.existsSync(pluginDir)) {
return currentDir;
}
// 检查是否存在 package.json
const packageJson = path.join(currentDir, 'package.json');
if (fs.existsSync(packageJson)) {
return currentDir;
}
// 向上移动
currentDir = path.dirname(currentDir);
}
return null;
}
/**
* 路径拼接(跨平台)
* @param {...string} paths - 路径片段
* @returns {string} 拼接后的路径
*/
function joinPath(...paths) {
return path.join(...paths);
}
/**
* 获取绝对路径(跨平台)
* @param {...string} paths - 路径片段
* @returns {string} 绝对路径
*/
function resolvePath(...paths) {
return path.resolve(...paths);
}
/**
* 规范化路径(跨平台)
* @param {string} filePath - 文件路径
* @returns {string} 规范化后的路径
*/
function normalizePath(filePath) {
return path.normalize(filePath);
}
/**
* 读取 JSON 文件(跨平台)
* @param {string} filePath - JSON 文件路径
* @returns {Object|null} 解析后的 JSON 对象或 null
*/
function readJSON(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* 写入 JSON 文件(跨平台)
* @param {string} filePath - JSON 文件路径
* @param {Object} data - 要写入的数据
* @param {number|object|string} space - JSON 缩进空格数
* @returns {boolean} 是否成功
*/
function writeJSON(filePath, data, space = 2) {
try {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, JSON.stringify(data, null, space), 'utf8');
return true;
} catch {
return false;
}
}
/**
* 复制文件(跨平台)
* @param {string} src - 源文件路径
* @param {string} dest - 目标文件路径
* @returns {boolean} 是否成功
*/
function copyFile(src, dest) {
try {
ensureDir(path.dirname(dest));
fs.copyFileSync(src, dest);
return true;
} catch {
return false;
}
}
/**
* 获取平台信息
* @returns {Object} 平台信息对象
*/
function getPlatformInfo() {
return {
platform: process.platform,
isWindows,
isMacOS,
isLinux,
arch: process.arch,
nodeVersion: process.version,
homeDir: getHomeDir(),
tempDir: os.tmpdir()
};
}
// 导出所有函数
module.exports = {
isWindows,
isMacOS,
isLinux,
getHomeDir,
ensureDir,
commandExists,
runCommand,
getClaudeConfigDir,
getProjectRoot,
joinPath,
resolvePath,
normalizePath,
readJSON,
writeJSON,
copyFile,
getPlatformInfo
};

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* Package Manager Setup Script
*
* Interactive script to configure preferred package manager.
* Can be run directly or via the /setup-pm command.
*
* Usage:
* node scripts/setup-package-manager.js [pm-name]
* node scripts/setup-package-manager.js --detect
* node scripts/setup-package-manager.js --global pnpm
* node scripts/setup-package-manager.js --project bun
*/
const {
PACKAGE_MANAGERS,
getPackageManager,
setPreferredPackageManager,
setProjectPackageManager,
getAvailablePackageManagers,
detectFromLockFile,
detectFromPackageJson,
getSelectionPrompt
} = require('./lib/package-manager');
const { log } = require('./lib/utils');
function showHelp() {
console.log(`
Package Manager Setup for Claude Code
Usage:
node scripts/setup-package-manager.js [options] [package-manager]
Options:
--detect Detect and show current package manager
--global <pm> Set global preference (saves to ~/.claude/package-manager.json)
--project <pm> Set project preference (saves to .claude/package-manager.json)
--list List available package managers
--help Show this help message
Package Managers:
npm Node Package Manager (default with Node.js)
pnpm Fast, disk space efficient package manager
yarn Classic Yarn package manager
bun All-in-one JavaScript runtime & toolkit
Examples:
# Detect current package manager
node scripts/setup-package-manager.js --detect
# Set pnpm as global preference
node scripts/setup-package-manager.js --global pnpm
# Set bun for current project
node scripts/setup-package-manager.js --project bun
# List available package managers
node scripts/setup-package-manager.js --list
`);
}
function detectAndShow() {
const pm = getPackageManager();
const available = getAvailablePackageManagers();
const fromLock = detectFromLockFile();
const fromPkg = detectFromPackageJson();
console.log('\n=== Package Manager Detection ===\n');
console.log('Current selection:');
console.log(` Package Manager: ${pm.name}`);
console.log(` Source: ${pm.source}`);
console.log('');
console.log('Detection results:');
console.log(` From package.json: ${fromPkg || 'not specified'}`);
console.log(` From lock file: ${fromLock || 'not found'}`);
console.log(` Environment var: ${process.env.CLAUDE_PACKAGE_MANAGER || 'not set'}`);
console.log('');
console.log('Available package managers:');
for (const pmName of Object.keys(PACKAGE_MANAGERS)) {
const installed = available.includes(pmName);
const indicator = installed ? '✓' : '✗';
const current = pmName === pm.name ? ' (current)' : '';
console.log(` ${indicator} ${pmName}${current}`);
}
console.log('');
console.log('Commands:');
console.log(` Install: ${pm.config.installCmd}`);
console.log(` Run script: ${pm.config.runCmd} [script-name]`);
console.log(` Execute binary: ${pm.config.execCmd} [binary-name]`);
console.log('');
}
function listAvailable() {
const available = getAvailablePackageManagers();
const pm = getPackageManager();
console.log('\nAvailable Package Managers:\n');
for (const pmName of Object.keys(PACKAGE_MANAGERS)) {
const config = PACKAGE_MANAGERS[pmName];
const installed = available.includes(pmName);
const current = pmName === pm.name ? ' (current)' : '';
console.log(`${pmName}${current}`);
console.log(` Installed: ${installed ? 'Yes' : 'No'}`);
console.log(` Lock file: ${config.lockFile}`);
console.log(` Install: ${config.installCmd}`);
console.log(` Run: ${config.runCmd}`);
console.log('');
}
}
function setGlobal(pmName) {
if (!PACKAGE_MANAGERS[pmName]) {
console.error(`Error: Unknown package manager "${pmName}"`);
console.error(`Available: ${Object.keys(PACKAGE_MANAGERS).join(', ')}`);
process.exit(1);
}
const available = getAvailablePackageManagers();
if (!available.includes(pmName)) {
console.warn(`Warning: ${pmName} is not installed on your system`);
}
try {
setPreferredPackageManager(pmName);
console.log(`\n✓ Global preference set to: ${pmName}`);
console.log(' Saved to: ~/.claude/package-manager.json');
console.log('');
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
}
function setProject(pmName) {
if (!PACKAGE_MANAGERS[pmName]) {
console.error(`Error: Unknown package manager "${pmName}"`);
console.error(`Available: ${Object.keys(PACKAGE_MANAGERS).join(', ')}`);
process.exit(1);
}
try {
setProjectPackageManager(pmName);
console.log(`\n✓ Project preference set to: ${pmName}`);
console.log(' Saved to: .claude/package-manager.json');
console.log('');
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
}
// Main
const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
showHelp();
process.exit(0);
}
if (args.includes('--detect')) {
detectAndShow();
process.exit(0);
}
if (args.includes('--list')) {
listAvailable();
process.exit(0);
}
const globalIdx = args.indexOf('--global');
if (globalIdx !== -1) {
const pmName = args[globalIdx + 1];
if (!pmName) {
console.error('Error: --global requires a package manager name');
process.exit(1);
}
setGlobal(pmName);
process.exit(0);
}
const projectIdx = args.indexOf('--project');
if (projectIdx !== -1) {
const pmName = args[projectIdx + 1];
if (!pmName) {
console.error('Error: --project requires a package manager name');
process.exit(1);
}
setProject(pmName);
process.exit(0);
}
// If just a package manager name is provided, set it globally
const pmName = args[0];
if (PACKAGE_MANAGERS[pmName]) {
setGlobal(pmName);
} else {
console.error(`Error: Unknown option or package manager "${pmName}"`);
showHelp();
process.exit(1);
}

View File

@@ -0,0 +1,544 @@
#!/usr/bin/env bash
set -euo pipefail
CLAUDE_DIR="$HOME/.claude"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COMPONENTS=(skills commands agents rules hooks scripts templates)
CLAUDE_MD_SIDECAR="CLAUDE.scholar.md"
CLAUDE_ZH_MD_SIDECAR="CLAUDE.zh-CN.scholar.md"
BACKUP_ROOT="$CLAUDE_DIR/.claude-scholar-backups"
MANIFEST_FILE="$CLAUDE_DIR/.claude-scholar-manifest.txt"
STATE_FILE="$CLAUDE_DIR/.claude-scholar-install-state"
PREVIOUS_MANAGED_PATHS_FILE="$(mktemp)"
BACKUP_STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="$BACKUP_ROOT/$BACKUP_STAMP"
BACKUP_READY=0
BACKUP_COUNT=0
UPDATED_COUNT=0
SKIPPED_COUNT=0
SETTINGS_CREATED=0
MANAGED_PATHS=()
CLAUDE_TARGETS=()
SETTINGS_META_FILE="$(mktemp)"
LEGACY_INSTALL_DETECTED=0
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
error() { echo -e "\033[1;31m[ERROR]\033[0m $*"; exit 1; }
cleanup_temp_files() {
rm -f "$SETTINGS_META_FILE" "$PREVIOUS_MANAGED_PATHS_FILE"
}
trap cleanup_temp_files EXIT
check_deps() {
command -v git >/dev/null || error "Git is required. Install it first."
command -v node >/dev/null || error "Node.js is required (hooks depend on it). Install it first."
}
load_previous_manifest() {
if [ -f "$MANIFEST_FILE" ]; then
cp "$MANIFEST_FILE" "$PREVIOUS_MANAGED_PATHS_FILE"
else
: > "$PREVIOUS_MANAGED_PATHS_FILE"
fi
}
detect_legacy_install() {
local settings="$CLAUDE_DIR/settings.json"
[ -f "$MANIFEST_FILE" ] && return 0
[ -f "$settings" ] || return 0
if CLAUDE_SETTINGS_TARGET="$settings" node <<'NODE'
const fs = require('fs');
const settings = JSON.parse(fs.readFileSync(process.env.CLAUDE_SETTINGS_TARGET, 'utf8'));
const hookNeedles = [
'.claude/hooks/security-guard.js',
'.claude/hooks/session-summary.js',
'.claude/hooks/session-start.js',
'.claude/hooks/stop-summary.js',
'.claude/hooks/skill-forced-eval.js',
];
let detected = false;
for (const matchers of Object.values(settings.hooks || {})) {
if (!Array.isArray(matchers)) continue;
for (const matcher of matchers) {
for (const hook of matcher.hooks || []) {
if (typeof hook.command === 'string' && hookNeedles.some((needle) => hook.command.includes(needle))) {
detected = true;
}
}
}
}
process.exit(detected ? 0 : 1);
NODE
then
LEGACY_INSTALL_DETECTED=1
fi
}
record_managed_path() {
local target="$1"
local rel="${target#$CLAUDE_DIR/}"
[ "$rel" = "$target" ] && return 0
[ -z "$rel" ] && return 0
MANAGED_PATHS+=("$rel")
}
record_claude_target() {
local target="$1"
local rel="${target#$CLAUDE_DIR/}"
[ "$rel" = "$target" ] && return 0
[ -z "$rel" ] && return 0
CLAUDE_TARGETS+=("$rel")
}
was_previously_managed() {
local target="$1"
local rel="${target#$CLAUDE_DIR/}"
[ "$rel" = "$target" ] && return 1
grep -Fxq "$rel" "$PREVIOUS_MANAGED_PATHS_FILE"
}
should_adopt_existing_path() {
local target="$1"
if was_previously_managed "$target"; then
return 0
fi
[ "$LEGACY_INSTALL_DETECTED" -eq 1 ]
}
write_install_state() {
mkdir -p "$CLAUDE_DIR"
if [ "${#MANAGED_PATHS[@]}" -gt 0 ]; then
printf "%s\n" "${MANAGED_PATHS[@]}" | LC_ALL=C sort -u > "$MANIFEST_FILE"
else
: > "$MANIFEST_FILE"
fi
local managed_paths_file
local claude_targets_file
managed_paths_file="$(mktemp)"
claude_targets_file="$(mktemp)"
if [ "${#MANAGED_PATHS[@]}" -gt 0 ]; then
printf "%s\n" "${MANAGED_PATHS[@]}" | LC_ALL=C sort -u > "$managed_paths_file"
else
: > "$managed_paths_file"
fi
if [ "${#CLAUDE_TARGETS[@]}" -gt 0 ]; then
printf "%s\n" "${CLAUDE_TARGETS[@]}" | LC_ALL=C sort -u > "$claude_targets_file"
else
: > "$claude_targets_file"
fi
CLAUDE_STATE_FILE="$STATE_FILE" \
CLAUDE_SETTINGS_META_FILE="$SETTINGS_META_FILE" \
CLAUDE_MANAGED_PATHS_FILE="$managed_paths_file" \
CLAUDE_TARGETS_FILE="$claude_targets_file" \
CLAUDE_INSTALLED_AT="$BACKUP_STAMP" \
CLAUDE_SOURCE_DIR="$SRC_DIR" \
CLAUDE_SETTINGS_CREATED="$SETTINGS_CREATED" \
CLAUDE_BACKUP_DIR="$BACKUP_DIR" \
CLAUDE_BACKUP_READY="$BACKUP_READY" \
node <<'NODE'
const fs = require('fs');
function readLines(path) {
if (!path || !fs.existsSync(path)) return [];
return fs.readFileSync(path, 'utf8').split('\n').map((line) => line.trim()).filter(Boolean);
}
function readJson(path) {
if (!path || !fs.existsSync(path)) return {};
return JSON.parse(fs.readFileSync(path, 'utf8'));
}
const state = {
installedAt: process.env.CLAUDE_INSTALLED_AT,
sourceDir: process.env.CLAUDE_SOURCE_DIR,
settingsCreated: process.env.CLAUDE_SETTINGS_CREATED === '1',
backupDir: process.env.CLAUDE_BACKUP_READY === '1' ? process.env.CLAUDE_BACKUP_DIR : '',
managedPaths: readLines(process.env.CLAUDE_MANAGED_PATHS_FILE),
claudeTargets: readLines(process.env.CLAUDE_TARGETS_FILE),
settings: readJson(process.env.CLAUDE_SETTINGS_META_FILE),
};
fs.writeFileSync(process.env.CLAUDE_STATE_FILE, JSON.stringify(state, null, 2) + '\n');
NODE
rm -f "$managed_paths_file" "$claude_targets_file"
}
ensure_backup_dir() {
if [ "$BACKUP_READY" -eq 0 ]; then
mkdir -p "$BACKUP_DIR"
BACKUP_READY=1
info "Backup directory: $BACKUP_DIR"
fi
}
backup_path() {
local target="$1"
[ -e "$target" ] || return 0
ensure_backup_dir
local rel="${target#$CLAUDE_DIR/}"
if [ "$rel" = "$target" ]; then
rel="$(basename "$target")"
fi
mkdir -p "$BACKUP_DIR/$(dirname "$rel")"
if [ -d "$target" ]; then
cp -R "$target" "$BACKUP_DIR/$rel"
else
cp -p "$target" "$BACKUP_DIR/$rel"
fi
BACKUP_COUNT=$((BACKUP_COUNT + 1))
}
# Create settings.json from template
create_settings() {
local template="$1/settings.json.template"
local target="$CLAUDE_DIR/settings.json"
if [ -f "$template" ] && [ ! -f "$target" ]; then
cp "$template" "$target"
SETTINGS_CREATED=1
CLAUDE_SETTINGS_TEMPLATE="$template" CLAUDE_SETTINGS_META_FILE="$SETTINGS_META_FILE" node <<'NODE'
const fs = require('fs');
const template = JSON.parse(fs.readFileSync(process.env.CLAUDE_SETTINGS_TEMPLATE, 'utf8'));
const addedHooks = [];
for (const [eventName, matchers] of Object.entries(template.hooks || {})) {
for (const matcher of matchers || []) {
for (const hook of matcher.hooks || []) {
addedHooks.push({
event: eventName,
matcher: matcher.matcher || '*',
type: hook.type || '',
command: hook.command || '',
timeout: hook.timeout ?? null,
});
}
}
}
fs.writeFileSync(process.env.CLAUDE_SETTINGS_META_FILE, JSON.stringify({
addedHooks,
addedMcpServers: Object.keys(template.mcpServers || {}),
addedMcpServerFields: {},
addedEnabledPlugins: Object.keys(template.enabledPlugins || {}),
}, null, 2) + '\n');
NODE
info "Created settings.json from template."
info " → Edit $target to add your GITHUB_PERSONAL_ACCESS_TOKEN (optional)."
fi
}
# Merge hooks, mcpServers, enabledPlugins from template into existing settings.json
merge_settings() {
local template="$1/settings.json.template"
local target="$CLAUDE_DIR/settings.json"
[ -f "$template" ] || return 0
[ -f "$target" ] || { create_settings "$1"; return 0; }
# Backup
backup_path "$target"
cp "$target" "${target}.bak"
info "Backed up settings.json → settings.json.bak"
# Merge hooks, mcpServers, enabledPlugins while preserving user env/model/API key settings.
CLAUDE_SETTINGS_TARGET="$target" CLAUDE_SETTINGS_TEMPLATE="$template" CLAUDE_SETTINGS_META_FILE="$SETTINGS_META_FILE" node <<'NODE'
const fs = require('fs');
const targetPath = process.env.CLAUDE_SETTINGS_TARGET;
const templatePath = process.env.CLAUDE_SETTINGS_TEMPLATE;
const metaPath = process.env.CLAUDE_SETTINGS_META_FILE;
const existing = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
const addedHooks = [];
const addedMcpServers = [];
const addedMcpServerFields = {};
const addedEnabledPlugins = [];
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function mergeMissing(existingValue, templateValue, pathParts, addedPaths) {
if (existingValue === undefined) return clone(templateValue);
if (templateValue === null || Array.isArray(templateValue) || typeof templateValue !== 'object') {
return existingValue;
}
const output = { ...existingValue };
for (const [key, value] of Object.entries(templateValue)) {
if (!(key in output)) {
output[key] = clone(value);
addedPaths.push([...pathParts, key].join('.'));
continue;
}
if (
output[key] &&
value &&
!Array.isArray(output[key]) &&
!Array.isArray(value) &&
typeof output[key] === 'object' &&
typeof value === 'object'
) {
output[key] = mergeMissing(output[key], value, [...pathParts, key], addedPaths);
}
}
return output;
}
function mergeHooks(existingHooks, templateHooks) {
const output = existingHooks ? clone(existingHooks) : {};
for (const [eventName, templateMatchers] of Object.entries(templateHooks || {})) {
const existingMatchers = Array.isArray(output[eventName]) ? output[eventName] : [];
for (const templateMatcher of templateMatchers) {
const matchValue = templateMatcher.matcher || '*';
let existingMatcher = existingMatchers.find((item) => (item.matcher || '*') === matchValue);
if (!existingMatcher) {
existingMatchers.push(clone(templateMatcher));
for (const hook of templateMatcher.hooks || []) {
addedHooks.push({
event: eventName,
matcher: matchValue,
type: hook.type || '',
command: hook.command || '',
timeout: hook.timeout ?? null,
});
}
continue;
}
existingMatcher.hooks = Array.isArray(existingMatcher.hooks) ? existingMatcher.hooks : [];
const seen = new Set(
existingMatcher.hooks.map((hook) =>
JSON.stringify({
type: hook.type || '',
command: hook.command || '',
timeout: hook.timeout ?? null,
}),
),
);
for (const hook of templateMatcher.hooks || []) {
const signature = JSON.stringify({
type: hook.type || '',
command: hook.command || '',
timeout: hook.timeout ?? null,
});
if (!seen.has(signature)) {
existingMatcher.hooks.push(clone(hook));
seen.add(signature);
addedHooks.push({
event: eventName,
matcher: matchValue,
type: hook.type || '',
command: hook.command || '',
timeout: hook.timeout ?? null,
});
}
}
}
output[eventName] = existingMatchers;
}
return output;
}
existing.hooks = mergeHooks(existing.hooks, template.hooks);
if (template.mcpServers) {
existing.mcpServers = existing.mcpServers || {};
for (const [key, value] of Object.entries(template.mcpServers)) {
if (!(key in existing.mcpServers)) {
addedMcpServers.push(key);
existing.mcpServers[key] = clone(value);
continue;
}
const addedPaths = [];
existing.mcpServers[key] = mergeMissing(existing.mcpServers[key], value, [], addedPaths);
if (addedPaths.length > 0) {
addedMcpServerFields[key] = addedPaths;
}
}
}
if (template.enabledPlugins) {
existing.enabledPlugins = existing.enabledPlugins || {};
for (const [key, value] of Object.entries(template.enabledPlugins)) {
if (!(key in existing.enabledPlugins)) {
existing.enabledPlugins[key] = value;
addedEnabledPlugins.push(key);
}
}
}
fs.writeFileSync(targetPath, JSON.stringify(existing, null, 2) + '\n');
fs.writeFileSync(metaPath, JSON.stringify({
addedHooks,
addedMcpServers,
addedMcpServerFields,
addedEnabledPlugins,
}, null, 2) + '\n');
NODE
local merge_status=$?
if [ "$merge_status" -ne 0 ]; then
warn "Auto-merge failed. Please manually copy settings from settings.json.template."
return 0
fi
info "Merged hooks/mcpServers/enabledPlugins into settings.json without touching env/model/API key fields."
}
# Copy one file with backup-aware overwrite
copy_file_safely() {
local src_file="$1"
local target_file="$2"
mkdir -p "$(dirname "$target_file")"
if [ -f "$target_file" ] && cmp -s "$src_file" "$target_file"; then
if should_adopt_existing_path "$target_file"; then
record_managed_path "$target_file"
fi
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
return 0
fi
if [ -e "$target_file" ]; then
backup_path "$target_file"
[ -d "$target_file" ] && rm -rf "$target_file"
fi
cp -p "$src_file" "$target_file"
record_managed_path "$target_file"
UPDATED_COUNT=$((UPDATED_COUNT + 1))
}
# Copy component directories with per-file backup
copy_dir_safely() {
local src_dir="$1"
local target_dir="$2"
if [ -e "$target_dir" ] && [ ! -d "$target_dir" ]; then
backup_path "$target_dir"
rm -f "$target_dir"
fi
mkdir -p "$target_dir"
while IFS= read -r -d '' src_file; do
local rel="${src_file#$src_dir/}"
local target_file="$target_dir/$rel"
copy_file_safely "$src_file" "$target_file"
done < <(find "$src_dir" -type f -print0)
}
install_claude_md() {
local src_file="$1"
local target_file="$CLAUDE_DIR/CLAUDE.md"
local sidecar_file="$CLAUDE_DIR/$CLAUDE_MD_SIDECAR"
if [ -f "$target_file" ] && should_adopt_existing_path "$target_file"; then
copy_file_safely "$src_file" "$target_file"
record_claude_target "$target_file"
return 0
fi
if [ -f "$target_file" ]; then
warn "Preserving existing CLAUDE.md"
copy_file_safely "$src_file" "$sidecar_file"
record_claude_target "$sidecar_file"
info "Installed repository CLAUDE.md as $CLAUDE_MD_SIDECAR"
return 0
fi
copy_file_safely "$src_file" "$target_file"
record_claude_target "$target_file"
}
install_claude_zh_md() {
local src_file="$1"
local target_file="$CLAUDE_DIR/CLAUDE.zh-CN.md"
local sidecar_file="$CLAUDE_DIR/$CLAUDE_ZH_MD_SIDECAR"
if [ -f "$target_file" ] && should_adopt_existing_path "$target_file"; then
copy_file_safely "$src_file" "$target_file"
record_claude_target "$target_file"
return 0
fi
if [ -f "$target_file" ]; then
warn "Preserving existing CLAUDE.zh-CN.md"
copy_file_safely "$src_file" "$sidecar_file"
record_claude_target "$sidecar_file"
info "Installed repository CLAUDE.zh-CN.md as $CLAUDE_ZH_MD_SIDECAR"
return 0
fi
copy_file_safely "$src_file" "$target_file"
record_claude_target "$target_file"
}
copy_components() {
local src="$1"
if [ -f "$src/CLAUDE.md" ]; then
install_claude_md "$src/CLAUDE.md"
fi
if [ -f "$src/CLAUDE.zh-CN.md" ]; then
install_claude_zh_md "$src/CLAUDE.zh-CN.md"
fi
for comp in "${COMPONENTS[@]}"; do
if [ -e "$src/$comp" ]; then
if [ -d "$src/$comp" ]; then
copy_dir_safely "$src/$comp" "$CLAUDE_DIR/$comp"
else
copy_file_safely "$src/$comp" "$CLAUDE_DIR/$comp"
fi
fi
done
info "Updated components: ${COMPONENTS[*]}"
}
main() {
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ Claude Scholar Installer ║"
echo "╚══════════════════════════════════════╝"
echo ""
check_deps
load_previous_manifest
detect_legacy_install
info "Installing from: $SRC_DIR"
copy_components "$SRC_DIR"
merge_settings "$SRC_DIR"
write_install_state
info "Your existing env/model/API key/permissions settings are preserved."
info "Install manifest: $MANIFEST_FILE"
info "Updated files: $UPDATED_COUNT | Unchanged files skipped: $SKIPPED_COUNT | Backups created: $BACKUP_COUNT"
if [ "$BACKUP_READY" -eq 1 ]; then
info "Recover previous files from: $BACKUP_DIR"
fi
echo ""
info "Done! Restart Claude Code CLI to activate."
echo ""
}
main "$@"

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SOURCE_VAULT_DEFAULT="$REPO_ROOT/obsidian-vault"
usage() {
cat <<'EOF'
Usage:
bash scripts/sync_obsidian_to_windows.sh --windows-path /mnt/c/Users/<You>/Documents/Obsidian/claude-scholar-vault
Options:
--windows-path PATH Windows-local vault path mounted in WSL, e.g. /mnt/c/Users/Alice/Documents/Obsidian/claude-scholar-vault
--source-path PATH Source vault path inside WSL. Default: ./obsidian-vault
--dry-run Show what would change without copying
--no-delete Do not delete files in the Windows mirror that no longer exist in the source
-h, --help Show this help
Notes:
- Use a Windows-local path under /mnt/<drive>/... as the mirror target.
- Open the mirrored target directory with Windows Obsidian, not the WSL path.
- This script treats the WSL vault as the source of truth.
EOF
}
WINDOWS_PATH=""
SOURCE_PATH="$SOURCE_VAULT_DEFAULT"
DRY_RUN=0
DELETE_MODE=1
while [ "$#" -gt 0 ]; do
case "$1" in
--windows-path)
WINDOWS_PATH="${2:-}"
shift 2
;;
--source-path)
SOURCE_PATH="${2:-}"
shift 2
;;
--dry-run)
DRY_RUN=1
shift
;;
--no-delete)
DELETE_MODE=0
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
done
if [ -z "$WINDOWS_PATH" ]; then
echo "Missing required --windows-path" >&2
usage >&2
exit 1
fi
if ! command -v rsync >/dev/null 2>&1; then
echo "rsync is required but not installed." >&2
exit 1
fi
SOURCE_PATH="$(realpath "$SOURCE_PATH")"
WINDOWS_PATH="$(realpath -m "$WINDOWS_PATH")"
if [ ! -d "$SOURCE_PATH" ]; then
echo "Source vault does not exist: $SOURCE_PATH" >&2
exit 1
fi
case "$WINDOWS_PATH" in
/mnt/[a-zA-Z]/*) ;;
*)
echo "Target must be a Windows-local path mounted in WSL, for example /mnt/c/Users/<You>/Documents/Obsidian/claude-scholar-vault" >&2
echo "Received: $WINDOWS_PATH" >&2
exit 1
;;
esac
mkdir -p "$WINDOWS_PATH"
RSYNC_ARGS=(-a --info=stats2,progress2)
if [ "$DELETE_MODE" -eq 1 ]; then
RSYNC_ARGS+=(--delete)
fi
if [ "$DRY_RUN" -eq 1 ]; then
RSYNC_ARGS+=(--dry-run)
fi
echo "Source vault : $SOURCE_PATH"
echo "Windows copy : $WINDOWS_PATH"
echo "Delete extra : $DELETE_MODE"
echo "Dry run : $DRY_RUN"
rsync "${RSYNC_ARGS[@]}" "$SOURCE_PATH"/ "$WINDOWS_PATH"/
echo
echo "Done."
echo "Open this directory with Windows Obsidian:"
echo " $WINDOWS_PATH"

View File

@@ -0,0 +1,242 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SETUP_SH="$REPO_ROOT/scripts/setup.sh"
UNINSTALL_SH="$REPO_ROOT/scripts/uninstall.sh"
pass() {
echo "[PASS] $1"
}
make_home() {
mktemp -d /tmp/claude-scholar-test.XXXXXX
}
run_setup() {
HOME="$1" bash "$SETUP_SH" >/dev/null
}
run_uninstall() {
HOME="$1" bash "$UNINSTALL_SH" >/dev/null
}
test_roundtrip_empty_home() {
local home
home="$(make_home)"
run_setup "$home"
test -f "$home/.claude/.claude-scholar-manifest.txt"
test -f "$home/.claude/.claude-scholar-install-state"
test -f "$home/.claude/CLAUDE.md"
run_uninstall "$home"
test ! -f "$home/.claude/.claude-scholar-manifest.txt"
test ! -f "$home/.claude/.claude-scholar-install-state"
test ! -f "$home/.claude/settings.json"
if [ -d "$home/.claude" ]; then
! find "$home/.claude"/{skills,commands,agents,rules,hooks,scripts} -type f 2>/dev/null | grep -q .
fi
pass "roundtrip on empty home"
}
test_preserve_preexisting_settings_keys() {
local home
home="$(make_home)"
mkdir -p "$home/.claude"
python3 - "$home/.claude/settings.json" <<'PY'
import json, sys
path = sys.argv[1]
data = {
"mcpServers": {
"zotero": {
"command": "custom-zotero"
}
},
"enabledPlugins": {
"superpowers@claude-plugins-official": False
},
"hooks": {
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo user-hook",
"timeout": 3
}
]
}
]
}
}
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
run_setup "$home"
run_uninstall "$home"
python3 - "$home/.claude/settings.json" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
assert data["mcpServers"]["zotero"]["command"] == "custom-zotero"
assert "args" not in data["mcpServers"]["zotero"]
assert "env" not in data["mcpServers"]["zotero"]
assert data["enabledPlugins"]["superpowers@claude-plugins-official"] is False
hooks = data["hooks"]["Stop"][0]["hooks"]
assert len(hooks) == 1 and hooks[0]["command"] == "echo user-hook"
PY
pass "preserve pre-existing settings entries with overlapping keys"
}
test_manifest_missing_fails_safe() {
local home
home="$(make_home)"
run_setup "$home"
rm -f "$home/.claude/.claude-scholar-manifest.txt"
if HOME="$home" bash "$UNINSTALL_SH" >/tmp/claude-scholar-uninstall-fail.log 2>&1; then
echo "[FAIL] manifest missing should fail"
cat /tmp/claude-scholar-uninstall-fail.log
exit 1
fi
test -f "$home/.claude/CLAUDE.md"
test -f "$home/.claude/.claude-scholar-install-state"
pass "manifest missing fails safely"
}
test_state_records_direct_claude_targets() {
local home
home="$(make_home)"
run_setup "$home"
python3 - "$home/.claude/.claude-scholar-install-state" <<'PY'
import json, sys
state = json.load(open(sys.argv[1], encoding="utf-8"))
targets = set(state["claudeTargets"])
assert "CLAUDE.md" in targets, targets
assert "CLAUDE.zh-CN.md" in targets, targets
PY
run_uninstall "$home"
test ! -f "$home/.claude/CLAUDE.md"
test ! -f "$home/.claude/CLAUDE.zh-CN.md"
pass "state records direct CLAUDE targets"
}
test_identical_preexisting_file_is_not_owned() {
local home
home="$(make_home)"
mkdir -p "$home/.claude/hooks"
cp "$REPO_ROOT/hooks/security-guard.js" "$home/.claude/hooks/security-guard.js"
run_setup "$home"
run_uninstall "$home"
test -f "$home/.claude/hooks/security-guard.js"
pass "identical pre-existing file is not treated as owned"
}
test_reinstall_keeps_owned_files_owned() {
local home
home="$(make_home)"
run_setup "$home"
run_setup "$home"
run_uninstall "$home"
test ! -f "$home/.claude/hooks/security-guard.js"
test ! -f "$home/.claude/CLAUDE.md"
pass "reinstall preserves ownership of previously installed files"
}
test_legacy_install_upgrade_adopts_existing_files() {
local home
home="$(make_home)"
mkdir -p "$home/.claude/hooks"
cp "$REPO_ROOT/CLAUDE.md" "$home/.claude/CLAUDE.md"
cp "$REPO_ROOT/hooks/security-guard.js" "$home/.claude/hooks/security-guard.js"
python3 - "$home/.claude/settings.json" <<'PY'
import json, sys
data = {
"mcpServers": {
"streamable-mcp-server": {
"type": "streamable-http",
"url": "http://127.0.0.1:12306/mcp"
}
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path'),h=require('os').homedir();require('child_process').execSync('node '+p.join(h,'.claude/hooks/security-guard.js'),{stdio:'inherit'})\"",
"timeout": 5
}
]
}
]
}
}
with open(sys.argv[1], "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
run_setup "$home"
run_uninstall "$home"
test ! -f "$home/.claude/CLAUDE.md"
test ! -f "$home/.claude/hooks/security-guard.js"
pass "legacy install upgrade adopts existing managed files"
}
test_overlapping_mcp_keys_do_not_trigger_legacy_adoption() {
local home
home="$(make_home)"
mkdir -p "$home/.claude/hooks"
cp "$REPO_ROOT/hooks/security-guard.js" "$home/.claude/hooks/security-guard.js"
python3 - "$home/.claude/settings.json" <<'PY'
import json, sys
data = {
"mcpServers": {
"zotero": {
"command": "custom-zotero"
}
},
"enabledPlugins": {
"superpowers@claude-plugins-official": False
}
}
with open(sys.argv[1], "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
PY
run_setup "$home"
run_uninstall "$home"
test -f "$home/.claude/hooks/security-guard.js"
pass "overlapping MCP keys alone do not trigger legacy adoption"
}
main() {
bash -n "$SETUP_SH"
bash -n "$UNINSTALL_SH"
test_roundtrip_empty_home
test_preserve_preexisting_settings_keys
test_manifest_missing_fails_safe
test_state_records_direct_claude_targets
test_identical_preexisting_file_is_not_owned
test_reinstall_keeps_owned_files_owned
test_legacy_install_upgrade_adopts_existing_files
test_overlapping_mcp_keys_do_not_trigger_legacy_adoption
}
main "$@"

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env bash
set -euo pipefail
CLAUDE_DIR="$HOME/.claude"
MANIFEST_FILE="$CLAUDE_DIR/.claude-scholar-manifest.txt"
STATE_FILE="$CLAUDE_DIR/.claude-scholar-install-state"
BACKUP_ROOT="$CLAUDE_DIR/.claude-scholar-backups"
UNINSTALL_STAMP="$(date +%Y%m%d-%H%M%S)"
UNINSTALL_BACKUP_DIR="$BACKUP_ROOT/uninstall-$UNINSTALL_STAMP"
COMPONENT_DIRS=(skills commands agents rules hooks scripts templates)
LEGACY_MANAGED_PATHS=(
"skills/planning-with-files/SKILL.md"
"skills/planning-with-files/examples.md"
"skills/planning-with-files/reference.md"
)
REMOVED_COUNT=0
SKIPPED_COUNT=0
DRY_RUN=0
info() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
warn() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
error() { echo -e "\033[1;31m[ERROR]\033[0m $*" >&2; exit 1; }
usage() {
cat <<'EOF'
Usage: bash scripts/uninstall.sh [--dry-run]
Removes Claude Scholar managed files from ~/.claude without touching unrelated user files.
- Uses ~/.claude/.claude-scholar-manifest.txt when available.
- Falls back to the current repo checkout when run from a repo working tree.
- Cleans Claude Scholar hook / MCP / plugin entries from ~/.claude/settings.json.
EOF
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
DRY_RUN=1
;;
-h|--help)
usage
exit 0
;;
*)
error "Unknown argument: $1"
;;
esac
shift
done
}
require_install_metadata() {
[ -f "$MANIFEST_FILE" ] || error "Missing $MANIFEST_FILE. Refusing to guess ownership."
[ -f "$STATE_FILE" ] || error "Missing $STATE_FILE. Refusing to guess settings ownership."
}
backup_target() {
local target="$1"
[ -e "$target" ] || return 0
local rel="${target#$CLAUDE_DIR/}"
[ "$rel" = "$target" ] && rel="$(basename "$target")"
mkdir -p "$UNINSTALL_BACKUP_DIR/$(dirname "$rel")"
if [ "$DRY_RUN" -eq 0 ]; then
if [ -d "$target" ]; then
cp -R "$target" "$UNINSTALL_BACKUP_DIR/$rel"
else
cp -p "$target" "$UNINSTALL_BACKUP_DIR/$rel"
fi
fi
}
append_path() {
local path="$1"
[ -n "$path" ] || return 0
printf "%s\n" "$path"
}
collect_manifest_paths() {
cat "$MANIFEST_FILE"
printf "%s\n" "${LEGACY_MANAGED_PATHS[@]}"
}
remove_managed_files() {
local rel
while IFS= read -r rel; do
[ -n "$rel" ] || continue
case "$rel" in
.*|*..*|/*) continue ;;
esac
local target="$CLAUDE_DIR/$rel"
if [ ! -e "$target" ]; then
SKIPPED_COUNT=$((SKIPPED_COUNT + 1))
continue
fi
backup_target "$target"
if [ "$DRY_RUN" -eq 0 ]; then
rm -rf "$target"
fi
REMOVED_COUNT=$((REMOVED_COUNT + 1))
done < <(collect_manifest_paths | LC_ALL=C sort -u)
}
cleanup_empty_dirs() {
local comp
for comp in "${COMPONENT_DIRS[@]}"; do
if [ -d "$CLAUDE_DIR/$comp" ] && [ "$DRY_RUN" -eq 0 ]; then
find "$CLAUDE_DIR/$comp" -depth -type d -empty -delete
fi
done
}
cleanup_settings() {
local settings="$CLAUDE_DIR/settings.json"
[ -f "$settings" ] || return 0
backup_target "$settings"
if [ "$DRY_RUN" -eq 1 ]; then
info "Would clean Claude Scholar entries from $settings"
return 0
fi
SETTINGS_PATH="$settings" STATE_PATH="$STATE_FILE" node <<'NODE'
const fs = require('fs');
const settingsPath = process.env.SETTINGS_PATH;
const statePath = process.env.STATE_PATH;
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
const settingsCreated = Boolean(state.settingsCreated);
const addedHooks = Array.isArray(state.settings?.addedHooks) ? state.settings.addedHooks : [];
const addedMcpServers = new Set(Array.isArray(state.settings?.addedMcpServers) ? state.settings.addedMcpServers : []);
const addedMcpServerFields = state.settings?.addedMcpServerFields && typeof state.settings.addedMcpServerFields === 'object'
? state.settings.addedMcpServerFields
: {};
const addedEnabledPlugins = new Set(Array.isArray(state.settings?.addedEnabledPlugins) ? state.settings.addedEnabledPlugins : []);
function hookSignature(hook) {
return JSON.stringify({
type: hook?.type || '',
command: hook?.command || '',
timeout: hook?.timeout ?? null,
});
}
const ownedHookMatchers = new Map();
for (const hook of addedHooks) {
const key = `${hook.event}::${hook.matcher || '*'}`;
const sigs = ownedHookMatchers.get(key) || new Set();
sigs.add(hookSignature(hook));
ownedHookMatchers.set(key, sigs);
}
function pruneEmptyContainers(root, parts) {
for (let i = parts.length - 1; i > 0; i -= 1) {
const parent = parts.slice(0, i - 1).reduce((acc, key) => (acc && typeof acc === 'object') ? acc[key] : undefined, root);
const key = parts[i - 1];
if (!parent || typeof parent !== 'object') return;
const value = parent[key];
if (value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) {
delete parent[key];
continue;
}
return;
}
}
function removeNestedPath(root, dottedPath) {
if (!root || typeof root !== 'object' || !dottedPath) return;
const parts = dottedPath.split('.').filter(Boolean);
if (parts.length === 0) return;
let current = root;
for (let i = 0; i < parts.length - 1; i += 1) {
current = current?.[parts[i]];
if (!current || typeof current !== 'object') return;
}
delete current[parts[parts.length - 1]];
pruneEmptyContainers(root, parts);
}
if (settings.hooks && typeof settings.hooks === 'object') {
for (const [eventName, matchers] of Object.entries(settings.hooks)) {
if (!Array.isArray(matchers)) continue;
const nextMatchers = matchers
.map((matcher) => {
const key = `${eventName}::${matcher.matcher || '*'}`;
const owned = ownedHookMatchers.get(key) || new Set();
const hooks = Array.isArray(matcher.hooks)
? matcher.hooks.filter((hook) => !owned.has(hookSignature(hook)))
: [];
return hooks.length > 0 ? { ...matcher, hooks } : null;
})
.filter(Boolean);
if (nextMatchers.length > 0) {
settings.hooks[eventName] = nextMatchers;
} else {
delete settings.hooks[eventName];
}
}
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
}
if (settings.mcpServers && typeof settings.mcpServers === 'object') {
for (const key of addedMcpServers) {
delete settings.mcpServers[key];
}
for (const [key, paths] of Object.entries(addedMcpServerFields)) {
if (!(key in settings.mcpServers) || !Array.isArray(paths)) continue;
for (const dottedPath of paths) {
removeNestedPath(settings.mcpServers[key], dottedPath);
}
if (
settings.mcpServers[key] &&
typeof settings.mcpServers[key] === 'object' &&
!Array.isArray(settings.mcpServers[key]) &&
Object.keys(settings.mcpServers[key]).length === 0
) {
delete settings.mcpServers[key];
}
}
if (Object.keys(settings.mcpServers).length === 0) delete settings.mcpServers;
}
if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') {
for (const key of addedEnabledPlugins) {
delete settings.enabledPlugins[key];
}
if (Object.keys(settings.enabledPlugins).length === 0) delete settings.enabledPlugins;
}
const onlyDefaultTemplateRemainder =
settingsCreated &&
Object.keys(settings).every((key) => ['env', 'verbose'].includes(key)) &&
settings.verbose === true &&
settings.env &&
Object.keys(settings.env).length === 1 &&
settings.env.GITHUB_PERSONAL_ACCESS_TOKEN === '<your-github-token-optional>';
if (onlyDefaultTemplateRemainder) {
fs.unlinkSync(settingsPath);
} else {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
}
NODE
}
remove_metadata_files() {
local path
for path in "$MANIFEST_FILE" "$STATE_FILE"; do
[ -e "$path" ] || continue
backup_target "$path"
if [ "$DRY_RUN" -eq 0 ]; then
rm -f "$path"
fi
done
}
main() {
parse_args "$@"
require_install_metadata
echo ""
echo "╔══════════════════════════════════════╗"
echo "║ Claude Scholar Uninstaller ║"
echo "╚══════════════════════════════════════╝"
echo ""
remove_managed_files
cleanup_empty_dirs
cleanup_settings
remove_metadata_files
if [ "$DRY_RUN" -eq 1 ]; then
info "Dry run complete. Files that would be removed: $REMOVED_COUNT | Missing/skipped: $SKIPPED_COUNT"
exit 0
fi
info "Removed files: $REMOVED_COUNT | Missing/skipped: $SKIPPED_COUNT"
info "Uninstall backup: $UNINSTALL_BACKUP_DIR"
info "Done."
}
main "$@"