@jhizzard/termdeck 0.4.0 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +1 -1
- package/packages/cli/src/forge.js +262 -0
- package/packages/cli/src/index.js +24 -0
- package/packages/client/public/app.js +204 -6
- package/packages/client/public/style.css +298 -2
- package/packages/server/src/forge-prompt.js +265 -0
- package/packages/server/src/index.js +127 -3
- package/packages/server/src/mnestra-bridge/index.js +24 -14
- package/packages/server/src/session.js +7 -1
- package/packages/server/src/skill-installer.js +166 -0
|
@@ -57,7 +57,7 @@ const { RAGIntegration } = require('./rag');
|
|
|
57
57
|
const { createBridge } = require('./mnestra-bridge');
|
|
58
58
|
const { writeSessionLog } = require('./session-logger');
|
|
59
59
|
const { TranscriptWriter } = require('./transcripts');
|
|
60
|
-
const { createHealthHandler } = require('./preflight');
|
|
60
|
+
const { createHealthHandler, runPreflight } = require('./preflight');
|
|
61
61
|
const { themes, statusColors } = require('./themes');
|
|
62
62
|
const { loadConfig, addProject } = require('./config');
|
|
63
63
|
const { createAuthMiddleware, verifyWebSocketUpgrade, hasAuth } = require('./auth');
|
|
@@ -69,6 +69,11 @@ function createServer(config) {
|
|
|
69
69
|
|
|
70
70
|
app.use(express.json());
|
|
71
71
|
|
|
72
|
+
// First-run detection (Sprint 19 T3): true when ~/.termdeck/config.yaml
|
|
73
|
+
// does not exist. Surfaced on /api/config so the client can offer the
|
|
74
|
+
// setup wizard on first visit. T1's /api/setup endpoint may reuse this.
|
|
75
|
+
const firstRun = !fs.existsSync(path.join(os.homedir(), '.termdeck', 'config.yaml'));
|
|
76
|
+
|
|
72
77
|
// Optional token auth (Sprint 9 T3). Zero-op when no token is configured,
|
|
73
78
|
// so local users see no behavior change. Mounted before static + routes so
|
|
74
79
|
// unauthenticated requests never touch app.js / index.html.
|
|
@@ -140,6 +145,113 @@ function createServer(config) {
|
|
|
140
145
|
// or scope the response to a minimal {status, version} payload.
|
|
141
146
|
app.get('/api/health', createHealthHandler(config));
|
|
142
147
|
|
|
148
|
+
// GET /api/setup - setup wizard tier status (Sprint 19 T1)
|
|
149
|
+
// Reuses preflight checks (mnestra_reachable, rumen_recent) and pairs them
|
|
150
|
+
// with filesystem + config signals to classify which of the 4 TermDeck tiers
|
|
151
|
+
// the user has reached:
|
|
152
|
+
// 1. TermDeck running (always active when this handler responds)
|
|
153
|
+
// 2. Mnestra reachable + DATABASE_URL available (partial if only reachable)
|
|
154
|
+
// 3. Rumen job seen recently (partial if DATABASE_URL set but no recent job)
|
|
155
|
+
// 4. At least one project configured in config.yaml
|
|
156
|
+
// Cached for 60s so the setup UI can poll without re-running shell/PTY probes.
|
|
157
|
+
const SETUP_CONFIG_DIR = path.join(os.homedir(), '.termdeck');
|
|
158
|
+
const SETUP_SECRETS_PATH = path.join(SETUP_CONFIG_DIR, 'secrets.env');
|
|
159
|
+
const SETUP_CACHE_TTL_MS = 60_000;
|
|
160
|
+
let _setupCache = null;
|
|
161
|
+
let _setupCachedAt = 0;
|
|
162
|
+
|
|
163
|
+
app.get('/api/setup', async (req, res) => {
|
|
164
|
+
if (_setupCache && (Date.now() - _setupCachedAt) < SETUP_CACHE_TTL_MS) {
|
|
165
|
+
return res.json(_setupCache);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const preflight = await runPreflight(config);
|
|
170
|
+
const byName = {};
|
|
171
|
+
for (const c of preflight.checks) byName[c.name] = c;
|
|
172
|
+
|
|
173
|
+
const hasConfigFile = !firstRun;
|
|
174
|
+
const hasSecretsFile = fs.existsSync(SETUP_SECRETS_PATH);
|
|
175
|
+
const hasDatabaseUrl = !!process.env.DATABASE_URL;
|
|
176
|
+
const hasMnestraRunning = !!(byName.mnestra_reachable && byName.mnestra_reachable.passed);
|
|
177
|
+
const hasRumenDeployed = !!(byName.rumen_recent && byName.rumen_recent.passed);
|
|
178
|
+
const projectCount = Object.keys(config.projects || {}).length;
|
|
179
|
+
|
|
180
|
+
const tier1 = {
|
|
181
|
+
status: 'active',
|
|
182
|
+
detail: `TermDeck running on :${config.port || 3000}`
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
let tier2;
|
|
186
|
+
if (hasMnestraRunning && hasDatabaseUrl) {
|
|
187
|
+
tier2 = {
|
|
188
|
+
status: 'active',
|
|
189
|
+
detail: byName.mnestra_reachable.detail || 'Mnestra reachable'
|
|
190
|
+
};
|
|
191
|
+
} else if (hasMnestraRunning && !hasDatabaseUrl) {
|
|
192
|
+
tier2 = {
|
|
193
|
+
status: 'partial',
|
|
194
|
+
detail: 'Mnestra reachable but DATABASE_URL not set'
|
|
195
|
+
};
|
|
196
|
+
} else {
|
|
197
|
+
tier2 = {
|
|
198
|
+
status: 'not_configured',
|
|
199
|
+
detail: (byName.mnestra_reachable && byName.mnestra_reachable.detail) || 'Mnestra not reachable'
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let tier3;
|
|
204
|
+
if (hasRumenDeployed) {
|
|
205
|
+
tier3 = { status: 'active', detail: byName.rumen_recent.detail };
|
|
206
|
+
} else if (hasDatabaseUrl && byName.rumen_recent &&
|
|
207
|
+
/no completed Rumen jobs|stale/i.test(byName.rumen_recent.detail || '')) {
|
|
208
|
+
tier3 = { status: 'partial', detail: byName.rumen_recent.detail };
|
|
209
|
+
} else {
|
|
210
|
+
tier3 = {
|
|
211
|
+
status: 'not_configured',
|
|
212
|
+
detail: (byName.rumen_recent && byName.rumen_recent.detail) || 'Rumen not deployed'
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const tier4 = projectCount > 0
|
|
217
|
+
? { status: 'active', detail: `${projectCount} project${projectCount === 1 ? '' : 's'} configured` }
|
|
218
|
+
: { status: 'not_configured', detail: 'No project paths in config.yaml' };
|
|
219
|
+
|
|
220
|
+
const tiers = { 1: tier1, 2: tier2, 3: tier3, 4: tier4 };
|
|
221
|
+
|
|
222
|
+
// Current tier = highest contiguous tier with status active or partial.
|
|
223
|
+
let tier = 0;
|
|
224
|
+
for (let i = 1; i <= 4; i++) {
|
|
225
|
+
if (tiers[i].status === 'active' || tiers[i].status === 'partial') {
|
|
226
|
+
tier = i;
|
|
227
|
+
} else {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const payload = {
|
|
233
|
+
tier,
|
|
234
|
+
tiers,
|
|
235
|
+
config: {
|
|
236
|
+
hasSecretsFile,
|
|
237
|
+
hasConfigFile,
|
|
238
|
+
hasDatabaseUrl,
|
|
239
|
+
hasMnestraRunning,
|
|
240
|
+
hasRumenDeployed,
|
|
241
|
+
projectCount
|
|
242
|
+
},
|
|
243
|
+
firstRun
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
_setupCache = payload;
|
|
247
|
+
_setupCachedAt = Date.now();
|
|
248
|
+
res.json(payload);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
console.error('[setup] /api/setup failed:', err.message);
|
|
251
|
+
res.status(500).json({ error: err.message });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
143
255
|
// GET /api/sessions - list all active sessions
|
|
144
256
|
app.get('/api/sessions', (req, res) => {
|
|
145
257
|
res.json(sessions.getAll());
|
|
@@ -263,6 +375,7 @@ function createServer(config) {
|
|
|
263
375
|
// configured, regardless of the push-loop flag.
|
|
264
376
|
session.onErrorDetected = (sess, ctx) => {
|
|
265
377
|
const question = `${sess.meta.type} error ${ctx.lastCommand || ''} ${ctx.tail || ''}`.trim();
|
|
378
|
+
console.log(`[flashback] error detected in session ${sess.id} (type=${sess.meta.type}, project=${sess.meta.project || 'none'}), querying Mnestra via ${mnestraBridge.mode}…`);
|
|
266
379
|
mnestraBridge.queryMnestra({
|
|
267
380
|
question,
|
|
268
381
|
project: sess.meta.project,
|
|
@@ -274,16 +387,26 @@ function createServer(config) {
|
|
|
274
387
|
status: 'errored'
|
|
275
388
|
}
|
|
276
389
|
}).then((result) => {
|
|
390
|
+
const count = (result.memories || []).length;
|
|
391
|
+
console.log(`[flashback] query returned ${count} matches for session ${sess.id}`);
|
|
277
392
|
const hit = (result.memories || [])[0];
|
|
278
|
-
if (!hit)
|
|
393
|
+
if (!hit) {
|
|
394
|
+
console.log(`[flashback] no matches — skipping proactive_memory send for session ${sess.id}`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
279
397
|
if (sess.ws && sess.ws.readyState === 1) {
|
|
280
398
|
try {
|
|
281
399
|
sess.ws.send(JSON.stringify({ type: 'proactive_memory', hit }));
|
|
400
|
+
console.log(`[flashback] proactive_memory sent to session ${sess.id} (source_type=${hit.source_type}, project=${hit.project})`);
|
|
282
401
|
} catch (err) {
|
|
402
|
+
console.error('[flashback] proactive_memory send failed:', err);
|
|
283
403
|
console.error('[ws] proactive_memory send failed:', err);
|
|
284
404
|
}
|
|
405
|
+
} else {
|
|
406
|
+
console.log(`[flashback] ws not open for session ${sess.id} (readyState=${sess.ws ? sess.ws.readyState : 'null'}) — dropped hit`);
|
|
285
407
|
}
|
|
286
408
|
}).catch((err) => {
|
|
409
|
+
console.error(`[flashback] query failed for session ${sess.id}: ${err.message}`);
|
|
287
410
|
console.warn('[mnestra-bridge] proactive query failed:', err.message);
|
|
288
411
|
});
|
|
289
412
|
};
|
|
@@ -434,7 +557,8 @@ function createServer(config) {
|
|
|
434
557
|
defaultTheme: config.defaultTheme,
|
|
435
558
|
ragEnabled: rag.enabled,
|
|
436
559
|
aiQueryAvailable: !!(config.rag?.supabaseUrl && config.rag?.supabaseKey && config.rag?.openaiApiKey),
|
|
437
|
-
statusColors
|
|
560
|
+
statusColors,
|
|
561
|
+
firstRun
|
|
438
562
|
});
|
|
439
563
|
});
|
|
440
564
|
|
|
@@ -47,6 +47,24 @@ function createBridge(config) {
|
|
|
47
47
|
const embeddingData = await embeddingRes.json();
|
|
48
48
|
const embedding = embeddingData.data[0].embedding;
|
|
49
49
|
|
|
50
|
+
// NOTE: memory_hybrid_search (migrations/004) accepts exactly 8 named params:
|
|
51
|
+
// query_text, query_embedding, match_count, full_text_weight,
|
|
52
|
+
// semantic_weight, rrf_k, filter_project, filter_source_type.
|
|
53
|
+
// PostgREST matches RPC functions by the set of JSON keys in the body — any
|
|
54
|
+
// extra key (e.g. recency_weight, decay_days) makes it fail to resolve the
|
|
55
|
+
// overload and return 404 "Could not find the function". That was silently
|
|
56
|
+
// killing every Flashback query for 15 sprints.
|
|
57
|
+
const rpcBody = {
|
|
58
|
+
query_text: question,
|
|
59
|
+
query_embedding: `[${embedding.join(',')}]`,
|
|
60
|
+
match_count: 10,
|
|
61
|
+
full_text_weight: 1.0,
|
|
62
|
+
semantic_weight: 1.0,
|
|
63
|
+
rrf_k: 60,
|
|
64
|
+
filter_project: searchAll ? null : (project || null),
|
|
65
|
+
filter_source_type: null
|
|
66
|
+
};
|
|
67
|
+
console.log(`[flashback] direct RPC → memory_hybrid_search project=${rpcBody.filter_project ?? 'ALL'} q="${question.slice(0, 60)}"`);
|
|
50
68
|
const searchRes = await fetch(`${supabaseUrl}/rest/v1/rpc/memory_hybrid_search`, {
|
|
51
69
|
method: 'POST',
|
|
52
70
|
headers: {
|
|
@@ -54,31 +72,23 @@ function createBridge(config) {
|
|
|
54
72
|
'apikey': supabaseKey,
|
|
55
73
|
'Authorization': `Bearer ${supabaseKey}`
|
|
56
74
|
},
|
|
57
|
-
body: JSON.stringify(
|
|
58
|
-
query_text: question,
|
|
59
|
-
query_embedding: `[${embedding.join(',')}]`,
|
|
60
|
-
match_count: 10,
|
|
61
|
-
full_text_weight: 1.0,
|
|
62
|
-
semantic_weight: 1.0,
|
|
63
|
-
rrf_k: 60,
|
|
64
|
-
filter_project: searchAll ? null : (project || null),
|
|
65
|
-
filter_source_type: null,
|
|
66
|
-
recency_weight: 0.15,
|
|
67
|
-
decay_days: 30.0
|
|
68
|
-
})
|
|
75
|
+
body: JSON.stringify(rpcBody)
|
|
69
76
|
});
|
|
70
77
|
if (!searchRes.ok) {
|
|
71
78
|
const err = await searchRes.text();
|
|
79
|
+
console.error(`[flashback] direct RPC failed ${searchRes.status}:`, err);
|
|
72
80
|
console.error('[mnestra-bridge:direct] supabase search failed:', err);
|
|
73
|
-
throw new Error(
|
|
81
|
+
throw new Error(`Memory search failed (${searchRes.status})`);
|
|
74
82
|
}
|
|
75
83
|
const rows = await searchRes.json();
|
|
84
|
+
console.log(`[flashback] direct RPC returned ${rows.length} rows`);
|
|
76
85
|
return {
|
|
77
86
|
memories: rows.map((m) => ({
|
|
78
87
|
content: m.content,
|
|
79
88
|
source_type: m.source_type,
|
|
80
89
|
project: m.project,
|
|
81
|
-
|
|
90
|
+
// memory_hybrid_search returns `score`, not `similarity`.
|
|
91
|
+
similarity: m.similarity ?? m.score ?? null,
|
|
82
92
|
created_at: m.created_at
|
|
83
93
|
})),
|
|
84
94
|
total: rows.length
|
|
@@ -322,7 +322,10 @@ class Session {
|
|
|
322
322
|
|
|
323
323
|
// Server-side rate limit: at most one error_detected event every 30s per session
|
|
324
324
|
const now = Date.now();
|
|
325
|
-
if (now - this._lastErrorFireAt < 30000)
|
|
325
|
+
if (now - this._lastErrorFireAt < 30000) {
|
|
326
|
+
console.log(`[flashback] error detected in session ${this.id} but rate-limited (${Math.round((30000 - (now - this._lastErrorFireAt)) / 1000)}s left)`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
326
329
|
this._lastErrorFireAt = now;
|
|
327
330
|
|
|
328
331
|
if (this.onErrorDetected) {
|
|
@@ -333,8 +336,11 @@ class Session {
|
|
|
333
336
|
try {
|
|
334
337
|
this.onErrorDetected(this, { lastCommand, tail });
|
|
335
338
|
} catch (err) {
|
|
339
|
+
console.error('[flashback] onErrorDetected handler threw:', err);
|
|
336
340
|
console.error('[session] onErrorDetected handler error:', err);
|
|
337
341
|
}
|
|
342
|
+
} else {
|
|
343
|
+
console.log(`[flashback] error detected in session ${this.id} but no onErrorDetected handler wired`);
|
|
338
344
|
}
|
|
339
345
|
}
|
|
340
346
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Skill installer (Sprint 20 / SkillForge foundation).
|
|
2
|
+
// Writes generated skills to ~/.claude/skills/ as markdown files with frontmatter,
|
|
3
|
+
// lists installed skills, and removes them. Used by the `termdeck forge` CLI.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
const FRONTMATTER_KEYS = ['name', 'description', 'trigger', 'source', 'generated'];
|
|
10
|
+
|
|
11
|
+
function getSkillsDir() {
|
|
12
|
+
const override = process.env.TERMDECK_SKILLS_DIR;
|
|
13
|
+
if (override && override.trim()) return path.resolve(override);
|
|
14
|
+
const home = os.homedir();
|
|
15
|
+
if (!home) {
|
|
16
|
+
return path.resolve(process.cwd(), '.claude', 'skills');
|
|
17
|
+
}
|
|
18
|
+
return path.join(home, '.claude', 'skills');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureSkillsDir() {
|
|
22
|
+
const dir = getSkillsDir();
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
24
|
+
return dir;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validateName(name) {
|
|
28
|
+
if (!name || typeof name !== 'string') {
|
|
29
|
+
throw new Error('skill name is required');
|
|
30
|
+
}
|
|
31
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
|
|
32
|
+
throw new Error(`invalid skill name: ${name} (use letters, digits, - or _)`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function skillPath(name) {
|
|
37
|
+
validateName(name);
|
|
38
|
+
return path.join(getSkillsDir(), `${name}.md`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function escapeFrontmatterValue(value) {
|
|
42
|
+
const str = String(value ?? '');
|
|
43
|
+
if (str.includes('\n')) {
|
|
44
|
+
return JSON.stringify(str);
|
|
45
|
+
}
|
|
46
|
+
if (/^[\s"'`]|[:#]\s|[\s"'`]$/.test(str)) {
|
|
47
|
+
return JSON.stringify(str);
|
|
48
|
+
}
|
|
49
|
+
return str;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildMarkdown(skill) {
|
|
53
|
+
const generated = skill.generated || new Date().toISOString();
|
|
54
|
+
const frontmatter = { ...skill, generated };
|
|
55
|
+
const lines = ['---'];
|
|
56
|
+
for (const key of FRONTMATTER_KEYS) {
|
|
57
|
+
if (frontmatter[key] === undefined || frontmatter[key] === null) continue;
|
|
58
|
+
lines.push(`${key}: ${escapeFrontmatterValue(frontmatter[key])}`);
|
|
59
|
+
}
|
|
60
|
+
lines.push('---');
|
|
61
|
+
lines.push('');
|
|
62
|
+
const body = (skill.content || skill.body || '').replace(/\s+$/, '');
|
|
63
|
+
if (body) {
|
|
64
|
+
lines.push(body);
|
|
65
|
+
lines.push('');
|
|
66
|
+
}
|
|
67
|
+
return lines.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseFrontmatter(markdown) {
|
|
71
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
72
|
+
if (!match) return {};
|
|
73
|
+
const meta = {};
|
|
74
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
75
|
+
const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
|
|
76
|
+
if (!m) continue;
|
|
77
|
+
let value = m[2].trim();
|
|
78
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
79
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
80
|
+
try { value = JSON.parse(value); } catch (_) { value = value.slice(1, -1); }
|
|
81
|
+
}
|
|
82
|
+
meta[m[1]] = value;
|
|
83
|
+
}
|
|
84
|
+
return meta;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function installSkill(skill, options = {}) {
|
|
88
|
+
if (!skill || typeof skill !== 'object') {
|
|
89
|
+
throw new Error('installSkill: skill object is required');
|
|
90
|
+
}
|
|
91
|
+
validateName(skill.name);
|
|
92
|
+
const dir = ensureSkillsDir();
|
|
93
|
+
const filepath = path.join(dir, `${skill.name}.md`);
|
|
94
|
+
const exists = fs.existsSync(filepath);
|
|
95
|
+
if (exists && !options.overwrite) {
|
|
96
|
+
const err = new Error(`skill already exists: ${skill.name}`);
|
|
97
|
+
err.code = 'SKILL_EXISTS';
|
|
98
|
+
err.path = filepath;
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
const markdown = buildMarkdown(skill);
|
|
102
|
+
fs.writeFileSync(filepath, markdown, 'utf-8');
|
|
103
|
+
return { path: filepath, overwritten: exists };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function listInstalledSkills() {
|
|
107
|
+
const dir = getSkillsDir();
|
|
108
|
+
if (!fs.existsSync(dir)) return [];
|
|
109
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
110
|
+
const skills = [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
113
|
+
const filepath = path.join(dir, entry.name);
|
|
114
|
+
let meta = {};
|
|
115
|
+
let stat = null;
|
|
116
|
+
try {
|
|
117
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
118
|
+
meta = parseFrontmatter(content);
|
|
119
|
+
stat = fs.statSync(filepath);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
// skip unreadable file, keep going
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
skills.push({
|
|
125
|
+
name: meta.name || entry.name.replace(/\.md$/, ''),
|
|
126
|
+
description: meta.description || '',
|
|
127
|
+
trigger: meta.trigger || '',
|
|
128
|
+
source: meta.source || '',
|
|
129
|
+
generated: meta.generated || (stat ? stat.mtime.toISOString() : ''),
|
|
130
|
+
path: filepath
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
skills.sort((a, b) => a.name.localeCompare(b.name));
|
|
134
|
+
return skills;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function skillExists(name) {
|
|
138
|
+
try {
|
|
139
|
+
return fs.existsSync(skillPath(name));
|
|
140
|
+
} catch (_) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function removeSkill(name) {
|
|
146
|
+
const filepath = skillPath(name);
|
|
147
|
+
if (!fs.existsSync(filepath)) {
|
|
148
|
+
const err = new Error(`skill not found: ${name}`);
|
|
149
|
+
err.code = 'SKILL_NOT_FOUND';
|
|
150
|
+
err.path = filepath;
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
fs.unlinkSync(filepath);
|
|
154
|
+
return { path: filepath };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
getSkillsDir,
|
|
159
|
+
installSkill,
|
|
160
|
+
listInstalledSkills,
|
|
161
|
+
removeSkill,
|
|
162
|
+
skillExists,
|
|
163
|
+
// exposed for tests
|
|
164
|
+
_buildMarkdown: buildMarkdown,
|
|
165
|
+
_parseFrontmatter: parseFrontmatter
|
|
166
|
+
};
|