claude-limitline 1.5.2 → 1.5.3

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 (2) hide show
  1. package/dist/index.js +205 -25
  2. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -35,7 +35,11 @@ var DEFAULT_CONFIG = {
35
35
  enabled: true,
36
36
  displayStyle: "text",
37
37
  barWidth: 10,
38
- showTimeRemaining: true
38
+ showTimeRemaining: true,
39
+ timeDisplay: "remaining",
40
+ timeFormat: "12h",
41
+ showSparkline: false,
42
+ sparklineWidth: 8
39
43
  },
40
44
  weekly: {
41
45
  enabled: true,
@@ -161,32 +165,57 @@ async function getOAuthTokenWindows() {
161
165
  }
162
166
  return null;
163
167
  }
164
- async function getOAuthTokenMacOS() {
168
+ async function findKeychainServiceName() {
165
169
  try {
166
170
  const { stdout } = await execAsync(
167
- `security find-generic-password -s "Claude Code-credentials" -w`,
171
+ `security dump-keychain 2>/dev/null | grep -o '"Claude Code-credentials[^"]*"'`,
168
172
  { timeout: 5e3 }
169
173
  );
170
- const content = stdout.trim();
171
- if (content.startsWith("{")) {
172
- try {
173
- const parsed = JSON.parse(content);
174
- if (parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object") {
175
- const token = parsed.claudeAiOauth.accessToken;
176
- if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) {
177
- debug("Found OAuth token in macOS Keychain under claudeAiOauth.accessToken");
178
- return token;
179
- }
174
+ const matches = stdout.trim().split("\n").map((s) => s.replace(/^"|"$/g, "")).filter(Boolean).sort((a, b) => b.length - a.length);
175
+ if (matches.length > 0) {
176
+ debug(`Found keychain service: ${matches[0]}`);
177
+ return matches[0];
178
+ }
179
+ } catch (error) {
180
+ debug("Keychain service name lookup failed:", error);
181
+ }
182
+ return "Claude Code-credentials";
183
+ }
184
+ function extractTokenFromKeychainContent(content) {
185
+ if (content.startsWith("{")) {
186
+ try {
187
+ const parsed = JSON.parse(content);
188
+ if (parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object") {
189
+ const token = parsed.claudeAiOauth.accessToken;
190
+ if (token && typeof token === "string" && token.startsWith("sk-ant-oat")) {
191
+ return token;
180
192
  }
181
- } catch (parseError) {
182
- debug("Failed to parse keychain JSON:", parseError);
183
193
  }
194
+ } catch (parseError) {
195
+ debug("Failed to parse keychain JSON:", parseError);
184
196
  }
185
- if (content.startsWith("sk-ant-oat")) {
186
- return content;
197
+ }
198
+ if (content.startsWith("sk-ant-oat")) {
199
+ return content;
200
+ }
201
+ return null;
202
+ }
203
+ async function getOAuthTokenMacOS() {
204
+ const serviceName = await findKeychainServiceName();
205
+ for (const name of [serviceName, "Claude Code-credentials"]) {
206
+ try {
207
+ const { stdout } = await execAsync(
208
+ `security find-generic-password -s "${name}" -w`,
209
+ { timeout: 5e3 }
210
+ );
211
+ const token = extractTokenFromKeychainContent(stdout.trim());
212
+ if (token) {
213
+ debug(`Found OAuth token in macOS Keychain (${name})`);
214
+ return token;
215
+ }
216
+ } catch (error) {
217
+ debug(`macOS Keychain retrieval failed for "${name}":`, error);
187
218
  }
188
- } catch (error) {
189
- debug("macOS Keychain retrieval failed:", error);
190
219
  }
191
220
  const configPaths = [
192
221
  path2.join(os2.homedir(), ".claude", ".credentials.json"),
@@ -315,9 +344,57 @@ async function fetchUsageFromAPI(token) {
315
344
  return null;
316
345
  }
317
346
  }
318
- var cachedUsage = null;
319
- var previousUsage = null;
320
- var cacheTimestamp = 0;
347
+ var USAGE_CACHE_FILE = "limitline-usage-cache.json";
348
+ function getCachePath() {
349
+ return path2.join(os2.homedir(), ".claude", USAGE_CACHE_FILE);
350
+ }
351
+ function loadDiskCache() {
352
+ try {
353
+ const cachePath = getCachePath();
354
+ if (fs2.existsSync(cachePath)) {
355
+ const content = fs2.readFileSync(cachePath, "utf-8");
356
+ const data = JSON.parse(content);
357
+ const rehydrate = (usage) => {
358
+ if (!usage) return null;
359
+ const fix = (ud) => {
360
+ if (!ud) return null;
361
+ return { ...ud, resetAt: new Date(ud.resetAt) };
362
+ };
363
+ return {
364
+ ...usage,
365
+ fiveHour: fix(usage.fiveHour),
366
+ sevenDay: fix(usage.sevenDay),
367
+ sevenDayOpus: fix(usage.sevenDayOpus),
368
+ sevenDaySonnet: fix(usage.sevenDaySonnet)
369
+ };
370
+ };
371
+ return {
372
+ timestamp: data.timestamp,
373
+ usage: rehydrate(data.usage),
374
+ previousUsage: rehydrate(data.previousUsage)
375
+ };
376
+ }
377
+ } catch (error) {
378
+ debug("Failed to load usage cache from disk:", error);
379
+ }
380
+ return null;
381
+ }
382
+ function saveDiskCache(cache) {
383
+ try {
384
+ const cachePath = getCachePath();
385
+ const dir = path2.dirname(cachePath);
386
+ if (!fs2.existsSync(dir)) {
387
+ fs2.mkdirSync(dir, { recursive: true });
388
+ }
389
+ fs2.writeFileSync(cachePath, JSON.stringify(cache));
390
+ } catch (error) {
391
+ debug("Failed to save usage cache to disk:", error);
392
+ }
393
+ }
394
+ var diskCache = loadDiskCache();
395
+ var cachedUsage = diskCache?.usage ?? null;
396
+ var previousUsage = diskCache?.previousUsage ?? null;
397
+ var cacheTimestamp = diskCache?.timestamp ?? 0;
321
398
  var cachedToken = null;
322
399
  function getUsageTrend() {
323
400
  const result = {
@@ -362,6 +439,7 @@ async function getRealtimeUsage(pollIntervalMinutes = 15) {
362
439
  previousUsage = cachedUsage;
363
440
  cachedUsage = usage;
364
441
  cacheTimestamp = now;
442
+ saveDiskCache({ timestamp: now, usage: cachedUsage, previousUsage });
365
443
  debug("Refreshed realtime usage cache");
366
444
  } else {
367
445
  cachedToken = null;
@@ -678,6 +756,75 @@ function getTerminalWidth() {
678
756
  return process.stdout.columns || 80;
679
757
  }
680
758
 
759
+ // src/utils/history.ts
760
+ import fs3 from "fs";
761
+ import path3 from "path";
762
+ import os3 from "os";
763
+ var SPARKLINE_CHARS = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
764
+ var MAX_HISTORY_AGE_MS = 24 * 60 * 60 * 1e3;
765
+ var HISTORY_FILE = "limitline-history.json";
766
+ function getHistoryPath() {
767
+ return path3.join(os3.homedir(), ".claude", HISTORY_FILE);
768
+ }
769
+ function loadHistory() {
770
+ const historyPath = getHistoryPath();
771
+ try {
772
+ if (fs3.existsSync(historyPath)) {
773
+ const content = fs3.readFileSync(historyPath, "utf-8");
774
+ const data = JSON.parse(content);
775
+ return data;
776
+ }
777
+ } catch (error) {
778
+ debug("Failed to load history:", error);
779
+ }
780
+ return { samples: [] };
781
+ }
782
+ function saveHistory(data) {
783
+ const historyPath = getHistoryPath();
784
+ try {
785
+ const dir = path3.dirname(historyPath);
786
+ if (!fs3.existsSync(dir)) {
787
+ fs3.mkdirSync(dir, { recursive: true });
788
+ }
789
+ fs3.writeFileSync(historyPath, JSON.stringify(data, null, 2));
790
+ } catch (error) {
791
+ debug("Failed to save history:", error);
792
+ }
793
+ }
794
+ function pruneOldSamples(data) {
795
+ const cutoff = Date.now() - MAX_HISTORY_AGE_MS;
796
+ return {
797
+ samples: data.samples.filter((s) => s.timestamp > cutoff)
798
+ };
799
+ }
800
+ function addSample(blockPercent, weeklyPercent) {
801
+ const history = loadHistory();
802
+ history.samples.push({
803
+ timestamp: Date.now(),
804
+ blockPercent,
805
+ weeklyPercent
806
+ });
807
+ const pruned = pruneOldSamples(history);
808
+ saveHistory(pruned);
809
+ }
810
+ function getSparkline(samples, width) {
811
+ const validSamples = samples.filter((s) => s !== null);
812
+ if (validSamples.length === 0) {
813
+ return "";
814
+ }
815
+ const recentSamples = validSamples.slice(-width);
816
+ return recentSamples.map((value) => {
817
+ const clamped = Math.max(0, Math.min(100, value));
818
+ const index = Math.floor(clamped / 100 * 7);
819
+ return SPARKLINE_CHARS[index];
820
+ }).join("");
821
+ }
822
+ function getBlockSparkline(width) {
823
+ const history = loadHistory();
824
+ const blockSamples = history.samples.map((s) => s.blockPercent);
825
+ return getSparkline(blockSamples, width);
826
+ }
827
+
681
828
  // src/renderer.ts
682
829
  var Renderer = class {
683
830
  config;
@@ -733,6 +880,18 @@ var Renderer = class {
733
880
  }
734
881
  return `${minutes}m`;
735
882
  }
883
+ formatAbsoluteTime(resetAt, format) {
884
+ const hours = resetAt.getHours();
885
+ const minutes = resetAt.getMinutes();
886
+ const paddedMinutes = minutes.toString().padStart(2, "0");
887
+ if (format === "24h") {
888
+ const paddedHours = hours.toString().padStart(2, "0");
889
+ return `${paddedHours}:${paddedMinutes}`;
890
+ }
891
+ const hour12 = hours % 12 || 12;
892
+ const ampm = hours < 12 ? "am" : "pm";
893
+ return `${hour12}:${paddedMinutes}${ampm}`;
894
+ }
736
895
  getTrendSymbol(trend) {
737
896
  if (!this.config.showTrend) return "";
738
897
  if (trend === "up") return this.symbols.trendUp;
@@ -841,9 +1000,24 @@ var Renderer = class {
841
1000
  } else {
842
1001
  text = `${Math.round(percent)}%${trend}`;
843
1002
  }
844
- if (showTime && ctx.blockInfo.timeRemaining !== null && !ctx.compact) {
845
- const timeStr = this.formatTimeRemaining(ctx.blockInfo.timeRemaining, ctx.compact);
846
- text += ` (${timeStr})`;
1003
+ const showSparkline = this.config.block?.showSparkline ?? false;
1004
+ if (showSparkline && !ctx.compact) {
1005
+ const sparklineWidth = this.config.block?.sparklineWidth ?? 8;
1006
+ const sparkline = getBlockSparkline(sparklineWidth);
1007
+ if (sparkline) {
1008
+ text += ` ${sparkline}`;
1009
+ }
1010
+ }
1011
+ if (showTime && !ctx.compact) {
1012
+ const timeDisplay = this.config.block?.timeDisplay ?? "remaining";
1013
+ const timeFormat = this.config.block?.timeFormat ?? "12h";
1014
+ if (timeDisplay === "absolute" && ctx.blockInfo.resetAt) {
1015
+ const timeStr = this.formatAbsoluteTime(ctx.blockInfo.resetAt, timeFormat);
1016
+ text += ` (${timeStr})`;
1017
+ } else if (ctx.blockInfo.timeRemaining !== null) {
1018
+ const timeStr = this.formatTimeRemaining(ctx.blockInfo.timeRemaining, ctx.compact);
1019
+ text += ` (${timeStr})`;
1020
+ }
847
1021
  }
848
1022
  return {
849
1023
  text: ` ${icon} ${text} `,
@@ -1165,6 +1339,12 @@ async function main() {
1165
1339
  ]);
1166
1340
  debug("Block info:", JSON.stringify(blockInfo));
1167
1341
  debug("Weekly info:", JSON.stringify(weeklyInfo));
1342
+ if (blockInfo?.percentUsed !== null || weeklyInfo?.percentUsed !== null) {
1343
+ addSample(
1344
+ blockInfo?.percentUsed ?? null,
1345
+ weeklyInfo?.percentUsed ?? null
1346
+ );
1347
+ }
1168
1348
  const trendInfo = config.showTrend ? getUsageTrend() : null;
1169
1349
  debug("Trend info:", JSON.stringify(trendInfo));
1170
1350
  const renderer = new Renderer(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-limitline",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
4
4
  "description": "A statusline for Claude Code showing real-time usage limits and weekly tracking",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "type": "module",
10
10
  "scripts": {
11
+ "prepare": "tsup src/index.ts --format esm --dts --clean",
11
12
  "build": "tsup src/index.ts --format esm --dts --clean",
12
13
  "dev": "tsup src/index.ts --format esm --watch",
13
14
  "start": "node dist/index.js",