@taciturnaxolotl/traverse 0.1.0 → 0.1.1
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/README.md +13 -3
- package/package.json +1 -1
- package/src/index.ts +142 -93
- package/src/storage.ts +19 -0
- package/src/template.ts +26 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ interactive code walkthrough diagrams via MCP. share them with anyone.
|
|
|
4
4
|
|
|
5
5
|
The canonical repo for this is hosted on tangled over at [`dunkirk.sh/traverse`](https://tangled.org/@dunkirk.sh/traverse)
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## try it now
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
bunx @taciturnaxolotl/traverse
|
|
@@ -12,9 +12,17 @@ bunx @taciturnaxolotl/traverse
|
|
|
12
12
|
|
|
13
13
|
requires [bun](https://bun.sh). runs an MCP server on stdio and a web server on `localhost:4173`.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## setup
|
|
16
16
|
|
|
17
|
-
add to your MCP client
|
|
17
|
+
add to your MCP client:
|
|
18
|
+
|
|
19
|
+
**claude code:**
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
claude mcp add traverse -- bunx @taciturnaxolotl/traverse
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**claude desktop** — add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
18
26
|
|
|
19
27
|
```json
|
|
20
28
|
{
|
|
@@ -27,6 +35,8 @@ add to your MCP client config:
|
|
|
27
35
|
}
|
|
28
36
|
```
|
|
29
37
|
|
|
38
|
+
**other MCP clients** — same JSON config, wherever your client reads `.mcp.json` or equivalent.
|
|
39
|
+
|
|
30
40
|
your AI calls the `walkthrough_diagram` tool with mermaid code + node descriptions, and you get a clickable diagram in your browser.
|
|
31
41
|
|
|
32
42
|
diagrams persist to sqlite at `~/Library/Application Support/traverse/traverse.db` (macOS) or `$XDG_DATA_HOME/traverse/traverse.db` (linux). override with `TRAVERSE_DATA_DIR`.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { generateViewerHTML } from "./template.ts";
|
|
5
5
|
import type { WalkthroughDiagram } from "./types.ts";
|
|
6
|
-
import { initDb, loadAllDiagrams, saveDiagram, deleteDiagramFromDb, generateId } from "./storage.ts";
|
|
6
|
+
import { initDb, loadAllDiagrams, saveDiagram, deleteDiagramFromDb, generateId, getSharedUrl, saveSharedUrl } from "./storage.ts";
|
|
7
7
|
import { loadConfig } from "./config.ts";
|
|
8
8
|
|
|
9
9
|
const PORT = parseInt(process.env.TRAVERSE_PORT || "4173", 10);
|
|
@@ -18,107 +18,139 @@ initDb();
|
|
|
18
18
|
const diagrams = loadAllDiagrams();
|
|
19
19
|
|
|
20
20
|
// --- Web server for serving interactive diagrams ---
|
|
21
|
-
|
|
22
|
-
port: PORT,
|
|
23
|
-
async fetch(req) {
|
|
24
|
-
const url = new URL(req.url);
|
|
25
|
-
const diagramMatch = url.pathname.match(/^\/diagram\/([\w-]+)$/);
|
|
21
|
+
let isClient = false;
|
|
26
22
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
try {
|
|
24
|
+
Bun.serve({
|
|
25
|
+
port: PORT,
|
|
26
|
+
async fetch(req) {
|
|
27
|
+
const url = new URL(req.url);
|
|
28
|
+
const diagramMatch = url.pathname.match(/^\/diagram\/([\w-]+)$/);
|
|
29
|
+
|
|
30
|
+
if (diagramMatch) {
|
|
31
|
+
const id = diagramMatch[1]!;
|
|
32
|
+
const diagram = diagrams.get(id);
|
|
33
|
+
if (!diagram) {
|
|
34
|
+
return new Response(generate404HTML("Diagram not found", "This diagram doesn't exist or may have expired."), {
|
|
35
|
+
status: 404,
|
|
36
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const existingShareUrl = getSharedUrl(id);
|
|
40
|
+
return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd(), {
|
|
41
|
+
mode: MODE,
|
|
42
|
+
shareServerUrl: config.shareServerUrl,
|
|
43
|
+
diagramId: id,
|
|
44
|
+
existingShareUrl: existingShareUrl ?? undefined,
|
|
45
|
+
}), {
|
|
33
46
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
34
47
|
});
|
|
35
48
|
}
|
|
36
|
-
return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd(), {
|
|
37
|
-
mode: MODE,
|
|
38
|
-
shareServerUrl: config.shareServerUrl,
|
|
39
|
-
diagramId: id,
|
|
40
|
-
}), {
|
|
41
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
// DELETE /api/diagrams/:id
|
|
51
|
+
const apiMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)$/);
|
|
52
|
+
if (apiMatch && req.method === "DELETE") {
|
|
53
|
+
const id = apiMatch[1]!;
|
|
54
|
+
if (!diagrams.has(id)) {
|
|
55
|
+
return Response.json({ error: "not found" }, { status: 404 });
|
|
56
|
+
}
|
|
57
|
+
diagrams.delete(id);
|
|
58
|
+
deleteDiagramFromDb(id);
|
|
59
|
+
return Response.json({ ok: true, id });
|
|
51
60
|
}
|
|
52
|
-
diagrams.delete(id);
|
|
53
|
-
deleteDiagramFromDb(id);
|
|
54
|
-
return Response.json({ ok: true, id });
|
|
55
|
-
}
|
|
56
61
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
62
|
+
// POST /api/diagrams/:id/shared-url — save a shared URL for a local diagram
|
|
63
|
+
const sharedUrlMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)\/shared-url$/);
|
|
64
|
+
if (sharedUrlMatch && req.method === "POST") {
|
|
65
|
+
const id = sharedUrlMatch[1]!;
|
|
66
|
+
try {
|
|
67
|
+
const body = await req.json() as { url: string };
|
|
68
|
+
if (!body.url) {
|
|
69
|
+
return Response.json({ error: "missing required field: url" }, { status: 400 });
|
|
70
|
+
}
|
|
71
|
+
saveSharedUrl(id, body.url);
|
|
72
|
+
return Response.json({ ok: true, id, url: body.url });
|
|
73
|
+
} catch {
|
|
74
|
+
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
|
75
|
+
}
|
|
61
76
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
|
|
78
|
+
// GET /api/diagrams/:id/shared-url — retrieve a stored shared URL
|
|
79
|
+
if (sharedUrlMatch && req.method === "GET") {
|
|
80
|
+
const id = sharedUrlMatch[1]!;
|
|
81
|
+
const sharedUrl = getSharedUrl(id);
|
|
82
|
+
if (!sharedUrl) {
|
|
83
|
+
return Response.json({ url: null });
|
|
66
84
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
return Response.json({ url: sharedUrl });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// POST /api/diagrams — accept diagrams from remote or sibling instances
|
|
89
|
+
if (url.pathname === "/api/diagrams" && req.method === "POST") {
|
|
90
|
+
try {
|
|
91
|
+
const body = await req.json() as WalkthroughDiagram;
|
|
92
|
+
if (!body.code || !body.summary || !body.nodes) {
|
|
93
|
+
return Response.json({ error: "missing required fields: code, summary, nodes" }, { status: 400 });
|
|
94
|
+
}
|
|
95
|
+
const id = generateId();
|
|
96
|
+
const diagram: WalkthroughDiagram = {
|
|
97
|
+
code: body.code,
|
|
98
|
+
summary: body.summary,
|
|
99
|
+
nodes: body.nodes,
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
};
|
|
102
|
+
diagrams.set(id, diagram);
|
|
103
|
+
saveDiagram(id, diagram);
|
|
104
|
+
const diagramUrl = `${url.origin}/diagram/${id}`;
|
|
105
|
+
return Response.json({ id, url: diagramUrl }, {
|
|
106
|
+
status: 201,
|
|
107
|
+
headers: {
|
|
108
|
+
"Access-Control-Allow-Origin": "*",
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// OPTIONS /api/diagrams — CORS preflight
|
|
117
|
+
if (url.pathname === "/api/diagrams" && req.method === "OPTIONS") {
|
|
118
|
+
return new Response(null, {
|
|
119
|
+
status: 204,
|
|
79
120
|
headers: {
|
|
80
121
|
"Access-Control-Allow-Origin": "*",
|
|
122
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
123
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
81
124
|
},
|
|
82
125
|
});
|
|
83
|
-
} catch {
|
|
84
|
-
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
|
85
126
|
}
|
|
86
|
-
}
|
|
87
127
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
"Access-Control-Allow-Origin": "*",
|
|
94
|
-
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
95
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
}
|
|
128
|
+
if (url.pathname === "/icon.svg") {
|
|
129
|
+
return new Response(Bun.file(import.meta.dir + "/../icon.svg"), {
|
|
130
|
+
headers: { "Content-Type": "image/svg+xml" },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
99
133
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
// List available diagrams
|
|
135
|
+
if (url.pathname === "/") {
|
|
136
|
+
const html = MODE === "server"
|
|
137
|
+
? generateServerIndexHTML(diagrams.size, GIT_HASH)
|
|
138
|
+
: generateLocalIndexHTML(diagrams, GIT_HASH);
|
|
139
|
+
return new Response(html, {
|
|
140
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
105
143
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const html = MODE === "server"
|
|
109
|
-
? generateServerIndexHTML(diagrams.size, GIT_HASH)
|
|
110
|
-
: generateLocalIndexHTML(diagrams, GIT_HASH);
|
|
111
|
-
return new Response(html, {
|
|
144
|
+
return new Response(generate404HTML("Page not found", "There's nothing at this URL."), {
|
|
145
|
+
status: 404,
|
|
112
146
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
113
147
|
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
},
|
|
121
|
-
});
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
} catch {
|
|
151
|
+
isClient = true;
|
|
152
|
+
console.error(`Web server already running on port ${PORT}, running in client mode`);
|
|
153
|
+
}
|
|
122
154
|
|
|
123
155
|
// --- MCP Server (local mode only) ---
|
|
124
156
|
if (MODE === "local") {
|
|
@@ -158,17 +190,34 @@ Then build the diagram:
|
|
|
158
190
|
nodes: z.record(z.string(), nodeMetadataSchema),
|
|
159
191
|
}),
|
|
160
192
|
}, async ({ code, summary, nodes }) => {
|
|
161
|
-
|
|
162
|
-
const diagram: WalkthroughDiagram = {
|
|
163
|
-
code,
|
|
164
|
-
summary,
|
|
165
|
-
nodes,
|
|
166
|
-
createdAt: new Date().toISOString(),
|
|
167
|
-
};
|
|
168
|
-
diagrams.set(id, diagram);
|
|
169
|
-
saveDiagram(id, diagram);
|
|
193
|
+
let diagramUrl: string;
|
|
170
194
|
|
|
171
|
-
|
|
195
|
+
if (isClient) {
|
|
196
|
+
// POST diagram to the existing web server instance
|
|
197
|
+
const res = await fetch(`http://localhost:${PORT}/api/diagrams`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers: { "Content-Type": "application/json" },
|
|
200
|
+
body: JSON.stringify({ code, summary, nodes }),
|
|
201
|
+
});
|
|
202
|
+
if (!res.ok) {
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: "text", text: `Failed to send diagram to server: ${res.statusText}` }],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const data = await res.json() as { id: string; url: string };
|
|
208
|
+
diagramUrl = data.url;
|
|
209
|
+
} else {
|
|
210
|
+
const id = generateId();
|
|
211
|
+
const diagram: WalkthroughDiagram = {
|
|
212
|
+
code,
|
|
213
|
+
summary,
|
|
214
|
+
nodes,
|
|
215
|
+
createdAt: new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
diagrams.set(id, diagram);
|
|
218
|
+
saveDiagram(id, diagram);
|
|
219
|
+
diagramUrl = `http://localhost:${PORT}/diagram/${id}`;
|
|
220
|
+
}
|
|
172
221
|
|
|
173
222
|
return {
|
|
174
223
|
content: [
|
package/src/storage.ts
CHANGED
|
@@ -34,6 +34,13 @@ export function initDb(): Database {
|
|
|
34
34
|
created_at TEXT
|
|
35
35
|
)
|
|
36
36
|
`);
|
|
37
|
+
db.run(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS shared_urls (
|
|
39
|
+
local_id TEXT PRIMARY KEY,
|
|
40
|
+
remote_url TEXT,
|
|
41
|
+
shared_at TEXT
|
|
42
|
+
)
|
|
43
|
+
`);
|
|
37
44
|
return db;
|
|
38
45
|
}
|
|
39
46
|
|
|
@@ -57,6 +64,18 @@ export function deleteDiagramFromDb(id: string): void {
|
|
|
57
64
|
db.run("DELETE FROM diagrams WHERE id = ?", [id]);
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
export function getSharedUrl(localId: string): string | null {
|
|
68
|
+
const row = db.query("SELECT remote_url FROM shared_urls WHERE local_id = ?").get(localId) as { remote_url: string } | null;
|
|
69
|
+
return row?.remote_url ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function saveSharedUrl(localId: string, remoteUrl: string): void {
|
|
73
|
+
db.run(
|
|
74
|
+
"INSERT OR REPLACE INTO shared_urls (local_id, remote_url, shared_at) VALUES (?, ?, ?)",
|
|
75
|
+
[localId, remoteUrl, new Date().toISOString()]
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
60
79
|
export function generateId(): string {
|
|
61
80
|
return crypto.randomUUID();
|
|
62
81
|
}
|
package/src/template.ts
CHANGED
|
@@ -4,11 +4,12 @@ interface ViewerOptions {
|
|
|
4
4
|
mode?: "local" | "server";
|
|
5
5
|
shareServerUrl?: string;
|
|
6
6
|
diagramId?: string;
|
|
7
|
+
existingShareUrl?: string;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string = "dev", projectRoot: string = "", options: ViewerOptions = {}): string {
|
|
10
11
|
const diagramJSON = JSON.stringify(diagram).replace(/<\//g, "<\\/");
|
|
11
|
-
const { mode = "local", shareServerUrl = "", diagramId = "" } = options;
|
|
12
|
+
const { mode = "local", shareServerUrl = "", diagramId = "", existingShareUrl = "" } = options;
|
|
12
13
|
|
|
13
14
|
return `<!DOCTYPE html>
|
|
14
15
|
<html lang="en">
|
|
@@ -648,6 +649,7 @@ export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string
|
|
|
648
649
|
const SHARE_SERVER_URL = ${JSON.stringify(shareServerUrl)};
|
|
649
650
|
const DIAGRAM_ID = ${JSON.stringify(diagramId)};
|
|
650
651
|
const VIEWER_MODE = ${JSON.stringify(mode)};
|
|
652
|
+
let EXISTING_SHARE_URL = ${JSON.stringify(existingShareUrl)};
|
|
651
653
|
|
|
652
654
|
const COPY_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="8" height="8" rx="1.5"/><path d="M3 11V3a1.5 1.5 0 011.5-1.5H11"/></svg>';
|
|
653
655
|
const CHECK_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.5 3.5 6.5-7"/></svg>';
|
|
@@ -935,6 +937,18 @@ export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string
|
|
|
935
937
|
shareBtn.addEventListener("click", async () => {
|
|
936
938
|
shareBtn.disabled = true;
|
|
937
939
|
try {
|
|
940
|
+
// If we already have a shared URL, just copy it
|
|
941
|
+
if (EXISTING_SHARE_URL) {
|
|
942
|
+
await navigator.clipboard.writeText(EXISTING_SHARE_URL);
|
|
943
|
+
shareBtn.querySelector("span").textContent = "Copied link!";
|
|
944
|
+
shareBtn.classList.add("shared");
|
|
945
|
+
setTimeout(() => {
|
|
946
|
+
shareBtn.querySelector("span").textContent = "Share";
|
|
947
|
+
shareBtn.classList.remove("shared");
|
|
948
|
+
}, 2000);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
938
952
|
const res = await fetch(SHARE_SERVER_URL + "/api/diagrams", {
|
|
939
953
|
method: "POST",
|
|
940
954
|
headers: { "Content-Type": "application/json" },
|
|
@@ -943,6 +957,17 @@ export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string
|
|
|
943
957
|
if (!res.ok) throw new Error("Share failed");
|
|
944
958
|
const data = await res.json();
|
|
945
959
|
await navigator.clipboard.writeText(data.url);
|
|
960
|
+
|
|
961
|
+
// Save the shared URL locally so we don't re-upload next time
|
|
962
|
+
if (DIAGRAM_ID) {
|
|
963
|
+
fetch("/api/diagrams/" + DIAGRAM_ID + "/shared-url", {
|
|
964
|
+
method: "POST",
|
|
965
|
+
headers: { "Content-Type": "application/json" },
|
|
966
|
+
body: JSON.stringify({ url: data.url }),
|
|
967
|
+
}).catch(() => {}); // best-effort
|
|
968
|
+
EXISTING_SHARE_URL = data.url;
|
|
969
|
+
}
|
|
970
|
+
|
|
946
971
|
shareBtn.querySelector("span").textContent = "Copied link!";
|
|
947
972
|
shareBtn.classList.add("shared");
|
|
948
973
|
setTimeout(() => {
|