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/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);