Skip to content

Commit 00b9ee6

Browse files
authored
fix claude adapter path and preview regressions (#29)
1 parent 1036630 commit 00b9ee6

5 files changed

Lines changed: 430 additions & 86 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"mcp": "node src/mcp-server.mjs",
1818
"test": "npx playwright test --config tests/e2e/playwright.config.mjs",
1919
"test:headed": "npx playwright test --config tests/e2e/playwright.config.mjs --headed",
20-
"test:unit": "node --test tests/unit/"
20+
"test:unit": "node --test tests/unit/*.mjs"
2121
},
2222
"keywords": [
2323
"claude-code",

src/harness/adapters/claude.mjs

Lines changed: 238 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,115 @@ function resetSettingsCache() {
179179

180180
// ── Path decoding ────────────────────────────────────────────────────
181181

182+
/**
183+
* Ground-truth resolver: read a session file inside the encoded project dir
184+
* and pull the `cwd` field from an entry. Claude Code writes the real cwd into
185+
* sessions, so this avoids guessing when path encoding is lossy.
186+
*/
187+
async function resolveViaSessionCwd(claudeProjectDir) {
188+
let entries;
189+
try {
190+
entries = await readdir(claudeProjectDir, { withFileTypes: true });
191+
} catch {
192+
return null;
193+
}
194+
195+
const sessionFiles = entries
196+
.filter(e => e.isFile() && e.name.endsWith(".jsonl"))
197+
.slice(0, 3);
198+
199+
for (const entry of sessionFiles) {
200+
const lines = await readFirstLines(join(claudeProjectDir, entry.name), 20);
201+
for (const line of lines) {
202+
const cwd = parseJsonLine(line)?.cwd;
203+
if (typeof cwd === "string" && cwd.length > 0 && await exists(cwd)) return cwd;
204+
}
205+
}
206+
207+
return null;
208+
}
209+
210+
/**
211+
* Character-level fallback for encoded paths containing Unicode characters.
212+
* Claude Code preserves alphanumerics and hyphens, and encodes everything else
213+
* as "-". Treat encoded "-" as a non-alphanumeric wildcard.
214+
*/
215+
async function resolveEncodedProjectPathUnicode(encoded) {
216+
let pattern = encoded.replace(/^-/, "");
217+
let rootPath = "/";
218+
219+
if (RUNTIME_PLATFORM === "win32" && /^[a-z]--/i.test(pattern)) {
220+
rootPath = pattern[0].toUpperCase() + ":\\";
221+
pattern = pattern.slice(3);
222+
}
223+
224+
const alnum = /[A-Za-z0-9]/;
225+
226+
async function walk(currentPath, pos) {
227+
if (pos >= pattern.length) {
228+
return (await exists(currentPath)) ? currentPath : null;
229+
}
230+
231+
let entries;
232+
try {
233+
entries = (await readdir(currentPath, { withFileTypes: true }))
234+
.filter(e => e.isDirectory() || e.isSymbolicLink())
235+
.map(e => e.name);
236+
} catch {
237+
return null;
238+
}
239+
240+
const candidates = [];
241+
for (const name of entries) {
242+
if (pos + name.length > pattern.length) continue;
243+
244+
let ok = true;
245+
for (let i = 0; i < name.length; i++) {
246+
const pc = pattern[pos + i];
247+
const nc = name[i];
248+
if (pc === "-") {
249+
if (alnum.test(nc)) {
250+
ok = false;
251+
break;
252+
}
253+
} else if (pc.toLowerCase() !== nc.toLowerCase()) {
254+
ok = false;
255+
break;
256+
}
257+
}
258+
if (!ok) continue;
259+
260+
const nextPos = pos + name.length;
261+
if (nextPos === pattern.length) {
262+
candidates.push({ name, nextPos });
263+
} else if (pattern[nextPos] === "-") {
264+
candidates.push({ name, nextPos: nextPos + 1 });
265+
}
266+
}
267+
268+
candidates.sort((a, b) => b.name.length - a.name.length);
269+
for (const candidate of candidates) {
270+
const result = await walk(join(currentPath, candidate.name), candidate.nextPos);
271+
if (result) return result;
272+
}
273+
274+
return null;
275+
}
276+
277+
return walk(rootPath, 0);
278+
}
279+
280+
function prettifyEncodedPath(encoded) {
281+
let cleaned = encoded.replace(/^-/, "");
282+
if (/^[a-z]--/i.test(cleaned)) {
283+
cleaned = cleaned[0].toUpperCase() + ":/" + cleaned.slice(3);
284+
}
285+
cleaned = cleaned.replace(/-{2,}/g, "/.../");
286+
cleaned = cleaned.replace(/-/g, "/");
287+
cleaned = cleaned.replace(/\/+/g, "/").replace(/^\/|\/$/g, "");
288+
return cleaned || encoded;
289+
}
290+
182291
/**
183292
* Resolve an encoded project dir name back to a real filesystem path.
184293
* E.g. "-home-user-mycompany-repo1" → "/home/user/mycompany/repo1"
@@ -214,7 +323,7 @@ async function resolveEncodedProjectPath(encoded) {
214323
let entries;
215324
try {
216325
entries = await readdir(currentPath, { withFileTypes: true });
217-
entries = entries.filter(e => e.isDirectory());
326+
entries = entries.filter(e => e.isDirectory() || e.isSymbolicLink());
218327
} catch {
219328
return null;
220329
}
@@ -276,39 +385,49 @@ async function discoverScopes() {
276385
for (const d of projectDirs) {
277386
if (!d.isDirectory()) continue;
278387

279-
// Decode encoded path: try to find the real directory on disk.
280-
// The encoding replaces / with - and prepends -.
281-
// E.g. -home-user-mycompany-repo1 → /home/user/mycompany/repo1
282-
// Since directory names can contain dashes, we resolve by checking which real path exists.
283-
const realPath = await resolveEncodedProjectPath(d.name);
284-
if (!realPath) continue;
285-
286-
const shortName = basename(realPath);
287388
const projectDir = join(projectsDir, d.name);
288389

289390
// Discover any project directory that has content (not just memory).
290391
// Sessions, plans, or other items may exist without a memory/ subfolder.
291392
const entries = await readdir(projectDir);
292393
const hasContent = entries.some(e => e !== ".DS_Store");
394+
if (!hasContent) continue;
293395

294-
if (hasContent) {
295-
projectEntries.push({
296-
encodedName: d.name,
297-
realPath,
298-
shortName,
299-
claudeProjectDir: projectDir,
300-
});
301-
}
396+
let realPath = await resolveViaSessionCwd(projectDir);
397+
if (!realPath) realPath = await resolveEncodedProjectPath(d.name);
398+
if (!realPath) realPath = await resolveEncodedProjectPathUnicode(d.name);
399+
400+
projectEntries.push({
401+
encodedName: d.name,
402+
realPath,
403+
shortName: realPath ? basename(realPath) : prettifyEncodedPath(d.name),
404+
claudeProjectDir: projectDir,
405+
});
302406
}
303407

304-
// Sort by path depth (shorter = parent) then alphabetically
408+
// Sort by path depth (shorter = parent) then alphabetically. Unresolved
409+
// encoded scopes are kept last so their memory/session content stays visible.
305410
projectEntries.sort((a, b) => {
411+
if (!a.realPath && !b.realPath) return a.shortName.localeCompare(b.shortName);
412+
if (!a.realPath) return 1;
413+
if (!b.realPath) return -1;
306414
const da = a.realPath.split("/").length;
307415
const db = b.realPath.split("/").length;
308416
if (da !== db) return da - db;
309417
return a.realPath.localeCompare(b.realPath);
310418
});
311419

420+
const nameCount = new Map();
421+
for (const entry of projectEntries) {
422+
nameCount.set(entry.shortName, (nameCount.get(entry.shortName) || 0) + 1);
423+
}
424+
for (const entry of projectEntries) {
425+
if (entry.realPath && nameCount.get(entry.shortName) > 1) {
426+
const parts = entry.realPath.split(/[\/\\]/).filter(Boolean);
427+
if (parts.length >= 2) entry.shortName = `${parts.at(-2)}/${entry.shortName}`;
428+
}
429+
}
430+
312431
// Claude Code has two scopes: User (global) and Project.
313432
// Every project's parent is always global — there is no intermediate workspace scope.
314433
// Filesystem nesting (e.g. CompanyRepo/api inside CompanyRepo/) does NOT create
@@ -369,6 +488,10 @@ async function loadSkillBundles(repoDir) {
369488

370489
// ── Item scanners ────────────────────────────────────────────────────
371490

491+
function encodeClaudeProjectName(realPath) {
492+
return realPath.replace(/[^A-Za-z0-9-]/g, "-");
493+
}
494+
372495
async function scanMemories(scope) {
373496
const items = [];
374497
const settings = await getSettingsOverrides();
@@ -429,6 +552,64 @@ async function scanSkills(scope) {
429552
// Load bundle info from skills-lock.json
430553
const bundleMap = await loadSkillBundles(scope.repoDir);
431554

555+
async function readSkillEntry(skillsRoot, entryName, pluginName = null) {
556+
const skillDir = join(skillsRoot, entryName);
557+
const skillMd = join(skillDir, "SKILL.md");
558+
if (!(await exists(skillMd))) return null;
559+
560+
const s = await safeStat(skillMd);
561+
const content = await safeReadFile(skillMd);
562+
563+
// Extract description: first meaningful paragraph line after the heading
564+
let description = "";
565+
if (content) {
566+
const lines = content.split("\n");
567+
let pastHeading = false;
568+
for (const line of lines) {
569+
const trimmed = line.trim();
570+
if (trimmed.startsWith("# ")) { pastHeading = true; continue; }
571+
if (!pastHeading) continue;
572+
// Skip empty lines, frontmatter-like lines, code blocks, list items
573+
if (!trimmed) continue;
574+
if (trimmed.startsWith("```") || trimmed.startsWith("-") || trimmed.startsWith("|")) continue;
575+
if (trimmed.match(/^\w+:\s/)) continue; // skip "name: foo" style lines
576+
if (trimmed.startsWith("##")) continue;
577+
description = trimmed.slice(0, 120);
578+
break;
579+
}
580+
}
581+
582+
// Count files in skill directory
583+
const allFiles = await readdir(skillDir, { withFileTypes: true });
584+
const fileCount = allFiles.filter(f => f.isFile()).length;
585+
586+
// Total size of skill directory
587+
let totalSize = 0;
588+
for (const f of allFiles.filter(f => f.isFile())) {
589+
const fs = await safeStat(join(skillDir, f.name));
590+
if (fs) totalSize += fs.size;
591+
}
592+
593+
// Bundle detection from skills-lock.json
594+
const bundleInfo = bundleMap.get(entryName);
595+
596+
return {
597+
category: "skill",
598+
scopeId: scope.id,
599+
name: entryName,
600+
fileName: entryName, // directory name
601+
description,
602+
subType: pluginName ? "plugin-skill" : "skill",
603+
size: formatSize(totalSize),
604+
sizeBytes: totalSize,
605+
fileCount,
606+
mtime: s ? s.mtime.toISOString().slice(0, 16) : "",
607+
ctime: s ? s.birthtime.toISOString().slice(0, 16) : "",
608+
path: skillDir,
609+
bundle: pluginName || bundleInfo?.source || null,
610+
};
611+
}
612+
432613
for (const skillsRoot of skillDirs) {
433614
const entries = await readdir(skillsRoot, { withFileTypes: true });
434615
for (const entry of entries) {
@@ -437,61 +618,49 @@ async function scanSkills(scope) {
437618
// Skip "private" directory (usually copies of global skills)
438619
if (entry.name === "private") continue;
439620

440-
const skillDir = join(skillsRoot, entry.name);
441-
const skillMd = join(skillDir, "SKILL.md");
442-
if (!(await exists(skillMd))) continue;
443-
444-
const s = await safeStat(skillMd);
445-
const content = await safeReadFile(skillMd);
446-
447-
// Extract description: first meaningful paragraph line after the heading
448-
let description = "";
449-
if (content) {
450-
const lines = content.split("\n");
451-
let pastHeading = false;
452-
for (const line of lines) {
453-
const trimmed = line.trim();
454-
if (trimmed.startsWith("# ")) { pastHeading = true; continue; }
455-
if (!pastHeading) continue;
456-
// Skip empty lines, frontmatter-like lines, code blocks, list items
457-
if (!trimmed) continue;
458-
if (trimmed.startsWith("```") || trimmed.startsWith("-") || trimmed.startsWith("|")) continue;
459-
if (trimmed.match(/^\w+:\s/)) continue; // skip "name: foo" style lines
460-
if (trimmed.startsWith("##")) continue;
461-
description = trimmed.slice(0, 120);
462-
break;
463-
}
464-
}
621+
const item = await readSkillEntry(skillsRoot, entry.name);
622+
if (item) items.push(item);
623+
}
624+
}
465625

466-
// Count files in skill directory
467-
const allFiles = await readdir(skillDir, { withFileTypes: true });
468-
const fileCount = allFiles.filter(f => f.isFile()).length;
626+
const installedPluginsFile = join(CLAUDE_DIR, "plugins", "installed_plugins.json");
627+
const installedContent = await safeReadFile(installedPluginsFile);
628+
if (installedContent) {
629+
let installedData;
630+
try { installedData = JSON.parse(installedContent); } catch { installedData = null; }
631+
632+
for (const [pluginName, installs] of Object.entries(installedData?.plugins || {})) {
633+
for (const install of installs || []) {
634+
const isUserScope = install.scope === "user";
635+
const isProjectScope = install.scope === "project" && install.projectPath;
636+
637+
let belongs = false;
638+
if (scope.id === "global" && isUserScope) {
639+
belongs = true;
640+
} else if (scope.type === "project" && isProjectScope) {
641+
const pluginEncoded = encodeClaudeProjectName(install.projectPath);
642+
belongs = pluginEncoded === scope.id ||
643+
Boolean(scope.repoDir && install.projectPath.toLowerCase() === scope.repoDir.toLowerCase());
644+
}
645+
if (!belongs || !install.installPath) continue;
469646

470-
// Total size of skill directory
471-
let totalSize = 0;
472-
for (const f of allFiles.filter(f => f.isFile())) {
473-
const fs = await safeStat(join(skillDir, f.name));
474-
if (fs) totalSize += fs.size;
475-
}
647+
const pluginSkillsDir = join(install.installPath, "skills");
648+
if (!(await exists(pluginSkillsDir))) continue;
476649

477-
// Bundle detection from skills-lock.json
478-
const bundleInfo = bundleMap.get(entry.name);
650+
let entries;
651+
try {
652+
entries = await readdir(pluginSkillsDir, { withFileTypes: true });
653+
} catch {
654+
continue;
655+
}
479656

480-
items.push({
481-
category: "skill",
482-
scopeId: scope.id,
483-
name: entry.name,
484-
fileName: entry.name, // directory name
485-
description,
486-
subType: "skill",
487-
size: formatSize(totalSize),
488-
sizeBytes: totalSize,
489-
fileCount,
490-
mtime: s ? s.mtime.toISOString().slice(0, 16) : "",
491-
ctime: s ? s.birthtime.toISOString().slice(0, 16) : "",
492-
path: skillDir,
493-
bundle: bundleInfo?.source || null,
494-
});
657+
for (const entry of entries) {
658+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
659+
if (entry.name === "private") continue;
660+
const item = await readSkillEntry(pluginSkillsDir, entry.name, pluginName);
661+
if (item) items.push(item);
662+
}
663+
}
495664
}
496665
}
497666

src/ui/app.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3725,9 +3725,12 @@ function renderMarkdown(text) {
37253725
if (!text) return "";
37263726
// Strip YAML frontmatter
37273727
let content = text.replace(/^---\n[\s\S]*?\n---\n?/, "").trim();
3728-
// Fallback if marked CDN failed to load
3729-
if (typeof marked === "undefined") return `<pre>${esc(content)}</pre>`;
3730-
return marked.parse(content);
3728+
try {
3729+
if (typeof marked === "undefined") return `<pre>${esc(content)}</pre>`;
3730+
if (typeof marked.parse === "function") return marked.parse(content);
3731+
if (typeof marked === "function") return marked(content);
3732+
} catch {}
3733+
return `<pre>${esc(content)}</pre>`;
37313734
}
37323735

37333736
// ── MCP Policy Panel ──────────────────────────────────────────────

0 commit comments

Comments
 (0)