mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add CLI config preview API for Codex and Gemini
- Implemented `fetchCodexConfigPreview` and `fetchGeminiConfigPreview` functions in the API layer to retrieve masked configuration files. - Added new interfaces `CodexConfigPreviewResponse` and `GeminiConfigPreviewResponse` to define the structure of the API responses. - Created utility functions to read and mask sensitive values from `config.toml` and `auth.json` for Codex, and `settings.json` for Gemini. - Updated CLI settings routes to handle new preview endpoints. - Enhanced session content parser to support Claude JSONL format. - Updated UI components to reflect changes in history page and navigation, including new tabs for observability. - Localized changes for English and Chinese languages to reflect "CLI History" terminology.
This commit is contained in:
@@ -3,6 +3,10 @@
|
||||
* Handles Claude CLI settings file management API endpoints
|
||||
*/
|
||||
|
||||
import { homedir } from 'os';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import {
|
||||
saveEndpointSettings,
|
||||
@@ -20,6 +24,129 @@ import type { SaveEndpointRequest, ImportOptions } from '../../types/cli-setting
|
||||
import { validateSettings } from '../../types/cli-settings.js';
|
||||
import { syncBuiltinToolsAvailability, getBuiltinToolsSyncReport } from '../../tools/claude-cli-tools.js';
|
||||
|
||||
/**
|
||||
* Mask sensitive values (API keys) for display
|
||||
* Shows first 8 characters, masks the rest with asterisks
|
||||
*/
|
||||
function maskSensitiveValue(value: string): string {
|
||||
if (!value || value.length <= 8) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, 8) + '*'.repeat(value.length - 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask API keys in JSON content
|
||||
*/
|
||||
function maskJsonApiKeys(content: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
// Mask common API key fields
|
||||
const sensitiveFields = ['api_key', 'apiKey', 'API_KEY', 'OPENAI_API_KEY', 'token', 'auth_token'];
|
||||
for (const field of sensitiveFields) {
|
||||
if (parsed[field] && typeof parsed[field] === 'string') {
|
||||
parsed[field] = maskSensitiveValue(parsed[field]);
|
||||
}
|
||||
}
|
||||
// Handle nested objects (e.g., api key in providers)
|
||||
if (parsed.providers && Array.isArray(parsed.providers)) {
|
||||
parsed.providers = parsed.providers.map((provider: any) => {
|
||||
if (provider.api_key) {
|
||||
return { ...provider, api_key: maskSensitiveValue(provider.api_key) };
|
||||
}
|
||||
return provider;
|
||||
});
|
||||
}
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
}
|
||||
} catch {
|
||||
// If not valid JSON, return as-is
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask API keys in TOML content
|
||||
*/
|
||||
function maskTomlApiKeys(content: string): string {
|
||||
// Match patterns like api_key = "value" or apiKey = "value"
|
||||
return content.replace(
|
||||
/(api[_-]?key|apiKey|API_KEY|token|auth_token)\s*=\s*["']([^"']+)["']/gi,
|
||||
(match, key, value) => {
|
||||
return `${key} = "${maskSensitiveValue(value)}"`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and mask Codex config files for preview
|
||||
*/
|
||||
function readCodexConfigPreview(): { configToml: string | null; authJson: string | null; errors: string[] } {
|
||||
const home = homedir();
|
||||
const codexDir = join(home, '.codex');
|
||||
const result: { configToml: string | null; authJson: string | null; errors: string[] } = {
|
||||
configToml: null,
|
||||
authJson: null,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Read config.toml
|
||||
const configTomlPath = join(codexDir, 'config.toml');
|
||||
if (existsSync(configTomlPath)) {
|
||||
try {
|
||||
const content = readFileSync(configTomlPath, 'utf-8');
|
||||
result.configToml = maskTomlApiKeys(content);
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to read config.toml: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
result.errors.push('config.toml not found');
|
||||
}
|
||||
|
||||
// Read auth.json
|
||||
const authJsonPath = join(codexDir, 'auth.json');
|
||||
if (existsSync(authJsonPath)) {
|
||||
try {
|
||||
const content = readFileSync(authJsonPath, 'utf-8');
|
||||
result.authJson = maskJsonApiKeys(content);
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to read auth.json: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
result.errors.push('auth.json not found');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and mask Gemini settings file for preview
|
||||
*/
|
||||
function readGeminiConfigPreview(): { settingsJson: string | null; errors: string[] } {
|
||||
const home = homedir();
|
||||
const geminiDir = join(home, '.gemini');
|
||||
const result: { settingsJson: string | null; errors: string[] } = {
|
||||
settingsJson: null,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Read settings.json
|
||||
const settingsPath = join(geminiDir, 'settings.json');
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
result.settingsJson = maskJsonApiKeys(content);
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to read settings.json: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
result.errors.push('settings.json not found');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CLI Settings routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
@@ -334,5 +461,49 @@ export async function handleCliSettingsRoutes(ctx: RouteContext): Promise<boolea
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== CODEX CONFIG PREVIEW ==========
|
||||
// GET /api/cli/settings/codex/preview
|
||||
if (pathname === '/api/cli/settings/codex/preview' && req.method === 'GET') {
|
||||
try {
|
||||
const preview = readCodexConfigPreview();
|
||||
const home = homedir();
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
configPath: join(home, '.codex', 'config.toml'),
|
||||
authPath: join(home, '.codex', 'auth.json'),
|
||||
configToml: preview.configToml,
|
||||
authJson: preview.authJson,
|
||||
errors: preview.errors.length > 0 ? preview.errors : undefined
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== GEMINI CONFIG PREVIEW ==========
|
||||
// GET /api/cli/settings/gemini/preview
|
||||
if (pathname === '/api/cli/settings/gemini/preview' && req.method === 'GET') {
|
||||
try {
|
||||
const preview = readGeminiConfigPreview();
|
||||
const home = homedir();
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
settingsPath: join(home, '.gemini', 'settings.json'),
|
||||
settingsJson: preview.settingsJson,
|
||||
errors: preview.errors.length > 0 ? preview.errors : undefined
|
||||
}));
|
||||
} catch (err) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (err as Error).message }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
539
ccw/src/tools/claude-session-parser.ts
Normal file
539
ccw/src/tools/claude-session-parser.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Claude Session Parser - Parses Claude Code CLI JSONL session files
|
||||
* Storage path: ~/.claude/projects/<projectHash>/<sessionId>.jsonl
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { basename } from 'path';
|
||||
import type { ParsedSession, ParsedTurn, ToolCallInfo, TokenInfo } from './session-content-parser.js';
|
||||
|
||||
/**
|
||||
* Claude JSONL line types
|
||||
* Each line is a JSON object with a type field indicating the content type
|
||||
*/
|
||||
export interface ClaudeJsonlLine {
|
||||
type: 'user' | 'assistant' | 'summary' | 'file-history-snapshot';
|
||||
uuid: string;
|
||||
parentUuid: string | null;
|
||||
timestamp: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
version?: string;
|
||||
gitBranch?: string;
|
||||
isSidechain?: boolean;
|
||||
isMeta?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User message line in Claude JSONL
|
||||
* Content can be string or array of content blocks
|
||||
*/
|
||||
export interface ClaudeUserLine extends ClaudeJsonlLine {
|
||||
type: 'user';
|
||||
message: {
|
||||
role: 'user';
|
||||
content: string | ClaudeContentBlock[];
|
||||
};
|
||||
userType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assistant message line in Claude JSONL
|
||||
* Contains content blocks, tool calls, and usage info
|
||||
*/
|
||||
export interface ClaudeAssistantLine extends ClaudeJsonlLine {
|
||||
type: 'assistant';
|
||||
message: {
|
||||
role: 'assistant';
|
||||
content: ClaudeContentBlock[];
|
||||
model?: string;
|
||||
id?: string;
|
||||
stop_reason?: string | null;
|
||||
stop_sequence?: string | null;
|
||||
};
|
||||
usage?: ClaudeUsage;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary line in Claude JSONL (for compacted conversations)
|
||||
*/
|
||||
export interface ClaudeSummaryLine extends ClaudeJsonlLine {
|
||||
type: 'summary';
|
||||
summary: string;
|
||||
lemon?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File history snapshot (metadata, not conversation content)
|
||||
*/
|
||||
export interface ClaudeFileHistoryLine extends ClaudeJsonlLine {
|
||||
type: 'file-history-snapshot';
|
||||
messageId: string;
|
||||
snapshot: {
|
||||
messageId: string;
|
||||
trackedFileBackups: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
};
|
||||
isSnapshotUpdate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content block in Claude messages
|
||||
*/
|
||||
export interface ClaudeContentBlock {
|
||||
type: 'text' | 'tool_use' | 'tool_result' | 'image' | 'thinking';
|
||||
text?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
input?: Record<string, unknown>;
|
||||
tool_use_id?: string;
|
||||
content?: string;
|
||||
source?: {
|
||||
type: string;
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
path?: string;
|
||||
};
|
||||
thinking?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage information in Claude messages
|
||||
*/
|
||||
export interface ClaudeUsage {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
service_tier?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude Code CLI session file (JSONL format)
|
||||
* @param filePath - Path to the Claude JSONL session file
|
||||
* @returns ParsedSession object with turns, tokens, and metadata
|
||||
*/
|
||||
export function parseClaudeSession(filePath: string): ParsedSession | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
|
||||
// Extract session ID from filename (UUID format)
|
||||
const sessionId = extractSessionId(filePath);
|
||||
|
||||
const turns: ParsedTurn[] = [];
|
||||
let workingDir = '';
|
||||
let startTime = '';
|
||||
let lastUpdated = '';
|
||||
let model: string | undefined;
|
||||
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
||||
|
||||
// Track conversation structure using uuid/parentUuid
|
||||
const messageMap = new Map<string, ClaudeJsonlLine>();
|
||||
const rootUuids: string[] = [];
|
||||
|
||||
// First pass: collect all messages and find roots
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry: ClaudeJsonlLine = JSON.parse(line);
|
||||
|
||||
// Skip non-conversation types
|
||||
if (entry.type === 'file-history-snapshot') {
|
||||
continue;
|
||||
}
|
||||
|
||||
messageMap.set(entry.uuid, entry);
|
||||
|
||||
// Track root messages (no parent)
|
||||
if (!entry.parentUuid) {
|
||||
rootUuids.push(entry.uuid);
|
||||
}
|
||||
|
||||
// Extract metadata from first entry
|
||||
if (!startTime && entry.timestamp) {
|
||||
startTime = entry.timestamp;
|
||||
}
|
||||
if (!workingDir && entry.cwd) {
|
||||
workingDir = entry.cwd;
|
||||
}
|
||||
|
||||
// Update last timestamp
|
||||
if (entry.timestamp) {
|
||||
lastUpdated = entry.timestamp;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: build conversation turns
|
||||
let turnNumber = 0;
|
||||
const processedUuids = new Set<string>();
|
||||
|
||||
for (const rootUuid of rootUuids) {
|
||||
const turn = processConversationBranch(
|
||||
rootUuid,
|
||||
messageMap,
|
||||
processedUuids,
|
||||
++turnNumber
|
||||
);
|
||||
|
||||
if (turn) {
|
||||
turns.push(turn);
|
||||
|
||||
// Accumulate tokens
|
||||
if (turn.tokens) {
|
||||
totalTokens.input = (totalTokens.input || 0) + (turn.tokens.input || 0);
|
||||
totalTokens.output = (totalTokens.output || 0) + (turn.tokens.output || 0);
|
||||
totalTokens.total = (totalTokens.total || 0) + (turn.tokens.total || 0);
|
||||
}
|
||||
|
||||
// Track model
|
||||
if (!model && turn.tokens?.input) {
|
||||
// Model info is typically in assistant messages
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract model from assistant messages if not found
|
||||
if (!model) {
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'assistant' && entry.message?.model) {
|
||||
model = entry.message.model;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total tokens
|
||||
totalTokens.total = (totalTokens.input || 0) + (totalTokens.output || 0);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
tool: 'claude',
|
||||
workingDir,
|
||||
startTime,
|
||||
lastUpdated,
|
||||
turns,
|
||||
totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
|
||||
model
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse Claude session file ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract session ID from file path
|
||||
* Claude session files are named <uuid>.jsonl
|
||||
*/
|
||||
function extractSessionId(filePath: string): string {
|
||||
const filename = basename(filePath);
|
||||
// Remove .jsonl extension
|
||||
const nameWithoutExt = filename.replace(/\.jsonl$/i, '');
|
||||
// Check if it's a UUID format
|
||||
const uuidMatch = nameWithoutExt.match(
|
||||
/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
|
||||
);
|
||||
return uuidMatch ? uuidMatch[1] : nameWithoutExt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a conversation branch starting from a root UUID
|
||||
* Returns a combined turn with user and assistant messages
|
||||
*/
|
||||
function processConversationBranch(
|
||||
rootUuid: string,
|
||||
messageMap: Map<string, ClaudeJsonlLine>,
|
||||
processedUuids: Set<string>,
|
||||
turnNumber: number
|
||||
): ParsedTurn | null {
|
||||
const rootEntry = messageMap.get(rootUuid);
|
||||
if (!rootEntry || processedUuids.has(rootUuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the user message at this root
|
||||
let userContent = '';
|
||||
let userTimestamp = '';
|
||||
let assistantContent = '';
|
||||
let assistantTimestamp = '';
|
||||
let toolCalls: ToolCallInfo[] = [];
|
||||
let tokens: TokenInfo | undefined;
|
||||
let thoughts: string[] = [];
|
||||
|
||||
// Process this entry if it's a user message
|
||||
if (rootEntry.type === 'user') {
|
||||
const userEntry = rootEntry as ClaudeUserLine;
|
||||
processedUuids.add(rootEntry.uuid);
|
||||
|
||||
// Skip meta messages (command messages, etc.)
|
||||
if (userEntry.isMeta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
userContent = extractUserContent(userEntry);
|
||||
userTimestamp = rootEntry.timestamp;
|
||||
|
||||
// Find child assistant message
|
||||
for (const [uuid, entry] of messageMap) {
|
||||
if (entry.parentUuid === rootEntry.uuid && entry.type === 'assistant') {
|
||||
const assistantEntry = entry as ClaudeAssistantLine;
|
||||
processedUuids.add(uuid);
|
||||
|
||||
const extracted = extractAssistantContent(assistantEntry);
|
||||
assistantContent = extracted.content;
|
||||
assistantTimestamp = entry.timestamp;
|
||||
toolCalls = extracted.toolCalls;
|
||||
thoughts = extracted.thoughts;
|
||||
|
||||
if (assistantEntry.usage) {
|
||||
tokens = {
|
||||
input: assistantEntry.usage.input_tokens,
|
||||
output: assistantEntry.usage.output_tokens,
|
||||
total: assistantEntry.usage.input_tokens + assistantEntry.usage.output_tokens,
|
||||
cached: (assistantEntry.usage.cache_read_input_tokens || 0) +
|
||||
(assistantEntry.usage.cache_creation_input_tokens || 0)
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool result messages (follow-up user messages)
|
||||
for (const [uuid, entry] of messageMap) {
|
||||
if (entry.parentUuid === rootEntry.uuid && entry.type === 'user') {
|
||||
const followUpUser = entry as ClaudeUserLine;
|
||||
if (!followUpUser.isMeta && processedUuids.has(uuid)) {
|
||||
continue;
|
||||
}
|
||||
// Check if this is a tool result message
|
||||
if (followUpUser.message?.content && Array.isArray(followUpUser.message.content)) {
|
||||
const hasToolResult = followUpUser.message.content.some(
|
||||
block => block.type === 'tool_result'
|
||||
);
|
||||
if (hasToolResult) {
|
||||
processedUuids.add(uuid);
|
||||
// Tool results are typically not displayed as separate turns
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userContent) {
|
||||
return {
|
||||
turnNumber,
|
||||
timestamp: userTimestamp,
|
||||
role: 'user',
|
||||
content: userContent
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// If no user content but we have assistant content (edge case)
|
||||
if (assistantContent) {
|
||||
return {
|
||||
turnNumber,
|
||||
timestamp: assistantTimestamp,
|
||||
role: 'assistant',
|
||||
content: assistantContent,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
thoughts: thoughts.length > 0 ? thoughts : undefined,
|
||||
tokens
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from user message
|
||||
* Handles both string and array content formats
|
||||
*/
|
||||
function extractUserContent(entry: ClaudeUserLine): string {
|
||||
const content = entry.message?.content;
|
||||
if (!content) return '';
|
||||
|
||||
// Simple string content
|
||||
if (typeof content === 'string') {
|
||||
// Skip command messages
|
||||
if (content.startsWith('<command-') || content.includes('<local-command')) {
|
||||
return '';
|
||||
}
|
||||
// Skip meta messages
|
||||
if (content.includes('<local-command-caveat>')) {
|
||||
return '';
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
// Array content - extract text blocks
|
||||
if (Array.isArray(content)) {
|
||||
const textParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && block.text) {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
// Note: tool_result blocks contain tool outputs, not user text
|
||||
}
|
||||
return textParts.join('\n');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content, tool calls, and thoughts from assistant message
|
||||
*/
|
||||
function extractAssistantContent(entry: ClaudeAssistantLine): {
|
||||
content: string;
|
||||
toolCalls: ToolCallInfo[];
|
||||
thoughts: string[];
|
||||
} {
|
||||
const result: { content: string; toolCalls: ToolCallInfo[]; thoughts: string[] } = {
|
||||
content: '',
|
||||
toolCalls: [],
|
||||
thoughts: []
|
||||
};
|
||||
|
||||
const content = entry.message?.content;
|
||||
if (!content || !Array.isArray(content)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const block of content) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text) {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'thinking':
|
||||
if (block.thinking) {
|
||||
result.thoughts.push(block.thinking);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_use':
|
||||
result.toolCalls.push({
|
||||
name: block.name || 'unknown',
|
||||
arguments: block.input ? JSON.stringify(block.input) : undefined
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
result.content = textParts.join('\n');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Claude session content from string (for in-memory parsing)
|
||||
* @param content - The JSONL content as string
|
||||
* @param filePath - Optional file path for session ID extraction
|
||||
* @returns ParsedSession object
|
||||
*/
|
||||
export function parseClaudeSessionContent(content: string, filePath?: string): ParsedSession | null {
|
||||
const sessionId = filePath ? extractSessionId(filePath) : 'unknown';
|
||||
|
||||
const lines = content.split('\n').filter(l => l.trim());
|
||||
const turns: ParsedTurn[] = [];
|
||||
let workingDir = '';
|
||||
let startTime = '';
|
||||
let lastUpdated = '';
|
||||
let model: string | undefined;
|
||||
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
|
||||
|
||||
// Track conversation structure
|
||||
const messageMap = new Map<string, ClaudeJsonlLine>();
|
||||
const rootUuids: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry: ClaudeJsonlLine = JSON.parse(line);
|
||||
|
||||
if (entry.type === 'file-history-snapshot') {
|
||||
continue;
|
||||
}
|
||||
|
||||
messageMap.set(entry.uuid, entry);
|
||||
|
||||
if (!entry.parentUuid) {
|
||||
rootUuids.push(entry.uuid);
|
||||
}
|
||||
|
||||
if (!startTime && entry.timestamp) {
|
||||
startTime = entry.timestamp;
|
||||
}
|
||||
if (!workingDir && entry.cwd) {
|
||||
workingDir = entry.cwd;
|
||||
}
|
||||
if (entry.timestamp) {
|
||||
lastUpdated = entry.timestamp;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
let turnNumber = 0;
|
||||
const processedUuids = new Set<string>();
|
||||
|
||||
for (const rootUuid of rootUuids) {
|
||||
const turn = processConversationBranch(
|
||||
rootUuid,
|
||||
messageMap,
|
||||
processedUuids,
|
||||
++turnNumber
|
||||
);
|
||||
|
||||
if (turn) {
|
||||
turns.push(turn);
|
||||
|
||||
if (turn.tokens) {
|
||||
totalTokens.input = (totalTokens.input || 0) + (turn.tokens.input || 0);
|
||||
totalTokens.output = (totalTokens.output || 0) + (turn.tokens.output || 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract model
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === 'assistant' && entry.message?.model) {
|
||||
model = entry.message.model;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
totalTokens.total = (totalTokens.input || 0) + (totalTokens.output || 0);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
tool: 'claude',
|
||||
workingDir,
|
||||
startTime,
|
||||
lastUpdated,
|
||||
turns,
|
||||
totalTokens: totalTokens.total > 0 ? totalTokens : undefined,
|
||||
model
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Session Content Parser - Parses native CLI tool session files
|
||||
* Supports Gemini/Qwen JSON and Codex JSONL formats
|
||||
* Supports Gemini/Qwen JSON, Codex JSONL, and Claude JSONL formats
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { parseClaudeSession } from './claude-session-parser.js';
|
||||
|
||||
// Standardized conversation turn
|
||||
export interface ParsedTurn {
|
||||
@@ -197,6 +198,8 @@ export function parseSessionFile(filePath: string, tool: string): ParsedSession
|
||||
return parseGeminiQwenSession(content, tool);
|
||||
case 'codex':
|
||||
return parseCodexSession(content);
|
||||
case 'claude':
|
||||
return parseClaudeSession(filePath);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -298,3 +298,35 @@ export interface ImportResult {
|
||||
/** List of imported endpoint IDs */
|
||||
importedIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex config preview response
|
||||
*/
|
||||
export interface CodexConfigPreviewResponse {
|
||||
/** Whether preview was successful */
|
||||
success: boolean;
|
||||
/** Path to config.toml */
|
||||
configPath: string;
|
||||
/** Path to auth.json */
|
||||
authPath: string;
|
||||
/** config.toml content with sensitive values masked */
|
||||
configToml: string | null;
|
||||
/** auth.json content with API keys masked */
|
||||
authJson: string | null;
|
||||
/** Error messages if any files could not be read */
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini config preview response
|
||||
*/
|
||||
export interface GeminiConfigPreviewResponse {
|
||||
/** Whether preview was successful */
|
||||
success: boolean;
|
||||
/** Path to settings.json */
|
||||
settingsPath: string;
|
||||
/** settings.json content with sensitive values masked */
|
||||
settingsJson: string | null;
|
||||
/** Error messages if file could not be read */
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user