@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 +21 -0
- package/README.md +121 -0
- package/bin/easel +2 -0
- package/dist/cli.js +368 -0
- package/dist/client/index.css +441 -0
- package/dist/client/index.html +74 -0
- package/dist/client/index.js +236 -0
- package/dist/client/viewer.css +717 -0
- package/dist/client/viewer.html +100 -0
- package/dist/client/viewer.js +1215 -0
- package/dist/config-store.js +36 -0
- package/dist/http-entry.js +2 -0
- package/dist/http-server.js +202 -0
- package/dist/mcp.js +235 -0
- package/dist/paths.js +45 -0
- package/dist/server-manager.js +94 -0
- package/dist/session-id.js +78 -0
- package/dist/session-store.js +185 -0
- package/package.json +60 -0
- package/scripts/easel-session-id.mjs +140 -0
- package/skills/using-easel/SKILL.md +315 -0
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
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
|
+
});
|