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

Keeps a written record, in a folder you pick, of every tool Claude used, with secrets hidden.
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.
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.
.jsonl (one line of data per tool call).None of its own.
None. It works on its own.
Claude Code asks for these when you enable the mod. To change them later, open /config and find the mod by name.
| Setting | What it means | Default |
|---|---|---|
logDir (Log folder) | Folder for the log files. Pick one outside your projects. | (empty) |
enabled (Enabled) | Turn the mod off without uninstalling it. | true |
-part1, -part2…). An existing file is never overwritten.You need Claude Code 2.1.287 or newer in the terminal, or 2.1.286 or newer in the desktop app.
_claude-plugin to .claude-plugin (with a dot at the start). It ships without the dot so the examples never load by accident.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").
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.
hooks/register.ts 117 lines1import 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}
117hooks/entry.ts 71 lines1// 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