Skip to content

Commit 2d4afe7

Browse files
aarlintclaude
andcommitted
Add CodeMirror editors to Memory, Skills, and Agents viewers
Replace read-only textContent rendering with full CodeMirror editors matching the existing Plans viewer pattern. Add Save, Copy Content, and Copy Path buttons to each viewer header. Fix hardcoded '.claude' string checks to use CLAUDE_DIRNAME constant derived from CLAUDE_DIR. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e79ba60 commit 2d4afe7

5 files changed

Lines changed: 720 additions & 30 deletions

File tree

main.js

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@ const {
6060

6161
const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
6262
const PLANS_DIR = path.join(os.homedir(), '.claude', 'plans');
63+
const COMMANDS_DIR = path.join(os.homedir(), '.claude', 'commands');
64+
const SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');
6365
const CLAUDE_DIR = path.join(os.homedir(), '.claude');
66+
const CLAUDE_DIRNAME = path.basename(CLAUDE_DIR);
6467
const STATS_CACHE_PATH = path.join(CLAUDE_DIR, 'stats-cache.json');
6568
const MAX_BUFFER_SIZE = 256 * 1024;
6669

@@ -888,6 +891,220 @@ ipcMain.handle('read-memory', (_event, filePath) => {
888891
}
889892
});
890893

894+
// --- IPC: get-skills ---
895+
function parseFrontmatter(content) {
896+
const match = content.match(/^---\n([\s\S]*?)\n---/);
897+
if (!match) return {};
898+
const fm = {};
899+
for (const line of match[1].split('\n')) {
900+
const idx = line.indexOf(':');
901+
if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim().replace(/^["']|["']$/g, '');
902+
}
903+
return fm;
904+
}
905+
906+
function readMdFiles(dir, filter) {
907+
if (!fs.existsSync(dir)) return [];
908+
return fs.readdirSync(dir, { withFileTypes: true })
909+
.filter(filter || (d => d.isFile() && d.name.endsWith('.md')))
910+
.map(d => d.name);
911+
}
912+
913+
ipcMain.handle('get-skills', () => {
914+
const skills = [];
915+
try {
916+
// Global commands: ~/.claude/commands/*.md
917+
for (const file of readMdFiles(COMMANDS_DIR)) {
918+
try {
919+
const filePath = path.join(COMMANDS_DIR, file);
920+
const stat = fs.statSync(filePath);
921+
const content = fs.readFileSync(filePath, 'utf8');
922+
const fm = parseFrontmatter(content);
923+
const firstLine = content.replace(/^---[\s\S]*?---\n?/, '').split('\n').find(l => l.trim());
924+
const title = fm.name || fm.description || (firstLine && firstLine.startsWith('# ') ? firstLine.slice(2).trim() : file.replace(/\.md$/, ''));
925+
skills.push({ filename: file, title, description: fm.description || '', type: 'command', scope: 'global', filePath, modified: stat.mtime.toISOString() });
926+
} catch {}
927+
}
928+
929+
// Global skills: ~/.claude/skills/*/SKILL.md
930+
if (fs.existsSync(SKILLS_DIR)) {
931+
for (const d of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
932+
if (!d.isDirectory()) continue;
933+
const filePath = path.join(SKILLS_DIR, d.name, 'SKILL.md');
934+
if (!fs.existsSync(filePath)) continue;
935+
try {
936+
const stat = fs.statSync(filePath);
937+
const content = fs.readFileSync(filePath, 'utf8');
938+
const fm = parseFrontmatter(content);
939+
const title = fm.name || d.name;
940+
skills.push({ filename: d.name + '/SKILL.md', title, description: fm.description || '', type: 'skill', scope: 'global', filePath, modified: stat.mtime.toISOString() });
941+
} catch {}
942+
}
943+
}
944+
945+
// Per-project commands: {actualProjectDir}/.claude/commands/*.md
946+
if (fs.existsSync(PROJECTS_DIR)) {
947+
const folders = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
948+
.filter(d => d.isDirectory() && d.name !== '.git')
949+
.map(d => d.name);
950+
const seen = new Set();
951+
for (const folder of folders) {
952+
const projectPath = deriveProjectPath(path.join(PROJECTS_DIR, folder), folder);
953+
if (!projectPath || seen.has(projectPath)) continue;
954+
seen.add(projectPath);
955+
const shortPath = folderToShortPath(folder);
956+
const cmdDir = path.join(projectPath, '.claude', 'commands');
957+
for (const file of readMdFiles(cmdDir)) {
958+
try {
959+
const filePath = path.join(cmdDir, file);
960+
const stat = fs.statSync(filePath);
961+
const content = fs.readFileSync(filePath, 'utf8');
962+
const fm = parseFrontmatter(content);
963+
const firstLine = content.replace(/^---[\s\S]*?---\n?/, '').split('\n').find(l => l.trim());
964+
const title = fm.name || fm.description || (firstLine && firstLine.startsWith('# ') ? firstLine.slice(2).trim() : file.replace(/\.md$/, ''));
965+
skills.push({ filename: file, title, description: fm.description || '', type: 'command', scope: shortPath, filePath, modified: stat.mtime.toISOString() });
966+
} catch {}
967+
}
968+
}
969+
}
970+
971+
skills.sort((a, b) => new Date(b.modified) - new Date(a.modified));
972+
973+
// Index for FTS
974+
try {
975+
deleteSearchType('skill');
976+
upsertSearchEntries(skills.map(s => ({
977+
id: s.filePath, type: 'skill', folder: null,
978+
title: s.title + ' ' + s.type + ' ' + s.scope,
979+
body: fs.readFileSync(s.filePath, 'utf8'),
980+
})));
981+
} catch {}
982+
983+
return skills;
984+
} catch (err) {
985+
console.error('Error reading skills:', err);
986+
return [];
987+
}
988+
});
989+
990+
// --- IPC: read-skill ---
991+
ipcMain.handle('read-skill', (_event, filePath) => {
992+
try {
993+
const resolved = path.resolve(filePath);
994+
// Allow paths under ~/.claude/ or inside .claude/ of known projects
995+
if (!resolved.includes(path.sep + CLAUDE_DIRNAME + path.sep)) return { content: '', filePath: '' };
996+
return { content: fs.readFileSync(resolved, 'utf8'), filePath: resolved };
997+
} catch (err) {
998+
console.error('Error reading skill:', err);
999+
return { content: '', filePath: '' };
1000+
}
1001+
});
1002+
1003+
// --- IPC: get-agents ---
1004+
ipcMain.handle('get-agents', () => {
1005+
const agents = [];
1006+
try {
1007+
if (fs.existsSync(PROJECTS_DIR)) {
1008+
const folders = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })
1009+
.filter(d => d.isDirectory() && d.name !== '.git')
1010+
.map(d => d.name);
1011+
const seen = new Set();
1012+
for (const folder of folders) {
1013+
const projectPath = deriveProjectPath(path.join(PROJECTS_DIR, folder), folder);
1014+
if (!projectPath || seen.has(projectPath)) continue;
1015+
seen.add(projectPath);
1016+
const shortPath = folderToShortPath(folder);
1017+
const agentDir = path.join(projectPath, '.claude', 'agents');
1018+
for (const file of readMdFiles(agentDir)) {
1019+
try {
1020+
const filePath = path.join(agentDir, file);
1021+
const stat = fs.statSync(filePath);
1022+
const content = fs.readFileSync(filePath, 'utf8');
1023+
const fm = parseFrontmatter(content);
1024+
const firstLine = content.replace(/^---[\s\S]*?---\n?/, '').split('\n').find(l => l.trim());
1025+
const title = fm.name || (firstLine && firstLine.startsWith('# ') ? firstLine.slice(2).trim() : file.replace(/\.md$/, ''));
1026+
agents.push({ filename: file, title, description: fm.description || '', model: fm.model || '', scope: shortPath, filePath, modified: stat.mtime.toISOString() });
1027+
} catch {}
1028+
}
1029+
}
1030+
}
1031+
1032+
agents.sort((a, b) => new Date(b.modified) - new Date(a.modified));
1033+
1034+
// Index for FTS
1035+
try {
1036+
deleteSearchType('agent');
1037+
upsertSearchEntries(agents.map(a => ({
1038+
id: a.filePath, type: 'agent', folder: null,
1039+
title: a.title + ' ' + a.scope,
1040+
body: fs.readFileSync(a.filePath, 'utf8'),
1041+
})));
1042+
} catch {}
1043+
1044+
return agents;
1045+
} catch (err) {
1046+
console.error('Error reading agents:', err);
1047+
return [];
1048+
}
1049+
});
1050+
1051+
// --- IPC: read-agent ---
1052+
ipcMain.handle('read-agent', (_event, filePath) => {
1053+
try {
1054+
const resolved = path.resolve(filePath);
1055+
if (!resolved.includes(path.sep + CLAUDE_DIRNAME + path.sep)) return '';
1056+
return fs.readFileSync(resolved, 'utf8');
1057+
} catch (err) {
1058+
console.error('Error reading agent:', err);
1059+
return '';
1060+
}
1061+
});
1062+
1063+
// --- IPC: save-memory ---
1064+
ipcMain.handle('save-memory', (_event, filePath, content) => {
1065+
try {
1066+
const resolved = path.resolve(filePath);
1067+
if (!resolved.startsWith(CLAUDE_DIR)) {
1068+
return { ok: false, error: 'path outside .claude directory' };
1069+
}
1070+
fs.writeFileSync(resolved, content, 'utf8');
1071+
return { ok: true };
1072+
} catch (err) {
1073+
console.error('Error saving memory:', err);
1074+
return { ok: false, error: err.message };
1075+
}
1076+
});
1077+
1078+
// --- IPC: save-skill ---
1079+
ipcMain.handle('save-skill', (_event, filePath, content) => {
1080+
try {
1081+
const resolved = path.resolve(filePath);
1082+
if (!resolved.includes(path.sep + CLAUDE_DIRNAME + path.sep)) {
1083+
return { ok: false, error: 'path outside .claude directory' };
1084+
}
1085+
fs.writeFileSync(resolved, content, 'utf8');
1086+
return { ok: true };
1087+
} catch (err) {
1088+
console.error('Error saving skill:', err);
1089+
return { ok: false, error: err.message };
1090+
}
1091+
});
1092+
1093+
// --- IPC: save-agent ---
1094+
ipcMain.handle('save-agent', (_event, filePath, content) => {
1095+
try {
1096+
const resolved = path.resolve(filePath);
1097+
if (!resolved.includes(path.sep + CLAUDE_DIRNAME + path.sep)) {
1098+
return { ok: false, error: 'path outside .claude directory' };
1099+
}
1100+
fs.writeFileSync(resolved, content, 'utf8');
1101+
return { ok: true };
1102+
} catch (err) {
1103+
console.error('Error saving agent:', err);
1104+
return { ok: false, error: err.message };
1105+
}
1106+
});
1107+
8911108
// --- IPC: search ---
8921109
ipcMain.handle('search', (_event, type, query) => {
8931110
return searchByType(type, query, 50);

preload.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ contextBridge.exposeInMainWorld('api', {
1616
renameSession: (id, name) => ipcRenderer.invoke('rename-session', id, name),
1717
archiveSession: (id, archived) => ipcRenderer.invoke('archive-session', id, archived),
1818
openTerminal: (id, projectPath, isNew, sessionOptions) => ipcRenderer.invoke('open-terminal', id, projectPath, isNew, sessionOptions),
19+
getSkills: () => ipcRenderer.invoke('get-skills'),
20+
readSkill: (filePath) => ipcRenderer.invoke('read-skill', filePath),
21+
getAgents: () => ipcRenderer.invoke('get-agents'),
22+
readAgent: (filePath) => ipcRenderer.invoke('read-agent', filePath),
23+
saveMemory: (filePath, content) => ipcRenderer.invoke('save-memory', filePath, content),
24+
saveSkill: (filePath, content) => ipcRenderer.invoke('save-skill', filePath, content),
25+
saveAgent: (filePath, content) => ipcRenderer.invoke('save-agent', filePath, content),
1926
search: (type, query) => ipcRenderer.invoke('search', type, query),
2027
readSessionJsonl: (sessionId) => ipcRenderer.invoke('read-session-jsonl', sessionId),
2128

0 commit comments

Comments
 (0)