blyuecode 0.3.8 → 0.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.
@@ -1,6 +1,7 @@
1
1
  import { Provider } from './provider.js';
2
2
  import { getToolDefs, executeTool } from './tools.js';
3
3
  import { loadSession, saveSession, clearSession } from './session.js';
4
+ import { formatSkillForPrompt } from './skills.js';
4
5
  const SYSTEM_PROMPT = 'You are BLYUE, a coding assistant running in a terminal. ' +
5
6
  'Use tools to read, write, and run commands. ' +
6
7
  'If n8n tools are available, you can also manage n8n workflows. ' +
@@ -29,6 +30,16 @@ export class Agent {
29
30
  }
30
31
  catch { }
31
32
  }
33
+ buildSystemMessage(skills) {
34
+ let content = SYSTEM_PROMPT;
35
+ if (skills.length > 0) {
36
+ content += '\n\n# Active skills\n';
37
+ for (const sk of skills) {
38
+ content += '\n' + formatSkillForPrompt(sk) + '\n';
39
+ }
40
+ }
41
+ return { role: 'system', content };
42
+ }
32
43
  historySize() {
33
44
  return this.messages.length - 1;
34
45
  }
@@ -63,7 +74,8 @@ export class Agent {
63
74
  return null;
64
75
  }
65
76
  }
66
- async *run(userInput) {
77
+ async *run(userInput, opts) {
78
+ this.messages[0] = this.buildSystemMessage(opts?.activeSkills ?? []);
67
79
  this.messages.push({ role: 'user', content: userInput });
68
80
  this.persist();
69
81
  const compacted = await this.maybeAutoCompact();
@@ -28,6 +28,7 @@ function parseEnv(file) {
28
28
  model: map.MODEL || 'gpt-4o-mini',
29
29
  n8nUrl: map.N8N_URL || undefined,
30
30
  n8nApiKey: map.N8N_API_KEY || undefined,
31
+ skillsIndexUrl: map.SKILLS_INDEX_URL || undefined,
31
32
  };
32
33
  }
33
34
  export function saveConfig(cfg) {
@@ -44,6 +45,8 @@ export function saveConfig(cfg) {
44
45
  lines.push(`N8N_URL=${cfg.n8nUrl}`);
45
46
  if (cfg.n8nApiKey)
46
47
  lines.push(`N8N_API_KEY=${cfg.n8nApiKey}`);
48
+ if (cfg.skillsIndexUrl)
49
+ lines.push(`SKILLS_INDEX_URL=${cfg.skillsIndexUrl}`);
47
50
  lines.push('');
48
51
  fs.writeFileSync(GLOBAL_ENV, lines.join('\n'), { mode: 0o600 });
49
52
  return GLOBAL_ENV;
@@ -63,6 +66,10 @@ export function applyConfig(cfg) {
63
66
  process.env.N8N_API_KEY = cfg.n8nApiKey;
64
67
  else
65
68
  delete process.env.N8N_API_KEY;
69
+ if (cfg.skillsIndexUrl)
70
+ process.env.SKILLS_INDEX_URL = cfg.skillsIndexUrl;
71
+ else
72
+ delete process.env.SKILLS_INDEX_URL;
66
73
  }
67
74
  export function getConfig() {
68
75
  const cfg = loadConfig();
@@ -74,6 +81,7 @@ export function getConfig() {
74
81
  model: process.env.MODEL || 'gpt-4o-mini',
75
82
  n8nUrl: process.env.N8N_URL,
76
83
  n8nApiKey: process.env.N8N_API_KEY,
84
+ skillsIndexUrl: process.env.SKILLS_INDEX_URL,
77
85
  };
78
86
  }
79
87
  export function hasN8n() {
@@ -0,0 +1,88 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const SKILLS_DIR = path.join(os.homedir(), '.blyue', 'skills');
5
+ export function skillsDir() {
6
+ return SKILLS_DIR;
7
+ }
8
+ export function ensureSkillsDir() {
9
+ if (!fs.existsSync(SKILLS_DIR)) {
10
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
11
+ }
12
+ }
13
+ function stripQuotes(s) {
14
+ return s.replace(/^["']|["']$/g, '').trim();
15
+ }
16
+ function parseFrontmatter(raw) {
17
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
18
+ if (!m)
19
+ return null;
20
+ const [, front, body] = m;
21
+ const meta = {};
22
+ for (const line of front.split(/\r?\n/)) {
23
+ if (!line.trim() || line.trim().startsWith('#'))
24
+ continue;
25
+ const kv = line.match(/^(\w+):\s*(.*)$/);
26
+ if (!kv)
27
+ continue;
28
+ const [, key, rawVal] = kv;
29
+ if (key === 'triggers') {
30
+ const cleaned = rawVal.replace(/^\[|\]$/g, '');
31
+ meta.triggers = cleaned
32
+ .split(',')
33
+ .map((s) => stripQuotes(s))
34
+ .filter(Boolean);
35
+ }
36
+ else {
37
+ meta[key] = stripQuotes(rawVal);
38
+ }
39
+ }
40
+ return { meta, body: body.trim() };
41
+ }
42
+ function loadOne(skillFile) {
43
+ try {
44
+ const raw = fs.readFileSync(skillFile, 'utf8');
45
+ const parsed = parseFrontmatter(raw);
46
+ if (!parsed)
47
+ return null;
48
+ const fallbackName = path.basename(path.dirname(skillFile));
49
+ return {
50
+ name: parsed.meta.name || fallbackName,
51
+ description: parsed.meta.description || '',
52
+ triggers: parsed.meta.triggers || [],
53
+ body: parsed.body,
54
+ path: skillFile,
55
+ };
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export function listSkills() {
62
+ if (!fs.existsSync(SKILLS_DIR))
63
+ return [];
64
+ const out = [];
65
+ for (const entry of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
66
+ if (!entry.isDirectory())
67
+ continue;
68
+ const skillFile = path.join(SKILLS_DIR, entry.name, 'SKILL.md');
69
+ if (!fs.existsSync(skillFile))
70
+ continue;
71
+ const skill = loadOne(skillFile);
72
+ if (skill)
73
+ out.push(skill);
74
+ }
75
+ return out.sort((a, b) => a.name.localeCompare(b.name));
76
+ }
77
+ export function findSkill(name) {
78
+ const lc = name.toLowerCase();
79
+ return (listSkills().find((s) => s.name.toLowerCase() === lc) || null);
80
+ }
81
+ export function matchSkills(userInput) {
82
+ const lc = userInput.toLowerCase();
83
+ return listSkills().filter((s) => s.triggers.some((t) => t && lc.includes(t.toLowerCase())));
84
+ }
85
+ export function formatSkillForPrompt(skill) {
86
+ const desc = skill.description ? ` — ${skill.description}` : '';
87
+ return `## Skill: ${skill.name}${desc}\n${skill.body}`;
88
+ }
@@ -0,0 +1,118 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { skillsDir } from './skills.js';
4
+ const DEFAULT_INDEX_URL = 'https://raw.githubusercontent.com/lumenlabsllc/blyue-skills/main/index.json';
5
+ const CACHE_TTL_MS = 5 * 60 * 1000;
6
+ let cache = null;
7
+ export function indexUrl() {
8
+ return process.env.SKILLS_INDEX_URL || DEFAULT_INDEX_URL;
9
+ }
10
+ function baseUrlFor(url) {
11
+ return url.replace(/\/index\.json.*$/, '');
12
+ }
13
+ export async function fetchStore(opts) {
14
+ const url = indexUrl();
15
+ if (!opts?.force &&
16
+ cache &&
17
+ cache.url === url &&
18
+ Date.now() - cache.at < CACHE_TTL_MS) {
19
+ return cache.skills;
20
+ }
21
+ let res;
22
+ try {
23
+ res = await fetch(url, { headers: { Accept: 'application/json' } });
24
+ }
25
+ catch (err) {
26
+ throw new Error(`Cannot reach skill store: ${err.message}. ` +
27
+ `Check SKILLS_INDEX_URL in /settings.`);
28
+ }
29
+ if (!res.ok) {
30
+ throw new Error(`Skill store returned HTTP ${res.status} ${res.statusText} — ${url}`);
31
+ }
32
+ const body = (await res.json());
33
+ if (!body || !Array.isArray(body.skills)) {
34
+ throw new Error('Skill store index is malformed (expected { version, skills: [...] })');
35
+ }
36
+ cache = { at: Date.now(), url, skills: body.skills };
37
+ return body.skills;
38
+ }
39
+ export function clearStoreCache() {
40
+ cache = null;
41
+ }
42
+ export function isInstalled(name) {
43
+ return fs.existsSync(path.join(skillsDir(), name, 'SKILL.md'));
44
+ }
45
+ export function installedVersion(name) {
46
+ const file = path.join(skillsDir(), name, 'SKILL.md');
47
+ if (!fs.existsSync(file))
48
+ return null;
49
+ try {
50
+ const raw = fs.readFileSync(file, 'utf8');
51
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
52
+ if (!m)
53
+ return null;
54
+ const v = m[1].match(/^version:\s*["']?([^"'\r\n]+)["']?/m);
55
+ return v ? v[1].trim() : null;
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ }
61
+ export async function installSkill(skill) {
62
+ const url = `${baseUrlFor(indexUrl())}/${skill.path.replace(/^\/+/, '')}`;
63
+ let res;
64
+ try {
65
+ res = await fetch(url, { headers: { Accept: 'text/plain' } });
66
+ }
67
+ catch (err) {
68
+ throw new Error(`Download failed: ${err.message}`);
69
+ }
70
+ if (!res.ok) {
71
+ throw new Error(`Download failed: HTTP ${res.status} for ${url}`);
72
+ }
73
+ const text = await res.text();
74
+ if (!text.trim().startsWith('---')) {
75
+ throw new Error(`Downloaded SKILL.md for "${skill.name}" has no frontmatter`);
76
+ }
77
+ const dir = path.join(skillsDir(), skill.name);
78
+ fs.mkdirSync(dir, { recursive: true });
79
+ let out = text;
80
+ if (skill.version && !/^version:/m.test(text.split('---')[1] || '')) {
81
+ out = text.replace(/^---\r?\n([\s\S]*?)\r?\n---/, (_m, inner) => `---\n${inner}\nversion: ${skill.version}\n---`);
82
+ }
83
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), out, 'utf8');
84
+ }
85
+ export function uninstallSkill(name) {
86
+ const dir = path.join(skillsDir(), name);
87
+ if (!fs.existsSync(dir))
88
+ return;
89
+ fs.rmSync(dir, { recursive: true, force: true });
90
+ }
91
+ export async function updateAll() {
92
+ const store = await fetchStore();
93
+ const results = [];
94
+ for (const skill of store) {
95
+ if (!isInstalled(skill.name))
96
+ continue;
97
+ const from = installedVersion(skill.name);
98
+ const to = skill.version ?? null;
99
+ if (from && to && from === to) {
100
+ results.push({ name: skill.name, from, to, ok: true });
101
+ continue;
102
+ }
103
+ try {
104
+ await installSkill(skill);
105
+ results.push({ name: skill.name, from, to, ok: true });
106
+ }
107
+ catch (err) {
108
+ results.push({
109
+ name: skill.name,
110
+ from,
111
+ to,
112
+ ok: false,
113
+ error: err.message,
114
+ });
115
+ }
116
+ }
117
+ return results;
118
+ }
package/dist/ui/Chat.js CHANGED
@@ -19,6 +19,9 @@ import { bumpActivity } from './activity.js';
19
19
  import { theme } from './theme.js';
20
20
  import { getConfig, saveConfig, applyConfig } from '../core/config.js';
21
21
  import { recordModelUse } from '../core/modelUsage.js';
22
+ import { SkillsPanel } from './SkillsPanel.js';
23
+ import { SkillStorePanel } from './SkillStorePanel.js';
24
+ import { listSkills, findSkill, matchSkills, ensureSkillsDir, } from '../core/skills.js';
22
25
  let nextId = 0;
23
26
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
24
27
  const HistoryLine = ({ item }) => {
@@ -67,6 +70,7 @@ export const Chat = ({ resumedCount = 0 }) => {
67
70
  const [loading, setLoading] = useState(false);
68
71
  const [streaming, setStreaming] = useState('');
69
72
  const [overlay, setOverlay] = useState('none');
73
+ const [pendingSkill, setPendingSkill] = useState(null);
70
74
  const [permReq, setPermReq] = useState(null);
71
75
  const [currentModel, setCurrentModel] = useState(process.env.MODEL || '');
72
76
  const [agent, setAgent] = useState(() => new Agent());
@@ -105,6 +109,10 @@ export const Chat = ({ resumedCount = 0 }) => {
105
109
  if (key.ctrl && inputChar === 'o')
106
110
  setOverlay('settings');
107
111
  });
112
+ useEffect(() => { try {
113
+ ensureSkillsDir();
114
+ }
115
+ catch { } }, []);
108
116
  const flush = (kind, text) => {
109
117
  if (!text)
110
118
  return;
@@ -130,6 +138,36 @@ export const Chat = ({ resumedCount = 0 }) => {
130
138
  setOverlay('mcp');
131
139
  return;
132
140
  }
141
+ if (trimmed === '/skillstore') {
142
+ setInput('');
143
+ setOverlay('skillstore');
144
+ return;
145
+ }
146
+ if (trimmed === '/skills') {
147
+ setInput('');
148
+ setOverlay('skills');
149
+ return;
150
+ }
151
+ if (trimmed.startsWith('/skill ')) {
152
+ setInput('');
153
+ const name = trimmed.slice(7).trim();
154
+ const sk = findSkill(name);
155
+ if (!sk) {
156
+ const all = listSkills().map((x) => x.name).join(', ') || '(none)';
157
+ setHistory((h) => [
158
+ ...h,
159
+ mkItem('error', 'unknown skill: ' + name),
160
+ mkItem('tool', 'available: ' + all),
161
+ ]);
162
+ return;
163
+ }
164
+ setPendingSkill(sk);
165
+ setHistory((h) => [
166
+ ...h,
167
+ mkItem('tool', 'skill armed: ' + sk.name + ' (applies to next message)'),
168
+ ]);
169
+ return;
170
+ }
133
171
  if (trimmed === '/debug') {
134
172
  setInput('');
135
173
  const lines = readDebug();
@@ -176,6 +214,9 @@ export const Chat = ({ resumedCount = 0 }) => {
176
214
  mkItem('tool', ' /model switch model (Ctrl+L)'),
177
215
  mkItem('tool', ' /settings open settings (Ctrl+O)'),
178
216
  mkItem('tool', ' /mcp show MCP servers and tools'),
217
+ mkItem('tool', ' /skills list skills'),
218
+ mkItem('tool', ' /skillstore browse and install community skills'),
219
+ mkItem('tool', ' /skill <n> force-activate a skill for next msg'),
179
220
  mkItem('tool', ' /compact summarize history to free context'),
180
221
  mkItem('tool', ' /debug show last request to provider'),
181
222
  mkItem('tool', ' /clear clear session and permissions'),
@@ -191,7 +232,22 @@ export const Chat = ({ resumedCount = 0 }) => {
191
232
  let textBuf = '';
192
233
  let toolBuf = '';
193
234
  try {
194
- for await (const ev of agent.run(trimmed)) {
235
+ const matched = matchSkills(trimmed);
236
+ const activeSkills = [];
237
+ if (pendingSkill)
238
+ activeSkills.push(pendingSkill);
239
+ for (const m of matched) {
240
+ if (!activeSkills.find((x) => x.name === m.name))
241
+ activeSkills.push(m);
242
+ }
243
+ if (activeSkills.length > 0) {
244
+ setHistory((h) => [
245
+ ...h,
246
+ mkItem('tool', 'skills active: ' + activeSkills.map((x) => x.name).join(', ')),
247
+ ]);
248
+ }
249
+ setPendingSkill(null);
250
+ for await (const ev of agent.run(trimmed, { activeSkills })) {
195
251
  if (ev.type === 'text') {
196
252
  textBuf += ev.delta;
197
253
  setStreaming(textBuf);
@@ -255,6 +311,12 @@ export const Chat = ({ resumedCount = 0 }) => {
255
311
  if (overlay === 'mcp') {
256
312
  return (_jsx(Box, { flexDirection: "column", children: _jsx(McpPanel, { onClose: () => setOverlay('none') }) }));
257
313
  }
314
+ if (overlay === 'skillstore') {
315
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(SkillStorePanel, { onClose: () => setOverlay('none') }) }));
316
+ }
317
+ if (overlay === 'skills') {
318
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(SkillsPanel, { onClose: () => setOverlay('none') }) }));
319
+ }
258
320
  if (overlay === 'settings') {
259
321
  return (_jsx(Box, { flexDirection: "column", children: _jsx(Settings, { onClose: () => setOverlay('none'), onModelChange: (m) => {
260
322
  setCurrentModel(m);
@@ -72,6 +72,14 @@ export const Settings = ({ onClose, onModelChange }) => {
72
72
  })(),
73
73
  action: () => { setMcpCursor(0); setScreen('mcp'); },
74
74
  },
75
+ {
76
+ label: 'Skills store URL',
77
+ hint: process.env.SKILLS_INDEX_URL || 'default (github · lumenlabsllc)',
78
+ action: () => {
79
+ setDraft(process.env.SKILLS_INDEX_URL || '');
80
+ setScreen('skillstore-url');
81
+ },
82
+ },
75
83
  { label: 'Close', hint: 'esc', action: onClose },
76
84
  ];
77
85
  useInput((_, key) => {
@@ -276,6 +284,22 @@ export const Settings = ({ onClose, onModelChange }) => {
276
284
  if (screen === 'mcp-confirm-del') {
277
285
  return (_jsxs(Box, { flexDirection: "column", children: [top('delete server'), row(_jsxs(Text, { color: theme.warning, children: ["Delete MCP server \"", mcpConfirmDel, "\"?"] })), row(_jsx(Text, { color: theme.muted, children: "This removes it from mcp.json." })), bot, _jsxs(Text, { children: [_jsx(Text, { color: theme.success, bold: true, children: " [y]" }), _jsx(Text, { color: theme.muted, children: " yes " }), _jsx(Text, { color: theme.error, bold: true, children: "[n]" }), _jsx(Text, { color: theme.muted, children: " no" })] })] }));
278
286
  }
287
+ if (screen === 'skillstore-url') {
288
+ return (_jsxs(Box, { flexDirection: "column", children: [top('skills store url'), row(_jsx(Text, { color: theme.muted, children: "Leave empty to use the default LUMEN store" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: draft, onChange: setDraft, placeholder: "https://raw.githubusercontent.com/you/repo/main/index.json", onSubmit: () => {
289
+ const v = draft.trim();
290
+ const next = { ...cfg };
291
+ if (v)
292
+ next.skillsIndexUrl = v;
293
+ else
294
+ delete next.skillsIndexUrl;
295
+ persist(next);
296
+ if (v)
297
+ process.env.SKILLS_INDEX_URL = v;
298
+ else
299
+ delete process.env.SKILLS_INDEX_URL;
300
+ setScreen('main');
301
+ } })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 save \u00B7 esc cancel" })] }));
302
+ }
279
303
  if (screen === 'mcp-add-name') {
280
304
  return (_jsxs(Box, { flexDirection: "column", children: [top('add mcp · step 1/4'), row(_jsx(Text, { color: theme.muted, children: "Name (unique, e.g. \"filesystem\")" })), row(_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, bold: true, children: '❯ ' }), _jsx(KeyInput, { value: mcpDraftName, onChange: setMcpDraftName, placeholder: "filesystem", onSubmit: () => setScreen('mcp-add-command') })] })), bot, _jsx(Text, { color: theme.muted, children: "type \u00B7 \u21B5 next \u00B7 esc cancel" })] }));
281
305
  }
@@ -0,0 +1,145 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState, useEffect, useMemo } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { fetchStore, installSkill, uninstallSkill, updateAll, isInstalled, indexUrl, } from '../core/skillstore.js';
5
+ import { theme } from './theme.js';
6
+ export const SkillStorePanel = ({ onClose, onChanged }) => {
7
+ const { stdout } = useStdout();
8
+ const width = Math.max(40, stdout?.columns ?? 60);
9
+ const inner = width - 4;
10
+ const [skills, setSkills] = useState([]);
11
+ const [loading, setLoading] = useState(true);
12
+ const [error, setError] = useState(null);
13
+ const [idx, setIdx] = useState(0);
14
+ const [filter, setFilter] = useState('');
15
+ const [status, setStatus] = useState(null);
16
+ const [version, setVersion] = useState(0);
17
+ const reload = async (force = false) => {
18
+ setLoading(true);
19
+ setError(null);
20
+ try {
21
+ const list = await fetchStore({ force });
22
+ setSkills(list);
23
+ setIdx(0);
24
+ }
25
+ catch (err) {
26
+ setError(err.message);
27
+ }
28
+ setLoading(false);
29
+ };
30
+ useEffect(() => {
31
+ void reload(false);
32
+ }, []);
33
+ const filtered = useMemo(() => {
34
+ const q = filter.toLowerCase();
35
+ if (!q)
36
+ return skills;
37
+ return skills.filter((s) => s.name.toLowerCase().includes(q) ||
38
+ s.description.toLowerCase().includes(q) ||
39
+ (s.tags || []).some((t) => t.toLowerCase().includes(q)));
40
+ }, [skills, filter]);
41
+ const current = filtered[idx];
42
+ const flash = (msg) => {
43
+ setStatus(msg);
44
+ setTimeout(() => setStatus(null), 2500);
45
+ };
46
+ const doInstall = async () => {
47
+ if (!current)
48
+ return;
49
+ try {
50
+ await installSkill(current);
51
+ flash('✓ installed ' + current.name);
52
+ setVersion((v) => v + 1);
53
+ onChanged?.();
54
+ }
55
+ catch (err) {
56
+ flash('✗ ' + err.message);
57
+ }
58
+ };
59
+ const doUninstall = () => {
60
+ if (!current)
61
+ return;
62
+ if (!isInstalled(current.name))
63
+ return;
64
+ try {
65
+ uninstallSkill(current.name);
66
+ flash('✓ removed ' + current.name);
67
+ setVersion((v) => v + 1);
68
+ onChanged?.();
69
+ }
70
+ catch (err) {
71
+ flash('✗ ' + err.message);
72
+ }
73
+ };
74
+ const doUpdateAll = async () => {
75
+ setStatus('updating…');
76
+ try {
77
+ const res = await updateAll();
78
+ const updated = res.filter((r) => r.from !== r.to).length;
79
+ const failed = res.filter((r) => !r.ok).length;
80
+ flash(failed > 0
81
+ ? `updated ${updated} · ${failed} failed`
82
+ : `✓ updated ${updated} skill${updated === 1 ? '' : 's'}`);
83
+ setVersion((v) => v + 1);
84
+ onChanged?.();
85
+ }
86
+ catch (err) {
87
+ flash('✗ ' + err.message);
88
+ }
89
+ };
90
+ useInput((input, key) => {
91
+ if (key.escape)
92
+ return onClose();
93
+ if (key.upArrow && filtered.length)
94
+ setIdx((i) => (i - 1 + filtered.length) % filtered.length);
95
+ if (key.downArrow && filtered.length)
96
+ setIdx((i) => (i + 1) % filtered.length);
97
+ if (input === 'i' || input === 'I') {
98
+ void doInstall();
99
+ return;
100
+ }
101
+ if (input === 'd' || input === 'D') {
102
+ doUninstall();
103
+ return;
104
+ }
105
+ if (input === 'u' || input === 'U') {
106
+ void doUpdateAll();
107
+ return;
108
+ }
109
+ if (input === 'r' || input === 'R') {
110
+ void reload(true);
111
+ return;
112
+ }
113
+ if (key.backspace || key.delete) {
114
+ setFilter((f) => f.slice(0, -1));
115
+ setIdx(0);
116
+ return;
117
+ }
118
+ if (input && !key.ctrl && !key.meta) {
119
+ const clean = input.replace(/[\x00-\x1F\x7F]/g, '');
120
+ if (clean) {
121
+ setFilter((f) => f + clean);
122
+ setIdx(0);
123
+ }
124
+ }
125
+ });
126
+ const label = filter ? `store · filter: ${filter}` : 'skill store';
127
+ const topPad = Math.max(1, inner - label.length - 4);
128
+ const maxVisible = 12;
129
+ const start = Math.max(0, Math.min(idx - Math.floor(maxVisible / 2), Math.max(0, filtered.length - maxVisible)));
130
+ const visible = filtered.slice(start, start + maxVisible);
131
+ const installedCount = skills.filter((s) => isInstalled(s.name)).length;
132
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: [skills.length, " skill", skills.length === 1 ? '' : 's', " \u00B7 ", installedCount, " installed"] }), status && (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.muted, children: " \u00B7 " }), _jsx(Text, { color: status.startsWith('✗') ? theme.error : theme.success, children: status })] }))] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.muted, children: indexUrl().slice(0, inner - 4) })] }), _jsx(Text, { color: theme.muted, children: '│ ' + '─'.repeat(Math.max(1, inner - 2)) }), loading ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.muted, children: "fetching index\u2026" })] })) : error ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.error, children: ["\u2717 ", error.slice(0, inner - 6)] })] })) : filtered.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.warning, children: "no skills match" })] })) : (visible.map((s, i) => {
133
+ const realIdx = start + i;
134
+ const active = realIdx === idx;
135
+ const installed = isInstalled(s.name);
136
+ const marker = installed ? '✓ ' : ' ';
137
+ const markerColor = installed ? theme.success : theme.muted;
138
+ const tags = (s.tags || []).slice(0, 3).join(', ');
139
+ const right = installed ? 'installed' : (s.version ? 'v' + s.version : '');
140
+ const nameCap = Math.max(10, inner - right.length - 8);
141
+ const name = s.name.length > nameCap ? s.name.slice(0, nameCap - 1) + '…' : s.name;
142
+ const pad = Math.max(1, inner - name.length - right.length - 5);
143
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: active ? theme.accent : theme.muted, bold: active, children: active ? '❯ ' : ' ' }), _jsx(Text, { color: markerColor, children: marker }), _jsx(Text, { color: active ? theme.text : theme.muted, bold: active, children: name }), _jsx(Text, { color: theme.muted, children: ' '.repeat(pad) }), _jsx(Text, { color: theme.muted, children: right })] }), active && s.description && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.muted, children: s.description.slice(0, inner - 8) })] })), active && tags && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ tags: ' }), _jsx(Text, { color: theme.muted, children: tags })] }))] }, s.name));
144
+ })), _jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: " [" }), _jsx(Text, { color: theme.accent, children: "i" }), _jsx(Text, { color: theme.muted, children: "] install \u00B7 [" }), _jsx(Text, { color: theme.accent, children: "d" }), _jsx(Text, { color: theme.muted, children: "] remove \u00B7 [" }), _jsx(Text, { color: theme.accent, children: "u" }), _jsx(Text, { color: theme.muted, children: "] update all \u00B7 [" }), _jsx(Text, { color: theme.accent, children: "r" }), _jsx(Text, { color: theme.muted, children: "] refresh \u00B7 [" }), _jsx(Text, { color: theme.accent, children: "esc" }), _jsx(Text, { color: theme.muted, children: "] close" })] })] }));
145
+ };
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput, useStdout } from 'ink';
4
+ import { listSkills, skillsDir } from '../core/skills.js';
5
+ import { theme } from './theme.js';
6
+ export const SkillsPanel = ({ onClose }) => {
7
+ const { stdout } = useStdout();
8
+ const width = Math.max(40, stdout?.columns ?? 60);
9
+ const inner = width - 4;
10
+ const [skills, setSkills] = useState(() => listSkills());
11
+ useInput((input, key) => {
12
+ if (key.escape)
13
+ return onClose();
14
+ if (input === 'r' || input === 'R')
15
+ setSkills(listSkills());
16
+ });
17
+ const label = 'skills';
18
+ const topPad = Math.max(1, inner - label.length - 4);
19
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["dir: ", skillsDir()] })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: [skills.length, " skill", skills.length === 1 ? '' : 's', " loaded"] })] }), _jsx(Text, { color: theme.muted, children: '│ ' + '─'.repeat(Math.max(1, inner - 2)) }), skills.length === 0 ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.warning, children: "no skills found" })] }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["create ", skillsDir(), "/<name>/SKILL.md"] })] })] })) : (skills.map((s) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.success, children: '● ' }), _jsx(Text, { color: theme.text, bold: true, children: s.name })] }), s.description && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.muted, children: s.description.slice(0, inner - 6) })] })), s.triggers.length > 0 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ triggers: ' }), _jsx(Text, { color: theme.muted, children: s.triggers.slice(0, 6).join(', ') })] }))] }, s.name)))), _jsx(Text, { color: theme.muted, children: '╰' + '─'.repeat(inner) + '╯' }), _jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: " [" }), _jsx(Text, { color: theme.accent, children: "r" }), _jsx(Text, { color: theme.muted, children: "] reload \u00B7 [" }), _jsx(Text, { color: theme.accent, children: "esc" }), _jsx(Text, { color: theme.muted, children: "] close \u00B7 use " }), _jsx(Text, { color: theme.accent, children: "/skill <name>" }), _jsx(Text, { color: theme.muted, children: " to force-activate" })] })] }));
20
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.3.8",
3
+ "version": "0.5.0",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",