@polderlabs/bizar 4.4.12 → 4.5.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 (91) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +427 -110
  27. package/bizar-dash/src/server/providers-store.mjs +966 -6
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/minimax.mjs +50 -57
  33. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  34. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  35. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  36. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  37. package/bizar-dash/src/server/routes/update.mjs +340 -0
  38. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  39. package/bizar-dash/src/server/serve-info.mjs +135 -4
  40. package/bizar-dash/src/server/server.mjs +4 -0
  41. package/bizar-dash/src/server/skills-store.mjs +152 -262
  42. package/bizar-dash/src/web/App.tsx +118 -29
  43. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  44. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  45. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  46. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  47. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  48. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  49. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  50. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  51. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  52. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  53. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  54. package/bizar-dash/src/web/lib/api.ts +43 -0
  55. package/bizar-dash/src/web/main.tsx +1 -0
  56. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  57. package/bizar-dash/src/web/styles/chat.css +135 -1
  58. package/bizar-dash/src/web/styles/main.css +46 -0
  59. package/bizar-dash/src/web/styles/minimax-usage.css +471 -0
  60. package/bizar-dash/src/web/styles/settings.css +418 -0
  61. package/bizar-dash/src/web/styles/skills.css +302 -0
  62. package/bizar-dash/src/web/styles/tasks.css +288 -0
  63. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  64. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  65. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +620 -240
  66. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  67. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  68. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  69. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  70. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  71. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  72. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  73. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  74. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  75. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  76. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  77. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  78. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  79. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  80. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  81. package/bizar-dash/tests/update-check.test.mjs +127 -0
  82. package/bizar-dash/tests/update-run.test.mjs +266 -0
  83. package/cli/bin.mjs +345 -4
  84. package/cli/provision.mjs +118 -4
  85. package/config/agents/_shared/SKILLS.md +109 -0
  86. package/package.json +1 -1
  87. package/bizar-dash/dist/assets/main-BKXEqU1w.css +0 -1
  88. package/bizar-dash/dist/assets/main-EK_fzXn_.js +0 -352
  89. package/bizar-dash/dist/assets/main-EK_fzXn_.js.map +0 -1
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  91. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * tests/skills-list.test.mjs
3
+ *
4
+ * Tests for skills-store.mjs — verifies:
5
+ * 1. All skill sources are scanned
6
+ * 2. SKILL.md frontmatter is parsed correctly
7
+ * 3. Skills have required fields
8
+ * 4. Cache invalidation works
9
+ */
10
+ import { describe, it, beforeEach, afterEach } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
15
+
16
+ const TEST_ROOT = join(tmpdir(), `bizar-skills-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+
18
+ // Dynamically import so we can set projectRoot before use
19
+ async function getStore() {
20
+ const { skillsStore, setProjectRoot, invalidateCache } = await import('../src/server/skills-store.mjs');
21
+ return { skillsStore, setProjectRoot, invalidateCache };
22
+ }
23
+
24
+ describe('skills-store', () => {
25
+ let skillsStore;
26
+ let setProjectRoot;
27
+ let invalidateCache;
28
+
29
+ // Skill directory fixtures
30
+ let FIXTURE_USER_OPENCODE;
31
+ let FIXTURE_USER_AGENTS;
32
+ let FIXTURE_SHIPPED;
33
+ let FIXTURE_PROJECT;
34
+
35
+ beforeEach(async () => {
36
+ mkdirSync(TEST_ROOT, { recursive: true });
37
+
38
+ // Helper: create skill dir + write SKILL.md in one shot
39
+ const skillFile = (base, name, content) => {
40
+ const dir = join(base, name);
41
+ mkdirSync(dir, { recursive: true });
42
+ writeFileSync(join(dir, 'SKILL.md'), content);
43
+ };
44
+
45
+ // Set up fake shipped directory
46
+ const shipped = join(TEST_ROOT, 'bizar-dash', 'skills');
47
+ mkdirSync(shipped, { recursive: true });
48
+ skillFile(shipped, 'shipped-skill', `---\nname: shipped-display\ndescription: A shipped skill description.\n---\n# Shipped Skill\nBody text.\n`);
49
+ skillFile(shipped, 'bizar', `---\nname: bizar\ndescription: Bizar main skill.\n---\n# Bizar\nAgent routing.\n`);
50
+
51
+ // Set up project-local .agents/skills
52
+ const projectAgents = join(TEST_ROOT, '.agents', 'skills');
53
+ mkdirSync(projectAgents, { recursive: true });
54
+ skillFile(projectAgents, 'project-skill', `---\nname: Project Skill\ndescription: A project-local skill.\n---\n# Project Skill\n`);
55
+ skillFile(projectAgents, 'chat', `---\nname: chat\ndescription: Chat integration.\n---\n# Chat\n`);
56
+
57
+ // Set up project-local .opencode/skills
58
+ const projectOpencode = join(TEST_ROOT, '.opencode', 'skills');
59
+ mkdirSync(projectOpencode, { recursive: true });
60
+ skillFile(projectOpencode, 'usage', `---\nname: usage\ndescription: Usage monitoring.\n---\n# Usage\n`);
61
+
62
+ // Set up shipped path (relative to project root)
63
+ FIXTURE_SHIPPED = shipped;
64
+
65
+ const mod = await getStore();
66
+ skillsStore = mod.skillsStore;
67
+ setProjectRoot = mod.setProjectRoot;
68
+ invalidateCache = mod.invalidateCache;
69
+
70
+ setProjectRoot(TEST_ROOT);
71
+ invalidateCache();
72
+ });
73
+
74
+ afterEach(() => {
75
+ try { rmSync(TEST_ROOT, { recursive: true, force: true }); } catch { /* ignore */ }
76
+ });
77
+
78
+ // ── list() ─────────────────────────────────────────────────────────────────
79
+
80
+ describe('list()', () => {
81
+ it('returns all skills as a flat array', async () => {
82
+ const all = await skillsStore.list();
83
+ assert.ok(Array.isArray(all));
84
+ assert.ok(all.length > 0);
85
+ });
86
+
87
+ it('includes shipped skills from bizar-dash/skills/', async () => {
88
+ const all = await skillsStore.list();
89
+ const shipped = all.filter((s) => s.source === 'shipped');
90
+ assert.ok(shipped.length > 0, 'should have at least one shipped skill');
91
+ const bizar = shipped.find((s) => s.name === 'bizar');
92
+ assert.ok(bizar, 'bizar shipped skill should be present');
93
+ });
94
+
95
+ it('includes project-local skills from .agents/skills/', async () => {
96
+ const all = await skillsStore.list();
97
+ const project = all.filter((s) => s.source === 'project');
98
+ assert.ok(project.length > 0, 'should have project skills');
99
+ });
100
+
101
+ it('every skill has name, description, source, path', async () => {
102
+ const all = await skillsStore.list();
103
+ for (const s of all) {
104
+ assert.ok(typeof s.name === 'string' && s.name.length > 0, `${JSON.stringify(s)} needs a name`);
105
+ assert.ok(typeof s.description === 'string', `${s.name} needs a description string`);
106
+ assert.ok(['shipped', 'user', 'project'].includes(s.source), `${s.name} needs a valid source`);
107
+ assert.ok(typeof s.path === 'string' && s.path.endsWith('SKILL.md'), `${s.name} needs a path ending in SKILL.md`);
108
+ }
109
+ });
110
+
111
+ it('description is truncated to 200 chars', async () => {
112
+ const long = 'A'.repeat(400);
113
+ mkdirSync(join(FIXTURE_SHIPPED, 'long-desc'), { recursive: true });
114
+ writeFileSync(join(FIXTURE_SHIPPED, 'long-desc', 'SKILL.md'), `---\nname: long-desc\ndescription: ${long}\n---\n# Long\n`);
115
+ invalidateCache();
116
+ const all = await skillsStore.list();
117
+ const longSkill = all.find((s) => s.name === 'long-desc');
118
+ assert.ok(longSkill.description.length <= 200, 'description should be truncated to 200 chars');
119
+ });
120
+
121
+ it('display name falls back to H1 heading when frontmatter name is missing', async () => {
122
+ mkdirSync(join(FIXTURE_SHIPPED, 'heading-only'), { recursive: true });
123
+ writeFileSync(join(FIXTURE_SHIPPED, 'heading-only', 'SKILL.md'), `---\ndescription: Has heading but no name field.\n---\n# My Heading Skill\nBody.\n`);
124
+ invalidateCache();
125
+ const all = await skillsStore.list();
126
+ const skill = all.find((s) => s.name === 'My Heading Skill');
127
+ assert.ok(skill, 'should fall back to H1 heading as name');
128
+ });
129
+
130
+ it('display name falls back to directory name when both frontmatter name and H1 are absent', async () => {
131
+ mkdirSync(join(FIXTURE_SHIPPED, 'directory-named-skill'), { recursive: true });
132
+ writeFileSync(join(FIXTURE_SHIPPED, 'directory-named-skill', 'SKILL.md'), `---\ndescription: No name anywhere.\n---\nPlain body without heading.\n`);
133
+ invalidateCache();
134
+ const all = await skillsStore.list();
135
+ const skill = all.find((s) => s.path.endsWith('directory-named-skill/SKILL.md'));
136
+ assert.ok(skill, 'should fall back to directory name');
137
+ });
138
+
139
+ it('returns empty array when no skill directories exist', async () => {
140
+ const empty = join(tmpdir(), `bizar-empty-${Date.now()}`);
141
+ mkdirSync(empty, { recursive: true });
142
+ setProjectRoot(empty);
143
+ invalidateCache();
144
+ const all = await skillsStore.list();
145
+ assert.ok(Array.isArray(all));
146
+ });
147
+ });
148
+
149
+ // ── search() ────────────────────────────────────────────────────────────────
150
+
151
+ describe('search()', () => {
152
+ it('returns all skills when query is empty', async () => {
153
+ const all = await skillsStore.list();
154
+ const results = await skillsStore.search('');
155
+ assert.strictEqual(results.length, all.length);
156
+ });
157
+
158
+ it('filters by name or description substring', async () => {
159
+ const results = await skillsStore.search('bizar');
160
+ assert.ok(results.length > 0, 'should find at least one skill matching bizar');
161
+ // Search matches name OR description, so at least one result should contain 'bizar'
162
+ assert.ok(
163
+ results.some((s) => `${s.name} ${s.description}`.toLowerCase().includes('bizar')),
164
+ 'results should contain bizar in name or description',
165
+ );
166
+ });
167
+
168
+ it('filters by description substring', async () => {
169
+ const results = await skillsStore.search('monitoring');
170
+ assert.ok(results.length > 0, 'should find skill with monitoring in description');
171
+ });
172
+
173
+ it('returns plain strings — no ANSI escape codes in name or description', async () => {
174
+ const results = await skillsStore.search('bizar');
175
+ for (const s of results) {
176
+ assert.ok(!s.name.includes('\x1b'), `name "${s.name}" should not contain ANSI escape codes`);
177
+ assert.ok(!s.description.includes('\x1b'), `description should not contain ANSI codes`);
178
+ }
179
+ });
180
+
181
+ it('matches multi-term queries (AND semantics)', async () => {
182
+ const results = await skillsStore.search('bizar skill');
183
+ for (const s of results) {
184
+ const hay = `${s.name} ${s.description}`.toLowerCase();
185
+ assert.ok(hay.includes('bizar') && hay.includes('skill'), `${s.name} should match both terms`);
186
+ }
187
+ });
188
+
189
+ it('is case-insensitive', async () => {
190
+ const r1 = await skillsStore.search('BIZAR');
191
+ const r2 = await skillsStore.search('bizar');
192
+ const r3 = await skillsStore.search('BiZaR');
193
+ assert.strictEqual(r1.length, r2.length);
194
+ assert.strictEqual(r2.length, r3.length);
195
+ });
196
+ });
197
+
198
+ // ── refresh() ─────────────────────────────────────────────────────────────
199
+
200
+ describe('refresh()', () => {
201
+ it('invalidates cache and rescans', async () => {
202
+ const first = await skillsStore.list();
203
+ assert.ok(first.length > 0);
204
+
205
+ // Add a new skill
206
+ mkdirSync(join(FIXTURE_SHIPPED, 'brand-new'), { recursive: true });
207
+ writeFileSync(join(FIXTURE_SHIPPED, 'brand-new', 'SKILL.md'), `---\nname: Brand New\ndescription: A freshly added skill.\n---\n# Brand New\n`);
208
+ invalidateCache();
209
+
210
+ const after = await skillsStore.list();
211
+ assert.ok(after.length > first.length, 'newly added skill should appear after refresh');
212
+ const found = after.find((s) => s.name === 'Brand New');
213
+ assert.ok(found, 'Brand New skill should be in list');
214
+ });
215
+ });
216
+
217
+ // ── get() ─────────────────────────────────────────────────────────────────
218
+
219
+ describe('get()', () => {
220
+ it('returns a single skill by source and name', async () => {
221
+ const skill = await skillsStore.get('shipped', 'bizar');
222
+ assert.ok(skill, 'should find the bizar skill');
223
+ assert.strictEqual(skill.name, 'bizar');
224
+ assert.strictEqual(skill.source, 'shipped');
225
+ });
226
+
227
+ it('returns null for unknown skill', async () => {
228
+ const skill = await skillsStore.get('shipped', 'nonexistent-skill-xyz');
229
+ assert.strictEqual(skill, null);
230
+ });
231
+ });
232
+ });
@@ -0,0 +1,222 @@
1
+ /**
2
+ * tests/skills-search.test.mjs
3
+ *
4
+ * Tests for skills search — verifies:
5
+ * 1. Fuzzy search returns correct matches
6
+ * 2. NO ASCII garbage / ANSI codes in search results
7
+ * 3. Multi-term queries work correctly
8
+ * 4. Empty query returns all skills
9
+ */
10
+ import { describe, it, beforeEach, afterEach } from 'node:test';
11
+ import assert from 'node:assert';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
15
+
16
+ const TEST_ROOT = join(tmpdir(), `bizar-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
17
+
18
+ async function getStore() {
19
+ const { skillsStore, setProjectRoot, invalidateCache } = await import('../src/server/skills-store.mjs');
20
+ return { skillsStore, setProjectRoot, invalidateCache };
21
+ }
22
+
23
+ describe('skills search', () => {
24
+ let skillsStore;
25
+ let setProjectRoot;
26
+ let invalidateCache;
27
+
28
+ beforeEach(async () => {
29
+ mkdirSync(TEST_ROOT, { recursive: true });
30
+
31
+ const shipped = join(TEST_ROOT, 'bizar-dash', 'skills');
32
+ mkdirSync(shipped, { recursive: true });
33
+
34
+ // Create test skills with realistic names and descriptions
35
+ const fixtures = [
36
+ {
37
+ dir: 'bizar',
38
+ name: 'bizar',
39
+ description: 'Norse-pantheon multi-agent system. Covers Odin routing, agent tiers, cost-aware dispatch.',
40
+ },
41
+ {
42
+ dir: 'agent-baseline',
43
+ name: 'agent-baseline',
44
+ description: 'Always-on rules for every Bizar agent. Covers Semble, Skills CLI, loop guard, parallel execution.',
45
+ },
46
+ {
47
+ dir: 'self-improvement',
48
+ name: 'self-improvement',
49
+ description: 'Self-improvement protocol. Records lessons to .bizar/AGENTS_SELF_IMPROVEMENT.md.',
50
+ },
51
+ {
52
+ dir: 'obsidian',
53
+ name: 'obsidian',
54
+ description: 'Bizar Memory Service using Obsidian-compatible Markdown. Git sync and LightRAG indexing.',
55
+ },
56
+ {
57
+ dir: 'minimax',
58
+ name: 'minimax',
59
+ description: 'MiniMax integration. Covers multi-key rotation, usage tracking, rate limits.',
60
+ },
61
+ {
62
+ dir: 'providers',
63
+ name: 'providers',
64
+ description: 'Provider subsystem. Backup keys, auto-add wizard, provider catalog management.',
65
+ },
66
+ {
67
+ dir: 'chat',
68
+ name: 'chat',
69
+ description: 'Chat and opencode session integration. Real-time SSE streaming, message history.',
70
+ },
71
+ {
72
+ dir: 'usage',
73
+ name: 'usage',
74
+ description: 'Token usage monitoring and cost estimation. MiniMax usage dashboard.',
75
+ },
76
+ {
77
+ dir: 'skills-cli',
78
+ name: 'skills-cli',
79
+ description: 'Skills CLI reference. Discover and install skills from skills.sh ecosystem.',
80
+ },
81
+ {
82
+ dir: 'lightrag',
83
+ name: 'lightrag',
84
+ description: 'LightRAG full-text search integration. Indexing, querying, memory service integration.',
85
+ },
86
+ ];
87
+
88
+ for (const f of fixtures) {
89
+ mkdirSync(join(shipped, f.dir), { recursive: true });
90
+ writeFileSync(
91
+ join(shipped, f.dir, 'SKILL.md'),
92
+ `---\nname: ${f.name}\ndescription: ${f.description}\n---\n# ${f.name}\nBody content.\n`,
93
+ );
94
+ }
95
+
96
+ const mod = await getStore();
97
+ skillsStore = mod.skillsStore;
98
+ setProjectRoot = mod.setProjectRoot;
99
+ invalidateCache = mod.invalidateCache;
100
+
101
+ setProjectRoot(TEST_ROOT);
102
+ invalidateCache();
103
+ });
104
+
105
+ afterEach(() => {
106
+ try { rmSync(TEST_ROOT, { recursive: true, force: true }); } catch { /* ignore */ }
107
+ });
108
+
109
+ // ── ANSI / ASCII garbage check ──────────────────────────────────────────────
110
+
111
+ function assertNoAnsi(label, value) {
112
+ const ansiPattern = /\x1b\[[0-9;]*[a-zA-Z]/;
113
+ assert.ok(
114
+ !ansiPattern.test(value),
115
+ `${label} should not contain ANSI escape codes: ${JSON.stringify(value.slice(0, 80))}`,
116
+ );
117
+ }
118
+
119
+ it('search results contain no ANSI escape codes', async () => {
120
+ const queries = ['', 'bizar', 'skill', 'memory', 'agent', 'provider'];
121
+ for (const q of queries) {
122
+ const results = await skillsStore.search(q);
123
+ for (const s of results) {
124
+ assertNoAnsi(`name (query="${q}")`, s.name);
125
+ assertNoAnsi(`description (query="${q}")`, s.description);
126
+ assertNoAnsi(`path (query="${q}")`, s.path);
127
+ assertNoAnsi(`source (query="${q}")`, s.source);
128
+ }
129
+ }
130
+ });
131
+
132
+ it('list results contain no ANSI escape codes', async () => {
133
+ const all = await skillsStore.list();
134
+ for (const s of all) {
135
+ assertNoAnsi('name', s.name);
136
+ assertNoAnsi('description', s.description);
137
+ assertNoAnsi('path', s.path);
138
+ assertNoAnsi('source', s.source);
139
+ }
140
+ });
141
+
142
+ it('get() result contains no ANSI escape codes', async () => {
143
+ const skill = await skillsStore.get('shipped', 'bizar');
144
+ if (skill) {
145
+ assertNoAnsi('name', skill.name);
146
+ assertNoAnsi('description', skill.description);
147
+ }
148
+ });
149
+
150
+ // ── Search accuracy ─────────────────────────────────────────────────────────
151
+
152
+ it('finds skill by name (case-insensitive)', async () => {
153
+ const results = await skillsStore.search('BIZAR');
154
+ assert.ok(results.length > 0);
155
+ assert.ok(results.some((s) => s.name.toLowerCase() === 'bizar'));
156
+ });
157
+
158
+ it('finds skill by partial name', async () => {
159
+ const results = await skillsStore.search('self-improv');
160
+ assert.ok(results.length > 0);
161
+ assert.ok(results.some((s) => s.name === 'self-improvement'));
162
+ });
163
+
164
+ it('finds skill by description keyword', async () => {
165
+ const results = await skillsStore.search('multi-key rotation');
166
+ assert.ok(results.length > 0);
167
+ assert.ok(results.some((s) => s.name === 'minimax'));
168
+ });
169
+
170
+ it('finds skill by description keyword — lightrag', async () => {
171
+ const results = await skillsStore.search('full-text search');
172
+ assert.ok(results.length > 0);
173
+ assert.ok(results.some((s) => s.name === 'lightrag'));
174
+ });
175
+
176
+ it('returns empty array for non-matching query', async () => {
177
+ const results = await skillsStore.search('zzznonexistent999xyz');
178
+ assert.ok(Array.isArray(results));
179
+ assert.strictEqual(results.length, 0);
180
+ });
181
+
182
+ it('multi-term query: both terms must match', async () => {
183
+ const results = await skillsStore.search('obsidian git');
184
+ for (const s of results) {
185
+ const hay = `${s.name} ${s.description}`.toLowerCase();
186
+ assert.ok(
187
+ hay.includes('obsidian') && hay.includes('git'),
188
+ `${s.name} should match both "obsidian" and "git"`,
189
+ );
190
+ }
191
+ });
192
+
193
+ it('whitespace-only query returns all', async () => {
194
+ const all = await skillsStore.list();
195
+ const results = await skillsStore.search(' ');
196
+ assert.strictEqual(results.length, all.length);
197
+ });
198
+
199
+ // ── Result shape ────────────────────────────────────────────────────────────
200
+
201
+ it('search result has required fields: name, description, source, path', async () => {
202
+ const results = await skillsStore.search('bizar');
203
+ assert.ok(results.length > 0);
204
+ for (const s of results) {
205
+ assert.ok(typeof s.name === 'string' && s.name.length > 0, 'name required');
206
+ assert.ok(typeof s.description === 'string', 'description required');
207
+ assert.ok(['shipped', 'user', 'project'].includes(s.source), 'valid source required');
208
+ assert.ok(typeof s.path === 'string' && s.path.endsWith('SKILL.md'), 'path required');
209
+ }
210
+ });
211
+
212
+ it('empty query returns all skills with all fields', async () => {
213
+ const results = await skillsStore.search('');
214
+ assert.ok(results.length > 0);
215
+ for (const s of results) {
216
+ assert.ok(typeof s.name === 'string');
217
+ assert.ok(typeof s.description === 'string');
218
+ assert.ok(typeof s.source === 'string');
219
+ assert.ok(typeof s.path === 'string');
220
+ }
221
+ });
222
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * tasks-create.test.mjs — tests for POST /api/tasks (no agent selection).
3
+ *
4
+ * Run with: node --test bizar-dash/tests/tasks-create.test.mjs
5
+ *
6
+ * Strategy: build a minimal Express app with just the tasks router
7
+ * (mounted on /api). Sandbox HOME so the tasks-store writes to a
8
+ * tmpdir instead of ~/.config/bizar. Pass an explicit projectId in
9
+ * every request body so we don't depend on the active project.
10
+ */
11
+ import { describe, it, before, after, beforeEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import express from 'express';
14
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
15
+ import { join, resolve } from 'node:path';
16
+ import { tmpdir } from 'node:os';
17
+
18
+ const REPO = resolve(import.meta.dirname, '..', '..');
19
+
20
+ let SANDBOX_HOME;
21
+ let ORIGINAL_HOME;
22
+ let app;
23
+ let broadcastEvents;
24
+ let tasksFilePath;
25
+
26
+ before(async () => {
27
+ SANDBOX_HOME = mkdtempSync(join(tmpdir(), `bizar-tasks-test-${Date.now()}-`));
28
+ ORIGINAL_HOME = process.env.HOME;
29
+ process.env.HOME = SANDBOX_HOME;
30
+ // tasksStore writes to ~/.config/opencode/projects/<id>/tasks.json
31
+ // (projects-store.mjs:PROJECTS_DIR).
32
+ tasksFilePath = join(SANDBOX_HOME, '.config', 'opencode', 'projects', 'test-proj', 'tasks.json');
33
+ });
34
+
35
+ after(() => {
36
+ process.env.HOME = ORIGINAL_HOME;
37
+ try { rmSync(SANDBOX_HOME, { recursive: true, force: true }); } catch { /* ignore */ }
38
+ });
39
+
40
+ beforeEach(async () => {
41
+ broadcastEvents = [];
42
+ // Reset file between tests so each one starts clean.
43
+ try { rmSync(tasksFilePath); } catch { /* ignore */ }
44
+ // Fresh import of the router — build a minimal app.
45
+ const { createTasksRouter } = await import(
46
+ `${REPO}/bizar-dash/src/server/routes/tasks.mjs`
47
+ );
48
+ app = express();
49
+ app.use(express.json());
50
+ app.use('/api', createTasksRouter({
51
+ state: {},
52
+ broadcast: (msg) => broadcastEvents.push(msg),
53
+ projectRoot: SANDBOX_HOME,
54
+ }));
55
+ });
56
+
57
+ async function request(method, path, body) {
58
+ const http = await import('node:http');
59
+ return new Promise((resolveP, reject) => {
60
+ const server = app.listen(0, '127.0.0.1', () => {
61
+ const { port } = server.address();
62
+ const bodyStr = body ? JSON.stringify(body) : '';
63
+ const req = http.request({
64
+ hostname: '127.0.0.1',
65
+ port,
66
+ path,
67
+ method,
68
+ headers: bodyStr
69
+ ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyStr) }
70
+ : {},
71
+ }, (res) => {
72
+ let data = '';
73
+ res.on('data', (c) => { data += c; });
74
+ res.on('end', () => {
75
+ server.close();
76
+ try {
77
+ resolveP({ status: res.statusCode, body: JSON.parse(data) });
78
+ } catch {
79
+ resolveP({ status: res.statusCode, body: data });
80
+ }
81
+ });
82
+ });
83
+ req.on('error', (err) => { server.close(); reject(err); });
84
+ if (bodyStr) req.write(bodyStr);
85
+ req.end();
86
+ });
87
+ });
88
+ }
89
+
90
+ describe('POST /api/tasks — simplified create flow (no agent selection)', () => {
91
+ it('creates a task with just title + description, returning 201', async () => {
92
+ const res = await request('POST', '/api/tasks', {
93
+ projectId: 'test-proj',
94
+ title: 'Test task',
95
+ description: 'A simple task without any agent field.',
96
+ });
97
+ assert.equal(res.status, 201, `expected 201, got ${res.status}: ${JSON.stringify(res.body)}`);
98
+ assert.ok(res.body.id, 'response should include id');
99
+ assert.equal(res.body.title, 'Test task');
100
+ assert.equal(res.body.description, 'A simple task without any agent field.');
101
+ });
102
+
103
+ it('does not store any agent-related field when none is provided', async () => {
104
+ const res = await request('POST', '/api/tasks', {
105
+ projectId: 'test-proj',
106
+ title: 'No agent task',
107
+ description: 'Just title and description.',
108
+ });
109
+ assert.equal(res.status, 201);
110
+ // The store does not have an `agent` field — it has `assignee`
111
+ // and `workedBy`. Both must be null/empty since no agent was picked.
112
+ assert.equal(res.body.assignee, null, 'assignee should be null when not provided');
113
+ assert.equal(res.body.workedBy, null, 'workedBy should be null when not provided');
114
+ // Defensive: no field literally named `agent` should leak through.
115
+ assert.equal(res.body.agent, undefined, 'response should not have an `agent` field');
116
+ });
117
+
118
+ it('still respects an explicitly passed assignee (backward compat)', async () => {
119
+ // The store API has always accepted `assignee`. We don't break it —
120
+ // we just don't ask for it in the simplified UI. Tests that callers
121
+ // can still set it explicitly when they want to.
122
+ const res = await request('POST', '/api/tasks', {
123
+ projectId: 'test-proj',
124
+ title: 'Manually assigned',
125
+ description: 'Caller passed assignee explicitly.',
126
+ assignee: 'odin',
127
+ });
128
+ assert.equal(res.status, 201);
129
+ assert.equal(res.body.assignee, 'odin');
130
+ });
131
+
132
+ it('ignores an unrecognized `agent` field (does not store it)', async () => {
133
+ const res = await request('POST', '/api/tasks', {
134
+ projectId: 'test-proj',
135
+ title: 'With unknown field',
136
+ description: 'Caller passed agent field by mistake.',
137
+ agent: 'thor',
138
+ });
139
+ assert.equal(res.status, 201);
140
+ // The store doesn't have an `agent` field. Any incoming `agent`
141
+ // is just an unknown prop and should not appear on the stored task.
142
+ assert.equal(res.body.agent, undefined, 'agent field should not be stored');
143
+ assert.equal(res.body.assignee, null, 'assignee should remain null');
144
+ });
145
+
146
+ it('persists the task to disk with no agent data', async () => {
147
+ const res = await request('POST', '/api/tasks', {
148
+ projectId: 'test-proj',
149
+ title: 'Persisted',
150
+ description: 'Check disk state.',
151
+ });
152
+ assert.equal(res.status, 201);
153
+
154
+ assert.ok(existsSync(tasksFilePath), 'tasks.json should be written to disk');
155
+ const stored = JSON.parse(readFileSync(tasksFilePath, 'utf8'));
156
+ assert.equal(stored.tasks.length, 1);
157
+ const task = stored.tasks[0];
158
+ assert.equal(task.title, 'Persisted');
159
+ assert.equal(task.assignee, null);
160
+ assert.equal(task.workedBy, null);
161
+ assert.equal(task.agent, undefined);
162
+ });
163
+
164
+ it('rejects create without a title (non-201 with title-related error)', async () => {
165
+ const res = await request('POST', '/api/tasks', {
166
+ projectId: 'test-proj',
167
+ description: 'No title — should fail.',
168
+ });
169
+ assert.notEqual(res.status, 201, 'should not succeed without a title');
170
+ assert.ok(
171
+ res.body.message?.toLowerCase().includes('title'),
172
+ `error message should mention title, got: ${JSON.stringify(res.body)}`,
173
+ );
174
+ });
175
+
176
+ it('emits a tasks:change broadcast event', async () => {
177
+ const res = await request('POST', '/api/tasks', {
178
+ projectId: 'test-proj',
179
+ title: 'Broadcast test',
180
+ description: 'Should fire tasks:change.',
181
+ });
182
+ assert.equal(res.status, 201);
183
+ assert.equal(broadcastEvents.length, 1, 'should emit exactly one event');
184
+ assert.equal(broadcastEvents[0].type, 'tasks:change');
185
+ assert.equal(broadcastEvents[0].task.title, 'Broadcast test');
186
+ });
187
+ });