@sakanano-moe/agenthelm 6.8.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 +228 -147
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +64 -59
  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 +4 -4
  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 +26 -1
  23. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +399 -80
  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 +210 -76
  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 +210 -62
  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 +4 -0
  33. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +2 -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 +222 -189
  65. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
  66. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +241 -204
  67. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +145 -89
  68. package/runtime/plugins/project-orchestrator/runtime/project_real.py +184 -105
  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:
@@ -165,14 +170,14 @@ def command_status() -> dict:
165
170
  def missing_text(data: dict, commands: dict) -> dict:
166
171
  """Wording for “no user-level runtime installed”: says which commands work, instead of leaving “wf: not found” unexplained."""
167
172
  def state(name: str) -> str:
168
- return f"{name} 在 PATH 上({commands[name]})" if commands.get(name) else f"终端里的 {name} 命令目前不可用"
173
+ return _("{0} 在 PATH 上({1})").format(name, commands[name]) if commands.get(name) else _("终端里的 {0} 命令目前不可用").format(name)
169
174
  return {
170
- "reason": f"本次由 {data['running_from']} 提供;{state('agenthelm')},{state('wf')}。",
171
- "impact": "项目根目录的 ./menu 会通过客户端插件兜底,所以现在能用;"
175
+ "reason": _("本次由 {0} 提供;{1},{2}。").format(data['running_from'], state('agenthelm'), state('wf')),
176
+ "impact": _("项目根目录的 ./menu 会通过客户端插件兜底,所以现在能用;"
172
177
  "没有 AI 客户端或客户端插件被移除时打不开。wf 是单独的查找命令,运行时不会提供它:"
173
- "用 ./menu find 词 或 agenthelm project find 词 代替",
174
- "label": "安装运行时(agenthelm runtime install --apply,只写用户数据目录);"
175
- "要在终端直接输入 agenthelm 时加 --link-bin ~/.local/bin(只链接 agenthelm,不含 wf)",
178
+ "用 ./menu find 词 或 agenthelm project find 词 代替"),
179
+ "label": _("安装运行时(agenthelm runtime install --apply,只写用户数据目录);"
180
+ "要在终端直接输入 agenthelm 时加 --link-bin ~/.local/bin(只链接 agenthelm,不含 wf)"),
176
181
  }
177
182
 
178
183
 
@@ -199,15 +204,15 @@ def plan(env: Any, root: Path, origin: str, *, link_bin: Path | None = None) ->
199
204
  existing = _read_manifest(target)
200
205
  active = current(env)
201
206
  if existing and existing.get("digest") != info["digest"]:
202
- raise RuntimeInstallError(f"运行时 {info['version']} 已安装,但内容与来源不同(来源:{origin})",
203
- [f"已装:{target};同一版本不会被改写",
204
- "确认要换掉时先手动移走该目录(例如移到 ~/.agent-pack/recovery/),再重新安装"])
207
+ raise RuntimeInstallError(_("运行时 {0} 已安装,但内容与来源不同(来源:{1})").format(info['version'], origin),
208
+ [_("已装:{0};同一版本不会被改写").format(target),
209
+ _("确认要换掉时先手动移走该目录(例如移到 ~/.agent-pack/recovery/),再重新安装")])
205
210
  writes = []
206
211
  if not existing:
207
- writes.append({"path": str(target), "scope": "用户数据目录(新版本,旧版本保留)"})
212
+ writes.append({"path": str(target), "scope": _("用户数据目录(新版本,旧版本保留)")})
208
213
  switch = not active or active.get("version") != info["version"]
209
214
  if switch:
210
- writes.append({"path": str(current_link(env)), "scope": f"切换当前版本 → {info['version']}"})
215
+ writes.append({"path": str(current_link(env)), "scope": _("切换当前版本 → {0}").format(info['version'])})
211
216
  link = None
212
217
  if link_bin is not None:
213
218
  link = link_bin.expanduser() / "agenthelm"
@@ -215,9 +220,9 @@ def plan(env: Any, root: Path, origin: str, *, link_bin: Path | None = None) ->
215
220
  if link.is_symlink() and Path(os.readlink(link)) == expected:
216
221
  link = None
217
222
  elif link.exists() or link.is_symlink():
218
- raise RuntimeInstallError(f"{link} 已存在且不是本运行时的链接,不会覆盖", ["换一个 --link-bin 目录,或手动处理该文件"])
223
+ raise RuntimeInstallError(_("{0} 已存在且不是本运行时的链接,不会覆盖").format(link), [_("换一个 --link-bin 目录,或手动处理该文件")])
219
224
  else:
220
- writes.append({"path": str(link), "scope": "命令链接(指向当前运行时)"})
225
+ writes.append({"path": str(link), "scope": _("命令链接(指向当前运行时)")})
221
226
  return {"version": info["version"], "plugins": info["plugins"], "digest": info["digest"], "source": origin,
222
227
  "target": str(target), "install": not existing, "switch": switch,
223
228
  "previous": active.get("version") if active else None, "link": str(link) if link else None,
@@ -237,7 +242,7 @@ def apply(env: Any, planned: dict, root: Path) -> dict:
237
242
  shutil.copy2(root / relative, destination)
238
243
  shutil.copy2(root / "index.json", staging / "index.json")
239
244
  if describe(staging)["digest"] != planned["digest"]:
240
- raise RuntimeInstallError("复制后的内容与来源摘要不一致,已放弃安装")
245
+ raise RuntimeInstallError(_("复制后的内容与来源摘要不一致,已放弃安装"))
241
246
  shim = staging / "bin" / "agenthelm"
242
247
  shim.parent.mkdir()
243
248
  shim.write_text(SHIM.format(version=planned["version"]), encoding="utf-8")
@@ -260,8 +265,8 @@ def apply(env: Any, planned: dict, root: Path) -> dict:
260
265
  shim = current_link(env) / "bin" / "agenthelm"
261
266
  verified = bool(active and active["version"] == planned["version"] and os.access(shim, os.X_OK))
262
267
  return {"ok": verified, "version": planned["version"], "current": active,
263
- "verify": (f"重新读取:当前运行时 {planned['version']},命令 {shim}" if verified
264
- else "回读不一致:请运行 agenthelm runtime status 查看"),
268
+ "verify": (_("重新读取:当前运行时 {0},命令 {1}").format(planned['version'], shim) if verified
269
+ else _("回读不一致:请运行 agenthelm runtime status 查看")),
265
270
  "rollback": f"agenthelm runtime use {planned['previous']} --apply" if planned["previous"]
266
271
  and planned["previous"] != planned["version"] else None}
267
272
 
@@ -278,8 +283,8 @@ def _switch(env: Any, target: Path) -> None:
278
283
  def use(env: Any, version: str, *, apply_change: bool) -> dict:
279
284
  target = runtimes_dir(env) / version
280
285
  if not _read_manifest(target):
281
- versions = "、".join(item["version"] for item in installed(env)) or "(无)"
282
- 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)])
283
288
  active = current(env)
284
289
  result = {"version": version, "previous": active.get("version") if active else None, "applied": False}
285
290
  if apply_change:
@@ -300,45 +305,45 @@ def resolve_source(host: Any, env: Any, args: argparse.Namespace, workdir: Path,
300
305
  host._safe_extract(source, extracted)
301
306
  roots = [path for path in extracted.iterdir() if path.is_dir()]
302
307
  if len(roots) != 1:
303
- raise RuntimeInstallError("发布包结构不符合预期")
304
- return roots[0], f"本地发布包 {source.name}"
305
- return source, f"本地目录 {source}"
308
+ raise RuntimeInstallError(_("发布包结构不符合预期"))
309
+ return roots[0], _("本地发布包 {0}").format(source.name)
310
+ return source, _("本地目录 {0}").format(source)
306
311
  bundled = bundled_root()
307
312
  if bundled is not None and not prefer_release and not args.from_release:
308
- return bundled, f"随附内容({running_from(env)})"
313
+ return bundled, _("随附内容({0})").format(running_from(env))
309
314
  try:
310
- latest, _ = host.load_latest(env, args.latest, max_age=0)
311
- 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(已核对摘要)")
312
317
  except (host.CheckError, OSError, ValueError) as exc:
313
- raise RuntimeInstallError(f"无法下载发布包:{exc}",
314
- ["离线时用 --source 指向发布包(.tar.gz)或仓库检出",
315
- "下载地址见 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
316
321
 
317
322
 
318
323
  # ---------------------------------------------------------------- command line
319
324
 
320
325
  def parser() -> argparse.ArgumentParser:
321
- result = argparse.ArgumentParser(prog="agenthelm runtime", description=__doc__.split("\n\n")[0])
326
+ result = argparse.ArgumentParser(prog="agenthelm runtime", description=_("agenthelm 用户级运行时:把四个插件的确定性工具装进用户数据目录,日常只需要 Python 3.11+。"))
322
327
  sub = result.add_subparsers(dest="action", required=True)
323
- for name, text in (("status", "当前运行时、已装版本与可用更新"),
324
- ("install", "安装运行时(默认用本命令随附的内容;默认预览)"),
325
- ("update", "联网安装最新发布版本并切换(默认预览)"),
326
- ("use", "切换到已安装的某个版本(默认预览)")):
328
+ for name, text in (("status", _("当前运行时、已装版本与可用更新")),
329
+ ("install", _("安装运行时(默认用本命令随附的内容;默认预览)")),
330
+ ("update", _("联网安装最新发布版本并切换(默认预览)")),
331
+ ("use", _("切换到已安装的某个版本(默认预览)"))):
327
332
  command = sub.add_parser(name, help=text)
328
333
  command.add_argument("--output", choices=("text", "json"), default="text")
329
334
  command.add_argument("--home", type=Path, help=argparse.SUPPRESS)
330
- command.add_argument("--latest", help="latest.json 的 URL 或本地路径")
335
+ command.add_argument("--latest", help=_("latest.json 的 URL 或本地路径"))
331
336
  if name == "use":
332
337
  command.add_argument("version")
333
338
  if name in ("install", "update"):
334
- command.add_argument("--source", type=Path, help="本地目录或 .tar.gz 发布包(离线安装)")
335
- 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)"))
336
341
  if name == "install":
337
- command.add_argument("--from-release", action="store_true", help="联网下载发布包,而不用随附内容")
342
+ command.add_argument("--from-release", action="store_true", help=_("联网下载发布包,而不用随附内容"))
338
343
  if name != "status":
339
344
  command.add_argument("--apply", action="store_true")
340
345
  if name == "status":
341
- command.add_argument("--offline", action="store_true", help="只用缓存的版本信息")
346
+ command.add_argument("--offline", action="store_true", help=_("只用缓存的版本信息"))
342
347
  return result
343
348
 
344
349
 
@@ -353,55 +358,55 @@ def main(argv: list[str], host: Any) -> int:
353
358
  args = parser().parse_args(argv)
354
359
  env = host.current_env(args.home)
355
360
  if sys.platform == "win32":
356
- print("agenthelm runtime 目前只支持 macOS 与 Linux。", file=sys.stderr)
361
+ print(_("agenthelm runtime 目前只支持 macOS 与 Linux。"), file=sys.stderr)
357
362
  return 4
358
363
  try:
359
364
  if args.action == "status":
360
365
  try:
361
- latest, _ = host.load_latest(env, args.latest, offline=args.offline)
366
+ latest, _origin = host.load_latest(env, args.latest, offline=args.offline)
362
367
  except (host.CheckError, OSError, ValueError):
363
368
  latest = None
364
369
  data = report(env, latest)
365
370
  active = data["current"]
366
- lines = [f"当前运行时:{active['version']}({active['path']})" if active else "当前运行时:未安装",
367
- f"已安装:{'、'.join(item['version'] for item in data['installed']) or '(无)'}",
368
- 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'])]
369
374
  if data["update_available"]:
370
- lines.append(f"有新版本 {data['latest_release']}:agenthelm runtime update 预览")
375
+ lines.append(_("有新版本 {0}:agenthelm runtime update 预览").format(data['latest_release']))
371
376
  if not active:
372
- lines.append("安装:agenthelm runtime install(预览)→ 加 --apply 执行")
377
+ lines.append(_("安装:agenthelm runtime install(预览)→ 加 --apply 执行"))
373
378
  _print({"ok": True, **data}, args.output, lines)
374
379
  return 0
375
380
  if args.action == "use":
376
381
  result = use(env, args.version, apply_change=args.apply)
377
- lines = [f"{'已切换' if result['applied'] else '将切换'}:{result['previous']} → {result['version']}"]
382
+ lines = [_("{0}:{1} → {2}").format(_('已切换') if result['applied'] else _('将切换'), result['previous'], result['version'])]
378
383
  if not args.apply:
379
- lines.append("以上为预览;加 --apply 执行。")
384
+ lines.append(_("以上为预览;加 --apply 执行。"))
380
385
  _print({"ok": True, **result}, args.output, lines)
381
386
  return 0
382
387
  with tempfile.TemporaryDirectory(prefix="agenthelm-runtime-") as temporary:
383
388
  root, origin = resolve_source(host, env, args, Path(temporary), prefer_release=args.action == "update")
384
389
  planned = plan(env, root, origin, link_bin=args.link_bin)
385
- lines = [f"运行时 {planned['version']}(来源:{origin})"]
386
- 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 [_(" 已是当前版本,无需改动。")]
387
392
  if not args.apply or planned["action"] == "none" and not planned["link"]:
388
393
  if args.apply is False and planned["writes"]:
389
- lines.append("以上为预览;加 --apply 执行。")
394
+ lines.append(_("以上为预览;加 --apply 执行。"))
390
395
  _print({"ok": True, "applied": False, "plan": planned}, args.output, lines)
391
396
  return 0
392
397
  result = apply(env, planned, root)
393
398
  lines.append((" " + result["verify"]))
394
399
  if result["rollback"]:
395
- lines.append(f" 回到旧版本:{result['rollback']}")
400
+ lines.append(_(" 回到旧版本:{0}").format(result['rollback']))
396
401
  if not os.environ.get("PATH", "").count(str(current_link(env) / "bin")) and not planned["link"]:
397
- 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'))
398
403
  _print({"ok": result["ok"], "applied": True, "plan": planned, **result}, args.output, lines)
399
404
  return 0 if result["ok"] else 6
400
405
  except RuntimeInstallError as exc:
401
406
  if args.output == "json":
402
407
  print(json.dumps({"ok": False, "error": str(exc), "next": exc.next_steps}, ensure_ascii=False, indent=2))
403
408
  else:
404
- print(f"出错:{exc}", file=sys.stderr)
409
+ print(_("出错:{0}").format(exc), file=sys.stderr)
405
410
  for step in exc.next_steps:
406
411
  print(f" · {step}", file=sys.stderr)
407
- 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