11import 'dart:convert' ;
2+ import 'dart:io' ;
23
34import 'package:flutter/services.dart' ;
45import 'package:flutter_riverpod/flutter_riverpod.dart' ;
@@ -15,6 +16,7 @@ import '../memory/project_config.dart';
1516import '../memory/qdrant_service.dart' ;
1617import '../search/search_capability.dart' ;
1718import '../skill/skill_model.dart' ;
19+ import '../skill/skill_registry.dart' ;
1820import '../toolshell/agent_loop.dart' ;
1921import '../toolshell/executor.dart' ;
2022import '../../providers/database_provider.dart' ;
@@ -179,6 +181,15 @@ class AgentContextBuilder {
179181 final capabilities = _collectCapabilities ();
180182 final customHandlers = < String , CustomToolHandler > {};
181183
184+ // 技能安装工具 (toolshell_install_skill): 把工作目录里临时做好的技能
185+ // 提升到全局技能库。仅在工作目录模式注册 (其工具定义也只在 toolshell/tools.json)。
186+ if (_ref.read (workingDirectoryProvider).isNotEmpty) {
187+ final registry = _ref.read (skillRegistryProvider);
188+ customHandlers['toolshell_install_skill' ] = (args) =>
189+ _installSkill (registry, args);
190+ toolSourceMapping['toolshell_install_skill' ] = '元技能:ToolShell' ;
191+ }
192+
182193 for (final cap in capabilities) {
183194 if (! cap.isActive) continue ;
184195 // 注册工具定义
@@ -291,15 +302,109 @@ class AgentContextBuilder {
291302
292303 Map <String , String > _lastSourceMapping = {};
293304
305+ /// 自定义工具 `toolshell_install_skill` 的执行体。
306+ ///
307+ /// 把工作目录里临时做好的技能目录 (须直接含 SKILL.md) 提升为全局技能,
308+ /// 落在 `Skills/` 目录、出现在技能页。安装后刷新 [skillsProvider] 让技能页即时更新。
309+ ///
310+ /// 安装成功后,若源目录位于 `<工作目录>/.toolshell/` 内(即模型用 /skill-cti 流程
311+ /// 搭建的临时 staging 目录),会删除该源目录——技能从"项目临时"毕业为"全局可复用",
312+ /// 不在工作目录留副本。这样避免同一技能被项目技能扫描器重复加载(双载)。
313+ /// 内容已存入全局 Skills/,删除不丢数据;仅删模型显式传入、且确属工作目录 .toolshell 下的目录。
314+ ///
315+ /// 返回 JSON 字符串供模型解析。
316+ Future <String > _installSkill (
317+ SkillRegistry registry,
318+ Map <String , dynamic > args,
319+ ) async {
320+ final sourceDir = (args['source_dir' ] as String ? )? .trim () ?? '' ;
321+ final name = (args['name' ] as String ? )? .trim ();
322+ if (sourceDir.isEmpty) {
323+ return jsonEncode ({
324+ 'status' : 'error' ,
325+ 'message' : '缺少必需参数 source_dir (技能源目录的绝对路径)' ,
326+ });
327+ }
328+ try {
329+ final skill = await registry.installFromDirectory (
330+ sourceDir,
331+ name: (name != null && name.isNotEmpty) ? name : null ,
332+ );
333+ // 刷新技能页数据源,让新技能立即出现在技能管理 UI
334+ _ref.invalidate (skillsProvider);
335+ AppLogger .instance.log (
336+ '[AgentContext] 技能已安装到全局: ${skill .name } (${skill .path })' ,
337+ );
338+
339+ // 清理 staging 源目录:仅当其位于 <工作目录>/.toolshell/ 内时删除,
340+ // 避免技能在工作目录里残留成无法关闭的项目临时副本(导致双载)。
341+ final cleaned = await _cleanupStagingDir (sourceDir);
342+ if (cleaned) {
343+ _ref.invalidate (projectSkillsProvider);
344+ }
345+
346+ return jsonEncode ({
347+ 'status' : 'ok' ,
348+ 'name' : skill.name,
349+ 'path' : skill.path,
350+ 'tool_count' : skill.toolCount,
351+ 'staging_cleaned' : cleaned,
352+ 'message' : '技能「${skill .name }」已安装到全局技能库,可在技能页管理并在任意工作目录复用'
353+ '${cleaned ? "(工作目录的临时副本已清理)" : "" }。' ,
354+ });
355+ } catch (e) {
356+ AppLogger .instance.log ('[AgentContext] 技能安装失败: $e ' );
357+ return jsonEncode ({'status' : 'error' , 'message' : '安装失败: $e ' });
358+ }
359+ }
360+
361+ /// 若 [sourceDir] 位于当前工作目录的 `.toolshell/` 下,则删除它并返回 true;
362+ /// 否则不动并返回 false。用于 /skill-cti 安装后清理临时 staging 目录。
363+ ///
364+ /// 边界保护:解析为绝对/规范化路径后,严格校验 sourceDir 在
365+ /// `<工作目录>/.toolshell/` 之内,绝不删除工作目录本身或其外部路径。
366+ Future <bool > _cleanupStagingDir (String sourceDir) async {
367+ try {
368+ final workDir = _ref.read (workingDirectoryProvider);
369+ if (workDir.isEmpty) return false ;
370+
371+ final toolshellRoot = p.canonicalize (p.join (workDir, '.toolshell' ));
372+ final src = p.canonicalize (sourceDir);
373+
374+ // src 必须严格在 .toolshell/ 之内(且不等于 .toolshell 本身)
375+ final isInside = p.isWithin (toolshellRoot, src);
376+ if (! isInside) return false ;
377+
378+ final dir = Directory (src);
379+ if (await dir.exists ()) {
380+ await dir.delete (recursive: true );
381+ AppLogger .instance.log ('[AgentContext] 已清理 staging 技能目录: $src ' );
382+ return true ;
383+ }
384+ return false ;
385+ } catch (e) {
386+ AppLogger .instance.log ('[AgentContext] 清理 staging 目录失败(忽略): $e ' );
387+ return false ;
388+ }
389+ }
390+
294391 /// 合并全局技能与项目级临时技能,供 Agent 运行时统一消费。
295392 ///
296393 /// 全局技能来自 [skillsProvider] (技能页可管理),项目技能来自
297394 /// [projectSkillsProvider] (仅扫描工作目录 `.toolshell/skills/` ,恒定激活)。
298395 /// 两者数据源隔离:项目技能只在此处合并挂载,不污染任何全局技能管理 UI。
396+ ///
397+ /// 去重安全网:项目技能与全局技能同名时丢弃项目版(全局优先)。避免某技能既装到
398+ /// 全局、又在工作目录 `.toolshell/skills/` 留有同名副本时被重复加载(工具名注册两遍、
399+ /// 提示词注入两段)。这是纯内存去重,不删除任何文件。
299400 List <Skill > _collectAllSkills () {
300401 final global = _ref.read (skillsProvider).valueOrNull ?? const [];
301402 final project = _ref.read (projectSkillsProvider).valueOrNull ?? const [];
302- return [...global, ...project];
403+ final globalNames = global.map ((s) => s.name).toSet ();
404+ final dedupedProject = project
405+ .where ((s) => ! globalNames.contains (s.name))
406+ .toList ();
407+ return [...global, ...dedupedProject];
303408 }
304409
305410 Future <List <Map <String , dynamic >>> _loadTools () async {
0 commit comments