dsh-mobile-plugin 0.5.27 → 0.5.29

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 (350) hide show
  1. package/extensions/catalog.json +10 -1
  2. package/extensions/magpie/agentcompact.js +104 -0
  3. package/extensions/magpie/app.css +4902 -0
  4. package/extensions/magpie/app.js +24474 -0
  5. package/extensions/magpie/autobackup.js +185 -0
  6. package/extensions/magpie/autoresetown.js +151 -0
  7. package/extensions/magpie/balfound.js +38 -0
  8. package/extensions/magpie/boot.js +23 -0
  9. package/extensions/magpie/cfgblocked.js +39 -0
  10. package/extensions/magpie/checkinnow.js +111 -0
  11. package/extensions/magpie/claudemode.js +92 -0
  12. package/extensions/magpie/compat.js +41 -0
  13. package/extensions/magpie/context.css +433 -0
  14. package/extensions/magpie/context.js +1576 -0
  15. package/extensions/magpie/ctxtext.js +223 -0
  16. package/extensions/magpie/fonts.js +42 -0
  17. package/extensions/magpie/i18n.js +18942 -0
  18. package/extensions/magpie/icons/ai21.svg +1 -0
  19. package/extensions/magpie/icons/ai302-color.svg +1 -0
  20. package/extensions/magpie/icons/ai360-color.svg +1 -0
  21. package/extensions/magpie/icons/aihubmix-color.svg +1 -0
  22. package/extensions/magpie/icons/aionui.svg +1 -0
  23. package/extensions/magpie/icons/air.svg +4 -0
  24. package/extensions/magpie/icons/alibaba-color.svg +1 -0
  25. package/extensions/magpie/icons/alibabacloud-color.svg +1 -0
  26. package/extensions/magpie/icons/alma.png +0 -0
  27. package/extensions/magpie/icons/anthropic.svg +1 -0
  28. package/extensions/magpie/icons/antigravity-color.svg +1 -0
  29. package/extensions/magpie/icons/anyscale-color.svg +1 -0
  30. package/extensions/magpie/icons/app-alma.png +0 -0
  31. package/extensions/magpie/icons/app-cc-switch.png +0 -0
  32. package/extensions/magpie/icons/apple-touch-icon.png +0 -0
  33. package/extensions/magpie/icons/aside.svg +28 -0
  34. package/extensions/magpie/icons/astrbot.svg +1 -0
  35. package/extensions/magpie/icons/atomcode.png +0 -0
  36. package/extensions/magpie/icons/aws.svg +1 -0
  37. package/extensions/magpie/icons/azure-color.svg +1 -0
  38. package/extensions/magpie/icons/azureai-color.svg +1 -0
  39. package/extensions/magpie/icons/baichuan-color.svg +1 -0
  40. package/extensions/magpie/icons/baidu-color.svg +1 -0
  41. package/extensions/magpie/icons/baiducloud-color.svg +1 -0
  42. package/extensions/magpie/icons/bailian-color.svg +1 -0
  43. package/extensions/magpie/icons/baseten.svg +1 -0
  44. package/extensions/magpie/icons/bedrock-color.svg +1 -0
  45. package/extensions/magpie/icons/burncloud-color.svg +1 -0
  46. package/extensions/magpie/icons/bytedance-color.svg +1 -0
  47. package/extensions/magpie/icons/cerebras.svg +1 -0
  48. package/extensions/magpie/icons/chatglm-color.svg +1 -0
  49. package/extensions/magpie/icons/cherryin-color.svg +1 -0
  50. package/extensions/magpie/icons/chutes.svg +1 -0
  51. package/extensions/magpie/icons/cindy.png +0 -0
  52. package/extensions/magpie/icons/claude-color.svg +1 -0
  53. package/extensions/magpie/icons/claudecode-color.svg +1 -0
  54. package/extensions/magpie/icons/cline.svg +1 -0
  55. package/extensions/magpie/icons/cloudflare-color.svg +1 -0
  56. package/extensions/magpie/icons/codebuddy-color.svg +1 -0
  57. package/extensions/magpie/icons/codex-color.svg +1 -0
  58. package/extensions/magpie/icons/cohere-color.svg +1 -0
  59. package/extensions/magpie/icons/cometapi-color.svg +1 -0
  60. package/extensions/magpie/icons/commandcode.svg +1 -0
  61. package/extensions/magpie/icons/copilot-color.svg +1 -0
  62. package/extensions/magpie/icons/coze.svg +1 -0
  63. package/extensions/magpie/icons/crush.png +0 -0
  64. package/extensions/magpie/icons/curl.svg +1 -0
  65. package/extensions/magpie/icons/cursor.svg +1 -0
  66. package/extensions/magpie/icons/deepinfra-color.svg +1 -0
  67. package/extensions/magpie/icons/deepmind-color.svg +1 -0
  68. package/extensions/magpie/icons/deepseek-color.svg +1 -0
  69. package/extensions/magpie/icons/deveco-color.svg +12 -0
  70. package/extensions/magpie/icons/devin.svg +1 -0
  71. package/extensions/magpie/icons/doubao-color.svg +1 -0
  72. package/extensions/magpie/icons/empryo-color.svg +1 -0
  73. package/extensions/magpie/icons/factory.svg +1 -0
  74. package/extensions/magpie/icons/favicon.svg +1 -0
  75. package/extensions/magpie/icons/featherless-color.svg +1 -0
  76. package/extensions/magpie/icons/fireworks-color.svg +1 -0
  77. package/extensions/magpie/icons/friendli.svg +1 -0
  78. package/extensions/magpie/icons/fx.svg +1 -0
  79. package/extensions/magpie/icons/gemini-color.svg +1 -0
  80. package/extensions/magpie/icons/geminicli-color.svg +1 -0
  81. package/extensions/magpie/icons/gemma-color.svg +1 -0
  82. package/extensions/magpie/icons/giteeai.svg +1 -0
  83. package/extensions/magpie/icons/github.svg +1 -0
  84. package/extensions/magpie/icons/githubcopilot.svg +1 -0
  85. package/extensions/magpie/icons/glama.svg +1 -0
  86. package/extensions/magpie/icons/gmicloud.svg +1 -0
  87. package/extensions/magpie/icons/google-color.svg +1 -0
  88. package/extensions/magpie/icons/googlecloud-color.svg +1 -0
  89. package/extensions/magpie/icons/goose.svg +1 -0
  90. package/extensions/magpie/icons/grok.svg +1 -0
  91. package/extensions/magpie/icons/groq.svg +1 -0
  92. package/extensions/magpie/icons/hanako.png +0 -0
  93. package/extensions/magpie/icons/hermes.svg +1 -0
  94. package/extensions/magpie/icons/huawei-color.svg +1 -0
  95. package/extensions/magpie/icons/huaweicloud-color.svg +1 -0
  96. package/extensions/magpie/icons/huggingface-color.svg +1 -0
  97. package/extensions/magpie/icons/hunyuan-color.svg +1 -0
  98. package/extensions/magpie/icons/hyperbolic-color.svg +1 -0
  99. package/extensions/magpie/icons/icon-512.png +0 -0
  100. package/extensions/magpie/icons/iflytekcloud-color.svg +1 -0
  101. package/extensions/magpie/icons/inference.svg +1 -0
  102. package/extensions/magpie/icons/infinigence-color.svg +1 -0
  103. package/extensions/magpie/icons/internlm-color.svg +1 -0
  104. package/extensions/magpie/icons/jcode.svg +1 -0
  105. package/extensions/magpie/icons/jina.svg +1 -0
  106. package/extensions/magpie/icons/kilo.svg +1 -0
  107. package/extensions/magpie/icons/kilocode.svg +1 -0
  108. package/extensions/magpie/icons/kimi-code-desktop.png +0 -0
  109. package/extensions/magpie/icons/kimi.svg +1 -0
  110. package/extensions/magpie/icons/kiro-color.svg +1 -0
  111. package/extensions/magpie/icons/kling-color.svg +1 -0
  112. package/extensions/magpie/icons/lambda.svg +1 -0
  113. package/extensions/magpie/icons/leptonai-color.svg +1 -0
  114. package/extensions/magpie/icons/liquid.svg +1 -0
  115. package/extensions/magpie/icons/liveagent-color.svg +1 -0
  116. package/extensions/magpie/icons/llmapi.svg +1 -0
  117. package/extensions/magpie/icons/lmstudio.svg +1 -0
  118. package/extensions/magpie/icons/lody.png +0 -0
  119. package/extensions/magpie/icons/longcat.svg +1 -0
  120. package/extensions/magpie/icons/magpie.svg +1 -0
  121. package/extensions/magpie/icons/meta-color.svg +1 -0
  122. package/extensions/magpie/icons/metaai-color.svg +1 -0
  123. package/extensions/magpie/icons/microsoft-color.svg +1 -0
  124. package/extensions/magpie/icons/mimocode.svg +1 -0
  125. package/extensions/magpie/icons/minimax-color.svg +1 -0
  126. package/extensions/magpie/icons/mistermorph-color.svg +1 -0
  127. package/extensions/magpie/icons/mistral-color.svg +1 -0
  128. package/extensions/magpie/icons/mlx-serve.png +0 -0
  129. package/extensions/magpie/icons/modelscope-color.svg +1 -0
  130. package/extensions/magpie/icons/monica-color.svg +1 -0
  131. package/extensions/magpie/icons/moonshot.svg +1 -0
  132. package/extensions/magpie/icons/morph-color.svg +1 -0
  133. package/extensions/magpie/icons/mothx.png +0 -0
  134. package/extensions/magpie/icons/nebius.svg +1 -0
  135. package/extensions/magpie/icons/newapi-color.svg +1 -0
  136. package/extensions/magpie/icons/nousresearch.svg +1 -0
  137. package/extensions/magpie/icons/nova-color.svg +1 -0
  138. package/extensions/magpie/icons/novita-color.svg +1 -0
  139. package/extensions/magpie/icons/nvidia-color.svg +1 -0
  140. package/extensions/magpie/icons/ollama.svg +1 -0
  141. package/extensions/magpie/icons/omlx.svg +1 -0
  142. package/extensions/magpie/icons/omo.svg +1 -0
  143. package/extensions/magpie/icons/omp.svg +1 -0
  144. package/extensions/magpie/icons/openai.svg +1 -0
  145. package/extensions/magpie/icons/openchamber.svg +11 -0
  146. package/extensions/magpie/icons/openclaw.svg +1 -0
  147. package/extensions/magpie/icons/opencode.svg +1 -0
  148. package/extensions/magpie/icons/openmausbot.svg +1 -0
  149. package/extensions/magpie/icons/openrouter.svg +1 -0
  150. package/extensions/magpie/icons/openwebui.svg +1 -0
  151. package/extensions/magpie/icons/parasail.svg +1 -0
  152. package/extensions/magpie/icons/penguin-color.svg +1 -0
  153. package/extensions/magpie/icons/perplexity-color.svg +1 -0
  154. package/extensions/magpie/icons/phala-color.svg +1 -0
  155. package/extensions/magpie/icons/pi.svg +1 -0
  156. package/extensions/magpie/icons/pipellm-color.svg +5 -0
  157. package/extensions/magpie/icons/poe.svg +1 -0
  158. package/extensions/magpie/icons/ppio-color.svg +1 -0
  159. package/extensions/magpie/icons/prime-agent.svg +1 -0
  160. package/extensions/magpie/icons/qiniu-color.svg +1 -0
  161. package/extensions/magpie/icons/qoder.svg +1 -0
  162. package/extensions/magpie/icons/qwen-color.svg +1 -0
  163. package/extensions/magpie/icons/qwenpaw.svg +1 -0
  164. package/extensions/magpie/icons/raycast.svg +1 -0
  165. package/extensions/magpie/icons/reasonix-color.svg +48 -0
  166. package/extensions/magpie/icons/reka.svg +1 -0
  167. package/extensions/magpie/icons/replicate.svg +1 -0
  168. package/extensions/magpie/icons/roocode.svg +1 -0
  169. package/extensions/magpie/icons/rtk.png +0 -0
  170. package/extensions/magpie/icons/sambanova-color.svg +1 -0
  171. package/extensions/magpie/icons/sensenova-color.svg +1 -0
  172. package/extensions/magpie/icons/siliconcloud-color.svg +1 -0
  173. package/extensions/magpie/icons/snow.png +0 -0
  174. package/extensions/magpie/icons/snowflake-color.svg +1 -0
  175. package/extensions/magpie/icons/sophnet-color.svg +1 -0
  176. package/extensions/magpie/icons/spark-color.svg +1 -0
  177. package/extensions/magpie/icons/stepfun-color.svg +1 -0
  178. package/extensions/magpie/icons/straico-color.svg +1 -0
  179. package/extensions/magpie/icons/submodel-color.svg +1 -0
  180. package/extensions/magpie/icons/t3code.svg +4 -0
  181. package/extensions/magpie/icons/targon-color.svg +1 -0
  182. package/extensions/magpie/icons/tencent-color.svg +1 -0
  183. package/extensions/magpie/icons/tencentcloud-color.svg +1 -0
  184. package/extensions/magpie/icons/tiangong-color.svg +1 -0
  185. package/extensions/magpie/icons/together-color.svg +1 -0
  186. package/extensions/magpie/icons/trae.svg +1 -0
  187. package/extensions/magpie/icons/typesafe.png +0 -0
  188. package/extensions/magpie/icons/upstage-color.svg +1 -0
  189. package/extensions/magpie/icons/venice-color.svg +1 -0
  190. package/extensions/magpie/icons/vercel.svg +1 -0
  191. package/extensions/magpie/icons/vertexai-color.svg +1 -0
  192. package/extensions/magpie/icons/vllm-color.svg +1 -0
  193. package/extensions/magpie/icons/volcengine-color.svg +1 -0
  194. package/extensions/magpie/icons/voyage-color.svg +1 -0
  195. package/extensions/magpie/icons/vscode.svg +1 -0
  196. package/extensions/magpie/icons/wenxin-color.svg +1 -0
  197. package/extensions/magpie/icons/windsurf.svg +1 -0
  198. package/extensions/magpie/icons/workbuddy-color.svg +1 -0
  199. package/extensions/magpie/icons/workersai-color.svg +1 -0
  200. package/extensions/magpie/icons/xai.svg +1 -0
  201. package/extensions/magpie/icons/xiaomimimo.svg +1 -0
  202. package/extensions/magpie/icons/xinference-color.svg +1 -0
  203. package/extensions/magpie/icons/yi.svg +1 -0
  204. package/extensions/magpie/icons/yuanbao-color.svg +1 -0
  205. package/extensions/magpie/icons/yylx.svg +5 -0
  206. package/extensions/magpie/icons/zai.svg +1 -0
  207. package/extensions/magpie/icons/zcode.png +0 -0
  208. package/extensions/magpie/icons/zed.svg +1 -0
  209. package/extensions/magpie/icons/zenmux.svg +1 -0
  210. package/extensions/magpie/icons/zeroone.svg +1 -0
  211. package/extensions/magpie/icons/zhipu-color.svg +1 -0
  212. package/extensions/magpie/icons/zoo-code.png +0 -0
  213. package/extensions/magpie/index.html +572 -0
  214. package/extensions/magpie/kernel.js +274 -0
  215. package/extensions/magpie/keyspend.js +63 -0
  216. package/extensions/magpie/layout.js +26 -0
  217. package/extensions/magpie/manifest.json +37 -0
  218. package/extensions/magpie/navlamp.js +47 -0
  219. package/extensions/magpie/omarchy.css +72 -0
  220. package/extensions/magpie/omarchy.js +46 -0
  221. package/extensions/magpie/passerrors.js +21 -0
  222. package/extensions/magpie/pluginpages.js +405 -0
  223. package/extensions/magpie/plugins/gateway/client.js +1194 -0
  224. package/extensions/magpie/plugins/gateway/keys.js +972 -0
  225. package/extensions/magpie/plugins/gateway/settings.js +459 -0
  226. package/extensions/magpie/plugins/library/client.js +6788 -0
  227. package/extensions/magpie/plugins/library/i18n.js +3228 -0
  228. package/extensions/magpie/plugins/library/library.css +848 -0
  229. package/extensions/magpie/plugins/otel/i18n.js +48 -0
  230. package/extensions/magpie/plugins/otel/page.js +189 -0
  231. package/extensions/magpie/plugins/redact/client.js +46 -0
  232. package/extensions/magpie/plugins/redact/demo.js +461 -0
  233. package/extensions/magpie/plugins/redact/i18n.js +432 -0
  234. package/extensions/magpie/plugins/redact/page.js +985 -0
  235. package/extensions/magpie/plugins/redact/redact.css +385 -0
  236. package/extensions/magpie/plugins/redact/settings.js +248 -0
  237. package/extensions/magpie/plugins/redact/xq_dark.jpg +0 -0
  238. package/extensions/magpie/plugins/redact/xq_dark.webm +0 -0
  239. package/extensions/magpie/plugins/redact/xq_light.jpg +0 -0
  240. package/extensions/magpie/plugins/redact/xq_light.webm +0 -0
  241. package/extensions/magpie/plugins/routing/client.js +8593 -0
  242. package/extensions/magpie/plugins/routing/i18n.js +2696 -0
  243. package/extensions/magpie/plugins/routing/routing.css +1111 -0
  244. package/extensions/magpie/plugins/search/settings.js +460 -0
  245. package/extensions/magpie/plugins/sessions/client.js +1622 -0
  246. package/extensions/magpie/plugins/sessions/i18n.js +360 -0
  247. package/extensions/magpie/plugins/sessions/sessions.css +181 -0
  248. package/extensions/magpie/plugins/sessions/settings.js +100 -0
  249. package/extensions/magpie/plugins/usage/client.js +150 -0
  250. package/extensions/magpie/plugins/usage/i18n.js +984 -0
  251. package/extensions/magpie/plugins/usage/page.js +4307 -0
  252. package/extensions/magpie/plugins.css +319 -0
  253. package/extensions/magpie/plugins.js +1926 -0
  254. package/extensions/magpie/profilerename.js +27 -0
  255. package/extensions/magpie/purposes.js +2 -0
  256. package/extensions/magpie/quitask.js +79 -0
  257. package/extensions/magpie/quotaall.js +142 -0
  258. package/extensions/magpie/ratewindows.js +123 -0
  259. package/extensions/magpie/registry.js +87 -0
  260. package/extensions/magpie/resetask.js +137 -0
  261. package/extensions/magpie/ruleedit.js +53 -0
  262. package/extensions/magpie/scrollends.js +91 -0
  263. package/extensions/magpie/settingshints.js +75 -0
  264. package/extensions/magpie/signsheets.js +86 -0
  265. package/extensions/magpie/start.js +26 -0
  266. package/extensions/magpie/syncfiles.js +216 -0
  267. package/extensions/magpie/theme.js +29 -0
  268. package/extensions/magpie/traywindows.js +41 -0
  269. package/extensions/magpie/webfonts.js +130 -0
  270. package/extensions/magpie/whatsnew.js +252 -0
  271. package/extensions/magpie.zip +0 -0
  272. package/knowledge/apps/baijiahao.md +30 -0
  273. package/knowledge/apps/bilibili.md +1 -0
  274. package/knowledge/apps/dedao.md +33 -0
  275. package/knowledge/apps/feishu.md +35 -0
  276. package/knowledge/apps/gmail.md +33 -0
  277. package/knowledge/apps/jike.md +31 -0
  278. package/knowledge/apps/kuaishou.md +34 -0
  279. package/knowledge/apps/linkedin.md +33 -0
  280. package/knowledge/apps/qqmail.md +30 -0
  281. package/knowledge/apps/telegram.md +27 -0
  282. package/knowledge/apps/toutiao.md +32 -0
  283. package/knowledge/apps/uber-eats.md +26 -0
  284. package/knowledge/apps/uber.md +27 -0
  285. package/knowledge/apps/whatsapp.md +27 -0
  286. package/knowledge/apps/x.md +32 -0
  287. package/knowledge/apps/xiaohongshu.md +1 -0
  288. package/knowledge/apps/zhihu.md +1 -0
  289. package/knowledge/sites/bilibili.com/SITE.md +50 -0
  290. package/knowledge/sites/bilibili.com/references/view-fields.md +17 -0
  291. package/knowledge/sites/github.com/SITE.md +51 -0
  292. package/knowledge/sites/github.com/references/repo-fields.md +13 -0
  293. package/knowledge/sites/x.com/SITE.md +47 -0
  294. package/knowledge/sites/xiaohongshu.com/SITE.md +48 -0
  295. package/knowledge/sites/xiaohongshu.com/references/note-fields.md +18 -0
  296. package/knowledge/sites/youtube.com/SITE.md +49 -0
  297. package/knowledge/sites/youtube.com/references/innertube-fields.md +14 -0
  298. package/knowledge/sites/zhihu.com/SITE.md +47 -0
  299. package/knowledge/sites/zhihu.com/references/api-fields.md +21 -0
  300. package/lib/mobile/extension-catalog.js +41 -1
  301. package/lib/mobile/extension-data.js +224 -0
  302. package/lib/mobile/extension-dev.js +3 -1
  303. package/lib/mobile/file-ops.js +252 -0
  304. package/lib/mobile/history-derive.js +80 -4
  305. package/lib/mobile/host-bridge/endpoints.js +48 -6
  306. package/lib/mobile/host-bridge/event-synthesis.js +33 -6
  307. package/lib/mobile/jobs-control.js +118 -0
  308. package/lib/mobile/memory.js +635 -0
  309. package/lib/mobile/model-badges.js +77 -0
  310. package/lib/mobile/pet-tools.js +50 -4
  311. package/lib/mobile/pets.js +214 -18
  312. package/lib/mobile/phone/access.js +85 -0
  313. package/lib/mobile/phone/evidence.js +228 -0
  314. package/lib/mobile/phone/gate.js +290 -0
  315. package/lib/mobile/phone/grounding.js +314 -0
  316. package/lib/mobile/phone/guard.js +465 -0
  317. package/lib/mobile/phone/irreversible.js +88 -8
  318. package/lib/mobile/phone/knowledge-tools.js +665 -0
  319. package/lib/mobile/phone/learn.js +329 -0
  320. package/lib/mobile/phone/redact.js +237 -0
  321. package/lib/mobile/phone/risk.js +95 -0
  322. package/lib/mobile/phone/routes.js +28 -1
  323. package/lib/mobile/phone/service.js +23 -0
  324. package/lib/mobile/phone/site-commands.js +271 -0
  325. package/lib/mobile/phone/step-summary.js +201 -0
  326. package/lib/mobile/phone/stop-summary.js +235 -0
  327. package/lib/mobile/phone/tool-groups.js +125 -0
  328. package/lib/mobile/phone/tools.js +589 -0
  329. package/lib/mobile/phone/untrusted.js +65 -0
  330. package/lib/mobile/phone-knowledge.js +20 -4
  331. package/lib/mobile/schedule-edit.js +500 -0
  332. package/lib/mobile/schedule.js +5 -0
  333. package/lib/mobile/send-continuation.js +52 -0
  334. package/lib/mobile/session-models.js +27 -31
  335. package/lib/mobile/session-send.js +12 -12
  336. package/lib/mobile/skill-import.js +338 -0
  337. package/lib/mobile/skill-store.js +524 -0
  338. package/lib/routes/mobile-api.js +42 -0
  339. package/package.json +7 -6
  340. package/skills/skill-authoring/SKILL.md +46 -0
  341. package/skills/skill-authoring/references/examples.md +82 -0
  342. package/skills/skill-authoring/references/frontmatter-and-fields.md +46 -0
  343. package/skills/skill-authoring/scripts/check_skill.py +167 -0
  344. package/skills/skill-authoring/scripts/manifest.json +3 -0
  345. package/knowledge/sites/bilibili.com.md +0 -27
  346. package/knowledge/sites/github.com.md +0 -27
  347. package/knowledge/sites/x.com.md +0 -28
  348. package/knowledge/sites/xiaohongshu.com.md +0 -27
  349. package/knowledge/sites/youtube.com.md +0 -28
  350. package/knowledge/sites/zhihu.com.md +0 -27
@@ -0,0 +1,1926 @@
1
+ // Plugins: the market of OpenCode plugins. Discover lists the suggested
2
+ // ones and finds the rest on npm; Installed is what was added. Each part is
3
+ // drawn as it comes (#488): /api/plugins, /api/plugins/listings, then
4
+ // /api/plugins/npm; a sign-in happens in the Providers add sheet.
5
+ (() => {
6
+ const page = $("#view-plugins");
7
+ if (!page) return;
8
+ window.mdRender = markdown;
9
+
10
+ let mine = null; // /api/plugins: what's installed
11
+ let listings = null; // /api/plugins/listings: the plugins suggested
12
+ let tagged = null; // /api/plugins/github: repositories their authors tagged magpie-plugin, as listings
13
+ let taggedErr = ""; // why the list isn't GitHub's whole answer: it couldn't be asked, or...
14
+ let taggedKind = ""; // its kind: "slow", "limited" or ""
15
+ let taggedMissing = 0; // ...this many repositories it named couldn't be read
16
+ let topic = "magpie-plugin";
17
+ const npm = {}; // /api/plugins/npm: package → what npm says of it
18
+ const heads = {}; // /api/plugins/git: a git plugin's spec → the commit its repository is at
19
+ let failed = ""; // why what's installed couldn't be loaded
20
+ let failedList = ""; // ...and the plugins suggested
21
+ let tab = "discover", regBox;
22
+ try { tab = localStorage.getItem("magpie.pluginTab") || tab; } catch {}
23
+ let query = "";
24
+ let hits = null; // npm's answer for query: { q, list } | { q, loading } | { q, error }
25
+ let searchTimer = 0;
26
+ const busy = new Map(); // package → "add" | "remove" | "upgrade" | "off"
27
+ let editing = null; // { pkg, text, error }: a middleware's options being edited in its row
28
+ let asking = null; // { pkg, op }: a remove or switch-off waiting on the user's yes
29
+ let checking = false; // Check for updates: npm being asked now
30
+ let checked = null; // ...and what it said: { at, by: spec → check }
31
+ let shown = "";
32
+
33
+ const SEARCH = "M7 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M10 10l3 3";
34
+ const DOWN = "M8 3v7.5 M4.8 7.6 8 10.8l3.2-3.2 M3.5 13h9";
35
+ const STAR = "M8 2.2l1.8 3.7 4 .6-2.9 2.8.7 4L8 11.4l-3.6 1.9.7-4L2.2 6.5l4-.6z";
36
+ const SHIELD = "M8 2.3 13 4v3.8c0 3-2.1 5.1-5 5.9-2.9-.8-5-2.9-5-5.9V4z M5.8 8l1.6 1.6 2.9-3";
37
+ const OUTL = "M9.5 3.5h3v3 M12.5 3.5 7.5 8.5 M11 9.5v3H3.5V5h3";
38
+ const PLUG = "M6 2v3 M10 2v3 M4.5 5h7v2.5a3.5 3.5 0 0 1-7 0z M8 11v3";
39
+ const PIPE = "M1.5 8h3 M11.5 8h3 M4.5 4.5h7v7h-7z";
40
+ const TERM = "M2 3.5h12v9H2z M4.5 6.5l2 1.5-2 1.5 M8 9.5h3";
41
+ const PAGE = "M2 3.5h12v9H2z M2 6h12 M4 4.75h.01";
42
+ const TOOL = "M10.6 2.4a3 3 0 0 0-3.4 4L2.6 11a1.2 1.2 0 0 0 1.7 1.7l4.6-4.6a3 3 0 0 0 4-3.4l-1.7 1.7-1.6-.3-.3-1.6z";
43
+ const FOLDER = "M14.7 12.7a1.3 1.3 0 0 1-1.3 1.3H2.6a1.3 1.3 0 0 1-1.3-1.3V3.3A1.3 1.3 0 0 1 2.6 2h3.3l1.3 2h6.2a1.3 1.3 0 0 1 1.3 1.3z";
44
+
45
+ // a folder's path is its name whole, as plugin.Name: C:\Users\me@corp\…
46
+ // has an @ that is no version
47
+ const name = (spec) => { if (isPath(spec)) return spec; const i = spec.lastIndexOf("@"); return i > 0 && !spec.includes("/", i) ? spec.slice(0, i) : spec; };
48
+ // a plugin added from a folder is a folder's path, not a package name.
49
+ // a leading \ is one on every system, as plugin.IsPath (#2229): a UNC
50
+ // share (\\server\share\plugin) and a leading-backslash path
51
+ const isPath = (p) => /^file:\/\//.test(p) || /^\./.test(p) || /^[/\\]/.test(p) || /^[A-Za-z]:[\\/]/.test(p);
52
+ // ...and is known by the folder's name, not its whole path
53
+ const label = (spec) => { const n = name(spec); return isPath(n) ? n.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || n : n; };
54
+ // one added from a git repository (github:owner/repo, git+https://…), as
55
+ // the plugin store tells them apart: known by the package it installed
56
+ const isGit = (p) => !isPath(p) && (/^(?:(?:github|gitlab|bitbucket):|git\+[a-z]+:\/\/|git:\/\/)\S+$/.test(p) || /^https?:\/\/(?:www\.)?(?:github\.com|gitlab\.com|bitbucket\.org)\/\S+$/.test(p) || /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9_.-]+(?:#\S+)?$/.test(p));
57
+ // ...and its page on the web, for the plugin's Source link
58
+ const gitWeb = (p) => {
59
+ const s = p.replace(/#.*$/, "");
60
+ const m = /^(github|gitlab|bitbucket):(.+)$/.exec(s) || (/^[^:]+\/[^:]+$/.test(s) && ["", "github", s]);
61
+ if (m) return "https://" + { github: "github.com", gitlab: "gitlab.com", bitbucket: "bitbucket.org" }[m[1]] + "/" + m[2];
62
+ const u = s.replace(/^git\+/, "").replace(/\.git$/, "");
63
+ return /^https?:\/\//.test(u) ? u : "";
64
+ };
65
+ const lang = () => (document.documentElement.lang || "en").slice(0, 2);
66
+ const summary = (l) => l.summary?.[lang()] || l.summary?.en || l.npm?.description || "";
67
+ const count = (n) => n >= 1e6 ? (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M" : n >= 1e3 ? (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "k" : String(n || 0);
68
+ // a repository tagged magpie-plugin is installed by whichever spec
69
+ // names it (github:owner/repo, its page's or a git URL), never by its
70
+ // package's name, which another repository can give too (#3685)
71
+ const ghRepo = (spec) => { const m = /^(?:github:|(?:git\+)?(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?(?:www\.|ssh\.)?github\.com(?::\d+(?=\/))?[/:])([^/#\s]+\/[^/#\s]+?)(?:\.git)?(?:[/#].*)?$/i.exec(spec) || (isGit(spec) && /^([^:/#]+\/[^:/#]+)(?:#.*)?$/.exec(spec)); return m ? m[1].toLowerCase() : ""; };
72
+ const entryOf = (pkg) => {
73
+ const g = (tagged || []).find((x) => x.package === pkg)?.github;
74
+ return mine?.plugins?.find((e) => name(e.spec) === pkg || (g && ghRepo(e.spec) === g.repo.toLowerCase()));
75
+ };
76
+ // npm has the versions of a package: not a folder's, nor a git one's
77
+ const onNPM = (spec) => !isPath(spec) && !isGit(spec);
78
+ // a git one is out of date when its repository is at another commit than
79
+ // the one installed, whatever its version (ARNO); a short sha names the full one
80
+ const sameCommit = (a, b) => !!a && !!b && (a.length > b.length ? a.startsWith(b) : b.startsWith(a));
81
+ const gitBehind = (e) => isGit(e.spec) && !!e.commit && !!e.head && !sameCommit(e.commit, e.head);
82
+ // the providers a plugin signs in to, as the add sheet knows them
83
+ const subsOf = (pkg) => (providers?.plugins || []).filter((x) => name(x.spec) === pkg);
84
+ // as the Add sheet names it
85
+ const subName = (x) => SUBS.find((y) => y.agent === x.id)?.name || x.name;
86
+ // with several, the reader picks one by name
87
+ const signInTo = (b, outs, ev) => {
88
+ if (outs.length === 1) return pluginSignIn(outs[0].id, ev);
89
+ openProtoMenu(b, outs.map((x) => ({ v: x.id, name: subName(x), literalName: true, note: "" })), "",
90
+ (id, e) => pluginSignIn(id, e), "Sign in to which subscription", "", "right");
91
+ };
92
+ // whether version a comes after b in semver's order, as update.Newer
93
+ const newer = (a, b) => {
94
+ const p = (v) => {
95
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([^+]*))?(?:\+.*)?$/.exec(String(v || "").trim());
96
+ return m && { n: [+m[1], +m[2], +m[3]], pre: m[4] || "" };
97
+ };
98
+ const [x, y] = [p(a), p(b)];
99
+ if (!x || !y) return false;
100
+ for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] > y.n[i];
101
+ if (x.pre === y.pre) return false;
102
+ if (!x.pre || !y.pre) return !x.pre;
103
+ const [s, u] = [x.pre.split("."), y.pre.split(".")];
104
+ for (let i = 0; i < s.length && i < u.length; i++) {
105
+ const [d, e] = [/^\d+$/.test(s[i]), /^\d+$/.test(u[i])];
106
+ if (d && e) { if (+s[i] !== +u[i]) return +s[i] > +u[i]; }
107
+ else if (d !== e) return e;
108
+ else if (s[i] !== u[i]) return s[i] > u[i];
109
+ }
110
+ return s.length > u.length;
111
+ };
112
+
113
+ function glyph(d, size = 14, stroke = 1.5) { return svg(d, size, stroke); }
114
+ // kindChip says what a plugin is, as KINDS' words say
115
+ const KINDS = {
116
+ pv: [PLUG, "Provider", "Signs in to a subscription and serves its models"],
117
+ mw: [PIPE, "Middleware", "Runs in magpie's gateway on the requests your agents send and on the replies"],
118
+ ag: [TERM, "Agent", "Adds an agent magpie has no setup of its own for: pick its model on the Agents page and it goes through magpie"],
119
+ tl: [TOOL, "Tools", "Gives every agent tools of its own, through magpie's MCP server"],
120
+ pg: [PAGE, "Page", "Adds a page of its own to the sidebar. It runs shut off from the rest of magpie and can read only what its row lists"],
121
+ pn: [PAGE, "Panels", "Adds panels to magpie's own pages. They run shut off from the rest of magpie and can read only what its row lists"],
122
+ };
123
+ const kindOf = (l) => ({ middleware: "mw", hooks: "mw", agent: "ag", tools: "tl", page: "pg" })[l.kind] || "pv";
124
+ function kindChip(kind, name) {
125
+ const [d, word, tip] = KINDS[kind];
126
+ const c = el("span", "pm-chip kind " + kind);
127
+ c.append(glyph(d, 10, 1.5), el("span", "", t(name || word)));
128
+ c.title = t(tip);
129
+ return c;
130
+ }
131
+ function logo(ic, big, kind) {
132
+ const box = el("span", "pm-logo" + (big ? " big" : ""));
133
+ if (ic) box.append(icon(ic, !big));
134
+ else box.append(glyph({ mw: PIPE, ag: TERM, tl: TOOL, pg: PAGE }[kind] || PUZZLE, big ? 22 : 17, 1.4));
135
+ return box;
136
+ }
137
+
138
+ // npm's answers, put where the cards and rows read them: on the
139
+ // listing and the entry themselves, so a plugin's page opened before
140
+ // npm answered has them too
141
+ function merge() {
142
+ for (const l of listings || []) if (npm[l.package]) l.npm = npm[l.package];
143
+ for (const e of mine?.plugins || []) if (onNPM(e.spec) && npm[name(e.spec)]?.version) e.latest = npm[name(e.spec)].version;
144
+ for (const e of mine?.plugins || []) if (isGit(e.spec) && heads[e.spec]) e.head = heads[e.spec];
145
+ }
146
+ async function loadMine() {
147
+ try {
148
+ mine = await api("plugins");
149
+ failed = "";
150
+ // a plugin turned on or off brings its page's tab, or takes it
151
+ window.loadPluginPages?.();
152
+ } catch (e) {
153
+ failed = e.message;
154
+ }
155
+ merge();
156
+ }
157
+ // a repository tagged magpie-plugin as the market's cards read a
158
+ // listing: its owner as the publisher, its version, and installed by its
159
+ // spec (its npm name when published there from the repository, else
160
+ // github:owner/repo); its name and summary its package.json's own (#3608)
161
+ const ghListing = (r) => ({
162
+ package: r.spec, name: r.name || r.repo.split("/")[1], kind: r.kind, github: r, icon: r.icon || undefined,
163
+ summary: r.summary || r.description ? { ...(r.description && { en: r.description }), ...r.summary } : undefined,
164
+ own: !!(r.name || r.summary),
165
+ npm: { version: r.version || "", publisher: r.owner, repository: r.url, license: r.license, weekly: 0 },
166
+ });
167
+ // A failed ask is said, with why, list or not (till on Discord); a list
168
+ // had before stands. again: Try again, which asks GitHub at once.
169
+ async function loadTagged(again) {
170
+ try {
171
+ const r = await api("plugins/github" + (again ? "?again=1" : ""));
172
+ const got = (r.repos || []).map(ghListing);
173
+ if (!(r.error && !got.length && tagged?.length)) tagged = got;
174
+ taggedErr = r.error || "";
175
+ taggedKind = r.why || "";
176
+ taggedMissing = r.missing || 0;
177
+ if (r.topic) topic = r.topic;
178
+ } catch (e) {
179
+ taggedErr = e.message || String(e);
180
+ taggedKind = "";
181
+ taggedMissing = 0;
182
+ tagged = tagged || [];
183
+ }
184
+ }
185
+ async function loadListings() {
186
+ try {
187
+ const r = await api("plugins/listings");
188
+ listings = r.listings || [];
189
+ failedList = "";
190
+ } catch (e) {
191
+ failedList = e.message;
192
+ }
193
+ merge();
194
+ }
195
+ // what npm says of the plugins suggested and those installed: drawn
196
+ // when it comes, never waited on
197
+ let npmTries = 0;
198
+ async function askNPM(only) {
199
+ const names = [...new Set([...(listings || []).map((l) => l.package), ...(mine?.plugins || []).filter((e) => onNPM(e.spec)).map((e) => name(e.spec))])]
200
+ .filter((n) => !only || !npm[n]);
201
+ if (!names.length) return;
202
+ try {
203
+ const r = await api("plugins/npm?names=" + encodeURIComponent(names.join(",")));
204
+ Object.assign(npm, r.npm || {});
205
+ } catch {}
206
+ merge();
207
+ // one npm said nothing of: unknown (Install), asked again
208
+ let u = 0;
209
+ for (const l of listings || []) if (!l.npm || l.npm.u) u = l.npm = { weekly: 0, u: 1 };
210
+ if (u && npmTries++ < 4) setTimeout(() => askNPM(true), 15000);
211
+ drawBody();
212
+ }
213
+
214
+ // the commit each git plugin's repository is at now: drawn when it
215
+ // comes, never waited on (only: when one, just added, isn't known yet)
216
+ async function askGit(only) {
217
+ if (!(mine?.plugins || []).some((e) => isGit(e.spec) && (!only || !heads[e.spec]))) return;
218
+ try {
219
+ const r = await api("plugins/git");
220
+ Object.assign(heads, r.heads || {});
221
+ } catch { return; }
222
+ merge();
223
+ drawBody();
224
+ }
225
+
226
+ async function load() {
227
+ if (!mine && !listings) draw();
228
+ const parts = [
229
+ loadMine().then(() => { draw(); window.renderPluginDot?.(); }),
230
+ loadListings().then(draw),
231
+ loadTagged().then(drawBody),
232
+ loadBuiltins(),
233
+ providers ? null : loadProviders().then(drawBody, () => {}),
234
+ ];
235
+ await Promise.all(parts);
236
+ askNPM();
237
+ askGit();
238
+ }
239
+ window.loadPlugins = load;
240
+ // the sidebar's dot, followed: Plugins opens on Installed, where the
241
+ // plugins with an update are named at the top
242
+ window.pluginTabTo = (id) => { tab = id; };
243
+ // pluginQuery: Discover, looking for q (the add sheet found nothing by it)
244
+ window.pluginQuery = (q) => {
245
+ query = q;
246
+ tab = "discover";
247
+ clearTimeout(searchTimer);
248
+ hits = q.trim().length >= 2 ? { q: q.trim(), loading: true } : null;
249
+ if (hits) searchNPM(hits.q);
250
+ draw();
251
+ };
252
+
253
+ // pluginAgentsTo: Discover, at its Agents (the Agents page's More in
254
+ // Plugins). The scroll is the reader's click's, so it is made only while
255
+ // the click is fresh: Agents drawn later, the listings slow, is left
256
+ // where the page is.
257
+ let agentsFor = null; // the click that asked for Agents, till they are drawn
258
+ window.pluginAgentsTo = (ev) => {
259
+ query = "";
260
+ tab = "discover";
261
+ clearTimeout(searchTimer);
262
+ hits = null;
263
+ agentsFor = ev;
264
+ draw();
265
+ };
266
+
267
+ async function refresh() {
268
+ // a plugin installed, switched on or off, or removed brings its
269
+ // page's tab, or takes it, now: not on the Plugins page's next load
270
+ window.loadPluginPages?.();
271
+ try {
272
+ const [m, p] = await Promise.all([api("plugins"), api("providers")]);
273
+ mine = m;
274
+ providers = p;
275
+ merge();
276
+ // the Providers page owns the dialog as it draws: only drawn when shown
277
+ if (view === "providers") renderProviders();
278
+ } catch (e) { statusErr(e); }
279
+ draw();
280
+ window.renderPluginDot?.();
281
+ askNPM(true);
282
+ askGit(true);
283
+ }
284
+
285
+ async function act(pkg, op, body, done) {
286
+ if (busy.size && op !== "off") return;
287
+ busy.set(pkg, op);
288
+ draw();
289
+ try {
290
+ await api("plugins/" + op, body);
291
+ busy.delete(pkg);
292
+ for (const e of mine?.plugins || []) if (op === "update" || e.spec === body?.spec) delete heads[e.spec]; // stale once installed (#4802)
293
+ await refresh();
294
+ if (done) done();
295
+ } catch (e) {
296
+ busy.delete(pkg);
297
+ draw();
298
+ statusErr(e);
299
+ }
300
+ }
301
+ // Check for updates: npm asked now, not what it said within the hour,
302
+ // for each installed plugin's newest version. Each row then says so (an
303
+ // update, up to date, or why it couldn't be checked), and its Update, or
304
+ // Update all, installs it as before. Nothing is installed by the check.
305
+ async function checkUpdates() {
306
+ if (checking || busy.size) return;
307
+ checking = true;
308
+ draw();
309
+ try {
310
+ const r = await api("plugins/check", {});
311
+ mine = r.state || mine;
312
+ const by = {};
313
+ for (const c of r.plugins || []) {
314
+ by[c.spec] = c;
315
+ // npm's answer now is the one the rows read, and a git one's
316
+ // repository's
317
+ if (c.latest) npm[c.package] = { ...(npm[c.package] || {}), version: c.latest };
318
+ if (c.head) heads[c.spec] = c.head;
319
+ }
320
+ checked = { at: r.at, by };
321
+ merge();
322
+ const asked = (r.plugins || []).filter((c) => c.status === "update" || c.status === "current" || c.status === "unknown");
323
+ const ups = asked.filter((c) => c.status === "update" && !c.off).length;
324
+ const unknown = asked.filter((c) => c.status === "unknown");
325
+ if (asked.length && unknown.length === asked.length) {
326
+ status(t("Couldn't check for updates: {error}", { error: whyText(unknown[0]) }), "err", 8000);
327
+ } else {
328
+ // with some not checked, "every plugin" isn't known to be up to
329
+ // date: only the ones checked are (#1942)
330
+ let msg = ups ? (ups === 1 ? t("1 plugin has an update") : t("{n} plugins have updates", { n: ups }))
331
+ : unknown.length ? t("No updates for the plugins checked") : t("Every plugin is up to date");
332
+ if (unknown.length) {
333
+ msg += " · " + t("{n} couldn't be checked: {error}", { n: unknown.length, error: whyText(unknown[0]) });
334
+ status(msg, "warn", 8000);
335
+ } else status(msg, "ok");
336
+ }
337
+ } catch (e) {
338
+ status(t("Couldn't check for updates: {error}", { error: e.message }), "err", 8000);
339
+ }
340
+ checking = false;
341
+ draw();
342
+ window.renderPluginDot?.();
343
+ }
344
+ // why npm said nothing of a plugin, in the page's words; a git one's
345
+ // repository, in its own
346
+ function whyText(c) {
347
+ if (isGit(c.spec || "")) return t("Couldn't ask {repo} for its newest commit: {error}", { repo: c.spec, error: c.limited ? limitedText(c.limited) : c.error || "" });
348
+ switch (c.why) {
349
+ case "offline": return t("npm couldn't be reached — check your connection or proxy");
350
+ case "limited": return t("npm is turning requests away for now (too many); try again in a few minutes");
351
+ case "missing": return t("npm doesn't have it");
352
+ default: return t("npm answered with an error: {error}", { error: c.error || "" });
353
+ }
354
+ }
355
+ // GitHub's rate limit used up, in the page's words as the library's
356
+ // skill check says it: when it lifts, and how a token raises it (#1942)
357
+ function limitedText(l) {
358
+ const d = l.until ? new Date(l.until) : null;
359
+ const time = d && !isNaN(d) ? d.toLocaleTimeString(intlLang(), { hour: "2-digit", minute: "2-digit" }) : "";
360
+ if (l.token) return time ? t("GitHub's rate limit for your GitHub token is used up until {time}", { time })
361
+ : t("GitHub's rate limit for your GitHub token is used up for now");
362
+ return time ? t("GitHub allows 60 requests an hour without a token, used up until {time}. Add a GitHub token in Settings → Network and sharing to raise it to 5,000.", { time })
363
+ : t("GitHub allows 60 requests an hour without a token, used up for now. Add a GitHub token in Settings → Network and sharing to raise it to 5,000.");
364
+ }
365
+ const checkedAt = () => checked ? new Date(checked.at).toLocaleTimeString(intlLang(), { hour: "2-digit", minute: "2-digit" }) : "";
366
+
367
+ // the built-ins with accounts this plugin could run in their place
368
+ const movableOf = (pkg) => (mine?.movable || []).filter((c) => c.package === pkg);
369
+ // move: a built-in's accounts onto its plugin, installing it if need be,
370
+ // as its editor's Runs on does
371
+ async function move(c) {
372
+ if (busy.size) return;
373
+ busy.set(c.package, "move");
374
+ asking = null;
375
+ draw();
376
+ try {
377
+ providers = await api("provider/move", { id: c.id });
378
+ busy.delete(c.package);
379
+ await refresh();
380
+ const now = providers.providers.find((x) => x.id === c.id);
381
+ const accts = now?.account?.logins?.length || c.accounts, models = (now?.models || []).filter((x) => x.on).length;
382
+ status(t("{name} now runs on its plugin — {accounts}, {models}. Its editor in Providers moves it back.", {
383
+ name: c.name,
384
+ accounts: t(accts === 1 ? "{n} account" : "{n} accounts", { n: accts }),
385
+ models: t(models === 1 ? "{n} model" : "{n} models", { n: models }),
386
+ }), "ok", 9000);
387
+ } catch (e) {
388
+ busy.delete(c.package);
389
+ draw();
390
+ // no editor here to sign in above: say where
391
+ const why = e.why?.code === "lapsed"
392
+ ? t("every {name} account needs signing in again. Sign one in under Providers, then move.", { name: c.name })
393
+ : moveWhy({ why: e.why, error: e.message }, { name: c.name });
394
+ status(t("It stays built-in: {error}", { error: why }), "err", 12000);
395
+ }
396
+ }
397
+ const moveButton = (b, c, here) => {
398
+ // one word beside the name, as Install is: the card already says whose
399
+ // accounts, the title says how many and what it does (a whole sentence
400
+ // was a solid bar under the card that broke onto two lines)
401
+ b.classList.add("get", "move");
402
+ b.textContent = t("Move");
403
+ b.title = (here ? t("Move {name} here", { name: c.name }) : t(c.accounts === 1 ? "Move my {n} {name} account" : "Move my {n} {name} accounts", { name: c.name, n: c.accounts }))
404
+ + " — " + t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: c.name });
405
+ b.disabled = busy.size > 0;
406
+ b.onclick = (ev) => { ev.stopPropagation(); move(c); };
407
+ return b;
408
+ };
409
+ // the installed plugin spec's Install would replace (plugins/ holds one
410
+ // package of a name, plugin.Add), both named by source (#3685). typed:
411
+ // the field's, where a bare name may be a community plugin's and an
412
+ // unread repository's package is unknown: neither is asked about.
413
+ const sourceOf = (spec) => isGit(spec) ? spec : "npm:" + name(spec.replace(/^npm:\s*/, ""));
414
+ const namesakeOf = (spec, typed) => {
415
+ const r = ghRepo(spec);
416
+ const g = (tagged || []).find((x) => x.package === spec || (r && x.github.repo.toLowerCase() === r))?.github;
417
+ const as = g ? g.package || "" : spec.startsWith("npm:") ? name(spec.slice(4).trim()) : onNPM(spec) && (!typed || spec.startsWith("@")) ? name(spec) : "";
418
+ const e = as && mine?.plugins?.find((x) => (isGit(x.spec) ? x.package : onNPM(x.spec) && name(x.spec)) === as);
419
+ // no other plugin: the same repository, or npm's at another version
420
+ if (!e || (r ? ghRepo(e.spec) === r : !isGit(spec) && !isGit(e.spec))) return null;
421
+ return { old: sourceOf(e.spec), new: sourceOf(spec), package: as, off: !!e.off };
422
+ };
423
+ // false when the user keeps the plugin spec would replace
424
+ const replaceOK = async (spec, typed) => {
425
+ const was = namesakeOf(spec, typed);
426
+ if (!was) return true;
427
+ return confirmAction(t("Replace {old} with {new}?", was), was.off
428
+ ? t("{new} and {old} are both the package {package}, and only one of them can be installed: {old} is removed, and its options and sign-ins pass to {new}. {old} is off, so {new} is installed off too.", was)
429
+ : t("{new} and {old} are both the package {package}, and only one of them can be installed: {old} is removed, and its options and sign-ins pass to {new}.", was), t("Replace"));
430
+ };
431
+ const install = async (pkg, nm) => {
432
+ if (!await replaceOK(pkg)) return;
433
+ act(pkg, "add", { spec: pkg }, () => {
434
+ const subs = subsOf(pkg);
435
+ status(subs.length ? t("{name} is installed — sign in to use it", { name: nm }) : t("{name} is installed", { name: nm }), "ok");
436
+ });
437
+ };
438
+
439
+ // A plugin's Update, wherever it is offered: npm has a newer version, or
440
+ // a git one's repository a newer commit. Makes b that button and says
441
+ // whether there is one.
442
+ function updateButton(b, e) {
443
+ if (!e || e.off) return false;
444
+ const npm = e.latest && e.version && newer(e.latest, e.version);
445
+ if (!npm && !gitBehind(e)) return false;
446
+ const pkg = name(e.spec);
447
+ const l = (listings || []).find((x) => x.package === pkg);
448
+ b.classList.add("pm-act", "pm-up");
449
+ b.type = "button";
450
+ b.textContent = busy.get(pkg) === "upgrade" || busy.get(e.spec) === "upgrade" ? t("Updating…") : t("Update");
451
+ b.disabled = busy.size > 0 || checking;
452
+ if (npm) {
453
+ b.title = t("Update to {v}", { v: "v" + e.latest });
454
+ // the community's would come by itself: Update brings it now
455
+ if (pkg.startsWith("@magpie-community/") && !/@(?!latest$)[^@/]+$/.test(e.spec.slice(pkg.length))) b.title += "\n" + t("magpie updates it by itself within the hour; Update does it now");
456
+ } else b.title = t("Fetches it from {repo} again", { repo: e.spec }) + "\n" + t("{have} installed, {repo} is at {head}", { have: e.commit, repo: e.spec, head: e.head });
457
+ b.onclick = (ev) => {
458
+ ev.stopPropagation();
459
+ const was = e.commit;
460
+ act(pkg, "upgrade", { spec: e.spec }, () => {
461
+ const now = mine?.plugins?.find((x) => x.spec === e.spec)?.commit;
462
+ status(npm ? t("{name} updated to v{v}", { name: l?.name || pkg, v: e.latest }) : now && was && !sameCommit(now, was) ? t("{name} updated to {commit}", { name: shownName(e), commit: now }) : t("{name} is up to date", { name: shownName(e) }), "ok");
463
+ });
464
+ };
465
+ b.onkeydown = (ev) => ev.stopPropagation();
466
+ return true;
467
+ }
468
+
469
+ // the one button a plugin shows: what to do with it next
470
+ function actionFor(pkg, nm, listed) {
471
+ const b = el("button", "pm-act");
472
+ const e = entryOf(pkg);
473
+ const op = busy.get(pkg);
474
+ if (op === "add" || op === "upgrade" || op === "move") {
475
+ b.classList.add("busy");
476
+ if (op === "move") b.classList.add("move");
477
+ b.append(el("span", "spin"), el("span", "", op === "move" ? t("Moving…") : op === "upgrade" ? t("Updating…") : mine?.bun ? t("Installing…") : t("Getting Bun…")));
478
+ b.disabled = true;
479
+ if (!mine?.bun) b.title = t("Plugins run on Bun {v}, downloaded once", { v: mine?.bunVersion || "" });
480
+ return b;
481
+ }
482
+ // not known yet whether it's installed, or whether npm has it: a
483
+ // button that waits, rather than one that says the wrong thing
484
+ if (!mine || (!e && listed && !listed.npm)) {
485
+ b.classList.add("busy");
486
+ b.append(el("span", "spin"));
487
+ b.disabled = true;
488
+ return b;
489
+ }
490
+ if (!e) {
491
+ if (listed && !listed.github && !listed.npm?.version && !listed.npm.u) {
492
+ b.textContent = t("Coming soon");
493
+ b.disabled = true;
494
+ b.title = t("Not on npm yet");
495
+ return b;
496
+ }
497
+ const c = movableOf(pkg)[0];
498
+ if (c) return moveButton(b, c);
499
+ b.classList.add("get");
500
+ b.textContent = t("Install");
501
+ b.disabled = busy.size > 0;
502
+ b.onclick = (ev) => { ev.stopPropagation(); install(pkg, nm); };
503
+ return b;
504
+ }
505
+ if (e.off) {
506
+ b.textContent = t("Switch on");
507
+ b.onclick = (ev) => { ev.stopPropagation(); act(pkg, "off", { spec: e.spec, off: false }); };
508
+ return b;
509
+ }
510
+ const subs = subsOf(pkg);
511
+ const out = subs.find((x) => !x.signedIn);
512
+ // signed in to nothing yet, with a built-in's accounts to bring
513
+ const c = !subs.some((x) => x.signedIn) && movableOf(pkg)[0];
514
+ if (c) return moveButton(b, c, true);
515
+ // one of its subscriptions signed in (Qoder, beside Qoder CN; #556): the
516
+ // card says it is, rather than a Sign in that reads as signed out; the
517
+ // Installed row offers the others by name
518
+ const on = subs.find((x) => x.signedIn);
519
+ if (out && !on) {
520
+ b.classList.add("go");
521
+ b.textContent = t("Sign in");
522
+ b.title = subs.length > 1 ? t("Sign in to {name}", { name: subs.map(subName).join(t(", ")) }) : "";
523
+ b.onclick = (ev) => { ev.stopPropagation(); signInTo(b, subs, ev); };
524
+ return b;
525
+ }
526
+ // an update out: the one button says so in place of Signed in or
527
+ // Installed, the size of Install in a colour of its own, as an app
528
+ // store's Update takes Open's place (#1587). Sign in, not done yet,
529
+ // comes first; the Installed row offers its Update then
530
+ if (updateButton(b, e)) return b;
531
+ if (on) {
532
+ b.classList.add("done");
533
+ b.append(glyph(CHECK, 11, 2), el("span", "", t("Signed in")));
534
+ b.title = t("Open {name} in Providers", { name: on.name });
535
+ b.onclick = (ev) => { ev.stopPropagation(); openProvider(on.id); };
536
+ return b;
537
+ }
538
+ b.classList.add("done");
539
+ b.append(glyph(CHECK, 11, 2), el("span", "", t("Installed")));
540
+ b.disabled = true;
541
+ return b;
542
+ }
543
+
544
+ function card(l) {
545
+ const c = el("div", "pm-card");
546
+ c.tabIndex = 0;
547
+ c.setAttribute("role", "button");
548
+ c.dataset.pkg = l.package;
549
+ const e = entryOf(l.package);
550
+ if (e) c.classList.add("have");
551
+ const top = el("div", "pm-top");
552
+ const who = el("div", "pm-who");
553
+ const nm = el("div", "pm-name");
554
+ nm.append(el("b", "", l.name));
555
+ who.append(nm);
556
+ const by = el("div", "pm-by");
557
+ if (l.community) {
558
+ const v = el("span", "pm-verified");
559
+ v.append(glyph(SHIELD, 11, 1.5), el("span", "", t("magpie community")));
560
+ v.title = t("Written for magpie by its community, in github.com/magpie-community/plugins");
561
+ by.append(v);
562
+ } else by.append(el("span", "", l.npm?.publisher || l.package));
563
+ who.append(by);
564
+ if (l.github) c.classList.add("gh");
565
+ const go = actionFor(l.package, l.name, l);
566
+ top.append(logo(l.icon || l.npm?.icon, false, kindOf(l)), who, go);
567
+ const sum = el("p", "pm-sum", summary(l));
568
+ const meta = el("div", "pm-meta");
569
+ meta.append(kindChip(kindOf(l), l.kind === "hooks" && "Request hook"));
570
+ if (l.own) {
571
+ const o = el("span", "pm-chip", t("Its own words"));
572
+ o.title = t("Its name and summary are what the package says of itself in its package.json: nobody reviewed them");
573
+ meta.append(o);
574
+ }
575
+ if (l.github) meta.append(stars(l.github.stars));
576
+ if (l.npm?.weekly) {
577
+ const d = el("span", "pm-dl");
578
+ d.append(glyph(DOWN, 11, 1.5), el("span", "", t("{n}/week", { n: count(l.npm.weekly) })));
579
+ d.title = t("{n} downloads on npm last week", { n: l.npm.weekly.toLocaleString(intlLang()) });
580
+ meta.append(d);
581
+ }
582
+ if (l.npm?.version) {
583
+ // an update out, and the card's button is its Update: the version it
584
+ // goes to beside the one installed (#1587)
585
+ const up = go.classList.contains("pm-up") && e.latest && e.version && newer(e.latest, e.version);
586
+ meta.append(el("span", "pm-ver", "v" + (e?.version || l.npm.version) + (up ? " → v" + e.latest : "")));
587
+ }
588
+ if (e?.error && !e.off) meta.append(el("span", "pm-chip bad", t("Didn't load")));
589
+ else if (e?.off) meta.append(el("span", "pm-chip", t("Off")));
590
+ if (l.replaces) {
591
+ const b = el("span", "pm-chip soft", t("Replaces built-in"));
592
+ b.title = t("magpie's own sign-in to this subscription is deprecated: this plugin takes its place, and accounts signed in there move to it");
593
+ meta.append(b);
594
+ }
595
+ c.append(top, sum, meta);
596
+ c.onclick = () => detail(l);
597
+ c.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); detail(l); } };
598
+ return c;
599
+ }
600
+
601
+ function stars(n) {
602
+ const d = el("span", "pm-dl");
603
+ d.append(glyph(STAR, 11, 1.4), el("span", "", count(n)));
604
+ d.title = t("{n} stars on GitHub", { n: (n || 0).toLocaleString(intlLang()) });
605
+ return d;
606
+ }
607
+
608
+ // cards still to come, drawn as a card is laid out (its picture, name
609
+ // and author, two lines of summary, its facts) for a light to pass over,
610
+ // one card after the other
611
+ function skeleton(n) {
612
+ const g = el("div", "pm-grid");
613
+ g.setAttribute("aria-hidden", "true");
614
+ const bar = (c) => el("div", "sk " + c);
615
+ for (let i = 0; i < n; i++) {
616
+ const c = el("div", "pm-card ghost");
617
+ c.style.setProperty("--i", i);
618
+ const top = el("div", "gtop"), who = el("div", "gwho");
619
+ who.append(bar("gname"), bar("gby"));
620
+ top.append(bar("glogo"), who, bar("gact"));
621
+ const meta = el("div", "gmeta");
622
+ meta.append(bar("gchip"), bar("gchip short"));
623
+ c.append(top, bar("gline"), bar("gline short"), meta);
624
+ g.append(c);
625
+ }
626
+ return g;
627
+ }
628
+
629
+ function section(title, hint, list) {
630
+ const box = el("section", "pm-sec");
631
+ const h = el("div", "pm-sechead");
632
+ h.append(el("h3", "", title));
633
+ if (hint) h.append(el("span", "", hint));
634
+ const g = el("div", "pm-grid");
635
+ for (const l of list) g.append(card(l));
636
+ box.append(h, g);
637
+ return box;
638
+ }
639
+
640
+ // GitHub's section: its own kinds, an installed strip and a field, each
641
+ // counting what the others leave; only the section redraws (#1055)
642
+ let ghKind = ""; // "" (all) | "pv" | "mw" | "ag"
643
+ let ghHave = ""; // "" (all) | "have" | "none"
644
+ let ghQuery = "";
645
+ let ghList = []; // what the page's search left of the repositories
646
+ const ghSec = el("section", "pm-sec pm-ghsec");
647
+ const ghHead = el("div", "pm-sechead");
648
+ const ghBar = el("div", "pm-ghbar");
649
+ const ghGrid = el("div", "pm-grid");
650
+ const ghFind = el("label", "pm-find pm-ghfind");
651
+ const ghQ = el("input");
652
+ ghQ.type = "search";
653
+ ghQ.spellcheck = false;
654
+ ghQ.autocomplete = "off";
655
+ ghQ.oninput = () => { ghQuery = ghQ.value; drawGh(); };
656
+ ghQ.onkeydown = (ev) => { ev.stopPropagation(); if (ev.key === "Escape" && ghQ.value) { ghQ.value = ""; ghQ.oninput(); } };
657
+ ghFind.append(glyph(SEARCH, 13, 1.6), ghQ);
658
+ const ghMatch = (l) => {
659
+ const f = ghQuery.trim().toLowerCase();
660
+ return !f || [l.name, l.github.repo, l.github.owner, l.package, summary(l)].join(" ").toLowerCase().includes(f);
661
+ };
662
+ const ghTitle = () => [el("h3", "", t("Unofficial, on GitHub")), el("span", "", t("repositories their authors tagged {topic} — nobody has reviewed them; read the code before you install one", { topic }))];
663
+ // until GitHub answers, its section stands in place, saying it is looking
664
+ let ghArrive = false;
665
+ function ghWaiting() {
666
+ const look = el("span", "pm-ghlook");
667
+ look.setAttribute("role", "status");
668
+ const dots = el("span", "dots");
669
+ dots.setAttribute("aria-hidden", "true");
670
+ dots.append(el("i"), el("i"), el("i"));
671
+ look.append(dots, el("span", "", t("Looking on GitHub")));
672
+ ghHead.replaceChildren(...ghTitle(), look);
673
+ ghSec.setAttribute("aria-busy", "true");
674
+ ghArrive = true;
675
+ replaceKeeping(ghSec, [ghHead, skeleton(3)]);
676
+ return ghSec;
677
+ }
678
+ // why the list isn't GitHub's whole answer; kept: cards are shown. A
679
+ // known kind in the reader's words, the raw error as its hover
680
+ const ghWhy = (kept) => {
681
+ const error = { slow: t("GitHub didn't answer in time"), limited: t("GitHub is turning requests away for now") }[taggedKind] || taggedErr;
682
+ const s = el("span", "", taggedMissing
683
+ ? t(kept ? "{n} more on GitHub couldn't be read: {error}" : "GitHub found {n} repositories, but none of them could be read: {error}", { n: taggedMissing, error })
684
+ : t(kept ? "Couldn't ask GitHub, so these are from before: {error}" : "Couldn't ask GitHub: {error}", { error }));
685
+ if (error !== taggedErr) s.title = taggedErr;
686
+ return s;
687
+ };
688
+ // and one GitHub couldn't be asked of says so, with another go
689
+ function ghFailed() {
690
+ ghHead.replaceChildren(...ghTitle());
691
+ ghSec.removeAttribute("aria-busy");
692
+ const box = el("div", "pm-ghnone");
693
+ const again = el("button", "text", t("Try again"));
694
+ again.type = "button";
695
+ again.onclick = () => {
696
+ tagged = null;
697
+ taggedErr = "";
698
+ taggedMissing = 0;
699
+ drawBody();
700
+ loadTagged(true).then(drawBody);
701
+ };
702
+ box.append(ghWhy(false), again);
703
+ replaceKeeping(ghSec, [ghHead, box]);
704
+ return ghSec;
705
+ }
706
+ // the cards GitHub could give, and a line saying what it couldn't, with
707
+ // another go that keeps them until it answers
708
+ function ghNote() {
709
+ const note = el("div", "pm-ghwarn");
710
+ note.setAttribute("role", "status");
711
+ const again = el("button", "text", t("Try again"));
712
+ again.type = "button";
713
+ again.onclick = () => {
714
+ again.disabled = true;
715
+ loadTagged(true).then(drawBody);
716
+ };
717
+ note.append(ghWhy(true), again);
718
+ return note;
719
+ }
720
+ function ghSection(list) {
721
+ ghList = list;
722
+ ghHead.replaceChildren(...ghTitle());
723
+ ghSec.removeAttribute("aria-busy");
724
+ drawGh();
725
+ // the first cards after the wait come in, one after the other; the
726
+ // grid drawn again for a pick doesn't
727
+ if (ghArrive) {
728
+ ghArrive = false;
729
+ ghSec.classList.add("pm-arrive");
730
+ [...ghGrid.children].forEach((c, i) => c.style.setProperty("--i", Math.min(i, 9)));
731
+ setTimeout(() => ghSec.classList.remove("pm-arrive"), 900);
732
+ }
733
+ return ghSec;
734
+ }
735
+ function drawGh() {
736
+ const head = taggedErr ? [ghHead, ghNote()] : [ghHead];
737
+ if ((tagged || []).length <= 6) {
738
+ ghGrid.replaceChildren(...ghList.map(card));
739
+ replaceKeeping(ghSec, [...head, ghGrid]);
740
+ return;
741
+ }
742
+ // the kinds there are, in the market's order, each with how many the
743
+ // field leaves of it; one kind alone needs no picking
744
+ const found = ghList.filter(ghMatch);
745
+ const kinds = ["pv", "mw", "ag", "tl", "pg"].filter((k) => ghList.some((l) => kindOf(l) === k));
746
+ if (!kinds.includes(ghKind)) ghKind = "";
747
+ // which of them are here, asked once each: the counts below would ask
748
+ // it again for every pick in both strips. Like a kind alone, a list
749
+ // that is all installed or all not has nothing to pick out, and a pick
750
+ // that can no longer be seen doesn't go on narrowing the grid.
751
+ const here = new Map(ghList.map((l) => [l, !!entryOf(l.package)]));
752
+ const have = (l) => here.get(l);
753
+ const both = ghList.some(have) && ghList.some((l) => !have(l));
754
+ if (!both) ghHave = "";
755
+ // the field and the other strip narrow it, so each count is what a
756
+ // pick in this strip would leave; the kind's own pick is the one left
757
+ // out, or every count but All would be the picked kind's alone
758
+ const n = (k, h) => found.filter((l) => (!k || kindOf(l) === k) && (!h || have(l) === (h === "have"))).length;
759
+ const bar = [];
760
+ if (kinds.length > 1) {
761
+ const pick = segs([["", t("All") + " · " + n("", ghHave)], ...kinds.map((k) => [k, t(KINDS[k][1]) + " · " + n(k, ghHave)])], ghKind, (k) => { ghKind = k; drawGh(); });
762
+ pick.classList.add("pm-ghkinds");
763
+ pick.setAttribute("role", "group");
764
+ pick.setAttribute("aria-label", t("Kind of plugin"));
765
+ bar.push(pick);
766
+ }
767
+ // what's installed, on both sides of it: a reader looks for what to
768
+ // add as much as for what they have
769
+ if (both) {
770
+ const pick = segs([["", t("All")], ["have", t("Installed") + " · " + n(ghKind, "have")], ["none", t("Not installed") + " · " + n(ghKind, "none")]], ghHave, (h) => { ghHave = h; drawGh(); });
771
+ pick.classList.add("pm-ghhave");
772
+ pick.setAttribute("role", "group");
773
+ pick.setAttribute("aria-label", t("Installed or not"));
774
+ bar.push(pick);
775
+ }
776
+ bar.push(el("span", "grow"), ghFind);
777
+ ghQ.placeholder = t("Filter by name, author or description");
778
+ ghQ.setAttribute("aria-label", t("Filter the plugins on GitHub"));
779
+ if (ghQ.value !== ghQuery) ghQ.value = ghQuery;
780
+ replaceKeeping(ghBar, bar);
781
+ const shown = found.filter((l) => (!ghKind || kindOf(l) === ghKind) && (!ghHave || have(l) === (ghHave === "have")));
782
+ if (shown.length) {
783
+ ghGrid.replaceChildren(...shown.map(card));
784
+ replaceKeeping(ghSec, [...head, ghBar, ghGrid]);
785
+ return;
786
+ }
787
+ const none = el("div", "pm-ghnone");
788
+ const all = el("button", "text", t("Show all"));
789
+ all.type = "button";
790
+ all.onclick = () => { ghKind = ""; ghHave = ""; ghQuery = ""; drawGh(); ghQ.focus(); };
791
+ none.append(el("span", "", t("No plugin on GitHub matches")), all);
792
+ replaceKeeping(ghSec, [...head, ghBar, none]);
793
+ }
794
+
795
+ // the head and its search field are made once and drawn again in
796
+ // place: the field is never taken out of the page, or a key an IME
797
+ // commits while it is out lands twice (#1055)
798
+ const headBox = el("div", "lib-head pm-head");
799
+ const find = el("label", "pm-find");
800
+ const q = el("input");
801
+ q.type = "search";
802
+ q.spellcheck = false;
803
+ q.autocomplete = "off";
804
+ q.oninput = () => {
805
+ query = q.value;
806
+ if (tab !== "discover") { tab = "discover"; draw(); }
807
+ clearTimeout(searchTimer);
808
+ const s = query.trim();
809
+ if (s.length < 2) hits = null;
810
+ else {
811
+ hits = { q: s, loading: true };
812
+ searchTimer = setTimeout(() => searchNPM(s), 350);
813
+ }
814
+ drawBody();
815
+ };
816
+ q.onkeydown = (ev) => { ev.stopPropagation(); if (ev.key === "Escape" && q.value) { q.value = ""; q.oninput(); } };
817
+ find.append(glyph(SEARCH, 13, 1.6), q);
818
+
819
+ function head() {
820
+ const n = mine?.plugins?.length || 0;
821
+ const tabs = segs([["builtin", t("Built-in")], ["discover", t("Discover")], ["installed", t("Installed") + (n ? " · " + n : "")]], tab, (id) => {
822
+ tab = id;
823
+ try { localStorage.setItem("magpie.pluginTab", id); } catch {}
824
+ searchShown();
825
+ drawBody();
826
+ });
827
+ tabs.classList.add("lib-tabs");
828
+ tabs.querySelectorAll(".opt")[2].dataset.sub = "installed";
829
+ q.placeholder = t("Search plugins and npm…");
830
+ if (q.value !== query) q.value = query;
831
+ q.setAttribute("aria-label", t("Search plugins"));
832
+ mirror = mirrorSwitch();
833
+ replaceKeeping(headBox, [tabs, el("span", "grow"), mirror, find]);
834
+ searchShown();
835
+ headBox.classList.toggle("stuck", page.scrollTop > 0);
836
+ return headBox;
837
+ }
838
+
839
+ // 「国内镜像」: the list, npm's packages and answers, and Bun asked of
840
+ // mirrors in China first (npmmirror, jsDelivr); none before what's
841
+ // installed has come, which says how it is set
842
+ let mirrorSaving = false;
843
+ // the built-in plugins are a short list of switches: nothing to search
844
+ // or fetch through a mirror
845
+ let mirror = null;
846
+ function searchShown() {
847
+ find.hidden = tab === "builtin";
848
+ if (mirror) mirror.hidden = !mine || tab === "builtin";
849
+ }
850
+ function mirrorSwitch() {
851
+ const on = !!mine?.mirror;
852
+ const b = el("button", "pm-mirror" + (on ? " on" : ""));
853
+ b.type = "button";
854
+ b.hidden = !mine;
855
+ b.disabled = mirrorSaving;
856
+ b.setAttribute("role", "switch");
857
+ b.setAttribute("aria-checked", on ? "true" : "false");
858
+ b.setAttribute("aria-label", t("Mirrors in China"));
859
+ b.title = t("Download the plugin list, plugins and Bun from mirrors in China first (npmmirror, jsDelivr), their official addresses after. Each is a copy of the very file, checked as the original is");
860
+ const sw = el("span", "lib-switch" + (on ? " on" : ""));
861
+ sw.append(el("i"));
862
+ b.append(el("span", "", t("Mirrors in China")), sw);
863
+ b.onclick = async () => {
864
+ if (mirrorSaving || !mine) return;
865
+ mirrorSaving = true;
866
+ mine.mirror = !on;
867
+ draw();
868
+ try {
869
+ const r = await api("plugins/mirror", { on: !on });
870
+ mine.mirror = r.mirror;
871
+ // the list again, now from the mirror, and npm's answers with it
872
+ if (r.mirror) loadListings().then(() => { draw(); askNPM(); });
873
+ } catch (e) {
874
+ mine.mirror = on;
875
+ statusTErr(e);
876
+ }
877
+ mirrorSaving = false;
878
+ draw();
879
+ };
880
+ return b;
881
+ }
882
+
883
+ async function searchNPM(s) {
884
+ try {
885
+ const r = await api("plugins/search?q=" + encodeURIComponent(s));
886
+ if (hits?.q === s) hits = { q: s, list: r.hits };
887
+ } catch (e) {
888
+ if (hits?.q === s) hits = { q: s, error: e.message };
889
+ }
890
+ if (hits?.q === s) drawBody();
891
+ }
892
+
893
+ page.addEventListener("scroll", () => page.querySelector(".pm-head")?.classList.toggle("stuck", page.scrollTop > 0), { passive: true });
894
+
895
+ let body = null;
896
+ function draw() {
897
+ const scroll = page.scrollTop;
898
+ // the body is made once, so the GitHub section's field stays in the page
899
+ body = body || el("div", "lib-body pm-body");
900
+ replaceKeeping(page, [head(), body]);
901
+ drawBody();
902
+ page.scrollTop = scroll;
903
+ }
904
+
905
+ function drawBody() {
906
+ if (!body) return;
907
+ const key = tab + (query ? ":q" : "");
908
+ body.classList.toggle("enter", shown !== key);
909
+ shown = key;
910
+ // the search field keeps its focus: only the body is redrawn while
911
+ // typing, and the GitHub section's field is kept in it
912
+ if (tab === "installed" || tab === "builtin" || !listings) body.replaceChildren();
913
+ if (tab === "installed") return drawInstalled();
914
+ if (tab === "builtin") return drawBuiltin();
915
+ if (failedList && !listings) {
916
+ body.append(el("p", "pm-empty", t("Couldn't load the plugins: {error}", { error: failedList })));
917
+ return;
918
+ }
919
+ if (!listings) {
920
+ body.append(intro(), skeleton(6));
921
+ return;
922
+ }
923
+ const f = query.trim().toLowerCase();
924
+ const match = (l) => !f || [l.name, l.package, summary(l), (l.providers || []).join(" "), l.npm?.publisher || ""].join(" ").toLowerCase().includes(f);
925
+ const ls = listings.filter(match);
926
+ // a tagged repository the market already lists is the market's card
927
+ const listed = new Set(listings.map((l) => l.package));
928
+ const gh = (tagged || []).filter((l) => !(l.github.package && listed.has(l.github.package)) && match(l));
929
+ // put in place around the GitHub section, which stays in the page
930
+ const out = [];
931
+ if (!f) {
932
+ out.push(intro());
933
+ // magpie's community's alone: others' plugins are found by a search
934
+ const ours = ls.filter((l) => l.community && kindOf(l) === "pv");
935
+ const mws = ls.filter((l) => l.community && kindOf(l) === "mw");
936
+ const ags = ls.filter((l) => l.community && kindOf(l) === "ag");
937
+ const tls = ls.filter((l) => l.community && kindOf(l) === "tl");
938
+ const pgs = ls.filter((l) => l.community && kindOf(l) === "pg");
939
+ if (ours.length) out.push(section(t("Subscriptions"), t("written for magpie, checked against its own sign-ins"), ours));
940
+ if (mws.length) out.push(section(t("Gateway middleware"), t("runs in magpie's gateway on what every agent sends and gets back, whichever provider serves it"), mws));
941
+ const agSec = ags.length ? section(t("Agents"), t("agents magpie has no setup of its own for, connected to it as its own are"), ags) : null;
942
+ if (agSec) { agSec.classList.add("pm-agents"); out.push(agSec); }
943
+ if (tls.length) out.push(section(t("Tools for agents"), t("tools every agent can call, through magpie's MCP server"), tls));
944
+ if (pgs.length) out.push(section(t("Pages"), t("pages of their own in magpie's window, each shut off from the rest and reading only what it lists"), pgs));
945
+ if (gh.length) out.push(ghSection(gh));
946
+ else if (!tagged) out.push(ghWaiting());
947
+ else if (taggedErr && !tagged.length) out.push(ghFailed());
948
+ out.push(manual());
949
+ if (mine?.registry) {
950
+ out.push(regBox = regBox || el("div", "pm-registry"));
951
+ import("./registry.js").then(() => pmRegistry(regBox, mine, askNPM), statusTErr);
952
+ }
953
+ replaceKeeping(body, out);
954
+ if (agentsFor && agSec) {
955
+ // after the view is put back where the reader was, as showing it
956
+ // does; the section then is the one drawn last (npm's answers draw
957
+ // it again)
958
+ if (window.scrollOnPurpose?.(agentsFor)) requestAnimationFrame(() => { body.querySelector(".pm-agents")?.scrollIntoView({ block: "start" }); window.arrived?.(page); });
959
+ agentsFor = null;
960
+ }
961
+ return;
962
+ }
963
+ if (ls.length) out.push(section(t("Suggested"), "", ls));
964
+ if (gh.length) out.push(ghSection(gh));
965
+ else if (!tagged) out.push(ghWaiting());
966
+ else if (taggedErr && !tagged.length) out.push(ghFailed());
967
+ // npm's card goes where a card above is its package (a listing, or a
968
+ // tagged repository npm has), not where a repository's package.json
969
+ // only names it (#3685)
970
+ const known = new Set([...listings.map((l) => l.package), ...(tagged || []).map((l) => l.package)]);
971
+ const box = el("section", "pm-sec");
972
+ const h = el("div", "pm-sechead");
973
+ h.append(el("h3", "", t("On npm")), el("span", "", t("OpenCode plugins and pi packages anyone published — read what one does before you install it")));
974
+ box.append(h);
975
+ if (!hits) box.append(el("p", "pm-note", t("Type two letters or more to search npm")));
976
+ else if (hits.loading) box.append(skeleton(2));
977
+ else if (hits.error) box.append(el("p", "pm-note bad", hits.error));
978
+ else {
979
+ const list = hits.list.filter((x) => !known.has(x.package));
980
+ if (!list.length) box.append(el("p", "pm-note", ls.length ? t("Nothing else on npm") : t("No plugin called “{q}”", { q: query.trim() })));
981
+ const g = el("div", "pm-grid");
982
+ for (const x of list) g.append(card({ package: x.package, name: x.package, npm: x }));
983
+ box.append(g);
984
+ }
985
+ out.push(box);
986
+ replaceKeeping(body, out);
987
+ }
988
+
989
+ function intro() {
990
+ const box = el("div", "pm-intro");
991
+ const text = el("div", "pm-introtext");
992
+ text.append(el("h2", "", t("Plugins extend magpie")));
993
+ text.append(el("p", "", t("Plugins sign in to coding plans and make their requests; the models then work in every agent, like any provider's. They're OpenCode's provider plugins or pi's packages, run on Bun.")));
994
+ const mw = el("p", "");
995
+ mw.append(document.createTextNode(t("A plugin can also run in magpie's gateway on what agents send and get back, pick which account a routing asks, give every agent tools, connect an agent, or add a page of its own.") + " "));
996
+ // the docs in the reader's language: the site has en, zh and ja, and
997
+ // Chinese is nearer a zh-TW reader than English is
998
+ const how = el("a", "pm-link pm-write", "");
999
+ how.href = "https://usemagpie.ai/docs/" + ({ zh: "zh/", "zh-TW": "zh/", ja: "ja/" }[lang()] || "") + "plugins";
1000
+ how.append(el("span", "", t("Write a plugin")), glyph(OUTL, 11, 1.5));
1001
+ how.onclick = (ev) => { ev.preventDefault(); api("open", { url: how.href }).catch(() => {}); };
1002
+ mw.append(how);
1003
+ text.append(mw);
1004
+ const trust = el("p", "pm-trust");
1005
+ trust.append(glyph(SHIELD, 12, 1.5), el("span", "", t("A plugin is someone else's code with your sign-in or your requests: install the ones you trust.")));
1006
+ text.append(trust);
1007
+ const art = el("div", "pm-art");
1008
+ for (const ic of ["zcode", "githubcopilot", "gemini-color", "zed", "kiro-color"]) art.append(logo(ic));
1009
+ box.append(text, art);
1010
+ return box;
1011
+ }
1012
+
1013
+ // a package or a folder the market doesn't list
1014
+ function manual() {
1015
+ const box = el("form", "pm-manual");
1016
+ box.append(el("span", "pm-mlabel", t("Have one in mind?")));
1017
+ const spec = el("input", "pm-minput");
1018
+ spec.placeholder = t("npm package, GitHub repo (github:owner/repo) or a folder");
1019
+ spec.spellcheck = false;
1020
+ spec.autocomplete = "off";
1021
+ spec.setAttribute("aria-label", t("Plugin"));
1022
+ spec.onkeydown = (ev) => ev.stopPropagation();
1023
+ const go = el("button", "text primary", t("Install"));
1024
+ go.type = "submit";
1025
+ go.disabled = busy.size > 0;
1026
+ if (busy.has("+")) { go.textContent = t("Installing…"); spec.disabled = true; }
1027
+ box.onsubmit = async (ev) => {
1028
+ ev.preventDefault();
1029
+ const s = spec.value.trim();
1030
+ if (!s || busy.size || !await replaceOK(s, true)) return;
1031
+ act("+", "add", { spec: s }, () => status(t("{name} is installed", { name: s }), "ok"));
1032
+ };
1033
+ box.append(spec);
1034
+ // a folder is picked, not typed, where magpie can show the system's
1035
+ // picker (`magpie web` can't, and the market says so)
1036
+ if (mine?.picker) box.append(browse(spec, go));
1037
+ box.append(go);
1038
+ return box;
1039
+ }
1040
+
1041
+ // the system's folder picker: what it answers goes in the field, for the
1042
+ // Install beside it
1043
+ function browse(spec, go) {
1044
+ const b = el("button", "pm-browse");
1045
+ b.type = "button";
1046
+ b.append(glyph(FOLDER, 14, 1.5));
1047
+ b.title = t("Choose a folder on this computer");
1048
+ b.setAttribute("aria-label", t("Choose a folder"));
1049
+ b.disabled = busy.size > 0 || spec.disabled;
1050
+ b.onclick = async () => {
1051
+ b.disabled = true;
1052
+ try {
1053
+ const r = await api("plugins/choose", {});
1054
+ if (r.dir) {
1055
+ spec.value = r.dir;
1056
+ spec.focus();
1057
+ }
1058
+ } catch (e) {
1059
+ statusErr(e);
1060
+ } finally {
1061
+ b.disabled = busy.size > 0 || spec.disabled;
1062
+ }
1063
+ };
1064
+ return b;
1065
+ }
1066
+
1067
+ // Built-in: magpie's own plugins (Sessions, Library, routing groups…),
1068
+ // each with a switch (renderBuiltins in app.js), read afresh each time
1069
+ // the tab is drawn
1070
+ let builtins = null, builtinsFailed = "";
1071
+ async function loadBuiltins() {
1072
+ try {
1073
+ builtins = await api("plugins/builtin");
1074
+ builtinsFailed = "";
1075
+ } catch (e) {
1076
+ builtinsFailed = e.message;
1077
+ }
1078
+ if (tab === "builtin") drawBody();
1079
+ }
1080
+ function drawBuiltin() {
1081
+ const say = el("p", "feat-say", t("Parts of magpie not everyone needs. Switched off, a part leaves the sidebar, its pages and Settings; magpie works the same either way."));
1082
+ const list = el("div", "list prefs");
1083
+ list.id = "builtinList";
1084
+ body.append(say, list);
1085
+ if (builtinsFailed && !builtins) list.append(el("p", "pm-empty", t(builtinsFailed)));
1086
+ else if (builtins) window.renderBuiltins(list, builtins, (next) => { builtins = next; drawBody(); });
1087
+ }
1088
+
1089
+ function drawInstalled() {
1090
+ if (failed && !mine) {
1091
+ body.append(el("p", "pm-empty", t("Couldn't load the plugins: {error}", { error: failed })));
1092
+ return;
1093
+ }
1094
+ if (!mine) { body.append(skeleton(2)); return; }
1095
+ const es = mine.plugins || [];
1096
+ if (!es.length) {
1097
+ const none = el("div", "pm-none");
1098
+ none.append(logo("", true), el("b", "", t("No plugins yet")), el("p", "", t("Install a subscription or a middleware from Discover; a subscription signs in from here.")));
1099
+ const b = el("button", "pm-act get", t("Discover plugins"));
1100
+ b.onclick = () => { tab = "discover"; draw(); };
1101
+ none.append(b);
1102
+ body.append(none);
1103
+ return;
1104
+ }
1105
+ // what the sidebar's dot is about, said where it's acted on: which
1106
+ // plugins have a newer version, and one press to update them all
1107
+ const outdated = es.filter((e) => (e.latest && e.version && newer(e.latest, e.version)) || (gitBehind(e) && !e.off));
1108
+ if (outdated.length) {
1109
+ const bar = el("div", "pm-updates");
1110
+ const names = outdated.map(shownName).join(t(", "));
1111
+ bar.append(el("span", "pm-dot"), el("span", "grow", outdated.length === 1
1112
+ ? t("A newer version of {name} is out", { name: names })
1113
+ : t("Newer versions of {n} plugins are out: {names}", { n: outdated.length, names })));
1114
+ const up = el("button", "pm-act pm-up", busy.has("*") ? t("Updating…") : t("Update all ({n})", { n: outdated.length }));
1115
+ up.type = "button";
1116
+ up.disabled = busy.size > 0 || checking;
1117
+ up.onclick = () => act("*", "update", {}, () => status(t("Plugins updated"), "ok"));
1118
+ bar.append(up);
1119
+ body.append(bar);
1120
+ }
1121
+ // by name, in the order the reader picked (#481), not the order they
1122
+ // were installed in
1123
+ const list = el("div", "list pm-list");
1124
+ const fill = () => list.replaceChildren(...[...es].sort(byName(sortOf("plugins"), shownName)).map(installedRow));
1125
+ if (es.length > 1) {
1126
+ const h = el("div", "row-head pm-listhead");
1127
+ h.append(el("span", "grow"), sortBy("plugins", NAME_SORTS, fill));
1128
+ body.append(h);
1129
+ }
1130
+ fill();
1131
+ body.append(list);
1132
+ const foot = el("div", "pm-foot");
1133
+ foot.append(el("span", "", mine.bun ? t("Plugins run on Bun {v}", { v: mine.bunVersion }) : ""), el("span", "grow"));
1134
+ // npm has versions to ask for, and a git one's repository commits; a
1135
+ // folder has none
1136
+ if (es.some((e) => onNPM(e.spec) || isGit(e.spec))) {
1137
+ const c = el("button", "text pm-check" + (checking ? " busy" : ""));
1138
+ if (checking) c.append(el("span", "spin"));
1139
+ c.append(el("span", "", checking ? t("Checking…") : t("Check for updates")));
1140
+ c.title = t("Ask npm now for each plugin's newest version") + (checked ? "\n" + t("Last checked {time}", { time: checkedAt() }) : "");
1141
+ c.disabled = checking || busy.size > 0;
1142
+ c.onclick = () => checkUpdates();
1143
+ foot.append(c);
1144
+ }
1145
+ body.append(foot);
1146
+ }
1147
+
1148
+ // an installed plugin's name as its row shows it: the market's, else the
1149
+ // one its package.json gives itself (magpie.name, #4558), else the
1150
+ // package's or the folder's
1151
+ function shownName(e) {
1152
+ const pkg = name(e.spec);
1153
+ return (listings || []).find((x) => x.package === pkg)?.name || e.ownName || (isGit(e.spec) && e.package) || label(e.spec);
1154
+ }
1155
+
1156
+ function installedRow(e) {
1157
+ const pkg = name(e.spec);
1158
+ const l = (listings || []).find((x) => x.package === pkg);
1159
+ const r = el("div", "row pm-row" + (e.off ? " off" : ""));
1160
+ const who = el("div", "who");
1161
+ const nm = el("div", "name");
1162
+ nm.append(el("span", "", shownName(e)));
1163
+ if (e.version) nm.append(el("span", "pm-ver", "v" + e.version));
1164
+ // a git one's commit says which of its repository's it is
1165
+ if (isGit(e.spec) && e.commit) {
1166
+ const c = el("span", "pm-ver", e.commit);
1167
+ c.title = t("The commit of {repo} installed", { repo: e.spec });
1168
+ nm.append(c);
1169
+ }
1170
+ const ck = checked?.by[e.spec];
1171
+ if (gitBehind(e)) {
1172
+ const c = el("span", "pm-chip up", t("New commits"));
1173
+ c.title = t("{have} installed, {repo} is at {head}", { have: e.commit, repo: e.spec, head: e.head });
1174
+ nm.append(c);
1175
+ } else if (e.latest && e.version && newer(e.latest, e.version)) {
1176
+ const c = el("span", "pm-chip up", t("v{v} out", { v: e.latest }));
1177
+ c.title = t("v{have} installed, v{v} on npm", { have: e.version, v: e.latest });
1178
+ nm.append(c);
1179
+ } else if (ck?.status === "unknown") {
1180
+ // asked just now, and npm didn't say: the row says why
1181
+ const c = el("span", "pm-chip warn");
1182
+ c.append(el("span", "dot"), el("span", "", t("Couldn't check")));
1183
+ c.title = whyText(ck);
1184
+ nm.append(c);
1185
+ } else if (e.autoUpdated && e.autoUpdated.to === e.version) {
1186
+ // magpie updated it by itself lately: the row says so, quietly
1187
+ const c = el("span", "pm-chip soft", t("Auto-updated"));
1188
+ c.title = t("magpie updated it from v{from} to v{to} on {date}", { from: e.autoUpdated.from, to: e.autoUpdated.to, date: new Date(e.autoUpdated.at).toLocaleDateString(intlLang(), { month: "short", day: "numeric" }) });
1189
+ nm.append(c);
1190
+ } else if ((ck?.status === "current" || ck?.status === "update") && e.version) {
1191
+ const c = el("span", "pm-chip soft", t("Up to date"));
1192
+ c.title = (isGit(e.spec) ? t("{head} is the newest commit of {repo}", { head: e.head || ck.head || "", repo: e.spec }) : t("v{v} is the newest on npm", { v: e.version })) + "\n" + t("Last checked {time}", { time: checkedAt() });
1193
+ nm.append(c);
1194
+ }
1195
+ // built-ins moved onto it, then its other providers if 2+ (#3902)
1196
+ const moved = (e.moved || []).map((id) => SUBS.find((x) => x.agent === id)?.name || id);
1197
+ const names = moved.join(t(", "));
1198
+ const all = [...moved, ...e.serves || []];
1199
+ if (all[0] && !e.off) {
1200
+ const c = el("span", "pm-chip moved");
1201
+ c.append(el("span", "dot"), el("span", "", t("Serves {names}", { names: all.join(t(", ")) })));
1202
+ if (names) c.title = t("{names} runs on this plugin in place of the built-in", { names });
1203
+ nm.append(c);
1204
+ }
1205
+ // gateway middleware runs in magpie's gateway, not as a subscription:
1206
+ // the row says so, and how it is doing, on a line of its own
1207
+ const mw = e.middleware;
1208
+ const gw = e.gateway;
1209
+ const ag = e.agent;
1210
+ // a provider's or agent's options (plugins#592)
1211
+ const ownOpts = !e.isMiddleware && !e.isGateway && !!(e.options || e.optionsExample);
1212
+ if (mw || e.isMiddleware) nm.append(kindChip("mw"));
1213
+ if (gw || e.isGateway) nm.append(kindChip("mw", "Request hook"));
1214
+ if (ag || e.isAgent) nm.append(kindChip("ag"));
1215
+ // a plugin of tools alone signs in to nothing: no Provider chip
1216
+ const toolsOnly = !!e.tools?.length && !e.providers?.length;
1217
+ if (e.tools?.length) nm.append(kindChip("tl"));
1218
+ if (e.page) nm.append(kindChip("pg"));
1219
+ if (e.panels?.length) nm.append(kindChip("pn"));
1220
+ if (e.providers?.length || !(e.gatewayOnly || e.middlewareOnly || e.inMagpieOnly || e.isAgent || toolsOnly || (mw && !e.isMiddleware) || (ag && !e.isAgent))) nm.append(kindChip("pv"));
1221
+ who.append(nm);
1222
+ const subs = subsOf(pkg);
1223
+ const sub = el("div", "sub");
1224
+ const cands = e.off ? [] : movableOf(pkg);
1225
+ // a provider another installed plugin signs in to as well (neiko on
1226
+ // Discord: a third-party plugin and their own one both name it). One
1227
+ // of them serves it; the row says which, and the other gets a way to
1228
+ // take it over, rather than the provider just going missing
1229
+ const clashes = e.off || e.error ? [] : e.clashes || [];
1230
+ const nameOfSpec = (spec) => { const o = (mine?.plugins || []).find((x) => x.spec === spec); return o ? shownName(o) : label(spec); };
1231
+ const ask = asking?.pkg === pkg && !busy.size && (asking.op === "move" ? cands.length : moved.length) ? asking.op : "";
1232
+ if (ask === "move") {
1233
+ sub.textContent = t("{names} can run on it again, with the same accounts.", { names: cands.map((c) => c.name).join(t(", ")) });
1234
+ sub.classList.add("ask");
1235
+ } else if (ask) {
1236
+ sub.textContent = t(ask === "remove" ? "{names} stops working until the plugin is installed again. Its accounts are kept." : "{names} stops working while the plugin is off. Its accounts are kept.", { names });
1237
+ sub.classList.add("ask");
1238
+ } else if (e.off) sub.textContent = t("Off");
1239
+ else if (e.error) { sub.textContent = t("Didn't load: {error}", { error: e.error }); sub.classList.add("bad"); sub.title = e.error; }
1240
+ else if (subs.length) {
1241
+ for (const [i, x] of subs.entries()) {
1242
+ if (i) sub.append(el("span", "sep", " · "));
1243
+ const s = el("span", "pm-sub" + (x.signedIn ? " in" : ""));
1244
+ // its built-in runs it, with its accounts: nothing to fix here
1245
+ const builtin = !x.signedIn && cands.some((c) => c.id === x.pid);
1246
+ const nx = subName(x);
1247
+ s.append(el("span", "dot"), el("span", "", x.signedIn ? t("{name}: signed in", { name: nx }) : builtin ? t("{name}: on magpie's built-in", { name: nx }) : t("{name}: not signed in", { name: nx })));
1248
+ if (builtin) s.title = t("{name} runs on magpie's built-in, with your accounts; Move {name} here runs it on this plugin", { name: nx });
1249
+ else if (!x.signedIn && subs.some((y) => y.signedIn)) s.title = t("{name} is a subscription of its own; {other} works without it", { name: nx, other: subName(subs.find((y) => y.signedIn)) });
1250
+ sub.append(s);
1251
+ }
1252
+ } else if ((mw || ag || gw || e.isGateway || toolsOnly || (e.page || e.panels?.length) && e.inMagpieOnly) && !e.providers.length) {
1253
+ // only middleware, a request hook, an agent, tools, a page or panels: its own line says what it does
1254
+ } else if (clashes.length && !e.providers.length) {
1255
+ // its own line below says whose it is
1256
+ } else if (e.providers.length) sub.textContent = t("Signs in to {names}", { names: e.providers.join(t(", ")) });
1257
+ // an empty list only means "nothing" once the plugins have answered: a
1258
+ // plugin installed a moment ago has no names yet, and the page says
1259
+ // nothing rather than claiming here that it signs in to nothing magpie
1260
+ // can use, which isn't known until the answer comes (#1112)
1261
+ else if (e.named) sub.textContent = t("Signs in to nothing magpie can use");
1262
+ if (sub.textContent) who.append(sub);
1263
+ if (!ask) for (const g of clashGroups(e, clashes)) who.append(clashLine(e, g, nameOfSpec));
1264
+ if (mw && !e.off && !ask) who.append(mwLine(mw));
1265
+ if (gw && !e.off && !ask) who.append(gatewayLine(gw));
1266
+ if (ag && !e.off && !ask) who.append(agentLine(ag));
1267
+ if (e.tools?.length && !e.off && !ask) who.append(toolsLine(e.tools, e));
1268
+ // off too: what its page may read is said before it is turned on
1269
+ if (e.page && !ask) who.append(pageLine(e.page));
1270
+ if (e.panels?.length && !ask) who.append(panelsLine(e.panels, e.panelReads));
1271
+ if (e.actions?.length && !ask) who.append(actionsLine(e.actions));
1272
+ if ((mw || e.isGateway || ownOpts) && !e.off && !ask && editing?.pkg === pkg) who.append(optionsEditor(e));
1273
+ // the plugin's own picture (package.json's magpie.icon) before its
1274
+ // first provider's, which a plugin of several gives each its own (#1775)
1275
+ r.append(logo(l?.icon || l?.npm?.icon || e.icon || subs[0]?.icon || ag?.icon, false, e.middlewareOnly ? "mw" : e.isAgent && !e.isMiddleware && !e.providers.length ? "ag" : e.inMagpieOnly && (e.page || e.panels?.length) && !e.isAgent && !e.isMiddleware ? "pg" : toolsOnly && !e.page && !mw && !ag ? "tl" : ""), who);
1276
+ const val = el("div", "val");
1277
+ const b = busy.get(pkg) || busy.get(e.spec);
1278
+ // npm's newer version, or a git one's repository at a newer commit
1279
+ // than the one installed (Update shown all the time before, and still
1280
+ // there right after an update): the same Update as a card's (#1587)
1281
+ const up = el("button");
1282
+ if (updateButton(up, e)) val.append(up);
1283
+ const here = !e.off && !e.error && !subs.some((x) => x.signedIn) && cands[0];
1284
+ if (here && !ask) {
1285
+ const mv = el("button", "text primary", b === "move" ? t("Moving…") : t("Move {name} here", { name: here.name }));
1286
+ mv.title = t("Runs {name} on this plugin in place of the built-in, with the same accounts; nothing changes if one doesn't work through it", { name: here.name });
1287
+ mv.disabled = busy.size > 0;
1288
+ mv.onclick = () => move(here);
1289
+ val.append(mv);
1290
+ }
1291
+ for (const c of ask ? [] : clashes.filter((c) => c.by !== e.spec)) {
1292
+ const u = el("button", "text", b === "prefer" ? t("Switching…") : t("Use for {name}", { name: c.name }));
1293
+ u.title = t("{name} runs on this plugin instead of {other}; both stay installed", { name: c.name, other: nameOfSpec(c.by) });
1294
+ u.disabled = busy.size > 0;
1295
+ u.onclick = () => act(pkg, "prefer", { spec: e.spec, provider: c.id }, () => status(t("{name} now runs on {plugin}", { name: c.name, plugin: shownName(e) }), "ok"));
1296
+ val.append(u);
1297
+ }
1298
+ if (!e.off && !e.error && subs.some((x) => !x.signedIn)) {
1299
+ // signed in to one of its subscriptions already (Qoder, beside Qoder
1300
+ // CN; #556): a blue Sign in read as the plugin signed out, so the
1301
+ // button names the one it signs in to and stays quiet
1302
+ const outs = subs.filter((x) => !x.signedIn), some = subs.some((x) => x.signedIn);
1303
+ const s = el("button", "text" + (here || some ? "" : " primary"), !some ? t("Sign in") : outs.length > 1 ? t("Sign in to another…") : t("Sign in to {name}", { name: subName(outs[0]) }));
1304
+ if (outs.length > 1) s.setAttribute("aria-haspopup", "menu");
1305
+ s.onclick = (ev) => { ev.stopPropagation(); signInTo(s, outs, ev); };
1306
+ val.append(s);
1307
+ }
1308
+ // switched on again: a built-in it can carry is offered its way, in
1309
+ // the row
1310
+ const again = () => { if (movableOf(pkg).length) { asking = { pkg, op: "move" }; draw(); } };
1311
+ const off = () => act(pkg, "off", { spec: e.spec, off: !e.off }, e.off ? again : null);
1312
+ const remove = () => act(pkg, "remove", { spec: e.spec }, () => status(t("{name} removed", { name: l?.name || pkg }), "ok"));
1313
+ if (ask) {
1314
+ // asked in the row, not a dialog: the user sees which go back
1315
+ const yes = el("button", "text primary", t(ask === "move" ? "Move" : ask === "remove" ? "Remove" : "Switch off"));
1316
+ yes.onclick = () => { asking = null; ask === "move" ? move(cands[0]) : ask === "remove" ? remove() : off(); };
1317
+ const no = el("button", "text", t(ask === "move" ? "Not now" : "Cancel"));
1318
+ no.onclick = () => { asking = null; draw(); };
1319
+ val.append(no, yes);
1320
+ r.append(val);
1321
+ return r;
1322
+ }
1323
+ if ((e.isGateway || (mw && !mw.error) || ownOpts) && !e.off && editing?.pkg !== pkg) {
1324
+ const o = el("button", "text", t("Options"));
1325
+ o.title = e.options ? t("The options it runs with") : t("It runs with no options; its package suggests some");
1326
+ o.disabled = !!b;
1327
+ o.onclick = () => {
1328
+ editing = { pkg, text: JSON.stringify(e.options || e.optionsExample || {}, null, 2), error: "" };
1329
+ draw();
1330
+ // the field the click opened takes the keys, where it is
1331
+ page.querySelector(".pm-opts textarea")?.focus(NO_SCROLL);
1332
+ };
1333
+ val.append(o);
1334
+ }
1335
+ const onoff = el("button", "text", t(e.off ? "Switch on" : "Switch off"));
1336
+ onoff.disabled = !!b;
1337
+ onoff.onclick = () => { if (!e.off && moved.length) { asking = { pkg, op: "off" }; draw(); } else off(); };
1338
+ const rm = el("button", "text quiet", b === "remove" ? t("Removing…") : t("Remove"));
1339
+ rm.title = moved.length ? t("{names} runs on this plugin: removed, it stops working until the plugin is installed again", { names }) : t("Removes the plugin and what it installed; its sign-ins are kept until you sign out");
1340
+ rm.disabled = busy.size > 0;
1341
+ rm.onclick = async () => {
1342
+ if (moved.length) {
1343
+ asking = { pkg, op: "remove" };
1344
+ draw();
1345
+ return;
1346
+ }
1347
+ if (await confirmRemoval(l?.name || pkg, rm.title)) remove();
1348
+ };
1349
+ val.append(onoff, rm);
1350
+ r.append(val);
1351
+ r.onclick = (ev) => { if (!ev.target.closest("button, .pm-opts")) detail(l || (isGit(e.spec) ? { package: e.spec, name: shownName(e), icon: e.icon, npm: { version: e.version, repository: gitWeb(e.spec) } } : { package: pkg, name: shownName(e), icon: e.icon, npm: { version: e.latest } })); };
1352
+ return r;
1353
+ }
1354
+
1355
+ // optionsEditor is a plugin's options as JSON, in its row: what it
1356
+ // runs with, or what its package suggests when it has none. The draft is
1357
+ // kept in editing, so a redraw while typing doesn't lose it.
1358
+ function optionsEditor(e) {
1359
+ const box = el("div", "pm-opts");
1360
+ const ta = document.createElement("textarea");
1361
+ ta.setAttribute("aria-label", t("Options"));
1362
+ ta.setAttribute("aria-describedby", "pluginOptionsNote pluginOptionsError");
1363
+ ta.setAttribute("aria-invalid", String(!!editing.error));
1364
+ ta.spellcheck = false;
1365
+ ta.value = editing.text;
1366
+ ta.rows = Math.min(14, Math.max(4, editing.text.split("\n").length + 1));
1367
+ ta.oninput = () => { editing.text = ta.value; };
1368
+ ta.onkeydown = (ev) => {
1369
+ if (ev.key === "Escape") { editing = null; draw(); }
1370
+ if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); save(); }
1371
+ };
1372
+ const err = el("div", "pm-opts-err", editing.error);
1373
+ err.id = "pluginOptionsError";
1374
+ err.setAttribute("role", "alert");
1375
+ const bar = el("div", "pm-opts-bar");
1376
+ const note = el("span", "", e.options ? t("Applied to the next request") : t("Not set: these are its package's example"));
1377
+ note.id = "pluginOptionsNote";
1378
+ const save = () => {
1379
+ let v;
1380
+ try { v = JSON.parse(editing.text || "{}"); } catch (x) { editing.error = t("Not JSON: {error}", { error: x.message }); return draw(); }
1381
+ if (!v || typeof v !== "object" || Array.isArray(v)) { editing.error = t("Options are a JSON object"); return draw(); }
1382
+ editing = null;
1383
+ act(name(e.spec), "options", { spec: e.spec, options: v }, () => status(t("{name}'s options saved", { name: shownName(e) }), "ok"));
1384
+ };
1385
+ const ok = el("button", "text primary", t("Save"));
1386
+ ok.onclick = save;
1387
+ const no = el("button", "text", t("Cancel"));
1388
+ no.onclick = () => { editing = null; draw(); };
1389
+ bar.append(note);
1390
+ if (e.options) {
1391
+ const clr = el("button", "text quiet", t("Clear"));
1392
+ clr.title = t("Runs it with no options");
1393
+ clr.onclick = async () => {
1394
+ if (!await confirmAction(t("Clear"), t("Saved options will be removed. The plugin will run with no options."), t("Clear"))) return;
1395
+ editing = null;
1396
+ act(name(e.spec), "options", { spec: e.spec, options: null }, () => status(t("{name}'s options cleared", { name: shownName(e) }), "ok"));
1397
+ };
1398
+ bar.append(clr);
1399
+ }
1400
+ bar.append(no, ok);
1401
+ box.append(ta, err, bar);
1402
+ return box;
1403
+ }
1404
+
1405
+ // clashGroups are a row's clashes, one per other plugin and side: a
1406
+ // plugin that signs in to two providers another one does too is one
1407
+ // line naming both, not a line each saying the same thing (#565)
1408
+ function clashGroups(e, clashes) {
1409
+ const out = [];
1410
+ for (const c of clashes) {
1411
+ const here = c.by === e.spec;
1412
+ const others = here ? c.with : [c.by];
1413
+ const g = out.find((x) => x.here === here && x.others.join("\n") === others.join("\n"));
1414
+ if (g) g.names.push(c.name);
1415
+ else out.push({ here, others, names: [c.name] });
1416
+ }
1417
+ return out;
1418
+ }
1419
+
1420
+ // clashLine is the providers two installed plugins both sign in to, as
1421
+ // either row says it. The other one goes by its name, or by where it
1422
+ // came from when that is this row's name too (one plugin installed
1423
+ // twice: it read as the plugin naming itself, #565)
1424
+ function clashLine(e, g, nameOfSpec) {
1425
+ const d = el("div", "sub pm-clash" + (g.here ? "" : " warn"));
1426
+ const own = shownName(e);
1427
+ const copies = g.others.filter((s) => nameOfSpec(s) === own);
1428
+ const names = g.names.join(t(", "));
1429
+ const many = g.names.length > 1;
1430
+ let text;
1431
+ if (copies.length && copies.length === g.others.length) {
1432
+ const spec = copies.join(t(", "));
1433
+ text = g.here
1434
+ ? t("Another copy of this plugin ({spec}) signs in to {names} too; this copy serves them", { spec, names })
1435
+ : t("Another copy of this plugin ({spec}) serves {names}", { spec, names });
1436
+ } else {
1437
+ const other = g.others.map(nameOfSpec).join(t(", "));
1438
+ text = g.here
1439
+ ? many ? t("{other} signs in to {names} too; this one serves them", { other, names }) : t("{other} signs in to {name} too; this one serves it", { other, name: names })
1440
+ : many ? t("{names} are served by {other}, which signs in to them too", { other, names }) : t("{name} is served by {other}, which signs in to it too", { other, name: names });
1441
+ }
1442
+ d.append(el("span", "dot"), el("span", "", text));
1443
+ d.title = g.names.map((name) => g.here
1444
+ ? t("Two plugins sign in to {name}; magpie runs it on one. Use for {name} on the other one's row switches it", { name })
1445
+ : t("Two plugins sign in to {name}; magpie runs it on one. Use for {name} switches it to this one, or remove the one you don't want", { name })).join("\n");
1446
+ return d;
1447
+ }
1448
+
1449
+ // agentLine is the agent a plugin adds, as the Agents page lists it, or
1450
+ // why it isn't there
1451
+ function agentLine(a) {
1452
+ const d = el("div", "sub pm-mw");
1453
+ if (a.error) {
1454
+ d.textContent = t("Agent didn't load: {error}", { error: a.error });
1455
+ d.classList.add("bad");
1456
+ d.title = a.error;
1457
+ return d;
1458
+ }
1459
+ // not found here, so Agents has no row (zzZ): say so, and the plugin's notice
1460
+ if (a.missing) {
1461
+ d.classList.add("pm-clash");
1462
+ d.textContent = d.title = [t("{name} isn't in Agents: magpie doesn't find it in {path}", { name: a.name, path: a.looks || "~" }), a.notice].filter(Boolean).join(" ");
1463
+ return d;
1464
+ }
1465
+ d.textContent = d.title = t("Adds {name} to Agents", { name: a.name });
1466
+ return d;
1467
+ }
1468
+
1469
+ // toolsLine is the tools a plugin gives, which magpie's plugin tools MCP
1470
+ // server offers every agent: each by the name an agent sees, its
1471
+ // description on hover, and the server's address to copy into an agent
1472
+ // and, with its Library server (toolsServer), a switch for every agent
1473
+ function toolsLine(tools, e) {
1474
+ const d = el("div", "sub pm-mw pm-tools"), sv = e.toolsServer;
1475
+ const names = tools.map((x) => x.mcp || x.name).join(t(", "));
1476
+ d.append(el("span", "", t(!sv ? "Tools for every agent: {names}" : sv.on ? "Tools for agents: {names}" : "Tools, hidden from agents: {names}", { names })));
1477
+ d.title = tools.map((x) => (x.mcp || x.name) + (x.description ? " — " + x.description : "")).join("\n");
1478
+ const url = mcpAddress(state.settings || {}, "/mcp/magpie/tools");
1479
+ const b = copyBtn(url, t("MCP server address"));
1480
+ b.title = t("Copy the MCP server's address ({url}): add it to an agent as an MCP server (Streamable HTTP)", { url });
1481
+ d.append(b);
1482
+ if (sv && !gatewayMode && !featuresOff.has("library")) {
1483
+ const f = el("button", "link pm-tools-flip", t(sv.on ? "Hide from agents" : "Give to agents"));
1484
+ f.onclick = (ev) => {
1485
+ ev.stopPropagation();
1486
+ f.disabled = true;
1487
+ api("library/servers/plugin-on", { name: sv.plugin, on: !sv.on }).then(refresh, (err) => { f.disabled = false; statusErr(err); });
1488
+ };
1489
+ d.append(f);
1490
+ }
1491
+ return d;
1492
+ }
1493
+
1494
+ // what a plugin's page may read of magpie's: what its package.json's
1495
+ // magpie.permissions declares, each a read with no secret in it
1496
+ const READS = { "usage:summary": "your usage totals, by model", "providers:list": "your providers' names" };
1497
+ const PAGES = { sessions: "Sessions", library: "Library", routing: "Routing", usage: "Usage" };
1498
+ function pageLine(p) {
1499
+ const d = el("div", "sub pm-mw pm-page");
1500
+ const reads = (p.reads || []).map((r) => t(READS[r] || r));
1501
+ const page = p.replaces && t(PAGES[p.replaces] || p.replaces);
1502
+ d.textContent = d.title = page
1503
+ ? reads.length
1504
+ ? t("Takes the place of the {page} page, and can read {reads}", { page, reads: reads.join(t(", ")) })
1505
+ : t("Takes the place of the {page} page, and reads nothing of magpie's", { page })
1506
+ : reads.length
1507
+ ? t("Adds a page to the sidebar, which can read {reads}", { reads: reads.join(t(", ")) })
1508
+ : t("Adds a page to the sidebar, which reads nothing of magpie's");
1509
+ return d;
1510
+ }
1511
+
1512
+ // where a plugin's panels go in magpie's own pages (magpie.panels), and
1513
+ // the row menus its page adds items to (magpie.actions), titled with
1514
+ // their titles; picking an item tells the page the row's name only
1515
+ const SLOTS = { agents: "the Agents page", usage: "the Usage page", settings: "Settings", provider: "each provider's editor" };
1516
+ const MENUS = { provider: "providers' menus", agent: "agents' menus" };
1517
+ function whereLine(xs, key, names, text) {
1518
+ const d = el("div", "sub pm-mw pm-page");
1519
+ d.textContent = text([...new Set(xs.map((x) => x[key]))].map((s) => t(names[s] || s)).join(t(", ")));
1520
+ d.title = xs.map((x) => x.title).filter(Boolean).join("\n") || d.textContent;
1521
+ return d;
1522
+ }
1523
+ function panelsLine(panels, panelReads) {
1524
+ const reads = (panelReads || []).map((r) => t(READS[r] || r)).join(t(", "));
1525
+ return whereLine(panels, "slot", SLOTS, (where) => reads
1526
+ ? t("Adds panels to {where}, which can read {reads}", { where, reads })
1527
+ : t("Adds panels to {where}, which read nothing of magpie's", { where }));
1528
+ }
1529
+ function actionsLine(actions) {
1530
+ return whereLine(actions, "menu", MENUS, (where) => t("Adds items to {where}, which open its page", { where }));
1531
+ }
1532
+
1533
+ // mwLine is a middleware's hooks, calls, time each and failures, or why it didn't load
1534
+ function mwLine(m) {
1535
+ const d = el("div", "sub pm-mw");
1536
+ if (m.error) {
1537
+ d.textContent = t("Middleware didn't load: {error}", { error: m.error });
1538
+ d.classList.add("bad");
1539
+ d.title = m.error;
1540
+ return d;
1541
+ }
1542
+ const lang = intlLang();
1543
+ // a gateway module's hooks, the routings it serves (strategies) and
1544
+ // its usage observer (onUsage), each said when it has it
1545
+ const parts = [];
1546
+ if (m.hooks.length) {
1547
+ let s = t("Gateway middleware: {hooks}", { hooks: m.hooks.join(t(", ")) });
1548
+ if (m.calls) s += " · " + t("{n} calls, {us} µs each", { n: m.calls.toLocaleString(lang), us: m.avgMicros < 10 ? m.avgMicros.toFixed(1) : Math.round(m.avgMicros).toLocaleString(lang) });
1549
+ parts.push(s);
1550
+ }
1551
+ if (m.strategies?.length) parts.push(t("Routings: {routings}", { routings: m.strategies.join(t(", ")) }) + (m.orders ? " · " + t("asked {n} times", { n: m.orders.toLocaleString(lang) }) : ""));
1552
+ if (m.usage) parts.push(t("Usage observer: {n} rows", { n: (m.rows || 0).toLocaleString(lang) }));
1553
+ d.append(el("span", "", parts.join(" · ")));
1554
+ if (m.events?.length) d.title = t("onEvent sees only {events} events", { events: m.events.join(t(", ")) });
1555
+ if (m.failures) {
1556
+ const f = el("span", "pm-mw-fail", t("{n} failed", { n: m.failures.toLocaleString(lang) }));
1557
+ f.title = t("A failed call leaves the request or reply as it was") + (m.lastError ? "\n" + t("Last: {error}", { error: m.lastError }) : "");
1558
+ d.append(el("span", "sep", " · "), f);
1559
+ }
1560
+ if (m.dropped) {
1561
+ const f = el("span", "pm-mw-fail", t("{n} rows dropped", { n: m.dropped.toLocaleString(lang) }));
1562
+ f.title = t("Rows that came while onUsage was still busy are dropped: a request never waits for it");
1563
+ d.append(el("span", "sep", " · "), f);
1564
+ }
1565
+ return d;
1566
+ }
1567
+
1568
+ function gatewayLine(g) {
1569
+ const d = el("div", "sub pm-mw pm-gateway");
1570
+ const labels = {
1571
+ idle: "Waiting for a request", ready: "Context injected", empty: "No matching context",
1572
+ unbound: "Scope not bound", unauthorized: "Authorization failed",
1573
+ unavailable: "Context service unavailable", invalid: "Invalid context response",
1574
+ skipped: "Request skipped", cancelled: "Request cancelled", timeout: "Recall timed out", error: "Request hook failed",
1575
+ };
1576
+ let text = t("Gateway request hook") + ": " + t(labels[g.lastOutcome] || labels.idle);
1577
+ if (g.calls) text += " · " + t("{n} calls, {ms} ms each", { n: g.calls.toLocaleString(intlLang()), ms: g.avgMillis.toFixed(1) });
1578
+ if (g.lastBytes) text += " · " + t("{n} bytes injected", { n: g.lastBytes.toLocaleString(intlLang()) });
1579
+ d.append(el("span", "", text));
1580
+ if (g.failures) d.append(el("span", "sep", " · "), el("span", "pm-mw-fail", t("{n} failed", { n: g.failures.toLocaleString(intlLang()) })));
1581
+ d.title = t("Diagnostics reset when the plugin host restarts; prompts and recalled text are not stored");
1582
+ return d;
1583
+ }
1584
+
1585
+ // a README on its way: its lines are strings, and magpie's bird drops in
1586
+ // and bounces down them, each bending under its feet and wobbling after
1587
+ // it; leave() sends it off when the README comes. Its marks are data-*,
1588
+ // not classes, so the dialog's observer doesn't place it again each hop
1589
+ function bounceLoader() {
1590
+ const box = el("div", "pm-bounce"), s = document.createElementNS("http://www.w3.org/2000/svg", "svg"), b = el("div", "sk-bird");
1591
+ s.innerHTML = "<path/><path/><path/><path/>";
1592
+ b.innerHTML = BIRD;
1593
+ // with Settings' Loading bird off, the strings stand still, birdless
1594
+ const off = birdOff();
1595
+ box.append(s);
1596
+ if (!off) box.append(b);
1597
+ const ps = [...s.children], ws = [1, .94, .6, .82], at = [.16, .44, .3, .58];
1598
+ const L = ws.map((w, i) => ({ y: 31 + i * 22, d: 0, v: 0, bx: 0, hold: false }));
1599
+ // the bird is placed by its feet: f faces it, r leans it, sx/sy squash it
1600
+ let W = 0, x = 0, y = 0, f = 1, r = 0, sx = 1, sy = 1, gone = false;
1601
+ const put = (o) => { ({ x = x, y = y, f = f, r = r, sx = sx, sy = sy } = o); b.style.transform = `translate(${x}px,${y}px) rotate(${r * f}deg) scale(${f * sx},${sy})`; };
1602
+ const draw = () => L.forEach((l, i) => {
1603
+ const h = l.y, d = l.d, x1 = W * ws[i] - 5, c = Math.min(x1 - 12, Math.max(17, l.bx));
1604
+ ps[i].setAttribute("d", `M5 ${h}C${(5 + c) / 2} ${h} ${c - (c - 5) * .35} ${h + d} ${c} ${h + d}C${c + (x1 - c) * .35} ${h + d} ${(c + x1) / 2} ${h} ${x1} ${h}`);
1605
+ });
1606
+ // a step over frames; it stops when the box is gone, or the README came
1607
+ const tween = (ms, fn, ease = (t) => t, exit) => new Promise((ok, no) => {
1608
+ const t0 = performance.now();
1609
+ const step = (now) => {
1610
+ if (!box.isConnected || (gone && !exit)) return no();
1611
+ const t = Math.min(1, (now - t0) / ms);
1612
+ fn(ease(t), t);
1613
+ t < 1 ? requestAnimationFrame(step) : ok();
1614
+ };
1615
+ requestAnimationFrame(step);
1616
+ });
1617
+ const easeIn = (t) => t * t * t, easeOut = (t) => 1 - (1 - t) ** 3;
1618
+ // a README waits for three landings (the owner, 10-30), 3s at most
1619
+ let ready;
1620
+ box.ready = new Promise((ok) => { ready = ok; });
1621
+ setTimeout(ready, 3000);
1622
+ (async () => {
1623
+ await new Promise(requestAnimationFrame);
1624
+ if (!box.isConnected || gone) return;
1625
+ W = box.clientWidth;
1626
+ if (off) return draw();
1627
+ if (calm()) return draw(), put({ x: at[0] * W, y: L[0].y - 5 });
1628
+ // each string a damped spring, let go of when the bird leaves it
1629
+ (async () => {
1630
+ let last = performance.now();
1631
+ for (;;) {
1632
+ const now = await new Promise(requestAnimationFrame), dt = Math.min(.032, (now - last) / 1000);
1633
+ if (!box.isConnected || gone) return;
1634
+ last = now;
1635
+ for (const l of L) if (!l.hold) { l.v += (-625 * l.d - 6 * l.v) * dt; l.d += l.v * dt; }
1636
+ draw();
1637
+ }
1638
+ })();
1639
+ for (;;) {
1640
+ W = box.clientWidth;
1641
+ ps.forEach((p) => delete p.dataset.lit);
1642
+ const x0 = at[0] * W;
1643
+ await tween(300, (e, t) => put({ x: x0 - 24 + 24 * t, y: -30 + (L[0].y + 25) * e, f: 1, r: 0, sx: 1 - .05 * t, sy: 1 + .08 * t }), easeIn);
1644
+ for (let i = 0; i < L.length; i++) {
1645
+ const l = L[i], top = l.y - 5, deep = i === L.length - 1 ? 16 : 10;
1646
+ l.hold = true;
1647
+ l.bx = x;
1648
+ ps[i].dataset.lit = "";
1649
+ await tween(140, (e) => { put({ y: top + deep * e, sx: 1 + .16 * e, sy: 1 - .2 * e }); l.d = y - top; }, easeOut);
1650
+ await tween(90, (e) => { put({ y: top + deep * (1 - e), sx: 1.16 - .16 * e, sy: .8 + .3 * e }); l.d = y - top; }, easeIn);
1651
+ l.hold = false;
1652
+ l.v = -deep * 14;
1653
+ if (i === 2) ready();
1654
+ const xa = x, ya = y;
1655
+ if (i + 1 < L.length) {
1656
+ const xb = at[i + 1] * W, yb = L[i + 1].y - 5;
1657
+ put({ f: xb > xa ? 1 : -1 });
1658
+ await tween(340, (e, t) => put({ x: xa + (xb - xa) * t, y: ya + (yb - ya) * t - 72 * t * (1 - t), sx: 1 - .06 * Math.sin(Math.PI * t), sy: 1 + .12 * Math.sin(Math.PI * t), r: (t - .5) * 20 }));
1659
+ } else {
1660
+ // the last one throws it high, out of sight, and it drops in again
1661
+ b.dataset.flap = "";
1662
+ await tween(520, (e) => put({ x: xa + 70 * e, y: ya - (ya + 60) * e, f: 1, r: -12, sx: 1, sy: 1 }), easeOut);
1663
+ delete b.dataset.flap;
1664
+ await tween(260, () => {});
1665
+ }
1666
+ }
1667
+ }
1668
+ })().catch(() => {}).finally(ready);
1669
+ box.leave = () => {
1670
+ if (off) return box.remove();
1671
+ gone = true;
1672
+ box.dataset.gone = b.dataset.flap = "";
1673
+ const xa = x, ya = y;
1674
+ tween(420, (e) => put({ x: xa + (W + 60 - xa) * e, y: ya - (ya + 80) * e, f: 1, r: -14, sx: 1, sy: 1 }), easeIn, true)
1675
+ // gone once the dialog has grown to the README, not while it does
1676
+ .then(() => tween(200, () => {}, undefined, true)).then(() => box.remove(), () => {});
1677
+ };
1678
+ return box;
1679
+ }
1680
+
1681
+ // a README read once shows at once when its page opens again, birdless,
1682
+ // and is read again quietly (the owner, 10-30). The last 20 opened only,
1683
+ // a Map in the order they were: the oldest goes first
1684
+ const pages = new Map();
1685
+ const keep = (k, p) => { pages.delete(k); pages.set(k, p); if (pages.size > 20) pages.delete(pages.keys().next().value); };
1686
+
1687
+ // the plugin's page: what it is, what npm says of it, and its README
1688
+ async function detail(l) {
1689
+ // a folder plugin: what it is comes from the folder, not from npm; a
1690
+ // git one's from the folder it was installed to
1691
+ const local = isPath(l.package) || isGit(l.package);
1692
+ const ed = el("div", "editor pm-detail");
1693
+ const hd = el("div", "ehead pm-dhead");
1694
+ hd.append(logo(l.icon || l.npm?.icon, true, kindOf(l)));
1695
+ const who = el("div", "pm-who");
1696
+ const nm = el("div", "pm-name");
1697
+ nm.append(el("b", "", l.name));
1698
+ const by = el("div", "pm-by"); // the words in a span of their own, or a flex row cuts them without the ellipsis
1699
+ by.append(el("span", "", l.community ? t("magpie community") + " · " + l.package : [l.npm?.publisher, l.package].filter(Boolean).join(" · ")));
1700
+ who.append(nm, by);
1701
+ const actBox = el("div", "pm-dact");
1702
+ // redrawn only when what it would say changes: a button made afresh
1703
+ // loses the pointer's hover
1704
+ let said = "";
1705
+ const redrawAct = () => {
1706
+ const b = actionFor(l.package, l.name, l.npm ? l : null);
1707
+ const now = b.className + b.textContent + b.disabled;
1708
+ if (now !== said) { said = now; actBox.replaceChildren(b); }
1709
+ };
1710
+ redrawAct();
1711
+ hd.append(who, el("span", "grow"), actBox);
1712
+ ed.append(hd);
1713
+ const main = el("div", "pm-dbody");
1714
+ const sum = summary(l);
1715
+ if (sum) main.append(el("p", "pm-dsum", sum));
1716
+ const facts = el("div", "pm-facts");
1717
+ const fact = (k, v) => { if (!v) return; const f = el("div", "pm-fact"); f.append(el("span", "k", k), typeof v === "string" ? el("span", "v", v) : v); facts.append(f); };
1718
+ fact(t("Version"), l.npm?.version && "v" + l.npm.version);
1719
+ fact(t("Downloads"), l.npm?.weekly ? t("{n}/week", { n: count(l.npm.weekly) }) : "");
1720
+ if (l.github) fact(t("Stars"), count(l.github.stars));
1721
+ fact(t("License"), l.npm?.license);
1722
+ const upd = el("span", "v", "…");
1723
+ fact(t("Updated"), upd);
1724
+ if (l.providers?.length) fact(t("Signs in to"), l.providers.join(t(", ")));
1725
+ main.append(facts);
1726
+ const links = el("div", "pm-links");
1727
+ const link = (label, href) => {
1728
+ if (!href) return;
1729
+ const a = el("a", "pm-link");
1730
+ a.href = href;
1731
+ a.append(el("span", "", label), glyph(OUTL, 11, 1.5));
1732
+ a.onclick = (ev) => { ev.preventDefault(); api("open", { url: href }).catch(() => {}); };
1733
+ links.append(a);
1734
+ };
1735
+ // npm is where a package's page is; a folder on this computer has none
1736
+ if (!local) link("npm", "https://www.npmjs.com/package/" + l.package);
1737
+ link(l.github ? "GitHub" : t("Source"), l.npm?.repository);
1738
+ if (l.npm?.homepage && l.npm.homepage !== l.npm.repository && !l.npm.homepage.startsWith(l.npm.repository + "#")) link(t("Homepage"), l.npm.homepage);
1739
+ main.append(links);
1740
+ if (l.github) {
1741
+ const w = el("p", "pm-note warn");
1742
+ // from npm when its author published it there from the repository
1743
+ const from = isGit(l.package)
1744
+ ? "Unofficial: {owner} tagged it {topic} on GitHub, and nobody at magpie has reviewed it. It is installed from the repository as it stands, and runs with your sign-in or your requests."
1745
+ : "Unofficial: {owner} tagged it {topic} on GitHub, and nobody at magpie has reviewed it. It is installed from npm, as its author published it from the repository, and runs with your sign-in or your requests.";
1746
+ w.append(glyph(SHIELD, 12, 1.5), el("span", "", t(from, { owner: l.github.owner, topic })));
1747
+ main.append(w);
1748
+ }
1749
+ if (l.replaces) main.append(el("p", "pm-note", t("magpie's own sign-in to this subscription is deprecated: this plugin takes its place, and accounts signed in there move to it.")));
1750
+ const readme = el("div", "pm-readme");
1751
+ const had = pages.get(l.package);
1752
+ const day = (p) => {
1753
+ const at = l.github && !entryOf(l.package) ? l.github.pushed : p.updated;
1754
+ upd.textContent = at && !at.startsWith("0001") ? new Date(at).toLocaleDateString(intlLang(), { year: "numeric", month: "short", day: "numeric" }) : "—";
1755
+ };
1756
+ if (had) day(had);
1757
+ readme.append(had ? markdown(had.readme || t("No README")) : bounceLoader());
1758
+ main.append(readme);
1759
+ ed.append(main);
1760
+ const bar = el("div", "bar");
1761
+ bar.append(el("span", "note", t("Plugins are other people's code: they sign in and make the requests, or see them as middleware.")), el("span", "grow"));
1762
+ const close = el("button", "text", t("Close"));
1763
+ close.onclick = () => { stop(); closeModal(); };
1764
+ bar.append(close);
1765
+ ed.append(bar);
1766
+ openModal(ed);
1767
+ // the button follows an install or a sign-in made while it's open
1768
+ const tick = setInterval(() => { if (!ed.isConnected) return stop(); redrawAct(); }, 600);
1769
+ function stop() { clearInterval(tick); }
1770
+ // the README takes the skeleton's place, and the dialog grows to it on
1771
+ // the spring it opened on, centred as it goes, not in one jump; the
1772
+ // bird flies off over it
1773
+ const fill = async (n) => {
1774
+ await readme.querySelector(".pm-bounce")?.ready;
1775
+ if (!ed.isConnected) return;
1776
+ const d = ed.parentElement, h = d.offsetHeight, y = d.style.top, wait = readme.querySelector(".pm-bounce");
1777
+ const m = d.parentElement, at = wait?.getBoundingClientRect(), o = m.getBoundingClientRect();
1778
+ readme.replaceChildren(n);
1779
+ if (!ed.isConnected || calm()) return;
1780
+ // it leaves from over the dialog, where it was: anywhere in it, WebKit
1781
+ // lays the fields out 8px short while it is there
1782
+ if (wait) {
1783
+ Object.assign(wait.style, { top: at.top - o.top + m.scrollTop + "px", left: at.left - o.left + m.scrollLeft + "px", width: at.width + "px" });
1784
+ m.append(wait);
1785
+ wait.leave();
1786
+ }
1787
+ holdModal();
1788
+ d.animate([{ height: h + "px", top: y, overflow: "hidden" }, { height: d.offsetHeight + "px", top: d.style.top, overflow: "hidden" }], { duration: 570, easing: SPRING });
1789
+ n.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, easing: "ease-out" });
1790
+ };
1791
+ try {
1792
+ const p = await api("plugins/page?name=" + encodeURIComponent(l.package));
1793
+ keep(l.package, p);
1794
+ day(p);
1795
+ if (!had) fill(markdown(p.readme || t("No README")));
1796
+ else if (p.readme !== had.readme) readme.replaceChildren(markdown(p.readme || t("No README")));
1797
+ } catch (e) {
1798
+ if (had) return;
1799
+ upd.textContent = "—";
1800
+ fill(el("p", "pm-note", t(l.github && isGit(l.package) && !entryOf(l.package) ? "GitHub didn't answer: {error}" : local ? "Couldn't read the folder's README: {error}" : "npm didn't answer: {error}", { error: e.message })));
1801
+ }
1802
+ }
1803
+
1804
+ // markdown: a README as text, links and code — no HTML of its own, no
1805
+ // pictures (a README's badges and screenshots come from anywhere)
1806
+ function markdown(src) {
1807
+ const root = el("div", "pm-md");
1808
+ const lines = src.replace(/\r\n?/g, "\n").replace(/<!--[\s\S]*?-->|<(script|style)\b[\s\S]*?<\/\1\s*>/gi, "")
1809
+ .replace(/\[\s*!\[[^\]]*\]\([^)]*\)\s*\]\([^)]*\)/g, "") // a badge: a picture in a link
1810
+ .split("\n");
1811
+ // a link: to the web, opened outside; to a heading of the README
1812
+ // ("#-quick-start"), there, as the reader asked
1813
+ const link = (href, text, into) => {
1814
+ const a = el("a", "", "");
1815
+ a.href = href;
1816
+ inline(text.replace(/!\[[^\]]*\]\([^)]*\)/g, "").trim() || href, a);
1817
+ a.onclick = (ev) => {
1818
+ ev.preventDefault();
1819
+ if (href[0] !== "#") return api("open", { url: href }).catch(() => {});
1820
+ const to = root.querySelector(`[data-slug="${CSS.escape(decodeURIComponent(href.slice(1)).toLowerCase())}"]`);
1821
+ if (to && window.scrollOnPurpose?.(ev)) to.scrollIntoView({ block: "start", behavior: "smooth" });
1822
+ };
1823
+ into.append(a);
1824
+ };
1825
+ const inline = (text, into) => {
1826
+ const re = /(`[^`]+`)|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*)\*|!\[[^\]]*\]\([^)]*\)|\[([^\]]+)\]\(([^)\s]+)[^)]*\)|<(https?:\/\/[^>\s]+)>|<a\s[^>]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/a\s*>|<\/?[a-zA-Z][^>]*>/g;
1827
+ let at = 0, m;
1828
+ while ((m = re.exec(text))) {
1829
+ if (m.index > at) into.append(text.slice(at, m.index));
1830
+ if (m[1]) into.append(el("code", "", m[1].slice(1, -1)));
1831
+ else if (m[2] || m[3]) { const b = el("strong"); inline(m[2] || m[3], b); into.append(b); }
1832
+ else if (m[4]) { const e = el("em"); inline(m[4], e); into.append(e); }
1833
+ else if (m[5] || m[8]) {
1834
+ const href = m[6] || m[8], text = m[5] || m[9].replace(/<[^>]*>/g, "");
1835
+ if (/^https?:\/\//.test(href) || /^#./.test(href)) link(href, text, into);
1836
+ else inline(text, into);
1837
+ } else if (m[7]) link(m[7], m[7], into);
1838
+ at = re.lastIndex;
1839
+ }
1840
+ if (at < text.length) into.append(text.slice(at));
1841
+ };
1842
+ // GitHub's anchor for a heading: lower case, its punctuation and
1843
+ // emoji gone, spaces as dashes ("💡 Philosophy" is "-philosophy")
1844
+ const slug = (s) => s.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, "").replace(/\s/g, "-");
1845
+ let i = 0, list = null, para = null;
1846
+ const flush = () => { list = null; para = null; };
1847
+ while (i < lines.length) {
1848
+ const line = lines[i];
1849
+ const fence = line.match(/^\s*(```|~~~)\s*([\w+-]*)/);
1850
+ if (fence) {
1851
+ flush();
1852
+ const code = [];
1853
+ i++;
1854
+ while (i < lines.length && !lines[i].trimStart().startsWith(fence[1])) code.push(lines[i++]);
1855
+ i++;
1856
+ const pre = el("pre", "", "");
1857
+ pre.append(el("code", "", code.join("\n")));
1858
+ root.append(pre);
1859
+ continue;
1860
+ }
1861
+ const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
1862
+ if (h) {
1863
+ flush();
1864
+ const e = el("h" + Math.min(6, h[1].length + 2));
1865
+ inline(h[2], e);
1866
+ e.dataset.slug = slug(e.textContent);
1867
+ if (e.textContent.trim()) root.append(e);
1868
+ i++;
1869
+ continue;
1870
+ }
1871
+ if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1] || "")) {
1872
+ flush();
1873
+ const cells = (s) => s.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
1874
+ const wrap = el("div", "pm-table");
1875
+ const table = el("table");
1876
+ const tr = el("tr");
1877
+ for (const c of cells(line)) { const th = el("th"); inline(c, th); tr.append(th); }
1878
+ table.append(tr);
1879
+ i += 2;
1880
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
1881
+ const r = el("tr");
1882
+ for (const c of cells(lines[i])) { const td = el("td"); inline(c, td); r.append(td); }
1883
+ table.append(r);
1884
+ i++;
1885
+ }
1886
+ wrap.append(table);
1887
+ root.append(wrap);
1888
+ continue;
1889
+ }
1890
+ const li = line.match(/^\s*([-*+]|\d+[.)])\s+(.*)$/);
1891
+ if (li) {
1892
+ para = null;
1893
+ const ordered = /\d/.test(li[1]);
1894
+ if (!list || list.tagName !== (ordered ? "OL" : "UL")) { list = el(ordered ? "ol" : "ul"); root.append(list); }
1895
+ const item = el("li");
1896
+ inline(li[2].replace(/^\[( |x)\]\s*/i, (s, x) => x.trim() ? "✓ " : "☐ "), item);
1897
+ list.append(item);
1898
+ i++;
1899
+ continue;
1900
+ }
1901
+ const q = line.match(/^\s*>\s?(.*)$/);
1902
+ if (q) {
1903
+ flush();
1904
+ const bq = el("blockquote");
1905
+ const parts = [];
1906
+ while (i < lines.length && /^\s*>/.test(lines[i])) parts.push(lines[i++].replace(/^\s*>\s?/, ""));
1907
+ inline(parts.join(" ").replace(/^\[!(\w+)\]\s*/, (s, k) => k.toUpperCase() + ": "), bq);
1908
+ root.append(bq);
1909
+ continue;
1910
+ }
1911
+ if (/^\s*([-*_])\s*\1\s*\1[\s\1]*$/.test(line)) { flush(); root.append(el("hr")); i++; continue; }
1912
+ if (!line.trim()) { flush(); i++; continue; }
1913
+ if (list && /^\s{2,}\S/.test(line)) { const last = list.lastElementChild; last.append(" "); inline(line.trim(), last); i++; continue; }
1914
+ if (!para) { para = el("p"); root.append(para); list = null; } else para.append(" ");
1915
+ inline(line.trim(), para);
1916
+ if (!para.textContent.trim() && !para.children.length) { para.remove(); para = null; }
1917
+ i++;
1918
+ }
1919
+ for (const p of root.querySelectorAll("p")) if (!p.textContent.trim()) p.remove();
1920
+ return root;
1921
+ }
1922
+
1923
+ // opened on the Plugins tab (?view=plugins): app.js showed it before
1924
+ // this file was here to load it
1925
+ if (view === "plugins") load();
1926
+ })();