545 lines
16 KiB
Bash
545 lines
16 KiB
Bash
#!/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 "$@"
|