|
38 | 38 | ANTHROPIC_VERSION = "2023-06-01" |
39 | 39 |
|
40 | 40 |
|
| 41 | +def get_session_summary(filepath, max_length=200): |
| 42 | + """Extract a human-readable summary from a session file. |
| 43 | +
|
| 44 | + Supports both JSON and JSONL formats. |
| 45 | + Returns a summary string or "(no summary)" if none found. |
| 46 | + """ |
| 47 | + filepath = Path(filepath) |
| 48 | + try: |
| 49 | + if filepath.suffix == ".jsonl": |
| 50 | + return _get_jsonl_summary(filepath, max_length) |
| 51 | + else: |
| 52 | + # For JSON files, try to get first user message |
| 53 | + with open(filepath, "r", encoding="utf-8") as f: |
| 54 | + data = json.load(f) |
| 55 | + loglines = data.get("loglines", []) |
| 56 | + for entry in loglines: |
| 57 | + if entry.get("type") == "user": |
| 58 | + msg = entry.get("message", {}) |
| 59 | + content = msg.get("content", "") |
| 60 | + if isinstance(content, str) and content.strip(): |
| 61 | + if len(content) > max_length: |
| 62 | + return content[: max_length - 3] + "..." |
| 63 | + return content |
| 64 | + return "(no summary)" |
| 65 | + except Exception: |
| 66 | + return "(no summary)" |
| 67 | + |
| 68 | + |
| 69 | +def _get_jsonl_summary(filepath, max_length=200): |
| 70 | + """Extract summary from JSONL file.""" |
| 71 | + try: |
| 72 | + with open(filepath, "r", encoding="utf-8") as f: |
| 73 | + for line in f: |
| 74 | + line = line.strip() |
| 75 | + if not line: |
| 76 | + continue |
| 77 | + try: |
| 78 | + obj = json.loads(line) |
| 79 | + # First priority: summary type entries |
| 80 | + if obj.get("type") == "summary" and obj.get("summary"): |
| 81 | + summary = obj["summary"] |
| 82 | + if len(summary) > max_length: |
| 83 | + return summary[: max_length - 3] + "..." |
| 84 | + return summary |
| 85 | + except json.JSONDecodeError: |
| 86 | + continue |
| 87 | + |
| 88 | + # Second pass: find first non-meta user message |
| 89 | + with open(filepath, "r", encoding="utf-8") as f: |
| 90 | + for line in f: |
| 91 | + line = line.strip() |
| 92 | + if not line: |
| 93 | + continue |
| 94 | + try: |
| 95 | + obj = json.loads(line) |
| 96 | + if ( |
| 97 | + obj.get("type") == "user" |
| 98 | + and not obj.get("isMeta") |
| 99 | + and obj.get("message", {}).get("content") |
| 100 | + ): |
| 101 | + content = obj["message"]["content"] |
| 102 | + if isinstance(content, str): |
| 103 | + content = content.strip() |
| 104 | + if content and not content.startswith("<"): |
| 105 | + if len(content) > max_length: |
| 106 | + return content[: max_length - 3] + "..." |
| 107 | + return content |
| 108 | + except json.JSONDecodeError: |
| 109 | + continue |
| 110 | + except Exception: |
| 111 | + pass |
| 112 | + |
| 113 | + return "(no summary)" |
| 114 | + |
| 115 | + |
| 116 | +def find_local_sessions(folder, limit=10): |
| 117 | + """Find recent JSONL session files in the given folder. |
| 118 | +
|
| 119 | + Returns a list of (Path, summary) tuples sorted by modification time. |
| 120 | + Excludes agent files and warmup/empty sessions. |
| 121 | + """ |
| 122 | + folder = Path(folder) |
| 123 | + if not folder.exists(): |
| 124 | + return [] |
| 125 | + |
| 126 | + results = [] |
| 127 | + for f in folder.glob("**/*.jsonl"): |
| 128 | + if f.name.startswith("agent-"): |
| 129 | + continue |
| 130 | + summary = get_session_summary(f) |
| 131 | + # Skip boring/empty sessions |
| 132 | + if summary.lower() == "warmup" or summary == "(no summary)": |
| 133 | + continue |
| 134 | + results.append((f, summary)) |
| 135 | + |
| 136 | + # Sort by modification time, most recent first |
| 137 | + results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True) |
| 138 | + return results[:limit] |
| 139 | + |
| 140 | + |
| 141 | +def parse_session_file(filepath): |
| 142 | + """Parse a session file and return normalized data. |
| 143 | +
|
| 144 | + Supports both JSON and JSONL formats. |
| 145 | + Returns a dict with 'loglines' key containing the normalized entries. |
| 146 | + """ |
| 147 | + filepath = Path(filepath) |
| 148 | + |
| 149 | + if filepath.suffix == ".jsonl": |
| 150 | + return _parse_jsonl_file(filepath) |
| 151 | + else: |
| 152 | + # Standard JSON format |
| 153 | + with open(filepath, "r", encoding="utf-8") as f: |
| 154 | + return json.load(f) |
| 155 | + |
| 156 | + |
| 157 | +def _parse_jsonl_file(filepath): |
| 158 | + """Parse JSONL file and convert to standard format.""" |
| 159 | + loglines = [] |
| 160 | + |
| 161 | + with open(filepath, "r", encoding="utf-8") as f: |
| 162 | + for line in f: |
| 163 | + line = line.strip() |
| 164 | + if not line: |
| 165 | + continue |
| 166 | + try: |
| 167 | + obj = json.loads(line) |
| 168 | + entry_type = obj.get("type") |
| 169 | + |
| 170 | + # Skip non-message entries |
| 171 | + if entry_type not in ("user", "assistant"): |
| 172 | + continue |
| 173 | + |
| 174 | + # Convert to standard format |
| 175 | + entry = { |
| 176 | + "type": entry_type, |
| 177 | + "timestamp": obj.get("timestamp", ""), |
| 178 | + "message": obj.get("message", {}), |
| 179 | + } |
| 180 | + |
| 181 | + # Preserve isCompactSummary if present |
| 182 | + if obj.get("isCompactSummary"): |
| 183 | + entry["isCompactSummary"] = True |
| 184 | + |
| 185 | + loglines.append(entry) |
| 186 | + except json.JSONDecodeError: |
| 187 | + continue |
| 188 | + |
| 189 | + return {"loglines": loglines} |
| 190 | + |
| 191 | + |
41 | 192 | class CredentialsError(Exception): |
42 | 193 | """Raised when credentials cannot be obtained.""" |
43 | 194 |
|
@@ -730,9 +881,8 @@ def generate_html(json_path, output_dir, github_repo=None): |
730 | 881 | output_dir = Path(output_dir) |
731 | 882 | output_dir.mkdir(exist_ok=True) |
732 | 883 |
|
733 | | - # Load JSON file |
734 | | - with open(json_path, "r") as f: |
735 | | - data = json.load(f) |
| 884 | + # Load session file (supports both JSON and JSONL) |
| 885 | + data = parse_session_file(json_path) |
736 | 886 |
|
737 | 887 | loglines = data.get("loglines", []) |
738 | 888 |
|
@@ -920,13 +1070,64 @@ def generate_html(json_path, output_dir, github_repo=None): |
920 | 1070 | ) |
921 | 1071 |
|
922 | 1072 |
|
923 | | -@click.group(cls=DefaultGroup, default="session", default_if_no_args=False) |
| 1073 | +@click.group(cls=DefaultGroup, default="list-local", default_if_no_args=True) |
924 | 1074 | @click.version_option(None, "-v", "--version", package_name="claude-code-publish") |
925 | 1075 | def cli(): |
926 | 1076 | """Convert Claude Code session JSON to mobile-friendly HTML pages.""" |
927 | 1077 | pass |
928 | 1078 |
|
929 | 1079 |
|
| 1080 | +@cli.command("list-local") |
| 1081 | +@click.option( |
| 1082 | + "--limit", |
| 1083 | + default=10, |
| 1084 | + help="Maximum number of sessions to show (default: 10)", |
| 1085 | +) |
| 1086 | +def list_local(limit): |
| 1087 | + """List available local Claude Code sessions.""" |
| 1088 | + projects_folder = Path.home() / ".claude" / "projects" |
| 1089 | + |
| 1090 | + if not projects_folder.exists(): |
| 1091 | + click.echo(f"Projects folder not found: {projects_folder}") |
| 1092 | + click.echo("No local Claude Code sessions available.") |
| 1093 | + return |
| 1094 | + |
| 1095 | + click.echo("Loading local sessions...") |
| 1096 | + results = find_local_sessions(projects_folder, limit=limit) |
| 1097 | + |
| 1098 | + if not results: |
| 1099 | + click.echo("No local sessions found.") |
| 1100 | + return |
| 1101 | + |
| 1102 | + # Calculate terminal width for formatting |
| 1103 | + try: |
| 1104 | + term_width = shutil.get_terminal_size().columns |
| 1105 | + except Exception: |
| 1106 | + term_width = 80 |
| 1107 | + |
| 1108 | + # Fixed width: date(16) + spaces(2) + size(8) + spaces(2) = 28 |
| 1109 | + fixed_width = 28 |
| 1110 | + summary_width = max(20, term_width - fixed_width - 1) |
| 1111 | + |
| 1112 | + click.echo("") |
| 1113 | + click.echo("Recent local sessions:") |
| 1114 | + click.echo("") |
| 1115 | + |
| 1116 | + from datetime import datetime |
| 1117 | + |
| 1118 | + for filepath, summary in results: |
| 1119 | + stat = filepath.stat() |
| 1120 | + mod_time = datetime.fromtimestamp(stat.st_mtime) |
| 1121 | + size_kb = stat.st_size / 1024 |
| 1122 | + date_str = mod_time.strftime("%Y-%m-%d %H:%M") |
| 1123 | + |
| 1124 | + # Truncate summary if needed |
| 1125 | + if len(summary) > summary_width: |
| 1126 | + summary = summary[: summary_width - 3] + "..." |
| 1127 | + |
| 1128 | + click.echo(f"{date_str} {size_kb:6.0f} KB {summary}") |
| 1129 | + |
| 1130 | + |
930 | 1131 | @cli.command() |
931 | 1132 | @click.argument("json_file", type=click.Path(exists=True)) |
932 | 1133 | @click.option( |
|
0 commit comments