claude-multiacc 1.0.0
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/CLAUDE_ACCS_TASK.md +236 -0
- package/README.md +263 -0
- package/bin/claude +359 -0
- package/bin/claude-accounts +1127 -0
- package/bin/cli.mjs +112 -0
- package/install.sh +277 -0
- package/lib/common.sh +200 -0
- package/package.json +55 -0
- package/scripts/postinstall.mjs +32 -0
- package/tests/run-tests.sh +643 -0
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// claude-multiacc npm entrypoint. Thin dispatcher over the bash addon:
|
|
3
|
+
// claude-multiacc [install] run install.sh (install or update, idempotent)
|
|
4
|
+
// claude-multiacc uninstall [--purge-data]
|
|
5
|
+
// claude-multiacc self-update npm i -g claude-multiacc@latest, then re-install
|
|
6
|
+
// claude-multiacc doctor environment + auth summary
|
|
7
|
+
// claude-multiacc <accounts...> passthrough to claude-accounts (list/status/add/...)
|
|
8
|
+
// A globally-installed package auto-runs install.sh on (re)install via postinstall, so
|
|
9
|
+
// `npm i -g claude-multiacc@latest` fully updates the addon. update-notifier nudges
|
|
10
|
+
// users when a newer version is on npm.
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const ROOT = join(__dirname, '..');
|
|
19
|
+
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
|
20
|
+
|
|
21
|
+
function notifyUpdate() {
|
|
22
|
+
// Best-effort, never fatal: update-notifier is optional at runtime.
|
|
23
|
+
try {
|
|
24
|
+
const req = createRequire(import.meta.url);
|
|
25
|
+
const updateNotifier = req('update-notifier');
|
|
26
|
+
updateNotifier({ pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }).notify({ defer: true });
|
|
27
|
+
} catch {
|
|
28
|
+
/* dependency missing or offline — ignore */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sh(cmd, args, opts = {}) {
|
|
33
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit', ...opts });
|
|
34
|
+
return typeof r.status === 'number' ? r.status : 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function runInstall(extra = []) {
|
|
38
|
+
return sh('bash', [join(ROOT, 'install.sh'), ...extra]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runAccounts(args) {
|
|
42
|
+
return sh('bash', [join(ROOT, 'bin', 'claude-accounts'), ...args]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const HELP = `claude-multiacc ${pkg.version}
|
|
46
|
+
|
|
47
|
+
Every 'claude' / 'claude -p' runs under a randomly-picked subscription account with the
|
|
48
|
+
most usage headroom; the account set mirrors to a deploy server. No API keys.
|
|
49
|
+
|
|
50
|
+
USAGE
|
|
51
|
+
claude-multiacc [install] install or update the addon (idempotent)
|
|
52
|
+
claude-multiacc uninstall [--purge-data]
|
|
53
|
+
claude-multiacc self-update update to the latest npm release + re-install
|
|
54
|
+
claude-multiacc doctor show environment + account health
|
|
55
|
+
claude-multiacc <cmd> [args...] run a claude-accounts command, e.g.
|
|
56
|
+
list | status | add <email> | login <acct-NN> |
|
|
57
|
+
remove <acct-NN> | sync | verify | limits | health
|
|
58
|
+
claude-multiacc -v | --version
|
|
59
|
+
claude-multiacc -h | --help
|
|
60
|
+
|
|
61
|
+
INSTALL
|
|
62
|
+
npm install -g claude-multiacc # postinstall wires up the PATH shim + schedules
|
|
63
|
+
# or, no global install:
|
|
64
|
+
npx claude-multiacc # runs the installer once
|
|
65
|
+
|
|
66
|
+
Docs: ${pkg.homepage}`;
|
|
67
|
+
|
|
68
|
+
const argv = process.argv.slice(2);
|
|
69
|
+
const cmd = argv[0];
|
|
70
|
+
let status = 0;
|
|
71
|
+
|
|
72
|
+
switch (cmd) {
|
|
73
|
+
case undefined:
|
|
74
|
+
case 'install':
|
|
75
|
+
case 'update':
|
|
76
|
+
status = runInstall(argv.slice(1));
|
|
77
|
+
break;
|
|
78
|
+
case 'uninstall':
|
|
79
|
+
status = runInstall(['--uninstall', ...argv.slice(1)]);
|
|
80
|
+
break;
|
|
81
|
+
case 'self-update': {
|
|
82
|
+
console.log('Updating claude-multiacc from npm…');
|
|
83
|
+
const up = sh('npm', ['install', '-g', `${pkg.name}@latest`]);
|
|
84
|
+
if (up !== 0) {
|
|
85
|
+
console.error('npm update failed — is this a global npm install? (git checkouts update with: git pull && ./install.sh)');
|
|
86
|
+
status = up;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
// The freshly-installed global package re-runs its own postinstall, so the addon
|
|
90
|
+
// is already reconciled; nothing else to do here.
|
|
91
|
+
console.log('Updated. Open a new shell to pick up any PATH changes.');
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case 'doctor':
|
|
95
|
+
status = runAccounts(['status']);
|
|
96
|
+
break;
|
|
97
|
+
case '-v':
|
|
98
|
+
case '--version':
|
|
99
|
+
console.log(pkg.version);
|
|
100
|
+
break;
|
|
101
|
+
case '-h':
|
|
102
|
+
case '--help':
|
|
103
|
+
console.log(HELP);
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
// Anything else is a claude-accounts subcommand (list/status/add/login/...).
|
|
107
|
+
status = runAccounts(argv);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
notifyUpdate();
|
|
112
|
+
process.exit(status);
|
package/install.sh
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# claude-multiacc installer — macOS + Linux, idempotent, fully reversible.
|
|
3
|
+
# Installs an addon that PATH-shadows `claude`; NEVER touches the Claude Code app,
|
|
4
|
+
# its install dir, or its update machinery.
|
|
5
|
+
#
|
|
6
|
+
# ./install.sh [--server user@host] install or update (data untouched)
|
|
7
|
+
# ./install.sh --uninstall remove addon (keeps ~/.claude-accounts)
|
|
8
|
+
# ./install.sh --uninstall --purge-data remove addon + all account data
|
|
9
|
+
set -u
|
|
10
|
+
|
|
11
|
+
REPO_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|
12
|
+
# shellcheck source=lib/common.sh
|
|
13
|
+
. "$REPO_DIR/lib/common.sh"
|
|
14
|
+
|
|
15
|
+
MARK_BEGIN="# >>> claude-multiacc >>>"
|
|
16
|
+
MARK_END="# <<< claude-multiacc <<<"
|
|
17
|
+
CRON_TAG="# claude-multiacc"
|
|
18
|
+
PLIST_LIMITS="$HOME/Library/LaunchAgents/com.claude-multiacc.limits.plist"
|
|
19
|
+
PLIST_HEALTH="$HOME/Library/LaunchAgents/com.claude-multiacc.health.plist"
|
|
20
|
+
PLIST_UPDATE="$HOME/Library/LaunchAgents/com.claude-multiacc.update.plist"
|
|
21
|
+
PROFILED="/etc/profile.d/claude-multiacc.sh"
|
|
22
|
+
|
|
23
|
+
UNINSTALL=0
|
|
24
|
+
PURGE=0
|
|
25
|
+
SERVER_OVERRIDE=""
|
|
26
|
+
NO_SCHEDULE=0
|
|
27
|
+
while [ $# -gt 0 ]; do
|
|
28
|
+
case "$1" in
|
|
29
|
+
--uninstall) UNINSTALL=1; shift ;;
|
|
30
|
+
--purge-data) PURGE=1; shift ;;
|
|
31
|
+
--server) SERVER_OVERRIDE="${2:?--server requires a value}"; shift 2 ;;
|
|
32
|
+
--no-schedule) NO_SCHEDULE=1; shift ;;
|
|
33
|
+
*) echo "unknown flag: $1" >&2; exit 1 ;;
|
|
34
|
+
esac
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
strip_block() { # remove our marked block from a file (portable, no sed -i)
|
|
38
|
+
local f="$1"
|
|
39
|
+
[ -f "$f" ] || return 0
|
|
40
|
+
# Unmatched begin marker (end line hand-deleted): stripping would eat the rest
|
|
41
|
+
# of the rc file. Leave it alone and say so.
|
|
42
|
+
if grep -qF "$MARK_BEGIN" "$f" && ! grep -qF "$MARK_END" "$f"; then
|
|
43
|
+
echo " WARNING: $f has an unterminated claude-multiacc block — fix it by hand; not touching this file" >&2
|
|
44
|
+
return 1
|
|
45
|
+
fi
|
|
46
|
+
awk -v b="$MARK_BEGIN" -v e="$MARK_END" '
|
|
47
|
+
$0 == b { skip = 1; next }
|
|
48
|
+
$0 == e { skip = 0; next }
|
|
49
|
+
!skip { print }
|
|
50
|
+
' "$f" > "$f.claude-multiacc.tmp" && mv "$f.claude-multiacc.tmp" "$f"
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
path_block_body() {
|
|
54
|
+
# Move (not just add) the shim dir to the front: later rc lines prepend
|
|
55
|
+
# ~/.local/bin, so a plain add-once guard would leave the real binary first.
|
|
56
|
+
printf 'PATH="$(printf %%s ":$PATH:" | sed '\''s|:%s/bin:|:|g; s|^:||; s|:$||'\'')"\n' "$REPO_DIR"
|
|
57
|
+
printf 'export PATH="%s/bin:$PATH"\n' "$REPO_DIR"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
append_block() { # strip then append our PATH block to a file
|
|
61
|
+
local f="$1"
|
|
62
|
+
strip_block "$f" || return 1 # never create a second block next to a broken one
|
|
63
|
+
{
|
|
64
|
+
printf '%s\n' "$MARK_BEGIN"
|
|
65
|
+
path_block_body
|
|
66
|
+
printf '%s\n' "$MARK_END"
|
|
67
|
+
} >> "$f"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
mac_schedule_install() {
|
|
71
|
+
mkdir -p "$HOME/Library/LaunchAgents"
|
|
72
|
+
cat > "$PLIST_LIMITS" <<EOF
|
|
73
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
74
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
75
|
+
<plist version="1.0"><dict>
|
|
76
|
+
<key>Label</key><string>com.claude-multiacc.limits</string>
|
|
77
|
+
<key>ProgramArguments</key><array>
|
|
78
|
+
<string>$REPO_DIR/bin/claude-accounts</string>
|
|
79
|
+
<string>limits</string>
|
|
80
|
+
<string>--quiet</string>
|
|
81
|
+
</array>
|
|
82
|
+
<key>StartInterval</key><integer>60</integer>
|
|
83
|
+
<key>RunAtLoad</key><true/>
|
|
84
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
85
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
86
|
+
</dict></plist>
|
|
87
|
+
EOF
|
|
88
|
+
cat > "$PLIST_HEALTH" <<EOF
|
|
89
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
90
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
91
|
+
<plist version="1.0"><dict>
|
|
92
|
+
<key>Label</key><string>com.claude-multiacc.health</string>
|
|
93
|
+
<key>ProgramArguments</key><array>
|
|
94
|
+
<string>$REPO_DIR/bin/claude-accounts</string>
|
|
95
|
+
<string>health</string>
|
|
96
|
+
</array>
|
|
97
|
+
<key>StartCalendarInterval</key><dict>
|
|
98
|
+
<key>Weekday</key><integer>1</integer>
|
|
99
|
+
<key>Hour</key><integer>9</integer>
|
|
100
|
+
<key>Minute</key><integer>17</integer>
|
|
101
|
+
</dict>
|
|
102
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
103
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
104
|
+
</dict></plist>
|
|
105
|
+
EOF
|
|
106
|
+
# Weekly auto-update (Sunday 04:07). No-op unless this is an npm or git install.
|
|
107
|
+
cat > "$PLIST_UPDATE" <<EOF
|
|
108
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
109
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
110
|
+
<plist version="1.0"><dict>
|
|
111
|
+
<key>Label</key><string>com.claude-multiacc.update</string>
|
|
112
|
+
<key>ProgramArguments</key><array>
|
|
113
|
+
<string>$REPO_DIR/bin/claude-accounts</string>
|
|
114
|
+
<string>self-update</string>
|
|
115
|
+
<string>--quiet</string>
|
|
116
|
+
</array>
|
|
117
|
+
<key>StartCalendarInterval</key><dict>
|
|
118
|
+
<key>Weekday</key><integer>0</integer>
|
|
119
|
+
<key>Hour</key><integer>4</integer>
|
|
120
|
+
<key>Minute</key><integer>7</integer>
|
|
121
|
+
</dict>
|
|
122
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
123
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
124
|
+
</dict></plist>
|
|
125
|
+
EOF
|
|
126
|
+
launchctl unload "$PLIST_LIMITS" 2>/dev/null || true
|
|
127
|
+
launchctl unload "$PLIST_HEALTH" 2>/dev/null || true
|
|
128
|
+
launchctl unload "$PLIST_UPDATE" 2>/dev/null || true
|
|
129
|
+
launchctl load -w "$PLIST_LIMITS" 2>/dev/null || echo " (launchctl load limits agent failed — limits refresh will rely on the shim's opportunistic kick)"
|
|
130
|
+
launchctl load -w "$PLIST_HEALTH" 2>/dev/null || true
|
|
131
|
+
[ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && launchctl load -w "$PLIST_UPDATE" 2>/dev/null || true
|
|
132
|
+
echo " launchd: limits refresh every 60s + weekly health check + weekly auto-update"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
mac_schedule_remove() {
|
|
136
|
+
launchctl unload "$PLIST_LIMITS" 2>/dev/null || true
|
|
137
|
+
launchctl unload "$PLIST_HEALTH" 2>/dev/null || true
|
|
138
|
+
launchctl unload "$PLIST_UPDATE" 2>/dev/null || true
|
|
139
|
+
rm -f "$PLIST_LIMITS" "$PLIST_HEALTH" "$PLIST_UPDATE"
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
linux_schedule_install() {
|
|
143
|
+
local tmp
|
|
144
|
+
tmp="$(mktemp)"
|
|
145
|
+
{ crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true; } > "$tmp"
|
|
146
|
+
{
|
|
147
|
+
echo "* * * * * $REPO_DIR/bin/claude-accounts limits --quiet >/dev/null 2>&1 $CRON_TAG"
|
|
148
|
+
echo "17 9 * * 1 $REPO_DIR/bin/claude-accounts health >/dev/null 2>&1 $CRON_TAG"
|
|
149
|
+
[ "${CLAUDE_MULTIACC_AUTOUPDATE:-1}" = "1" ] && \
|
|
150
|
+
echo "7 4 * * 0 $REPO_DIR/bin/claude-accounts self-update --quiet >/dev/null 2>&1 $CRON_TAG"
|
|
151
|
+
} >> "$tmp"
|
|
152
|
+
crontab "$tmp"
|
|
153
|
+
rm -f "$tmp"
|
|
154
|
+
echo " cron: limits refresh every 60s + weekly health check + weekly auto-update"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
linux_schedule_remove() {
|
|
158
|
+
local tmp
|
|
159
|
+
tmp="$(mktemp)"
|
|
160
|
+
{ crontab -l 2>/dev/null | grep -v "$CRON_TAG" || true; } > "$tmp"
|
|
161
|
+
crontab "$tmp"
|
|
162
|
+
rm -f "$tmp"
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
do_uninstall() {
|
|
166
|
+
echo "claude-multiacc: uninstalling (restoring stock behavior)"
|
|
167
|
+
strip_block "$HOME/.zshenv"
|
|
168
|
+
strip_block "$HOME/.zprofile"
|
|
169
|
+
strip_block "$HOME/.zshrc"
|
|
170
|
+
strip_block "$HOME/.bashrc"
|
|
171
|
+
strip_block "$HOME/.bash_profile"
|
|
172
|
+
strip_block "$HOME/.profile"
|
|
173
|
+
if [ "$(machine_kind)" = "mac" ]; then
|
|
174
|
+
mac_schedule_remove
|
|
175
|
+
else
|
|
176
|
+
linux_schedule_remove
|
|
177
|
+
[ -f "$PROFILED" ] && rm -f "$PROFILED"
|
|
178
|
+
if [ -L /usr/local/bin/claude ] && [ "$(canon_path /usr/local/bin/claude)" = "$(canon_path "$REPO_DIR/bin/claude")" ]; then
|
|
179
|
+
rm -f /usr/local/bin/claude
|
|
180
|
+
echo " removed /usr/local/bin/claude shim symlink"
|
|
181
|
+
fi
|
|
182
|
+
fi
|
|
183
|
+
if [ "$PURGE" = "1" ]; then
|
|
184
|
+
case "$ACC_ROOT" in
|
|
185
|
+
*/.claude-accounts) rm -rf "$ACC_ROOT"; echo " purged $ACC_ROOT" ;;
|
|
186
|
+
*) echo " refusing to purge unusual ACC_ROOT: $ACC_ROOT" >&2 ;;
|
|
187
|
+
esac
|
|
188
|
+
else
|
|
189
|
+
echo " account data kept at $ACC_ROOT (use --purge-data to remove)"
|
|
190
|
+
fi
|
|
191
|
+
echo "uninstall complete — stock claude behavior restored"
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
do_install() {
|
|
195
|
+
local kind
|
|
196
|
+
kind="$(machine_kind)"
|
|
197
|
+
echo "claude-multiacc: installing (mode: $kind, repo: $REPO_DIR)"
|
|
198
|
+
|
|
199
|
+
[ -x "$REPO_DIR/bin/claude" ] || chmod +x "$REPO_DIR/bin/claude" 2>/dev/null || true
|
|
200
|
+
[ -x "$REPO_DIR/bin/claude-accounts" ] || chmod +x "$REPO_DIR/bin/claude-accounts" 2>/dev/null || true
|
|
201
|
+
|
|
202
|
+
local real
|
|
203
|
+
if real="$(find_real_claude "$REPO_DIR/bin/claude")"; then
|
|
204
|
+
echo " real claude binary: $real ($("$real" --version 2>/dev/null | head -1 || echo 'version unknown'))"
|
|
205
|
+
else
|
|
206
|
+
echo " WARNING: no real claude binary found yet — install Claude Code first (https://claude.com/claude-code)" >&2
|
|
207
|
+
fi
|
|
208
|
+
|
|
209
|
+
# Keychain-mode detection: per-dir /login isolation needs file-based credentials.
|
|
210
|
+
if [ "$kind" = "mac" ] && [ ! -f "$HOME/.claude/.credentials.json" ] \
|
|
211
|
+
&& security find-generic-password -s "Claude Code-credentials" >/dev/null 2>&1; then
|
|
212
|
+
echo " WARNING: this Mac stores Claude Code credentials in the Keychain, not files." >&2
|
|
213
|
+
echo " Per-directory logins may not isolate; use token-based accounts (claude-accounts mint/import --token-file)." >&2
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
manifest_init "${SERVER_OVERRIDE:-$DEFAULT_SERVER}"
|
|
217
|
+
if [ -n "$SERVER_OVERRIDE" ]; then
|
|
218
|
+
"$PYBIN" - "$MANIFEST" "$SERVER_OVERRIDE" <<'PYEOF'
|
|
219
|
+
import json, os, sys
|
|
220
|
+
doc = json.load(open(sys.argv[1]))
|
|
221
|
+
doc['server'] = sys.argv[2]
|
|
222
|
+
with open(sys.argv[1] + '.tmp', 'w') as f:
|
|
223
|
+
json.dump(doc, f, indent=2)
|
|
224
|
+
f.write('\n')
|
|
225
|
+
os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
226
|
+
PYEOF
|
|
227
|
+
fi
|
|
228
|
+
echo " account pool: $ACC_ROOT (manifest ready)"
|
|
229
|
+
|
|
230
|
+
if [ "$kind" = "mac" ]; then
|
|
231
|
+
# .zshenv covers non-interactive zsh; the .zshrc block must be LAST so it wins
|
|
232
|
+
# over ~/.local/bin re-prepends done earlier in .zshrc/.zprofile.
|
|
233
|
+
# zsh reads: .zshenv always; .zprofile for login; .zshrc for interactive.
|
|
234
|
+
# The block must end each file that later re-prepends ~/.local/bin.
|
|
235
|
+
touch "$HOME/.zshenv"
|
|
236
|
+
append_block "$HOME/.zshenv"
|
|
237
|
+
touch "$HOME/.zprofile"
|
|
238
|
+
append_block "$HOME/.zprofile"
|
|
239
|
+
touch "$HOME/.zshrc"
|
|
240
|
+
append_block "$HOME/.zshrc"
|
|
241
|
+
[ -f "$HOME/.bash_profile" ] && append_block "$HOME/.bash_profile"
|
|
242
|
+
[ -f "$HOME/.bashrc" ] && append_block "$HOME/.bashrc"
|
|
243
|
+
echo " PATH block: end of ~/.zshenv, ~/.zprofile, ~/.zshrc (+ bash rc files if present)"
|
|
244
|
+
[ "$NO_SCHEDULE" = "1" ] || mac_schedule_install
|
|
245
|
+
else
|
|
246
|
+
touch "$HOME/.bashrc"
|
|
247
|
+
append_block "$HOME/.bashrc"
|
|
248
|
+
if [ -w /etc/profile.d ] 2>/dev/null || [ "$(id -u)" = "0" ]; then
|
|
249
|
+
{
|
|
250
|
+
printf '%s\n' "$MARK_BEGIN"
|
|
251
|
+
path_block_body
|
|
252
|
+
printf '%s\n' "$MARK_END"
|
|
253
|
+
} > "$PROFILED"
|
|
254
|
+
echo " PATH block: ~/.bashrc + $PROFILED"
|
|
255
|
+
fi
|
|
256
|
+
if [ "$(id -u)" = "0" ]; then
|
|
257
|
+
if [ -e /usr/local/bin/claude ] && [ ! -L /usr/local/bin/claude ]; then
|
|
258
|
+
echo " WARNING: /usr/local/bin/claude exists and is a real file — NOT overwriting." >&2
|
|
259
|
+
else
|
|
260
|
+
ln -sfn "$REPO_DIR/bin/claude" /usr/local/bin/claude
|
|
261
|
+
echo " shim: /usr/local/bin/claude -> $REPO_DIR/bin/claude (systemd-PATH compatible)"
|
|
262
|
+
fi
|
|
263
|
+
fi
|
|
264
|
+
[ "$NO_SCHEDULE" = "1" ] || linux_schedule_install
|
|
265
|
+
fi
|
|
266
|
+
|
|
267
|
+
echo
|
|
268
|
+
echo "install complete. Open a new shell (or 'export PATH=\"$REPO_DIR/bin:\$PATH\"'), then:"
|
|
269
|
+
echo " claude-accounts list # see the pool"
|
|
270
|
+
echo " claude-accounts status # auth + limits detail"
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if [ "$UNINSTALL" = "1" ]; then
|
|
274
|
+
do_uninstall
|
|
275
|
+
else
|
|
276
|
+
do_install
|
|
277
|
+
fi
|
package/lib/common.sh
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lib/common.sh — claude-multiacc shared helpers.
|
|
3
|
+
# Sourced by bin/claude-accounts and install.sh. Bash 3.2 compatible (macOS stock).
|
|
4
|
+
# shellcheck disable=SC2034
|
|
5
|
+
|
|
6
|
+
ACC_ROOT="${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}"
|
|
7
|
+
MANIFEST="$ACC_ROOT/accounts.json"
|
|
8
|
+
PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
|
|
9
|
+
DEFAULT_SERVER="root@138.197.36.107"
|
|
10
|
+
DEFAULT_SERVER_ROOT="/root/.claude-accounts"
|
|
11
|
+
DEFAULT_SERVER_REPO="/root/claude-multiacc"
|
|
12
|
+
USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage}"
|
|
13
|
+
|
|
14
|
+
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
|
15
|
+
epoch_now() { date +%s; }
|
|
16
|
+
|
|
17
|
+
log_to() { # log_to <file-in-acc-root> <msg...>
|
|
18
|
+
local f="$1"; shift
|
|
19
|
+
[ -d "$ACC_ROOT" ] || return 0
|
|
20
|
+
printf '%s %s\n' "$(ts_utc)" "$*" >> "$ACC_ROOT/$f" 2>/dev/null || true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
die() { printf 'claude-accounts: error: %s\n' "$*" >&2; exit 1; }
|
|
24
|
+
warn() { printf 'claude-accounts: warning: %s\n' "$*" >&2; }
|
|
25
|
+
|
|
26
|
+
machine_kind() { case "$(uname -s)" in Darwin) echo mac ;; *) echo linux ;; esac; }
|
|
27
|
+
|
|
28
|
+
if [ "$(uname -s)" = "Darwin" ]; then
|
|
29
|
+
file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
|
|
30
|
+
else
|
|
31
|
+
file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
canon_path() { # resolve symlinks (loop-guarded) and normalize; always prints something
|
|
35
|
+
local p="$1" t i=0 d b
|
|
36
|
+
case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
|
|
37
|
+
while [ -L "$p" ] && [ "$i" -lt 40 ]; do
|
|
38
|
+
t="$(readlink "$p")" || break
|
|
39
|
+
case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac
|
|
40
|
+
i=$((i+1))
|
|
41
|
+
done
|
|
42
|
+
d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$p"; return 0; }
|
|
43
|
+
b="$(basename "$p")"
|
|
44
|
+
if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q claude-multiacc-shim; }
|
|
48
|
+
|
|
49
|
+
# Prints an INVOCABLE path to the real claude binary (kept as the symlink path,
|
|
50
|
+
# not the canonical target, so process cmdlines keep matching monitors like
|
|
51
|
+
# `pgrep -f '.local/bin/claude'`). $1 = path of the caller to exclude (optional).
|
|
52
|
+
find_real_claude() {
|
|
53
|
+
local self_c cand c d
|
|
54
|
+
self_c="$(canon_path "${1:-${BASH_SOURCE[0]}}")"
|
|
55
|
+
local oldifs="$IFS"
|
|
56
|
+
IFS=':'; set -f
|
|
57
|
+
# shellcheck disable=SC2086
|
|
58
|
+
set -- $PATH
|
|
59
|
+
IFS="$oldifs"; set +f
|
|
60
|
+
for d in "$@"; do
|
|
61
|
+
[ -n "$d" ] || continue
|
|
62
|
+
cand="$d/claude"
|
|
63
|
+
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
64
|
+
c="$(canon_path "$cand")"
|
|
65
|
+
[ "$c" = "$self_c" ] && continue
|
|
66
|
+
case "$c" in "$ACC_ROOT"/*) continue ;; esac
|
|
67
|
+
is_shim_file "$c" && continue
|
|
68
|
+
printf '%s\n' "$cand"; return 0
|
|
69
|
+
done
|
|
70
|
+
for cand in "$HOME/.local/bin/claude" /usr/local/bin/claude /opt/homebrew/bin/claude /usr/bin/claude; do
|
|
71
|
+
[ -f "$cand" ] && [ -x "$cand" ] || continue
|
|
72
|
+
c="$(canon_path "$cand")"
|
|
73
|
+
[ "$c" = "$self_c" ] && continue
|
|
74
|
+
is_shim_file "$c" && continue
|
|
75
|
+
printf '%s\n' "$cand"; return 0
|
|
76
|
+
done
|
|
77
|
+
return 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
manifest_get() { # manifest_get <dot.path> [default]
|
|
81
|
+
local val
|
|
82
|
+
if [ -f "$MANIFEST" ]; then
|
|
83
|
+
val="$("$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
|
|
84
|
+
import json, sys
|
|
85
|
+
cur = json.load(open(sys.argv[1]))
|
|
86
|
+
for part in sys.argv[2].split('.'):
|
|
87
|
+
if isinstance(cur, dict) and part in cur:
|
|
88
|
+
cur = cur[part]
|
|
89
|
+
else:
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
print(cur if not isinstance(cur, (dict, list)) else json.dumps(cur))
|
|
92
|
+
PYEOF
|
|
93
|
+
)" && { printf '%s\n' "$val"; return 0; }
|
|
94
|
+
fi
|
|
95
|
+
[ $# -ge 2 ] && { printf '%s\n' "$2"; return 0; }
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Account ids are used to build filesystem paths and remote shell words, so they are
|
|
100
|
+
# validated at EVERY boundary — a hand-edited or corrupted manifest must never be able
|
|
101
|
+
# to smuggle `../` (path traversal) or shell metacharacters out of the pool directory.
|
|
102
|
+
valid_acct_id() { case "$1" in acct-[0-9][0-9]) return 0 ;; *) return 1 ;; esac; }
|
|
103
|
+
|
|
104
|
+
account_ids() { # prints VALID manifest account ids, one per line
|
|
105
|
+
[ -f "$MANIFEST" ] || return 0
|
|
106
|
+
"$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
|
|
107
|
+
import json, re, sys
|
|
108
|
+
for a in json.load(open(sys.argv[1])).get('accounts', []):
|
|
109
|
+
if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))):
|
|
110
|
+
print(a['id'])
|
|
111
|
+
PYEOF
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Remote paths and ssh targets come from the manifest and are interpolated into a
|
|
115
|
+
# remote shell command, so they must be strictly shaped — no quotes, spaces, $, ;, etc.
|
|
116
|
+
# Reject-by-character-class (a trailing `*` glob would match metacharacters and defeat
|
|
117
|
+
# the check), then require the leading shape.
|
|
118
|
+
valid_remote_path() {
|
|
119
|
+
case "$1" in
|
|
120
|
+
*[!A-Za-z0-9._/-]*) return 1 ;; # any char outside the safe set => reject
|
|
121
|
+
/?*) return 0 ;; # must be an absolute path
|
|
122
|
+
*) return 1 ;;
|
|
123
|
+
esac
|
|
124
|
+
}
|
|
125
|
+
valid_ssh_target() {
|
|
126
|
+
case "$1" in
|
|
127
|
+
*[!A-Za-z0-9._@-]*) return 1 ;;
|
|
128
|
+
?*) return 0 ;;
|
|
129
|
+
*) return 1 ;;
|
|
130
|
+
esac
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
acct_dir() { printf '%s/%s\n' "$ACC_ROOT" "$1"; }
|
|
134
|
+
|
|
135
|
+
has_local_auth() { # $1 = acct dir
|
|
136
|
+
[ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
manifest_init() { # manifest_init [server-target]
|
|
140
|
+
mkdir -p "$ACC_ROOT/tmp"
|
|
141
|
+
chmod 700 "$ACC_ROOT"
|
|
142
|
+
[ -f "$MANIFEST" ] && return 0
|
|
143
|
+
"$PYBIN" - "$MANIFEST" "${1:-$DEFAULT_SERVER}" "$DEFAULT_SERVER_ROOT" "$DEFAULT_SERVER_REPO" <<'PYEOF'
|
|
144
|
+
import json, sys
|
|
145
|
+
doc = {
|
|
146
|
+
"version": 1,
|
|
147
|
+
"server": sys.argv[2],
|
|
148
|
+
"server_root": sys.argv[3],
|
|
149
|
+
"server_repo": sys.argv[4],
|
|
150
|
+
"threshold": 90,
|
|
151
|
+
"accounts": [],
|
|
152
|
+
}
|
|
153
|
+
import os
|
|
154
|
+
with open(sys.argv[1] + '.tmp', 'w') as f:
|
|
155
|
+
json.dump(doc, f, indent=2)
|
|
156
|
+
f.write('\n')
|
|
157
|
+
os.replace(sys.argv[1] + '.tmp', sys.argv[1])
|
|
158
|
+
PYEOF
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Seed an account config dir so headless runs never prompt: stripped .claude.json,
|
|
162
|
+
# copied settings.json, shared projects/ symlink (shared history => --continue/--resume
|
|
163
|
+
# work regardless of which account was picked).
|
|
164
|
+
seed_account_dir() { # $1 = acct dir
|
|
165
|
+
local d="$1"
|
|
166
|
+
mkdir -p "$d"
|
|
167
|
+
chmod 700 "$d"
|
|
168
|
+
if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
|
|
169
|
+
"$PYBIN" - "$HOME/.claude.json" "$d/.claude.json" <<'PYEOF' 2>/dev/null || true
|
|
170
|
+
import json, sys
|
|
171
|
+
try:
|
|
172
|
+
doc = json.load(open(sys.argv[1]))
|
|
173
|
+
except Exception:
|
|
174
|
+
sys.exit(0)
|
|
175
|
+
for k in ('oauthAccount', 'userID'):
|
|
176
|
+
doc.pop(k, None)
|
|
177
|
+
import os
|
|
178
|
+
with open(sys.argv[2] + '.tmp', 'w') as f:
|
|
179
|
+
json.dump(doc, f)
|
|
180
|
+
os.replace(sys.argv[2] + '.tmp', sys.argv[2])
|
|
181
|
+
PYEOF
|
|
182
|
+
fi
|
|
183
|
+
if [ ! -f "$d/settings.json" ] && [ -f "$HOME/.claude/settings.json" ]; then
|
|
184
|
+
cp "$HOME/.claude/settings.json" "$d/settings.json" 2>/dev/null || true
|
|
185
|
+
fi
|
|
186
|
+
if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
|
|
187
|
+
ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
|
|
188
|
+
fi
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
rotate_log() { # keep logs bounded: rotate_log <file-in-acc-root> (keeps last ~256KB)
|
|
192
|
+
local f="$ACC_ROOT/$1"
|
|
193
|
+
[ -f "$f" ] || return 0
|
|
194
|
+
local sz
|
|
195
|
+
if [ "$(uname -s)" = "Darwin" ]; then sz="$(stat -f %z "$f" 2>/dev/null || echo 0)"
|
|
196
|
+
else sz="$(stat -c %s "$f" 2>/dev/null || echo 0)"; fi
|
|
197
|
+
if [ "${sz:-0}" -gt 524288 ]; then
|
|
198
|
+
tail -c 262144 "$f" > "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f"
|
|
199
|
+
fi
|
|
200
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-multiacc",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Multi-account addon for Claude Code: every claude / claude -p runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-multiacc": "bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"install.sh",
|
|
13
|
+
"scripts/postinstall.mjs",
|
|
14
|
+
"tests/",
|
|
15
|
+
"README.md",
|
|
16
|
+
"CLAUDE_ACCS_TASK.md"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"claude",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"anthropic",
|
|
22
|
+
"multi-account",
|
|
23
|
+
"subscription",
|
|
24
|
+
"rate-limit",
|
|
25
|
+
"usage-limits",
|
|
26
|
+
"addon",
|
|
27
|
+
"cli",
|
|
28
|
+
"gowalk"
|
|
29
|
+
],
|
|
30
|
+
"author": {
|
|
31
|
+
"name": "Gowalk"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/gowalk-public/claude-multiacc.git"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/gowalk-public/claude-multiacc#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/gowalk-public/claude-multiacc/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
50
|
+
"test": "bash tests/run-tests.sh"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"update-notifier": "^7.3.1"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Runs after `npm install`. On a GLOBAL install/update it wires the addon up by
|
|
3
|
+
// running install.sh (idempotent), so `npm i -g claude-multiacc@latest` fully updates
|
|
4
|
+
// the shim, CLI, and scheduled jobs with no extra step. Deliberately best-effort:
|
|
5
|
+
// npm's own CI, sandboxes, and non-global/dev installs must never be broken by it, and
|
|
6
|
+
// a failed install.sh must not fail `npm install` — it only prints how to finish by hand.
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
|
|
13
|
+
function skip(reason) {
|
|
14
|
+
console.log(`claude-multiacc: skipping auto-setup (${reason}).`);
|
|
15
|
+
console.log(' Finish setup any time with: claude-multiacc install');
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Only auto-run for a real global install. `npm_config_global` is set by npm for -g.
|
|
20
|
+
if (process.env.CLAUDE_MULTIACC_SKIP_POSTINSTALL === '1') skip('CLAUDE_MULTIACC_SKIP_POSTINSTALL=1');
|
|
21
|
+
if (process.env.CI) skip('CI environment');
|
|
22
|
+
const isGlobal = process.env.npm_config_global === 'true' || process.env.npm_config_global === '1';
|
|
23
|
+
if (!isGlobal) skip('local/dev install — not global');
|
|
24
|
+
if (process.platform === 'win32') skip('Windows is not supported by the shim');
|
|
25
|
+
|
|
26
|
+
console.log('claude-multiacc: configuring the PATH shim + scheduled jobs (install.sh)…');
|
|
27
|
+
const r = spawnSync('bash', [join(ROOT, 'install.sh')], { stdio: 'inherit' });
|
|
28
|
+
if (r.status !== 0) {
|
|
29
|
+
console.error('claude-multiacc: auto-setup did not complete. Run it manually: claude-multiacc install');
|
|
30
|
+
}
|
|
31
|
+
// Never fail the npm install regardless of install.sh's exit code.
|
|
32
|
+
process.exit(0);
|