qi-coding-agent 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.
- qi_agent/__init__.py +18 -0
- qi_agent/abort.py +40 -0
- qi_agent/auth.py +216 -0
- qi_agent/cli.py +2178 -0
- qi_agent/compaction.py +475 -0
- qi_agent/config.py +409 -0
- qi_agent/docs/cli.md +188 -0
- qi_agent/docs/compaction.md +148 -0
- qi_agent/docs/configuration.md +89 -0
- qi_agent/docs/containerization.md +80 -0
- qi_agent/docs/docs.json +1 -0
- qi_agent/docs/environment-variables.md +47 -0
- qi_agent/docs/extensions.md +382 -0
- qi_agent/docs/how-qi-works.md +93 -0
- qi_agent/docs/index.md +61 -0
- qi_agent/docs/json.md +112 -0
- qi_agent/docs/keybindings.md +83 -0
- qi_agent/docs/models.md +138 -0
- qi_agent/docs/packages.md +155 -0
- qi_agent/docs/providers.md +288 -0
- qi_agent/docs/quickstart.md +109 -0
- qi_agent/docs/security.md +69 -0
- qi_agent/docs/session-format.md +299 -0
- qi_agent/docs/sessions.md +127 -0
- qi_agent/docs/settings.md +138 -0
- qi_agent/docs/skills.md +124 -0
- qi_agent/docs/slash-commands.md +55 -0
- qi_agent/docs/themes.md +78 -0
- qi_agent/docs/tui.md +109 -0
- qi_agent/docs/usage.md +112 -0
- qi_agent/export_html.py +95 -0
- qi_agent/extensions.py +2267 -0
- qi_agent/llm.py +609 -0
- qi_agent/loader.py +299 -0
- qi_agent/model_catalog.py +108 -0
- qi_agent/models.py +103 -0
- qi_agent/packages.py +430 -0
- qi_agent/paths.py +242 -0
- qi_agent/pep723.py +74 -0
- qi_agent/presets.py +293 -0
- qi_agent/prompt.py +150 -0
- qi_agent/registry.py +485 -0
- qi_agent/runner.py +584 -0
- qi_agent/runtime.py +1940 -0
- qi_agent/session.py +739 -0
- qi_agent/settings.py +501 -0
- qi_agent/share.py +111 -0
- qi_agent/system_prompt.py +278 -0
- qi_agent/theme.py +389 -0
- qi_agent/themes/dark.json +90 -0
- qi_agent/themes/light.json +89 -0
- qi_agent/titling.py +90 -0
- qi_agent/tools/__init__.py +349 -0
- qi_agent/tools/shell.py +276 -0
- qi_agent/trust.py +106 -0
- qi_agent/tui.py +6224 -0
- qi_agent/workspaces.py +116 -0
- qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
- qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
- qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/tools/shell.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""shell 解析与执行(对齐 pi 的 `dist/utils/shell.js` + `dist/core/tools/bash.js`)。
|
|
2
|
+
|
|
3
|
+
pi 的 `bash` 工具**不会**把命令丢给系统默认 shell:它解析一个**真正的 bash 二进制**
|
|
4
|
+
(settings.shellPath → Windows 上 Git Bash 的已知路径 → PATH 上的 bash → `/bin/bash` → `sh`),
|
|
5
|
+
而 Windows 原生命令走**另一条** `powershell` 工具。
|
|
6
|
+
|
|
7
|
+
qi v1 早期用 `asyncio.create_subprocess_shell` —— 在 Windows 上那等于 `cmd.exe`:
|
|
8
|
+
工具名叫 bash,跑的却是 cmd,模型按 bash 语法写 `&&` / `$(...)` / `ls -la` 会静默出错。
|
|
9
|
+
这里按 pi 的顺序解析真 shell,并且**从不用 `shell=True`**。
|
|
10
|
+
|
|
11
|
+
有意的差异:
|
|
12
|
+
- pi 手写 `where` / `which` 探测,qi 用 `shutil.which`(语义等价、跨平台更稳)。
|
|
13
|
+
- pi 对二进制输出还做一次控制字符清洗(`sanitizeBinaryOutput`);qi 只做 UTF-8 替换解码。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import contextlib
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import signal
|
|
23
|
+
import sys
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from ..abort import AbortSignal
|
|
28
|
+
|
|
29
|
+
# PowerShell 调用参数(照搬 pi):不加载 profile、非交互、绕过执行策略
|
|
30
|
+
POWERSHELL_ARGS = ("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command")
|
|
31
|
+
|
|
32
|
+
# 让 PowerShell 用 UTF-8 输出(照搬 pi):否则中文/emoji 经管道回来会变问号
|
|
33
|
+
POWERSHELL_UTF8_PREFIX = "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ShellError(RuntimeError):
|
|
37
|
+
"""解析不到可用 shell —— 配置/环境问题。信息要能让人直接照做。"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ShellConfig:
|
|
42
|
+
shell: str
|
|
43
|
+
args: tuple[str, ...]
|
|
44
|
+
command_via_stdin: bool = False # 老版 WSL 的 bash.exe 不认 -c,只能喂 stdin
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ShellResult:
|
|
49
|
+
text: str
|
|
50
|
+
exit_code: int | None = None
|
|
51
|
+
timed_out: bool = False
|
|
52
|
+
aborted: bool = False # 用户中断(进程组已回收),与超时分开报
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_windows() -> bool:
|
|
56
|
+
return sys.platform == "win32"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_legacy_wsl_bash(path: str) -> bool:
|
|
60
|
+
"""`C:\\Windows\\System32\\bash.exe`(老 WSL launcher)只认 stdin,不接受 `-c`。"""
|
|
61
|
+
normalized = path.replace("/", "\\").lower()
|
|
62
|
+
return normalized.startswith(("c:\\windows\\system32\\bash.exe",
|
|
63
|
+
"c:\\windows\\sysnative\\bash.exe"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _bash_config(shell: str) -> ShellConfig:
|
|
67
|
+
if _is_legacy_wsl_bash(shell):
|
|
68
|
+
return ShellConfig(shell=shell, args=("-s",), command_via_stdin=True)
|
|
69
|
+
return ShellConfig(shell=shell, args=("-c",))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _git_bash_candidates() -> list[str]:
|
|
73
|
+
"""Windows 上 Git for Windows 的常见安装位置(顺序同 pi)。"""
|
|
74
|
+
out: list[str] = []
|
|
75
|
+
for var in ("ProgramFiles", "ProgramFiles(x86)"):
|
|
76
|
+
base = os.environ.get(var)
|
|
77
|
+
if base:
|
|
78
|
+
out.append(str(Path(base) / "Git" / "bin" / "bash.exe"))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_shell_config(shell_path: str | None = None) -> ShellConfig:
|
|
83
|
+
"""按 pi 的顺序解析 bash。
|
|
84
|
+
|
|
85
|
+
1. 显式 `shellPath`(settings.json)
|
|
86
|
+
2. Windows:Git Bash 已知路径 → PATH 上的 `bash.exe`
|
|
87
|
+
3. Unix:`/bin/bash` → PATH 上的 `bash` → 兜底 `sh`
|
|
88
|
+
"""
|
|
89
|
+
if shell_path:
|
|
90
|
+
path = Path(shell_path).expanduser()
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
raise ShellError(f"settings.shellPath 指向的文件不存在: {path}")
|
|
93
|
+
return _bash_config(str(path))
|
|
94
|
+
|
|
95
|
+
if is_windows():
|
|
96
|
+
candidates = _git_bash_candidates()
|
|
97
|
+
for candidate in candidates:
|
|
98
|
+
if Path(candidate).is_file():
|
|
99
|
+
return _bash_config(candidate)
|
|
100
|
+
found = shutil.which("bash.exe") or shutil.which("bash")
|
|
101
|
+
if found:
|
|
102
|
+
return _bash_config(found)
|
|
103
|
+
searched = ("\n已查过 Git Bash 路径:\n" + "\n".join(f" {c}" for c in candidates)
|
|
104
|
+
if candidates else "")
|
|
105
|
+
raise ShellError(
|
|
106
|
+
"找不到 bash。三种解法:\n"
|
|
107
|
+
" 1. 装 Git for Windows: https://git-scm.com/download/win\n"
|
|
108
|
+
" 2. 把已有的 bash(Cygwin / MSYS2 / WSL)加进 PATH\n"
|
|
109
|
+
" 3. 在 settings.json 里设 shellPath" + searched
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# `which` 也认绝对路径:存在且可执行才返回它 —— 于是 /bin/bash → PATH bash 一步到位
|
|
113
|
+
found = shutil.which("/bin/bash") or shutil.which("bash")
|
|
114
|
+
if found:
|
|
115
|
+
return _bash_config(found)
|
|
116
|
+
return ShellConfig(shell="sh", args=("-c",))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_powershell_config() -> ShellConfig:
|
|
120
|
+
"""Windows 上优先 PowerShell 7(`pwsh.exe`),退回内置 `powershell.exe`。
|
|
121
|
+
|
|
122
|
+
非 Windows 直接报错(与 pi 的 "only available on Windows" 同义),给出可照做的替代路径。
|
|
123
|
+
"""
|
|
124
|
+
if not is_windows():
|
|
125
|
+
raise ShellError(
|
|
126
|
+
f"powershell 工具仅 Windows 可用(当前平台 {sys.platform})。"
|
|
127
|
+
"类 Unix 上请用 bash;确需 pwsh 就用 bash 调 `pwsh -Command ...`。"
|
|
128
|
+
)
|
|
129
|
+
shell = shutil.which("pwsh.exe") or shutil.which("pwsh") or shutil.which("powershell.exe")
|
|
130
|
+
if shell is None:
|
|
131
|
+
raise ShellError("找不到 PowerShell:装 PowerShell 7(pwsh.exe),或把 powershell.exe 加进 PATH。")
|
|
132
|
+
return ShellConfig(shell=shell, args=POWERSHELL_ARGS)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# 会话环境变量(对齐 pi 的 `resolveSpawnContext`):被**删掉再填**,不是简单覆盖 ——
|
|
136
|
+
# 免得嵌在里面的子进程(子 agent 再跑 bash)继承上一层的值,那是很难查的一类串味。
|
|
137
|
+
SESSION_ENV_KEYS = ("QI_SESSION_ID", "QI_SESSION_FILE", "QI_PROVIDER", "QI_MODEL",
|
|
138
|
+
"QI_REASONING_LEVEL")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def session_env(session_id: str | None, session_file: str | None,
|
|
142
|
+
provider: str | None, model: str | None,
|
|
143
|
+
reasoning_level: str | None) -> dict[str, str]:
|
|
144
|
+
"""构造这一回合的会话环境变量(pi 的 `exposeSessionEnvironment`)。
|
|
145
|
+
|
|
146
|
+
给 bash / powershell 的运行时环境:`env | grep QI_` 就能自查"当前是哪个模型、
|
|
147
|
+
哪个会话"。**为什么必须走环境变量**:换模型是界面状态,不进对话上下文,
|
|
148
|
+
所以工具是 agent 唯一能**自证**"现在跑的是什么"的通道(见 docs/environment-variables.md)。
|
|
149
|
+
|
|
150
|
+
取不到的值**不设**(而不是设成空串):空串会被读成"设过了,值是空的"。
|
|
151
|
+
"""
|
|
152
|
+
pairs = {
|
|
153
|
+
"QI_SESSION_ID": session_id,
|
|
154
|
+
"QI_SESSION_FILE": session_file,
|
|
155
|
+
"QI_PROVIDER": provider,
|
|
156
|
+
"QI_MODEL": model,
|
|
157
|
+
"QI_REASONING_LEVEL": reasoning_level,
|
|
158
|
+
}
|
|
159
|
+
return {key: str(value) for key, value in pairs.items()
|
|
160
|
+
if value is not None and str(value) != ""}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def merged_env(extra: dict[str, str] | None) -> dict[str, str]:
|
|
164
|
+
"""当前进程环境,先把 `SESSION_ENV_KEYS` 摘掉再叠 `extra`(pi 同款顺序)。"""
|
|
165
|
+
env = dict(os.environ)
|
|
166
|
+
for key in SESSION_ENV_KEYS:
|
|
167
|
+
env.pop(key, None)
|
|
168
|
+
if extra:
|
|
169
|
+
env.update(extra)
|
|
170
|
+
return env
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _kill_tree(proc: asyncio.subprocess.Process) -> None:
|
|
174
|
+
"""超时要连子进程一起杀,否则 `sleep 100 &` 之类会逃逸成孤儿。"""
|
|
175
|
+
if not is_windows():
|
|
176
|
+
# start_new_session=True 时 shell 自己就是进程组组长
|
|
177
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
178
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
179
|
+
return
|
|
180
|
+
proc.kill()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# 在跑的子进程组(pid → 进程)。信号处理器里必须**同步**回收,所以用模块级登记表
|
|
184
|
+
# (pi 的 `trackDetachedChildPids` 同形);asyncio 单线程,普通 dict 就够,不需要锁。
|
|
185
|
+
_LIVE_CHILDREN: dict[int, asyncio.subprocess.Process] = {}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def kill_live_children() -> None:
|
|
189
|
+
"""杀掉当前所有在跑的子进程组 —— 供**同步**调用者用(信号处理器不能 await)。"""
|
|
190
|
+
for pid, proc in list(_LIVE_CHILDREN.items()):
|
|
191
|
+
_LIVE_CHILDREN.pop(pid, None) # 先除名:pid 可能被复用,别误杀别人
|
|
192
|
+
_kill_tree(proc)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _teardown(proc: asyncio.subprocess.Process, comm: asyncio.Future) -> None:
|
|
196
|
+
"""超时/中断后的回收:先停读取、再按进程组杀、最后 wait。
|
|
197
|
+
|
|
198
|
+
顺序同旧实现(取消 communicate → kill → wait):只 kill 不 wait 的话,transport
|
|
199
|
+
会拖到事件循环关闭后才被 GC,触发 "Event loop is closed" 的 unraisable 异常。
|
|
200
|
+
"""
|
|
201
|
+
if not comm.done():
|
|
202
|
+
comm.cancel()
|
|
203
|
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
|
204
|
+
await comm
|
|
205
|
+
_kill_tree(proc)
|
|
206
|
+
with contextlib.suppress(Exception):
|
|
207
|
+
await proc.wait()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def run_shell(config: ShellConfig, command: str, *, cwd: Path,
|
|
211
|
+
timeout: float, abort: AbortSignal | None = None,
|
|
212
|
+
env: dict[str, str] | None = None) -> ShellResult:
|
|
213
|
+
"""执行一条命令,返回 `(输出文本, 退出码)`。
|
|
214
|
+
|
|
215
|
+
命令以**参数**或 **stdin** 交给解析出的 shell 二进制,不走 `shell=True`,
|
|
216
|
+
所以执行器不会随平台漂移到 cmd.exe。stdin 用 DEVNULL:命令读不到 TUI 的按键。
|
|
217
|
+
|
|
218
|
+
`abort` 置位时不等命令自己退出:立即按**进程组**回收并返回 `aborted=True`。
|
|
219
|
+
没有这一步,一个 `sleep 300` 就会把 escape 拖成好几分钟的“没反应”。
|
|
220
|
+
|
|
221
|
+
`env` 是这一回合的**会话环境变量**(`QI_MODEL` 等,见 `session_env`);不给就照旧
|
|
222
|
+
继承进程环境(但会把 `SESSION_ENV_KEYS` 摘掉 —— 不许串味)。
|
|
223
|
+
|
|
224
|
+
异常/被取消时也**不会留孤儿**:收尾在 `finally` 里做(tools 的调用者被
|
|
225
|
+
`Task.cancel()`、SIGINT 让 asyncio 取消主任务,都会走到这里)。
|
|
226
|
+
"""
|
|
227
|
+
if config.command_via_stdin:
|
|
228
|
+
argv = [config.shell, *config.args]
|
|
229
|
+
stdin = asyncio.subprocess.PIPE
|
|
230
|
+
else:
|
|
231
|
+
argv = [config.shell, *config.args, command]
|
|
232
|
+
stdin = asyncio.subprocess.DEVNULL
|
|
233
|
+
|
|
234
|
+
popen_kwargs: dict = {"env": merged_env(env)}
|
|
235
|
+
if not is_windows():
|
|
236
|
+
popen_kwargs["start_new_session"] = True # 独立进程组,便于整组回收
|
|
237
|
+
|
|
238
|
+
proc = await asyncio.create_subprocess_exec(
|
|
239
|
+
*argv,
|
|
240
|
+
cwd=cwd,
|
|
241
|
+
stdin=stdin,
|
|
242
|
+
stdout=asyncio.subprocess.PIPE,
|
|
243
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
244
|
+
**popen_kwargs,
|
|
245
|
+
)
|
|
246
|
+
if config.command_via_stdin and proc.stdin is not None:
|
|
247
|
+
proc.stdin.write(command.encode("utf-8"))
|
|
248
|
+
with contextlib.suppress(Exception):
|
|
249
|
+
await proc.stdin.drain()
|
|
250
|
+
proc.stdin.close()
|
|
251
|
+
|
|
252
|
+
_LIVE_CHILDREN[proc.pid] = proc
|
|
253
|
+
comm = asyncio.ensure_future(proc.communicate())
|
|
254
|
+
watch = asyncio.ensure_future(abort.wait()) if abort is not None else None
|
|
255
|
+
try:
|
|
256
|
+
waiters = {comm} if watch is None else {comm, watch}
|
|
257
|
+
done, _pending = await asyncio.wait(waiters, timeout=timeout,
|
|
258
|
+
return_when=asyncio.FIRST_COMPLETED)
|
|
259
|
+
if not done: # 超时:拿不到退出码
|
|
260
|
+
return ShellResult(text="", exit_code=None, timed_out=True)
|
|
261
|
+
if watch is not None and watch in done and comm not in done:
|
|
262
|
+
return ShellResult(text="", exit_code=None, aborted=True) # 中断
|
|
263
|
+
out, _ = comm.result()
|
|
264
|
+
finally:
|
|
265
|
+
# 还在跑 = 超时 / 中断 / **被取消** / 异常。不能只处理前两者:调用方的任务被
|
|
266
|
+
# cancel(escape 强制终止、Ctrl-C 让 asyncio 取消主任务)时不会有人回来杀它。
|
|
267
|
+
# `_kill_tree` 是同步的,所以即使下面的 await 又被取消,进程也已经死了。
|
|
268
|
+
if proc.returncode is None:
|
|
269
|
+
await _teardown(proc, comm)
|
|
270
|
+
_LIVE_CHILDREN.pop(proc.pid, None)
|
|
271
|
+
if watch is not None:
|
|
272
|
+
watch.cancel()
|
|
273
|
+
with contextlib.suppress(Exception, asyncio.CancelledError):
|
|
274
|
+
await watch
|
|
275
|
+
|
|
276
|
+
return ShellResult(text=out.decode("utf-8", errors="replace"), exit_code=proc.returncode)
|
qi_agent/trust.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""按**目录**记住信任决定(`~/.qi/agent/trust.json`)—— 对齐 pi。
|
|
2
|
+
|
|
3
|
+
两个要点(都是 pi 文档里的原话):
|
|
4
|
+
|
|
5
|
+
1. **存在用户 home,不在仓库里。** 仓库不能为仓库自己背书 —— 否则一行
|
|
6
|
+
`.qi/settings.json` 就能让自己的任意代码跑起来(那个漏洞真存在过,见
|
|
7
|
+
`tests/test_trust_scope.py`)。
|
|
8
|
+
2. **沿祖先链查,最近的那条先生效**:pi 的 "the closest saved decision on the current or
|
|
9
|
+
parent path applies before the global default"。也就是说只轮到 `defaultProjectTrust`
|
|
10
|
+
是在**没有任何已存决定**的时候。
|
|
11
|
+
|
|
12
|
+
`/trust` 连带记住**上一层目录**(pi 的 `/trust` 明确这么做:同一条路径下的平级项目都会被覆盖),
|
|
13
|
+
并且**当前会话不重载** —— 写完要重启才生效(pi 同样如此,见 `docs/security.md`)。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
TRUST_FILE_NAME = "trust.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def trust_file(path: Path | None = None) -> Path:
|
|
26
|
+
"""信任库的位置:`~/.qi/agent/trust.json`(可用 `QI_AGENT_HOME` 覆盖那一层)。"""
|
|
27
|
+
if path is not None:
|
|
28
|
+
return path
|
|
29
|
+
from .paths import global_home
|
|
30
|
+
|
|
31
|
+
return global_home() / TRUST_FILE_NAME
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _canonical(path: Path | str) -> str:
|
|
35
|
+
"""规范目录 —— 决定按它存(pi 用的也是 canonical directory)。"""
|
|
36
|
+
try:
|
|
37
|
+
return str(Path(path).expanduser().resolve())
|
|
38
|
+
except OSError:
|
|
39
|
+
return str(Path(path).expanduser())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TrustStore:
|
|
43
|
+
"""`~/.qi/agent/trust.json`:`{规范目录: true|false}`。"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
46
|
+
self.path = trust_file(path)
|
|
47
|
+
|
|
48
|
+
# ── 读 ────────────────────────────────────────────────────────────
|
|
49
|
+
def load(self) -> dict[str, bool]:
|
|
50
|
+
"""读整表。坏文件当"没有决定"—— 启动路径上不能因此崩(它只影响信任,不影响别的)。"""
|
|
51
|
+
if not self.path.is_file():
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return {}
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
return {}
|
|
59
|
+
return {str(key): bool(value) for key, value in data.items() if isinstance(value, bool)}
|
|
60
|
+
|
|
61
|
+
def get(self, cwd: Path | str) -> tuple[bool, str] | None:
|
|
62
|
+
"""当前目录或**父目录**上最近的决定 → `(trusted, 命中的那个目录)`;没有则 None。"""
|
|
63
|
+
table = self.load()
|
|
64
|
+
here = Path(_canonical(cwd))
|
|
65
|
+
for candidate in (here, *here.parents):
|
|
66
|
+
found = table.get(str(candidate))
|
|
67
|
+
if found is not None:
|
|
68
|
+
return found, str(candidate)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def decisions(self) -> dict[str, bool]:
|
|
72
|
+
"""全部决定(给 `/trust` 或诊断用),按路径排序。"""
|
|
73
|
+
return dict(sorted(self.load().items()))
|
|
74
|
+
|
|
75
|
+
# ── 写 ────────────────────────────────────────────────────────────
|
|
76
|
+
def set(self, cwd: Path | str, trusted: bool, *, parent: bool = False) -> Path:
|
|
77
|
+
"""记住某个目录的决定;`parent=True` 时**连带上一层**(pi 的 `/trust` 行为)。
|
|
78
|
+
|
|
79
|
+
写盘用「临时文件 + `os.replace`」并置 0600:这是个安全相关的文件,
|
|
80
|
+
半个写坏的信任库比没有更糟(读的时候会被当成"没有决定",于是静默失效)。
|
|
81
|
+
"""
|
|
82
|
+
table = self.load()
|
|
83
|
+
target = Path(_canonical(cwd))
|
|
84
|
+
table[str(target)] = bool(trusted)
|
|
85
|
+
if parent and target.parent != target:
|
|
86
|
+
table[str(target.parent)] = bool(trusted)
|
|
87
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
tmp = self.path.with_suffix(".tmp")
|
|
89
|
+
tmp.write_text(json.dumps(table, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
90
|
+
encoding="utf-8")
|
|
91
|
+
os.chmod(tmp, 0o600)
|
|
92
|
+
os.replace(tmp, self.path)
|
|
93
|
+
return self.path
|
|
94
|
+
|
|
95
|
+
def forget(self, cwd: Path | str) -> bool:
|
|
96
|
+
"""删掉某个目录的决定(注意:上层若还有决定,它照样生效)。"""
|
|
97
|
+
table = self.load()
|
|
98
|
+
if table.pop(_canonical(cwd), None) is None:
|
|
99
|
+
return False
|
|
100
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
tmp = self.path.with_suffix(".tmp")
|
|
102
|
+
tmp.write_text(json.dumps(table, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
103
|
+
encoding="utf-8")
|
|
104
|
+
os.chmod(tmp, 0o600)
|
|
105
|
+
os.replace(tmp, self.path)
|
|
106
|
+
return True
|