shraga 0.1.61 → 0.1.62

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.61",
3
+ "version": "0.1.62",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,10 +1,14 @@
1
1
  // Claude Code subscription usage, read from Anthropic's OAuth usage endpoint using the credentials
2
2
  // the Claude Code CLI maintains on this box. Fails CLOSED: every error path returns null, and the
3
3
  // caller renders nothing — a broken or zeroed gauge is worse than no gauge.
4
+ import { execFile } from 'node:child_process';
4
5
  import { readFile } from 'node:fs/promises';
5
6
  import { homedir } from 'node:os';
6
7
  import path from 'node:path';
7
8
  import { createRequire } from 'node:module';
9
+ import { promisify } from 'node:util';
10
+
11
+ const execFileAsync = promisify(execFile);
8
12
 
9
13
  const TAG = '[claude-usage]';
10
14
 
@@ -39,6 +43,33 @@ export class ClaudeUsageOptions {
39
43
  * on the same terms, so a 403/API-key box does not retry on every client poll. */
40
44
  ttlMs = 60_000;
41
45
  timeoutMs = 8_000;
46
+ /** macOS stores Claude Code's OAuth credentials in the login Keychain and writes NO credentials
47
+ * file, so on darwin an absent file is not proof of an API-key deployment — we look there second.
48
+ * Linux keeps the file as the only source; we never shell out there. */
49
+ platform: string = process.platform;
50
+ keychainService = 'Claude Code-credentials';
51
+ /** A locked keychain can block (or prompt) indefinitely — never let that stall a client poll. */
52
+ keychainTimeoutMs = 3_000;
53
+ /** Seam: hands back the raw secret string, or null on ANY failure. Tests inject here so the suite
54
+ * never shells out to the real `security` binary. */
55
+ readKeychain: (options: ClaudeUsageOptions) => Promise<string | null> = readKeychainSecret;
56
+ }
57
+
58
+ /** `security` writes the secret to stdout with -w. The value is never logged or returned upward;
59
+ * only the parsed, scope-gated accessToken leaves this module. */
60
+ async function readKeychainSecret(options: ClaudeUsageOptions): Promise<string | null> {
61
+ try {
62
+ const { stdout } = await execFileAsync('security', ['find-generic-password', '-s', options.keychainService, '-w'], {
63
+ timeout: options.keychainTimeoutMs,
64
+ killSignal: 'SIGKILL',
65
+ encoding: 'utf8',
66
+ });
67
+ return stdout.trim() || null;
68
+ } catch (err) {
69
+ // Missing binary, non-zero exit (no such item), locked keychain, timeout — all the same answer.
70
+ console.debug(`${TAG} keychain lookup failed: ${(err as Error).message}`);
71
+ return null;
72
+ }
42
73
  }
43
74
 
44
75
  export class ClaudeUsageReader {
@@ -67,8 +98,6 @@ export class ClaudeUsageReader {
67
98
  }
68
99
 
69
100
  private async fetchUsage(): Promise<ClaudeUsage | null> {
70
- // Re-read the file EVERY time: accessToken lives ~8h and the Claude Code SDK rewrites this file
71
- // when it refreshes. A token cached in memory goes stale; a fresh file read never does.
72
101
  const creds = await this.readCredentials();
73
102
  if (!creds) return null;
74
103
 
@@ -101,24 +130,57 @@ export class ClaudeUsageReader {
101
130
  }
102
131
  }
103
132
 
133
+ /** Re-read per poll from whichever source holds them — the token lives ~8h and Claude Code
134
+ * refreshes it in place, so nothing here may be cached in a field. File first, Keychain second. */
104
135
  private async readCredentials(): Promise<{ accessToken: string; subscriptionType: string | null } | null> {
136
+ let fileAbsent = false;
105
137
  try {
106
- const raw = await readFile(this.options.credentialsPath, 'utf8');
107
- const oauth = JSON.parse(raw)?.claudeAiOauth;
108
- if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') return null;
109
- if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes(REQUIRED_SCOPE)) {
110
- console.warn(`${TAG} oauth token lacks the ${REQUIRED_SCOPE} scope; hiding widget`);
111
- return null;
112
- }
113
- return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null };
138
+ return parseCredentials(await readFile(this.options.credentialsPath, 'utf8'), 'credentials file');
114
139
  } catch (err) {
115
- // ENOENT is the ordinary API-key deployment, not a fault — keep it quiet at debug level.
140
+ // ENOENT is the ordinary API-key deployment on Linux, not a fault — keep it quiet at debug level.
116
141
  const code = (err as NodeJS.ErrnoException).code;
117
- if (code === 'ENOENT') console.debug(`${TAG} no credentials file at ${this.options.credentialsPath} (API-key deployment)`);
118
- else console.warn(`${TAG} could not read credentials:`, (err as Error).message);
142
+ if (code === 'ENOENT') {
143
+ fileAbsent = true;
144
+ console.debug(`${TAG} no credentials file at ${this.options.credentialsPath}`);
145
+ } else {
146
+ console.warn(`${TAG} could not read credentials:`, (err as Error).message);
147
+ }
148
+ }
149
+
150
+ if (!fileAbsent || this.options.platform !== 'darwin') return null;
151
+ // The seam is contractually fail-closed, but readCredentials runs OUTSIDE fetchUsage's try —
152
+ // an unexpected rejection here would surface as a 500 rather than a hidden widget.
153
+ const secret = await this.options.readKeychain(this.options).catch((err: Error) => {
154
+ console.warn(`${TAG} keychain reader threw:`, err.message);
155
+ return null;
156
+ });
157
+ if (!secret) {
158
+ console.debug(`${TAG} keychain held no "${this.options.keychainService}" secret (API-key deployment)`);
119
159
  return null;
120
160
  }
161
+ return parseCredentials(secret, 'keychain');
162
+ }
163
+ }
164
+
165
+ /** The ONE gate, shared by both sources: a usable accessToken carrying user:profile. Never throws —
166
+ * every rejection is a logged null, i.e. hidden widget and zero upstream calls. */
167
+ function parseCredentials(raw: string, source: string): { accessToken: string; subscriptionType: string | null } | null {
168
+ let oauth: any;
169
+ try {
170
+ oauth = JSON.parse(raw)?.claudeAiOauth;
171
+ } catch {
172
+ console.warn(`${TAG} ${source} did not hold valid JSON; hiding widget`);
173
+ return null;
174
+ }
175
+ if (!oauth?.accessToken || typeof oauth.accessToken !== 'string') {
176
+ console.debug(`${TAG} ${source} carried no claudeAiOauth accessToken; hiding widget`);
177
+ return null;
178
+ }
179
+ if (!Array.isArray(oauth.scopes) || !oauth.scopes.includes(REQUIRED_SCOPE)) {
180
+ console.warn(`${TAG} oauth token lacks the ${REQUIRED_SCOPE} scope; hiding widget`);
181
+ return null;
121
182
  }
183
+ return { accessToken: oauth.accessToken, subscriptionType: oauth.subscriptionType ?? null };
122
184
  }
123
185
 
124
186
  function toLimit(l: any): ClaudeUsageLimit | null {