coderfleet 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.
Files changed (45) hide show
  1. coderfleet/__init__.py +1 -0
  2. coderfleet/__main__.py +4 -0
  3. coderfleet/cli.py +212 -0
  4. coderfleet/compose.py +176 -0
  5. coderfleet/config.py +69 -0
  6. coderfleet/config_cmds.py +243 -0
  7. coderfleet/data/Dockerfile +92 -0
  8. coderfleet/data/__init__.py +0 -0
  9. coderfleet/data/accounts.conf.example +26 -0
  10. coderfleet/data/config.conf.example +31 -0
  11. coderfleet/data/entrypoint.sh +56 -0
  12. coderfleet/data/projects.conf.example +17 -0
  13. coderfleet/data/scripts/coderfleet_usage_status.py +138 -0
  14. coderfleet/docker_ops.py +385 -0
  15. coderfleet/init_wizard.py +227 -0
  16. coderfleet/login_cmd.py +168 -0
  17. coderfleet/server/__init__.py +0 -0
  18. coderfleet/server/docker_mgr.py +45 -0
  19. coderfleet/server/main.py +546 -0
  20. coderfleet/server/models.py +285 -0
  21. coderfleet/server/scheduler.py +1219 -0
  22. coderfleet/server/static/css/main.css +2906 -0
  23. coderfleet/server/static/index.html +378 -0
  24. coderfleet/server/static/js/accounts.js +85 -0
  25. coderfleet/server/static/js/app.js +28 -0
  26. coderfleet/server/static/js/chat.js +743 -0
  27. coderfleet/server/static/js/log.js +145 -0
  28. coderfleet/server/static/js/nav.js +46 -0
  29. coderfleet/server/static/js/projects.js +298 -0
  30. coderfleet/server/static/js/renderer.js +586 -0
  31. coderfleet/server/static/js/state.js +76 -0
  32. coderfleet/server/static/js/submit.js +200 -0
  33. coderfleet/server/static/js/tasks.js +92 -0
  34. coderfleet/server/static/js/terminal.js +347 -0
  35. coderfleet/server/static/js/utils.js +147 -0
  36. coderfleet/server/static/vendor/marked.min.js +6 -0
  37. coderfleet/server/static/vendor/xterm/addon-fit.js +2 -0
  38. coderfleet/server/static/vendor/xterm/xterm.css +218 -0
  39. coderfleet/server/static/vendor/xterm/xterm.js +2 -0
  40. coderfleet/server/terminal.py +129 -0
  41. coderfleet/task_cmds.py +311 -0
  42. coderfleet-0.1.0.dist-info/METADATA +492 -0
  43. coderfleet-0.1.0.dist-info/RECORD +45 -0
  44. coderfleet-0.1.0.dist-info/WHEEL +4 -0
  45. coderfleet-0.1.0.dist-info/entry_points.txt +2 -0
coderfleet/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
coderfleet/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from coderfleet.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
coderfleet/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import signal
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ import click
9
+ from pathlib import Path
10
+
11
+ from coderfleet.config import get_workspace, load_config
12
+
13
+
14
+ # ── server daemon helpers ──────────────────────────────────────
15
+
16
+
17
+ def _pid_file(ws: Path) -> Path:
18
+ return ws / "server.pid"
19
+
20
+
21
+ def _log_file(ws: Path) -> Path:
22
+ return ws / "server.log"
23
+
24
+
25
+ def _is_running(pid: int) -> bool:
26
+ try:
27
+ os.kill(pid, 0)
28
+ return True
29
+ except (OSError, ProcessLookupError):
30
+ return False
31
+
32
+
33
+ def _read_pid(ws: Path) -> int | None:
34
+ p = _pid_file(ws)
35
+ if not p.exists():
36
+ return None
37
+ try:
38
+ return int(p.read_text().strip())
39
+ except ValueError:
40
+ return None
41
+
42
+
43
+ def _server_start_daemon(ws: Path, port: int) -> None:
44
+ pid = _read_pid(ws)
45
+ if pid and _is_running(pid):
46
+ click.secho(f"server 已在运行(PID {pid})", fg="yellow")
47
+ click.echo(f" 日志:{_log_file(ws)}")
48
+ click.echo(f" 停止:coderfleet server --stop")
49
+ return
50
+
51
+ log = _log_file(ws)
52
+ cmd = [sys.executable, "-m", "coderfleet", "server", "--port", str(port)]
53
+
54
+ with log.open("a") as fh:
55
+ proc = subprocess.Popen(
56
+ cmd,
57
+ stdout=fh,
58
+ stderr=fh,
59
+ stdin=subprocess.DEVNULL,
60
+ start_new_session=True, # 脱离当前终端会话
61
+ env={**os.environ, "CODERFLEET_WORKSPACE": str(ws), "CODERFLEET_PORT": str(port)},
62
+ )
63
+
64
+ _pid_file(ws).write_text(str(proc.pid))
65
+
66
+ # 短暂等待确认进程存活
67
+ time.sleep(1)
68
+ if _is_running(proc.pid):
69
+ click.secho(f"✓ server 已在后台启动(PID {proc.pid})", fg="green")
70
+ click.echo(f" Web UI:http://localhost:{port}")
71
+ click.echo(f" 日志:{log}")
72
+ click.echo(f" 停止:coderfleet server --stop")
73
+ else:
74
+ _pid_file(ws).unlink(missing_ok=True)
75
+ raise click.ClickException(f"server 启动失败,查看日志:{log}")
76
+
77
+
78
+ def _server_stop(ws: Path) -> None:
79
+ pid = _read_pid(ws)
80
+ if pid is None:
81
+ click.secho("server 未在运行(找不到 PID 文件)", fg="yellow")
82
+ return
83
+ if not _is_running(pid):
84
+ _pid_file(ws).unlink(missing_ok=True)
85
+ click.secho("server 未在运行(PID 文件已清理)", fg="yellow")
86
+ return
87
+
88
+ os.kill(pid, signal.SIGTERM)
89
+ for _ in range(20): # 最多等 10 秒
90
+ time.sleep(0.5)
91
+ if not _is_running(pid):
92
+ break
93
+ else:
94
+ os.kill(pid, signal.SIGKILL)
95
+
96
+ _pid_file(ws).unlink(missing_ok=True)
97
+ click.secho(f"✓ server 已停止(PID {pid})", fg="green")
98
+
99
+
100
+ def _server_status(ws: Path) -> None:
101
+ pid = _read_pid(ws)
102
+ if pid and _is_running(pid):
103
+ click.secho(f"● server 运行中(PID {pid})", fg="green")
104
+ click.echo(f" 日志:{_log_file(ws)}")
105
+ else:
106
+ if pid:
107
+ _pid_file(ws).unlink(missing_ok=True)
108
+ click.secho("○ server 未运行", fg="yellow")
109
+
110
+
111
+ @click.group()
112
+ @click.pass_context
113
+ def main(ctx: click.Context) -> None:
114
+ """CoderFleet
115
+
116
+ Manages multiple Claude Code / Codex accounts in isolated Docker containers.
117
+
118
+ Workspace location: CODERFLEET_WORKSPACE env var, or ~/.coderfleet/ by default.
119
+ """
120
+ ctx.ensure_object(dict)
121
+ ctx.obj["workspace"] = get_workspace()
122
+
123
+
124
+ from coderfleet.task_cmds import task_group
125
+ from coderfleet.config_cmds import account_group, project_group
126
+ from coderfleet.docker_ops import (
127
+ cmd_build, cmd_apply, cmd_up, cmd_down,
128
+ cmd_restart, cmd_status, cmd_logs, cmd_enter, cmd_check_proxy,
129
+ )
130
+ from coderfleet.login_cmd import cmd_login
131
+
132
+ main.add_command(task_group)
133
+ main.add_command(account_group)
134
+ main.add_command(project_group)
135
+ main.add_command(cmd_build)
136
+ main.add_command(cmd_apply)
137
+ main.add_command(cmd_up)
138
+ main.add_command(cmd_down)
139
+ main.add_command(cmd_restart)
140
+ main.add_command(cmd_status)
141
+ main.add_command(cmd_logs)
142
+ main.add_command(cmd_enter)
143
+ main.add_command(cmd_check_proxy)
144
+ main.add_command(cmd_login)
145
+
146
+
147
+ @main.command("init")
148
+ @click.pass_context
149
+ def cmd_init(ctx: click.Context) -> None:
150
+ """Initialize the CoderFleet workspace (interactive setup wizard)."""
151
+ from coderfleet.init_wizard import run_init_wizard
152
+ run_init_wizard(ctx.obj["workspace"])
153
+
154
+
155
+ @main.command("server")
156
+ @click.option("--port", default=None, type=int, envvar="CODERFLEET_PORT",
157
+ help="监听端口(默认 8765)")
158
+ @click.option("--daemon", "-d", is_flag=True,
159
+ help="后台守护进程模式运行")
160
+ @click.option("--stop", "do_stop", is_flag=True,
161
+ help="停止后台运行的 server")
162
+ @click.option("--status", "do_status", is_flag=True,
163
+ help="查看 server 运行状态")
164
+ @click.pass_context
165
+ def cmd_server(
166
+ ctx: click.Context,
167
+ port: int | None,
168
+ daemon: bool,
169
+ do_stop: bool,
170
+ do_status: bool,
171
+ ) -> None:
172
+ """Start the FastAPI scheduler server and Web UI.
173
+
174
+ \b
175
+ coderfleet server # 前台运行(Ctrl+C 停止)
176
+ coderfleet server --daemon # 后台守护进程
177
+ coderfleet server --stop # 停止后台 server
178
+ coderfleet server --status # 查看运行状态
179
+ """
180
+ import uvicorn
181
+
182
+ ws = ctx.obj["workspace"]
183
+
184
+ if do_stop:
185
+ _server_stop(ws)
186
+ return
187
+
188
+ if do_status:
189
+ _server_status(ws)
190
+ return
191
+
192
+ cfg = load_config(ws)
193
+ resolved_port = port or int(cfg.get("CODERFLEET_PORT", 8765))
194
+
195
+ if daemon:
196
+ _server_start_daemon(ws, resolved_port)
197
+ return
198
+
199
+ # 前台模式
200
+ os.environ["CODERFLEET_WORKSPACE"] = str(ws)
201
+ os.environ["CODERFLEET_PORT"] = str(resolved_port)
202
+
203
+ click.echo(f"Workspace: {ws}")
204
+ click.echo(f"Starting CoderFleet server at http://localhost:{resolved_port}")
205
+
206
+ uvicorn.run(
207
+ "coderfleet.server.main:app",
208
+ host="0.0.0.0",
209
+ port=resolved_port,
210
+ reload=False,
211
+ workers=1,
212
+ )
coderfleet/compose.py ADDED
@@ -0,0 +1,176 @@
1
+ """
2
+ compose.py — docker-compose.yml 生成器
3
+
4
+ 将 accounts.conf / projects.conf / config.conf 翻译为 docker-compose.yml。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import click
12
+ import yaml
13
+
14
+ from coderfleet.config import load_config, parse_conf
15
+
16
+
17
+ def _make_dumper() -> type[yaml.Dumper]:
18
+ """Return a YAML Dumper that quotes YAML-1.1 boolean-ambiguous strings."""
19
+ _BOOL_LIKE = frozenset(["off", "on", "yes", "no", "true", "false", "null", "~"])
20
+
21
+ class QuotedDumper(yaml.Dumper):
22
+ pass
23
+
24
+ def _str_repr(dumper: yaml.Dumper, data: str) -> yaml.ScalarNode:
25
+ if data.lower() in _BOOL_LIKE:
26
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data, style='"')
27
+ return dumper.represent_scalar("tag:yaml.org,2002:str", data)
28
+
29
+ QuotedDumper.add_representer(str, _str_repr)
30
+ return QuotedDumper
31
+
32
+
33
+ def generate_compose(ws: Path) -> dict[str, Any]:
34
+ """Build docker-compose data dict from workspace config files."""
35
+ cfg = load_config(ws)
36
+ projects = [
37
+ p for p in parse_conf(ws / "projects.conf")
38
+ if "NAME" in p and "ACCOUNT" in p and "PATH" in p
39
+ ]
40
+ if not projects:
41
+ raise click.ClickException("projects.conf 中没有有效项目")
42
+
43
+ accounts = {r["NAME"]: r for r in parse_conf(ws / "accounts.conf") if "NAME" in r}
44
+
45
+ image = f"{cfg.get('IMAGE_NAME', 'coderfleet')}:{cfg.get('IMAGE_TAG', 'latest')}"
46
+ subnet = cfg.get("INTERNAL_SUBNET", "172.21.0.0/16")
47
+ relay_ip = cfg.get("RELAY_IP", "172.21.0.2")
48
+ relay_port = cfg.get("RELAY_LISTEN_PORT", "7890")
49
+ proxy_host = cfg.get("PROXY_HOST", "host.docker.internal")
50
+ http_port = cfg.get("PROXY_HTTP_PORT", "7890")
51
+ relay_image = cfg.get("RELAY_IMAGE", "gogost/gost:3")
52
+ build_platform = cfg.get("BUILD_PLATFORM", "linux/amd64")
53
+
54
+ proxy_url = f"http://{relay_ip}:{relay_port}"
55
+ no_proxy = f"localhost,127.0.0.1,{subnet}"
56
+
57
+ services: dict[str, Any] = {}
58
+
59
+ services["proxy-relay"] = {
60
+ "image": relay_image,
61
+ "container_name": "coderfleet-proxy-relay",
62
+ "restart": "unless-stopped",
63
+ "networks": {
64
+ "intnet": {"ipv4_address": relay_ip},
65
+ "extnet": {},
66
+ },
67
+ "extra_hosts": ["host.docker.internal:host-gateway"],
68
+ "command": f"-L http://:{relay_port} -F \"{proxy_host}:{http_port}\"",
69
+ "healthcheck": {
70
+ "test": ["CMD", "sh", "-c", f"nc -z localhost {relay_port}"],
71
+ "interval": "8s",
72
+ "timeout": "4s",
73
+ "retries": 5,
74
+ "start_period": "5s",
75
+ },
76
+ }
77
+
78
+ count = 0
79
+ for p in projects:
80
+ pname = p["NAME"]
81
+ paccount = p["ACCOUNT"]
82
+ ppath = str(Path(p["PATH"]).expanduser())
83
+
84
+ acc = accounts.get(paccount)
85
+ if not acc:
86
+ click.secho(f" 警告:跳过项目 {pname}:账号 {paccount} 不存在", fg="yellow")
87
+ continue
88
+
89
+ acc_type = acc.get("TYPE", "codex")
90
+ acc_auth = acc.get("AUTH", "login")
91
+ acc_env_file = acc.get("ENV_FILE", "")
92
+ acc_proxy = acc.get("PROXY", "relay")
93
+
94
+ svc_name = f"{acc_type}-project-{pname}"
95
+ ctr_name = f"{acc_type}-{pname}"
96
+ auth_src = f"./accounts/{paccount}"
97
+ auth_dst = "/home/byclaw/.codex" if acc_type == "codex" else "/home/byclaw/.claude"
98
+
99
+ (ws / "accounts" / paccount).mkdir(parents=True, exist_ok=True)
100
+
101
+ environment: dict[str, str] = {
102
+ "CODEX_HOME": "/home/byclaw/.codex",
103
+ "CLAUDE_CONFIG_DIR": "/home/byclaw/.claude",
104
+ "CODERFLEET_ACCOUNT_NAME": paccount,
105
+ "CODERFLEET_ACCOUNT_TYPE": acc_type,
106
+ "CODERFLEET_ACCOUNT_AUTH": acc_auth,
107
+ "CODERFLEET_ACCOUNT_PROXY": acc_proxy,
108
+ }
109
+
110
+ if acc_proxy != "off":
111
+ environment.update({
112
+ "HTTP_PROXY": proxy_url,
113
+ "HTTPS_PROXY": proxy_url,
114
+ "http_proxy": proxy_url,
115
+ "https_proxy": proxy_url,
116
+ "ALL_PROXY": proxy_url,
117
+ "all_proxy": proxy_url,
118
+ "NO_PROXY": no_proxy,
119
+ "no_proxy": no_proxy,
120
+ "CODERFLEET_RELAY_IP": relay_ip,
121
+ "CODERFLEET_RELAY_PORT": relay_port,
122
+ })
123
+
124
+ svc: dict[str, Any] = {
125
+ "image": image,
126
+ "platform": build_platform,
127
+ "pull_policy": "never",
128
+ "container_name": ctr_name,
129
+ "restart": "unless-stopped",
130
+ "networks": {"intnet": {}} if acc_proxy != "off" else {"extnet": {}},
131
+ "environment": environment,
132
+ "volumes": [
133
+ f"{auth_src}:{auth_dst}",
134
+ f"{ppath}:/workspace",
135
+ ],
136
+ "working_dir": "/workspace",
137
+ }
138
+
139
+ if acc_auth == "env" and acc_env_file and acc_env_file != "-":
140
+ svc["env_file"] = [acc_env_file]
141
+
142
+ if acc_proxy != "off":
143
+ svc["depends_on"] = {"proxy-relay": {"condition": "service_healthy"}}
144
+
145
+ services[svc_name] = svc
146
+ count += 1
147
+ click.secho(f" [{acc_type}] {pname}(账号:{paccount},代理:{acc_proxy})", fg="cyan")
148
+
149
+ if count == 0:
150
+ raise click.ClickException("没有可用的项目(账号配置可能有误)")
151
+
152
+ return {
153
+ "networks": {
154
+ "intnet": {
155
+ "driver": "bridge",
156
+ "internal": True,
157
+ "ipam": {"config": [{"subnet": subnet}]},
158
+ },
159
+ "extnet": {"driver": "bridge"},
160
+ },
161
+ "services": services,
162
+ }
163
+
164
+
165
+ def write_compose(ws: Path) -> Path:
166
+ """Generate docker-compose.yml into workspace. Returns the file path."""
167
+ data = generate_compose(ws)
168
+ dumper = _make_dumper()
169
+ content = (
170
+ "# !! 此文件由 coderfleet apply 自动生成,请勿手动编辑 !!\n\n"
171
+ + yaml.dump(data, Dumper=dumper, allow_unicode=True,
172
+ sort_keys=False, default_flow_style=False)
173
+ )
174
+ path = ws / "docker-compose.yml"
175
+ path.write_text(content, encoding="utf-8")
176
+ return path
coderfleet/config.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+
8
+ DEFAULT_WORKSPACE = Path.home() / ".coderfleet"
9
+
10
+
11
+ def get_workspace() -> Path:
12
+ """
13
+ Resolve CoderFleet workspace directory.
14
+ Priority: CODERFLEET_WORKSPACE env var → ~/.coderfleet/
15
+ """
16
+ env = os.environ.get("CODERFLEET_WORKSPACE", "")
17
+ if env:
18
+ return Path(env).expanduser().resolve()
19
+ return DEFAULT_WORKSPACE
20
+
21
+
22
+ def ensure_workspace(ws: Path) -> None:
23
+ """Create workspace and required subdirectories."""
24
+ for sub in ("accounts", "tasks", "conversations"):
25
+ (ws / sub).mkdir(parents=True, exist_ok=True)
26
+
27
+
28
+ def parse_conf(path: Path) -> list[dict[str, str]]:
29
+ """
30
+ Parse a KEY=value space-tokenized .conf file.
31
+ Each non-comment, non-blank line becomes a dict.
32
+ """
33
+ records: list[dict[str, str]] = []
34
+ if not path.exists():
35
+ return records
36
+ for line in path.read_text(encoding="utf-8").splitlines():
37
+ line = line.strip().rstrip("\r")
38
+ if not line or line.startswith("#"):
39
+ continue
40
+ record: dict[str, str] = {}
41
+ for token in line.split():
42
+ if "=" in token:
43
+ k, v = token.split("=", 1)
44
+ record[k.upper()] = v
45
+ if record:
46
+ records.append(record)
47
+ return records
48
+
49
+
50
+ def write_conf_line(path: Path, tokens: dict[str, str]) -> None:
51
+ """Append a KEY=value line to a conf file."""
52
+ parts = [f"{k}={v}" for k, v in tokens.items()]
53
+ with path.open("a", encoding="utf-8") as f:
54
+ f.write(" ".join(parts) + "\n")
55
+
56
+
57
+ def remove_conf_entry(path: Path, key: str, value: str) -> None:
58
+ """Remove lines where KEY=value appears as a token, rewrite file in-place."""
59
+ pattern = re.compile(rf"\b{re.escape(key)}={re.escape(value)}(\s|$)")
60
+ lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
61
+ path.write_text("".join(l for l in lines if not pattern.search(l)), encoding="utf-8")
62
+
63
+
64
+ def load_config(ws: Path) -> dict[str, str]:
65
+ """Load config.conf from workspace into a flat dict."""
66
+ result: dict[str, str] = {}
67
+ for record in parse_conf(ws / "config.conf"):
68
+ result.update(record)
69
+ return result