@karpeleslab/teamclaude 1.0.5 → 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/LICENSE +21 -0
- package/README.md +93 -14
- package/package.json +7 -2
- package/src/account-manager.js +219 -12
- package/src/alias.js +123 -0
- package/src/config.js +26 -0
- package/src/identity.js +65 -0
- package/src/index.js +458 -93
- package/src/oauth.js +80 -9
- package/src/server.js +97 -68
- package/src/tui.js +105 -12
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: {
|
package/src/identity.js
ADDED
|
@@ -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
|
+
}
|