@glasshome/widget-cli 0.14.5 → 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 +7 -2
- package/package.json +4 -3
- package/src/commands/add.ts +14 -7
- package/src/commands/create.ts +2 -0
- package/src/commands/preview.ts +18 -11
- package/src/commands/upgrade.ts +16 -0
- package/src/preview/capture.ts +15 -4
- package/src/preview/constraints.ts +8 -0
- package/src/preview/harness/harness.tsx +12 -1
- package/src/preview/serve.ts +2 -0
- package/src/preview/sweep.ts +2 -0
- package/src/utils/agent-guide.test.ts +68 -0
- package/src/utils/agent-guide.ts +84 -0
- package/template/src/index.tsx.template +105 -32
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.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "CLI for creating and managing GlassHome widgets",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -45,13 +45,14 @@
|
|
|
45
45
|
"@fontsource-variable/caveat": "^5.2.8",
|
|
46
46
|
"@fontsource-variable/geist": "^5.2.9",
|
|
47
47
|
"@fontsource-variable/geist-mono": "^5.2.8",
|
|
48
|
-
"@glasshome/sync-layer": "0.
|
|
48
|
+
"@glasshome/sync-layer": "0.11.0",
|
|
49
49
|
"@glasshome/widget-contract": "0.3.0",
|
|
50
|
-
"@glasshome/widget-sdk": "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",
|
package/src/commands/add.ts
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
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(
|
|
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 =
|
|
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)) {
|
package/src/commands/create.ts
CHANGED
|
@@ -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
|
|
package/src/commands/preview.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { log } from "@clack/prompts";
|
|
2
2
|
import color from "picocolors";
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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 ?? [],
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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}`);
|
package/src/preview/capture.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
|
169
|
+
onCrash: (err) => {
|
|
170
|
+
console.error("[harness] widget threw:", err);
|
|
171
|
+
reportCrash(err);
|
|
172
|
+
},
|
|
162
173
|
});
|
|
163
174
|
}
|
|
164
175
|
|
package/src/preview/serve.ts
CHANGED
|
@@ -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
|
|
package/src/preview/sweep.ts
CHANGED
|
@@ -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 {
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
const
|
|
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
|
-
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:
|
|
103
|
+
maxSize: { w: 8, h: 8 },
|
|
33
104
|
defaultSize: { w: 2, h: 2 },
|
|
34
105
|
sdkVersion: "SDK_RANGE",
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
label:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
});
|