@heylemon/lemonade 0.0.5 → 0.0.7
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/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3b89844316612a865be1e4c2c9302a4043b8e428920fc2948219aa7e67113aa3
|
|
@@ -8,7 +8,15 @@ import { loadInternalHooks } from "../hooks/loader.js";
|
|
|
8
8
|
import { startPluginServices } from "../plugins/services.js";
|
|
9
9
|
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
|
10
10
|
import { scheduleRestartSentinelWake, shouldWakeFromRestartSentinel, } from "./server-restart-sentinel.js";
|
|
11
|
+
import { pullSkillPreferences } from "./skills-sync.js";
|
|
11
12
|
export async function startGatewaySidecars(params) {
|
|
13
|
+
// Restore skill preferences from cloud (non-blocking).
|
|
14
|
+
try {
|
|
15
|
+
await pullSkillPreferences();
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
params.log.warn(`skills-sync: pull failed on startup: ${String(err)}`);
|
|
19
|
+
}
|
|
12
20
|
// Start Lemonade browser control server (unless disabled via config).
|
|
13
21
|
let browserControl = null;
|
|
14
22
|
try {
|
|
@@ -20,6 +20,7 @@ import { resolveBundledSkillsDir } from "../agents/skills/bundled-dir.js";
|
|
|
20
20
|
import { authorizeGatewayConnect } from "./auth.js";
|
|
21
21
|
import { sendJson, sendMethodNotAllowed, sendUnauthorized, sendInvalidRequest, readJsonBodyOrError, } from "./http-common.js";
|
|
22
22
|
import { getBearerToken } from "./http-utils.js";
|
|
23
|
+
import { pushSkillPreferences } from "./skills-sync.js";
|
|
23
24
|
const fsp = fs.promises;
|
|
24
25
|
/**
|
|
25
26
|
* Handle skills API requests.
|
|
@@ -92,6 +93,25 @@ export async function handleSkillsHttpRequest(req, res, opts) {
|
|
|
92
93
|
}
|
|
93
94
|
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
94
95
|
const MANAGED_SKILLS_DIR = path.join(CONFIG_DIR, "skills");
|
|
96
|
+
function getBuiltInSkillNames() {
|
|
97
|
+
const bundledDir = resolveBundledSkillsDir();
|
|
98
|
+
if (!bundledDir || !fs.existsSync(bundledDir))
|
|
99
|
+
return new Set();
|
|
100
|
+
const loadSkills = (params) => {
|
|
101
|
+
const loaded = loadSkillsFromDir(params);
|
|
102
|
+
if (Array.isArray(loaded))
|
|
103
|
+
return loaded;
|
|
104
|
+
if (loaded &&
|
|
105
|
+
typeof loaded === "object" &&
|
|
106
|
+
"skills" in loaded &&
|
|
107
|
+
Array.isArray(loaded.skills)) {
|
|
108
|
+
return loaded.skills;
|
|
109
|
+
}
|
|
110
|
+
return [];
|
|
111
|
+
};
|
|
112
|
+
const skills = loadSkills({ dir: bundledDir, source: "lemonade-bundled" });
|
|
113
|
+
return new Set(skills.map((s) => s.name));
|
|
114
|
+
}
|
|
95
115
|
function loadAllSkillEntries() {
|
|
96
116
|
const config = loadConfig();
|
|
97
117
|
const loadSkills = (params) => {
|
|
@@ -175,53 +195,68 @@ async function handleCreateSkill(req, res) {
|
|
|
175
195
|
sendInvalidRequest(res, "name is invalid after sanitization");
|
|
176
196
|
return true;
|
|
177
197
|
}
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
198
|
+
const builtInNames = getBuiltInSkillNames();
|
|
199
|
+
let finalName = safeName;
|
|
200
|
+
if (builtInNames.has(safeName)) {
|
|
201
|
+
finalName = `${safeName}-custom`;
|
|
202
|
+
}
|
|
203
|
+
const customSkillDir = path.join(MANAGED_SKILLS_DIR, finalName);
|
|
204
|
+
const customSkillFile = path.join(customSkillDir, "SKILL.md");
|
|
205
|
+
if (fs.existsSync(customSkillFile)) {
|
|
182
206
|
sendJson(res, 409, {
|
|
183
|
-
error: {
|
|
207
|
+
error: {
|
|
208
|
+
message: `You already have a custom skill '${finalName}'. You can replace its content or rename your new skill.`,
|
|
209
|
+
type: "conflict",
|
|
210
|
+
},
|
|
184
211
|
});
|
|
185
212
|
return true;
|
|
186
213
|
}
|
|
187
214
|
// Build SKILL.md
|
|
188
215
|
let fileContent;
|
|
189
216
|
if (content.startsWith("---")) {
|
|
190
|
-
// Content already has frontmatter
|
|
191
217
|
fileContent = content;
|
|
192
218
|
}
|
|
193
219
|
else {
|
|
194
|
-
fileContent = `---\nname: ${
|
|
220
|
+
fileContent = `---\nname: ${finalName}\ndescription: "${description || "Custom skill"}"\n---\n\n${content}`;
|
|
195
221
|
}
|
|
196
|
-
await fsp.mkdir(
|
|
197
|
-
await fsp.writeFile(
|
|
198
|
-
// Enable in config
|
|
222
|
+
await fsp.mkdir(customSkillDir, { recursive: true });
|
|
223
|
+
await fsp.writeFile(customSkillFile, fileContent, "utf-8");
|
|
199
224
|
const config = loadConfig();
|
|
200
225
|
if (!config.skills)
|
|
201
226
|
config.skills = {};
|
|
202
227
|
if (!config.skills.entries)
|
|
203
228
|
config.skills.entries = {};
|
|
204
|
-
|
|
229
|
+
// If this shadows a built-in, disable the built-in and enable the custom one
|
|
230
|
+
if (finalName !== safeName && builtInNames.has(safeName)) {
|
|
231
|
+
config.skills.entries[safeName] = { ...config.skills.entries[safeName], enabled: false };
|
|
232
|
+
}
|
|
233
|
+
config.skills.entries[finalName] = { enabled: true };
|
|
205
234
|
await writeConfigFile(config);
|
|
206
235
|
sendJson(res, 201, {
|
|
207
236
|
ok: true,
|
|
208
237
|
skill: {
|
|
209
|
-
name:
|
|
238
|
+
name: finalName,
|
|
210
239
|
description: description || "Custom skill",
|
|
211
|
-
path:
|
|
240
|
+
path: customSkillFile,
|
|
212
241
|
isBuiltIn: false,
|
|
213
242
|
enabled: true,
|
|
214
243
|
},
|
|
244
|
+
...(finalName !== safeName
|
|
245
|
+
? {
|
|
246
|
+
note: `Renamed to '${finalName}' because '${safeName}' is a built-in skill. The built-in version has been deactivated.`,
|
|
247
|
+
}
|
|
248
|
+
: {}),
|
|
215
249
|
});
|
|
250
|
+
void pushSkillPreferences().catch(() => { });
|
|
216
251
|
return true;
|
|
217
252
|
}
|
|
218
253
|
async function handleUpdateSkill(req, res, skillName) {
|
|
219
254
|
const body = (await readJsonBodyOrError(req, res, 1_000_000));
|
|
220
255
|
if (!body)
|
|
221
256
|
return true;
|
|
257
|
+
const builtInNames = getBuiltInSkillNames();
|
|
222
258
|
const config = loadConfig();
|
|
223
259
|
const updated = {};
|
|
224
|
-
// Update enabled state in config
|
|
225
260
|
if (typeof body.enabled === "boolean") {
|
|
226
261
|
if (!config.skills)
|
|
227
262
|
config.skills = {};
|
|
@@ -234,12 +269,19 @@ async function handleUpdateSkill(req, res, skillName) {
|
|
|
234
269
|
await writeConfigFile(config);
|
|
235
270
|
updated.enabled = body.enabled;
|
|
236
271
|
}
|
|
237
|
-
// Update content on disk (only for user/managed skills)
|
|
238
272
|
if (typeof body.content === "string") {
|
|
273
|
+
if (builtInNames.has(skillName)) {
|
|
274
|
+
sendJson(res, 403, {
|
|
275
|
+
error: {
|
|
276
|
+
message: `'${skillName}' is a built-in skill and its content cannot be modified. You can only enable or disable it.`,
|
|
277
|
+
type: "forbidden",
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
239
282
|
const skillFile = path.join(MANAGED_SKILLS_DIR, skillName, "SKILL.md");
|
|
240
283
|
if (fs.existsSync(skillFile)) {
|
|
241
284
|
let newContent = body.content.trim();
|
|
242
|
-
// Ensure frontmatter exists
|
|
243
285
|
if (!newContent.startsWith("---")) {
|
|
244
286
|
const desc = typeof body.description === "string" ? body.description : "Custom skill";
|
|
245
287
|
newContent = `---\nname: ${skillName}\ndescription: "${desc}"\n---\n\n${newContent}`;
|
|
@@ -258,9 +300,20 @@ async function handleUpdateSkill(req, res, skillName) {
|
|
|
258
300
|
}
|
|
259
301
|
}
|
|
260
302
|
sendJson(res, 200, { ok: true, updated });
|
|
303
|
+
void pushSkillPreferences().catch(() => { });
|
|
261
304
|
return true;
|
|
262
305
|
}
|
|
263
306
|
async function handleDeleteSkill(res, skillName) {
|
|
307
|
+
const builtInNames = getBuiltInSkillNames();
|
|
308
|
+
if (builtInNames.has(skillName)) {
|
|
309
|
+
sendJson(res, 403, {
|
|
310
|
+
error: {
|
|
311
|
+
message: `'${skillName}' is a built-in skill and cannot be deleted. You can only enable or disable it.`,
|
|
312
|
+
type: "forbidden",
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
264
317
|
const skillDir = path.join(MANAGED_SKILLS_DIR, skillName);
|
|
265
318
|
if (!fs.existsSync(skillDir)) {
|
|
266
319
|
sendJson(res, 404, {
|
|
@@ -268,14 +321,25 @@ async function handleDeleteSkill(res, skillName) {
|
|
|
268
321
|
});
|
|
269
322
|
return true;
|
|
270
323
|
}
|
|
271
|
-
// Remove directory
|
|
272
324
|
await fsp.rm(skillDir, { recursive: true, force: true });
|
|
273
|
-
// Remove from config
|
|
274
325
|
const config = loadConfig();
|
|
275
326
|
if (config.skills?.entries?.[skillName]) {
|
|
276
327
|
delete config.skills.entries[skillName];
|
|
277
|
-
await writeConfigFile(config);
|
|
278
328
|
}
|
|
329
|
+
// If deleting a custom override (e.g. "pdf-custom"), re-enable the built-in
|
|
330
|
+
const baseSkillName = skillName.replace(/-custom$/, "");
|
|
331
|
+
if (baseSkillName !== skillName && builtInNames.has(baseSkillName)) {
|
|
332
|
+
if (!config.skills)
|
|
333
|
+
config.skills = {};
|
|
334
|
+
if (!config.skills.entries)
|
|
335
|
+
config.skills.entries = {};
|
|
336
|
+
config.skills.entries[baseSkillName] = {
|
|
337
|
+
...config.skills.entries[baseSkillName],
|
|
338
|
+
enabled: true,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
await writeConfigFile(config);
|
|
279
342
|
sendJson(res, 200, { ok: true, deleted: skillName });
|
|
343
|
+
void pushSkillPreferences().catch(() => { });
|
|
280
344
|
return true;
|
|
281
345
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud sync for skill preferences.
|
|
3
|
+
*
|
|
4
|
+
* - pushSkillPreferences() — after local skill changes, push to backend
|
|
5
|
+
* - pullSkillPreferences() — on startup, pull from backend and apply locally
|
|
6
|
+
*
|
|
7
|
+
* Uses the Anthropic provider's baseUrl to derive the backend URL,
|
|
8
|
+
* and the gateway token for auth.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { loadConfig, writeConfigFile } from "../config/config.js";
|
|
13
|
+
import { CONFIG_DIR } from "../utils.js";
|
|
14
|
+
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
15
|
+
const log = createSubsystemLogger("skills-sync");
|
|
16
|
+
const fsp = fs.promises;
|
|
17
|
+
const MANAGED_SKILLS_DIR = path.join(CONFIG_DIR, "skills");
|
|
18
|
+
function resolveSyncEndpoint() {
|
|
19
|
+
const cfg = loadConfig();
|
|
20
|
+
const token = cfg.gateway?.auth?.token ?? process.env.LEMONADE_GATEWAY_TOKEN ?? undefined;
|
|
21
|
+
if (!token)
|
|
22
|
+
return null;
|
|
23
|
+
const providers = cfg.models?.providers ?? {};
|
|
24
|
+
let backendBase;
|
|
25
|
+
for (const key of ["anthropic", "openai"]) {
|
|
26
|
+
const entry = providers[key];
|
|
27
|
+
if (entry?.baseUrl?.trim()) {
|
|
28
|
+
const raw = entry.baseUrl.trim();
|
|
29
|
+
const idx = raw.indexOf("/api/lemonade/proxy");
|
|
30
|
+
if (idx > 0) {
|
|
31
|
+
backendBase = raw.slice(0, idx);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (!backendBase)
|
|
37
|
+
return null;
|
|
38
|
+
return {
|
|
39
|
+
url: `${backendBase}/api/lemonade/skills/sync`,
|
|
40
|
+
token,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function collectCurrentState() {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const preferences = {};
|
|
46
|
+
const customSkills = {};
|
|
47
|
+
const entries = cfg.skills?.entries ?? {};
|
|
48
|
+
for (const [name, entry] of Object.entries(entries)) {
|
|
49
|
+
if (typeof entry?.enabled === "boolean") {
|
|
50
|
+
preferences[name] = { enabled: entry.enabled };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (fs.existsSync(MANAGED_SKILLS_DIR)) {
|
|
54
|
+
try {
|
|
55
|
+
const dirs = fs.readdirSync(MANAGED_SKILLS_DIR, { withFileTypes: true });
|
|
56
|
+
for (const d of dirs) {
|
|
57
|
+
if (!d.isDirectory())
|
|
58
|
+
continue;
|
|
59
|
+
const skillFile = path.join(MANAGED_SKILLS_DIR, d.name, "SKILL.md");
|
|
60
|
+
if (fs.existsSync(skillFile)) {
|
|
61
|
+
try {
|
|
62
|
+
const content = fs.readFileSync(skillFile, "utf-8");
|
|
63
|
+
customSkills[d.name] = { content, description: "" };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// skip unreadable files
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// skip if dir can't be read
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { preferences, customSkills };
|
|
76
|
+
}
|
|
77
|
+
export async function pushSkillPreferences() {
|
|
78
|
+
try {
|
|
79
|
+
const endpoint = resolveSyncEndpoint();
|
|
80
|
+
if (!endpoint) {
|
|
81
|
+
log.debug("skills-sync: no backend endpoint configured, skipping push");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const { preferences, customSkills } = collectCurrentState();
|
|
85
|
+
const res = await fetch(endpoint.url, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
Authorization: `Bearer ${endpoint.token}`,
|
|
90
|
+
},
|
|
91
|
+
body: JSON.stringify({ preferences, customSkills }),
|
|
92
|
+
signal: AbortSignal.timeout(10_000),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
log.warn(`skills-sync: push failed (status ${res.status})`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
log.info("skills-sync: pushed preferences to cloud");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
log.warn(`skills-sync: push error: ${String(err)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function pullSkillPreferences() {
|
|
106
|
+
try {
|
|
107
|
+
const endpoint = resolveSyncEndpoint();
|
|
108
|
+
if (!endpoint) {
|
|
109
|
+
log.debug("skills-sync: no backend endpoint configured, skipping pull");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const res = await fetch(endpoint.url, {
|
|
113
|
+
method: "GET",
|
|
114
|
+
headers: {
|
|
115
|
+
Authorization: `Bearer ${endpoint.token}`,
|
|
116
|
+
},
|
|
117
|
+
signal: AbortSignal.timeout(10_000),
|
|
118
|
+
});
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
log.warn(`skills-sync: pull failed (status ${res.status})`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const data = (await res.json());
|
|
124
|
+
if (!data.ok)
|
|
125
|
+
return;
|
|
126
|
+
const cloudPrefs = data.preferences ?? {};
|
|
127
|
+
const cloudCustom = data.customSkills ?? {};
|
|
128
|
+
if (Object.keys(cloudPrefs).length === 0 && Object.keys(cloudCustom).length === 0) {
|
|
129
|
+
log.info("skills-sync: no cloud preferences to apply");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const cfg = loadConfig();
|
|
133
|
+
let changed = false;
|
|
134
|
+
// Apply preference overrides (enabled/disabled)
|
|
135
|
+
if (Object.keys(cloudPrefs).length > 0) {
|
|
136
|
+
if (!cfg.skills)
|
|
137
|
+
cfg.skills = {};
|
|
138
|
+
if (!cfg.skills.entries)
|
|
139
|
+
cfg.skills.entries = {};
|
|
140
|
+
for (const [name, pref] of Object.entries(cloudPrefs)) {
|
|
141
|
+
const current = cfg.skills.entries[name];
|
|
142
|
+
if (!current || current.enabled !== pref.enabled) {
|
|
143
|
+
cfg.skills.entries[name] = { ...current, enabled: pref.enabled };
|
|
144
|
+
changed = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Restore custom skills (write SKILL.md files)
|
|
149
|
+
let customCount = 0;
|
|
150
|
+
for (const [name, skill] of Object.entries(cloudCustom)) {
|
|
151
|
+
if (!skill.content?.trim())
|
|
152
|
+
continue;
|
|
153
|
+
const skillDir = path.join(MANAGED_SKILLS_DIR, name);
|
|
154
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
155
|
+
// Only write if the file doesn't already exist locally
|
|
156
|
+
if (!fs.existsSync(skillFile)) {
|
|
157
|
+
await fsp.mkdir(skillDir, { recursive: true });
|
|
158
|
+
await fsp.writeFile(skillFile, skill.content, "utf-8");
|
|
159
|
+
customCount++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (changed) {
|
|
163
|
+
await writeConfigFile(cfg);
|
|
164
|
+
}
|
|
165
|
+
log.info(`skills-sync: pulled preferences from cloud (${Object.keys(cloudPrefs).length} prefs, ${customCount} custom skills restored)`);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
log.warn(`skills-sync: pull error: ${String(err)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|