@chatpanel/bridge 0.10.26 → 0.10.28

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.
package/src/server.js CHANGED
@@ -11,6 +11,12 @@
11
11
  // {type:'status'|'reasoning', text?}
12
12
  // {type:'done', text?} (text only if not streamed)
13
13
  // {type:'error', error}
14
+ // POST /v1/chat/completions, /v1/completions, /v1/responses
15
+ // → OpenAI-compatible text adapters for the local agents
16
+ // POST /v1/messages → Anthropic-compatible text adapter for the local agents
17
+ // GET /skills → skill packages on disk (name + description + files)
18
+ // GET /skills/<name> → one skill, SKILL.md body included
19
+ // GET /skills/<name>/file/<path> → one reference/template/asset from that package
14
20
  //
15
21
  // Binds to 127.0.0.1 only. A request guard (see `guard()`) enforces a loopback
16
22
  // Host (anti DNS-rebinding) and an allowlisted Origin; the command-spawning
@@ -30,17 +36,35 @@ import { pi, opencode, kiro, copilot, deepseek } from './engines/cli-agents.js';
30
36
  import { connectorsFor } from './connectors.js';
31
37
  import * as custom from './engines/custom.js';
32
38
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
39
+ import { skillIndex, listRecords, readRecord, readPackageFile, skillsHealth } from './skills.js';
33
40
  import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
34
41
  import { stripHidden } from './sanitize.js';
35
42
  import { checkForUpdate, selfUpdate } from './update.js';
36
43
  import { callLocalMcp } from './mcp-local.js';
37
44
  import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
38
45
  import { startRun, endRun, cancelRun, cancelAll, activeRuns } from './runs.js';
46
+ import {
47
+ CompatError,
48
+ anthropicError,
49
+ anthropicStream,
50
+ chatCompletionStream,
51
+ completionStream,
52
+ createAnthropicMessage,
53
+ createChatCompletion,
54
+ createCompletion,
55
+ createResponse,
56
+ openAIError,
57
+ parseAnthropicMessage,
58
+ parseChatCompletion,
59
+ parseCompletion,
60
+ parseResponse,
61
+ responseStream,
62
+ } from './api-compat.js';
39
63
 
40
64
  // Hardcoded (not read from package.json) so it survives Bun's single-file
41
65
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
42
66
  // this drifts from package.json, so the two can't silently diverge.
43
- const VERSION = '0.10.26';
67
+ const VERSION = '0.10.28';
44
68
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
45
69
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
46
70
 
@@ -163,7 +187,7 @@ function cors(req, res) {
163
187
  const allow = originAllowed(origin);
164
188
  res.setHeader('Access-Control-Allow-Origin', allow ? origin || '*' : 'null');
165
189
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
166
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-ChatPanel-Token');
190
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key, Anthropic-Version, X-ChatPanel-Token');
167
191
  res.setHeader('Vary', 'Origin');
168
192
  }
169
193
 
@@ -219,7 +243,9 @@ function ensureToken() {
219
243
  function tokenOk(req) {
220
244
  if (!AUTH_TOKEN) return false;
221
245
  const h = String(req.headers['authorization'] || '');
222
- const provided = (h.startsWith('Bearer ') ? h.slice(7) : String(req.headers['x-chatpanel-token'] || '')).trim();
246
+ const provided = (h.startsWith('Bearer ')
247
+ ? h.slice(7)
248
+ : String(req.headers['x-api-key'] || req.headers['x-chatpanel-token'] || '')).trim();
223
249
  if (!provided) return false;
224
250
  const a = Buffer.from(provided);
225
251
  const b = Buffer.from(AUTH_TOKEN);
@@ -232,6 +258,10 @@ function tokenOk(req) {
232
258
  // local coding-agent CLIs connect to them with no Origin header by design.
233
259
  const PRIVILEGED_POST = new Set([
234
260
  '/chat',
261
+ '/v1/chat/completions',
262
+ '/v1/completions',
263
+ '/v1/responses',
264
+ '/v1/messages',
235
265
  '/mcp-local',
236
266
  '/mcp-remote',
237
267
  '/fetch-title',
@@ -247,6 +277,28 @@ const PRIVILEGED_POST = new Set([
247
277
  ]);
248
278
  const PRIVILEGED_GET = new Set(['/debug']);
249
279
 
280
+ // /skills* is NOT privileged, and that is a considered position rather than a
281
+ // convenience. `privileged` adds exactly one thing over the origin allowlist: it requires
282
+ // the Origin header to be PRESENT, so a no-Origin local process cannot pose as the
283
+ // extension. Work through who that actually excludes for a read-only listing of files:
284
+ //
285
+ // • a web page — always sends Origin, and a non-allowed one is already refused above;
286
+ // • a page on http://localhost — sends Origin, which `isExtensionOrigin` already
287
+ // accepts, so the privileged check never stopped it either way;
288
+ // • a page using <img>/<script> to force a no-Origin GET — cannot read the response,
289
+ // because it is JSON with no CORS grant to that origin;
290
+ // • remote SSRF into 127.0.0.1 — arrives with a non-loopback Host and dies at
291
+ // `hostAllowed` long before this;
292
+ // • a local process — the only caller left, and it can read the very same SKILL.md
293
+ // files straight off the disk.
294
+ //
295
+ // So the requirement excluded nothing that could not already read the bytes, while
296
+ // breaking the one client that should have them: Chrome omits Origin on a simple GET from
297
+ // an extension page (the POST routes get it only because a JSON body forces a preflight).
298
+ // The origin allowlist stays and is what keeps web pages out. /debug remains privileged —
299
+ // it exposes configuration a local process cannot otherwise see.
300
+ const isPrivilegedGetPath = (p) => PRIVILEGED_GET.has(p);
301
+
250
302
  // SSRF guard for /mcp-remote lives in ./ssrf.js (assertPublicHttpUrl). Loopback
251
303
  // is allowed (the user's own localhost MCP server — the common "via bridge"
252
304
  // case; the extension can reach it directly anyway), cloud metadata is always
@@ -261,7 +313,7 @@ function guard(req, pathname) {
261
313
  if (origin && !originAllowed(origin)) return 'forbidden origin';
262
314
  const privileged =
263
315
  (req.method === 'POST' && PRIVILEGED_POST.has(pathname)) ||
264
- (req.method === 'GET' && PRIVILEGED_GET.has(pathname));
316
+ (req.method === 'GET' && isPrivilegedGetPath(pathname));
265
317
  if (privileged && !(isExtensionOrigin(origin) || tokenOk(req))) {
266
318
  return 'forbidden: this endpoint requires the ChatPanel extension or a valid bridge token';
267
319
  }
@@ -310,7 +362,65 @@ async function handleHealth(res) {
310
362
  }),
311
363
  );
312
364
  const update = await checkForUpdate(VERSION).catch(() => ({ current: VERSION, updateAvailable: false }));
313
- json(res, 200, { ok: true, version: VERSION, agents, update });
365
+ // ADDITIVE, and the client's only way to know this bridge can host skill packages —
366
+ // an older bridge simply omits it, which is what stops a newer extension assuming the
367
+ // endpoints exist. Never let a scan failure cost the caller its health check.
368
+ const skills = await skillsHealth().catch(() => null);
369
+ json(res, 200, { ok: true, version: VERSION, agents, update, ...(skills ? { skills } : {}) });
370
+ }
371
+
372
+ // --------------------------------------------------------------------------
373
+ // Skill packages (F6 S2). Read-only: the store serves what is already on disk.
374
+ // Installing FROM a hub waits for the scanner — a write endpoint that lands before
375
+ // the gate is a window in which unscanned packages can be written, and windows like
376
+ // that do not close on schedule.
377
+ //
378
+ // The three routes are the progressive-disclosure ladder, so a client pays for a
379
+ // skill's body only when it picks one, and for a reference file only when it needs it.
380
+ // --------------------------------------------------------------------------
381
+ async function handleSkillsList(res) {
382
+ const { index, problems } = await skillIndex();
383
+ json(res, 200, { ok: true, skills: listRecords(index), problems });
384
+ }
385
+
386
+ async function handleSkillRead(res, name) {
387
+ const { index } = await skillIndex();
388
+ const skill = readRecord(index, name);
389
+ if (!skill) return json(res, 404, { ok: false, error: 'unknown skill' });
390
+ json(res, 200, { ok: true, skill });
391
+ }
392
+
393
+ async function handleSkillFile(res, name, relPath) {
394
+ const { index } = await skillIndex();
395
+ const out = await readPackageFile(index, name, relPath);
396
+ // One shape for every refusal: a caller learns that it may not have the file, not
397
+ // whether the path exists, which is the difference between an error and an oracle.
398
+ if (out.error) return json(res, out.error === 'unknown skill' ? 404 : 400, { ok: false, error: out.error });
399
+ json(res, 200, { ok: true, ...out });
400
+ }
401
+
402
+ async function compatibleModels() {
403
+ const rows = await Promise.all(
404
+ Object.entries(ENGINES)
405
+ .filter(([, entry]) => !entry.hidden)
406
+ .map(async ([id, entry]) => ({
407
+ id,
408
+ object: 'model',
409
+ created: 0,
410
+ owned_by: 'chatpanel',
411
+ available: !!(await entry.engine.available().catch(() => ({ ok: false }))).ok,
412
+ })),
413
+ );
414
+ return rows.filter((row) => row.available).map(({ available, ...row }) => row);
415
+ }
416
+
417
+ async function handleCompatibleModels(res, modelId = '') {
418
+ const models = await compatibleModels();
419
+ if (modelId) {
420
+ const model = models.find((row) => row.id === modelId);
421
+ return model ? json(res, 200, model) : json(res, 404, openAIError(new CompatError(`Model "${modelId}" not found`, 404, 'model')));
422
+ }
423
+ return json(res, 200, { object: 'list', data: models });
314
424
  }
315
425
 
316
426
  // POST /update — self-update (compiled-binary installs). Swaps the binary, replies,
@@ -404,6 +514,162 @@ async function handleChat(req, res) {
404
514
  }
405
515
  }
406
516
 
517
+ async function runCompatibleAgent(config, onDelta, res) {
518
+ const target = ENGINES[config.agent];
519
+ if (!target || target.hidden) throw new CompatError(`Unknown ChatPanel agent "${config.agent}"`, 400, 'model');
520
+ const availability = await target.engine.available().catch((e) => ({ ok: false, reason: e?.message || String(e) }));
521
+ if (!availability.ok) throw new CompatError(availability.reason || `${config.agent} is unavailable`, 503, 'model');
522
+
523
+ const runId = `run_${Math.random().toString(36).slice(2, 10)}`;
524
+ const run = startRun(runId);
525
+ const onGone = () => run.cancel('disconnected');
526
+ res.on('close', onGone);
527
+ let output = '';
528
+ try {
529
+ await target.engine.chat(
530
+ { messages: config.messages, system: config.system, options: config.options, images: [] },
531
+ (event) => {
532
+ if (event?.type === 'delta' && event.text) {
533
+ output += event.text;
534
+ onDelta(event.text);
535
+ } else if (event?.type === 'done' && event.text && !output) {
536
+ output = event.text;
537
+ onDelta(event.text);
538
+ }
539
+ },
540
+ { signal: run.signal },
541
+ );
542
+ return output;
543
+ } finally {
544
+ res.off('close', onGone);
545
+ endRun(runId);
546
+ }
547
+ }
548
+
549
+ function beginSse(res) {
550
+ res.writeHead(200, {
551
+ 'Content-Type': 'text/event-stream',
552
+ 'Cache-Control': 'no-cache',
553
+ Connection: 'keep-alive',
554
+ });
555
+ }
556
+
557
+ function writeData(res, value) {
558
+ if (!res.writableEnded) res.write(`data: ${typeof value === 'string' ? value : JSON.stringify(value)}\n\n`);
559
+ }
560
+
561
+ function writeNamedEvent(res, name, value) {
562
+ if (!res.writableEnded) res.write(`event: ${name}\ndata: ${JSON.stringify(value)}\n\n`);
563
+ }
564
+
565
+ async function compatibleBody(req, res, parser, errorShape) {
566
+ try {
567
+ return parser(await readBody(req));
568
+ } catch (error) {
569
+ json(res, error?.status || 400, errorShape(error?.message?.startsWith('Unexpected') ? new CompatError(`Bad JSON: ${error.message}`) : error));
570
+ return null;
571
+ }
572
+ }
573
+
574
+ async function handleOpenAIChatCompletions(req, res) {
575
+ const config = await compatibleBody(req, res, parseChatCompletion, openAIError);
576
+ if (!config) return;
577
+ if (!config.stream) {
578
+ try {
579
+ const text = await runCompatibleAgent(config, () => {}, res);
580
+ return json(res, 200, createChatCompletion(config.requestedModel, text));
581
+ } catch (error) {
582
+ if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
583
+ return;
584
+ }
585
+ }
586
+
587
+ beginSse(res);
588
+ const stream = chatCompletionStream(config.requestedModel, (event) => writeData(res, event));
589
+ try {
590
+ await runCompatibleAgent(config, (text) => stream.delta(text), res);
591
+ stream.done();
592
+ } catch (error) {
593
+ writeData(res, openAIError(error));
594
+ }
595
+ writeData(res, '[DONE]');
596
+ res.end();
597
+ }
598
+
599
+ async function handleOpenAICompletions(req, res) {
600
+ const config = await compatibleBody(req, res, parseCompletion, openAIError);
601
+ if (!config) return;
602
+ if (!config.stream) {
603
+ try {
604
+ const text = await runCompatibleAgent(config, () => {}, res);
605
+ return json(res, 200, createCompletion(config.requestedModel, text));
606
+ } catch (error) {
607
+ if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
608
+ return;
609
+ }
610
+ }
611
+
612
+ beginSse(res);
613
+ const stream = completionStream(config.requestedModel, (event) => writeData(res, event));
614
+ try {
615
+ await runCompatibleAgent(config, (text) => stream.delta(text), res);
616
+ stream.done();
617
+ } catch (error) {
618
+ writeData(res, openAIError(error));
619
+ }
620
+ writeData(res, '[DONE]');
621
+ res.end();
622
+ }
623
+
624
+ async function handleOpenAIResponses(req, res) {
625
+ const config = await compatibleBody(req, res, parseResponse, openAIError);
626
+ if (!config) return;
627
+ if (!config.stream) {
628
+ try {
629
+ const text = await runCompatibleAgent(config, () => {}, res);
630
+ return json(res, 200, createResponse(config.requestedModel, text));
631
+ } catch (error) {
632
+ if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
633
+ return;
634
+ }
635
+ }
636
+
637
+ beginSse(res);
638
+ const stream = responseStream(config.requestedModel, (event) => writeNamedEvent(res, event.type, event));
639
+ try {
640
+ await runCompatibleAgent(config, (text) => stream.delta(text), res);
641
+ stream.done();
642
+ } catch (error) {
643
+ const event = { type: 'error', sequence_number: 0, ...openAIError(error) };
644
+ writeNamedEvent(res, 'error', event);
645
+ }
646
+ res.end();
647
+ }
648
+
649
+ async function handleAnthropicMessages(req, res) {
650
+ const config = await compatibleBody(req, res, parseAnthropicMessage, anthropicError);
651
+ if (!config) return;
652
+ if (!config.stream) {
653
+ try {
654
+ const text = await runCompatibleAgent(config, () => {}, res);
655
+ return json(res, 200, createAnthropicMessage(config.requestedModel, text));
656
+ } catch (error) {
657
+ if (!res.writableEnded) return json(res, error?.status || 500, anthropicError(error));
658
+ return;
659
+ }
660
+ }
661
+
662
+ beginSse(res);
663
+ const stream = anthropicStream(config.requestedModel, (name, event) => writeNamedEvent(res, name, event));
664
+ try {
665
+ await runCompatibleAgent(config, (text) => stream.delta(text), res);
666
+ stream.done();
667
+ } catch (error) {
668
+ writeNamedEvent(res, 'error', anthropicError(error));
669
+ }
670
+ res.end();
671
+ }
672
+
407
673
  /**
408
674
  * POST /cancel { id } — stop a run by name.
409
675
  *
@@ -772,6 +1038,21 @@ const server = createServer(async (req, res) => {
772
1038
  if (blocked) return json(res, 403, { error: blocked });
773
1039
  try {
774
1040
  if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
1041
+ if (req.method === 'GET' && url.pathname === '/skills') return handleSkillsList(res);
1042
+ if (req.method === 'GET' && url.pathname.startsWith('/skills/')) {
1043
+ const rest = url.pathname.slice('/skills/'.length);
1044
+ const cut = rest.indexOf('/file/');
1045
+ if (cut === -1) return handleSkillRead(res, decodeURIComponent(rest));
1046
+ return handleSkillFile(
1047
+ res,
1048
+ decodeURIComponent(rest.slice(0, cut)),
1049
+ decodeURIComponent(rest.slice(cut + '/file/'.length)),
1050
+ );
1051
+ }
1052
+ if (req.method === 'GET' && url.pathname === '/v1/models') return handleCompatibleModels(res);
1053
+ if (req.method === 'GET' && url.pathname.startsWith('/v1/models/')) {
1054
+ return handleCompatibleModels(res, decodeURIComponent(url.pathname.slice('/v1/models/'.length)));
1055
+ }
775
1056
  if (req.method === 'GET' && url.pathname === '/debug') {
776
1057
  // L6: by default expose only version + agent AVAILABILITY (a boolean) — enough
777
1058
  // to diagnose "is codex installed?". The full home dir, $PATH, and resolved
@@ -786,6 +1067,10 @@ const server = createServer(async (req, res) => {
786
1067
  });
787
1068
  }
788
1069
  if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
1070
+ if (req.method === 'POST' && url.pathname === '/v1/chat/completions') return handleOpenAIChatCompletions(req, res);
1071
+ if (req.method === 'POST' && url.pathname === '/v1/completions') return handleOpenAICompletions(req, res);
1072
+ if (req.method === 'POST' && url.pathname === '/v1/responses') return handleOpenAIResponses(req, res);
1073
+ if (req.method === 'POST' && url.pathname === '/v1/messages') return handleAnthropicMessages(req, res);
789
1074
  // Stable endpoint: routes to the active chat. For CLIs configured once with a
790
1075
  // fixed URL (e.g. `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`).
791
1076
  if (url.pathname === '/mcp') {
package/src/skills.js ADDED
@@ -0,0 +1,297 @@
1
+ // skills.js — the skill package store.
2
+ //
3
+ // A ChatPanel skill has been a prompt in the extension's settings, which is fine until a
4
+ // skill wants to carry the things the agentskills.io format carries: reference documents
5
+ // loaded only when needed, templates, and scripts. None of those can live in a browser
6
+ // extension — it has no filesystem, and running someone else's script inside it is on the
7
+ // Never list. They live here.
8
+ //
9
+ // So the bridge becomes a skill HOST, not a skill consumer. It scans directories, parses
10
+ // each SKILL.md into the shared record, and serves three levels on demand — the
11
+ // progressive-disclosure ladder the format is built around:
12
+ //
13
+ // list() name + description + what files exist (cheap, every turn)
14
+ // read(name) the full SKILL.md body (when it is chosen)
15
+ // readFile(name, p) one reference/asset (when it is needed)
16
+ //
17
+ // It also scans ~/.agents/skills — the cross-tool convention several agent CLIs already
18
+ // use — so a skill written in another tool appears here with no export step. That is the
19
+ // point of putting the store in the bridge rather than in one client.
20
+ //
21
+ // ── SECURITY ──────────────────────────────────────────────────────────────────────────
22
+ // This module turns HTTP request strings into filesystem reads, which makes it the most
23
+ // dangerous file in the repo. Three rules, none of them optional:
24
+ //
25
+ // 1. A REQUESTED NAME IS NEVER A PATH. `read('../../.ssh/id_rsa')` resolves the name
26
+ // against the scanned INDEX; a name that is not in the index does not exist. There
27
+ // is no code path from a URL segment to a path join.
28
+ // 2. A REQUESTED FILE PATH IS CHECKED TWICE — lexically (the shared `isSafeSkillPath`,
29
+ // which refuses traversal, absolute paths, drive letters, backslashes and control
30
+ // characters) and then again after resolution, because a SYMLINK inside a skill
31
+ // directory passes every lexical check and still points at ~/.ssh.
32
+ // 3. EVERY READ IS CAPPED. A skill directory is not necessarily authored by the person
33
+ // running it, and an unbounded read of a file someone else chose is a denial of
34
+ // service against the process the browser depends on.
35
+
36
+ import { readFile as fsReadFile, readdir, stat, realpath } from 'node:fs/promises';
37
+ import { createHash } from 'node:crypto';
38
+ import os from 'node:os';
39
+ import { join, resolve, sep } from 'node:path';
40
+ import { isSafeSkillPath, normalizeSkill, SKILL_FILE_KINDS } from './events/skill-manifest.js';
41
+
42
+ const MAX_SKILL_MD = 512 * 1024; // a procedure document, not a corpus
43
+ const MAX_ASSET = 4 * 1024 * 1024; // a reference doc or a template; images live elsewhere
44
+ const MAX_SKILLS = 500; // a scan is bounded work, not "whatever is on disk"
45
+ const MAX_DEPTH = 2; // <root>/<name>/ and <root>/<category>/<name>/
46
+
47
+ /**
48
+ * Where skills are scanned from, in precedence order — first hit wins on a name clash,
49
+ * which makes ChatPanel's own directory authoritative over a shared one.
50
+ *
51
+ * `~/.agents/skills` is the cross-tool location; it is READ, never written, because it
52
+ * belongs to whatever else the user runs.
53
+ */
54
+ export function skillRoots(env = process.env, home = os.homedir()) {
55
+ const extra = String(env.CHATPANEL_SKILL_DIRS || '')
56
+ .split(/[:;\n]/)
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ return [
60
+ { dir: join(home, '.chatpanel', 'skills'), source: 'local', writable: true },
61
+ { dir: join(home, '.agents', 'skills'), source: 'agents-dir', writable: false },
62
+ ...extra.map((dir) => ({ dir: resolve(dir), source: 'external', writable: false })),
63
+ ];
64
+ }
65
+
66
+ /**
67
+ * The YAML subset a SKILL.md frontmatter block actually uses: scalars, inline `[a, b]`
68
+ * lists, and one level of nesting. Deliberately NOT a YAML parser — a real one is a
69
+ * dependency the bridge does not take, and a full parser is a larger attack surface than
70
+ * the five fields we read. Anything it does not understand is ignored, never guessed at.
71
+ */
72
+ export function parseFrontmatter(text) {
73
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(String(text || ''));
74
+ if (!m) return { meta: {}, body: String(text || '') };
75
+ const out = {};
76
+ const stack = [{ indent: -1, obj: out }];
77
+ for (const raw of m[1].split(/\r?\n/)) {
78
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
79
+ const indent = raw.length - raw.trimStart().length;
80
+ const line = raw.trim();
81
+ const kv = /^([A-Za-z_][\w.-]*)\s*:\s*(.*)$/.exec(line);
82
+ if (!kv) continue;
83
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
84
+ const parent = stack[stack.length - 1].obj;
85
+ const [, key, rest] = kv;
86
+ if (rest === '') {
87
+ const child = {};
88
+ parent[key] = child;
89
+ stack.push({ indent, obj: child });
90
+ continue;
91
+ }
92
+ parent[key] = scalar(rest);
93
+ }
94
+ return { meta: out, body: String(text).slice(m[0].length) };
95
+ }
96
+
97
+ function scalar(v) {
98
+ const s = v.trim().replace(/\s+#.*$/, '').trim();
99
+ if (/^\[.*\]$/.test(s)) {
100
+ return s.slice(1, -1).split(',').map((x) => unquote(x.trim())).filter(Boolean);
101
+ }
102
+ if (s === 'true') return true;
103
+ if (s === 'false') return false;
104
+ return unquote(s);
105
+ }
106
+
107
+ const unquote = (s) => (/^(['"]).*\1$/.test(s) ? s.slice(1, -1) : s);
108
+
109
+ /** A directory name usable as a skill id when the frontmatter has no `name`. */
110
+ const ID = /^[a-z0-9][a-z0-9_-]*$/;
111
+
112
+ /**
113
+ * Turn a parsed SKILL.md plus its directory listing into the shared record.
114
+ *
115
+ * Local skills carry an ORIGIN, and therefore read as `community` rather than `user`.
116
+ * That is deliberate and it is the conservative answer: we cannot tell a skill the user
117
+ * wrote from one that arrived in a synced or checked-out directory, and treating the
118
+ * second as trusted is exactly the mistake. Only skills authored in ChatPanel itself are
119
+ * the user's own.
120
+ */
121
+ export function skillRecord({ meta, body, dirName, relPath, source, files, hash }) {
122
+ const name = typeof meta.name === 'string' && meta.name.trim() ? meta.name.trim() : dirName;
123
+ const id = ID.test(String(name)) ? String(name) : dirName;
124
+ const grouped = {};
125
+ for (const kind of SKILL_FILE_KINDS) {
126
+ const list = files.filter((f) => f.startsWith(`${kind}/`)).map((f) => f.slice(kind.length + 1));
127
+ if (list.length) grouped[kind] = list;
128
+ }
129
+ return normalizeSkill({
130
+ id,
131
+ name: String(name),
132
+ command: ID.test(id) ? id : '',
133
+ description: typeof meta.description === 'string' ? meta.description : '',
134
+ prompt: body.trim(),
135
+ ...(typeof meta.version === 'string' ? { version: meta.version } : {}),
136
+ ...(Array.isArray(meta.platforms) ? { platforms: meta.platforms } : {}),
137
+ ...(Object.keys(grouped).length ? { files: grouped } : {}),
138
+ origin: { source, id: relPath, hash },
139
+ });
140
+ }
141
+
142
+ /** Does this skill run on the machine we are on? */
143
+ export function platformOk(skill, platform = process.platform) {
144
+ const want = skill?.platforms;
145
+ if (!Array.isArray(want) || !want.length) return true;
146
+ const here = platform === 'darwin' ? 'macos' : platform === 'win32' ? 'windows' : 'linux';
147
+ return want.includes(here);
148
+ }
149
+
150
+ async function listDir(dir) {
151
+ try {
152
+ return await readdir(dir, { withFileTypes: true });
153
+ } catch {
154
+ return []; // a root that does not exist is not an error — most machines have neither
155
+ }
156
+ }
157
+
158
+ /** Files a package carries, relative to its directory, capped and traversal-checked. */
159
+ async function packageFiles(dir) {
160
+ const out = [];
161
+ for (const kind of SKILL_FILE_KINDS) {
162
+ for (const entry of await listDir(join(dir, kind))) {
163
+ if (!entry.isFile()) continue; // no recursion, and never a symlink or a device
164
+ const rel = `${kind}/${entry.name}`;
165
+ if (isSafeSkillPath(rel)) out.push(rel);
166
+ if (out.length >= 200) return out;
167
+ }
168
+ }
169
+ return out;
170
+ }
171
+
172
+ async function loadSkill(dir, relPath, source) {
173
+ let text;
174
+ try {
175
+ const st = await stat(join(dir, 'SKILL.md'));
176
+ if (!st.isFile() || st.size > MAX_SKILL_MD) return null;
177
+ text = await fsReadFile(join(dir, 'SKILL.md'), 'utf8');
178
+ } catch {
179
+ return null;
180
+ }
181
+ const { meta, body } = parseFrontmatter(text);
182
+ const hash = `sha256-${createHash('sha256').update(text).digest('hex').slice(0, 32)}`;
183
+ const dirName = relPath.split('/').pop();
184
+ const files = await packageFiles(dir);
185
+ const skill = skillRecord({ meta, body, dirName, relPath, source, files, hash });
186
+ return { skill, dir };
187
+ }
188
+
189
+ /**
190
+ * Scan every root. Returns an INDEX keyed by id — and that index is the only thing a
191
+ * later read may resolve a requested name against.
192
+ */
193
+ export async function scanSkills({ roots = skillRoots(), platform = process.platform } = {}) {
194
+ const index = new Map();
195
+ const problems = [];
196
+ for (const { dir: root, source } of roots) {
197
+ const walk = async (dir, rel, depth) => {
198
+ if (index.size >= MAX_SKILLS || depth > MAX_DEPTH) return;
199
+ for (const entry of await listDir(dir)) {
200
+ if (index.size >= MAX_SKILLS) return;
201
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
202
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
203
+ const child = join(dir, entry.name);
204
+ const loaded = await loadSkill(child, childRel, source).catch((e) => {
205
+ problems.push({ path: childRel, reason: String(e?.message || e) });
206
+ return null;
207
+ });
208
+ if (loaded) {
209
+ // First root wins: ChatPanel's own directory is authoritative over a shared one.
210
+ if (!index.has(loaded.skill.id) && platformOk(loaded.skill, platform)) {
211
+ index.set(loaded.skill.id, { ...loaded, root, source });
212
+ }
213
+ } else {
214
+ await walk(child, childRel, depth + 1);
215
+ }
216
+ }
217
+ };
218
+ await walk(root, '', 1);
219
+ }
220
+ return { index, problems };
221
+ }
222
+
223
+ /** Level 0 — what exists, cheaply. No bodies. */
224
+ export function listRecords(index) {
225
+ return [...index.values()].map(({ skill }) => {
226
+ const { prompt, ...rest } = skill;
227
+ return { ...rest, promptChars: (prompt || '').length };
228
+ });
229
+ }
230
+
231
+ /** Level 1 — one skill, body included. `name` is resolved against the index, never joined. */
232
+ export function readRecord(index, name) {
233
+ const hit = index.get(String(name || ''));
234
+ return hit ? hit.skill : null;
235
+ }
236
+
237
+ /**
238
+ * Level 2 — one file inside a package.
239
+ *
240
+ * The lexical check happens first and the resolved-path containment check happens after,
241
+ * because they catch different things: the first stops `../`, the second stops a symlink
242
+ * that points outside the package while looking perfectly ordinary.
243
+ */
244
+ export async function readPackageFile(index, name, relPath) {
245
+ const hit = index.get(String(name || ''));
246
+ if (!hit) return { error: 'unknown skill' };
247
+ if (!isSafeSkillPath(relPath)) return { error: 'unsafe path' };
248
+ const kind = String(relPath).split('/')[0];
249
+ if (!SKILL_FILE_KINDS.includes(kind)) return { error: 'unsafe path' };
250
+
251
+ const target = resolve(hit.dir, relPath);
252
+ let real;
253
+ try {
254
+ real = await realpath(target);
255
+ } catch {
256
+ return { error: 'not found' };
257
+ }
258
+ // A symlink passes every string check ever written. Compare what the filesystem
259
+ // actually resolved to against the package root, with a separator so `/skills-evil`
260
+ // cannot pass as a child of `/skills`.
261
+ const rootReal = await realpath(hit.dir).catch(() => hit.dir);
262
+ if (real !== rootReal && !real.startsWith(rootReal + sep)) return { error: 'outside package' };
263
+
264
+ const st = await stat(real).catch(() => null);
265
+ if (!st?.isFile()) return { error: 'not found' };
266
+ if (st.size > MAX_ASSET) return { error: 'file too large' };
267
+ return { path: relPath, text: await fsReadFile(real, 'utf8') };
268
+ }
269
+
270
+ /**
271
+ * A cached view, because /health and every turn ask "what skills exist" far more often
272
+ * than the directories change. Short TTL rather than a watcher: a watcher on directories
273
+ * that may not exist, across three roots, is more moving parts than a 5-second staleness
274
+ * window is worth.
275
+ */
276
+ const CACHE_MS = 5000;
277
+ let cached = null;
278
+
279
+ export async function skillIndex({ force = false, now = Date.now } = {}) {
280
+ const t = now();
281
+ if (!force && cached && t - cached.at < CACHE_MS) return cached.value;
282
+ const value = await scanSkills();
283
+ cached = { at: t, value };
284
+ return value;
285
+ }
286
+
287
+ export function clearSkillCache() { cached = null; }
288
+
289
+ /** The `/health` summary — counts and roots, never contents. */
290
+ export async function skillsHealth() {
291
+ const { index, problems } = await skillIndex();
292
+ return {
293
+ count: index.size,
294
+ roots: skillRoots().filter((r) => [...index.values()].some((v) => v.root === r.dir)).map((r) => r.dir),
295
+ ...(problems.length ? { problems: problems.length } : {}),
296
+ };
297
+ }