Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

connect-claude.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

connect-claude.tsxBlame1250 lines · 2 contributors
662ce86Claude1/**
2 * Connect Claude — user-facing one-click MCP setup page.
3 *
4 * Routes
5 * GET /connect/claude site-wide auth-gated; hero + token mint +
6 * three install-path cards + live tools grid
7 * + connection status panel.
8 * GET /settings/claude alias → redirect to /connect/claude
9 * (kept for symmetry with /settings/* nav).
10 * POST /connect/claude/token session-cookie mint of a fresh `glc_` PAT
11 * (internally calls the same logic as the
12 * existing /api/v2/auth/install-token route,
13 * so the audit trail and scope rules stay
14 * consistent).
15 * GET /connect/claude/dxt mint-on-download — returns a .dxt zip
16 * bundle with the caller's freshly-minted
17 * PAT embedded in `manifest.json` so
18 * installing requires zero typing.
19 * GET /api/connect/status JSON poll endpoint for the live
20 * "last called" + "total calls" panel.
21 *
22 * Hard rules (per the build task brief):
23 * - DO NOT modify any shared view file or any of the MCP server files.
24 * - The marketing /gluecron.dxt remains untouched (served by src/routes/dxt.ts);
25 * this file exposes the personalized variant at a different URL.
26 * - Re-use the existing PAT mechanism — never invent a parallel mint flow.
27 *
28 * Design language follows commits a004c46 (dashboard hero gradient), 98eb360
29 * (settings card polish), and 7a99d47 (numbered import option cards). All CSS
30 * is scoped under `.connect-claude-` so it cannot bleed.
31 */
32
33import { Hono } from "hono";
34import { and, desc, eq } from "drizzle-orm";
35import { deflateRawSync } from "node:zlib";
36import { db } from "../db";
37import { apiTokens, auditLog } from "../db/schema";
38import type { AuthEnv } from "../middleware/auth";
39import { requireAuth } from "../middleware/auth";
40import { Layout } from "../views/layout";
41import { audit } from "../lib/notify";
42import { config } from "../lib/config";
43import { defaultTools } from "../lib/mcp-tools";
44
45const connectClaude = new Hono<AuthEnv>();
46
47// ─── Auth guard ────────────────────────────────────────────────────────────
48// Every user-facing route here demands a logged-in user. Public 404 visitors
49// will get the standard /login redirect from requireAuth.
50connectClaude.use("/connect/claude", requireAuth);
51connectClaude.use("/connect/claude/*", requireAuth);
52connectClaude.use("/settings/claude", requireAuth);
53connectClaude.use("/api/connect/status", requireAuth);
54
55// ─── PAT-mint helpers ──────────────────────────────────────────────────────
56// We deliberately duplicate the tiny pieces of /api/v2/auth/install-token
57// rather than importing its handler, because the install-token handler does
58// its own session-cookie gating (we already have the user from requireAuth)
59// and HTTP body parsing (we want a programmatic mint). The on-disk shape
60// (token format, hash, audit row) is identical.
61
62function generateInstallToken(): string {
63 const bytes = crypto.getRandomValues(new Uint8Array(32));
64 return (
65 "glc_" +
66 Array.from(bytes)
67 .map((b) => b.toString(16).padStart(2, "0"))
68 .join("")
69 );
70}
71
72async function hashInstallToken(token: string): Promise<string> {
73 const data = new TextEncoder().encode(token);
74 const hash = await crypto.subtle.digest("SHA-256", data);
75 return Array.from(new Uint8Array(hash))
76 .map((b) => b.toString(16).padStart(2, "0"))
77 .join("");
78}
79
80async function mintConnectPat(opts: {
81 userId: string;
82 ip?: string;
83 userAgent?: string;
84 source: "ui" | "dxt";
85}): Promise<{ token: string; id: string; name: string }> {
86 const stamp = Math.floor(Date.now() / 1000)
87 .toString(36)
88 .slice(-6);
89 const name =
90 opts.source === "dxt"
91 ? `gluecron-claude-dxt-${stamp}`
92 : `gluecron-claude-ui-${stamp}`;
93 const token = generateInstallToken();
94 const tokenHash = await hashInstallToken(token);
95 const tokenPrefix = token.slice(0, 12);
96
97 const [row] = await db
98 .insert(apiTokens)
99 .values({
100 userId: opts.userId,
101 name,
102 tokenHash,
103 tokenPrefix,
104 // Same defaults the install-token endpoint uses for `scope=admin`.
105 scopes: "admin,repo,user",
106 })
107 .returning();
108
109 await audit({
110 userId: opts.userId,
111 action:
112 opts.source === "dxt"
113 ? "mcp.dxt.minted"
114 : "auth.install_token.created",
115 targetType: "api_token",
116 targetId: row?.id,
117 ip: opts.ip,
118 userAgent: opts.userAgent,
119 metadata: { name, scope: "admin", prefix: tokenPrefix, source: opts.source },
120 });
121
122 return { token, id: row!.id, name };
123}
124
125// ─── Minimal in-process .zip writer ────────────────────────────────────────
126// `.dxt` is a plain zip of (manifest.json, icon.png, …). We rebuild the
127// archive on-the-fly with the user's PAT baked into manifest.json. Using
128// `node:zlib.deflateRawSync` keeps the bundle small without a userland
129// dependency. Format: PKZIP 2.0 (no zip64, no encryption).
130
131function crc32(buf: Uint8Array): number {
132 let c = 0xffffffff;
133 for (let i = 0; i < buf.length; i++) {
134 c ^= buf[i]!;
135 for (let k = 0; k < 8; k++) {
136 c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
137 }
138 }
139 return (c ^ 0xffffffff) >>> 0;
140}
141
142type ZipEntry = { name: string; data: Uint8Array };
143
144function buildZip(entries: ZipEntry[]): Uint8Array {
145 const localParts: Uint8Array[] = [];
146 const centralParts: Uint8Array[] = [];
147 let offset = 0;
148
149 for (const entry of entries) {
150 const nameBytes = new TextEncoder().encode(entry.name);
151 const crc = crc32(entry.data);
152 const uncompressedSize = entry.data.length;
153
154 // Try deflate; fall back to STORED if it'd inflate the payload.
155 let method = 8;
156 let compressed: Uint8Array;
157 try {
158 const out = deflateRawSync(entry.data);
159 compressed = new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
160 if (compressed.length >= uncompressedSize) {
161 method = 0;
162 compressed = entry.data;
163 }
164 } catch {
165 method = 0;
166 compressed = entry.data;
167 }
168 const compressedSize = compressed.length;
169
170 // Local file header.
171 const local = new Uint8Array(30 + nameBytes.length + compressedSize);
172 const lv = new DataView(local.buffer);
173 lv.setUint32(0, 0x04034b50, true); // local file sig
174 lv.setUint16(4, 20, true); // version
175 lv.setUint16(6, 0, true); // flags
176 lv.setUint16(8, method, true);
177 lv.setUint16(10, 0, true); // mod time
178 lv.setUint16(12, 0, true); // mod date
179 lv.setUint32(14, crc, true);
180 lv.setUint32(18, compressedSize, true);
181 lv.setUint32(22, uncompressedSize, true);
182 lv.setUint16(26, nameBytes.length, true);
183 lv.setUint16(28, 0, true); // extra
184 local.set(nameBytes, 30);
185 local.set(compressed, 30 + nameBytes.length);
186 localParts.push(local);
187
188 // Central directory record.
189 const central = new Uint8Array(46 + nameBytes.length);
190 const cv = new DataView(central.buffer);
191 cv.setUint32(0, 0x02014b50, true);
192 cv.setUint16(4, 20, true); // version made by
193 cv.setUint16(6, 20, true); // version needed
194 cv.setUint16(8, 0, true); // flags
195 cv.setUint16(10, method, true);
196 cv.setUint16(12, 0, true); // mod time
197 cv.setUint16(14, 0, true); // mod date
198 cv.setUint32(16, crc, true);
199 cv.setUint32(20, compressedSize, true);
200 cv.setUint32(24, uncompressedSize, true);
201 cv.setUint16(28, nameBytes.length, true);
202 cv.setUint16(30, 0, true); // extra
203 cv.setUint16(32, 0, true); // comment
204 cv.setUint16(34, 0, true); // disk
205 cv.setUint16(36, 0, true); // internal attrs
206 cv.setUint32(38, 0, true); // external attrs
207 cv.setUint32(42, offset, true); // local header offset
208 central.set(nameBytes, 46);
209 centralParts.push(central);
210
211 offset += local.length;
212 }
213
214 const centralSize = centralParts.reduce((n, p) => n + p.length, 0);
215 const centralOffset = offset;
216
217 // End of central directory record.
218 const end = new Uint8Array(22);
219 const ev = new DataView(end.buffer);
220 ev.setUint32(0, 0x06054b50, true);
221 ev.setUint16(4, 0, true);
222 ev.setUint16(6, 0, true);
223 ev.setUint16(8, entries.length, true);
224 ev.setUint16(10, entries.length, true);
225 ev.setUint32(12, centralSize, true);
226 ev.setUint32(16, centralOffset, true);
227 ev.setUint16(20, 0, true);
228
229 const total =
230 localParts.reduce((n, p) => n + p.length, 0) + centralSize + end.length;
231 const out = new Uint8Array(total);
232 let pos = 0;
233 for (const p of localParts) {
234 out.set(p, pos);
235 pos += p.length;
236 }
237 for (const p of centralParts) {
238 out.set(p, pos);
239 pos += p.length;
240 }
241 out.set(end, pos);
242 return out;
243}
244
245// ─── Personalized .dxt manifest ───────────────────────────────────────────
246// We embed the freshly-minted PAT directly in the manifest. The user only has
247// to double-click the file and Claude Desktop picks up everything else.
248
249function buildPersonalizedManifest(opts: {
250 username: string;
251 token: string;
252 host: string;
253}): string {
254 const tools = Object.values(defaultTools()).map((h) => ({
255 name: h.tool.name,
256 description: h.tool.description.split(".")[0] + ".",
257 }));
258 const manifest = {
259 dxt_version: "0.1",
260 name: "gluecron",
261 display_name: "Gluecron",
262 version: "1.0.0",
263 description:
264 "AI-native git hosting. Claude can open PRs, review code, merge, manage issues, and ship — all on Gluecron.",
265 long_description: `Personalized for ${opts.username}. Drop into Claude Desktop and you are connected — no further configuration required.`,
266 author: { name: "Gluecron", url: "https://gluecron.com" },
267 homepage: opts.host,
268 documentation: `${opts.host}/help#mcp`,
269 support: `${opts.host}/help`,
270 server: {
271 type: "http",
272 endpoint: `${opts.host}/mcp`,
273 headers: {
274 Authorization: `Bearer ${opts.token}`,
275 },
276 },
277 user_config: {
278 gluecron_host: {
279 type: "string",
280 title: "Gluecron host",
281 description: "URL of your Gluecron instance.",
282 default: opts.host,
283 required: false,
284 },
285 },
286 tools,
287 compatibility: {
288 claude_desktop: ">=0.10.0",
289 platforms: ["darwin", "win32", "linux"],
290 },
291 personalized_for: opts.username,
292 generated_at: new Date().toISOString(),
293 };
294 return JSON.stringify(manifest, null, 2);
295}
296
297// ─── CSS ───────────────────────────────────────────────────────────────────
298const styles = `
eed4684Claude299 .connect-claude-container { max-width: 1320px; margin: 0 auto; padding: 0 0 var(--space-6); }
662ce86Claude300
301 /* ─── Hero ─── */
302 .connect-claude-hero {
303 position: relative;
304 margin-bottom: var(--space-6);
305 padding: var(--space-5) var(--space-6);
306 background: var(--bg-elevated);
307 border: 1px solid var(--border);
308 border-radius: 18px;
309 overflow: hidden;
310 }
311 .connect-claude-hero::before {
312 content: '';
313 position: absolute;
314 top: 0; left: 0; right: 0;
315 height: 2px;
6fd5915Claude316 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
662ce86Claude317 opacity: 0.75;
318 pointer-events: none;
319 }
320 .connect-claude-hero-orb {
321 position: absolute;
322 inset: -30% -10% auto auto;
323 width: 460px; height: 460px;
6fd5915Claude324 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
662ce86Claude325 filter: blur(80px);
326 opacity: 0.65;
327 pointer-events: none;
328 animation: connectClaudeOrb 16s ease-in-out infinite;
329 }
330 @keyframes connectClaudeOrb {
331 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
332 50% { transform: scale(1.08) translate(-12px, 8px); opacity: 0.85; }
333 }
334 @media (prefers-reduced-motion: reduce) {
335 .connect-claude-hero-orb { animation: none; }
336 }
337 .connect-claude-hero-inner { position: relative; z-index: 1; max-width: 680px; }
338 .connect-claude-eyebrow {
339 font-size: 13px;
340 color: var(--text-muted);
341 margin-bottom: var(--space-2);
342 }
343 .connect-claude-eyebrow strong {
344 color: var(--accent);
345 font-weight: 600;
346 }
347 .connect-claude-title {
348 font-size: clamp(32px, 5vw, 48px);
349 font-family: var(--font-display);
350 font-weight: 800;
351 letter-spacing: -0.03em;
352 line-height: 1.04;
353 margin: 0 0 var(--space-3);
354 color: var(--text-strong);
355 }
356 .connect-claude-title .gradient {
6fd5915Claude357 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
662ce86Claude358 -webkit-background-clip: text;
359 background-clip: text;
360 -webkit-text-fill-color: transparent;
361 color: transparent;
362 }
363 .connect-claude-sub {
364 font-size: 16px;
365 color: var(--text-muted);
366 margin: 0;
367 line-height: 1.55;
368 }
369
370 /* ─── Section ─── */
371 .connect-claude-section {
372 position: relative;
373 margin-bottom: var(--space-5);
374 background: var(--bg-elevated);
375 border: 1px solid var(--border);
376 border-radius: 14px;
377 overflow: hidden;
378 }
379 .connect-claude-section-head {
380 padding: var(--space-4) var(--space-5) var(--space-2);
381 }
382 .connect-claude-step-row {
383 display: flex;
384 align-items: center;
385 gap: 12px;
386 margin-bottom: 6px;
387 }
388 .connect-claude-step-num {
389 display: inline-flex;
390 align-items: center;
391 justify-content: center;
392 width: 26px; height: 26px;
393 border-radius: 50%;
6fd5915Claude394 background: linear-gradient(135deg, rgba(91,110,232,0.20), rgba(95,143,160,0.14));
662ce86Claude395 color: #c5b3ff;
6fd5915Claude396 border: 1px solid rgba(91,110,232,0.40);
662ce86Claude397 font-family: var(--font-display);
398 font-weight: 700;
399 font-size: 13px;
400 }
401 .connect-claude-section-title {
402 font-family: var(--font-display);
403 font-size: 18px;
404 font-weight: 700;
405 letter-spacing: -0.012em;
406 color: var(--text-strong);
407 margin: 0;
408 }
409 .connect-claude-section-desc {
410 margin: 0;
411 color: var(--text-muted);
412 font-size: 14px;
413 line-height: 1.5;
414 }
415 .connect-claude-section-body {
416 padding: var(--space-2) var(--space-5) var(--space-5);
417 }
418
419 /* ─── Token block ─── */
420 .connect-claude-token-row {
421 display: flex;
422 gap: 10px;
423 align-items: stretch;
424 flex-wrap: wrap;
425 margin-top: var(--space-3);
426 }
427 .connect-claude-btn {
428 appearance: none;
429 border: 1px solid var(--border-strong);
430 background: var(--bg-secondary);
431 color: var(--text);
432 padding: 10px 16px;
433 border-radius: 10px;
434 font-family: inherit;
435 font-size: 14px;
436 font-weight: 500;
437 cursor: pointer;
438 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
439 text-decoration: none;
440 display: inline-flex; align-items: center; gap: 8px;
441 }
442 .connect-claude-btn:hover {
443 border-color: var(--border-focus);
444 background: rgba(255,255,255,0.03);
445 transform: translateY(-1px);
446 }
447 .connect-claude-btn-primary {
6fd5915Claude448 border-color: rgba(91,110,232,0.45);
449 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.12));
662ce86Claude450 color: var(--text-strong);
451 }
452 .connect-claude-btn-primary:hover {
6fd5915Claude453 border-color: rgba(91,110,232,0.65);
454 background: linear-gradient(135deg, rgba(91,110,232,0.26), rgba(95,143,160,0.18));
662ce86Claude455 }
456 .connect-claude-token-display {
457 flex: 1;
458 min-width: 220px;
459 display: flex;
460 align-items: center;
461 gap: 10px;
462 padding: 10px 12px;
463 background: var(--bg-secondary);
464 border: 1px solid var(--border-subtle);
465 border-radius: 10px;
466 font-family: var(--font-mono);
467 font-size: 12.5px;
468 color: var(--text-strong);
469 word-break: break-all;
470 }
471 .connect-claude-token-display .dot {
472 width: 8px; height: 8px;
473 border-radius: 50%;
e589f77ccantynz-alt474 background: var(--green);
662ce86Claude475 box-shadow: 0 0 8px rgba(63,185,80,0.6);
476 flex-shrink: 0;
477 }
478 .connect-claude-token-empty {
479 color: var(--text-faint);
480 font-style: italic;
481 }
482 .connect-claude-token-note {
483 margin-top: 10px;
484 font-size: 12.5px;
485 color: var(--text-muted);
486 }
487
488 /* ─── Three option cards (Step 2) ─── */
489 .connect-claude-options {
490 display: grid;
491 grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
492 gap: var(--space-3);
493 }
494 .connect-claude-option {
495 position: relative;
496 padding: var(--space-4);
497 background: var(--bg-secondary);
498 border: 1px solid var(--border-subtle);
499 border-radius: 12px;
500 display: flex;
501 flex-direction: column;
502 gap: 10px;
503 transition: border-color 150ms ease, transform 150ms ease;
504 }
505 .connect-claude-option:hover {
506 border-color: var(--border-strong);
507 transform: translateY(-1px);
508 }
509 .connect-claude-option.is-recommended {
6fd5915Claude510 border-color: rgba(91,110,232,0.45);
511 background: linear-gradient(180deg, rgba(91,110,232,0.06), var(--bg-secondary) 60%);
662ce86Claude512 }
513 .connect-claude-option-badge {
514 position: absolute;
515 top: -10px; right: 12px;
516 padding: 2px 8px;
6fd5915Claude517 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
662ce86Claude518 color: #fff;
519 border-radius: 999px;
520 font-size: 11px;
521 font-weight: 600;
522 letter-spacing: 0.02em;
523 text-transform: uppercase;
524 }
525 .connect-claude-option-title {
526 font-family: var(--font-display);
527 font-weight: 700;
528 font-size: 15px;
529 color: var(--text-strong);
530 margin: 0;
531 }
532 .connect-claude-option-desc {
533 font-size: 13px;
534 color: var(--text-muted);
535 margin: 0;
536 line-height: 1.5;
537 }
538 .connect-claude-code {
539 position: relative;
540 background: var(--bg-tertiary, #0d1018);
541 border: 1px solid var(--border-subtle);
542 border-radius: 8px;
543 padding: 10px 12px;
544 font-family: var(--font-mono);
545 font-size: 12px;
546 color: var(--text-strong);
547 overflow-x: auto;
548 white-space: pre;
549 line-height: 1.5;
550 }
551 .connect-claude-copy {
552 appearance: none;
553 border: 1px solid var(--border-subtle);
554 background: var(--bg-elevated);
555 color: var(--text-muted);
556 padding: 6px 10px;
557 border-radius: 8px;
558 font-family: inherit;
559 font-size: 12px;
560 cursor: pointer;
561 align-self: flex-start;
562 }
563 .connect-claude-copy:hover {
564 color: var(--text-strong);
565 border-color: var(--border-strong);
566 }
567
19cbf20ccantynz-alt568 /* ─── First-task repo list (Step 4) ─── */
569 .connect-claude-repos {
570 list-style: none;
571 margin: var(--space-4) 0 0;
572 padding: 0;
573 display: flex;
574 flex-direction: column;
575 gap: 8px;
576 }
577 .connect-claude-repo {
578 display: flex;
579 align-items: center;
580 justify-content: space-between;
581 gap: var(--space-3);
582 padding: 14px 16px;
583 border: 1px solid var(--border);
584 border-radius: 12px;
585 background: var(--bg-elevated);
586 flex-wrap: wrap;
587 }
588 .connect-claude-repo-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
589 .connect-claude-repo-name {
590 font-family: var(--font-display);
591 font-size: 15px;
592 font-weight: 650;
593 color: var(--text-strong);
594 text-decoration: none;
595 }
596 .connect-claude-repo-name:hover { color: var(--accent); }
597 .connect-claude-repo-meta {
598 font-size: 12.5px;
599 color: var(--text-muted);
600 font-variant-numeric: tabular-nums;
601 }
602
662ce86Claude603 /* ─── Tools grid (Step 3) ─── */
604 .connect-claude-tools-group {
605 margin-top: var(--space-3);
606 }
607 .connect-claude-tools-group-label {
608 font-size: 11px;
609 font-weight: 600;
610 letter-spacing: 0.08em;
611 text-transform: uppercase;
612 color: var(--text-faint);
613 margin-bottom: 8px;
614 }
615 .connect-claude-tools {
616 display: grid;
617 grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
618 gap: 10px;
619 }
620 .connect-claude-tool {
621 padding: 12px 14px;
622 background: var(--bg-secondary);
623 border: 1px solid var(--border-subtle);
624 border-radius: 10px;
625 display: flex;
626 flex-direction: column;
627 gap: 4px;
628 }
629 .connect-claude-tool.is-write {
630 border-color: rgba(248, 197, 81, 0.30);
631 background: linear-gradient(180deg, rgba(248,197,81,0.04), var(--bg-secondary) 60%);
632 }
633 .connect-claude-tool-name {
634 font-family: var(--font-mono);
635 font-size: 12.5px;
636 font-weight: 600;
637 color: var(--text-strong);
638 }
639 .connect-claude-tool-desc {
640 font-size: 12px;
641 color: var(--text-muted);
642 line-height: 1.45;
643 }
644
645 /* ─── Status panel (Step 4) ─── */
646 .connect-claude-status {
647 display: flex;
648 align-items: center;
649 gap: 14px;
650 padding: var(--space-3);
651 background: var(--bg-secondary);
652 border: 1px solid var(--border-subtle);
653 border-radius: 10px;
654 }
655 .connect-claude-status-dot {
656 width: 10px; height: 10px;
657 border-radius: 50%;
658 background: var(--text-faint);
659 flex-shrink: 0;
660 }
661 .connect-claude-status.is-live .connect-claude-status-dot {
e589f77ccantynz-alt662 background: var(--green);
662ce86Claude663 box-shadow: 0 0 8px rgba(63,185,80,0.6);
664 }
665 .connect-claude-status-main { flex: 1; }
666 .connect-claude-status-title {
667 font-weight: 600;
668 color: var(--text-strong);
669 font-size: 14px;
670 }
671 .connect-claude-status-sub {
672 font-size: 12.5px;
673 color: var(--text-muted);
674 margin-top: 2px;
675 }
676
677 @media (max-width: 600px) {
678 .connect-claude-options { grid-template-columns: 1fr; }
679 }
680`;
681
682// ─── Tools-by-mode partitioning ────────────────────────────────────────────
683// Anything whose name contains a verb like create/comment/close/reopen/merge
684// is a write tool. Everything else is read-only. Keeps the surface honest
685// without having to extend the McpTool shape.
686function isWriteTool(name: string): boolean {
687 return /(create|comment|close|reopen|merge)/.test(name);
688}
689
690// ─── Pre-rendered server data ──────────────────────────────────────────────
691function buildToolsView(): {
692 read: Array<{ name: string; description: string }>;
693 write: Array<{ name: string; description: string }>;
694} {
695 const handlers = defaultTools();
696 const read: Array<{ name: string; description: string }> = [];
697 const write: Array<{ name: string; description: string }> = [];
698 for (const h of Object.values(handlers)) {
699 const entry = { name: h.tool.name, description: h.tool.description };
700 if (isWriteTool(entry.name)) write.push(entry);
701 else read.push(entry);
702 }
703 return { read, write };
704}
705
706function clientScript() {
707 // Inline page state machine: token mint via fetch → reveal → snippets &
708 // download link refreshed in-place. No framework, just DOM.
709 return `
710 (function(){
711 const elGen = document.getElementById('cc-generate');
712 const elDisplay = document.getElementById('cc-token-display');
713 const elDxt = document.getElementById('cc-dxt-link');
714 const elCliCode = document.getElementById('cc-cli-code');
715 const elJsonCode = document.getElementById('cc-json-code');
716 const host = ${JSON.stringify(config.appBaseUrl || "https://gluecron.com")};
717
718 function applyToken(tok) {
719 if (elDisplay) {
720 elDisplay.innerHTML = '<span class="dot" aria-hidden="true"></span><span>'+tok+'</span>';
721 elDisplay.classList.remove('connect-claude-token-empty');
722 }
723 if (elCliCode) {
724 elCliCode.textContent = 'claude mcp add gluecron ' + host + '/mcp --header "Authorization: Bearer ' + tok + '"';
725 }
726 if (elJsonCode) {
727 const cfg = {
728 mcpServers: {
729 gluecron: {
730 transport: 'http',
731 url: host + '/mcp',
732 headers: { Authorization: 'Bearer ' + tok }
733 }
734 }
735 };
736 elJsonCode.textContent = JSON.stringify(cfg, null, 2);
737 }
738 }
739
740 if (elGen) {
741 elGen.addEventListener('click', async function() {
742 elGen.disabled = true;
743 elGen.textContent = 'Generating…';
744 try {
745 const res = await fetch('/connect/claude/token', {
746 method: 'POST',
747 credentials: 'same-origin',
748 headers: { 'content-type': 'application/json' },
749 body: '{}'
750 });
751 if (!res.ok) throw new Error('mint failed: ' + res.status);
752 const j = await res.json();
753 applyToken(j.token);
754 elGen.textContent = 'Token generated ✓';
755 } catch (e) {
756 elGen.disabled = false;
757 elGen.textContent = 'Try again';
758 console.error(e);
759 }
760 });
761 }
762
763 // Copy buttons
764 document.querySelectorAll('[data-cc-copy]').forEach(function(btn){
765 btn.addEventListener('click', function(){
766 const target = document.getElementById(btn.getAttribute('data-cc-copy'));
767 if (!target) return;
768 const text = target.textContent || '';
769 if (navigator.clipboard && navigator.clipboard.writeText) {
770 navigator.clipboard.writeText(text).then(function(){
771 const prev = btn.textContent;
772 btn.textContent = 'Copied ✓';
773 setTimeout(function(){ btn.textContent = prev; }, 1400);
774 });
775 }
776 });
777 });
778
779 // Optional live-status poll (every 30s). Best-effort — failures stay silent.
780 const statusEl = document.getElementById('cc-status');
781 if (statusEl) {
782 async function refresh() {
783 try {
784 const res = await fetch('/api/connect/status', { credentials: 'same-origin' });
785 if (!res.ok) return;
786 const j = await res.json();
787 if (!j) return;
788 const title = statusEl.querySelector('.connect-claude-status-title');
789 const sub = statusEl.querySelector('.connect-claude-status-sub');
790 if (j.totalCalls > 0) {
791 statusEl.classList.add('is-live');
792 if (title) title.textContent = 'Connected · ' + j.totalCalls + ' MCP call' + (j.totalCalls === 1 ? '' : 's');
793 if (sub && j.lastCalledAt) sub.textContent = 'Last call ' + new Date(j.lastCalledAt).toLocaleString();
794 }
795 } catch (_) { /* ignore */ }
796 }
797 refresh();
798 setInterval(refresh, 30000);
799 }
800 })();
801 `;
802}
803
804// ─── GET /connect/claude ───────────────────────────────────────────────────
805connectClaude.get("/connect/claude", async (c) => {
806 const user = c.get("user")!;
807 const tools = buildToolsView();
19cbf20ccantynz-alt808
809 // The user's repositories, with how much of each is semantically indexed —
810 // that number is what decides whether the agent can actually search the
811 // codebase or is working blind. Best-effort: this page's job is to get
812 // Claude connected, and it must still render if the query fails.
813 let firstTaskRepos: Array<{ name: string; indexedFiles: number }> = [];
814 try {
815 // code_embeddings is queried as a correlated subquery rather than a join
816 // so a repo with no embeddings still returns a row (with 0).
817 const { repositories } = await import("../db/schema");
818 const { eq: eqOp, sql: sqlOp, desc: descOp } = await import("drizzle-orm");
819 const rows = await db
820 .select({
821 name: repositories.name,
822 indexedFiles: sqlOp<number>`(
823 SELECT count(*)::int FROM code_embeddings
824 WHERE code_embeddings.repository_id = ${repositories.id}
825 )`,
826 })
827 .from(repositories)
828 .where(eqOp(repositories.ownerId, user.id))
829 .orderBy(descOp(repositories.updatedAt))
830 .limit(3);
831 firstTaskRepos = rows.map((r) => ({
832 name: r.name,
833 indexedFiles: Number(r.indexedFiles ?? 0),
834 }));
835 } catch (err) {
836 console.error("[connect-claude] first-task repos query failed:", err);
837 }
662ce86Claude838 const host = config.appBaseUrl || "https://gluecron.com";
839
840 // Best-effort: look up the most recent MCP audit row for this user.
841 let lastCalledAt: string | null = null;
842 let totalCalls = 0;
843 try {
844 const rows = await db
845 .select({ createdAt: auditLog.createdAt })
846 .from(auditLog)
847 .where(
848 and(eq(auditLog.userId, user.id), eq(auditLog.action, "mcp.tool.called"))
849 )
850 .orderBy(desc(auditLog.createdAt))
851 .limit(1);
852 if (rows.length > 0 && rows[0]) {
853 lastCalledAt = rows[0].createdAt.toISOString();
854 }
855 // Cheap count: pull up to 500 rows and use the length. Good enough for
856 // a status badge; we don't need exact for-large-N numbers here.
857 const counted = await db
858 .select({ id: auditLog.id })
859 .from(auditLog)
860 .where(
861 and(eq(auditLog.userId, user.id), eq(auditLog.action, "mcp.tool.called"))
862 )
863 .limit(500);
864 totalCalls = counted.length;
865 } catch {
866 // audit_log may not exist in some test envs — render the empty state.
867 }
868
869 const placeholderCli = `claude mcp add gluecron ${host}/mcp --header "Authorization: Bearer <YOUR TOKEN>"`;
870 const placeholderJson = JSON.stringify(
871 {
872 mcpServers: {
873 gluecron: {
874 transport: "http",
875 url: `${host}/mcp`,
876 headers: { Authorization: "Bearer <YOUR TOKEN>" },
877 },
878 },
879 },
880 null,
881 2
882 );
883
884 return c.html(
885 <Layout title="Connect Claude" user={user}>
886 <style dangerouslySetInnerHTML={{ __html: styles }} />
887 <div class="connect-claude-container">
888 {/* ─── Hero ─── */}
889 <section class="connect-claude-hero">
890 <div class="connect-claude-hero-orb" aria-hidden="true" />
891 <div class="connect-claude-hero-inner">
892 <div class="connect-claude-eyebrow">
893 Connect Claude · <strong>@{user.username}</strong>
894 </div>
895 <h1 class="connect-claude-title">
896 Drive gluecron from <span class="gradient">Claude</span>.
897 </h1>
898 <p class="connect-claude-sub">
899 One-click setup. Your Claude Desktop, Claude Code, or any
900 MCP-aware client gets full access to your repos — search code,
901 read files, open issues, ship pull requests.
902 </p>
903 </div>
904 </section>
905
906 {/* ─── Step 1: Generate token ─── */}
907 <section class="connect-claude-section">
908 <div class="connect-claude-section-head">
909 <div class="connect-claude-step-row">
910 <span class="connect-claude-step-num">1</span>
911 <h2 class="connect-claude-section-title">
912 Generate your Claude token
913 </h2>
914 </div>
915 <p class="connect-claude-section-desc">
916 A fresh personal access token, scoped to <code>admin,repo,user</code>{" "}
917 — same shape as the existing install script mints. Shown once;
918 we don't store the plaintext.
919 </p>
920 </div>
921 <div class="connect-claude-section-body">
922 <div class="connect-claude-token-row">
923 <button
924 id="cc-generate"
925 type="button"
926 class="connect-claude-btn connect-claude-btn-primary"
927 >
928 Generate token
929 </button>
930 <div
931 id="cc-token-display"
932 class="connect-claude-token-display connect-claude-token-empty"
933 >
934 <span>No token yet — click Generate.</span>
935 </div>
936 </div>
937 <p class="connect-claude-token-note">
938 Already have a PAT? Skip to Step 2 — every install path accepts
939 tokens minted at <a href="/settings/tokens">/settings/tokens</a>.
940 </p>
941 </div>
942 </section>
943
944 {/* ─── Step 2: Install path ─── */}
945 <section class="connect-claude-section">
946 <div class="connect-claude-section-head">
947 <div class="connect-claude-step-row">
948 <span class="connect-claude-step-num">2</span>
949 <h2 class="connect-claude-section-title">Pick your install path</h2>
950 </div>
951 <p class="connect-claude-section-desc">
952 Three ways to wire Claude up. Generate the token in Step 1 first
953 so the snippets below auto-fill.
954 </p>
955 </div>
956 <div class="connect-claude-section-body">
957 <div class="connect-claude-options">
958 {/* Desktop */}
959 <div class="connect-claude-option is-recommended">
960 <span class="connect-claude-option-badge">Recommended</span>
961 <h3 class="connect-claude-option-title">Claude Desktop</h3>
962 <p class="connect-claude-option-desc">
963 Download a personalized <code>.dxt</code> bundle with your
964 token already embedded. Double-click to install in Claude
965 Desktop.
966 </p>
967 <a
968 id="cc-dxt-link"
969 href="/connect/claude/dxt"
970 class="connect-claude-btn connect-claude-btn-primary"
971 download={`gluecron-${user.username}.dxt`}
972 >
973 Download personalized .dxt
974 </a>
975 </div>
976
977 {/* CLI */}
978 <div class="connect-claude-option">
979 <h3 class="connect-claude-option-title">Claude Code (CLI)</h3>
980 <p class="connect-claude-option-desc">
981 Wire Claude Code up over MCP with a single command:
982 </p>
983 <pre id="cc-cli-code" class="connect-claude-code">{placeholderCli}</pre>
984 <button
985 type="button"
986 class="connect-claude-copy"
987 data-cc-copy="cc-cli-code"
988 >
989 Copy command
990 </button>
991 </div>
992
993 {/* Manual */}
994 <div class="connect-claude-option">
995 <h3 class="connect-claude-option-title">Manual config</h3>
996 <p class="connect-claude-option-desc">
997 Paste this into your <code>claude_desktop_config.json</code>:
998 </p>
999 <pre id="cc-json-code" class="connect-claude-code">{placeholderJson}</pre>
1000 <button
1001 type="button"
1002 class="connect-claude-copy"
1003 data-cc-copy="cc-json-code"
1004 >
1005 Copy JSON
1006 </button>
1007 </div>
1008 </div>
1009 </div>
1010 </section>
1011
1012 {/* ─── Step 3: Tools grid ─── */}
1013 <section class="connect-claude-section">
1014 <div class="connect-claude-section-head">
1015 <div class="connect-claude-step-row">
1016 <span class="connect-claude-step-num">3</span>
1017 <h2 class="connect-claude-section-title">
1018 Tools your Claude will have
1019 </h2>
1020 </div>
1021 <p class="connect-claude-section-desc">
1022 {tools.read.length + tools.write.length} MCP tools, live from the
1023 server. Read-only first; write tools require <code>admin</code> scope.
1024 </p>
1025 </div>
1026 <div class="connect-claude-section-body">
1027 <div class="connect-claude-tools-group">
1028 <div class="connect-claude-tools-group-label">
1029 Read · {tools.read.length}
1030 </div>
1031 <div class="connect-claude-tools">
1032 {tools.read.map((t) => (
1033 <div class="connect-claude-tool">
1034 <span class="connect-claude-tool-name">{t.name}</span>
1035 <span class="connect-claude-tool-desc">{t.description}</span>
1036 </div>
1037 ))}
1038 </div>
1039 </div>
1040 <div class="connect-claude-tools-group">
1041 <div class="connect-claude-tools-group-label">
1042 Write · {tools.write.length}
1043 </div>
1044 <div class="connect-claude-tools">
1045 {tools.write.map((t) => (
1046 <div class="connect-claude-tool is-write">
1047 <span class="connect-claude-tool-name">{t.name}</span>
1048 <span class="connect-claude-tool-desc">{t.description}</span>
1049 </div>
1050 ))}
1051 </div>
1052 </div>
1053 </div>
1054 </section>
1055
ebbb527Claude1056 {/* ─── Hosted Claude loops CTA ─── */}
1057 <section class="connect-claude-section">
1058 <div class="connect-claude-section-head">
1059 <div class="connect-claude-step-row">
1060 <span class="connect-claude-step-num">⚡</span>
1061 <h2 class="connect-claude-section-title">
1062 Host a Claude tool-use loop
1063 </h2>
1064 </div>
1065 <p class="connect-claude-section-desc">
1066 Paste a Claude loop, get a hosted endpoint with a built-in budget
1067 meter. Your code runs in a 30s sandboxed Bun subprocess —
1068 no infra to wire up.
1069 </p>
1070 </div>
1071 <div class="connect-claude-section-body">
1072 <a
1073 href="/connect/claude/deploy"
1074 class="connect-claude-btn connect-claude-btn-primary"
1075 >
1076 Open Claude loops deploy wizard →
1077 </a>
1078 </div>
1079 </section>
1080
662ce86Claude1081 {/* ─── Step 4: Status ─── */}
1082 <section class="connect-claude-section">
1083 <div class="connect-claude-section-head">
1084 <div class="connect-claude-step-row">
1085 <span class="connect-claude-step-num">4</span>
1086 <h2 class="connect-claude-section-title">Connection status</h2>
1087 </div>
1088 <p class="connect-claude-section-desc">
1089 We watch the MCP audit log for tool calls signed by your tokens.
1090 First call shows up within seconds.
1091 </p>
1092 </div>
1093 <div class="connect-claude-section-body">
1094 <div
1095 id="cc-status"
1096 class={`connect-claude-status${
1097 totalCalls > 0 ? " is-live" : ""
1098 }`}
1099 >
1100 <span class="connect-claude-status-dot" aria-hidden="true" />
1101 <div class="connect-claude-status-main">
1102 <div class="connect-claude-status-title">
1103 {totalCalls > 0
1104 ? `Connected · ${totalCalls} MCP call${totalCalls === 1 ? "" : "s"}`
1105 : "Not yet connected"}
1106 </div>
1107 <div class="connect-claude-status-sub">
1108 {lastCalledAt
1109 ? `Last call ${new Date(lastCalledAt).toLocaleString()}`
1110 : "Generate a token, install Claude, and your first call will appear here."}
1111 </div>
1112 </div>
1113 </div>
1114 </div>
1115 </section>
19cbf20ccantynz-alt1116
1117 {/* ─── Step 4: the first real task ───────────────────────────────
1118 Connecting used to be the end of this page: a working MCP
1119 connection, a tools grid, and no indication of what to do with
1120 any of it. Agent-first onboarding means the agent does something
1121 real, so point at the user's own repositories and the one action
1122 that produces a pull request. */}
1123 {firstTaskRepos.length > 0 && (
1124 <section class="connect-claude-section">
1125 <div class="connect-claude-step-head">
1126 <span class="connect-claude-step-num">4</span>
1127 <div>
1128 <h2 class="connect-claude-step-title">Give it something to do</h2>
1129 <p class="connect-claude-step-sub">
1130 Describe a change in plain English and the agent opens the
1131 pull request — reviewed and gated like any other.
1132 </p>
1133 </div>
1134 </div>
1135 <ul class="connect-claude-repos">
1136 {firstTaskRepos.map((r) => (
1137 <li class="connect-claude-repo">
1138 <div class="connect-claude-repo-main">
1139 <a
1140 class="connect-claude-repo-name"
1141 href={`/${user.username}/${r.name}`}
1142 >
1143 {r.name}
1144 </a>
1145 <span class="connect-claude-repo-meta">
1146 {r.indexedFiles > 0
1147 ? `${r.indexedFiles} files indexed — the agent can search this repo`
1148 : "Not indexed yet — push once and search comes online"}
1149 </span>
1150 </div>
1151 <a
1152 class="btn btn-primary"
1153 href={`/${user.username}/${r.name}/spec`}
1154 >
1155 Build something
1156 </a>
1157 </li>
1158 ))}
1159 </ul>
1160 </section>
1161 )}
662ce86Claude1162 </div>
1163 <script dangerouslySetInnerHTML={{ __html: clientScript() }} />
1164 </Layout>
1165 );
1166});
1167
1168// ─── Alias: /settings/claude → /connect/claude ─────────────────────────────
1169connectClaude.get("/settings/claude", (c) => c.redirect("/connect/claude"));
1170
1171// ─── POST /connect/claude/token — session-cookie mint via fetch ───────────
1172connectClaude.post("/connect/claude/token", async (c) => {
1173 const user = c.get("user")!;
1174 const { token, id, name } = await mintConnectPat({
1175 userId: user.id,
1176 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
1177 userAgent: c.req.header("user-agent") || undefined,
1178 source: "ui",
1179 });
1180 return c.json({ token, id, name, scope: "admin" }, 201);
1181});
1182
1183// ─── GET /connect/claude/dxt — personalized .dxt download ─────────────────
1184connectClaude.get("/connect/claude/dxt", async (c) => {
1185 const user = c.get("user")!;
1186 const { token } = await mintConnectPat({
1187 userId: user.id,
1188 ip: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined,
1189 userAgent: c.req.header("user-agent") || undefined,
1190 source: "dxt",
1191 });
1192
1193 const host = config.appBaseUrl || "https://gluecron.com";
1194 const manifestJson = buildPersonalizedManifest({
1195 username: user.username,
1196 token,
1197 host,
1198 });
1199
1200 const entries: ZipEntry[] = [
1201 { name: "manifest.json", data: new TextEncoder().encode(manifestJson) },
1202 {
1203 name: "README.md",
1204 data: new TextEncoder().encode(
1205 `# Gluecron · personalized for ${user.username}\n\n` +
1206 `This .dxt was minted on ${new Date().toISOString()} and includes a\n` +
1207 `personal access token. Treat it like a password — anyone with this\n` +
1208 `file can act as you on ${host}.\n\n` +
1209 `Revoke at: ${host}/settings/tokens\n`
1210 ),
1211 },
1212 ];
1213
1214 const bundle = buildZip(entries);
1215
1216 return new Response(bundle as unknown as BodyInit, {
1217 headers: {
1218 "Content-Type": "application/octet-stream",
1219 "Content-Disposition": `attachment; filename="gluecron-${user.username}.dxt"`,
1220 "Content-Length": String(bundle.length),
1221 // Personalized — never cache.
1222 "Cache-Control": "private, no-store",
1223 },
1224 });
1225});
1226
1227// ─── GET /api/connect/status — JSON poll for the status panel ─────────────
1228connectClaude.get("/api/connect/status", async (c) => {
1229 const user = c.get("user")!;
1230 try {
1231 const rows = await db
1232 .select({ createdAt: auditLog.createdAt, id: auditLog.id })
1233 .from(auditLog)
1234 .where(
1235 and(eq(auditLog.userId, user.id), eq(auditLog.action, "mcp.tool.called"))
1236 )
1237 .orderBy(desc(auditLog.createdAt))
1238 .limit(500);
1239 const totalCalls = rows.length;
1240 const lastCalledAt =
1241 totalCalls > 0 && rows[0]
1242 ? rows[0].createdAt.toISOString()
1243 : null;
1244 return c.json({ totalCalls, lastCalledAt });
1245 } catch {
1246 return c.json({ totalCalls: 0, lastCalledAt: null });
1247 }
1248});
1249
1250export default connectClaude;