@heylemon/lemonade 0.0.6 → 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.6",
3
- "commit": "726ef3eb29cf96569eaa0380126b995c82e43cf3",
4
- "builtAt": "2026-02-20T05:47:58.066Z"
2
+ "version": "0.0.7",
3
+ "commit": "7f0b46cd788721fa1e258a2542070bdb178f32a7",
4
+ "builtAt": "2026-02-20T06:05:51.160Z"
5
5
  }
@@ -1 +1 @@
1
- 51eb12e5a38916af8a01151207c0974086f45b5a7c962e824bb8ebd5fe8887d1
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.
@@ -241,9 +242,12 @@ async function handleCreateSkill(req, res) {
241
242
  enabled: true,
242
243
  },
243
244
  ...(finalName !== safeName
244
- ? { note: `Renamed to '${finalName}' because '${safeName}' is a built-in skill. The built-in version has been deactivated.` }
245
+ ? {
246
+ note: `Renamed to '${finalName}' because '${safeName}' is a built-in skill. The built-in version has been deactivated.`,
247
+ }
245
248
  : {}),
246
249
  });
250
+ void pushSkillPreferences().catch(() => { });
247
251
  return true;
248
252
  }
249
253
  async function handleUpdateSkill(req, res, skillName) {
@@ -296,6 +300,7 @@ async function handleUpdateSkill(req, res, skillName) {
296
300
  }
297
301
  }
298
302
  sendJson(res, 200, { ok: true, updated });
303
+ void pushSkillPreferences().catch(() => { });
299
304
  return true;
300
305
  }
301
306
  async function handleDeleteSkill(res, skillName) {
@@ -328,9 +333,13 @@ async function handleDeleteSkill(res, skillName) {
328
333
  config.skills = {};
329
334
  if (!config.skills.entries)
330
335
  config.skills.entries = {};
331
- config.skills.entries[baseSkillName] = { ...config.skills.entries[baseSkillName], enabled: true };
336
+ config.skills.entries[baseSkillName] = {
337
+ ...config.skills.entries[baseSkillName],
338
+ enabled: true,
339
+ };
332
340
  }
333
341
  await writeConfigFile(config);
334
342
  sendJson(res, 200, { ok: true, deleted: skillName });
343
+ void pushSkillPreferences().catch(() => { });
335
344
  return true;
336
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.6",
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"