claude-multiacc 2.0.27 → 2.0.28
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/README.md +11 -138
- package/bin/claude-accounts +2 -3
- package/bin/claude-multiacc +3 -0
- package/bin/cli.mjs +16 -18
- package/bin/codex-accounts +2 -3
- package/bin/multiacc-select +4 -0
- package/docs/ACCOUNT_OPERATIONS.md +3 -61
- package/docs/CODEX.md +83 -0
- package/docs/INSTALLATION.md +91 -0
- package/docs/UNIFIED_SELECTOR.md +2 -0
- package/docs/VERIFICATION.md +68 -0
- package/install.sh +9 -284
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/install-help.txt +20 -0
- package/lib/install_actions.sh +238 -0
- package/lib/install_calendar.sh +59 -0
- package/package.json +5 -2
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/packaged_command_support.py +81 -0
- package/tests/test_packaged_commands.py +170 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Calendar job templates, sourced only by mac_schedule_install in install.sh.
|
|
3
|
+
|
|
4
|
+
cat > "$PLIST_HEALTH" <<EOF
|
|
5
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
6
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
7
|
+
<plist version="1.0"><dict>
|
|
8
|
+
<key>Label</key><string>$LABEL.health</string>
|
|
9
|
+
<key>ProgramArguments</key><array>
|
|
10
|
+
<string>$REPO_DIR/bin/claude-accounts</string>
|
|
11
|
+
<string>health</string>
|
|
12
|
+
</array>
|
|
13
|
+
$(plist_env_block) <key>StartCalendarInterval</key><dict>
|
|
14
|
+
<key>Weekday</key><integer>1</integer>
|
|
15
|
+
<key>Hour</key><integer>9</integer>
|
|
16
|
+
<key>Minute</key><integer>17</integer>
|
|
17
|
+
</dict>
|
|
18
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
19
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
20
|
+
</dict></plist>
|
|
21
|
+
EOF
|
|
22
|
+
|
|
23
|
+
cat > "$PLIST_UPDATE" <<EOF
|
|
24
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
25
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
26
|
+
<plist version="1.0"><dict>
|
|
27
|
+
<key>Label</key><string>$LABEL.update</string>
|
|
28
|
+
<key>ProgramArguments</key><array>
|
|
29
|
+
<string>$REPO_DIR/bin/claude-accounts</string>
|
|
30
|
+
<string>self-update</string>
|
|
31
|
+
<string>--quiet</string>
|
|
32
|
+
</array>
|
|
33
|
+
$(plist_env_block) <key>StartCalendarInterval</key><dict>
|
|
34
|
+
<key>Hour</key><integer>4</integer>
|
|
35
|
+
<key>Minute</key><integer>7</integer>
|
|
36
|
+
</dict>
|
|
37
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
38
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
39
|
+
</dict></plist>
|
|
40
|
+
EOF
|
|
41
|
+
|
|
42
|
+
cat > "$PLIST_CODEX_HEALTH" <<EOF
|
|
43
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
45
|
+
<plist version="1.0"><dict>
|
|
46
|
+
<key>Label</key><string>$LABEL.codex-health</string>
|
|
47
|
+
<key>ProgramArguments</key><array>
|
|
48
|
+
<string>$REPO_DIR/bin/codex-accounts</string>
|
|
49
|
+
<string>health</string>
|
|
50
|
+
</array>
|
|
51
|
+
$(plist_env_block) <key>StartCalendarInterval</key><dict>
|
|
52
|
+
<key>Weekday</key><integer>1</integer>
|
|
53
|
+
<key>Hour</key><integer>9</integer>
|
|
54
|
+
<key>Minute</key><integer>37</integer>
|
|
55
|
+
</dict>
|
|
56
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
57
|
+
<key>StandardErrorPath</key><string>/dev/null</string>
|
|
58
|
+
</dict></plist>
|
|
59
|
+
EOF
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.28",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"claude-multiacc": "bin/cli.mjs",
|
|
8
|
+
"claude-accounts": "bin/claude-accounts",
|
|
9
|
+
"codex-accounts": "bin/codex-accounts",
|
|
8
10
|
"multiacc-select": "bin/multiacc-select"
|
|
9
11
|
},
|
|
10
12
|
"files": [
|
|
@@ -53,7 +55,8 @@
|
|
|
53
55
|
},
|
|
54
56
|
"scripts": {
|
|
55
57
|
"postinstall": "node scripts/postinstall.mjs",
|
|
56
|
-
"test": "bash tests/run-tests.sh && python3 tests/test_selector.py"
|
|
58
|
+
"test": "bash tests/run-tests.sh && python3 tests/test_selector.py && npm run test:commands",
|
|
59
|
+
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
57
60
|
},
|
|
58
61
|
"dependencies": {
|
|
59
62
|
"update-notifier": "^7.3.1"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Offline npm installation and isolated command execution for the CLI contract."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run(args, env, cwd, **kwargs):
|
|
14
|
+
return subprocess.run(args, env=env, cwd=cwd, text=True, capture_output=True, timeout=90, **kwargs)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def install_package(test):
|
|
18
|
+
scratch = tempfile.TemporaryDirectory(prefix="multiacc-packaged-commands-")
|
|
19
|
+
test.addClassCleanup(scratch.cleanup)
|
|
20
|
+
test.work = Path(scratch.name).resolve()
|
|
21
|
+
test.prefix = test.work / "npm prefix"
|
|
22
|
+
test.home = test.work / "home"
|
|
23
|
+
test.home.mkdir()
|
|
24
|
+
npm = shutil.which("npm")
|
|
25
|
+
cache = subprocess.check_output([npm, "config", "get", "cache"], text=True).strip()
|
|
26
|
+
paths = [str(Path(shutil.which(name)).parent) for name in ("node", "python3", "npm")]
|
|
27
|
+
test.env = {
|
|
28
|
+
"HOME": str(test.home), "PATH": os.pathsep.join(dict.fromkeys(paths + ["/usr/bin", "/bin"])),
|
|
29
|
+
"CI": "1", "NO_UPDATE_NOTIFIER": "1", "PYTHONDONTWRITEBYTECODE": "1",
|
|
30
|
+
"CLAUDE_MULTIACC_KEYCHAIN": "0", "CLAUDE_MULTIACC_NO_SYNC": "1", "CODEX_MULTIACC_NO_SYNC": "1",
|
|
31
|
+
"CLAUDE_ACCOUNTS_ROOT": str(test.home / ".claude-accounts"),
|
|
32
|
+
"CODEX_ACCOUNTS_ROOT": str(test.home / ".codex-accounts"),
|
|
33
|
+
"npm_config_cache": cache, "npm_config_update_notifier": "false",
|
|
34
|
+
"npm_config_prefix": str(test.prefix), "npm_config_offline": "true",
|
|
35
|
+
"npm_config_ignore_scripts": "true",
|
|
36
|
+
}
|
|
37
|
+
packed = run([npm, "pack", "--ignore-scripts", "--pack-destination", str(test.work)],
|
|
38
|
+
test.env, ROOT)
|
|
39
|
+
if packed.returncode:
|
|
40
|
+
raise RuntimeError(packed.stderr)
|
|
41
|
+
# npm 11 emits a JSON array; npm 12 keys it by package name. Test the artifact
|
|
42
|
+
# itself so npm's presentation format cannot strand an otherwise valid release.
|
|
43
|
+
archives = list(test.work.glob("*.tgz"))
|
|
44
|
+
if len(archives) != 1:
|
|
45
|
+
raise RuntimeError(f"Expected one packed archive, found {len(archives)}")
|
|
46
|
+
archive = archives[0]
|
|
47
|
+
installed = run([npm, "install", "-g", "--prefix", str(test.prefix), "--ignore-scripts", "--offline",
|
|
48
|
+
"--no-audit", "--no-fund", str(archive)], test.env, test.work)
|
|
49
|
+
if installed.returncode:
|
|
50
|
+
raise RuntimeError("Offline install failed; run npm install --ignore-scripts to warm dependencies.\n"
|
|
51
|
+
+ installed.stderr)
|
|
52
|
+
test.package = test.prefix / "lib/node_modules/claude-multiacc"
|
|
53
|
+
test.env["PATH"] = str(test.prefix / "bin") + os.pathsep + test.env["PATH"]
|
|
54
|
+
guard = test.prefix / "bin/npm"
|
|
55
|
+
guard.write_text('#!/usr/bin/env bash\necho "unexpected npm invocation" >&2\nexit 89\n')
|
|
56
|
+
guard.chmod(0o755)
|
|
57
|
+
for name in ("claude", "codex", "security", "launchctl", "crontab", "ln"):
|
|
58
|
+
guard = test.prefix / "bin" / name
|
|
59
|
+
guard.write_text('#!/usr/bin/env bash\n'
|
|
60
|
+
'printf "%s\\n" "$0 $*" >> "$HOME/tool-calls"\nexit 1\n')
|
|
61
|
+
guard.chmod(0o755)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def seed_pools(test):
|
|
65
|
+
for provider in ("claude", "codex"):
|
|
66
|
+
pool = Path(test.env[f"{provider.upper()}_ACCOUNTS_ROOT"])
|
|
67
|
+
account = pool / "acct-01"
|
|
68
|
+
account.mkdir(parents=True)
|
|
69
|
+
(pool / "accounts.json").write_text(json.dumps({
|
|
70
|
+
"version": 1, "server": "none", "threshold": 90,
|
|
71
|
+
"accounts": [{"id": "acct-01", "email": f"{provider}@example.test",
|
|
72
|
+
"home": "mac" if os.uname().sysname == "Darwin" else "server"}],
|
|
73
|
+
}))
|
|
74
|
+
credential = ({"tokens": {"access_token": "fixture", "refresh_token": "fixture"}}
|
|
75
|
+
if provider == "codex" else {"claudeAiOauth": {
|
|
76
|
+
"accessToken": "fixture", "refreshToken": "fixture", "expiresAt": 9999999999999}})
|
|
77
|
+
filename = "auth.json" if provider == "codex" else ".credentials.json"
|
|
78
|
+
(account / filename).write_text(json.dumps(credential))
|
|
79
|
+
fake = test.prefix / "bin" / provider
|
|
80
|
+
fake.write_text('#!/usr/bin/env bash\nprintf "OK\\n"\n')
|
|
81
|
+
fake.chmod(0o755)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Exercise documented commands from npm's actual bin links, without shell rc files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import plistlib
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import unittest
|
|
10
|
+
|
|
11
|
+
from packaged_command_support import ROOT, install_package, run, seed_pools
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@unittest.skipUnless(shutil.which("node") and shutil.which("npm"), "Node/npm required")
|
|
15
|
+
class PackagedCommandsTests(unittest.TestCase):
|
|
16
|
+
@classmethod
|
|
17
|
+
def setUpClass(cls):
|
|
18
|
+
install_package(cls)
|
|
19
|
+
|
|
20
|
+
def test_global_commands_work_without_postinstall_or_shell_rc(self):
|
|
21
|
+
for command in ("claude-multiacc", "claude-accounts", "codex-accounts", "multiacc-select"):
|
|
22
|
+
with self.subTest(command=command):
|
|
23
|
+
resolved = shutil.which(command, path=self.env["PATH"])
|
|
24
|
+
self.assertEqual(resolved, str(self.prefix / "bin" / command))
|
|
25
|
+
flag = "--version" if command == "multiacc-select" else "--help"
|
|
26
|
+
result = run([command, flag], self.env, self.work)
|
|
27
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
28
|
+
for shell in ("bash", "zsh"):
|
|
29
|
+
if not shutil.which(shell):
|
|
30
|
+
continue
|
|
31
|
+
result = run([shell, "-fc", "codex-accounts verify --help"], self.env, self.work)
|
|
32
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
33
|
+
for command in ("claude-accounts", "codex-accounts"):
|
|
34
|
+
result = run([command, "list"], self.env, self.work)
|
|
35
|
+
self.assertNotEqual(result.returncode, 0)
|
|
36
|
+
self.assertIn("run claude-multiacc install first", result.stderr)
|
|
37
|
+
|
|
38
|
+
def test_every_account_help_command_dispatches_directly_and_via_wrapper(self):
|
|
39
|
+
for provider in ("claude", "codex"):
|
|
40
|
+
direct = f"{provider}-accounts"
|
|
41
|
+
help_result = run([str(self.package / "bin" / direct), "--help"], self.env, self.work)
|
|
42
|
+
verbs = set(re.findall(rf"^ {direct} ([a-z-]+)", help_result.stdout, re.MULTILINE))
|
|
43
|
+
self.assertGreaterEqual(len(verbs), 18)
|
|
44
|
+
wrapper = ["claude-multiacc"] + (["codex"] if provider == "codex" else [])
|
|
45
|
+
for prefix in ([direct], wrapper):
|
|
46
|
+
for verb in sorted(verbs):
|
|
47
|
+
with self.subTest(prefix=prefix, verb=verb):
|
|
48
|
+
result = run(prefix + [verb, "--help"], self.env, self.work)
|
|
49
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
50
|
+
self.assertIn(f"{direct} {verb}", result.stdout)
|
|
51
|
+
result = run(prefix + ["not-a-command", "--help"], self.env, self.work)
|
|
52
|
+
self.assertNotEqual(result.returncode, 0)
|
|
53
|
+
|
|
54
|
+
def test_wrapper_and_installer_help_have_no_side_effects(self):
|
|
55
|
+
before = sorted(str(path) for path in self.home.rglob("*"))
|
|
56
|
+
for verb in ("help", "install", "update", "uninstall", "doctor", "self-update", "codex", "select"):
|
|
57
|
+
with self.subTest(verb=verb):
|
|
58
|
+
result = run(["claude-multiacc", verb, "--help"], self.env, self.work)
|
|
59
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
60
|
+
self.assertRegex(result.stdout, r"(?i)usage")
|
|
61
|
+
result = run([str(self.package / "install.sh"), "--help"], self.env, self.work)
|
|
62
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
63
|
+
self.assertEqual(before, sorted(str(path) for path in self.home.rglob("*")))
|
|
64
|
+
|
|
65
|
+
def test_verify_and_json_reports_use_the_correct_pool(self):
|
|
66
|
+
seed_pools(self)
|
|
67
|
+
for provider in ("claude", "codex"):
|
|
68
|
+
wrapper = ["claude-multiacc"] + (["codex"] if provider == "codex" else [])
|
|
69
|
+
for prefix in ([f"{provider}-accounts"], wrapper):
|
|
70
|
+
for verb in ("list", "status"):
|
|
71
|
+
result = run(prefix + [verb, "--json"], self.env, self.work)
|
|
72
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
73
|
+
self.assertEqual(json.loads(result.stdout)["provider"], provider)
|
|
74
|
+
for flags, expected in ((["--quick"], ": OK"), ([], ": PASS")):
|
|
75
|
+
result = run(prefix + ["verify"] + flags, self.env, self.work)
|
|
76
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
77
|
+
self.assertIn(f"{provider}@example.test{expected}", result.stdout)
|
|
78
|
+
|
|
79
|
+
def test_self_update_uses_the_running_install_prefix(self):
|
|
80
|
+
fake_npm = self.prefix / "bin/npm"
|
|
81
|
+
original = fake_npm.read_text()
|
|
82
|
+
fake_npm.write_text('#!/usr/bin/env bash\n'
|
|
83
|
+
'if [ "$1" = view ]; then echo 999.0.0; exit 0; fi\n'
|
|
84
|
+
'printf "%s\\n" "$@" > "$HOME/update-args"\nexit 42\n')
|
|
85
|
+
fake_npm.chmod(0o755)
|
|
86
|
+
try:
|
|
87
|
+
result = run(["claude-multiacc", "self-update"], self.env, self.work)
|
|
88
|
+
self.assertNotEqual(result.returncode, 0)
|
|
89
|
+
args = (self.home / "update-args").read_text().splitlines()
|
|
90
|
+
self.assertEqual(args, ["install", "-g", "--prefix", str(self.prefix), "claude-multiacc@latest"])
|
|
91
|
+
finally:
|
|
92
|
+
fake_npm.write_text(original)
|
|
93
|
+
|
|
94
|
+
def test_git_and_npx_path_entrypoint_matches_npm(self):
|
|
95
|
+
entrypoint = self.package / "bin/claude-multiacc"
|
|
96
|
+
self.assertTrue(entrypoint.is_file())
|
|
97
|
+
result = run([str(entrypoint), "--version"], self.env, self.work)
|
|
98
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
99
|
+
self.assertEqual(result.stdout.strip(), json.loads((self.package / "package.json").read_text())["version"])
|
|
100
|
+
|
|
101
|
+
def test_installer_is_idempotent_and_instances_leave_system_commands_alone(self):
|
|
102
|
+
uname = self.prefix / "bin/uname"
|
|
103
|
+
identity = self.prefix / "bin/id"
|
|
104
|
+
uname.write_text('#!/usr/bin/env bash\necho Darwin\n')
|
|
105
|
+
identity.write_text('#!/usr/bin/env bash\necho 0\n')
|
|
106
|
+
uname.chmod(0o755)
|
|
107
|
+
identity.chmod(0o755)
|
|
108
|
+
try:
|
|
109
|
+
for _ in range(2):
|
|
110
|
+
result = run(["claude-multiacc", "install", "--no-server", "--no-schedule"], self.env, self.work)
|
|
111
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
112
|
+
self.assertNotIn("status |", result.stdout)
|
|
113
|
+
for rc in (".zshenv", ".zprofile", ".zshrc"):
|
|
114
|
+
self.assertEqual((self.home / rc).read_text().count("# >>> claude-multiacc >>>"), 1)
|
|
115
|
+
calls = (self.home / "tool-calls").read_text()
|
|
116
|
+
self.assertNotRegex(calls, r"/(launchctl|crontab|ln) ")
|
|
117
|
+
script = 'source "$0" --no-server --no-schedule; mac_schedule_install'
|
|
118
|
+
result = run(["bash", "-c", script, str(self.package / "install.sh")], self.env, self.work)
|
|
119
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
120
|
+
plists = list((self.home / "Library/LaunchAgents").glob("*.plist"))
|
|
121
|
+
self.assertEqual(len(plists), 5)
|
|
122
|
+
for path in plists:
|
|
123
|
+
job = plistlib.loads(path.read_bytes())
|
|
124
|
+
self.assertIn("PATH", job["EnvironmentVariables"])
|
|
125
|
+
self.assertTrue(Path(job["ProgramArguments"][0]).is_file())
|
|
126
|
+
result = run(["claude-multiacc", "uninstall"], self.env, self.work)
|
|
127
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
128
|
+
self.assertNotIn("claude-multiacc", (self.home / ".zshrc").read_text())
|
|
129
|
+
self.assertTrue((self.home / ".codex-accounts/accounts.json").exists())
|
|
130
|
+
uname.write_text('#!/usr/bin/env bash\necho Linux\n')
|
|
131
|
+
(self.home / "tool-calls").write_text("")
|
|
132
|
+
args = ["install", "--instance", "sandbox", "--no-server", "--no-schedule"]
|
|
133
|
+
result = run(["claude-multiacc"] + args, self.env, self.work)
|
|
134
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
135
|
+
self.assertNotIn("/ln ", (self.home / "tool-calls").read_text())
|
|
136
|
+
finally:
|
|
137
|
+
uname.unlink()
|
|
138
|
+
identity.unlink()
|
|
139
|
+
|
|
140
|
+
def test_selector_transport_and_version_match_across_entrypoints(self):
|
|
141
|
+
expected = None
|
|
142
|
+
for prefix in (["multiacc-select"], ["claude-multiacc", "select"]):
|
|
143
|
+
version = run(prefix + ["--version"], self.env, self.work)
|
|
144
|
+
self.assertEqual(version.returncode, 0, version.stderr)
|
|
145
|
+
result = run(prefix + ["--request-json", "-", "--response-json", "-"], self.env, self.work, input="{}")
|
|
146
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
147
|
+
response = json.loads(result.stdout)
|
|
148
|
+
self.assertEqual(response["selector_version"], version.stdout.strip())
|
|
149
|
+
self.assertEqual(response["error_code"], "invalid_request")
|
|
150
|
+
if expected is not None:
|
|
151
|
+
self.assertEqual(response, expected)
|
|
152
|
+
expected = response
|
|
153
|
+
|
|
154
|
+
def test_documented_shell_examples_parse_and_command_names_exist(self):
|
|
155
|
+
documents = [ROOT / "README.md", *sorted((ROOT / "docs").glob("*.md"))]
|
|
156
|
+
for document in documents:
|
|
157
|
+
for block in re.findall(r"```(?:bash|sh)\n(.*?)```", document.read_text(), re.DOTALL):
|
|
158
|
+
result = run(["bash", "-n"], self.env, self.work, input=block)
|
|
159
|
+
self.assertEqual(result.returncode, 0, f"{document.name}: {result.stderr}")
|
|
160
|
+
for line in block.splitlines():
|
|
161
|
+
tokens = shlex.split(line, comments=True)
|
|
162
|
+
if not tokens or tokens[0] not in ("claude-accounts", "codex-accounts", "claude-multiacc"):
|
|
163
|
+
continue
|
|
164
|
+
self.assertNotIn("|", tokens, f"{document.name}: alternatives must be separate commands")
|
|
165
|
+
self.assertIsNone(re.search(r"--[a-z-]+#", line), line)
|
|
166
|
+
self.assertIsNotNone(shutil.which(tokens[0], path=self.env["PATH"]), line)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
unittest.main()
|