claude-multiacc 2.0.60 → 2.0.61
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 +6 -1
- package/bin/claude +14 -3
- package/bin/codex +12 -3
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.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/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/lib/mcp_mirror.py +49 -0
- package/lib/mcp_mirror.sh +47 -0
- package/lib/mcp_registry.py +7 -2
- package/package.json +2 -2
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.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/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
- package/tests/run-tests.sh +3 -1
- package/tests/test_mcp_mirror.py +148 -0
- package/tests/test_mcp_mirror_args.py +129 -0
package/README.md
CHANGED
|
@@ -571,7 +571,12 @@ claude-accounts mcp apply # re-apply now (repair drift)
|
|
|
571
571
|
config dir outside the pool is plain passthrough. Global options in front of `mcp` are
|
|
572
572
|
looked past the way the real parser does (`alias claude='claude
|
|
573
573
|
--dangerously-skip-permissions'` puts one before every typed `claude mcp add`; until
|
|
574
|
-
2026-10-22 that kept motionsites in 2 of 11 accounts).
|
|
574
|
+
2026-10-22 that kept motionsites in 2 of 11 accounts). Mirroring is limited to the server
|
|
575
|
+
named by the command, including its project approvals: a concurrent runner update to
|
|
576
|
+
another server cannot become a synced override or tombstone. An unrecognized command
|
|
577
|
+
option leaves the native command intact and warns that its change was not mirrored.
|
|
578
|
+
Claude's desktop bulk import mirrors changes except names and retirements belonging to
|
|
579
|
+
another machine-local overlay owner; an explicit named command can still override one.
|
|
575
580
|
- **Adoption**: whatever reached an account by another route — the real binary run
|
|
576
581
|
directly, a hand edit, another tool's installer — is adopted by the next reconcile: a
|
|
577
582
|
server (user scope or a project entry) or a project's `.mcp.json` approval that no section
|
package/bin/claude
CHANGED
|
@@ -224,15 +224,26 @@ mcp_mutation_argv() {
|
|
|
224
224
|
i="$(cli_positional_at "$@")" || return 1
|
|
225
225
|
[ "${!i}" = "mcp" ] || return 1
|
|
226
226
|
j=$((i + 1))
|
|
227
|
-
case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) return 0 ;; esac
|
|
227
|
+
case "${!j:-}" in add|add-json|add-from-claude-desktop|remove) MCP_MUTATION_AT="$j"; return 0 ;; esac
|
|
228
228
|
return 1
|
|
229
229
|
}
|
|
230
230
|
|
|
231
231
|
mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
|
|
232
|
-
local d="$1" snap="" rc
|
|
232
|
+
local d="$1" snap="" rc parse_rc name="" mirror_args=()
|
|
233
233
|
shift
|
|
234
234
|
mcp_mutation_argv "$@" || return 1
|
|
235
235
|
[ "${CLAUDE_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
|
|
236
|
+
if [ "${!MCP_MUTATION_AT}" != add-from-claude-desktop ]; then
|
|
237
|
+
if name="$(. "$SELF_DIR/../lib/mcp_mirror.sh" && mcp_mutation_name claude "${@:$MCP_MUTATION_AT}")"; then
|
|
238
|
+
mirror_args=("--name=$name")
|
|
239
|
+
else
|
|
240
|
+
parse_rc=$?
|
|
241
|
+
"$REAL" "$@"; rc=$?
|
|
242
|
+
[ "$rc" -ne 0 ] || [ "$parse_rc" = 2 ] \
|
|
243
|
+
|| printf 'claude-multiacc: could not identify the MCP target; change not mirrored\n' >&2
|
|
244
|
+
exit "$rc"
|
|
245
|
+
fi
|
|
246
|
+
fi
|
|
236
247
|
mcp_ensure "$d"
|
|
237
248
|
snap="$(mktemp "${TMPDIR:-/tmp}/claude-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
|
|
238
249
|
if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude snapshot \
|
|
@@ -243,7 +254,7 @@ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled
|
|
|
243
254
|
rc=$?
|
|
244
255
|
if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
|
|
245
256
|
"$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider claude learn --account-dir "$d" \
|
|
246
|
-
--before "$snap" --project "$(pwd -P 2>/dev/null || pwd)" </dev/null \
|
|
257
|
+
--before "$snap" --project "$(pwd -P 2>/dev/null || pwd)" ${mirror_args[@]+"${mirror_args[@]}"} </dev/null \
|
|
247
258
|
|| printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
|
|
248
259
|
elif [ "$rc" -eq 0 ]; then
|
|
249
260
|
printf 'claude-multiacc: could not mirror the MCP change to the other accounts — run: claude-accounts mcp apply\n' >&2
|
package/bin/codex
CHANGED
|
@@ -165,15 +165,24 @@ mcp_mutation_argv() {
|
|
|
165
165
|
*) break ;;
|
|
166
166
|
esac
|
|
167
167
|
done
|
|
168
|
-
case "${!j:-}" in add|remove) return 0 ;; esac
|
|
168
|
+
case "${!j:-}" in add|remove) MCP_MUTATION_AT="$j"; return 0 ;; esac
|
|
169
169
|
return 1
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled the run
|
|
173
|
-
local d="$1" snap="" rc
|
|
173
|
+
local d="$1" snap="" rc parse_rc name=""
|
|
174
174
|
shift
|
|
175
175
|
mcp_mutation_argv "$@" || return 1
|
|
176
176
|
[ "${CODEX_MULTIACC_MCP:-1}" != "0" ] && [ -f "$MCP_PY" ] || return 1
|
|
177
|
+
if name="$(. "$SELF_DIR/../lib/mcp_mirror.sh" && mcp_mutation_name codex "${@:$MCP_MUTATION_AT}")"; then
|
|
178
|
+
:
|
|
179
|
+
else
|
|
180
|
+
parse_rc=$?
|
|
181
|
+
"$REAL" "$@"; rc=$?
|
|
182
|
+
[ "$rc" -ne 0 ] || [ "$parse_rc" = 2 ] \
|
|
183
|
+
|| printf 'codex-multiacc: could not identify the MCP target; change not mirrored\n' >&2
|
|
184
|
+
exit "$rc"
|
|
185
|
+
fi
|
|
177
186
|
mcp_ensure "$d"
|
|
178
187
|
snap="$(mktemp "${TMPDIR:-/tmp}/codex-mcp-snap.XXXXXX" 2>/dev/null)" || snap=""
|
|
179
188
|
if [ -n "$snap" ] && ! "$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex snapshot \
|
|
@@ -184,7 +193,7 @@ mcp_mirror_run() { # $1 acct dir, then the user's argv — exits when it handled
|
|
|
184
193
|
rc=$?
|
|
185
194
|
if [ "$rc" -eq 0 ] && [ -n "$snap" ]; then
|
|
186
195
|
"$MCP_PYBIN" "$MCP_PY" --root "$ACC_ROOT" --provider codex learn --account-dir "$d" \
|
|
187
|
-
--before "$snap" </dev/null \
|
|
196
|
+
--before "$snap" "--name=$name" </dev/null \
|
|
188
197
|
|| printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
|
|
189
198
|
elif [ "$rc" -eq 0 ]; then
|
|
190
199
|
printf 'codex-multiacc: could not mirror the MCP change to the other accounts — run: codex-accounts mcp apply\n' >&2
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Keep stock MCP mutations scoped to the server the command actually named."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _external_names(overlay, target_owner):
|
|
7
|
+
"""Bulk imports cannot promote another local owner's machine-specific entries."""
|
|
8
|
+
user, everywhere, projects = set(), set(), {}
|
|
9
|
+
for owner, section in overlay.get("owners", {}).items():
|
|
10
|
+
if owner == target_owner:
|
|
11
|
+
continue
|
|
12
|
+
everywhere.update(section.get("retired") or [])
|
|
13
|
+
user.update(section.get("mcpServers") or {})
|
|
14
|
+
user.update(section.get("retiredUser") or [])
|
|
15
|
+
for path, entry in (section.get("projects") or {}).items():
|
|
16
|
+
names = projects.setdefault(path, set())
|
|
17
|
+
names.update(entry.get("mcpServers") or {})
|
|
18
|
+
names.update(entry.get("retired") or [])
|
|
19
|
+
names.update(entry.get("enabledMcpjsonServers") or [])
|
|
20
|
+
return user | everywhere, everywhere, projects
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _filtered_snapshot(snapshot, keep_user, keep_project):
|
|
24
|
+
result = copy.deepcopy(snapshot)
|
|
25
|
+
result["user"] = {name: block for name, block in (result.get("user") or {}).items()
|
|
26
|
+
if keep_user(name)}
|
|
27
|
+
for path, entry in (result.get("projects") or {}).items():
|
|
28
|
+
entry["mcpServers"] = {name: block for name, block in (entry.get("mcpServers") or {}).items()
|
|
29
|
+
if keep_project(path, name)}
|
|
30
|
+
for key in ("enabledMcpjsonServers", "disabledMcpjsonServers"):
|
|
31
|
+
entry[key] = [name for name in (entry.get(key) or []) if keep_project(path, name)]
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def scoped_snapshots(before, after, name, overlay, target_owner=None):
|
|
36
|
+
"""Named commands mirror only their target, including project approvals.
|
|
37
|
+
|
|
38
|
+
Without a name (desktop bulk import), externally owned names are excluded even
|
|
39
|
+
if a delayed reconcile writes an older generation than the current overlay.
|
|
40
|
+
"""
|
|
41
|
+
if name is not None:
|
|
42
|
+
keep_user = lambda candidate: candidate == name
|
|
43
|
+
keep_project = lambda _path, candidate: candidate == name
|
|
44
|
+
else:
|
|
45
|
+
user, everywhere, projects = _external_names(overlay, target_owner)
|
|
46
|
+
keep_user = lambda candidate: candidate not in user
|
|
47
|
+
keep_project = lambda path, candidate: candidate not in everywhere | projects.get(path, set())
|
|
48
|
+
return (_filtered_snapshot(before, keep_user, keep_project),
|
|
49
|
+
_filtered_snapshot(after, keep_user, keep_project))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Target of a stock MCP mutation. Global CLI options are already skipped by the shim.
|
|
2
|
+
# Unknown options fail closed: the native command still runs, but is not mirrored.
|
|
3
|
+
# Status 2 is a help request before the target; pass it through without a warning.
|
|
4
|
+
mcp_mutation_name() { # provider, verb, then the verb's argv -> server name
|
|
5
|
+
local provider="$1" verb="$2" values="" variadic="" flags=" " a opt attached
|
|
6
|
+
shift 2
|
|
7
|
+
case "$provider:$verb" in
|
|
8
|
+
claude:add)
|
|
9
|
+
values=" -s --scope -t --transport --callback-port --client-id "
|
|
10
|
+
variadic=" -e --env -H --header "; flags="$flags--client-secret " ;;
|
|
11
|
+
claude:add-json) values=" -s --scope "; flags="$flags--client-secret " ;;
|
|
12
|
+
claude:remove) values=" -s --scope " ;;
|
|
13
|
+
codex:add)
|
|
14
|
+
values=" -c --config --env --enable --disable --url --bearer-token-env-var \
|
|
15
|
+
--oauth-client-id --oauth-client-secret --oauth-client-registration --oauth-resource " ;;
|
|
16
|
+
codex:remove) values=" -c --config --enable --disable " ;;
|
|
17
|
+
*) return 1 ;;
|
|
18
|
+
esac
|
|
19
|
+
while [ "$#" -gt 0 ]; do
|
|
20
|
+
a="$1"; shift
|
|
21
|
+
case "$a" in
|
|
22
|
+
--) [ "$#" -gt 0 ] || return 1; printf '%s\n' "$1"; return 0 ;;
|
|
23
|
+
-h|--help) return 2 ;;
|
|
24
|
+
-?*) ;;
|
|
25
|
+
*) printf '%s\n' "$a"; return 0 ;;
|
|
26
|
+
esac
|
|
27
|
+
opt="$a"; attached=0
|
|
28
|
+
case "$a" in
|
|
29
|
+
--*=*) opt="${a%%=*}"; attached=1 ;;
|
|
30
|
+
--*) ;;
|
|
31
|
+
-?*) opt="${a:0:2}"; [ "${#a}" -le 2 ] || attached=1 ;;
|
|
32
|
+
esac
|
|
33
|
+
case "$values" in
|
|
34
|
+
*" $opt "*)
|
|
35
|
+
if [ "$attached" = 0 ]; then [ "$#" -gt 0 ] || return 1; shift; fi
|
|
36
|
+
continue ;;
|
|
37
|
+
esac
|
|
38
|
+
case "$variadic" in
|
|
39
|
+
*" $opt "*)
|
|
40
|
+
while [ "$#" -gt 0 ]; do case "$1" in -*) break ;; esac; shift; done
|
|
41
|
+
continue ;;
|
|
42
|
+
esac
|
|
43
|
+
case "$flags" in *" $opt "*) [ "$attached" = 0 ] || return 1; continue ;; esac
|
|
44
|
+
return 1
|
|
45
|
+
done
|
|
46
|
+
return 1
|
|
47
|
+
}
|
package/lib/mcp_registry.py
CHANGED
|
@@ -1413,14 +1413,18 @@ def cmd_snapshot(args):
|
|
|
1413
1413
|
|
|
1414
1414
|
|
|
1415
1415
|
def cmd_learn(args):
|
|
1416
|
+
from mcp_mirror import scoped_snapshots
|
|
1417
|
+
|
|
1416
1418
|
try:
|
|
1417
1419
|
with open(args.before, encoding="utf-8") as handle:
|
|
1418
1420
|
before = json.load(handle)
|
|
1419
1421
|
except (OSError, ValueError) as exc:
|
|
1420
1422
|
raise RegistryError(f"snapshot {args.before}: {exc}") from exc
|
|
1421
|
-
after = snapshot(args.provider, args.account_dir)
|
|
1422
1423
|
with _Lock(args.root):
|
|
1424
|
+
after = snapshot(args.provider, args.account_dir)
|
|
1423
1425
|
target = _Target(args.root)
|
|
1426
|
+
before, after = scoped_snapshots(before, after, args.name, load_overlay(args.root),
|
|
1427
|
+
"local" if target.replica else None)
|
|
1424
1428
|
changes = _learn_into(target.section, before, after)
|
|
1425
1429
|
if changes:
|
|
1426
1430
|
target.save()
|
|
@@ -1519,7 +1523,8 @@ def build_parser():
|
|
|
1519
1523
|
p = modes.add_parser("learn")
|
|
1520
1524
|
p.add_argument("--account-dir", required=True)
|
|
1521
1525
|
p.add_argument("--before", required=True)
|
|
1522
|
-
p.add_argument("--
|
|
1526
|
+
p.add_argument("--name", help="mirror only this stock command's server, including project approvals")
|
|
1527
|
+
p.add_argument("--project", help="accepted for the shim; --name applies across project entries")
|
|
1523
1528
|
|
|
1524
1529
|
p = modes.add_parser("import-local")
|
|
1525
1530
|
p.add_argument("--owner", required=True)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.61",
|
|
4
4
|
"description": "Unified Claude Code and OpenAI Codex subscription pooling with quota-aware selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"test": "npm run test:core && npm run test:seeding && npm run test:commands",
|
|
59
59
|
"test:core": "bash tests/run-tests.sh && npm run test:selector && python3 tests/test_shim_path.py",
|
|
60
60
|
"test:selector": "python3 -m unittest discover -s tests -p 'test_selector*.py'",
|
|
61
|
-
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3
|
|
61
|
+
"test:seeding": "python3 tests/test_account_seeding.py && python3 tests/test_claude_pool_prepare.py && python3 -m unittest discover -s tests -p 'test_mcp_*.py' && npm run test:codex-settings",
|
|
62
62
|
"test:codex-settings": "python3 tests/test_codex_config_edit.py && python3 tests/test_codex_settings.py",
|
|
63
63
|
"test:commands": "python3 tests/test_packaged_commands.py"
|
|
64
64
|
},
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/tests/run-tests.sh
CHANGED
|
@@ -10251,7 +10251,9 @@ fi
|
|
|
10251
10251
|
# reconciles with whatever `python3` is on PATH, and a runner Mac's is /usr/bin/python3.
|
|
10252
10252
|
# Skipped silently where that interpreter is absent or already 3.11+ (CI is ubuntu).
|
|
10253
10253
|
if [ -x /usr/bin/python3 ] && /usr/bin/python3 -c 'import sys; sys.exit(0 if sys.version_info < (3, 11) else 1)' 2>/dev/null; then
|
|
10254
|
-
|
|
10254
|
+
# Only this unit suite's disposable pools opt back in; legacy sections stay protected.
|
|
10255
|
+
if CLAUDE_MULTIACC_MCP_ADOPT=1 CODEX_MULTIACC_MCP_ADOPT=1 \
|
|
10256
|
+
/usr/bin/python3 "$REPO_DIR/tests/test_mcp_registry.py" >/dev/null 2>&1; then
|
|
10255
10257
|
t_ok "MCP registry unit suite on stock python $(/usr/bin/python3 -c 'import sys;print("%d.%d"%sys.version_info[:2])')"
|
|
10256
10258
|
else
|
|
10257
10259
|
t_fail "MCP registry unit suite on stock python" "/usr/bin/python3 tests/test_mcp_registry.py failed"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stock mutations must not promote concurrent runner publications into the registry."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from test_mcp_registry import HAS_TOMLLIB, HTTP, R, RegistryCase, STDIO
|
|
10
|
+
|
|
11
|
+
APP = "appinspire-mcp"
|
|
12
|
+
MOTION = "motionsites"
|
|
13
|
+
V1 = {"type": "stdio", "command": "/releases/1000249/python", "args": ["launcher.pyc"], "env": {}}
|
|
14
|
+
V2 = {"type": "stdio", "command": "/releases/1000250/python", "args": ["launcher.pyc"], "env": {}}
|
|
15
|
+
V3 = {"type": "stdio", "command": "/bin/sh", "args": ["-p", "-c", "exec node server.mjs"], "env": {}}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MirrorCases:
|
|
19
|
+
def setUp(self):
|
|
20
|
+
super().setUp()
|
|
21
|
+
if self.provider == "codex" and not HAS_TOMLLIB:
|
|
22
|
+
self.skipTest("Codex requires tomllib")
|
|
23
|
+
if self.replica:
|
|
24
|
+
(self.root / "sync-role").write_text("replica\n")
|
|
25
|
+
|
|
26
|
+
def publish(self, servers):
|
|
27
|
+
rc, _, err = self.run_cli(self.provider, "import-local", "--owner", "app-robot",
|
|
28
|
+
stdin=json.dumps({"mcpServers": servers}))
|
|
29
|
+
self.assertEqual(rc, 0, err)
|
|
30
|
+
|
|
31
|
+
def write_account(self, servers, projects=None):
|
|
32
|
+
if self.provider == "claude":
|
|
33
|
+
R._claude_save(self.acct(), {"mcpServers": servers, "projects": projects or {}})
|
|
34
|
+
else:
|
|
35
|
+
eff = R.Effective()
|
|
36
|
+
eff.servers = servers
|
|
37
|
+
text, notes = R.reconcile_codex_text("", eff)
|
|
38
|
+
self.assertFalse(notes)
|
|
39
|
+
(Path(self.acct()) / "config.toml").write_text(text)
|
|
40
|
+
|
|
41
|
+
def before(self):
|
|
42
|
+
rc, snapshot, err = self.run_cli(self.provider, "snapshot", "--account-dir", self.acct())
|
|
43
|
+
self.assertEqual(rc, 0, err)
|
|
44
|
+
path = self.root / "before.json"
|
|
45
|
+
path.write_text(snapshot)
|
|
46
|
+
return str(path)
|
|
47
|
+
|
|
48
|
+
def learn(self, before, name):
|
|
49
|
+
scope = ["--name", name] if name is not None else []
|
|
50
|
+
rc, _, err = self.run_cli(self.provider, "learn", "--account-dir", self.acct(),
|
|
51
|
+
"--before", before, *scope)
|
|
52
|
+
self.assertEqual(rc, 0, err)
|
|
53
|
+
return R._Target(str(self.root)).section
|
|
54
|
+
|
|
55
|
+
def test_named_add_ignores_delayed_runner_generation(self):
|
|
56
|
+
self.publish({APP: V1})
|
|
57
|
+
before = self.before()
|
|
58
|
+
self.publish({APP: V2})
|
|
59
|
+
# import-local commits G3 under the lock, then applies accounts outside it.
|
|
60
|
+
# Pause before apply: the client still sees G2, which differs from current G3.
|
|
61
|
+
with patch.object(R, "_apply_all", return_value=0):
|
|
62
|
+
self.publish({APP: V3})
|
|
63
|
+
self.write_account({APP: V2, MOTION: HTTP})
|
|
64
|
+
section = self.learn(before, MOTION)
|
|
65
|
+
self.assertEqual(set(section["mcpServers"]), {MOTION})
|
|
66
|
+
self.assertEqual(section["retired"] + section["retiredUser"], [])
|
|
67
|
+
self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V3})
|
|
68
|
+
for account in ("acct-01", "acct-02"):
|
|
69
|
+
snapshot = R.snapshot(self.provider, self.acct(account))
|
|
70
|
+
self.assertEqual(snapshot["user"][APP]["command"], "/bin/sh")
|
|
71
|
+
self.assertEqual(snapshot["user"][MOTION]["url"], HTTP["url"])
|
|
72
|
+
|
|
73
|
+
def test_named_remove_does_not_promote_runner_retirement(self):
|
|
74
|
+
self.publish({APP: V1})
|
|
75
|
+
self.run_cli(self.provider, "add-json", MOTION, json.dumps(HTTP))
|
|
76
|
+
before = self.before()
|
|
77
|
+
self.publish({})
|
|
78
|
+
self.write_account({})
|
|
79
|
+
section = self.learn(before, MOTION)
|
|
80
|
+
self.assertEqual(section["mcpServers"], {})
|
|
81
|
+
self.assertEqual(section["retiredUser"], [MOTION])
|
|
82
|
+
self.assertEqual(section["retired"], [])
|
|
83
|
+
self.assertEqual(self.overlay()["owners"]["app-robot"]["retired"], [APP])
|
|
84
|
+
|
|
85
|
+
def test_explicit_owner_override_and_removal_still_mirror(self):
|
|
86
|
+
self.publish({APP: V1})
|
|
87
|
+
before = self.before()
|
|
88
|
+
self.write_account({APP: HTTP})
|
|
89
|
+
section = self.learn(before, APP)
|
|
90
|
+
self.assertEqual(section["mcpServers"][APP]["url"], HTTP["url"])
|
|
91
|
+
self.assertEqual(self.overlay()["owners"]["app-robot"]["mcpServers"], {APP: V1})
|
|
92
|
+
before = self.before()
|
|
93
|
+
self.write_account({})
|
|
94
|
+
section = self.learn(before, APP)
|
|
95
|
+
self.assertEqual(section["mcpServers"], {})
|
|
96
|
+
self.assertEqual(section["retiredUser"], [APP])
|
|
97
|
+
self.assertEqual(section["retired"], [])
|
|
98
|
+
|
|
99
|
+
def test_bulk_import_excludes_external_updates_and_retirements(self):
|
|
100
|
+
self.publish({APP: V1, "old-runner-tool": STDIO})
|
|
101
|
+
self.run_cli(self.provider, "import-local", "--owner", "local",
|
|
102
|
+
stdin=json.dumps({"mcpServers": {"local-tool": V1}}))
|
|
103
|
+
before = self.before()
|
|
104
|
+
self.publish({APP: V2})
|
|
105
|
+
with patch.object(R, "_apply_all", return_value=0):
|
|
106
|
+
self.publish({APP: V3})
|
|
107
|
+
self.write_account({APP: V2, MOTION: HTTP, "local-tool": HTTP})
|
|
108
|
+
section = self.learn(before, None)
|
|
109
|
+
self.assertEqual(set(section["mcpServers"]), {MOTION, "local-tool"} if self.replica else {MOTION})
|
|
110
|
+
local = self.overlay()["owners"]["local"]["mcpServers"]["local-tool"]
|
|
111
|
+
self.assertEqual(local.get("url") if self.replica else local, HTTP["url"] if self.replica else V1)
|
|
112
|
+
self.assertEqual(section["retired"] + section["retiredUser"], [])
|
|
113
|
+
|
|
114
|
+
def test_named_project_changes_and_approvals_stay_scoped(self):
|
|
115
|
+
if self.provider != "claude":
|
|
116
|
+
self.skipTest("project MCP entries are Claude-only")
|
|
117
|
+
for name in (MOTION, "other"):
|
|
118
|
+
self.run_cli("claude", "add-json", name, json.dumps(STDIO), "--scope", "project",
|
|
119
|
+
"--project", "/project")
|
|
120
|
+
before = self.before()
|
|
121
|
+
self.write_account({}, {"/project": {"mcpServers": {MOTION: HTTP, "other": HTTP},
|
|
122
|
+
"enabledMcpjsonServers": [MOTION, "other"]}})
|
|
123
|
+
section = self.learn(before, MOTION)
|
|
124
|
+
project = section["projects"]["/project"]
|
|
125
|
+
self.assertEqual(project["mcpServers"], {MOTION: HTTP, "other": STDIO})
|
|
126
|
+
self.assertEqual(project["enabledMcpjsonServers"], [MOTION])
|
|
127
|
+
other = self.claude_doc("acct-02")["projects"]["/project"]
|
|
128
|
+
self.assertEqual(other["enabledMcpjsonServers"], [MOTION])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ClaudeSourceTests(MirrorCases, RegistryCase):
|
|
132
|
+
provider, replica = "claude", False
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ClaudeReplicaTests(MirrorCases, RegistryCase):
|
|
136
|
+
provider, replica = "claude", True
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CodexSourceTests(MirrorCases, RegistryCase):
|
|
140
|
+
provider, replica = "codex", False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CodexReplicaTests(MirrorCases, RegistryCase):
|
|
144
|
+
provider, replica = "codex", True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
unittest.main()
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""MCP target parsing uses native verb options, without reading command payloads."""
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
from test_mcp_registry import R
|
|
10
|
+
|
|
11
|
+
HELPER = Path(__file__).resolve().parents[1] / "lib" / "mcp_mirror.sh"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MutationNameTests(unittest.TestCase):
|
|
15
|
+
def parse(self, *args):
|
|
16
|
+
return subprocess.run(["/bin/bash", "-uc", '. "$1"; shift; mcp_mutation_name "$@"',
|
|
17
|
+
"mcp-target-test", str(HELPER), *args], capture_output=True, text=True)
|
|
18
|
+
|
|
19
|
+
def test_native_options_before_name(self):
|
|
20
|
+
cases = [
|
|
21
|
+
("claude", "add", "-s", "user", "-t", "http", "motionsites", "https://example.test"),
|
|
22
|
+
("claude", "add", "-suser", "-thttp", "motionsites", "https://example.test"),
|
|
23
|
+
("claude", "add", "--scope=user", "--callback-port", "1234", "--client-id", "cid",
|
|
24
|
+
"--client-secret", "motionsites", "https://example.test"),
|
|
25
|
+
("claude", "add", "-e", "A=1", "B=2", "-H", "X: 1", "Y: 2", "--", "motionsites", "echo"),
|
|
26
|
+
("claude", "add", "-eA=1", "B=2", "--scope=user", "motionsites", "--", "echo", "--unknown"),
|
|
27
|
+
("claude", "add-json", "-suser", "--client-secret", "motionsites", '{"command":"echo"}'),
|
|
28
|
+
("claude", "remove", "--scope=user", "motionsites"),
|
|
29
|
+
("codex", "add", "-c", "x=1", "--env", "A=1", "--url=https://example.test", "motionsites"),
|
|
30
|
+
("codex", "add", "--enable", "x", "--disable=y", "--bearer-token-env-var", "MCP_TOKEN",
|
|
31
|
+
"--oauth-client-id", "cid", "--oauth-client-secret", "dummy", "--oauth-resource", "r",
|
|
32
|
+
"--oauth-client-registration", "dynamic", "motionsites"),
|
|
33
|
+
("codex", "remove", "-cx=1", "--enable=x", "--disable", "y", "motionsites"),
|
|
34
|
+
("codex", "add", "motionsites", "--", "node", "--env", "appinspire-mcp"),
|
|
35
|
+
("claude", "add", "motionsites", "--", "node", "--help"),
|
|
36
|
+
("codex", "add", "motionsites", "--help"),
|
|
37
|
+
("claude", "remove", "motionsites", "-h"),
|
|
38
|
+
("claude", "add", "--client-id", "--help", "motionsites", "https://example.test"),
|
|
39
|
+
]
|
|
40
|
+
for args in cases:
|
|
41
|
+
with self.subTest(args=args):
|
|
42
|
+
result = self.parse(*args)
|
|
43
|
+
self.assertEqual((result.returncode, result.stdout, result.stderr), (0, "motionsites\n", ""))
|
|
44
|
+
|
|
45
|
+
def test_unknown_or_incomplete_options_never_become_a_broad_mirror(self):
|
|
46
|
+
cases = [
|
|
47
|
+
("claude", "add", "--future-value", "appinspire-mcp", "motionsites"),
|
|
48
|
+
("claude", "remove", "--scope"),
|
|
49
|
+
("claude", "add", "-e", "A=1", "motionsites", "echo"),
|
|
50
|
+
("claude", "add-json", "--client-secret=unexpected", "motionsites", "{}"),
|
|
51
|
+
("codex", "add", "--unknown=value", "motionsites"),
|
|
52
|
+
("codex", "remove", "--config"),
|
|
53
|
+
("codex", "add", "--"),
|
|
54
|
+
("claude", "add-from-claude-desktop"),
|
|
55
|
+
]
|
|
56
|
+
for args in cases:
|
|
57
|
+
with self.subTest(args=args):
|
|
58
|
+
result = self.parse(*args)
|
|
59
|
+
self.assertNotEqual(result.returncode, 0)
|
|
60
|
+
self.assertEqual(result.stdout, "")
|
|
61
|
+
|
|
62
|
+
def test_help_without_a_target_has_a_distinct_quiet_status(self):
|
|
63
|
+
for provider in ("claude", "codex"):
|
|
64
|
+
for flag in ("-h", "--help"):
|
|
65
|
+
result = self.parse(provider, "add", flag)
|
|
66
|
+
self.assertEqual((result.returncode, result.stdout, result.stderr), (2, "", ""))
|
|
67
|
+
|
|
68
|
+
def run_shim(self, provider, args, native_exit=0):
|
|
69
|
+
with tempfile.TemporaryDirectory() as work:
|
|
70
|
+
root = Path(work)
|
|
71
|
+
pool, fakebin = root / "pool", root / "bin"
|
|
72
|
+
(pool / "acct-01").mkdir(parents=True)
|
|
73
|
+
fakebin.mkdir()
|
|
74
|
+
(pool / "accounts.json").write_text('{"version":1,"accounts":[{"id":"acct-01"}]}')
|
|
75
|
+
native = fakebin / provider
|
|
76
|
+
native.write_text('#!/bin/sh\nprintf "NATIVE\\n"\nprintf "%s\\n" "$@"\nexit "$FAKE_EXIT"\n')
|
|
77
|
+
native.chmod(0o755)
|
|
78
|
+
logger = fakebin / "mcp-python"
|
|
79
|
+
logger.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$MCP_TEST_LOG"\n'
|
|
80
|
+
'case "$*" in *" snapshot "*) printf "{}\\n" ;; esac\n')
|
|
81
|
+
logger.chmod(0o755)
|
|
82
|
+
log = root / "mirror.log"
|
|
83
|
+
env = {"PATH": f"{fakebin}:/usr/bin:/bin", "HOME": work, "USER": "test", "LOGNAME": "test",
|
|
84
|
+
"TMPDIR": work, "FAKE_EXIT": str(native_exit), "MCP_TEST_LOG": str(log),
|
|
85
|
+
"CLAUDE_MULTIACC_PYTHON": str(logger), f"{provider.upper()}_ACCOUNTS_ROOT": str(pool),
|
|
86
|
+
f"{provider.upper()}_SHIM_ACTIVE": "1",
|
|
87
|
+
"CLAUDE_CONFIG_DIR" if provider == "claude" else "CODEX_HOME": str(pool / "acct-01")}
|
|
88
|
+
result = subprocess.run([str(HELPER.parents[1] / "bin" / provider), *args], env=env,
|
|
89
|
+
capture_output=True, text=True, timeout=10)
|
|
90
|
+
return result, log.read_text() if log.exists() else ""
|
|
91
|
+
|
|
92
|
+
def test_literal_help_global_value_still_mirrors(self):
|
|
93
|
+
args = ["--system-prompt", "--help", "mcp", "add-json", "--scope", "user", "motionsites",
|
|
94
|
+
'{"command":"echo","args":[]}']
|
|
95
|
+
result, log = self.run_shim("claude", args)
|
|
96
|
+
self.assertEqual((result.returncode, result.stderr), (0, ""))
|
|
97
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
|
|
98
|
+
self.assertIn("\nlearn\n", log)
|
|
99
|
+
self.assertIn("\n--name=motionsites\n", log)
|
|
100
|
+
|
|
101
|
+
def test_leading_dash_name_reaches_the_registry_parser(self):
|
|
102
|
+
args = ["mcp", "add", "--", "-probe-name", "echo"]
|
|
103
|
+
for provider in ("claude", "codex"):
|
|
104
|
+
result, log = self.run_shim(provider, args)
|
|
105
|
+
self.assertEqual((result.returncode, result.stderr), (0, ""))
|
|
106
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
|
|
107
|
+
words = log.splitlines()
|
|
108
|
+
tail = words[words.index("learn") + 1:]
|
|
109
|
+
parsed = R.build_parser().parse_args(["--root", "unused", "--provider", provider, "learn", *tail])
|
|
110
|
+
self.assertEqual(parsed.name, "-probe-name")
|
|
111
|
+
|
|
112
|
+
def test_help_is_quiet_native_passthrough(self):
|
|
113
|
+
for provider in ("claude", "codex"):
|
|
114
|
+
result, log = self.run_shim(provider, ["mcp", "add", "--help"])
|
|
115
|
+
self.assertEqual((result.returncode, result.stderr, log), (0, "", ""))
|
|
116
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", "mcp", "add", "--help"])
|
|
117
|
+
|
|
118
|
+
def test_unknown_options_keep_native_argv_and_exit_without_mirroring(self):
|
|
119
|
+
args = ["mcp", "add", "--future-value", "appinspire-mcp", "motionsites"]
|
|
120
|
+
for provider in ("claude", "codex"):
|
|
121
|
+
for native_exit in (0, 7):
|
|
122
|
+
result, log = self.run_shim(provider, args, native_exit)
|
|
123
|
+
self.assertEqual((result.returncode, log), (native_exit, ""))
|
|
124
|
+
self.assertEqual(result.stdout.splitlines(), ["NATIVE", *args])
|
|
125
|
+
self.assertEqual("change not mirrored" in result.stderr, native_exit == 0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
unittest.main()
|