Skip to content
Open
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
53 changes: 53 additions & 0 deletions scripts/hooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Skills Tracking Hook

Logs skill invocations for the ccstatusline Skills widget.

## What It Tracks

- **User slash commands** (`/commit`, `/review-pr`) via `UserPromptSubmit` hook
- **Claude's Skill tool** invocations via `PreToolUse` hook

## Installation

```bash
# Copy and make executable
mkdir -p ~/.claude/hooks
cp scripts/hooks/track-skill.sh ~/.claude/hooks/
chmod +x ~/.claude/hooks/track-skill.sh
```

Add to `~/.claude/settings.json`:

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/track-skill.sh" }]
}
],
"UserPromptSubmit": [
{
"hooks": [{ "type": "command", "command": "~/.claude/hooks/track-skill.sh" }]
}
]
}
}
```

Restart Claude Code.

## Output

Writes JSONL to `~/.claude/ccstatusline/skills-{session}.jsonl`:

```json
{"timestamp":"2025-01-15T10:30:00Z","session_id":"abc123","skill":"commit","source":"UserPromptSubmit"}
{"timestamp":"2025-01-15T10:35:00Z","session_id":"abc123","skill":"review-pr","source":"PreToolUse"}
```

## Requirements

- `jq` (`brew install jq` on macOS)
- Bash 4.0+
30 changes: 30 additions & 0 deletions scripts/hooks/track-skill.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/bash
# Tracks skill invocations for ccstatusline Skills widget
# Handles: PreToolUse (Claude's Skill tool) and UserPromptSubmit (user slash commands)
set -euo pipefail

INPUT=$(cat)
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty')
[ -z "$SESSION_ID" ] && { echo '{}'; exit 0; }

HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // empty')
SKILL_NAME=""

if [ "$HOOK_EVENT" = "PreToolUse" ]; then
[ "$(echo "$INPUT" | jq -r '.tool_name')" = "Skill" ] && \
SKILL_NAME=$(echo "$INPUT" | jq -r '.tool_input.skill // empty')
elif [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
PROMPT=$(echo "$INPUT" | jq -r '.prompt // empty')
[[ "$PROMPT" =~ ^/([a-zA-Z0-9_:-]+) ]] && SKILL_NAME="${BASH_REMATCH[1]}"
fi

[ -z "$SKILL_NAME" ] && { echo '{}'; exit 0; }

OUTPUT_DIR="$HOME/.claude/ccstatusline"
mkdir -p "$OUTPUT_DIR"

jq -c -n --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg sid "$SESSION_ID" \
--arg skill "$SKILL_NAME" --arg src "$HOOK_EVENT" \
'{timestamp:$ts,session_id:$sid,skill:$skill,source:$src}' >> "$OUTPUT_DIR/skills-$SESSION_ID.jsonl"

echo '{}'
11 changes: 11 additions & 0 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import chalk from 'chalk';
import { runTUI } from './tui';
import type {
BlockMetrics,
SkillsMetrics,
TokenMetrics
} from './types';
import type { RenderContext } from './types/RenderContext';
Expand All @@ -24,6 +25,7 @@ import {
preRenderAllWidgets,
renderStatusLine
} from './utils/renderer';
import { getSkillsMetrics } from './utils/skills';

async function readStdin(): Promise<string | null> {
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
Expand Down Expand Up @@ -75,6 +77,9 @@ async function renderMultipleLines(data: StatusJSON) {
// Check if block timer is needed
const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer'));

// Check if skills widget is needed
const hasSkills = lines.some(line => line.some(item => item.type === 'skills'));

let tokenMetrics: TokenMetrics | null = null;
if (hasTokenItems && data.transcript_path) {
tokenMetrics = await getTokenMetrics(data.transcript_path);
Expand All @@ -90,12 +95,18 @@ async function renderMultipleLines(data: StatusJSON) {
blockMetrics = getBlockMetrics();
}

let skillsMetrics: SkillsMetrics | null = null;
if (hasSkills && data.session_id) {
skillsMetrics = getSkillsMetrics(data.session_id);
}

// Create render context
const context: RenderContext = {
data,
tokenMetrics,
sessionDuration,
blockMetrics,
skillsMetrics,
isPreview: false
};

Expand Down
3 changes: 2 additions & 1 deletion src/types/RenderContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { BlockMetrics } from '../types';
import type { BlockMetrics, SkillsMetrics } from '../types';

import type { StatusJSON } from './StatusJSON';
import type { TokenMetrics } from './TokenMetrics';
Expand All @@ -8,6 +8,7 @@ export interface RenderContext {
tokenMetrics?: TokenMetrics | null;
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
terminalWidth?: number | null;
isPreview?: boolean;
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
Expand Down
13 changes: 13 additions & 0 deletions src/types/SkillsMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface SkillInvocation {
timestamp: string;
session_id: string;
skill: string;
source: string;
}

export interface SkillsMetrics {
totalInvocations: number;
uniqueSkills: string[];
lastSkill: string | null;
invocations: SkillInvocation[];
}
3 changes: 2 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@ export type { RenderContext } from './RenderContext';
export type { PowerlineFontStatus } from './PowerlineFontStatus';
export type { ClaudeSettings } from './ClaudeSettings';
export type { ColorEntry } from './ColorEntry';
export type { BlockMetrics } from './BlockMetrics';
export type { BlockMetrics } from './BlockMetrics';
export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics';
37 changes: 37 additions & 0 deletions src/utils/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as fs from 'fs';
import * as path from 'path';
import type { SkillInvocation, SkillsMetrics } from '../types/SkillsMetrics';
import { getClaudeConfigDir } from './claude-settings';

const emptyMetrics: SkillsMetrics = { totalInvocations: 0, uniqueSkills: [], lastSkill: null, invocations: [] };

export function getSkillsFilePath(sessionId: string): string {
return path.join(getClaudeConfigDir(), 'ccstatusline', `skills-${sessionId}.jsonl`);
}

export function computeSkillsMetrics(invocations: SkillInvocation[]): SkillsMetrics {
if (invocations.length === 0) return emptyMetrics;
const sorted = [...invocations].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
return {
totalInvocations: sorted.length,
uniqueSkills: [...new Set(sorted.map(i => i.skill))],
lastSkill: sorted.at(-1)?.skill ?? null,
invocations: sorted
};
}

export function getSkillsMetrics(sessionId: string): SkillsMetrics {
const filePath = getSkillsFilePath(sessionId);
if (!fs.existsSync(filePath)) return emptyMetrics;

try {
const invocations = fs.readFileSync(filePath, 'utf-8')
.trim().split('\n')
.filter(line => line.trim())
.map(line => { try { return JSON.parse(line); } catch { return null; } })
.filter((e): e is SkillInvocation => e?.skill && e?.session_id);
return computeSkillsMetrics(invocations);
} catch {
return emptyMetrics;
}
}
3 changes: 2 additions & 1 deletion src/utils/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
['version', new widgets.VersionWidget()],
['custom-text', new widgets.CustomTextWidget()],
['custom-command', new widgets.CustomCommandWidget()],
['claude-session-id', new widgets.ClaudeSessionIdWidget()]
['claude-session-id', new widgets.ClaudeSessionIdWidget()],
['skills', new widgets.SkillsWidget()]
]);

export function getWidget(type: WidgetItemType): Widget | null {
Expand Down
54 changes: 54 additions & 0 deletions src/widgets/Skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type { CustomKeybind, Widget, WidgetEditorDisplay, WidgetItem } from '../types/Widget';

type Mode = 'current' | 'count' | 'list';
const MODES: Mode[] = ['current', 'count', 'list'];
const MODE_LABELS: Record<Mode, string> = { current: 'last used', count: 'total count', list: 'unique list' };

export class SkillsWidget implements Widget {
getDefaultColor(): string { return 'magenta'; }
getDescription(): string { return 'Shows Claude Code skill invocations from hook data'; }
getDisplayName(): string { return 'Skills'; }
supportsRawValue(): boolean { return true; }
supportsColors(): boolean { return true; }

getCustomKeybinds(): CustomKeybind[] {
return [{ key: 'm', label: '(m)ode: current/count/list', action: 'cycle-mode' }];
}

getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return { displayText: 'Skills', modifierText: `(${MODE_LABELS[this.getMode(item)]})` };
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
if (action !== 'cycle-mode') return null;
const nextMode = MODES[(MODES.indexOf(this.getMode(item)) + 1) % MODES.length] ?? 'current';
return { ...item, metadata: { ...item.metadata, mode: nextMode } };
}

render(item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
const mode = this.getMode(item);
const raw = item.rawValue;

if (context.isPreview) {
if (mode === 'current') return raw ? 'commit' : 'Skill: commit';
if (mode === 'count') return raw ? '5' : 'Skills: 5';
return raw ? 'commit, review-pr' : 'Skills: commit, review-pr';
}

const m = context.skillsMetrics;
if (!m || m.totalInvocations === 0) return null;

if (mode === 'current') return m.lastSkill ? (raw ? m.lastSkill : `Skill: ${m.lastSkill}`) : null;
if (mode === 'count') return raw ? String(m.totalInvocations) : `Skills: ${m.totalInvocations}`;
if (m.uniqueSkills.length === 0) return null;
const list = m.uniqueSkills.join(', ');
return raw ? list : `Skills: ${list}`;
}

private getMode(item: WidgetItem): Mode {
const mode = item.metadata?.mode;
return mode && MODES.includes(mode as Mode) ? mode as Mode : 'current';
}
}
3 changes: 2 additions & 1 deletion src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText';
export { CustomCommandWidget } from './CustomCommand';
export { BlockTimerWidget } from './BlockTimer';
export { CurrentWorkingDirWidget } from './CurrentWorkingDir';
export { ClaudeSessionIdWidget } from './ClaudeSessionId';
export { ClaudeSessionIdWidget } from './ClaudeSessionId';
export { SkillsWidget } from './Skills';