finch-skin-studio 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,723 @@
1
+ // src/index.ts
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, unlink, writeFile } from "node:fs/promises";
4
+ import { join, sep } from "node:path";
5
+ function getAppearance(ctx) {
6
+ const appearance = ctx.appearance;
7
+ if (!appearance) {
8
+ throw new Error(
9
+ "This Finch build does not expose ctx.appearance yet. Please update Finch to a version that supports the Appearance API."
10
+ );
11
+ }
12
+ return appearance;
13
+ }
14
+ var KEY_CUSTOM_SKINS = "customSkins";
15
+ var KEY_BACKGROUND = "background";
16
+ var KEY_LAST_APPLIED = "lastApplied";
17
+ var KEY_ACTIVE_MODE = "activeMode";
18
+ var BUILTIN_SKINS = [
19
+ {
20
+ id: "light-snow",
21
+ name: "Snow White",
22
+ base: "light",
23
+ colors: {
24
+ bgRoot: "#ffffff",
25
+ bgMain: "#ffffff",
26
+ bgSidebar: "#f6f6f8",
27
+ bgElevated: "#ffffff",
28
+ bgHover: "#f0f1f3",
29
+ bgActive: "#e6e8eb",
30
+ textPrimary: "#1c1c1f",
31
+ textSecondary: "#5b5d66",
32
+ textTertiary: "#9a9ca6",
33
+ accent: "#2f6fed",
34
+ accentDim: "#e2ebfd",
35
+ border: "#e6e7eb"
36
+ }
37
+ },
38
+ {
39
+ id: "light-mint",
40
+ name: "Mint Morning",
41
+ base: "light",
42
+ colors: {
43
+ bgRoot: "#f6fbf8",
44
+ bgMain: "#f6fbf8",
45
+ bgSidebar: "#eef7f1",
46
+ bgElevated: "#ffffff",
47
+ bgHover: "#e6f3ea",
48
+ bgActive: "#d8ecdf",
49
+ textPrimary: "#173226",
50
+ textSecondary: "#4d6b5b",
51
+ textTertiary: "#93ab9d",
52
+ accent: "#0e9f6e",
53
+ accentDim: "#d9f4e6",
54
+ border: "#dcece2"
55
+ }
56
+ },
57
+ {
58
+ id: "light-peach",
59
+ name: "Peach Cream",
60
+ base: "light",
61
+ colors: {
62
+ bgRoot: "#fff8f6",
63
+ bgMain: "#fff8f6",
64
+ bgSidebar: "#fdeeec",
65
+ bgElevated: "#ffffff",
66
+ bgHover: "#fbe3e0",
67
+ bgActive: "#f7d2ce",
68
+ textPrimary: "#3a1f1d",
69
+ textSecondary: "#7a4e49",
70
+ textTertiary: "#b78d87",
71
+ accent: "#e0527a",
72
+ accentDim: "#fbdce6",
73
+ border: "#f3ddd8"
74
+ }
75
+ },
76
+ {
77
+ id: "dark-graphite",
78
+ name: "Graphite",
79
+ base: "dark",
80
+ colors: {
81
+ bgRoot: "#17181b",
82
+ bgMain: "#1b1c20",
83
+ bgSidebar: "#131417",
84
+ bgElevated: "#222327",
85
+ bgHover: "#28292e",
86
+ bgActive: "#313239",
87
+ textPrimary: "#f2f2f4",
88
+ textSecondary: "#a7a8b2",
89
+ textTertiary: "#6c6d78",
90
+ accent: "#6366f1",
91
+ accentDim: "#26264a",
92
+ border: "#2b2c31"
93
+ }
94
+ },
95
+ {
96
+ id: "dark-berry",
97
+ name: "Midnight Berry",
98
+ base: "dark",
99
+ colors: {
100
+ bgRoot: "#180f16",
101
+ bgMain: "#1d1219",
102
+ bgSidebar: "#140c12",
103
+ bgElevated: "#25151f",
104
+ bgHover: "#2c1a25",
105
+ bgActive: "#38212e",
106
+ textPrimary: "#f6eef2",
107
+ textSecondary: "#c39cae",
108
+ textTertiary: "#8a6577",
109
+ accent: "#e02a8b",
110
+ accentDim: "#3d1530",
111
+ border: "#301c29"
112
+ }
113
+ },
114
+ {
115
+ id: "dark-teal",
116
+ name: "Deep Teal",
117
+ base: "dark",
118
+ colors: {
119
+ bgRoot: "#0d1a1c",
120
+ bgMain: "#0f2124",
121
+ bgSidebar: "#0a1517",
122
+ bgElevated: "#15292c",
123
+ bgHover: "#1a3134",
124
+ bgActive: "#20393c",
125
+ textPrimary: "#eaf5f4",
126
+ textSecondary: "#9fc0bf",
127
+ textTertiary: "#5f8583",
128
+ accent: "#0d9488",
129
+ accentDim: "#0f3a35",
130
+ border: "#1c3335"
131
+ }
132
+ }
133
+ ];
134
+ var BUILTIN_BY_ID = new Map(BUILTIN_SKINS.map((s) => [s.id, s]));
135
+ function tr(ctx, key, fallback, vars) {
136
+ return ctx.i18n.has(key) ? ctx.i18n.t(key, vars) : fallback;
137
+ }
138
+ function builtinSkinName(ctx, skin) {
139
+ return tr(ctx, `skin.builtin.${skin.id}`, skin.name);
140
+ }
141
+ async function loadCustomSkins(ctx) {
142
+ return await ctx.storage.get(KEY_CUSTOM_SKINS) ?? [];
143
+ }
144
+ async function saveCustomSkins(ctx, skins) {
145
+ await ctx.storage.set(KEY_CUSTOM_SKINS, skins);
146
+ }
147
+ async function loadBackground(ctx) {
148
+ return await ctx.storage.get(KEY_BACKGROUND) ?? {
149
+ placement: "fill",
150
+ tone: "balanced"
151
+ };
152
+ }
153
+ async function saveBackground(ctx, cfg) {
154
+ await ctx.storage.set(KEY_BACKGROUND, cfg);
155
+ }
156
+ var BACKGROUNDS_DIR_NAME = "backgrounds";
157
+ var MAX_DROPPED_IMAGE_BYTES = 15 * 1024 * 1024;
158
+ var IMAGE_MIME_EXTENSIONS = {
159
+ "image/png": "png",
160
+ "image/jpeg": "jpg",
161
+ "image/jpg": "jpg",
162
+ "image/webp": "webp",
163
+ "image/gif": "gif",
164
+ "image/avif": "avif"
165
+ };
166
+ function backgroundsDir(ctx) {
167
+ return join(ctx.storagePath, BACKGROUNDS_DIR_NAME);
168
+ }
169
+ function isManagedBackgroundPath(ctx, imagePath) {
170
+ if (!imagePath) return false;
171
+ const dir = backgroundsDir(ctx) + sep;
172
+ return imagePath.startsWith(dir);
173
+ }
174
+ async function cleanupManagedBackground(ctx, imagePath) {
175
+ if (!isManagedBackgroundPath(ctx, imagePath)) return;
176
+ try {
177
+ await unlink(imagePath);
178
+ } catch {
179
+ }
180
+ }
181
+ function sanitizeDroppedFileName(name) {
182
+ const base = (name ?? "background").replace(/\.[^./\\]+$/, "");
183
+ const cleaned = base.replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]+/g, "-").slice(0, 60);
184
+ return cleaned || "background";
185
+ }
186
+ async function saveDroppedImage(ctx, dataUrl, originalName) {
187
+ const match = /^data:([a-zA-Z0-9.+/-]+);base64,(.+)$/.exec(dataUrl);
188
+ if (!match) {
189
+ throw new Error(tr(ctx, "panel.error.dropInvalidType", "Please drop an image file."));
190
+ }
191
+ const mime = match[1].toLowerCase();
192
+ const ext = IMAGE_MIME_EXTENSIONS[mime];
193
+ if (!ext) {
194
+ throw new Error(tr(ctx, "panel.error.dropInvalidType", "Please drop an image file."));
195
+ }
196
+ const base64 = match[2];
197
+ const approxBytes = base64.length * 3 / 4;
198
+ if (approxBytes > MAX_DROPPED_IMAGE_BYTES) {
199
+ throw new Error(tr(ctx, "panel.error.dropTooLarge", "Image is too large (max 15MB)."));
200
+ }
201
+ const buffer = Buffer.from(base64, "base64");
202
+ const dir = backgroundsDir(ctx);
203
+ await mkdir(dir, { recursive: true });
204
+ const fileName = `${Date.now()}-${sanitizeDroppedFileName(originalName)}.${ext}`;
205
+ const filePath = join(dir, fileName);
206
+ await writeFile(filePath, buffer);
207
+ return filePath;
208
+ }
209
+ async function loadLastApplied(ctx) {
210
+ return await ctx.storage.get(KEY_LAST_APPLIED) ?? void 0;
211
+ }
212
+ async function saveLastApplied(ctx, applied) {
213
+ await ctx.storage.set(KEY_LAST_APPLIED, applied);
214
+ }
215
+ async function loadActiveMode(ctx) {
216
+ return await ctx.storage.get(KEY_ACTIVE_MODE) ?? void 0;
217
+ }
218
+ async function saveActiveMode(ctx, mode) {
219
+ await ctx.storage.set(KEY_ACTIVE_MODE, mode);
220
+ }
221
+ async function resolveSkinById(ctx, id) {
222
+ const builtin = BUILTIN_BY_ID.get(id);
223
+ if (builtin) {
224
+ return { id: builtin.id, name: builtinSkinName(ctx, builtin), base: builtin.base, colors: builtin.colors, isCustom: false };
225
+ }
226
+ const custom = (await loadCustomSkins(ctx)).find((s) => s.id === id);
227
+ if (custom) {
228
+ return { id: custom.id, name: custom.name, base: custom.base, colors: custom.colors, isCustom: true };
229
+ }
230
+ return void 0;
231
+ }
232
+ async function applySkin(ctx, skin) {
233
+ await getAppearance(ctx).setTheme({
234
+ customTheme: { name: skin.name, base: skin.base, colors: skin.colors }
235
+ });
236
+ await saveLastApplied(ctx, {
237
+ id: skin.id,
238
+ name: skin.name,
239
+ base: skin.base,
240
+ colors: skin.colors,
241
+ appliedAt: Date.now()
242
+ });
243
+ await saveActiveMode(ctx, { mode: "skin" });
244
+ }
245
+ async function applySystemTheme(ctx, theme) {
246
+ await getAppearance(ctx).setTheme({ theme });
247
+ await saveActiveMode(ctx, { mode: "system", systemTheme: theme });
248
+ }
249
+ function sanitizeColors(input) {
250
+ if (!input || typeof input !== "object") return {};
251
+ const allowedKeys = [
252
+ "bgRoot",
253
+ "bgMain",
254
+ "bgSidebar",
255
+ "bgElevated",
256
+ "bgHover",
257
+ "bgActive",
258
+ "textPrimary",
259
+ "textSecondary",
260
+ "textTertiary",
261
+ "accent",
262
+ "accentDim",
263
+ "border"
264
+ ];
265
+ const out = {};
266
+ const src = input;
267
+ for (const key of allowedKeys) {
268
+ const v = src[key];
269
+ if (typeof v === "string" && v.trim()) out[key] = v.trim();
270
+ }
271
+ return out;
272
+ }
273
+ async function buildStatePayload(ctx, panel) {
274
+ const [customSkins, background, lastApplied, activeMode] = await Promise.all([
275
+ loadCustomSkins(ctx),
276
+ loadBackground(ctx),
277
+ loadLastApplied(ctx),
278
+ loadActiveMode(ctx)
279
+ ]);
280
+ const builtin = BUILTIN_SKINS.map((s) => ({ id: s.id, name: builtinSkinName(ctx, s), base: s.base, colors: s.colors }));
281
+ return {
282
+ type: "state",
283
+ builtin,
284
+ custom: customSkins,
285
+ background,
286
+ lastAppliedId: lastApplied?.id,
287
+ activeMode: activeMode ?? null,
288
+ env: {
289
+ sessionId: panel.sessionId ?? "",
290
+ view: panel.view ?? "",
291
+ spaceId: panel.spaceId ?? "",
292
+ spaceName: panel.spaceName ?? "",
293
+ locale: ctx.i18n.locale
294
+ }
295
+ };
296
+ }
297
+ var livePanels = /* @__PURE__ */ new Set();
298
+ var boundPanels = /* @__PURE__ */ new WeakSet();
299
+ async function broadcastState(ctx) {
300
+ for (const panel of livePanels) {
301
+ try {
302
+ await panel.postMessage(await buildStatePayload(ctx, panel));
303
+ } catch (err) {
304
+ ctx.logger.warn(`Failed to push state to panel ${panel.id}: ${String(err)}`);
305
+ }
306
+ }
307
+ }
308
+ async function handlePanelMessage(ctx, panel, message) {
309
+ const msg = message;
310
+ switch (msg.type) {
311
+ case "requestState": {
312
+ await panel.postMessage(await buildStatePayload(ctx, panel));
313
+ break;
314
+ }
315
+ case "applySkin": {
316
+ if (!msg.id) break;
317
+ const resolved = await resolveSkinById(ctx, msg.id);
318
+ if (!resolved) {
319
+ await panel.postMessage({ type: "error", message: tr(ctx, "panel.error.skinNotFound", "Skin not found.") });
320
+ break;
321
+ }
322
+ try {
323
+ await applySkin(ctx, resolved);
324
+ await broadcastState(ctx);
325
+ } catch (err) {
326
+ await panel.postMessage({ type: "error", message: err instanceof Error ? err.message : String(err) });
327
+ }
328
+ break;
329
+ }
330
+ case "removeSkin": {
331
+ if (!msg.id) break;
332
+ const all = await loadCustomSkins(ctx);
333
+ const filtered = all.filter((s) => s.id !== msg.id);
334
+ if (filtered.length === all.length) break;
335
+ await saveCustomSkins(ctx, filtered);
336
+ await broadcastState(ctx);
337
+ break;
338
+ }
339
+ case "requestSaveCurrent": {
340
+ const lastApplied = await loadLastApplied(ctx);
341
+ if (!lastApplied) {
342
+ await panel.postMessage({
343
+ type: "error",
344
+ message: tr(
345
+ ctx,
346
+ "panel.error.noCurrentSkin",
347
+ "No applied skin to save yet \u2014 apply a preset or an AI-designed skin first."
348
+ )
349
+ });
350
+ break;
351
+ }
352
+ const result = await ctx.ui.showModalDialog({
353
+ title: tr(ctx, "modal.saveSkin.title", "Save current skin"),
354
+ description: tr(
355
+ ctx,
356
+ "modal.saveSkin.description",
357
+ "Save the currently applied colors as a custom skin you can switch to later."
358
+ ),
359
+ fields: [
360
+ {
361
+ key: "name",
362
+ label: tr(ctx, "modal.saveSkin.nameLabel", "Skin name"),
363
+ type: "text",
364
+ required: true,
365
+ default: lastApplied.name,
366
+ placeholder: tr(ctx, "modal.saveSkin.namePlaceholder", "My Custom Skin")
367
+ }
368
+ ],
369
+ actions: [
370
+ { id: "cancel", label: tr(ctx, "modal.cancel", "Cancel") },
371
+ { id: "save", label: tr(ctx, "modal.save", "Save"), variant: "primary" }
372
+ ]
373
+ });
374
+ if (result.action !== "save") break;
375
+ const name = String(result.values?.name ?? "").trim() || lastApplied.name;
376
+ const custom = await loadCustomSkins(ctx);
377
+ const entry = {
378
+ id: randomUUID(),
379
+ name,
380
+ base: lastApplied.base,
381
+ colors: lastApplied.colors,
382
+ createdAt: Date.now()
383
+ };
384
+ custom.push(entry);
385
+ await saveCustomSkins(ctx, custom);
386
+ await broadcastState(ctx);
387
+ break;
388
+ }
389
+ case "requestImportSkin": {
390
+ const result = await ctx.ui.showModalDialog({
391
+ title: tr(ctx, "modal.importSkin.title", "Import skin"),
392
+ description: tr(
393
+ ctx,
394
+ "modal.importSkin.description",
395
+ "Paste a skin exported from Skin Studio to add it to your library."
396
+ ),
397
+ fields: [
398
+ {
399
+ key: "json",
400
+ label: tr(ctx, "modal.importSkin.jsonLabel", "Skin data"),
401
+ type: "textarea",
402
+ required: true,
403
+ placeholder: tr(ctx, "modal.importSkin.jsonPlaceholder", "Paste the copied skin JSON here\u2026")
404
+ }
405
+ ],
406
+ actions: [
407
+ { id: "cancel", label: tr(ctx, "modal.cancel", "Cancel") },
408
+ { id: "import", label: tr(ctx, "modal.import", "Import"), variant: "primary" }
409
+ ]
410
+ });
411
+ if (result.action !== "import") break;
412
+ const raw = String(result.values?.json ?? "").trim();
413
+ let parsed;
414
+ try {
415
+ parsed = JSON.parse(raw);
416
+ } catch {
417
+ await panel.postMessage({
418
+ type: "error",
419
+ message: tr(ctx, "panel.error.importInvalid", "This is not a valid skin export.")
420
+ });
421
+ break;
422
+ }
423
+ const record = parsed;
424
+ const importedName = typeof record.name === "string" ? record.name.trim() : "";
425
+ const importedBase = record.base === "dark" ? "dark" : "light";
426
+ const importedColors = sanitizeColors(record.colors);
427
+ if (!importedName || Object.keys(importedColors).length === 0) {
428
+ await panel.postMessage({
429
+ type: "error",
430
+ message: tr(ctx, "panel.error.importInvalid", "This is not a valid skin export.")
431
+ });
432
+ break;
433
+ }
434
+ const custom = await loadCustomSkins(ctx);
435
+ const entry = {
436
+ id: randomUUID(),
437
+ name: importedName,
438
+ base: importedBase,
439
+ colors: importedColors,
440
+ createdAt: Date.now()
441
+ };
442
+ custom.push(entry);
443
+ await saveCustomSkins(ctx, custom);
444
+ await broadcastState(ctx);
445
+ await panel.postMessage({ type: "toast", message: tr(ctx, "panel.skinImported", "Skin imported") });
446
+ break;
447
+ }
448
+ case "setSystemTheme": {
449
+ const theme = msg.theme === "light" || msg.theme === "dark" ? msg.theme : "system";
450
+ try {
451
+ await applySystemTheme(ctx, theme);
452
+ await broadcastState(ctx);
453
+ } catch (err) {
454
+ await panel.postMessage({ type: "error", message: err instanceof Error ? err.message : String(err) });
455
+ }
456
+ break;
457
+ }
458
+ case "uploadBackgroundImage": {
459
+ const dataUrl = typeof msg.dataUrl === "string" ? msg.dataUrl : "";
460
+ const originalName = typeof msg.name === "string" ? msg.name : void 0;
461
+ const cfg = await loadBackground(ctx);
462
+ try {
463
+ const imagePath = await saveDroppedImage(ctx, dataUrl, originalName);
464
+ const next = { ...cfg, imagePath };
465
+ await getAppearance(ctx).setHomeBackground({ imagePath, placement: next.placement, tone: next.tone });
466
+ await cleanupManagedBackground(ctx, cfg.imagePath);
467
+ await saveBackground(ctx, next);
468
+ await broadcastState(ctx);
469
+ } catch (err) {
470
+ await panel.postMessage({ type: "error", message: err instanceof Error ? err.message : String(err) });
471
+ }
472
+ break;
473
+ }
474
+ case "setBackgroundOptions": {
475
+ const cfg = await loadBackground(ctx);
476
+ const next = {
477
+ imagePath: cfg.imagePath,
478
+ placement: msg.placement ?? cfg.placement,
479
+ tone: msg.tone ?? cfg.tone
480
+ };
481
+ await saveBackground(ctx, next);
482
+ if (next.imagePath) {
483
+ try {
484
+ await getAppearance(ctx).setHomeBackground({ imagePath: next.imagePath, placement: next.placement, tone: next.tone });
485
+ } catch (err) {
486
+ await panel.postMessage({ type: "error", message: err instanceof Error ? err.message : String(err) });
487
+ break;
488
+ }
489
+ }
490
+ await broadcastState(ctx);
491
+ break;
492
+ }
493
+ case "clearBackground": {
494
+ try {
495
+ await getAppearance(ctx).setHomeBackground({ clear: true });
496
+ } catch (err) {
497
+ await panel.postMessage({ type: "error", message: err instanceof Error ? err.message : String(err) });
498
+ break;
499
+ }
500
+ const cfg = await loadBackground(ctx);
501
+ await cleanupManagedBackground(ctx, cfg.imagePath);
502
+ await saveBackground(ctx, { placement: "fill", tone: "balanced" });
503
+ await broadcastState(ctx);
504
+ break;
505
+ }
506
+ }
507
+ }
508
+ function text(message, isError = false) {
509
+ return { content: [{ type: "text", text: message }], isError };
510
+ }
511
+ function formatSkinList(builtin, custom) {
512
+ const lines = [];
513
+ lines.push("Built-in presets:");
514
+ for (const s of builtin) lines.push(` - ${s.id} [${s.base}] ${s.name}`);
515
+ lines.push(custom.length ? "Custom skins:" : "Custom skins: (none saved yet)");
516
+ for (const s of custom) lines.push(` - ${s.id} [${s.base}] ${s.name}`);
517
+ return lines.join("\n");
518
+ }
519
+ function registerThemeTool(ctx) {
520
+ return ctx.tools.register({
521
+ name: "skin_studio_theme",
522
+ title: "Skin Studio Theme",
523
+ description: `Design, apply, save, and remove Finch color skins. Changes take effect immediately, same as editing Appearance Settings.
524
+ action:
525
+ list \u2014 list built-in presets (3 light + 3 dark) and the user's saved custom skins.
526
+ apply \u2014 apply a skin. Pass id to apply a built-in preset or a saved custom skin. To design and apply a new AI-generated skin in one step, omit id and pass name+base+colors instead; add save:true to also persist it as a custom skin in the same call. To switch back to Finch's own built-in theme instead of a custom color skin, pass systemTheme ("system"|"light"|"dark") and omit id/name/colors.
527
+ save \u2014 persist a skin into the custom library. Pass name+base+colors to save a specific palette (e.g. one just designed by AI), or omit colors to save the most recently applied skin under a new name ("extract current skin").
528
+ remove \u2014 delete a custom skin by id (built-in presets cannot be removed).`,
529
+ inputSchema: {
530
+ type: "object",
531
+ properties: {
532
+ action: { type: "string", enum: ["list", "apply", "save", "remove"] },
533
+ id: { type: "string", description: "Built-in preset id or custom skin id. Required for remove; for apply, provide this, OR name+base+colors, OR systemTheme." },
534
+ systemTheme: {
535
+ type: "string",
536
+ enum: ["system", "light", "dark"],
537
+ description: "For action=apply only: switch back to Finch's own built-in light/dark/system theme instead of a custom color skin. Takes priority over id/name+base+colors when present."
538
+ },
539
+ name: { type: "string", description: "Skin display name. Required for save when colors is provided, and for an ad-hoc apply without id." },
540
+ base: { type: "string", enum: ["light", "dark"], description: "Base preset unspecified colors inherit from. Required alongside colors." },
541
+ colors: {
542
+ type: "object",
543
+ description: "Partial color map \u2014 only set what you want, rest inherit from base. Keys: bgRoot, bgMain, bgSidebar, bgElevated, bgHover, bgActive, textPrimary, textSecondary, textTertiary, accent (must stay dark/saturated enough for white text), accentDim, border.",
544
+ properties: {
545
+ bgRoot: { type: "string" },
546
+ bgMain: { type: "string" },
547
+ bgSidebar: { type: "string" },
548
+ bgElevated: { type: "string" },
549
+ bgHover: { type: "string" },
550
+ bgActive: { type: "string" },
551
+ textPrimary: { type: "string" },
552
+ textSecondary: { type: "string" },
553
+ textTertiary: { type: "string" },
554
+ accent: { type: "string" },
555
+ accentDim: { type: "string" },
556
+ border: { type: "string" }
557
+ }
558
+ },
559
+ save: { type: "boolean", description: "For action=apply with inline colors: also persist the applied skin into the custom library." }
560
+ },
561
+ required: ["action"]
562
+ },
563
+ risk: "medium",
564
+ async execute(input) {
565
+ const action = String(input.action ?? "");
566
+ switch (action) {
567
+ case "list": {
568
+ const builtin = BUILTIN_SKINS.map((s) => ({ ...s, name: builtinSkinName(ctx, s) }));
569
+ const custom = await loadCustomSkins(ctx);
570
+ return text(formatSkinList(builtin, custom));
571
+ }
572
+ case "apply": {
573
+ const systemTheme = input.systemTheme === "light" || input.systemTheme === "dark" || input.systemTheme === "system" ? input.systemTheme : void 0;
574
+ if (systemTheme) {
575
+ await applySystemTheme(ctx, systemTheme);
576
+ await broadcastState(ctx);
577
+ return text(`Switched to Finch's own built-in "${systemTheme}" theme.`);
578
+ }
579
+ const id = typeof input.id === "string" ? input.id.trim() : "";
580
+ if (id) {
581
+ const resolved = await resolveSkinById(ctx, id);
582
+ if (!resolved) return text(`No skin found with id "${id}". Use action=list to see available ids.`, true);
583
+ await applySkin(ctx, resolved);
584
+ await broadcastState(ctx);
585
+ return text(`Applied skin "${resolved.name}" (${id}).`);
586
+ }
587
+ const name = typeof input.name === "string" ? input.name.trim() : "";
588
+ const base = input.base === "dark" ? "dark" : input.base === "light" ? "light" : void 0;
589
+ if (!name || !base) return text('apply requires either "id", or "name"+"base"(+colors) for an ad-hoc skin.', true);
590
+ const colors = sanitizeColors(input.colors);
591
+ await applySkin(ctx, { name, base, colors });
592
+ let savedNote = "";
593
+ if (input.save === true) {
594
+ const custom = await loadCustomSkins(ctx);
595
+ const entry = { id: randomUUID(), name, base, colors, createdAt: Date.now() };
596
+ custom.push(entry);
597
+ await saveCustomSkins(ctx, custom);
598
+ savedNote = ` Saved to custom library as id ${entry.id}.`;
599
+ }
600
+ await broadcastState(ctx);
601
+ return text(`Applied ad-hoc skin "${name}" (${base}).${savedNote}`);
602
+ }
603
+ case "save": {
604
+ const name = typeof input.name === "string" ? input.name.trim() : "";
605
+ if (!name) return text('save requires "name".', true);
606
+ const hasColors = input.colors && typeof input.colors === "object" && Object.keys(input.colors).length > 0;
607
+ let base;
608
+ let colors;
609
+ if (hasColors) {
610
+ const b = input.base === "dark" ? "dark" : input.base === "light" ? "light" : void 0;
611
+ if (!b) return text('save with explicit colors also requires "base" ("light" or "dark").', true);
612
+ base = b;
613
+ colors = sanitizeColors(input.colors);
614
+ } else {
615
+ const lastApplied = await loadLastApplied(ctx);
616
+ if (!lastApplied) {
617
+ return text("No skin has been applied through Skin Studio yet, so there is nothing to extract. Pass base+colors explicitly, or apply a skin first.", true);
618
+ }
619
+ base = lastApplied.base;
620
+ colors = lastApplied.colors;
621
+ }
622
+ const custom = await loadCustomSkins(ctx);
623
+ const entry = { id: randomUUID(), name, base, colors, createdAt: Date.now() };
624
+ custom.push(entry);
625
+ await saveCustomSkins(ctx, custom);
626
+ await broadcastState(ctx);
627
+ return text(`Saved custom skin "${name}" with id ${entry.id}.`);
628
+ }
629
+ case "remove": {
630
+ const id = typeof input.id === "string" ? input.id.trim() : "";
631
+ if (!id) return text('remove requires "id".', true);
632
+ if (BUILTIN_BY_ID.has(id)) return text("Built-in presets cannot be removed.", true);
633
+ const all = await loadCustomSkins(ctx);
634
+ const found = all.find((s) => s.id === id);
635
+ if (!found) return text(`No custom skin found with id: ${id}`, true);
636
+ await saveCustomSkins(ctx, all.filter((s) => s.id !== id));
637
+ await broadcastState(ctx);
638
+ return text(`Removed custom skin "${found.name}" (${id}).`);
639
+ }
640
+ default:
641
+ return text(`Unknown action: ${action}`, true);
642
+ }
643
+ }
644
+ });
645
+ }
646
+ function registerBackgroundTool(ctx) {
647
+ return ctx.tools.register({
648
+ name: "skin_studio_background",
649
+ title: "Skin Studio Background",
650
+ description: `Set or clear Finch's Home background image. Changes take effect immediately, same as editing Appearance Settings.
651
+ action:
652
+ set \u2014 requires imagePath (absolute local path to a PNG/JPEG/WebP/GIF/AVIF file). Optional placement ("fill"|"tile", default "fill") and tone ("brightest"|"bright"|"balanced"|"dark"|"darkest", default "balanced").
653
+ clear \u2014 remove the Home background and revert to the plain theme surface.`,
654
+ inputSchema: {
655
+ type: "object",
656
+ properties: {
657
+ action: { type: "string", enum: ["set", "clear"] },
658
+ imagePath: { type: "string", description: "Absolute local image path. Required for action=set." },
659
+ placement: { type: "string", enum: ["fill", "tile"], description: 'Default "fill".' },
660
+ tone: { type: "string", enum: ["brightest", "bright", "balanced", "dark", "darkest"], description: 'Default "balanced".' }
661
+ },
662
+ required: ["action"]
663
+ },
664
+ risk: "medium",
665
+ async execute(input) {
666
+ const action = String(input.action ?? "");
667
+ if (action === "set") {
668
+ const imagePath = typeof input.imagePath === "string" ? input.imagePath.trim() : "";
669
+ if (!imagePath) return text('set requires "imagePath" (absolute local path).', true);
670
+ const placement = input.placement === "tile" ? "tile" : "fill";
671
+ const tone = input.tone === "brightest" || input.tone === "bright" || input.tone === "dark" || input.tone === "darkest" ? input.tone : "balanced";
672
+ try {
673
+ await getAppearance(ctx).setHomeBackground({ imagePath, placement, tone });
674
+ await saveBackground(ctx, { imagePath, placement, tone });
675
+ await broadcastState(ctx);
676
+ return text(`Home background set (${placement}, ${tone}): ${imagePath}`);
677
+ } catch (err) {
678
+ return text(`Failed to set Home background: ${err instanceof Error ? err.message : String(err)}`, true);
679
+ }
680
+ }
681
+ if (action === "clear") {
682
+ try {
683
+ await getAppearance(ctx).setHomeBackground({ clear: true });
684
+ await saveBackground(ctx, { placement: "fill", tone: "balanced" });
685
+ await broadcastState(ctx);
686
+ return text("Home background cleared.");
687
+ } catch (err) {
688
+ return text(`Failed to clear Home background: ${err instanceof Error ? err.message : String(err)}`, true);
689
+ }
690
+ }
691
+ return text(`Unknown action: ${action}`, true);
692
+ }
693
+ });
694
+ }
695
+ function activate(ctx) {
696
+ ctx.subscriptions.push(registerThemeTool(ctx));
697
+ ctx.subscriptions.push(registerBackgroundTool(ctx));
698
+ ctx.subscriptions.push(
699
+ ctx.ui.onDidOpenPanel((panel) => {
700
+ livePanels.add(panel);
701
+ if (!boundPanels.has(panel)) {
702
+ boundPanels.add(panel);
703
+ ctx.subscriptions.push(
704
+ panel.onDidDispose(() => livePanels.delete(panel)),
705
+ panel.onDidReceiveMessage((msg) => handlePanelMessage(ctx, panel, msg))
706
+ );
707
+ }
708
+ buildStatePayload(ctx, panel).then((payload) => panel.postMessage(payload));
709
+ })
710
+ );
711
+ ctx.subscriptions.push(
712
+ ctx.composerActions.register("skin-studio-open", {
713
+ async onClick() {
714
+ const panel = ctx.ui.createPanel({ instanceMode: "single" });
715
+ await panel.reveal();
716
+ }
717
+ })
718
+ );
719
+ ctx.logger.info("finch-skin-studio activated");
720
+ }
721
+ export {
722
+ activate
723
+ };