@@ -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 - Z a - z 0 - 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 - Z a - z 0 - 9 - ] / g, "-" ) ;
493+ }
494+
372495async 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
0 commit comments