@polderlabs/bizar 4.4.13 → 4.5.1

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 (118) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js +2 -0
  3. package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
  4. package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
  5. package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
  6. package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
  7. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
  8. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
  9. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
  10. package/bizar-dash/dist/assets/mobile-O6ANdD4W.js.map +1 -0
  11. package/bizar-dash/dist/index.html +3 -3
  12. package/bizar-dash/dist/mobile.html +2 -2
  13. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  14. package/bizar-dash/skills/bizar/SKILL.md +116 -0
  15. package/bizar-dash/skills/chat/SKILL.md +74 -0
  16. package/bizar-dash/skills/headroom/SKILL.md +94 -0
  17. package/bizar-dash/skills/lightrag/SKILL.md +86 -0
  18. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  19. package/bizar-dash/skills/obsidian/SKILL.md +68 -0
  20. package/bizar-dash/skills/providers/SKILL.md +75 -0
  21. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  22. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  23. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  24. package/bizar-dash/skills/usage/SKILL.md +62 -0
  25. package/bizar-dash/src/server/api.mjs +18 -0
  26. package/bizar-dash/src/server/headroom.mjs +645 -0
  27. package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
  28. package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
  29. package/bizar-dash/src/server/memory-store.mjs +189 -0
  30. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  31. package/bizar-dash/src/server/minimax.mjs +196 -5
  32. package/bizar-dash/src/server/providers-store.mjs +956 -0
  33. package/bizar-dash/src/server/routes/_shared.mjs +17 -0
  34. package/bizar-dash/src/server/routes/config.mjs +52 -1
  35. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  36. package/bizar-dash/src/server/routes/headroom.mjs +126 -0
  37. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  38. package/bizar-dash/src/server/routes/memory.mjs +668 -1
  39. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  40. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  41. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  42. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  43. package/bizar-dash/src/server/routes/update.mjs +340 -0
  44. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  45. package/bizar-dash/src/server/serve-info.mjs +135 -4
  46. package/bizar-dash/src/server/server.mjs +20 -0
  47. package/bizar-dash/src/server/skills-store.mjs +152 -262
  48. package/bizar-dash/src/web/App.tsx +120 -29
  49. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  50. package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
  51. package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
  52. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  53. package/bizar-dash/src/web/components/Topbar.tsx +2 -1
  54. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  55. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  56. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  57. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  58. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  59. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  60. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  61. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  62. package/bizar-dash/src/web/lib/api.ts +43 -0
  63. package/bizar-dash/src/web/lib/types.ts +16 -0
  64. package/bizar-dash/src/web/main.tsx +2 -0
  65. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  66. package/bizar-dash/src/web/styles/chat.css +135 -1
  67. package/bizar-dash/src/web/styles/main.css +46 -0
  68. package/bizar-dash/src/web/styles/memory.css +955 -0
  69. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  70. package/bizar-dash/src/web/styles/settings.css +418 -0
  71. package/bizar-dash/src/web/styles/skills.css +302 -0
  72. package/bizar-dash/src/web/styles/tasks.css +288 -0
  73. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  74. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  75. package/bizar-dash/src/web/views/Memory.tsx +140 -0
  76. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  77. package/bizar-dash/src/web/views/Overview.tsx +3 -0
  78. package/bizar-dash/src/web/views/Settings.tsx +36 -0
  79. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  80. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  81. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
  82. package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
  83. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
  84. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
  85. package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
  86. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
  87. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
  88. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  89. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  90. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  91. package/bizar-dash/tests/headroom-install.test.mjs +173 -0
  92. package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
  93. package/bizar-dash/tests/headroom-status.test.mjs +117 -0
  94. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  95. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
  96. package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
  97. package/bizar-dash/tests/memory-tab.test.mjs +322 -0
  98. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  99. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  100. package/bizar-dash/tests/mod-upgrade.node.test.mjs +1 -1
  101. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  102. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  103. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  104. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  105. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  106. package/bizar-dash/tests/submit-feedback.test.mjs +6 -6
  107. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  108. package/bizar-dash/tests/update-check.test.mjs +127 -0
  109. package/bizar-dash/tests/update-run.test.mjs +266 -0
  110. package/cli/bin.mjs +247 -1
  111. package/cli/provision.mjs +118 -4
  112. package/config/agents/_shared/SKILLS.md +109 -0
  113. package/package.json +1 -1
  114. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  115. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  116. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  117. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  118. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -60,12 +60,27 @@ import { createConnection, isIP } from 'node:net';
60
60
  const HOME = homedir();
61
61
 
62
62
  // Mirrors plugins/bizar/src/serve-info.ts → BG_DIRS pattern.
63
- const SERVE_INFO_FILES = [
63
+ const DEFAULT_SERVE_INFO_FILES = [
64
64
  join(HOME, '.cache', 'bizar', 'serve.json'),
65
65
  join(HOME, '.config', 'opencode', 'serve.json'),
66
66
  join(HOME, '.bizar', 'serve.json'),
67
67
  ];
68
68
 
69
+ /**
70
+ * Resolve the serve.json candidate paths at call time so test
71
+ * suites can override with `BIZAR_SERVE_JSON_PATH=/tmp/...json`.
72
+ * Order: env override (if set and exists) → default search list.
73
+ *
74
+ * @returns {string[]}
75
+ */
76
+ function serveInfoFiles() {
77
+ const override = process.env.BIZAR_SERVE_JSON_PATH;
78
+ if (override && typeof override === 'string' && override.length > 0) {
79
+ return [override];
80
+ }
81
+ return DEFAULT_SERVE_INFO_FILES;
82
+ }
83
+
69
84
  /**
70
85
  * @typedef {Object} ServeInfo
71
86
  * @property {string} baseUrl e.g. "http://127.0.0.1:4097"
@@ -100,7 +115,7 @@ const SERVE_INFO_FILES = [
100
115
  * @returns {ServeInfo|null}
101
116
  */
102
117
  export function readServeInfo() {
103
- for (const file of SERVE_INFO_FILES) {
118
+ for (const file of serveInfoFiles()) {
104
119
  if (!existsSync(file)) continue;
105
120
  try {
106
121
  const raw = readFileSync(file, 'utf8');
@@ -304,7 +319,7 @@ export async function listOpencodeSessions(info, timeoutMs = 5000) {
304
319
  }
305
320
  }
306
321
 
307
- export const SERVE_INFO_FILE_PATHS = SERVE_INFO_FILES;
322
+ export const SERVE_INFO_FILE_PATHS = DEFAULT_SERVE_INFO_FILES;
308
323
 
309
324
  // ── v3.5.4 (bug: dispatch stuck) — spawn helpers ─────────────────────────
310
325
  //
@@ -623,7 +638,123 @@ export function normalizeOpencodeMessage(msg) {
623
638
  }
624
639
 
625
640
  /**
626
- * `GET /health`used by the dispatcher startup check to verify the
641
+ * `DELETE /api/session/{id}?directory=...`delete a session on the
642
+ * opencode serve child. v4.2.4 dashboard feature parity: the rail
643
+ * menu offers delete and the chat info panel can prune finished
644
+ * sessions from inside the dashboard.
645
+ *
646
+ * Idempotent: a 404 (already gone) is treated as a successful delete.
647
+ *
648
+ * @param {ServeInfo} info
649
+ * @param {string} sessionId
650
+ * @param {string} [directory]
651
+ * @param {number} [timeoutMs]
652
+ * @returns {Promise<{ok:true,status:number}|{ok:false,error:string,status?:number}>}
653
+ */
654
+ export async function deleteOpencodeSession(info, sessionId, directory, timeoutMs = DEFAULT_TIMEOUT_MS) {
655
+ if (!info) return { ok: false, error: 'serve-info not available — plugin is not running' };
656
+ if (!sessionId) return { ok: false, error: 'sessionId is required' };
657
+ const dir = directory || info.worktree || '';
658
+ const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(dir)}`;
659
+ const ac = new AbortController();
660
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
661
+ try {
662
+ const res = await fetch(url, {
663
+ method: 'DELETE',
664
+ headers: {
665
+ Authorization: buildAuthHeader(info),
666
+ Accept: 'application/json',
667
+ },
668
+ signal: ac.signal,
669
+ });
670
+ if (res.ok || res.status === 404) {
671
+ return { ok: true, status: res.status };
672
+ }
673
+ let detail = '';
674
+ try { detail = (await res.text()).slice(0, 500); } catch { /* ignore */ }
675
+ return {
676
+ ok: false,
677
+ status: res.status,
678
+ error: `DELETE /api/session/${sessionId} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`,
679
+ };
680
+ } catch (err) {
681
+ const msg = err instanceof Error ? err.message : String(err);
682
+ const isAbort = err instanceof Error && err.name === 'AbortError';
683
+ return {
684
+ ok: false,
685
+ error: isAbort ? `deleteSession timed out after ${timeoutMs}ms` : `deleteSession network error: ${msg}`,
686
+ };
687
+ } finally {
688
+ clearTimeout(timer);
689
+ }
690
+ }
691
+
692
+ /**
693
+ * `PATCH /api/session/{id}?directory=...` — update session fields
694
+ * (currently just `title`) on the opencode serve child. Used by the
695
+ * rail row-menu "Rename" affordance.
696
+ *
697
+ * @param {ServeInfo} info
698
+ * @param {string} sessionId
699
+ * @param {{ title?: string }} patch
700
+ * @param {string} [directory]
701
+ * @param {number} [timeoutMs]
702
+ * @returns {Promise<{ok:true,session:object}|{ok:false,error:string,status?:number}>}
703
+ */
704
+ export async function updateOpencodeSession(info, sessionId, patch, directory, timeoutMs = DEFAULT_TIMEOUT_MS) {
705
+ if (!info) return { ok: false, error: 'serve-info not available — plugin is not running' };
706
+ if (!sessionId) return { ok: false, error: 'sessionId is required' };
707
+ if (!patch || typeof patch !== 'object') return { ok: false, error: 'patch is required' };
708
+ const dir = directory || info.worktree || '';
709
+ const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(dir)}`;
710
+ const ac = new AbortController();
711
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
712
+ try {
713
+ const body = {};
714
+ if (typeof patch.title === 'string') {
715
+ const t = patch.title.trim();
716
+ if (!t) return { ok: false, error: 'title cannot be empty' };
717
+ if (t.length > 200) return { ok: false, error: 'title too long (> 200 chars)' };
718
+ body.title = t;
719
+ } else {
720
+ return { ok: false, error: 'no supported fields in patch (only `title` is wired)' };
721
+ }
722
+ const res = await fetch(url, {
723
+ method: 'PATCH',
724
+ headers: {
725
+ Authorization: buildAuthHeader(info),
726
+ 'Content-Type': 'application/json',
727
+ Accept: 'application/json',
728
+ },
729
+ body: JSON.stringify(body),
730
+ signal: ac.signal,
731
+ });
732
+ if (!res.ok) {
733
+ let detail = '';
734
+ try { detail = (await res.text()).slice(0, 500); } catch { /* ignore */ }
735
+ return {
736
+ ok: false,
737
+ status: res.status,
738
+ error: `PATCH /api/session/${sessionId} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`,
739
+ };
740
+ }
741
+ let session = null;
742
+ try { session = await res.json(); } catch { /* non-JSON body is fine — we still got 2xx */ }
743
+ return { ok: true, session };
744
+ } catch (err) {
745
+ const msg = err instanceof Error ? err.message : String(err);
746
+ const isAbort = err instanceof Error && err.name === 'AbortError';
747
+ return {
748
+ ok: false,
749
+ error: isAbort ? `updateSession timed out after ${timeoutMs}ms` : `updateSession network error: ${msg}`,
750
+ };
751
+ } finally {
752
+ clearTimeout(timer);
753
+ }
754
+ }
755
+
756
+ /**
757
+ * GET /health — used by the dispatcher startup check to verify the
627
758
  * serve child is actually reachable before we try to enqueue work.
628
759
  *
629
760
  * v3.11.0 — Replaced the HTTP `GET /health` probe with a TCP-connect
@@ -302,6 +302,10 @@ export async function createServer({
302
302
  }
303
303
  currentBroadcast = localBroadcast;
304
304
 
305
+ // v4 — Load BIZAR_* env vars from ~/.config/bizar/env.json into process.env.
306
+ const { loadEnvJson } = await import('./routes/env-vars.mjs');
307
+ loadEnvJson();
308
+
305
309
  const apiRouter = await createApiRouter({
306
310
  state,
307
311
  watcher,
@@ -311,6 +315,22 @@ export async function createServer({
311
315
  broadcast: localBroadcast,
312
316
  });
313
317
 
318
+ // v5.0.0 — Headroom startup hook. Runs after api.mjs is loaded so the
319
+ // headroom routes are registered. Errors are caught and logged — startup
320
+ // must not fail if Headroom has issues.
321
+ const { headroomStartupHook } = await import('./headroom.mjs');
322
+ const { readSettings } = await import('./routes/_shared.mjs');
323
+ try {
324
+ const settings = readSettings();
325
+ if (settings?.data?.headroom) {
326
+ headroomStartupHook(settings.data.headroom).catch((err) => {
327
+ console.warn('[bizar-dash] headroomStartupHook error:', err?.message || err);
328
+ });
329
+ }
330
+ } catch (err) {
331
+ console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
332
+ }
333
+
314
334
  // All /api/* routes go through apiRouter (after mod routes are checked).
315
335
  // IMPORTANT: mount v2 router FIRST so `/api/v2/*` matches before
316
336
  // apiRouter's internal 404 catch-all (api.mjs line ~109) can swallow it.
@@ -1,300 +1,190 @@
1
- /**
2
- * src/server/skills-store.mjs
3
- *
4
- * v3.1.0 Skills registry. Wraps the `skills` CLI (npm i -g skills)
5
- * and exposes a stable JSON surface for the dashboard.
6
- *
7
- * - list: skills list --json
8
- * - search: skills search <query> (or local mock)
9
- * - install: skills add <pkg>
10
- * - disable / enable: edit a small registry under
11
- * ~/.config/bizar/skills-state.json so we don't tamper with the
12
- * upstream CLI's own state.
13
- */
1
+ // src/server/skills-store.mjs
2
+ //
3
+ // v4.0.0 - Skills registry that scans SKILL.md files from all local
4
+ // sources rather than relying on the `skills` CLI npm registry.
5
+ //
6
+ // Sources (in priority order for conflicts):
7
+ // 1. ~/.opencode/skills/<name>/SKILL.md - user-overridable builtins
8
+ // 2. ~/.agents/skills/<name>/SKILL.md - user-added skills
9
+ // 3. bizar-dash/skills/<name>/SKILL.md - BizarHarness shipped
10
+ // 4. .agents/skills/<name>/SKILL.md - project-local
11
+ // 5. .opencode/skills/<name>/SKILL.md - project-local
12
+ //
13
+ // Each SKILL.md is parsed for YAML frontmatter (description:) and
14
+ // the first # H1 heading (display name fallback). No external deps.
14
15
  import {
15
16
  existsSync,
17
+ readdirSync,
16
18
  readFileSync,
17
- writeFileSync,
18
- renameSync,
19
- mkdirSync,
19
+ statSync,
20
20
  } from 'node:fs';
21
- import { dirname, join } from 'node:path';
21
+ import { join, dirname } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
- import { execFile } from 'node:child_process';
24
- import { promisify } from 'node:util';
25
23
 
26
- const execFileP = promisify(execFile);
27
24
  const HOME = homedir();
28
- const STATE_FILE = join(HOME, '.config', 'bizar', 'skills-state.json');
29
25
 
30
- // Atomic JSON write: serialize to a sibling temp file, then rename into
31
- // place. `rename` is atomic on POSIX (same filesystem), so a crash
32
- // between write and rename never leaves a half-written / corrupt file.
33
- function atomicWriteJson(filePath, data) {
34
- const tmp = `${filePath}.tmp.${process.pid}`;
35
- writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
36
- renameSync(tmp, filePath);
26
+ /** Project root is injected at construction time. */
27
+ let PROJECT_ROOT = process.cwd();
28
+
29
+ export function setProjectRoot(p) { PROJECT_ROOT = p; }
30
+
31
+ // -- Source directories --------------------------------------------------------
32
+
33
+ /** All SKILL.md scan roots in priority order (first wins for conflicts). */
34
+ function sourceRoots() {
35
+ return [
36
+ { dir: join(HOME, '.opencode', 'skills'), source: 'user' },
37
+ { dir: join(HOME, '.agents', 'skills'), source: 'user' },
38
+ { dir: join(PROJECT_ROOT, 'bizar-dash', 'skills'), source: 'shipped' },
39
+ { dir: join(PROJECT_ROOT, '.agents', 'skills'), source: 'project' },
40
+ { dir: join(PROJECT_ROOT, '.opencode', 'skills'), source: 'project' },
41
+ ];
37
42
  }
38
43
 
44
+ // -- Frontmatter parser (no external deps) ------------------------------------
45
+
39
46
  /**
40
- * Categories surfaced in the Skills view. Matched against the
41
- * `category` field returned by `skills list --json`. The CLI's actual
42
- * output is loosely categorized, so we accept any string.
47
+ * Parse YAML frontmatter from a SKILL.md string.
48
+ * Returns { description, name, ...rest } from frontmatter, plus the body.
43
49
  */
44
- export const CATEGORIES = [
45
- { id: 'all', label: 'All', icon: 'sparkles' },
46
- { id: 'languages', label: 'Programming Languages', icon: 'code' },
47
- { id: 'frameworks', label: 'Frameworks', icon: 'layers' },
48
- { id: 'tools', label: 'Tools', icon: 'wrench' },
49
- { id: 'testing', label: 'Testing', icon: 'flask' },
50
- { id: 'design', label: 'Design', icon: 'palette' },
51
- { id: 'reasoning', label: 'Reasoning', icon: 'brain' },
52
- { id: 'planning', label: 'Planning', icon: 'map' },
53
- { id: 'gitops', label: 'GitOps', icon: 'git-branch' },
54
- { id: 'docs', label: 'Docs', icon: 'book' },
55
- ];
50
+ function parseFrontmatter(raw) {
51
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
52
+ if (!fmMatch) return { description: '', name: '', body: raw };
56
53
 
57
- function loadState() {
58
- try {
59
- if (!existsSync(STATE_FILE)) return {};
60
- return JSON.parse(readFileSync(STATE_FILE, 'utf8')) || {};
61
- } catch {
62
- return {};
54
+ const fmStr = fmMatch[1];
55
+ const body = fmMatch[2];
56
+ const fm = {};
57
+
58
+ // Very simple YAML key: value parser (values only - no nested structures)
59
+ for (const line of fmStr.split('\n')) {
60
+ const colonIdx = line.indexOf(':');
61
+ if (colonIdx < 0) continue;
62
+ const key = line.slice(0, colonIdx).trim();
63
+ const val = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, '');
64
+ fm[key] = val;
63
65
  }
66
+
67
+ return {
68
+ description: fm.description || '',
69
+ name: fm.name || '',
70
+ body,
71
+ ...fm,
72
+ };
64
73
  }
65
74
 
66
- function saveState(state) {
75
+ /**
76
+ * Extract display name from body: first # Heading or ## Heading.
77
+ * Returns null if none found.
78
+ */
79
+ function extractHeading(body) {
80
+ const m = body.match(/^#{1,2}\s+(.+)$/m);
81
+ return m ? m[1].trim() : null;
82
+ }
83
+
84
+ // -- Core scanner -------------------------------------------------------------
85
+
86
+ /**
87
+ * Scan one directory for SKILL.md files.
88
+ * Returns an array of parsed skill objects.
89
+ */
90
+ function scanDir(dir, source) {
91
+ if (!existsSync(dir)) return [];
92
+ const out = [];
67
93
  try {
68
- mkdirSync(dirname(STATE_FILE), { recursive: true });
69
- atomicWriteJson(STATE_FILE, state);
94
+ const entries = readdirSync(dir, { withFileTypes: true });
95
+ for (const entry of entries) {
96
+ if (!entry.isDirectory()) continue;
97
+ const mdPath = join(dir, entry.name, 'SKILL.md');
98
+ if (!existsSync(mdPath)) continue;
99
+ let raw;
100
+ try {
101
+ raw = readFileSync(mdPath, 'utf8');
102
+ } catch {
103
+ continue;
104
+ }
105
+ const { description, name: fmName, body } = parseFrontmatter(raw);
106
+ const displayName = fmName || extractHeading(body) || entry.name;
107
+ const relPath = mdPath; // absolute path to the file
108
+
109
+ out.push({
110
+ name: displayName,
111
+ description: description.slice(0, 200),
112
+ source,
113
+ path: relPath,
114
+ body, // full body for detail view
115
+ });
116
+ }
70
117
  } catch {
71
- /* best effort */
118
+ // Directory unreadable - skip
72
119
  }
120
+ return out;
73
121
  }
74
122
 
75
- function safeExec(args, { timeoutMs = 8000 } = {}) {
76
- return execFileP('skills', args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 })
77
- .then((r) => r.stdout)
78
- .catch((err) => {
79
- // Don't throw — return empty so the UI can fall back to a mock.
80
- // eslint-disable-next-line no-console
81
- console.warn('[skills] command failed:', err.message);
82
- return '';
83
- });
123
+ /** In-memory cache with manual invalidation. */
124
+ let _cache = null;
125
+ let _cacheAge = 0;
126
+ const CACHE_TTL_MS = 30_000;
127
+
128
+ function getCache() {
129
+ if (_cache && Date.now() - _cacheAge < CACHE_TTL_MS) return _cache;
130
+ const skills = [];
131
+ for (const { dir, source } of sourceRoots()) {
132
+ skills.push(...scanDir(dir, source));
133
+ }
134
+ _cache = skills;
135
+ _cacheAge = Date.now();
136
+ return skills;
84
137
  }
85
138
 
86
- /** Local mock used as a fallback if the CLI isn't installed. */
87
- function mockCatalog() {
88
- return [
89
- {
90
- id: 'anthropics/skills',
91
- name: 'Anthropic Skills',
92
- description: 'Curated skill set from Anthropic — Claude prompt patterns, agents, evals.',
93
- category: 'reasoning',
94
- source: 'anthropics/skills',
95
- tags: ['reasoning', 'agents'],
96
- version: '1.0.0',
97
- installCmd: 'skills add anthropics/skills',
98
- },
99
- {
100
- id: 'vercel-labs/agent-skills',
101
- name: 'Vercel Agent Skills',
102
- description: 'Vercel-Labs agent skill set — React, Next.js, frontend performance.',
103
- category: 'frameworks',
104
- source: 'vercel-labs/agent-skills',
105
- tags: ['react', 'nextjs', 'frontend'],
106
- version: '1.0.0',
107
- installCmd: 'skills add vercel-labs/agent-skills',
108
- },
109
- {
110
- id: 'supabase/agent-skills',
111
- name: 'Supabase Agent Skills',
112
- description: 'Supabase agent skill set — Postgres, Auth, Edge Functions.',
113
- category: 'tools',
114
- source: 'supabase/agent-skills',
115
- tags: ['postgres', 'auth'],
116
- version: '1.0.0',
117
- installCmd: 'skills add supabase/agent-skills',
118
- },
119
- {
120
- id: 'mattpocock/skills',
121
- name: 'Matt Pocock Skills',
122
- description: 'TypeScript, TDD, testing patterns from Matt Pocock.',
123
- category: 'testing',
124
- source: 'mattpocock/skills',
125
- tags: ['typescript', 'tdd'],
126
- version: '1.0.0',
127
- installCmd: 'skills add mattpocock/skills',
128
- },
129
- {
130
- id: 'cloudflare/skills',
131
- name: 'Cloudflare Skills',
132
- description: 'Cloudflare Workers, Durable Objects, Agents SDK.',
133
- category: 'tools',
134
- source: 'cloudflare/skills',
135
- tags: ['cloudflare', 'workers'],
136
- version: '1.0.0',
137
- installCmd: 'skills add cloudflare/skills',
138
- },
139
- {
140
- id: 'anthropics/claude-code-skills',
141
- name: 'Claude Code Skills',
142
- description: 'Skills for Claude Code — prompt patterns and tool usage.',
143
- category: 'reasoning',
144
- source: 'anthropics/claude-code-skills',
145
- tags: ['claude', 'code'],
146
- version: '1.0.0',
147
- installCmd: 'skills add anthropics/claude-code-skills',
148
- },
149
- ];
139
+ export function invalidateCache() {
140
+ _cache = null;
141
+ _cacheAge = 0;
150
142
  }
151
143
 
152
- function inferCategoryFromTags(tags = []) {
153
- const lower = tags.map((t) => t.toLowerCase());
154
- if (lower.some((t) => /react|next|vue|svelte|frontend|tailwind/.test(t))) return 'frameworks';
155
- if (lower.some((t) => /tdd|test|jest|vitest|playwright|pytest/.test(t))) return 'testing';
156
- if (lower.some((t) => /postgres|auth|cloudflare|supabase|kubernetes|docker/.test(t))) return 'tools';
157
- if (lower.some((t) => /design|ui|ux|figma|tailwind|theme/.test(t))) return 'design';
158
- if (lower.some((t) => /reasoning|agent|claude|gpt|llm|chat/.test(t))) return 'reasoning';
159
- if (lower.some((t) => /plan|roadmap|architecture/.test(t))) return 'planning';
160
- if (lower.some((t) => /git|github|gh|ci|deploy/.test(t))) return 'gitops';
161
- if (lower.some((t) => /doc|readme|wiki|changelog/.test(t))) return 'docs';
162
- if (lower.some((t) => /python|js|ts|rust|go|kotlin|swift|c\+\+|ruby|java|php/.test(t))) return 'languages';
163
- return 'tools';
144
+ // -- Fuzzy search (simple includes-based, no external deps) --------------------
145
+
146
+ function fuzzyMatch(skill, q) {
147
+ const hay = `${skill.name} ${skill.description}`.toLowerCase();
148
+ const terms = q.toLowerCase().split(/\s+/);
149
+ return terms.every((t) => hay.includes(t));
164
150
  }
165
151
 
166
- export const skillsStore = {
167
- STATE_FILE,
168
- CATEGORIES,
152
+ function stripAnsi(str) {
153
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
154
+ }
155
+
156
+ // -- Public API ----------------------------------------------------------------
169
157
 
170
- /** List installed skills. */
158
+ export const skillsStore = {
159
+ /** List all skills grouped by source. */
171
160
  async list() {
172
- const state = loadState();
173
- const out = await safeExec(['list', '--json']);
174
- let items = [];
175
- if (out && out.trim().startsWith('[')) {
176
- try {
177
- items = JSON.parse(out);
178
- } catch {
179
- items = [];
180
- }
181
- } else if (out && out.trim().startsWith('{')) {
182
- // Some versions return { skills: [...] }
183
- try {
184
- const parsed = JSON.parse(out);
185
- items = parsed.skills || parsed.items || [];
186
- } catch {
187
- items = [];
188
- }
189
- }
190
- // If the CLI is not installed or returned nothing, fall back to a
191
- // minimal local mock so the UI is still useful in dev.
192
- if (items.length === 0) {
193
- items = mockCatalog().map((m) => ({ ...m, installed: true, mock: true }));
194
- }
195
- return items.map((it) => {
196
- const id = it.id || it.name || it.source || it.path || 'unknown';
197
- const name = it.name || id.split('/').pop() || id;
198
- const tags = Array.isArray(it.tags) ? it.tags : [];
199
- const category = it.category || inferCategoryFromTags(tags);
200
- const enabled = state[id]?.enabled !== false; // default enabled
201
- return {
202
- id,
203
- name,
204
- description: it.description || '',
205
- category,
206
- tags,
207
- version: it.version || '0.0.0',
208
- source: it.source || id,
209
- path: it.path || null,
210
- installed: true,
211
- enabled,
212
- mock: !!it.mock,
213
- };
214
- });
161
+ return getCache();
215
162
  },
216
163
 
217
- /** Search the registry. Falls back to a mock local index. */
164
+ /**
165
+ * Fuzzy search by name + description.
166
+ * Returns plain objects - NO ANSI codes, NO formatted terminal output.
167
+ */
218
168
  async search(query) {
219
- const trimmed = (query || '').trim();
220
- let items = [];
221
- if (trimmed) {
222
- const out = await safeExec(['search', trimmed]);
223
- if (out) {
224
- try {
225
- const parsed = JSON.parse(out);
226
- items = Array.isArray(parsed) ? parsed : parsed.results || parsed.items || [];
227
- } catch {
228
- // Fall through to text-mode parse.
229
- items = out
230
- .split(/\r?\n/)
231
- .filter((l) => l.trim())
232
- .map((l) => ({ name: l.trim(), id: l.trim(), source: l.trim() }));
233
- }
234
- }
235
- }
236
- if (items.length === 0) {
237
- // Mock fallback: filter the mock catalog by query.
238
- const q = trimmed.toLowerCase();
239
- items = mockCatalog().filter((m) =>
240
- !q ||
241
- m.name.toLowerCase().includes(q) ||
242
- m.description.toLowerCase().includes(q) ||
243
- m.tags.some((t) => t.toLowerCase().includes(q)),
244
- );
245
- if (items.length === 0 && !q) items = mockCatalog();
246
- }
247
- return items.map((it) => {
248
- const id = it.id || it.name || it.source || 'unknown';
249
- const name = it.name || id.split('/').pop() || id;
250
- const tags = Array.isArray(it.tags) ? it.tags : [];
251
- return {
252
- id,
253
- name,
254
- description: it.description || '',
255
- category: it.category || inferCategoryFromTags(tags),
256
- tags,
257
- version: it.version || '0.0.0',
258
- source: it.source || id,
259
- installCmd: it.installCmd || `skills add ${id}`,
260
- };
261
- });
262
- },
263
-
264
- /** Install a skill by name/source. */
265
- async install(name, source) {
266
- const target = source || name;
267
- try {
268
- const { stdout, stderr } = await execFileP('skills', ['add', target], {
269
- timeout: 60_000,
270
- maxBuffer: 4 * 1024 * 1024,
271
- });
272
- return {
273
- ok: true,
274
- name: target,
275
- output: [stdout, stderr].filter(Boolean).join('\n').trim(),
276
- };
277
- } catch (err) {
278
- return {
279
- ok: false,
280
- name: target,
281
- error: err instanceof Error ? err.message : String(err),
282
- };
283
- }
169
+ const q = (query || '').trim();
170
+ const all = getCache();
171
+ if (!q) return all;
172
+ return all.filter((s) => fuzzyMatch(s, q));
284
173
  },
285
174
 
286
- /** Disable a skill (kept on disk, not loaded). */
287
- disable(id) {
288
- const state = loadState();
289
- state[id] = { ...(state[id] || {}), enabled: false, disabledAt: Date.now() };
290
- saveState(state);
291
- return { ok: true, enabled: false };
175
+ /**
176
+ * Return a single skill by source + name.
177
+ */
178
+ async get(source, name) {
179
+ const all = getCache();
180
+ return all.find(
181
+ (s) => s.source === source && (s.name === name || s.path.endsWith(`/${name}/SKILL.md`)),
182
+ ) || null;
292
183
  },
293
184
 
294
- enable(id) {
295
- const state = loadState();
296
- state[id] = { ...(state[id] || {}), enabled: true, enabledAt: Date.now() };
297
- saveState(state);
298
- return { ok: true, enabled: true };
185
+ /** Force a cache refresh. */
186
+ refresh() {
187
+ invalidateCache();
188
+ return getCache();
299
189
  },
300
190
  };