multi-codex 0.7.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """multi-codex:管理多个 Codex CLI 账号目录、启动命令与代理设置。"""
2
+
3
+ __version__ = "0.7.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,245 @@
1
+ """账号收敛引擎:对比新旧配置与实际文件状态,生成并执行动作列表(方案 §5.1.1、§5.1.2)。
2
+
3
+ 所有写命令(init、add、proxy、remove、apply,以及迁移最后的登记步骤)都走这里:
4
+ 1. 调用方算出新配置;
5
+ 2. plan() 只读地生成完整动作列表,并就地更新新配置里的 managed_links;
6
+ 3. 有冲突就直接返回 3,什么都不写;
7
+ 4. 否则先原子写入 config.json,再逐个执行文件动作。
8
+
9
+ 先写配置再执行文件动作,是为了让“文件动作中途失败”可以靠重跑收敛:
10
+ 配置已经是新的,下次 plan() 会补齐缺的文件。反过来先改文件再写配置,
11
+ 失败时会留下配置里没有记录的链接,之后再也不会被清理。
12
+ """
13
+
14
+ import os
15
+ from typing import Dict, FrozenSet, Iterable, List, Optional, Sequence, Set, Union
16
+
17
+ from . import launcher, shared
18
+ from .actions import (CONFLICT, CREATE, DELETE, SKIP, UNCHANGED, UPDATE, Action, error,
19
+ has_conflict, info, print_action)
20
+ from .config import Account, Config, config_path, dump_config, save_config
21
+ from .fsutil import KIND_DIR, KIND_MISSING, atomic_write, entry_kind, expand, read_text
22
+
23
+ EXIT_OK = 0
24
+ EXIT_ERROR = 1
25
+ EXIT_USAGE = 2
26
+ EXIT_CONFLICT = 3
27
+ EXIT_BUSY = 4
28
+
29
+ # orphan_scope 取这个值时,清理启动命令目录里所有不在配置中的受管启动命令。
30
+ ALL_ORPHANS = "all"
31
+ NO_ORPHANS: FrozenSet[str] = frozenset()
32
+
33
+
34
+ def account_dir(config: Config, name: str) -> str:
35
+ return os.path.join(expand(config.root), name)
36
+
37
+
38
+ def plan(old: Config, new: Config, *, config_exists: bool = True,
39
+ orphan_scope: Union[str, FrozenSet[str]] = NO_ORPHANS,
40
+ assume_dirs: Iterable[str] = (), adopt_accounts: FrozenSet[str] = frozenset(),
41
+ extra_actions: Sequence[Action] = ()) -> List[Action]:
42
+ """生成从 old 收敛到 new 所需的全部动作。只读文件系统,不做任何修改。
43
+
44
+ orphan_scope:要清理的孤儿启动命令的账号名集合(casefold 后比较);ALL_ORPHANS 表示全部清理,
45
+ NO_ORPHANS 表示不清理。只有 apply 和 remove 需要清理孤儿。
46
+ assume_dirs:视为已存在的账号目录。迁移在移动数据前做预检时,目标目录还不存在,
47
+ 但迁移完成后它一定存在,不应计划“创建目录”。
48
+ adopt_accounts:要接管已有共享软链的账号名集合(casefold 后比较),只有 `add --adopt` 会传入。
49
+ extra_actions:一次性的附加动作(目前只有 `add --config-from` 的复制),追加在最后,
50
+ 所以总在账号目录创建之后执行,并且与其它动作一起参与“有冲突就什么都不写”的判断。
51
+ """
52
+ actions: List[Action] = []
53
+ assumed = {expand(path) for path in assume_dirs}
54
+ old_root, new_root = expand(old.root), expand(new.root)
55
+ old_bin, new_bin = expand(old.bin_dir), expand(new.bin_dir)
56
+
57
+ # 工具不会搬迁账号目录,所以已有账号时改根目录只能判冲突。
58
+ if old.accounts and old_root != new_root:
59
+ actions.append(Action(CONFLICT, "config", config_path(),
60
+ "cannot change root from {} to {} while accounts are registered".format(
61
+ old_root, new_root)))
62
+
63
+ planned_deletes: Set[str] = set()
64
+ for account in new.accounts.values():
65
+ old_account = old.find(account.name)
66
+ if old_account is not None and old_account.name != account.name:
67
+ # 只改了大小写:在区分大小写的文件系统上会新建另一份目录和启动命令,
68
+ # 而旧启动命令因为按大小写不敏感匹配,既不算孤儿也不会被清理。
69
+ actions.append(Action(CONFLICT, "config", config_path(),
70
+ "account {!r} is registered as {!r}; renaming is not supported".format(
71
+ account.name, old_account.name)))
72
+ continue
73
+ directory = os.path.join(new_root, account.name)
74
+ actions.extend(_plan_account_dir(directory, assumed))
75
+ actions.extend(_plan_launcher(new_bin, account.name, directory, account.proxy, account.env))
76
+ if old_account is not None and old_bin != new_bin:
77
+ actions.extend(_plan_launcher_delete(old_bin, old_account.name, planned_deletes,
78
+ "bin_dir changed"))
79
+ actions.extend(shared.plan_shared(new, account, directory, old.shared_dir,
80
+ adopt=account.name.casefold() in adopt_accounts))
81
+
82
+ for old_account in old.accounts.values():
83
+ if new.find(old_account.name) is None:
84
+ actions.extend(_plan_launcher_delete(old_bin, old_account.name, planned_deletes,
85
+ "account removed"))
86
+ actions.extend(shared.plan_remove_links(old_account, os.path.join(old_root, old_account.name),
87
+ old.shared_dir))
88
+
89
+ if orphan_scope:
90
+ for name, path in launcher.scan_managed(new_bin).items():
91
+ if new.find(name) is not None:
92
+ continue
93
+ if orphan_scope == ALL_ORPHANS or name.casefold() in orphan_scope:
94
+ actions.extend(_plan_launcher_delete(new_bin, name, planned_deletes,
95
+ "orphan launcher", path=path))
96
+
97
+ config_changed = not config_exists or dump_config(old) != dump_config(new)
98
+ config_action = Action(CREATE if not config_exists else (UPDATE if config_changed else UNCHANGED),
99
+ "config", config_path())
100
+ return [config_action] + actions + list(extra_actions)
101
+
102
+
103
+ def _plan_account_dir(directory: str, assumed: Set[str]) -> List[Action]:
104
+ if directory in assumed:
105
+ return []
106
+ kind = entry_kind(directory)
107
+ if kind == KIND_MISSING:
108
+ return [Action(CREATE, "account-dir", directory, run=lambda: _make_private_dir(directory))]
109
+ # 账号目录本身是软链(例如用户手工迁移后留的链接)也可以接受,只要最终指向目录。
110
+ if kind == KIND_DIR or os.path.isdir(directory):
111
+ return [Action(UNCHANGED, "account-dir", directory)]
112
+ return [Action(CONFLICT, "account-dir", directory, "exists but is not a directory")]
113
+
114
+
115
+ def plan_config_copy(config: Config, account: Account, content: str, source_name: str) -> List[Action]:
116
+ """`add --config-from`:把另一个账号的 config.toml 内容复制到本账号,只复制一次。
117
+
118
+ config / account 是应用了本条命令其它选项(如 --shared)之后的新配置。判断顺序见
119
+ feature-config-copy §5.1:共享检查最先,因为共享的 config.toml 应当是软链,
120
+ atomic_write 的 os.replace 会把软链换成普通文件,之后 apply 就会判冲突。
121
+ 已有不同内容时不覆盖:那可能是用户已经改过的配置。
122
+ """
123
+ target = os.path.join(account_dir(config, account.name), "config.toml")
124
+ if account.shared and "config.toml" in config.shared_items:
125
+ return [Action(CONFLICT, "config-file", target,
126
+ "config.toml is shared for this account and does not need copying")]
127
+ kind = entry_kind(target)
128
+ if kind == KIND_MISSING:
129
+ def run() -> None:
130
+ # 配置里可能有 experimental_bearer_token 之类的密钥,只允许本人读写。
131
+ atomic_write(target, content, mode=0o600)
132
+ return [Action(CREATE, "config-file", target, "copied from {}".format(source_name), run)]
133
+ if not os.path.isfile(target):
134
+ return [Action(CONFLICT, "config-file", target, "exists but is not a file")]
135
+ if read_text(target) == content:
136
+ return [Action(UNCHANGED, "config-file", target)]
137
+ return [Action(CONFLICT, "config-file", target, "already exists with different content")]
138
+
139
+
140
+ def _make_private_dir(directory: str) -> None:
141
+ # 目录里会保存 auth.json,所以账号目录和根目录都只允许本人访问。
142
+ parent = os.path.dirname(directory)
143
+ if not os.path.isdir(parent):
144
+ os.makedirs(parent, mode=0o700)
145
+ os.mkdir(directory, 0o700)
146
+ os.chmod(directory, 0o700)
147
+
148
+
149
+ def _plan_launcher(bin_dir: str, name: str, directory: str, proxy: str,
150
+ env: Optional[Dict[str, str]] = None) -> List[Action]:
151
+ path = launcher.launcher_path(bin_dir, name)
152
+ content = launcher.render(name, directory, proxy, env)
153
+ mode = launcher.launcher_mode(env)
154
+ kind = entry_kind(path)
155
+ if kind == KIND_MISSING:
156
+ return [Action(CREATE, "launcher", path, run=_write_launcher(path, content, mode))]
157
+ owner = launcher.managed_account(path)
158
+ if owner is None:
159
+ return [Action(CONFLICT, "launcher", path, "already exists and is not managed by multi-codex")]
160
+ if owner.casefold() != name.casefold():
161
+ return [Action(CONFLICT, "launcher", path, "managed by multi-codex for account {!r}".format(owner))]
162
+ if launcher.launcher_file_ok(path, content, env):
163
+ return [Action(UNCHANGED, "launcher", path)]
164
+ return [Action(UPDATE, "launcher", path, run=_write_launcher(path, content, mode))]
165
+
166
+
167
+ def _write_launcher(path: str, content: str, mode: int):
168
+ def run() -> None:
169
+ atomic_write(path, content, mode=mode)
170
+ return run
171
+
172
+
173
+ def _plan_launcher_delete(bin_dir: str, name: str, planned: Set[str], reason: str,
174
+ path: Optional[str] = None) -> List[Action]:
175
+ path = path or launcher.launcher_path(bin_dir, name)
176
+ if path in planned or entry_kind(path) == KIND_MISSING:
177
+ return []
178
+ owner = launcher.managed_account(path)
179
+ if owner is None or owner.casefold() != name.casefold():
180
+ return [Action(SKIP, "launcher", path, "not managed by multi-codex for this account; left untouched")]
181
+ planned.add(path)
182
+ return [Action(DELETE, "launcher", path, reason, lambda: os.unlink(path))]
183
+
184
+
185
+ def execute(old: Config, new: Config, actions: List[Action], *, dry_run: bool, verbose: bool = False) -> int:
186
+ """按 §5.1.1 的顺序执行:有冲突则全部不写;否则先写配置,再逐个执行文件动作。
187
+
188
+ verbose=False(默认)时不打印 unchanged 行:重复执行时满屏 unchanged 会把真正的变化淹没
189
+ (feature-onboarding-commands B3)。create / update / delete / skip / conflict 照常打印——
190
+ skip 是提示信息(例如共享目录缺少某项),不能当成“没变化”藏起来。
191
+ 一行都没打印时说一句 already up to date,免得用户以为命令什么都没做。
192
+ """
193
+ printed = 0
194
+ for action in actions:
195
+ if verbose or action.status != UNCHANGED:
196
+ print_action(action, dry_run=dry_run)
197
+ printed += 1
198
+ if printed == 0:
199
+ info("already up to date")
200
+ if has_conflict(actions):
201
+ info("nothing was changed because of the conflicts above")
202
+ return EXIT_CONFLICT
203
+ if dry_run:
204
+ return EXIT_OK
205
+ config_action = actions[0]
206
+ if config_action.changes:
207
+ try:
208
+ save_config(new)
209
+ except OSError as exc:
210
+ error(str(exc), phase="config", path=config_action.path)
211
+ return EXIT_ERROR
212
+ for action in actions[1:]:
213
+ if not action.changes or action.run is None:
214
+ continue
215
+ try:
216
+ action.run()
217
+ except OSError as exc:
218
+ # 配置已经写入,重跑同一命令或 `apply` 会从这里继续收敛。
219
+ error("{}; rerun the command or `multi-codex apply` to finish".format(exc),
220
+ phase=action.kind, path=action.path)
221
+ return EXIT_ERROR
222
+ return EXIT_OK
223
+
224
+
225
+ def converge(old: Config, new: Config, *, config_exists: bool, dry_run: bool,
226
+ orphan_scope: Union[str, FrozenSet[str]] = NO_ORPHANS, assume_dirs: Iterable[str] = (),
227
+ adopt_accounts: FrozenSet[str] = frozenset(), extra_actions: Sequence[Action] = (),
228
+ verbose: bool = False) -> int:
229
+ actions = plan(old, new, config_exists=config_exists, orphan_scope=orphan_scope,
230
+ assume_dirs=assume_dirs, adopt_accounts=adopt_accounts, extra_actions=extra_actions)
231
+ return execute(old, new, actions, dry_run=dry_run, verbose=verbose)
232
+
233
+
234
+ def launcher_status(config: Config, name: str) -> str:
235
+ """list 命令用:ok、missing、conflict 或 stale(内容与配置不一致)。"""
236
+ account = config.find(name)
237
+ path = launcher.launcher_path(expand(config.bin_dir), name)
238
+ if entry_kind(path) == KIND_MISSING:
239
+ return "missing"
240
+ owner = launcher.managed_account(path)
241
+ if owner is None or owner.casefold() != name.casefold():
242
+ return "conflict"
243
+ env = account.env if account else {}
244
+ expected = launcher.render(name, account_dir(config, name), account.proxy if account else "inherit", env)
245
+ return "ok" if launcher.launcher_file_ok(path, expected, env) else "stale"
multi_codex/actions.py ADDED
@@ -0,0 +1,76 @@
1
+ """动作列表:计划阶段产出、执行阶段逐个执行的最小单元,以及统一的输出格式。
2
+
3
+ 所有写命令都先生成完整的动作列表,只要其中有一个 conflict 就什么都不写(方案 §5.1.1),
4
+ 所以“计划”与“执行”必须分开:计划阶段只读文件系统,副作用全部放在 run 回调里。
5
+ """
6
+
7
+ import sys
8
+ from typing import Callable, List, Optional
9
+
10
+ from .fsutil import display_path
11
+
12
+ CREATE = "create"
13
+ UPDATE = "update"
14
+ DELETE = "delete"
15
+ UNCHANGED = "unchanged"
16
+ CONFLICT = "conflict"
17
+ # 不需要改动、但值得告诉用户的情况,例如共享目录里没有某个条目。
18
+ SKIP = "skip"
19
+
20
+ LOG_PREFIX = "[multi-codex]"
21
+
22
+
23
+ class Action(object):
24
+ def __init__(self, status: str, kind: str, path: str, reason: str = "",
25
+ run: Optional[Callable[[], None]] = None) -> None:
26
+ self.status = status
27
+ # 对象类型:config、account-dir、launcher、shared-link、backup 等
28
+ self.kind = kind
29
+ self.path = path
30
+ self.reason = reason
31
+ self.run = run
32
+
33
+ @property
34
+ def changes(self) -> bool:
35
+ return self.status in (CREATE, UPDATE, DELETE)
36
+
37
+
38
+ def has_conflict(actions: List[Action]) -> bool:
39
+ return any(action.status == CONFLICT for action in actions)
40
+
41
+
42
+ def print_action(action: Action, dry_run: bool = False) -> None:
43
+ status = action.status + (" (dry-run)" if dry_run and action.changes else "")
44
+ # 路径缩写成 ~/…,只为好读;脚本应读 --json,这里的文本格式不保证稳定。
45
+ line = "{} {} {} {}".format(LOG_PREFIX, status, action.kind, display_path(action.path))
46
+ if action.reason:
47
+ line += " ({})".format(action.reason)
48
+ stream = sys.stderr if action.status == CONFLICT else sys.stdout
49
+ print(line, file=stream)
50
+
51
+
52
+ def info(message: str) -> None:
53
+ print("{} {}".format(LOG_PREFIX, message))
54
+
55
+
56
+ def hint(message: str) -> None:
57
+ """给新用户的下一步提示。写 stderr:stdout 可能被脚本逐行读取(例如 `use` 不带参数时输出账号名)。
58
+
59
+ 先 flush stdout:输出接到管道时 stdout 有缓冲,不 flush 的话提示会排在它所针对的动作行前面。
60
+ """
61
+ sys.stdout.flush()
62
+ print("{} hint: {}".format(LOG_PREFIX, message), file=sys.stderr)
63
+
64
+
65
+ def warn(message: str) -> None:
66
+ print("{} warning: {}".format(LOG_PREFIX, message), file=sys.stderr)
67
+
68
+
69
+ def error(message: str, phase: Optional[str] = None, path: Optional[str] = None) -> None:
70
+ """错误输出:带阶段和路径,让用户不必翻代码就能知道失败在哪一步、哪个文件。"""
71
+ parts = [LOG_PREFIX, "error"]
72
+ if phase:
73
+ parts.append("phase={}".format(phase))
74
+ if path:
75
+ parts.append("path={}".format(path))
76
+ print("{}: {}".format(" ".join(parts), message), file=sys.stderr)
multi_codex/apps.py ADDED
@@ -0,0 +1,74 @@
1
+ """按账号打开 VS Code 与 Codex 桌面端(实验功能,feature-app-launch)。
2
+
3
+ 两者都依赖未公开的行为(核实过程见方案 §1.1):
4
+ - VS Code:不同的 --user-data-dir 一定开出新实例;从命令行启动时不读登录 shell 的环境,
5
+ OpenAI 扩展把 process.env 原样传给它启动的 codex,所以 CODEX_HOME 能一路传到 codex app-server。
6
+ - 桌面端:启动后会把登录 shell 的环境合并进来,只有设置了 CODEX_ELECTRON_USER_DATA_PATH 时
7
+ 才把 CODEX_HOME 恢复成启动时的值,用户数据目录与单实例锁也由这个变量决定。
8
+
9
+ 每个账号的 GUI 数据放在 <root>/.apps/<名>/:账号名以字母或数字开头,`.apps` 不会与账号目录重名。
10
+ """
11
+
12
+ import os
13
+ import plistlib
14
+ import shutil
15
+ from typing import Optional
16
+
17
+ from .config import Config
18
+ from .fsutil import expand
19
+
20
+ VERIFIED_WITH = "VS Code 1.139.1 / extension 26.928.31416 / Codex desktop 26.831.11858"
21
+ DEFAULT_DESKTOP_APP = "/Applications/ChatGPT.app"
22
+ # 只允许 Codex 桌面端;同一目录下的 ChatGPT Classic(com.openai.chat)是另一个应用。
23
+ DESKTOP_BUNDLE_ID = "com.openai.codex"
24
+ # 不从 PATH 查找 open,避免同名命令被误用;测试用这个环境变量换成假命令。
25
+ OPEN_OVERRIDE_ENV = "MULTI_CODEX_TEST_OPEN"
26
+ # macOS 的 unix 套接字路径上限是 104 字节,VS Code 的主套接字放在用户数据目录下(<版本>-main.sock)。
27
+ SOCKET_DIR_WARN_LENGTH = 80
28
+
29
+
30
+ def _private_dir(path: str) -> str:
31
+ os.makedirs(path, mode=0o700, exist_ok=True)
32
+ os.chmod(path, 0o700) # makedirs 的 mode 受 umask 影响,这里显式收紧
33
+ return path
34
+
35
+
36
+ def gui_data_dir(config: Config, name: str, kind: str) -> str:
37
+ """返回并创建 <root>/.apps/<名>/<kind>(kind 为 vscode 或 desktop),各级目录都只允许本人访问。"""
38
+ apps_root = _private_dir(os.path.join(expand(config.root), ".apps"))
39
+ account_root = _private_dir(os.path.join(apps_root, name))
40
+ return _private_dir(os.path.join(account_root, kind))
41
+
42
+
43
+ def desktop_log(config: Config, name: str) -> str:
44
+ path = os.path.join(expand(config.root), ".apps", name, "desktop.log")
45
+ fd = os.open(path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
46
+ os.close(fd)
47
+ os.chmod(path, 0o600)
48
+ return path
49
+
50
+
51
+ def find_code(override: Optional[str]) -> Optional[str]:
52
+ candidate = override or shutil.which("code")
53
+ if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK):
54
+ return candidate
55
+ return None
56
+
57
+
58
+ def desktop_app_problem(app: str) -> Optional[str]:
59
+ """应用包不可用时返回原因,可用时返回 None。"""
60
+ info_path = os.path.join(app, "Contents", "Info.plist")
61
+ try:
62
+ with open(info_path, "rb") as handle:
63
+ info = plistlib.load(handle)
64
+ except (OSError, ValueError) as exc: # plistlib 的解析错误继承自 ValueError
65
+ return "cannot read {}: {}".format(info_path, exc)
66
+ bundle_id = info.get("CFBundleIdentifier") if isinstance(info, dict) else None
67
+ if bundle_id != DESKTOP_BUNDLE_ID:
68
+ return "{} is not the Codex desktop app (bundle id {!r}, expected {!r})".format(
69
+ app, bundle_id, DESKTOP_BUNDLE_ID)
70
+ return None
71
+
72
+
73
+ def open_command() -> str:
74
+ return os.environ.get(OPEN_OVERRIDE_ENV) or "/usr/bin/open"
multi_codex/binding.py ADDED
@@ -0,0 +1,66 @@
1
+ """目录绑定账号(feature-dir-binding):`run` 省略账号名时,按当前目录向上找最近一个绑定。
2
+
3
+ 绑定的键是规范路径 normalize_dir:
4
+ - 先 realpath 解析软链(macOS 上 /tmp 会变成 /private/tmp);
5
+ - 再逐级修正大小写:macOS 默认文件系统不区分大小写,realpath("casedir") 会保留输入的大小写,
6
+ 而 os.getcwd() 返回磁盘上的真实大小写,不修正的话键永远匹配不上。
7
+ """
8
+
9
+ import os
10
+ from typing import Optional, Tuple
11
+
12
+ from .config import Config
13
+
14
+
15
+ def _true_name(parent: str, name: str) -> str:
16
+ """parent 下与 name 对应的磁盘上的名字。
17
+
18
+ 原名精确存在时保留原名:区分大小写的文件系统上 Foo 与 foo 可以并存,不能挑错。
19
+ 精确匹配不到时才按 casefold 找,且只在恰好一个时采用;listdir 失败(例如只有执行权限)时保留原名。
20
+ """
21
+ try:
22
+ entries = os.listdir(parent)
23
+ except OSError:
24
+ return name
25
+ if name in entries:
26
+ return name
27
+ folded = name.casefold()
28
+ matches = [entry for entry in entries if entry.casefold() == folded]
29
+ return matches[0] if len(matches) == 1 else name
30
+
31
+
32
+ def normalize_dir(path: str) -> str:
33
+ real = os.path.realpath(os.path.expanduser(path))
34
+ parts = []
35
+ current = real
36
+ while True:
37
+ parent, name = os.path.split(current)
38
+ if not name:
39
+ root = current
40
+ break
41
+ parts.append((parent, name))
42
+ current = parent
43
+ result = root
44
+ for parent, name in reversed(parts):
45
+ # 父目录已按真实大小写拼好,用它去 listdir;这一级不存在时 _true_name 原样返回。
46
+ result = os.path.join(result, _true_name(result, name))
47
+ return result
48
+
49
+
50
+ def resolve(config: Config, cwd: str) -> Optional[Tuple[str, str]]:
51
+ """从 cwd 开始逐级向上,返回最近一个绑定的 (目录, 账号名);没有时返回 None。"""
52
+ current = normalize_dir(cwd)
53
+ while True:
54
+ if current in config.bindings:
55
+ return current, config.bindings[current]
56
+ parent = os.path.dirname(current)
57
+ if parent == current:
58
+ return None
59
+ current = parent
60
+
61
+
62
+ def drop_account(config: Config, name: str) -> None:
63
+ """账号被注销时(remove、restore、apply -f)删除指向它的绑定,按 casefold 匹配账号名。"""
64
+ folded = name.casefold()
65
+ for path in [path for path, bound in config.bindings.items() if bound.casefold() == folded]:
66
+ del config.bindings[path]