@karpeleslab/teamclaude 1.0.6 → 1.0.7

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/src/alias.js ADDED
@@ -0,0 +1,123 @@
1
+ // `claude` shell alias — print or install/uninstall.
2
+ //
3
+ // The alias simply routes plain `claude` through `teamclaude run`, which itself
4
+ // probes the proxy and falls back to launching claude directly when it's down.
5
+ // So the alias stays a dumb passthrough and all the smarts live in `run`.
6
+ //
7
+ // This only affects interactive shells (aliases aren't seen by editors/scripts
8
+ // that exec `claude` themselves). It's intentionally lighter than a PATH shim:
9
+ // no binary shadowing, one line per rc, trivially reversible.
10
+
11
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
12
+ import { join, dirname } from 'node:path';
13
+ import { homedir } from 'node:os';
14
+
15
+ const MARKER = '# teamclaude alias';
16
+
17
+ /** Basename of the user's login shell, e.g. "zsh". Defaults to bash. */
18
+ export function detectShell() {
19
+ return (process.env.SHELL || '').split('/').pop() || 'bash';
20
+ }
21
+
22
+ /** Whether a bare command resolves on the current $PATH. */
23
+ function commandOnPath(cmd) {
24
+ for (const dir of (process.env.PATH || '').split(':')) {
25
+ if (dir && existsSync(join(dir, cmd))) return true;
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * How the alias should invoke teamclaude. Prefer the bare `teamclaude` when it's
32
+ * on $PATH; otherwise embed the absolute path to this CLI (quoted) so the alias
33
+ * still works when teamclaude isn't installed on PATH — e.g. run from a clone.
34
+ */
35
+ export function teamclaudeRef() {
36
+ if (commandOnPath('teamclaude')) return 'teamclaude';
37
+ const entry = process.argv[1];
38
+ if (!entry) return 'teamclaude';
39
+ let abs;
40
+ try { abs = realpathSync(entry); } catch { abs = entry; }
41
+ return `"${abs}"`;
42
+ }
43
+
44
+ /** The alias definition for a given shell family. */
45
+ export function aliasLine(shell = detectShell(), ref = teamclaudeRef()) {
46
+ const body = `${ref} run --`;
47
+ if (shell === 'fish') return `alias claude '${body}'`;
48
+ return `alias claude='${body}'`;
49
+ }
50
+
51
+ /** The rc file an alias for this shell should live in. */
52
+ export function rcPathForShell(shell = detectShell()) {
53
+ const home = homedir();
54
+ switch (shell) {
55
+ case 'zsh': return join(home, '.zshrc');
56
+ case 'sh': return join(home, '.profile');
57
+ case 'fish': {
58
+ const cfg = process.env.XDG_CONFIG_HOME || join(home, '.config');
59
+ return join(cfg, 'fish', 'conf.d', 'teamclaude.fish');
60
+ }
61
+ case 'bash':
62
+ default: return join(home, '.bashrc');
63
+ }
64
+ }
65
+
66
+ export function printAlias({ shell = detectShell() } = {}) {
67
+ const line = aliasLine(shell);
68
+ console.log('# Route plain `claude` through the proxy (when it is running; direct otherwise).');
69
+ console.log('# Add this to your shell config:');
70
+ console.log('');
71
+ console.log(` ${line}`);
72
+ console.log('');
73
+ console.log(`# Or install it automatically: teamclaude alias --install`);
74
+ console.log(`# → writes to ${rcPathForShell(shell)} (override with --shell <bash|zsh|fish|sh>)`);
75
+ }
76
+
77
+ export function installAlias({ shell = detectShell(), rcPath = rcPathForShell(shell) } = {}) {
78
+ const line = aliasLine(shell);
79
+ mkdirSync(dirname(rcPath), { recursive: true });
80
+ let text = existsSync(rcPath) ? readFileSync(rcPath, 'utf8') : '';
81
+
82
+ if (text.includes(line)) {
83
+ console.log(`Alias already present in ${rcPath}`);
84
+ return;
85
+ }
86
+ if (text && !text.endsWith('\n')) text += '\n';
87
+ text += `${MARKER}\n${line}\n`;
88
+ writeFileSync(rcPath, text);
89
+ console.log(`Installed alias in ${rcPath}`);
90
+ console.log('Reload your shell (or open a new terminal) to use it.');
91
+ }
92
+
93
+ export function uninstallAlias({ shell = detectShell(), rcPath = rcPathForShell(shell) } = {}) {
94
+ if (!existsSync(rcPath)) {
95
+ console.log(`Nothing to remove (${rcPath} does not exist)`);
96
+ return;
97
+ }
98
+ const text = readFileSync(rcPath, 'utf8');
99
+ // Strip our marked block: the marker comment + the single line after it.
100
+ // Matching by marker (not by exact alias text) makes this robust even if the
101
+ // embedded teamclaude path differs from what's computed now.
102
+ const blockRe = new RegExp(`\\n?${escapeRe(MARKER)}\\n[^\\n]*\\n?`, 'g');
103
+ let cleaned = text.replace(blockRe, '\n');
104
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
105
+
106
+ if (cleaned === text) {
107
+ console.log(`Alias not found in ${rcPath}`);
108
+ return;
109
+ }
110
+
111
+ // For the dedicated fish drop-file, remove it entirely if now empty.
112
+ if (rcPath.endsWith('teamclaude.fish') && cleaned.trim() === '') {
113
+ rmSync(rcPath);
114
+ console.log(`Removed ${rcPath}`);
115
+ return;
116
+ }
117
+ writeFileSync(rcPath, cleaned);
118
+ console.log(`Removed alias from ${rcPath}`);
119
+ }
120
+
121
+ function escapeRe(s) {
122
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
123
+ }
package/src/config.js CHANGED
@@ -9,6 +9,32 @@ export function getConfigPath() {
9
9
  return join(configDir, 'teamclaude.json');
10
10
  }
11
11
 
12
+ /**
13
+ * Path to the runtime state file (a sibling of the config). This holds volatile
14
+ * data learned at runtime — e.g. quota utilization observed passively from
15
+ * traffic — kept out of the hand-editable config so config stays clean and
16
+ * isn't rewritten on every state save.
17
+ */
18
+ export function getStatePath() {
19
+ const cfg = getConfigPath();
20
+ return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.state.json') : cfg + '.state';
21
+ }
22
+
23
+ export async function loadState() {
24
+ try {
25
+ return JSON.parse(await readFile(getStatePath(), 'utf-8'));
26
+ } catch (err) {
27
+ if (err.code === 'ENOENT') return null;
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ export async function saveState(state) {
33
+ const path = getStatePath();
34
+ await mkdir(dirname(path), { recursive: true });
35
+ await writeFile(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
36
+ }
37
+
12
38
  export function createDefaultConfig() {
13
39
  return {
14
40
  proxy: {
@@ -0,0 +1,65 @@
1
+ // Account identity helpers.
2
+ //
3
+ // An OAuth account is identified by its Anthropic account UUID (the *person*)
4
+ // plus the organization it is scoped to. The same email/person can belong to
5
+ // multiple organizations — e.g. a corporate Pro org and a personal Max org —
6
+ // each with its own OAuth token and quota. The org must therefore be part of
7
+ // the identity; otherwise multi-org logins overwrite each other, removals match
8
+ // the wrong entry, and token rotation persists onto the wrong account.
9
+ //
10
+ // The org discriminator prefers the org UUID but falls back to the org name
11
+ // (the profile endpoint has always returned a name), so identity still works on
12
+ // entries created before org UUIDs were stored.
13
+
14
+ /** Stable org discriminator for an account record: org UUID, else org name, else null. */
15
+ export function orgKey(acct) {
16
+ return acct?.orgUuid || acct?.orgName || null;
17
+ }
18
+
19
+ /**
20
+ * Whether two account records refer to the same account+org.
21
+ *
22
+ * - Both have an accountUuid: it must match. If both org keys are known they
23
+ * must also match; but if either side's org is still unknown we treat them as
24
+ * the same. This lets a freshly-profiled login backfill a legacy entry (which
25
+ * has no stored org) instead of creating a duplicate. Once both sides carry an
26
+ * org key, a *different* org is correctly seen as a distinct account.
27
+ * - Otherwise (API-key accounts, or no UUID yet): fall back to matching by name.
28
+ */
29
+ export function sameIdentity(a, b) {
30
+ if (a?.accountUuid && b?.accountUuid) {
31
+ if (a.accountUuid !== b.accountUuid) return false;
32
+ const ka = orgKey(a);
33
+ const kb = orgKey(b);
34
+ if (ka && kb) return ka === kb;
35
+ return true;
36
+ }
37
+ return a?.name === b?.name;
38
+ }
39
+
40
+ /** The email portion of a display name, stripping any " (org)" suffix. */
41
+ export function emailOf(acct) {
42
+ return (acct?.name || '').replace(/ \(.*\)$/, '');
43
+ }
44
+
45
+ /**
46
+ * Find accounts matching a name-or-email query, optionally narrowed by org.
47
+ *
48
+ * An exact display-name match wins outright. Otherwise match by email (so
49
+ * `remove user@x.com` finds `user@x.com (Acme)`). `orgFilter` narrows by org
50
+ * name or org UUID (prefix allowed). Returns the array of matches; the caller
51
+ * decides what to do with 0, 1, or many.
52
+ */
53
+ export function matchAccounts(accounts, query, orgFilter) {
54
+ let matches = accounts.filter(a => a.name === query);
55
+ if (matches.length === 0) {
56
+ matches = accounts.filter(a => emailOf(a) === query);
57
+ }
58
+ if (orgFilter) {
59
+ matches = matches.filter(a =>
60
+ (a.orgName && a.orgName === orgFilter) ||
61
+ (a.orgUuid && (a.orgUuid === orgFilter || a.orgUuid.startsWith(orgFilter)))
62
+ );
63
+ }
64
+ return matches;
65
+ }