session-intelligence-cli
Advanced tools
+2
-1
| { | ||
| "name": "session-intelligence-cli", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "description": "Real-time dashboard for AI coding sessions — track activity, git changes, token usage, and session history across Claude Code and Cursor", | ||
@@ -31,2 +31,3 @@ "type": "module", | ||
| "dist", | ||
| "!dist/__tests__", | ||
| "README.md" | ||
@@ -33,0 +34,0 @@ ], |
| export {}; |
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { execSync } from "node:child_process"; | ||
| import { readGitState, readGitDiffStats } from "../git.js"; | ||
| vi.mock("node:child_process", () => ({ | ||
| execSync: vi.fn(), | ||
| })); | ||
| const mockExecSync = vi.mocked(execSync); | ||
| beforeEach(() => { | ||
| mockExecSync.mockReset(); | ||
| }); | ||
| describe("readGitState", () => { | ||
| it("returns null when not in a git repo", () => { | ||
| mockExecSync.mockReturnValue(""); | ||
| expect(readGitState("/tmp")).toBeNull(); | ||
| }); | ||
| it("returns null when git command throws", () => { | ||
| mockExecSync.mockImplementation(() => { throw new Error("not a git repo"); }); | ||
| expect(readGitState("/tmp")).toBeNull(); | ||
| }); | ||
| it("parses git state correctly", () => { | ||
| mockExecSync.mockImplementation((cmd) => { | ||
| const cmdStr = String(cmd); | ||
| if (cmdStr.includes("rev-parse --abbrev-ref")) | ||
| return "feat/login"; | ||
| if (cmdStr.includes("git log -1")) | ||
| return "abc123\nfix login redirect"; | ||
| if (cmdStr.includes("git status --porcelain")) | ||
| return " M src/login.ts"; | ||
| return ""; | ||
| }); | ||
| const state = readGitState("/project"); | ||
| expect(state).toEqual({ | ||
| branch: "feat/login", | ||
| lastCommitSha: "abc123", | ||
| lastCommitMessage: "fix login redirect", | ||
| hasUncommittedChanges: true, | ||
| }); | ||
| }); | ||
| it("reports no uncommitted changes when porcelain is empty", () => { | ||
| mockExecSync.mockImplementation((cmd) => { | ||
| const cmdStr = String(cmd); | ||
| if (cmdStr.includes("rev-parse --abbrev-ref")) | ||
| return "main"; | ||
| if (cmdStr.includes("git log -1")) | ||
| return "def456\ninitial commit"; | ||
| if (cmdStr.includes("git status --porcelain")) | ||
| return ""; | ||
| return ""; | ||
| }); | ||
| const state = readGitState("/project"); | ||
| expect(state).not.toBeNull(); | ||
| expect(state.hasUncommittedChanges).toBe(false); | ||
| }); | ||
| }); | ||
| describe("readGitDiffStats", () => { | ||
| it("parses shortstat output with all fields", () => { | ||
| mockExecSync.mockReturnValue(" 3 files changed, 45 insertions(+), 12 deletions(-)"); | ||
| const stats = readGitDiffStats("/project"); | ||
| expect(stats).toEqual({ | ||
| filesChanged: 3, | ||
| insertions: 45, | ||
| deletions: 12, | ||
| }); | ||
| }); | ||
| it("handles insertions-only shortstat", () => { | ||
| mockExecSync.mockReturnValue(" 1 file changed, 10 insertions(+)"); | ||
| const stats = readGitDiffStats("/project"); | ||
| expect(stats).toEqual({ | ||
| filesChanged: 1, | ||
| insertions: 10, | ||
| deletions: 0, | ||
| }); | ||
| }); | ||
| it("handles deletions-only shortstat", () => { | ||
| mockExecSync.mockReturnValue(" 2 files changed, 5 deletions(-)"); | ||
| const stats = readGitDiffStats("/project"); | ||
| expect(stats).toEqual({ | ||
| filesChanged: 2, | ||
| insertions: 0, | ||
| deletions: 5, | ||
| }); | ||
| }); | ||
| it("returns zeros when no diff output", () => { | ||
| mockExecSync.mockReturnValue(""); | ||
| const stats = readGitDiffStats("/project"); | ||
| expect(stats).toEqual({ | ||
| filesChanged: 0, | ||
| insertions: 0, | ||
| deletions: 0, | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect } from "vitest"; | ||
| import { buildCursorPayload, CURSOR_EVENT_MAP } from "../report-cursor.js"; | ||
| describe("CURSOR_EVENT_MAP", () => { | ||
| it("maps all expected event types", () => { | ||
| expect(CURSOR_EVENT_MAP["session-start"]).toBe("SessionStart"); | ||
| expect(CURSOR_EVENT_MAP["session-end"]).toBe("SessionEnd"); | ||
| expect(CURSOR_EVENT_MAP["prompt"]).toBe("UserPromptSubmit"); | ||
| expect(CURSOR_EVENT_MAP["tool-use"]).toBe("PostToolUse"); | ||
| expect(CURSOR_EVENT_MAP["file-edit"]).toBe("PostToolUse"); | ||
| expect(CURSOR_EVENT_MAP["stop"]).toBe("Stop"); | ||
| expect(CURSOR_EVENT_MAP["subagent-stop"]).toBe("SubagentStop"); | ||
| expect(CURSOR_EVENT_MAP["shell"]).toBe("PostToolUse"); | ||
| }); | ||
| }); | ||
| describe("buildCursorPayload", () => { | ||
| it("maps conversation_id to session_id", () => { | ||
| const payload = buildCursorPayload("session-start", { | ||
| conversation_id: "conv-123", | ||
| }, "/home/user/project"); | ||
| expect(payload.session_id).toBe("conv-123"); | ||
| }); | ||
| it("always sets source to cursor", () => { | ||
| const payload = buildCursorPayload("session-start", {}, "/tmp"); | ||
| expect(payload.source).toBe("cursor"); | ||
| }); | ||
| it("uses provided cwd", () => { | ||
| const payload = buildCursorPayload("session-start", {}, "/home/user/project"); | ||
| expect(payload.cwd).toBe("/home/user/project"); | ||
| }); | ||
| it("defaults session_id to empty string when missing", () => { | ||
| const payload = buildCursorPayload("session-start", {}, "/tmp"); | ||
| expect(payload.session_id).toBe(""); | ||
| }); | ||
| it("passes through unknown event types", () => { | ||
| const payload = buildCursorPayload("unknown-event", {}, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("unknown-event"); | ||
| }); | ||
| describe("session-start", () => { | ||
| it("produces a SessionStart payload", () => { | ||
| const payload = buildCursorPayload("session-start", { | ||
| conversation_id: "abc", | ||
| }, "/home/user/project"); | ||
| expect(payload).toEqual({ | ||
| session_id: "abc", | ||
| cwd: "/home/user/project", | ||
| hook_event_name: "SessionStart", | ||
| source: "cursor", | ||
| }); | ||
| }); | ||
| }); | ||
| describe("prompt", () => { | ||
| it("extracts prompt from input field", () => { | ||
| const payload = buildCursorPayload("prompt", { | ||
| conversation_id: "abc", | ||
| input: "fix the bug", | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("UserPromptSubmit"); | ||
| expect(payload.prompt).toBe("fix the bug"); | ||
| }); | ||
| it("falls back to prompt field when input is missing", () => { | ||
| const payload = buildCursorPayload("prompt", { | ||
| conversation_id: "abc", | ||
| prompt: "fallback prompt", | ||
| }, "/tmp"); | ||
| expect(payload.prompt).toBe("fallback prompt"); | ||
| }); | ||
| }); | ||
| describe("tool-use", () => { | ||
| it("passes through tool_name and tool_input", () => { | ||
| const payload = buildCursorPayload("tool-use", { | ||
| conversation_id: "abc", | ||
| tool_name: "Read", | ||
| tool_input: { file_path: "/tmp/foo.ts" }, | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("PostToolUse"); | ||
| expect(payload.tool_name).toBe("Read"); | ||
| expect(payload.tool_input).toEqual({ file_path: "/tmp/foo.ts" }); | ||
| }); | ||
| }); | ||
| describe("file-edit", () => { | ||
| it("normalizes to Edit tool with file_path", () => { | ||
| const payload = buildCursorPayload("file-edit", { | ||
| conversation_id: "abc", | ||
| file_path: "/home/user/project/src/index.ts", | ||
| edits: [{ old_string: "foo", new_string: "bar" }], | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("PostToolUse"); | ||
| expect(payload.tool_name).toBe("Edit"); | ||
| expect(payload.tool_input).toEqual({ file_path: "/home/user/project/src/index.ts" }); | ||
| }); | ||
| it("omits tool_input when file_path is missing", () => { | ||
| const payload = buildCursorPayload("file-edit", { | ||
| conversation_id: "abc", | ||
| }, "/tmp"); | ||
| expect(payload.tool_name).toBe("Edit"); | ||
| expect(payload.tool_input).toBeUndefined(); | ||
| }); | ||
| }); | ||
| describe("shell", () => { | ||
| it("normalizes to Bash tool with command", () => { | ||
| const payload = buildCursorPayload("shell", { | ||
| conversation_id: "abc", | ||
| command: "npm test", | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("PostToolUse"); | ||
| expect(payload.tool_name).toBe("Bash"); | ||
| expect(payload.tool_input).toEqual({ command: "npm test" }); | ||
| }); | ||
| it("omits tool_input when command is missing", () => { | ||
| const payload = buildCursorPayload("shell", { | ||
| conversation_id: "abc", | ||
| }, "/tmp"); | ||
| expect(payload.tool_name).toBe("Bash"); | ||
| expect(payload.tool_input).toBeUndefined(); | ||
| }); | ||
| }); | ||
| describe("subagent-stop", () => { | ||
| it("extracts agent_type", () => { | ||
| const payload = buildCursorPayload("subagent-stop", { | ||
| conversation_id: "abc", | ||
| agent_type: "task", | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("SubagentStop"); | ||
| expect(payload.agent_type).toBe("task"); | ||
| }); | ||
| }); | ||
| describe("stop", () => { | ||
| it("produces a Stop payload", () => { | ||
| const payload = buildCursorPayload("stop", { | ||
| conversation_id: "abc", | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("Stop"); | ||
| expect(payload.source).toBe("cursor"); | ||
| }); | ||
| }); | ||
| describe("session-end", () => { | ||
| it("produces a SessionEnd payload without usage", () => { | ||
| const payload = buildCursorPayload("session-end", { | ||
| conversation_id: "abc", | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("SessionEnd"); | ||
| expect(payload.usage).toBeUndefined(); | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect } from "vitest"; | ||
| import { buildClaudePayload, CLAUDE_EVENT_MAP } from "../report.js"; | ||
| describe("CLAUDE_EVENT_MAP", () => { | ||
| it("maps all expected event types", () => { | ||
| expect(CLAUDE_EVENT_MAP["session-start"]).toBe("SessionStart"); | ||
| expect(CLAUDE_EVENT_MAP["stop"]).toBe("Stop"); | ||
| expect(CLAUDE_EVENT_MAP["session-end"]).toBe("SessionEnd"); | ||
| expect(CLAUDE_EVENT_MAP["tool-use"]).toBe("PostToolUse"); | ||
| expect(CLAUDE_EVENT_MAP["notification"]).toBe("Notification"); | ||
| expect(CLAUDE_EVENT_MAP["subagent-stop"]).toBe("SubagentStop"); | ||
| }); | ||
| }); | ||
| describe("buildClaudePayload", () => { | ||
| it("maps session_id directly", () => { | ||
| const payload = buildClaudePayload("session-start", { | ||
| session_id: "sess-123", | ||
| }, "/home/user/project"); | ||
| expect(payload.session_id).toBe("sess-123"); | ||
| }); | ||
| it("always sets source to claude-code", () => { | ||
| const payload = buildClaudePayload("session-start", {}, "/tmp"); | ||
| expect(payload.source).toBe("claude-code"); | ||
| }); | ||
| it("defaults session_id to empty string when missing", () => { | ||
| const payload = buildClaudePayload("session-start", {}, "/tmp"); | ||
| expect(payload.session_id).toBe(""); | ||
| }); | ||
| it("passes through model, tool_name, tool_input", () => { | ||
| const payload = buildClaudePayload("tool-use", { | ||
| session_id: "sess-1", | ||
| model: "claude-sonnet-4-5-20250514", | ||
| tool_name: "Edit", | ||
| tool_input: { file_path: "/tmp/foo.ts" }, | ||
| }, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("PostToolUse"); | ||
| expect(payload.model).toBe("claude-sonnet-4-5-20250514"); | ||
| expect(payload.tool_name).toBe("Edit"); | ||
| expect(payload.tool_input).toEqual({ file_path: "/tmp/foo.ts" }); | ||
| }); | ||
| it("passes through unknown event types", () => { | ||
| const payload = buildClaudePayload("unknown-event", {}, "/tmp"); | ||
| expect(payload.hook_event_name).toBe("unknown-event"); | ||
| }); | ||
| it("produces correct session-start payload", () => { | ||
| const payload = buildClaudePayload("session-start", { | ||
| session_id: "sess-abc", | ||
| model: "claude-opus-4-6", | ||
| }, "/home/user/project"); | ||
| expect(payload).toEqual({ | ||
| session_id: "sess-abc", | ||
| cwd: "/home/user/project", | ||
| hook_event_name: "SessionStart", | ||
| source: "claude-code", | ||
| model: "claude-opus-4-6", | ||
| tool_name: undefined, | ||
| tool_input: undefined, | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect } from "vitest"; | ||
| import { writeFileSync, mkdirSync, rmSync } from "node:fs"; | ||
| import { join, dirname } from "node:path"; | ||
| import { tmpdir } from "node:os"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { parseTranscript, discoverTranscripts } from "../transcript.js"; | ||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
| const FIXTURE_PATH = join(__dirname, "fixtures", "transcript-full.jsonl"); | ||
| function writeTempTranscript(lines) { | ||
| const dir = join(tmpdir(), `si-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); | ||
| mkdirSync(dir, { recursive: true }); | ||
| const filePath = join(dir, "transcript.jsonl"); | ||
| writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join("\n")); | ||
| return filePath; | ||
| } | ||
| function cleanup(filePath) { | ||
| rmSync(dirname(filePath), { recursive: true, force: true }); | ||
| } | ||
| describe("parseTranscript", () => { | ||
| it("returns null for missing file", () => { | ||
| expect(parseTranscript("/nonexistent/path.jsonl")).toBeNull(); | ||
| }); | ||
| it("returns null for empty file", () => { | ||
| const fp = writeTempTranscript([]); | ||
| try { | ||
| expect(parseTranscript(fp)).toBeNull(); | ||
| } | ||
| finally { | ||
| cleanup(fp); | ||
| } | ||
| }); | ||
| it("returns null when fewer than 2 events are parsed", () => { | ||
| const fp = writeTempTranscript([ | ||
| { | ||
| type: "user", | ||
| sessionId: "sess-1", | ||
| cwd: "/project", | ||
| timestamp: "2026-01-01T10:00:00Z", | ||
| message: { content: "hello" }, | ||
| }, | ||
| ]); | ||
| try { | ||
| expect(parseTranscript(fp)).toBeNull(); | ||
| } | ||
| finally { | ||
| cleanup(fp); | ||
| } | ||
| }); | ||
| describe("with full realistic fixture", () => { | ||
| it("extracts session metadata", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| expect(result).not.toBeNull(); | ||
| expect(result.session_id).toBe("test-session-001"); | ||
| expect(result.cwd).toBe("/Users/testuser/projects/my-app"); | ||
| expect(result.git_branch).toBe("feat/auth"); | ||
| expect(result.model).toBe("claude-sonnet-4-5-20250514"); | ||
| }); | ||
| it("tracks correct time range", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| // First timestamped event is the progress at 10:00:01, last is the final progress at 10:01:10.500 | ||
| expect(result.started_at).toBeLessThanOrEqual(new Date("2026-03-10T10:00:03.000Z").getTime()); | ||
| expect(result.ended_at).toBeGreaterThanOrEqual(new Date("2026-03-10T10:01:10.000Z").getTime()); | ||
| }); | ||
| it("accumulates token usage across all assistant messages", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| // Sum of all input_tokens across assistant messages: 50+50+50+100+150+200+250+300+350 = 1500 | ||
| expect(result.usage.input_tokens).toBe(1500); | ||
| // Sum of output_tokens: 10+20+30+25+40+35+15+20+15 = 210 | ||
| expect(result.usage.output_tokens).toBe(210); | ||
| }); | ||
| it("accumulates cache tokens using cache_read_input_tokens field", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| // cache_read_input_tokens: 500+500+500+2000+2500+3000+3500+4000+4500 = 21000 | ||
| expect(result.usage.cache_read_tokens).toBe(21000); | ||
| // cache_creation_input_tokens: 1000+1000+1000+0+0+0+0+0+0 = 3000 | ||
| expect(result.usage.cache_creation_tokens).toBe(3000); | ||
| }); | ||
| it("skips file-history-snapshot entries", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const hasSnapshot = result.events.some((e) => e.event_type === "file-history-snapshot"); | ||
| expect(hasSnapshot).toBe(false); | ||
| }); | ||
| it("skips progress entries", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const hasProgress = result.events.some((e) => e.event_type === "progress"); | ||
| expect(hasProgress).toBe(false); | ||
| }); | ||
| it("skips system entries", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const hasSystem = result.events.some((e) => e.event_type === "system"); | ||
| expect(hasSystem).toBe(false); | ||
| }); | ||
| it("extracts user prompts with string content", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const prompts = result.events.filter((e) => e.event_type === "UserPromptSubmit" && e.prompt !== null); | ||
| expect(prompts.some((p) => p.prompt === "add authentication to the API endpoints")).toBe(true); | ||
| }); | ||
| it("extracts user prompts with multi-block array content", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const prompts = result.events.filter((e) => e.event_type === "UserPromptSubmit" && e.prompt !== null); | ||
| expect(prompts.some((p) => p.prompt === "looks good, can you also add rate limiting?")).toBe(true); | ||
| }); | ||
| it("extracts Read tool with relativized file path", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const readEvent = result.events.find((e) => e.tool_name === "Read"); | ||
| expect(readEvent).toBeDefined(); | ||
| expect(readEvent.file_paths).toEqual(["src/api/routes.ts"]); | ||
| }); | ||
| it("extracts Edit tool with relativized file path", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const editEvent = result.events.find((e) => e.tool_name === "Edit"); | ||
| expect(editEvent).toBeDefined(); | ||
| expect(editEvent.file_paths).toEqual(["src/api/routes.ts"]); | ||
| }); | ||
| it("extracts Write tool with relativized file path", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const writeEvent = result.events.find((e) => e.tool_name === "Write"); | ||
| expect(writeEvent).toBeDefined(); | ||
| expect(writeEvent.file_paths).toEqual(["src/middleware/auth.ts"]); | ||
| }); | ||
| it("extracts Grep tool with relativized path", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const grepEvent = result.events.find((e) => e.tool_name === "Grep"); | ||
| expect(grepEvent).toBeDefined(); | ||
| expect(grepEvent.file_paths).toEqual(["src"]); | ||
| }); | ||
| it("extracts Glob tool with relativized path", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const globEvent = result.events.find((e) => e.tool_name === "Glob"); | ||
| expect(globEvent).toBeDefined(); | ||
| expect(globEvent.file_paths).toEqual(["src/middleware"]); | ||
| }); | ||
| it("extracts Bash command", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| const bashEvent = result.events.find((e) => e.tool_name === "Bash"); | ||
| expect(bashEvent).toBeDefined(); | ||
| expect(bashEvent.command).toBe("npm test"); | ||
| }); | ||
| it("does not extract events from thinking-only assistant blocks", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| // thinking blocks should not produce tool events | ||
| const thinkingTools = result.events.filter((e) => e.tool_name === "thinking"); | ||
| expect(thinkingTools).toHaveLength(0); | ||
| }); | ||
| it("does not extract events from text-only assistant blocks", () => { | ||
| const result = parseTranscript(FIXTURE_PATH); | ||
| // text-only assistant messages should not produce tool events | ||
| // but tool_use blocks from the same message chain should | ||
| const textTools = result.events.filter((e) => e.tool_name === "text"); | ||
| expect(textTools).toHaveLength(0); | ||
| }); | ||
| }); | ||
| it("truncates prompts to 500 characters", () => { | ||
| const longPrompt = "x".repeat(600); | ||
| const fp = writeTempTranscript([ | ||
| { | ||
| type: "user", | ||
| sessionId: "sess-1", | ||
| cwd: "/project", | ||
| timestamp: "2026-01-01T10:00:00Z", | ||
| message: { content: longPrompt }, | ||
| }, | ||
| { | ||
| type: "assistant", | ||
| timestamp: "2026-01-01T10:00:05Z", | ||
| message: { | ||
| model: "claude-sonnet-4-5-20250514", | ||
| content: [{ type: "tool_use", name: "Read", input: { file_path: "/project/foo.ts" } }], | ||
| }, | ||
| }, | ||
| ]); | ||
| try { | ||
| const result = parseTranscript(fp); | ||
| const prompt = result.events.find((e) => e.prompt !== null); | ||
| expect(prompt.prompt.length).toBe(500); | ||
| } | ||
| finally { | ||
| cleanup(fp); | ||
| } | ||
| }); | ||
| it("handles malformed JSON lines gracefully", () => { | ||
| const dir = join(tmpdir(), `si-test-malformed-${Date.now()}`); | ||
| mkdirSync(dir, { recursive: true }); | ||
| const fp = join(dir, "transcript.jsonl"); | ||
| const content = [ | ||
| '{"type":"user","sessionId":"s1","cwd":"/p","timestamp":"2026-01-01T10:00:00Z","message":{"content":"hello"}}', | ||
| "this is not json", | ||
| "", | ||
| '{"type":"assistant","timestamp":"2026-01-01T10:00:05Z","message":{"model":"claude-sonnet-4-5-20250514","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/p/foo.ts"}}]}}', | ||
| ].join("\n"); | ||
| writeFileSync(fp, content); | ||
| try { | ||
| const result = parseTranscript(fp); | ||
| expect(result).not.toBeNull(); | ||
| expect(result.events.length).toBeGreaterThanOrEqual(2); | ||
| } | ||
| finally { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| it("handles file paths not under cwd", () => { | ||
| const fp = writeTempTranscript([ | ||
| { | ||
| type: "user", | ||
| sessionId: "sess-1", | ||
| cwd: "/project", | ||
| timestamp: "2026-01-01T10:00:00Z", | ||
| message: { content: "read this" }, | ||
| }, | ||
| { | ||
| type: "assistant", | ||
| timestamp: "2026-01-01T10:00:05Z", | ||
| message: { | ||
| model: "claude-sonnet-4-5-20250514", | ||
| content: [ | ||
| { type: "tool_use", name: "Read", input: { file_path: "/other/path/file.ts" } }, | ||
| ], | ||
| }, | ||
| }, | ||
| ]); | ||
| try { | ||
| const result = parseTranscript(fp); | ||
| const readEvent = result.events.find((e) => e.tool_name === "Read"); | ||
| expect(readEvent.file_paths).toEqual(["/other/path/file.ts"]); | ||
| } | ||
| finally { | ||
| cleanup(fp); | ||
| } | ||
| }); | ||
| }); | ||
| describe("discoverTranscripts", () => { | ||
| it("returns empty array for nonexistent directory", () => { | ||
| expect(discoverTranscripts("/nonexistent/path")).toEqual([]); | ||
| }); | ||
| it("discovers jsonl files in project subdirectories", () => { | ||
| const base = join(tmpdir(), `si-discover-${Date.now()}`); | ||
| const projectDir = join(base, "my-project"); | ||
| mkdirSync(projectDir, { recursive: true }); | ||
| writeFileSync(join(projectDir, "session-1.jsonl"), "{}"); | ||
| writeFileSync(join(projectDir, "session-2.jsonl"), "{}"); | ||
| writeFileSync(join(projectDir, "notes.txt"), "not a transcript"); | ||
| try { | ||
| const results = discoverTranscripts(base); | ||
| expect(results).toHaveLength(2); | ||
| expect(results.every((r) => r.endsWith(".jsonl"))).toBe(true); | ||
| } | ||
| finally { | ||
| rmSync(base, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| it("ignores non-directory entries", () => { | ||
| const base = join(tmpdir(), `si-discover-file-${Date.now()}`); | ||
| mkdirSync(base, { recursive: true }); | ||
| writeFileSync(join(base, "not-a-dir.jsonl"), "{}"); | ||
| try { | ||
| const results = discoverTranscripts(base); | ||
| expect(results).toHaveLength(0); | ||
| } | ||
| finally { | ||
| rmSync(base, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| }); |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
5
-16.67%6
-14.29%34640
-41.2%24
-25%875
-39.15%