blyuecode 0.4.0 → 0.6.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.
@@ -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,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
@@ -20,6 +20,7 @@ import { theme } from './theme.js';
20
20
  import { getConfig, saveConfig, applyConfig } from '../core/config.js';
21
21
  import { recordModelUse } from '../core/modelUsage.js';
22
22
  import { SkillsPanel } from './SkillsPanel.js';
23
+ import { SkillStorePanel } from './SkillStorePanel.js';
23
24
  import { listSkills, findSkill, matchSkills, ensureSkillsDir, } from '../core/skills.js';
24
25
  let nextId = 0;
25
26
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
@@ -107,6 +108,8 @@ export const Chat = ({ resumedCount = 0 }) => {
107
108
  setOverlay('model');
108
109
  if (key.ctrl && inputChar === 'o')
109
110
  setOverlay('settings');
111
+ if (key.ctrl && inputChar === 'k')
112
+ setOverlay('skillstore');
110
113
  });
111
114
  useEffect(() => { try {
112
115
  ensureSkillsDir();
@@ -137,6 +140,11 @@ export const Chat = ({ resumedCount = 0 }) => {
137
140
  setOverlay('mcp');
138
141
  return;
139
142
  }
143
+ if (trimmed === '/skillstore') {
144
+ setInput('');
145
+ setOverlay('skillstore');
146
+ return;
147
+ }
140
148
  if (trimmed === '/skills') {
141
149
  setInput('');
142
150
  setOverlay('skills');
@@ -209,6 +217,7 @@ export const Chat = ({ resumedCount = 0 }) => {
209
217
  mkItem('tool', ' /settings open settings (Ctrl+O)'),
210
218
  mkItem('tool', ' /mcp show MCP servers and tools'),
211
219
  mkItem('tool', ' /skills list skills'),
220
+ mkItem('tool', ' /skillstore browse and install community skills'),
212
221
  mkItem('tool', ' /skill <n> force-activate a skill for next msg'),
213
222
  mkItem('tool', ' /compact summarize history to free context'),
214
223
  mkItem('tool', ' /debug show last request to provider'),
@@ -304,6 +313,9 @@ export const Chat = ({ resumedCount = 0 }) => {
304
313
  if (overlay === 'mcp') {
305
314
  return (_jsx(Box, { flexDirection: "column", children: _jsx(McpPanel, { onClose: () => setOverlay('none') }) }));
306
315
  }
316
+ if (overlay === 'skillstore') {
317
+ return (_jsx(Box, { flexDirection: "column", children: _jsx(SkillStorePanel, { onClose: () => setOverlay('none') }) }));
318
+ }
307
319
  if (overlay === 'skills') {
308
320
  return (_jsx(Box, { flexDirection: "column", children: _jsx(SkillsPanel, { onClose: () => setOverlay('none') }) }));
309
321
  }
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",