Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "ctrlb-hq",
"owner": {
"name": "CtrlB",
"email": "engineering@ctrlb.ai"
},
"metadata": {
"description": "Official CtrlB plugins for Claude Code"
},
"plugins": [
{
"name": "ctrlb-decompose",
"source": {
"source": "git-subdir",
"url": "https://github.com/hvsk004/ctrlb-decompose.git",
"path": "plugin"
},
"description": "Analyze log files with ctrlb-decompose — pattern clustering, anomaly detection, and severity scoring",
"version": "1.0.0",
"author": { "name": "CtrlB", "email": "engineering@ctrlb.ai" },
"homepage": "https://github.com/hvsk004/ctrlb-decompose",
"repository": "https://github.com/hvsk004/ctrlb-decompose",
"license": "MIT",
"keywords": ["logs", "log-analysis", "observability", "patterns", "anomaly-detection"]
}
]
}
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,31 @@ Options:

---

## Claude Code Plugin

Use ctrlb-decompose directly from [Claude Code](https://claude.ai/code) — no CLI knowledge needed. The plugin installs ctrlb-decompose automatically and lets you analyze logs just by asking.

### Install

```
/plugin marketplace add ctrlb-hq/ctrlb-decompose
/plugin install ctrlb-decompose@ctrlb-hq
```

### Usage

Just describe what you want in plain language:

- "Analyze the errors in `/var/log/app.log`"
- "What are the most common patterns in this log file?"
- "Summarize these logs and highlight anomalies"

Claude will check if ctrlb-decompose is installed (and walk you through installation if not), run the analysis, and explain the results — surfacing errors first, calling out anomalies, and suggesting what to investigate next.

See [`plugin/README.md`](plugin/README.md) for full details.

---

## License

[MIT](LICENSE)
12 changes: 12 additions & 0 deletions plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "ctrlb-decompose",
"description": "Analyze log files with ctrlb-decompose — pattern clustering, anomaly detection, and severity scoring",
"version": "1.0.0",
"author": {
"name": "CtrlB",
"email": "engineering@ctrlb.ai"
},
"homepage": "https://github.com/ctrlb-hq/ctrlb-decompose",
"repository": "https://github.com/ctrlb-hq/ctrlb-decompose",
"license": "MIT"
}
49 changes: 49 additions & 0 deletions plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# ctrlb-decompose Claude Code Plugin

Adds log analysis to Claude Code via [ctrlb-decompose](https://github.com/ctrlb-hq/ctrlb-decompose) — compress millions of log lines into a handful of actionable patterns with typed variable statistics, anomaly detection, and severity scoring.

## Install

In Claude Code:

```
/plugin marketplace add ctrlb-hq/ctrlb-decompose
/plugin install ctrlb-decompose@ctrlb-hq
```

## Usage

Just ask Claude to analyze logs — the skill triggers automatically:

- "Analyze the errors in `/var/log/app.log`"
- "What are the most common patterns in this log file?"
- "Summarize these logs:" followed by pasted log lines

If `ctrlb-decompose` is not installed on your system, Claude will detect your OS
and walk you through installation (Homebrew, apt/rpm package, or binary download).
If those fail, it can build from source using the Rust toolchain.

## What the skill does

1. Checks if `ctrlb-decompose` is installed; installs it if not
2. Runs `ctrlb-decompose --llm` on the log file or pasted text
3. Interprets the output — surfaces errors first, explains anomalies, summarizes variable patterns

## Manual install of ctrlb-decompose

**macOS:**
```bash
brew tap ctrlb-hq/tap && brew install ctrlb-decompose
```

**Debian / Ubuntu:**
```bash
curl -LO https://github.com/ctrlb-hq/ctrlb-decompose/releases/latest/download/ctrlb-decompose_amd64.deb
sudo dpkg -i ctrlb-decompose_amd64.deb
```

**Build from source:**
```bash
git clone https://github.com/ctrlb-hq/ctrlb-decompose.git
cd ctrlb-decompose && cargo build --release
```
202 changes: 202 additions & 0 deletions plugin/bin/check-log-paste
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""
UserPromptSubmit hook for ctrlb-decompose plugin.

When the user pastes a large volume of log-like content:
1. Extracts the log block (first..last log-matching line, preserving continuations)
2. Runs ctrlb-decompose --llm on the log block
3. Blocks the original prompt (raw lines never enter Claude's context)
4. Injects only the compressed ctrlb-decompose output as additionalContext

Falls back to a warning-only inject (no block) if ctrlb-decompose is not
installed or fails, so the hook never breaks the conversation.

Input (stdin): JSON {"prompt": "...", ...}
Output (stdout): JSON with decision/hookSpecificOutput
Exit code: always 0
"""

import json
import os
import re
import shutil
import subprocess
import sys
import tempfile

# Trigger threshold: minimum log-matching lines to activate the hook
THRESHOLD = 50

# Set to False to disable blocking (inject-only fallback mode)
BLOCK_ENABLED = True

LOG_PATTERN = re.compile(
r"("
r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}" # ISO 8601 / datetime
r"|\w{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}" # syslog: Jan 5 12:00:00
r"|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}.*\[.*\]" # Apache: IP [date]
r"|\b(ERROR|WARN(?:ING)?|INFO|DEBUG|FATAL|CRITICAL|TRACE|NOTICE)\b"
r")",
re.IGNORECASE,
)


def block_with_context(reason_msg, context):
"""Block the prompt and inject context for Claude."""
print(json.dumps({
"decision": "block",
"reason": reason_msg,
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": context,
},
}))
sys.exit(0)


def not_installed_fallback(log_line_count):
"""ctrlb-decompose is not installed — block and ask Claude to install it first."""
block_with_context(
reason_msg=(
f"Your message contained {log_line_count} log lines but ctrlb-decompose "
f"is not installed yet. Let me install it first — then paste your logs again."
),
context=(
f"[ctrlb-decompose hook] The user pasted ~{log_line_count} log lines but "
f"ctrlb-decompose is not installed on this machine.\n\n"
f"Install ctrlb-decompose now using the analyze-logs skill installation flow "
f"(Step 1), then tell the user: "
f"'ctrlb-decompose is now installed — please paste your logs again and I'll "
f"analyze them automatically.'"
),
)


def process_failed_fallback(log_line_count):
"""ctrlb-decompose ran but produced no output — block and redirect to file."""
block_with_context(
reason_msg=(
f"Your message contained {log_line_count} log lines but ctrlb-decompose "
f"could not process the pasted content. Please save the logs to a file and "
f"share the path instead."
),
context=(
f"[ctrlb-decompose hook] The user pasted ~{log_line_count} log lines but "
f"ctrlb-decompose failed to produce output (possible format issue or timeout).\n\n"
f"Ask the user to save their logs to a file and share the path. "
f"Then run: ctrlb-decompose --llm --top 20 /path/to/their.log"
),
)


def main():
try:
data = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
sys.exit(0)

prompt = data.get("prompt", "")
if not prompt:
sys.exit(0)

lines = prompt.splitlines()
total_lines = len(lines)

# Find which lines match the log pattern
matches = [bool(LOG_PATTERN.search(line)) for line in lines]
log_line_count = sum(matches)

if log_line_count < THRESHOLD:
sys.exit(0)

# --- Separate log block from user question ---
# Log block: everything from the first to last matching line (inclusive).
# This preserves continuation lines (stack traces, JSON payloads) that sit
# between log-matching lines but don't match the pattern themselves.
first_log_idx = next(i for i, m in enumerate(matches) if m)
last_log_idx = len(matches) - 1 - next(i for i, m in enumerate(reversed(matches)) if m)

log_block_lines = lines[first_log_idx:last_log_idx + 1]

# User's question: non-empty lines before the first log line and after the last
question_lines = (
[l.strip() for l in lines[:first_log_idx] if l.strip()] +
[l.strip() for l in lines[last_log_idx + 1:] if l.strip()]
)
user_question = " ".join(question_lines) if question_lines else None

# --- Check ctrlb-decompose is available ---
if not shutil.which("ctrlb-decompose"):
not_installed_fallback(log_line_count)

# --- Run ctrlb-decompose on the extracted log block ---
tmp_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".log", prefix="ctrlb_hook_", delete=False
) as tmp:
tmp.write("\n".join(log_block_lines))
tmp_path = tmp.name

result = subprocess.run(
["ctrlb-decompose", "--llm", "--top", "20", tmp_path],
capture_output=True,
text=True,
timeout=30,
)
ctrlb_output = result.stdout.strip()
except Exception:
ctrlb_output = ""
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)

if not ctrlb_output:
process_failed_fallback(log_line_count)

# --- Build context to inject ---
question_note = (
f"User's question: {user_question}\n\n" if user_question
else ""
)
additional_context = (
f"{question_note}"
f"ctrlb-decompose compressed {log_line_count} log lines "
f"({len(log_block_lines)} in log block) into patterns:\n\n"
f"{ctrlb_output}\n\n"
f"Answer the user's question based solely on this output. "
f"Do not reference or quote any raw log lines."
)

if BLOCK_ENABLED:
question_ack = f'Your question ("{user_question}") was noted.' if user_question else ""
reason = (
f"Your message contained {log_line_count} log lines. "
f"They were compressed to patterns by ctrlb-decompose. "
f"{question_ack} "
f"Continue the conversation to get the analysis."
).strip()

print(json.dumps({
"decision": "block",
"reason": reason,
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": additional_context,
},
}))
else:
# Inject-only fallback: raw lines still in context but Claude is
# told to use only the compressed output
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": additional_context,
},
}))

sys.exit(0)


if __name__ == "__main__":
main()
14 changes: 14 additions & 0 deletions plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/bin/check-log-paste"
}
]
}
]
}
}
Loading
Loading