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.
- coderfleet/__init__.py +1 -0
- coderfleet/__main__.py +4 -0
- coderfleet/cli.py +212 -0
- coderfleet/compose.py +176 -0
- coderfleet/config.py +69 -0
- coderfleet/config_cmds.py +243 -0
- coderfleet/data/Dockerfile +92 -0
- coderfleet/data/__init__.py +0 -0
- coderfleet/data/accounts.conf.example +26 -0
- coderfleet/data/config.conf.example +31 -0
- coderfleet/data/entrypoint.sh +56 -0
- coderfleet/data/projects.conf.example +17 -0
- coderfleet/data/scripts/coderfleet_usage_status.py +138 -0
- coderfleet/docker_ops.py +385 -0
- coderfleet/init_wizard.py +227 -0
- coderfleet/login_cmd.py +168 -0
- coderfleet/server/__init__.py +0 -0
- coderfleet/server/docker_mgr.py +45 -0
- coderfleet/server/main.py +546 -0
- coderfleet/server/models.py +285 -0
- coderfleet/server/scheduler.py +1219 -0
- coderfleet/server/static/css/main.css +2906 -0
- coderfleet/server/static/index.html +378 -0
- coderfleet/server/static/js/accounts.js +85 -0
- coderfleet/server/static/js/app.js +28 -0
- coderfleet/server/static/js/chat.js +743 -0
- coderfleet/server/static/js/log.js +145 -0
- coderfleet/server/static/js/nav.js +46 -0
- coderfleet/server/static/js/projects.js +298 -0
- coderfleet/server/static/js/renderer.js +586 -0
- coderfleet/server/static/js/state.js +76 -0
- coderfleet/server/static/js/submit.js +200 -0
- coderfleet/server/static/js/tasks.js +92 -0
- coderfleet/server/static/js/terminal.js +347 -0
- coderfleet/server/static/js/utils.js +147 -0
- coderfleet/server/static/vendor/marked.min.js +6 -0
- coderfleet/server/static/vendor/xterm/addon-fit.js +2 -0
- coderfleet/server/static/vendor/xterm/xterm.css +218 -0
- coderfleet/server/static/vendor/xterm/xterm.js +2 -0
- coderfleet/server/terminal.py +129 -0
- coderfleet/task_cmds.py +311 -0
- coderfleet-0.1.0.dist-info/METADATA +492 -0
- coderfleet-0.1.0.dist-info/RECORD +45 -0
- coderfleet-0.1.0.dist-info/WHEEL +4 -0
- 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
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
|