khotan-data 0.0.1 → 0.1.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.
Files changed (51) hide show
  1. package/AGENTS.md +54 -0
  2. package/README.md +62 -0
  3. package/dist/cli.js +2585 -0
  4. package/dist/factory.cjs +2319 -0
  5. package/dist/factory.cjs.map +1 -0
  6. package/dist/factory.d.cts +475 -0
  7. package/dist/factory.d.ts +475 -0
  8. package/dist/factory.js +2311 -0
  9. package/dist/factory.js.map +1 -0
  10. package/dist/plug-client.cjs +99 -0
  11. package/dist/plug-client.cjs.map +1 -0
  12. package/dist/plug-client.d.cts +71 -0
  13. package/dist/plug-client.d.ts +71 -0
  14. package/dist/plug-client.js +96 -0
  15. package/dist/plug-client.js.map +1 -0
  16. package/dist/templates/agent-skill.md +73 -0
  17. package/dist/templates/agents.md +41 -0
  18. package/dist/templates/catch.example.ts +36 -0
  19. package/dist/templates/catch.ts +107 -0
  20. package/dist/templates/config-page.tsx +20 -0
  21. package/dist/templates/debug-index-page.tsx +101 -0
  22. package/dist/templates/debug-page.tsx +48 -0
  23. package/dist/templates/graph-page.tsx +11 -0
  24. package/dist/templates/hub.tsx +450 -0
  25. package/dist/templates/inflow.example.ts +61 -0
  26. package/dist/templates/inflow.ts +99 -0
  27. package/dist/templates/khotan-config.ts +40 -0
  28. package/dist/templates/khotan-route.ts +13 -0
  29. package/dist/templates/logs-page.tsx +9 -0
  30. package/dist/templates/logs.tsx +20 -0
  31. package/dist/templates/outflow.example.ts +52 -0
  32. package/dist/templates/outflow.ts +90 -0
  33. package/dist/templates/pass.example.ts +51 -0
  34. package/dist/templates/pass.ts +124 -0
  35. package/dist/templates/plug-debugger.tsx +1185 -0
  36. package/dist/templates/plug.example.ts +93 -0
  37. package/dist/templates/plug.ts +806 -0
  38. package/dist/templates/relay.example.ts +61 -0
  39. package/dist/templates/relay.ts +95 -0
  40. package/dist/templates/runs-table.tsx +592 -0
  41. package/dist/templates/schema.ts +424 -0
  42. package/dist/templates/skill-dashboard.md +144 -0
  43. package/dist/templates/skill-plug.md +193 -0
  44. package/dist/templates/skill-setup.md +119 -0
  45. package/dist/templates/skill-webhook.md +196 -0
  46. package/dist/templates/topology-canvas.tsx +1406 -0
  47. package/dist/templates/var-panel.tsx +276 -0
  48. package/dist/templates/webhook-events-table.tsx +241 -0
  49. package/dist/templates/wire-panel.tsx +216 -0
  50. package/dist/templates/wire.ts +155 -0
  51. package/package.json +46 -5
@@ -0,0 +1,1185 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useState, useCallback, useRef } from "react";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+
9
+ // ============================================================================
10
+ // Plug Debugger — Lightweight Postman for your plugs
11
+ // Generated by khotan CLI · https://github.com/khotan-data
12
+ //
13
+ // Fires requests through the real plug code path (auth, retry, hooks).
14
+ // Gated behind KHOTAN_DEBUG env var — hidden in production.
15
+ // ============================================================================
16
+
17
+ interface EndpointMeta {
18
+ method: string;
19
+ path: string;
20
+ description?: string;
21
+ body?: Record<string, unknown> | null;
22
+ query?: Record<string, unknown> | null;
23
+ responses?: Record<string, Record<string, unknown> | null> | null;
24
+ }
25
+
26
+ interface Endpoint {
27
+ name: string;
28
+ method: string;
29
+ path: string;
30
+ description?: string;
31
+ body?: Record<string, unknown> | null;
32
+ query?: Record<string, unknown> | null;
33
+ responses?: Record<string, Record<string, unknown> | null> | null;
34
+ }
35
+
36
+ interface VarField {
37
+ key: string;
38
+ label: string;
39
+ type: string;
40
+ secret?: boolean;
41
+ defaultValue?: string;
42
+ }
43
+
44
+ interface PlugMeta {
45
+ name: string;
46
+ baseUrl: string;
47
+ authType: string;
48
+ endpoints: Record<string, EndpointMeta> | null;
49
+ vars: {
50
+ fields: VarField[];
51
+ configured: boolean;
52
+ values?: Record<string, string>;
53
+ };
54
+ }
55
+
56
+ interface DebugResponse {
57
+ status: number;
58
+ statusText: string;
59
+ headers: Record<string, string>;
60
+ body: unknown;
61
+ timing: number;
62
+ endpoint?: { name: string; method: string; path: string };
63
+ error?: string;
64
+ }
65
+
66
+ interface HistoryEntry {
67
+ id: string;
68
+ method: string;
69
+ path: string;
70
+ status: number;
71
+ timing: number;
72
+ timestamp: number;
73
+ body?: string;
74
+ params?: string;
75
+ headers?: string;
76
+ }
77
+
78
+ interface PlugDebuggerProps {
79
+ plugName: string;
80
+ basePath?: string;
81
+ }
82
+
83
+ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
84
+
85
+ const METHOD_COLORS: Record<string, string> = {
86
+ GET: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20",
87
+ POST: "bg-amber-500/10 text-amber-700 border-amber-500/20",
88
+ PUT: "bg-blue-500/10 text-blue-700 border-blue-500/20",
89
+ PATCH: "bg-purple-500/10 text-purple-700 border-purple-500/20",
90
+ DELETE: "bg-red-500/10 text-red-700 border-red-500/20",
91
+ };
92
+
93
+ const STATUS_COLORS: Record<string, string> = {
94
+ "2": "text-emerald-600",
95
+ "4": "text-red-600",
96
+ "5": "text-red-600",
97
+ };
98
+
99
+ function getStatusColor(status: number): string {
100
+ return STATUS_COLORS[String(status)[0] ?? ""] ?? "text-amber-600";
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Path param detection — extracts :param segments from a path template
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function extractPathParams(pathTemplate: string): string[] {
108
+ return (pathTemplate.match(/:([a-zA-Z_]\w*)/g) ?? []).map((p) => p.slice(1));
109
+ }
110
+
111
+ function interpolatePath(
112
+ pathTemplate: string,
113
+ values: Record<string, string>,
114
+ ): string {
115
+ let result = pathTemplate;
116
+ for (const [key, val] of Object.entries(values)) {
117
+ if (val) result = result.replace(`:${key}`, encodeURIComponent(val));
118
+ }
119
+ return result;
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // JSON path builder — builds dot-notation paths for nested objects
124
+ // ---------------------------------------------------------------------------
125
+
126
+ interface DiffInfo {
127
+ matched: Set<string>;
128
+ unexpected: Set<string>;
129
+ missing: string[];
130
+ }
131
+
132
+ function buildJsonTree(
133
+ obj: unknown,
134
+ prefix: string,
135
+ onCopy: (path: string) => void,
136
+ diff?: DiffInfo,
137
+ depth = 0,
138
+ ): React.ReactNode {
139
+ if (obj === null || obj === undefined) {
140
+ return <span className="text-muted-foreground">null</span>;
141
+ }
142
+ if (typeof obj !== "object") {
143
+ return <span className="text-foreground">{JSON.stringify(obj)}</span>;
144
+ }
145
+ if (Array.isArray(obj)) {
146
+ if (obj.length === 0)
147
+ return <span className="text-muted-foreground">[]</span>;
148
+ return (
149
+ <div className="pl-3">
150
+ {obj.slice(0, 20).map((item, i) => (
151
+ <div key={i} className="border-l border-border/50 pl-2 my-0.5">
152
+ <button
153
+ onClick={() => onCopy(`${prefix}[${i}]`)}
154
+ className="text-[10px] text-muted-foreground/60 hover:text-foreground mr-1"
155
+ title={`Copy path: ${prefix}[${i}]`}
156
+ >
157
+ [{i}]
158
+ </button>
159
+ {buildJsonTree(
160
+ item,
161
+ `${prefix}[${i}]`,
162
+ onCopy,
163
+ undefined,
164
+ depth + 1,
165
+ )}
166
+ </div>
167
+ ))}
168
+ {obj.length > 20 && (
169
+ <p className="text-[10px] text-muted-foreground italic">
170
+ ...{obj.length - 20} more items
171
+ </p>
172
+ )}
173
+ </div>
174
+ );
175
+ }
176
+
177
+ const entries = Object.entries(obj as Record<string, unknown>);
178
+ return (
179
+ <div className={depth > 0 ? "pl-3" : ""}>
180
+ {/* Missing fields at top (only at depth 0) */}
181
+ {diff &&
182
+ depth === 0 &&
183
+ diff.missing.map((key) => (
184
+ <div
185
+ key={`missing-${key}`}
186
+ className="my-0.5 -mx-1 px-1 rounded bg-red-500/10"
187
+ >
188
+ <span className="select-none inline-block w-4 text-[10px] text-red-500">
189
+
190
+ </span>
191
+ <span className="text-xs font-mono text-red-400 line-through">
192
+ {key}
193
+ </span>
194
+ <span className="text-red-400/60 text-xs">: ...</span>
195
+ </div>
196
+ ))}
197
+ {entries.map(([key, val]) => {
198
+ const fullPath = prefix ? `${prefix}.${key}` : key;
199
+ const isObject = val !== null && typeof val === "object";
200
+ let bgClass = "";
201
+ let prefixChar = " ";
202
+ if (diff && depth === 0) {
203
+ if (diff.matched.has(key)) {
204
+ bgClass = "bg-emerald-500/10 -mx-1 px-1 rounded";
205
+ } else if (diff.unexpected.has(key)) {
206
+ bgClass = "bg-amber-500/10 -mx-1 px-1 rounded";
207
+ prefixChar = "+";
208
+ }
209
+ }
210
+ return (
211
+ <div key={key} className={`my-0.5 ${bgClass}`}>
212
+ {diff && depth === 0 && (
213
+ <span
214
+ className={`select-none inline-block w-4 text-[10px] ${prefixChar === "+" ? "text-amber-600" : "text-transparent"}`}
215
+ >
216
+ {prefixChar}
217
+ </span>
218
+ )}
219
+ <button
220
+ onClick={() => onCopy(fullPath)}
221
+ className="text-xs font-mono font-medium text-foreground hover:text-blue-600 transition-colors"
222
+ title={`Copy path: ${fullPath}`}
223
+ >
224
+ {key}
225
+ </button>
226
+ <span className="text-muted-foreground text-xs">: </span>
227
+ {isObject ? (
228
+ buildJsonTree(val, fullPath, onCopy, undefined, depth + 1)
229
+ ) : (
230
+ <span className="text-xs font-mono text-foreground">
231
+ {JSON.stringify(val)}
232
+ </span>
233
+ )}
234
+ </div>
235
+ );
236
+ })}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Schema block display
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function SchemaBlock({
246
+ title,
247
+ schema,
248
+ }: {
249
+ title: string;
250
+ schema: Record<string, unknown>;
251
+ }) {
252
+ if (schema._type === "array" && schema.items) {
253
+ return (
254
+ <div className="space-y-1">
255
+ <p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
256
+ {title}
257
+ </p>
258
+ <div className="rounded bg-muted/50 p-2 font-mono text-[11px]">
259
+ <span className="text-muted-foreground">Array of:</span>
260
+ <div className="pl-2 mt-0.5">
261
+ {Object.entries(schema.items as Record<string, unknown>).map(
262
+ ([key, val]) => (
263
+ <div key={key}>
264
+ <span className="text-foreground">{key}</span>
265
+ <span className="text-muted-foreground">: {String(val)}</span>
266
+ </div>
267
+ ),
268
+ )}
269
+ </div>
270
+ </div>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ const entries = Object.entries(schema).filter(([k]) => k !== "_type");
276
+ if (entries.length === 0) return null;
277
+
278
+ return (
279
+ <div className="space-y-1">
280
+ <p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
281
+ {title}
282
+ </p>
283
+ <div className="rounded bg-muted/50 p-2 font-mono text-[11px] space-y-0.5">
284
+ {entries.map(([key, val]) => (
285
+ <div key={key}>
286
+ <span className="text-foreground">{key}</span>
287
+ <span className="text-muted-foreground">
288
+ : {typeof val === "object" && val ? "object" : String(val)}
289
+ </span>
290
+ </div>
291
+ ))}
292
+ </div>
293
+ </div>
294
+ );
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // History helpers
299
+ // ---------------------------------------------------------------------------
300
+
301
+ const HISTORY_KEY = "khotan-debug-history";
302
+ const MAX_HISTORY = 20;
303
+
304
+ function loadHistory(plugName: string): HistoryEntry[] {
305
+ try {
306
+ const raw = sessionStorage.getItem(`${HISTORY_KEY}:${plugName}`);
307
+ return raw ? JSON.parse(raw) : [];
308
+ } catch {
309
+ return [];
310
+ }
311
+ }
312
+
313
+ function saveHistory(plugName: string, entries: HistoryEntry[]) {
314
+ try {
315
+ sessionStorage.setItem(
316
+ `${HISTORY_KEY}:${plugName}`,
317
+ JSON.stringify(entries.slice(0, MAX_HISTORY)),
318
+ );
319
+ } catch {
320
+ /* quota exceeded */
321
+ }
322
+ }
323
+
324
+ // ===========================================================================
325
+ // Main component
326
+ // ===========================================================================
327
+
328
+ export function PlugDebugger({
329
+ plugName,
330
+ basePath = "/api/khotan",
331
+ }: PlugDebuggerProps) {
332
+ const [meta, setMeta] = useState<PlugMeta | null>(null);
333
+ const [loading, setLoading] = useState(true);
334
+
335
+ const [method, setMethod] = useState<string>("GET");
336
+ const [path, setPath] = useState("");
337
+ const [pathParams, setPathParams] = useState<Record<string, string>>({});
338
+ const [body, setBody] = useState("");
339
+ const [params, setParams] = useState("");
340
+ const [headers, setHeaders] = useState("");
341
+ const [activeTab, setActiveTab] = useState<"body" | "params" | "headers">(
342
+ "params",
343
+ );
344
+
345
+ const [sending, setSending] = useState(false);
346
+ const [response, setResponse] = useState<DebugResponse | null>(null);
347
+ const [responseTab, setResponseTab] = useState<"body" | "headers">("body");
348
+ const [responseView, setResponseView] = useState<"raw" | "tree">("raw");
349
+
350
+ const [history, setHistory] = useState<HistoryEntry[]>([]);
351
+ const [copiedPath, setCopiedPath] = useState<string | null>(null);
352
+
353
+ const containerRef = useRef<HTMLDivElement>(null);
354
+
355
+ const fetchMeta = useCallback(async () => {
356
+ try {
357
+ const res = await fetch(`${basePath}/debug/${plugName}`);
358
+ if (!res.ok) {
359
+ setMeta(null);
360
+ return;
361
+ }
362
+ setMeta(await res.json());
363
+ } catch {
364
+ setMeta(null);
365
+ } finally {
366
+ setLoading(false);
367
+ }
368
+ }, [basePath, plugName]);
369
+
370
+ useEffect(() => {
371
+ fetchMeta();
372
+ }, [fetchMeta]);
373
+ useEffect(() => {
374
+ setHistory(loadHistory(plugName));
375
+ }, [plugName]);
376
+
377
+ // Cmd+Enter global shortcut
378
+ useEffect(() => {
379
+ function handleKeyDown(e: KeyboardEvent) {
380
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
381
+ e.preventDefault();
382
+ if (path && !sending) fireRequest();
383
+ }
384
+ }
385
+ const el = containerRef.current;
386
+ el?.addEventListener("keydown", handleKeyDown);
387
+ return () => el?.removeEventListener("keydown", handleKeyDown);
388
+ });
389
+
390
+ // Detect path params when path changes
391
+ const detectedParams = extractPathParams(path);
392
+ const resolvedPath =
393
+ detectedParams.length > 0 ? interpolatePath(path, pathParams) : path;
394
+
395
+ if (loading) {
396
+ return (
397
+ <div className="space-y-4">
398
+ <div className="h-8 w-64 animate-pulse rounded bg-muted" />
399
+ <div className="h-64 animate-pulse rounded bg-muted" />
400
+ </div>
401
+ );
402
+ }
403
+
404
+ if (!meta) {
405
+ return (
406
+ <div className="rounded-lg border border-border p-6 text-center">
407
+ <p className="text-muted-foreground">
408
+ Debug mode is not available for this plug.
409
+ </p>
410
+ </div>
411
+ );
412
+ }
413
+
414
+ const endpointList: Endpoint[] = meta.endpoints
415
+ ? Object.entries(meta.endpoints).map(([name, ep]) => ({
416
+ name,
417
+ method: ep.method.toUpperCase(),
418
+ path: ep.path,
419
+ description: ep.description,
420
+ body: ep.body,
421
+ query: ep.query,
422
+ responses: ep.responses,
423
+ }))
424
+ : [];
425
+
426
+ const selectedEndpoint = endpointList.find(
427
+ (ep) => ep.method === method && ep.path === path,
428
+ );
429
+
430
+ function selectEndpoint(ep: Endpoint) {
431
+ setMethod(ep.method);
432
+ setPath(ep.path);
433
+ setPathParams({});
434
+ setResponse(null);
435
+ }
436
+
437
+ function loadFromHistory(entry: HistoryEntry) {
438
+ setMethod(entry.method);
439
+ setPath(entry.path);
440
+ setBody(entry.body ?? "");
441
+ setParams(entry.params ?? "");
442
+ setHeaders(entry.headers ?? "");
443
+ setPathParams({});
444
+ setResponse(null);
445
+ }
446
+
447
+ async function fireRequest() {
448
+ const finalPath = resolvedPath || path;
449
+ if (!finalPath) return;
450
+ setSending(true);
451
+ setResponse(null);
452
+ try {
453
+ const payload: Record<string, unknown> = { method, path: finalPath };
454
+
455
+ if (body.trim()) {
456
+ try {
457
+ payload.body = JSON.parse(body);
458
+ } catch {
459
+ payload.body = body;
460
+ }
461
+ }
462
+ if (params.trim()) {
463
+ try {
464
+ payload.params = JSON.parse(params);
465
+ } catch {
466
+ /* skip */
467
+ }
468
+ }
469
+ if (headers.trim()) {
470
+ try {
471
+ payload.headers = JSON.parse(headers);
472
+ } catch {
473
+ /* skip */
474
+ }
475
+ }
476
+
477
+ const res = await fetch(`${basePath}/debug/${plugName}`, {
478
+ method: "POST",
479
+ headers: { "Content-Type": "application/json" },
480
+ body: JSON.stringify(payload),
481
+ });
482
+ const data: DebugResponse = await res.json();
483
+ setResponse(data);
484
+
485
+ const entry: HistoryEntry = {
486
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
487
+ method,
488
+ path: finalPath,
489
+ status: data.status,
490
+ timing: data.timing,
491
+ timestamp: Date.now(),
492
+ body: body || undefined,
493
+ params: params || undefined,
494
+ headers: headers || undefined,
495
+ };
496
+ const updated = [entry, ...history].slice(0, MAX_HISTORY);
497
+ setHistory(updated);
498
+ saveHistory(plugName, updated);
499
+ } catch (err) {
500
+ setResponse({
501
+ status: 0,
502
+ statusText: "Network Error",
503
+ headers: {},
504
+ body: null,
505
+ timing: 0,
506
+ error: err instanceof Error ? err.message : "Unknown error",
507
+ });
508
+ } finally {
509
+ setSending(false);
510
+ }
511
+ }
512
+
513
+ function handleBodyBlur() {
514
+ if (!body.trim()) return;
515
+ try {
516
+ const parsed = JSON.parse(body);
517
+ const formatted = JSON.stringify(parsed, null, 2);
518
+ if (formatted !== body) setBody(formatted);
519
+ } catch {
520
+ /* not valid JSON, leave as-is */
521
+ }
522
+ }
523
+
524
+ function copyJsonPath(jsonPath: string) {
525
+ navigator.clipboard.writeText(jsonPath);
526
+ setCopiedPath(jsonPath);
527
+ setTimeout(() => setCopiedPath(null), 1500);
528
+ }
529
+
530
+ const statusColor = response ? getStatusColor(response.status) : "";
531
+
532
+ // Compute response size
533
+ const responseRaw =
534
+ response?.body != null
535
+ ? typeof response.body === "string"
536
+ ? response.body
537
+ : JSON.stringify(response.body)
538
+ : "";
539
+ const responseSizeKB = responseRaw
540
+ ? (new Blob([responseRaw]).size / 1024).toFixed(1)
541
+ : "0";
542
+
543
+ // Schema diff computation
544
+ const schemaDiff = (() => {
545
+ if (
546
+ !selectedEndpoint?.responses ||
547
+ !response?.body ||
548
+ typeof response.body !== "object"
549
+ )
550
+ return null;
551
+ const schemaForStatus = selectedEndpoint.responses[String(response.status)];
552
+ if (!schemaForStatus) return null;
553
+ const expectedKeys = new Set(
554
+ Object.keys(schemaForStatus).filter((k) => k !== "_type"),
555
+ );
556
+ const actualKeys = new Set(
557
+ Object.keys(response.body as Record<string, unknown>),
558
+ );
559
+ const matched = [...expectedKeys].filter((k) => actualKeys.has(k));
560
+ const unexpected = [...actualKeys].filter((k) => !expectedKeys.has(k));
561
+ const missing = [...expectedKeys].filter((k) => !actualKeys.has(k));
562
+ return { matched, unexpected, missing, expectedKeys };
563
+ })();
564
+
565
+ return (
566
+ <div ref={containerRef} className="flex gap-6" tabIndex={-1}>
567
+ {/* Sidebar */}
568
+ <div className="w-56 shrink-0 space-y-4">
569
+ {/* Plug info */}
570
+ <div className="rounded-lg border border-border p-3 space-y-2">
571
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
572
+ {meta.name}
573
+ </p>
574
+ <p
575
+ className="text-xs text-muted-foreground truncate"
576
+ title={meta.baseUrl}
577
+ >
578
+ {meta.baseUrl}
579
+ </p>
580
+ <div className="flex items-center gap-2 flex-wrap">
581
+ {meta.authType && meta.authType !== "none" && (
582
+ <Badge variant="outline" className="text-[10px]">
583
+ {meta.authType}
584
+ </Badge>
585
+ )}
586
+ <Badge
587
+ variant={meta.vars.configured ? "default" : "secondary"}
588
+ className="text-[10px]"
589
+ >
590
+ {meta.vars.configured ? "Vars ✓" : "No vars"}
591
+ </Badge>
592
+ </div>
593
+ {meta.vars.fields.length > 0 && (
594
+ <div className="pt-1 space-y-1">
595
+ {meta.vars.fields.map((f) => {
596
+ const value = meta.vars.values?.[f.key];
597
+ return (
598
+ <div key={f.key} className="text-[10px]">
599
+ <span className="text-muted-foreground">
600
+ {f.label || f.key}
601
+ </span>
602
+ {value && (
603
+ <span className="ml-1.5 font-mono text-foreground">
604
+ {f.secret ? "••••••••" : value}
605
+ </span>
606
+ )}
607
+ {!value && (
608
+ <span className="ml-1.5 text-muted-foreground/50 italic">
609
+ not set
610
+ </span>
611
+ )}
612
+ </div>
613
+ );
614
+ })}
615
+ </div>
616
+ )}
617
+ </div>
618
+
619
+ {/* New request */}
620
+ <div className="space-y-1">
621
+ <button
622
+ onClick={() => {
623
+ setMethod("GET");
624
+ setPath("");
625
+ setBody("");
626
+ setParams("");
627
+ setHeaders("");
628
+ setPathParams({});
629
+ setResponse(null);
630
+ }}
631
+ className={`w-full text-left rounded-md px-2 py-1.5 text-xs font-medium transition-colors hover:bg-muted ${
632
+ !selectedEndpoint && !path ? "bg-muted" : ""
633
+ }`}
634
+ >
635
+ + New Request
636
+ </button>
637
+ </div>
638
+
639
+ {/* Endpoints */}
640
+ {endpointList.length > 0 && (
641
+ <div className="space-y-1">
642
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
643
+ Endpoints
644
+ </p>
645
+ {endpointList.map((ep) => (
646
+ <button
647
+ key={ep.name}
648
+ onClick={() => selectEndpoint(ep)}
649
+ className={`w-full text-left rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-muted ${
650
+ method === ep.method && path === ep.path
651
+ ? "bg-muted font-medium"
652
+ : ""
653
+ }`}
654
+ >
655
+ <span
656
+ className={`inline-block w-12 font-mono text-[10px] font-semibold ${METHOD_COLORS[ep.method]?.split(" ")[1] ?? ""}`}
657
+ >
658
+ {ep.method}
659
+ </span>
660
+ <span className="text-muted-foreground ml-1">{ep.path}</span>
661
+ <span className="block text-[10px] text-muted-foreground/60 pl-[52px]">
662
+ {ep.name}
663
+ </span>
664
+ </button>
665
+ ))}
666
+ </div>
667
+ )}
668
+
669
+ {endpointList.length === 0 && (
670
+ <div className="rounded-lg border border-dashed border-border p-3 text-center">
671
+ <p className="text-[10px] text-muted-foreground">
672
+ No typed endpoints registered. Add{" "}
673
+ <code className="bg-muted px-1 rounded">endpoints</code> to your
674
+ plug config to see them here.
675
+ </p>
676
+ </div>
677
+ )}
678
+
679
+ {/* History */}
680
+ {history.length > 0 && (
681
+ <div className="space-y-1">
682
+ <div className="flex items-center justify-between px-1">
683
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
684
+ History
685
+ </p>
686
+ <button
687
+ onClick={() => {
688
+ setHistory([]);
689
+ saveHistory(plugName, []);
690
+ }}
691
+ className="text-[10px] text-muted-foreground hover:text-foreground"
692
+ >
693
+ Clear
694
+ </button>
695
+ </div>
696
+ {history.slice(0, 8).map((entry) => (
697
+ <button
698
+ key={entry.id}
699
+ onClick={() => loadFromHistory(entry)}
700
+ className="w-full text-left rounded-md px-2 py-1 text-[10px] transition-colors hover:bg-muted"
701
+ >
702
+ <span
703
+ className={`inline-block w-10 font-mono font-semibold ${METHOD_COLORS[entry.method]?.split(" ")[1] ?? ""}`}
704
+ >
705
+ {entry.method}
706
+ </span>
707
+ <span className="text-muted-foreground ml-1 truncate">
708
+ {entry.path}
709
+ </span>
710
+ <span className="float-right">
711
+ <span className={`font-mono ${getStatusColor(entry.status)}`}>
712
+ {entry.status}
713
+ </span>
714
+ <span className="text-muted-foreground/50 ml-1">
715
+ {entry.timing}ms
716
+ </span>
717
+ </span>
718
+ </button>
719
+ ))}
720
+ </div>
721
+ )}
722
+ </div>
723
+
724
+ {/* Main pane */}
725
+ <div className="flex-1 min-w-0 space-y-4">
726
+ {/* URL bar */}
727
+ <div className="flex gap-2">
728
+ <select
729
+ className={`w-24 rounded-md border px-2 py-2 text-xs font-mono font-semibold ${METHOD_COLORS[method] ?? ""}`}
730
+ value={method}
731
+ onChange={(e) => setMethod(e.target.value)}
732
+ >
733
+ {METHODS.map((m) => (
734
+ <option key={m} value={m}>
735
+ {m}
736
+ </option>
737
+ ))}
738
+ </select>
739
+ <div className="flex-1 relative">
740
+ <Input
741
+ className="font-mono text-sm pr-20"
742
+ placeholder="/products"
743
+ value={path}
744
+ onChange={(e) => setPath(e.target.value)}
745
+ onKeyDown={(e) => {
746
+ if (e.key === "Enter" && path) fireRequest();
747
+ }}
748
+ />
749
+ <span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground pointer-events-none">
750
+ ⌘↵
751
+ </span>
752
+ </div>
753
+ <Button
754
+ onClick={fireRequest}
755
+ disabled={sending || !path}
756
+ size="sm"
757
+ className="px-4"
758
+ >
759
+ {sending ? "..." : "Send"}
760
+ </Button>
761
+ </div>
762
+
763
+ {/* Path params interpolation */}
764
+ {detectedParams.length > 0 && (
765
+ <div className="flex items-center gap-3 flex-wrap">
766
+ {detectedParams.map((param) => (
767
+ <div key={param} className="flex items-center gap-1.5">
768
+ <Label className="text-[10px] text-muted-foreground font-mono">
769
+ :{param}
770
+ </Label>
771
+ <Input
772
+ className="w-32 h-7 text-xs font-mono"
773
+ placeholder={param}
774
+ value={pathParams[param] ?? ""}
775
+ onChange={(e) =>
776
+ setPathParams((prev) => ({
777
+ ...prev,
778
+ [param]: e.target.value,
779
+ }))
780
+ }
781
+ />
782
+ </div>
783
+ ))}
784
+ {Object.values(pathParams).some(Boolean) && (
785
+ <span className="text-[10px] text-muted-foreground font-mono">
786
+ → {resolvedPath}
787
+ </span>
788
+ )}
789
+ </div>
790
+ )}
791
+
792
+ {/* Request tabs */}
793
+ <div className="rounded-lg border border-border">
794
+ <div className="flex border-b border-border">
795
+ {(["params", "body", "headers"] as const).map((tab) => (
796
+ <button
797
+ key={tab}
798
+ onClick={() => setActiveTab(tab)}
799
+ className={`px-4 py-2 text-xs font-medium transition-colors ${
800
+ activeTab === tab
801
+ ? "border-b-2 border-foreground text-foreground"
802
+ : "text-muted-foreground hover:text-foreground"
803
+ }`}
804
+ >
805
+ {tab === "body"
806
+ ? "Body"
807
+ : tab === "params"
808
+ ? "Params"
809
+ : "Headers"}
810
+ </button>
811
+ ))}
812
+ </div>
813
+ <div className="p-3">
814
+ {activeTab === "params" && (
815
+ <div className="space-y-1">
816
+ <Label className="text-[10px] text-muted-foreground">
817
+ Query parameters as JSON
818
+ </Label>
819
+ <Input
820
+ className="font-mono text-xs"
821
+ placeholder='{ "limit": "10", "page": "1" }'
822
+ value={params}
823
+ onChange={(e) => setParams(e.target.value)}
824
+ />
825
+ </div>
826
+ )}
827
+ {activeTab === "body" && (
828
+ <div className="space-y-1">
829
+ <Label className="text-[10px] text-muted-foreground">
830
+ Request body (JSON) — auto-formats on blur
831
+ </Label>
832
+ <textarea
833
+ className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs min-h-[120px] resize-y"
834
+ placeholder='{ "key": "value" }'
835
+ value={body}
836
+ onChange={(e) => setBody(e.target.value)}
837
+ onBlur={handleBodyBlur}
838
+ />
839
+ </div>
840
+ )}
841
+ {activeTab === "headers" && (
842
+ <div className="space-y-1">
843
+ <Label className="text-[10px] text-muted-foreground">
844
+ Extra headers as JSON (auth headers are added automatically)
845
+ </Label>
846
+ <Input
847
+ className="font-mono text-xs"
848
+ placeholder='{ "X-Custom-Header": "value" }'
849
+ value={headers}
850
+ onChange={(e) => setHeaders(e.target.value)}
851
+ />
852
+ </div>
853
+ )}
854
+ </div>
855
+ </div>
856
+
857
+ {/* Schema info for selected endpoint */}
858
+ {selectedEndpoint &&
859
+ (selectedEndpoint.body ||
860
+ selectedEndpoint.query ||
861
+ selectedEndpoint.responses) && (
862
+ <div className="rounded-lg border border-border">
863
+ <div className="px-4 py-2 border-b border-border">
864
+ <p className="text-xs font-medium">
865
+ {selectedEndpoint.description || selectedEndpoint.name}
866
+ </p>
867
+ </div>
868
+ <div className="p-3 grid grid-cols-1 md:grid-cols-2 gap-3">
869
+ {selectedEndpoint.body && (
870
+ <SchemaBlock
871
+ title="Request Body"
872
+ schema={selectedEndpoint.body}
873
+ />
874
+ )}
875
+ {selectedEndpoint.query && (
876
+ <SchemaBlock
877
+ title="Query Params"
878
+ schema={selectedEndpoint.query}
879
+ />
880
+ )}
881
+ {selectedEndpoint.responses &&
882
+ Object.entries(selectedEndpoint.responses).map(
883
+ ([code, schema]) =>
884
+ schema ? (
885
+ <SchemaBlock
886
+ key={code}
887
+ title={`Response ${code}`}
888
+ schema={schema}
889
+ />
890
+ ) : null,
891
+ )}
892
+ </div>
893
+ </div>
894
+ )}
895
+
896
+ {/* Response */}
897
+ {response && (
898
+ <div className="rounded-lg border border-border">
899
+ <div className="flex items-center justify-between border-b border-border px-4 py-2">
900
+ <div className="flex items-center gap-3">
901
+ <span className={`font-mono text-sm font-bold ${statusColor}`}>
902
+ {response.status}
903
+ </span>
904
+ <span className="text-xs text-muted-foreground">
905
+ {response.statusText}
906
+ </span>
907
+ {response.endpoint && (
908
+ <Badge variant="outline" className="text-[10px]">
909
+ {response.endpoint.name}
910
+ </Badge>
911
+ )}
912
+ </div>
913
+ <div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
914
+ <span>{response.timing}ms</span>
915
+ <span className="text-muted-foreground/40">·</span>
916
+ <span>{responseSizeKB} KB</span>
917
+ </div>
918
+ </div>
919
+
920
+ {response.error && (
921
+ <div className="px-4 py-2 border-b border-border bg-red-500/5">
922
+ <p className="text-xs text-red-600">{response.error}</p>
923
+ </div>
924
+ )}
925
+
926
+ {/* Schema diff summary */}
927
+ {schemaDiff &&
928
+ (schemaDiff.unexpected.length > 0 ||
929
+ schemaDiff.missing.length > 0) && (
930
+ <div className="px-4 py-2 border-b border-border bg-muted/30">
931
+ <div className="flex flex-wrap gap-2 text-[10px]">
932
+ {schemaDiff.matched.length > 0 && (
933
+ <span className="text-emerald-600">
934
+ ✓ {schemaDiff.matched.length} matched
935
+ </span>
936
+ )}
937
+ {schemaDiff.unexpected.length > 0 && (
938
+ <span className="text-amber-600">
939
+ + {schemaDiff.unexpected.length} undocumented:{" "}
940
+ <span className="font-mono">
941
+ {schemaDiff.unexpected.join(", ")}
942
+ </span>
943
+ </span>
944
+ )}
945
+ {schemaDiff.missing.length > 0 && (
946
+ <span className="text-red-600">
947
+ − {schemaDiff.missing.length} missing:{" "}
948
+ <span className="font-mono">
949
+ {schemaDiff.missing.join(", ")}
950
+ </span>
951
+ </span>
952
+ )}
953
+ </div>
954
+ </div>
955
+ )}
956
+
957
+ <div className="flex items-center border-b border-border">
958
+ <div className="flex flex-1">
959
+ {(["body", "headers"] as const).map((tab) => (
960
+ <button
961
+ key={tab}
962
+ onClick={() => setResponseTab(tab)}
963
+ className={`px-4 py-1.5 text-xs font-medium transition-colors ${
964
+ responseTab === tab
965
+ ? "border-b-2 border-foreground text-foreground"
966
+ : "text-muted-foreground hover:text-foreground"
967
+ }`}
968
+ >
969
+ {tab === "body"
970
+ ? "Body"
971
+ : `Headers (${Object.keys(response.headers).length})`}
972
+ </button>
973
+ ))}
974
+ {responseTab === "body" && (
975
+ <div className="flex items-center gap-1 ml-2">
976
+ <button
977
+ onClick={() => setResponseView("raw")}
978
+ className={`px-2 py-0.5 text-[10px] rounded transition-colors ${responseView === "raw" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
979
+ >
980
+ Raw
981
+ </button>
982
+ <button
983
+ onClick={() => setResponseView("tree")}
984
+ className={`px-2 py-0.5 text-[10px] rounded transition-colors ${responseView === "tree" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
985
+ >
986
+ Tree
987
+ </button>
988
+ </div>
989
+ )}
990
+ </div>
991
+ <div className="flex items-center gap-1">
992
+ {copiedPath && (
993
+ <span className="text-[10px] text-emerald-600 mr-1">
994
+ {copiedPath}
995
+ </span>
996
+ )}
997
+ {responseTab === "body" && (
998
+ <button
999
+ onClick={() => {
1000
+ const text =
1001
+ typeof response.body === "string"
1002
+ ? response.body
1003
+ : JSON.stringify(response.body, null, 2);
1004
+ navigator.clipboard.writeText(text);
1005
+ }}
1006
+ className="px-3 py-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
1007
+ title="Copy response body"
1008
+ >
1009
+ Copy
1010
+ </button>
1011
+ )}
1012
+ </div>
1013
+ </div>
1014
+
1015
+ <div className="p-4 max-h-[480px] overflow-auto">
1016
+ {responseTab === "body" &&
1017
+ responseView === "raw" &&
1018
+ (() => {
1019
+ const raw =
1020
+ typeof response.body === "string"
1021
+ ? response.body
1022
+ : JSON.stringify(response.body, null, 2);
1023
+ const MAX_DISPLAY = 50_000;
1024
+ const truncated = raw.length > MAX_DISPLAY;
1025
+ const displayRaw = truncated
1026
+ ? raw.slice(0, MAX_DISPLAY)
1027
+ : raw;
1028
+
1029
+ if (
1030
+ !schemaDiff ||
1031
+ typeof response.body !== "object" ||
1032
+ response.body === null
1033
+ ) {
1034
+ return (
1035
+ <>
1036
+ <pre className="text-xs font-mono whitespace-pre-wrap break-words">
1037
+ {displayRaw}
1038
+ </pre>
1039
+ {truncated && (
1040
+ <div className="mt-3 rounded bg-muted/50 px-3 py-2 text-center">
1041
+ <p className="text-[10px] text-muted-foreground">
1042
+ Response truncated (
1043
+ {(raw.length / 1024).toFixed(0)} KB total). Copy
1044
+ to see full output.
1045
+ </p>
1046
+ </div>
1047
+ )}
1048
+ </>
1049
+ );
1050
+ }
1051
+
1052
+ const lines = displayRaw.split("\n");
1053
+ const topLevelKeys = new Set([
1054
+ ...schemaDiff.matched,
1055
+ ...schemaDiff.unexpected,
1056
+ ]);
1057
+ const keyLineMap = new Map<
1058
+ string,
1059
+ "matched" | "unexpected"
1060
+ >();
1061
+ for (const k of schemaDiff.matched)
1062
+ keyLineMap.set(k, "matched");
1063
+ for (const k of schemaDiff.unexpected)
1064
+ keyLineMap.set(k, "unexpected");
1065
+
1066
+ const missingLines = schemaDiff.missing.map((key) => ({
1067
+ key,
1068
+ line: ` "${key}": ...`,
1069
+ }));
1070
+
1071
+ return (
1072
+ <>
1073
+ <div className="text-xs font-mono whitespace-pre-wrap break-words">
1074
+ {lines.map((line, i) => {
1075
+ const isOpeningBrace = line.trim() === "{";
1076
+
1077
+ const keyMatch = line.match(/^\s+"([^"]+)":/);
1078
+ let bg = "";
1079
+ let prefix = " ";
1080
+ if (keyMatch && topLevelKeys.has(keyMatch[1])) {
1081
+ const status = keyLineMap.get(keyMatch[1]);
1082
+ if (status === "matched") {
1083
+ bg = "bg-emerald-500/10";
1084
+ } else if (status === "unexpected") {
1085
+ bg = "bg-amber-500/10";
1086
+ prefix = "+";
1087
+ }
1088
+ }
1089
+
1090
+ return (
1091
+ <React.Fragment key={i}>
1092
+ <div className={`px-1 -mx-1 rounded-sm ${bg}`}>
1093
+ <span
1094
+ className={`select-none inline-block w-4 text-[10px] ${prefix === "+" ? "text-amber-600" : "text-transparent"}`}
1095
+ >
1096
+ {prefix}
1097
+ </span>
1098
+ {line}
1099
+ </div>
1100
+ {isOpeningBrace &&
1101
+ missingLines.length > 0 &&
1102
+ missingLines.map((m) => (
1103
+ <div
1104
+ key={m.key}
1105
+ className="px-1 -mx-1 rounded-sm bg-red-500/10"
1106
+ >
1107
+ <span className="select-none inline-block w-4 text-[10px] text-red-500">
1108
+
1109
+ </span>
1110
+ <span className="text-red-400 line-through">
1111
+ {m.line}
1112
+ </span>
1113
+ </div>
1114
+ ))}
1115
+ </React.Fragment>
1116
+ );
1117
+ })}
1118
+ </div>
1119
+ {truncated && (
1120
+ <div className="mt-3 rounded bg-muted/50 px-3 py-2 text-center">
1121
+ <p className="text-[10px] text-muted-foreground">
1122
+ Response truncated ({(raw.length / 1024).toFixed(0)}{" "}
1123
+ KB total). Copy to see full output.
1124
+ </p>
1125
+ </div>
1126
+ )}
1127
+ </>
1128
+ );
1129
+ })()}
1130
+ {responseTab === "body" && responseView === "tree" && (
1131
+ <div className="text-xs">
1132
+ {buildJsonTree(
1133
+ response.body,
1134
+ "",
1135
+ copyJsonPath,
1136
+ schemaDiff
1137
+ ? {
1138
+ matched: new Set(schemaDiff.matched),
1139
+ unexpected: new Set(schemaDiff.unexpected),
1140
+ missing: schemaDiff.missing,
1141
+ }
1142
+ : undefined,
1143
+ )}
1144
+ </div>
1145
+ )}
1146
+ {responseTab === "headers" && (
1147
+ <div className="space-y-1">
1148
+ {Object.entries(response.headers).map(([key, value]) => (
1149
+ <div key={key} className="flex gap-2 text-xs">
1150
+ <span className="font-mono font-medium text-foreground">
1151
+ {key}:
1152
+ </span>
1153
+ <span className="text-muted-foreground break-all">
1154
+ {value}
1155
+ </span>
1156
+ </div>
1157
+ ))}
1158
+ {Object.keys(response.headers).length === 0 && (
1159
+ <p className="text-xs text-muted-foreground">
1160
+ No headers returned.
1161
+ </p>
1162
+ )}
1163
+ </div>
1164
+ )}
1165
+ </div>
1166
+ </div>
1167
+ )}
1168
+
1169
+ {!response && !sending && (
1170
+ <div className="rounded-lg border border-dashed border-border p-12 text-center">
1171
+ <p className="text-sm text-muted-foreground">
1172
+ {endpointList.length > 0
1173
+ ? "Select an endpoint or enter a path and hit Send."
1174
+ : "Enter a path and hit Send to fire a request through this plug."}
1175
+ </p>
1176
+ <p className="text-xs text-muted-foreground mt-2">
1177
+ Requests flow through the real plug code path — auth, retry, and
1178
+ hooks all apply.
1179
+ </p>
1180
+ </div>
1181
+ )}
1182
+ </div>
1183
+ </div>
1184
+ );
1185
+ }