claude-code-discord-presence 1.0.1 → 1.0.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.
package/README.md CHANGED
@@ -9,9 +9,7 @@ Cross-platform Discord Rich Presence for Claude Code Desktop and Claude Code CLI
9
9
  model, effort, usage limits, reset countdowns, tools, agents, token usage, cost, context, plan mode,
10
10
  Fast mode, and secure remote SSH sessions on Windows, macOS, and Linux.
11
11
 
12
- <p align="center">
13
- <img src="assets/claude-code.png" width="180" alt="Claude Code Discord Presence icon">
14
- </p>
12
+ ![Claude Code Discord Rich Presence with plan limits, model, effort, activity status, usage statistics, and session timer](https://raw.githubusercontent.com/dayfinggg/claude-code-discord-presence/main/assets/claude-code-presence-cover.png)
15
13
 
16
14
  ## Quick start — no manual hook setup
17
15
 
@@ -44,6 +42,8 @@ required. Existing unrelated Claude settings and hooks are preserved.
44
42
 
45
43
  The account usage endpoint is preferred over placeholder statusline zeroes. This prevents the false
46
44
  `100% left` display that some Claude clients emit while keeping statusline data as an offline fallback.
45
+ On macOS, the service reads the same OAuth metadata from the encrypted macOS Keychain that Claude
46
+ Code uses; Linux and Windows use Claude's protected `.credentials.json` file.
47
47
 
48
48
  ## Hooks and statusline
49
49
 
@@ -59,6 +59,11 @@ user-level `settings.json` used by Claude Code CLI and Desktop.
59
59
  - Existing settings are backed up under `~/.claude/backups/claude-code-presence/`; only five backups
60
60
  are retained.
61
61
 
62
+ If `settings.json` already contains a statusline that is not managed by this package, the installer
63
+ leaves it unchanged. Claude Code supports one `statusLine` command, so the presence statusline is not
64
+ installed in that case: hooks and basic activity still work, but statusline-only fields may be
65
+ unavailable until the user chooses which statusline to keep or combines the commands manually.
66
+
62
67
  Run the installer explicitly at any time with `claude-code-presence setup`.
63
68
 
64
69
  ## Desktop, CLI, and platforms
@@ -1,10 +1,14 @@
1
1
  import { join } from "node:path";
2
+ import { execFile } from "node:child_process";
2
3
  import { readFile, writeFile } from "node:fs/promises";
4
+ import { promisify } from "node:util";
3
5
  import { createLogger } from "../util/logger.js";
4
6
  import { claudeConfigDir } from "./paths.js";
5
7
  const log = createLogger("plan-info");
8
+ const execFileAsync = promisify(execFile);
6
9
  const CLAUDE_DIR = claudeConfigDir();
7
10
  export const CREDENTIALS_PATH = join(CLAUDE_DIR, ".credentials.json");
11
+ const MACOS_KEYCHAIN_SERVICES = ["Claude Code-credentials", "Claude Code"];
8
12
  const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
9
13
  const CACHE_TTL_MS = 60 * 60 * 1000;
10
14
  const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
@@ -34,11 +38,50 @@ export function planNameFrom(subscriptionType, rateLimitTier) {
34
38
  return sub.charAt(0).toUpperCase() + sub.slice(1);
35
39
  return "Claude";
36
40
  }
41
+ const runSecurity = async (file, args, options) => execFileAsync(file, args, options);
42
+ function parseCredentials(raw) {
43
+ try {
44
+ const parsed = JSON.parse(raw);
45
+ return parsed && typeof parsed === "object" ? parsed : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ export async function readMacosKeychainCredentials(run = runSecurity) {
52
+ for (const service of MACOS_KEYCHAIN_SERVICES) {
53
+ try {
54
+ const { stdout } = await run("/usr/bin/security", ["find-generic-password", "-s", service, "-w"], { encoding: "utf8", timeout: 10_000, maxBuffer: 512 * 1024 });
55
+ if (stdout.trim())
56
+ return stdout.trim();
57
+ }
58
+ catch {
59
+ // Try the legacy service name before falling back to the credentials file.
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+ export async function readCredentialsForPlatform(platform, readCredentialsFile, readKeychain) {
65
+ if (platform === "darwin") {
66
+ const keychainRaw = await readKeychain();
67
+ const keychainCredentials = keychainRaw ? parseCredentials(keychainRaw) : undefined;
68
+ if (keychainCredentials)
69
+ return keychainCredentials;
70
+ }
71
+ try {
72
+ return parseCredentials(await readCredentialsFile(CREDENTIALS_PATH, "utf8"));
73
+ }
74
+ catch {
75
+ return undefined;
76
+ }
77
+ }
37
78
  async function readCredentials() {
38
79
  for (let attempt = 0; attempt < 3; attempt++) {
39
80
  try {
40
- const raw = await readFile(CREDENTIALS_PATH, "utf8");
41
- return JSON.parse(raw);
81
+ const credentials = await readCredentialsForPlatform(process.platform, readFile, readMacosKeychainCredentials);
82
+ if (credentials)
83
+ return credentials;
84
+ throw Object.assign(new Error("Claude Code credentials were not found"), { code: "ENOENT" });
42
85
  }
43
86
  catch (err) {
44
87
  if (attempt === 2) {
@@ -68,6 +111,10 @@ export async function readOauthCredentials() {
68
111
  };
69
112
  }
70
113
  export async function saveRefreshedCredentials(accessToken, refreshToken, expiresInS) {
114
+ if (process.platform === "darwin") {
115
+ log.warn("token refresh cannot update macOS Keychain; Claude Code will refresh its credentials");
116
+ return false;
117
+ }
71
118
  try {
72
119
  const raw = await readFile(CREDENTIALS_PATH, "utf8");
73
120
  const parsed = JSON.parse(raw);
@@ -290,6 +290,7 @@ export class SessionStore {
290
290
  case "PostToolUse":
291
291
  break;
292
292
  case "Stop":
293
+ case "StopFailure":
293
294
  session.status = "idle";
294
295
  session.action = "Idle";
295
296
  break;
@@ -329,9 +330,10 @@ export class SessionStore {
329
330
  else if (session.transcriptPath) {
330
331
  if (!this.effectiveModel(session) || turnBoundary)
331
332
  void this.refreshModelFromTranscript(session);
332
- const statsEvent = event === "Stop" || event === "UserPromptSubmit" || event === "PostToolUse" || event === "SessionStart";
333
+ const statsEvent = event === "Stop" || event === "StopFailure" || event === "UserPromptSubmit" ||
334
+ event === "PostToolUse" || event === "SessionStart";
333
335
  if (statsEvent && !fromAgent) {
334
- void this.refreshStatsFromTranscript(session, event === "Stop" || event === "SessionStart");
336
+ void this.refreshStatsFromTranscript(session, event === "Stop" || event === "StopFailure" || event === "SessionStart");
335
337
  }
336
338
  }
337
339
  this.onChange();
@@ -59,6 +59,8 @@ export class UsagePoller {
59
59
  return base + Math.floor((0.5 - Math.abs((Date.now() % 1000) / 1000 - 0.5)) * spread);
60
60
  }
61
61
  watchCredentials() {
62
+ if (process.platform === "darwin")
63
+ return;
62
64
  try {
63
65
  this.watcher = watch(CREDENTIALS_PATH, () => {
64
66
  if (this.watchDebounce)
package/dist/cli.js CHANGED
@@ -96,7 +96,7 @@ function setupInstalled() {
96
96
  try {
97
97
  const config = JSON.parse(readFileSync(join(claudeDir(), "discord-presence", "config.json"), "utf8"));
98
98
  const settings = readFileSync(join(claudeDir(), "settings.json"), "utf8");
99
- return config.installerVersion === 1 && /discord-presence[\\/]hook\.mjs/i.test(settings) &&
99
+ return config.installerVersion === 2 && /discord-presence[\\/]hook\.mjs/i.test(settings) &&
100
100
  /discord-presence[\\/]statusline\.mjs/i.test(settings);
101
101
  }
102
102
  catch {
@@ -1,13 +1,6 @@
1
1
  const MIN = 2;
2
2
  const MAX = 128;
3
3
  const SEP = " • ";
4
- const MODEL_NAMES = {
5
- "claude-fable-5": "Fable 5",
6
- "claude-opus-4-8": "Opus 4.8",
7
- "claude-opus-4-7": "Opus 4.7",
8
- "claude-sonnet-5": "Sonnet 5",
9
- "claude-haiku-4-5": "Haiku 4.5",
10
- };
11
4
  const EFFORT_LABELS = {
12
5
  minimal: "Minimal",
13
6
  low: "Light",
@@ -21,12 +14,42 @@ export function effortLabel(level) {
21
14
  return EFFORT_LABELS[level];
22
15
  }
23
16
  export function modelDisplayName(id, fallback) {
24
- if (id && MODEL_NAMES[id])
25
- return MODEL_NAMES[id];
26
- if (fallback && fallback.trim() !== "")
27
- return fallback;
28
- if (id && id.trim() !== "")
29
- return id;
17
+ const fallbackName = fallback?.trim();
18
+ if (fallbackName && !/[-_[\]]/.test(fallbackName))
19
+ return fallbackName;
20
+ const raw = fallbackName || id?.trim();
21
+ if (raw) {
22
+ const normalized = raw
23
+ .replace(/^claude[-_\s]+/i, "")
24
+ .replace(/\s*\[[^\]]*\]\s*$/g, "")
25
+ .replace(/[_\s]+/g, "-")
26
+ .replace(/-(?:latest|\d+[km]|\d{8})$/i, "")
27
+ .replace(/-+/g, "-")
28
+ .replace(/^-|-$/g, "");
29
+ const tokens = normalized.split("-").filter(Boolean);
30
+ const title = (value) => value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
31
+ if (/^\d/.test(tokens[0] ?? "")) {
32
+ const version = [];
33
+ while (tokens.length > 0 && /^\d+(?:\.\d+)?$/.test(tokens[0]))
34
+ version.push(tokens.shift());
35
+ const family = tokens.shift();
36
+ if (family)
37
+ return `${title(family)} ${version.join(".")}`.trim();
38
+ }
39
+ else {
40
+ const family = [];
41
+ while (tokens.length > 0 && !/^\d+(?:\.\d+)?$/.test(tokens[0]))
42
+ family.push(tokens.shift());
43
+ const version = [];
44
+ while (tokens.length > 0 && /^\d+(?:\.\d+)?$/.test(tokens[0]))
45
+ version.push(tokens.shift());
46
+ if (family.length > 0) {
47
+ return `${family.map(title).join(" ")}${version.length > 0 ? ` ${version.join(".")}` : ""}`;
48
+ }
49
+ }
50
+ if (normalized)
51
+ return normalized;
52
+ }
30
53
  return "Claude";
31
54
  }
32
55
  function pctLeft(usedPercentage) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-discord-presence",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Cross-platform Discord Rich Presence for Claude Code Desktop and CLI with live models, limits, costs, tools, agents, statusline, and secure SSH remotes.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -23,6 +23,7 @@ const events = [
23
23
  "PostToolUse",
24
24
  "PostToolUseFailure",
25
25
  "Stop",
26
+ "StopFailure",
26
27
  "SubagentStart",
27
28
  "SubagentStop",
28
29
  "Notification",
@@ -82,7 +83,7 @@ await copyFile(join(scriptsDir, "hook.mjs"), hookTarget);
82
83
  await copyFile(join(scriptsDir, "statusline.mjs"), statuslineTarget);
83
84
  await writeFile(
84
85
  join(installDir, "config.json"),
85
- `${JSON.stringify({ port, remote: false, installerVersion: 1 }, null, 2)}\n`,
86
+ `${JSON.stringify({ port, remote: false, installerVersion: 2 }, null, 2)}\n`,
86
87
  { mode: 0o600 },
87
88
  );
88
89
 
@@ -21,7 +21,7 @@ const hookTarget = join(installDir, "hook.mjs");
21
21
  const statuslineTarget = join(installDir, "statusline.mjs");
22
22
  const events = [
23
23
  "SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PostToolUseFailure",
24
- "Stop", "SubagentStart", "SubagentStop", "Notification", "SessionEnd",
24
+ "Stop", "StopFailure", "SubagentStart", "SubagentStop", "Notification", "SessionEnd",
25
25
  ];
26
26
  const matcherEvents = new Set(["PreToolUse", "PostToolUse", "PostToolUseFailure"]);
27
27
  const quote = (value) => `"${value.replaceAll("\\", "/").replaceAll('"', '\\"')}"`;
@@ -60,7 +60,7 @@ await writeFile(hookTarget, hookSource, { mode: 0o700 });
60
60
  await writeFile(statuslineTarget, statuslineSource, { mode: 0o700 });
61
61
  await writeFile(
62
62
  join(installDir, "config.json"),
63
- `${JSON.stringify({ port, remote: true, installerVersion: 1 }, null, 2)}\n`,
63
+ `${JSON.stringify({ port, remote: true, installerVersion: 2 }, null, 2)}\n`,
64
64
  { mode: 0o600 },
65
65
  );
66
66