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
@@ -0,0 +1,243 @@
1
+ """
2
+ config_cmds.py — coderfleet account / project 子命令
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ import click
12
+
13
+ from coderfleet.config import ensure_workspace, parse_conf, remove_conf_entry, write_conf_line
14
+
15
+ _NAME_RE = re.compile(r"^[a-zA-Z0-9-]+$")
16
+
17
+
18
+ def _container_name(project_name: str, acc_type: str) -> str:
19
+ return f"{acc_type}-{project_name}"
20
+
21
+
22
+ def _is_running(container: str) -> bool:
23
+ r = subprocess.run(
24
+ ["docker", "inspect", container, "--format", "{{.State.Running}}"],
25
+ capture_output=True, text=True,
26
+ )
27
+ return r.stdout.strip() == "true"
28
+
29
+
30
+ # ── account ───────────────────────────────────────────────────
31
+
32
+
33
+ @click.group("account")
34
+ def account_group() -> None:
35
+ """Account management commands."""
36
+ pass
37
+
38
+
39
+ @account_group.command("add")
40
+ @click.argument("name")
41
+ @click.argument("typearg", metavar="TYPE=codex|claude")
42
+ @click.option("--auth", default="login", type=click.Choice(["login", "env"]),
43
+ show_default=True, help="Authentication method")
44
+ @click.option("--env-file", "env_file", default=None,
45
+ help="Env file path (for --auth env, default: accounts/<name>/env)")
46
+ @click.option("--proxy", default="relay", type=click.Choice(["relay", "off"]),
47
+ show_default=True, help="Proxy mode")
48
+ @click.pass_context
49
+ def cmd_account_add(
50
+ ctx: click.Context,
51
+ name: str,
52
+ typearg: str,
53
+ auth: str,
54
+ env_file: Optional[str],
55
+ proxy: str,
56
+ ) -> None:
57
+ """Add a new account. TYPE=codex|claude (or just codex|claude)."""
58
+ ws: Path = ctx.obj["workspace"]
59
+
60
+ if not _NAME_RE.match(name):
61
+ raise click.ClickException("NAME 只能包含字母、数字、连字符")
62
+
63
+ acc_type = typearg[5:] if typearg.startswith("TYPE=") else typearg
64
+ if acc_type not in ("codex", "claude"):
65
+ raise click.ClickException("TYPE 不合法,只支持 TYPE=codex 或 TYPE=claude")
66
+
67
+ if auth == "env":
68
+ if acc_type != "claude":
69
+ raise click.ClickException("AUTH=env 目前只支持 TYPE=claude")
70
+ if not env_file:
71
+ env_file = f"./accounts/{name}/env"
72
+
73
+ accounts_conf = ws / "accounts.conf"
74
+ for r in parse_conf(accounts_conf):
75
+ if r.get("NAME") == name:
76
+ raise click.ClickException(f"账号 '{name}' 已存在,若要修改请先 remove 再 add")
77
+
78
+ tokens: dict[str, str] = {"NAME": name, "TYPE": acc_type, "AUTH": auth}
79
+ if auth == "env" and env_file:
80
+ tokens["ENV_FILE"] = env_file
81
+ tokens["PROXY"] = proxy
82
+
83
+ ensure_workspace(ws)
84
+ write_conf_line(accounts_conf, tokens)
85
+ (ws / "accounts" / name).mkdir(parents=True, exist_ok=True)
86
+
87
+ click.secho(f"✓ 账号 '{name}' 已添加(类型:{acc_type},认证:{auth},代理:{proxy})", fg="green")
88
+ if auth == "env":
89
+ click.secho(f" 请在 {env_file} 中配置 ANTHROPIC_API_KEY 等环境变量", fg="yellow")
90
+ click.secho(f" 接下来:coderfleet project add <项目名> {name} <项目路径>", dim=True)
91
+ click.secho(" 执行 coderfleet apply 使配置生效", fg="yellow")
92
+
93
+
94
+ @account_group.command("remove")
95
+ @click.argument("name")
96
+ @click.pass_context
97
+ def cmd_account_remove(ctx: click.Context, name: str) -> None:
98
+ """Remove an account (stops associated containers)."""
99
+ ws: Path = ctx.obj["workspace"]
100
+ accounts_conf = ws / "accounts.conf"
101
+ projects_conf = ws / "projects.conf"
102
+
103
+ accounts = {r["NAME"]: r for r in parse_conf(accounts_conf) if "NAME" in r}
104
+ if name not in accounts:
105
+ raise click.ClickException(f"账号 '{name}' 不存在")
106
+
107
+ acc_type = accounts[name].get("TYPE", "")
108
+ if acc_type:
109
+ for p in parse_conf(projects_conf):
110
+ if p.get("ACCOUNT") != name:
111
+ continue
112
+ pname = p.get("NAME", "")
113
+ ctr = _container_name(pname, acc_type)
114
+ if _is_running(ctr):
115
+ click.echo(f" 停止容器 {ctr}...")
116
+ subprocess.run(["docker", "stop", ctr], capture_output=True)
117
+ subprocess.run(["docker", "rm", ctr], capture_output=True)
118
+
119
+ remove_conf_entry(accounts_conf, "NAME", name)
120
+ click.secho(f"✓ 账号 '{name}' 已从配置中移除", fg="green")
121
+ click.secho(f" 认证数据保留在 accounts/{name}/ 如需清除:rm -rf accounts/{name}", fg="yellow")
122
+ click.secho(" 执行 coderfleet apply 重新生成配置", fg="yellow")
123
+
124
+
125
+ @account_group.command("list")
126
+ @click.pass_context
127
+ def cmd_account_list(ctx: click.Context) -> None:
128
+ """List all accounts."""
129
+ ws: Path = ctx.obj["workspace"]
130
+ accounts = parse_conf(ws / "accounts.conf")
131
+ projects = parse_conf(ws / "projects.conf")
132
+
133
+ click.echo()
134
+ click.echo(" ── 账号列表 " + "─" * 58)
135
+ click.echo(f" {'名称':<20} {'类型':<8} {'认证':<8} {'代理':<8} 状态")
136
+ click.echo(" " + "─" * 70)
137
+
138
+ if not accounts:
139
+ click.secho(" 暂无账号,使用 coderfleet account add 添加", fg="yellow")
140
+ click.echo()
141
+ return
142
+
143
+ for rec in accounts:
144
+ name = rec.get("NAME", "")
145
+ acc_type = rec.get("TYPE", "")
146
+ auth = rec.get("AUTH", "login")
147
+ proxy = rec.get("PROXY", "relay")
148
+
149
+ any_running = any(
150
+ _is_running(_container_name(p.get("NAME", ""), acc_type))
151
+ for p in projects
152
+ if p.get("ACCOUNT") == name
153
+ )
154
+ status = click.style("● 运行中", fg="green") if any_running else click.style("○ 已停止", fg="yellow")
155
+ click.echo(f" {name:<20} {acc_type:<8} {auth:<8} {proxy:<8} {status}")
156
+
157
+ click.echo()
158
+
159
+
160
+ # ── project ───────────────────────────────────────────────────
161
+
162
+
163
+ @click.group("project")
164
+ def project_group() -> None:
165
+ """Project management commands."""
166
+ pass
167
+
168
+
169
+ @project_group.command("add")
170
+ @click.argument("name")
171
+ @click.argument("account")
172
+ @click.argument("path")
173
+ @click.pass_context
174
+ def cmd_project_add(
175
+ ctx: click.Context,
176
+ name: str,
177
+ account: str,
178
+ path: str,
179
+ ) -> None:
180
+ """Add a new project."""
181
+ ws: Path = ctx.obj["workspace"]
182
+
183
+ if not _NAME_RE.match(name):
184
+ raise click.ClickException("项目名称只能包含字母、数字、连字符")
185
+
186
+ accounts_conf = ws / "accounts.conf"
187
+ accounts = {r["NAME"] for r in parse_conf(accounts_conf) if "NAME" in r}
188
+ if account not in accounts:
189
+ raise click.ClickException(f"账号 '{account}' 不存在")
190
+
191
+ projects_conf = ws / "projects.conf"
192
+ for p in parse_conf(projects_conf):
193
+ if p.get("NAME") == name:
194
+ raise click.ClickException(f"项目 '{name}' 已存在,若要修改请先 remove 再 add")
195
+
196
+ ensure_workspace(ws)
197
+ write_conf_line(projects_conf, {"NAME": name, "ACCOUNT": account, "PATH": path})
198
+
199
+ expanded = str(Path(path).expanduser())
200
+ click.secho(f"✓ 项目 '{name}' 已添加(账号:{account} 路径:{expanded})", fg="green")
201
+ click.secho(" 执行 coderfleet apply 使配置生效", fg="yellow")
202
+
203
+
204
+ @project_group.command("remove")
205
+ @click.argument("name")
206
+ @click.pass_context
207
+ def cmd_project_remove(ctx: click.Context, name: str) -> None:
208
+ """Remove a project."""
209
+ ws: Path = ctx.obj["workspace"]
210
+ projects_conf = ws / "projects.conf"
211
+
212
+ if not any(p.get("NAME") == name for p in parse_conf(projects_conf)):
213
+ raise click.ClickException(f"项目 '{name}' 不存在")
214
+
215
+ remove_conf_entry(projects_conf, "NAME", name)
216
+ click.secho(f"✓ 项目 '{name}' 已移除", fg="green")
217
+ click.secho(" 执行 coderfleet apply 重新生成配置", fg="yellow")
218
+
219
+
220
+ @project_group.command("list")
221
+ @click.pass_context
222
+ def cmd_project_list(ctx: click.Context) -> None:
223
+ """List all projects."""
224
+ ws: Path = ctx.obj["workspace"]
225
+ projects = parse_conf(ws / "projects.conf")
226
+
227
+ click.echo()
228
+ click.echo(" ── 项目列表 " + "─" * 62)
229
+ click.echo(f" {'名称':<20} {'账号':<20} 路径")
230
+ click.echo(" " + "─" * 74)
231
+
232
+ if not projects:
233
+ click.secho(" 暂无项目,使用 coderfleet project add 添加", fg="yellow")
234
+ click.echo()
235
+ return
236
+
237
+ for p in projects:
238
+ name = p.get("NAME", "")
239
+ account = p.get("ACCOUNT", "")
240
+ path = p.get("PATH", "")
241
+ click.echo(f" {name:<20} {account:<20} {path}")
242
+
243
+ click.echo()
@@ -0,0 +1,92 @@
1
+ # ============================================================
2
+ # CoderFleet — 统一工作容器镜像
3
+ # 包含:Python 3.12 / Node.js 20 / Codex CLI / Claude Code
4
+ # 平台:linux/amd64(Apple Silicon 通过 Docker 模拟运行)
5
+ # ============================================================
6
+
7
+ FROM --platform=linux/amd64 ubuntu:24.04
8
+
9
+ # 避免交互式安装询问
10
+ ENV DEBIAN_FRONTEND=noninteractive
11
+ ENV TZ=Asia/Shanghai
12
+
13
+ # ── 基础系统工具 ──────────────────────────────────────────
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ # 网络工具
16
+ curl wget ca-certificates netcat-openbsd \
17
+ # 开发基础
18
+ git build-essential pkg-config \
19
+ # Python 依赖
20
+ libssl-dev libffi-dev zlib1g-dev libbz2-dev \
21
+ libreadline-dev libsqlite3-dev liblzma-dev \
22
+ # 系统工具
23
+ bash-completion less vim nano \
24
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
25
+
26
+ # ── Node.js 20 (via NodeSource) ───────────────────────────
27
+ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
28
+ && apt-get install -y nodejs \
29
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
30
+
31
+ # 验证 Node 版本并安装常用全局包
32
+ RUN node --version && npm --version \
33
+ && npm install -g npm@latest
34
+
35
+ # ── Python 3.12 (via deadsnakes PPA) ─────────────────────
36
+ RUN apt-get update && apt-get install -y --no-install-recommends \
37
+ software-properties-common \
38
+ && add-apt-repository ppa:deadsnakes/ppa \
39
+ && apt-get update && apt-get install -y --no-install-recommends \
40
+ python3.12 python3.12-dev python3.12-venv python3-pip \
41
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
42
+
43
+ # 设置 python3.12 为默认 python3
44
+ RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.12 1 \
45
+ && update-alternatives --install /usr/bin/python python /usr/bin/python3.12 1
46
+
47
+ # 安装常用 Python 工具
48
+ # Ubuntu 24.04 实行 PEP 668,系统 Python 需加 --break-system-packages
49
+ RUN pip3 install --no-cache-dir --break-system-packages \
50
+ uv ruff black mypy pexpect
51
+
52
+ # ── Codex CLI ─────────────────────────────────────────────
53
+ RUN npm install -g @openai/codex
54
+
55
+ # ── Claude Code CLI ───────────────────────────────────────
56
+ RUN npm install -g @anthropic-ai/claude-code
57
+
58
+ # ── Rust 1.93.0 ───────────────────────────────────────────
59
+ ENV RUSTUP_HOME=/usr/local/rustup \
60
+ CARGO_HOME=/usr/local/cargo \
61
+ PATH=/usr/local/cargo/bin:$PATH \
62
+ RUSTUP_DIST_SERVER=https://rsproxy.cn \
63
+ RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup
64
+ RUN curl --proto '=https' --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh -s -- -y --no-modify-path --default-toolchain 1.93.0 \
65
+ && chmod -R a+w $RUSTUP_HOME $CARGO_HOME \
66
+ && rustc --version
67
+
68
+ # ── 创建非特权用户 byclaw ─────────────────────────────────
69
+ RUN groupadd -r byclaw && useradd -r -g byclaw -m -s /bin/bash byclaw
70
+
71
+ # ── 工作目录和环境变量 ────────────────────────────────────
72
+ RUN mkdir -p /workspace && chown byclaw:byclaw /workspace
73
+ WORKDIR /workspace
74
+
75
+ # 这两个目录由 CoderFleet 按账号挂载,容器内路径固定
76
+ # Codex 认证:/home/byclaw/.codex 由 CODEX_HOME 控制
77
+ # Claude 认证:/home/byclaw/.claude 由 CLAUDE_CONFIG_DIR 控制
78
+ ENV CODEX_HOME=/home/byclaw/.codex
79
+ ENV CLAUDE_CONFIG_DIR=/home/byclaw/.claude
80
+
81
+ # ── 启动脚本 ──────────────────────────────────────────────
82
+ COPY entrypoint.sh /entrypoint.sh
83
+ RUN chmod +x /entrypoint.sh
84
+
85
+ COPY scripts/coderfleet_usage_status.py /usr/local/bin/coderfleet-usage-status
86
+ RUN chmod +x /usr/local/bin/coderfleet-usage-status
87
+
88
+ # ── 切换为非特权用户 ──────────────────────────────────────
89
+ USER byclaw
90
+
91
+ ENTRYPOINT ["/entrypoint.sh"]
92
+ CMD ["sleep", "infinity"]
File without changes
@@ -0,0 +1,26 @@
1
+ # ============================================================
2
+ # accounts.conf — CoderFleet 账号配置
3
+ #
4
+ # 字段说明:
5
+ # NAME=<名称> 必填,只允许字母/数字/连字符
6
+ # TYPE=codex|claude 必填,使用哪个 AI CLI
7
+ # AUTH=login|env 可选,默认 login
8
+ # ENV_FILE=<路径> AUTH=env 时可选;省略则默认 ./accounts/<名称>/env
9
+ # PROXY=relay|off 可选,默认 relay;off 表示该账号下项目不注入代理
10
+ #
11
+ # 项目路径请配置在 projects.conf。
12
+ # 修改后执行 coderfleet apply 生效。
13
+ # ============================================================
14
+
15
+ # Codex 账号示例
16
+ # NAME=codex-alice TYPE=codex
17
+
18
+ # Claude Code 账号示例
19
+ # NAME=claude-alice TYPE=claude
20
+
21
+ # Claude Code 环境变量认证示例(API key 会优先于订阅登录态)
22
+ # NAME=claude-api TYPE=claude AUTH=env
23
+ # NAME=claude-api TYPE=claude AUTH=env ENV_FILE=./accounts/claude-api/env
24
+
25
+ # 不走代理的账号示例(该账号关联的项目都会直连)
26
+ # NAME=claude-local TYPE=claude PROXY=off
@@ -0,0 +1,31 @@
1
+ # ============================================================
2
+ # config.conf — CoderFleet 全局配置
3
+ # 修改后执行 coderfleet apply 生效
4
+ # ============================================================
5
+
6
+ # ── 镜像配置 ──────────────────────────────────────────────
7
+ IMAGE_NAME=coderfleet
8
+ IMAGE_TAG=latest
9
+
10
+ # 构建平台(Mac M 系列芯片固定用 linux/amd64,由 Docker Rosetta 模拟运行)
11
+ BUILD_PLATFORM=linux/arm64
12
+
13
+ # ── 代理配置 ──────────────────────────────────────────────
14
+ # 宿主机代理地址(容器通过 host.docker.internal 访问宿主机)
15
+ PROXY_HOST=host.docker.internal
16
+
17
+ # 宿主机代理端口
18
+ # 混合端口(同时支持 HTTP/SOCKS5,如 v2ray/sing-box 的混合入站):两个填同一个值
19
+ # 分开端口(如 Clash:HTTP=7890 SOCKS5=7891):分别填写
20
+ PROXY_HTTP_PORT=10808
21
+ PROXY_SOCKS5_PORT=10808
22
+
23
+ # ── 内部网络配置 ──────────────────────────────────────────
24
+ INTERNAL_SUBNET=172.21.0.0/16
25
+ RELAY_IP=172.21.0.2
26
+
27
+ # 代理中继对内网监听的端口(容器内 HTTP_PROXY 指向此端口)
28
+ RELAY_LISTEN_PORT=10808
29
+
30
+ # 代理中继镜像
31
+ RELAY_IMAGE=gogost/gost:3
@@ -0,0 +1,56 @@
1
+ #!/bin/bash
2
+ # ============================================================
3
+ # CoderFleet — 容器启动脚本
4
+ # 负责:代理验证、认证目录初始化、启动保活进程
5
+ # ============================================================
6
+
7
+ set -e
8
+
9
+ # ── 打印启动信息 ─────────────────────────────────────────
10
+ echo "========================================"
11
+ echo " CoderFleet Container"
12
+ echo " 账号:${CODERFLEET_ACCOUNT_NAME:-unknown}"
13
+ echo " 类型:${CODERFLEET_ACCOUNT_TYPE:-unknown}"
14
+ echo " 认证:${CODERFLEET_ACCOUNT_AUTH:-login}"
15
+ echo "========================================"
16
+
17
+ # ── 等待代理中继就绪 ──────────────────────────────────────
18
+ if [ "${CODERFLEET_ACCOUNT_PROXY:-relay}" = "off" ]; then
19
+ echo "账号代理:关闭(不等待代理中继)"
20
+ else
21
+ PROXY_HOST="${CODERFLEET_RELAY_IP:-172.20.0.2}"
22
+ PROXY_PORT="${CODERFLEET_RELAY_PORT:-7890}"
23
+ MAX_WAIT=30
24
+ waited=0
25
+
26
+ echo "等待代理中继 ${PROXY_HOST}:${PROXY_PORT}..."
27
+ while ! nc -z "$PROXY_HOST" "$PROXY_PORT" 2>/dev/null; do
28
+ if [ "$waited" -ge "$MAX_WAIT" ]; then
29
+ echo "警告:代理中继未就绪,继续启动(网络可能不可用)"
30
+ break
31
+ fi
32
+ sleep 1
33
+ waited=$((waited + 1))
34
+ done
35
+ [ "$waited" -lt "$MAX_WAIT" ] && echo "代理中继已就绪"
36
+ fi
37
+
38
+ # ── 初始化认证目录 ────────────────────────────────────────
39
+ ACCOUNT_TYPE="${CODERFLEET_ACCOUNT_TYPE:-codex}"
40
+ case "$ACCOUNT_TYPE" in
41
+ codex)
42
+ mkdir -p "$CODEX_HOME"
43
+ echo "Codex 认证目录:$CODEX_HOME"
44
+ ;;
45
+ claude)
46
+ mkdir -p "$CLAUDE_CONFIG_DIR"
47
+ echo "Claude 认证目录:$CLAUDE_CONFIG_DIR"
48
+ if [ "${CODERFLEET_ACCOUNT_AUTH:-login}" = "env" ]; then
49
+ echo "Claude 环境变量认证:已启用"
50
+ fi
51
+ ;;
52
+ esac
53
+
54
+ # ── 执行传入的命令(默认 sleep infinity)─────────────────
55
+ echo "容器启动完成,执行:$*"
56
+ exec "$@"
@@ -0,0 +1,17 @@
1
+ # ============================================================
2
+ # projects.conf — CoderFleet 项目配置
3
+ #
4
+ # 字段说明:
5
+ # NAME=<名称> 必填,只允许字母/数字/连字符
6
+ # ACCOUNT=<账号名> 必填,使用 accounts.conf 中的账号
7
+ # PATH=<项目路径> 必填,挂载进该账号容器的目录(支持 ~)
8
+ #
9
+ # 修改后执行 coderfleet apply 生效。
10
+ # ============================================================
11
+
12
+ # 示例:
13
+ # NAME=my-app ACCOUNT=codex-alice PATH=~/projects/my-app
14
+ # NAME=local-app ACCOUNT=claude-local PATH=~/projects/local-app
15
+
16
+ NAME=byclaw-code-agent ACCOUNT=codex-zhchxiao123 PATH=/home/demo/demo-byclaw
17
+ NAME=timelog ACCOUNT=claude-fajikmomi PATH=/home/demo/demo-timelog
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from typing import Any
10
+
11
+
12
+ ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
13
+ STATUS_LINE_RE = re.compile(
14
+ r"(5h|five|weekly|week|limit|left|remaining|usage|quota|reset|used|剩余|用量|限制|重置)",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+
19
+ def strip_ansi(text: str) -> str:
20
+ return ANSI_RE.sub("", text)
21
+
22
+
23
+ def unique_lines(lines: list[str]) -> list[str]:
24
+ seen: set[str] = set()
25
+ result: list[str] = []
26
+ for line in lines:
27
+ if line not in seen:
28
+ seen.add(line)
29
+ result.append(line)
30
+ return result
31
+
32
+
33
+ def clean_status_line(line: str) -> str:
34
+ cleaned = line.strip().strip("│").strip()
35
+ cleaned = re.sub(r"\[[█░▒▓#=\-\s]+\]\s*", "", cleaned)
36
+ cleaned = re.sub(r"\s{2,}", " ", cleaned)
37
+ cleaned = re.sub(r"^([A-Za-z0-9 ]+ limit:)\s+", r"\1 ", cleaned)
38
+ return cleaned
39
+
40
+
41
+ def extract_codex_usage_summary(text: str) -> str:
42
+ cleaned_text = strip_ansi(text)
43
+ matched = [
44
+ clean_status_line(line)
45
+ for line in cleaned_text.splitlines()
46
+ if re.search(r"\b(5h|Weekly) limit:\s+", line, re.IGNORECASE)
47
+ ]
48
+ matched = [line for line in matched if line]
49
+ return "\n".join(unique_lines(matched))
50
+
51
+
52
+ def check_codex_login() -> bool:
53
+ try:
54
+ result = subprocess.run(
55
+ ["codex", "login", "status"],
56
+ stdout=subprocess.PIPE,
57
+ stderr=subprocess.STDOUT,
58
+ text=True,
59
+ timeout=20,
60
+ check=False,
61
+ )
62
+ return result.returncode == 0
63
+ except (OSError, subprocess.SubprocessError):
64
+ return False
65
+
66
+
67
+ def read_available(child: Any, pexpect_module: Any, size: int = 20000, timeout: int = 3) -> str:
68
+ try:
69
+ return child.read_nonblocking(size=size, timeout=timeout)
70
+ except pexpect_module.TIMEOUT:
71
+ return ""
72
+
73
+
74
+ def collect_codex_status() -> str:
75
+ if shutil.which("codex") is None:
76
+ return "Codex CLI 不存在,跳过用量检查"
77
+ if not check_codex_login():
78
+ return "Codex 未登录或凭证不可用,跳过用量检查"
79
+
80
+ import pexpect
81
+
82
+ child = pexpect.spawn(
83
+ "codex",
84
+ encoding="utf-8",
85
+ timeout=25,
86
+ dimensions=(40, 160),
87
+ )
88
+ buf = ""
89
+
90
+ try:
91
+ time.sleep(2)
92
+ buf += read_available(child, pexpect, timeout=1)
93
+
94
+ child.sendline("/status")
95
+ time.sleep(4)
96
+ buf += read_available(child, pexpect)
97
+
98
+ child.sendline("/exit")
99
+ try:
100
+ child.expect(pexpect.EOF, timeout=8)
101
+ buf += child.before or ""
102
+ except pexpect.TIMEOUT:
103
+ child.sendcontrol("c")
104
+ child.close(force=True)
105
+ finally:
106
+ if child.isalive():
107
+ child.close(force=True)
108
+
109
+ summary = extract_codex_usage_summary(buf)
110
+ if summary:
111
+ return summary
112
+
113
+ text = strip_ansi(buf)
114
+ matched = [
115
+ clean_status_line(line)
116
+ for line in text.splitlines()
117
+ if line.strip() and STATUS_LINE_RE.search(line)
118
+ ]
119
+ matched = [line for line in matched if line]
120
+ return "\n".join(unique_lines(matched)) if matched else text.strip()
121
+
122
+
123
+ def main(argv: list[str]) -> int:
124
+ cli = argv[1] if len(argv) > 1 else "codex"
125
+ if cli != "codex":
126
+ print(f"{cli} 暂未支持自动用量检查")
127
+ return 0
128
+
129
+ try:
130
+ output = collect_codex_status()
131
+ except Exception as err:
132
+ output = f"Codex 用量检查失败:{err}"
133
+ print(output or "未获取到 Codex 用量信息")
134
+ return 0
135
+
136
+
137
+ if __name__ == "__main__":
138
+ raise SystemExit(main(sys.argv))