agent-cli-lib 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.
@@ -0,0 +1,296 @@
1
+ """opencode 聚合包:版本分派层 + CLI 兜底实现(v8 设计 §4.2–§4.5)。
2
+
3
+ - `OpencodeBackend`:注册名 "opencode",探测 PATH 上 opencode 的主版本并按 Spec 分派
4
+ (ACP 路径由 runner 路由到 AcpDriver;CLI 兜底与 export 委托给 `OpencodeCliImpl`);
5
+ - `Opencode2Backend`:注册名 "opencode2",显式选 v2(opencode2 别名优先,适配 npm/curl 双装);
6
+ - `OpencodeCliImpl`:transport="cli" 与 export 的执行体,一个类按 spec 参数化 v1/v2。
7
+
8
+ 关键实测依据(2.2.5 / 1.19.5,见 docs/dev/backend-commands.md):
9
+ - v2 CLI:`run --standalone --auto --format json`;`-s` 未知 id 静默新建;无 `--dir`
10
+ - v1 CLI:`run --format json --dir <path>`;未知 `-s` RC=1 报错
11
+ - 导出:v1 `export <id>` / v2 `session export <id> --standalone`,stdout JSON
12
+ - 事件流两代同构:顶层 `sessionID` + `type=="text"` 的 `part.text`;error 事件 RC 不稳
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import json
19
+ import os
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import tempfile
24
+ from functools import cached_property
25
+
26
+ from agent_cli_lib.backends.base import AcpSpec, RunOutput, RunRequest
27
+ from agent_cli_lib.backends.opencode.specs import V1, V2, OpencodeSpec
28
+ from agent_cli_lib.errors import (
29
+ AcpProtocolError,
30
+ BackendLaunchError,
31
+ BackendRunError,
32
+ ProcessResult,
33
+ SessionError,
34
+ )
35
+
36
+ # opencode --version 输出形如 "1.19.5"(v1)/ "opencode v2.2.5"(v2),
37
+ # 解析口径对齐官方安装脚本:取末 token、去 v 前缀、校验 x.y.z
38
+ _VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
39
+
40
+
41
+ def _looks_like_session_id(s: str) -> bool:
42
+ """session id / 文件路径粗判(镜像 core/session.py 启发式,避免 backends→core 反向依赖)。"""
43
+ return not (s.endswith((".json", ".jsonl", ".md", ".html")) or "/" in s or "\\" in s)
44
+
45
+
46
+ def _major_version(binary: str, backend: str = "opencode") -> int:
47
+ """解析 opencode 主版本;失败/超时/不可读 → BackendLaunchError(不静默猜测)。"""
48
+ try:
49
+ proc = subprocess.run(
50
+ [binary, "--version"],
51
+ capture_output=True,
52
+ text=True,
53
+ encoding="utf-8",
54
+ errors="replace",
55
+ timeout=10,
56
+ check=False,
57
+ )
58
+ except (OSError, subprocess.TimeoutExpired) as e:
59
+ raise BackendLaunchError(backend, f"版本探测失败({binary} --version): {e}") from e
60
+ tokens = (proc.stdout or "").split()
61
+ version = tokens[-1].lstrip("vV") if tokens else ""
62
+ if proc.returncode != 0 or not _VERSION_RE.match(version):
63
+ raise BackendLaunchError(
64
+ backend, f"版本探测不可读(rc={proc.returncode}): {(proc.stdout or '')[:100]}"
65
+ )
66
+ return int(version.split(".")[0])
67
+
68
+
69
+ def _resolve_opencode(name: str) -> str:
70
+ """定位 PATH 上的 opencode(分派层用;版本由调用方继续判定)。"""
71
+ path = shutil.which("opencode")
72
+ if path is None:
73
+ raise BackendLaunchError(
74
+ name,
75
+ "未找到可执行文件 opencode,请先安装(v1: npm i -g opencode-ai;"
76
+ "v2: npm i -g @opencode/cli)",
77
+ )
78
+ return path
79
+
80
+
81
+ class OpencodeCliImpl:
82
+ """CLI 兜底实现:transport="cli" 的执行体与 export 的唯一路径(按 spec 参数化 v1/v2)。"""
83
+
84
+ def __init__(self, name: str, spec: OpencodeSpec, binary: str) -> None:
85
+ self.name = name
86
+ self.spec = spec
87
+ self.binary = binary
88
+
89
+ def build_argv(self, req: RunRequest) -> list[str]:
90
+ if self.spec.major == 2 and req.session and not _looks_like_session_id(req.session):
91
+ # v2 的 -s 未知 id 会静默新建:路径形态传进去必然"失忆",显式拒绝
92
+ raise SessionError(
93
+ f"{self.name} 恢复会话仅支持 session id(文件路径恢复见二期 session import)"
94
+ )
95
+ argv: list[str] = ["run", *self.spec.cli_run_flags]
96
+ if self.spec.cli_has_dir_flag and req.workdir:
97
+ argv += ["--dir", req.workdir]
98
+ if req.model:
99
+ argv += ["-m", req.model]
100
+ if req.session:
101
+ if self.spec.major == 2 and not req.prompt.strip():
102
+ # v2 无消息恢复输出 error 事件且 RC 不稳(实测),提前拒绝;
103
+ # v1 实测允许空消息恢复(argv 省略 prompt),保持既有行为
104
+ raise SessionError(f"{self.name} 恢复会话必须带 prompt(不支持空消息恢复)")
105
+ argv += ["-s", req.session]
106
+ if req.prompt:
107
+ # "--" 防 - 开头 prompt 被当 flag(两代 cobra/同类 CLI 通用行为,实测可用)
108
+ argv += ["--", req.prompt]
109
+ return argv
110
+
111
+ def parse_output(self, stdout: str, stderr: str) -> RunOutput:
112
+ session_id = ""
113
+ text_parts: list[str] = []
114
+ errors: list[str] = []
115
+ for line in stdout.splitlines():
116
+ line = line.strip()
117
+ if not line:
118
+ continue
119
+ try:
120
+ evt = json.loads(line)
121
+ except json.JSONDecodeError:
122
+ continue
123
+ if not isinstance(evt, dict):
124
+ continue
125
+ sid = evt.get("sessionID")
126
+ if sid and not session_id:
127
+ session_id = str(sid)
128
+ if evt.get("type") == "error":
129
+ err = evt.get("error")
130
+ if isinstance(err, dict) and err.get("message"):
131
+ errors.append(str(err["message"]))
132
+ continue
133
+ if evt.get("type") == "text":
134
+ part = evt.get("part")
135
+ if isinstance(part, dict) and part.get("text"):
136
+ text_parts.append(str(part["text"]))
137
+ if errors:
138
+ # 严格模式:有 error 即抛(CLI 事件流 RC 不稳,error 事件是更可信的失败信号)
139
+ raise BackendRunError(self.name, 0, "; ".join(errors))
140
+ return RunOutput(
141
+ text="\n".join(text_parts) if text_parts else stdout, session_id=session_id
142
+ )
143
+
144
+ def export_session_file(self, session_id: str, target: str) -> None:
145
+ """跑原生导出命令,把 stdout JSON 落盘(导出物可被 import 恢复)。
146
+
147
+ 临时文件 + 原子替换:写盘中途失败不留下半截 JSON,也不破坏已有完好文件。
148
+ """
149
+ if not session_id:
150
+ raise SessionError("导出需要有效的 session_id")
151
+ argv = [part.format(id=session_id) for part in self.spec.export_argv]
152
+ proc = subprocess.run(
153
+ [self.binary, *argv],
154
+ capture_output=True,
155
+ text=True,
156
+ encoding="utf-8",
157
+ errors="replace",
158
+ check=False,
159
+ )
160
+ ProcessResult(self.name, proc.returncode, proc.stdout, proc.stderr).raise_for_status()
161
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(target) or ".", suffix=".tmp")
162
+ try:
163
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
164
+ f.write(proc.stdout)
165
+ os.replace(tmp, target)
166
+ except OSError as e:
167
+ _best_effort_unlink(tmp)
168
+ raise SessionError(f"写入导出文件失败: {target}: {e}") from e
169
+
170
+ def session_exists(self, session_id: str) -> bool:
171
+ """CLI 兜底路径的 resume 前置探测(仅 v2 生效;v1 恒 True,CLI 自己报错)。
172
+
173
+ fail-open:只有确定性"不存在"(RC==1 且 stderr 明示 not found)才 False,
174
+ DB 锁/启动失败/超时等一律放行——探测自身故障不得误伤 resume。
175
+ """
176
+ if not self.spec.cli_probe_session:
177
+ return True
178
+ if not session_id:
179
+ return False
180
+ argv = [part.format(id=session_id) for part in self.spec.export_argv]
181
+ try:
182
+ proc = subprocess.run(
183
+ [self.binary, *argv],
184
+ capture_output=True,
185
+ text=True,
186
+ encoding="utf-8",
187
+ errors="replace",
188
+ timeout=10,
189
+ check=False,
190
+ )
191
+ except (OSError, subprocess.TimeoutExpired):
192
+ return True
193
+ return not (proc.returncode == 1 and "not found" in (proc.stderr or "").lower())
194
+
195
+
196
+ class _OpencodeBase:
197
+ """分派层公共骨架:版本探测 + Spec 选择 + 协议成员委托。"""
198
+
199
+ # 子类覆盖:注册名
200
+ name: str = "opencode"
201
+
202
+ # 探测后为 "v1" | "v2",探测前为 ""(测试/调试断言用)
203
+ resolved_version: str = ""
204
+
205
+ def __init__(self, name: str | None = None) -> None:
206
+ if name is not None:
207
+ self.name = name
208
+
209
+ @cached_property
210
+ def _cli(self) -> OpencodeCliImpl:
211
+ spec, binary = self._resolve()
212
+ self.resolved_version = f"v{spec.major}"
213
+ return OpencodeCliImpl(name=self.name, spec=spec, binary=binary)
214
+
215
+ def _resolve(self) -> tuple[OpencodeSpec, str]:
216
+ """探测并返回 (spec, binary);子类实现各自的解析策略。"""
217
+ raise NotImplementedError
218
+
219
+ @property
220
+ def binary(self) -> str:
221
+ return self._cli.binary
222
+
223
+ @property
224
+ def acp(self) -> AcpSpec | None:
225
+ spec = self._cli.spec
226
+ return AcpSpec(command=(self._cli.binary, "acp"), cwd_bound=spec.cwd_bound)
227
+
228
+ def build_argv(self, req: RunRequest) -> list[str]:
229
+ return self._cli.build_argv(req)
230
+
231
+ def parse_output(self, stdout: str, stderr: str) -> RunOutput:
232
+ return self._cli.parse_output(stdout, stderr)
233
+
234
+ def export_session_file(self, session_id: str, target: str) -> None:
235
+ self._cli.export_session_file(session_id, target)
236
+
237
+ def session_exists(self, session_id: str) -> bool:
238
+ return self._cli.session_exists(session_id)
239
+
240
+
241
+ class OpencodeBackend(_OpencodeBase):
242
+ """注册名 "opencode":跟随 PATH 上 opencode 命令的主版本(v1 或 v2)。
243
+
244
+ 真实升级路径(v1→v2 后 bin 名不变)下,调用方 backend="opencode" 零改动切换。
245
+ """
246
+
247
+ name = "opencode"
248
+
249
+ def _resolve(self) -> tuple[OpencodeSpec, str]:
250
+ binary = _resolve_opencode(self.name)
251
+ major = _major_version(binary)
252
+ if major == 1:
253
+ return V1, binary
254
+ if major == 2:
255
+ return V2, binary
256
+ # 未来 v3+ 不静默落 v1(拿 v1 的命令面打未知 CLI 比明确报错更糟)
257
+ raise BackendLaunchError(
258
+ self.name, f"PATH 上的 opencode 主版本为 {major},本库仅支持 v1/v2"
259
+ )
260
+
261
+
262
+ class Opencode2Backend(_OpencodeBase):
263
+ """注册名 "opencode2":显式选 v2(npm/curl 双装机器走 opencode2 别名优先)。
264
+
265
+ 别名存在也校验版本(陈旧 shim 指向 v1 时降级到 opencode 分支继续校验,两分支
266
+ 都不匹配才报错);只有 v1 的机器上按下面报错(调用方明确要了 v2)。
267
+ """
268
+
269
+ name = "opencode2"
270
+
271
+ def _resolve(self) -> tuple[OpencodeSpec, str]:
272
+ hint = (
273
+ "升级安装:npm i -g @opencode/cli 或 brew install anomalyco/tap/opencode-v2"
274
+ "(会替换 opencode);双装:npm/curl 渠道自带 opencode2 别名"
275
+ )
276
+ alias = shutil.which("opencode2")
277
+ if alias is not None and _major_version(alias, backend=self.name) == 2:
278
+ return V2, alias
279
+ binary = shutil.which("opencode")
280
+ if binary is not None:
281
+ major = _major_version(binary)
282
+ if major == 2:
283
+ return V2, binary
284
+ raise BackendLaunchError(
285
+ self.name, f"PATH 上的 opencode 是 v{major}(opencode v2 需主版本 2)。{hint}"
286
+ )
287
+ raise BackendLaunchError(self.name, f"未找到可执行文件 opencode2/opencode(v2)。{hint}")
288
+
289
+
290
+ def _best_effort_unlink(path: str) -> None:
291
+ """清理可能残留的临时文件。"""
292
+ with contextlib.suppress(OSError):
293
+ os.unlink(path)
294
+
295
+
296
+ __all__ = ["V1", "V2", "AcpProtocolError", "Opencode2Backend", "OpencodeBackend", "OpencodeCliImpl"]
@@ -0,0 +1,39 @@
1
+ """opencode v1/v2 能力与命令面声明(数据,不是类)。
2
+
3
+ 原则:v1/v2 差异只允许出现在本文件(设计 v8 §4.2),出现在别处即设计违规。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import NamedTuple
9
+
10
+
11
+ class OpencodeSpec(NamedTuple):
12
+ """opencode 某主版本的能力与命令面。"""
13
+
14
+ major: int # 1 | 2(分派探测结果)
15
+ cwd_bound: bool # v2 True:ACP resume 协议级绑定会话原目录(v1 实测不受限)
16
+ export_argv: tuple[str, ...] # 导出子命令模板,{id} 为占位符
17
+ cli_run_flags: tuple[str, ...] # CLI 兜底 run 的固定旗标
18
+ cli_has_dir_flag: bool # v1 True(--dir)/ v2 False(workdir 走 subprocess cwd)
19
+ # v2 CLI 兜底的 -s 未知 id 会静默新建,需 fail-open 存在性探测;v1 自己报错无需探测
20
+ cli_probe_session: bool
21
+
22
+
23
+ V1 = OpencodeSpec(
24
+ major=1,
25
+ cwd_bound=False,
26
+ export_argv=("export", "{id}"),
27
+ cli_run_flags=("--format", "json"),
28
+ cli_has_dir_flag=True,
29
+ cli_probe_session=False,
30
+ )
31
+
32
+ V2 = OpencodeSpec(
33
+ major=2,
34
+ cwd_bound=True,
35
+ export_argv=("session", "export", "{id}", "--standalone"),
36
+ cli_run_flags=("--standalone", "--auto", "--format", "json"),
37
+ cli_has_dir_flag=False,
38
+ cli_probe_session=True,
39
+ )
@@ -0,0 +1,5 @@
1
+ """pi 后端适配(占位骨架,待实现)."""
2
+
3
+ from agent_cli_lib.backends.pi.backend import PiBackend
4
+
5
+ __all__ = ["PiBackend"]
@@ -0,0 +1,116 @@
1
+ """pi CLI 封装实现。
2
+
3
+ 关键映射(实测 1.5.0):
4
+ - 一次性运行:pi -p <msg> --mode json
5
+ - 工作目录:通过 subprocess cwd(pi 无 --dir flag)
6
+ - 恢复会话:--session <path|id>
7
+ - session id:--mode json 首事件 {"type":"session","id":...}(字段名是 id)
8
+ - 最终文本:message_end 事件 message.content[].text(role=="assistant")
9
+ - 导出:pi 无 json 导出命令(--export 只出 HTML),session jsonl 文件即原生存储本体,
10
+ 复制该文件即为导出物,可被 --session <path> 恢复
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import glob
16
+ import json
17
+ import os
18
+ import shutil
19
+ from pathlib import Path
20
+
21
+ from agent_cli_lib.backends.base import AcpSpec, RunOutput, RunRequest
22
+ from agent_cli_lib.errors import SessionError
23
+
24
+
25
+ def _session_dir() -> Path:
26
+ """pi 的 session 存储目录(PI_CODING_AGENT_SESSION_DIR / PI_CODING_AGENT_DIR 可改)。"""
27
+ env = os.environ.get("PI_CODING_AGENT_SESSION_DIR")
28
+ if env:
29
+ return Path(env)
30
+ root = os.environ.get("PI_CODING_AGENT_DIR")
31
+ if root:
32
+ return Path(root) / "sessions"
33
+ return Path.home() / ".pi" / "agent" / "sessions"
34
+
35
+
36
+ class PiBackend:
37
+ @property
38
+ def acp(self) -> AcpSpec | None:
39
+ """pi 无 ACP,整体走 CLI 子进程路径。"""
40
+ return None
41
+
42
+ def session_exists(self, session_id: str) -> bool:
43
+ """CLI 对未知 id 自己报错,无需探测。"""
44
+ return True
45
+
46
+ name = "pi"
47
+ binary = "pi"
48
+
49
+ def build_argv(self, req: RunRequest) -> list[str]:
50
+ argv: list[str] = ["-p", "--mode", "json"]
51
+ if req.model:
52
+ argv += ["--model", req.model]
53
+ if req.session:
54
+ # pi 原生支持 --session <path|id>
55
+ argv += ["--session", req.session]
56
+ if req.prompt:
57
+ argv.append(req.prompt)
58
+ return argv
59
+
60
+ def parse_output(self, stdout: str, stderr: str) -> RunOutput:
61
+ session_id = ""
62
+ text_parts: list[str] = []
63
+ for line in stdout.splitlines():
64
+ line = line.strip()
65
+ if not line:
66
+ continue
67
+ try:
68
+ evt = json.loads(line)
69
+ except json.JSONDecodeError:
70
+ continue
71
+ if not isinstance(evt, dict):
72
+ continue
73
+ if evt.get("type") == "session":
74
+ # id 为 JSON null 时 .get 默认值不生效,须显式真值判断(防 "None" 假 id)
75
+ sid = evt.get("id")
76
+ if sid and not session_id:
77
+ session_id = str(sid)
78
+ elif evt.get("type") == "message_end":
79
+ msg = evt.get("message")
80
+ if isinstance(msg, dict) and msg.get("role") == "assistant":
81
+ content = msg.get("content")
82
+ if isinstance(content, list):
83
+ for part in content:
84
+ if isinstance(part, dict) and part.get("type") == "text":
85
+ text = part.get("text")
86
+ if text:
87
+ text_parts.append(str(text))
88
+ return RunOutput(
89
+ text="\n".join(text_parts) if text_parts else stdout, session_id=session_id
90
+ )
91
+
92
+ def export_session_file(self, session_id: str, target: str) -> None:
93
+ """复制 pi 的 session jsonl 原文件(存储本体即导出物,--session <path> 可直接恢复)。
94
+
95
+ ⚠️ 文件布局 <时间戳>_<session_id>.jsonl 是 pi 的内部存储格式而非文档化接口,
96
+ pi 升级可能改变布局,此实现需随之更新。
97
+ """
98
+ if not session_id:
99
+ raise SessionError("导出需要有效的 session_id")
100
+ base = _session_dir()
101
+ if not base.is_dir():
102
+ raise SessionError(f"pi session 目录不存在: {base}")
103
+ # 文件名以 session id 结尾;rglob 覆盖按 cwd slug 分组的子目录(escape 防止
104
+ # id 中的 glob 元字符污染模式)。注意 rglob 不跟随符号链接目录。
105
+ hits = sorted(base.rglob(f"*_{glob.escape(session_id)}.jsonl"))
106
+ if not hits:
107
+ raise SessionError(f"在 {base} 下未找到 session {session_id} 的 jsonl 文件")
108
+ if len(hits) > 1:
109
+ listed = ", ".join(str(h) for h in hits[:5])
110
+ raise SessionError(f"session {session_id} 命中多个文件,存储态异常: {listed}")
111
+ try:
112
+ shutil.copy2(hits[0], target)
113
+ except OSError as e:
114
+ # 不留下半截副本(target 不可信)
115
+ Path(target).unlink(missing_ok=True)
116
+ raise SessionError(f"复制 session 文件失败: {hits[0]} -> {target}: {e}") from e
@@ -0,0 +1 @@
1
+ """核心层:统一运行入口、prompt 处理、session 管理."""
@@ -0,0 +1,13 @@
1
+ """ACP 传输层(官方 agent-client-protocol 类型 + 自管 stdio 泵,v8 设计)。"""
2
+
3
+ from agent_cli_lib.core.acp.client import AcpConnection, AcpJsonRpcError
4
+ from agent_cli_lib.core.acp.driver import AcpDriver
5
+ from agent_cli_lib.core.acp.wire import message_text, parse_notification
6
+
7
+ __all__ = [
8
+ "AcpConnection",
9
+ "AcpDriver",
10
+ "AcpJsonRpcError",
11
+ "message_text",
12
+ "parse_notification",
13
+ ]