Support absolute local skill package imports

This commit is contained in:
Haitao Pan 2026-03-26 17:12:37 +08:00
parent ac335f24c7
commit 508d6cc330
4 changed files with 140 additions and 12 deletions

View File

@ -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',
),
),
),

View File

@ -431,7 +431,15 @@ List<ExternalAcpEndpointProfile> 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<AssistantFocusEntry>(),
)

View File

@ -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 <String>[
'/Users/tester/workspaces/ai-workflow-craft/skills/docx',
],
);
expect(find.text('docx'), findsOneWidget);
},
);
testWidgets('SettingsPage gateway sections can collapse individually', (
WidgetTester tester,
) async {

View File

@ -321,6 +321,78 @@ void main() {
},
);
test(
'AppController accepts authorized single skill package paths and keeps fixed-root scanning intact',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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>[
AuthorizedSkillDirectory(
path: '${externalRepoSkill.path}/SKILL.md',
),
],
),
);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentSharedSkillScanRootOverrides: <String>[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),
<String>['${tempDirectory.path}/ai-workflow-craft/skills/docx'],
);
},
);
test(
'AppController keeps thread-bound skills isolated and restores them after restart',
() async {