coding-tool-x 3.8.4 → 3.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (434) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +86 -15
  3. package/dist/web/assets/AgentsDrawer-Db9wAyaZ.js +1 -0
  4. package/dist/web/assets/AgentsDrawer-Pw2k-ZAa.css +1 -0
  5. package/dist/web/assets/Analytics-C4gMzv7g.js +1 -0
  6. package/dist/web/assets/Analytics-CyHhR126.css +1 -0
  7. package/dist/web/assets/AssetPathField-Bd_nMFu3.js +1 -0
  8. package/dist/web/assets/ChatHistoryDrawer-B2qBQs9L.css +1 -0
  9. package/dist/web/assets/ChatHistoryDrawer-CwzJHyNP.js +1 -0
  10. package/dist/web/assets/CollapseItem-CTCfSiue.js +1 -0
  11. package/dist/web/assets/CommandsDrawer-BZJxlPvV.css +1 -0
  12. package/dist/web/assets/CommandsDrawer-vDghB1U9.js +1 -0
  13. package/dist/web/assets/ConfigExportDrawer-C-XDvlHc.js +1 -0
  14. package/dist/web/assets/ConfigExportDrawer-dwruRKWr.css +1 -0
  15. package/dist/web/assets/ConfigTemplates-CYY8XRED.js +1 -0
  16. package/dist/web/assets/ConfigTemplatesDrawer-BfDi8yO3.css +1 -0
  17. package/dist/web/assets/ConfigTemplatesDrawer-CbsJp76X.js +1 -0
  18. package/dist/web/assets/ConfigTemplatesPanel-CWyPtk67.css +1 -0
  19. package/dist/web/assets/ConfigTemplatesPanel-liMSTh7s.js +1 -0
  20. package/dist/web/assets/Divider-D28g1gwj.js +1 -0
  21. package/dist/web/assets/DrawerContent-ChXgZ1Xm.js +1 -0
  22. package/dist/web/assets/FavoritesDrawer-CG9eI9Ay.css +1 -0
  23. package/dist/web/assets/FavoritesDrawer-D6QSiYaL.js +1 -0
  24. package/dist/web/assets/GatewayConvertDrawer-CuNlf7AC.css +1 -0
  25. package/dist/web/assets/GatewayConvertDrawer-RvET6iNG.js +1 -0
  26. package/dist/web/assets/Home-CSuFuJ9Y.css +1 -0
  27. package/dist/web/assets/Home-P8eseZcf.js +1 -0
  28. package/dist/web/assets/MarkdownEditor-CYmLGYju.js +1 -0
  29. package/dist/web/assets/MarkdownEditor-qfVgSre_.css +1 -0
  30. package/dist/web/assets/McpDrawer-C0dnsZhQ.css +1 -0
  31. package/dist/web/assets/McpDrawer-Ch3bkKIq.js +1 -0
  32. package/dist/web/assets/McpFormDrawer-BRbDcawn.css +1 -0
  33. package/dist/web/assets/McpFormDrawer-BcTlgSpN.js +1 -0
  34. package/dist/web/assets/PluginManager-Dh2o0xkZ.js +1 -0
  35. package/dist/web/assets/PluginsDrawer-DM3QjprE.css +1 -0
  36. package/dist/web/assets/PluginsDrawer-deKQ5u3I.js +1 -0
  37. package/dist/web/assets/PluginsPanel-BofsgQgj.js +1 -0
  38. package/dist/web/assets/PluginsPanel-D6ZqITcR.css +1 -0
  39. package/dist/web/assets/ProjectList-677RLZ8O.css +1 -0
  40. package/dist/web/assets/ProjectList-DhSWJHTA.js +1 -0
  41. package/dist/web/assets/PromptsDrawer-BIgiYCEQ.css +1 -0
  42. package/dist/web/assets/PromptsDrawer-Cfb2FeeU.js +1 -0
  43. package/dist/web/assets/RecentSessionsDrawer-3eXamJhr.js +1 -0
  44. package/dist/web/assets/RecentSessionsDrawer-CKFsOYDM.css +1 -0
  45. package/dist/web/assets/SessionCard-CW0eHURI.js +1 -0
  46. package/dist/web/assets/SessionCard-aM2NHu6i.css +1 -0
  47. package/dist/web/assets/SessionList-BpYRz1cX.css +1 -0
  48. package/dist/web/assets/SessionList-DQfyDGWU.js +1 -0
  49. package/dist/web/assets/SettingsDrawer-Dw5NUgHC.css +1 -0
  50. package/dist/web/assets/SettingsDrawer-u_vwUuPS.js +1 -0
  51. package/dist/web/assets/SkillManager-C0GCT2Kb.css +1 -0
  52. package/dist/web/assets/SkillManager-DHMprJLA.js +1 -0
  53. package/dist/web/assets/SkillsDrawer-BCNme-aJ.css +1 -0
  54. package/dist/web/assets/SkillsDrawer-CkF3BpyC.js +1 -0
  55. package/dist/web/assets/SkillsPanel-B3Nm5j32.css +1 -0
  56. package/dist/web/assets/SkillsPanel-DWE4pFGx.js +1 -0
  57. package/dist/web/assets/SpeedTestDrawer-BgfG1Ked.js +1 -0
  58. package/dist/web/assets/SpeedTestDrawer-Dc0WToJ_.css +1 -0
  59. package/dist/web/assets/Upload-CNwaGOws.js +1 -0
  60. package/dist/web/assets/WorkspaceDrawer-Br5CeCzL.js +1 -0
  61. package/dist/web/assets/WorkspaceDrawer-CGnMu9p1.css +1 -0
  62. package/dist/web/assets/WorkspaceManager-B2O8-qIw.js +1 -0
  63. package/dist/web/assets/WorkspacePanel-5oDKit3c.js +1 -0
  64. package/dist/web/assets/WorkspacePanel-D0-zHTHP.css +1 -0
  65. package/dist/web/assets/_plugin-vue_export-helper-BeLZJZij.js +1 -0
  66. package/dist/web/assets/client-UDwXBP9i.js +1 -0
  67. package/dist/web/assets/clipboard-B46yyoz_.js +1 -0
  68. package/dist/web/assets/headers-cf-QapUq.js +1 -0
  69. package/dist/web/assets/icons-B2Vg-IOC.js +1 -0
  70. package/dist/web/assets/index-BMAUPq5K.js +2 -0
  71. package/dist/web/assets/index-Bi1MQHYX.css +1 -0
  72. package/dist/web/assets/light-Cp0t7v32.js +1 -0
  73. package/dist/web/assets/markdown-u082bbtj.js +1 -0
  74. package/dist/web/assets/platforms-CBkB45JP.js +1 -0
  75. package/dist/web/assets/platforms-FTpjxAT6.js +1 -0
  76. package/dist/web/assets/project-config-B2baPzYL.js +1 -0
  77. package/dist/web/assets/sessions-Cmy31slD.js +1 -0
  78. package/dist/web/assets/sessions-yC_oVVh8.js +1 -0
  79. package/dist/web/assets/skill-repo-input-B_fkGlUK.js +1 -0
  80. package/dist/web/assets/use-lock-html-scroll-DXIqmUW4.js +1 -0
  81. package/dist/web/assets/use-message-DoLM8C1C.js +1 -0
  82. package/dist/web/assets/useResponsiveDrawer-jbmqZuJs.js +1 -0
  83. package/dist/web/assets/vendors-D0c_0KLP.js +1 -0
  84. package/dist/web/assets/vue-vendor-BqZbbXHA.js +1 -0
  85. package/dist/web/assets/workspaces-mjyPVGS8.js +1 -0
  86. package/dist/web/assets/xml-CgY3RMlX.js +1 -0
  87. package/dist/web/index.html +12 -8
  88. package/docs/native-log-memory-2026-09-16.md +43 -0
  89. package/docs/superpowers/plans/2026-08-26-configuration-driven-cli-platform.md +1578 -0
  90. package/docs/superpowers/plans/2026-08-30-project-scoped-config-control.md +1260 -0
  91. package/docs/superpowers/plans/2026-09-01-omp-skill-refresh-boundary.md +213 -0
  92. package/docs/superpowers/plans/2026-09-01-realtime-logs-panel.md +125 -0
  93. package/docs/superpowers/plans/2026-09-02-config-driven-platform-boundary.md +86 -0
  94. package/docs/superpowers/plans/2026-09-02-platform-implementation-drivers.md +508 -0
  95. package/docs/superpowers/plans/2026-09-03-cli-platform-correctness-boundary.md +760 -0
  96. package/docs/superpowers/plans/2026-09-03-dependency-security-remediation.md +133 -0
  97. package/docs/superpowers/plans/2026-09-04-model-catalog-pricing-sync.md +1078 -0
  98. package/docs/superpowers/plans/2026-09-05-codex-project-scan-500.md +128 -0
  99. package/docs/superpowers/plans/2026-09-06-project-skill-path-fix.md +97 -0
  100. package/docs/superpowers/specs/2026-08-26-configuration-driven-cli-platform-design.md +408 -0
  101. package/docs/superpowers/specs/2026-08-30-project-scoped-config-control-design.md +467 -0
  102. package/docs/superpowers/specs/2026-09-01-claude-session-content-repair-design.md +111 -0
  103. package/docs/superpowers/specs/2026-09-01-omp-skill-refresh-boundary-design.md +83 -0
  104. package/docs/superpowers/specs/2026-09-01-realtime-logs-panel-design.md +27 -0
  105. package/docs/superpowers/specs/2026-09-02-config-driven-platform-boundary-design.md +64 -0
  106. package/docs/superpowers/specs/2026-09-02-platform-implementation-driver-boundary-design.md +123 -0
  107. package/docs/superpowers/specs/2026-09-03-cli-platform-correctness-boundary-design.md +249 -0
  108. package/docs/superpowers/specs/2026-09-03-dependency-security-remediation-design.md +87 -0
  109. package/docs/superpowers/specs/2026-09-04-model-catalog-pricing-sync-design.md +301 -0
  110. package/docs/superpowers/specs/2026-09-05-codex-project-scan-500-design.md +24 -0
  111. package/package.json +8 -4
  112. package/src/commands/channels.js +88 -191
  113. package/src/commands/cli-type.js +19 -11
  114. package/src/commands/daemon.js +104 -32
  115. package/src/commands/doctor.js +16 -9
  116. package/src/commands/export-config.js +23 -5
  117. package/src/commands/list.js +130 -52
  118. package/src/commands/logs.js +44 -27
  119. package/src/commands/platform-command-registry.js +78 -0
  120. package/src/commands/plugin.js +11 -5
  121. package/src/commands/port-config.js +34 -52
  122. package/src/commands/proxy-control.js +207 -145
  123. package/src/commands/proxy.js +2 -2
  124. package/src/commands/resume.js +87 -91
  125. package/src/commands/search.js +137 -67
  126. package/src/commands/stats.js +30 -22
  127. package/src/commands/switch.js +1 -1
  128. package/src/commands/toggle-proxy.js +94 -111
  129. package/src/commands/ui.js +67 -6
  130. package/src/commands/workspace.js +90 -8
  131. package/src/config/default.js +6 -53
  132. package/src/config/loader.js +61 -24
  133. package/src/config/model-metadata.js +49 -59
  134. package/src/config/model-metadata.json +4095 -99
  135. package/src/config/paths.js +181 -8
  136. package/src/index.js +134 -71
  137. package/src/platforms/access.js +196 -0
  138. package/src/{server/services → platforms}/agents-service.js +354 -197
  139. package/src/platforms/channel-auth-registry.js +165 -0
  140. package/src/platforms/channel-auth-service.js +107 -0
  141. package/src/{server/services → platforms}/commands-service.js +195 -115
  142. package/src/platforms/driver-registry.js +59 -0
  143. package/src/platforms/drivers/claude/api-operations.js +62 -0
  144. package/src/{server/services/channels.js → platforms/drivers/claude/channels-implementation.js} +131 -33
  145. package/src/platforms/drivers/claude/channels.js +39 -0
  146. package/src/{server/services/config-sync-service.js → platforms/drivers/claude/config-sync.js} +29 -5
  147. package/src/{server/services → platforms/drivers/claude}/health-check.js +4 -4
  148. package/src/platforms/drivers/claude/health.js +22 -0
  149. package/src/platforms/drivers/claude/mcp.js +9 -0
  150. package/src/{server/services/settings-manager.js → platforms/drivers/claude/native-config-implementation.js} +13 -4
  151. package/src/platforms/drivers/claude/native-config.js +46 -0
  152. package/src/platforms/drivers/claude/native-logs.js +72 -0
  153. package/src/{server/services/claude-openai-gateway.js → platforms/drivers/claude/openai-gateway.js} +8 -65
  154. package/src/platforms/drivers/claude/project-config.js +21 -0
  155. package/src/platforms/drivers/claude/projects.js +62 -0
  156. package/src/platforms/drivers/claude/prompts.js +9 -0
  157. package/src/{server/proxy-server.js → platforms/drivers/claude/proxy-implementation.js} +41 -278
  158. package/src/platforms/drivers/claude/proxy.js +16 -0
  159. package/src/platforms/drivers/claude/resource-sync.js +9 -0
  160. package/src/platforms/drivers/claude/session-history-adapter.js +313 -0
  161. package/src/{server/services/sessions.js → platforms/drivers/claude/sessions-implementation.js} +145 -35
  162. package/src/platforms/drivers/claude/sessions.js +76 -0
  163. package/src/{server/services/claude-statistics-service.js → platforms/drivers/claude/statistics-implementation.js} +2 -2
  164. package/src/platforms/drivers/claude/statistics.js +16 -0
  165. package/src/{server/services/claude-wire.js → platforms/drivers/claude/wire.js} +1 -1
  166. package/src/platforms/drivers/codex/api-operations.js +9 -0
  167. package/src/{server/services/codex-channels.js → platforms/drivers/codex/channels-implementation.js} +149 -14
  168. package/src/platforms/drivers/codex/channels.js +45 -0
  169. package/src/{server/services/codex-config.js → platforms/drivers/codex/config.js} +12 -2
  170. package/src/{server/services/codex-env-manager.js → platforms/drivers/codex/env-manager.js} +1 -1
  171. package/src/platforms/drivers/codex/mcp.js +9 -0
  172. package/src/{server/services/codex-settings-manager.js → platforms/drivers/codex/native-config-implementation.js} +15 -6
  173. package/src/platforms/drivers/codex/native-config.js +47 -0
  174. package/src/platforms/drivers/codex/native-logs.js +166 -0
  175. package/src/platforms/drivers/codex/project-config.js +21 -0
  176. package/src/platforms/drivers/codex/projects.js +26 -0
  177. package/src/platforms/drivers/codex/prompts.js +9 -0
  178. package/src/{server/codex-proxy-server.js → platforms/drivers/codex/proxy-implementation.js} +30 -233
  179. package/src/platforms/drivers/codex/proxy.js +16 -0
  180. package/src/platforms/drivers/codex/resource-sync.js +9 -0
  181. package/src/{server/services/session-history-adapters/codex.js → platforms/drivers/codex/session-history-adapter.js} +37 -23
  182. package/src/{server/services/codex-sessions.js → platforms/drivers/codex/sessions-implementation.js} +81 -75
  183. package/src/platforms/drivers/codex/sessions.js +36 -0
  184. package/src/{server/services/codex-statistics-service.js → platforms/drivers/codex/statistics-implementation.js} +2 -2
  185. package/src/platforms/drivers/codex/statistics.js +16 -0
  186. package/src/platforms/drivers/gemini/api-operations.js +9 -0
  187. package/src/{server/services/gemini-channels.js → platforms/drivers/gemini/channels-implementation.js} +202 -37
  188. package/src/platforms/drivers/gemini/channels.js +39 -0
  189. package/src/{server/services/gemini-config.js → platforms/drivers/gemini/config.js} +12 -2
  190. package/src/platforms/drivers/gemini/mcp.js +9 -0
  191. package/src/{server/services/gemini-settings-manager.js → platforms/drivers/gemini/native-config-implementation.js} +14 -5
  192. package/src/platforms/drivers/gemini/native-config.js +39 -0
  193. package/src/platforms/drivers/gemini/native-logs.js +180 -0
  194. package/src/platforms/drivers/gemini/project-config.js +21 -0
  195. package/src/platforms/drivers/gemini/projects.js +26 -0
  196. package/src/platforms/drivers/gemini/prompts.js +9 -0
  197. package/src/{server/gemini-proxy-server.js → platforms/drivers/gemini/proxy-implementation.js} +36 -267
  198. package/src/platforms/drivers/gemini/proxy.js +16 -0
  199. package/src/platforms/drivers/gemini/resource-sync.js +9 -0
  200. package/src/{server/services/session-history-adapters/gemini.js → platforms/drivers/gemini/session-history-adapter.js} +70 -18
  201. package/src/{server/services/gemini-sessions.js → platforms/drivers/gemini/sessions-implementation.js} +53 -37
  202. package/src/platforms/drivers/gemini/sessions.js +47 -0
  203. package/src/{server/services/gemini-statistics-service.js → platforms/drivers/gemini/statistics-implementation.js} +2 -2
  204. package/src/platforms/drivers/gemini/statistics.js +16 -0
  205. package/src/{server/services/gemini-wire.js → platforms/drivers/gemini/wire.js} +1 -1
  206. package/src/platforms/drivers/generic-filesystem.js +166 -0
  207. package/src/platforms/drivers/generic-jsonl.js +141 -0
  208. package/src/platforms/drivers/generic-mcp.js +44 -0
  209. package/src/platforms/drivers/generic-openai-compatible.js +45 -0
  210. package/src/platforms/drivers/generic-prompt.js +41 -0
  211. package/src/platforms/drivers/legacy.js +677 -0
  212. package/src/platforms/drivers/native-config-snapshot.js +125 -0
  213. package/src/platforms/drivers/native-log-utils.js +1292 -0
  214. package/src/platforms/drivers/notification-hooks.js +578 -0
  215. package/src/{server/api/omp-config.js → platforms/drivers/omp/api-operations.js} +52 -64
  216. package/src/{server/services/omp-auth-provider-worker.js → platforms/drivers/omp/auth-provider-worker.js} +1 -1
  217. package/src/{server/services/omp-auth-providers.js → platforms/drivers/omp/auth-providers.js} +1 -1
  218. package/src/{server/services/omp-channels.js → platforms/drivers/omp/channels-implementation.js} +166 -39
  219. package/src/platforms/drivers/omp/channels.js +58 -0
  220. package/src/{server/services/omp-config.js → platforms/drivers/omp/config.js} +53 -13
  221. package/src/{server/services/omp-gateway-routing.js → platforms/drivers/omp/gateway-routing.js} +14 -7
  222. package/src/{server/services/omp-gateway.js → platforms/drivers/omp/gateway.js} +4 -262
  223. package/src/platforms/drivers/omp/mcp.js +9 -0
  224. package/src/{server/services/omp-settings-manager.js → platforms/drivers/omp/native-config-implementation.js} +75 -10
  225. package/src/platforms/drivers/omp/native-config.js +46 -0
  226. package/src/platforms/drivers/omp/native-logs.js +52 -0
  227. package/src/{server/services/omp-native-plugin-adapter.js → platforms/drivers/omp/native-plugin-adapter.js} +91 -4
  228. package/src/platforms/drivers/omp/project-config.js +29 -0
  229. package/src/platforms/drivers/omp/projects.js +20 -0
  230. package/src/platforms/drivers/omp/prompts.js +9 -0
  231. package/src/{server/omp-proxy-server.js → platforms/drivers/omp/proxy-implementation.js} +6 -15
  232. package/src/platforms/drivers/omp/proxy.js +16 -0
  233. package/src/platforms/drivers/omp/resource-sync.js +9 -0
  234. package/src/{server/services/session-history-adapters/omp.js → platforms/drivers/omp/session-history-adapter.js} +42 -18
  235. package/src/{server/services/omp-sessions.js → platforms/drivers/omp/sessions-implementation.js} +130 -94
  236. package/src/platforms/drivers/omp/sessions.js +36 -0
  237. package/src/{server/services/omp-skill-discovery.js → platforms/drivers/omp/skill-discovery.js} +159 -32
  238. package/src/{server/services/omp-skill-settings-service.js → platforms/drivers/omp/skill-settings.js} +1 -1
  239. package/src/{server/services/omp-statistics-service.js → platforms/drivers/omp/statistics-implementation.js} +3 -3
  240. package/src/platforms/drivers/omp/statistics.js +16 -0
  241. package/src/platforms/drivers/opencode/api-operations.js +9 -0
  242. package/src/{server/services/opencode-channels.js → platforms/drivers/opencode/channels-implementation.js} +28 -9
  243. package/src/platforms/drivers/opencode/channels.js +40 -0
  244. package/src/platforms/drivers/opencode/conversion.js +41 -0
  245. package/src/{server/services/opencode-gateway-converter.js → platforms/drivers/opencode/gateway-converter.js} +1 -1
  246. package/src/platforms/drivers/opencode/mcp.js +9 -0
  247. package/src/{server/services/opencode-settings-manager.js → platforms/drivers/opencode/native-config-implementation.js} +17 -6
  248. package/src/platforms/drivers/opencode/native-config.js +26 -0
  249. package/src/platforms/drivers/opencode/native-logs.js +133 -0
  250. package/src/platforms/drivers/opencode/project-config.js +98 -0
  251. package/src/platforms/drivers/opencode/projects.js +20 -0
  252. package/src/platforms/drivers/opencode/prompts.js +9 -0
  253. package/src/{server/opencode-proxy-server.js → platforms/drivers/opencode/proxy-implementation.js} +27 -362
  254. package/src/platforms/drivers/opencode/proxy.js +16 -0
  255. package/src/platforms/drivers/opencode/resource-sync.js +9 -0
  256. package/src/{server/services/opencode-sessions.js → platforms/drivers/opencode/sessions-implementation.js} +211 -64
  257. package/src/platforms/drivers/opencode/sessions.js +48 -0
  258. package/src/{server/services/opencode-statistics-service.js → platforms/drivers/opencode/statistics-implementation.js} +2 -2
  259. package/src/platforms/drivers/opencode/statistics.js +16 -0
  260. package/src/platforms/drivers/secure-file-driver.js +272 -0
  261. package/src/platforms/drivers/unsupported.js +7 -0
  262. package/src/platforms/manifest-schema.js +398 -0
  263. package/src/platforms/manifests/claude.json +489 -0
  264. package/src/platforms/manifests/codex.json +448 -0
  265. package/src/platforms/manifests/gemini.json +447 -0
  266. package/src/platforms/manifests/omp.json +496 -0
  267. package/src/platforms/manifests/opencode.json +422 -0
  268. package/src/{server/services → platforms}/native-oauth-adapters.js +98 -418
  269. package/src/{server/services → platforms}/notification-hooks.js +190 -1023
  270. package/src/platforms/oauth-credentials-service.js +635 -0
  271. package/src/platforms/path-resolver.js +179 -0
  272. package/src/platforms/platform-path-context.js +305 -0
  273. package/src/platforms/registry.js +248 -0
  274. package/src/platforms/remote-notification-providers.js +678 -0
  275. package/src/platforms/runtime.js +259 -0
  276. package/src/{server/services/session-history-adapters/index.js → platforms/session-history-adapters.js} +8 -1
  277. package/src/plugins/plugin-loader.js +2 -2
  278. package/src/plugins/plugin-manager.js +2 -2
  279. package/src/plugins/registry.js +27 -10
  280. package/src/reset-config.js +21 -5
  281. package/src/server/api/agents.js +46 -72
  282. package/src/server/api/commands.js +16 -6
  283. package/src/server/api/config-registry.js +206 -59
  284. package/src/server/api/config-sync.js +3 -2
  285. package/src/server/api/config-templates.js +20 -4
  286. package/src/server/api/config.js +111 -102
  287. package/src/server/api/convert.js +54 -178
  288. package/src/server/api/dashboard.js +168 -102
  289. package/src/server/api/hooks.js +1 -1
  290. package/src/server/api/mcp.js +224 -84
  291. package/src/server/api/platform-api-config.js +102 -0
  292. package/src/server/api/platform-route-factory.js +215 -0
  293. package/src/server/api/platform-routes.js +26 -0
  294. package/src/server/api/platforms.js +110 -0
  295. package/src/server/api/plugins.js +57 -57
  296. package/src/server/api/project-config.js +168 -0
  297. package/src/server/api/prompts.js +58 -45
  298. package/src/server/api/settings.js +55 -8
  299. package/src/server/api/skills.js +262 -133
  300. package/src/server/api/ui-config.js +2 -2
  301. package/src/server/api/validation-errors.js +18 -4
  302. package/src/server/api/workspaces.js +47 -12
  303. package/src/server/index.js +164 -213
  304. package/src/server/platform-context.js +81 -0
  305. package/src/server/services/channel-balance.js +133 -24
  306. package/src/server/services/channel-health.js +21 -6
  307. package/src/server/services/channel-scheduler.js +29 -39
  308. package/src/server/services/channel-sync-utils.js +23 -13
  309. package/src/server/services/config-export-service.js +350 -319
  310. package/src/server/services/config-registry-service.js +280 -54
  311. package/src/server/services/config-sync-manager.js +172 -944
  312. package/src/server/services/config-templates-service.js +419 -353
  313. package/src/server/services/control-manifest-store.js +206 -0
  314. package/src/server/services/dashboard-snapshot-worker.js +656 -100
  315. package/src/server/services/effective-control-service.js +557 -0
  316. package/src/server/services/local-resource-index.js +314 -0
  317. package/src/server/services/mcp-service.js +607 -977
  318. package/src/server/services/model-detector.js +15 -10
  319. package/src/server/services/native-log-channel-resolver.js +214 -0
  320. package/src/server/services/native-log-observer.js +291 -0
  321. package/src/server/services/network-access.js +12 -2
  322. package/src/server/services/platform-catalog.js +82 -0
  323. package/src/server/services/platform-resolution.js +32 -29
  324. package/src/server/services/plugins-service.js +376 -122
  325. package/src/server/services/project-config-service.js +550 -0
  326. package/src/server/services/project-path-validation.js +31 -0
  327. package/src/server/services/prompts-service.js +267 -233
  328. package/src/server/services/proxy-runtime.js +7 -4
  329. package/src/server/services/remote-credential-cache.js +43 -0
  330. package/src/server/services/repo-scanner-base.js +182 -75
  331. package/src/server/services/request-logger.js +7 -4
  332. package/src/server/services/session-history-index.js +876 -214
  333. package/src/server/services/session-history-worker.js +228 -33
  334. package/src/server/services/session-snapshot-worker.js +124 -34
  335. package/src/server/services/session-snapshots.js +4 -2
  336. package/src/server/services/skill-artifact-store.js +355 -0
  337. package/src/server/services/skill-format-adapters.js +65 -0
  338. package/src/server/services/skill-projection-service.js +323 -0
  339. package/src/server/services/skill-refresh-task-service.js +275 -0
  340. package/src/server/services/skill-service.js +2020 -677
  341. package/src/server/services/snapshot-cache.js +30 -8
  342. package/src/server/services/speed-test.js +8 -8
  343. package/src/server/services/sqlite-connection.js +19 -3
  344. package/src/server/services/statistics-service.js +240 -57
  345. package/src/server/services/ui-config.js +107 -76
  346. package/src/server/services/usage-log-utils.js +98 -0
  347. package/src/server/services/web-build.js +1 -1
  348. package/src/server/services/workspace-service.js +59 -29
  349. package/src/server/utils/pricing.js +10 -19
  350. package/src/server/websocket-server.js +153 -28
  351. package/src/{server/services/base → shared}/base-channel-service.js +156 -28
  352. package/src/shared/capability-driver.js +104 -0
  353. package/src/shared/channel-auth-policy.js +27 -0
  354. package/src/shared/channel-driver.js +161 -0
  355. package/src/shared/driver-factories/api.js +180 -0
  356. package/src/shared/driver-factories/mcp.js +244 -0
  357. package/src/shared/driver-factories/projects.js +121 -0
  358. package/src/shared/driver-factories/prompts.js +58 -0
  359. package/src/shared/driver-factories/resource-sync.js +375 -0
  360. package/src/shared/driver-result.js +78 -0
  361. package/src/shared/mcp-format.js +189 -0
  362. package/src/shared/platforms.js +104 -167
  363. package/src/shared/project-config.js +512 -0
  364. package/src/shared/proxy-driver.js +57 -0
  365. package/src/{server/services/base → shared}/response-usage-parser.js +8 -4
  366. package/src/ui/menu.js +91 -67
  367. package/src/utils/port-helper.js +35 -19
  368. package/src/utils/session.js +1 -1
  369. package/dist/web/assets/Analytics-COEUXmd4.js +0 -25
  370. package/dist/web/assets/Analytics-RNn1BUbG.css +0 -1
  371. package/dist/web/assets/ConfigTemplates-DLLkS53H.js +0 -1
  372. package/dist/web/assets/Home-DCEyLzwX.js +0 -1
  373. package/dist/web/assets/Home-DYVy4cGZ.css +0 -1
  374. package/dist/web/assets/PluginManager-Oe1ZHoMO.js +0 -1
  375. package/dist/web/assets/ProjectList-CWjuAFof.js +0 -1
  376. package/dist/web/assets/ProjectList-VeMNXfgO.css +0 -1
  377. package/dist/web/assets/SessionList-C4chCPxK.css +0 -1
  378. package/dist/web/assets/SessionList-v3ehGg9U.js +0 -1
  379. package/dist/web/assets/SkillManager-BsX8Qpj0.css +0 -1
  380. package/dist/web/assets/SkillManager-DUF_qP5S.js +0 -1
  381. package/dist/web/assets/WorkspaceManager-CjAWBosO.js +0 -1
  382. package/dist/web/assets/icons-nGdSKQn2.js +0 -1
  383. package/dist/web/assets/index-D5hyHPj7.js +0 -2
  384. package/dist/web/assets/index-bJRG3RXH.css +0 -1
  385. package/dist/web/assets/markdown-BNJfEgCA.js +0 -1
  386. package/dist/web/assets/markdown-BfC0goYb.css +0 -10
  387. package/dist/web/assets/naive-ui-BALVpMgd.js +0 -1
  388. package/dist/web/assets/vendors-DQA4Hw9v.js +0 -7
  389. package/dist/web/assets/vue-vendor-B5RqjEjQ.js +0 -45
  390. package/src/config/model-pricing.js +0 -35
  391. package/src/server/api/channels.js +0 -439
  392. package/src/server/api/claude-hooks.js +0 -56
  393. package/src/server/api/claude-statistics.js +0 -57
  394. package/src/server/api/codex-channels.js +0 -491
  395. package/src/server/api/codex-projects.js +0 -117
  396. package/src/server/api/codex-proxy.js +0 -293
  397. package/src/server/api/codex-sessions.js +0 -652
  398. package/src/server/api/codex-statistics.js +0 -57
  399. package/src/server/api/gemini-channels.js +0 -392
  400. package/src/server/api/gemini-projects.js +0 -103
  401. package/src/server/api/gemini-proxy.js +0 -231
  402. package/src/server/api/gemini-sessions.js +0 -550
  403. package/src/server/api/gemini-statistics.js +0 -57
  404. package/src/server/api/health-check.js +0 -31
  405. package/src/server/api/oauth-credentials.js +0 -198
  406. package/src/server/api/omp-channels.js +0 -575
  407. package/src/server/api/omp-projects.js +0 -88
  408. package/src/server/api/omp-proxy.js +0 -125
  409. package/src/server/api/omp-sessions.js +0 -386
  410. package/src/server/api/omp-statistics.js +0 -40
  411. package/src/server/api/opencode-channels.js +0 -471
  412. package/src/server/api/opencode-projects.js +0 -110
  413. package/src/server/api/opencode-proxy.js +0 -354
  414. package/src/server/api/opencode-sessions.js +0 -466
  415. package/src/server/api/opencode-statistics.js +0 -57
  416. package/src/server/api/projects.js +0 -120
  417. package/src/server/api/proxy.js +0 -330
  418. package/src/server/api/sessions.js +0 -810
  419. package/src/server/services/oauth-credentials-service.js +0 -945
  420. package/src/server/services/omp-session-log-observer.js +0 -138
  421. package/src/server/services/opencode-gateway-adapters.js +0 -43
  422. package/src/server/services/project-snapshots.js +0 -59
  423. package/src/server/services/proxy-log-helper.js +0 -298
  424. package/src/server/services/session-cache.js +0 -127
  425. package/src/server/services/session-converter.js +0 -582
  426. package/src/server/services/session-history-adapters/claude.js +0 -235
  427. /package/src/{server/services/claude-token-recovery.js → platforms/drivers/claude/token-recovery.js} +0 -0
  428. /package/src/{server/services/codex-parser.js → platforms/drivers/codex/parser.js} +0 -0
  429. /package/src/{server/services/codex-speed-test-template.json → platforms/drivers/codex/speed-test-template.json} +0 -0
  430. /package/src/{server/services/codex-wire.js → platforms/drivers/codex/wire.js} +0 -0
  431. /package/src/{server/services/omp-auth-gateway-client.js → platforms/drivers/omp/auth-gateway-client.js} +0 -0
  432. /package/src/{server/services/opencode-normalization.js → platforms/drivers/opencode/normalization.js} +0 -0
  433. /package/src/{server/services → shared}/config-artifact-paths.js +0 -0
  434. /package/src/{server/services/base → shared}/proxy-utils.js +0 -0
@@ -7,31 +7,57 @@
7
7
 
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
- const os = require('os');
11
10
  const https = require('https');
12
11
  const http = require('http');
13
12
  const { execFileSync } = require('child_process');
14
- const { createWriteStream } = require('fs');
15
- const { pipeline } = require('stream/promises');
16
- const AdmZip = require('adm-zip');
13
+ const { randomUUID } = require('crypto');
17
14
  const {
18
15
  parseSkillContent,
19
16
  } = require('./format-converter');
20
17
  const { maskToken } = require('./oauth-utils');
21
- const { NATIVE_PATHS, HOME_DIR, PATHS } = require('../../config/paths');
22
- const { getOmpPaths } = require('./omp-config');
23
- const { discoverOmpSkills } = require('./omp-skill-discovery');
18
+ const pathsModule = require('../../config/paths');
19
+ const { NATIVE_PATHS, HOME_DIR, PATHS } = pathsModule;
20
+ const getPlatformStatePaths = typeof pathsModule.getPlatformStatePaths === 'function'
21
+ ? pathsModule.getPlatformStatePaths
22
+ : platform => ({
23
+ localSkills: PATHS.localSkills?.[platform],
24
+ skillRepos: PATHS.skillRepos?.[platform],
25
+ skillCaches: PATHS.skillCaches?.[platform]
26
+ });
27
+ const { getOmpPaths } = require('../../platforms/drivers/omp/config');
28
+ const { discoverOmpSkills } = require('../../platforms/drivers/omp/skill-discovery');
24
29
  const { migratePiStorage } = require('./pi-omp-migration');
25
30
  const { resolveManagedPlatform } = require('./platform-resolution');
26
31
  const {
27
32
  normalizeSafeRelativePath,
28
33
  pathHasProtectedSegment,
29
34
  resolveInsideRoot
30
- } = require('./config-artifact-paths');
35
+ } = require('../../shared/config-artifact-paths');
36
+ const remoteCredentialCache = require('./remote-credential-cache');
37
+ const { getPlatformContext } = require('../platform-context');
38
+ const {
39
+ assertExistingProjectRoot,
40
+ assertNoSymlinkComponents,
41
+ resolveProjectTarget
42
+ } = require('../../shared/project-config');
43
+ const { ControlManifestStore } = require('./control-manifest-store');
44
+ const { EffectiveControlService } = require('./effective-control-service');
45
+ const { SkillArtifactStore } = require('./skill-artifact-store');
46
+ const { SkillFormatAdapter } = require('./skill-format-adapters');
47
+ const { SkillProjectionService } = require('./skill-projection-service');
31
48
 
32
49
  const SUPPORTED_REPO_PROVIDERS = ['github', 'gitlab', 'local'];
33
50
  const DEFAULT_GITHUB_HOST = 'https://github.com';
34
51
  const DEFAULT_GITLAB_HOST = 'https://gitlab.com';
52
+ const CACHE_TTL = 5 * 60 * 1000;
53
+
54
+ function sanitizeRefreshError(value) {
55
+ return String(value || '')
56
+ .replace(/(https?:\/\/[^\s?]+)\?[^\s]+/gi, '$1?[REDACTED]')
57
+ .replace(/(Bearer\s+)[^\s]+/gi, '$1[REDACTED]')
58
+ .replace(/([?&](?:access[_-]?token|api[-_]?key|token|password|secret|key)=)[^&\s]+/gi, '$1[REDACTED]')
59
+ .replace(/((?:token|secret|password|api[-_]?key|authorization)\s*[:=]\s*)[^,\s}]+/gi, '$1[REDACTED]');
60
+ }
35
61
 
36
62
  function cloneRepos(repos = []) {
37
63
  return repos.map(repo => ({ ...repo }));
@@ -98,6 +124,25 @@ function resolveLocalRepoPath(input = '') {
98
124
  return path.resolve(expanded);
99
125
  }
100
126
 
127
+ function resolveExistingLocalRepoRoot(input) {
128
+ const value = String(input || '').trim();
129
+ if (!value) throw new Error('Missing local repository path');
130
+ const resolved = path.resolve(value);
131
+ let stat;
132
+ try {
133
+ stat = fs.lstatSync(resolved);
134
+ } catch (error) {
135
+ throw new Error(`Local repo path not found: ${resolved}`, { cause: error });
136
+ }
137
+ if (stat.isSymbolicLink()) {
138
+ throw new Error(`Local repo root contains symlink: ${resolved}`);
139
+ }
140
+ if (!stat.isDirectory()) {
141
+ throw new Error(`Local repo path is not a directory: ${resolved}`);
142
+ }
143
+ return fs.realpathSync(resolved);
144
+ }
145
+
101
146
  function normalizeRepoHost(host, provider = 'github') {
102
147
  const fallback = provider === 'gitlab' ? DEFAULT_GITLAB_HOST : DEFAULT_GITHUB_HOST;
103
148
  let normalized = String(host || '').trim();
@@ -145,6 +190,21 @@ function buildRepoLabel(repo) {
145
190
  return [repo.owner, repo.name].filter(Boolean).join('/');
146
191
  }
147
192
 
193
+ function sanitizeRepoUrl(value, fallback = '') {
194
+ const raw = String(value || '').trim();
195
+ if (!raw) return fallback;
196
+ try {
197
+ const parsed = new URL(raw);
198
+ parsed.username = '';
199
+ parsed.password = '';
200
+ parsed.search = '';
201
+ parsed.hash = '';
202
+ return parsed.toString().replace(/\/$/, '');
203
+ } catch {
204
+ return fallback;
205
+ }
206
+ }
207
+
148
208
  function buildRepoId(repo) {
149
209
  const directory = normalizeRepoDirectory(repo.directory);
150
210
  const branch = String(repo.branch || 'main').trim() || 'main';
@@ -214,11 +274,17 @@ const PLATFORM_CONFIG = {
214
274
  }
215
275
  };
216
276
 
217
- // 缓存有效期(5分钟)
218
- const CACHE_TTL = 5 * 60 * 1000;
219
-
220
277
  class SkillService {
221
- constructor(platform = 'claude') {
278
+ constructor(
279
+ platform = 'claude',
280
+ {
281
+ registry = getPlatformContext().registry,
282
+ controlService = null,
283
+ artifactStore = null,
284
+ formatAdapter = null
285
+ } = {}
286
+ ) {
287
+ this.registry = registry;
222
288
  this.platform = resolveManagedPlatform(platform).platform;
223
289
  if (this.platform === 'omp') {
224
290
  const migration = migratePiStorage(PATHS);
@@ -228,17 +294,55 @@ class SkillService {
228
294
  }
229
295
  this.configDir = PATHS.config;
230
296
 
231
- const platformConfig = PLATFORM_CONFIG[this.platform];
232
- this.installDir = this.platform === 'omp'
297
+ this.pathContext = null;
298
+ try {
299
+ if (typeof this.registry?.resolvePathContext === 'function') {
300
+ this.pathContext = this.registry.resolvePathContext(this.platform);
301
+ }
302
+ } catch {
303
+ this.pathContext = null;
304
+ }
305
+ const pathContext = this.pathContext?.customized ? this.pathContext : null;
306
+ const native = pathContext?.native || NATIVE_PATHS[this.platform] || {};
307
+ const state = pathContext?.state || getPlatformStatePaths(this.platform);
308
+ const platformConfig = PLATFORM_CONFIG[this.platform] || {};
309
+ this.installDir = this.platform === 'omp' && !this.pathContext?.customized
233
310
  ? getOmpPaths().skills
234
- : platformConfig.installDir;
235
- this.storageDir = platformConfig.storageDir;
236
- this.reposConfigPath = platformConfig.reposFile;
237
- this.cachePath = platformConfig.cacheFile;
311
+ : native.skills
312
+ || (native.dir ? path.join(native.dir, 'skills') : null)
313
+ || platformConfig.installDir
314
+ || path.join(this.configDir, 'native', this.platform, 'skills');
315
+ this.storageDir = state.localSkills || platformConfig.storageDir;
316
+ this.reposConfigPath = state.skillRepos || platformConfig.reposFile;
317
+ this.cachePath = state.skillCaches || platformConfig.cacheFile;
318
+ this.artifactStore = artifactStore || (
319
+ PATHS.skillArtifacts
320
+ ? new SkillArtifactStore({ root: PATHS.skillArtifacts })
321
+ : null
322
+ );
323
+ this.formatAdapter = formatAdapter || new SkillFormatAdapter();
324
+ this.controlService = controlService || (
325
+ PATHS.effectiveControlManifest
326
+ ? new EffectiveControlService({
327
+ store: new ControlManifestStore({
328
+ userPath: PATHS.effectiveControlManifest,
329
+ projectPathResolver: ({ projectPath }) => path.join(projectPath, '.ctx-control.json')
330
+ }),
331
+ projection: new SkillProjectionService({ registry: this.registry })
332
+ })
333
+ : null
334
+ );
238
335
 
239
- // 内存缓存
336
+ // Prepared projections are keyed by scope and canonical cwd; raw repository skills are cwd-independent.
240
337
  this.skillsCache = null;
241
338
  this.cacheTime = 0;
339
+ this._preparedSkillsCache = new Map();
340
+ this._cacheGeneration = 0;
341
+ this._remoteSkillsCache = null;
342
+ this._remoteSkillsFetchedAt = 0;
343
+ this._legacyMigrationChecked = false;
344
+
345
+ this.githubTokenCache = new Map();
242
346
 
243
347
  // 确保目录存在
244
348
  this.ensureDirs();
@@ -246,7 +350,20 @@ class SkillService {
246
350
 
247
351
  refreshOmpPaths() {
248
352
  if (this.platform !== 'omp') return;
249
- const nextInstallDir = getOmpPaths().skills;
353
+ let nextContext = null;
354
+ try {
355
+ if (typeof this.registry?.resolvePathContext === 'function') {
356
+ nextContext = this.registry.resolvePathContext(this.platform);
357
+ }
358
+ } catch {
359
+ nextContext = null;
360
+ }
361
+ const nextInstallDir = nextContext?.customized
362
+ ? (nextContext.native?.skills || getOmpPaths().skills)
363
+ : nextContext?.paths?.skills
364
+ ? nextContext.native?.skills
365
+ : getOmpPaths().skills;
366
+ this.pathContext = nextContext || this.pathContext;
250
367
  if (this.installDir !== nextInstallDir) {
251
368
  this.installDir = nextInstallDir;
252
369
  this.clearCache();
@@ -271,11 +388,21 @@ class SkillService {
271
388
  if (!fs.existsSync(cacheDir)) {
272
389
  fs.mkdirSync(cacheDir, { recursive: true });
273
390
  }
391
+ if (this.artifactStore?.root && !fs.existsSync(this.artifactStore.root)) {
392
+ fs.mkdirSync(this.artifactStore.root, { recursive: true });
393
+ }
274
394
  }
275
395
 
276
396
  clearCache({ removeFile = false } = {}) {
397
+ this._cacheGeneration++;
277
398
  this.skillsCache = null;
278
399
  this.cacheTime = 0;
400
+ this._preparedSkillsCache.clear();
401
+ this._remoteSkillsCache = null;
402
+ this._remoteSkillsFetchedAt = 0;
403
+ this.githubTokenCache.clear();
404
+ remoteCredentialCache.clear('github');
405
+ remoteCredentialCache.clear('gitlab');
279
406
 
280
407
  if (removeFile) {
281
408
  try {
@@ -288,6 +415,588 @@ class SkillService {
288
415
  }
289
416
  }
290
417
 
418
+ _skillCwdKey(options = {}) {
419
+ if (!options.cwd) return '';
420
+ const resolved = path.resolve(options.cwd);
421
+ try {
422
+ return fs.realpathSync(resolved);
423
+ } catch {
424
+ return resolved;
425
+ }
426
+ }
427
+
428
+ _skillCacheKey(options = {}) {
429
+ const scope = options.scope || 'user';
430
+ return `${scope}:${this._skillCwdKey(options)}`;
431
+ }
432
+
433
+
434
+ _storePrepared(cacheKey, skills, generation = this._cacheGeneration) {
435
+ const value = Array.isArray(skills) ? skills.map(skill => ({ ...skill })) : [];
436
+ if (generation !== this._cacheGeneration) {
437
+ return value.map(skill => ({ ...skill }));
438
+ }
439
+ const entry = { value, cachedAt: Date.now(), generation };
440
+ this._preparedSkillsCache.set(cacheKey, entry);
441
+ if (cacheKey === 'user:') {
442
+ this.skillsCache = value;
443
+ this.cacheTime = entry.cachedAt;
444
+ }
445
+ return value.map(skill => ({ ...skill }));
446
+ }
447
+
448
+ _artifactSkills(options = {}) {
449
+ if (!this.artifactStore || typeof this.artifactStore.list !== 'function') return [];
450
+ const artifacts = this.artifactStore.list({ platform: this.platform });
451
+ const projectPath = options.scope === 'project' ? this._skillCwdKey(options) : null;
452
+ return artifacts.map(artifact => {
453
+ if (!artifact || typeof artifact.root !== 'string') return null;
454
+ const artifactScope = artifact.sourceScope || 'user';
455
+ if (
456
+ (artifactScope === 'project' && (!projectPath || artifact.projectPath !== projectPath))
457
+ || (artifactScope !== 'project' && artifact.projectPath && artifact.projectPath !== projectPath)
458
+ ) return null;
459
+ const skillPath = path.join(artifact.root, 'SKILL.md');
460
+ try {
461
+ if (!fs.existsSync(skillPath) || fs.lstatSync(skillPath).isSymbolicLink()) return null;
462
+ const metadata = this.parseSkillMd(fs.readFileSync(skillPath, 'utf8'));
463
+ const rawSourceProvider = artifact.sourceProvider || 'remote';
464
+ const sourceProvider = rawSourceProvider === 'remote'
465
+ ? (artifact.repoProvider || 'remote')
466
+ : rawSourceProvider;
467
+ const source = rawSourceProvider === 'remote'
468
+ ? 'remote'
469
+ : (rawSourceProvider === 'local-repo' ? 'local-repo' : 'provider-installed');
470
+ const directory = artifact.targetDirectory
471
+ || artifact.directory
472
+ || artifact.fullDirectory
473
+ || metadata.name;
474
+ return {
475
+ key: `artifact:${this.platform}:${artifact.sourceKey}:${artifact.format}`,
476
+ sourceKey: artifact.sourceKey,
477
+ name: metadata.name || artifact.name || directory,
478
+ description: metadata.description || artifact.description || '',
479
+ directory,
480
+ fullDirectory: artifact.fullDirectory || directory,
481
+ installed: false,
482
+ cached: true,
483
+ isLocal: ['local', 'local-repo', 'template'].includes(sourceProvider),
484
+ source,
485
+ sourceProvider,
486
+ sourceScope: artifactScope,
487
+ projectPath: artifact.projectPath || null,
488
+ sourcePath: skillPath,
489
+ artifact: {
490
+ root: artifact.root,
491
+ contentHash: artifact.contentHash || null,
492
+ format: artifact.format || null,
493
+ state: artifact.state || 'ready',
494
+ fetchedAt: artifact.fetchedAt || null
495
+ },
496
+ repoProvider: artifact.repoProvider || null,
497
+ repoOwner: artifact.repoOwner || null,
498
+ repoName: artifact.repoName || null,
499
+ repoBranch: artifact.repoBranch || null,
500
+ repoDirectory: artifact.repoDirectory || artifact.fullDirectory || directory,
501
+ repoHost: artifact.repoHost || null,
502
+ repoProjectPath: artifact.repoProjectPath || null,
503
+ repoLocalPath: artifact.repoLocalPath || null,
504
+ repoUrl: artifact.repoUrl || null,
505
+ protected: false,
506
+ shadowedSources: [],
507
+ readmeUrl: artifact.readmeUrl || null,
508
+ license: metadata.license || null
509
+ };
510
+ } catch (error) {
511
+ console.warn(`[SkillService] Read artifact ${artifact.sourceKey || ''} error:`, error.message);
512
+ return null;
513
+ }
514
+ }).filter(Boolean);
515
+ }
516
+ _legacyCachedSkills(options = {}) {
517
+ const cached = this._readRawRemoteCache();
518
+ if (!cached || !Array.isArray(cached.skills)) return [];
519
+ const scope = options.scope || 'user';
520
+ const projectPath = scope === 'project' ? this._skillCwdKey(options) : null;
521
+ return cached.skills
522
+ .filter(skill => {
523
+ const skillScope = skill.sourceScope || 'user';
524
+ if (skillScope === 'project') {
525
+ return scope === 'project' && skill.projectPath === projectPath;
526
+ }
527
+ return !skill.projectPath || skill.projectPath === projectPath;
528
+ })
529
+ .map(skill => ({
530
+ ...skill,
531
+ source: skill.source || 'remote',
532
+ sourceProvider: skill.sourceProvider || 'remote',
533
+ sourceScope: skill.sourceScope || 'user',
534
+ sourceKey: skill.sourceKey || `legacy-cache:${this.platform}:${skill.repoId || skill.repoName || ''}:${skill.fullDirectory || skill.directory || skill.name || ''}`,
535
+ cached: false,
536
+ artifactRoot: null,
537
+ artifactState: 'metadata_only',
538
+ artifact: {
539
+ ...(skill.artifact || {}),
540
+ root: null,
541
+ state: 'metadata_only'
542
+ }
543
+ }));
544
+ }
545
+
546
+ _sourceKeyForSkill(skill = {}, options = {}) {
547
+ const sourceProvider = skill.sourceProvider || skill.source || 'native';
548
+ const isProjectLocal = options.scope === 'project'
549
+ && skill.sourceScope === 'project'
550
+ && sourceProvider !== 'remote'
551
+ && !skill.repoId;
552
+ const projectPath = isProjectLocal ? this._skillCwdKey(options) : '';
553
+ const addProjectIdentity = sourceKey => (
554
+ isProjectLocal && projectPath && !sourceKey.includes(':project:')
555
+ ? `${sourceKey}:project:${projectPath}`
556
+ : sourceKey
557
+ );
558
+ if (skill.sourceKey) return addProjectIdentity(String(skill.sourceKey));
559
+ const directory = skill.fullDirectory || skill.directory || skill.name || '';
560
+ let sourceKey;
561
+ if (sourceProvider === 'native' || sourceProvider === 'native-installed') {
562
+ sourceKey = `native:${this.platform}:${directory}`;
563
+ } else if (sourceProvider === 'cc-tool' || sourceProvider === 'local') {
564
+ sourceKey = `local:${this.platform}:${directory}`;
565
+ } else {
566
+ const repo = skill.repoId || [skill.repoOwner, skill.repoName, skill.repoBranch].filter(Boolean).join('/');
567
+ sourceKey = repo
568
+ ? `repo:${repo}:${directory}`
569
+ : `${sourceProvider}:${this.platform}:${skill.realPath || skill.sourcePath || directory}`;
570
+ }
571
+ return addProjectIdentity(sourceKey);
572
+ }
573
+
574
+ _controlKeyForSkill(skill, scope, projectPath, sourceKey) {
575
+ const location = scope === 'project' ? projectPath : 'user';
576
+ return skill.controlKey || `skill:${this.platform}:${scope}:${location}:${sourceKey}`;
577
+ }
578
+
579
+ _materializeLocalArtifact(skill, sourceKey, options = {}) {
580
+ if (!this.artifactStore || !skill.sourcePath) return null;
581
+ if (skill.source === 'remote' || skill.sourceProvider === 'remote' || skill.artifactRoot) return null;
582
+ const root = path.dirname(skill.sourcePath);
583
+ const projectPath = skill.sourceScope === 'project' && options.scope === 'project'
584
+ ? this._skillCwdKey(options)
585
+ : null;
586
+ try {
587
+ const files = this._collectLocalSkillFiles(root);
588
+ const normalized = this.formatAdapter.normalize({
589
+ platform: this.platform,
590
+ files,
591
+ sourceMetadata: {
592
+ name: skill.name || skill.directory,
593
+ sourceScope: skill.sourceScope || 'user',
594
+ projectPath
595
+ }
596
+ });
597
+ const existing = this.artifactStore.get?.({
598
+ platform: this.platform,
599
+ sourceKey,
600
+ format: normalized.format
601
+ });
602
+ const contentHash = this.artifactStore.hashFileTree?.(normalized.files);
603
+ if (existing && (!contentHash || existing.contentHash === contentHash)) return existing;
604
+ return this.artifactStore.publishSkill({
605
+ platform: this.platform,
606
+ sourceKey,
607
+ format: normalized.format,
608
+ files: normalized.files,
609
+ metadata: {
610
+ name: skill.name || skill.directory,
611
+ description: skill.description || '',
612
+ directory: skill.directory,
613
+ fullDirectory: skill.fullDirectory || skill.directory,
614
+ sourceProvider: skill.sourceProvider || skill.source || 'native',
615
+ sourceScope: skill.sourceScope || 'user',
616
+ ...(projectPath ? { projectPath } : {}),
617
+ revision: skill.revision || null
618
+ }
619
+ });
620
+ } catch (error) {
621
+ console.warn(`[SkillService] Materialize local Skill ${skill.directory || ''} failed:`, error.message);
622
+ return {
623
+ root: null,
624
+ contentHash: null,
625
+ state: 'unsupported',
626
+ fetchedAt: null,
627
+ lastError: error.message
628
+ };
629
+ }
630
+ }
631
+
632
+ _skillProjectionCapability(scope) {
633
+ const capability = this.controlService?.projection?.getCapability?.(this.platform, scope);
634
+ if (capability) return capability;
635
+ const mapping = this.registry?.resolve?.(this.platform)?.projectResources?.skills;
636
+ return mapping || scope === 'user'
637
+ ? { mode: 'native-copy', format: null }
638
+ : { mode: 'unsupported', format: null };
639
+ }
640
+
641
+ _buildSkillControlEntry(skill, options = {}) {
642
+ const sourceScope = skill.sourceScope === 'project' && options.scope === 'project'
643
+ ? 'project'
644
+ : 'user';
645
+ const projectPath = sourceScope === 'project' ? this._skillCwdKey(options) : null;
646
+ const sourceKey = this._sourceKeyForSkill(skill, options);
647
+ const controlKey = this._controlKeyForSkill(skill, sourceScope, projectPath, sourceKey);
648
+ const isRemote = skill.source === 'remote'
649
+ || skill.sourceProvider === 'remote'
650
+ || skill.artifactState === 'metadata_only'
651
+ || Boolean(skill.repoId);
652
+ const projectionCapability = this._skillProjectionCapability(sourceScope);
653
+ const localArtifact = this._materializeLocalArtifact(skill, sourceKey, options);
654
+ const artifact = localArtifact || skill.artifact || null;
655
+ const artifactRoot = skill.artifactRoot || artifact?.root || (
656
+ skill.sourcePath ? path.dirname(skill.sourcePath) : null
657
+ );
658
+ const projectionUnsupported = skill.protected
659
+ || skill.readonly === true
660
+ || artifact?.state === 'unsupported'
661
+ || projectionCapability.mode !== 'native-copy';
662
+ return {
663
+ kind: 'skill',
664
+ controlKey,
665
+ platform: this.platform,
666
+ scope: sourceScope,
667
+ projectPath,
668
+ sourceKey,
669
+ source: {
670
+ kind: skill.sourceProvider || skill.source || 'native',
671
+ repoId: skill.repoId || null,
672
+ fullDirectory: skill.fullDirectory || skill.directory || '',
673
+ revision: skill.revision || null
674
+ },
675
+ artifact: {
676
+ ...(artifact || {}),
677
+ root: artifactRoot,
678
+ contentHash: skill.contentHash || artifact?.contentHash || null,
679
+ state: skill.artifactState || artifact?.state || (isRemote ? 'metadata_only' : 'ready'),
680
+ fetchedAt: artifact?.fetchedAt || null
681
+ },
682
+ targetDirectory: skill.directory,
683
+ cached: skill.cached !== false && skill.artifactState !== 'metadata_only',
684
+ enabled: isRemote ? false : skill.installed !== false,
685
+ trust: isRemote ? 'pending' : 'approved',
686
+ projection: {
687
+ mode: projectionUnsupported ? 'unsupported' : projectionCapability.mode,
688
+ sourceKey: !projectionUnsupported
689
+ && !isRemote
690
+ && (
691
+ skill.sourceProvider === 'native'
692
+ || skill.source === 'native-installed'
693
+ || skill.source === 'project-installed'
694
+ )
695
+ ? sourceKey
696
+ : null,
697
+ path: null,
698
+ updatedAt: null
699
+ },
700
+ managed: true,
701
+ lastError: null,
702
+ createdAt: Date.now(),
703
+ updatedAt: Date.now()
704
+ };
705
+ }
706
+
707
+ _applyControlState(skills, options = {}) {
708
+ if (!this.controlService) {
709
+ return skills.map(skill => ({
710
+ ...skill,
711
+ managed: true,
712
+ cached: skill.cached !== false,
713
+ enabled: skill.enabled === true || skill.installed === true,
714
+ trust: skill.trust || (skill.source === 'remote' ? 'pending' : 'approved'),
715
+ installed: skill.enabled === true || skill.installed === true
716
+ }));
717
+ }
718
+
719
+ return skills.map(skill => {
720
+ const candidate = this._buildSkillControlEntry(skill, options);
721
+ const scopeOptions = { scope: candidate.scope, projectPath: candidate.projectPath };
722
+ let entry = this.controlService.getSkill(candidate.controlKey, scopeOptions);
723
+ if (!entry && (skill.sourceProvider === 'native' || skill.source === 'native-installed')) {
724
+ const effective = this.controlService.getEffectiveSnapshot?.({
725
+ platform: this.platform,
726
+ scope: candidate.scope,
727
+ projectPath: candidate.projectPath
728
+ });
729
+ entry = effective?.skills?.active?.find(item => (
730
+ item.scope === candidate.scope
731
+ && item.projectPath === candidate.projectPath
732
+ && item.targetDirectory === candidate.targetDirectory
733
+ && item.sourceKey !== candidate.sourceKey
734
+ && item.artifact?.root
735
+ )) || null;
736
+ }
737
+ if (!entry) {
738
+ entry = this.controlService.registerSkill(candidate);
739
+ } else if (
740
+ candidate.artifact?.root
741
+ && candidate.artifact?.contentHash
742
+ && candidate.artifact.contentHash !== entry.artifact?.contentHash
743
+ ) {
744
+ if (entry.enabled || entry.projection?.state === 'enabled') {
745
+ try {
746
+ this.controlService.setSkillEnabled({
747
+ platform: this.platform,
748
+ controlKey: entry.controlKey,
749
+ scope: candidate.scope,
750
+ projectPath: candidate.projectPath,
751
+ enabled: false
752
+ });
753
+ } catch (error) {
754
+ candidate.lastError = error.message;
755
+ }
756
+ }
757
+ entry = this.controlService.registerSkill({
758
+ ...entry,
759
+ artifact: { ...(entry.artifact || {}), ...candidate.artifact },
760
+ cached: candidate.cached,
761
+ enabled: false,
762
+ trust: 'needs_review',
763
+ projection: { ...(entry.projection || {}), state: 'disabled', updatedAt: Date.now() },
764
+ lastError: candidate.lastError || null,
765
+ updatedAt: Date.now()
766
+ });
767
+ } else if (
768
+ (skill.sourceProvider === 'native' || skill.source === 'native-installed')
769
+ && entry.sourceKey === candidate.sourceKey
770
+ && !entry.projection?.sourceKey
771
+ && candidate.projection?.sourceKey
772
+ ) {
773
+ entry = this.controlService.registerSkill({
774
+ ...entry,
775
+ projection: {
776
+ ...(entry.projection || {}),
777
+ sourceKey: candidate.projection.sourceKey,
778
+ state: entry.enabled ? 'enabled' : (entry.projection?.state || 'disabled'),
779
+ updatedAt: Date.now()
780
+ },
781
+ updatedAt: Date.now()
782
+ });
783
+ }
784
+ const loadable = entry.trust === 'approved' && entry.artifact?.state === 'ready';
785
+ return {
786
+ ...skill,
787
+ controlKey: entry.controlKey,
788
+ sourceKey: entry.sourceKey || candidate.sourceKey,
789
+ scope: entry.scope,
790
+ sourceScope: entry.scope,
791
+ projectPath: entry.projectPath,
792
+ managed: true,
793
+ cached: entry.cached,
794
+ enabled: loadable && entry.enabled === true,
795
+ trust: entry.trust,
796
+ artifact: entry.artifact,
797
+ installed: loadable && entry.enabled === true,
798
+ lastError: entry.lastError || null
799
+ };
800
+ });
801
+ }
802
+
803
+ _latestRefreshTask(options = {}) {
804
+ if (!PATHS.skillRefreshTasks || !fs.existsSync(PATHS.skillRefreshTasks)) return null;
805
+ try {
806
+ const persisted = JSON.parse(fs.readFileSync(PATHS.skillRefreshTasks, 'utf8'));
807
+ const scope = options.scope || 'user';
808
+ const projectPath = scope === 'project' ? this._skillCwdKey(options) : null;
809
+ return (Array.isArray(persisted?.tasks) ? persisted.tasks : [])
810
+ .filter(task => (
811
+ task?.platform === this.platform
812
+ && (task.scope || 'user') === scope
813
+ && (task.projectPath || null) === projectPath
814
+ ))
815
+ .sort((a, b) => Number(b.finishedAt || b.createdAt || 0) - Number(a.finishedAt || a.createdAt || 0))[0] || null;
816
+ } catch {
817
+ return null;
818
+ }
819
+ }
820
+
821
+ _refreshSnapshot(options = {}) {
822
+ const scope = options.scope || 'user';
823
+ const projectPath = scope === 'project' ? this._skillCwdKey(options) : null;
824
+ const cached = this._readRawRemoteCache();
825
+ const matchesScope = item => {
826
+ const itemScope = item?.sourceScope || 'user';
827
+ if (itemScope === 'project') {
828
+ return scope === 'project' && item.projectPath === projectPath;
829
+ }
830
+ return !item.projectPath || item.projectPath === projectPath;
831
+ };
832
+ const cachedSkills = Array.isArray(cached?.skills)
833
+ ? cached.skills.filter(matchesScope)
834
+ : [];
835
+ const cachedFetchedAt = cachedSkills.reduce(
836
+ (latest, skill) => Math.max(latest, Number(skill.fetchedAt || 0)),
837
+ 0
838
+ ) || (cachedSkills.length > 0 ? Number(cached?.fetchedAt || 0) : 0);
839
+ const artifactFetchedAt = (this.artifactStore?.list?.({ platform: this.platform }) || [])
840
+ .filter(matchesScope)
841
+ .filter(artifact => artifact.sourceProvider === 'remote' || artifact.source === 'remote' || artifact.repoId)
842
+ .reduce((latest, artifact) => Math.max(latest, Number(artifact.fetchedAt || 0)), 0);
843
+ const fetchedAt = Math.max(cachedFetchedAt, artifactFetchedAt);
844
+ const task = this._latestRefreshTask(options);
845
+ return {
846
+ state: task?.status || (fetchedAt > 0 ? 'idle' : 'never_fetched'),
847
+ taskId: task?.id || null,
848
+ fetchedAt: fetchedAt || null,
849
+ error: task?.error ? sanitizeRefreshError(task.error) : null
850
+ };
851
+ }
852
+
853
+ _ensureLegacyControlMigration() {
854
+ if (this._legacyMigrationChecked || !this.controlService || !PATHS.configRegistry || !PATHS.configs) {
855
+ return;
856
+ }
857
+ this._legacyMigrationChecked = true;
858
+ try {
859
+ const { ConfigRegistryService } = require('./config-registry-service');
860
+ new ConfigRegistryService({ controlService: this.controlService }).migrateSkillControls();
861
+ } catch (error) {
862
+ this._legacyMigrationChecked = false;
863
+ console.warn('[SkillService] Legacy control migration skipped:', error.message);
864
+ }
865
+ }
866
+
867
+ _controlOnlySkills(options = {}, knownControlKeys = new Set()) {
868
+ if (!this.controlService?.getEffectiveSnapshot) return [];
869
+ const scope = options.scope || 'user';
870
+ const projectPath = scope === 'project' ? this._skillCwdKey(options) : null;
871
+ const snapshot = this.controlService.getEffectiveSnapshot({
872
+ platform: this.platform,
873
+ scope,
874
+ projectPath
875
+ });
876
+ return (snapshot.skills?.active || [])
877
+ .filter(entry => entry?.controlKey && !knownControlKeys.has(entry.controlKey))
878
+ .map(entry => {
879
+ const artifactRoot = typeof entry.artifact?.root === 'string' ? entry.artifact.root : null;
880
+ const skillFile = artifactRoot ? path.join(artifactRoot, 'SKILL.md') : null;
881
+ const artifactAllowed = !artifactRoot || !this.artifactStore?.root || (() => {
882
+ try {
883
+ assertNoSymlinkComponents(this.artifactStore.root, artifactRoot, fs);
884
+ const relative = path.relative(path.resolve(this.artifactStore.root), path.resolve(artifactRoot));
885
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
886
+ } catch {
887
+ return false;
888
+ }
889
+ })();
890
+ const available = Boolean(
891
+ artifactAllowed
892
+ && skillFile
893
+ && path.isAbsolute(artifactRoot)
894
+ && fs.existsSync(skillFile)
895
+ && !fs.lstatSync(artifactRoot).isSymbolicLink()
896
+ && !fs.lstatSync(skillFile).isSymbolicLink()
897
+ );
898
+ const artifactState = available ? (entry.artifact?.state || 'ready') : 'missing';
899
+ const loadable = available && artifactState === 'ready' && entry.trust === 'approved';
900
+ const source = entry.source || {};
901
+ const sourceKind = typeof source === 'string' ? source : source.kind || 'unknown';
902
+ const directory = String(entry.targetDirectory || source.fullDirectory || entry.name || entry.controlKey);
903
+ return {
904
+ key: entry.controlKey,
905
+ controlKey: entry.controlKey,
906
+ sourceKey: entry.sourceKey || null,
907
+ name: entry.name || path.basename(directory),
908
+ description: entry.description || '',
909
+ directory,
910
+ installed: loadable && entry.enabled === true,
911
+ enabled: loadable && entry.enabled === true,
912
+ cached: available && entry.cached !== false,
913
+ isLocal: sourceKind === 'local' || sourceKind === 'template',
914
+ source: sourceKind === 'remote' || sourceKind === 'git' ? 'remote' : sourceKind,
915
+ sourceProvider: sourceKind,
916
+ sourceScope: entry.scope,
917
+ scope: entry.scope,
918
+ projectPath: entry.projectPath || null,
919
+ sourcePath: skillFile,
920
+ artifact: {
921
+ ...(entry.artifact || {}),
922
+ root: artifactRoot,
923
+ state: artifactState
924
+ },
925
+ artifactRoot,
926
+ projectionOverlay: true,
927
+ artifactState,
928
+ contentHash: entry.artifact?.contentHash || null,
929
+ revision: source.revision || null,
930
+ repoId: source.repoId || null,
931
+ cachedAt: entry.artifact?.fetchedAt || null,
932
+ trust: entry.trust || 'pending',
933
+ managed: true,
934
+ projection: entry.projection || { mode: 'unsupported', state: 'unsupported' },
935
+ lastError: entry.lastError || (!available ? 'Skill artifact is missing' : null),
936
+ readonly: sourceKind === 'remote' || sourceKind === 'git',
937
+ protected: false,
938
+ shadowedSources: [],
939
+ readmeUrl: null,
940
+ license: null
941
+ };
942
+ });
943
+ }
944
+
945
+ async scanSkills(options = {}) {
946
+ this._ensureLegacyControlMigration();
947
+ const scope = options.scope || 'user';
948
+ const scopeOptions = scope === 'project'
949
+ ? this.resolveScopeOptions(options)
950
+ : { scope: 'user', cwd: '' };
951
+ const normalizedOptions = {
952
+ ...options,
953
+ scope,
954
+ ...(scope === 'project' ? { cwd: scopeOptions.cwd } : {})
955
+ };
956
+ const cacheKey = this._skillCacheKey({
957
+ scope,
958
+ cwd: options.cwd || (scope === 'project' ? scopeOptions.cwd : '')
959
+ });
960
+ const generation = this._cacheGeneration;
961
+ const rawSkills = [
962
+ ...this._artifactSkills(normalizedOptions),
963
+ ...(options.includeRemote === true ? this._legacyCachedSkills(normalizedOptions) : [])
964
+ ];
965
+ const prepared = this.prepareSkills(rawSkills, normalizedOptions);
966
+ const controlled = this._applyControlState(prepared, normalizedOptions);
967
+ const knownControlKeys = new Set(controlled.map(skill => skill.controlKey).filter(Boolean));
968
+ const missingControls = this._controlOnlySkills(normalizedOptions, knownControlKeys);
969
+ const skills = [...controlled, ...missingControls];
970
+ this.deduplicateSkills(skills);
971
+ const preparedSkills = this._storePrepared(cacheKey, skills, generation);
972
+ return {
973
+ skills: preparedSkills,
974
+ refresh: this._refreshSnapshot(normalizedOptions)
975
+ };
976
+ }
977
+
978
+ _readRawRemoteCache() {
979
+ const now = Date.now();
980
+ if (Array.isArray(this._remoteSkillsCache)) {
981
+ return {
982
+ skills: this._remoteSkillsCache,
983
+ fetchedAt: this._remoteSkillsFetchedAt,
984
+ fresh: this._remoteSkillsFetchedAt > 0 && now - this._remoteSkillsFetchedAt < CACHE_TTL
985
+ };
986
+ }
987
+
988
+ const cached = this._readCacheEnvelope();
989
+ if (!cached) return null;
990
+ this._remoteSkillsCache = cached.skills;
991
+ this._remoteSkillsFetchedAt = cached.fetchedAt;
992
+ return {
993
+ skills: cached.skills,
994
+ fetchedAt: cached.fetchedAt,
995
+ fresh: cached.fetchedAt > 0 && now - cached.fetchedAt < CACHE_TTL
996
+ };
997
+ }
998
+
999
+
291
1000
  prepareSkills(skills = [], options = {}) {
292
1001
  this.refreshOmpPaths();
293
1002
  const preparedSkills = Array.isArray(skills)
@@ -295,15 +1004,21 @@ class SkillService {
295
1004
  : [];
296
1005
 
297
1006
  if (this.platform === 'omp') {
298
- preparedSkills.unshift(...discoverOmpSkills(this, options));
1007
+ preparedSkills.unshift(...discoverOmpSkills(this, { ...options, pathContext: this.pathContext }));
299
1008
  }
300
1009
  this.mergeLocalSkills(preparedSkills);
301
1010
  if (this.platform !== 'omp') {
302
- this.mergeInstalledSkills(preparedSkills);
1011
+ this.mergeInstalledSkills(preparedSkills, options);
1012
+ if (options.scope === 'project') {
1013
+ this.mergeProjectSkills(preparedSkills, options);
1014
+ }
303
1015
  }
304
1016
  this.deduplicateSkills(preparedSkills);
305
1017
  preparedSkills.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
306
- this.updateInstallStatus(preparedSkills);
1018
+ this.updateInstallStatus(preparedSkills, {
1019
+ pathsRefreshed: this.platform === 'omp',
1020
+ options
1021
+ });
307
1022
 
308
1023
  return preparedSkills;
309
1024
  }
@@ -360,7 +1075,7 @@ class SkillService {
360
1075
  } else if (provider === 'gitlab') {
361
1076
  normalized.host = normalizeRepoHost(repo.host, 'gitlab');
362
1077
  normalized.projectPath = normalizeRepoPath(repo.projectPath || [repo.owner, repo.name].filter(Boolean).join('/'));
363
- if (!normalized.projectPath) {
1078
+ if (!normalized.projectPath || normalized.projectPath.split('/').some(segment => !segment || segment === '.' || segment === '..')) {
364
1079
  throw new Error('Missing GitLab project path');
365
1080
  }
366
1081
  normalized.name = stripGitSuffix(normalized.projectPath.split('/').pop() || '');
@@ -374,7 +1089,7 @@ class SkillService {
374
1089
  }
375
1090
  }
376
1091
 
377
- normalized.repoUrl = repo.repoUrl || buildRepoUrl(normalized);
1092
+ normalized.repoUrl = sanitizeRepoUrl(repo.repoUrl, buildRepoUrl(normalized));
378
1093
  normalized.label = buildRepoLabel(normalized);
379
1094
  normalized.id = buildRepoId(normalized);
380
1095
 
@@ -500,16 +1215,87 @@ class SkillService {
500
1215
  removeRepo(owner, name, directory = '', repoId = '') {
501
1216
  const repos = this.loadRepos();
502
1217
  const normalizedDirectory = normalizeRepoDirectory(directory);
503
- const filtered = repos.filter(r => {
504
- if (repoId) {
505
- return r.id !== repoId;
1218
+ const removedRepoIds = new Set(
1219
+ repos
1220
+ .filter(r => {
1221
+ if (repoId) {
1222
+ return r.id === repoId;
1223
+ }
1224
+ return (
1225
+ (r.owner || '') === owner &&
1226
+ (r.name || '') === name &&
1227
+ normalizeRepoDirectory(r.directory) === normalizedDirectory
1228
+ );
1229
+ })
1230
+ .map(r => r.id)
1231
+ .filter(Boolean)
1232
+ );
1233
+ const filtered = repos.filter(r => !removedRepoIds.has(r.id) && !(
1234
+ !repoId &&
1235
+ (r.owner || '') === owner &&
1236
+ (r.name || '') === name &&
1237
+ normalizeRepoDirectory(r.directory) === normalizedDirectory
1238
+ ));
1239
+ if (this.artifactStore && removedRepoIds.size > 0) {
1240
+ const disabledControlKeys = new Set();
1241
+ for (const artifact of this.artifactStore.list({ platform: this.platform })) {
1242
+ if (!removedRepoIds.has(artifact.repoId)) continue;
1243
+ const scope = artifact.sourceScope || 'user';
1244
+ const projectPath = scope === 'project' ? artifact.projectPath : null;
1245
+ const controlKey = `skill:${this.platform}:${scope}:${projectPath || 'user'}:${artifact.sourceKey}`;
1246
+ if (!disabledControlKeys.has(controlKey) && this.controlService?.getSkill && this.controlService?.setSkillEnabled) {
1247
+ let existing = null;
1248
+ try {
1249
+ existing = this.controlService.getSkill(controlKey, { scope, projectPath });
1250
+ } catch (_) {
1251
+ // A deleted project has no readable project control manifest.
1252
+ }
1253
+ if (existing) {
1254
+ try {
1255
+ const disabled = this.controlService.setSkillEnabled({
1256
+ platform: this.platform,
1257
+ controlKey,
1258
+ scope,
1259
+ projectPath,
1260
+ enabled: false
1261
+ });
1262
+ if (disabled?.enabled !== false && this.controlService.registerSkill) {
1263
+ this.controlService.registerSkill({
1264
+ ...existing,
1265
+ enabled: false,
1266
+ projection: {
1267
+ ...(existing.projection || {}),
1268
+ ...(disabled?.projection || {}),
1269
+ state: disabled?.projection?.state || 'conflict',
1270
+ status: disabled?.projection?.status || 'conflict',
1271
+ updatedAt: Date.now()
1272
+ },
1273
+ lastError: disabled?.lastError || 'Skill projection ownership could not be verified',
1274
+ updatedAt: Date.now()
1275
+ });
1276
+ }
1277
+ } catch (error) {
1278
+ if (this.controlService.registerSkill) {
1279
+ this.controlService.registerSkill({
1280
+ ...existing,
1281
+ enabled: false,
1282
+ projection: { ...(existing.projection || {}), state: 'error', status: 'error', updatedAt: Date.now() },
1283
+ lastError: error.message,
1284
+ updatedAt: Date.now()
1285
+ });
1286
+ }
1287
+ }
1288
+ }
1289
+ disabledControlKeys.add(controlKey);
1290
+ }
1291
+ this.artifactStore.markState({
1292
+ platform: this.platform,
1293
+ sourceKey: artifact.sourceKey,
1294
+ format: artifact.format,
1295
+ state: 'orphaned'
1296
+ });
506
1297
  }
507
- return !(
508
- (r.owner || '') === owner &&
509
- (r.name || '') === name &&
510
- normalizeRepoDirectory(r.directory) === normalizedDirectory
511
- );
512
- });
1298
+ }
513
1299
  this.saveRepos(filtered);
514
1300
  this.clearCache({ removeFile: true });
515
1301
  return this.loadRepos();
@@ -581,159 +1367,86 @@ class SkillService {
581
1367
  }
582
1368
 
583
1369
  /**
584
- * 获取所有技能列表(带缓存)
1370
+ * 获取本地 Skill 列表。网络刷新必须由显式 refresh task 触发。
1371
+ *
1372
+ * @deprecated 内部调用请使用 scanSkills;此方法只保留现有脚本的数组响应兼容性。
585
1373
  */
586
- async listSkills(forceRefresh = false, options = {}) {
587
- if (this.platform === 'omp') {
588
- return this.listOmpSkills(options);
589
- }
590
- // 强制刷新时仅清空内存缓存,保留磁盘缓存作为回退来源
591
- if (forceRefresh) {
592
- this.clearCache();
593
- }
594
-
595
- const fileCache = this.loadCacheFromFile();
596
-
597
- // 检查内存缓存
598
- if (!forceRefresh && Array.isArray(this.skillsCache) && this.skillsCache.length > 0) {
599
- if (Array.isArray(fileCache) && fileCache.length > this.skillsCache.length) {
600
- this.skillsCache = this.prepareSkills(fileCache);
601
- this.cacheTime = Date.now();
602
- return this.skillsCache;
603
- }
604
- this.skillsCache = this.prepareSkills(this.skillsCache);
605
- this.cacheTime = Date.now();
606
- return this.skillsCache;
607
- }
608
-
609
- // 检查文件缓存
610
- if (!forceRefresh) {
611
- if (fileCache && fileCache.length > 0) {
612
- this.skillsCache = this.prepareSkills(fileCache);
613
- this.cacheTime = Date.now();
614
- return this.skillsCache;
615
- }
616
- }
617
-
618
- const repos = this.loadRepos();
619
- const skills = [];
620
-
621
- // 并行获取所有启用仓库的技能(带超时保护)
622
- const enabledRepos = repos.filter(r => r.enabled);
623
- const enabledRemoteRepos = enabledRepos.filter(repo => repo.provider !== 'local');
624
- let remoteFailureCount = 0;
625
-
626
- if (enabledRepos.length > 0) {
627
- const results = await Promise.allSettled(
628
- enabledRepos.map(repo =>
629
- Promise.race([
630
- this.fetchRepoSkills(repo),
631
- new Promise((_, reject) =>
632
- setTimeout(() => reject(new Error('Fetch timeout')), 30000) // 30秒超时
633
- )
634
- ])
635
- )
636
- );
637
-
638
- for (let i = 0; i < results.length; i++) {
639
- const result = results[i];
640
- const repo = enabledRepos[i];
641
- const repoInfo = `${repo.owner}/${repo.name}`;
642
- if (result.status === 'fulfilled') {
643
- skills.push(...result.value);
644
- } else {
645
- console.warn(`[SkillService] Fetch repo ${repoInfo} failed:`, result.reason?.message);
646
- if (repo.provider !== 'local') {
647
- remoteFailureCount++;
648
- }
649
- }
1374
+ async listSkills(options = {}, legacyOptions = {}) {
1375
+ const scanOptions = typeof options === 'boolean'
1376
+ ? {
1377
+ ...legacyOptions,
1378
+ ...(options ? { force: true } : {}),
1379
+ scope: legacyOptions.scope || (legacyOptions.cwd ? 'project' : 'user'),
1380
+ includeRemote: legacyOptions.includeRemote !== false
650
1381
  }
651
- }
652
-
653
- const preparedSkills = this.prepareSkills(skills);
654
-
655
- const hasUsableFileCache = Array.isArray(fileCache) && fileCache.length > 0;
656
- const preparedFileCache = hasUsableFileCache ? this.prepareSkills(fileCache) : null;
657
- const shouldUseStaleFileCache = hasUsableFileCache && (
658
- (enabledRemoteRepos.length > 0 && remoteFailureCount === enabledRemoteRepos.length) ||
659
- (remoteFailureCount > 0 && preparedFileCache.length > preparedSkills.length)
660
- );
661
-
662
- if (shouldUseStaleFileCache) {
663
- this.skillsCache = preparedFileCache;
664
- this.cacheTime = Date.now();
665
- return this.skillsCache;
666
- }
667
-
668
- // 更新缓存
669
- this.skillsCache = preparedSkills;
670
- this.cacheTime = Date.now();
671
- this.saveCacheToFile(preparedSkills);
672
-
673
- return preparedSkills;
1382
+ : { ...(options || {}), includeRemote: options?.includeRemote !== false };
1383
+ const result = await this.scanSkills(scanOptions || {});
1384
+ return result.skills;
674
1385
  }
675
1386
 
676
- async listOmpSkills(options = {}) {
677
- this.refreshOmpPaths();
678
- const skills = [];
679
- const repos = this.loadRepos().filter(repo => repo.enabled);
680
- const results = await Promise.allSettled(repos.map(repo => this.fetchRepoSkills(repo)));
681
- results.forEach((result, index) => {
682
- if (result.status === 'fulfilled') {
683
- skills.push(...result.value);
684
- } else {
685
- console.warn(
686
- `[SkillService] Fetch OMP repo ${repos[index]?.label || repos[index]?.id || ''} failed:`,
687
- result.reason?.message
688
- );
689
- }
690
- });
691
- const prepared = this.prepareSkills(skills, options);
692
- this.skillsCache = prepared;
693
- this.cacheTime = Date.now();
694
- return prepared;
695
- }
696
1387
 
697
1388
  /**
698
1389
  * 从文件加载缓存
699
1390
  */
700
- loadCacheFromFile() {
1391
+ _readCacheEnvelope() {
701
1392
  try {
702
- if (fs.existsSync(this.cachePath)) {
703
- const data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'));
704
- if (Array.isArray(data.skills)) {
705
- return data.skills;
706
- }
1393
+ if (!fs.existsSync(this.cachePath)) return null;
1394
+ const data = JSON.parse(fs.readFileSync(this.cachePath, 'utf-8'));
1395
+ if (Array.isArray(data)) {
1396
+ return { skills: data, fetchedAt: 0 };
1397
+ }
1398
+ if (Array.isArray(data?.skills)) {
1399
+ return {
1400
+ skills: data.skills,
1401
+ fetchedAt: Number(data.fetchedAt || data.time || 0) || 0
1402
+ };
707
1403
  }
708
1404
  } catch (err) {
709
- // 忽略缓存读取错误
1405
+ // Ignore malformed cache files and refresh from repositories.
710
1406
  }
711
1407
  return null;
712
1408
  }
713
1409
 
1410
+ /**
1411
+ * 从文件加载缓存
1412
+ */
1413
+ loadCacheFromFile() {
1414
+ return this._readCacheEnvelope()?.skills || null;
1415
+ }
1416
+
714
1417
  /**
715
1418
  * 保存缓存到文件
716
1419
  */
717
- saveCacheToFile(skills) {
1420
+ saveCacheToFile(skills, fetchedAt = Date.now()) {
718
1421
  try {
719
1422
  fs.writeFileSync(this.cachePath, JSON.stringify({
720
- time: Date.now(),
1423
+ fetchedAt,
1424
+ time: fetchedAt,
721
1425
  skills
722
1426
  }));
723
1427
  } catch (err) {
724
- // 忽略缓存写入错误
1428
+ // Ignore cache write failures; callers retain the in-memory value.
725
1429
  }
726
1430
  }
727
1431
 
728
1432
  /**
729
1433
  * 更新技能的安装状态
730
1434
  */
731
- updateInstallStatus(skills) {
1435
+ updateInstallStatus(skills, { pathsRefreshed = false, options = {} } = {}) {
1436
+ if (this.platform === 'omp' && !pathsRefreshed) {
1437
+ this.refreshOmpPaths();
1438
+ }
1439
+
732
1440
  for (const skill of skills) {
733
- if (skill.sourceProvider || skill.sourcePath || skill.readonly) {
1441
+ if (skill.sourceScope === 'project') {
1442
+ skill.installed = true;
1443
+ } else if (skill.sourceProvider || skill.sourcePath || skill.readonly) {
734
1444
  skill.installed = skill.installed !== false;
735
1445
  } else {
736
- skill.installed = this.isInstalled(skill.directory);
1446
+ const installOptions = options.scope === 'project'
1447
+ ? { scope: 'project', cwd: options.cwd, refresh: false }
1448
+ : { scope: 'user', refresh: false };
1449
+ skill.installed = this.isInstalled(skill.directory, installOptions);
737
1450
  }
738
1451
  }
739
1452
  }
@@ -754,6 +1467,592 @@ class SkillService {
754
1467
  return this.fetchGitHubRepoSkills(repo);
755
1468
  }
756
1469
 
1470
+ _skillBundleSourceKey(repo, fullDirectory) {
1471
+ return `repo:${repo.id || buildRepoId(repo)}:${normalizeRepoPath(fullDirectory)}`;
1472
+ }
1473
+
1474
+ _treeSkillRoots(treeItems, repo) {
1475
+ const baseDir = normalizeRepoDirectory(repo.directory);
1476
+ if (baseDir) {
1477
+ normalizeSkillRelativePath(baseDir, 'skill repository directory', { allowHiddenSegments: true });
1478
+ }
1479
+ const basePrefix = baseDir ? `${baseDir}/` : '';
1480
+ return treeItems
1481
+ .filter(item => {
1482
+ if (!item || item.type !== 'blob' || !isRootSkillFile(item.path)) return false;
1483
+ const normalizedPath = normalizeRepoPath(item.path);
1484
+ try {
1485
+ normalizeSkillRelativePath(normalizedPath, 'skill repository file path', { allowHiddenSegments: true });
1486
+ } catch {
1487
+ return false;
1488
+ }
1489
+ return !baseDir || normalizedPath === baseDir || normalizedPath.startsWith(basePrefix);
1490
+ })
1491
+ .map(item => ({
1492
+ file: item,
1493
+ fullDirectory: normalizeRepoPath(item.path).replace(/\/SKILL\.md$/i, '')
1494
+ }))
1495
+ .filter(item => item.fullDirectory)
1496
+ .sort((a, b) => a.fullDirectory.length - b.fullDirectory.length);
1497
+ }
1498
+
1499
+ async _fetchGitSkillBundles(repo, treeItems, revision = null) {
1500
+ const roots = this._treeSkillRoots(treeItems, repo);
1501
+ const rootPaths = roots.map(item => item.fullDirectory);
1502
+ const bundles = [];
1503
+
1504
+ for (const root of roots) {
1505
+ const prefix = `${root.fullDirectory}/`;
1506
+ const nestedRoots = rootPaths.filter(candidate => (
1507
+ candidate !== root.fullDirectory && candidate.startsWith(prefix)
1508
+ ));
1509
+ const files = treeItems
1510
+ .filter(item => {
1511
+ if (!item || item.type !== 'blob') return false;
1512
+ const filePath = normalizeRepoPath(item.path);
1513
+ if (filePath !== `${root.fullDirectory}/SKILL.md` && !filePath.startsWith(prefix)) return false;
1514
+ normalizeSkillRelativePath(filePath, 'skill repository file path', { allowHiddenSegments: true });
1515
+ if (item.mode === '120000' || item.type === 'symlink') {
1516
+ throw new Error(`Skill repository contains symlink: ${filePath}`);
1517
+ }
1518
+ return !nestedRoots.some(nested => filePath === nested || filePath.startsWith(`${nested}/`));
1519
+ })
1520
+ .map(item => ({
1521
+ relativePath: normalizeRepoPath(item.path).slice(prefix.length),
1522
+ source: item
1523
+ }));
1524
+ const contentFiles = [];
1525
+ for (const file of files) {
1526
+ const content = await this.fetchSkillFileContent(repo, file.source);
1527
+ contentFiles.push({ relativePath: file.relativePath, content });
1528
+ }
1529
+ const skillMd = contentFiles.find(file => file.relativePath === 'SKILL.md');
1530
+ if (!skillMd) continue;
1531
+ const metadata = this.parseSkillMd(String(skillMd.content));
1532
+ const directory = this.resolveSkillDirectory(root.fullDirectory, repo.directory || '', repo);
1533
+ bundles.push({
1534
+ sourceKey: this._skillBundleSourceKey(repo, root.fullDirectory),
1535
+ directory,
1536
+ fullDirectory: root.fullDirectory,
1537
+ files: contentFiles,
1538
+ metadata: {
1539
+ name: metadata.name || directory,
1540
+ description: metadata.description || '',
1541
+ revision: revision || repo.revision || repo.commit || null,
1542
+ repoId: repo.id,
1543
+ repoOwner: repo.owner || null,
1544
+ repoName: repo.name || null,
1545
+ repoBranch: repo.branch || null,
1546
+ repoProvider: repo.provider,
1547
+ repoUrl: repo.repoUrl || buildRepoUrl(repo),
1548
+ readmeUrl: this.buildSkillReadmeUrl(repo, root.fullDirectory)
1549
+ }
1550
+ });
1551
+ }
1552
+ return bundles;
1553
+ }
1554
+
1555
+ _collectLocalSkillFiles(skillDir) {
1556
+ const files = [];
1557
+ const visit = currentDir => {
1558
+ let entries;
1559
+ try {
1560
+ if (fs.lstatSync(currentDir).isSymbolicLink()) {
1561
+ throw new Error(`Skill repository contains symlink: ${currentDir}`);
1562
+ }
1563
+ entries = fs.readdirSync(currentDir, { withFileTypes: true });
1564
+ } catch (error) {
1565
+ if (error.message.includes('symlink')) throw error;
1566
+ throw new Error(`Unable to read local Skill directory: ${currentDir}`, { cause: error });
1567
+ }
1568
+ for (const entry of entries) {
1569
+ const sourcePath = path.join(currentDir, entry.name);
1570
+ if (entry.isSymbolicLink()) {
1571
+ throw new Error(`Skill repository contains symlink: ${sourcePath}`);
1572
+ }
1573
+ if (entry.isDirectory()) {
1574
+ if (entry.name === 'node_modules') continue;
1575
+ if (currentDir !== skillDir && fs.existsSync(path.join(sourcePath, 'SKILL.md'))) {
1576
+ continue;
1577
+ }
1578
+ visit(sourcePath);
1579
+ continue;
1580
+ }
1581
+ if (!entry.isFile()) continue;
1582
+ const relativePath = normalizeSkillRelativePath(path.relative(skillDir, sourcePath), 'skill file path', {
1583
+ allowHiddenSegments: true
1584
+ });
1585
+ files.push({ relativePath, content: fs.readFileSync(sourcePath) });
1586
+ }
1587
+ };
1588
+ visit(skillDir);
1589
+ return files;
1590
+ }
1591
+
1592
+ _registerCreatedSkill({ root, directory, scope = 'user', cwd = null }) {
1593
+ const files = this._collectLocalSkillFiles(root);
1594
+ const normalized = this.formatAdapter.normalize({
1595
+ platform: this.platform,
1596
+ files,
1597
+ sourceMetadata: { name: directory }
1598
+ });
1599
+ const projectPath = scope === 'project' ? this._skillCwdKey({ cwd }) : null;
1600
+ const sourceKey = projectPath
1601
+ ? `local:${this.platform}:project:${projectPath}:${directory}`
1602
+ : `local:${this.platform}:${directory}`;
1603
+ const controlKey = `skill:${this.platform}:${scope}:${projectPath || 'user'}:${sourceKey}`;
1604
+ const projectionCapability = this._skillProjectionCapability(scope);
1605
+ const published = this.artifactStore
1606
+ ? this.artifactStore.publishSkill({
1607
+ platform: this.platform,
1608
+ sourceKey,
1609
+ format: normalized.format,
1610
+ files: normalized.files,
1611
+ metadata: {
1612
+ name: directory,
1613
+ directory,
1614
+ sourceProvider: 'local',
1615
+ sourceScope: scope,
1616
+ ...(projectPath ? { projectPath } : {}),
1617
+ revision: null
1618
+ }
1619
+ })
1620
+ : {
1621
+ root,
1622
+ state: 'ready',
1623
+ contentHash: null,
1624
+ fetchedAt: Date.now()
1625
+ };
1626
+ const entry = {
1627
+ kind: 'skill',
1628
+ controlKey,
1629
+ platform: this.platform,
1630
+ scope,
1631
+ projectPath,
1632
+ sourceKey,
1633
+ source: {
1634
+ kind: 'local',
1635
+ repoId: null,
1636
+ fullDirectory: directory,
1637
+ revision: null
1638
+ },
1639
+ artifact: {
1640
+ root: published.root,
1641
+ contentHash: published.contentHash || null,
1642
+ state: published.state || 'ready',
1643
+ fetchedAt: published.fetchedAt || Date.now()
1644
+ },
1645
+ targetDirectory: directory,
1646
+ cached: true,
1647
+ enabled: false,
1648
+ trust: 'pending',
1649
+ projection: {
1650
+ mode: projectionCapability.mode,
1651
+ state: projectionCapability.mode === 'native-copy' ? 'disabled' : 'unsupported',
1652
+ path: null,
1653
+ updatedAt: null
1654
+ },
1655
+ managed: true,
1656
+ lastError: null,
1657
+ createdAt: Date.now(),
1658
+ updatedAt: Date.now()
1659
+ };
1660
+ const registered = this.controlService?.registerSkill
1661
+ ? this.controlService.registerSkill(entry)
1662
+ : entry;
1663
+ return {
1664
+ controlKey: registered.controlKey,
1665
+ artifact: registered.artifact,
1666
+ cached: true,
1667
+ enabled: false,
1668
+ trust: 'pending',
1669
+ managed: true,
1670
+ warnings: normalized.warnings
1671
+ };
1672
+ }
1673
+
1674
+ registerTemplateSkill({ directory, files = null, repo = null, fullDirectory = null, scope = 'user', cwd = null } = {}) {
1675
+ const safeDirectory = this.normalizeSkillDirectory(directory);
1676
+ const projectPath = scope === 'project' ? this._skillCwdKey({ cwd }) : null;
1677
+ const projectionCapability = this._skillProjectionCapability(scope);
1678
+ if (scope === 'project' && !projectPath) {
1679
+ throw new Error('Project template Skill requires a valid cwd');
1680
+ }
1681
+
1682
+ if (repo) {
1683
+ const normalizedRepo = this.normalizeRepoConfig(repo);
1684
+ const sourceDirectory = fullDirectory || safeDirectory;
1685
+ const sourceKey = this._skillBundleSourceKey(normalizedRepo, sourceDirectory);
1686
+ const controlKey = `skill:${this.platform}:user:user:${sourceKey}`;
1687
+ const existing = this.controlService?.getSkill?.(controlKey, { scope: 'user' }) || null;
1688
+ const entry = existing || {
1689
+ kind: 'skill',
1690
+ controlKey,
1691
+ platform: this.platform,
1692
+ scope: 'user',
1693
+ projectPath: null,
1694
+ sourceKey,
1695
+ source: {
1696
+ kind: 'remote',
1697
+ repoId: normalizedRepo.id,
1698
+ fullDirectory: sourceDirectory,
1699
+ revision: null
1700
+ },
1701
+ artifact: { root: null, contentHash: null, state: 'metadata_only', fetchedAt: null },
1702
+ targetDirectory: safeDirectory,
1703
+ cached: false,
1704
+ enabled: false,
1705
+ trust: 'pending',
1706
+ projection: {
1707
+ mode: projectionCapability.mode,
1708
+ state: projectionCapability.mode === 'native-copy' ? 'disabled' : 'unsupported',
1709
+ path: null,
1710
+ updatedAt: null
1711
+ },
1712
+ managed: true,
1713
+ lastError: null,
1714
+ createdAt: Date.now(),
1715
+ updatedAt: Date.now()
1716
+ };
1717
+ const registered = existing || this.controlService?.registerSkill?.(entry) || entry;
1718
+ return {
1719
+ status: 'pending_refresh',
1720
+ controlKey: registered.controlKey,
1721
+ cached: registered.cached,
1722
+ enabled: registered.enabled,
1723
+ trust: registered.trust,
1724
+ managed: true
1725
+ };
1726
+ }
1727
+
1728
+ let sourceFiles = files;
1729
+ if (!Array.isArray(sourceFiles)) {
1730
+ const localRoot = this.resolveStoragePath(safeDirectory).path;
1731
+ if (!fs.existsSync(localRoot)) throw new Error(`本地模板 Skill "${safeDirectory}" 不存在`);
1732
+ sourceFiles = this._collectLocalSkillFiles(localRoot);
1733
+ } else {
1734
+ sourceFiles = sourceFiles.map(file => ({
1735
+ relativePath: file.relativePath || file.path,
1736
+ content: file.content,
1737
+ ...(file.encoding ? { encoding: file.encoding } : {}),
1738
+ ...(file.mode ? { mode: file.mode } : {})
1739
+ }));
1740
+ }
1741
+ const normalized = this.formatAdapter.normalize({
1742
+ platform: this.platform,
1743
+ files: sourceFiles,
1744
+ sourceMetadata: { name: safeDirectory }
1745
+ });
1746
+ const sourceKey = projectPath
1747
+ ? `template:${this.platform}:project:${projectPath}:${safeDirectory}`
1748
+ : `template:${this.platform}:${safeDirectory}`;
1749
+ const controlKey = `skill:${this.platform}:${scope}:${projectPath || 'user'}:${sourceKey}`;
1750
+ const scopeOptions = { scope, projectPath };
1751
+ const existing = this.controlService?.getSkill?.(controlKey, scopeOptions) || null;
1752
+ if (existing) {
1753
+ return {
1754
+ status: 'pending',
1755
+ controlKey: existing.controlKey,
1756
+ cached: existing.cached,
1757
+ enabled: existing.enabled,
1758
+ trust: existing.trust,
1759
+ managed: true
1760
+ };
1761
+ }
1762
+ const published = this.artifactStore?.publishSkill?.({
1763
+ platform: this.platform,
1764
+ sourceKey,
1765
+ format: normalized.format,
1766
+ files: normalized.files,
1767
+ metadata: {
1768
+ name: safeDirectory,
1769
+ directory: safeDirectory,
1770
+ sourceProvider: 'template',
1771
+ sourceScope: scope,
1772
+ ...(projectPath ? { projectPath } : {}),
1773
+ revision: null
1774
+ }
1775
+ }) || {
1776
+ root: null,
1777
+ contentHash: null,
1778
+ state: 'ready',
1779
+ fetchedAt: Date.now()
1780
+ };
1781
+ const entry = {
1782
+ kind: 'skill',
1783
+ controlKey,
1784
+ platform: this.platform,
1785
+ scope,
1786
+ projectPath,
1787
+ sourceKey,
1788
+ source: { kind: 'template', repoId: null, fullDirectory: safeDirectory, revision: null },
1789
+ artifact: {
1790
+ root: published.root,
1791
+ contentHash: published.contentHash,
1792
+ state: published.state || 'ready',
1793
+ fetchedAt: published.fetchedAt || Date.now()
1794
+ },
1795
+ targetDirectory: safeDirectory,
1796
+ cached: true,
1797
+ enabled: false,
1798
+ trust: 'pending',
1799
+ projection: {
1800
+ mode: projectionCapability.mode,
1801
+ state: projectionCapability.mode === 'native-copy' ? 'disabled' : 'unsupported',
1802
+ path: null,
1803
+ updatedAt: null
1804
+ },
1805
+ managed: true,
1806
+ createdAt: Date.now(),
1807
+ updatedAt: Date.now()
1808
+ };
1809
+ const registered = this.controlService?.registerSkill?.(entry) || entry;
1810
+ return {
1811
+ status: 'pending',
1812
+ controlKey: registered.controlKey,
1813
+ cached: registered.cached,
1814
+ enabled: registered.enabled,
1815
+ trust: registered.trust,
1816
+ managed: true,
1817
+ warnings: normalized.warnings
1818
+ };
1819
+ }
1820
+
1821
+ _collectLocalSkillBundles(repo) {
1822
+ const repoRoot = resolveExistingLocalRepoRoot(repo.localPath);
1823
+ const safeDirectory = normalizeSkillRelativePath(repo.directory || '', 'skill repository directory', {
1824
+ allowEmpty: true
1825
+ });
1826
+ const scanRoot = safeDirectory
1827
+ ? resolveInsideRoot(repoRoot, safeDirectory, 'Skill repository directory', { allowHiddenSegments: true })
1828
+ : repoRoot;
1829
+ assertNoSymlinkComponents(repoRoot, scanRoot, fs);
1830
+ if (!fs.existsSync(scanRoot)) throw new Error(`Local repo path not found: ${scanRoot}`);
1831
+
1832
+ const bundles = [];
1833
+ const visit = currentDir => {
1834
+ if (fs.lstatSync(currentDir).isSymbolicLink()) {
1835
+ throw new Error(`Skill repository contains symlink: ${currentDir}`);
1836
+ }
1837
+ const skillMdPath = path.join(currentDir, 'SKILL.md');
1838
+ if (fs.existsSync(skillMdPath)) {
1839
+ if (fs.lstatSync(skillMdPath).isSymbolicLink()) {
1840
+ throw new Error(`Skill repository contains symlink: ${skillMdPath}`);
1841
+ }
1842
+ const files = this._collectLocalSkillFiles(currentDir);
1843
+ const skillMd = files.find(file => file.relativePath === 'SKILL.md');
1844
+ const metadata = this.parseSkillMd(String(skillMd.content));
1845
+ const fullDirectory = normalizeRepoPath(path.relative(repoRoot, currentDir));
1846
+ const directory = this.resolveSkillDirectory(fullDirectory, repo.directory || '', repo);
1847
+ bundles.push({
1848
+ sourceKey: this._skillBundleSourceKey(repo, fullDirectory),
1849
+ directory,
1850
+ fullDirectory,
1851
+ files,
1852
+ metadata: {
1853
+ name: metadata.name || directory,
1854
+ description: metadata.description || '',
1855
+ revision: repo.revision || null,
1856
+ repoId: repo.id,
1857
+ repoProvider: 'local',
1858
+ repoLocalPath: repo.localPath,
1859
+ readmeUrl: null
1860
+ }
1861
+ });
1862
+ return;
1863
+ }
1864
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
1865
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
1866
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
1867
+ visit(path.join(currentDir, entry.name));
1868
+ }
1869
+ };
1870
+ visit(scanRoot);
1871
+ return bundles;
1872
+ }
1873
+
1874
+ async fetchRepoSkillBundles(repo) {
1875
+ if (repo.provider === 'local') {
1876
+ return this._collectLocalSkillBundles(repo);
1877
+ }
1878
+ const tree = repo.provider === 'gitlab'
1879
+ ? await this.fetchGitLabTree(repo)
1880
+ : await this.fetchGitHubRepoTree(repo);
1881
+ return this._fetchGitSkillBundles(repo, tree, tree.revision || repo.revision || repo.commit || null);
1882
+ }
1883
+
1884
+ _controlEntryForBundle(bundle, repo, artifact, scope = 'user', projectPath = null) {
1885
+ const normalizedProjectPath = scope === 'project' ? String(projectPath || '').trim() : null;
1886
+ if (scope === 'project' && !normalizedProjectPath) {
1887
+ throw new Error('Project Skill refresh requires a canonical projectPath');
1888
+ }
1889
+ const sourceKey = bundle.sourceKey;
1890
+ const location = normalizedProjectPath || 'user';
1891
+ const controlKey = `skill:${this.platform}:${scope}:${location}:${sourceKey}`;
1892
+ const existing = this.controlService?.getSkill?.(controlKey, {
1893
+ scope,
1894
+ projectPath: normalizedProjectPath
1895
+ }) || null;
1896
+ const revision = bundle.metadata?.revision || null;
1897
+ const changed = !existing
1898
+ || existing.artifact?.contentHash !== artifact.contentHash
1899
+ || (existing.source?.revision || null) !== revision;
1900
+ let staleProjectionError = null;
1901
+ if (changed && (existing?.enabled || existing?.projection?.state === 'enabled') && this.controlService?.setSkillEnabled) {
1902
+ try {
1903
+ const disabled = this.controlService.setSkillEnabled({
1904
+ platform: this.platform,
1905
+ controlKey,
1906
+ scope,
1907
+ projectPath: normalizedProjectPath,
1908
+ enabled: false
1909
+ });
1910
+ if (disabled?.status === 'projection_failed') staleProjectionError = disabled.lastError || 'Unable to remove previous projection';
1911
+ } catch (error) {
1912
+ staleProjectionError = error.message;
1913
+ }
1914
+ }
1915
+ const next = {
1916
+ ...bundle.metadata,
1917
+ kind: 'skill',
1918
+ controlKey,
1919
+ platform: this.platform,
1920
+ scope,
1921
+ projectPath: normalizedProjectPath,
1922
+ sourceKey,
1923
+ source: {
1924
+ kind: repo.provider === 'local' ? 'local' : 'remote',
1925
+ repoId: repo.id || null,
1926
+ fullDirectory: bundle.fullDirectory,
1927
+ revision
1928
+ },
1929
+ artifact: {
1930
+ root: artifact.root,
1931
+ contentHash: artifact.contentHash,
1932
+ format: artifact.format || null,
1933
+ state: artifact.state || 'ready',
1934
+ fetchedAt: artifact.fetchedAt || Date.now()
1935
+ },
1936
+ targetDirectory: bundle.directory,
1937
+ cached: true,
1938
+ enabled: changed ? false : existing.enabled === true,
1939
+ trust: changed ? 'needs_review' : (existing.trust || 'pending'),
1940
+ projection: changed
1941
+ ? { ...(existing?.projection || {}), mode: existing?.projection?.mode || 'native-copy', state: 'disabled', updatedAt: Date.now() }
1942
+ : (existing?.projection || { mode: 'native-copy', state: 'disabled', updatedAt: Date.now() }),
1943
+ managed: true,
1944
+ lastError: staleProjectionError,
1945
+ createdAt: existing?.createdAt || Date.now(),
1946
+ updatedAt: Date.now()
1947
+ };
1948
+ return this.controlService?.registerSkill
1949
+ ? this.controlService.registerSkill(next)
1950
+ : next;
1951
+ }
1952
+
1953
+ async refreshRemoteSkills({ platform = this.platform, scope = 'user', projectPath = null, reportProgress = () => {} } = {}) {
1954
+ if (platform !== this.platform) throw new Error(`SkillService platform mismatch: ${platform}`);
1955
+ if (!['user', 'project'].includes(scope)) throw new Error('Invalid Skill refresh scope');
1956
+ const normalizedProjectPath = scope === 'project' ? this._skillCwdKey({ cwd: projectPath }) : null;
1957
+ if (scope === 'project' && !normalizedProjectPath) {
1958
+ throw new Error('Project Skill refresh requires a canonical projectPath');
1959
+ }
1960
+ const repos = this.loadRepos().filter(repo => repo.enabled);
1961
+ const successfulSkills = [];
1962
+ const failedRepos = [];
1963
+ const updatedControlKeys = [];
1964
+ const results = await Promise.allSettled(repos.map(async repo => {
1965
+ const repoId = repo.id || buildRepoLabel(repo);
1966
+ reportProgress({ [repoId]: { status: 'running' } });
1967
+ try {
1968
+ const bundles = await this.fetchRepoSkillBundles(repo);
1969
+ for (const bundle of bundles) {
1970
+ const scopedSourceKey = scope === 'project'
1971
+ ? `${bundle.sourceKey}:project:${normalizedProjectPath}`
1972
+ : bundle.sourceKey;
1973
+ const scopedBundle = scopedSourceKey === bundle.sourceKey
1974
+ ? bundle
1975
+ : { ...bundle, sourceKey: scopedSourceKey };
1976
+ const normalized = this.formatAdapter.normalize({
1977
+ platform: this.platform,
1978
+ files: scopedBundle.files,
1979
+ sourceMetadata: scopedBundle.metadata
1980
+ });
1981
+ if (!this.artifactStore) throw new Error('Skill artifact store is unavailable');
1982
+ const artifact = await this.artifactStore.publishSkill({
1983
+ platform: this.platform,
1984
+ sourceKey: scopedBundle.sourceKey,
1985
+ format: normalized.format,
1986
+ files: normalized.files,
1987
+ metadata: {
1988
+ ...scopedBundle.metadata,
1989
+ directory: scopedBundle.directory,
1990
+ fullDirectory: scopedBundle.fullDirectory,
1991
+ sourceProvider: repo.provider === 'local' ? 'local-repo' : 'remote',
1992
+ sourceScope: scope,
1993
+ ...(normalizedProjectPath ? { projectPath: normalizedProjectPath } : {}),
1994
+ revision: scopedBundle.metadata?.revision || null
1995
+ }
1996
+ });
1997
+ const control = this._controlEntryForBundle(
1998
+ scopedBundle,
1999
+ repo,
2000
+ artifact,
2001
+ scope,
2002
+ normalizedProjectPath
2003
+ );
2004
+ if (control?.controlKey) updatedControlKeys.push(control.controlKey);
2005
+ successfulSkills.push({ ...scopedBundle, artifact, warnings: normalized.warnings });
2006
+ }
2007
+ reportProgress({ [repoId]: { status: 'succeeded', skillCount: bundles.length } });
2008
+ return { repo, bundles };
2009
+ } catch (error) {
2010
+ const safeError = sanitizeRefreshError(error?.message || error);
2011
+ failedRepos.push({ repoId, error: safeError });
2012
+ reportProgress({ [repoId]: { status: 'failed', error: safeError } });
2013
+ throw error;
2014
+ }
2015
+ }));
2016
+
2017
+ const fetchedRepos = results.filter(result => result.status === 'fulfilled').length;
2018
+ const fetchedSkills = successfulSkills.length;
2019
+ const status = failedRepos.length === 0
2020
+ ? 'succeeded'
2021
+ : (fetchedRepos > 0 || fetchedSkills > 0 ? 'partial' : 'failed');
2022
+ if (fetchedSkills > 0) {
2023
+ const fetchedAt = Date.now();
2024
+ const summaries = successfulSkills.map(bundle => ({
2025
+ ...this.createSkillListItem({
2026
+ metadata: bundle.metadata,
2027
+ repo: repos.find(repo => repo.id === bundle.metadata?.repoId) || {},
2028
+ directory: bundle.directory,
2029
+ fullDirectory: bundle.fullDirectory
2030
+ }),
2031
+ sourceKey: bundle.sourceKey,
2032
+ sourceProvider: bundle.metadata?.repoProvider === 'local' ? 'local-repo' : 'remote',
2033
+ sourceScope: scope,
2034
+ projectPath: normalizedProjectPath,
2035
+ artifactRoot: bundle.artifact.root,
2036
+ artifactState: bundle.artifact.state || 'ready',
2037
+ contentHash: bundle.artifact.contentHash,
2038
+ revision: bundle.metadata?.revision || null,
2039
+ cached: true,
2040
+ installed: false,
2041
+ fetchedAt
2042
+ }));
2043
+ this._remoteSkillsCache = summaries;
2044
+ this._remoteSkillsFetchedAt = fetchedAt;
2045
+ this.saveCacheToFile(summaries, fetchedAt);
2046
+ }
2047
+ return {
2048
+ status,
2049
+ fetchedRepos,
2050
+ fetchedSkills,
2051
+ failedRepos,
2052
+ updatedControlKeys
2053
+ };
2054
+ }
2055
+
757
2056
  async fetchGitHubRepoSkills(repo) {
758
2057
  const skills = [];
759
2058
 
@@ -857,13 +2156,14 @@ class SkillService {
857
2156
 
858
2157
  async fetchLocalRepoSkills(repo) {
859
2158
  const skills = [];
860
- const repoRoot = repo.localPath;
2159
+ const repoRoot = resolveExistingLocalRepoRoot(repo.localPath);
861
2160
  const safeDirectory = normalizeSkillRelativePath(repo.directory || '', 'skill repository directory', {
862
2161
  allowEmpty: true
863
2162
  });
864
2163
  const scanRoot = safeDirectory
865
2164
  ? resolveInsideRoot(repoRoot, safeDirectory, 'Skill repository directory', { allowHiddenSegments: true })
866
2165
  : repoRoot;
2166
+ assertNoSymlinkComponents(repoRoot, scanRoot, fs);
867
2167
 
868
2168
  if (!fs.existsSync(scanRoot)) {
869
2169
  throw new Error(`Local repo path not found: ${scanRoot}`);
@@ -901,10 +2201,16 @@ class SkillService {
901
2201
  * 递归扫描外部本地仓库目录
902
2202
  */
903
2203
  scanRepoLocalDir(currentDir, repoRoot, skills, repo) {
904
- const skillMdPath = path.join(currentDir, 'SKILL.md');
2204
+ try {
2205
+ if (fs.lstatSync(currentDir).isSymbolicLink()) return;
2206
+ } catch {
2207
+ return;
2208
+ }
905
2209
 
2210
+ const skillMdPath = path.join(currentDir, 'SKILL.md');
906
2211
  if (fs.existsSync(skillMdPath)) {
907
2212
  try {
2213
+ if (fs.lstatSync(skillMdPath).isSymbolicLink()) return;
908
2214
  const content = fs.readFileSync(skillMdPath, 'utf-8');
909
2215
  const metadata = this.parseSkillMd(content);
910
2216
  const fullDirectory = normalizeRepoPath(path.relative(repoRoot, currentDir));
@@ -925,7 +2231,7 @@ class SkillService {
925
2231
  try {
926
2232
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
927
2233
  for (const entry of entries) {
928
- if (!entry.isDirectory()) continue;
2234
+ if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
929
2235
  if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
930
2236
  this.scanRepoLocalDir(path.join(currentDir, entry.name), repoRoot, skills, repo);
931
2237
  }
@@ -979,8 +2285,10 @@ class SkillService {
979
2285
  return this.fetchGitLabFileContent(repo, file.path);
980
2286
  }
981
2287
  if (repo.provider === 'local') {
2288
+ const repoRoot = resolveExistingLocalRepoRoot(repo.localPath);
982
2289
  const safeFilePath = normalizeSkillRelativePath(file.path, 'skill repository file path');
983
- const localFilePath = resolveInsideRoot(repo.localPath, safeFilePath, 'Skill repository file path', { allowHiddenSegments: true });
2290
+ const localFilePath = resolveInsideRoot(repoRoot, safeFilePath, 'Skill repository file path', { allowHiddenSegments: true });
2291
+ assertNoSymlinkComponents(repoRoot, localFilePath, fs);
984
2292
  return fs.readFileSync(localFilePath, 'utf-8');
985
2293
  }
986
2294
  return this.fetchGitHubBlobContent(file.sha, repo);
@@ -991,7 +2299,7 @@ class SkillService {
991
2299
  */
992
2300
  async fetchGitHubBlobContent(sha, repo) {
993
2301
  const url = `https://api.github.com/repos/${repo.owner}/${repo.name}/git/blobs/${sha}`;
994
- const data = await this.fetchGitHubApi(url);
2302
+ const data = await this.fetchGitHubApi(url, repo);
995
2303
  if (!data || typeof data.content !== 'string') {
996
2304
  throw new Error('Invalid GitHub blob response');
997
2305
  }
@@ -1052,79 +2360,68 @@ class SkillService {
1052
2360
  getGitHubToken(repoOrHost = DEFAULT_GITHUB_HOST) {
1053
2361
  if (repoOrHost && typeof repoOrHost === 'object') {
1054
2362
  const repoToken = this.resolveRepoToken(repoOrHost);
1055
- if (repoToken) {
1056
- return repoToken;
1057
- }
2363
+ if (repoToken) return repoToken;
1058
2364
  }
1059
2365
 
1060
2366
  const host = typeof repoOrHost === 'string'
1061
2367
  ? repoOrHost
1062
2368
  : (repoOrHost?.host || DEFAULT_GITHUB_HOST);
2369
+ const hostname = extractHostname(host) || 'github.com';
1063
2370
 
1064
- // 优先从环境变量获取
1065
- if (process.env.GITHUB_TOKEN) {
1066
- return process.env.GITHUB_TOKEN;
1067
- }
1068
-
2371
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
1069
2372
  const configToken = this.getTokenFromConfigFile('github-token.txt');
1070
- if (configToken) {
1071
- return configToken;
1072
- }
2373
+ if (configToken) return configToken;
2374
+
2375
+ const cached = remoteCredentialCache.get('github', hostname);
2376
+ if (cached.hit) return cached.value;
1073
2377
 
1074
- const hostname = extractHostname(host);
1075
2378
  if (hostname) {
1076
2379
  const ghHostToken = this.getTokenFromCommand('gh', ['auth', 'token', '--hostname', hostname]);
1077
2380
  if (ghHostToken) {
1078
- return ghHostToken;
2381
+ return remoteCredentialCache.set('github', hostname, ghHostToken);
1079
2382
  }
1080
2383
  }
1081
2384
 
1082
2385
  const ghToken = this.getTokenFromCommand('gh', ['auth', 'token']);
1083
2386
  if (ghToken) {
1084
- return ghToken;
2387
+ return remoteCredentialCache.set('github', hostname, ghToken);
1085
2388
  }
1086
2389
 
1087
- return this.getTokenFromGitCredential(host);
2390
+ const credentialToken = this.getTokenFromGitCredential(host);
2391
+ return remoteCredentialCache.set('github', hostname, credentialToken);
1088
2392
  }
1089
2393
 
1090
2394
  getGitLabToken(repoOrHost = DEFAULT_GITLAB_HOST) {
1091
2395
  if (repoOrHost && typeof repoOrHost === 'object') {
1092
2396
  const repoToken = this.resolveRepoToken(repoOrHost);
1093
- if (repoToken) {
1094
- return repoToken;
1095
- }
2397
+ if (repoToken) return repoToken;
1096
2398
  }
1097
2399
 
1098
2400
  const host = typeof repoOrHost === 'string'
1099
- ? repoOrHost
1100
- : (repoOrHost?.host || DEFAULT_GITLAB_HOST);
1101
-
1102
- if (process.env.GITLAB_TOKEN) {
1103
- return process.env.GITLAB_TOKEN;
1104
- }
1105
- if (process.env.GITLAB_PRIVATE_TOKEN) {
1106
- return process.env.GITLAB_PRIVATE_TOKEN;
1107
- }
2401
+ ? repoOrHost
2402
+ : (repoOrHost?.host || DEFAULT_GITLAB_HOST);
2403
+ const hostname = extractHostname(host) || 'gitlab.com';
1108
2404
 
2405
+ if (process.env.GITLAB_TOKEN) return process.env.GITLAB_TOKEN;
2406
+ if (process.env.GITLAB_PRIVATE_TOKEN) return process.env.GITLAB_PRIVATE_TOKEN;
1109
2407
  const configToken = this.getTokenFromConfigFile('gitlab-token.txt');
1110
- if (configToken) {
1111
- return configToken;
1112
- }
2408
+ if (configToken) return configToken;
1113
2409
 
1114
- const hostname = extractHostname(host);
1115
- if (hostname) {
1116
- const glabHostToken = this.getTokenFromCommand('glab', ['auth', 'token', '--hostname', hostname]);
1117
- if (glabHostToken) {
1118
- return glabHostToken;
1119
- }
2410
+ const cached = remoteCredentialCache.get('gitlab', hostname);
2411
+ if (cached.hit) return cached.value;
2412
+
2413
+ const glabHostToken = this.getTokenFromCommand('glab', ['auth', 'token', '--hostname', hostname]);
2414
+ if (glabHostToken) {
2415
+ return remoteCredentialCache.set('gitlab', hostname, glabHostToken);
1120
2416
  }
1121
2417
 
1122
2418
  const glabToken = this.getTokenFromCommand('glab', ['auth', 'token']);
1123
2419
  if (glabToken) {
1124
- return glabToken;
2420
+ return remoteCredentialCache.set('gitlab', hostname, glabToken);
1125
2421
  }
1126
2422
 
1127
- return this.getTokenFromGitCredential(host);
2423
+ const credentialToken = this.getTokenFromGitCredential(host);
2424
+ return remoteCredentialCache.set('gitlab', hostname, credentialToken);
1128
2425
  }
1129
2426
 
1130
2427
  /**
@@ -1174,7 +2471,13 @@ class SkillService {
1174
2471
  if (tree?.truncated) {
1175
2472
  console.warn(`[SkillService] GitHub tree truncated for ${repo.owner}/${repo.name}`);
1176
2473
  }
1177
- return tree?.tree || [];
2474
+ const items = Array.isArray(tree?.tree) ? tree.tree : [];
2475
+ Object.defineProperty(items, 'revision', {
2476
+ value: tree?.sha || repo.revision || repo.commit || null,
2477
+ enumerable: false,
2478
+ configurable: true
2479
+ });
2480
+ return items;
1178
2481
  }
1179
2482
 
1180
2483
  async fetchGitLabApi(url, { raw = false, repo = null } = {}) {
@@ -1242,6 +2545,11 @@ class SkillService {
1242
2545
  page = Number.isFinite(nextPage) && nextPage > 0 ? nextPage : 0;
1243
2546
  }
1244
2547
 
2548
+ Object.defineProperty(tree, 'revision', {
2549
+ value: repo.revision || repo.commit || null,
2550
+ enumerable: false,
2551
+ configurable: true
2552
+ });
1245
2553
  return tree;
1246
2554
  }
1247
2555
 
@@ -1351,17 +2659,38 @@ class SkillService {
1351
2659
  /**
1352
2660
  * 获取文件内容
1353
2661
  */
1354
- async fetchFileContent(url) {
1355
- return new Promise((resolve, reject) => {
1356
- const protocol = url.startsWith('https') ? https : http;
2662
+ async fetchFileContent(url, { allowedHost = null, redirects = 0 } = {}) {
2663
+ const currentUrl = new URL(String(url));
2664
+ if (!['http:', 'https:'].includes(currentUrl.protocol)) {
2665
+ throw new Error('Skill file download requires HTTP(S)');
2666
+ }
2667
+ const host = allowedHost || currentUrl.host;
2668
+ if (currentUrl.host !== host) {
2669
+ throw new Error('Skill file redirect leaves the provider host');
2670
+ }
2671
+ if (redirects > 5) throw new Error('Skill file download redirected too many times');
2672
+ const protocol = currentUrl.protocol === 'https:' ? https : http;
1357
2673
 
1358
- const req = protocol.get(url, {
2674
+ return new Promise((resolve, reject) => {
2675
+ const req = protocol.get(currentUrl, {
1359
2676
  headers: { 'User-Agent': 'cc-cli-skill-service' },
1360
2677
  timeout: 10000
1361
2678
  }, (res) => {
1362
- // 处理重定向
1363
2679
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
1364
- this.fetchFileContent(res.headers.location).then(resolve).catch(reject);
2680
+ let nextUrl;
2681
+ try {
2682
+ nextUrl = new URL(res.headers.location, currentUrl);
2683
+ } catch {
2684
+ reject(new Error('Invalid Skill file redirect'));
2685
+ return;
2686
+ }
2687
+ if (nextUrl.host !== host) {
2688
+ reject(new Error('Skill file redirect leaves the provider host'));
2689
+ return;
2690
+ }
2691
+ this.fetchFileContent(nextUrl.toString(), { allowedHost: host, redirects: redirects + 1 })
2692
+ .then(resolve)
2693
+ .catch(reject);
1365
2694
  return;
1366
2695
  }
1367
2696
 
@@ -1411,23 +2740,114 @@ class SkillService {
1411
2740
  return normalizeSkillRelativePath(directory, label);
1412
2741
  }
1413
2742
 
1414
- resolveInstallPath(directory, label = 'skill directory') {
1415
- this.refreshOmpPaths();
2743
+ resolveScopeOptions(options = {}) {
2744
+ const scope = options.scope || 'user';
2745
+ if (scope === 'user') {
2746
+ return {
2747
+ scope,
2748
+ cwd: '',
2749
+ roots: [this.installDir],
2750
+ writeRoot: this.installDir
2751
+ };
2752
+ }
2753
+ if (scope !== 'project') {
2754
+ throw new Error('Invalid scope: expected "user" or "project"');
2755
+ }
2756
+ if (!options.cwd) {
2757
+ throw new Error('Project scope requires a valid cwd');
2758
+ }
2759
+
2760
+ const projectRoot = assertExistingProjectRoot(options.cwd, fs);
2761
+ const mapping = this.registry?.resolve?.(this.platform)?.projectResources?.skills;
2762
+ if (!mapping) {
2763
+ throw new Error(`Project Skills are not supported for ${this.platform}`);
2764
+ }
2765
+
2766
+ const roots = mapping.readRoots.map(relativeRoot => resolveProjectTarget(
2767
+ projectRoot,
2768
+ relativeRoot,
2769
+ 'project skill root',
2770
+ { allowRoot: true },
2771
+ fs
2772
+ ));
2773
+ const writeRoot = resolveProjectTarget(
2774
+ projectRoot,
2775
+ mapping.canonicalRoot,
2776
+ 'project skill root',
2777
+ { allowRoot: true },
2778
+ fs
2779
+ );
2780
+ return { scope, cwd: projectRoot, roots, writeRoot };
2781
+ }
2782
+
2783
+ resolveInstallPath(directory, label = 'skill directory', options = {}) {
2784
+ if (options.refresh !== false) {
2785
+ this.refreshOmpPaths();
2786
+ }
1416
2787
  const safeDirectory = this.normalizeSkillDirectory(directory, label);
2788
+ const scopeOptions = this.resolveScopeOptions(options);
2789
+ const target = resolveInsideRoot(scopeOptions.writeRoot, safeDirectory, label, { allowHiddenSegments: true });
2790
+ assertNoSymlinkComponents(scopeOptions.writeRoot, target, fs);
1417
2791
  return {
1418
2792
  safeDirectory,
1419
- path: resolveInsideRoot(this.installDir, safeDirectory, label, { allowHiddenSegments: true })
2793
+ scope: scopeOptions.scope,
2794
+ path: target
1420
2795
  };
1421
2796
  }
1422
2797
 
2798
+
1423
2799
  resolveStoragePath(directory, label = 'skill directory') {
1424
2800
  const safeDirectory = this.normalizeSkillDirectory(directory, label);
2801
+ const target = resolveInsideRoot(this.storageDir, safeDirectory, label, { allowHiddenSegments: true });
2802
+ assertNoSymlinkComponents(this.storageDir, target, fs);
1425
2803
  return {
1426
2804
  safeDirectory,
1427
- path: resolveInsideRoot(this.storageDir, safeDirectory, label, { allowHiddenSegments: true })
2805
+ path: target
1428
2806
  };
1429
2807
  }
1430
2808
 
2809
+ resolveWritableSkillPath(directory, options = {}) {
2810
+ if (options.scope === 'project') {
2811
+ return this.resolveInstallPath(directory, 'skill directory', options);
2812
+ }
2813
+ return this.resolveStoragePath(directory);
2814
+ }
2815
+
2816
+ _writeSkillFileAtomic(filePath, content, targetRoot = this.storageDir) {
2817
+ const parentDir = path.dirname(filePath);
2818
+ assertNoSymlinkComponents(targetRoot, parentDir, fs);
2819
+ assertNoSymlinkComponents(targetRoot, filePath, fs);
2820
+ fs.mkdirSync(parentDir, { recursive: true });
2821
+ assertNoSymlinkComponents(targetRoot, parentDir, fs);
2822
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
2823
+ let descriptor;
2824
+ try {
2825
+ const flags = fs.constants.O_WRONLY
2826
+ | fs.constants.O_CREAT
2827
+ | fs.constants.O_EXCL
2828
+ | (fs.constants.O_NOFOLLOW || 0);
2829
+ descriptor = fs.openSync(tempPath, flags, 0o600);
2830
+ fs.writeFileSync(descriptor, content);
2831
+ fs.closeSync(descriptor);
2832
+ descriptor = undefined;
2833
+ const tempRealPath = fs.realpathSync(tempPath);
2834
+ const rootRealPath = fs.realpathSync(targetRoot);
2835
+ const relative = path.relative(rootRealPath, tempRealPath);
2836
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
2837
+ throw new Error('Skill file escapes target root');
2838
+ }
2839
+ fs.renameSync(tempPath, filePath);
2840
+ } catch (error) {
2841
+ if (descriptor !== undefined) {
2842
+ try { fs.closeSync(descriptor); } catch (_) {}
2843
+ }
2844
+ try {
2845
+ if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
2846
+ } catch (_) {}
2847
+ throw error;
2848
+ }
2849
+ }
2850
+
1431
2851
  isProtectedSkillDirectory(directory) {
1432
2852
  return this.platform === 'codex' && pathHasProtectedSegment(directory, ['.system']);
1433
2853
  }
@@ -1435,15 +2855,16 @@ class SkillService {
1435
2855
  /**
1436
2856
  * 检查技能是否已安装
1437
2857
  */
1438
- isInstalled(directory) {
2858
+ isInstalled(directory, options = {}) {
1439
2859
  try {
1440
- const { path: skillPath } = this.resolveInstallPath(directory);
2860
+ const { path: skillPath } = this.resolveInstallPath(directory, 'skill directory', options);
1441
2861
  return fs.existsSync(path.join(skillPath, 'SKILL.md'));
1442
2862
  } catch {
1443
2863
  return false;
1444
2864
  }
1445
2865
  }
1446
2866
 
2867
+
1447
2868
  /**
1448
2869
  * 合并本地 cc-tool 托管的技能(扫描 storageDir,根据 installDir 判断安装状态)
1449
2870
  */
@@ -1457,20 +2878,47 @@ class SkillService {
1457
2878
  /**
1458
2879
  * 合并平台原生已安装技能(扫描 installDir)。
1459
2880
  */
1460
- mergeInstalledSkills(skills) {
2881
+ mergeInstalledSkills(skills, options = {}) {
1461
2882
  if (!fs.existsSync(this.installDir)) return;
1462
2883
  this.scanLocalDir(this.installDir, this.installDir, skills, {
1463
2884
  includeHiddenDirs: this.platform === 'codex',
1464
2885
  forceInstalled: true,
1465
- source: 'native-installed'
2886
+ source: 'native-installed',
2887
+ sourceScope: 'user',
2888
+ installOptions: { scope: 'user', refresh: false }
1466
2889
  });
1467
2890
  }
1468
2891
 
2892
+ mergeProjectSkills(skills, options = {}) {
2893
+ const scopeOptions = this.resolveScopeOptions(options);
2894
+ for (const root of scopeOptions.roots) {
2895
+ if (!fs.existsSync(root)) continue;
2896
+ this.scanLocalDir(root, root, skills, {
2897
+ includeHiddenDirs: true,
2898
+ forceInstalled: true,
2899
+ source: 'project-installed',
2900
+ sourceScope: 'project',
2901
+ installOptions: { scope: 'project', cwd: scopeOptions.cwd, refresh: false }
2902
+ });
2903
+ }
2904
+ }
2905
+
1469
2906
  /**
1470
2907
  * 递归扫描本地目录
1471
2908
  */
1472
2909
  scanLocalDir(currentDir, baseDir, skills, options = {}) {
2910
+ try {
2911
+ if (fs.lstatSync(currentDir).isSymbolicLink()) return;
2912
+ } catch (error) {
2913
+ if (error.code !== 'ENOENT') return;
2914
+ }
2915
+
1473
2916
  const skillMdPath = path.join(currentDir, 'SKILL.md');
2917
+ try {
2918
+ if (fs.existsSync(skillMdPath) && fs.lstatSync(skillMdPath).isSymbolicLink()) return;
2919
+ } catch {
2920
+ return;
2921
+ }
1474
2922
 
1475
2923
  if (fs.existsSync(skillMdPath)) {
1476
2924
  const directory = currentDir === baseDir
@@ -1479,12 +2927,16 @@ class SkillService {
1479
2927
 
1480
2928
  const protectedSkill = this.isProtectedSkillDirectory(directory);
1481
2929
  const skillSource = protectedSkill ? 'system-installed' : (options.source || 'local');
2930
+ const sourceScope = options.sourceScope || 'user';
2931
+ const sourceProvider = options.source === 'native-installed' ? 'native' : 'cc-tool';
1482
2932
 
1483
- // 判断是否已安装到平台目录
1484
2933
  let isInstalled = options.forceInstalled === true;
1485
2934
  if (!isInstalled) {
1486
2935
  try {
1487
- isInstalled = fs.existsSync(path.join(this.resolveInstallPath(directory).path, 'SKILL.md'));
2936
+ isInstalled = fs.existsSync(path.join(
2937
+ this.resolveInstallPath(directory, 'skill directory', options.installOptions || {}).path,
2938
+ 'SKILL.md'
2939
+ ));
1488
2940
  } catch {
1489
2941
  isInstalled = false;
1490
2942
  }
@@ -1493,9 +2945,15 @@ class SkillService {
1493
2945
  try {
1494
2946
  const content = fs.readFileSync(skillMdPath, 'utf-8');
1495
2947
  const metadata = this.parseSkillMd(content);
2948
+ const sourceKey = protectedSkill
2949
+ ? `system:${this.platform}:${directory}`
2950
+ : (sourceProvider === 'native'
2951
+ ? `native:${this.platform}:${directory}`
2952
+ : `local:${this.platform}:${directory}`);
1496
2953
 
1497
2954
  const skill = {
1498
- key: `local:${directory}`,
2955
+ key: `local:${sourceScope}:${directory}`,
2956
+ sourceKey,
1499
2957
  name: metadata.name || directory,
1500
2958
  description: metadata.description || '',
1501
2959
  directory,
@@ -1504,8 +2962,8 @@ class SkillService {
1504
2962
  source: skillSource,
1505
2963
  protected: protectedSkill,
1506
2964
  readonly: false,
1507
- sourceProvider: options.source === 'native-installed' ? 'native' : 'cc-tool',
1508
- sourceScope: 'user',
2965
+ sourceProvider,
2966
+ sourceScope,
1509
2967
  sourcePath: skillMdPath,
1510
2968
  shadowedSources: [],
1511
2969
  readmeUrl: null,
@@ -1524,18 +2982,21 @@ class SkillService {
1524
2982
  console.warn(`[SkillService] Parse local skill ${directory} error:`, err.message);
1525
2983
  }
1526
2984
 
1527
- return; // 找到 SKILL.md 后不再递归
2985
+ return;
1528
2986
  }
1529
2987
 
1530
- // 递归子目录
1531
2988
  try {
1532
2989
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
1533
2990
  for (const entry of entries) {
1534
- if (entry.isDirectory() && (options.includeHiddenDirs || !entry.name.startsWith('.'))) {
2991
+ if (
2992
+ entry.isDirectory()
2993
+ && !entry.isSymbolicLink()
2994
+ && (options.includeHiddenDirs || !entry.name.startsWith('.'))
2995
+ ) {
1535
2996
  this.scanLocalDir(path.join(currentDir, entry.name), baseDir, skills, options);
1536
2997
  }
1537
2998
  }
1538
- } catch (err) {
2999
+ } catch {
1539
3000
  // 忽略读取错误
1540
3001
  }
1541
3002
  }
@@ -1548,250 +3009,118 @@ class SkillService {
1548
3009
  const identityMap = new Map();
1549
3010
  const realPathMap = new Map();
1550
3011
  const managedOverlayTargets = new Map();
3012
+ const artifactTargets = new Map();
3013
+ const priority = skill => {
3014
+ const isArtifact = Boolean(skill.artifactRoot || skill.artifact?.root);
3015
+ const sourceProvider = skill.sourceProvider || skill.source;
3016
+ if (!isArtifact && skill.sourceScope === 'project') return 4;
3017
+ if (!isArtifact && ['cc-tool', 'native', 'local'].includes(sourceProvider)) return 3;
3018
+ if (skill.sourceScope === 'project' || isArtifact) return 2;
3019
+ if (skill.sourceProvider === 'native' || skill.source === 'native-installed') return 1;
3020
+ if (skill.installed) return 1;
3021
+ return 0;
3022
+ };
1551
3023
 
1552
3024
  for (const skill of skills) {
1553
3025
  const normalizedDirectory = normalizeRepoPath(skill.directory).toLowerCase();
1554
3026
  const identity = this.platform === 'omp'
1555
- ? `name:${String(skill.name || skill.directory).toLowerCase()}`
1556
- : [
3027
+ ? (skill.sourceKey || `${skill.sourceProvider || skill.source || 'unknown'}:${String(skill.name || skill.directory).toLowerCase()}`)
3028
+ : (skill.sourceKey || [
1557
3029
  normalizedDirectory,
1558
3030
  skill.repoId || ''
1559
- ].join('::');
1560
- const realPath = skill.realPath || (() => {
3031
+ ].join('::'));
3032
+ let realPath = skill.realPath || '';
3033
+ if (!realPath && skill.sourcePath) {
1561
3034
  try {
1562
- return skill.sourcePath ? fs.realpathSync(skill.sourcePath) : '';
3035
+ if (!fs.lstatSync(skill.sourcePath).isSymbolicLink()) {
3036
+ realPath = fs.realpathSync(skill.sourcePath);
3037
+ }
1563
3038
  } catch {
1564
- return skill.sourcePath || '';
3039
+ realPath = '';
1565
3040
  }
1566
- })();
1567
- const isManagedOverlay = !skill.repoId && (
3041
+ }
3042
+ const isArtifact = Boolean(skill.artifactRoot || skill.artifact?.root);
3043
+ const isManagedOverlay = skill.projectionOverlay || (!skill.repoId && (
1568
3044
  skill.sourceProvider === 'cc-tool'
1569
3045
  || skill.sourceProvider === 'native'
1570
3046
  || ['local', 'native-installed', 'system-installed'].includes(skill.source)
1571
- );
3047
+ ));
1572
3048
  const existingIndex = identityMap.get(identity)
1573
3049
  ?? (realPath ? realPathMap.get(realPath) : undefined)
1574
- ?? (isManagedOverlay ? managedOverlayTargets.get(normalizedDirectory) : undefined);
3050
+ ?? (isManagedOverlay ? managedOverlayTargets.get(normalizedDirectory) : undefined)
3051
+ ?? (isManagedOverlay ? artifactTargets.get(normalizedDirectory) : undefined);
1575
3052
 
1576
3053
  if (existingIndex == null) {
1577
3054
  identityMap.set(identity, deduplicated.length);
1578
3055
  if (realPath) realPathMap.set(realPath, deduplicated.length);
1579
- if (skill.repoId && !managedOverlayTargets.has(normalizedDirectory)) {
3056
+ if (isManagedOverlay && !managedOverlayTargets.has(normalizedDirectory)) {
1580
3057
  managedOverlayTargets.set(normalizedDirectory, deduplicated.length);
1581
3058
  }
3059
+ if (isArtifact && !artifactTargets.has(normalizedDirectory)) {
3060
+ artifactTargets.set(normalizedDirectory, deduplicated.length);
3061
+ }
1582
3062
  deduplicated.push(skill);
1583
3063
  continue;
1584
3064
  }
1585
3065
 
1586
3066
  const existing = deduplicated[existingIndex];
1587
- const shadow = skill.sourceProvider || skill.sourcePath
3067
+ const incomingShadow = skill.sourceProvider || skill.sourcePath
1588
3068
  ? {
1589
3069
  sourceProvider: skill.sourceProvider || skill.source || 'unknown',
1590
3070
  sourceScope: skill.sourceScope || 'user',
1591
3071
  sourcePath: skill.sourcePath || ''
1592
3072
  }
1593
3073
  : null;
1594
- if (shadow) {
1595
- existing.shadowedSources = [...(existing.shadowedSources || []), shadow];
3074
+ const existingShadow = existing.sourceProvider || existing.sourcePath
3075
+ ? {
3076
+ sourceProvider: existing.sourceProvider || existing.source || 'unknown',
3077
+ sourceScope: existing.sourceScope || 'user',
3078
+ sourcePath: existing.sourcePath || ''
3079
+ }
3080
+ : null;
3081
+ if (
3082
+ (skill.sourceProvider === 'native' || skill.source === 'native-installed')
3083
+ && existing.artifactRoot
3084
+ && (existing.source === 'remote' || existing.sourceProvider === 'local-repo' || existing.repoId)
3085
+ && existing.sourceScope === (skill.sourceScope || 'user')
3086
+ ) {
3087
+ existing.shadowedSources = [
3088
+ ...(existing.shadowedSources || []),
3089
+ ...(incomingShadow ? [incomingShadow] : [])
3090
+ ];
3091
+ if (skill.installed) existing.installed = true;
3092
+ continue;
1596
3093
  }
1597
3094
 
1598
- const shouldPreferIncoming = Boolean(skill.installed && !existing.installed);
1599
- if (shouldPreferIncoming) {
3095
+ if (priority(skill) > priority(existing)) {
1600
3096
  deduplicated[existingIndex] = {
1601
- ...existing,
1602
3097
  ...skill,
1603
- installed: true,
1604
- shadowedSources: existing.shadowedSources || []
3098
+ installed: Boolean(skill.installed || existing.installed),
3099
+ shadowedSources: [
3100
+ ...(existing.shadowedSources || []),
3101
+ ...(existingShadow ? [existingShadow] : [])
3102
+ ]
1605
3103
  };
1606
- } else if (skill.installed) {
1607
- existing.installed = true;
1608
- }
1609
- }
1610
-
1611
- skills.splice(0, skills.length, ...deduplicated);
1612
- }
1613
-
1614
- /**
1615
- * 安装技能
1616
- * @param {string} directory - 本地安装目录(相对于 installDir)
1617
- * @param {Object} repo - 仓库配置
1618
- * @param {string} [fullDirectory] - 仓库中的完整路径(可选,默认与 directory 相同)
1619
- */
1620
- async installSkill(directory, repo, fullDirectory = null) {
1621
- const { safeDirectory, path: dest } = this.resolveInstallPath(directory);
1622
- if (this.isProtectedSkillDirectory(safeDirectory)) {
1623
- throw new Error('不能安装到 Codex 系统技能目录');
1624
- }
1625
- const normalizedRepo = this.normalizeRepoConfig(repo);
1626
-
1627
- // 已安装则跳过
1628
- if (fs.existsSync(dest)) {
1629
- return { success: true, message: 'Already installed' };
1630
- }
1631
-
1632
- // 使用 fullDirectory(仓库中的完整路径)或 directory(向后兼容)
1633
- const sourcePath = fullDirectory || safeDirectory;
1634
- const safeSourcePath = sourcePath
1635
- ? normalizeSkillRelativePath(sourcePath, 'skill source directory')
1636
- : '';
1637
-
1638
- if (normalizedRepo.provider === 'local') {
1639
- const sourceDir = safeSourcePath
1640
- ? resolveInsideRoot(normalizedRepo.localPath, safeSourcePath, 'Skill source directory', { allowHiddenSegments: true })
1641
- : normalizedRepo.localPath;
1642
-
1643
- if (!fs.existsSync(sourceDir)) {
1644
- throw new Error(`Skill directory not found: ${sourcePath || normalizedRepo.localPath}`);
1645
- }
1646
-
1647
- fs.mkdirSync(dest, { recursive: true });
1648
- this.copyDirRecursive(sourceDir, dest);
1649
-
1650
- this.clearCache({ removeFile: true });
1651
- return { success: true, message: 'Installed successfully' };
1652
- }
1653
-
1654
- const tempDir = path.join(os.tmpdir(), `skill-${Date.now()}`);
1655
- const zipPath = path.join(tempDir, 'repo.zip');
1656
-
1657
- try {
1658
- fs.mkdirSync(tempDir, { recursive: true });
1659
-
1660
- let zipUrl = '';
1661
- let zipHeaders = {};
1662
-
1663
- if (normalizedRepo.provider === 'gitlab') {
1664
- const projectId = encodeURIComponent(normalizedRepo.projectPath);
1665
- zipUrl = `${normalizedRepo.host}/api/v4/projects/${projectId}/repository/archive.zip?sha=${encodeURIComponent(normalizedRepo.branch)}`;
1666
- const token = this.getGitLabToken(normalizedRepo);
1667
- if (token) {
1668
- zipHeaders['PRIVATE-TOKEN'] = token;
1669
- }
3104
+ if (isArtifact) artifactTargets.set(normalizedDirectory, existingIndex);
1670
3105
  } else {
1671
- zipUrl = `https://api.github.com/repos/${normalizedRepo.owner}/${normalizedRepo.name}/zipball/${encodeURIComponent(normalizedRepo.branch)}`;
1672
- const token = this.getGitHubToken(normalizedRepo);
1673
- zipHeaders.Accept = 'application/vnd.github+json';
1674
- if (token) {
1675
- zipHeaders.Authorization = `token ${token}`;
1676
- }
1677
- }
1678
-
1679
- await this.downloadFile(zipUrl, zipPath, zipHeaders);
1680
-
1681
- // 解压
1682
- const zip = new AdmZip(zipPath);
1683
- zip.extractAllTo(tempDir, true);
1684
-
1685
- // 找到解压后的目录(GitHub ZIP 会有一个根目录)
1686
- const extractedDirs = fs.readdirSync(tempDir).filter(f =>
1687
- fs.statSync(path.join(tempDir, f)).isDirectory()
1688
- );
1689
-
1690
- if (extractedDirs.length === 0) {
1691
- throw new Error('Empty archive');
1692
- }
1693
-
1694
- const repoDir = path.join(tempDir, extractedDirs[0]);
1695
- const sourceDir = safeSourcePath
1696
- ? resolveInsideRoot(repoDir, safeSourcePath, 'Skill source directory', { allowHiddenSegments: true })
1697
- : repoDir;
1698
-
1699
- if (!fs.existsSync(sourceDir)) {
1700
- throw new Error(`Skill directory not found: ${sourcePath}`);
1701
- }
1702
-
1703
- // 复制到安装目录
1704
- fs.mkdirSync(dest, { recursive: true });
1705
- this.copyDirRecursive(sourceDir, dest);
1706
-
1707
- this.clearCache({ removeFile: true });
1708
-
1709
- return { success: true, message: 'Installed successfully' };
1710
- } finally {
1711
- // 清理临时目录
1712
- try {
1713
- fs.rmSync(tempDir, { recursive: true, force: true });
1714
- } catch (e) {
1715
- // 忽略清理错误
3106
+ existing.shadowedSources = [
3107
+ ...(existing.shadowedSources || []),
3108
+ ...(incomingShadow ? [incomingShadow] : [])
3109
+ ];
3110
+ if (skill.installed) existing.installed = true;
1716
3111
  }
1717
3112
  }
1718
- }
1719
-
1720
- /**
1721
- * 下载文件
1722
- */
1723
- async downloadFile(url, dest, headers = {}) {
1724
- return new Promise((resolve, reject) => {
1725
- const file = createWriteStream(dest);
1726
- const transport = url.startsWith('http:') ? http : https;
1727
-
1728
- const request = transport.get(url, {
1729
- headers: {
1730
- 'User-Agent': 'cc-cli-skill-service',
1731
- ...headers
1732
- },
1733
- timeout: 60000
1734
- }, (response) => {
1735
- // 处理重定向
1736
- if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
1737
- file.close();
1738
- this.downloadFile(response.headers.location, dest, headers).then(resolve).catch(reject);
1739
- return;
1740
- }
1741
-
1742
- if (response.statusCode !== 200) {
1743
- file.close();
1744
- reject(new Error(`Download failed: HTTP ${response.statusCode}`));
1745
- return;
1746
- }
1747
-
1748
- response.pipe(file);
1749
- file.on('finish', () => {
1750
- file.close();
1751
- resolve();
1752
- });
1753
- });
1754
3113
 
1755
- request.on('error', (err) => {
1756
- file.close();
1757
- fs.unlink(dest, () => { });
1758
- reject(err);
1759
- });
1760
-
1761
- request.on('timeout', () => {
1762
- request.destroy();
1763
- file.close();
1764
- fs.unlink(dest, () => { });
1765
- reject(new Error('Download timeout'));
1766
- });
1767
- });
3114
+ skills.splice(0, skills.length, ...deduplicated);
1768
3115
  }
1769
3116
 
1770
- /**
1771
- * 递归复制目录
1772
- */
1773
- copyDirRecursive(src, dest) {
1774
- const entries = fs.readdirSync(src, { withFileTypes: true });
1775
-
1776
- for (const entry of entries) {
1777
- const srcPath = path.join(src, entry.name);
1778
- const destPath = path.join(dest, entry.name);
1779
-
1780
- if (entry.isDirectory()) {
1781
- fs.mkdirSync(destPath, { recursive: true });
1782
- this.copyDirRecursive(srcPath, destPath);
1783
- } else {
1784
- fs.copyFileSync(srcPath, destPath);
1785
- }
1786
- }
1787
- }
1788
3117
 
1789
3118
  /**
1790
3119
  * 创建自定义技能
1791
3120
  */
1792
- createCustomSkill({ name, directory, description, content }) {
1793
- const { safeDirectory, path: dest } = this.resolveStoragePath(directory);
1794
-
3121
+ createCustomSkill({ name, directory, description, content, scope = 'user', cwd } = {}) {
3122
+ const options = { scope, ...(cwd ? { cwd } : {}) };
3123
+ const { safeDirectory, path: dest } = this.resolveWritableSkillPath(directory, options);
1795
3124
  // 检查是否已存在
1796
3125
  if (fs.existsSync(dest)) {
1797
3126
  throw new Error(`技能目录 "${directory}" 已存在`);
@@ -1812,12 +3141,24 @@ description: "${normalizedDescription}"
1812
3141
  ${content}
1813
3142
  `;
1814
3143
 
1815
- // 写入文件
1816
- fs.writeFileSync(path.join(dest, 'SKILL.md'), skillMdContent, 'utf-8');
1817
-
3144
+ const projectRoot = scope === 'project' ? this.resolveScopeOptions(options).cwd : null;
3145
+ this._writeSkillFileAtomic(
3146
+ path.join(dest, 'SKILL.md'),
3147
+ skillMdContent,
3148
+ projectRoot || this.storageDir
3149
+ );
3150
+ const control = this._registerCreatedSkill({
3151
+ root: dest,
3152
+ directory: safeDirectory,
3153
+ scope,
3154
+ cwd
3155
+ });
3156
+ if (scope === 'project') {
3157
+ fs.rmSync(dest, { recursive: true, force: true });
3158
+ }
1818
3159
  this.clearCache({ removeFile: true });
1819
3160
 
1820
- return { success: true, message: '技能创建成功', directory: safeDirectory };
3161
+ return { success: true, message: '技能创建成功', directory: safeDirectory, ...control };
1821
3162
  }
1822
3163
 
1823
3164
  /**
@@ -1826,8 +3167,9 @@ ${content}
1826
3167
  * @param {Array<{path: string, content: string}>} files - 文件数组
1827
3168
  * @returns {Object} 创建结果
1828
3169
  */
1829
- createSkillWithFiles({ directory, files }) {
1830
- const { safeDirectory, path: dest } = this.resolveStoragePath(directory);
3170
+ createSkillWithFiles({ directory, files, scope = 'user', cwd } = {}) {
3171
+ const options = { scope, ...(cwd ? { cwd } : {}) };
3172
+ const { safeDirectory, path: dest } = this.resolveWritableSkillPath(directory, options);
1831
3173
 
1832
3174
  // 检查是否已存在
1833
3175
  if (fs.existsSync(dest)) {
@@ -1849,32 +3191,33 @@ ${content}
1849
3191
  // 创建目录
1850
3192
  fs.mkdirSync(dest, { recursive: true });
1851
3193
 
1852
- // 写入所有文件
3194
+ const projectRoot = scope === 'project' ? this.resolveScopeOptions(options).cwd : null;
3195
+ const targetRoot = projectRoot || this.storageDir;
1853
3196
  for (const file of safeFiles) {
1854
3197
  const filePath = resolveInsideRoot(dest, file.safePath, 'skill file path', { allowHiddenSegments: true });
1855
- const fileDir = path.dirname(filePath);
1856
-
1857
- // 确保父目录存在
1858
- if (!fs.existsSync(fileDir)) {
1859
- fs.mkdirSync(fileDir, { recursive: true });
1860
- }
1861
-
1862
- // 写入文件内容
1863
- if (file.isBase64) {
1864
- // 二进制文件使用 base64 编码
1865
- fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
1866
- } else {
1867
- fs.writeFileSync(filePath, file.content, 'utf-8');
1868
- }
3198
+ const fileContent = file.isBase64
3199
+ ? Buffer.from(file.content, 'base64')
3200
+ : Buffer.from(file.content, 'utf8');
3201
+ this._writeSkillFileAtomic(filePath, fileContent, targetRoot);
1869
3202
  }
1870
3203
 
3204
+ const control = this._registerCreatedSkill({
3205
+ root: dest,
3206
+ directory: safeDirectory,
3207
+ scope,
3208
+ cwd
3209
+ });
3210
+ if (scope === 'project') {
3211
+ fs.rmSync(dest, { recursive: true, force: true });
3212
+ }
1871
3213
  this.clearCache({ removeFile: true });
1872
3214
 
1873
3215
  return {
1874
3216
  success: true,
1875
3217
  message: '技能创建成功',
1876
3218
  directory: safeDirectory,
1877
- fileCount: files.length
3219
+ fileCount: files.length,
3220
+ ...control
1878
3221
  };
1879
3222
  }
1880
3223
 
@@ -1883,8 +3226,8 @@ ${content}
1883
3226
  * @param {string} directory - 技能目录名
1884
3227
  * @returns {Array<{path: string, size: number, isDirectory: boolean}>}
1885
3228
  */
1886
- getSkillFiles(directory) {
1887
- const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory);
3229
+ getSkillFiles(directory, options = {}) {
3230
+ const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory, 'skill directory', options);
1888
3231
 
1889
3232
  if (!fs.existsSync(skillPath)) {
1890
3233
  throw new Error(`技能 "${safeDirectory}" 不存在`);
@@ -1899,9 +3242,15 @@ ${content}
1899
3242
  * 递归扫描目录获取文件列表
1900
3243
  */
1901
3244
  _scanFilesRecursive(currentDir, baseDir, files) {
3245
+ try {
3246
+ if (fs.lstatSync(currentDir).isSymbolicLink()) return;
3247
+ } catch {
3248
+ return;
3249
+ }
1902
3250
  const entries = fs.readdirSync(currentDir, { withFileTypes: true });
1903
3251
 
1904
3252
  for (const entry of entries) {
3253
+ if (entry.isSymbolicLink()) continue;
1905
3254
  const fullPath = path.join(currentDir, entry.name);
1906
3255
  const relativePath = path.relative(baseDir, fullPath);
1907
3256
 
@@ -1912,7 +3261,7 @@ ${content}
1912
3261
  isDirectory: true
1913
3262
  });
1914
3263
  this._scanFilesRecursive(fullPath, baseDir, files);
1915
- } else {
3264
+ } else if (entry.isFile()) {
1916
3265
  const stats = fs.statSync(fullPath);
1917
3266
  files.push({
1918
3267
  path: relativePath,
@@ -1929,14 +3278,18 @@ ${content}
1929
3278
  * @param {string} filePath - 文件相对路径
1930
3279
  * @returns {Object} 文件内容
1931
3280
  */
1932
- getSkillFileContent(directory, filePath) {
1933
- const { path: skillPath } = this.resolveInstallPath(directory);
3281
+ getSkillFileContent(directory, filePath, options = {}) {
3282
+ const { path: skillPath } = this.resolveInstallPath(directory, 'skill directory', options);
1934
3283
  const safeFilePath = normalizeSkillRelativePath(filePath, 'skill file path');
1935
3284
  const fullPath = resolveInsideRoot(skillPath, safeFilePath, 'skill file path', { allowHiddenSegments: true });
3285
+ assertNoSymlinkComponents(skillPath, fullPath, fs);
1936
3286
 
1937
3287
  if (!fs.existsSync(fullPath)) {
1938
3288
  throw new Error(`文件 "${safeFilePath}" 不存在`);
1939
3289
  }
3290
+ if (fs.lstatSync(fullPath).isSymbolicLink()) {
3291
+ throw new Error(`skill file path contains symlink: ${safeFilePath}`);
3292
+ }
1940
3293
 
1941
3294
  const stats = fs.statSync(fullPath);
1942
3295
  if (stats.isDirectory()) {
@@ -1970,8 +3323,8 @@ ${content}
1970
3323
  * @param {string} directory - 技能目录名
1971
3324
  * @param {Array<{path: string, content: string, isBase64?: boolean}>} files - 文件数组
1972
3325
  */
1973
- addSkillFiles(directory, files) {
1974
- const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory);
3326
+ addSkillFiles(directory, files, options = {}) {
3327
+ const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory, 'skill directory', options);
1975
3328
  if (this.isProtectedSkillDirectory(safeDirectory)) {
1976
3329
  throw new Error('不能修改 Codex 系统技能');
1977
3330
  }
@@ -1980,23 +3333,22 @@ ${content}
1980
3333
  throw new Error(`技能 "${safeDirectory}" 不存在`);
1981
3334
  }
1982
3335
 
3336
+ const projectRoot = options.scope === 'project' ? this.resolveScopeOptions(options).cwd : null;
3337
+ const targetRoot = projectRoot || this.installDir;
3338
+
1983
3339
  const added = [];
1984
3340
  for (const file of files) {
1985
3341
  const safeFilePath = normalizeSkillRelativePath(file.path, 'skill file path');
1986
3342
  const filePath = resolveInsideRoot(skillPath, safeFilePath, 'skill file path', { allowHiddenSegments: true });
1987
- const fileDir = path.dirname(filePath);
1988
-
1989
- // 确保父目录存在
1990
- if (!fs.existsSync(fileDir)) {
1991
- fs.mkdirSync(fileDir, { recursive: true });
3343
+ assertNoSymlinkComponents(targetRoot, filePath, fs);
3344
+ if (fs.existsSync(filePath) && fs.lstatSync(filePath).isSymbolicLink()) {
3345
+ throw new Error(`skill file path contains symlink: ${safeFilePath}`);
1992
3346
  }
1993
3347
 
1994
- // 写入文件
1995
- if (file.isBase64) {
1996
- fs.writeFileSync(filePath, Buffer.from(file.content, 'base64'));
1997
- } else {
1998
- fs.writeFileSync(filePath, file.content, 'utf-8');
1999
- }
3348
+ const fileContent = file.isBase64
3349
+ ? Buffer.from(file.content, 'base64')
3350
+ : Buffer.from(file.content, 'utf8');
3351
+ this._writeSkillFileAtomic(filePath, fileContent, targetRoot);
2000
3352
  added.push(safeFilePath);
2001
3353
  }
2002
3354
 
@@ -2010,8 +3362,8 @@ ${content}
2010
3362
  * @param {string} directory - 技能目录名
2011
3363
  * @param {string} filePath - 文件相对路径
2012
3364
  */
2013
- deleteSkillFile(directory, filePath) {
2014
- const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory);
3365
+ deleteSkillFile(directory, filePath, options = {}) {
3366
+ const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory, 'skill directory', options);
2015
3367
  if (this.isProtectedSkillDirectory(safeDirectory)) {
2016
3368
  throw new Error('不能修改 Codex 系统技能');
2017
3369
  }
@@ -2030,11 +3382,14 @@ ${content}
2030
3382
  throw new Error('不能删除 SKILL.md 文件');
2031
3383
  }
2032
3384
  const fullPath = resolveInsideRoot(skillPath, safeFilePath, 'skill file path', { allowHiddenSegments: true });
3385
+ assertNoSymlinkComponents(skillPath, fullPath, fs);
2033
3386
 
2034
3387
  if (!fs.existsSync(fullPath)) {
2035
3388
  throw new Error(`文件 "${safeFilePath}" 不存在`);
2036
3389
  }
2037
-
3390
+ if (fs.lstatSync(fullPath).isSymbolicLink()) {
3391
+ throw new Error(`skill file path contains symlink: ${safeFilePath}`);
3392
+ }
2038
3393
  const stats = fs.statSync(fullPath);
2039
3394
  if (stats.isDirectory()) {
2040
3395
  fs.rmSync(fullPath, { recursive: true, force: true });
@@ -2054,8 +3409,8 @@ ${content}
2054
3409
  * @param {string} content - 新内容
2055
3410
  * @param {boolean} isBase64 - 是否为 base64 编码
2056
3411
  */
2057
- updateSkillFile(directory, filePath, content, isBase64 = false) {
2058
- const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory);
3412
+ updateSkillFile(directory, filePath, content, isBase64 = false, options = {}) {
3413
+ const { safeDirectory, path: skillPath } = this.resolveInstallPath(directory, 'skill directory', options);
2059
3414
  if (this.isProtectedSkillDirectory(safeDirectory)) {
2060
3415
  throw new Error('不能修改 Codex 系统技能');
2061
3416
  }
@@ -2066,263 +3421,251 @@ ${content}
2066
3421
 
2067
3422
  const safeFilePath = normalizeSkillRelativePath(filePath, 'skill file path');
2068
3423
  const fullPath = resolveInsideRoot(skillPath, safeFilePath, 'skill file path', { allowHiddenSegments: true });
3424
+ assertNoSymlinkComponents(skillPath, fullPath, fs);
2069
3425
 
2070
3426
  if (!fs.existsSync(fullPath)) {
2071
3427
  throw new Error(`文件 "${safeFilePath}" 不存在`);
2072
3428
  }
2073
-
2074
- if (isBase64) {
2075
- fs.writeFileSync(fullPath, Buffer.from(content, 'base64'));
2076
- } else {
2077
- fs.writeFileSync(fullPath, content, 'utf-8');
3429
+ if (fs.lstatSync(fullPath).isSymbolicLink()) {
3430
+ throw new Error(`skill file path contains symlink: ${safeFilePath}`);
2078
3431
  }
2079
3432
 
3433
+ const fileContent = isBase64
3434
+ ? Buffer.from(content, 'base64')
3435
+ : Buffer.from(content, 'utf8');
3436
+ const projectRoot = options.scope === 'project' ? this.resolveScopeOptions(options).cwd : null;
3437
+ this._writeSkillFileAtomic(fullPath, fileContent, projectRoot || this.installDir);
3438
+
2080
3439
  this.clearCache({ removeFile: true });
2081
3440
 
2082
3441
  return { success: true, updated: safeFilePath };
2083
3442
  }
2084
3443
 
2085
3444
 
2086
- /**
2087
- * 安装 cc-tool 本地托管的技能(从 storageDir cp 到 installDir)
2088
- */
2089
- installLocalSkill(directory) {
2090
- const { safeDirectory, path: src } = this.resolveStoragePath(directory);
2091
- const { path: dest } = this.resolveInstallPath(safeDirectory);
2092
- if (this.isProtectedSkillDirectory(safeDirectory)) {
2093
- throw new Error('不能安装到 Codex 系统技能目录');
2094
- }
2095
-
2096
- if (!fs.existsSync(src)) {
2097
- throw new Error(`本地技能 "${safeDirectory}" 不存在`);
2098
- }
2099
-
2100
- if (fs.existsSync(dest)) {
2101
- return { success: true, message: 'Already installed' };
2102
- }
2103
-
2104
- fs.mkdirSync(dest, { recursive: true });
2105
- this.copyDirRecursive(src, dest);
2106
- this.clearCache({ removeFile: true });
2107
- return { success: true, message: 'Installed successfully' };
2108
- }
2109
-
2110
- /**
2111
- * 卸载技能
2112
- */
2113
- uninstallSkill(directory, options = {}) {
2114
- const safeDirectory = this.normalizeSkillDirectory(directory);
2115
- const projectInstallDir = this.platform === 'omp' && options.scope === 'project' && options.cwd
2116
- ? path.join(path.resolve(options.cwd), '.omp', 'skills')
2117
- : null;
2118
- const dest = projectInstallDir
2119
- ? resolveInsideRoot(projectInstallDir, safeDirectory, 'skill directory', {
2120
- allowHiddenSegments: true
2121
- })
2122
- : this.resolveInstallPath(safeDirectory).path;
2123
- if (this.isProtectedSkillDirectory(safeDirectory)) {
2124
- throw new Error('不能卸载 Codex 系统技能');
2125
- }
2126
-
2127
- if (fs.existsSync(dest)) {
2128
- fs.rmSync(dest, { recursive: true, force: true });
2129
- this.clearCache({ removeFile: true });
2130
- return { success: true, message: 'Uninstalled successfully' };
2131
- }
2132
-
2133
- return { success: true, message: 'Not installed' };
2134
- }
2135
3445
 
2136
3446
  /**
2137
- * 获取技能详情(完整内容)
3447
+ * 获取本地 Skill 详情。远端内容只能来自已经发布的 artifact。
2138
3448
  */
2139
- async getSkillDetail(directory, repoHint = null, fullDirectoryHint = '') {
3449
+ async getSkillDetail(directory, repoHint = null, fullDirectoryHint = '', options = {}) {
2140
3450
  const safeDirectory = this.normalizeSkillDirectory(directory);
2141
- // 先检查本地是否安装
2142
- const localSkillDir = this.resolveInstallPath(safeDirectory).path;
2143
- const localPath = path.join(localSkillDir, 'SKILL.md');
2144
-
2145
- if (fs.existsSync(localPath)) {
2146
- const content = fs.readFileSync(localPath, 'utf-8');
2147
- const metadata = this.parseSkillMd(content);
2148
-
2149
- // 提取正文内容(去除 frontmatter)
2150
- const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
2151
- const body = bodyMatch ? bodyMatch[1].trim() : content;
2152
-
2153
- return {
2154
- directory: safeDirectory,
2155
- name: metadata.name || safeDirectory,
2156
- description: metadata.description || '',
2157
- content: body,
2158
- fullContent: content,
2159
- installed: true,
2160
- path: localSkillDir,
2161
- fullPath: localPath,
2162
- installPath: localSkillDir,
2163
- source: this.isProtectedSkillDirectory(safeDirectory) ? 'system-installed' : 'local',
2164
- protected: this.isProtectedSkillDirectory(safeDirectory)
2165
- };
2166
- }
3451
+ const scope = options.scope || 'user';
2167
3452
 
2168
- const parseRemoteSkillContent = (content, repo, fullDirectory = '') => {
2169
- const metadata = this.parseSkillMd(content);
3453
+ const readDetail = (root, metadata = {}, controlEntry = null) => {
3454
+ const skillPath = path.resolve(root);
3455
+ const skillFile = path.join(skillPath, 'SKILL.md');
3456
+ if (!fs.existsSync(skillFile) || fs.lstatSync(skillFile).isSymbolicLink()) {
3457
+ throw new Error('Skill detail contains an unavailable or symlinked SKILL.md');
3458
+ }
3459
+ const content = fs.readFileSync(skillFile, 'utf8');
3460
+ const parsed = this.parseSkillMd(content);
2170
3461
  const bodyMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
2171
3462
  const body = bodyMatch ? bodyMatch[1].trim() : content;
2172
-
3463
+ const enabled = controlEntry?.enabled === true || (
3464
+ controlEntry == null && metadata.enabled === true
3465
+ );
2173
3466
  return {
2174
3467
  directory: safeDirectory,
2175
- name: metadata.name || safeDirectory,
2176
- description: metadata.description || '',
3468
+ name: parsed.name || metadata.name || safeDirectory,
3469
+ description: parsed.description || metadata.description || '',
2177
3470
  content: body,
2178
3471
  fullContent: content,
2179
- installed: false,
2180
- source: repo.provider === 'local' ? 'local-repo' : repo.provider,
2181
- fullDirectory,
2182
- repoProvider: repo.provider,
2183
- repoOwner: repo.owner || null,
2184
- repoName: repo.name || null,
2185
- repoBranch: repo.branch || 'main',
2186
- repoDirectory: repo.directory || '',
2187
- repoHost: repo.host || null,
2188
- repoProjectPath: repo.projectPath || null,
2189
- repoLocalPath: repo.localPath || null,
2190
- repoId: repo.id,
2191
- repoUrl: repo.repoUrl || buildRepoUrl(repo),
2192
- path: fullDirectory
3472
+ installed: enabled,
3473
+ enabled,
3474
+ cached: metadata.cached !== false,
3475
+ trust: controlEntry?.trust || metadata.trust || (metadata.source === 'remote' ? 'pending' : 'approved'),
3476
+ managed: true,
3477
+ controlKey: controlEntry?.controlKey || metadata.controlKey || null,
3478
+ scope: controlEntry?.scope || scope,
3479
+ sourceScope: controlEntry?.scope || metadata.sourceScope || scope,
3480
+ path: skillPath,
3481
+ fullPath: skillFile,
3482
+ installPath: skillPath,
3483
+ source: metadata.source || 'local',
3484
+ sourceProvider: metadata.sourceProvider || null,
3485
+ sourceKey: metadata.sourceKey || null,
3486
+ repoProvider: metadata.repoProvider || null,
3487
+ repoOwner: metadata.repoOwner || null,
3488
+ repoName: metadata.repoName || null,
3489
+ repoBranch: metadata.repoBranch || null,
3490
+ repoDirectory: metadata.repoDirectory || '',
3491
+ repoHost: metadata.repoHost || null,
3492
+ repoProjectPath: metadata.repoProjectPath || null,
3493
+ repoLocalPath: metadata.repoLocalPath || null,
3494
+ repoId: metadata.repoId || null,
3495
+ repoUrl: metadata.repoUrl || null,
3496
+ protected: this.isProtectedSkillDirectory(safeDirectory),
3497
+ readonly: metadata.readonly === true
2193
3498
  };
2194
3499
  };
2195
3500
 
2196
- const tryLoadRemoteDetailFromRepo = async (repo, extraCandidateDirs = []) => {
2197
- try {
2198
- if (repo.provider === 'local') {
2199
- const normalizedDirectory = normalizeRepoPath(safeDirectory);
2200
- const candidateDirs = new Set([
2201
- normalizedDirectory,
2202
- normalizeRepoPath(fullDirectoryHint || ''),
2203
- ...extraCandidateDirs.map(candidate => normalizeRepoPath(candidate))
2204
- ]);
2205
-
2206
- for (const candidateDir of candidateDirs) {
2207
- const skillMdPath = candidateDir
2208
- ? path.join(repo.localPath, candidateDir, 'SKILL.md')
2209
- : path.join(repo.localPath, 'SKILL.md');
2210
- if (!fs.existsSync(skillMdPath)) continue;
2211
- const content = fs.readFileSync(skillMdPath, 'utf-8');
2212
- return {
2213
- ...parseRemoteSkillContent(content, repo, candidateDir),
2214
- path: path.dirname(skillMdPath),
2215
- fullPath: skillMdPath
2216
- };
2217
- }
2218
- return null;
2219
- }
2220
-
2221
- const treeItems = repo.provider === 'gitlab'
2222
- ? await this.fetchGitLabTree(repo)
2223
- : await this.fetchGitHubRepoTree(repo);
2224
- if (!treeItems?.length) return null;
2225
-
2226
- const normalizedDirectory = normalizeRepoPath(safeDirectory);
2227
- const candidateDirs = new Set();
2228
- candidateDirs.add(normalizedDirectory);
2229
-
2230
- for (const candidate of extraCandidateDirs) {
2231
- const normalized = normalizeRepoPath(candidate);
2232
- if (normalized) candidateDirs.add(normalized);
2233
- }
2234
-
2235
- if (repo.directory) {
2236
- candidateDirs.add(normalizeRepoPath(`${repo.directory}/${normalizedDirectory}`));
2237
- }
2238
-
2239
- let skillFile = null;
2240
- for (const candidateDir of candidateDirs) {
2241
- skillFile = treeItems.find(item =>
2242
- item.type === 'blob' && (
2243
- candidateDir
2244
- ? item.path === `${candidateDir}/SKILL.md`
2245
- : item.path === 'SKILL.md'
2246
- )
2247
- );
2248
- if (skillFile) break;
2249
- }
2250
-
2251
- if (!skillFile) return null;
2252
-
2253
- const content = await this.fetchSkillFileContent(repo, skillFile);
2254
- const fullDirectory = normalizeRepoPath(skillFile.path.replace(/(^|\/)SKILL\.md$/, ''));
2255
- return parseRemoteSkillContent(content, repo, fullDirectory);
2256
- } catch (err) {
2257
- console.warn('[SkillService] Fetch remote skill detail error:', err.message);
2258
- return null;
3501
+ const previousSkillsCache = Array.isArray(this.skillsCache)
3502
+ ? this.skillsCache.map(skill => ({ ...skill }))
3503
+ : null;
3504
+ const scanResult = await this.scanSkills({
3505
+ ...options,
3506
+ scope,
3507
+ ...(scope === 'project' && options.cwd ? { cwd: options.cwd } : {})
3508
+ });
3509
+ const candidates = (scanResult.skills || []).filter(skill => (
3510
+ normalizeRepoPath(skill.directory) === normalizeRepoPath(safeDirectory)
3511
+ || (fullDirectoryHint && normalizeRepoPath(skill.fullDirectory) === normalizeRepoPath(fullDirectoryHint))
3512
+ ));
3513
+ const hinted = candidates.find(skill => (
3514
+ repoHint && (
3515
+ (repoHint.id && skill.repoId === repoHint.id)
3516
+ || (repoHint.owner && repoHint.name && skill.repoOwner === repoHint.owner && skill.repoName === repoHint.name)
3517
+ )
3518
+ ));
3519
+ const selected = hinted || candidates[0];
3520
+ if (selected) {
3521
+ const selectedScope = selected.scope || (selected.sourceScope === 'project' ? 'project' : 'user');
3522
+ const selectedProjectPath = selectedScope === 'project'
3523
+ ? (selected.projectPath || this._skillCwdKey(options))
3524
+ : null;
3525
+ const controlEntry = selected.controlKey
3526
+ ? this.controlService?.getSkill?.(selected.controlKey, {
3527
+ scope: selectedScope,
3528
+ projectPath: selectedProjectPath
3529
+ })
3530
+ : null;
3531
+ const root = selected.artifact?.root
3532
+ || selected.artifactRoot
3533
+ || (selected.sourcePath ? path.dirname(selected.sourcePath) : null);
3534
+ if (root && fs.existsSync(root) && !fs.lstatSync(root).isSymbolicLink()) {
3535
+ return readDetail(root, selected, controlEntry);
2259
3536
  }
2260
- };
3537
+ }
2261
3538
 
2262
- if (repoHint) {
2263
- try {
2264
- const normalizedRepoHint = this.normalizeRepoConfig(repoHint);
2265
- const detail = await tryLoadRemoteDetailFromRepo(normalizedRepoHint, [
2266
- fullDirectoryHint || '',
2267
- repoHint.directory ? `${repoHint.directory}/${safeDirectory}` : '',
2268
- repoHint.fullDirectory || ''
2269
- ]);
2270
- if (detail) return detail;
2271
- } catch (err) {
2272
- console.warn('[SkillService] Invalid repo hint for detail:', err.message);
3539
+ const localSkillDir = this.resolveInstallPath(safeDirectory, 'skill directory', options).path;
3540
+ const localPath = path.join(localSkillDir, 'SKILL.md');
3541
+ if (fs.existsSync(localPath) && !fs.lstatSync(localPath).isSymbolicLink()) {
3542
+ const projectPath = scope === 'project' ? this._skillCwdKey(options) : null;
3543
+ const sourceKey = projectPath
3544
+ ? `native:${this.platform}:${safeDirectory}:project:${projectPath}`
3545
+ : `native:${this.platform}:${safeDirectory}`;
3546
+ const controlKey = `skill:${this.platform}:${scope}:${projectPath || 'user'}:${sourceKey}`;
3547
+ const controlEntry = this.controlService?.getSkill?.(controlKey, {
3548
+ scope,
3549
+ projectPath
3550
+ }) || null;
3551
+ return readDetail(localSkillDir, {
3552
+ source: scope === 'project' ? 'project' : 'native',
3553
+ sourceProvider: 'native',
3554
+ sourceScope: scope,
3555
+ enabled: true,
3556
+ cached: true,
3557
+ readonly: false
3558
+ }, controlEntry);
3559
+ }
3560
+
3561
+ const localStoragePath = this.resolveStoragePath(safeDirectory).path;
3562
+ const localStorageSkillPath = path.join(localStoragePath, 'SKILL.md');
3563
+ if (fs.existsSync(localStorageSkillPath) && !fs.lstatSync(localStorageSkillPath).isSymbolicLink()) {
3564
+ const sourceKey = `local:${this.platform}:${safeDirectory}`;
3565
+ const controlKey = `skill:${this.platform}:user:user:${sourceKey}`;
3566
+ const controlEntry = this.controlService?.getSkill?.(controlKey, { scope: 'user' }) || null;
3567
+ return readDetail(localStoragePath, {
3568
+ source: 'local',
3569
+ sourceProvider: 'cc-tool',
3570
+ sourceScope: 'user',
3571
+ sourceKey
3572
+ }, controlEntry);
3573
+ }
3574
+
3575
+ const cachedSkills = previousSkillsCache || this.skillsCache;
3576
+ const cachedLocalSkill = Array.isArray(cachedSkills)
3577
+ ? cachedSkills.find(skill => (
3578
+ normalizeRepoPath(skill.directory) === normalizeRepoPath(safeDirectory)
3579
+ && (skill.sourcePath || skill.realPath)
3580
+ && !(skill.repoOwner || skill.repoProjectPath || skill.repoLocalPath)
3581
+ ))
3582
+ : null;
3583
+ const cachedSkillPath = cachedLocalSkill?.realPath || cachedLocalSkill?.sourcePath;
3584
+ if (cachedSkillPath && fs.existsSync(cachedSkillPath) && !fs.lstatSync(cachedSkillPath).isSymbolicLink()) {
3585
+ return readDetail(path.dirname(cachedSkillPath), {
3586
+ ...cachedLocalSkill,
3587
+ source: cachedLocalSkill.source || 'provider-installed',
3588
+ enabled: cachedLocalSkill.enabled !== false && cachedLocalSkill.installed !== false,
3589
+ cached: true,
3590
+ readonly: cachedLocalSkill.readonly === true
3591
+ });
3592
+ }
3593
+
3594
+ const requestedProjectPath = scope === 'project' ? this._skillCwdKey(options) : null;
3595
+ const artifact = this.artifactStore?.list?.({ platform: this.platform }).find(item => {
3596
+ const artifactScope = item?.sourceScope || 'user';
3597
+ if (artifactScope === 'project') {
3598
+ if (scope !== 'project' || item.projectPath !== requestedProjectPath) return false;
3599
+ } else if (item.projectPath && item.projectPath !== requestedProjectPath) {
3600
+ return false;
2273
3601
  }
3602
+ const itemDirectory = item.targetDirectory || item.directory || item.fullDirectory || '';
3603
+ return normalizeRepoPath(itemDirectory) === normalizeRepoPath(safeDirectory)
3604
+ || (fullDirectoryHint && normalizeRepoPath(item.fullDirectory) === normalizeRepoPath(fullDirectoryHint));
3605
+ });
3606
+ if (artifact) {
3607
+ const sourceKey = artifact.sourceKey || '';
3608
+ const artifactScope = artifact.sourceScope || 'user';
3609
+ const artifactProjectPath = artifactScope === 'project' ? artifact.projectPath : null;
3610
+ const controlKey = `skill:${this.platform}:${artifactScope}:${artifactProjectPath || 'user'}:${sourceKey}`;
3611
+ const controlEntry = this.controlService?.getSkill?.(controlKey, {
3612
+ scope: artifactScope,
3613
+ projectPath: artifactProjectPath
3614
+ }) || null;
3615
+ return readDetail(artifact.root, {
3616
+ ...artifact,
3617
+ source: artifact.source || 'remote',
3618
+ sourceProvider: artifact.sourceProvider || 'remote',
3619
+ sourceScope: artifactScope,
3620
+ projectPath: artifactProjectPath,
3621
+ sourceKey,
3622
+ cached: true,
3623
+ readonly: true
3624
+ }, controlEntry);
2274
3625
  }
2275
3626
 
2276
- // 先尝试使用缓存中的 repo 信息(最快)
2277
- const cachedSkill = this.skillsCache?.find(s =>
2278
- normalizeRepoPath(s.directory) === normalizeRepoPath(safeDirectory)
2279
- );
2280
- if (cachedSkill && (cachedSkill.repoOwner || cachedSkill.repoProjectPath || cachedSkill.repoLocalPath)) {
2281
- const cachedRepo = this.normalizeRepoConfig({
2282
- provider: cachedSkill.repoProvider || (cachedSkill.repoLocalPath ? 'local' : (cachedSkill.repoProjectPath ? 'gitlab' : 'github')),
2283
- owner: cachedSkill.repoOwner,
2284
- name: cachedSkill.repoName,
2285
- branch: cachedSkill.repoBranch || 'main',
2286
- directory: cachedSkill.repoDirectory || '',
2287
- host: cachedSkill.repoHost,
2288
- projectPath: cachedSkill.repoProjectPath,
2289
- localPath: cachedSkill.repoLocalPath,
2290
- repoUrl: cachedSkill.repoUrl
2291
- });
2292
- const detail = await tryLoadRemoteDetailFromRepo(cachedRepo, [
2293
- fullDirectoryHint || '',
2294
- cachedSkill.fullDirectory || '',
2295
- cachedSkill.repoDirectory ? `${cachedSkill.repoDirectory}/${safeDirectory}` : ''
2296
- ]);
2297
- if (detail) return detail;
2298
- }
2299
-
2300
- // 缓存缺失或过期时,回退到遍历仓库配置,避免详情页报错
2301
- const repos = this.loadRepos().filter(repo => repo.enabled !== false);
2302
- for (const repo of repos) {
2303
- const detail = await tryLoadRemoteDetailFromRepo(
2304
- repo,
2305
- [repo.directory ? `${repo.directory}/${safeDirectory}` : '']
2306
- );
2307
- if (detail) return detail;
3627
+ if (this.platform === 'omp') {
3628
+ const discovered = discoverOmpSkills(this, {
3629
+ ...options,
3630
+ cwd: options.cwd || null,
3631
+ force: false,
3632
+ pathContext: this.pathContext
3633
+ }).find(skill => normalizeRepoPath(skill.directory) === normalizeRepoPath(safeDirectory));
3634
+ const discoveredPath = discovered?.realPath || discovered?.sourcePath;
3635
+ if (discoveredPath && fs.existsSync(discoveredPath) && !fs.lstatSync(discoveredPath).isSymbolicLink()) {
3636
+ return readDetail(path.dirname(discoveredPath), {
3637
+ ...discovered,
3638
+ source: discovered.source || 'provider-installed',
3639
+ sourceProvider: discovered.sourceProvider,
3640
+ sourceScope: discovered.sourceScope,
3641
+ enabled: true,
3642
+ readonly: discovered.readonly !== false
3643
+ });
3644
+ }
2308
3645
  }
2309
3646
 
2310
- throw new Error('技能不存在或无法获取');
3647
+ throw new Error('技能未缓存,请手动点击 Skill 面板的“刷新”后重试');
2311
3648
  }
2312
3649
 
2313
3650
  /**
2314
3651
  * 获取已安装技能列表
2315
3652
  */
2316
- getInstalledSkills() {
3653
+ getInstalledSkills(options = {}) {
2317
3654
  this.refreshOmpPaths();
2318
3655
  if (this.platform === 'omp') {
2319
- return this.prepareSkills([]);
3656
+ return this.prepareSkills([], options);
3657
+ }
3658
+ if (options.scope === 'project') {
3659
+ const skills = [];
3660
+ this.mergeProjectSkills(skills, options);
3661
+ return skills;
2320
3662
  }
2321
3663
  const skills = [];
2322
3664
  this.scanLocalDir(this.installDir, this.installDir, skills, {
2323
3665
  includeHiddenDirs: this.platform === 'codex',
2324
3666
  forceInstalled: true,
2325
- source: 'native-installed'
3667
+ source: 'native-installed',
3668
+ sourceScope: 'user'
2326
3669
  });
2327
3670
  return skills;
2328
3671
  }