@sakanano-moe/agenthelm 6.7.0 → 6.11.0-rc.1

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 (86) hide show
  1. package/package.json +1 -1
  2. package/runtime/index.json +6 -6
  3. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
  4. package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
  5. package/runtime/plugins/agent-pack/plugin.json +1 -1
  6. package/runtime/plugins/agent-pack/references/local-only.md +126 -0
  7. package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
  8. package/runtime/plugins/agent-pack/references/nudge.md +58 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
  10. package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
  12. package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
  13. package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
  14. package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
  15. package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
  16. package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
  17. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
  18. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
  19. package/runtime/plugins/agents-init/content.json +17 -2
  20. package/runtime/plugins/agents-init/plugin.json +1 -1
  21. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
  23. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
  24. package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
  25. package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
  26. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
  27. package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
  28. package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
  29. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
  30. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
  31. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
  32. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
  33. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
  34. package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
  35. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
  36. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  37. package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
  38. package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
  39. package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
  40. package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
  41. package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
  42. package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
  43. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
  44. package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
  45. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
  46. package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
  47. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
  48. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
  49. package/runtime/plugins/project-orchestrator/content.json +30 -3
  50. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  51. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
  52. package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
  53. package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
  54. package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
  55. package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
  56. package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
  57. package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
  58. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
  59. package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
  60. package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
  61. package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
  62. package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
  63. package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
  64. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
  65. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
  66. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
  67. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
  68. package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
  69. package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
  70. package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
  71. package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
  72. package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
  73. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
  74. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
  75. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
  76. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
  77. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
  78. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
  79. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
  80. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
  81. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
  82. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
  83. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
  84. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
  85. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
  86. package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
@@ -23,12 +23,13 @@ import tempfile
23
23
  from datetime import UTC, datetime
24
24
  from pathlib import Path
25
25
  from typing import Any
26
+ from i18n_agent_pack import _ # noqa: E402
26
27
 
27
28
  PLUGINS = ("agent-pack", "agents-init", "opinion-manager", "project-orchestrator")
28
29
  SKIP_PARTS = {"tests", "__pycache__", ".DS_Store", "node_modules"}
29
30
  MANIFEST = "runtime.json"
30
31
  SHIM = """#!/bin/sh
31
- # agenthelm 运行时 {version}(agenthelm runtime install 生成)。只需要 Python 3.11+,不需要 AI 客户端。
32
+ # agenthelm runtime {version} (generated by agenthelm runtime install). Needs only Python 3.11+, no AI client.
32
33
  self=$0
33
34
  while [ -L "$self" ]; do # a command link (--link-bin) points here; follow it to the runtime
34
35
  link=$(readlink "$self")
@@ -42,15 +43,19 @@ for candidate in "${{AGENTHELM_PYTHON:-}}" python3 python; do
42
43
  exec "$candidate" "$here/plugins/agent-pack/runtime/agenthelm.py" "$@"
43
44
  fi
44
45
  done
45
- echo "agenthelm 需要 Python 3.11 或更高版本:安装后重试,或设置 AGENTHELM_PYTHON。" >&2
46
+ case "${{AGENTHELM_LANG:-${{LC_ALL:-${{LANG:-}}}}}}" in
47
+ zh*) echo "agenthelm 需要 Python 3.11 或更高版本:安装后重试,或设置 AGENTHELM_PYTHON。" >&2 ;;
48
+ *) echo "agenthelm needs Python 3.11 or newer: install it and retry, or set AGENTHELM_PYTHON." >&2 ;;
49
+ esac
46
50
  exit 3
47
51
  """
48
52
 
49
53
 
50
54
  class RuntimeInstallError(Exception):
51
- def __init__(self, message: str, next_steps: list[str] | None = None) -> None:
55
+ def __init__(self, message: str, next_steps: list[str] | None = None, *, download: bool = False) -> None:
52
56
  super().__init__(message)
53
57
  self.next_steps = next_steps or []
58
+ self.download = download
54
59
 
55
60
 
56
61
  # ---------------------------------------------------------------- locations
@@ -110,12 +115,12 @@ def _shipped_files(root: Path) -> list[Path]:
110
115
 
111
116
  def describe(root: Path) -> dict:
112
117
  if not _is_release_root(root):
113
- raise RuntimeInstallError(f"{root} 不是 agenthelm 发布内容(缺少 index.json 或 plugins/<插件>)",
114
- ["--source 指向仓库检出、解压后的发布包目录或 .tar.gz 发布包"])
118
+ raise RuntimeInstallError(_("{0} 不是 agenthelm 发布内容(缺少 index.json 或 plugins/<插件>)").format(root),
119
+ [_("--source 指向仓库检出、解压后的发布包目录或 .tar.gz 发布包")])
115
120
  index = json.loads((root / "index.json").read_text(encoding="utf-8"))
116
121
  version = str(index.get("plugin", {}).get("version") or "")
117
122
  if not version:
118
- raise RuntimeInstallError(f"{root}/index.json 没有发布版本号")
123
+ raise RuntimeInstallError(_("{0}/index.json 没有发布版本号").format(root))
119
124
  digest = hashlib.sha256()
120
125
  files = _shipped_files(root)
121
126
  for relative in files:
@@ -157,6 +162,25 @@ def release_version(latest: dict | None) -> str | None:
157
162
  return tag[1:] if isinstance(tag, str) and tag.startswith("v") else tag
158
163
 
159
164
 
165
+ def command_status() -> dict:
166
+ """Which of the commands people type are on PATH right now (``wf`` is never installed by the runtime)."""
167
+ return {"agenthelm": shutil.which("agenthelm"), "wf": shutil.which("wf")}
168
+
169
+
170
+ def missing_text(data: dict, commands: dict) -> dict:
171
+ """Wording for “no user-level runtime installed”: says which commands work, instead of leaving “wf: not found” unexplained."""
172
+ def state(name: str) -> str:
173
+ return _("{0} 在 PATH 上({1})").format(name, commands[name]) if commands.get(name) else _("终端里的 {0} 命令目前不可用").format(name)
174
+ return {
175
+ "reason": _("本次由 {0} 提供;{1},{2}。").format(data['running_from'], state('agenthelm'), state('wf')),
176
+ "impact": _("项目根目录的 ./menu 会通过客户端插件兜底,所以现在能用;"
177
+ "没有 AI 客户端或客户端插件被移除时打不开。wf 是单独的查找命令,运行时不会提供它:"
178
+ "用 ./menu find 词 或 agenthelm project find 词 代替"),
179
+ "label": _("安装运行时(agenthelm runtime install --apply,只写用户数据目录);"
180
+ "要在终端直接输入 agenthelm 时加 --link-bin ~/.local/bin(只链接 agenthelm,不含 wf)"),
181
+ }
182
+
183
+
160
184
  def report(env: Any, latest: dict | None = None) -> dict:
161
185
  active = current(env)
162
186
  newest = release_version(latest)
@@ -180,15 +204,15 @@ def plan(env: Any, root: Path, origin: str, *, link_bin: Path | None = None) ->
180
204
  existing = _read_manifest(target)
181
205
  active = current(env)
182
206
  if existing and existing.get("digest") != info["digest"]:
183
- raise RuntimeInstallError(f"运行时 {info['version']} 已安装,但内容与来源不同(来源:{origin})",
184
- [f"已装:{target};同一版本不会被改写",
185
- "确认要换掉时先手动移走该目录(例如移到 ~/.agent-pack/recovery/),再重新安装"])
207
+ raise RuntimeInstallError(_("运行时 {0} 已安装,但内容与来源不同(来源:{1})").format(info['version'], origin),
208
+ [_("已装:{0};同一版本不会被改写").format(target),
209
+ _("确认要换掉时先手动移走该目录(例如移到 ~/.agent-pack/recovery/),再重新安装")])
186
210
  writes = []
187
211
  if not existing:
188
- writes.append({"path": str(target), "scope": "用户数据目录(新版本,旧版本保留)"})
212
+ writes.append({"path": str(target), "scope": _("用户数据目录(新版本,旧版本保留)")})
189
213
  switch = not active or active.get("version") != info["version"]
190
214
  if switch:
191
- writes.append({"path": str(current_link(env)), "scope": f"切换当前版本 → {info['version']}"})
215
+ writes.append({"path": str(current_link(env)), "scope": _("切换当前版本 → {0}").format(info['version'])})
192
216
  link = None
193
217
  if link_bin is not None:
194
218
  link = link_bin.expanduser() / "agenthelm"
@@ -196,9 +220,9 @@ def plan(env: Any, root: Path, origin: str, *, link_bin: Path | None = None) ->
196
220
  if link.is_symlink() and Path(os.readlink(link)) == expected:
197
221
  link = None
198
222
  elif link.exists() or link.is_symlink():
199
- raise RuntimeInstallError(f"{link} 已存在且不是本运行时的链接,不会覆盖", ["换一个 --link-bin 目录,或手动处理该文件"])
223
+ raise RuntimeInstallError(_("{0} 已存在且不是本运行时的链接,不会覆盖").format(link), [_("换一个 --link-bin 目录,或手动处理该文件")])
200
224
  else:
201
- writes.append({"path": str(link), "scope": "命令链接(指向当前运行时)"})
225
+ writes.append({"path": str(link), "scope": _("命令链接(指向当前运行时)")})
202
226
  return {"version": info["version"], "plugins": info["plugins"], "digest": info["digest"], "source": origin,
203
227
  "target": str(target), "install": not existing, "switch": switch,
204
228
  "previous": active.get("version") if active else None, "link": str(link) if link else None,
@@ -218,7 +242,7 @@ def apply(env: Any, planned: dict, root: Path) -> dict:
218
242
  shutil.copy2(root / relative, destination)
219
243
  shutil.copy2(root / "index.json", staging / "index.json")
220
244
  if describe(staging)["digest"] != planned["digest"]:
221
- raise RuntimeInstallError("复制后的内容与来源摘要不一致,已放弃安装")
245
+ raise RuntimeInstallError(_("复制后的内容与来源摘要不一致,已放弃安装"))
222
246
  shim = staging / "bin" / "agenthelm"
223
247
  shim.parent.mkdir()
224
248
  shim.write_text(SHIM.format(version=planned["version"]), encoding="utf-8")
@@ -241,8 +265,8 @@ def apply(env: Any, planned: dict, root: Path) -> dict:
241
265
  shim = current_link(env) / "bin" / "agenthelm"
242
266
  verified = bool(active and active["version"] == planned["version"] and os.access(shim, os.X_OK))
243
267
  return {"ok": verified, "version": planned["version"], "current": active,
244
- "verify": (f"重新读取:当前运行时 {planned['version']},命令 {shim}" if verified
245
- else "回读不一致:请运行 agenthelm runtime status 查看"),
268
+ "verify": (_("重新读取:当前运行时 {0},命令 {1}").format(planned['version'], shim) if verified
269
+ else _("回读不一致:请运行 agenthelm runtime status 查看")),
246
270
  "rollback": f"agenthelm runtime use {planned['previous']} --apply" if planned["previous"]
247
271
  and planned["previous"] != planned["version"] else None}
248
272
 
@@ -259,8 +283,8 @@ def _switch(env: Any, target: Path) -> None:
259
283
  def use(env: Any, version: str, *, apply_change: bool) -> dict:
260
284
  target = runtimes_dir(env) / version
261
285
  if not _read_manifest(target):
262
- versions = "、".join(item["version"] for item in installed(env)) or "(无)"
263
- raise RuntimeInstallError(f"没有安装运行时 {version}", [f"已安装:{versions}"])
286
+ versions = _("、").join(item["version"] for item in installed(env)) or _("(无)")
287
+ raise RuntimeInstallError(_("没有安装运行时 {0}").format(version), [_("已安装:{0}").format(versions)])
264
288
  active = current(env)
265
289
  result = {"version": version, "previous": active.get("version") if active else None, "applied": False}
266
290
  if apply_change:
@@ -281,45 +305,45 @@ def resolve_source(host: Any, env: Any, args: argparse.Namespace, workdir: Path,
281
305
  host._safe_extract(source, extracted)
282
306
  roots = [path for path in extracted.iterdir() if path.is_dir()]
283
307
  if len(roots) != 1:
284
- raise RuntimeInstallError("发布包结构不符合预期")
285
- return roots[0], f"本地发布包 {source.name}"
286
- return source, f"本地目录 {source}"
308
+ raise RuntimeInstallError(_("发布包结构不符合预期"))
309
+ return roots[0], _("本地发布包 {0}").format(source.name)
310
+ return source, _("本地目录 {0}").format(source)
287
311
  bundled = bundled_root()
288
312
  if bundled is not None and not prefer_release and not args.from_release:
289
- return bundled, f"随附内容({running_from(env)})"
313
+ return bundled, _("随附内容({0})").format(running_from(env))
290
314
  try:
291
- latest, _ = host.load_latest(env, args.latest, max_age=0)
292
- return host.release_source(latest, workdir), "GitHub Release(已核对摘要)"
315
+ latest, _origin = host.load_latest(env, args.latest, max_age=0)
316
+ return host.release_source(latest, workdir), _("GitHub Release(已核对摘要)")
293
317
  except (host.CheckError, OSError, ValueError) as exc:
294
- raise RuntimeInstallError(f"无法下载发布包:{exc}",
295
- ["离线时用 --source 指向发布包(.tar.gz)或仓库检出",
296
- "下载地址见 https://github.com/lc4t/agenthelm/releases"]) from exc
318
+ raise RuntimeInstallError(_("无法下载发布包:{0}").format(exc),
319
+ [_("离线时用 --source 指向发布包(.tar.gz)或仓库检出"),
320
+ _("下载地址见 https://github.com/lc4t/agenthelm/releases")], download=True) from exc
297
321
 
298
322
 
299
323
  # ---------------------------------------------------------------- command line
300
324
 
301
325
  def parser() -> argparse.ArgumentParser:
302
- result = argparse.ArgumentParser(prog="agenthelm runtime", description=__doc__.split("\n\n")[0])
326
+ result = argparse.ArgumentParser(prog="agenthelm runtime", description=_("agenthelm 用户级运行时:把四个插件的确定性工具装进用户数据目录,日常只需要 Python 3.11+。"))
303
327
  sub = result.add_subparsers(dest="action", required=True)
304
- for name, text in (("status", "当前运行时、已装版本与可用更新"),
305
- ("install", "安装运行时(默认用本命令随附的内容;默认预览)"),
306
- ("update", "联网安装最新发布版本并切换(默认预览)"),
307
- ("use", "切换到已安装的某个版本(默认预览)")):
328
+ for name, text in (("status", _("当前运行时、已装版本与可用更新")),
329
+ ("install", _("安装运行时(默认用本命令随附的内容;默认预览)")),
330
+ ("update", _("联网安装最新发布版本并切换(默认预览)")),
331
+ ("use", _("切换到已安装的某个版本(默认预览)"))):
308
332
  command = sub.add_parser(name, help=text)
309
333
  command.add_argument("--output", choices=("text", "json"), default="text")
310
334
  command.add_argument("--home", type=Path, help=argparse.SUPPRESS)
311
- command.add_argument("--latest", help="latest.json 的 URL 或本地路径")
335
+ command.add_argument("--latest", help=_("latest.json 的 URL 或本地路径"))
312
336
  if name == "use":
313
337
  command.add_argument("version")
314
338
  if name in ("install", "update"):
315
- command.add_argument("--source", type=Path, help="本地目录或 .tar.gz 发布包(离线安装)")
316
- command.add_argument("--link-bin", type=Path, help="在这个目录建 agenthelm 命令链接(例如 ~/.local/bin)")
339
+ command.add_argument("--source", type=Path, help=_("本地目录或 .tar.gz 发布包(离线安装)"))
340
+ command.add_argument("--link-bin", type=Path, help=_("在这个目录建 agenthelm 命令链接(例如 ~/.local/bin)"))
317
341
  if name == "install":
318
- command.add_argument("--from-release", action="store_true", help="联网下载发布包,而不用随附内容")
342
+ command.add_argument("--from-release", action="store_true", help=_("联网下载发布包,而不用随附内容"))
319
343
  if name != "status":
320
344
  command.add_argument("--apply", action="store_true")
321
345
  if name == "status":
322
- command.add_argument("--offline", action="store_true", help="只用缓存的版本信息")
346
+ command.add_argument("--offline", action="store_true", help=_("只用缓存的版本信息"))
323
347
  return result
324
348
 
325
349
 
@@ -334,55 +358,55 @@ def main(argv: list[str], host: Any) -> int:
334
358
  args = parser().parse_args(argv)
335
359
  env = host.current_env(args.home)
336
360
  if sys.platform == "win32":
337
- print("agenthelm runtime 目前只支持 macOS 与 Linux。", file=sys.stderr)
361
+ print(_("agenthelm runtime 目前只支持 macOS 与 Linux。"), file=sys.stderr)
338
362
  return 4
339
363
  try:
340
364
  if args.action == "status":
341
365
  try:
342
- latest, _ = host.load_latest(env, args.latest, offline=args.offline)
366
+ latest, _origin = host.load_latest(env, args.latest, offline=args.offline)
343
367
  except (host.CheckError, OSError, ValueError):
344
368
  latest = None
345
369
  data = report(env, latest)
346
370
  active = data["current"]
347
- lines = [f"当前运行时:{active['version']}({active['path']})" if active else "当前运行时:未安装",
348
- f"已安装:{'、'.join(item['version'] for item in data['installed']) or '(无)'}",
349
- f"本次命令来自:{data['running_from']}"]
371
+ lines = [_("当前运行时:{0}({1})").format(active['version'], active['path']) if active else _("当前运行时:未安装"),
372
+ _("已安装:{0}").format(_('、').join(item['version'] for item in data['installed']) or _('(无)')),
373
+ _("本次命令来自:{0}").format(data['running_from'])]
350
374
  if data["update_available"]:
351
- lines.append(f"有新版本 {data['latest_release']}:agenthelm runtime update 预览")
375
+ lines.append(_("有新版本 {0}:agenthelm runtime update 预览").format(data['latest_release']))
352
376
  if not active:
353
- lines.append("安装:agenthelm runtime install(预览)→ 加 --apply 执行")
377
+ lines.append(_("安装:agenthelm runtime install(预览)→ 加 --apply 执行"))
354
378
  _print({"ok": True, **data}, args.output, lines)
355
379
  return 0
356
380
  if args.action == "use":
357
381
  result = use(env, args.version, apply_change=args.apply)
358
- lines = [f"{'已切换' if result['applied'] else '将切换'}:{result['previous']} → {result['version']}"]
382
+ lines = [_("{0}:{1} → {2}").format(_('已切换') if result['applied'] else _('将切换'), result['previous'], result['version'])]
359
383
  if not args.apply:
360
- lines.append("以上为预览;加 --apply 执行。")
384
+ lines.append(_("以上为预览;加 --apply 执行。"))
361
385
  _print({"ok": True, **result}, args.output, lines)
362
386
  return 0
363
387
  with tempfile.TemporaryDirectory(prefix="agenthelm-runtime-") as temporary:
364
388
  root, origin = resolve_source(host, env, args, Path(temporary), prefer_release=args.action == "update")
365
389
  planned = plan(env, root, origin, link_bin=args.link_bin)
366
- lines = [f"运行时 {planned['version']}(来源:{origin})"]
367
- lines += [f" 将写入:{item['path']}({item['scope']})" for item in planned["writes"]] or [" 已是当前版本,无需改动。"]
390
+ lines = [_("运行时 {0}(来源:{1})").format(planned['version'], origin)]
391
+ lines += [_(" 将写入:{0}({1})").format(item['path'], item['scope']) for item in planned["writes"]] or [_(" 已是当前版本,无需改动。")]
368
392
  if not args.apply or planned["action"] == "none" and not planned["link"]:
369
393
  if args.apply is False and planned["writes"]:
370
- lines.append("以上为预览;加 --apply 执行。")
394
+ lines.append(_("以上为预览;加 --apply 执行。"))
371
395
  _print({"ok": True, "applied": False, "plan": planned}, args.output, lines)
372
396
  return 0
373
397
  result = apply(env, planned, root)
374
398
  lines.append((" " + result["verify"]))
375
399
  if result["rollback"]:
376
- lines.append(f" 回到旧版本:{result['rollback']}")
400
+ lines.append(_(" 回到旧版本:{0}").format(result['rollback']))
377
401
  if not os.environ.get("PATH", "").count(str(current_link(env) / "bin")) and not planned["link"]:
378
- lines.append(f" 直接用命令:把 {current_link(env) / 'bin'} 加入 PATH,或重新运行并加 --link-bin ~/.local/bin")
402
+ lines.append(_(" 直接用命令:把 {0} 加入 PATH,或重新运行并加 --link-bin ~/.local/bin").format(current_link(env) / 'bin'))
379
403
  _print({"ok": result["ok"], "applied": True, "plan": planned, **result}, args.output, lines)
380
404
  return 0 if result["ok"] else 6
381
405
  except RuntimeInstallError as exc:
382
406
  if args.output == "json":
383
407
  print(json.dumps({"ok": False, "error": str(exc), "next": exc.next_steps}, ensure_ascii=False, indent=2))
384
408
  else:
385
- print(f"出错:{exc}", file=sys.stderr)
409
+ print(_("出错:{0}").format(exc), file=sys.stderr)
386
410
  for step in exc.next_steps:
387
411
  print(f" · {step}", file=sys.stderr)
388
- return 4 if "无法下载" in str(exc) else 2
412
+ return 4 if exc.download else 2
@@ -0,0 +1,78 @@
1
+ """运行时文案的多语言:源语言是中文,``_("中文原文")`` 在中文下原样返回,在英文下查 ``locale/en.json``。
2
+
3
+ 语言按这个顺序决定:调用方指定(如 ``nudge.language``)→ 环境变量 ``AGENTHELM_LANG`` → 用户配置
4
+ ``~/.config/agenthelm/config.json`` 的 ``language`` → 系统 locale(``LC_ALL``、``LC_MESSAGES``、``LANG``)→ 英文。
5
+ ``auto``、空值和 C/POSIX 视为未指定;中文 locale 用中文,其他语言的 locale 暂用英文。
6
+ 没有英文译文的文案原样输出(中文),测试守住译文完整。四个插件各带一份相同的本模块(模块名不同,以免
7
+ 同一进程里互相覆盖),``tests/test_i18n_parity.py`` 守住它们行为一致。模块级常量里的文案用 ``N_`` 标记
8
+ (原样返回,仅供提取),使用处再调用 ``_``,这样语言在运行时而不是导入时决定。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Mapping
17
+
18
+ LANGUAGES = ("zh", "en")
19
+ DEFAULT_LANGUAGE = "en"
20
+ LOCALE_DIR = Path(__file__).resolve().parent / "locale"
21
+ _catalogs: dict[str, dict[str, str]] = {}
22
+ _configured: dict[str, tuple[tuple, str | None]] = {}
23
+
24
+
25
+ def normalize(value: object) -> str | None:
26
+ text = str(value or "").strip().lower().replace("_", "-")
27
+ if not text or text in ("auto", "c", "posix") or text.startswith(("c.", "posix.")):
28
+ return None
29
+ return "zh" if text.startswith("zh") else "en"
30
+
31
+
32
+ def configured_language(environ: Mapping[str, str]) -> str | None:
33
+ """用户配置里的 language;按文件修改时间缓存,读不了就当没有。"""
34
+ base = environ.get("XDG_CONFIG_HOME") or (str(Path(environ["HOME"]) / ".config") if environ.get("HOME") else "")
35
+ if not base:
36
+ return None
37
+ path = Path(base) / "agenthelm" / "config.json"
38
+ try:
39
+ info = path.stat()
40
+ signature = (info.st_mtime_ns, info.st_size)
41
+ cached = _configured.get(str(path))
42
+ if cached and cached[0] == signature:
43
+ return cached[1]
44
+ data = json.loads(path.read_text(encoding="utf-8"))
45
+ value = normalize(data.get("language")) if isinstance(data, dict) else None
46
+ except (OSError, ValueError):
47
+ return None
48
+ _configured[str(path)] = (signature, value)
49
+ return value
50
+
51
+
52
+ def resolve_language(requested: str | None = None, environ: Mapping[str, str] | None = None) -> str:
53
+ environ = os.environ if environ is None else environ
54
+ for candidate in (requested, environ.get("AGENTHELM_LANG"), configured_language(environ),
55
+ environ.get("LC_ALL"), environ.get("LC_MESSAGES"), environ.get("LANG")):
56
+ language = normalize(candidate)
57
+ if language:
58
+ return language
59
+ return DEFAULT_LANGUAGE
60
+
61
+
62
+ def catalog(language: str) -> dict[str, str]:
63
+ if language not in _catalogs:
64
+ try:
65
+ data = json.loads((LOCALE_DIR / f"{language}.json").read_text(encoding="utf-8"))
66
+ except (OSError, ValueError):
67
+ data = {}
68
+ _catalogs[language] = data if isinstance(data, dict) else {}
69
+ return _catalogs[language]
70
+
71
+
72
+ def _(message: str) -> str:
73
+ language = resolve_language()
74
+ return message if language == "zh" else catalog(language).get(message, message)
75
+
76
+
77
+ def N_(message: str) -> str:
78
+ return message