@ammduncan/easel 0.2.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/LICENCE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ammiel Yawson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # easel
2
+
3
+ A live browser tab for every Claude Code session. Agents push HTML — explanations, mockups, diagrams, diffs, comparisons — to a scrolling feed you keep open in split-screen. No more wall-of-text in the terminal.
4
+
5
+ ```
6
+ ┌──────────── Claude Code (left) ────────────┐ ┌────── easel (right) ──────────────┐
7
+ │ │ │ s/<session-id> • 3 pushes • live│
8
+ │ > walk me through the new auth flow │ │ ───────────────────────────────── │
9
+ │ │ │ #1 Auth flow overview │
10
+ │ pushed to display ↗ — #1 │ │ ┌────────────────────────────────┐│
11
+ │ │ │ │ Three actors talk to each… ││
12
+ │ > what could break? │ │ └────────────────────────────────┘│
13
+ │ │ │ │
14
+ │ pushed to display ↗ — #2 │ │ #2 Failure modes │
15
+ └────────────────────────────────────────────┘ └────────────────────────────────────┘
16
+ ```
17
+
18
+ ## Why
19
+
20
+ Long markdown explanations bury what the agent is actually doing. Visual content (mockups, comparisons, diagrams) is even worse in a TTY. `easel` gives each chat session its own browser tab, and a single MCP tool — `push` — that the agent uses proactively. The terminal stays as a conversation log; the browser carries the substance.
21
+
22
+ ## Install
23
+
24
+ Requires Node 20+, `git`, and Claude Code.
25
+
26
+ ```bash
27
+ curl -fsSL https://raw.githubusercontent.com/AmmDuncan/easel/main/scripts/install.sh | bash
28
+ ```
29
+
30
+ The installer clones to `~/.local/share/easel` (override with `EASEL_DIR=…`), runs `npm install && npm run build`, then registers the MCP at user scope and adds two `SessionStart` hooks (session-id capture + auto-open tab). Idempotent — safe to re-run to update.
31
+
32
+ Restart Claude Code afterwards.
33
+
34
+ ### Manual install
35
+
36
+ ```bash
37
+ git clone https://github.com/AmmDuncan/easel.git ~/work/tools/easel
38
+ cd ~/work/tools/easel
39
+ npm install && npm run build
40
+ bin/easel setup
41
+ ```
42
+
43
+ ## Tools the agent gets
44
+
45
+ | Tool | What it does |
46
+ |---|---|
47
+ | `push({ html, title?, kind? })` | Append an HTML card to this session's scrolling feed |
48
+ | `open()` | Force-open a fresh browser tab for the current session |
49
+ | `config({ preset?, theme?, density? })` | Switch palette / mode / layout live across every tab |
50
+ | `label({ label })` | Name the session so it's findable in the switcher |
51
+
52
+ Agents invoke them as `mcp__easel__push`, `mcp__easel__open`, etc.
53
+
54
+ ## Theming
55
+
56
+ - **Presets**: `paper` (warm pitstop-style, amber accent — default), `aurora` (deep canvas + violet glow halos), `slate` (cool neutral, cyan accent)
57
+ - **Themes**: light / dark, with sun-moon toggle in the topbar
58
+ - **Density**: `carded` (bordered cards) or `flat` (no chrome, whitespace separates pushes)
59
+ - Three swatches + density toggle live in the topbar; config persists in `~/.easel/config.json` and SSE-broadcasts across all open tabs
60
+
61
+ ## Sessions
62
+
63
+ - Each Claude Code session gets its own URL: `localhost:7878/s/<session-id>`
64
+ - Session IDs come from Claude Code itself (via the pitstop-style SessionStart hook)
65
+ - Sessions auto-rename to `cwd-basename` by default; you can rename them via the click-to-edit label in the topbar, or the agent can via the `label` tool
66
+ - Idle sessions (>24h since last push) are GC'd every 10 minutes
67
+ - Up to 50 pushes per session; oldest evicted from disk first
68
+ - Per-push delete (trash icon on each card) + per-session delete (hover any row in the switcher or index)
69
+
70
+ ## Tool surface
71
+
72
+ The MCP exposes one server (`display`) with four tools. HTML is rendered in a sandboxed iframe (`sandbox="allow-scripts"`) with a baseline design system injected — off-white / charcoal, Inter, presentation-scale typography — so plain `<h1>/<h2>/<p>` markup looks right without extra CSS. Authors can also write a full `<!DOCTYPE html>` document and take ownership of styling.
73
+
74
+ Inside pushed HTML, semantic chips are available out of the box:
75
+
76
+ ```html
77
+ <span class="chip bug">BUG</span>
78
+ <span class="chip ux">UX</span>
79
+ <span class="chip polish">POLISH</span>
80
+ <span class="chip ok">OK</span>
81
+ <span class="chip info">INFO</span>
82
+ <span class="chip accent">FOCUS</span>
83
+ ```
84
+
85
+ Each is themed for both light and dark with a soft outer glow.
86
+
87
+ ## Files
88
+
89
+ ```
90
+ src/
91
+ mcp.ts stdio MCP — exposes push / open / config / label (as mcp__easel__*)
92
+ http-server.ts express + SSE + static client + sweeper
93
+ http-entry.ts process entry for the HTTP server
94
+ server-manager.ts lockfile + spawn coordination
95
+ session-store.ts disk persistence + retention sweep
96
+ session-id.ts 3-tier resolver (env / hook file / transcript scan)
97
+ config-store.ts preset / theme / density persistence
98
+ paths.ts shared constants
99
+ cli.ts `easel open|url|setup|config|server|version`
100
+ client/
101
+ viewer.html single-session feed
102
+ index.html sessions index page
103
+ viewer.css viewer + index styles
104
+ viewer.js feed wiring + SSE + theming
105
+ index.css index styles + preset/density picker
106
+ index.js index page client
107
+ scripts/
108
+ easel-session-id.mjs SessionStart hook (Node, zero deps)
109
+ install.sh one-shot installer
110
+ copy-client.mjs build-time copy of client assets
111
+ bin/
112
+ easel shebang → dist/cli.js
113
+ ```
114
+
115
+ ## Author
116
+
117
+ Built by Claude Code with @ammielyawson, across a series of focused chat sessions. Session-id resolution lifted from [pitstop](https://github.com/AmmDuncan/pitstop) — thanks.
118
+
119
+ ## Licence
120
+
121
+ MIT.
package/bin/easel ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import("../dist/cli.js");
package/dist/cli.js ADDED
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env node
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { homedir } from "node:os";
7
+ import { ensureHttpServer, readLock } from "./server-manager.js";
8
+ import { resolveClaudeSessionId } from "./session-id.js";
9
+ import { HOOK_DIR, DATA_ROOT } from "./paths.js";
10
+ import { registerSession } from "./session-store.js";
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const PROJECT_ROOT = resolve(__dirname, "..");
13
+ function help() {
14
+ console.log(`easel — live browser feed for Claude Code for Claude Code sessions
15
+
16
+ Usage:
17
+ easel open ensure server is running, open this session's tab (or skip if a tab is already alive)
18
+ easel open --quiet same but no stdout (for SessionStart hook)
19
+ easel open --force always open a new browser tab regardless of presence
20
+ easel url print this session's URL
21
+ easel config print current { preset, theme }
22
+ easel config preset paper set preset to paper | aurora | slate
23
+ easel config theme dark set theme to light | dark
24
+ easel config preset aurora theme light set both at once
25
+ easel setup install SessionStart hook + register MCP in ~/.claude/settings.json
26
+ easel update git pull + npm install + build + setup (re-runs setup to apply new conventions)
27
+ easel restart kill the running HTTP server and respawn it (picks up new builds/paths)
28
+ easel server run the HTTP server in the foreground (debug)
29
+ easel version
30
+ `);
31
+ }
32
+ async function cmdOpen(opts) {
33
+ mkdirSync(HOOK_DIR, { recursive: true });
34
+ mkdirSync(DATA_ROOT, { recursive: true });
35
+ const { port } = await ensureHttpServer();
36
+ const sessionId = resolveClaudeSessionId();
37
+ registerSession(sessionId);
38
+ await registerSessionWithServer(port, sessionId);
39
+ const url = `http://localhost:${port}/s/${sessionId}`;
40
+ const shouldOpen = opts.force || (await tabsAlive(port)) === 0;
41
+ if (shouldOpen) {
42
+ openInBrowser(url);
43
+ if (!opts.quiet)
44
+ console.log(url);
45
+ }
46
+ else if (!opts.quiet) {
47
+ console.log(`[easel] tab already open — registered session ${sessionId.slice(0, 8)} silently. Use the topbar switcher to view it, or 'easel open --force' for a new window.`);
48
+ }
49
+ }
50
+ async function tabsAlive(port) {
51
+ try {
52
+ const r = await fetch(`http://127.0.0.1:${port}/api/presence`, {
53
+ signal: AbortSignal.timeout(800),
54
+ });
55
+ if (!r.ok)
56
+ return 0;
57
+ const data = (await r.json());
58
+ return typeof data.tabs === "number" ? data.tabs : 0;
59
+ }
60
+ catch {
61
+ return 0;
62
+ }
63
+ }
64
+ async function registerSessionWithServer(port, sessionId) {
65
+ try {
66
+ await fetch(`http://127.0.0.1:${port}/api/register`, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify({ sessionId, cwd: process.cwd() }),
70
+ signal: AbortSignal.timeout(1200),
71
+ });
72
+ }
73
+ catch {
74
+ /* non-fatal — the session is still usable */
75
+ }
76
+ }
77
+ async function cmdUrl() {
78
+ const { port } = await ensureHttpServer();
79
+ const sessionId = resolveClaudeSessionId();
80
+ console.log(`http://localhost:${port}/s/${sessionId}`);
81
+ }
82
+ function openInBrowser(url) {
83
+ const platform = process.platform;
84
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
85
+ const args = platform === "win32" ? ["", url] : [url];
86
+ try {
87
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
88
+ child.unref();
89
+ }
90
+ catch (err) {
91
+ console.error(`[easel] couldn't open browser: ${err.message}`);
92
+ }
93
+ }
94
+ function cmdSetup() {
95
+ mkdirSync(HOOK_DIR, { recursive: true });
96
+ const settingsPath = join(homedir(), ".claude", "settings.json");
97
+ const hookScript = resolve(PROJECT_ROOT, "scripts", "easel-session-id.mjs");
98
+ const mcpEntry = resolve(PROJECT_ROOT, "dist", "mcp.js");
99
+ const cliEntry = resolve(PROJECT_ROOT, "bin", "easel");
100
+ if (!existsSync(hookScript)) {
101
+ console.error(`[easel] hook script missing at ${hookScript}`);
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ // 1. Register the MCP via the Claude Code CLI (writes ~/.claude.json).
106
+ // Falling back to a direct edit if the CLI isn't on PATH.
107
+ registerMcp(mcpEntry);
108
+ // 1b. Install the `using-easel` skill so agents discover when/how to push.
109
+ installSkill();
110
+ // 2. Add SessionStart hooks to ~/.claude/settings.json (hooks DO belong here).
111
+ const settings = existsSync(settingsPath)
112
+ ? JSON.parse(readFileSync(settingsPath, "utf-8"))
113
+ : {};
114
+ // Drop any prior mcpServers.display entry — it lives in ~/.claude.json now.
115
+ if (settings.mcpServers && typeof settings.mcpServers === "object") {
116
+ delete settings.mcpServers["display"];
117
+ }
118
+ const hooks = settings.hooks ?? {};
119
+ let sessionStart = hooks.SessionStart ?? [];
120
+ // Drop legacy entries from prior versions (the old bash hook, paths under the
121
+ // claude-display name) before re-adding the current Node-based hook.
122
+ const isLegacy = (block) => {
123
+ const inner = block?.hooks ?? [block];
124
+ if (!Array.isArray(inner))
125
+ return false;
126
+ return inner.some((h) => {
127
+ const cmd = h?.command;
128
+ if (typeof cmd !== "string")
129
+ return false;
130
+ return (cmd.includes("claude-display-session-id.sh") ||
131
+ cmd.includes("easel-session-id.sh") ||
132
+ cmd.includes("bin/claude-display "));
133
+ });
134
+ };
135
+ sessionStart = sessionStart.filter((b) => !isLegacy(b));
136
+ const idCaptureBlock = {
137
+ hooks: [{ type: "command", command: `node ${hookScript}` }],
138
+ };
139
+ const autoOpenBlock = {
140
+ hooks: [{ type: "command", command: `${cliEntry} open --quiet` }],
141
+ };
142
+ const containsBlockMatching = (substr) => sessionStart.some((block) => {
143
+ const inner = block?.hooks ?? [block];
144
+ return (Array.isArray(inner) ? inner : []).some((h) => typeof h === "object" &&
145
+ h !== null &&
146
+ typeof h.command === "string" &&
147
+ (h.command).includes(substr));
148
+ });
149
+ if (!containsBlockMatching("easel-session-id.mjs")) {
150
+ sessionStart.push(idCaptureBlock);
151
+ }
152
+ if (!containsBlockMatching("easel") || !containsBlockMatching("open --quiet")) {
153
+ sessionStart.push(autoOpenBlock);
154
+ }
155
+ hooks.SessionStart = sessionStart;
156
+ settings.hooks = hooks;
157
+ mkdirSync(dirname(settingsPath), { recursive: true });
158
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
159
+ console.log(`[easel] setup complete`);
160
+ console.log(` - MCP registered at user scope (\`claude mcp list\` to verify)`);
161
+ console.log(` - SessionStart hooks added to ${settingsPath}`);
162
+ console.log(`Restart Claude Code (fully quit + relaunch) to activate.`);
163
+ }
164
+ function installSkill() {
165
+ const src = resolve(PROJECT_ROOT, "skills", "using-easel", "SKILL.md");
166
+ if (!existsSync(src)) {
167
+ console.warn(`[easel] skill source missing at ${src} — skipping skill install`);
168
+ return;
169
+ }
170
+ const destDir = join(homedir(), ".claude", "skills", "using-easel");
171
+ const dest = join(destDir, "SKILL.md");
172
+ mkdirSync(destDir, { recursive: true });
173
+ copyFileSync(src, dest);
174
+ // Remove the legacy skill from prior versions if it exists.
175
+ const legacy = join(homedir(), ".claude", "skills", "using-display");
176
+ if (existsSync(legacy)) {
177
+ try {
178
+ rmSync(legacy, { recursive: true, force: true });
179
+ }
180
+ catch {
181
+ /* swallow */
182
+ }
183
+ }
184
+ console.log(` - using-easel skill installed to ${dest}`);
185
+ }
186
+ function registerMcp(mcpEntry) {
187
+ // Try `claude mcp add` first — that's the supported path and writes to ~/.claude.json.
188
+ // Re-add idempotently by removing first (CLI errors if the name already exists).
189
+ const trySpawn = (args) => {
190
+ try {
191
+ const r = spawnSync("claude", args, {
192
+ encoding: "utf-8",
193
+ stdio: ["ignore", "pipe", "pipe"],
194
+ });
195
+ return r.status === 0;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ };
201
+ // Drop any old registrations from prior versions.
202
+ trySpawn(["mcp", "remove", "display", "--scope", "user"]);
203
+ trySpawn(["mcp", "remove", "easel", "--scope", "user"]);
204
+ const added = trySpawn([
205
+ "mcp",
206
+ "add",
207
+ "--scope",
208
+ "user",
209
+ "easel",
210
+ "node",
211
+ mcpEntry,
212
+ ]);
213
+ if (added)
214
+ return;
215
+ // Fallback: patch ~/.claude.json directly.
216
+ const userConfigPath = join(homedir(), ".claude.json");
217
+ const config = existsSync(userConfigPath)
218
+ ? JSON.parse(readFileSync(userConfigPath, "utf-8"))
219
+ : {};
220
+ const mcpServers = config.mcpServers ?? {};
221
+ delete mcpServers["display"];
222
+ mcpServers["easel"] = {
223
+ type: "stdio",
224
+ command: "node",
225
+ args: [mcpEntry],
226
+ };
227
+ config.mcpServers = mcpServers;
228
+ writeFileSync(userConfigPath, JSON.stringify(config, null, 2));
229
+ }
230
+ async function cmdConfig(args) {
231
+ const { port } = await ensureHttpServer();
232
+ if (args.length === 0) {
233
+ const r = await fetch(`http://127.0.0.1:${port}/api/config`);
234
+ const data = (await r.json());
235
+ console.log(JSON.stringify(data.config, null, 2));
236
+ return;
237
+ }
238
+ const body = {};
239
+ for (let i = 0; i < args.length; i += 2) {
240
+ const key = args[i];
241
+ const val = args[i + 1];
242
+ if (!key || !val)
243
+ continue;
244
+ if (key === "preset" || key === "theme" || key === "density")
245
+ body[key] = val;
246
+ }
247
+ if (Object.keys(body).length === 0) {
248
+ console.error("usage: easel config [preset paper|aurora|slate] [theme light|dark] [density carded|flat]");
249
+ process.exitCode = 1;
250
+ return;
251
+ }
252
+ const r = await fetch(`http://127.0.0.1:${port}/api/config`, {
253
+ method: "POST",
254
+ headers: { "content-type": "application/json" },
255
+ body: JSON.stringify(body),
256
+ });
257
+ const data = (await r.json());
258
+ console.log(JSON.stringify(data.config, null, 2));
259
+ }
260
+ function cmdUpdate() {
261
+ console.log("[easel] checking for updates…");
262
+ const run = (cmd, args) => spawnSync(cmd, args, { stdio: "inherit", cwd: PROJECT_ROOT });
263
+ let r = run("git", ["fetch", "--quiet", "origin", "main"]);
264
+ if (r.status !== 0) {
265
+ console.error("[easel] git fetch failed");
266
+ process.exitCode = 1;
267
+ return;
268
+ }
269
+ r = run("git", ["pull", "--ff-only", "--quiet", "origin", "main"]);
270
+ if (r.status !== 0) {
271
+ console.error("[easel] git pull failed (local changes? merge conflict?)");
272
+ process.exitCode = 1;
273
+ return;
274
+ }
275
+ r = run("npm", ["install", "--silent", "--no-audit", "--no-fund"]);
276
+ if (r.status !== 0) {
277
+ console.error("[easel] npm install failed");
278
+ process.exitCode = 1;
279
+ return;
280
+ }
281
+ r = run("npm", ["run", "build", "--silent"]);
282
+ if (r.status !== 0) {
283
+ console.error("[easel] build failed");
284
+ process.exitCode = 1;
285
+ return;
286
+ }
287
+ // Re-run setup so any new hook/skill conventions take effect.
288
+ cmdSetup();
289
+ console.log("[easel] updated. Restart Claude Code to pick up tool/skill changes.");
290
+ }
291
+ async function cmdRestart() {
292
+ const lock = readLock();
293
+ if (lock?.pid) {
294
+ try {
295
+ process.kill(lock.pid, "SIGTERM");
296
+ }
297
+ catch {
298
+ // process is already dead — fine
299
+ }
300
+ // give the OS a moment to release the port + clean up
301
+ await new Promise((r) => setTimeout(r, 300));
302
+ }
303
+ try {
304
+ rmSync(join(DATA_ROOT, "server.lock"));
305
+ }
306
+ catch {
307
+ // no lockfile to remove — fine
308
+ }
309
+ const { port } = await ensureHttpServer();
310
+ console.log(`easel server restarted on port ${port}`);
311
+ }
312
+ async function cmdServer() {
313
+ const { startHttpServer } = await import("./http-server.js");
314
+ startHttpServer();
315
+ process.stdin.resume();
316
+ }
317
+ function cmdVersion() {
318
+ const pkg = JSON.parse(readFileSync(resolve(PROJECT_ROOT, "package.json"), "utf-8"));
319
+ console.log(pkg.version);
320
+ }
321
+ async function main() {
322
+ const [, , cmd, ...rest] = process.argv;
323
+ switch (cmd) {
324
+ case "open":
325
+ await cmdOpen({
326
+ quiet: rest.includes("--quiet"),
327
+ force: rest.includes("--force"),
328
+ });
329
+ return;
330
+ case "url":
331
+ await cmdUrl();
332
+ return;
333
+ case "setup":
334
+ cmdSetup();
335
+ return;
336
+ case "server":
337
+ await cmdServer();
338
+ return;
339
+ case "config":
340
+ await cmdConfig(rest);
341
+ return;
342
+ case "update":
343
+ cmdUpdate();
344
+ return;
345
+ case "restart":
346
+ await cmdRestart();
347
+ return;
348
+ case "version":
349
+ case "--version":
350
+ case "-v":
351
+ cmdVersion();
352
+ return;
353
+ case undefined:
354
+ case "help":
355
+ case "--help":
356
+ case "-h":
357
+ help();
358
+ return;
359
+ default:
360
+ console.error(`unknown command: ${cmd}`);
361
+ help();
362
+ process.exitCode = 1;
363
+ }
364
+ }
365
+ main().catch((err) => {
366
+ console.error("[easel cli] fatal:", err);
367
+ process.exit(1);
368
+ });