@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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.5",
3
- "commit": "e4c8970d613551e3b0086ba6e128b5624c5f2155",
4
- "builtAt": "2026-02-20T05:39:04.795Z"
2
+ "version": "0.0.7",
3
+ "commit": "7f0b46cd788721fa1e258a2542070bdb178f32a7",
4
+ "builtAt": "2026-02-20T06:05:51.160Z"
5
5
  }
@@ -1 +1 @@
1
- 0084b5bec47a901a88b379363f5f31530feeb541ab85d15e5f944c012ebbef96
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 skillDir = path.join(MANAGED_SKILLS_DIR, safeName);
179
- const skillFile = path.join(skillDir, "SKILL.md");
180
- // Check if already exists
181
- if (fs.existsSync(skillFile)) {
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: { message: `Skill '${safeName}' already exists`, type: "conflict" },
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: ${safeName}\ndescription: "${description || "Custom skill"}"\n---\n\n${content}`;
220
+ fileContent = `---\nname: ${finalName}\ndescription: "${description || "Custom skill"}"\n---\n\n${content}`;
195
221
  }
196
- await fsp.mkdir(skillDir, { recursive: true });
197
- await fsp.writeFile(skillFile, fileContent, "utf-8");
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
- config.skills.entries[safeName] = { enabled: true };
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: safeName,
238
+ name: finalName,
210
239
  description: description || "Custom skill",
211
- path: skillFile,
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heylemon/lemonade",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "AI gateway CLI for Lemon - local AI assistant with integrations",
5
5
  "publishConfig": {
6
6
  "access": "restricted"