From 508d6cc330d51cfcac603a0638e2d3cef7560640 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Thu, 26 Mar 2026 17:12:37 +0800 Subject: [PATCH] Support absolute local skill package imports --- .../skill_directory_authorization_card.dart | 16 ++--- lib/runtime/runtime_models.dart | 15 ++-- test/features/settings_page_suite.dart | 49 +++++++++++++ .../app_controller_thread_skills_suite.dart | 72 +++++++++++++++++++ 4 files changed, 140 insertions(+), 12 deletions(-) diff --git a/lib/features/settings/skill_directory_authorization_card.dart b/lib/features/settings/skill_directory_authorization_card.dart index d79e3364..d5881973 100644 --- a/lib/features/settings/skill_directory_authorization_card.dart +++ b/lib/features/settings/skill_directory_authorization_card.dart @@ -55,8 +55,8 @@ class _SkillDirectoryAuthorizationCardState const SizedBox(height: 8), Text( appText( - '预设目录支持直接按路径加入;也可以把终端输出里的路径直接贴进来批量导入。系统目录选择器保留在同行旁侧,作为可选授权方式。设置中心修改会写入 settings.yaml。', - 'Preset roots can be added directly by path, and terminal output paths can be pasted for batch import. The system directory picker remains available as an optional side action. Settings Center writes changes back to settings.yaml.', + '预设目录支持直接按路径加入;也可以把终端输出里的目录或单个技能包路径直接贴进来批量导入。系统目录选择器保留在同行旁侧,作为可选授权方式。设置中心修改会写入 settings.yaml。', + 'Preset roots can be added directly by path, and terminal output containing directories or single skill package paths can be pasted for batch import. The system directory picker remains available as an optional side action. Settings Center writes changes back to settings.yaml.', ), style: theme.textTheme.bodyMedium, ), @@ -382,8 +382,8 @@ class _SkillDirectoryAuthorizationCardState setState(() { _statusMessage = null; _errorMessage = appText( - '没有识别到可用目录路径。请每行提供一个以 / 或 ~/ 开头的目录。', - 'No usable directory paths were detected. Provide one directory per line starting with / or ~/.', + '没有识别到可用路径。请每行提供一个以 / 或 ~/ 开头的目录、技能包目录,或 SKILL.md 文件路径。', + 'No usable paths were detected. Provide one directory, skill package directory, or SKILL.md path per line starting with / or ~/.', ); }); return; @@ -404,8 +404,8 @@ class _SkillDirectoryAuthorizationCardState setState(() { _busy = false; _statusMessage = appText( - '已同步 ${paths.length} 个目录到 settings.yaml;如 macOS 仍无法读取,可再使用目录向导补授权。', - 'Synced ${paths.length} directories to settings.yaml. If macOS still cannot read one, use the picker flow to grant access.', + '已同步 ${paths.length} 个路径到 settings.yaml;如 macOS 仍无法读取,可再使用目录向导补授权。', + 'Synced ${paths.length} paths to settings.yaml. If macOS still cannot read one, use the picker flow to grant access.', ); }); } catch (error) { @@ -645,8 +645,8 @@ class _SkillDirectoryAuthorizationDialogState onChanged: (_) => setState(() {}), decoration: InputDecoration( hintText: appText( - '一行一个目录,或直接粘贴命令输出\n~/.agents/skills\n~/.codex/skills\n/Users/shenlan/.workbuddy/skills', - 'One directory per line, or paste command output\n~/.agents/skills\n~/.codex/skills\n/Users/shenlan/.workbuddy/skills', + '一行一个目录或技能包路径,也可直接粘贴命令输出\n~/.agents/skills\n~/.codex/skills\n/Users/shenlan/workspaces/demo/skills/docx\n/Users/shenlan/workspaces/demo/skills/docx/SKILL.md', + 'One directory or skill package path per line, or paste command output\n~/.agents/skills\n~/.codex/skills\n/Users/shenlan/workspaces/demo/skills/docx\n/Users/shenlan/workspaces/demo/skills/docx/SKILL.md', ), ), ), diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 61235892..24d5071d 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -431,7 +431,15 @@ List replaceExternalAcpEndpointForProvider( } String normalizeAuthorizedSkillDirectoryPath(String path) { - final trimmed = path.trim(); + var trimmed = path.trim(); + if (trimmed.isEmpty) { + return trimmed; + } + trimmed = trimmed.replaceFirst(RegExp(r'[\\/]+$'), ''); + trimmed = trimmed.replaceFirst( + RegExp(r'([\\/])SKILL\.md$', caseSensitive: false), + '', + ); if (trimmed.length <= 1) { return trimmed; } @@ -1837,9 +1845,8 @@ class SettingsSnapshot { ? normalizeAssistantNavigationDestinations( rawAssistantNavigationDestinations .map( - (item) => AssistantFocusEntryCopy.fromJsonValue( - item?.toString(), - ), + (item) => + AssistantFocusEntryCopy.fromJsonValue(item?.toString()), ) .whereType(), ) diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 7db8fc0f..e232be84 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -469,6 +469,55 @@ paths: ); }); + testWidgets( + 'SettingsPage batch add normalizes pasted SKILL.md paths to skill package directories', + (WidgetTester tester) async { + final controller = await _createControllerWithSkillAccessService( + tester, + _FakeSkillDirectoryAccessService(userHomeDirectory: '/Users/tester'), + ); + + await pumpPage( + tester, + child: SettingsPage(controller: controller), + platform: TargetPlatform.macOS, + ); + + await tester.tap(find.text('集成')); + await tester.pumpAndSettle(); + await tester.tap(find.text('SKILLS 目录授权')); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('skill-directory-batch-add-button')), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('skill-directory-path-input')), + '/Users/tester/workspaces/ai-workflow-craft/skills/docx/SKILL.md', + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('skill-directory-direct-add-button')), + ); + await tester.pump(); + for ( + var attempt = 0; + attempt < 10 && controller.authorizedSkillDirectories.isEmpty; + attempt += 1 + ) { + await tester.pump(const Duration(milliseconds: 100)); + } + + expect( + controller.authorizedSkillDirectories.map((item) => item.path), + const [ + '/Users/tester/workspaces/ai-workflow-craft/skills/docx', + ], + ); + expect(find.text('docx'), findsOneWidget); + }, + ); + testWidgets('SettingsPage gateway sections can collapse individually', ( WidgetTester tester, ) async { diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index 9179dea5..e4278740 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -321,6 +321,78 @@ void main() { }, ); + test( + 'AppController accepts authorized single skill package paths and keeps fixed-root scanning intact', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-single-skill-package-path-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final fixedRoot = Directory('${tempDirectory.path}/fixed-root'); + final externalRepoSkill = Directory( + '${tempDirectory.path}/ai-workflow-craft/skills/docx', + ); + await _writeSkill( + fixedRoot, + 'docx', + skillName: 'docx', + description: 'Fixed root version', + ); + await _writeSkill( + externalRepoSkill.parent, + 'docx', + skillName: 'docx', + description: 'Imported package version', + ); + + final store = await _createStore(tempDirectory.path); + await store.saveSettingsSnapshot( + _singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith( + authorizedSkillDirectories: [ + AuthorizedSkillDirectory( + path: '${externalRepoSkill.path}/SKILL.md', + ), + ], + ), + ); + + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + singleAgentSharedSkillScanRootOverrides: [fixedRoot.path], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.singleAgent, + ); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .any((item) => item.label == 'docx'), + ); + + final docxSkill = controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .firstWhere((item) => item.label == 'docx'); + expect(docxSkill.description, 'Imported package version'); + expect(docxSkill.source, 'custom'); + expect( + controller.authorizedSkillDirectories.map((item) => item.path), + ['${tempDirectory.path}/ai-workflow-craft/skills/docx'], + ); + }, + ); + test( 'AppController keeps thread-bound skills isolated and restores them after restart', () async {