An open API service indexing awesome lists of open source software.

https://github.com/tywalch/llm-cmd-logger


https://github.com/tywalch/llm-cmd-logger

Last synced: 19 days ago
JSON representation

Awesome Lists containing this project

README

          

# cmd-log

Unified command logger for AI coding agents. Captures every shell command executed by **Claude Code**, **ChatGPT/Codex**, and **Cursor** into monthly log files with a consistent format.

Each tool has its own hook system with different payload shapes and configuration files. This project provides a single TypeScript script that auto-detects the source from the payload structure, extracts the relevant fields, and appends a formatted log line.

## Quick start

```bash
# Install dependencies and build
npm install
npm run build

# Register hooks in all three tools (idempotent — safe to re-run)
npm run install-hooks

# Restart Claude Code, Codex, and Cursor for hooks to take effect
```

Logs will appear in `./logs/YYYY-MM.log` as commands are executed.

## Project structure

```
cmd-log/
src/
index.ts # Main logging script — reads stdin, classifies, logs
install.ts # Idempotent hook installer for all three tools
dist/ # Compiled JavaScript output (generated by `npm run build`)
logs/ # Monthly log files (e.g. 2026-04.log)
archived/ # Legacy v1 scripts and docs (Python, shell)
package.json
tsconfig.json
```

## How it works

Each AI tool fires a hook when a shell command is executed. The hook spawns this script and pipes a JSON payload on stdin. The script:

1. Reads JSON from stdin
2. Classifies the payload into a typed event using a discriminated union
3. Extracts timestamp, source, model, cwd, and command
4. Appends a single formatted line to `./logs/YYYY-MM.log`

### Discriminated union

The core type system uses a discriminated union on the `kind` field:

```typescript
type HookEvent = CursorCommand | CodexCommand | ClaudeCommand | ClaudeSession;

interface CursorCommand { kind: "cursor-command"; command: string; cwd: string; model: string; }
interface CodexCommand { kind: "codex-command"; command: string; cwd: string; model: string; }
interface ClaudeCommand { kind: "claude-command"; command: string; cwd: string; model: string; }
interface ClaudeSession { kind: "claude-session"; model: string; }
```

`ClaudeSession` is a non-loggable event (no command). The `CommandEvent` subtype excludes it:

```typescript
type CommandEvent = CursorCommand | CodexCommand | ClaudeCommand;
```

### Source detection

The `classify(raw: unknown)` function inspects the raw JSON to determine which tool sent it. Each tool has a unique payload fingerprint:

| Source | Fingerprint | Hook event |
|--------|------------|------------|
| Cursor | top-level `command` + `workspace_roots` array | `afterShellExecution` |
| Codex/ChatGPT | `tool_input.command` + top-level `model` | `PostToolUse` (Bash) |
| Claude (session) | `event: "SessionStart"` | `SessionStart` |
| Claude (command) | `tool_input.command` without `model` | `PostToolUse` (Bash) |

Detection order matters. The function checks Cursor first (most distinctive shape), then Codex (has both `tool_input.command` and `model`), then Claude variants.

## Hook payload reference

### Cursor (`afterShellExecution`)

Fires after the agent runs a shell command. Passive — no stdout response required.

```json
{
"command": "echo hello",
"cwd": "/Users/you/project",
"workspace_roots": ["/Users/you/project"],
"model": "gpt-5.3",
"model_name": "composer-2-fast",
"modelId": "composer-fast"
}
```

- `command` is always present
- `cwd` may be empty; falls back to `workspace_roots[0]`
- Model is extracted with priority: `modelId` > `model_name` > `model` > `"unknown"`
- `workspace_roots` is the key differentiator from other payloads

### ChatGPT / Codex (`PostToolUse`, matcher: `Bash`)

Fires after a Bash tool call. Passive — no stdout response required.

```json
{
"tool_input": {
"command": "go test ./..."
},
"model": "gpt-4",
"cwd": "/Users/you/project"
}
```

- `tool_input.command` is the shell command
- `model` is always present (this distinguishes Codex from Claude)
- `cwd` is the working directory

### Claude Code (`SessionStart`)

Fires when a new Claude Code session begins. **Not logged** — used only for detection.

```json
{
"event": "SessionStart",
"model": "claude-opus-4-6"
}
```

The `model` field is only available in this event, not in `PostToolUse`. See [Design decisions](#design-decisions) for why this is not cached.

### Claude Code (`PostToolUse`, matcher: `Bash`)

Fires after a Bash tool call. Passive — no stdout response required.

```json
{
"tool_input": {
"command": "git status"
},
"cwd": "/Users/you/project"
}
```

- Same shape as Codex but **without a `model` field** — this is how `classify` distinguishes them
- Model is logged as `"unknown"` for Claude commands

## Log format

Each entry is a single line with pipe-delimited fields:

```
YYYY-MM-DD HH:MM:SS | source:model | cwd | command
```

### Examples

```
2026-04-07 14:22:44 | cursor:gpt-5.3 | /tmp | echo cursor-test
2026-04-07 14:22:44 | chatgpt:gpt-4 | /Users/you/proj | go test ./...
2026-04-07 14:22:44 | claude:unknown | /Users/you/media | git status
2026-04-07 14:22:44 | chatgpt:gpt-4 | /tmp | echo foo\necho bar
```

- Newlines in commands are escaped as `\n` and `\r` to keep one line per entry
- Log files are partitioned by month: `logs/2026-04.log`, `logs/2026-05.log`, etc.

### Error entries

Script errors are logged to the same file with the format:

```
YYYY-MM-DD HH:MM:SS | ERROR | context | message | stdin:
```

Example:

```
2026-04-07 14:24:34 | ERROR | parse | Unexpected token 'o' ... | stdin: not valid json{{{
```

The `context` field indicates where the error occurred: `parse` (JSON parsing), `write` (log file I/O), or `main` (top-level).

## Hook configuration reference

### Claude Code — `~/.claude/settings.json`

```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "/usr/local/bin/node /dist/index.js /logs"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/local/bin/node /dist/index.js /logs"
}
]
}
]
}
}
```

Two hooks are registered: `SessionStart` (detected but not logged) and `PostToolUse` with the `Bash` matcher (logged).

### ChatGPT / Codex — `~/.codex/hooks.json`

```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/local/bin/node /dist/index.js /logs",
"statusMessage": "Logging terminal command"
}
]
}
]
}
}
```

Codex also requires `~/.codex/config.toml` to have hooks enabled and the log directory in the sandbox write list:

```toml
[features]
codex_hooks = true

[sandbox_workspace_write]
writable_roots = [
"/logs"
]
```

### Cursor — `~/.cursor/hooks.json`

```json
{
"version": 1,
"hooks": {
"afterShellExecution": [
{
"command": "/usr/local/bin/node /dist/index.js /logs"
}
]
}
}
```

## Install script

`src/install.ts` idempotently registers hooks in all three tools. Run with:

```bash
npm run install-hooks
```

For each provider it:
- Reads the existing config file (or starts from `{}` if missing)
- Finds any existing `cmd-log` hook entry by checking if the command string contains `"cmd-log"`
- Updates the entry in place, or appends a new one if not found
- Writes the file back

For Codex it also ensures `config.toml` has `codex_hooks = true` and the log directory in `writable_roots`.

The script derives all paths relative to its own location (`import.meta.dirname`), so it works regardless of where the project is cloned.

## CLI usage

```bash
# The logging script reads JSON from stdin and requires a log directory argument:
echo '{"tool_input":{"command":"echo hi"},"model":"gpt-4","cwd":"/tmp"}' \
| node dist/index.js ./logs

# Without the argument, it prints usage and exits with code 1:
echo '{}' | node dist/index.js
# stderr: Usage: cmd-log
```

## npm scripts

| Script | Description |
|--------|-------------|
| `npm run build` | Compile TypeScript to `dist/` |
| `npm run clean` | Remove `dist/` |
| `npm run rebuild` | Clean + build |
| `npm run install-hooks` | Register hooks in Claude, Codex, and Cursor configs |

## Design decisions

### Claude model is logged as "unknown"

Claude Code's `PostToolUse` hook payload does not include the model name. The `SessionStart` event does, but caching it to a file (the v1 approach) breaks when multiple Claude sessions run in parallel — they overwrite each other's cached model. Rather than introduce fragile state, commands from Claude are logged with `model: "unknown"`.

If Anthropic adds a session identifier or model field to the `PostToolUse` payload in the future, this can be revisited.

### Cursor uses `afterShellExecution`, not `beforeShellExecution`

Cursor's `beforeShellExecution` hook is an **active gate** — the script must write `{"permission": "allow"}` to stdout or the command is blocked. This is inappropriate for passive logging:

- Any script error would block all Cursor shell commands
- The hook is designed for approval workflows, not observation

`afterShellExecution` is a passive, fire-and-forget hook that fires after the command runs. No stdout response is needed. Note: community reports suggest this hook can be inconsistent in the Cursor CLI (works reliably in the IDE).

### Source detection is payload-based, not config-based

Rather than requiring each hook config to pass a `--source=cursor` flag, the script infers the source from the JSON payload structure. This keeps the hook commands identical across providers (same script, same args) and avoids configuration drift.

The detection relies on each tool having a unique payload fingerprint — see [Source detection](#source-detection).

## Adding a new provider

1. **Define the event type** in `src/index.ts`:

```typescript
export interface NewToolCommand {
kind: "newtool-command";
command: string;
cwd: string;
model: string;
}
```

2. **Add it to the union types**:

```typescript
export type HookEvent = CursorCommand | CodexCommand | ClaudeCommand | ClaudeSession | NewToolCommand;
export type CommandEvent = CursorCommand | CodexCommand | ClaudeCommand | NewToolCommand;
```

3. **Add a source label**:

```typescript
const SOURCE_LABEL: Record = {
// ...existing entries
"newtool-command": "newtool",
};
```

4. **Add classification logic** in `classify()` — identify the unique payload fingerprint and extract fields.

5. **Add an install function** in `src/install.ts` for the new tool's config file format.

6. **Build and install**:

```bash
npm run rebuild
npm run install-hooks
```