chat-glass 1.0.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/LICENSE +21 -0
- package/README.md +71 -0
- package/agent/chat-glass.md +109 -0
- package/bin/cli.js +51 -0
- package/package.json +48 -0
- package/src/commands/clean.js +33 -0
- package/src/commands/install-agent.js +64 -0
- package/src/commands/list.js +42 -0
- package/src/commands/show.js +215 -0
- package/src/index.js +4 -0
- package/src/server/index.js +132 -0
- package/src/server/routes.js +147 -0
- package/src/server/start.js +17 -0
- package/src/server/watcher.js +51 -0
- package/src/ui/gallery.html +274 -0
- package/src/ui/main.html +500 -0
- package/src/utils/config.js +31 -0
- package/src/utils/find-chrome.js +27 -0
- package/src/utils/paths.js +17 -0
- package/src/utils/port.js +24 -0
- package/src/utils/screenshot.js +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lindow Consulting Ltd
|
|
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,71 @@
|
|
|
1
|
+
# chat-glass
|
|
2
|
+
|
|
3
|
+
Visual thinking companion for Claude Code.
|
|
4
|
+
|
|
5
|
+
chat-glass gives Claude Code the ability to create rich, interactive visualizations -- diagrams, charts, comparisons, flowcharts -- and display them in a live-reloading browser viewer.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
When Claude Code needs to explain something visually, it delegates to the chat-glass agent. The agent writes a self-contained HTML file, and chat-glass serves it in a browser viewer with a gallery of past visualizations. Everything updates in real time via WebSocket.
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g chat-glass
|
|
15
|
+
chat-glass install-agent
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That's it. The agent is now available in Claude Code. When a visual explanation would help, Claude will automatically use it.
|
|
19
|
+
|
|
20
|
+
To install the agent for only the current project:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
chat-glass install-agent --local
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
1. Claude Code decides a visual would help and delegates to the chat-glass subagent
|
|
29
|
+
2. The subagent writes a self-contained HTML file to `.chat-glass/pages/`
|
|
30
|
+
3. The subagent runs `chat-glass show`, which starts a local server (or reloads an existing one)
|
|
31
|
+
4. The browser viewer updates in real time via WebSocket
|
|
32
|
+
5. A gallery strip lets you browse all past visualizations
|
|
33
|
+
|
|
34
|
+
## Commands
|
|
35
|
+
|
|
36
|
+
| Command | Description |
|
|
37
|
+
|---------|-------------|
|
|
38
|
+
| `chat-glass show` | Open the visualization viewer (starts server if needed) |
|
|
39
|
+
| `chat-glass list` | List saved visualizations |
|
|
40
|
+
| `chat-glass clean` | Delete all saved pages |
|
|
41
|
+
| `chat-glass install-agent` | Install the chat-glass agent file globally |
|
|
42
|
+
| `chat-glass install-agent --local` | Install the agent for the current project only |
|
|
43
|
+
| `chat-glass --help` | Show help |
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
chat-glass stores data in a `.chat-glass/` directory in your project root:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
.chat-glass/
|
|
51
|
+
config.json # Server state (port, pid, timestamps)
|
|
52
|
+
pages/ # Generated HTML visualizations
|
|
53
|
+
latest.html # Symlink to most recent page
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The server listens on `127.0.0.1` on a port in the range 3737-3747 and shuts down automatically after 30 minutes of inactivity.
|
|
57
|
+
|
|
58
|
+
## For contributors
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone https://github.com/tfLConsulting/chat-glass.git
|
|
62
|
+
cd chat-glass
|
|
63
|
+
npm install
|
|
64
|
+
npm test
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: chat-glass
|
|
3
|
+
description: >
|
|
4
|
+
Use this agent whenever a visual explanation would be clearer than text.
|
|
5
|
+
Comparisons, architectures, flows, data, relationships, tradeoffs,
|
|
6
|
+
layouts, timelines, hierarchies — any time showing is better than telling.
|
|
7
|
+
Be proactive: if something would benefit from a visual, use this without
|
|
8
|
+
being asked.
|
|
9
|
+
tools: Write, Bash
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
You are a visualization agent. You receive a plain-English description of something to visualize and produce a single self-contained HTML file.
|
|
13
|
+
|
|
14
|
+
## Output rules
|
|
15
|
+
|
|
16
|
+
1. Generate exactly one HTML file — inline all CSS and JS, no external stylesheets or scripts except CDN libraries.
|
|
17
|
+
2. Write the file to `.chat-glass/pages/{timestamp}.html` where timestamp is the current time formatted as `YYYY-MM-DDTHH-mm-ss` (filesystem-safe ISO). Create the directory if it does not exist.
|
|
18
|
+
3. Run `chat-glass show` via Bash to open/reload the viewer.
|
|
19
|
+
4. Return a single sentence describing what you visualized. Nothing more.
|
|
20
|
+
5. **Never** return HTML, SVG, or code in your response to the main agent.
|
|
21
|
+
6. If `chat-glass show` outputs a screenshot path, include it in your response
|
|
22
|
+
as: "Screenshot: /path/to/screenshot.png" — this lets the calling context
|
|
23
|
+
see what was rendered.
|
|
24
|
+
|
|
25
|
+
## HTML file requirements
|
|
26
|
+
|
|
27
|
+
- Must be fully self-contained and renderable by opening the file directly
|
|
28
|
+
- Must include a descriptive `<title>` tag (the gallery uses this)
|
|
29
|
+
- Target viewport: approximately 1200 x 800 pixels
|
|
30
|
+
- No scrolling required — content should fit in view
|
|
31
|
+
|
|
32
|
+
## CDN libraries you may use
|
|
33
|
+
|
|
34
|
+
Pick whichever fits the visualization best:
|
|
35
|
+
|
|
36
|
+
- **Tailwind CSS** — utility styling via `<script src="https://cdn.tailwindcss.com"></script>`
|
|
37
|
+
- **Mermaid** — diagrams, flowcharts, sequence diagrams, gantt charts
|
|
38
|
+
- **D3.js** — custom data visualizations, force graphs, trees
|
|
39
|
+
- **Chart.js** — bar, line, pie, radar, and other standard charts
|
|
40
|
+
- **Prism / Highlight.js** — syntax-highlighted code blocks
|
|
41
|
+
- **KaTeX** — mathematical notation
|
|
42
|
+
- **Three.js** — 3D visualizations
|
|
43
|
+
- Any other CDN library that fits the task
|
|
44
|
+
|
|
45
|
+
## Style guide
|
|
46
|
+
|
|
47
|
+
### Theme
|
|
48
|
+
|
|
49
|
+
- Background: `#1a1a2e` (deep blue-black)
|
|
50
|
+
- Text: `#e0e0e0`
|
|
51
|
+
- Accent palette — rotate through these: `#00d4ff`, `#ff6b6b`, `#4ecdc4`, `#ffe66d`, `#a29bfe`
|
|
52
|
+
|
|
53
|
+
### Typography
|
|
54
|
+
|
|
55
|
+
- Body: system font stack (`-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`)
|
|
56
|
+
- Headings/labels that need character: pull a single Google Font via CDN — vary it per visualization
|
|
57
|
+
- Monospace: `'SF Mono', 'Fira Code', 'Cascadia Code', monospace`
|
|
58
|
+
|
|
59
|
+
### Layout principles
|
|
60
|
+
|
|
61
|
+
- Generous whitespace — let the content breathe
|
|
62
|
+
- Content centered with max-width ~1000px
|
|
63
|
+
- Use CSS grid/flexbox for layout
|
|
64
|
+
- No scrolling if possible — fit the viewport
|
|
65
|
+
- No unnecessary chrome or decoration
|
|
66
|
+
|
|
67
|
+
### Diagram style
|
|
68
|
+
|
|
69
|
+
- Prefer organic SVG feel over rigid boxes
|
|
70
|
+
- Rounded corners, subtle shadows, curved connecting lines
|
|
71
|
+
- Use color to encode meaning, not decoration
|
|
72
|
+
|
|
73
|
+
### Comparison style
|
|
74
|
+
|
|
75
|
+
- Side-by-side cards with clear headers and key points
|
|
76
|
+
- Color-coded borders for pros/cons or categories
|
|
77
|
+
- Highlight differences with accent colors
|
|
78
|
+
|
|
79
|
+
### Flow / process style
|
|
80
|
+
|
|
81
|
+
- Nodes as rounded rectangles
|
|
82
|
+
- Edges as curved SVG paths with arrowheads
|
|
83
|
+
- Subtle load animation if it aids understanding
|
|
84
|
+
|
|
85
|
+
### Data visualization style
|
|
86
|
+
|
|
87
|
+
- Clean axes, minimal gridlines
|
|
88
|
+
- Direct labels preferred over legends
|
|
89
|
+
- Accent colors for data series
|
|
90
|
+
- No chartjunk — every pixel should inform
|
|
91
|
+
|
|
92
|
+
## Timestamp generation
|
|
93
|
+
|
|
94
|
+
Use this Bash one-liner to generate the filename timestamp:
|
|
95
|
+
```bash
|
|
96
|
+
date +%Y-%m-%dT%H-%M-%S
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or in JavaScript:
|
|
100
|
+
```javascript
|
|
101
|
+
new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '')
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Example workflow
|
|
105
|
+
|
|
106
|
+
1. Receive: "Show the request lifecycle through our middleware stack"
|
|
107
|
+
2. Write: `.chat-glass/pages/2025-06-15T14-30-22.html` containing a styled flowchart
|
|
108
|
+
3. Run: `chat-glass show` — output includes the server URL and optionally a screenshot path
|
|
109
|
+
4. Reply: "Created a flowchart showing the request lifecycle through the middleware stack. Screenshot: /path/to/project/.chat-glass/pages/screenshot-1718459422000.png"
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import show from "../src/commands/show.js";
|
|
4
|
+
import installAgent from "../src/commands/install-agent.js";
|
|
5
|
+
import list from "../src/commands/list.js";
|
|
6
|
+
import clean from "../src/commands/clean.js";
|
|
7
|
+
|
|
8
|
+
const commands = {
|
|
9
|
+
show,
|
|
10
|
+
"install-agent": installAgent,
|
|
11
|
+
list,
|
|
12
|
+
clean,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function printUsage() {
|
|
16
|
+
console.log(`chat-glass — Visual thinking companion for Claude Code
|
|
17
|
+
|
|
18
|
+
Usage: chat-glass <command>
|
|
19
|
+
|
|
20
|
+
Commands:
|
|
21
|
+
show Open the visualization viewer
|
|
22
|
+
install-agent Install the chat-glass agent file
|
|
23
|
+
list List saved visualizations
|
|
24
|
+
clean Delete all saved pages
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--help Show this help message`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const command = args[0];
|
|
32
|
+
|
|
33
|
+
if (!command || command === "--help" || command === "-h") {
|
|
34
|
+
printUsage();
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const handler = commands[command];
|
|
39
|
+
|
|
40
|
+
if (!handler) {
|
|
41
|
+
console.error(`Unknown command: ${command}\n`);
|
|
42
|
+
printUsage();
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
await handler(args.slice(1));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`Error: ${err.message}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chat-glass",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Visual thinking companion for Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"chat-glass": "./bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:coverage": "vitest run --coverage",
|
|
13
|
+
"dev": "node bin/cli.js install-agent --local --dev"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"bin",
|
|
18
|
+
"agent"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/tfLConsulting/chat-glass.git"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/tfLConsulting/chat-glass",
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/tfLConsulting/chat-glass/issues"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"claude",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"visualization",
|
|
35
|
+
"ai",
|
|
36
|
+
"developer-tools"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
41
|
+
"vitest": "^4.0.18"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"open": "^11.0.0",
|
|
45
|
+
"puppeteer-core": "^24.0.0",
|
|
46
|
+
"ws": "^8.19.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pagesDir, getProjectDir } from "../utils/paths.js";
|
|
4
|
+
|
|
5
|
+
export default async function clean() {
|
|
6
|
+
const dir = pagesDir(getProjectDir());
|
|
7
|
+
let files;
|
|
8
|
+
try {
|
|
9
|
+
files = await readdir(dir);
|
|
10
|
+
} catch {
|
|
11
|
+
console.log("No pages directory found.");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const targets = files.filter((f) => f.endsWith(".html"));
|
|
16
|
+
|
|
17
|
+
if (targets.length === 0) {
|
|
18
|
+
console.log("No pages to clean.");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let removed = 0;
|
|
23
|
+
for (const filename of targets) {
|
|
24
|
+
try {
|
|
25
|
+
await unlink(join(dir, filename));
|
|
26
|
+
removed++;
|
|
27
|
+
} catch {
|
|
28
|
+
// skip files that can't be removed
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(`Removed ${removed} page(s).`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { join, dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = dirname(__filename);
|
|
8
|
+
const PACKAGE_ROOT = resolve(__dirname, "..", "..");
|
|
9
|
+
const AGENT_SOURCE = join(PACKAGE_ROOT, "agent", "chat-glass.md");
|
|
10
|
+
const CLI_PATH = join(PACKAGE_ROOT, "bin", "cli.js");
|
|
11
|
+
|
|
12
|
+
export default async function installAgent(args = []) {
|
|
13
|
+
const isLocal = args.includes("--local");
|
|
14
|
+
const isDev = args.includes("--dev");
|
|
15
|
+
|
|
16
|
+
const destDir = isLocal
|
|
17
|
+
? join(process.cwd(), ".claude", "agents")
|
|
18
|
+
: join(homedir(), ".claude", "agents");
|
|
19
|
+
|
|
20
|
+
const destPath = join(destDir, "chat-glass.md");
|
|
21
|
+
|
|
22
|
+
// Read source agent file
|
|
23
|
+
let source;
|
|
24
|
+
try {
|
|
25
|
+
source = await readFile(AGENT_SOURCE, "utf8");
|
|
26
|
+
} catch {
|
|
27
|
+
console.error("Could not read agent file from package. Is chat-glass installed correctly?");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Dev mode: rewrite `chat-glass` commands to use the local checkout
|
|
32
|
+
if (isDev) {
|
|
33
|
+
const localCmd = `node ${CLI_PATH}`;
|
|
34
|
+
source = source.replace(/`chat-glass show`/g, `\`${localCmd} show\``);
|
|
35
|
+
source = source.replace(/`chat-glass /g, `\`${localCmd} `);
|
|
36
|
+
source = source.replace(
|
|
37
|
+
/Run: `node .* show`/,
|
|
38
|
+
`Run: \`${localCmd} show\``
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check if destination already exists and is identical
|
|
43
|
+
try {
|
|
44
|
+
const existing = await readFile(destPath, "utf8");
|
|
45
|
+
if (existing === source) {
|
|
46
|
+
console.log("Agent already up to date.");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// File doesn't exist yet — that's fine
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Write agent file
|
|
54
|
+
await mkdir(destDir, { recursive: true });
|
|
55
|
+
await writeFile(destPath, source, "utf8");
|
|
56
|
+
|
|
57
|
+
if (isDev) {
|
|
58
|
+
console.log(`Agent installed (dev mode) — commands point to ${CLI_PATH}`);
|
|
59
|
+
} else if (isLocal) {
|
|
60
|
+
console.log("Agent installed to .claude/agents/chat-glass.md — available in this project");
|
|
61
|
+
} else {
|
|
62
|
+
console.log("Agent installed to ~/.claude/agents/chat-glass.md — available in all projects");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pagesDir, getProjectDir } from "../utils/paths.js";
|
|
4
|
+
|
|
5
|
+
function extractTitle(html) {
|
|
6
|
+
const match = html.match(/<title>(.*?)<\/title>/i);
|
|
7
|
+
return match ? match[1] : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default async function list() {
|
|
11
|
+
const dir = pagesDir(getProjectDir());
|
|
12
|
+
let files;
|
|
13
|
+
try {
|
|
14
|
+
files = await readdir(dir);
|
|
15
|
+
} catch {
|
|
16
|
+
console.log("No pages directory found.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const htmlFiles = files
|
|
21
|
+
.filter((f) => f.endsWith(".html") && f !== "latest.html")
|
|
22
|
+
.sort((a, b) => b.localeCompare(a));
|
|
23
|
+
|
|
24
|
+
if (htmlFiles.length === 0) {
|
|
25
|
+
console.log("No pages found.");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const filename of htmlFiles) {
|
|
30
|
+
let title = null;
|
|
31
|
+
try {
|
|
32
|
+
const content = await readFile(join(dir, filename), "utf8");
|
|
33
|
+
title = extractTitle(content);
|
|
34
|
+
} catch {
|
|
35
|
+
// skip unreadable files
|
|
36
|
+
}
|
|
37
|
+
const display = title ? `${filename} ${title}` : filename;
|
|
38
|
+
console.log(display);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`\n${htmlFiles.length} page(s)`);
|
|
42
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readdir, symlink, unlink } from "node:fs/promises";
|
|
3
|
+
import { resolve, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readConfig, ensureDirs, writeConfig } from "../utils/config.js";
|
|
6
|
+
import { pagesDir, latestPath, getProjectDir } from "../utils/paths.js";
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const SERVER_ENTRY = resolve(__dirname, "..", "server", "start.js");
|
|
11
|
+
|
|
12
|
+
const HEALTH_CHECK_TIMEOUT_MS = 2000;
|
|
13
|
+
const SERVER_START_TIMEOUT_MS = 5000;
|
|
14
|
+
const READY_POLL_INTERVAL_MS = 200;
|
|
15
|
+
const READY_MAX_ATTEMPTS = 15;
|
|
16
|
+
|
|
17
|
+
async function isServerAlive(port) {
|
|
18
|
+
try {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
|
|
21
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
});
|
|
24
|
+
clearTimeout(timeout);
|
|
25
|
+
return res.ok;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isProcessAlive(pid) {
|
|
32
|
+
try {
|
|
33
|
+
process.kill(pid, 0);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function updateLatestSymlink(projectDir) {
|
|
41
|
+
const dir = pagesDir(projectDir);
|
|
42
|
+
let files;
|
|
43
|
+
try {
|
|
44
|
+
files = await readdir(dir);
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const htmlFiles = files
|
|
50
|
+
.filter((f) => f.endsWith(".html") && f !== "latest.html")
|
|
51
|
+
.sort();
|
|
52
|
+
|
|
53
|
+
if (htmlFiles.length === 0) return;
|
|
54
|
+
|
|
55
|
+
const newest = htmlFiles[htmlFiles.length - 1];
|
|
56
|
+
const linkPath = latestPath(projectDir);
|
|
57
|
+
|
|
58
|
+
// Remove existing symlink/file
|
|
59
|
+
try {
|
|
60
|
+
await unlink(linkPath);
|
|
61
|
+
} catch {
|
|
62
|
+
// doesn't exist yet
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await symlink(newest, linkPath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function spawnServer(projectDir) {
|
|
69
|
+
const child = spawn(process.execPath, [SERVER_ENTRY, projectDir], {
|
|
70
|
+
detached: true,
|
|
71
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Read the port from stdout
|
|
75
|
+
let stdout = "";
|
|
76
|
+
let stderr = "";
|
|
77
|
+
|
|
78
|
+
// Wait for the server to write its port, or fail
|
|
79
|
+
const port = await new Promise((resolvePort, reject) => {
|
|
80
|
+
const timeout = setTimeout(() => {
|
|
81
|
+
reject(new Error("Server failed to start within 5 seconds"));
|
|
82
|
+
}, SERVER_START_TIMEOUT_MS);
|
|
83
|
+
|
|
84
|
+
child.stdout.on("data", (chunk) => {
|
|
85
|
+
stdout += chunk.toString();
|
|
86
|
+
const parsed = parseInt(stdout.trim(), 10);
|
|
87
|
+
if (parsed > 0) {
|
|
88
|
+
clearTimeout(timeout);
|
|
89
|
+
resolvePort(parsed);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.stderr.on("data", (chunk) => {
|
|
94
|
+
stderr += chunk.toString();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
child.on("error", (err) => {
|
|
98
|
+
clearTimeout(timeout);
|
|
99
|
+
reject(err);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.on("exit", (code) => {
|
|
103
|
+
if (code !== null && code !== 0) {
|
|
104
|
+
clearTimeout(timeout);
|
|
105
|
+
reject(new Error(stderr.trim() || `Server exited with code ${code}`));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Detach — let the server run independently
|
|
111
|
+
child.unref();
|
|
112
|
+
child.stdout.destroy();
|
|
113
|
+
child.stderr.destroy();
|
|
114
|
+
|
|
115
|
+
return port;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function waitForReady(port, maxAttempts = READY_MAX_ATTEMPTS) {
|
|
119
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
120
|
+
if (await isServerAlive(port)) return true;
|
|
121
|
+
await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL_MS));
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function show(projectDir, { openBrowser = true } = {}) {
|
|
127
|
+
// 1. Ensure directories exist
|
|
128
|
+
await ensureDirs(projectDir);
|
|
129
|
+
|
|
130
|
+
// 2. Update latest.html symlink
|
|
131
|
+
await updateLatestSymlink(projectDir);
|
|
132
|
+
|
|
133
|
+
// 3. Check if server is already running
|
|
134
|
+
const config = await readConfig(projectDir);
|
|
135
|
+
let port = config.port;
|
|
136
|
+
let isNew = false;
|
|
137
|
+
|
|
138
|
+
let alive = false;
|
|
139
|
+
if (port && config.pid) {
|
|
140
|
+
// Check if the process is still alive and responding
|
|
141
|
+
if (isProcessAlive(config.pid)) {
|
|
142
|
+
alive = await isServerAlive(port);
|
|
143
|
+
}
|
|
144
|
+
// Stale config — process is dead
|
|
145
|
+
if (!alive) {
|
|
146
|
+
port = null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!alive) {
|
|
151
|
+
// 4. Start server as background process
|
|
152
|
+
port = await spawnServer(projectDir);
|
|
153
|
+
|
|
154
|
+
// 5. Wait for server to be ready
|
|
155
|
+
const ready = await waitForReady(port);
|
|
156
|
+
if (!ready) {
|
|
157
|
+
throw new Error("Server started but failed to respond");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
isNew = true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 6. Open browser on first start
|
|
164
|
+
if (isNew && openBrowser) {
|
|
165
|
+
try {
|
|
166
|
+
const open = (await import("open")).default;
|
|
167
|
+
await open(`http://localhost:${port}`);
|
|
168
|
+
} catch {
|
|
169
|
+
// Non-fatal — headless environments may not have a browser
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Track that browser was opened
|
|
173
|
+
const currentConfig = await readConfig(projectDir);
|
|
174
|
+
await writeConfig(projectDir, { ...currentConfig, browserOpened: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 7. Ping reload
|
|
178
|
+
try {
|
|
179
|
+
await fetch(`http://127.0.0.1:${port}/reload`);
|
|
180
|
+
} catch {
|
|
181
|
+
// Non-fatal
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 8. Attempt screenshot capture
|
|
185
|
+
let screenshot = null;
|
|
186
|
+
try {
|
|
187
|
+
const { captureScreenshot } = await import("../utils/screenshot.js");
|
|
188
|
+
const outputFile = resolve(pagesDir(projectDir), `screenshot-${Date.now()}.png`);
|
|
189
|
+
screenshot = await captureScreenshot(port, outputFile);
|
|
190
|
+
} catch {
|
|
191
|
+
// Non-fatal — screenshot is a bonus
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { port, isNew, screenshot };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export default async function showCommand() {
|
|
198
|
+
try {
|
|
199
|
+
const projectDir = getProjectDir();
|
|
200
|
+
const { port, screenshot } = await show(projectDir);
|
|
201
|
+
console.log(`chat-glass running on http://localhost:${port}`);
|
|
202
|
+
if (screenshot) {
|
|
203
|
+
console.log(`screenshot: ${screenshot}`);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err.message.includes("No free port")) {
|
|
207
|
+
console.error(
|
|
208
|
+
"All ports 3737-3747 in use. Close some chat-glass instances."
|
|
209
|
+
);
|
|
210
|
+
} else {
|
|
211
|
+
console.error(`Failed to start server: ${err.message}`);
|
|
212
|
+
}
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/index.js
ADDED