claude-code-pulsify 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # claude-code-pulsify
2
+
3
+ Context-aware statusline and context monitor for [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
4
+
5
+ Shows a live progress bar of context window usage, active task info, and warns you when context is running low.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx claude-code-pulsify@latest
11
+ ```
12
+
13
+ This copies hooks to `~/.claude/hooks/claude-code-pulsify/` and patches `~/.claude/settings.json`. Restart Claude Code to activate.
14
+
15
+ ## Update
16
+
17
+ Run the same command:
18
+
19
+ ```bash
20
+ npx claude-code-pulsify@latest
21
+ ```
22
+
23
+ The statusline also shows an arrow indicator when an update is available.
24
+
25
+ ## Uninstall
26
+
27
+ ```bash
28
+ npx claude-code-pulsify@latest --uninstall
29
+ ```
30
+
31
+ ## What it does
32
+
33
+ - **Statusline** -- Shows model name, working directory, context usage bar (color-coded), and active task
34
+ - **Context monitor** -- Warns at 65% and 75% context usage via PostToolUse hook
35
+ - **Update checker** -- Background version check on session start, non-blocking
36
+
37
+ ## Configuration
38
+
39
+ Respects the `CLAUDE_CONFIG_DIR` environment variable. Defaults to `~/.claude`.
package/bin/install.js ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+
7
+ const PACKAGE_VERSION = require('../package.json').version
8
+ const HOOKS_SOURCE = path.join(__dirname, '..', 'hooks')
9
+ const HOOK_FILES = ['statusline.js', 'context-monitor.js', 'check-update.js']
10
+
11
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude')
12
+ const hooksTarget = path.join(configDir, 'hooks', 'claude-code-pulsify')
13
+ const settingsPath = path.join(configDir, 'settings.json')
14
+
15
+ // --- Helpers ---
16
+
17
+ function readJSON(filePath) {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
20
+ } catch {
21
+ return {}
22
+ }
23
+ }
24
+
25
+ function writeJSON(filePath, data) {
26
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
27
+ }
28
+
29
+ function isOurEntry(entry) {
30
+ const cmd = entry?.hooks?.[0]?.command || ''
31
+ return cmd.includes('claude-code-pulsify')
32
+ }
33
+
34
+ function upsertHookArray(arr, newEntry) {
35
+ const idx = arr.findIndex(isOurEntry)
36
+ if (idx >= 0) {
37
+ arr[idx] = newEntry
38
+ } else {
39
+ arr.push(newEntry)
40
+ }
41
+ return arr
42
+ }
43
+
44
+ function removeFromHookArray(arr) {
45
+ return arr.filter((entry) => !isOurEntry(entry))
46
+ }
47
+
48
+ // --- Install ---
49
+
50
+ function install() {
51
+ console.log(`\nInstalling claude-code-pulsify v${PACKAGE_VERSION}...\n`)
52
+
53
+ // 1. Copy hooks
54
+ fs.mkdirSync(hooksTarget, { recursive: true })
55
+ for (const file of HOOK_FILES) {
56
+ const src = path.join(HOOKS_SOURCE, file)
57
+ const dst = path.join(hooksTarget, file)
58
+ fs.copyFileSync(src, dst)
59
+ console.log(` Copied ${file}`)
60
+ }
61
+
62
+ // 2. Write VERSION
63
+ fs.writeFileSync(path.join(hooksTarget, 'VERSION'), PACKAGE_VERSION, 'utf8')
64
+ console.log(` Wrote VERSION (${PACKAGE_VERSION})`)
65
+
66
+ // 3. Patch settings.json
67
+ const settings = readJSON(settingsPath)
68
+
69
+ // statusLine
70
+ settings.statusLine = {
71
+ type: 'command',
72
+ command: `node ${path.join(hooksTarget, 'statusline.js')}`,
73
+ }
74
+
75
+ // hooks
76
+ if (!settings.hooks) settings.hooks = {}
77
+
78
+ // PostToolUse — context-monitor
79
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = []
80
+ upsertHookArray(settings.hooks.PostToolUse, {
81
+ hooks: [
82
+ {
83
+ type: 'command',
84
+ command: `node ${path.join(hooksTarget, 'context-monitor.js')}`,
85
+ },
86
+ ],
87
+ })
88
+
89
+ // SessionStart — check-update
90
+ if (!settings.hooks.SessionStart) settings.hooks.SessionStart = []
91
+ upsertHookArray(settings.hooks.SessionStart, {
92
+ hooks: [
93
+ {
94
+ type: 'command',
95
+ command: `node ${path.join(hooksTarget, 'check-update.js')}`,
96
+ },
97
+ ],
98
+ })
99
+
100
+ writeJSON(settingsPath, settings)
101
+ console.log(` Patched settings.json`)
102
+
103
+ console.log(`\nDone! Restart Claude Code to activate the statusline.\n`)
104
+ }
105
+
106
+ // --- Uninstall ---
107
+
108
+ function uninstall() {
109
+ console.log(`\nUninstalling claude-code-pulsify...\n`)
110
+
111
+ // 1. Remove hooks directory
112
+ if (fs.existsSync(hooksTarget)) {
113
+ fs.rmSync(hooksTarget, { recursive: true })
114
+ console.log(` Removed ${hooksTarget}`)
115
+ }
116
+
117
+ // 2. Clean settings.json
118
+ if (fs.existsSync(settingsPath)) {
119
+ const settings = readJSON(settingsPath)
120
+
121
+ // Remove statusLine if it's ours
122
+ if (settings.statusLine?.command?.includes('claude-code-pulsify')) {
123
+ delete settings.statusLine
124
+ console.log(` Removed statusLine config`)
125
+ }
126
+
127
+ // Remove hook entries
128
+ if (settings.hooks) {
129
+ for (const event of ['PostToolUse', 'SessionStart']) {
130
+ if (Array.isArray(settings.hooks[event])) {
131
+ settings.hooks[event] = removeFromHookArray(settings.hooks[event])
132
+ if (settings.hooks[event].length === 0) {
133
+ delete settings.hooks[event]
134
+ }
135
+ }
136
+ }
137
+ if (Object.keys(settings.hooks).length === 0) {
138
+ delete settings.hooks
139
+ }
140
+ console.log(` Cleaned hook entries`)
141
+ }
142
+
143
+ writeJSON(settingsPath, settings)
144
+ }
145
+
146
+ // 3. Remove cache file
147
+ const cachePath = path.join(configDir, 'cache', 'claude-code-pulsify-update.json')
148
+ try {
149
+ fs.unlinkSync(cachePath)
150
+ } catch {
151
+ // Doesn't exist — fine
152
+ }
153
+
154
+ console.log(`\nDone! claude-code-pulsify has been removed.\n`)
155
+ }
156
+
157
+ // --- Main ---
158
+
159
+ const args = process.argv.slice(2)
160
+
161
+ if (args.includes('--version')) {
162
+ console.log(PACKAGE_VERSION)
163
+ } else if (args.includes('--uninstall')) {
164
+ uninstall()
165
+ } else {
166
+ install()
167
+ }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * SessionStart hook -- background version check for claude-code-pulsify.
6
+ * Spawns a detached process to compare installed version vs npm latest,
7
+ * writes result to a cache file that statusline.js reads.
8
+ */
9
+
10
+ const { spawn } = require('child_process')
11
+ const fs = require('fs')
12
+ const path = require('path')
13
+
14
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude')
15
+ const hooksDir = path.join(configDir, 'hooks', 'claude-code-pulsify')
16
+ const cacheDir = path.join(configDir, 'cache')
17
+ const cachePath = path.join(cacheDir, 'claude-code-pulsify-update.json')
18
+
19
+ async function main() {
20
+ // Consume stdin (required by hook protocol)
21
+ let input = ''
22
+ for await (const chunk of process.stdin) {
23
+ input += chunk
24
+ }
25
+
26
+ // Read installed version
27
+ const versionFile = path.join(hooksDir, 'VERSION')
28
+ let installed
29
+ try {
30
+ installed = fs.readFileSync(versionFile, 'utf8').trim()
31
+ } catch {
32
+ return // No VERSION file — not installed properly
33
+ }
34
+
35
+ // Spawn detached background check so we don't block session startup
36
+ const script = `
37
+ const { execSync } = require('child_process');
38
+ const fs = require('fs');
39
+ const cachePath = ${JSON.stringify(cachePath)};
40
+ const cacheDir = ${JSON.stringify(cacheDir)};
41
+ const installed = ${JSON.stringify(installed)};
42
+ try {
43
+ const latest = execSync('npm view claude-code-pulsify version', {
44
+ timeout: 10000,
45
+ encoding: 'utf8',
46
+ stdio: ['pipe', 'pipe', 'pipe']
47
+ }).trim();
48
+ fs.mkdirSync(cacheDir, { recursive: true });
49
+ fs.writeFileSync(cachePath, JSON.stringify({
50
+ installed,
51
+ latest,
52
+ updateAvailable: latest !== installed,
53
+ checkedAt: Date.now()
54
+ }));
55
+ } catch {
56
+ // Network error or npm not available — silently ignore
57
+ }
58
+ `
59
+
60
+ const child = spawn(process.execPath, ['-e', script], {
61
+ detached: true,
62
+ stdio: 'ignore',
63
+ })
64
+ child.unref()
65
+ }
66
+
67
+ main().catch(() => process.exit(0))
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * Claude Code PostToolUse hook -- context monitor.
6
+ * Reads the bridge file written by statusline.js and injects
7
+ * warnings when context is running low.
8
+ */
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ // Thresholds on "used percentage" (normalized, 0-100)
14
+ const WARNING_THRESHOLD = 65 // remaining ~35% usable
15
+ const CRITICAL_THRESHOLD = 75 // remaining ~25% usable
16
+
17
+ // Debounce: minimum tool calls between repeated warnings at the same severity
18
+ const DEBOUNCE_COUNT = 5
19
+
20
+ // State file to track debounce across invocations
21
+ function getStateFile(sessionId) {
22
+ return `/tmp/claude-ctx-monitor-state-${sessionId}.json`
23
+ }
24
+
25
+ function readState(sessionId) {
26
+ try {
27
+ return JSON.parse(fs.readFileSync(getStateFile(sessionId), 'utf8'))
28
+ } catch {
29
+ return { lastSeverity: null, callsSinceWarning: 0 }
30
+ }
31
+ }
32
+
33
+ function writeState(sessionId, state) {
34
+ try {
35
+ fs.writeFileSync(getStateFile(sessionId), JSON.stringify(state), 'utf8')
36
+ } catch {
37
+ // Silently ignore
38
+ }
39
+ }
40
+
41
+ async function main() {
42
+ let input = ''
43
+ for await (const chunk of process.stdin) {
44
+ input += chunk
45
+ }
46
+
47
+ let hookData
48
+ try {
49
+ hookData = JSON.parse(input)
50
+ } catch {
51
+ return
52
+ }
53
+
54
+ const sessionId = hookData.session?.id || hookData.session_id || null
55
+ if (!sessionId) return
56
+
57
+ // Read bridge file from statusline
58
+ const bridgePath = `/tmp/claude-ctx-${sessionId}.json`
59
+ let metrics
60
+ try {
61
+ metrics = JSON.parse(fs.readFileSync(bridgePath, 'utf8'))
62
+ } catch {
63
+ return // No bridge file yet -- statusline hasn't run
64
+ }
65
+
66
+ // Check staleness (>60s old)
67
+ if (Date.now() - (metrics.timestamp || 0) > 60_000) return
68
+
69
+ const usedPct = metrics.used_percentage
70
+ if (usedPct == null) return
71
+
72
+ // Determine severity
73
+ let severity = null
74
+ if (usedPct >= CRITICAL_THRESHOLD) severity = 'CRITICAL'
75
+ else if (usedPct >= WARNING_THRESHOLD) severity = 'WARNING'
76
+
77
+ if (!severity) return // Context is fine
78
+
79
+ // Debounce logic
80
+ const state = readState(sessionId)
81
+ state.callsSinceWarning = (state.callsSinceWarning || 0) + 1
82
+
83
+ const isEscalation = severity === 'CRITICAL' && state.lastSeverity === 'WARNING'
84
+ const shouldWarn = isEscalation || state.callsSinceWarning >= DEBOUNCE_COUNT
85
+
86
+ if (!shouldWarn) {
87
+ writeState(sessionId, state)
88
+ return
89
+ }
90
+
91
+ // Reset debounce counter
92
+ state.callsSinceWarning = 0
93
+ state.lastSeverity = severity
94
+ writeState(sessionId, state)
95
+
96
+ // Emit warning
97
+ const pct = Math.round(usedPct)
98
+ const remaining = 100 - pct
99
+
100
+ if (severity === 'CRITICAL') {
101
+ process.stderr.write(
102
+ `\n⚠️ CONTEXT CRITICAL (${pct}% used, ~${remaining}% remaining)\n` +
103
+ `Context window is nearly exhausted. Inform the user and ask how to proceed.\n` +
104
+ `Consider: summarize progress, commit work, or start a new session.\n`,
105
+ )
106
+ } else {
107
+ process.stderr.write(
108
+ `\n⚡ CONTEXT WARNING (${pct}% used, ~${remaining}% remaining)\n` +
109
+ `Context is getting limited. Avoid starting new complex work.\n` +
110
+ `Focus on completing current tasks and wrapping up.\n`,
111
+ )
112
+ }
113
+ }
114
+
115
+ main().catch(() => process.exit(0))
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ 'use strict'
3
+
4
+ /**
5
+ * Claude Code statusline hook.
6
+ * Reads session JSON from stdin, outputs a formatted statusline,
7
+ * and writes a bridge file for the context monitor.
8
+ */
9
+
10
+ const fs = require('fs')
11
+ const path = require('path')
12
+
13
+ // ANSI helpers
14
+ const ESC = '\x1b['
15
+ const RESET = `${ESC}0m`
16
+ const BOLD = `${ESC}1m`
17
+ const DIM = `${ESC}2m`
18
+ const FG = (r, g, b) => `${ESC}38;2;${r};${g};${b}m`
19
+
20
+ // Colors
21
+ const GRAY = FG(100, 100, 100)
22
+ const WHITE = FG(200, 200, 200)
23
+ const GREEN = FG(80, 200, 80)
24
+ const YELLOW = FG(220, 200, 60)
25
+ const ORANGE = FG(230, 140, 40)
26
+ const RED = FG(220, 60, 60)
27
+ const CYAN = FG(80, 200, 220)
28
+
29
+ const SEPARATOR = `${GRAY}\u2502${RESET}`
30
+
31
+ // Autocompact kicks in around 16.5% remaining -- normalize so bar reflects usable context
32
+ const AUTOCOMPACT_BUFFER = 16.5
33
+
34
+ function normalizeUsage(remainingPct) {
35
+ // remaining_percentage goes from 100 (empty) to 0 (full)
36
+ // usable range is 100 down to ~16.5 (autocompact buffer)
37
+ const usableRange = 100 - AUTOCOMPACT_BUFFER
38
+ const usableRemaining = Math.max(0, remainingPct - AUTOCOMPACT_BUFFER)
39
+ const usedPct = ((usableRange - usableRemaining) / usableRange) * 100
40
+ return Math.min(100, Math.max(0, usedPct))
41
+ }
42
+
43
+ function getBarColor(usedPct) {
44
+ if (usedPct < 50) return { fg: GREEN, label: GREEN }
45
+ if (usedPct < 65) return { fg: YELLOW, label: YELLOW }
46
+ if (usedPct < 80) return { fg: ORANGE, label: ORANGE }
47
+ return { fg: RED, label: RED }
48
+ }
49
+
50
+ function buildProgressBar(usedPct, segments = 15) {
51
+ const filled = Math.round((usedPct / 100) * segments)
52
+ const empty = segments - filled
53
+ const colors = getBarColor(usedPct)
54
+ const emphasis = usedPct >= 80 ? BOLD : ''
55
+
56
+ const filledBar = `${emphasis}${colors.fg}${'█'.repeat(filled)}${RESET}`
57
+ const emptyBar = `${GRAY}${'░'.repeat(empty)}${RESET}`
58
+ const pctLabel = `${colors.label}${BOLD}${Math.round(usedPct)}%${RESET}`
59
+
60
+ return `${filledBar}${emptyBar} ${pctLabel}`
61
+ }
62
+
63
+ function getActiveTask(data) {
64
+ const todos = data.todos || []
65
+ const active = todos.find((t) => t.status === 'in_progress') || todos.find((t) => t.status === 'pending')
66
+ if (!active) return null
67
+ const label = active.content || active.description || ''
68
+ return label.length > 40 ? label.slice(0, 37) + '...' : label
69
+ }
70
+
71
+ function getUpdateIndicator() {
72
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(require('os').homedir(), '.claude')
73
+ const cachePath = path.join(configDir, 'cache', 'claude-code-pulsify-update.json')
74
+ try {
75
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
76
+ if (cache.updateAvailable && cache.latest) {
77
+ return ` ${CYAN}\u2191${cache.latest}${RESET}`
78
+ }
79
+ } catch {
80
+ // No cache file or invalid — no indicator
81
+ }
82
+ return ''
83
+ }
84
+
85
+ function getGitBranch(cwd) {
86
+ try {
87
+ const head = fs.readFileSync(path.join(cwd, '.git', 'HEAD'), 'utf8').trim()
88
+ const match = head.match(/^ref: refs\/heads\/(.+)$/)
89
+ return match ? match[1] : null
90
+ } catch {
91
+ return null
92
+ }
93
+ }
94
+
95
+ function writeBridgeFile(sessionId, metrics) {
96
+ if (!sessionId) return
97
+ const bridgePath = `/tmp/claude-ctx-${sessionId}.json`
98
+ try {
99
+ fs.writeFileSync(bridgePath, JSON.stringify({ ...metrics, timestamp: Date.now() }), 'utf8')
100
+ } catch {
101
+ // Silently ignore write errors
102
+ }
103
+ }
104
+
105
+ async function main() {
106
+ let input = ''
107
+ for await (const chunk of process.stdin) {
108
+ input += chunk
109
+ }
110
+
111
+ let data
112
+ try {
113
+ data = JSON.parse(input)
114
+ } catch {
115
+ process.stdout.write(`${DIM}statusline: no data${RESET}`)
116
+ return
117
+ }
118
+
119
+ // Debug payload dump
120
+ if (process.env.CLAUDE_PULSIFY_DEBUG) {
121
+ try {
122
+ fs.writeFileSync('/tmp/claude-pulsify-debug.json', JSON.stringify(data, null, 2), 'utf8')
123
+ } catch {
124
+ // Silently ignore write errors
125
+ }
126
+ }
127
+
128
+ // Extract fields
129
+ const model = data.model?.display_name || data.model?.name || 'unknown'
130
+ const cwd = data.workspace?.current_dir || process.cwd()
131
+ const dir = path.basename(cwd)
132
+ const remainingPct = data.context_window?.remaining_percentage ?? 100
133
+ const sessionId = data.session?.id || data.session_id || null
134
+
135
+ // Normalize and build bar
136
+ const usedPct = normalizeUsage(remainingPct)
137
+ const bar = buildProgressBar(usedPct)
138
+
139
+ // Git branch
140
+ const branch = getGitBranch(cwd)
141
+ const dirLabel = branch ? `${DIM}${dir} (${branch})${RESET}` : `${DIM}${dir}${RESET}`
142
+
143
+ // Active task
144
+ const task = getActiveTask(data)
145
+ const taskSegment = task ? ` ${SEPARATOR} ${DIM}${task}${RESET}` : ''
146
+
147
+ // Update indicator
148
+ const updateIndicator = getUpdateIndicator()
149
+
150
+ // Write bridge file for context-monitor
151
+ writeBridgeFile(sessionId, {
152
+ remaining_percentage: remainingPct,
153
+ used_percentage: usedPct,
154
+ session_id: sessionId,
155
+ model,
156
+ })
157
+
158
+ // Output statusline
159
+ const line = `${WHITE}${model}${RESET} ${SEPARATOR} ${dirLabel} ${SEPARATOR} ${bar}${taskSegment}${updateIndicator}`
160
+ process.stdout.write(line)
161
+ }
162
+
163
+ main().catch(() => process.exit(0))
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "claude-code-pulsify",
3
+ "version": "1.0.0",
4
+ "description": "Context-aware statusline and context monitor for Claude Code",
5
+ "bin": {
6
+ "claude-code-pulsify": "bin/install.js"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/SpitzN/claude-code-pulsify"
11
+ },
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "license": "MIT",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "files": [
20
+ "bin/",
21
+ "hooks/"
22
+ ]
23
+ }