diff --git a/scripts/hooks/README.md b/scripts/hooks/README.md new file mode 100644 index 0000000..9b5a3f1 --- /dev/null +++ b/scripts/hooks/README.md @@ -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+ diff --git a/scripts/hooks/track-skill.sh b/scripts/hooks/track-skill.sh new file mode 100755 index 0000000..8bf9f5f --- /dev/null +++ b/scripts/hooks/track-skill.sh @@ -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 '{}' diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 0bc999d..281365d 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -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'; @@ -24,6 +25,7 @@ import { preRenderAllWidgets, renderStatusLine } from './utils/renderer'; +import { getSkillsMetrics } from './utils/skills'; async function readStdin(): Promise { // Check if stdin is a TTY (terminal) - if it is, there's no piped data @@ -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); @@ -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 }; diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9e088c9..c60511c 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -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'; @@ -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) diff --git a/src/types/SkillsMetrics.ts b/src/types/SkillsMetrics.ts new file mode 100644 index 0000000..fb2b530 --- /dev/null +++ b/src/types/SkillsMetrics.ts @@ -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[]; +} diff --git a/src/types/index.ts b/src/types/index.ts index 916ded0..bb18e75 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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'; \ No newline at end of file +export type { BlockMetrics } from './BlockMetrics'; +export type { SkillInvocation, SkillsMetrics } from './SkillsMetrics'; \ No newline at end of file diff --git a/src/utils/skills.ts b/src/utils/skills.ts new file mode 100644 index 0000000..13a618e --- /dev/null +++ b/src/utils/skills.ts @@ -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; + } +} diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 15a2510..87c4bd9 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -27,7 +27,8 @@ const widgetRegistry = new Map([ ['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 { diff --git a/src/widgets/Skills.ts b/src/widgets/Skills.ts new file mode 100644 index 0000000..e5aa8e4 --- /dev/null +++ b/src/widgets/Skills.ts @@ -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 = { 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'; + } +} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index faaa705..5cd0217 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; -export { ClaudeSessionIdWidget } from './ClaudeSessionId'; \ No newline at end of file +export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { SkillsWidget } from './Skills'; \ No newline at end of file