codex-workspaces 0.1.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.
- codex_workspaces/__init__.py +3 -0
- codex_workspaces/__main__.py +5 -0
- codex_workspaces/cli.py +95 -0
- codex_workspaces/config.py +75 -0
- codex_workspaces/core.py +531 -0
- codex_workspaces/errors.py +7 -0
- codex_workspaces/platforms.py +264 -0
- codex_workspaces-0.1.0.dist-info/METADATA +168 -0
- codex_workspaces-0.1.0.dist-info/RECORD +12 -0
- codex_workspaces-0.1.0.dist-info/WHEEL +5 -0
- codex_workspaces-0.1.0.dist-info/entry_points.txt +2 -0
- codex_workspaces-0.1.0.dist-info/top_level.txt +1 -0
codex_workspaces/cli.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional, Sequence
|
|
5
|
+
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .core import WorkspaceManager, workspace_dir, usage
|
|
8
|
+
from .errors import CodexWorkspacesError
|
|
9
|
+
from .platforms import SystemPlatform
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _print_error(message: str, lang: str) -> None:
|
|
13
|
+
prefix = "错误" if lang == "zh" else "Error"
|
|
14
|
+
print(f"{prefix}: {message}", file=sys.stderr)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(argv: Sequence[str], manager: WorkspaceManager) -> int:
|
|
18
|
+
command = argv[0] if argv else "help"
|
|
19
|
+
args = list(argv[1:])
|
|
20
|
+
|
|
21
|
+
if command in {"help", "-h", "--help"}:
|
|
22
|
+
manager.info(usage(manager.config.lang))
|
|
23
|
+
return 0
|
|
24
|
+
if command in {"list", "ls"}:
|
|
25
|
+
manager.list_workspaces()
|
|
26
|
+
return 0
|
|
27
|
+
if command in {"current", "whoami"}:
|
|
28
|
+
manager.show_current()
|
|
29
|
+
return 0
|
|
30
|
+
if command in {"use", "switch", "sw"}:
|
|
31
|
+
if not args:
|
|
32
|
+
manager.fail(
|
|
33
|
+
"缺少工作区名,例如: codex-workspaces use work",
|
|
34
|
+
"Missing workspace name, for example: codex-workspaces use work",
|
|
35
|
+
)
|
|
36
|
+
manager.switch_workspace(args[0], args[1:], argv)
|
|
37
|
+
return 0
|
|
38
|
+
if command in {"stop", "quit", "close"}:
|
|
39
|
+
force = False
|
|
40
|
+
for arg in args:
|
|
41
|
+
if arg in {"--force", "-f"}:
|
|
42
|
+
force = True
|
|
43
|
+
else:
|
|
44
|
+
manager.fail(f"未知参数: {arg}", f"Unknown option: {arg}")
|
|
45
|
+
manager.stop_codex(force, argv)
|
|
46
|
+
return 0
|
|
47
|
+
if command in {"start", "open"}:
|
|
48
|
+
if args:
|
|
49
|
+
manager.fail("start 不需要参数", "start does not take arguments")
|
|
50
|
+
manager.start_codex()
|
|
51
|
+
return 0
|
|
52
|
+
if command in {"restart", "reopen"}:
|
|
53
|
+
force = False
|
|
54
|
+
for arg in args:
|
|
55
|
+
if arg in {"--force", "-f"}:
|
|
56
|
+
force = True
|
|
57
|
+
else:
|
|
58
|
+
manager.fail(f"未知参数: {arg}", f"Unknown option: {arg}")
|
|
59
|
+
manager.restart_codex(force, argv)
|
|
60
|
+
return 0
|
|
61
|
+
if command in {"create", "new"}:
|
|
62
|
+
if not args:
|
|
63
|
+
manager.create_workspace("", [])
|
|
64
|
+
else:
|
|
65
|
+
manager.create_workspace(args[0], args[1:])
|
|
66
|
+
return 0
|
|
67
|
+
if command == "install":
|
|
68
|
+
if len(args) > 1:
|
|
69
|
+
manager.fail(f"未知参数: {args[1]}", f"Unknown option: {args[1]}")
|
|
70
|
+
manager.install_self(args[0] if args else None)
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if workspace_dir(manager.config, command).is_dir():
|
|
75
|
+
manager.switch_workspace(command, args, argv)
|
|
76
|
+
return 0
|
|
77
|
+
except CodexWorkspacesError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
manager.fail(f"未知命令或工作区不存在: {command}", f"Unknown command or workspace does not exist: {command}")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
85
|
+
platform_service = SystemPlatform()
|
|
86
|
+
config = Config.from_env(apple_language=platform_service.apple_language())
|
|
87
|
+
manager = WorkspaceManager(config, platform_service)
|
|
88
|
+
try:
|
|
89
|
+
return run(list(sys.argv[1:] if argv is None else argv), manager)
|
|
90
|
+
except CodexWorkspacesError as exc:
|
|
91
|
+
_print_error(exc.message, config.lang)
|
|
92
|
+
if exc.message.startswith("Unknown command") or exc.message.startswith("未知命令"):
|
|
93
|
+
print(file=sys.stderr)
|
|
94
|
+
print(usage(config.lang), file=sys.stderr)
|
|
95
|
+
return exc.exit_code
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Mapping, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _looks_zh(value: str) -> bool:
|
|
10
|
+
return value.lower().replace("_", "-").startswith("zh")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _looks_en(value: str) -> bool:
|
|
14
|
+
return value.lower().replace("_", "-").startswith("en")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect_ui_lang(
|
|
18
|
+
env: Mapping[str, str],
|
|
19
|
+
apple_language: Optional[str] = None,
|
|
20
|
+
) -> str:
|
|
21
|
+
forced = env.get("CODEX_WORKSPACES_LANG") or ""
|
|
22
|
+
if _looks_zh(forced):
|
|
23
|
+
return "zh"
|
|
24
|
+
if _looks_en(forced):
|
|
25
|
+
return "en"
|
|
26
|
+
|
|
27
|
+
if apple_language:
|
|
28
|
+
if _looks_zh(apple_language):
|
|
29
|
+
return "zh"
|
|
30
|
+
return "en"
|
|
31
|
+
|
|
32
|
+
env_lang = env.get("LC_ALL") or env.get("LC_MESSAGES") or env.get("LANG") or ""
|
|
33
|
+
return "zh" if _looks_zh(env_lang) else "en"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _expand_path(value: str) -> str:
|
|
37
|
+
return os.path.expandvars(os.path.expanduser(value))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Config:
|
|
42
|
+
app_name: str
|
|
43
|
+
home_dir: Path
|
|
44
|
+
active_link: Path
|
|
45
|
+
workspace_prefix: str
|
|
46
|
+
quit_timeout: int
|
|
47
|
+
lang: str
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_env(
|
|
51
|
+
cls,
|
|
52
|
+
env: Optional[Mapping[str, str]] = None,
|
|
53
|
+
home: Optional[Path] = None,
|
|
54
|
+
apple_language: Optional[str] = None,
|
|
55
|
+
) -> "Config":
|
|
56
|
+
env = dict(os.environ if env is None else env)
|
|
57
|
+
home_dir = Path(home or env.get("HOME") or Path.home()).expanduser()
|
|
58
|
+
active_link = Path(
|
|
59
|
+
_expand_path(
|
|
60
|
+
env.get("CODEX_WORKSPACES_LINK") or str(home_dir / ".codex")
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
workspace_prefix = _expand_path(
|
|
64
|
+
env.get("CODEX_WORKSPACES_PREFIX") or str(home_dir / ".codex-")
|
|
65
|
+
)
|
|
66
|
+
quit_timeout = int(env.get("CODEX_QUIT_TIMEOUT") or "20")
|
|
67
|
+
|
|
68
|
+
return cls(
|
|
69
|
+
app_name=env.get("CODEX_APP_NAME") or "Codex",
|
|
70
|
+
home_dir=home_dir,
|
|
71
|
+
active_link=active_link,
|
|
72
|
+
workspace_prefix=workspace_prefix,
|
|
73
|
+
quit_timeout=quit_timeout,
|
|
74
|
+
lang=detect_ui_lang(env, apple_language),
|
|
75
|
+
)
|
codex_workspaces/core.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable, List, Optional, Sequence, TextIO
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .errors import CodexWorkspacesError
|
|
15
|
+
from .platforms import SystemPlatform
|
|
16
|
+
|
|
17
|
+
WORKSPACE_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def strip_workspace_name(value: str) -> str:
|
|
21
|
+
name = re.split(r"[\\/]+", value.rstrip("\\/"))[-1]
|
|
22
|
+
if name.startswith(".codex-"):
|
|
23
|
+
name = name[len(".codex-") :]
|
|
24
|
+
return name
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def validate_workspace_name(name: str) -> None:
|
|
28
|
+
if not name:
|
|
29
|
+
raise CodexWorkspacesError("Workspace name cannot be empty")
|
|
30
|
+
if name in {".", ".."}:
|
|
31
|
+
raise CodexWorkspacesError(f"Workspace name cannot be {name}")
|
|
32
|
+
if not WORKSPACE_RE.match(name):
|
|
33
|
+
raise CodexWorkspacesError(
|
|
34
|
+
"Workspace name can only contain letters, numbers, dots, underscores, and hyphens: "
|
|
35
|
+
+ name
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def workspace_dir(config: Config, name: str) -> Path:
|
|
40
|
+
clean_name = strip_workspace_name(name)
|
|
41
|
+
validate_workspace_name(clean_name)
|
|
42
|
+
return Path(config.workspace_prefix + clean_name)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class CurrentTarget:
|
|
47
|
+
kind: str
|
|
48
|
+
path: Optional[Path] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class WorkspaceManager:
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
config: Config,
|
|
55
|
+
platform_service: Optional[SystemPlatform] = None,
|
|
56
|
+
stdout: Optional[TextIO] = None,
|
|
57
|
+
stderr: Optional[TextIO] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.config = config
|
|
60
|
+
self.platform = platform_service or SystemPlatform()
|
|
61
|
+
self.stdout = stdout or sys.stdout
|
|
62
|
+
self.stderr = stderr or sys.stderr
|
|
63
|
+
|
|
64
|
+
def is_zh(self) -> bool:
|
|
65
|
+
return self.config.lang == "zh"
|
|
66
|
+
|
|
67
|
+
def message(self, zh: str, en: str) -> str:
|
|
68
|
+
return zh if self.is_zh() else en
|
|
69
|
+
|
|
70
|
+
def fail(self, zh: str, en: str) -> None:
|
|
71
|
+
raise CodexWorkspacesError(self.message(zh, en))
|
|
72
|
+
|
|
73
|
+
def info(self, text: str = "") -> None:
|
|
74
|
+
print(text, file=self.stdout)
|
|
75
|
+
|
|
76
|
+
def bold(self, text: str) -> str:
|
|
77
|
+
isatty = getattr(self.stdout, "isatty", lambda: False)
|
|
78
|
+
return f"\033[1m{text}\033[0m" if isatty() else text
|
|
79
|
+
|
|
80
|
+
def workspace_dir(self, name: str) -> Path:
|
|
81
|
+
return workspace_dir(self.config, name)
|
|
82
|
+
|
|
83
|
+
def real_dir(self, path: Path) -> Path:
|
|
84
|
+
if self.platform.is_directory_link(path):
|
|
85
|
+
return path.resolve(strict=False)
|
|
86
|
+
if path.is_dir():
|
|
87
|
+
return path.resolve(strict=True)
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
def current_target(self) -> CurrentTarget:
|
|
91
|
+
active = self.config.active_link
|
|
92
|
+
if self.platform.is_directory_link(active):
|
|
93
|
+
return CurrentTarget("target", self.real_dir(active))
|
|
94
|
+
if active.exists():
|
|
95
|
+
return CurrentTarget("not-a-link", active)
|
|
96
|
+
return CurrentTarget("missing")
|
|
97
|
+
|
|
98
|
+
def workspace_dirs(self) -> List[Path]:
|
|
99
|
+
dirs = [Path(path) for path in glob.glob(self.config.workspace_prefix + "*")]
|
|
100
|
+
return sorted(path for path in dirs if path.is_dir())
|
|
101
|
+
|
|
102
|
+
def same_path(self, left: Path, right: Path) -> bool:
|
|
103
|
+
left_s = os.path.normcase(os.path.abspath(left))
|
|
104
|
+
right_s = os.path.normcase(os.path.abspath(right))
|
|
105
|
+
return left_s == right_s
|
|
106
|
+
|
|
107
|
+
def current_name(self, target: Path) -> Optional[str]:
|
|
108
|
+
for directory in self.workspace_dirs():
|
|
109
|
+
if self.same_path(self.real_dir(directory), target):
|
|
110
|
+
return strip_workspace_name(str(directory))
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def list_workspaces(self) -> None:
|
|
114
|
+
current = self.current_target()
|
|
115
|
+
self.info(self.bold(self.message("Codex 工作区", "Codex workspaces")))
|
|
116
|
+
found = False
|
|
117
|
+
for directory in self.workspace_dirs():
|
|
118
|
+
found = True
|
|
119
|
+
name = strip_workspace_name(str(directory))
|
|
120
|
+
marker = " "
|
|
121
|
+
if current.kind == "target" and current.path:
|
|
122
|
+
marker = "*" if self.same_path(self.real_dir(directory), current.path) else " "
|
|
123
|
+
self.info(f" {marker} {name:<16} {directory}")
|
|
124
|
+
|
|
125
|
+
if not found:
|
|
126
|
+
self.info(
|
|
127
|
+
self.message(
|
|
128
|
+
"未找到工作区目录。可以先执行: codex-workspaces create work",
|
|
129
|
+
"No workspace directories found. You can create one with: codex-workspaces create work",
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.info()
|
|
134
|
+
if current.kind == "missing":
|
|
135
|
+
self.info(
|
|
136
|
+
self.message(
|
|
137
|
+
f"当前 {self.config.active_link} 不存在。",
|
|
138
|
+
f"Current {self.config.active_link} does not exist.",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
elif current.kind == "not-a-link":
|
|
142
|
+
self.info(
|
|
143
|
+
self.message(
|
|
144
|
+
f"当前 {self.config.active_link} 存在,但不是软链接,切换前需要手动处理。",
|
|
145
|
+
f"Current {self.config.active_link} exists, but it is not a symlink. Please handle it manually before switching.",
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
elif current.path:
|
|
149
|
+
name = self.current_name(current.path)
|
|
150
|
+
if name:
|
|
151
|
+
self.info(
|
|
152
|
+
self.message(
|
|
153
|
+
f"当前工作区: {name} -> {current.path}",
|
|
154
|
+
f"Current workspace: {name} -> {current.path}",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
self.info(
|
|
159
|
+
self.message(
|
|
160
|
+
f"当前工作区: 未匹配到工作区目录 -> {current.path}",
|
|
161
|
+
f"Current workspace: no matching workspace directory -> {current.path}",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def show_current(self) -> None:
|
|
166
|
+
current = self.current_target()
|
|
167
|
+
if current.kind == "missing":
|
|
168
|
+
self.fail(
|
|
169
|
+
f"{self.config.active_link} 不存在",
|
|
170
|
+
f"{self.config.active_link} does not exist",
|
|
171
|
+
)
|
|
172
|
+
if current.kind == "not-a-link":
|
|
173
|
+
self.fail(
|
|
174
|
+
f"{self.config.active_link} 存在,但不是软链接",
|
|
175
|
+
f"{self.config.active_link} exists, but it is not a symlink",
|
|
176
|
+
)
|
|
177
|
+
assert current.path is not None
|
|
178
|
+
name = self.current_name(current.path) or "unknown"
|
|
179
|
+
self.info(f"{name} -> {current.path}")
|
|
180
|
+
|
|
181
|
+
def stop_codex(self, force: bool = False, argv: Optional[Sequence[str]] = None) -> None:
|
|
182
|
+
if self.platform.is_codex_terminal():
|
|
183
|
+
if self.platform.supports_external_terminal_delegation:
|
|
184
|
+
self.platform.delegate_to_external_terminal(
|
|
185
|
+
self.config,
|
|
186
|
+
self.message("关闭 Codex", "stop Codex"),
|
|
187
|
+
list(argv or (["stop", "--force"] if force else ["stop"])),
|
|
188
|
+
self.stdout,
|
|
189
|
+
)
|
|
190
|
+
return
|
|
191
|
+
self.require_external_terminal("stop")
|
|
192
|
+
|
|
193
|
+
if not self.platform.supports_app_control:
|
|
194
|
+
self.fail(
|
|
195
|
+
"当前平台不支持自动关闭 Codex App。切换工作区时可使用 --no-stop。",
|
|
196
|
+
"App stop is only supported on macOS. Use --no-stop when switching workspaces on this platform.",
|
|
197
|
+
)
|
|
198
|
+
self.platform.stop_app(
|
|
199
|
+
self.config.app_name,
|
|
200
|
+
self.config.quit_timeout,
|
|
201
|
+
force,
|
|
202
|
+
self.stdout,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def start_codex(self) -> None:
|
|
206
|
+
self.require_external_terminal("start")
|
|
207
|
+
if not self.platform.supports_app_control:
|
|
208
|
+
self.fail(
|
|
209
|
+
"当前平台不支持自动启动 Codex App。",
|
|
210
|
+
"App start is only supported on macOS.",
|
|
211
|
+
)
|
|
212
|
+
self.info(self.message(f"正在启动 {self.config.app_name} ...", f"Starting {self.config.app_name} ..."))
|
|
213
|
+
self.platform.start_app(self.config.app_name)
|
|
214
|
+
|
|
215
|
+
def restart_codex(self, force: bool = False, argv: Optional[Sequence[str]] = None) -> None:
|
|
216
|
+
if self.platform.is_codex_terminal():
|
|
217
|
+
if self.platform.supports_external_terminal_delegation:
|
|
218
|
+
self.platform.delegate_to_external_terminal(
|
|
219
|
+
self.config,
|
|
220
|
+
self.message("重启 Codex", "restart Codex"),
|
|
221
|
+
list(argv or (["restart", "--force"] if force else ["restart"])),
|
|
222
|
+
self.stdout,
|
|
223
|
+
)
|
|
224
|
+
return
|
|
225
|
+
self.require_external_terminal("restart")
|
|
226
|
+
self.stop_codex(force)
|
|
227
|
+
self.start_codex()
|
|
228
|
+
|
|
229
|
+
def require_external_terminal(self, action: str) -> None:
|
|
230
|
+
if not self.platform.is_codex_terminal():
|
|
231
|
+
return
|
|
232
|
+
zh_actions = {
|
|
233
|
+
"stop": "关闭 Codex",
|
|
234
|
+
"start": "启动 Codex",
|
|
235
|
+
"restart": "重启 Codex",
|
|
236
|
+
"switch": "切换工作区",
|
|
237
|
+
"migration": "迁移工作区目录",
|
|
238
|
+
}
|
|
239
|
+
self.fail(
|
|
240
|
+
f"不能在 Codex 内置 Terminal 中执行{zh_actions.get(action, action)}。请打开外部系统 Terminal,在 Codex 外部运行该命令。",
|
|
241
|
+
f"Cannot run {action} from the built-in Codex terminal. Open an external system Terminal and run this command outside Codex.",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def ensure_app_not_running_for_migration(self) -> None:
|
|
245
|
+
status = self.platform.app_running_status(self.config.app_name)
|
|
246
|
+
if status is True:
|
|
247
|
+
self.fail(
|
|
248
|
+
f"{self.config.app_name} 正在运行。为避免配置损坏,请先从外部 Terminal 关闭 {self.config.app_name} 后再执行迁移。",
|
|
249
|
+
f"{self.config.app_name} is running. To avoid corrupting config files, quit {self.config.app_name} from an external terminal before migration.",
|
|
250
|
+
)
|
|
251
|
+
if status is None and self.platform.supports_app_control:
|
|
252
|
+
self.fail(
|
|
253
|
+
f"无法确认 {self.config.app_name} 是否运行。为避免配置损坏,请先从外部 Terminal 确认 {self.config.app_name} 已关闭后再执行迁移。",
|
|
254
|
+
f"Cannot confirm whether {self.config.app_name} is running. To avoid corrupting config files, confirm {self.config.app_name} is closed from an external terminal before migration.",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def switch_workspace(self, name: str, args: Sequence[str], original_argv: Sequence[str]) -> None:
|
|
258
|
+
stop_first = True
|
|
259
|
+
start_after = True
|
|
260
|
+
force = False
|
|
261
|
+
for arg in args:
|
|
262
|
+
if arg == "--no-stop":
|
|
263
|
+
stop_first = False
|
|
264
|
+
elif arg == "--no-start":
|
|
265
|
+
start_after = False
|
|
266
|
+
elif arg in {"--force", "-f"}:
|
|
267
|
+
force = True
|
|
268
|
+
elif arg in {"-h", "--help"}:
|
|
269
|
+
self.info(usage(self.config.lang))
|
|
270
|
+
return
|
|
271
|
+
else:
|
|
272
|
+
self.fail(f"未知参数: {arg}", f"Unknown option: {arg}")
|
|
273
|
+
|
|
274
|
+
if self.platform.is_codex_terminal():
|
|
275
|
+
if self.platform.supports_external_terminal_delegation:
|
|
276
|
+
self.platform.delegate_to_external_terminal(
|
|
277
|
+
self.config,
|
|
278
|
+
self.message("切换工作区", "switch workspaces"),
|
|
279
|
+
original_argv,
|
|
280
|
+
self.stdout,
|
|
281
|
+
)
|
|
282
|
+
return
|
|
283
|
+
self.require_external_terminal("switch")
|
|
284
|
+
|
|
285
|
+
clean_name = strip_workspace_name(name)
|
|
286
|
+
validate_workspace_name(clean_name)
|
|
287
|
+
directory = self.workspace_dir(clean_name)
|
|
288
|
+
if not directory.is_dir():
|
|
289
|
+
self.fail(
|
|
290
|
+
f"工作区不存在: {directory}。可先执行: codex-workspaces create {clean_name}",
|
|
291
|
+
f"Workspace does not exist: {directory}. You can create it with: codex-workspaces create {clean_name}",
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
active = self.config.active_link
|
|
295
|
+
if active.exists() and not self.platform.is_directory_link(active):
|
|
296
|
+
self.fail(
|
|
297
|
+
f"{active} 已存在但不是软链接。为避免误删,请先手动备份/迁移它。",
|
|
298
|
+
f"{active} already exists but is not a symlink. Please back it up or migrate it manually before switching.",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if stop_first:
|
|
302
|
+
if self.platform.supports_app_control:
|
|
303
|
+
self.stop_codex(force)
|
|
304
|
+
else:
|
|
305
|
+
self.info(
|
|
306
|
+
self.message(
|
|
307
|
+
"当前平台不支持自动关闭 Codex App,继续只切换工作区链接。",
|
|
308
|
+
"App stop is not supported on this platform; continuing with the workspace link switch.",
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
active.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
if self.platform.is_directory_link(active):
|
|
314
|
+
self.platform.remove_directory_link(active)
|
|
315
|
+
self.platform.create_directory_link(directory, active)
|
|
316
|
+
self.info(self.message(f"已切换到: {clean_name} -> {directory}", f"Switched to: {clean_name} -> {directory}"))
|
|
317
|
+
|
|
318
|
+
if start_after:
|
|
319
|
+
if self.platform.supports_app_control:
|
|
320
|
+
self.start_codex()
|
|
321
|
+
else:
|
|
322
|
+
self.info(
|
|
323
|
+
self.message(
|
|
324
|
+
"当前平台不支持自动启动 Codex App,工作区链接已完成切换。",
|
|
325
|
+
"App start is not supported on this platform; the workspace link has been switched.",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def create_workspace(self, name: str, args: Sequence[str]) -> None:
|
|
330
|
+
migrate_current = False
|
|
331
|
+
for arg in args:
|
|
332
|
+
if arg in {"--migrate-current", "--migrate"}:
|
|
333
|
+
migrate_current = True
|
|
334
|
+
elif arg in {"-h", "--help"}:
|
|
335
|
+
self.info(usage(self.config.lang))
|
|
336
|
+
return
|
|
337
|
+
else:
|
|
338
|
+
self.fail(f"未知参数: {arg}", f"Unknown option: {arg}")
|
|
339
|
+
|
|
340
|
+
if not name:
|
|
341
|
+
self.fail(
|
|
342
|
+
"缺少工作区名,例如: codex-workspaces create work",
|
|
343
|
+
"Missing workspace name, for example: codex-workspaces create work",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
clean_name = strip_workspace_name(name)
|
|
347
|
+
validate_workspace_name(clean_name)
|
|
348
|
+
directory = self.workspace_dir(clean_name)
|
|
349
|
+
if directory.exists():
|
|
350
|
+
self.fail(f"工作区目录已存在: {directory}", f"Workspace directory already exists: {directory}")
|
|
351
|
+
|
|
352
|
+
if migrate_current:
|
|
353
|
+
self.require_external_terminal("migration")
|
|
354
|
+
self.ensure_app_not_running_for_migration()
|
|
355
|
+
active = self.config.active_link
|
|
356
|
+
if self.platform.is_directory_link(active):
|
|
357
|
+
self.fail(
|
|
358
|
+
f"{active} 已经是软链接,无需迁移。",
|
|
359
|
+
f"{active} is already a symlink; there is nothing to migrate.",
|
|
360
|
+
)
|
|
361
|
+
if not active.exists():
|
|
362
|
+
self.fail(f"{active} 不存在,无法迁移。", f"{active} does not exist, so it cannot be migrated.")
|
|
363
|
+
if not active.is_dir():
|
|
364
|
+
self.fail(
|
|
365
|
+
f"{active} 存在但不是目录,无法迁移。",
|
|
366
|
+
f"{active} exists but is not a directory, so it cannot be migrated.",
|
|
367
|
+
)
|
|
368
|
+
directory.parent.mkdir(parents=True, exist_ok=True)
|
|
369
|
+
shutil.move(str(active), str(directory))
|
|
370
|
+
self.platform.create_directory_link(directory, active)
|
|
371
|
+
self.info(self.message(f"已迁移当前工作区: {active} -> {directory}", f"Migrated current workspace: {active} -> {directory}"))
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
directory.mkdir(parents=True, exist_ok=False)
|
|
375
|
+
self.info(self.message(f"已创建工作区目录: {directory}", f"Created workspace directory: {directory}"))
|
|
376
|
+
|
|
377
|
+
def install_self(self, destination: Optional[str] = None) -> None:
|
|
378
|
+
dest = Path(destination) if destination else self.default_install_dir()
|
|
379
|
+
if dest is None:
|
|
380
|
+
self.fail(
|
|
381
|
+
"无法判断安装目录,请指定,例如: codex-workspaces install /usr/local/bin",
|
|
382
|
+
"Could not choose an install directory. Please specify one, for example: codex-workspaces install /usr/local/bin",
|
|
383
|
+
)
|
|
384
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
385
|
+
if self.platform.is_windows:
|
|
386
|
+
launcher = dest / "codex-workspaces.cmd"
|
|
387
|
+
launcher.write_text(
|
|
388
|
+
f'@echo off\r\n"{sys.executable}" -m codex_workspaces %*\r\n',
|
|
389
|
+
encoding="utf-8",
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
launcher = dest / "codex-workspaces"
|
|
393
|
+
launcher.write_text(
|
|
394
|
+
"#!/usr/bin/env sh\n"
|
|
395
|
+
f"exec {shlex.quote(sys.executable)} -m codex_workspaces \"$@\"\n",
|
|
396
|
+
encoding="utf-8",
|
|
397
|
+
)
|
|
398
|
+
launcher.chmod(0o755)
|
|
399
|
+
self.info(self.message(f"已安装: {launcher}", f"Installed: {launcher}"))
|
|
400
|
+
|
|
401
|
+
if not self.path_contains_dir(dest):
|
|
402
|
+
self.info(
|
|
403
|
+
self.message(
|
|
404
|
+
f"提醒: {dest} 目前不在 PATH 中,需要加入 shell 配置。",
|
|
405
|
+
f"Note: {dest} is not currently in PATH. Add it to your shell config before using the command directly.",
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def path_contains_dir(self, directory: Path) -> bool:
|
|
410
|
+
path_parts = os.environ.get("PATH", "").split(os.pathsep)
|
|
411
|
+
target = os.path.normcase(os.path.abspath(directory))
|
|
412
|
+
return any(os.path.normcase(os.path.abspath(part or ".")) == target for part in path_parts)
|
|
413
|
+
|
|
414
|
+
def default_install_dir(self) -> Optional[Path]:
|
|
415
|
+
candidates: Iterable[Path]
|
|
416
|
+
if self.platform.is_windows:
|
|
417
|
+
candidates = [
|
|
418
|
+
self.config.home_dir / "AppData" / "Roaming" / "Python" / "Scripts",
|
|
419
|
+
self.config.home_dir / ".local" / "bin",
|
|
420
|
+
]
|
|
421
|
+
else:
|
|
422
|
+
candidates = [
|
|
423
|
+
self.config.home_dir / ".local" / "bin",
|
|
424
|
+
self.config.home_dir / "bin",
|
|
425
|
+
Path("/opt/homebrew/bin"),
|
|
426
|
+
Path("/usr/local/bin"),
|
|
427
|
+
]
|
|
428
|
+
for directory in candidates:
|
|
429
|
+
if self.path_contains_dir(directory) and directory.is_dir() and os.access(directory, os.W_OK):
|
|
430
|
+
return directory
|
|
431
|
+
for directory in candidates:
|
|
432
|
+
if self.path_contains_dir(directory) or directory == self.config.home_dir / ".local" / "bin":
|
|
433
|
+
return directory
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def usage(lang: str) -> str:
|
|
438
|
+
if lang == "zh":
|
|
439
|
+
return """codex-workspaces - Codex 多工作区切换工具
|
|
440
|
+
|
|
441
|
+
工作区约定:
|
|
442
|
+
当前工作区: ~/.codex 软链接/目录链接
|
|
443
|
+
工作区目录: ~/.codex-work 工作区名 work
|
|
444
|
+
~/.codex-personal 工作区名 personal
|
|
445
|
+
|
|
446
|
+
用法:
|
|
447
|
+
codex-workspaces list | ls
|
|
448
|
+
查看所有工作区目录,并标出当前工作区。
|
|
449
|
+
|
|
450
|
+
codex-workspaces current
|
|
451
|
+
显示当前 ~/.codex 指向哪个工作区。
|
|
452
|
+
|
|
453
|
+
codex-workspaces use <工作区名> [--no-stop] [--no-start] [--force]
|
|
454
|
+
codex-workspaces switch <工作区名> [--no-stop] [--no-start] [--force]
|
|
455
|
+
codex-workspaces <工作区名>
|
|
456
|
+
切换 ~/.codex 链接到指定工作区目录。
|
|
457
|
+
macOS 上默认会关闭 Codex App、切换工作区、再启动 Codex App。
|
|
458
|
+
Linux/Windows 上会跳过 App 启停,只切换工作区链接。
|
|
459
|
+
|
|
460
|
+
codex-workspaces stop [--force]
|
|
461
|
+
关闭 Codex App。当前仅支持 macOS。
|
|
462
|
+
|
|
463
|
+
codex-workspaces start
|
|
464
|
+
启动 Codex App。当前仅支持 macOS。
|
|
465
|
+
|
|
466
|
+
codex-workspaces restart [--force]
|
|
467
|
+
重启 Codex App。当前仅支持 macOS。
|
|
468
|
+
|
|
469
|
+
codex-workspaces create <工作区名> [--migrate-current]
|
|
470
|
+
创建新的工作区目录 ~/.codex-<工作区名>。
|
|
471
|
+
加 --migrate-current 可将已有的真实 ~/.codex 目录迁移为该工作区。
|
|
472
|
+
|
|
473
|
+
codex-workspaces install [目录]
|
|
474
|
+
安装 Python 启动器到 PATH 目录。推荐优先使用 pipx 或 pip 安装。
|
|
475
|
+
|
|
476
|
+
codex-workspaces help
|
|
477
|
+
显示帮助。
|
|
478
|
+
|
|
479
|
+
环境变量:
|
|
480
|
+
CODEX_APP_NAME App 名称,默认 Codex
|
|
481
|
+
CODEX_QUIT_TIMEOUT 等待 App 退出秒数,默认 20
|
|
482
|
+
CODEX_WORKSPACES_LINK 当前工作区链接,默认 ~/.codex
|
|
483
|
+
CODEX_WORKSPACES_PREFIX 工作区目录前缀,默认 ~/.codex-
|
|
484
|
+
CODEX_WORKSPACES_LANG 强制提示语言,可设为 zh 或 en"""
|
|
485
|
+
|
|
486
|
+
return """codex-workspaces - Codex multi-workspace switcher
|
|
487
|
+
|
|
488
|
+
Workspace layout:
|
|
489
|
+
Active workspace: ~/.codex symlink/directory link
|
|
490
|
+
Workspace dirs: ~/.codex-work workspace name: work
|
|
491
|
+
~/.codex-personal workspace name: personal
|
|
492
|
+
|
|
493
|
+
Usage:
|
|
494
|
+
codex-workspaces list | ls
|
|
495
|
+
List all workspace directories and mark the active workspace.
|
|
496
|
+
|
|
497
|
+
codex-workspaces current
|
|
498
|
+
Show where ~/.codex currently points.
|
|
499
|
+
|
|
500
|
+
codex-workspaces use <workspace> [--no-stop] [--no-start] [--force]
|
|
501
|
+
codex-workspaces switch <workspace> [--no-stop] [--no-start] [--force]
|
|
502
|
+
codex-workspaces <workspace>
|
|
503
|
+
Switch the ~/.codex link to the selected workspace directory.
|
|
504
|
+
On macOS this quits Codex App, switches the workspace, then starts Codex App.
|
|
505
|
+
On Linux and Windows app control is skipped and only the workspace link changes.
|
|
506
|
+
|
|
507
|
+
codex-workspaces stop [--force]
|
|
508
|
+
Quit Codex App. Currently supported on macOS only.
|
|
509
|
+
|
|
510
|
+
codex-workspaces start
|
|
511
|
+
Start Codex App. Currently supported on macOS only.
|
|
512
|
+
|
|
513
|
+
codex-workspaces restart [--force]
|
|
514
|
+
Restart Codex App. Currently supported on macOS only.
|
|
515
|
+
|
|
516
|
+
codex-workspaces create <workspace> [--migrate-current]
|
|
517
|
+
Create a new workspace directory ~/.codex-<workspace>.
|
|
518
|
+
Add --migrate-current to migrate an existing real ~/.codex directory.
|
|
519
|
+
|
|
520
|
+
codex-workspaces install [directory]
|
|
521
|
+
Install a Python launcher into a PATH directory. pipx or pip is preferred.
|
|
522
|
+
|
|
523
|
+
codex-workspaces help
|
|
524
|
+
Show this help.
|
|
525
|
+
|
|
526
|
+
Environment variables:
|
|
527
|
+
CODEX_APP_NAME App name, default: Codex
|
|
528
|
+
CODEX_QUIT_TIMEOUT Seconds to wait for app exit, default: 20
|
|
529
|
+
CODEX_WORKSPACES_LINK Active workspace link, default: ~/.codex
|
|
530
|
+
CODEX_WORKSPACES_PREFIX Workspace directory prefix, default: ~/.codex-
|
|
531
|
+
CODEX_WORKSPACES_LANG Force output language: zh or en"""
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shlex
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Mapping, Optional, Sequence, TextIO
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .errors import CodexWorkspacesError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
CODEX_TERMINAL_ENV = (
|
|
20
|
+
"CODEX_THREAD_ID",
|
|
21
|
+
"CODEX_SANDBOX",
|
|
22
|
+
"CODEX_CI",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
CODEX_DELEGATE_UNSET_ENV = (
|
|
26
|
+
"CODEX_SHELL",
|
|
27
|
+
"CODEX_THREAD_ID",
|
|
28
|
+
"CODEX_SANDBOX",
|
|
29
|
+
"CODEX_SANDBOX_NETWORK_DISABLED",
|
|
30
|
+
"CODEX_CI",
|
|
31
|
+
"CODEX_INTERNAL_ORIGINATOR_OVERRIDE",
|
|
32
|
+
"__CFBundleIdentifier",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SystemPlatform:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
env: Optional[Mapping[str, str]] = None,
|
|
40
|
+
python_executable: Optional[str] = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
self.env = dict(os.environ if env is None else env)
|
|
43
|
+
self.python_executable = python_executable or sys.executable
|
|
44
|
+
self.system = platform.system().lower()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_macos(self) -> bool:
|
|
48
|
+
return self.system == "darwin"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_windows(self) -> bool:
|
|
52
|
+
return self.system == "windows"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def supports_app_control(self) -> bool:
|
|
56
|
+
return self.is_macos
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def supports_external_terminal_delegation(self) -> bool:
|
|
60
|
+
return self.is_macos
|
|
61
|
+
|
|
62
|
+
def apple_language(self) -> Optional[str]:
|
|
63
|
+
if not self.is_macos or shutil.which("defaults") is None:
|
|
64
|
+
return None
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
["defaults", "read", "-g", "AppleLanguages"],
|
|
67
|
+
check=False,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
)
|
|
71
|
+
if result.returncode != 0:
|
|
72
|
+
return None
|
|
73
|
+
for line in result.stdout.splitlines():
|
|
74
|
+
if '"' in line:
|
|
75
|
+
parts = line.split('"')
|
|
76
|
+
if len(parts) >= 2 and parts[1]:
|
|
77
|
+
return parts[1]
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def is_codex_terminal(self) -> bool:
|
|
81
|
+
if self.env.get("CODEX_SHELL", "").lower() in {"1", "true", "yes"}:
|
|
82
|
+
return True
|
|
83
|
+
if any(self.env.get(name) for name in CODEX_TERMINAL_ENV):
|
|
84
|
+
return True
|
|
85
|
+
origin = self.env.get("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "")
|
|
86
|
+
if "codex" in origin.lower():
|
|
87
|
+
return True
|
|
88
|
+
bundle_id = self.env.get("__CFBundleIdentifier", "")
|
|
89
|
+
return bundle_id.startswith("com.openai.codex")
|
|
90
|
+
|
|
91
|
+
def is_directory_link(self, path: Path) -> bool:
|
|
92
|
+
if path.is_symlink():
|
|
93
|
+
return True
|
|
94
|
+
if not self.is_windows or not path.exists() or not path.is_dir():
|
|
95
|
+
return False
|
|
96
|
+
try:
|
|
97
|
+
absolute = os.path.normcase(os.path.abspath(path))
|
|
98
|
+
resolved = os.path.normcase(os.path.realpath(path))
|
|
99
|
+
except OSError:
|
|
100
|
+
return False
|
|
101
|
+
return absolute != resolved
|
|
102
|
+
|
|
103
|
+
def create_directory_link(self, target: Path, link: Path) -> None:
|
|
104
|
+
try:
|
|
105
|
+
link.symlink_to(target, target_is_directory=True)
|
|
106
|
+
return
|
|
107
|
+
except OSError as exc:
|
|
108
|
+
if not self.is_windows:
|
|
109
|
+
raise exc
|
|
110
|
+
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
|
113
|
+
check=False,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
)
|
|
117
|
+
if result.returncode != 0:
|
|
118
|
+
detail = (result.stderr or result.stdout).strip()
|
|
119
|
+
raise CodexWorkspacesError(
|
|
120
|
+
f"Could not create directory link {link} -> {target}: {detail}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def remove_directory_link(self, link: Path) -> None:
|
|
124
|
+
if link.is_symlink():
|
|
125
|
+
link.unlink()
|
|
126
|
+
return
|
|
127
|
+
if self.is_windows and self.is_directory_link(link):
|
|
128
|
+
link.rmdir()
|
|
129
|
+
return
|
|
130
|
+
raise CodexWorkspacesError(f"Refusing to remove non-link path: {link}")
|
|
131
|
+
|
|
132
|
+
def app_running_status(self, app_name: str) -> Optional[bool]:
|
|
133
|
+
if shutil.which("pgrep") is None:
|
|
134
|
+
return None
|
|
135
|
+
result = subprocess.run(
|
|
136
|
+
["pgrep", "-x", app_name],
|
|
137
|
+
check=False,
|
|
138
|
+
stdout=subprocess.DEVNULL,
|
|
139
|
+
stderr=subprocess.DEVNULL,
|
|
140
|
+
)
|
|
141
|
+
if result.returncode == 0:
|
|
142
|
+
return True
|
|
143
|
+
if result.returncode == 1:
|
|
144
|
+
return False
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def stop_app(
|
|
148
|
+
self,
|
|
149
|
+
app_name: str,
|
|
150
|
+
timeout: int,
|
|
151
|
+
force: bool,
|
|
152
|
+
stdout: TextIO,
|
|
153
|
+
) -> None:
|
|
154
|
+
if not self.supports_app_control:
|
|
155
|
+
raise CodexWorkspacesError(
|
|
156
|
+
f"App stop is only supported on macOS. Use --no-stop on this platform."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
running = self.app_running_status(app_name)
|
|
160
|
+
if running is False:
|
|
161
|
+
print(f"{app_name} is not running.", file=stdout)
|
|
162
|
+
return
|
|
163
|
+
if running is None:
|
|
164
|
+
raise CodexWorkspacesError(f"Cannot confirm whether {app_name} is running.")
|
|
165
|
+
|
|
166
|
+
print(f"Quitting {app_name} ...", file=stdout)
|
|
167
|
+
subprocess.run(
|
|
168
|
+
["osascript", "-e", f'tell application "{app_name}" to quit'],
|
|
169
|
+
check=False,
|
|
170
|
+
stdout=subprocess.DEVNULL,
|
|
171
|
+
stderr=subprocess.DEVNULL,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
waited = 0
|
|
175
|
+
while self.app_running_status(app_name) and waited < timeout:
|
|
176
|
+
time.sleep(1)
|
|
177
|
+
waited += 1
|
|
178
|
+
|
|
179
|
+
if self.app_running_status(app_name):
|
|
180
|
+
if not force:
|
|
181
|
+
raise CodexWorkspacesError(
|
|
182
|
+
f"{app_name} did not exit within {timeout}s; add --force to force quit"
|
|
183
|
+
)
|
|
184
|
+
print(f"{app_name} did not exit within {timeout}s; forcing it to quit.", file=stdout)
|
|
185
|
+
subprocess.run(
|
|
186
|
+
["killall", app_name],
|
|
187
|
+
check=False,
|
|
188
|
+
stdout=subprocess.DEVNULL,
|
|
189
|
+
stderr=subprocess.DEVNULL,
|
|
190
|
+
)
|
|
191
|
+
time.sleep(1)
|
|
192
|
+
|
|
193
|
+
print(f"{app_name} has quit.", file=stdout)
|
|
194
|
+
|
|
195
|
+
def start_app(self, app_name: str) -> None:
|
|
196
|
+
if not self.supports_app_control:
|
|
197
|
+
raise CodexWorkspacesError("App start is only supported on macOS.")
|
|
198
|
+
result = subprocess.run(["open", "-a", app_name], check=False)
|
|
199
|
+
if result.returncode != 0:
|
|
200
|
+
raise CodexWorkspacesError(f"Could not start {app_name}.")
|
|
201
|
+
|
|
202
|
+
def delegate_to_external_terminal(
|
|
203
|
+
self,
|
|
204
|
+
config: Config,
|
|
205
|
+
action: str,
|
|
206
|
+
argv: Sequence[str],
|
|
207
|
+
stdout: TextIO,
|
|
208
|
+
) -> None:
|
|
209
|
+
if not self.supports_external_terminal_delegation:
|
|
210
|
+
raise CodexWorkspacesError(
|
|
211
|
+
"Cannot delegate to an external terminal on this platform."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
print(
|
|
215
|
+
f"Detected the built-in Codex terminal; delegating {action} to Terminal.app...",
|
|
216
|
+
file=stdout,
|
|
217
|
+
)
|
|
218
|
+
command = self._delegated_command(config, argv)
|
|
219
|
+
fd, raw_name = tempfile.mkstemp(prefix="codex-workspaces.", dir=tempfile.gettempdir())
|
|
220
|
+
os.close(fd)
|
|
221
|
+
launcher = Path(raw_name + ".command")
|
|
222
|
+
Path(raw_name).rename(launcher)
|
|
223
|
+
launcher.write_text(
|
|
224
|
+
"\n".join(
|
|
225
|
+
[
|
|
226
|
+
"#!/usr/bin/env bash",
|
|
227
|
+
command,
|
|
228
|
+
"status=$?",
|
|
229
|
+
'printf "\\n[codex-workspaces] Done with exit status %s. You can close this window.\\n" "$status"',
|
|
230
|
+
'rm -f "$0"',
|
|
231
|
+
'exit "$status"',
|
|
232
|
+
"",
|
|
233
|
+
]
|
|
234
|
+
),
|
|
235
|
+
encoding="utf-8",
|
|
236
|
+
)
|
|
237
|
+
launcher.chmod(launcher.stat().st_mode | stat.S_IXUSR)
|
|
238
|
+
result = subprocess.run(
|
|
239
|
+
["open", "-a", "Terminal", str(launcher)],
|
|
240
|
+
check=False,
|
|
241
|
+
stdout=subprocess.DEVNULL,
|
|
242
|
+
stderr=subprocess.DEVNULL,
|
|
243
|
+
)
|
|
244
|
+
if result.returncode != 0:
|
|
245
|
+
launcher.unlink(missing_ok=True)
|
|
246
|
+
raise CodexWorkspacesError(
|
|
247
|
+
"Could not open Terminal.app. Open the system Terminal manually and run this command again."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _delegated_command(self, config: Config, argv: Sequence[str]) -> str:
|
|
251
|
+
pieces = [f"unset {' '.join(CODEX_DELEGATE_UNSET_ENV)};"]
|
|
252
|
+
exports = {
|
|
253
|
+
"CODEX_APP_NAME": config.app_name,
|
|
254
|
+
"CODEX_QUIT_TIMEOUT": str(config.quit_timeout),
|
|
255
|
+
"CODEX_WORKSPACES_LINK": str(config.active_link),
|
|
256
|
+
"CODEX_WORKSPACES_PREFIX": config.workspace_prefix,
|
|
257
|
+
"CODEX_WORKSPACES_LANG": config.lang,
|
|
258
|
+
}
|
|
259
|
+
for key, value in exports.items():
|
|
260
|
+
pieces.append(f"export {key}={shlex.quote(value)};")
|
|
261
|
+
pieces.append(shlex.quote(self.python_executable))
|
|
262
|
+
pieces.append("-m codex_workspaces")
|
|
263
|
+
pieces.extend(shlex.quote(arg) for arg in argv)
|
|
264
|
+
return " ".join(pieces)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-workspaces
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-platform Codex workspace switcher with a preserved macOS shell workflow.
|
|
5
|
+
Author: blockchain-project-lives
|
|
6
|
+
Project-URL: Homepage, https://github.com/blockchain-project-lives/codex-workspaces
|
|
7
|
+
Project-URL: Repository, https://github.com/blockchain-project-lives/codex-workspaces
|
|
8
|
+
Project-URL: Changelog, https://github.com/blockchain-project-lives/codex-workspaces/blob/main/CHANGELOG.md
|
|
9
|
+
Keywords: codex,workspaces,cli,symlink
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
27
|
+
Requires-Dist: twine>=5; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# codex-workspaces
|
|
30
|
+
|
|
31
|
+
English | [简体中文](README.zh-CN.md) | [Changelog](CHANGELOG.md)
|
|
32
|
+
|
|
33
|
+
`codex-workspaces` switches between multiple Codex workspace directories by keeping each workspace in `~/.codex-<name>` and pointing the active `~/.codex` path at the selected workspace.
|
|
34
|
+
|
|
35
|
+
The project now has two entry points:
|
|
36
|
+
|
|
37
|
+
- A cross-platform Python 3 CLI for Linux, macOS, and Windows.
|
|
38
|
+
- The original macOS Bash script, still kept at [`codex-workspaces`](codex-workspaces), for users who installed the shell workflow directly.
|
|
39
|
+
|
|
40
|
+
On macOS, the Python CLI preserves the original app workflow: stop Codex App, switch the workspace link, and start Codex App again. On Linux and Windows, app control is skipped and the CLI only switches the workspace link.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- Manage workspace directories such as `~/.codex-work` and `~/.codex-personal`.
|
|
45
|
+
- Switch the active `~/.codex` symlink or directory link.
|
|
46
|
+
- Create workspace directories and migrate an existing real `~/.codex` directory.
|
|
47
|
+
- Keep macOS Codex App stop/start/restart support.
|
|
48
|
+
- Block unsafe operations from a detected Codex built-in terminal when they cannot be delegated safely.
|
|
49
|
+
- Support English and Chinese output through `CODEX_WORKSPACES_LANG`.
|
|
50
|
+
- Package as a Python project with tests, CI, and PyPI publishing workflow.
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Python 3.9 or newer for the Python CLI.
|
|
55
|
+
- macOS is required only for Codex App control commands: `start`, `stop`, and `restart`.
|
|
56
|
+
- Linux and macOS use directory symlinks.
|
|
57
|
+
- Windows uses directory symlinks when available and falls back to directory junctions.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
Install the Python CLI from PyPI:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python3 -m pip install codex-workspaces
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
For isolated CLI installs, `pipx` is recommended:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pipx install codex-workspaces
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Install from a local checkout for development:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python3 -m pip install -e ".[dev]"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The legacy macOS shell installer is still available:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
tmp="$(mktemp -t codex-workspaces.XXXXXX)" && curl -fsSL https://raw.githubusercontent.com/blockchain-project-lives/codex-workspaces/main/codex-workspaces -o "$tmp" && bash "$tmp" install && rm -f "$tmp"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Workspace Layout
|
|
86
|
+
|
|
87
|
+
Default layout:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
~/.codex -> active workspace link
|
|
91
|
+
~/.codex-work workspace directory named work
|
|
92
|
+
~/.codex-personal workspace directory named personal
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Customize paths with `CODEX_WORKSPACES_LINK` and `CODEX_WORKSPACES_PREFIX`.
|
|
96
|
+
|
|
97
|
+
## Usage
|
|
98
|
+
|
|
99
|
+
Create workspaces:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
codex-workspaces create personal
|
|
103
|
+
codex-workspaces create work
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If you already have a real `~/.codex` directory, migrate it first:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
codex-workspaces create personal --migrate-current
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Switch workspaces:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
codex-workspaces work
|
|
116
|
+
codex-workspaces use personal
|
|
117
|
+
codex-workspaces switch work
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
On macOS, switching stops and restarts Codex App by default. Skip those steps when needed:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
codex-workspaces work --no-stop --no-start
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Inspect workspaces:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
codex-workspaces list
|
|
130
|
+
codex-workspaces current
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Control Codex App on macOS:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
codex-workspaces stop
|
|
137
|
+
codex-workspaces start
|
|
138
|
+
codex-workspaces restart
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Environment Variables
|
|
142
|
+
|
|
143
|
+
| Variable | Default | Description |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `CODEX_APP_NAME` | `Codex` | macOS app name to control. |
|
|
146
|
+
| `CODEX_QUIT_TIMEOUT` | `20` | Seconds to wait for app exit. |
|
|
147
|
+
| `CODEX_WORKSPACES_LINK` | `$HOME/.codex` | Active workspace link path. |
|
|
148
|
+
| `CODEX_WORKSPACES_PREFIX` | `$HOME/.codex-` | Workspace directory prefix. |
|
|
149
|
+
| `CODEX_WORKSPACES_LANG` | auto | Force output language with `en` or `zh`. |
|
|
150
|
+
|
|
151
|
+
Only the `CODEX_WORKSPACES_*` variables are used for workspace-specific configuration.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
python3 -m pip install -e ".[dev]"
|
|
157
|
+
python3 -m pytest
|
|
158
|
+
python3 -m build
|
|
159
|
+
python3 -m twine check dist/*
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Design, test, and release notes live in [`docs/`](docs/).
|
|
163
|
+
|
|
164
|
+
## Publishing
|
|
165
|
+
|
|
166
|
+
CI runs tests on Linux, macOS, and Windows across Python 3.9, 3.11, and 3.13. The `Publish to PyPI` workflow builds and publishes distributions when a GitHub Release is published or the workflow is run manually.
|
|
167
|
+
|
|
168
|
+
Configure TestPyPI and PyPI Trusted Publishing for this repository before using the release workflows. See [`docs/RELEASE.zh-CN.md`](docs/RELEASE.zh-CN.md).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
codex_workspaces/__init__.py,sha256=s7IvQpwPf13ld8MXIP1fauIP9o4ls4k5vbQnVlJuvS4,66
|
|
2
|
+
codex_workspaces/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
|
|
3
|
+
codex_workspaces/cli.py,sha256=Y1P3R7hRsExz2yLE2a8ckxrrIYWmBgl_AOMsNz8RHMQ,3326
|
|
4
|
+
codex_workspaces/config.py,sha256=JiZVR8Sn_liOwoTXse_oIwq57qmuOZTTMV_5bH8T4qs,2057
|
|
5
|
+
codex_workspaces/core.py,sha256=_ZYFgc991raVxrWV0XGgytz-wzq21shn5FEg44Uh_Ow,22092
|
|
6
|
+
codex_workspaces/errors.py,sha256=ZCwq1IWf3sM8h6Z1Tcy_dEGo958KbQ4AQ83P8lwTKWI,258
|
|
7
|
+
codex_workspaces/platforms.py,sha256=7Ypj0MJONUUk8tm_sUiiHV8im4zlAdFQ3pkR2FHkM1Q,8747
|
|
8
|
+
codex_workspaces-0.1.0.dist-info/METADATA,sha256=trEuBgWeeTpJkrb9U44-u8HgFGOfPICmW9c6lD0qbyM,5449
|
|
9
|
+
codex_workspaces-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
codex_workspaces-0.1.0.dist-info/entry_points.txt,sha256=l0zxN8J4Dd067XgEC4GIRgEEMzvYs63MrrYGzwZGXsQ,63
|
|
11
|
+
codex_workspaces-0.1.0.dist-info/top_level.txt,sha256=2ExV9AFvjN9HfHpx29hpgKWlcHiajfe_NDByijO7YTE,17
|
|
12
|
+
codex_workspaces-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
codex_workspaces
|