pikiloop 0.4.0

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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. package/package.json +82 -0
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Dashboard API routes for extension management — MCP servers and skills.
3
+ *
4
+ * Catalog-first design: GET /catalog returns a unified list of recommended
5
+ * registry entries merged with the user's installed servers, each tagged with
6
+ * a single `state` field. The frontend uses that state to render the right CTA
7
+ * (Install / Authorize / Enable / Disable / Remove).
8
+ *
9
+ * OAuth endpoints:
10
+ * POST /oauth/start — kicks off auth-code flow, returns auth URL + state
11
+ * GET /oauth/callback — provider redirects here; exchanges code for tokens
12
+ * POST /oauth/revoke — clears stored tokens for a server
13
+ */
14
+ import { Hono } from 'hono';
15
+ import { execFile } from 'node:child_process';
16
+ import { addGlobalMcpExtension, removeGlobalMcpExtension, updateGlobalMcpExtension, addWorkspaceMcpExtension, removeWorkspaceMcpExtension, updateWorkspaceMcpExtension, getCatalogItems, buildInstalledConfigFromRecommended, checkMcpHealth, getCachedHealth, cacheHealth, getRecommendedMcpServer, listSkills, installSkill, removeSkill, getRecommendedSkillRepos, searchSkillRepos, searchMcpServers, startAuthorization, completeAuthorization, deleteMcpToken, getMcpToken, } from '../../agent/index.js';
17
+ import { loadUserConfig, saveUserConfig } from '../../core/config/user-config.js';
18
+ import { runtime } from '../runtime.js';
19
+ import path from 'node:path';
20
+ import fs from 'node:fs';
21
+ /**
22
+ * Builtin catalog entries don't live in `extensions.mcp` — they're toggled by
23
+ * a top-level config flag. Each catalogId maps to one flag; add a branch when
24
+ * registering a new builtin.
25
+ */
26
+ function setBuiltinEnabled(catalogId, enabled) {
27
+ if (catalogId === 'pikiloop-browser') {
28
+ saveUserConfig({ ...loadUserConfig(), browserEnabled: enabled });
29
+ return true;
30
+ }
31
+ if (catalogId === 'peekaboo') {
32
+ saveUserConfig({ ...loadUserConfig(), peekabooEnabled: enabled });
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+ const app = new Hono();
38
+ function isValidWorkdir(dir) {
39
+ if (!dir || typeof dir !== 'string')
40
+ return false;
41
+ if (!path.isAbsolute(dir))
42
+ return false;
43
+ try {
44
+ return fs.statSync(dir).isDirectory();
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ function getCallbackRedirectUri(c) {
51
+ // Build from the current request so the URL matches whatever port/origin
52
+ // the dashboard is actually served on (dev, prod, custom port, etc.).
53
+ const origin = new URL(c.req.url).origin;
54
+ return `${origin}/api/extensions/mcp/oauth/callback`;
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // MCP — unified catalog
58
+ // ---------------------------------------------------------------------------
59
+ /** GET /api/extensions/mcp/catalog — Unified recommended + installed list with state. */
60
+ app.get('/api/extensions/mcp/catalog', (c) => {
61
+ const workdir = c.req.query('workdir') || runtime.getRequestWorkdir();
62
+ const scopeParam = c.req.query('scope');
63
+ const scope = scopeParam === 'global' || scopeParam === 'workspace' || scopeParam === 'both'
64
+ ? scopeParam
65
+ : undefined;
66
+ const items = getCatalogItems({ workdir, scope });
67
+ return c.json({ ok: true, items });
68
+ });
69
+ /**
70
+ * POST /api/extensions/mcp/install
71
+ * Install a recommended registry entry. Body:
72
+ * { catalogId, scope, workdir?, credentials?, enable? }
73
+ * Missing credentials for mcp-oauth servers is fine — they'll surface as
74
+ * `needs_auth` in the catalog, and the UI should call /oauth/start next.
75
+ */
76
+ app.post('/api/extensions/mcp/install', async (c) => {
77
+ try {
78
+ const body = await c.req.json();
79
+ const { catalogId, scope = 'global', workdir: reqWorkdir, credentials, enable = true, } = body;
80
+ if (!catalogId?.trim())
81
+ return c.json({ ok: false, error: 'catalogId is required' }, 400);
82
+ const rec = getRecommendedMcpServer(catalogId.trim());
83
+ if (!rec)
84
+ return c.json({ ok: false, error: `unknown catalogId: ${catalogId}` }, 404);
85
+ if (rec.isBuiltin) {
86
+ const ok = setBuiltinEnabled(rec.id, enable !== false);
87
+ return c.json({ ok, enabled: ok && enable !== false });
88
+ }
89
+ // Don't enable yet for mcp-oauth if no token exists — user still needs to authorize.
90
+ let shouldEnable = enable;
91
+ if (rec.auth.type === 'mcp-oauth' && !getMcpToken(rec.id))
92
+ shouldEnable = false;
93
+ if (rec.auth.type === 'credentials') {
94
+ for (const f of rec.auth.fields) {
95
+ if (f.required && !(credentials || {})[f.key]?.trim()) {
96
+ shouldEnable = false;
97
+ break;
98
+ }
99
+ }
100
+ }
101
+ const config = buildInstalledConfigFromRecommended(rec, { enabled: shouldEnable, credentials });
102
+ if (scope === 'workspace') {
103
+ const wd = reqWorkdir || runtime.getRequestWorkdir();
104
+ if (!isValidWorkdir(wd))
105
+ return c.json({ ok: false, error: 'valid workdir is required for workspace scope' }, 400);
106
+ addWorkspaceMcpExtension(wd, rec.id, config);
107
+ }
108
+ else {
109
+ addGlobalMcpExtension(rec.id, config);
110
+ }
111
+ return c.json({ ok: true, enabled: shouldEnable });
112
+ }
113
+ catch (e) {
114
+ return c.json({ ok: false, error: e?.message || 'internal error' }, 500);
115
+ }
116
+ });
117
+ /**
118
+ * POST /api/extensions/mcp/toggle
119
+ * Enable/disable an installed server by its installed key.
120
+ */
121
+ app.post('/api/extensions/mcp/toggle', async (c) => {
122
+ try {
123
+ const body = await c.req.json();
124
+ const { name, enabled, scope = 'global', workdir: reqWorkdir } = body;
125
+ if (!name?.trim())
126
+ return c.json({ ok: false, error: 'name is required' }, 400);
127
+ if (setBuiltinEnabled(name.trim(), !!enabled)) {
128
+ return c.json({ ok: true, updated: true });
129
+ }
130
+ const patch = { enabled: !!enabled };
131
+ let updated;
132
+ if (scope === 'workspace') {
133
+ const wd = reqWorkdir || runtime.getRequestWorkdir();
134
+ if (!isValidWorkdir(wd))
135
+ return c.json({ ok: false, error: 'valid workdir is required' }, 400);
136
+ updated = updateWorkspaceMcpExtension(wd, name.trim(), patch);
137
+ }
138
+ else {
139
+ updated = updateGlobalMcpExtension(name.trim(), patch);
140
+ }
141
+ return c.json({ ok: true, updated });
142
+ }
143
+ catch (e) {
144
+ return c.json({ ok: false, error: e?.message || 'internal error' }, 500);
145
+ }
146
+ });
147
+ /** POST /api/extensions/mcp/update — patch config fields (credentials, url, etc.). */
148
+ app.post('/api/extensions/mcp/update', async (c) => {
149
+ try {
150
+ const body = await c.req.json();
151
+ const { name, patch, scope = 'global', workdir: reqWorkdir } = body;
152
+ if (!name?.trim())
153
+ return c.json({ ok: false, error: 'name is required' }, 400);
154
+ let updated;
155
+ if (scope === 'workspace') {
156
+ const wd = reqWorkdir || runtime.getRequestWorkdir();
157
+ if (!isValidWorkdir(wd))
158
+ return c.json({ ok: false, error: 'valid workdir is required' }, 400);
159
+ updated = updateWorkspaceMcpExtension(wd, name.trim(), patch);
160
+ }
161
+ else {
162
+ updated = updateGlobalMcpExtension(name.trim(), patch);
163
+ }
164
+ return c.json({ ok: true, updated });
165
+ }
166
+ catch (e) {
167
+ return c.json({ ok: false, error: e?.message || 'internal error' }, 500);
168
+ }
169
+ });
170
+ /** POST /api/extensions/mcp/remove — uninstall. Also clears any OAuth tokens. */
171
+ app.post('/api/extensions/mcp/remove', async (c) => {
172
+ try {
173
+ const body = await c.req.json();
174
+ const { name, scope = 'global', workdir: reqWorkdir, catalogId } = body;
175
+ if (!name?.trim())
176
+ return c.json({ ok: false, error: 'name is required' }, 400);
177
+ if (setBuiltinEnabled(name.trim(), false)) {
178
+ return c.json({ ok: true, removed: true });
179
+ }
180
+ let removed;
181
+ if (scope === 'workspace') {
182
+ const wd = reqWorkdir || runtime.getRequestWorkdir();
183
+ if (!isValidWorkdir(wd))
184
+ return c.json({ ok: false, error: 'valid workdir is required' }, 400);
185
+ removed = removeWorkspaceMcpExtension(wd, name.trim());
186
+ }
187
+ else {
188
+ removed = removeGlobalMcpExtension(name.trim());
189
+ }
190
+ if (catalogId)
191
+ deleteMcpToken(catalogId);
192
+ return c.json({ ok: true, removed });
193
+ }
194
+ catch (e) {
195
+ return c.json({ ok: false, error: e?.message || 'internal error' }, 500);
196
+ }
197
+ });
198
+ /** POST /api/extensions/mcp/custom — add a user-defined server not in the registry. */
199
+ app.post('/api/extensions/mcp/custom', async (c) => {
200
+ try {
201
+ const body = await c.req.json();
202
+ const { name, config, scope = 'global', workdir: reqWorkdir } = body;
203
+ if (!name?.trim())
204
+ return c.json({ ok: false, error: 'name is required' }, 400);
205
+ if (!config)
206
+ return c.json({ ok: false, error: 'config is required' }, 400);
207
+ const clean = { ...config };
208
+ delete clean.catalogId;
209
+ if (clean.enabled === undefined)
210
+ clean.enabled = true;
211
+ if (scope === 'workspace') {
212
+ const wd = reqWorkdir || runtime.getRequestWorkdir();
213
+ if (!isValidWorkdir(wd))
214
+ return c.json({ ok: false, error: 'valid workdir is required for workspace scope' }, 400);
215
+ addWorkspaceMcpExtension(wd, name.trim(), clean);
216
+ }
217
+ else {
218
+ addGlobalMcpExtension(name.trim(), clean);
219
+ }
220
+ return c.json({ ok: true });
221
+ }
222
+ catch (e) {
223
+ return c.json({ ok: false, error: e?.message || 'internal error' }, 500);
224
+ }
225
+ });
226
+ /** POST /api/extensions/mcp/health — health check with 10-min cache per catalogId. */
227
+ app.post('/api/extensions/mcp/health', async (c) => {
228
+ try {
229
+ const body = await c.req.json();
230
+ const { id, config, noCache } = body;
231
+ if (!id?.trim())
232
+ return c.json({ ok: false, error: 'id is required' }, 400);
233
+ if (!config)
234
+ return c.json({ ok: false, error: 'config is required' }, 400);
235
+ if (!noCache) {
236
+ const cached = getCachedHealth(id, config);
237
+ if (cached)
238
+ return c.json({ ...cached, cached: true });
239
+ }
240
+ const result = await checkMcpHealth(config);
241
+ cacheHealth(id, config, result);
242
+ return c.json(result);
243
+ }
244
+ catch (e) {
245
+ return c.json({ ok: false, error: e?.message || 'internal error' }, 500);
246
+ }
247
+ });
248
+ /** GET /api/extensions/mcp/search — search community MCP servers (fallback path). */
249
+ app.get('/api/extensions/mcp/search', async (c) => {
250
+ const query = c.req.query('q') || '';
251
+ const parsed = parseInt(c.req.query('limit') || '20', 10);
252
+ const limit = Math.min(Number.isFinite(parsed) ? parsed : 20, 50);
253
+ try {
254
+ const results = await searchMcpServers(query, limit);
255
+ return c.json({ ok: true, results });
256
+ }
257
+ catch (e) {
258
+ return c.json({ ok: false, error: e?.message, results: [] });
259
+ }
260
+ });
261
+ // ---------------------------------------------------------------------------
262
+ // MCP — OAuth
263
+ // ---------------------------------------------------------------------------
264
+ /** POST /api/extensions/mcp/oauth/start — returns authUrl the client should open. */
265
+ app.post('/api/extensions/mcp/oauth/start', async (c) => {
266
+ try {
267
+ const body = await c.req.json();
268
+ const { catalogId } = body;
269
+ if (!catalogId?.trim())
270
+ return c.json({ ok: false, error: 'catalogId is required' }, 400);
271
+ const rec = getRecommendedMcpServer(catalogId.trim());
272
+ if (!rec)
273
+ return c.json({ ok: false, error: `unknown catalogId: ${catalogId}` }, 404);
274
+ if (rec.auth.type !== 'mcp-oauth') {
275
+ return c.json({ ok: false, error: 'this server does not use OAuth' }, 400);
276
+ }
277
+ if (rec.transport.type !== 'http') {
278
+ return c.json({ ok: false, error: 'OAuth is only supported for http transport' }, 400);
279
+ }
280
+ const redirectUri = getCallbackRedirectUri(c);
281
+ const { authUrl, state } = await startAuthorization({
282
+ serverId: rec.id,
283
+ auth: rec.auth,
284
+ resourceUrl: rec.transport.url,
285
+ redirectUri,
286
+ clientName: 'Pikiloop',
287
+ });
288
+ return c.json({ ok: true, authUrl, state });
289
+ }
290
+ catch (e) {
291
+ return c.json({ ok: false, error: e?.message || 'oauth start failed' }, 500);
292
+ }
293
+ });
294
+ /** GET /api/extensions/mcp/oauth/callback — browser landing page for the provider redirect. */
295
+ app.get('/api/extensions/mcp/oauth/callback', async (c) => {
296
+ const code = c.req.query('code') || '';
297
+ const state = c.req.query('state') || '';
298
+ const providerError = c.req.query('error') || '';
299
+ const providerDesc = c.req.query('error_description') || '';
300
+ const render = (opts) => c.html(`<!doctype html>
301
+ <html lang="en">
302
+ <head>
303
+ <meta charset="utf-8">
304
+ <title>${opts.ok ? 'Authorized' : 'Authorization failed'}</title>
305
+ <style>
306
+ body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f1115; color: #d4d4d8; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 24px; }
307
+ .card { max-width: 420px; padding: 28px; border: 1px solid #262a33; border-radius: 14px; background: #161922; text-align: center; }
308
+ .icon { font-size: 40px; margin-bottom: 12px; }
309
+ h1 { font-size: 17px; margin: 0 0 6px; font-weight: 600; color: #f4f4f5; }
310
+ p { font-size: 13px; line-height: 1.55; color: #a1a1aa; margin: 0; }
311
+ .close { display: inline-block; margin-top: 16px; font-size: 12px; color: #6366f1; text-decoration: none; }
312
+ </style>
313
+ </head>
314
+ <body>
315
+ <div class="card">
316
+ <div class="icon">${opts.ok ? '✅' : '⚠️'}</div>
317
+ <h1>${opts.title}</h1>
318
+ <p>${opts.detail}</p>
319
+ <a class="close" href="javascript:window.close()">Close window</a>
320
+ </div>
321
+ <script>
322
+ try {
323
+ if (window.opener) {
324
+ window.opener.postMessage({ type: 'mcp-oauth', ok: ${opts.ok}, state: ${JSON.stringify(state)} }, '*');
325
+ }
326
+ } catch (e) {}
327
+ setTimeout(function () { try { window.close(); } catch (e) {} }, 1500);
328
+ </script>
329
+ </body>
330
+ </html>`);
331
+ if (providerError) {
332
+ return render({
333
+ ok: false,
334
+ title: 'Authorization was cancelled',
335
+ detail: providerDesc || providerError,
336
+ });
337
+ }
338
+ if (!code || !state) {
339
+ return render({
340
+ ok: false,
341
+ title: 'Missing code or state',
342
+ detail: 'The provider did not return the expected parameters.',
343
+ });
344
+ }
345
+ try {
346
+ const result = await completeAuthorization({ state, code });
347
+ return render({
348
+ ok: true,
349
+ title: 'Authorized successfully',
350
+ detail: `Pikiloop can now connect to ${result.serverId}. You can close this window and return to the dashboard.`,
351
+ });
352
+ }
353
+ catch (e) {
354
+ return render({
355
+ ok: false,
356
+ title: 'Token exchange failed',
357
+ detail: e?.message || 'Unknown error',
358
+ });
359
+ }
360
+ });
361
+ /** POST /api/extensions/mcp/oauth/revoke — clear stored tokens. */
362
+ app.post('/api/extensions/mcp/oauth/revoke', async (c) => {
363
+ try {
364
+ const body = await c.req.json();
365
+ const { catalogId } = body;
366
+ if (!catalogId?.trim())
367
+ return c.json({ ok: false, error: 'catalogId is required' }, 400);
368
+ const removed = deleteMcpToken(catalogId.trim());
369
+ return c.json({ ok: true, removed });
370
+ }
371
+ catch (e) {
372
+ return c.json({ ok: false, error: e?.message || 'internal error' }, 500);
373
+ }
374
+ });
375
+ /**
376
+ * Extract the GitHub owner slug from a skill `source`. Accepts both the
377
+ * compact `owner/repo` form and full `https://github.com/owner/repo[...]`
378
+ * URLs. Returns null when the source doesn't look like a GitHub reference,
379
+ * in which case the dashboard falls back to a letter avatar.
380
+ */
381
+ function extractGithubOwner(source) {
382
+ if (!source)
383
+ return null;
384
+ const cleaned = source.trim().replace(/^https?:\/\/(www\.)?github\.com\//i, '');
385
+ const owner = cleaned.split('/')[0]?.trim();
386
+ if (!owner)
387
+ return null;
388
+ // GitHub usernames: alphanumerics + single hyphens, 1–39 chars.
389
+ return /^[a-z0-9](?:[a-z0-9-]{0,38})$/i.test(owner) ? owner : null;
390
+ }
391
+ const githubMetaCache = new Map();
392
+ const GITHUB_META_TTL_MS = 24 * 60 * 60 * 1000;
393
+ let githubMetaInflight = null;
394
+ const remoteSkillsCache = new Map();
395
+ const REMOTE_SKILLS_TTL_MS = 24 * 60 * 60 * 1000;
396
+ const remoteSkillsInflight = new Map();
397
+ /**
398
+ * Resolve a GitHub API token without making the user export anything. Order:
399
+ * 1. `GITHUB_TOKEN` env var (explicit override wins).
400
+ * 2. `gh auth token` — pikiloop recommends `gh` in its CLI catalog, so any
401
+ * user who's set up the CLI extensions has a token already. Cached for
402
+ * 10 minutes since tokens rarely rotate; on rotation the next refresh
403
+ * picks it up automatically.
404
+ *
405
+ * Returns null when neither path works (gh missing or not signed in). In that
406
+ * case GitHub Contents calls fall back to the 60 req/h unauth limit, which is
407
+ * still enough for most casual users.
408
+ */
409
+ let githubTokenCache = null;
410
+ let githubTokenInflight = null;
411
+ const GITHUB_TOKEN_TTL_MS = 10 * 60 * 1000;
412
+ async function resolveGithubToken() {
413
+ if (process.env.GITHUB_TOKEN)
414
+ return process.env.GITHUB_TOKEN;
415
+ if (githubTokenCache && Date.now() - githubTokenCache.resolvedAt < GITHUB_TOKEN_TTL_MS) {
416
+ return githubTokenCache.value;
417
+ }
418
+ // Singleflight: if a probe is already in flight, await its result instead of
419
+ // spawning N parallel `gh` subprocesses. The 14-source catalog warm-up used
420
+ // to race here and one keyring lock-contention failure would poison the
421
+ // cache for the next 10 minutes. We also only cache on success — a null
422
+ // result just falls through to the next caller's probe.
423
+ if (githubTokenInflight)
424
+ return githubTokenInflight;
425
+ githubTokenInflight = (async () => {
426
+ const value = await new Promise((resolve) => {
427
+ try {
428
+ execFile('gh', ['auth', 'token'], { timeout: 3_000 }, (err, stdout) => {
429
+ if (err) {
430
+ resolve(null);
431
+ return;
432
+ }
433
+ const out = (stdout?.toString() || '').trim();
434
+ resolve(out || null);
435
+ });
436
+ }
437
+ catch {
438
+ resolve(null);
439
+ }
440
+ });
441
+ if (value)
442
+ githubTokenCache = { value, resolvedAt: Date.now() };
443
+ return value;
444
+ })().finally(() => { githubTokenInflight = null; });
445
+ return githubTokenInflight;
446
+ }
447
+ function parseSourceToOwnerRepo(source) {
448
+ if (!source)
449
+ return null;
450
+ const cleaned = source.trim().replace(/^https?:\/\/(www\.)?github\.com\//i, '').replace(/\.git$/, '');
451
+ const [owner, repo] = cleaned.split('/');
452
+ if (!owner || !repo)
453
+ return null;
454
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,38})$/i.test(owner))
455
+ return null;
456
+ if (!/^[a-z0-9._-]+$/i.test(repo))
457
+ return null;
458
+ return { owner, repo };
459
+ }
460
+ async function fetchGithubContents(owner, repo, path) {
461
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${encodeURI(path)}`;
462
+ try {
463
+ const headers = {
464
+ 'User-Agent': 'pikiloop-dashboard',
465
+ 'Accept': 'application/vnd.github+json',
466
+ 'X-GitHub-Api-Version': '2022-11-28',
467
+ };
468
+ const token = await resolveGithubToken();
469
+ if (token)
470
+ headers['Authorization'] = `Bearer ${token}`;
471
+ const ctrl = new AbortController();
472
+ const timeout = setTimeout(() => ctrl.abort(), 8_000);
473
+ const res = await fetch(url, { headers, signal: ctrl.signal });
474
+ clearTimeout(timeout);
475
+ if (!res.ok)
476
+ return null;
477
+ const data = await res.json();
478
+ if (!Array.isArray(data))
479
+ return null;
480
+ return data;
481
+ }
482
+ catch {
483
+ return null;
484
+ }
485
+ }
486
+ /**
487
+ * Discover skills inside a remote repo. Tries the two common layouts:
488
+ * 1. `<repo>/skills/<name>/SKILL.md` (Anthropic-style)
489
+ * 2. `<repo>/<name>/SKILL.md` (flat layout)
490
+ *
491
+ * Returns a deduplicated list of subdirectory names that look like skills.
492
+ * Best-effort — a missing SKILL.md inside a subdir just gets included if the
493
+ * subdir name matches sensible conventions, since some repos lazy-load.
494
+ */
495
+ async function listRemoteSkillsFromGithub(source) {
496
+ const cached = remoteSkillsCache.get(source);
497
+ if (cached && Date.now() - cached.cachedAt < REMOTE_SKILLS_TTL_MS)
498
+ return cached.value;
499
+ const inflight = remoteSkillsInflight.get(source);
500
+ if (inflight)
501
+ return inflight;
502
+ const promise = (async () => {
503
+ const parsed = parseSourceToOwnerRepo(source);
504
+ if (!parsed)
505
+ return null;
506
+ const { owner, repo } = parsed;
507
+ // Look for skills/ subdir first; fall back to root if absent.
508
+ let listing = await fetchGithubContents(owner, repo, 'skills');
509
+ let basePath = 'skills';
510
+ if (!listing || listing.length === 0) {
511
+ listing = await fetchGithubContents(owner, repo, '');
512
+ basePath = '';
513
+ }
514
+ if (!listing)
515
+ return null;
516
+ const directories = listing.filter(e => e.type === 'dir' && !e.name.startsWith('.'));
517
+ // To keep one repo from costing 100+ subsequent fetches (verifying SKILL.md
518
+ // inside each subdir), we skip the per-skill verification and instead
519
+ // accept any directory under the resolved base as a candidate skill. This
520
+ // matches how `npx skills add` itself enumerates targets.
521
+ const skills = directories.map(d => ({
522
+ name: d.name,
523
+ path: d.path,
524
+ }));
525
+ // Some repos return >1000 entries; GitHub's contents endpoint caps there.
526
+ const partial = directories.length >= 1000;
527
+ const result = { skills, partial };
528
+ remoteSkillsCache.set(source, { value: result, cachedAt: Date.now() });
529
+ return result;
530
+ })().finally(() => remoteSkillsInflight.delete(source));
531
+ remoteSkillsInflight.set(source, promise);
532
+ return promise;
533
+ }
534
+ /** GET /api/extensions/skills/list?source=owner/repo — list a repo's available skills. */
535
+ app.get('/api/extensions/skills/list', async (c) => {
536
+ const source = c.req.query('source')?.trim();
537
+ if (!source)
538
+ return c.json({ ok: false, error: 'source is required', skills: [] }, 400);
539
+ const result = await listRemoteSkillsFromGithub(source);
540
+ if (!result) {
541
+ return c.json({
542
+ ok: false,
543
+ error: 'failed to list remote skills',
544
+ skills: [],
545
+ partial: false,
546
+ }, 502);
547
+ }
548
+ return c.json({ ok: true, skills: result.skills, partial: result.partial });
549
+ });
550
+ async function fetchOneRepoMeta(source) {
551
+ // Accept either `owner/repo` or a full GitHub URL.
552
+ const slug = source.replace(/^https?:\/\/github\.com\//, '').replace(/\.git$/, '');
553
+ if (!/^[^/]+\/[^/]+$/.test(slug))
554
+ return null;
555
+ try {
556
+ const controller = new AbortController();
557
+ const timer = setTimeout(() => controller.abort(), 5_000);
558
+ const token = await resolveGithubToken();
559
+ const res = await fetch(`https://api.github.com/repos/${slug}`, {
560
+ signal: controller.signal,
561
+ headers: {
562
+ 'Accept': 'application/vnd.github+json',
563
+ 'User-Agent': 'pikiloop-dashboard',
564
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
565
+ },
566
+ });
567
+ clearTimeout(timer);
568
+ if (!res.ok)
569
+ return null;
570
+ const data = await res.json();
571
+ if (typeof data.stargazers_count !== 'number')
572
+ return null;
573
+ return { stars: data.stargazers_count, pushedAt: data.pushed_at || '' };
574
+ }
575
+ catch {
576
+ return null;
577
+ }
578
+ }
579
+ async function ensureRepoMeta(sources) {
580
+ const now = Date.now();
581
+ const stale = sources.filter(s => {
582
+ const hit = githubMetaCache.get(s);
583
+ return !hit || now - hit.cachedAt > GITHUB_META_TTL_MS;
584
+ });
585
+ if (stale.length === 0)
586
+ return;
587
+ if (githubMetaInflight) {
588
+ await githubMetaInflight;
589
+ return;
590
+ }
591
+ githubMetaInflight = (async () => {
592
+ await Promise.all(stale.map(async (s) => {
593
+ const meta = await fetchOneRepoMeta(s);
594
+ if (meta)
595
+ githubMetaCache.set(s, { value: meta, cachedAt: now });
596
+ }));
597
+ })();
598
+ try {
599
+ await githubMetaInflight;
600
+ }
601
+ finally {
602
+ githubMetaInflight = null;
603
+ }
604
+ }
605
+ /** GET /api/extensions/skills/catalog — unified recommended + installed skills view. */
606
+ app.get('/api/extensions/skills/catalog', async (c) => {
607
+ const workdir = c.req.query('workdir') || runtime.getRequestWorkdir();
608
+ const scopeParam = c.req.query('scope');
609
+ const scope = scopeParam === 'global' || scopeParam === 'workspace' || scopeParam === 'both'
610
+ ? scopeParam
611
+ : undefined;
612
+ // Workspace view requires a workdir; global view can use the global skills dir without one.
613
+ if (scope === 'workspace' && !workdir) {
614
+ return c.json({ ok: false, error: 'workdir is required', items: [], installed: [] }, 400);
615
+ }
616
+ const installedResult = listSkills(workdir);
617
+ const installed = installedResult.skills || [];
618
+ const recommended = getRecommendedSkillRepos();
619
+ const filtered = recommended.filter(repo => {
620
+ if (!scope)
621
+ return true;
622
+ return repo.recommendedScope === scope || repo.recommendedScope === 'both';
623
+ });
624
+ // Best-effort GitHub metadata. We don't await on a cold cache here so the
625
+ // first paint isn't blocked by GitHub latency — if results are still cold,
626
+ // they'll appear on the next refresh (the dashboard already does SWR).
627
+ const sources = filtered.map(r => r.source);
628
+ const allCached = sources.every(s => githubMetaCache.has(s));
629
+ if (allCached) {
630
+ // Cheap path: nothing to fetch, just return.
631
+ }
632
+ else {
633
+ await ensureRepoMeta(sources).catch(() => { });
634
+ }
635
+ // Fire-and-forget warm-up for the remote-skills listing on any source we
636
+ // haven't seen yet. On the first ever catalog load this won't return totals,
637
+ // but the SWR refresh on the dashboard picks them up moments later.
638
+ for (const s of sources) {
639
+ if (!remoteSkillsCache.has(s) && !remoteSkillsInflight.has(s)) {
640
+ void listRemoteSkillsFromGithub(s).catch(() => { });
641
+ }
642
+ }
643
+ // Build a lookup keyed by installed-skill name (lowercased) so we can quickly
644
+ // mark a repo's remote skills as installed. Scope-filter once up front.
645
+ const scopedInstalled = scope === 'global'
646
+ ? installed.filter(s => s.scope === 'global')
647
+ : scope === 'workspace'
648
+ ? installed.filter(s => s.scope === 'project')
649
+ : installed;
650
+ const installedByName = new Map();
651
+ for (const s of scopedInstalled)
652
+ installedByName.set(s.name.toLowerCase(), s);
653
+ const items = filtered.map(repo => {
654
+ const meta = githubMetaCache.get(repo.source)?.value;
655
+ const remote = remoteSkillsCache.get(repo.source)?.value;
656
+ const owner = extractGithubOwner(repo.source);
657
+ const iconUrl = repo.iconUrl
658
+ ?? (owner ? `https://github.com/${owner}.png?size=80` : undefined);
659
+ // Prefer the remote-listing intersection over the static `repo.skills` hint
660
+ // — most catalog entries don't bother filling the hint, and the GitHub
661
+ // listing is authoritative when cached.
662
+ let installedSkillNames;
663
+ if (remote) {
664
+ installedSkillNames = remote.skills
665
+ .map(s => s.name)
666
+ .filter(name => installedByName.has(name.toLowerCase()));
667
+ }
668
+ else {
669
+ const hints = (repo.skills || []).map(s => s.toLowerCase());
670
+ installedSkillNames = scopedInstalled
671
+ .filter(s => hints.includes(s.name.toLowerCase()))
672
+ .map(s => s.name);
673
+ }
674
+ const firstMatch = installedSkillNames[0]
675
+ ? installedByName.get(installedSkillNames[0].toLowerCase())
676
+ : undefined;
677
+ return {
678
+ id: repo.id,
679
+ name: repo.name,
680
+ description: repo.description,
681
+ descriptionZh: repo.descriptionZh,
682
+ source: repo.source,
683
+ category: repo.category,
684
+ recommendedScope: repo.recommendedScope,
685
+ homepage: repo.homepage,
686
+ installed: installedSkillNames.length > 0,
687
+ scope: firstMatch?.scope,
688
+ installedNames: installedSkillNames,
689
+ stars: meta?.stars,
690
+ pushedAt: meta?.pushedAt,
691
+ iconUrl,
692
+ totalCount: remote?.skills.length,
693
+ partial: remote?.partial,
694
+ };
695
+ });
696
+ // Authority = community popularity. Sort by stars desc, with no-data entries
697
+ // sinking to the bottom so the most-loved repos surface first.
698
+ items.sort((a, b) => (b.stars ?? -1) - (a.stars ?? -1));
699
+ return c.json({ ok: true, items, installed });
700
+ });
701
+ /** POST /api/extensions/skills/install — install a skill via npx skills add. */
702
+ app.post('/api/extensions/skills/install', async (c) => {
703
+ try {
704
+ const body = await c.req.json();
705
+ const { source, global: isGlobal, skill, workdir: reqWorkdir } = body;
706
+ if (!source?.trim())
707
+ return c.json({ ok: false, error: 'source is required' }, 400);
708
+ const workdir = reqWorkdir || runtime.getRequestWorkdir();
709
+ if (!isGlobal && !isValidWorkdir(workdir)) {
710
+ return c.json({ ok: false, error: 'valid workdir is required for project-scoped install' }, 400);
711
+ }
712
+ const result = await installSkill(source.trim(), { global: isGlobal, skill, workdir });
713
+ return c.json(result);
714
+ }
715
+ catch (e) {
716
+ return c.json({ ok: false, error: e?.message || 'installation failed' }, 500);
717
+ }
718
+ });
719
+ /** POST /api/extensions/skills/remove — remove an installed skill. */
720
+ app.post('/api/extensions/skills/remove', async (c) => {
721
+ try {
722
+ const body = await c.req.json();
723
+ const { name, global: isGlobal, workdir: reqWorkdir } = body;
724
+ if (!name?.trim())
725
+ return c.json({ ok: false, error: 'name is required' }, 400);
726
+ const workdir = reqWorkdir || runtime.getRequestWorkdir();
727
+ const result = removeSkill(name.trim(), { global: isGlobal, workdir });
728
+ return c.json(result);
729
+ }
730
+ catch (e) {
731
+ return c.json({ ok: false, error: e?.message || 'removal failed' }, 500);
732
+ }
733
+ });
734
+ /** GET /api/extensions/skills/search — search community skills. */
735
+ app.get('/api/extensions/skills/search', async (c) => {
736
+ const query = c.req.query('q') || '';
737
+ try {
738
+ const results = await searchSkillRepos(query);
739
+ return c.json({ ok: true, results });
740
+ }
741
+ catch (e) {
742
+ return c.json({ ok: false, error: e?.message, results: [] });
743
+ }
744
+ });
745
+ export default app;