From 4c99678c4c181107d47d02f4714481b1fba540bb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 26 Mar 2026 12:03:24 +0800 Subject: [PATCH] Support symlinked skill directories --- lib/app/app_controller_desktop.dart | 60 ++++++++++++++++--- .../app_controller_thread_skills_suite.dart | 55 +++++++++++++++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 939d2ba1..1283e93d 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -4274,13 +4274,8 @@ class AppController extends ChangeNotifier { if (!await root.exists()) { continue; } - await for (final entity in root.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') { - continue; - } + final skillFiles = await _collectSkillFilesFromDirectory(root); + for (final entity in skillFiles) { final entry = await _skillEntryFromFile( entity, rootSpec, @@ -4303,6 +4298,57 @@ class AppController extends ChangeNotifier { return entries; } + Future> _collectSkillFilesFromDirectory(Directory root) async { + final skillFiles = []; + final visitedDirectories = {}; + + Future visitDirectory(Directory directory) async { + final directoryKey = await _directoryScanKey(directory); + if (!visitedDirectories.add(directoryKey)) { + return; + } + await for (final entity in directory.list(followLinks: false)) { + if (entity is File) { + if (entity.uri.pathSegments.last == 'SKILL.md') { + skillFiles.add(entity); + } + continue; + } + if (entity is Directory) { + await visitDirectory(entity); + continue; + } + if (entity is! Link) { + continue; + } + final resolvedType = await FileSystemEntity.type( + entity.path, + followLinks: true, + ); + if (resolvedType == FileSystemEntityType.file) { + if (entity.uri.pathSegments.last == 'SKILL.md') { + skillFiles.add(File(entity.path)); + } + continue; + } + if (resolvedType == FileSystemEntityType.directory) { + await visitDirectory(Directory(entity.path)); + } + } + } + + await visitDirectory(root); + return skillFiles; + } + + Future _directoryScanKey(Directory directory) async { + try { + return await directory.resolveSymbolicLinks(); + } catch (_) { + return directory.absolute.path; + } + } + Future> _scanSingleAgentSharedSkillEntries() { return _scanSingleAgentSkillEntries(_singleAgentSharedSkillScanRoots); } diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index a6873de1..9179dea5 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -210,6 +210,61 @@ void main() { }, ); + test( + 'AppController scans skills inside symlinked directories under shared roots', + () async { + if (Platform.isWindows) { + return; + } + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-skill-directory-symlink-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final sharedRoot = Directory('${tempDirectory.path}/shared-root'); + final actualSkillRoot = Directory('${tempDirectory.path}/actual-skills'); + await sharedRoot.create(recursive: true); + await _writeSkill( + actualSkillRoot, + 'linked-browser', + skillName: 'Linked Browser', + description: 'Loaded through a symlinked directory', + ); + await Link('${sharedRoot.path}/linked-pack').create(actualSkillRoot.path); + + final controller = AppController( + store: await _createStore(tempDirectory.path), + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + singleAgentSharedSkillScanRootOverrides: [sharedRoot.path], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .any((skill) => skill.label == 'Linked Browser'), + ); + + final linkedSkill = controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .firstWhere((skill) => skill.label == 'Linked Browser'); + expect(linkedSkill.description, 'Loaded through a symlinked directory'); + expect(linkedSkill.source, 'custom'); + expect(linkedSkill.sourceLabel, contains('linked-pack/linked-browser')); + }, + ); + test( 'AppController resolves preset shared roots against the access service home directory', () async {