SLOPSHOPPER

tool-log

Claude Code skill that builds, debugs, audits and packages Claude Code mods from a plain-language description

newguardtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-log
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ tool-log │ ⏺ Read(src/auth.ts) │ tool-log: choose a log folder in /config │ ⎿ Read 6 lines │ to start logging │ ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tool-log

Leia em português

Keeps a written record, in a folder you pick, of every tool Claude used, with secrets hidden.

What is a mod?

Claude Code is Anthropic's assistant for working with code, in the terminal or in the Claude desktop app. A mod is a small add-on for it: it can add a panel, a bar above the box where you type, a new /command, or a rule that checks what Claude is about to do. This folder is one complete mod you can install and use as it is, or copy as a starting point.

What it does

Sometimes you want to know afterwards what exactly Claude did: which commands it ran, which files it touched. This mod writes one line per tool call to a file, one file per session. Each line says which tool, when, and a short summary of what it was asked. Things that look like passwords or tokens are replaced with *** before anything is written.

What you see

  • Nothing on screen. The record goes to files named after the session, ending in .jsonl (one line of data per tool call).

How to use it

  1. In the settings, pick a Log folder. Choose one outside your projects, so the logs never end up in Git by accident.
  2. Use Claude normally. Open the folder to read the files with any text editor.
  3. To pause it without uninstalling, turn off Enabled.

Keyboard shortcuts

None of its own.

Commands

None. It works on its own.

Settings

Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.

SettingWhat it meansDefault
logDir (Log folder)Folder for the log files. Pick one outside your projects.(empty)
enabled (Enabled)Turn the mod off without uninstalling it.true

Good to know

  • Each summary is cut at 300 characters. File contents and tool results are never written.
  • When a file reaches 2 MB, a new one starts (-part1, -part2…). An existing file is never overwritten.
  • Hiding secrets is a best effort: it catches the common forms, not every possible one. Keep the folder private.
  • If writing fails, Claude keeps working normally; the log never blocks your session.

Install

You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.

  1. Copy this folder anywhere on your computer.
  2. Inside the copy, rename the folder _claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.
  3. Start Claude Code with the mod:
claude --plugin-dir path/to/tool-log

To keep it installed for good, ask the claude-mod-forge skill to package it ("package this mod").

Check it

From the skill folder (skills/claude-mod-forge), this runs the manifest check, a strict type check and the 16 automated tests of this mod:

node scripts/check-mod.mjs examples/tool-log

The tests cover the mod's logic and what it draws in the terminal and the desktop app. How it looks in your own window is worth a quick look in a real session.

For developers: the examples table lists the hooks and API calls this mod demonstrates.

Source 2 files
hooks/register.ts 117 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { byteLength, fileName, redact, shouldRoll, summarize } from './entry'
4import type { Entry } from './entry'
5
6// An OBSERVER: it never changes or blocks a tool call, and has no .catch, so a failure here
7// lets the call go on untouched. $.fs has no append: every flush is read + write of the whole
8// file, serialised through one promise chain because tool calls can run in parallel.
9
10type Target = { dir: string; sessionId: string; startedAt: number; part: number }
11
12let target: Target | undefined
13let queue: Entry[] = []
14let chain: Promise<void> = Promise.resolve()
15let hasWarned = false
16const MAX_PARTS_PER_FLUSH = 50
17
18async function writeQueued($: EngineInterface) {
19  if (target === undefined || queue.length === 0) return
20  const batch = queue
21  queue = []
22  const add = batch.map(e => JSON.stringify(e)).join('\n') + '\n'
23  try {
24    // Find the part to append to. After a reload or resume `part` starts again at 0, so walk past every
25    // part that already exists and is full, and always read what the chosen part holds: never assume
26    // a new part is empty. A part that cannot be read is skipped, never overwritten.
27    const addBytes = byteLength(add)
28    let path = fileName(target.dir, target.sessionId, target.startedAt, target.part)
29    let current = ''
30    for (let tries = 0; ; tries += 1) {
31      if (tries > MAX_PARTS_PER_FLUSH) throw new Error('no writable log part')
32      path = fileName(target.dir, target.sessionId, target.startedAt, target.part)
33      if (!(await $.fs.exists(path))) {
34        current = ''
35        break
36      }
37      const size = (await $.fs.stat(path)).size
38      if (shouldRoll(size, addBytes)) {
39        target.part += 1
40        continue
41      }
42      const read = await $.fs.read(path).then(
43        text => ({ text }),
44        () => undefined,
45      )
46      if (read === undefined) {
47        target.part += 1 // unreadable (e.g. over the 4 MiB read limit): leave it as it is, move on
48        continue
49      }
50      current = read.text
51      break
52    }
53    await $.fs.write(path, current + add)
54  } catch (error) {
55    // Lost batch is reported, never retried in a loop. No secrets in the message: only the error kind.
56    $.ui.log(`tool-log: could not write the log (${error instanceof Error ? error.name : 'error'})`, { to: 'debug' })
57    if (!hasWarned) {
58      hasWarned = true
59      $.ui.toast('tool-log: cannot write to the log folder; check it in /config')
60    }
61  }
62}
63
64function flush($: EngineInterface): Promise<void> {
65  chain = chain.then(() => writeQueued($))
66  return chain
67}
68
69export const register: Register = (on, options) => {
70  const dir = String(options.logDir ?? '').trim()
71  const isEnabled = options.enabled !== false && dir !== ''
72
73  on('session.start', async ($, e, next) => {
74    if (options.enabled !== false && dir === '') {
75      $.ui.toast('tool-log: choose a log folder in /config to start logging')
76    }
77    if (isEnabled) {
78      // A reload or resume keeps the same session id and day: start again at part 0 and let the
79      // writer walk past the parts that are already full, so no part is ever overwritten.
80      target = { dir, sessionId: await $.session.id(), startedAt: await $.clock.now(), part: 0 }
81    }
82    return next(e)
83  })
84
85  on('tool.call', async ($, e, next) => {
86    if (!isEnabled || target === undefined) return next(e)
87    const started = await $.clock.now()
88    const result = await next(e)
89    const ended = await $.clock.now()
90    const status: Entry['status'] = result.deny !== undefined ? 'denied' : result.isError === true ? 'error' : 'ok'
91    const tool = String(e.tool)
92    queue.push({
93      ts: new Date(ended).toISOString(),
94      tool,
95      id: e.tool_use_id,
96      ...(e.agentId === undefined ? {} : { agentId: e.agentId }),
97      status,
98      ms: Math.max(0, ended - started),
99      args: summarize(tool, e as unknown as Record<string, unknown>),
100      ...(result.deny === undefined ? {} : { reason: redact(String(result.deny)).slice(0, 200) }),
101    })
102    if (queue.length >= 20) void flush($)
103    return result // the call's result goes back exactly as it came
104  })
105
106  on('turn.complete', async ($, e, next) => {
107    if (isEnabled) await flush($)
108    return next(e)
109  })
110
111  on('session.end', async ($, e, next) => {
112    // All session.end hooks share ~1.5 s: the last batch can be lost if the disk is slow (documented).
113    if (isEnabled) await flush($)
114    return next(e)
115  })
116}
117
hooks/entry.ts 71 lines
1// Pure logic for tool-log: what an entry holds, how secrets are masked, when a file rolls. No $ here.
2
3export const MAX_FILE_BYTES = 2 * 1024 * 1024 // stay well under the 4 MiB $.fs write limit
4const ARG_CHARS = 300
5
6// Best effort: regexes catch the common spellings, not every one. A value stops at a space or a quote so
7// the command around it stays readable ("...Bearer ***" keeps its closing quote).
8const SECRETS: [RegExp, string][] = [
9  // Authorization: <scheme> <value>; the scheme (Bearer, Basic, Token, Digest) stays, the value goes.
10  [/\b(authorization\s*[:=]\s*)((?:bearer|basic|token|digest)\s+)?[^\s"']+/gi, '$1$2***'],
11  [/\bbearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, 'Bearer ***'],
12  // Any other header whose name ENDS in key/token/secret as its own word: X-Api-Key: abc, X-Auth-Token: abc.
13  // Not "monkey:", "tokenizer:", nor a host like https://keycloak:8443 (no "//" right before the name).
14  [/(^|[^A-Za-z0-9/])((?:[A-Za-z0-9]+[-_])*(?:key|token|secret)\s*:\s*)[^\s"']+/gi, '$1$2***'],
15  [/\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1***'],
16  [/(--?(?:password|token|api-key|secret)[= ]\s*)\S+/gi, '$1***'],
17  // curl -u user:pass / --user user:pass: the user stays, the password goes.
18  // A numeric uid:gid (docker run -u 1000:1000) is not a password and stays.
19  [/((?:^|\s)(?:-u|--user)(?:=|\s+)?)(?!\d+:\d+(?:\s|$))([^\s:"']+):[^\s"']+/g, '$1$2:***'],
20  // mysql -pSECRET (glued to -p; -P is the port, so case matters) and docker login -p SECRET.
21  [/(\b(?:mysql|mysqldump|mysqladmin|mariadb)\b[^|;&\n]*?\s-p)[^\s"']+/g, '$1***'],
22  [/(\bdocker\s+login\b[^|;&\n]*?\s-p\s+)[^\s"']+/g, '$1***'],
23  [/\b(sk|pk|rk|ghp|gho|github_pat|xox[abp])[-_][A-Za-z0-9_-]{10,}/g, '***'],
24  [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, '***'], // AWS access key ids
25  [/(\/\/[^/\s:@]+):[^@\s/]+@/g, '$1:***@'],
26]
27
28export function redact(text: string): string {
29  return SECRETS.reduce((out, [pattern, replacement]) => out.replace(pattern, replacement), text)
30}
31
32// A short, redacted summary of the call's arguments. File contents are never logged, only paths.
33export function summarize(tool: string, args: Record<string, unknown>): string {
34  const pick = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : undefined)
35  let text: string
36  if (['Edit', 'Write', 'NotebookEdit', 'Read'].includes(tool)) text = pick('file_path') ?? pick('notebook_path') ?? ''
37  else if (['Bash', 'PowerShell', 'Monitor'].includes(tool)) text = pick('command') ?? ''
38  else if (tool === 'Grep' || tool === 'Glob') text = `${pick('pattern') ?? ''} ${pick('path') ?? ''}`.trim()
39  else if (tool === 'WebFetch') text = pick('url') ?? ''
40  else text = Object.keys(args).filter(k => !['tool', 'tool_use_id', 'agentId'].includes(k)).join(',')
41  const clean = redact(text.replace(/\s+/g, ' '))
42  return clean.length > ARG_CHARS ? `${clean.slice(0, ARG_CHARS)}…` : clean
43}
44
45export type Entry = {
46  ts: string
47  tool: string
48  id: string
49  agentId?: string
50  status: 'ok' | 'error' | 'denied'
51  ms: number
52  args: string
53  reason?: string
54}
55
56export function fileName(dir: string, sessionId: string, startedAt: number, part: number): string {
57  const day = new Date(startedAt).toISOString().slice(0, 10)
58  const base = `${dir.replace(/[\\/]+$/, '')}/${day}_${sessionId.replace(/[^A-Za-z0-9_-]/g, '')}`
59  return part === 0 ? `${base}.jsonl` : `${base}-part${part}.jsonl`
60}
61
62// $.fs limits are in bytes, and string.length counts UTF-16 units: accents and CJK take more bytes.
63const encoder = new TextEncoder()
64export function byteLength(text: string): number {
65  return encoder.encode(text).length
66}
67
68export function shouldRoll(currentBytes: number, addBytes: number): boolean {
69  return currentBytes > 0 && currentBytes + addBytes > MAX_FILE_BYTES
70}
71