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.
- package/README.md +12 -5
- package/package.json +1 -1
- 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&size=sm&mode=dark"><img alt="GitHub Stars" src="https://www.shieldcn.dev/github/stars/MithunWijayasiri/ctxline-claude.svg?variant=secondary&size=sm&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?
|
|
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&size=sm&mode=dark"><img alt="Contributors" src="https://www.shieldcn.dev/github/contributors/MithunWijayasiri/ctxline-claude.svg?variant=secondary&size=sm&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&size=sm&mode=dark"><img alt="Last commit" src="https://www.shieldcn.dev/github/last-commit/MithunWijayasiri/ctxline-claude.svg?variant=secondary&size=sm&mode=light"></picture></a>
|
|
15
|
-
<a href="https://
|
|
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&variant=outline&size=sm&mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&variant=outline&size=sm&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&variant=secondary&size=sm&mode=dark"><img alt="Ko-fi" src="https://www.shieldcn.dev/badge/Ko--fi-support-FF5E5B.svg?logo=kofi&variant=secondary&size=sm&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&size=xs&mode=dark"><img alt="npm Version" src="https://www.shieldcn.dev/npm/ctxline-claude.svg?variant=secondary&size=xs&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=
|
|
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&size=xs&mode=dark"><img alt="npm Monthly Downloads" src="https://www.shieldcn.dev/npm/dm/ctxline-claude.svg?variant=secondary&size=xs&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&size=xs&mode=dark"><img alt="npm Total Downloads" src="https://www.shieldcn.dev/npm/dt/ctxline-claude.svg?variant=secondary&size=xs&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
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
|
|
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
|
-
|
|
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
|
|
1007
|
+
const mode = process.argv[2];
|
|
1008
|
+
const isSubagent = mode === 'subagent';
|
|
858
1009
|
const finish = isSubagent ? emitSubagent : emit;
|
|
859
1010
|
|
|
860
|
-
if (
|
|
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
|
}
|