shraga 0.1.125 → 0.1.127

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.
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-C3DF2H1h.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-DRTvNKXl.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-_XEpjgJO.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.125",
3
+ "version": "0.1.127",
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",
@@ -2,9 +2,10 @@ import { useCallback, useEffect, useState } from 'react';
2
2
  import { Button } from './ui/button';
3
3
  import { Input } from './ui/input';
4
4
  import { api } from '@/lib/api';
5
+ import { UsageMetric, type Usage } from './MachineStats';
5
6
 
6
- interface AccountStatus { connected: boolean; account: string | null; subscriptionType: string | null }
7
- interface Accounts { me: AccountStatus | null; global?: AccountStatus }
7
+ interface AccountStatus { connected: boolean; account: string | null; subscriptionType: string | null; useShared?: boolean }
8
+ interface Accounts { me: AccountStatus | null; global?: AccountStatus; usage?: { me: Usage | null; global: Usage | null } }
8
9
  type Target = 'me' | 'global';
9
10
 
10
11
  /** Connect / replace / disconnect a Claude Code subscription login from the browser (no SSH). */
@@ -44,21 +45,38 @@ export function ClaudeAccountSection({ getToken }: { getToken: () => Promise<str
44
45
  await api(`/api/claude-account/${target}`, getToken, { method: 'DELETE' });
45
46
  refresh();
46
47
  });
48
+ const useShared = (on: boolean) => act(async () => {
49
+ await api('/api/claude-account/me/use-shared', getToken, { method: 'PUT', body: JSON.stringify({ on }) });
50
+ refresh();
51
+ });
47
52
 
48
- const row = (target: Target, title: string, s: AccountStatus | null) => (
53
+ const active = (target: Target) => target === 'global'
54
+ ? !accounts?.me?.connected || !!accounts.me.useShared
55
+ : !!accounts?.me?.connected && !accounts.me.useShared;
56
+
57
+ const row = (target: Target, title: string, s: AccountStatus | null, canManage = true) => (
49
58
  <div className="flex items-center justify-between gap-2 flex-wrap">
50
59
  <div className="text-sm min-w-0">
51
- <div className="font-medium">{title}</div>
60
+ <div className="font-medium flex items-center gap-2">
61
+ {title}
62
+ {active(target) && <span className="text-[10px] uppercase tracking-wide text-emerald-500">active</span>}
63
+ <UsageMetric usage={accounts?.usage?.[target] ?? null} />
64
+ </div>
52
65
  <div className="text-xs text-muted-foreground truncate">
53
- {s?.connected ? `${s.account ?? 'Connected'}${s.subscriptionType ? ` · ${s.subscriptionType}` : ''}` : target === 'me' ? 'Not connected — using the shared subscription' : 'Not connected'}
66
+ {s?.connected ? `${s.account ?? 'Connected'}${s.subscriptionType ? ` · ${s.subscriptionType}` : ''}` : target === 'me' ? 'Not connected — using the shared subscription' : canManage ? 'Not connected' : ''}
54
67
  </div>
55
68
  </div>
56
- <div className="flex gap-2">
57
- <Button size="sm" variant="outline" disabled={busy} onClick={() => connect(target)}>{s?.connected ? 'Replace' : 'Connect'}</Button>
58
- {target === 'me' && s?.connected && (
59
- <Button size="sm" variant="ghost" disabled={busy} onClick={() => disconnect(target)}>Use shared</Button>
60
- )}
61
- </div>
69
+ {canManage && (
70
+ <div className="flex gap-2">
71
+ <Button size="sm" variant="outline" disabled={busy} onClick={() => connect(target)}>{s?.connected ? 'Replace' : 'Connect'}</Button>
72
+ {target === 'me' && s?.connected && (
73
+ <>
74
+ <Button size="sm" variant="ghost" disabled={busy} onClick={() => useShared(!s.useShared)}>{s.useShared ? 'Use mine' : 'Use shared'}</Button>
75
+ <Button size="sm" variant="ghost" disabled={busy} onClick={() => disconnect(target)}>Sign out</Button>
76
+ </>
77
+ )}
78
+ </div>
79
+ )}
62
80
  </div>
63
81
  );
64
82
 
@@ -69,7 +87,8 @@ export function ClaudeAccountSection({ getToken }: { getToken: () => Promise<str
69
87
  {accounts.me ? row('me', 'Your subscription', accounts.me) : (
70
88
  <p className="text-xs text-muted-foreground">Personal subscription unavailable: you are not a contact on this agent.</p>
71
89
  )}
72
- {accounts.global && row('global', 'Shared subscription', accounts.global)}
90
+ {accounts.global ? row('global', 'Shared subscription', accounts.global)
91
+ : accounts.usage?.global && row('global', 'Shared subscription', null, false)}
73
92
  {pending && (
74
93
  <div className="rounded-md bg-muted/50 px-3 py-2 space-y-2 text-xs">
75
94
  <p>
@@ -295,7 +295,14 @@ app.get('/api/claude-account', requireAuth, claudeLoginRoute(async (_req, res, u
295
295
  return null;
296
296
  });
297
297
  const global = user?.isOwner ? await claudeLogin.status(claudeLogin.resolve('global', user)) : undefined;
298
- res.json({ me, global });
298
+ // Cached readings only (the reader dedupes + TTLs upstream) — shared usage is visible to every member.
299
+ const meDir = me?.connected ? claudeLogin.resolve('me', user).dir : null;
300
+ const [meUsage, globalUsage] = await Promise.all([meDir ? claudeUsageFor(meDir).get() : null, claudeUsageFor(null).get()]);
301
+ res.json({ me, global, usage: { me: meUsage, global: globalUsage } });
302
+ }));
303
+ app.put('/api/claude-account/:target/use-shared', requireAuth, claudeLoginRoute(async (req, res, user) => {
304
+ await claudeLogin.setUseShared(claudeLogin.resolve(String(req.params.target), user), !!req.body?.on);
305
+ res.json({ ok: true });
299
306
  }));
300
307
  app.post('/api/claude-account/:target/login', requireAuth, claudeLoginRoute(async (req, res, user) => {
301
308
  res.json({ url: await claudeLogin.start(claudeLogin.resolve(String(req.params.target), user)) });
@@ -4,7 +4,7 @@
4
4
  * `CLAUDE_CONFIG_DIR=<dir> claude auth login`). Anything else → null → the default account, unchanged.
5
5
  * The folder is data-sync ignored (see DataSync.GITIGNORE_ENTRIES) and hidden from the workspace UI (dotdir).
6
6
  */
7
- import { statSync } from 'node:fs';
7
+ import { existsSync, statSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import * as contacts from './contacts.ts';
10
10
  import { WORKSPACE_DIR } from './workspace.ts';
@@ -19,7 +19,13 @@ export function claudeAccountDir(
19
19
  const contact = email ? find(email) : null;
20
20
  if (!contact?.id) return null;
21
21
  const dir = path.join(workspaceDir, 'users', contact.id, '.claude');
22
- try { return statSync(dir).isDirectory() ? dir : null; } catch { return null; }
22
+ try { return statSync(dir).isDirectory() && !usesShared(dir) ? dir : null; } catch { return null; }
23
+ }
24
+
25
+ /** Marker inside a personal login dir: keep the credentials but route runs to the shared login (hot-switch). */
26
+ export const USE_SHARED_MARKER = '.shraga-use-shared';
27
+ export function usesShared(dir: string): boolean {
28
+ return existsSync(path.join(dir, USE_SHARED_MARKER));
23
29
  }
24
30
 
25
31
  /** The login a run on `dir` (null = the box default) is signed in as, for provenance. Never a token. */
@@ -14,7 +14,7 @@ import { createRequire } from 'node:module';
14
14
  import { homedir, tmpdir } from 'node:os';
15
15
  import path from 'node:path';
16
16
  import * as contacts from './contacts.ts';
17
- import { applyClaudeAccount } from './claude-account.ts';
17
+ import { applyClaudeAccount, usesShared, USE_SHARED_MARKER } from './claude-account.ts';
18
18
  import { WORKSPACE_DIR } from './workspace.ts';
19
19
 
20
20
  const TAG = '[claude-login]';
@@ -32,7 +32,7 @@ export interface ClaudeTarget {
32
32
  explicitDir: boolean;
33
33
  }
34
34
 
35
- export interface ClaudeAccountStatus { connected: boolean; account: string | null; subscriptionType: string | null }
35
+ export interface ClaudeAccountStatus { connected: boolean; account: string | null; subscriptionType: string | null; useShared?: boolean }
36
36
 
37
37
  export class ClaudeLoginError extends Error {
38
38
  constructor(public status: number, message: string) { super(message); }
@@ -85,7 +85,8 @@ export class ClaudeLogin {
85
85
  try {
86
86
  const out = await this.run(['auth', 'status', '--json'], target.explicitDir ? target.dir : null);
87
87
  const s = JSON.parse(out);
88
- return { connected: !!s.loggedIn, account: s.email ?? null, subscriptionType: s.subscriptionType ?? null };
88
+ const useShared = target.name === 'me' && usesShared(target.dir);
89
+ return { connected: !!s.loggedIn, account: s.email ?? null, subscriptionType: s.subscriptionType ?? null, ...(useShared && { useShared }) };
89
90
  } catch (err) {
90
91
  console.warn(`${TAG} status failed for ${target.key}:`, (err as Error).message);
91
92
  return none;
@@ -97,7 +98,10 @@ export class ClaudeLogin {
97
98
  this.cancel(target.key);
98
99
  const loginDir = this.options.swap ? await mkdtemp(path.join(tmpdir(), 'shraga-claude-login-')) : target.dir;
99
100
  if (!this.options.swap) await mkdir(loginDir, { recursive: true, mode: 0o700 });
100
- const child = spawn(this.options.cliPath, ['auth', 'login', '--claudeai'], { env: this.env(loginDir, true), stdio: ['pipe', 'pipe', 'pipe'] });
101
+ // In-place (darwin): same dir rule as status()/runs. macOS keys the Keychain item by CLAUDE_CONFIG_DIR, so
102
+ // logging an implicit global login in with CLAUDE_CONFIG_DIR=~/.claude saved it under a suffixed item nobody reads.
103
+ const envDir = this.options.swap || target.explicitDir ? loginDir : null;
104
+ const child = spawn(this.options.cliPath, ['auth', 'login', '--claudeai'], { env: this.env(envDir, true), stdio: ['pipe', 'pipe', 'pipe'] });
101
105
  let out = '';
102
106
  const onData = (b: Buffer) => { out += b.toString(); };
103
107
  child.stdout!.on('data', onData);
@@ -142,10 +146,20 @@ export class ClaudeLogin {
142
146
  } finally {
143
147
  if (this.options.swap) await rm(p.loginDir, { recursive: true, force: true });
144
148
  }
149
+ if (target.name === 'me') await rm(path.join(target.dir, USE_SHARED_MARKER), { force: true }); // (re)connecting = use it
145
150
  this.options.onChange(target);
146
151
  return this.status(target);
147
152
  }
148
153
 
154
+ /** `me` only: hot-switch between the personal login and the shared one, keeping the credentials. */
155
+ async setUseShared(target: ClaudeTarget, on: boolean): Promise<void> {
156
+ if (target.name !== 'me') throw new ClaudeLoginError(400, 'Only a personal login can be switched');
157
+ if (!(await exists(target.dir))) throw new ClaudeLoginError(409, 'No personal login to switch');
158
+ const marker = path.join(target.dir, USE_SHARED_MARKER);
159
+ // No onChange: the login itself is unchanged, and each dir's usage cache stays valid.
160
+ if (on) await writeFile(marker, ''); else await rm(marker, { force: true });
161
+ }
162
+
149
163
  /** `me`: logout + remove the dir → runs fall back to the shared subscription. `global`: logout. */
150
164
  async disconnect(target: ClaudeTarget): Promise<void> {
151
165
  this.cancel(target.key);