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.
Files changed (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/packages.py ADDED
@@ -0,0 +1,430 @@
1
+ """packages 声明层:`settings.packages` ↔ 实际已装扩展的**比对**(只读)。
2
+
3
+ 为什么要单独一个模块:声明层解决的是 uv tool / pipx 那个坑 —— 它们会把 qi 自己的
4
+ 环境**整个重建**,把 pip 装进去的扩展一并抹掉。所以「这个环境该装哪些扩展」必须有一份
5
+ 落在 settings.json 里的声明,重建之后才有补装依据。
6
+
7
+ **本模块只回答「差在哪」,不调 pip、不写任何文件、不起子进程**(决策 C):
8
+ 安装动作交给用户,由 `install_hints()` 生成可复制的命令。这样没有只读解释器、
9
+ uv tool、pipx 那些环境分支要处理,也没有半途失败的中间态。
10
+
11
+ 洞见来自与 `_extension_report()` 同一条原则:信息取自**注册面的反查**(entry point /
12
+ 目录扫描),不是让扩展自报 —— 否则「声明里说装了」与「宿主真能加载」会各说各话。
13
+
14
+ 三条通道都要看,只看 entry point 会把目录通道的扩展误报成「声明未安装」:
15
+
16
+ * pip 通道 —— entry point 组 `qi.extensions`(dist 名 + 版本)
17
+ * 目录通道 —— `<全局|项目>/extensions/<名>/extension.py`
18
+ * 附加路径 —— `settings.extensions[]` 解析出的目录(由 `settings.extension_dirs()` 负责作用域标注)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import sys
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any, Iterable
28
+
29
+ from . import paths
30
+ from .registry import EXTENSION_ENTRY_FILE, EXTENSION_ENTRY_POINT_GROUP, HOST_DISTRIBUTION
31
+
32
+ #: 声明里区分通道的前缀(`pip:qi-mcp` / `local:/path/to/ext`)。
33
+ _PIP_PREFIX = "pip:"
34
+ _LOCAL_PREFIX = "local:"
35
+
36
+
37
+ def normalize_name(name: str) -> str:
38
+ """PEP 503 归一:大小写与 `-_.` 视为等价。
39
+
40
+ 与 `registry._normalize_dist_name` 同一规则(那边判「有没有钉宿主版本」,这边判
41
+ 「声明与已装是不是同一个东西」)。两处必须一致,否则 `Qi.MCP` 会在一边算命中、
42
+ 在另一边算不命中。
43
+ """
44
+ return re.sub(r"[-_.]+", "-", name or "").strip().lower()
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class DeclaredPackage:
49
+ """`settings.packages` 里的一条声明。"""
50
+
51
+ spec: str # 原样保留,出错时能原样回显给用户
52
+ name: str # 归一后的名字,用于比对
53
+ channel: str # pip | local
54
+ source: str # settings:user / settings:project …
55
+ requirement: str | None = None # pip 通道的原始 requirement(含版本约束),local 为 None
56
+ #: 对象形态里的 `extensions` 键(pi 的 "object form filters which resources to load",
57
+ #: 照 `docs/settings.md` 的 packages 一节)。`[]` = 这个包**不贡献扩展**;
58
+ #: 键缺失(`None`)= 不过滤(全部加载)。`qi config` 的"关掉"就是写 `[]`。
59
+ extensions: list[str] | None = None
60
+
61
+ @property
62
+ def contributes_extensions(self) -> bool:
63
+ """这个包该不该贡献扩展。对象形态里显式写了空数组 = 不贡献。"""
64
+ return self.extensions is None or bool(self.extensions)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class InstalledExtension:
69
+ """实际**能被宿主加载**的扩展。"""
70
+
71
+ name: str
72
+ channel: str # pip | local
73
+ origin: str # dist 名(带版本)或 extension.py 路径
74
+ scope: str # user / project / temporary
75
+ version: str | None = None
76
+
77
+
78
+ @dataclass
79
+ class PackageReport:
80
+ """比对结果。`missing` / `undeclared` 是两个方向,不能合成一个数。"""
81
+
82
+ installed: list[InstalledExtension] = field(default_factory=list)
83
+ declared: list[DeclaredPackage] = field(default_factory=list)
84
+ missing: list[DeclaredPackage] = field(default_factory=list) # 声明了但装不上
85
+ undeclared: list[InstalledExtension] = field(default_factory=list) # 装了但没声明
86
+ unparsed: list[str] = field(default_factory=list) # 认不出的声明(原样回显)
87
+
88
+ @property
89
+ def consistent(self) -> bool:
90
+ return not self.missing and not self.undeclared and not self.unparsed
91
+
92
+
93
+ def _extension_filter(value: Any) -> list[str] | None:
94
+ """对象形态里的 `extensions` 键 → `list[str] | None`。
95
+
96
+ `None`(键缺失)= **不过滤**,全部加载 —— pi 的语义("object form filters which
97
+ resources to load",没提的种类不过滤);`[]` = 这个包不贡献扩展。
98
+ 值写歪了(不是列表/字符串)也按“不过滤”处理 —— 不因为一个键写错就把包判死。
99
+ """
100
+ if value is None:
101
+ return None
102
+ if isinstance(value, str):
103
+ return [value]
104
+ if isinstance(value, (list, tuple)):
105
+ return [str(item) for item in value]
106
+ return None
107
+
108
+
109
+ def parse_declaration(entry: Any, source: str) -> DeclaredPackage | None:
110
+ """一条声明 → `DeclaredPackage`。**认不出就返回 None**(由调用方原样上报,不静默丢)。
111
+
112
+ 容忍两种形状:字符串 `"pip:qi-mcp"`,以及 dict `{"name": …, "source": …}`
113
+ (`settings.packages` 声明为 `list[Any]`,历史上允许写结构体)。
114
+ """
115
+ if isinstance(entry, dict):
116
+ raw = entry.get("source") or entry.get("spec") or entry.get("name")
117
+ else:
118
+ raw = entry
119
+ # 对象形态的其它键(pi:只挑要加载的资源种类)。目前只认 `extensions` —— qi 的
120
+ # pip 包也只贡献这一类资源;将来包带技能/主题时在这里扩键即可。
121
+ options = entry if isinstance(entry, dict) else None
122
+ declared_extensions = options.get("extensions") if options else None
123
+ spec = str(raw or "").strip()
124
+ if not spec:
125
+ return None
126
+ original = spec # `spec` 的契约是**原样保留**(报错时回显用户到底写了什么)
127
+
128
+ if spec.startswith(_LOCAL_PREFIX):
129
+ target = spec[len(_LOCAL_PREFIX):].strip()
130
+ name = Path(target).name or target
131
+ return DeclaredPackage(spec=spec, name=normalize_name(name),
132
+ channel="local", source=source,
133
+ extensions=_extension_filter(declared_extensions))
134
+ if spec.startswith(_PIP_PREFIX):
135
+ spec = spec[len(_PIP_PREFIX):].strip()
136
+ # 裸路径也算目录通道:`/abs/path`、`./rel`、`~/x`
137
+ if spec.startswith(("/", "./", "../", "~")) or spec.startswith("file:"):
138
+ name = Path(spec.removeprefix("file:")).name or spec
139
+ return DeclaredPackage(spec=spec, name=normalize_name(name),
140
+ channel="local", source=source)
141
+
142
+ # PEP 508 的 `名字 @ URL` 形式:名字是显式的,取它
143
+ explicit = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)\s*@\s*\S+", spec)
144
+ if explicit:
145
+ return DeclaredPackage(spec=original, name=normalize_name(explicit.group(1)),
146
+ channel="pip", source=source, requirement=spec)
147
+ # 裸 URL / VCS 地址没有可提取的名字 —— 不猜(否则 “git+https://host/qi-mcp” 会被
148
+ # 当成一个叫 "git" 的包,比认不出更坏)。交给调用方回显,让用户写成 `名字 @ URL`。
149
+ if "://" in spec:
150
+ return None
151
+
152
+ match = re.match(r"^([A-Za-z0-9][A-Za-z0-9._-]*)", spec)
153
+ if not match:
154
+ return None
155
+ return DeclaredPackage(spec=original, name=normalize_name(match.group(1)),
156
+ channel="pip", source=source, requirement=spec,
157
+ extensions=_extension_filter(declared_extensions))
158
+
159
+
160
+ def discover_declared(cwd: Path | None = None) -> tuple[list[DeclaredPackage], list[str]]:
161
+ """两个作用域的 `settings.packages` 都收;同名以**项目级**为准(近者胜)。
162
+
163
+ → `(可解析的声明, 认不出的原样 spec)`。第二个返回值不能少:静默丢掉一条声明
164
+ 等于“声明了但看起来没声明”,比报错难诊断得多。
165
+
166
+ 按作用域各自解析(不是合并后的数组),理由同 `settings.extension_dirs()`:
167
+ 合并会让一侧的声明落到另一侧的基准上。
168
+ """
169
+ from .settings import load_settings_by_scope
170
+
171
+ scopes = load_settings_by_scope(cwd) # 坏设置就直接抛:调用方必须报出来
172
+
173
+ out: dict[str, DeclaredPackage] = {}
174
+ unparsed: list[str] = []
175
+ for scope in ("user", "project"): # 先远后近,project 覆盖 user
176
+ settings = scopes.get(scope)
177
+ if settings is None:
178
+ continue
179
+ for entry in getattr(settings, "packages", None) or []:
180
+ decl = parse_declaration(entry, f"settings:{scope}")
181
+ if decl is None:
182
+ unparsed.append(str(entry))
183
+ else:
184
+ out[decl.name] = decl
185
+ return list(out.values()), unparsed
186
+
187
+
188
+ def _dir_extensions(root: Path, scope: str) -> list[InstalledExtension]:
189
+ """目录通道:`<root>/<名>/extension.py`。"""
190
+ if not root.is_dir():
191
+ return []
192
+ found = []
193
+ for child in sorted(root.iterdir()):
194
+ entry = child / EXTENSION_ENTRY_FILE
195
+ if child.is_dir() and entry.is_file():
196
+ found.append(InstalledExtension(name=normalize_name(child.name),
197
+ channel="local", origin=str(entry),
198
+ scope=scope))
199
+ return found
200
+
201
+
202
+ def discover_installed(cwd: Path | None = None, *, project_trusted: bool = False,
203
+ extra_dirs: Iterable[Path | tuple[Path, str]] | None = None
204
+ ) -> list[InstalledExtension]:
205
+ """实际可加载的扩展:entry point + 目录通道 + 附加路径。
206
+
207
+ `project_trusted=False`(doctor 的默认)时**不扫项目目录** —— 与装载路径
208
+ (`registry._iter_extension_loaders`)保持一致:声明层报告不该越过信任门控,
209
+ 否则未信任的项目会因为一份 `.qi/extensions/` 而显得"已装"。
210
+ """
211
+ import importlib.metadata as metadata
212
+
213
+ out: list[InstalledExtension] = []
214
+ for ep in metadata.entry_points(group=EXTENSION_ENTRY_POINT_GROUP):
215
+ dist = getattr(ep, "dist", None)
216
+ dist_name = getattr(dist, "name", None) or ep.value.split(":")[0].split(".")[0]
217
+ version = getattr(dist, "version", None)
218
+ out.append(InstalledExtension(
219
+ name=normalize_name(dist_name), channel="pip",
220
+ origin=f"{dist_name} {version}" if version else str(dist_name),
221
+ scope="user", version=version))
222
+
223
+ if project_trusted:
224
+ out += _dir_extensions(paths.project_home(cwd) / paths.EXTENSIONS_DIR_NAME, "project")
225
+ out += _dir_extensions(paths.global_home() / paths.EXTENSIONS_DIR_NAME, "user")
226
+
227
+ # 附加路径:可能是父目录,也可能本身就是一个扩展目录
228
+ for path, scope in _normalize_extra(extra_dirs):
229
+ if (path / EXTENSION_ENTRY_FILE).is_file():
230
+ out.append(InstalledExtension(name=normalize_name(path.name), channel="local",
231
+ origin=str(path / EXTENSION_ENTRY_FILE),
232
+ scope=scope))
233
+ else:
234
+ out += _dir_extensions(path, scope)
235
+
236
+ seen: set[str] = set()
237
+ unique: list[InstalledExtension] = []
238
+ for item in out: # 同名去重(entry point 先到先得)
239
+ if item.name not in seen:
240
+ seen.add(item.name)
241
+ unique.append(item)
242
+ return unique
243
+
244
+
245
+ def _normalize_extra(extra_dirs: Iterable[Path | tuple[Path, str]] | None
246
+ ) -> list[tuple[Path, str]]:
247
+ """与 `registry._normalize_extra` 同义:裸 Path 记 temporary。"""
248
+ out: list[tuple[Path, str]] = []
249
+ for item in extra_dirs or ():
250
+ if isinstance(item, tuple):
251
+ out.append((Path(item[0]), str(item[1])))
252
+ else:
253
+ out.append((Path(item), "temporary"))
254
+ return out
255
+
256
+
257
+ def build_report(installed: Iterable[InstalledExtension],
258
+ declared: Iterable[DeclaredPackage],
259
+ unparsed: Iterable[str] = ()) -> PackageReport:
260
+ """纯比对(不碰文件系统 / 元数据),所以测试不必造环境。"""
261
+ installed_list = list(installed)
262
+ declared_list = list(declared)
263
+ have = {item.name for item in installed_list}
264
+ want = {item.name for item in declared_list}
265
+ return PackageReport(
266
+ installed=installed_list,
267
+ declared=declared_list,
268
+ missing=[item for item in declared_list if item.name not in have],
269
+ undeclared=[item for item in installed_list if item.name not in want],
270
+ unparsed=list(unparsed),
271
+ )
272
+
273
+
274
+ def package_report(cwd: Path | None = None, *, project_trusted: bool = False
275
+ ) -> PackageReport:
276
+ """发现 + 比对。doctor / `qi list` 的唯一入口。
277
+
278
+ **可能抛 `settings.SettingsError`**(settings.json 校验失败)—— 不在这里吞:
279
+ 吞掉的话“设置写坏了”会表现成“一条声明也没有”,而这两种情况的处置完全不同。
280
+ """
281
+ from .settings import extension_dirs, load_settings_by_scope
282
+
283
+ extra: list[Path] = []
284
+ if load_settings_by_scope(cwd):
285
+ extra = [p for p, _ in extension_dirs(cwd, trusted=project_trusted)]
286
+ installed = discover_installed(cwd, project_trusted=project_trusted, extra_dirs=extra)
287
+ declared, unparsed = discover_declared(cwd)
288
+ return build_report(installed, declared, unparsed)
289
+
290
+
291
+ @dataclass(frozen=True)
292
+ class Resource:
293
+ """一个**可启停的资源项**:目录扩展 或 声明的 pip 包。
294
+
295
+ "关"的表示法两种(都照 pi,**都不是新字段**),所以状态是**记住的** ——
296
+ 面板能区分"没配过"与"主动关了",这也是 `pi config -l` 能把继承项置灰的前提:
297
+
298
+ * 目录扩展 → `settings.extensions[]` 里加一条否定项 `-<路径>`;
299
+ * pip 包 → `settings.packages` 里那一条换成对象形态 `{"source": X, "extensions": []}`。
300
+ """
301
+
302
+ kind: str # "extension" | "package"
303
+ name: str
304
+ detail: str # 路径 / 原样声明
305
+ scope: str # user | project
306
+ enabled: bool
307
+ spec: str = "" # package 用:"开"时写回的原样声明
308
+
309
+
310
+ def list_resources(cwd: Path | None = None, *, trusted: bool = True) -> list[Resource]:
311
+ """可启停的资源清单(目录扩展 + 声明的 pip 包),带当前启用状态。
312
+
313
+ 面得比 pi 窄一点:`local:` 声明目前只是**声明**(loader 不从 `packages` 读目录通道),
314
+ 所以它列不出来 —— 目录通道的资源请写进 `settings.extensions[]`(那才是它生效的地方)。
315
+ 不知道"关"状态的(比如 `settings.packages` 里的结构体写法)按**开**算。
316
+ """
317
+ from . import paths
318
+ from .settings import (load_settings_by_scope, settings_exclude_paths,
319
+ settings_include_paths)
320
+
321
+ scopes = load_settings_by_scope(cwd)
322
+ out: list[Resource] = []
323
+ for scope in ("user", "project"):
324
+ if scope == "project" and not trusted:
325
+ continue
326
+ settings = scopes.get(scope)
327
+ excluded = {_abs(p) for p in settings_exclude_paths(settings, scope, "extensions", cwd)}
328
+ seen: set[Path] = set()
329
+ home = paths.project_home(cwd) if scope == "project" else paths.global_home()
330
+ candidates: list[Path] = []
331
+ builtin = home / paths.EXTENSIONS_DIR_NAME
332
+ if builtin.is_dir():
333
+ candidates += sorted(builtin.iterdir())
334
+ for entry in settings_include_paths(settings, scope, "extensions", cwd):
335
+ candidates += [entry] if (entry / EXTENSION_ENTRY_FILE).is_file() else (
336
+ sorted(entry.iterdir()) if entry.is_dir() else [])
337
+ for child in candidates:
338
+ if not (child.is_dir() and (child / EXTENSION_ENTRY_FILE).is_file()):
339
+ continue
340
+ key = _abs(child)
341
+ if key in seen:
342
+ continue
343
+ seen.add(key)
344
+ out.append(Resource(kind="extension", name=child.name, detail=str(child),
345
+ scope=scope, enabled=key not in excluded))
346
+
347
+ declared, _unparsed = discover_declared(cwd)
348
+ for decl in declared:
349
+ if decl.channel != "pip":
350
+ continue # 目录通道的见上(它们该写在 settings.extensions)
351
+ out.append(Resource(kind="package", name=decl.name, detail=decl.spec,
352
+ scope=decl.source.split(":")[-1],
353
+ enabled=decl.contributes_extensions, spec=decl.spec))
354
+ return out
355
+
356
+
357
+ def set_resource_enabled(resource: Resource, enabled: bool, cwd: Path | None = None) -> str:
358
+ """把一个资源开关写成声明;返回一句人话(给 CLI 打出来)。
359
+
360
+ 只动该资源**所在作用域**的 settings —— 这正是"项目关掉、全局还开着"能表达的原因。
361
+ """
362
+ from .settings import load_settings_by_scope, set_value
363
+
364
+ scope = resource.scope
365
+ settings = load_settings_by_scope(cwd).get(scope)
366
+ if resource.kind == "extension":
367
+ values = [str(item) for item in (getattr(settings, "extensions", None) or [])]
368
+ negated = f"-{resource.detail}"
369
+ if enabled:
370
+ values = [item for item in values if item != negated]
371
+ elif negated not in values:
372
+ values.append(negated)
373
+ set_value(scope, "extensions", values, cwd)
374
+ return (f"{'启用' if enabled else '关闭'}扩展 {resource.name}"
375
+ f"({scope} settings.extensions {'去掉' if enabled else '加了'}否定项)")
376
+
377
+ raw = list(getattr(settings, "packages", None) or [])
378
+ rewritten: list[Any] = []
379
+ for item in raw:
380
+ decl = parse_declaration(item, f"settings:{scope}")
381
+ if decl is not None and decl.name == resource.name:
382
+ rewritten.append(resource.spec if enabled
383
+ else {"source": resource.spec, "extensions": []})
384
+ else:
385
+ rewritten.append(item)
386
+ set_value(scope, "packages", rewritten, cwd)
387
+ return (f"{'启用' if enabled else '关闭'}包 {resource.name}"
388
+ f"({scope} settings.packages 写成{'一行' if enabled else '对象形态 extensions: []'})")
389
+
390
+
391
+ def apply_resource_selection(resources: list[Resource], enabled: set[int],
392
+ cwd: Path | None = None) -> list[str]:
393
+ """面板 / 脚本交回“哪些保持启用”(下标)后,把**改动**写成声明。返回人话列表。
394
+
395
+ 纯函数式的分工:UI 只负责显示与收回勾选,写声明在这里 —— 于是
396
+ “面板怎么用”与“写了什么”可以分别测,而且取消时不会留下副作用(调用方根本不调它)。
397
+ 没变的项不动(不重写 settings,也不在输出里噬噬)。
398
+ """
399
+ notes: list[str] = []
400
+ for index, resource in enumerate(resources):
401
+ wanted = index in enabled
402
+ if wanted == resource.enabled:
403
+ continue
404
+ notes.append(set_resource_enabled(resource, wanted, cwd))
405
+ return notes
406
+
407
+
408
+ def _abs(path: Path) -> Path:
409
+ """比较用的绝对路径(解析不了就退原样 —— 不因此把排除项当成没写)。"""
410
+ try:
411
+ return path.resolve()
412
+ except OSError:
413
+ return path
414
+
415
+
416
+ def install_hints(spec: str) -> list[str]:
417
+ """一条声明的**可复制**装法。
418
+
419
+ 两条出路都给出,因为它们的失效面不同:直接 pip 装进 qi 的 venv 最直接,但
420
+ `uv tool install` 重建环境时会丢;写进 uv 的托管依赖则升级不丢。不选边 ——
421
+ 用户才知道自己是怎么装的。
422
+ """
423
+ if spec.startswith(_LOCAL_PREFIX):
424
+ spec = spec[len(_LOCAL_PREFIX):].strip()
425
+ if spec.startswith(_PIP_PREFIX):
426
+ spec = spec[len(_PIP_PREFIX):].strip()
427
+ return [
428
+ f'{sys.executable} -m pip install "{spec}"',
429
+ f'uv tool install {HOST_DISTRIBUTION} --with "{spec}"',
430
+ ]
qi_agent/paths.py ADDED
@@ -0,0 +1,242 @@
1
+ """运行时目录与路径约定(对齐 pi 的两级布局)。
2
+
3
+ pi 的全局侧比项目侧深一层,配对关系是 `~/.pi/agent/` ↔ `<项目>/.pi/`:
4
+
5
+ 全局(agent 目录) `~/.qi/agent` ← 对齐 pi 的 `~/.pi/agent` / PI_CODING_AGENT_DIR
6
+ 名字空间根 `~/.qi` ← 只作外壳,内容都在 agent/ 下
7
+ 项目 `<git根>/.qi` ← 与 `~/.qi/agent` 配对,保持扁平
8
+
9
+ `QI_AGENT_HOME` 指向的是 **agent 目录本身**(语义同 `PI_CODING_AGENT_DIR`),
10
+ `QI_CONFIG_DIR` 覆盖名字空间根,`QI_AGENT_CONFIG` 直接指定 models.json 文件。
11
+
12
+ 模型配置文件名 `models.json`,格式对齐 pi;凭证明文只在 `auth.json`。
13
+ 旧版扁平布局(`~/.qi/models.json` 等)在启动时自动迁移到 `~/.qi/agent/`。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ from pathlib import Path
21
+
22
+ GLOBAL_DIR_NAME = ".qi"
23
+ AGENT_DIR_NAME = "agent"
24
+ MODELS_FILE_NAME = "models.json"
25
+ AUTH_FILE_NAME = "auth.json"
26
+ SETTINGS_FILE_NAME = "settings.json"
27
+ SYSTEM_FILE_NAME = "SYSTEM.md"
28
+ #: 角色定义文件名。core **不读**它(qi-agents 读);这里只用来判「有没有角色目录」,
29
+ #: 好在没装 qi-agents 时说一句(见 `QiRuntime._note_missing_agent_support`)。
30
+ AGENT_FILE_NAME = "agent.md"
31
+ SESSIONS_DIR_NAME = "sessions"
32
+ AGENTS_DIR_NAME = "agents"
33
+ PLUGINS_DIR_NAME = "plugins" # ⚠️ v0.1 旧名,仅用于迁移;新代码用 EXTENSIONS_DIR_NAME
34
+ EXTENSIONS_DIR_NAME = "extensions" # 扩展目录(P-E1 改名:plugins → extensions)
35
+ SKILLS_DIR_NAME = "skills"
36
+ CROSS_TOOL_DIR_NAME = ".agents" # Agent Skills 标准的跨工具目录(~/.agents, .agents)
37
+ DOCS_DIR_NAME = "docs" # 手册目录(打包后住 qi_agent/docs/,源码树里在仓库根)
38
+ DOCS_INDEX_FILE_NAME = "docs.json" # 手册索引:导航 + 重定向(见 docs/docs.json)
39
+
40
+ # 环境变量
41
+ QI_AGENT_HOME = "QI_AGENT_HOME" # 覆盖全局 agent 目录(对齐 PI_CODING_AGENT_DIR)
42
+ QI_CONFIG_DIR = "QI_CONFIG_DIR" # 覆盖名字空间根(默认 ~/.qi)
43
+ QI_AGENT_CONFIG = "QI_AGENT_CONFIG" # 指向 models.json 具体文件(最高优先级)
44
+
45
+ # v0.1 的扁平布局条目:启动时搬进 agent/(目标已存在则不动)
46
+ LEGACY_GLOBAL_ENTRIES = (
47
+ MODELS_FILE_NAME,
48
+ AUTH_FILE_NAME,
49
+ SETTINGS_FILE_NAME,
50
+ SYSTEM_FILE_NAME,
51
+ AGENTS_DIR_NAME,
52
+ PLUGINS_DIR_NAME, # 旧名,落地时按 LEGACY_ENTRY_RENAMES 改成 extensions/
53
+ SKILLS_DIR_NAME,
54
+ SESSIONS_DIR_NAME,
55
+ )
56
+
57
+ #: 旧条目名 → 现名(改名过的)。搬运时按**现名**落点,否则搬过去也没人读。
58
+ LEGACY_ENTRY_RENAMES = {PLUGINS_DIR_NAME: EXTENSIONS_DIR_NAME}
59
+
60
+
61
+ def package_dir() -> Path:
62
+ """`qi_agent` 包目录。打包后的手册就住在它下面(`qi_agent/docs/`)。"""
63
+ return Path(__file__).resolve().parent
64
+
65
+
66
+ def docs_dir() -> Path | None:
67
+ """手册目录:优先 wheel 里那份(`qi_agent/docs/`),退回源码树的 `<repo>/docs/`。
68
+
69
+ 两处都找不到就返回 `None` —— 调用方据此**整块不注入**(而不是往提示词里写一个
70
+ 不存在的路径)。`--no-tools` 之类把 read/bash 拿掉的场景也由调用方另判。
71
+ """
72
+ packaged = package_dir() / DOCS_DIR_NAME
73
+ if packaged.is_dir():
74
+ return packaged
75
+ repo = package_dir().parents[1] / DOCS_DIR_NAME # src/qi_agent → 仓库根
76
+ return repo if repo.is_dir() else None
77
+
78
+
79
+ def config_root() -> Path:
80
+ """名字空间根(`~/.qi`;仅用于推导默认路径与迁移)。"""
81
+ override = os.environ.get(QI_CONFIG_DIR)
82
+ return Path(override).expanduser() if override else Path.home() / GLOBAL_DIR_NAME
83
+
84
+
85
+ def global_home() -> Path:
86
+ """全局 agent 目录(`~/.qi/agent`,可用 QI_AGENT_HOME 覆盖)。
87
+
88
+ 这是全部用户级状态的挂载点:settings.json / models.json / auth.json /
89
+ sessions / skills / agents / extensions。对齐 pi 的 `getAgentDir()`。
90
+ """
91
+ override = os.environ.get(QI_AGENT_HOME)
92
+ return Path(override).expanduser() if override else config_root() / AGENT_DIR_NAME
93
+
94
+
95
+ def find_project_root(start: Path | None = None) -> Path:
96
+ """向上找含 .git 的目录作为项目根;找不到则用 cwd。"""
97
+ cur = (start or Path.cwd()).resolve()
98
+ if cur.is_file():
99
+ cur = cur.parent
100
+ for p in (cur, *cur.parents):
101
+ if (p / ".git").exists():
102
+ return p
103
+ return cur
104
+
105
+
106
+ def project_home(cwd: Path | None = None) -> Path:
107
+ """项目 qi 目录(<git根>/.qi,扁平;与 user 侧 agent/ 配对)。"""
108
+ return find_project_root(cwd) / GLOBAL_DIR_NAME
109
+
110
+
111
+ def project_context_ancestors(cwd: Path | None = None) -> list[Path]:
112
+ """cwd → git 根(无 git 则到文件系统根)的祖先链,**由近到远**。
113
+
114
+ 用于 `.agents/skills` 这类允许逐级继承的目录(对齐 pi 的祖先探测)。
115
+ """
116
+ cur = (cwd or Path.cwd()).resolve()
117
+ if cur.is_file():
118
+ cur = cur.parent
119
+ root = find_project_root(cur)
120
+ chain = [cur, *cur.parents]
121
+ return [p for p in chain if p >= root]
122
+
123
+
124
+ def models_file_candidates(cwd: Path | None = None) -> list[Path]:
125
+ """按优先级从高到低返回可能的 models.json 路径(含 env 指定文件)。"""
126
+ candidates: list[Path] = []
127
+ env_file = os.environ.get(QI_AGENT_CONFIG)
128
+ if env_file:
129
+ candidates.append(Path(env_file).expanduser())
130
+ candidates.append(project_home(cwd) / MODELS_FILE_NAME)
131
+ candidates.append(global_home() / MODELS_FILE_NAME)
132
+ return candidates
133
+
134
+
135
+ def settings_file_candidates(cwd: Path | None = None) -> list[tuple[Path, str]]:
136
+ """按优先级从高到低返回 (settings.json, 来源)。
137
+
138
+ 来源取值 `project` / `user`;路径类字段相对**各自文件所在目录**解析。
139
+ """
140
+ return [
141
+ (project_home(cwd) / SETTINGS_FILE_NAME, "project"),
142
+ (global_home() / SETTINGS_FILE_NAME, "user"),
143
+ ]
144
+
145
+
146
+ def cross_tool_skills_dirs(cwd: Path | None = None) -> list[tuple[Path, str]]:
147
+ """Agent Skills 标准的 `.agents/skills` 目录,低 → 高优先级。
148
+
149
+ 全局 `~/.agents/skills`(永远视为已信任)与项目祖先链 `.agents/skills`
150
+ (由远到近,近者覆盖远者)。不对应任何单一工具,见 design/agent-config-design.md。
151
+ """
152
+ home = Path.home() / CROSS_TOOL_DIR_NAME / SKILLS_DIR_NAME
153
+ project = [
154
+ p / CROSS_TOOL_DIR_NAME / SKILLS_DIR_NAME
155
+ for p in reversed(project_context_ancestors(cwd))
156
+ ]
157
+ return [(home, "agents-global"), *((p, "agents-project") for p in project)]
158
+
159
+
160
+ def migrate_legacy_layout() -> list[tuple[Path, Path]]:
161
+ """把 v0.1 扁平布局(`~/.qi/<条目>`)搬进 `~/.qi/agent/`。
162
+
163
+ 仅在目标不存在时搬(绝不覆盖);显式设了 QI_AGENT_HOME 时不动 —— 那是测试/CI
164
+ 自己指定的目录,不该被猜测。返回实际搬迁的 (旧, 新) 列表。
165
+ """
166
+ if os.environ.get(QI_AGENT_HOME):
167
+ return []
168
+ root = config_root()
169
+ if not root.is_dir():
170
+ return []
171
+ target = global_home()
172
+ moved: list[tuple[Path, Path]] = []
173
+ for name in LEGACY_GLOBAL_ENTRIES:
174
+ src, dst = root / name, target / LEGACY_ENTRY_RENAMES.get(name, name)
175
+ if not src.exists() or dst.exists():
176
+ continue
177
+ try:
178
+ target.mkdir(parents=True, exist_ok=True)
179
+ shutil.move(str(src), str(dst))
180
+ except OSError:
181
+ continue
182
+ if name == PLUGINS_DIR_NAME:
183
+ _migrate_extension_entries(dst)
184
+ moved.append((src, dst))
185
+ return moved
186
+
187
+
188
+ #: 扩展目录内的旧入口名(registry.EXTENSION_ENTRY_FILE 的旧值)。
189
+ #: 这里写字面量而不是从 registry 导入:registry 反过来依赖 paths,不能成环。
190
+ _LEGACY_EXTENSION_ENTRY = "plugin.py"
191
+
192
+
193
+ def _migrate_extension_entries(root: Path) -> None:
194
+ """把已搬进 `extensions/` 的入口 `plugin.py` 改成 `extension.py`。
195
+
196
+ 不改名等于**搬了个没人读的目录** —— 目录名对了、入口名还是旧的,扩展不会加载。
197
+ 仍然只在目标不存在时改,不覆盖任何东西。
198
+ """
199
+ if not root.is_dir():
200
+ return
201
+ for child in sorted(root.iterdir()):
202
+ old, new = child / _LEGACY_EXTENSION_ENTRY, child / "extension.py"
203
+ if old.is_file() and not new.exists():
204
+ try:
205
+ old.rename(new)
206
+ except OSError:
207
+ continue
208
+
209
+
210
+ def project_extensions_dir(cwd: Path | None = None) -> Path:
211
+ """项目级扩展目录(`<项目>/.qi/extensions`)。"""
212
+ return project_home(cwd) / EXTENSIONS_DIR_NAME
213
+
214
+
215
+ def migrate_extensions_dir() -> list[tuple[Path, Path]]:
216
+ """`<agent 目录>/plugins/`(v1 名)→ `extensions/`。
217
+
218
+ **只动用户自己的 agent 目录**。项目 `.qi/` 属于仓库内容,擅自搬迁会改 git 状态,
219
+ 所以那边只提示(见 `legacy_project_extensions_dir`)。仍然遵守"目标存在则不动"。
220
+ """
221
+ target = global_home()
222
+ src, dst = target / PLUGINS_DIR_NAME, target / EXTENSIONS_DIR_NAME
223
+ if not src.is_dir() or dst.exists():
224
+ return []
225
+ try:
226
+ shutil.move(str(src), str(dst))
227
+ except OSError:
228
+ return []
229
+ _migrate_extension_entries(dst)
230
+ return [(src, dst)]
231
+
232
+
233
+ def legacy_project_extensions_dir(cwd: Path | None = None) -> Path | None:
234
+ """项目里残留的旧 `.qi/plugins/`(存在则返回路径)—— 专供启动提示,不自动搬。"""
235
+ legacy = project_home(cwd) / PLUGINS_DIR_NAME
236
+ return legacy if legacy.is_dir() else None
237
+
238
+
239
+ def ensure_layout() -> list[tuple[Path, Path]]:
240
+ """启动时调用:建好目录 + 完成旧布局迁移,返回搬迁记录。"""
241
+ global_home().mkdir(parents=True, exist_ok=True)
242
+ return migrate_legacy_layout() + migrate_extensions_dir()