ctxline-claude 1.6.2 → 1.7.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.
Files changed (3) hide show
  1. package/README.md +12 -5
  2. package/package.json +1 -1
  3. package/statusline.js +163 -9
package/README.md CHANGED
@@ -10,13 +10,12 @@
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://github.com/MithunWijayasiri/ctxline-claude/stargazers"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="GitHub Stars" src="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
13
- <a href="https://github.com/MithunWijayasiri/ctxline-claude/graphs/contributors"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?theme=emerald&amp;variant=ghost&amp;size=sm&amp;mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?theme=emerald&amp;variant=ghost&amp;size=sm&amp;mode=light"></picture></a>
13
+ <a href="https://github.com/MithunWijayasiri/ctxline-claude/graphs/contributors"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
14
14
  <a href="https://github.com/MithunWijayasiri/ctxline-claude/commits/main"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="Last commit" src="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
15
- <a href="https://github.com/MithunWijayasiri/ctxline-claude/actions"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/github/ci/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=dark"><img alt="CI" src="https://www.shieldcn.dev/github/ci/MithunWijayasiri/ctxline-claude.svg?variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
16
- <a href="https://ko-fi.com/mithunwijayasiri"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=outline&amp;size=sm&amp;mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=outline&amp;size=sm&amp;mode=light"></picture></a>
15
+ <a href="https://ko-fi.com/mithunwijayasiri"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=secondary&amp;size=sm&amp;mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&amp;variant=secondary&amp;size=sm&amp;mode=light"></picture></a>
17
16
  <br>
18
17
  <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Version" src="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
19
- <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=ghost&amp;size=xs&amp;mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=ghost&amp;size=xs&amp;mode=light"></picture></a>
18
+ <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
20
19
  <a href="https://www.npmjs.com/package/ctxline-claude"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=dark"><img alt="npm Total Downloads" src="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&amp;size=xs&amp;mode=light"></picture></a>
21
20
  </p>
22
21
 
@@ -138,6 +137,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
138
137
  | **Model limit** | Weekly limit scoped to a single model, when your account has one — labelled by the model's initial (`F` = Fable) |
139
138
  | **Cost** | Running session cost in USD (e.g. `$0.42`) |
140
139
  | **Task** | The in-progress todo, when there is one |
140
+ | **Update** | An extra row with the upgrade command when a newer release is on npm — checked once a week, in the background |
141
141
 
142
142
  > [!NOTE]
143
143
  > Usage bars change color automatically as you approach your limits.
@@ -152,7 +152,7 @@ Remove-Item "$env:USERPROFILE\.claude\cache\usage-cache.json" -ErrorAction Silen
152
152
 
153
153
  The statusline is zero-config by default. To **hide segments you don't want**, set the `CTXLINE_DISABLE` environment variable to a comma-separated list of any of:
154
154
 
155
- `branch` · `effort` · `cost` · `task` · `usage` (5-hour + weekly + model-scoped)
155
+ `branch` · `effort` · `cost` · `task` · `update` · `usage` (5-hour + weekly + model-scoped)
156
156
 
157
157
  Directory, model, and context always show; unknown names are ignored. Example below hides cost and the current task.
158
158
 
@@ -219,6 +219,13 @@ No, it's imperceptible. Almost every render reads a small local cache (sub-milli
219
219
 
220
220
  </details>
221
221
 
222
+ <details>
223
+ <summary>What is the ⬆ segment, and what does it send?</summary>
224
+
225
+ An extra row appears below the statusline — `⬆ 1.7.0 available · npx ctxline-claude@latest` — so the upgrade command is right there to copy. It shows only while you're behind, and the command works whichever way you installed. Once a week a short-lived background process asks the public npm registry for the package's latest version number and writes it to a local cache; the statusline itself only ever reads that cache, so no render waits on the network. The request carries no session data, credentials, or identifiers — it's a plain GET for a public package, though as with any HTTP request the registry does see your IP address. Hide it (and skip the request entirely) with `CTXLINE_DISABLE=update`.
226
+
227
+ </details>
228
+
222
229
  <details>
223
230
  <summary>Does this use the same data as /usage?</summary>
224
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxline-claude",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "A customizable statusline for Claude Code that tracks context usage and session limits",
5
5
  "bin": {
6
6
  "ctxline-claude": "bin/install.js"
package/statusline.js CHANGED
@@ -8,12 +8,18 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
10
  const https = require('https');
11
- const { execSync, execFileSync } = require('child_process');
11
+ const { execSync, execFileSync, spawn } = require('child_process');
12
+
13
+ // Installed version, compared against the npm registry's latest for the update nudge.
14
+ // This file is copied standalone into ~/.claude/hooks/ with no package.json beside it,
15
+ // so the version has to live here. Must match package.json "version" (see CLAUDE.md).
16
+ const VERSION = '1.7.0';
12
17
 
13
18
  const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
19
 
15
20
  // Optional segment opt-out: CTXLINE_DISABLE is a comma list of segments to hide.
16
- // Recognized: branch, effort, cost, task, usage (H+W+model-scoped). dir/model/context always render.
21
+ // Recognized: branch, effort, cost, task, update, usage (H+W+model-scoped). dir/model/context
22
+ // always render.
17
23
  // Unknown names are ignored. Disabling a segment also skips its work (git, todo read,
18
24
  // usage fetch).
19
25
  const DISABLED = new Set(
@@ -54,6 +60,17 @@ const GIT_FRESH_TTL_MS = 5000; // 5s: reuse counts within a render burs
54
60
  const GIT_STALE_TTL_MS = 60000; // 60s: fall back to last counts if git fails
55
61
  const GIT_TIMEOUT_MS = 500; // hard cap on the rev-list subprocess (warm ~130ms)
56
62
 
63
+ // Update check: compares VERSION against the npm registry's dist-tags.latest. The render
64
+ // only ever reads this cache; the refresh runs in a detached child (see refreshUpdateCheck),
65
+ // so no render ever waits on the registry.
66
+ const UPDATE_CACHE_FILE = path.join(CACHE_DIR, 'update-cache.json');
67
+ const UPDATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days between successful checks
68
+ const UPDATE_RETRY_MS = 60 * 60 * 1000; // 1h backoff after a failed/killed check
69
+ const UPDATE_TIMEOUT_MS = 2000; // socket idle AND whole-request deadline
70
+ const REGISTRY_HOST = 'registry.npmjs.org';
71
+ const PACKAGE_NAME = 'ctxline-claude';
72
+ const SEMVER_RE = /^\d+\.\d+\.\d+$/; // releases only: a prerelease never nudges
73
+
57
74
  // Subagent mode reads only stdin (no usage API to race), so its stdin read gets a
58
75
  // short hard cap of its own instead of the main-mode overallTimeout.
59
76
  const SUBAGENT_TIMEOUT_MS = 500;
@@ -497,6 +514,125 @@ function recordUsageAttempt() {
497
514
  }
498
515
  }
499
516
 
517
+ // Compare two strict "x.y.z" versions -> -1 | 0 | 1, or null when either side isn't that
518
+ // shape (prerelease tags, missing parts, non-numeric). The nudge is a nicety, so an
519
+ // unparseable version means no segment rather than a guess.
520
+ function compareVersions(a, b) {
521
+ const parse = (v) => SEMVER_RE.test(String(v ?? '')) ? String(v).split('.').map(Number) : null;
522
+ const x = parse(a);
523
+ const y = parse(b);
524
+ if (!x || !y) return null;
525
+ for (let i = 0; i < 3; i++) {
526
+ if (x[i] !== y[i]) return x[i] > y[i] ? 1 : -1;
527
+ }
528
+ return 0;
529
+ }
530
+
531
+ // Pure: an npm registry version-manifest body -> its "x.y.z" version string, or null on
532
+ // unparseable JSON, a missing version, or a non-release version (404 bodies land here too).
533
+ function parseRegistryVersion(body) {
534
+ try {
535
+ const v = JSON.parse(body)?.version;
536
+ return SEMVER_RE.test(String(v ?? '')) ? String(v) : null;
537
+ } catch (e) {
538
+ return null;
539
+ }
540
+ }
541
+
542
+ function readUpdateCache() {
543
+ try {
544
+ const c = JSON.parse(fs.readFileSync(UPDATE_CACHE_FILE, 'utf8'));
545
+ return c && typeof c === 'object' ? c : null;
546
+ } catch (e) {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ function writeUpdateCache(obj) {
552
+ try {
553
+ if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
554
+ fs.writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(obj), 'utf8');
555
+ } catch (e) {}
556
+ }
557
+
558
+ // The cached latest version when it's newer than VERSION, else ''. Cache-only by design:
559
+ // collectFacts calls this on the render path, and the render must never touch the network.
560
+ function getLatestUpdate() {
561
+ const cached = readUpdateCache();
562
+ if (!cached) return '';
563
+ return compareVersions(cached.latest, VERSION) === 1 ? String(cached.latest) : '';
564
+ }
565
+
566
+ // Fire the weekly check in a detached child, so the render neither waits on the registry
567
+ // nor races its own exit against the response. lastAttempt is stamped before the spawn, so
568
+ // an offline machine, a failed spawn, or a child that dies backs off UPDATE_RETRY_MS
569
+ // instead of respawning on every render.
570
+ function refreshUpdateCheck() {
571
+ try {
572
+ const cached = readUpdateCache();
573
+ const now = Date.now();
574
+ if (cached) {
575
+ if (Number.isFinite(cached.checkedAt) && now - cached.checkedAt < UPDATE_TTL_MS) return;
576
+ if (Number.isFinite(cached.lastAttempt) && now - cached.lastAttempt < UPDATE_RETRY_MS) return;
577
+ }
578
+ writeUpdateCache({ ...(cached || {}), lastAttempt: now });
579
+ // windowsHide: a detached console app would otherwise flash its own console window on
580
+ // Windows. detached + unref so the child outlives this render's exit(0).
581
+ spawn(process.execPath, [__filename, 'update-check'], {
582
+ detached: true, stdio: 'ignore', windowsHide: true
583
+ }).unref();
584
+ } catch (e) {}
585
+ }
586
+
587
+ // The 'update-check' entry point: the detached child. Fetches the registry's latest
588
+ // version, stamps the cache, exits. Writes nothing to stdout — it is not a statusline
589
+ // mode, and its stdio is discarded by the parent anyway. A failed fetch leaves checkedAt
590
+ // untouched, so the UPDATE_RETRY_MS backoff (not the weekly TTL) governs the next try.
591
+ function runUpdateCheck() {
592
+ let settled = false;
593
+ let deadline;
594
+
595
+ const done = (latest) => {
596
+ if (settled) return;
597
+ settled = true;
598
+ clearTimeout(deadline);
599
+ if (latest) writeUpdateCache({ ...(readUpdateCache() || {}), latest, checkedAt: Date.now() });
600
+ process.exit(0);
601
+ };
602
+
603
+ try {
604
+ const req = https.request({
605
+ hostname: REGISTRY_HOST,
606
+ path: `/${PACKAGE_NAME}/latest`,
607
+ method: 'GET',
608
+ headers: { 'Accept': 'application/json' },
609
+ timeout: UPDATE_TIMEOUT_MS
610
+ }, (res) => {
611
+ let body = '';
612
+ res.on('data', chunk => body += chunk);
613
+ res.on('end', () => done(parseRegistryVersion(body)));
614
+ });
615
+
616
+ req.on('error', () => done(null));
617
+ req.on('timeout', () => {
618
+ req.destroy();
619
+ done(null);
620
+ });
621
+
622
+ // The `timeout` option above is socket inactivity, not total duration -- a response
623
+ // that trickles bytes would keep this detached child alive indefinitely, and the
624
+ // parent's UPDATE_RETRY_MS only delays the next spawn, it can't reap this one.
625
+ deadline = setTimeout(() => {
626
+ req.destroy();
627
+ done(null);
628
+ }, UPDATE_TIMEOUT_MS);
629
+
630
+ req.end();
631
+ } catch (e) {
632
+ done(null);
633
+ }
634
+ }
635
+
500
636
  function getCredentials() {
501
637
  // Try file first (legacy / Linux / Windows)
502
638
  const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
@@ -669,13 +805,25 @@ function collectFacts(data) {
669
805
  const sync = branch ? formatAheadBehind(getGitAheadBehind(dir)) : '';
670
806
  const sessionId = data?.session_id || '';
671
807
  const task = DISABLED.has('task') ? '' : getCurrentTask(sessionId);
808
+ const update = DISABLED.has('update') ? '' : getLatestUpdate();
672
809
  const cols = parseInt(process.env.COLUMNS, 10);
673
- return { dirname, branch, sync, task, cols };
810
+ return { dirname, branch, sync, task, update, cols };
674
811
  } catch (e) {
675
- return { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
812
+ return { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
676
813
  }
677
814
  }
678
815
 
816
+ // The update nudge gets its own row rather than a segment: Claude Code renders every
817
+ // stdout line as a separate row, and the point of the nudge is the copy-pasteable command,
818
+ // which is too wide to inline without forcing the main line to wrap on most terminals.
819
+ // `npx <pkg>@latest` is the right command for script-installed users too — it recopies the
820
+ // hook. Only the target version is shown — the running one is what you're looking at.
821
+ function renderUpdateLine(latest) {
822
+ return `${colors.green}⬆ ${latest}${colors.reset} `
823
+ + `${colors.dim}available ·${colors.reset} `
824
+ + `${colors.bold}npx ${PACKAGE_NAME}@latest${colors.reset}`;
825
+ }
826
+
679
827
  // Pure: data + facts (see collectFacts) + resolved usage bars -> the rendered line(s).
680
828
  // No fs/child_process/network access, so it's callable directly in tests.
681
829
  function renderStatusLine(data, facts, usage) {
@@ -701,7 +849,8 @@ function renderStatusLine(data, facts, usage) {
701
849
  if (cost) line2.push(cost);
702
850
  if (facts.task) line2.push(`${colors.dim}${facts.task}${colors.reset}`);
703
851
 
704
- return layout(line1, line2, facts.cols);
852
+ const body = layout(line1, line2, facts.cols);
853
+ return facts.update ? body + '\n' + renderUpdateLine(facts.update) : body;
705
854
  }
706
855
 
707
856
  // Main
@@ -714,7 +863,7 @@ function outputStatus(data, facts, usage) {
714
863
  }
715
864
 
716
865
  function outputFallback(usage) {
717
- const facts = { dirname: '~', branch: '', sync: '', task: '', cols: undefined };
866
+ const facts = { dirname: '~', branch: '', sync: '', task: '', update: '', cols: undefined };
718
867
  process.stdout.write(renderStatusLine(null, facts, usage));
719
868
  }
720
869
 
@@ -774,6 +923,7 @@ function readStdinThen(timeoutMs, fn) {
774
923
 
775
924
  // Resolve usage for `data` (preferring stdin rate_limits), then render and exit.
776
925
  function emit(data) {
926
+ if (!DISABLED.has('update')) refreshUpdateCheck();
777
927
  resolveUsage(data, (usage) => {
778
928
  if (data) {
779
929
  outputStatus(data, collectFacts(data), usage);
@@ -854,10 +1004,14 @@ function emitSubagent(data) {
854
1004
  // directly (the /usage response shape is the easiest thing here to get wrong, and it
855
1005
  // can't be reached through stdin). Running the script normally is unchanged.
856
1006
  if (require.main === module) {
857
- const isSubagent = process.argv[2] === 'subagent';
1007
+ const mode = process.argv[2];
1008
+ const isSubagent = mode === 'subagent';
858
1009
  const finish = isSubagent ? emitSubagent : emit;
859
1010
 
860
- if (process.stdin.isTTY) {
1011
+ if (mode === 'update-check') {
1012
+ // Detached child spawned by refreshUpdateCheck: no stdin, no output, just the fetch.
1013
+ runUpdateCheck();
1014
+ } else if (process.stdin.isTTY) {
861
1015
  finish(null);
862
1016
  } else {
863
1017
  const timeoutMs = isSubagent
@@ -866,5 +1020,5 @@ if (require.main === module) {
866
1020
  readStdinThen(timeoutMs, (input) => finish(parseInput(input)));
867
1021
  }
868
1022
  } else {
869
- module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask };
1023
+ module.exports = { parseScopedLimits, parseUsagePayload, serializeUsageCache, normalizePercentage, readStdinThen, renderStatusLine, renderSubagentTask, compareVersions, parseRegistryVersion, VERSION };
870
1024
  }