@cookielab.io/klovi 0.10.4 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/public/{index-bz9hf9fj.js → index-n0p3trmr.js} +7 -6
- package/dist/public/index.html +1 -1
- package/dist/server.js +158 -115
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -18,10 +18,10 @@ Klovi reads session data directly from `~/.claude/projects/` (JSONL files) and r
|
|
|
18
18
|
Run directly without installing (requires [Bun](https://bun.sh) or [Node.js](https://nodejs.org) >=24):
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
bunx --bun @cookielab.io/klovi
|
|
22
|
-
npx @cookielab.io/klovi
|
|
23
|
-
yarn dlx @cookielab.io/klovi
|
|
24
|
-
pnpm dlx @cookielab.io/klovi
|
|
21
|
+
bunx --bun @cookielab.io/klovi@latest
|
|
22
|
+
npx @cookielab.io/klovi@latest
|
|
23
|
+
yarn dlx @cookielab.io/klovi@latest
|
|
24
|
+
pnpm dlx @cookielab.io/klovi@latest
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Or install globally:
|
|
@@ -31,6 +31,13 @@ bun install -g @cookielab.io/klovi
|
|
|
31
31
|
klovi
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
Or install via [Homebrew](https://brew.sh) (no runtime dependency):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
brew install cookielab/tap/klovi
|
|
38
|
+
klovi
|
|
39
|
+
```
|
|
40
|
+
|
|
34
41
|
Open http://localhost:3583
|
|
35
42
|
|
|
36
43
|
### Development
|
|
@@ -53000,12 +53000,13 @@ function MarkdownRenderer({ content: content3 }) {
|
|
|
53000
53000
|
|
|
53001
53001
|
// src/frontend/utils/model.ts
|
|
53002
53002
|
function shortModel(model) {
|
|
53003
|
-
|
|
53004
|
-
|
|
53005
|
-
|
|
53006
|
-
|
|
53007
|
-
|
|
53008
|
-
return
|
|
53003
|
+
const match = model.match(/claude-(opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?(?:-\d{8,})?$/);
|
|
53004
|
+
if (match) {
|
|
53005
|
+
const family = match[1].charAt(0).toUpperCase() + match[1].slice(1);
|
|
53006
|
+
const major = match[2];
|
|
53007
|
+
const minor = match[3];
|
|
53008
|
+
return minor ? `${family} ${major}.${minor}` : `${family} ${major}`;
|
|
53009
|
+
}
|
|
53009
53010
|
return model;
|
|
53010
53011
|
}
|
|
53011
53012
|
|
package/dist/public/index.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
<link rel="apple-touch-icon" href="./apple-touch-icon-st4rb42e.png" />
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
<link rel="stylesheet" crossorigin href="./index-6gtgar9e.css"><script type="module" crossorigin src="./index-
|
|
12
|
+
<link rel="stylesheet" crossorigin href="./index-6gtgar9e.css"><script type="module" crossorigin src="./index-n0p3trmr.js"></script></head>
|
|
13
13
|
<body>
|
|
14
14
|
<div id="root"></div>
|
|
15
15
|
|
package/dist/server.js
CHANGED
|
@@ -3,10 +3,13 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
4
|
|
|
5
5
|
// index.ts
|
|
6
|
-
import { existsSync as existsSync2
|
|
6
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
7
7
|
import { dirname, join as join4 } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
|
+
// src/server/cli.ts
|
|
11
|
+
import { readSync } from "node:fs";
|
|
12
|
+
|
|
10
13
|
// src/server/parser/claude-dir.ts
|
|
11
14
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
12
15
|
import { join as join2 } from "node:path";
|
|
@@ -499,8 +502,8 @@ async function handleSubAgent(sessionId, agentId, encodedPath) {
|
|
|
499
502
|
|
|
500
503
|
// src/server/version.ts
|
|
501
504
|
var appVersion = {
|
|
502
|
-
version: "0.
|
|
503
|
-
commitHash: "
|
|
505
|
+
version: "0.11.0",
|
|
506
|
+
commitHash: "45bb1d6"
|
|
504
507
|
};
|
|
505
508
|
|
|
506
509
|
// src/server/api/version.ts
|
|
@@ -508,6 +511,109 @@ function handleVersion() {
|
|
|
508
511
|
return Response.json(appVersion);
|
|
509
512
|
}
|
|
510
513
|
|
|
514
|
+
// src/server/cli.ts
|
|
515
|
+
function parseCliArgs(argv) {
|
|
516
|
+
const portIdx = argv.indexOf("--port");
|
|
517
|
+
let port = 3583;
|
|
518
|
+
if (portIdx !== -1) {
|
|
519
|
+
const val = argv[portIdx + 1];
|
|
520
|
+
if (!val || val.startsWith("-")) {
|
|
521
|
+
console.error("Error: --port requires a number argument.");
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
port = Number.parseInt(val, 10);
|
|
525
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
526
|
+
console.error("Error: --port must be a valid port number (1-65535).");
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const acceptRisks = argv.includes("--accept-risks");
|
|
531
|
+
const showHelp = argv.includes("--help") || argv.includes("-h");
|
|
532
|
+
const projectsDirIdx = argv.indexOf("--projects-dir");
|
|
533
|
+
if (projectsDirIdx !== -1) {
|
|
534
|
+
const dir = argv[projectsDirIdx + 1];
|
|
535
|
+
if (!dir || dir.startsWith("-")) {
|
|
536
|
+
console.error("Error: --projects-dir requires a path argument.");
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
setProjectsDir(dir);
|
|
540
|
+
}
|
|
541
|
+
return { port, acceptRisks, showHelp };
|
|
542
|
+
}
|
|
543
|
+
function showHelpText() {
|
|
544
|
+
console.log(`
|
|
545
|
+
Klovi — a web viewer for Claude Code sessions
|
|
546
|
+
|
|
547
|
+
Usage:
|
|
548
|
+
klovi [options]
|
|
549
|
+
|
|
550
|
+
Options:
|
|
551
|
+
--accept-risks Skip the startup security warning
|
|
552
|
+
--port <number> Server port (default: 3583)
|
|
553
|
+
--projects-dir <path> Override the Claude projects directory
|
|
554
|
+
-h, --help Show this help message
|
|
555
|
+
|
|
556
|
+
The server runs on http://localhost:3583 by default.
|
|
557
|
+
`);
|
|
558
|
+
}
|
|
559
|
+
function promptSecurityWarning(port) {
|
|
560
|
+
const resolvedDir = getProjectsDir();
|
|
561
|
+
const yellow = "\x1B[33m";
|
|
562
|
+
const bold = "\x1B[1m";
|
|
563
|
+
const reset = "\x1B[0m";
|
|
564
|
+
const dim = "\x1B[2m";
|
|
565
|
+
console.log("");
|
|
566
|
+
console.log(`${yellow}${bold} ⚠ WARNING${reset}`);
|
|
567
|
+
console.log("");
|
|
568
|
+
console.log(` Klovi reads Claude Code session history from ${resolvedDir}.`);
|
|
569
|
+
console.log(" Session data may contain sensitive information such as API keys,");
|
|
570
|
+
console.log(" credentials, or private code snippets.");
|
|
571
|
+
console.log("");
|
|
572
|
+
console.log(` The server will expose this data on ${bold}http://localhost:${port}${reset}.`);
|
|
573
|
+
console.log("");
|
|
574
|
+
console.log(` ${dim}To skip this prompt, pass --accept-risks${reset}`);
|
|
575
|
+
console.log("");
|
|
576
|
+
process.stdout.write(" Continue? (y/N) ");
|
|
577
|
+
const buf = Buffer.alloc(1024);
|
|
578
|
+
const bytesRead = readSync(0, buf, 0, 1024, null);
|
|
579
|
+
const answer = buf.toString("utf-8", 0, bytesRead).trim().toLowerCase();
|
|
580
|
+
if (answer !== "y" && answer !== "yes") {
|
|
581
|
+
console.log(" Aborted.");
|
|
582
|
+
process.exit(0);
|
|
583
|
+
}
|
|
584
|
+
console.log("");
|
|
585
|
+
}
|
|
586
|
+
function createRoutes() {
|
|
587
|
+
return [
|
|
588
|
+
{ pattern: "/api/version", handler: () => handleVersion() },
|
|
589
|
+
{ pattern: "/api/projects", handler: () => handleProjects() },
|
|
590
|
+
{
|
|
591
|
+
pattern: "/api/projects/:encodedPath/sessions",
|
|
592
|
+
handler: (_req, p) => handleSessions(p.encodedPath)
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
pattern: "/api/sessions/:sessionId",
|
|
596
|
+
handler: (req, p) => {
|
|
597
|
+
const project = new URL(req.url).searchParams.get("project");
|
|
598
|
+
if (!project) {
|
|
599
|
+
return Response.json({ error: "project query parameter required" }, { status: 400 });
|
|
600
|
+
}
|
|
601
|
+
return handleSession(p.sessionId, project);
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
pattern: "/api/sessions/:sessionId/subagents/:agentId",
|
|
606
|
+
handler: (req, p) => {
|
|
607
|
+
const project = new URL(req.url).searchParams.get("project");
|
|
608
|
+
if (!project) {
|
|
609
|
+
return Response.json({ error: "project query parameter required" }, { status: 400 });
|
|
610
|
+
}
|
|
611
|
+
return handleSubAgent(p.sessionId, p.agentId, project);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
];
|
|
615
|
+
}
|
|
616
|
+
|
|
511
617
|
// src/server/http.ts
|
|
512
618
|
import { existsSync } from "node:fs";
|
|
513
619
|
import { readFile as readFile2 } from "node:fs/promises";
|
|
@@ -568,30 +674,53 @@ async function serveStatic(pathname, staticDir) {
|
|
|
568
674
|
return null;
|
|
569
675
|
}
|
|
570
676
|
}
|
|
571
|
-
function
|
|
572
|
-
const
|
|
677
|
+
function serveEmbedded(pathname, assets) {
|
|
678
|
+
const key = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
679
|
+
const asset = assets.get(key);
|
|
680
|
+
if (asset) {
|
|
681
|
+
return new Response(asset.data.buffer, {
|
|
682
|
+
headers: { "content-type": asset.contentType }
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
if (!extname(pathname)) {
|
|
686
|
+
const index = assets.get("index.html");
|
|
687
|
+
if (index) {
|
|
688
|
+
return new Response(index.data.buffer, {
|
|
689
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
async function handleRequest(pathname, url, method, routes, staticDir, hasStaticDir, embeddedAssets) {
|
|
696
|
+
for (const route of routes) {
|
|
697
|
+
const params = matchRoute(route.pattern, pathname);
|
|
698
|
+
if (params !== null) {
|
|
699
|
+
const webReq = new Request(url.toString(), { method });
|
|
700
|
+
return route.handler(webReq, params);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (embeddedAssets) {
|
|
704
|
+
const response = serveEmbedded(pathname, embeddedAssets);
|
|
705
|
+
if (response)
|
|
706
|
+
return response;
|
|
707
|
+
} else if (hasStaticDir) {
|
|
708
|
+
const response = await serveStatic(pathname, staticDir);
|
|
709
|
+
if (response)
|
|
710
|
+
return response;
|
|
711
|
+
}
|
|
712
|
+
return new Response("Not Found", {
|
|
713
|
+
status: 404,
|
|
714
|
+
headers: { "content-type": "text/plain" }
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
function startServer(port, routes, staticDir, embeddedAssets) {
|
|
718
|
+
const hasStaticDir = !embeddedAssets && existsSync(staticDir);
|
|
573
719
|
const server = createServer(async (req, res) => {
|
|
574
720
|
try {
|
|
575
721
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
const params = matchRoute(route.pattern, pathname);
|
|
579
|
-
if (params !== null) {
|
|
580
|
-
const webReq = new Request(url.toString(), { method: req.method });
|
|
581
|
-
const response = await route.handler(webReq, params);
|
|
582
|
-
await writeResponse(res, response);
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
if (hasStaticDir) {
|
|
587
|
-
const response = await serveStatic(pathname, staticDir);
|
|
588
|
-
if (response) {
|
|
589
|
-
await writeResponse(res, response);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
res.writeHead(404, { "content-type": "text/plain" });
|
|
594
|
-
res.end("Not Found");
|
|
722
|
+
const response = await handleRequest(url.pathname, url, req.method, routes, staticDir, hasStaticDir, embeddedAssets);
|
|
723
|
+
await writeResponse(res, response);
|
|
595
724
|
} catch (err) {
|
|
596
725
|
console.error("Request error:", err);
|
|
597
726
|
res.writeHead(500, { "content-type": "text/plain" });
|
|
@@ -611,47 +740,11 @@ async function writeResponse(res, webResponse) {
|
|
|
611
740
|
}
|
|
612
741
|
|
|
613
742
|
// index.ts
|
|
614
|
-
var
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const val = process.argv[portIdx + 1];
|
|
618
|
-
if (!val || val.startsWith("-")) {
|
|
619
|
-
console.error("Error: --port requires a number argument.");
|
|
620
|
-
process.exit(1);
|
|
621
|
-
}
|
|
622
|
-
port = Number.parseInt(val, 10);
|
|
623
|
-
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
624
|
-
console.error("Error: --port must be a valid port number (1-65535).");
|
|
625
|
-
process.exit(1);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
var acceptRisks = process.argv.includes("--accept-risks");
|
|
629
|
-
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
630
|
-
console.log(`
|
|
631
|
-
Klovi — a web viewer for Claude Code sessions
|
|
632
|
-
|
|
633
|
-
Usage:
|
|
634
|
-
klovi [options]
|
|
635
|
-
|
|
636
|
-
Options:
|
|
637
|
-
--accept-risks Skip the startup security warning
|
|
638
|
-
--port <number> Server port (default: 3583)
|
|
639
|
-
--projects-dir <path> Override the Claude projects directory
|
|
640
|
-
-h, --help Show this help message
|
|
641
|
-
|
|
642
|
-
The server runs on http://localhost:3583 by default.
|
|
643
|
-
`);
|
|
743
|
+
var { port, acceptRisks, showHelp } = parseCliArgs(process.argv);
|
|
744
|
+
if (showHelp) {
|
|
745
|
+
showHelpText();
|
|
644
746
|
process.exit(0);
|
|
645
747
|
}
|
|
646
|
-
var projectsDirIdx = process.argv.indexOf("--projects-dir");
|
|
647
|
-
if (projectsDirIdx !== -1) {
|
|
648
|
-
const dir = process.argv[projectsDirIdx + 1];
|
|
649
|
-
if (!dir || dir.startsWith("-")) {
|
|
650
|
-
console.error("Error: --projects-dir requires a path argument.");
|
|
651
|
-
process.exit(1);
|
|
652
|
-
}
|
|
653
|
-
setProjectsDir(dir);
|
|
654
|
-
}
|
|
655
748
|
var resolvedDir = getProjectsDir();
|
|
656
749
|
if (!existsSync2(resolvedDir)) {
|
|
657
750
|
console.error(`Error: projects directory not found: ${resolvedDir}`);
|
|
@@ -659,59 +752,9 @@ if (!existsSync2(resolvedDir)) {
|
|
|
659
752
|
process.exit(1);
|
|
660
753
|
}
|
|
661
754
|
if (!acceptRisks) {
|
|
662
|
-
|
|
663
|
-
const bold = "\x1B[1m";
|
|
664
|
-
const reset = "\x1B[0m";
|
|
665
|
-
const dim = "\x1B[2m";
|
|
666
|
-
console.log("");
|
|
667
|
-
console.log(`${yellow}${bold} ⚠ WARNING${reset}`);
|
|
668
|
-
console.log("");
|
|
669
|
-
console.log(` Klovi reads Claude Code session history from ${resolvedDir}.`);
|
|
670
|
-
console.log(" Session data may contain sensitive information such as API keys,");
|
|
671
|
-
console.log(" credentials, or private code snippets.");
|
|
672
|
-
console.log("");
|
|
673
|
-
console.log(` The server will expose this data on ${bold}http://localhost:${port}${reset}.`);
|
|
674
|
-
console.log("");
|
|
675
|
-
console.log(` ${dim}To skip this prompt, pass --accept-risks${reset}`);
|
|
676
|
-
console.log("");
|
|
677
|
-
process.stdout.write(" Continue? (y/N) ");
|
|
678
|
-
const buf = Buffer.alloc(1024);
|
|
679
|
-
const bytesRead = readSync(0, buf, 0, 1024, null);
|
|
680
|
-
const answer = buf.toString("utf-8", 0, bytesRead).trim().toLowerCase();
|
|
681
|
-
if (answer !== "y" && answer !== "yes") {
|
|
682
|
-
console.log(" Aborted.");
|
|
683
|
-
process.exit(0);
|
|
684
|
-
}
|
|
685
|
-
console.log("");
|
|
755
|
+
promptSecurityWarning(port);
|
|
686
756
|
}
|
|
687
757
|
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
688
758
|
var staticDir = existsSync2(join4(__dirname2, "public", "index.html")) ? join4(__dirname2, "public") : join4(__dirname2, "dist", "public");
|
|
689
|
-
startServer(port,
|
|
690
|
-
{ pattern: "/api/version", handler: () => handleVersion() },
|
|
691
|
-
{ pattern: "/api/projects", handler: () => handleProjects() },
|
|
692
|
-
{
|
|
693
|
-
pattern: "/api/projects/:encodedPath/sessions",
|
|
694
|
-
handler: (_req, p) => handleSessions(p.encodedPath)
|
|
695
|
-
},
|
|
696
|
-
{
|
|
697
|
-
pattern: "/api/sessions/:sessionId",
|
|
698
|
-
handler: (req, p) => {
|
|
699
|
-
const project = new URL(req.url).searchParams.get("project");
|
|
700
|
-
if (!project) {
|
|
701
|
-
return Response.json({ error: "project query parameter required" }, { status: 400 });
|
|
702
|
-
}
|
|
703
|
-
return handleSession(p.sessionId, project);
|
|
704
|
-
}
|
|
705
|
-
},
|
|
706
|
-
{
|
|
707
|
-
pattern: "/api/sessions/:sessionId/subagents/:agentId",
|
|
708
|
-
handler: (req, p) => {
|
|
709
|
-
const project = new URL(req.url).searchParams.get("project");
|
|
710
|
-
if (!project) {
|
|
711
|
-
return Response.json({ error: "project query parameter required" }, { status: 400 });
|
|
712
|
-
}
|
|
713
|
-
return handleSubAgent(p.sessionId, p.agentId, project);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
], staticDir);
|
|
759
|
+
startServer(port, createRoutes(), staticDir);
|
|
717
760
|
console.log(`Klovi running at http://localhost:${port}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cookielab.io/klovi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "A local web app for browsing and presenting Claude Code session history",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"build": "bun run build:frontend && bun run build:server",
|
|
42
42
|
"build:frontend": "rm -rf dist/public && bun build ./index.html --outdir dist/public",
|
|
43
43
|
"build:server": "bun scripts/build-server.ts",
|
|
44
|
+
"build:compile": "bun scripts/build-compile.ts",
|
|
44
45
|
"test": "bun test",
|
|
45
46
|
"typecheck": "tsc --noEmit",
|
|
46
47
|
"lint": "bunx biome lint .",
|