Merge branch 'main' into fix/shell-script-permissions

This commit is contained in:
Affaan Mustafa
2026-03-27 02:46:42 -04:00
committed by GitHub
53 changed files with 5333 additions and 650 deletions

View File

@@ -24,9 +24,11 @@ log() {
run_or_echo() {
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run] %s\n' "$*"
printf '[dry-run]'
printf ' %q' "$@"
printf '\n'
else
eval "$*"
"$@"
fi
}
@@ -41,14 +43,14 @@ log "Global hooks destination: $DEST_DIR"
if [[ -d "$DEST_DIR" ]]; then
log "Backing up existing hooks directory to $BACKUP_DIR"
run_or_echo "mkdir -p \"$BACKUP_DIR\""
run_or_echo "cp -R \"$DEST_DIR\" \"$BACKUP_DIR/hooks\""
run_or_echo mkdir -p "$BACKUP_DIR"
run_or_echo cp -R "$DEST_DIR" "$BACKUP_DIR/hooks"
fi
run_or_echo "mkdir -p \"$DEST_DIR\""
run_or_echo "cp \"$SOURCE_DIR/pre-commit\" \"$DEST_DIR/pre-commit\""
run_or_echo "cp \"$SOURCE_DIR/pre-push\" \"$DEST_DIR/pre-push\""
run_or_echo "chmod +x \"$DEST_DIR/pre-commit\" \"$DEST_DIR/pre-push\""
run_or_echo mkdir -p "$DEST_DIR"
run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit"
run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push"
run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push"
if [[ "$MODE" == "apply" ]]; then
prev_hooks_path="$(git config --global core.hooksPath || true)"
@@ -56,7 +58,7 @@ if [[ "$MODE" == "apply" ]]; then
log "Previous global hooksPath: $prev_hooks_path"
fi
fi
run_or_echo "git config --global core.hooksPath \"$DEST_DIR\""
run_or_echo git config --global core.hooksPath "$DEST_DIR"
log "Installed ECC global git hooks."
log "Disable per repo by creating .ecc-hooks-disable in project root."

View File

@@ -61,11 +61,34 @@ const PROTECTED_FILES = new Set([
'.markdownlintrc',
]);
function parseInput(inputOrRaw) {
if (typeof inputOrRaw === 'string') {
try {
return inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {};
} catch {
return {};
}
}
return inputOrRaw && typeof inputOrRaw === 'object' ? inputOrRaw : {};
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
* Avoids the ~50-100ms spawnSync overhead when available.
*/
function run(input) {
function run(inputOrRaw, options = {}) {
if (options.truncated) {
return {
exitCode: 2,
stderr:
`BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. ` +
'Refusing to bypass config-protection on a truncated payload. ' +
'Retry with a smaller edit or disable the config-protection hook temporarily.'
};
}
const input = parseInput(inputOrRaw);
const filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
if (!filePath) return { exitCode: 0 };
@@ -75,9 +98,9 @@ function run(input) {
exitCode: 2,
stderr:
`BLOCKED: Modifying ${basename} is not allowed. ` +
`Fix the source code to satisfy linter/formatter rules instead of ` +
`weakening the config. If this is a legitimate config change, ` +
`disable the config-protection hook temporarily.`,
'Fix the source code to satisfy linter/formatter rules instead of ' +
'weakening the config. If this is a legitimate config change, ' +
'disable the config-protection hook temporarily.',
};
}
@@ -87,7 +110,7 @@ function run(input) {
module.exports = { run };
// Stdin fallback for spawnSync execution
let truncated = false;
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
@@ -100,25 +123,17 @@ process.stdin.on('data', chunk => {
});
process.stdin.on('end', () => {
// If stdin was truncated, the JSON is likely malformed. Fail open but
// log a warning so the issue is visible. The run() path (used by
// run-with-flags.js in-process) is not affected by this.
if (truncated) {
process.stderr.write('[config-protection] Warning: stdin exceeded 1MB, skipping check\n');
process.stdout.write(raw);
return;
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
});
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
try {
const input = raw.trim() ? JSON.parse(raw) : {};
const result = run(input);
if (result.exitCode === 2) {
process.stderr.write(result.stderr + '\n');
process.exit(2);
}
} catch {
// Keep hook non-blocking on parse errors.
if (result.exitCode === 2) {
process.exit(2);
}
process.stdout.write(raw);

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Desktop Notification Hook (Stop)
*
* Sends a native desktop notification with the task summary when Claude
* finishes responding. Currently supports macOS (osascript); other
* platforms exit silently. Windows (PowerShell) and Linux (notify-send)
* support is planned.
*
* Hook ID : stop:desktop-notify
* Profiles: standard, strict
*/
'use strict';
const { spawnSync } = require('child_process');
const { isMacOS, log } = require('../lib/utils');
const TITLE = 'Claude Code';
const MAX_BODY_LENGTH = 100;
/**
* Extract a short summary from the last assistant message.
* Takes the first non-empty line and truncates to MAX_BODY_LENGTH chars.
*/
function extractSummary(message) {
if (!message || typeof message !== 'string') return 'Done';
const firstLine = message
.split('\n')
.map(l => l.trim())
.find(l => l.length > 0);
if (!firstLine) return 'Done';
return firstLine.length > MAX_BODY_LENGTH
? `${firstLine.slice(0, MAX_BODY_LENGTH)}...`
: firstLine;
}
/**
* Send a macOS notification via osascript.
* AppleScript strings do not support backslash escapes, so we replace
* double quotes with curly quotes and strip backslashes before embedding.
*/
function notifyMacOS(title, body) {
const safeBody = body.replace(/\\/g, '').replace(/"/g, '\u201C');
const safeTitle = title.replace(/\\/g, '').replace(/"/g, '\u201C');
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 5000 });
if (result.error || result.status !== 0) {
log(`[DesktopNotify] osascript failed: ${result.error ? result.error.message : `exit ${result.status}`}`);
}
}
// TODO: future platform support
// function notifyWindows(title, body) { ... }
// function notifyLinux(title, body) { ... }
/**
* Fast-path entry point for run-with-flags.js (avoids extra process spawn).
*/
function run(raw) {
try {
if (!isMacOS) return raw;
const input = raw.trim() ? JSON.parse(raw) : {};
const summary = extractSummary(input.last_assistant_message);
notifyMacOS(TITLE, summary);
} catch (err) {
log(`[DesktopNotify] Error: ${err.message}`);
}
return raw;
}
module.exports = { run };
// Legacy stdin path (when invoked directly rather than via run-with-flags)
if (require.main === module) {
const MAX_STDIN = 1024 * 1024;
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
data += chunk.substring(0, MAX_STDIN - data.length);
}
});
process.stdin.on('end', () => {
const output = run(data);
if (output) process.stdout.write(output);
});
}

View File

@@ -10,6 +10,7 @@
* - policy_violation: Actions that violate configured policies
* - security_finding: Security-relevant tool invocations
* - approval_requested: Operations requiring explicit approval
* - hook_input_truncated: Hook input exceeded the safe inspection limit
*
* Enable: Set ECC_GOVERNANCE_CAPTURE=1
* Configure session: Set ECC_SESSION_ID for session correlation
@@ -101,6 +102,37 @@ function detectSensitivePath(filePath) {
return SENSITIVE_PATHS.some(pattern => pattern.test(filePath));
}
function fingerprintCommand(command) {
if (!command || typeof command !== 'string') return null;
return crypto.createHash('sha256').update(command).digest('hex').slice(0, 12);
}
function summarizeCommand(command) {
if (!command || typeof command !== 'string') {
return {
commandName: null,
commandFingerprint: null,
};
}
const trimmed = command.trim();
if (!trimmed) {
return {
commandName: null,
commandFingerprint: null,
};
}
return {
commandName: trimmed.split(/\s+/)[0] || null,
commandFingerprint: fingerprintCommand(trimmed),
};
}
function emitGovernanceEvent(event) {
process.stderr.write(`[governance] ${JSON.stringify(event)}\n`);
}
/**
* Analyze a hook input payload and return governance events to capture.
*
@@ -146,6 +178,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
if (toolName === 'Bash') {
const command = toolInput.command || '';
const approvalFindings = detectApprovalRequired(command);
const commandSummary = summarizeCommand(command);
if (approvalFindings.length > 0) {
events.push({
@@ -155,7 +188,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
payload: {
toolName,
hookPhase,
command: command.slice(0, 200),
...commandSummary,
matchedPatterns: approvalFindings.map(f => f.pattern),
severity: 'high',
},
@@ -188,6 +221,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') {
const command = toolInput.command || '';
const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
const commandSummary = summarizeCommand(command);
if (hasElevated) {
events.push({
@@ -197,7 +231,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
payload: {
toolName,
hookPhase,
command: command.slice(0, 200),
...commandSummary,
reason: 'elevated_privilege_command',
severity: 'medium',
},
@@ -216,16 +250,32 @@ function analyzeForGovernanceEvents(input, context = {}) {
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
function run(rawInput, options = {}) {
// Gate on feature flag
if (String(process.env.ECC_GOVERNANCE_CAPTURE || '').toLowerCase() !== '1') {
return rawInput;
}
const sessionId = process.env.ECC_SESSION_ID || null;
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
if (options.truncated) {
emitGovernanceEvent({
id: generateEventId(),
sessionId,
eventType: 'hook_input_truncated',
payload: {
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
sizeLimitBytes: options.maxStdin || MAX_STDIN,
severity: 'warning',
},
resolvedAt: null,
resolution: null,
});
}
try {
const input = JSON.parse(rawInput);
const sessionId = process.env.ECC_SESSION_ID || null;
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
const events = analyzeForGovernanceEvents(input, {
sessionId,
@@ -233,13 +283,8 @@ function run(rawInput) {
});
if (events.length > 0) {
// Write events to stderr as JSON-lines for the caller to capture.
// The state store write is async and handled by a separate process
// to avoid blocking the hook pipeline.
for (const event of events) {
process.stderr.write(
`[governance] ${JSON.stringify(event)}\n`
);
emitGovernanceEvent(event);
}
}
} catch {
@@ -252,16 +297,25 @@ function run(rawInput) {
// ── stdin entry point ────────────────────────────────
if (require.main === module) {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const result = run(raw);
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
});
process.stdout.write(result);
});
}

View File

@@ -99,15 +99,21 @@ function saveState(filePath, state) {
function readRawStdin() {
return new Promise(resolve => {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => resolve(raw));
process.stdin.on('error', () => resolve(raw));
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
@@ -155,6 +161,18 @@ function extractMcpTarget(input) {
};
}
function extractMcpTargetFromRaw(raw) {
const toolNameMatch = raw.match(/"(?:tool_name|name)"\s*:\s*"([^"]+)"/);
const serverMatch = raw.match(/"(?:server|mcp_server|connector)"\s*:\s*"([^"]+)"/);
const toolMatch = raw.match(/"(?:tool|mcp_tool)"\s*:\s*"([^"]+)"/);
return extractMcpTarget({
tool_name: toolNameMatch ? toolNameMatch[1] : '',
server: serverMatch ? serverMatch[1] : undefined,
tool: toolMatch ? toolMatch[1] : undefined
});
}
function resolveServerConfig(serverName) {
for (const filePath of configPaths()) {
const data = readJsonFile(filePath);
@@ -559,9 +577,9 @@ async function handlePostToolUseFailure(rawInput, input, target, statePathValue,
}
async function main() {
const rawInput = await readRawStdin();
const { raw: rawInput, truncated } = await readRawStdin();
const input = safeParse(rawInput);
const target = extractMcpTarget(input);
const target = extractMcpTarget(input) || (truncated ? extractMcpTargetFromRaw(rawInput) : null);
if (!target) {
process.stdout.write(rawInput);
@@ -569,6 +587,19 @@ async function main() {
return;
}
if (truncated) {
const limit = Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN;
const logs = [
shouldFailOpen()
? `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; allowing ${target.tool || 'tool'} because fail-open mode is enabled`
: `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; blocking ${target.tool || 'tool'} to avoid bypassing MCP health checks`
];
emitLogs(logs);
process.stdout.write(rawInput);
process.exit(shouldFailOpen() ? 0 : 2);
return;
}
const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse';
const now = Date.now();
const statePathValue = stateFilePath();

View File

@@ -18,18 +18,66 @@ const MAX_STDIN = 1024 * 1024;
function readStdinRaw() {
return new Promise(resolve => {
let raw = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => resolve(raw));
process.stdin.on('error', () => resolve(raw));
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
function writeStderr(stderr) {
if (typeof stderr !== 'string' || stderr.length === 0) {
return;
}
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
}
function emitHookResult(raw, output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
process.stdout.write(String(output));
return 0;
}
if (output && typeof output === 'object') {
writeStderr(output.stderr);
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
process.stdout.write(String(output.stdout ?? ''));
} else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) {
process.stdout.write(raw);
}
return Number.isInteger(output.exitCode) ? output.exitCode : 0;
}
process.stdout.write(raw);
return 0;
}
function writeLegacySpawnOutput(raw, result) {
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
if (stdout) {
process.stdout.write(stdout);
return;
}
if (Number.isInteger(result.status) && result.status === 0) {
process.stdout.write(raw);
}
}
function getPluginRoot() {
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
return process.env.CLAUDE_PLUGIN_ROOT;
@@ -39,7 +87,7 @@ function getPluginRoot() {
async function main() {
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
const raw = await readStdinRaw();
const { raw, truncated } = await readStdinRaw();
if (!hookId || !relScriptPath) {
process.stdout.write(raw);
@@ -89,8 +137,8 @@ async function main() {
if (hookModule && typeof hookModule.run === 'function') {
try {
const output = hookModule.run(raw);
if (output !== null && output !== undefined) process.stdout.write(output);
const output = hookModule.run(raw, { truncated, maxStdin: MAX_STDIN });
process.exit(emitHookResult(raw, output));
} catch (runErr) {
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
process.stdout.write(raw);
@@ -99,19 +147,32 @@ async function main() {
}
// Legacy path: spawn a child Node process for hooks without run() export
const result = spawnSync('node', [scriptPath], {
const result = spawnSync(process.execPath, [scriptPath], {
input: raw,
encoding: 'utf8',
env: process.env,
env: {
...process.env,
ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN)
},
cwd: process.cwd(),
timeout: 30000
});
if (result.stdout) process.stdout.write(result.stdout);
writeLegacySpawnOutput(raw, result);
if (result.stderr) process.stderr.write(result.stderr);
const code = Number.isInteger(result.status) ? result.status : 0;
process.exit(code);
if (result.error || result.signal || result.status === null) {
const failureDetail = result.error
? result.error.message
: result.signal
? `terminated by signal ${result.signal}`
: 'missing exit status';
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
process.exit(1);
}
process.exit(Number.isInteger(result.status) ? result.status : 0);
}
main().catch(err => {

View File

@@ -11,28 +11,59 @@
const {
getSessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
findFiles,
ensureDir,
readFile,
stripAnsi,
log,
output
log
} = require('../lib/utils');
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect');
const path = require('path');
function dedupeRecentSessions(searchDirs) {
const recentSessionsByName = new Map();
for (const [dirIndex, dir] of searchDirs.entries()) {
const matches = findFiles(dir, '*-session.tmp', { maxAge: 7 });
for (const match of matches) {
const basename = path.basename(match.path);
const current = {
...match,
basename,
dirIndex,
};
const existing = recentSessionsByName.get(basename);
if (
!existing
|| current.mtime > existing.mtime
|| (current.mtime === existing.mtime && current.dirIndex < existing.dirIndex)
) {
recentSessionsByName.set(basename, current);
}
}
}
return Array.from(recentSessionsByName.values())
.sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex);
}
async function main() {
const sessionsDir = getSessionsDir();
const learnedDir = getLearnedSkillsDir();
const additionalContextParts = [];
// Ensure directories exist
ensureDir(sessionsDir);
ensureDir(learnedDir);
// Check for recent session files (last 7 days)
const recentSessions = findFiles(sessionsDir, '*-session.tmp', { maxAge: 7 });
const recentSessions = dedupeRecentSessions(getSessionSearchDirs());
if (recentSessions.length > 0) {
const latest = recentSessions[0];
@@ -43,7 +74,7 @@ async function main() {
const content = stripAnsi(readFile(latest.path));
if (content && !content.includes('[Session context goes here]')) {
// Only inject if the session has actual content (not the blank template)
output(`Previous session summary:\n${content}`);
additionalContextParts.push(`Previous session summary:\n${content}`);
}
}
@@ -84,15 +115,49 @@ async function main() {
parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`);
}
log(`[SessionStart] Project detected — ${parts.join('; ')}`);
output(`Project type: ${JSON.stringify(projectInfo)}`);
additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`);
} else {
log('[SessionStart] No specific project type detected');
}
process.exit(0);
await writeSessionStartPayload(additionalContextParts.join('\n\n'));
}
function writeSessionStartPayload(additionalContext) {
return new Promise((resolve, reject) => {
let settled = false;
const payload = JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
});
const handleError = (err) => {
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
}
reject(err || new Error('stdout stream error'));
};
process.stdout.once('error', handleError);
process.stdout.write(payload, (err) => {
process.stdout.removeListener('error', handleError);
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
reject(err);
return;
}
resolve();
});
});
}
main().catch(err => {
console.error('[SessionStart] Error:', err.message);
process.exit(0); // Don't block on errors
process.exitCode = 0; // Don't block on errors
});

View File

@@ -10,8 +10,9 @@ const os = require('os');
* Tries, in order:
* 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user)
* 2. Standard install location (~/.claude/) — when scripts exist there
* 3. Plugin cache auto-detection — scans ~/.claude/plugins/cache/everything-claude-code/
* 4. Fallback to ~/.claude/ (original behaviour)
* 3. Exact legacy plugin roots under ~/.claude/plugins/
* 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/everything-claude-code/
* 5. Fallback to ~/.claude/ (original behaviour)
*
* @param {object} [options]
* @param {string} [options.homeDir] Override home directory (for testing)
@@ -38,6 +39,20 @@ function resolveEccRoot(options = {}) {
return claudeDir;
}
// Exact legacy plugin install locations. These preserve backwards
// compatibility without scanning arbitrary plugin trees.
const legacyPluginRoots = [
path.join(claudeDir, 'plugins', 'everything-claude-code'),
path.join(claudeDir, 'plugins', 'everything-claude-code@everything-claude-code'),
path.join(claudeDir, 'plugins', 'marketplace', 'everything-claude-code')
];
for (const candidate of legacyPluginRoots) {
if (fs.existsSync(path.join(candidate, probe))) {
return candidate;
}
}
// Plugin cache — Claude Code stores marketplace plugins under
// ~/.claude/plugins/cache/<plugin-name>/<org>/<version>/
try {
@@ -81,7 +96,7 @@ function resolveEccRoot(options = {}) {
* const _r = <paste INLINE_RESOLVE>;
* const sm = require(_r + '/scripts/lib/session-manager');
*/
const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()`;
const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var l of [p.join(d,'plugins','everything-claude-code'),p.join(d,'plugins','everything-claude-code@everything-claude-code'),p.join(d,'plugins','marketplace','everything-claude-code')])if(f.existsSync(p.join(l,q)))return l;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}catch(x){}return d})()`;
module.exports = {
resolveEccRoot,

View File

@@ -1,6 +1,7 @@
/**
* Session Manager Library for Claude Code.
* Provides CRUD operations for session files stored as markdown in ~/.claude/sessions/.
* Provides CRUD operations for session files stored as markdown in
* ~/.claude/session-data/ with legacy read compatibility for ~/.claude/sessions/.
*/
/** Parsed metadata from a session filename */

View File

@@ -2,7 +2,8 @@
* Session Manager Library for Claude Code
* Provides core session CRUD operations for listing, loading, and managing sessions
*
* Sessions are stored as markdown files in ~/.claude/sessions/ with format:
* Sessions are stored as markdown files in ~/.claude/session-data/ with
* legacy read compatibility for ~/.claude/sessions/:
* - YYYY-MM-DD-session.tmp (old format)
* - YYYY-MM-DD-<short-id>-session.tmp (new format)
*/
@@ -12,6 +13,7 @@ const path = require('path');
const {
getSessionsDir,
getSessionSearchDirs,
readFile,
log
} = require('./utils');
@@ -30,6 +32,7 @@ const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_
* @returns {object|null} Parsed metadata or null if invalid
*/
function parseSessionFilename(filename) {
if (!filename || typeof filename !== 'string') return null;
const match = filename.match(SESSION_FILENAME_REGEX);
if (!match) return null;
@@ -66,6 +69,145 @@ function getSessionPath(filename) {
return path.join(getSessionsDir(), filename);
}
function getSessionCandidates(options = {}) {
const {
date = null,
search = null
} = options;
const candidates = [];
for (const sessionsDir of getSessionSearchDirs()) {
if (!fs.existsSync(sessionsDir)) {
continue;
}
let entries;
try {
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
} catch (error) {
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
const filename = entry.name;
const metadata = parseSessionFilename(filename);
if (!metadata) continue;
if (date && metadata.date !== date) continue;
if (search && !metadata.shortId.includes(search)) continue;
const sessionPath = path.join(sessionsDir, filename);
let stats;
try {
stats = fs.statSync(sessionPath);
} catch (error) {
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
continue;
}
candidates.push({
...metadata,
sessionPath,
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
});
}
}
const deduped = [];
const seenFilenames = new Set();
for (const session of candidates) {
if (seenFilenames.has(session.filename)) {
continue;
}
seenFilenames.add(session.filename);
deduped.push(session);
}
deduped.sort((a, b) => b.modifiedTime - a.modifiedTime);
return deduped;
}
function buildSessionRecord(sessionPath, metadata) {
let stats;
try {
stats = fs.statSync(sessionPath);
} catch (error) {
log(`[SessionManager] Error stating session ${sessionPath}: ${error.message}`);
return null;
}
return {
...metadata,
sessionPath,
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
};
}
function sessionMatchesId(metadata, normalizedSessionId) {
const filename = metadata.filename;
const shortIdMatch = metadata.shortId !== 'no-id' && metadata.shortId.startsWith(normalizedSessionId);
const filenameMatch = filename === normalizedSessionId || filename === `${normalizedSessionId}.tmp`;
const noIdMatch = metadata.shortId === 'no-id' && filename === `${normalizedSessionId}-session.tmp`;
return shortIdMatch || filenameMatch || noIdMatch;
}
function getMatchingSessionCandidates(normalizedSessionId) {
const matches = [];
const seenFilenames = new Set();
for (const sessionsDir of getSessionSearchDirs()) {
if (!fs.existsSync(sessionsDir)) {
continue;
}
let entries;
try {
entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
} catch (error) {
log(`[SessionManager] Error reading sessions directory ${sessionsDir}: ${error.message}`);
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
const metadata = parseSessionFilename(entry.name);
if (!metadata || !sessionMatchesId(metadata, normalizedSessionId)) {
continue;
}
if (seenFilenames.has(metadata.filename)) {
continue;
}
const sessionPath = path.join(sessionsDir, metadata.filename);
const sessionRecord = buildSessionRecord(sessionPath, metadata);
if (!sessionRecord) {
continue;
}
seenFilenames.add(metadata.filename);
matches.push(sessionRecord);
}
}
matches.sort((a, b) => b.modifiedTime - a.modifiedTime);
return matches;
}
/**
* Read and parse session markdown content
* @param {string} sessionPath - Full path to session file
@@ -228,58 +370,12 @@ function getAllSessions(options = {}) {
const limitNum = Number(rawLimit);
const limit = Number.isNaN(limitNum) ? 50 : Math.max(1, Math.floor(limitNum));
const sessionsDir = getSessionsDir();
const sessions = getSessionCandidates({ date, search });
if (!fs.existsSync(sessionsDir)) {
if (sessions.length === 0) {
return { sessions: [], total: 0, offset, limit, hasMore: false };
}
const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
const sessions = [];
for (const entry of entries) {
// Skip non-files (only process .tmp files)
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
const filename = entry.name;
const metadata = parseSessionFilename(filename);
if (!metadata) continue;
// Apply date filter
if (date && metadata.date !== date) {
continue;
}
// Apply search filter (search in short ID)
if (search && !metadata.shortId.includes(search)) {
continue;
}
const sessionPath = path.join(sessionsDir, filename);
// Get file stats (wrapped in try-catch to handle TOCTOU race where
// file is deleted between readdirSync and statSync)
let stats;
try {
stats = fs.statSync(sessionPath);
} catch {
continue; // File was deleted between readdir and stat
}
sessions.push({
...metadata,
sessionPath,
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
});
}
// Sort by modified time (newest first)
sessions.sort((a, b) => b.modifiedTime - a.modifiedTime);
// Apply pagination
const paginatedSessions = sessions.slice(offset, offset + limit);
@@ -299,55 +395,28 @@ function getAllSessions(options = {}) {
* @returns {object|null} Session object or null if not found
*/
function getSessionById(sessionId, includeContent = false) {
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
if (typeof sessionId !== 'string') {
return null;
}
const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return null;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
const sessions = getMatchingSessionCandidates(normalizedSessionId);
const filename = entry.name;
const metadata = parseSessionFilename(filename);
if (!metadata) continue;
// Check if session ID matches (short ID or full filename without .tmp)
const shortIdMatch = sessionId.length > 0 && metadata.shortId !== 'no-id' && metadata.shortId.startsWith(sessionId);
const filenameMatch = filename === sessionId || filename === `${sessionId}.tmp`;
const noIdMatch = metadata.shortId === 'no-id' && filename === `${sessionId}-session.tmp`;
if (!shortIdMatch && !filenameMatch && !noIdMatch) {
continue;
}
const sessionPath = path.join(sessionsDir, filename);
let stats;
try {
stats = fs.statSync(sessionPath);
} catch {
return null; // File was deleted between readdir and stat
}
const session = {
...metadata,
sessionPath,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
};
for (const session of sessions) {
const sessionRecord = { ...session };
if (includeContent) {
session.content = getSessionContent(sessionPath);
session.metadata = parseSessionMetadata(session.content);
sessionRecord.content = getSessionContent(sessionRecord.sessionPath);
sessionRecord.metadata = parseSessionMetadata(sessionRecord.content);
// Pass pre-read content to avoid a redundant disk read
session.stats = getSessionStats(session.content || '');
sessionRecord.stats = getSessionStats(sessionRecord.content || '');
}
return session;
return sessionRecord;
}
return null;

View File

@@ -18,9 +18,15 @@ export function getHomeDir(): string;
/** Get the Claude config directory (~/.claude) */
export function getClaudeDir(): string;
/** Get the sessions directory (~/.claude/sessions) */
/** Get the canonical ECC sessions directory (~/.claude/session-data) */
export function getSessionsDir(): string;
/** Get the legacy Claude-managed sessions directory (~/.claude/sessions) */
export function getLegacySessionsDir(): string;
/** Get session directories to search, with canonical storage first and legacy fallback second */
export function getSessionSearchDirs(): string[];
/** Get the learned skills directory (~/.claude/skills/learned) */
export function getLearnedSkillsDir(): string;
@@ -47,9 +53,16 @@ export function getDateTimeString(): string;
// --- Session/Project ---
/**
* Sanitize a string for use as a session filename segment.
* Replaces invalid characters, strips leading dots, and returns null when
* nothing meaningful remains. Non-ASCII names are hashed for stability.
*/
export function sanitizeSessionId(raw: string | null | undefined): string | null;
/**
* Get short session ID from CLAUDE_SESSION_ID environment variable.
* Returns last 8 characters, falls back to project name then the provided fallback.
* Returns last 8 characters, falls back to a sanitized project name then the provided fallback.
*/
export function getSessionIdShort(fallback?: string): string;

View File

@@ -6,12 +6,20 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execSync, spawnSync } = require('child_process');
// Platform detection
const isWindows = process.platform === 'win32';
const isMacOS = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const SESSION_DATA_DIR_NAME = 'session-data';
const LEGACY_SESSIONS_DIR_NAME = 'sessions';
const WINDOWS_RESERVED_SESSION_IDS = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9'
]);
/**
* Get the user's home directory (cross-platform)
@@ -31,7 +39,21 @@ function getClaudeDir() {
* Get the sessions directory
*/
function getSessionsDir() {
return path.join(getClaudeDir(), 'sessions');
return path.join(getClaudeDir(), SESSION_DATA_DIR_NAME);
}
/**
* Get the legacy sessions directory used by older ECC installs
*/
function getLegacySessionsDir() {
return path.join(getClaudeDir(), LEGACY_SESSIONS_DIR_NAME);
}
/**
* Get all session directories to search, in canonical-first order
*/
function getSessionSearchDirs() {
return Array.from(new Set([getSessionsDir(), getLegacySessionsDir()]));
}
/**
@@ -107,16 +129,52 @@ function getProjectName() {
return path.basename(process.cwd()) || null;
}
/**
* Sanitize a string for use as a session filename segment.
* Replaces invalid characters with hyphens, collapses runs, strips
* leading/trailing hyphens, and removes leading dots so hidden-dir names
* like ".claude" map cleanly to "claude".
*
* Pure non-ASCII inputs get a stable 8-char hash so distinct names do not
* collapse to the same fallback session id. Mixed-script inputs retain their
* ASCII part and gain a short hash suffix for disambiguation.
*/
function sanitizeSessionId(raw) {
if (!raw || typeof raw !== 'string') return null;
const hasNonAscii = Array.from(raw).some(char => char.codePointAt(0) > 0x7f);
const normalized = raw.replace(/^\.+/, '');
const sanitized = normalized
.replace(/[^a-zA-Z0-9_-]/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^-+|-+$/g, '');
if (sanitized.length > 0) {
const suffix = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6);
if (WINDOWS_RESERVED_SESSION_IDS.has(sanitized.toUpperCase())) {
return `${sanitized}-${suffix}`;
}
if (!hasNonAscii) return sanitized;
return `${sanitized}-${suffix}`;
}
const meaningful = normalized.replace(/[\s\p{P}]/gu, '');
if (meaningful.length === 0) return null;
return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
}
/**
* Get short session ID from CLAUDE_SESSION_ID environment variable
* Returns last 8 characters, falls back to project name then 'default'
* Returns last 8 characters, falls back to a sanitized project name then 'default'.
*/
function getSessionIdShort(fallback = 'default') {
const sessionId = process.env.CLAUDE_SESSION_ID;
if (sessionId && sessionId.length > 0) {
return sessionId.slice(-8);
const sanitized = sanitizeSessionId(sessionId.slice(-8));
if (sanitized) return sanitized;
}
return getProjectName() || fallback;
return sanitizeSessionId(getProjectName()) || sanitizeSessionId(fallback) || 'default';
}
/**
@@ -525,6 +583,8 @@ module.exports = {
getHomeDir,
getClaudeDir,
getSessionsDir,
getLegacySessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
getTempDir,
ensureDir,
@@ -535,6 +595,7 @@ module.exports = {
getDateTimeString,
// Session/Project
sanitizeSessionId,
getSessionIdShort,
getGitRepoName,
getProjectName,

View File

@@ -43,9 +43,11 @@ log() { printf '[ecc-sync] %s\n' "$*"; }
run_or_echo() {
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run] %s\n' "$*"
printf '[dry-run]'
printf ' %q' "$@"
printf '\n'
else
eval "$@"
"$@"
fi
}
@@ -149,10 +151,10 @@ log "Repo root: $REPO_ROOT"
log "Codex home: $CODEX_HOME"
log "Creating backup folder: $BACKUP_DIR"
run_or_echo "mkdir -p \"$BACKUP_DIR\""
run_or_echo "cp \"$CONFIG_FILE\" \"$BACKUP_DIR/config.toml\""
run_or_echo mkdir -p "$BACKUP_DIR"
run_or_echo cp "$CONFIG_FILE" "$BACKUP_DIR/config.toml"
if [[ -f "$AGENTS_FILE" ]]; then
run_or_echo "cp \"$AGENTS_FILE\" \"$BACKUP_DIR/AGENTS.md\""
run_or_echo cp "$AGENTS_FILE" "$BACKUP_DIR/AGENTS.md"
fi
ECC_BEGIN_MARKER="<!-- BEGIN ECC -->"
@@ -234,19 +236,19 @@ else
fi
log "Syncing ECC Codex skills"
run_or_echo "mkdir -p \"$SKILLS_DEST\""
run_or_echo mkdir -p "$SKILLS_DEST"
skills_count=0
for skill_dir in "$SKILLS_SRC"/*; do
[[ -d "$skill_dir" ]] || continue
skill_name="$(basename "$skill_dir")"
dest="$SKILLS_DEST/$skill_name"
run_or_echo "rm -rf \"$dest\""
run_or_echo "cp -R \"$skill_dir\" \"$dest\""
run_or_echo rm -rf "$dest"
run_or_echo cp -R "$skill_dir" "$dest"
skills_count=$((skills_count + 1))
done
log "Generating prompt files from ECC commands"
run_or_echo "mkdir -p \"$PROMPTS_DEST\""
run_or_echo mkdir -p "$PROMPTS_DEST"
manifest="$PROMPTS_DEST/ecc-prompts-manifest.txt"
if [[ "$MODE" == "dry-run" ]]; then
printf '[dry-run] > %s\n' "$manifest"