mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-02-07 07:03:14 +08:00
feat: add /sessions command for session history management (#142)
Add a new /sessions command to manage Claude Code session history with alias support for quick access to previous sessions. Features: - List sessions with pagination and filtering (by date, ID) - Load and view session content and metadata - Create memorable aliases for sessions - Remove aliases - Display session statistics (lines, items, size) - List all aliases New libraries: - scripts/lib/session-manager.js - Core session CRUD operations - scripts/lib/session-aliases.js - Alias management with atomic saves New command: - commands/sessions.md - Complete command with embedded scripts Modified: - scripts/lib/utils.js - Add getAliasesPath() export - scripts/hooks/session-start.js - Show available aliases on session start Session format support: - Old: YYYY-MM-DD-session.tmp - New: YYYY-MM-DD-<short-id>-session.tmp Aliases are stored in ~/.claude/session-aliases.json with Windows- compatible atomic writes and backup support. Co-authored-by: 王志坚 <wangzhijian10@bgyfw.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
305
commands/sessions.md
Normal file
305
commands/sessions.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# Sessions Command
|
||||
|
||||
Manage Claude Code session history - list, load, alias, and edit sessions stored in `~/.claude/sessions/`.
|
||||
|
||||
## Usage
|
||||
|
||||
`/sessions [list|load|alias|info|help] [options]`
|
||||
|
||||
## Actions
|
||||
|
||||
### List Sessions
|
||||
|
||||
Display all sessions with metadata, filtering, and pagination.
|
||||
|
||||
```bash
|
||||
/sessions # List all sessions (default)
|
||||
/sessions list # Same as above
|
||||
/sessions list --limit 10 # Show 10 sessions
|
||||
/sessions list --date 2026-02-01 # Filter by date
|
||||
/sessions list --search abc # Search by session ID
|
||||
```
|
||||
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require('./scripts/lib/session-manager');
|
||||
const aa = require('./scripts/lib/session-aliases');
|
||||
|
||||
const result = sm.getAllSessions({ limit: 20 });
|
||||
const aliases = aa.listAliases();
|
||||
const aliasMap = {};
|
||||
for (const a of aliases) aliasMap[a.sessionPath] = a.name;
|
||||
|
||||
console.log('Sessions (showing ' + result.sessions.length + ' of ' + result.total + '):');
|
||||
console.log('');
|
||||
console.log('ID Date Time Size Lines Alias');
|
||||
console.log('────────────────────────────────────────────────────');
|
||||
|
||||
for (const s of result.sessions) {
|
||||
const alias = aliasMap[s.filename] || '';
|
||||
const size = sm.getSessionSize(s.sessionPath);
|
||||
const stats = sm.getSessionStats(s.sessionPath);
|
||||
const id = s.shortId === 'no-id' ? '(none)' : s.shortId.slice(0, 8);
|
||||
const time = s.modifiedTime.toTimeString().slice(0, 5);
|
||||
|
||||
console.log(id.padEnd(8) + ' ' + s.date + ' ' + time + ' ' + size.padEnd(7) + ' ' + String(stats.lineCount).padEnd(5) + ' ' + alias);
|
||||
}
|
||||
"
|
||||
```
|
||||
|
||||
### Load Session
|
||||
|
||||
Load and display a session's content (by ID or alias).
|
||||
|
||||
```bash
|
||||
/sessions load <id|alias> # Load session
|
||||
/sessions load 2026-02-01 # By date (for no-id sessions)
|
||||
/sessions load a1b2c3d4 # By short ID
|
||||
/sessions load my-alias # By alias name
|
||||
```
|
||||
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require('./scripts/lib/session-manager');
|
||||
const aa = require('./scripts/lib/session-aliases');
|
||||
const id = process.argv[1];
|
||||
|
||||
// First try to resolve as alias
|
||||
const resolved = aa.resolveAlias(id);
|
||||
const sessionId = resolved ? resolved.sessionPath : id;
|
||||
|
||||
const session = sm.getSessionById(sessionId, true);
|
||||
if (!session) {
|
||||
console.log('Session not found: ' + id);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = sm.getSessionStats(session.sessionPath);
|
||||
const size = sm.getSessionSize(session.sessionPath);
|
||||
const aliases = aa.getAliasesForSession(session.filename);
|
||||
|
||||
console.log('Session: ' + session.filename);
|
||||
console.log('Path: ~/.claude/sessions/' + session.filename);
|
||||
console.log('');
|
||||
console.log('Statistics:');
|
||||
console.log(' Lines: ' + stats.lineCount);
|
||||
console.log(' Total items: ' + stats.totalItems);
|
||||
console.log(' Completed: ' + stats.completedItems);
|
||||
console.log(' In progress: ' + stats.inProgressItems);
|
||||
console.log(' Size: ' + size);
|
||||
console.log('');
|
||||
|
||||
if (aliases.length > 0) {
|
||||
console.log('Aliases: ' + aliases.map(a => a.name).join(', '));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (session.metadata.title) {
|
||||
console.log('Title: ' + session.metadata.title);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (session.metadata.started) {
|
||||
console.log('Started: ' + session.metadata.started);
|
||||
}
|
||||
|
||||
if (session.metadata.lastUpdated) {
|
||||
console.log('Last Updated: ' + session.metadata.lastUpdated);
|
||||
}
|
||||
" "$ARGUMENTS"
|
||||
```
|
||||
|
||||
### Create Alias
|
||||
|
||||
Create a memorable alias for a session.
|
||||
|
||||
```bash
|
||||
/sessions alias <id> <name> # Create alias
|
||||
/sessions alias 2026-02-01 today-work # Create alias named "today-work"
|
||||
```
|
||||
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require('./scripts/lib/session-manager');
|
||||
const aa = require('./scripts/lib/session-aliases');
|
||||
|
||||
const sessionId = process.argv[1];
|
||||
const aliasName = process.argv[2];
|
||||
|
||||
if (!sessionId || !aliasName) {
|
||||
console.log('Usage: /sessions alias <id> <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get session filename
|
||||
const session = sm.getSessionById(sessionId);
|
||||
if (!session) {
|
||||
console.log('Session not found: ' + sessionId);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = aa.setAlias(aliasName, session.filename);
|
||||
if (result.success) {
|
||||
console.log('✓ Alias created: ' + aliasName + ' → ' + session.filename);
|
||||
} else {
|
||||
console.log('✗ Error: ' + result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
" "$ARGUMENTS"
|
||||
```
|
||||
|
||||
### Remove Alias
|
||||
|
||||
Delete an existing alias.
|
||||
|
||||
```bash
|
||||
/sessions alias --remove <name> # Remove alias
|
||||
/sessions unalias <name> # Same as above
|
||||
```
|
||||
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const aa = require('./scripts/lib/session-aliases');
|
||||
|
||||
const aliasName = process.argv[1];
|
||||
if (!aliasName) {
|
||||
console.log('Usage: /sessions alias --remove <name>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = aa.deleteAlias(aliasName);
|
||||
if (result.success) {
|
||||
console.log('✓ Alias removed: ' + aliasName);
|
||||
} else {
|
||||
console.log('✗ Error: ' + result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
" "$ARGUMENTS"
|
||||
```
|
||||
|
||||
### Session Info
|
||||
|
||||
Show detailed information about a session.
|
||||
|
||||
```bash
|
||||
/sessions info <id|alias> # Show session details
|
||||
```
|
||||
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const sm = require('./scripts/lib/session-manager');
|
||||
const aa = require('./scripts/lib/session-aliases');
|
||||
|
||||
const id = process.argv[1];
|
||||
const resolved = aa.resolveAlias(id);
|
||||
const sessionId = resolved ? resolved.sessionPath : id;
|
||||
|
||||
const session = sm.getSessionById(sessionId, true);
|
||||
if (!session) {
|
||||
console.log('Session not found: ' + id);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const stats = sm.getSessionStats(session.sessionPath);
|
||||
const size = sm.getSessionSize(session.sessionPath);
|
||||
const aliases = aa.getAliasesForSession(session.filename);
|
||||
|
||||
console.log('Session Information');
|
||||
console.log('════════════════════');
|
||||
console.log('ID: ' + (session.shortId === 'no-id' ? '(none)' : session.shortId));
|
||||
console.log('Filename: ' + session.filename);
|
||||
console.log('Date: ' + session.date);
|
||||
console.log('Modified: ' + session.modifiedTime.toISOString().slice(0, 19).replace('T', ' '));
|
||||
console.log('');
|
||||
console.log('Content:');
|
||||
console.log(' Lines: ' + stats.lineCount);
|
||||
console.log(' Total items: ' + stats.totalItems);
|
||||
console.log(' Completed: ' + stats.completedItems);
|
||||
console.log(' In progress: ' + stats.inProgressItems);
|
||||
console.log(' Size: ' + size);
|
||||
if (aliases.length > 0) {
|
||||
console.log('Aliases: ' + aliases.map(a => a.name).join(', '));
|
||||
}
|
||||
" "$ARGUMENTS"
|
||||
```
|
||||
|
||||
### List Aliases
|
||||
|
||||
Show all session aliases.
|
||||
|
||||
```bash
|
||||
/sessions aliases # List all aliases
|
||||
```
|
||||
|
||||
**Script:**
|
||||
```bash
|
||||
node -e "
|
||||
const aa = require('./scripts/lib/session-aliases');
|
||||
|
||||
const aliases = aa.listAliases();
|
||||
console.log('Session Aliases (' + aliases.length + '):');
|
||||
console.log('');
|
||||
|
||||
if (aliases.length === 0) {
|
||||
console.log('No aliases found.');
|
||||
} else {
|
||||
console.log('Name Session File Title');
|
||||
console.log('─────────────────────────────────────────────────────────────');
|
||||
for (const a of aliases) {
|
||||
const name = a.name.padEnd(12);
|
||||
const file = (a.sessionPath.length > 30 ? a.sessionPath.slice(0, 27) + '...' : a.sessionPath).padEnd(30);
|
||||
const title = a.title || '';
|
||||
console.log(name + ' ' + file + ' ' + title);
|
||||
}
|
||||
}
|
||||
"
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
$ARGUMENTS:
|
||||
- `list [options]` - List sessions
|
||||
- `--limit <n>` - Max sessions to show (default: 50)
|
||||
- `--date <YYYY-MM-DD>` - Filter by date
|
||||
- `--search <pattern>` - Search in session ID
|
||||
- `load <id|alias>` - Load session content
|
||||
- `alias <id> <name>` - Create alias for session
|
||||
- `alias --remove <name>` - Remove alias
|
||||
- `unalias <name>` - Same as `--remove`
|
||||
- `info <id|alias>` - Show session statistics
|
||||
- `aliases` - List all aliases
|
||||
- `help` - Show this help
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# List all sessions
|
||||
/sessions list
|
||||
|
||||
# Create an alias for today's session
|
||||
/sessions alias 2026-02-01 today
|
||||
|
||||
# Load session by alias
|
||||
/sessions load today
|
||||
|
||||
# Show session info
|
||||
/sessions info today
|
||||
|
||||
# Remove alias
|
||||
/sessions alias --remove today
|
||||
|
||||
# List all aliases
|
||||
/sessions aliases
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Sessions are stored as markdown files in `~/.claude/sessions/`
|
||||
- Aliases are stored in `~/.claude/session-aliases.json`
|
||||
- Session IDs can be shortened (first 4-8 characters usually unique enough)
|
||||
- Use aliases for frequently referenced sessions
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
log
|
||||
} = require('../lib/utils');
|
||||
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
|
||||
const { listAliases } = require('../lib/session-aliases');
|
||||
|
||||
async function main() {
|
||||
const sessionsDir = getSessionsDir();
|
||||
@@ -42,6 +43,15 @@ async function main() {
|
||||
log(`[SessionStart] ${learnedSkills.length} learned skill(s) available in ${learnedDir}`);
|
||||
}
|
||||
|
||||
// Check for available session aliases
|
||||
const aliases = listAliases({ limit: 5 });
|
||||
|
||||
if (aliases.length > 0) {
|
||||
const aliasNames = aliases.map(a => a.name).join(', ');
|
||||
log(`[SessionStart] ${aliases.length} session alias(es) available: ${aliasNames}`);
|
||||
log(`[SessionStart] Use /sessions load <alias> to continue a previous session`);
|
||||
}
|
||||
|
||||
// Detect and report package manager
|
||||
const pm = getPackageManager();
|
||||
log(`[SessionStart] Package manager: ${pm.name} (${pm.source})`);
|
||||
|
||||
433
scripts/lib/session-aliases.js
Normal file
433
scripts/lib/session-aliases.js
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Session Aliases Library for Claude Code
|
||||
* Manages session aliases stored in ~/.claude/session-aliases.json
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
getClaudeDir,
|
||||
ensureDir,
|
||||
readFile,
|
||||
writeFile,
|
||||
log
|
||||
} = require('./utils');
|
||||
|
||||
// Aliases file path
|
||||
function getAliasesPath() {
|
||||
return path.join(getClaudeDir(), 'session-aliases.json');
|
||||
}
|
||||
|
||||
// Current alias storage format version
|
||||
const ALIAS_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* Default aliases file structure
|
||||
*/
|
||||
function getDefaultAliases() {
|
||||
return {
|
||||
version: ALIAS_VERSION,
|
||||
aliases: {},
|
||||
metadata: {
|
||||
totalCount: 0,
|
||||
lastUpdated: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load aliases from file
|
||||
* @returns {object} Aliases object
|
||||
*/
|
||||
function loadAliases() {
|
||||
const aliasesPath = getAliasesPath();
|
||||
|
||||
if (!fs.existsSync(aliasesPath)) {
|
||||
return getDefaultAliases();
|
||||
}
|
||||
|
||||
const content = readFile(aliasesPath);
|
||||
if (!content) {
|
||||
return getDefaultAliases();
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(content);
|
||||
|
||||
// Validate structure
|
||||
if (!data.aliases || typeof data.aliases !== 'object') {
|
||||
log('[Aliases] Invalid aliases file structure, resetting');
|
||||
return getDefaultAliases();
|
||||
}
|
||||
|
||||
// Ensure version field
|
||||
if (!data.version) {
|
||||
data.version = ALIAS_VERSION;
|
||||
}
|
||||
|
||||
// Ensure metadata
|
||||
if (!data.metadata) {
|
||||
data.metadata = {
|
||||
totalCount: Object.keys(data.aliases).length,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
log(`[Aliases] Error parsing aliases file: ${err.message}`);
|
||||
return getDefaultAliases();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save aliases to file with atomic write
|
||||
* @param {object} aliases - Aliases object to save
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function saveAliases(aliases) {
|
||||
const aliasesPath = getAliasesPath();
|
||||
const tempPath = aliasesPath + '.tmp';
|
||||
const backupPath = aliasesPath + '.bak';
|
||||
|
||||
try {
|
||||
// Update metadata
|
||||
aliases.metadata = {
|
||||
totalCount: Object.keys(aliases.aliases).length,
|
||||
lastUpdated: new Date().toISOString()
|
||||
};
|
||||
|
||||
const content = JSON.stringify(aliases, null, 2);
|
||||
|
||||
// Ensure directory exists
|
||||
ensureDir(path.dirname(aliasesPath));
|
||||
|
||||
// Create backup if file exists
|
||||
if (fs.existsSync(aliasesPath)) {
|
||||
fs.copyFileSync(aliasesPath, backupPath);
|
||||
}
|
||||
|
||||
// Atomic write: write to temp file, then rename
|
||||
fs.writeFileSync(tempPath, content, 'utf8');
|
||||
|
||||
// On Windows, we need to delete the target file before renaming
|
||||
if (fs.existsSync(aliasesPath)) {
|
||||
fs.unlinkSync(aliasesPath);
|
||||
}
|
||||
fs.renameSync(tempPath, aliasesPath);
|
||||
|
||||
// Remove backup on success
|
||||
if (fs.existsSync(backupPath)) {
|
||||
fs.unlinkSync(backupPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[Aliases] Error saving aliases: ${err.message}`);
|
||||
|
||||
// Restore from backup if exists
|
||||
if (fs.existsSync(backupPath)) {
|
||||
try {
|
||||
fs.copyFileSync(backupPath, aliasesPath);
|
||||
log('[Aliases] Restored from backup');
|
||||
} catch (restoreErr) {
|
||||
log(`[Aliases] Failed to restore backup: ${restoreErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
if (fs.existsSync(tempPath)) {
|
||||
fs.unlinkSync(tempPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an alias to get session path
|
||||
* @param {string} alias - Alias name to resolve
|
||||
* @returns {object|null} Alias data or null if not found
|
||||
*/
|
||||
function resolveAlias(alias) {
|
||||
// Validate alias name (alphanumeric, dash, underscore)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = loadAliases();
|
||||
const aliasData = data.aliases[alias];
|
||||
|
||||
if (!aliasData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
sessionPath: aliasData.sessionPath,
|
||||
createdAt: aliasData.createdAt,
|
||||
title: aliasData.title || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update an alias for a session
|
||||
* @param {string} alias - Alias name (alphanumeric, dash, underscore)
|
||||
* @param {string} sessionPath - Session directory path
|
||||
* @param {string} title - Optional title for the alias
|
||||
* @returns {object} Result with success status and message
|
||||
*/
|
||||
function setAlias(alias, sessionPath, title = null) {
|
||||
// Validate alias name
|
||||
if (!alias || alias.length === 0) {
|
||||
return { success: false, error: 'Alias name cannot be empty' };
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(alias)) {
|
||||
return { success: false, error: 'Alias name must contain only letters, numbers, dashes, and underscores' };
|
||||
}
|
||||
|
||||
// Reserved alias names
|
||||
const reserved = ['list', 'help', 'remove', 'delete', 'create', 'set'];
|
||||
if (reserved.includes(alias.toLowerCase())) {
|
||||
return { success: false, error: `'${alias}' is a reserved alias name` };
|
||||
}
|
||||
|
||||
const data = loadAliases();
|
||||
const existing = data.aliases[alias];
|
||||
const isNew = !existing;
|
||||
|
||||
data.aliases[alias] = {
|
||||
sessionPath,
|
||||
createdAt: existing ? existing.createdAt : new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
title: title || null
|
||||
};
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
isNew,
|
||||
alias,
|
||||
sessionPath,
|
||||
title: data.aliases[alias].title
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to save alias' };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all aliases
|
||||
* @param {object} options - Options object
|
||||
* @param {string} options.search - Filter aliases by name (partial match)
|
||||
* @param {number} options.limit - Maximum number of aliases to return
|
||||
* @returns {Array} Array of alias objects
|
||||
*/
|
||||
function listAliases(options = {}) {
|
||||
const { search = null, limit = null } = options;
|
||||
const data = loadAliases();
|
||||
|
||||
let aliases = Object.entries(data.aliases).map(([name, info]) => ({
|
||||
name,
|
||||
sessionPath: info.sessionPath,
|
||||
createdAt: info.createdAt,
|
||||
updatedAt: info.updatedAt,
|
||||
title: info.title
|
||||
}));
|
||||
|
||||
// Sort by updated time (newest first)
|
||||
aliases.sort((a, b) => new Date(b.updatedAt || b.createdAt) - new Date(a.updatedAt || a.createdAt));
|
||||
|
||||
// Apply search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
aliases = aliases.filter(a =>
|
||||
a.name.toLowerCase().includes(searchLower) ||
|
||||
(a.title && a.title.toLowerCase().includes(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if (limit && limit > 0) {
|
||||
aliases = aliases.slice(0, limit);
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an alias
|
||||
* @param {string} alias - Alias name to delete
|
||||
* @returns {object} Result with success status
|
||||
*/
|
||||
function deleteAlias(alias) {
|
||||
const data = loadAliases();
|
||||
|
||||
if (!data.aliases[alias]) {
|
||||
return { success: false, error: `Alias '${alias}' not found` };
|
||||
}
|
||||
|
||||
const deleted = data.aliases[alias];
|
||||
delete data.aliases[alias];
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
alias,
|
||||
deletedSessionPath: deleted.sessionPath
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to delete alias' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an alias
|
||||
* @param {string} oldAlias - Current alias name
|
||||
* @param {string} newAlias - New alias name
|
||||
* @returns {object} Result with success status
|
||||
*/
|
||||
function renameAlias(oldAlias, newAlias) {
|
||||
const data = loadAliases();
|
||||
|
||||
if (!data.aliases[oldAlias]) {
|
||||
return { success: false, error: `Alias '${oldAlias}' not found` };
|
||||
}
|
||||
|
||||
if (data.aliases[newAlias]) {
|
||||
return { success: false, error: `Alias '${newAlias}' already exists` };
|
||||
}
|
||||
|
||||
// Validate new alias name
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(newAlias)) {
|
||||
return { success: false, error: 'New alias name must contain only letters, numbers, dashes, and underscores' };
|
||||
}
|
||||
|
||||
const aliasData = data.aliases[oldAlias];
|
||||
delete data.aliases[oldAlias];
|
||||
|
||||
aliasData.updatedAt = new Date().toISOString();
|
||||
data.aliases[newAlias] = aliasData;
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
oldAlias,
|
||||
newAlias,
|
||||
sessionPath: aliasData.sessionPath
|
||||
};
|
||||
}
|
||||
|
||||
// Restore old alias on failure
|
||||
data.aliases[oldAlias] = aliasData;
|
||||
return { success: false, error: 'Failed to rename alias' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session path by alias (convenience function)
|
||||
* @param {string} aliasOrId - Alias name or session ID
|
||||
* @returns {string|null} Session path or null if not found
|
||||
*/
|
||||
function resolveSessionAlias(aliasOrId) {
|
||||
// First try to resolve as alias
|
||||
const resolved = resolveAlias(aliasOrId);
|
||||
if (resolved) {
|
||||
return resolved.sessionPath;
|
||||
}
|
||||
|
||||
// If not an alias, return as-is (might be a session path)
|
||||
return aliasOrId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update alias title
|
||||
* @param {string} alias - Alias name
|
||||
* @param {string} title - New title
|
||||
* @returns {object} Result with success status
|
||||
*/
|
||||
function updateAliasTitle(alias, title) {
|
||||
const data = loadAliases();
|
||||
|
||||
if (!data.aliases[alias]) {
|
||||
return { success: false, error: `Alias '${alias}' not found` };
|
||||
}
|
||||
|
||||
data.aliases[alias].title = title;
|
||||
data.aliases[alias].updatedAt = new Date().toISOString();
|
||||
|
||||
if (saveAliases(data)) {
|
||||
return {
|
||||
success: true,
|
||||
alias,
|
||||
title
|
||||
};
|
||||
}
|
||||
|
||||
return { success: false, error: 'Failed to update alias title' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all aliases for a specific session
|
||||
* @param {string} sessionPath - Session path to find aliases for
|
||||
* @returns {Array} Array of alias names
|
||||
*/
|
||||
function getAliasesForSession(sessionPath) {
|
||||
const data = loadAliases();
|
||||
const aliases = [];
|
||||
|
||||
for (const [name, info] of Object.entries(data.aliases)) {
|
||||
if (info.sessionPath === sessionPath) {
|
||||
aliases.push({
|
||||
name,
|
||||
createdAt: info.createdAt,
|
||||
title: info.title
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up aliases for non-existent sessions
|
||||
* @param {Function} sessionExists - Function to check if session exists
|
||||
* @returns {object} Cleanup result
|
||||
*/
|
||||
function cleanupAliases(sessionExists) {
|
||||
const data = loadAliases();
|
||||
const removed = [];
|
||||
|
||||
for (const [name, info] of Object.entries(data.aliases)) {
|
||||
if (!sessionExists(info.sessionPath)) {
|
||||
removed.push({ name, sessionPath: info.sessionPath });
|
||||
delete data.aliases[name];
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0) {
|
||||
saveAliases(data);
|
||||
}
|
||||
|
||||
return {
|
||||
totalChecked: Object.keys(data.aliases).length + removed.length,
|
||||
removed: removed.length,
|
||||
removedAliases: removed
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAliasesPath,
|
||||
loadAliases,
|
||||
saveAliases,
|
||||
resolveAlias,
|
||||
setAlias,
|
||||
listAliases,
|
||||
deleteAlias,
|
||||
renameAlias,
|
||||
resolveSessionAlias,
|
||||
updateAliasTitle,
|
||||
getAliasesForSession,
|
||||
cleanupAliases
|
||||
};
|
||||
396
scripts/lib/session-manager.js
Normal file
396
scripts/lib/session-manager.js
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 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:
|
||||
* - YYYY-MM-DD-session.tmp (old format)
|
||||
* - YYYY-MM-DD-<short-id>-session.tmp (new format)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
getSessionsDir,
|
||||
readFile,
|
||||
log
|
||||
} = require('./utils');
|
||||
|
||||
// Session filename pattern: YYYY-MM-DD-[short-id]-session.tmp
|
||||
// The short-id is optional (old format) and can be 8+ alphanumeric characters
|
||||
// Matches: "2026-02-01-session.tmp" or "2026-02-01-a1b2c3d4-session.tmp"
|
||||
const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-z0-9]{8,}))?-session\.tmp$/;
|
||||
|
||||
/**
|
||||
* Parse session filename to extract metadata
|
||||
* @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp")
|
||||
* @returns {object|null} Parsed metadata or null if invalid
|
||||
*/
|
||||
function parseSessionFilename(filename) {
|
||||
const match = filename.match(SESSION_FILENAME_REGEX);
|
||||
if (!match) return null;
|
||||
|
||||
const dateStr = match[1];
|
||||
// match[2] is undefined for old format (no ID)
|
||||
const shortId = match[2] || 'no-id';
|
||||
|
||||
return {
|
||||
filename,
|
||||
shortId,
|
||||
date: dateStr,
|
||||
// Convert date string to Date object
|
||||
datetime: new Date(dateStr)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full path to a session file
|
||||
* @param {string} filename - Session filename
|
||||
* @returns {string} Full path to session file
|
||||
*/
|
||||
function getSessionPath(filename) {
|
||||
return path.join(getSessionsDir(), filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse session markdown content
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {string|null} Session content or null if not found
|
||||
*/
|
||||
function getSessionContent(sessionPath) {
|
||||
if (!fs.existsSync(sessionPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return readFile(sessionPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse session metadata from markdown content
|
||||
* @param {string} content - Session markdown content
|
||||
* @returns {object} Parsed metadata
|
||||
*/
|
||||
function parseSessionMetadata(content) {
|
||||
const metadata = {
|
||||
title: null,
|
||||
date: null,
|
||||
started: null,
|
||||
lastUpdated: null,
|
||||
completed: [],
|
||||
inProgress: [],
|
||||
notes: '',
|
||||
context: ''
|
||||
};
|
||||
|
||||
if (!content) return metadata;
|
||||
|
||||
// Extract title from first heading
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
if (titleMatch) {
|
||||
metadata.title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract date
|
||||
const dateMatch = content.match(/\*\*Date:\*\*\s*(\d{4}-\d{2}-\d{2})/);
|
||||
if (dateMatch) {
|
||||
metadata.date = dateMatch[1];
|
||||
}
|
||||
|
||||
// Extract started time
|
||||
const startedMatch = content.match(/\*\*Started:\*\*\s*([\d:]+)/);
|
||||
if (startedMatch) {
|
||||
metadata.started = startedMatch[1];
|
||||
}
|
||||
|
||||
// Extract last updated
|
||||
const updatedMatch = content.match(/\*\*Last Updated:\*\*\s*([\d:]+)/);
|
||||
if (updatedMatch) {
|
||||
metadata.lastUpdated = updatedMatch[1];
|
||||
}
|
||||
|
||||
// Extract completed items
|
||||
const completedSection = content.match(/### Completed\s*\n([\s\S]*?)(?=###|\n\n|$)/);
|
||||
if (completedSection) {
|
||||
const items = completedSection[1].match(/- \[x\]\s*(.+)/g);
|
||||
if (items) {
|
||||
metadata.completed = items.map(item => item.replace(/- \[x\]\s*/, '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract in-progress items
|
||||
const progressSection = content.match(/### In Progress\s*\n([\s\S]*?)(?=###|\n\n|$)/);
|
||||
if (progressSection) {
|
||||
const items = progressSection[1].match(/- \[ \]\s*(.+)/g);
|
||||
if (items) {
|
||||
metadata.inProgress = items.map(item => item.replace(/- \[ \]\s*/, '').trim());
|
||||
}
|
||||
}
|
||||
|
||||
// Extract notes
|
||||
const notesSection = content.match(/### Notes for Next Session\s*\n([\s\S]*?)(?=###|\n\n|$)/);
|
||||
if (notesSection) {
|
||||
metadata.notes = notesSection[1].trim();
|
||||
}
|
||||
|
||||
// Extract context to load
|
||||
const contextSection = content.match(/### Context to Load\s*\n```\n([\s\S]*?)```/);
|
||||
if (contextSection) {
|
||||
metadata.context = contextSection[1].trim();
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate statistics for a session
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {object} Statistics object
|
||||
*/
|
||||
function getSessionStats(sessionPath) {
|
||||
const content = getSessionContent(sessionPath);
|
||||
const metadata = parseSessionMetadata(content);
|
||||
|
||||
return {
|
||||
totalItems: metadata.completed.length + metadata.inProgress.length,
|
||||
completedItems: metadata.completed.length,
|
||||
inProgressItems: metadata.inProgress.length,
|
||||
lineCount: content ? content.split('\n').length : 0,
|
||||
hasNotes: !!metadata.notes,
|
||||
hasContext: !!metadata.context
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions with optional filtering and pagination
|
||||
* @param {object} options - Options object
|
||||
* @param {number} options.limit - Maximum number of sessions to return
|
||||
* @param {number} options.offset - Number of sessions to skip
|
||||
* @param {string} options.date - Filter by date (YYYY-MM-DD format)
|
||||
* @param {string} options.search - Search in short ID
|
||||
* @returns {object} Object with sessions array and pagination info
|
||||
*/
|
||||
function getAllSessions(options = {}) {
|
||||
const {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
date = null,
|
||||
search = null
|
||||
} = options;
|
||||
|
||||
const sessionsDir = getSessionsDir();
|
||||
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
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
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
sessions.push({
|
||||
...metadata,
|
||||
sessionPath,
|
||||
hasContent: stats.size > 0,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by modified time (newest first)
|
||||
sessions.sort((a, b) => b.modifiedTime - a.modifiedTime);
|
||||
|
||||
// Apply pagination
|
||||
const paginatedSessions = sessions.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
sessions: paginatedSessions,
|
||||
total: sessions.length,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: offset + limit < sessions.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single session by ID (short ID or full path)
|
||||
* @param {string} sessionId - Short ID or session filename
|
||||
* @param {boolean} includeContent - Include session content
|
||||
* @returns {object|null} Session object or null if not found
|
||||
*/
|
||||
function getSessionById(sessionId, includeContent = false) {
|
||||
const sessionsDir = getSessionsDir();
|
||||
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(sessionsDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue;
|
||||
|
||||
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 = 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);
|
||||
const stats = fs.statSync(sessionPath);
|
||||
|
||||
const session = {
|
||||
...metadata,
|
||||
sessionPath,
|
||||
size: stats.size,
|
||||
modifiedTime: stats.mtime,
|
||||
createdTime: stats.birthtime
|
||||
};
|
||||
|
||||
if (includeContent) {
|
||||
session.content = getSessionContent(sessionPath);
|
||||
session.metadata = parseSessionMetadata(session.content);
|
||||
session.stats = getSessionStats(sessionPath);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session title from content
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {string} Title or default text
|
||||
*/
|
||||
function getSessionTitle(sessionPath) {
|
||||
const content = getSessionContent(sessionPath);
|
||||
const metadata = parseSessionMetadata(content);
|
||||
|
||||
return metadata.title || 'Untitled Session';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session size in human-readable format
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {string} Formatted size (e.g., "1.2 KB")
|
||||
*/
|
||||
function getSessionSize(sessionPath) {
|
||||
if (!fs.existsSync(sessionPath)) {
|
||||
return '0 B';
|
||||
}
|
||||
|
||||
const stats = fs.statSync(sessionPath);
|
||||
const size = stats.size;
|
||||
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write session content to file
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @param {string} content - Markdown content to write
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function writeSessionContent(sessionPath, content) {
|
||||
try {
|
||||
fs.writeFileSync(sessionPath, content, 'utf8');
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[SessionManager] Error writing session: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append content to a session
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @param {string} content - Content to append
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function appendSessionContent(sessionPath, content) {
|
||||
try {
|
||||
fs.appendFileSync(sessionPath, content, 'utf8');
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`[SessionManager] Error appending to session: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session file
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {boolean} Success status
|
||||
*/
|
||||
function deleteSession(sessionPath) {
|
||||
try {
|
||||
if (fs.existsSync(sessionPath)) {
|
||||
fs.unlinkSync(sessionPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
log(`[SessionManager] Error deleting session: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session exists
|
||||
* @param {string} sessionPath - Full path to session file
|
||||
* @returns {boolean} True if session exists
|
||||
*/
|
||||
function sessionExists(sessionPath) {
|
||||
return fs.existsSync(sessionPath) && fs.statSync(sessionPath).isFile();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseSessionFilename,
|
||||
getSessionPath,
|
||||
getSessionContent,
|
||||
parseSessionMetadata,
|
||||
getSessionStats,
|
||||
getSessionTitle,
|
||||
getSessionSize,
|
||||
getAllSessions,
|
||||
getSessionById,
|
||||
writeSessionContent,
|
||||
appendSessionContent,
|
||||
deleteSession,
|
||||
sessionExists
|
||||
};
|
||||
@@ -34,6 +34,13 @@ function getSessionsDir() {
|
||||
return path.join(getClaudeDir(), 'sessions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session aliases file path
|
||||
*/
|
||||
function getAliasesPath() {
|
||||
return path.join(getClaudeDir(), 'session-aliases.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the learned skills directory
|
||||
*/
|
||||
@@ -382,6 +389,7 @@ module.exports = {
|
||||
getHomeDir,
|
||||
getClaudeDir,
|
||||
getSessionsDir,
|
||||
getAliasesPath,
|
||||
getLearnedSkillsDir,
|
||||
getTempDir,
|
||||
ensureDir,
|
||||
|
||||
Reference in New Issue
Block a user