@glasshome/widget-cli 0.14.6 → 0.15.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/bin/cli.ts CHANGED
@@ -23,7 +23,7 @@ glasshome-widget <command> [--dir <path>] [options]
23
23
 
24
24
  Commands:
25
25
  create Create a new widget project (default when no project found)
26
- add Add a new widget to the project
26
+ add Add a new widget to the project (prompts, or --name <widget> [--description <text>])
27
27
  build Build all widgets (per-widget, self-contained bundles)
28
28
  connect <url> Connect to a running dashboard for live testing
29
29
  --re-auth discards stored credentials and re-runs device approval
@@ -49,6 +49,7 @@ Examples:
49
49
  glasshome-widget Create a new widget project
50
50
  glasshome-widget create Create a new widget project
51
51
  glasshome-widget add
52
+ glasshome-widget add --name plant --description "How thirsty the plant is"
52
53
  glasshome-widget build
53
54
  glasshome-widget connect http://localhost:3333
54
55
  glasshome-widget preview
@@ -76,6 +77,7 @@ const { values: flags, positionals } = parseArgs({
76
77
  options: {
77
78
  dir: { type: "string" },
78
79
  name: { type: "string" },
80
+ description: { type: "string" },
79
81
  bump: { type: "string" },
80
82
  scope: { type: "string" },
81
83
  to: { type: "string" },
@@ -132,7 +134,10 @@ switch (effectiveCommand) {
132
134
 
133
135
  case "add": {
134
136
  const { runAdd } = await import("../src/commands/add");
135
- await runAdd(resolveWidgetDir());
137
+ await runAdd(resolveWidgetDir(), {
138
+ name: str(flags.name),
139
+ description: str(flags.description),
140
+ });
136
141
  outro("Done");
137
142
  break;
138
143
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.14.6",
3
+ "version": "0.15.0",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "keywords": [
6
6
  "cli",
@@ -47,11 +47,12 @@
47
47
  "@fontsource-variable/geist-mono": "^5.2.8",
48
48
  "@glasshome/sync-layer": "0.11.0",
49
49
  "@glasshome/widget-contract": "0.3.0",
50
- "@glasshome/widget-sdk": "1.18.1",
50
+ "@glasshome/widget-sdk": "1.19.0",
51
51
  "@iconify-json/lucide": "^1.2.0",
52
52
  "@iconify-json/mdi": "^1.2.3",
53
53
  "@iconify/utils": "^3.1.4",
54
54
  "@tailwindcss/vite": "^4.1.13",
55
+ "iconify-icon": "^3.0.2",
55
56
  "open": "^11.0.0",
56
57
  "picocolors": "^1.1.1",
57
58
  "semver": "^7.6.0",
@@ -45,17 +45,15 @@ export function scaffoldWidget(
45
45
  srcContent = srcContent.replace(/SDK_RANGE/g, sdkRange);
46
46
  writeFileSync(resolve(widgetDir, "index.tsx"), srcContent);
47
47
 
48
- // Generate manifest.json. `capabilities` is required for any SDK >= 1.0.0
49
- // range and an empty array is the honest default: a fresh widget reads
50
- // nothing from Home Assistant, and the declaration is what consent shows.
48
+ // Matches the template's inline manifest: it reads one sensor.
51
49
  const manifest = {
52
50
  name: displayName,
53
51
  description: widgetDescription,
54
52
  minSize: { w: 1, h: 1 },
55
- maxSize: { w: 4, h: 4 },
53
+ maxSize: { w: 8, h: 8 },
56
54
  defaultSize: { w: 2, h: 2 },
57
55
  sdkVersion: sdkRange,
58
- capabilities: [],
56
+ capabilities: [{ domain: "sensor", access: "read" }],
59
57
  version: "0.1.0",
60
58
  };
61
59
  writeFileSync(resolve(widgetDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -99,7 +97,10 @@ export async function promptWidgetDetails(defaults?: { widgetName?: string }): P
99
97
  };
100
98
  }
101
99
 
102
- export async function runAdd(cwd: string): Promise<void> {
100
+ export async function runAdd(
101
+ cwd: string,
102
+ flags: { name?: string; description?: string } = {},
103
+ ): Promise<void> {
103
104
  // Verify project structure
104
105
  const srcDir = resolve(cwd, "src");
105
106
  if (!existsSync(srcDir)) {
@@ -107,8 +108,14 @@ export async function runAdd(cwd: string): Promise<void> {
107
108
  process.exit(1);
108
109
  }
109
110
 
110
- const details = await promptWidgetDetails();
111
+ const details = flags.name
112
+ ? { widgetName: flags.name, description: flags.description ?? "" }
113
+ : await promptWidgetDetails();
111
114
  if (!details) process.exit(0);
115
+ if (!/^[a-z][a-z0-9-]*$/.test(details.widgetName)) {
116
+ log.error("--name must be lowercase letters, digits and hyphens, starting with a letter.");
117
+ process.exit(1);
118
+ }
112
119
 
113
120
  const widgetDir = resolve(srcDir, details.widgetName);
114
121
  if (existsSync(widgetDir)) {
@@ -11,6 +11,7 @@ import {
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { cancel, confirm, log, note, spinner, text } from "@clack/prompts";
13
13
  import color from "picocolors";
14
+ import { writeAgentStubs } from "../utils/agent-guide";
14
15
  import { cancelled } from "../utils/prompt";
15
16
  import { defaultSdkRange, FALLBACK_SDK_RANGE } from "../utils/sdk-version";
16
17
  import { getCliVersion } from "../utils/version";
@@ -140,6 +141,7 @@ export async function runCreate() {
140
141
 
141
142
  // Create the first widget in src/{widgetName}/
142
143
  scaffoldWidget(targetDir, widgetDetails);
144
+ writeAgentStubs(targetDir);
143
145
 
144
146
  s.stop("Widget project created!");
145
147
 
@@ -1,7 +1,6 @@
1
1
  import { log } from "@clack/prompts";
2
2
  import color from "picocolors";
3
- import { capturePreviews } from "../preview/capture";
4
- import { type Box, DEFAULT_SIZES, runSweep, type Theme } from "../preview/sweep";
3
+ import type { Box, Theme } from "../preview/sweep";
5
4
  import { withQuietStdout } from "../utils/quiet";
6
5
 
7
6
  /**
@@ -9,9 +8,7 @@ import { withQuietStdout } from "../utils/quiet";
9
8
  * `<project>/preview/`, rendered through the same constraints as the hub's
10
9
  * render worker: frozen clock, DNS blackhole, per-render timeout, hash pin.
11
10
  *
12
- * Playwright is an OPTIONAL peer — the CLI stays Chromium-free by default — so
13
- * detect it first and give an actionable install hint rather than a raw
14
- * module-not-found when it is absent.
11
+ * Playwright is an optional peer: check for it before importing anything that loads it.
15
12
  */
16
13
  async function requirePlaywright(): Promise<void> {
17
14
  try {
@@ -31,6 +28,7 @@ const progress = (m: string) => {
31
28
 
32
29
  export async function runPreview(cwd: string, names: string[], isolate: boolean): Promise<void> {
33
30
  await requirePlaywright();
31
+ const { capturePreviews } = await import("../preview/capture");
34
32
 
35
33
  log.info(names.length ? `Previewing ${names.join(", ")}` : "Previewing all widgets");
36
34
 
@@ -46,15 +44,16 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
46
44
 
47
45
  const hangs = summary.failures.filter((f) => f.kind === "hang");
48
46
  const integrity = summary.failures.filter((f) => f.kind === "integrity");
47
+ const crashes = summary.failures.filter((f) => f.kind === "crash");
49
48
  const networkWidgets = [
50
49
  ...new Set(summary.failures.filter((f) => f.kind === "network").map((f) => f.widget)),
51
50
  ];
52
- const attempted = summary.shots + hangs.length;
51
+ const attempted = summary.shots + hangs.length + crashes.length;
53
52
 
54
53
  // Headline: plain count of what landed in preview/. A miss only matters if a
55
54
  // render could not complete (hangs) or a bundle changed under us (integrity).
56
55
  const out = color.cyan("preview/");
57
- if (hangs.length || integrity.length) {
56
+ if (hangs.length || integrity.length || crashes.length) {
58
57
  log.warn(`Rendered ${summary.shots} of ${attempted} previews into ${out}`);
59
58
  } else {
60
59
  log.success(`Rendered ${summary.shots} previews (light + dark) into ${out}`);
@@ -64,6 +63,13 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
64
63
  log.message(color.dim(`No examples to render: ${summary.skipped.join(", ")}`));
65
64
  }
66
65
 
66
+ if (crashes.length) {
67
+ log.error(
68
+ `Widget crashed (no image written):\n` +
69
+ crashes.map((f) => ` · ${f.widget} ${f.detail}`).join("\n"),
70
+ );
71
+ }
72
+
67
73
  // Real miss: a render still too slow after one retry. Its slot is left without
68
74
  // a PNG; everything else rendered.
69
75
  if (hangs.length) {
@@ -97,7 +103,7 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
97
103
  // Vite's dev server leaves live handles behind (file watchers, keep-alive
98
104
  // sockets from browsers that crashed mid-render), so the process would sit
99
105
  // idle forever after the verdict instead of exiting. Leave deliberately.
100
- process.exit(hangs.length || integrity.length ? 1 : 0);
106
+ process.exit(hangs.length || integrity.length || crashes.length ? 1 : 0);
101
107
  }
102
108
 
103
109
  export interface SweepFlags {
@@ -120,9 +126,9 @@ function fail(message: string): never {
120
126
  process.exit(1);
121
127
  }
122
128
 
123
- function parseSizes(value: string | boolean | undefined): Box[] {
129
+ function parseSizes(value: string | boolean | undefined, grid: Box[]): Box[] {
124
130
  if (value === undefined) return [];
125
- if (value === "grid") return DEFAULT_SIZES;
131
+ if (value === "grid") return grid;
126
132
  return String(value)
127
133
  .split(",")
128
134
  .map((s) => {
@@ -163,6 +169,7 @@ export async function runSweepPreview(
163
169
  flags: SweepFlags,
164
170
  ): Promise<void> {
165
171
  await requirePlaywright();
172
+ const { DEFAULT_SIZES, runSweep } = await import("../preview/sweep");
166
173
  const at = flags.at ? new Date(flags.at) : undefined;
167
174
  if (at && Number.isNaN(at.getTime()))
168
175
  fail(`--at takes an ISO time like 2026-06-15T21:00:00 (got "${flags.at}")`);
@@ -176,7 +183,7 @@ export async function runSweepPreview(
176
183
  only: names,
177
184
  themes: parseThemes(flags.theme),
178
185
  examples,
179
- sizes: parseSizes(flags.sizes),
186
+ sizes: parseSizes(flags.sizes, DEFAULT_SIZES),
180
187
  at,
181
188
  config: parseConfig(flags.config),
182
189
  services: flags.service ?? [],
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { log, spinner } from "@clack/prompts";
4
+ import { installedGuide, writeAgentStubs } from "../utils/agent-guide";
4
5
  import { discoverWidgets, readManifest, writeManifest } from "../utils/manifest";
5
6
  import { getInstalledSdkVersion } from "../utils/sdk-version";
6
7
  import { runValidate } from "./validate";
@@ -42,6 +43,18 @@ function findWorkspaceSdkVersion(monorepoRoot: string): string | null {
42
43
  return null;
43
44
  }
44
45
 
46
+ function refreshAgentStubs(cwd: string): void {
47
+ const { skill, agents } = writeAgentStubs(cwd);
48
+ if (skill !== "kept" || agents !== "kept") {
49
+ log.info("Pointed your coding agent at the SDK's widget guide (.claude/skills, AGENTS.md)");
50
+ }
51
+ if (!installedGuide(cwd)) {
52
+ log.warn(
53
+ "The installed @glasshome/widget-sdk has no widget guide yet; your agent needs SDK 1.19 or later.",
54
+ );
55
+ }
56
+ }
57
+
45
58
  /** Update sdkVersion in all manifest.json files. */
46
59
  function syncManifestSdkVersions(cwd: string, newVersion: string): void {
47
60
  const widgets = discoverWidgets(cwd);
@@ -136,6 +149,7 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions = {}): Pro
136
149
  log.success(`Already up to date with workspace SDK (${sdkVersion})`);
137
150
  // Still sync manifests in case they're out of date
138
151
  syncManifestSdkVersions(cwd, sdkVersion);
152
+ refreshAgentStubs(cwd);
139
153
  return;
140
154
  }
141
155
 
@@ -153,6 +167,7 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions = {}): Pro
153
167
 
154
168
  // Sync sdkVersion in all manifest.json files
155
169
  syncManifestSdkVersions(cwd, sdkVersion);
170
+ refreshAgentStubs(cwd);
156
171
 
157
172
  // Run validate to check compatibility
158
173
  log.info("Checking compatibility...");
@@ -176,6 +191,7 @@ export async function runUpgrade(cwd: string, options: UpgradeOptions = {}): Pro
176
191
  return;
177
192
  }
178
193
  syncManifestSdkVersions(cwd, installed);
194
+ refreshAgentStubs(cwd);
179
195
  const valid = await runValidate(cwd);
180
196
  if (valid) {
181
197
  log.success(`Upgraded to @glasshome/widget-sdk@${installed}`);
@@ -5,6 +5,8 @@ import {
5
5
  hashWidgetArtifacts,
6
6
  settleAnimations,
7
7
  type SharedBrowser,
8
+ WidgetCrash,
9
+ assertNoCrash,
8
10
  watchEgress,
9
11
  withFreshBrowser,
10
12
  withRenderTimeout,
@@ -21,7 +23,7 @@ interface ShotListEntry {
21
23
 
22
24
  interface Failure {
23
25
  widget: string;
24
- kind: "network" | "hang" | "integrity";
26
+ kind: "network" | "hang" | "integrity" | "crash";
25
27
  detail: string;
26
28
  }
27
29
 
@@ -134,6 +136,7 @@ export async function capturePreviews(opts: PreviewOptions): Promise<PreviewSumm
134
136
  });
135
137
  // Fire rAF-gated mount animations before capturing.
136
138
  await settleAnimations(page);
139
+ await assertNoCrash(page);
137
140
  await page.locator("#stage").screenshot({ path: file, omitBackground: true });
138
141
  for (const a of lock.attempts) widgetAttempts.add(a);
139
142
  };
@@ -162,15 +165,23 @@ export async function capturePreviews(opts: PreviewOptions): Promise<PreviewSumm
162
165
  // browser is why it stalled) and give it one clean retry before
163
166
  // recording a miss.
164
167
  let ok = false;
165
- for (let tries = 0; tries < 2 && !ok; tries++) {
168
+ let crash: WidgetCrash | null = null;
169
+ for (let tries = 0; tries < 2 && !ok && !crash; tries++) {
166
170
  try {
167
171
  await attempt();
168
172
  ok = true;
169
- } catch {
170
- await shared?.recycle().catch(() => {});
173
+ } catch (err) {
174
+ if (err instanceof WidgetCrash) crash = err;
175
+ else await shared?.recycle().catch(() => {});
171
176
  }
172
177
  }
173
178
  if (ok) shot++;
179
+ else if (crash)
180
+ failures.push({
181
+ widget,
182
+ kind: "crash",
183
+ detail: `${label} (${theme}): ${crash.message}`,
184
+ });
174
185
  else failures.push({ widget, kind: "hang", detail: `${label} (${theme})` });
175
186
  }
176
187
  }
@@ -113,6 +113,14 @@ export async function freezeClock(page: Page): Promise<void> {
113
113
  }
114
114
 
115
115
  /** Advance the frozen clock enough to settle rAF-gated mount animations. */
116
+ export class WidgetCrash extends Error {}
117
+
118
+ /** Throws when the harness recorded a widget crash during mount. */
119
+ export async function assertNoCrash(page: Page): Promise<void> {
120
+ const crash = await page.evaluate(() => document.documentElement.dataset.harnessCrash);
121
+ if (crash) throw new WidgetCrash(`widget crashed: ${crash}`);
122
+ }
123
+
116
124
  export async function settleAnimations(page: Page, ms = 1_000): Promise<void> {
117
125
  await page.clock.runFor(ms);
118
126
  }
@@ -141,6 +141,14 @@ FROST_SHEET.replaceSync(
141
141
  const UI_SHEET = new CSSStyleSheet();
142
142
  UI_SHEET.replaceSync(uiCss);
143
143
 
144
+ /** Read by the capture driver, so a widget that throws fails the render instead of saving a blank shot. */
145
+ function reportCrash(err: unknown): void {
146
+ document.documentElement.dataset.harnessCrash =
147
+ err instanceof Error ? `${err.name}: ${err.message}` : String(err);
148
+ }
149
+ addEventListener("error", (e) => reportCrash(e.error ?? e.message));
150
+ addEventListener("unhandledrejection", (e) => reportCrash(e.reason));
151
+
144
152
  /** Mount via the shared SDK recipe (`@glasshome/widget-sdk/host`): closed shadow
145
153
  root, injected tokens, adopted widget CSS, WidgetCtx provider, `dark`
146
154
  mirrored onto the host. `dark` is the default `mirrorClasses`; the `dark`
@@ -158,7 +166,10 @@ function mount(
158
166
  ctx,
159
167
  cssText,
160
168
  extraSheets: [UI_SHEET, FROST_SHEET],
161
- onCrash: (err) => console.error("[harness] widget threw:", err),
169
+ onCrash: (err) => {
170
+ console.error("[harness] widget threw:", err);
171
+ reportCrash(err);
172
+ },
162
173
  });
163
174
  }
164
175
 
@@ -73,6 +73,8 @@ export async function serveHarness(
73
73
  // build and dash mount). tailwindcss() compiles @glasshome/ui/styles so the
74
74
  // app theme tokens land on :root exactly as they do in dash.
75
75
  plugins: [tailwindcss(), solid({ solid: { delegateEvents: false } })],
76
+ // ui resolves to its Solid source; prebundling would compile that JSX as React unless ui is a direct dependency.
77
+ optimizeDeps: { exclude: ["@glasshome/ui"] },
76
78
  server: { fs: { allow: [tempRoot, ...ancestors(projectDir)] } },
77
79
  });
78
80
 
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import type { Page } from "playwright";
4
4
  import { slug } from "./capture";
5
5
  import {
6
+ assertNoCrash,
6
7
  freezeClock,
7
8
  settleAnimations,
8
9
  watchEgress,
@@ -103,6 +104,7 @@ export async function runSweep(opts: SweepOptions): Promise<SweepSummary> {
103
104
  timeout: 20_000,
104
105
  });
105
106
  await settleAnimations(page);
107
+ await assertNoCrash(page);
106
108
  if (opts.click) {
107
109
  // Playwright's actionability checks wait on animation frames, which a frozen clock never runs.
108
110
  await page.clock.resume();
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { writeAgentStubs } from "./agent-guide";
6
+
7
+ const dirs: string[] = [];
8
+ function project(): string {
9
+ const dir = mkdtempSync(join(tmpdir(), "agent-guide-"));
10
+ dirs.push(dir);
11
+ return dir;
12
+ }
13
+ afterEach(() => {
14
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
15
+ });
16
+
17
+ const skillPath = (dir: string) => join(dir, ".claude/skills/glasshome-widget/SKILL.md");
18
+ const guide = "node_modules/@glasshome/widget-sdk/guide/widgets.md";
19
+
20
+ describe("writeAgentStubs", () => {
21
+ test("a fresh project gets a skill and an AGENTS.md that point at the SDK's guide", () => {
22
+ const dir = project();
23
+ expect(writeAgentStubs(dir)).toEqual({ skill: "written", agents: "written" });
24
+ expect(readFileSync(skillPath(dir), "utf-8")).toStartWith("---\nname: glasshome-widget\n");
25
+ expect(readFileSync(skillPath(dir), "utf-8")).toContain(guide);
26
+ expect(readFileSync(join(dir, "AGENTS.md"), "utf-8")).toContain(guide);
27
+ });
28
+
29
+ test("running again changes nothing", () => {
30
+ const dir = project();
31
+ writeAgentStubs(dir);
32
+ expect(writeAgentStubs(dir)).toEqual({ skill: "kept", agents: "kept" });
33
+ });
34
+
35
+ test("an author's AGENTS.md keeps its text and gains one block, replaced in place later", () => {
36
+ const dir = project();
37
+ writeFileSync(join(dir, "AGENTS.md"), "# Mine\n\nUse tabs.\n");
38
+ writeAgentStubs(dir);
39
+ const once = readFileSync(join(dir, "AGENTS.md"), "utf-8");
40
+ expect(once).toStartWith("# Mine\n\nUse tabs.\n\n<!-- glasshome-widget:start -->");
41
+
42
+ writeFileSync(
43
+ join(dir, "AGENTS.md"),
44
+ once.replace(
45
+ /start -->[\s\S]*<!-- glasshome-widget:end/,
46
+ "start -->\nold\n<!-- glasshome-widget:end",
47
+ ),
48
+ );
49
+ expect(writeAgentStubs(dir).agents).toBe("updated");
50
+ expect(readFileSync(join(dir, "AGENTS.md"), "utf-8")).toBe(once);
51
+ });
52
+
53
+ test("an AGENTS.md whose block lost its end marker is left alone", () => {
54
+ const dir = project();
55
+ const broken = "# Mine\n\n<!-- glasshome-widget:start -->\nhalf a block\n\nMore of mine.\n";
56
+ writeFileSync(join(dir, "AGENTS.md"), broken);
57
+ expect(writeAgentStubs(dir).agents).toBe("kept");
58
+ expect(readFileSync(join(dir, "AGENTS.md"), "utf-8")).toBe(broken);
59
+ });
60
+
61
+ test("a skill the author took over (marker removed) is left alone", () => {
62
+ const dir = project();
63
+ mkdirSync(join(dir, ".claude/skills/glasshome-widget"), { recursive: true });
64
+ writeFileSync(skillPath(dir), "my own skill\n");
65
+ expect(writeAgentStubs(dir).skill).toBe("kept");
66
+ expect(readFileSync(skillPath(dir), "utf-8")).toBe("my own skill\n");
67
+ });
68
+ });
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ const GUIDE_PATH = "node_modules/@glasshome/widget-sdk/guide/widgets.md";
6
+ const START = "<!-- glasshome-widget:start -->";
7
+ const END = "<!-- glasshome-widget:end -->";
8
+ const SKILL_FILE = ".claude/skills/glasshome-widget/SKILL.md";
9
+
10
+ const pointer = `Read \`${GUIDE_PATH}\` (or an ancestor's \`node_modules\`) before working on a widget. It matches the installed SDK; \`bun widget upgrade\` updates both.`;
11
+
12
+ const skill = `---
13
+ name: glasshome-widget
14
+ description: Use when building, styling, previewing or reviewing a GlassHome widget in this project.
15
+ ---
16
+
17
+ # GlassHome widgets
18
+
19
+ ${pointer}
20
+
21
+ ${START}
22
+ Managed by \`bun widget upgrade\`. Delete this block to own the file.
23
+ ${END}
24
+ `;
25
+
26
+ const agentsBlock = `${START}
27
+ ## GlassHome widgets
28
+
29
+ ${pointer}
30
+ ${END}`;
31
+
32
+ export type StubResult = "written" | "updated" | "kept";
33
+
34
+ /** The skill is ours while it still carries the marker; without it the author owns the file. */
35
+ function writeSkill(projectDir: string): StubResult {
36
+ const path = join(projectDir, SKILL_FILE);
37
+ if (!existsSync(path)) {
38
+ mkdirSync(dirname(path), { recursive: true });
39
+ writeFileSync(path, skill);
40
+ return "written";
41
+ }
42
+ const current = readFileSync(path, "utf-8");
43
+ if (!current.includes(START) || current === skill) return "kept";
44
+ writeFileSync(path, skill);
45
+ return "updated";
46
+ }
47
+
48
+ function writeAgents(projectDir: string): StubResult {
49
+ const path = join(projectDir, "AGENTS.md");
50
+ if (!existsSync(path)) {
51
+ writeFileSync(path, `${agentsBlock}\n`);
52
+ return "written";
53
+ }
54
+ const current = readFileSync(path, "utf-8");
55
+ const start = current.indexOf(START);
56
+ const end = current.indexOf(END);
57
+ if (start !== -1 && end < start) return "kept";
58
+ const next =
59
+ start !== -1
60
+ ? current.slice(0, start) + agentsBlock + current.slice(end + END.length)
61
+ : `${current.trimEnd()}\n\n${agentsBlock}\n`;
62
+ if (next === current) return "kept";
63
+ writeFileSync(path, next);
64
+ return "updated";
65
+ }
66
+
67
+ /** The guide the installed SDK ships, or null when the SDK predates it. */
68
+ export function installedGuide(projectDir: string): string | null {
69
+ try {
70
+ const require = createRequire(resolve(projectDir, "package.json"));
71
+ const guide = join(
72
+ dirname(require.resolve("@glasshome/widget-sdk/package.json")),
73
+ "guide/widgets.md",
74
+ );
75
+ return existsSync(guide) ? guide : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** Point coding agents (Claude skill, AGENTS.md) at the guide the installed SDK ships. */
82
+ export function writeAgentStubs(projectDir: string): { skill: StubResult; agents: StubResult } {
83
+ return { skill: writeSkill(projectDir), agents: writeAgents(projectDir) };
84
+ }
@@ -1,49 +1,122 @@
1
- import { defineWidget } from "@glasshome/widget-sdk";
2
- import { type Component, createSignal } from "solid-js";
1
+ import {
2
+ Button,
3
+ defineConfig,
4
+ defineWidget,
5
+ field,
6
+ Icon,
7
+ type Infer,
8
+ ResponsiveDialog,
9
+ ResponsiveDialogBody,
10
+ ResponsiveDialogContent,
11
+ ResponsiveDialogDescription,
12
+ ResponsiveDialogFooter,
13
+ ResponsiveDialogHeader,
14
+ ResponsiveDialogTitle,
15
+ SchemaForm,
16
+ Tabs,
17
+ TabsContent,
18
+ TabsList,
19
+ TabsTrigger,
20
+ useEntity,
21
+ useWidgetContext,
22
+ useWidgetDialog,
23
+ Widget,
24
+ WidgetDialog,
25
+ } from "@glasshome/widget-sdk";
26
+ import { Show } from "solid-js";
3
27
 
4
- interface WidgetConfig {
5
- label: string;
6
- }
28
+ const configSchema = defineConfig({
29
+ title: field.title(),
30
+ entityIds: field.entity("sensor"),
31
+ });
32
+
33
+ type WidgetConfig = Infer<typeof configSchema>;
34
+
35
+ const dialogParts = {
36
+ ResponsiveDialog,
37
+ ResponsiveDialogContent,
38
+ ResponsiveDialogHeader,
39
+ ResponsiveDialogTitle,
40
+ ResponsiveDialogDescription,
41
+ ResponsiveDialogBody,
42
+ ResponsiveDialogFooter,
43
+ Button,
44
+ SchemaForm,
45
+ Tabs,
46
+ TabsList,
47
+ TabsTrigger,
48
+ TabsContent,
49
+ } as const;
7
50
 
8
- const WIDGET_NAMEWidget: Component<{ config: WidgetConfig }> = (props) => {
9
- const [count, setCount] = createSignal(0);
51
+ function WIDGET_NAMEWidget(props: { config: WidgetConfig }) {
52
+ const ctx = useWidgetContext();
53
+ const { setShowDialog, dialogProps } = useWidgetDialog();
54
+ const entity = useEntity(() => props.config.entityIds[0] ?? "");
55
+
56
+ const name = () => props.config.title || entity()?.friendlyName || "WIDGET_NAME";
10
57
 
11
58
  return (
12
- <div class="flex h-full w-full flex-col items-center justify-center gap-3">
13
- <h3 class="text-sm font-medium text-muted-foreground">
14
- {props.config.label || "WIDGET_NAME"}
15
- </h3>
16
- <p class="text-4xl font-bold">{count()}</p>
17
- <button
18
- type="button"
19
- onClick={() => setCount((c) => c + 1)}
20
- class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
59
+ <>
60
+ <Widget
61
+ variant="classic-glass"
62
+ tone="info"
63
+ emptyState={
64
+ entity()
65
+ ? undefined
66
+ : {
67
+ icon: <Icon icon="mdi:gauge" width={32} />,
68
+ title: "No sensor",
69
+ message: "Hold to configure",
70
+ }
71
+ }
21
72
  >
22
- Increment
23
- </button>
24
- </div>
73
+ <Show when={entity()}>
74
+ {(e) => (
75
+ <Widget.Content>
76
+ <Widget.Glyph icon="mdi:gauge" />
77
+ <Widget.Head icon="mdi:gauge" name={name()} active />
78
+ <Widget.Hero value={e().state} unit={e().unitOfMeasurement ?? undefined} />
79
+ </Widget.Content>
80
+ )}
81
+ </Show>
82
+ </Widget>
83
+ <WidgetDialog
84
+ {...dialogParts}
85
+ {...dialogProps}
86
+ title="WIDGET_NAME"
87
+ configSchema={configSchema}
88
+ config={props.config}
89
+ onConfigSave={(config) => {
90
+ ctx.updateConfig(config);
91
+ setShowDialog(false);
92
+ }}
93
+ />
94
+ </>
25
95
  );
26
- };
96
+ }
27
97
 
28
98
  export default defineWidget<WidgetConfig>({
29
99
  manifest: {
30
100
  name: "WIDGET_NAME",
101
+ icon: "mdi:gauge",
31
102
  minSize: { w: 1, h: 1 },
32
- maxSize: { w: 4, h: 4 },
103
+ maxSize: { w: 8, h: 8 },
33
104
  defaultSize: { w: 2, h: 2 },
34
105
  sdkVersion: "SDK_RANGE",
35
- schema: {
36
- type: "object",
37
- properties: {
38
- label: {
39
- type: "string",
40
- title: "Widget Label",
41
- description: "Display label for the widget",
42
- default: "WIDGET_NAME",
43
- },
106
+ capabilities: [{ domain: "sensor", access: "read" }],
107
+ examples: [
108
+ {
109
+ label: "Living room",
110
+ size: { w: 2, h: 2 },
111
+ config: { title: "Living room", entityIds: ["sensor.temperature_living"] },
112
+ },
113
+ {
114
+ label: "Small",
115
+ size: { w: 1, h: 1 },
116
+ config: { entityIds: ["sensor.temperature_living"] },
44
117
  },
45
- },
46
- defaultConfig: { label: "WIDGET_NAME" },
118
+ ],
47
119
  },
120
+ configSchema,
48
121
  component: WIDGET_NAMEWidget,
49
122
  });