@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 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
- ## install
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
- ## usage
15
+ ## setup
16
16
 
17
- add to your MCP client config:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taciturnaxolotl/traverse",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Interactive code walkthrough diagrams via MCP",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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
- Bun.serve({
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
- if (diagramMatch) {
28
- const id = diagramMatch[1]!;
29
- const diagram = diagrams.get(id);
30
- if (!diagram) {
31
- return new Response(generate404HTML("Diagram not found", "This diagram doesn't exist or may have expired."), {
32
- status: 404,
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
- // DELETE /api/diagrams/:id
46
- const apiMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)$/);
47
- if (apiMatch && req.method === "DELETE") {
48
- const id = apiMatch[1]!;
49
- if (!diagrams.has(id)) {
50
- return Response.json({ error: "not found" }, { status: 404 });
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
- // POST /api/diagrams (server mode: accept diagrams from remote)
58
- if (url.pathname === "/api/diagrams" && req.method === "POST") {
59
- if (MODE !== "server") {
60
- return Response.json({ error: "POST only available in server mode" }, { status: 403 });
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
- try {
63
- const body = await req.json() as WalkthroughDiagram;
64
- if (!body.code || !body.summary || !body.nodes) {
65
- return Response.json({ error: "missing required fields: code, summary, nodes" }, { status: 400 });
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
- const id = generateId();
68
- const diagram: WalkthroughDiagram = {
69
- code: body.code,
70
- summary: body.summary,
71
- nodes: body.nodes,
72
- createdAt: new Date().toISOString(),
73
- };
74
- diagrams.set(id, diagram);
75
- saveDiagram(id, diagram);
76
- const diagramUrl = `${url.origin}/diagram/${id}`;
77
- return Response.json({ id, url: diagramUrl }, {
78
- status: 201,
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
- // OPTIONS /api/diagrams — CORS preflight
89
- if (url.pathname === "/api/diagrams" && req.method === "OPTIONS") {
90
- return new Response(null, {
91
- status: 204,
92
- headers: {
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
- if (url.pathname === "/icon.svg") {
101
- return new Response(Bun.file(import.meta.dir + "/../icon.svg"), {
102
- headers: { "Content-Type": "image/svg+xml" },
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
- // List available diagrams
107
- if (url.pathname === "/") {
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
- return new Response(generate404HTML("Page not found", "There's nothing at this URL."), {
117
- status: 404,
118
- headers: { "Content-Type": "text/html; charset=utf-8" },
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
- const id = generateId();
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
- const diagramUrl = `http://localhost:${PORT}/diagram/${id}`;
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(() => {