@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
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  import re
13
13
  import stat
14
14
  import sys
15
+ from i18n_orchestrator import _ # noqa: E402
15
16
 
16
17
 
17
18
  BLOCKED = {"local-ai", "confidential", ".git", ".agents", "credential"}
@@ -35,27 +36,27 @@ def rename_exclusive(source: Path, target: Path) -> None:
35
36
  args = (-100, os.fsencode(source), -100, os.fsencode(target), 1)
36
37
  function.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint)
37
38
  else:
38
- raise WorkspaceError('当前平台缺少原子禁止覆盖的 rename')
39
+ raise WorkspaceError(_('当前平台缺少原子禁止覆盖的 rename'))
39
40
  if function(*args) != 0:
40
41
  code = ctypes.get_errno()
41
42
  if code == errno.EEXIST:
42
- raise WorkspaceError('目标在移动时出现,拒绝覆盖')
43
+ raise WorkspaceError(_('目标在移动时出现,拒绝覆盖'))
43
44
  raise OSError(code, os.strerror(code))
44
45
 
45
46
 
46
47
  def safe_path(root: Path, value: str) -> Path:
47
48
  if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
48
- raise WorkspaceError("非法相对路径")
49
+ raise WorkspaceError(_("非法相对路径"))
49
50
  relative = Path(value)
50
51
  if relative.is_absolute() or ".." in relative.parts or "." == value or re.match(r"^[A-Za-z]:", value):
51
- raise WorkspaceError("路径必须处于项目内")
52
+ raise WorkspaceError(_("路径必须处于项目内"))
52
53
  if set(relative.parts) & BLOCKED:
53
- raise WorkspaceError("迁移范围包含受保护路径")
54
+ raise WorkspaceError(_("迁移范围包含受保护路径"))
54
55
  path = root
55
56
  for part in relative.parts:
56
57
  path /= part
57
58
  if path.is_symlink():
58
- raise WorkspaceError("拒绝符号链接")
59
+ raise WorkspaceError(_("拒绝符号链接"))
59
60
  return path
60
61
 
61
62
 
@@ -63,19 +64,19 @@ def case_root_path(root: Path) -> Path:
63
64
  profile = root / '.agents/moe.sakanano.agent-pack/project.json'
64
65
  for parent in (profile, profile.parent, profile.parent.parent):
65
66
  if parent.is_symlink():
66
- raise WorkspaceError('Project Profile 不允许符号链接')
67
+ raise WorkspaceError(_('Project Profile 不允许符号链接'))
67
68
  name = 'cases'
68
69
  if profile.exists():
69
70
  if not profile.is_file() or profile.stat().st_size > 512 * 1024:
70
- raise WorkspaceError('非法 Project Profile')
71
+ raise WorkspaceError(_('非法 Project Profile'))
71
72
  fd = os.open(profile, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
72
73
  with os.fdopen(fd, 'r', encoding='utf-8') as stream:
73
74
  data = json.load(stream)
74
75
  if not isinstance(data, dict):
75
- raise WorkspaceError('Project Profile 必须是对象')
76
+ raise WorkspaceError(_('Project Profile 必须是对象'))
76
77
  work = data.get('work') or {}
77
78
  if not isinstance(work, dict) or not isinstance(work.get('case') or {}, dict):
78
- raise WorkspaceError('非法 Case 配置')
79
+ raise WorkspaceError(_('非法 Case 配置'))
79
80
  name = (work.get('case') or {}).get('root', name)
80
81
  return safe_path(root, name)
81
82
 
@@ -91,7 +92,7 @@ def digest_file(path: Path) -> str:
91
92
  fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, 'O_NONBLOCK', 0))
92
93
  with os.fdopen(fd, "rb") as stream:
93
94
  if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
94
- raise WorkspaceError("拒绝非普通文件")
95
+ raise WorkspaceError(_("拒绝非普通文件"))
95
96
  h = hashlib.sha256()
96
97
  for chunk in iter(lambda: stream.read(1024 * 1024), b""):
97
98
  h.update(chunk)
@@ -100,9 +101,9 @@ def digest_file(path: Path) -> str:
100
101
 
101
102
  def inventory(path: Path) -> dict:
102
103
  if path.is_symlink():
103
- raise WorkspaceError("拒绝符号链接")
104
+ raise WorkspaceError(_("拒绝符号链接"))
104
105
  if not path.exists() or not (path.is_dir() or path.is_file()):
105
- raise WorkspaceError("源目录或文件不存在")
106
+ raise WorkspaceError(_("源目录或文件不存在"))
106
107
  files = []
107
108
  directories = []
108
109
  traversal = os.walk(path, followlinks=False) if path.is_dir() else [(path.parent, [], [path.name])]
@@ -110,13 +111,13 @@ def inventory(path: Path) -> dict:
110
111
  for name in sorted(dirs):
111
112
  child = Path(parent) / name
112
113
  if child.is_symlink():
113
- raise WorkspaceError("目录中存在符号链接")
114
+ raise WorkspaceError(_("目录中存在符号链接"))
114
115
  directories.append(child.relative_to(path).as_posix())
115
116
  for name in sorted(names):
116
117
  child = Path(parent) / name
117
118
  meta = child.lstat()
118
119
  if not stat.S_ISREG(meta.st_mode):
119
- raise WorkspaceError("目录中存在非普通文件")
120
+ raise WorkspaceError(_("目录中存在非普通文件"))
120
121
  item = {"path": child.relative_to(path).as_posix() if path.is_dir() else '.', "bytes": meta.st_size}
121
122
  private_ancestor = any(opaque(part) or part.name in BLOCKED for part in (child, *child.parents) if part != path.parent and path.parent in part.parents)
122
123
  if private_ancestor or opaque(path):
@@ -133,26 +134,26 @@ def inventory(path: Path) -> dict:
133
134
 
134
135
  def plan_migration(root: Path, mapping: dict) -> dict:
135
136
  if not isinstance(mapping, dict) or mapping.get("schema_version") != "1.0":
136
- raise WorkspaceError("映射必须声明 schema_version=1.0")
137
+ raise WorkspaceError(_("映射必须声明 schema_version=1.0"))
137
138
  moves = mapping.get("moves")
138
139
  records = mapping.get("records", [])
139
140
  if not isinstance(moves, list) or not moves or not isinstance(records, list):
140
- raise WorkspaceError("映射需要非空 moves 数组")
141
+ raise WorkspaceError(_("映射需要非空 moves 数组"))
141
142
  source_paths, target_paths, planned = [], [], []
142
143
  case_parts = case_root_path(root).relative_to(root).parts
143
144
  for move in moves:
144
145
  if not isinstance(move, dict):
145
- raise WorkspaceError("非法映射项")
146
+ raise WorkspaceError(_("非法映射项"))
146
147
  source = safe_path(root, move.get("source", ""))
147
148
  target = safe_path(root, move.get("target", ""))
148
149
  relative = target.relative_to(root)
149
150
  if len(source.relative_to(root).parts) < 2 or len(relative.parts) < len(case_parts) + 1 or relative.parts[:len(case_parts)] != case_parts:
150
- raise WorkspaceError("源需要明确子目录,目标需要位于 cases 的工作目录")
151
+ raise WorkspaceError(_("源需要明确子目录,目标需要位于 cases 的工作目录"))
151
152
  if target.exists():
152
- raise WorkspaceError("目标已存在,拒绝合并或覆盖")
153
+ raise WorkspaceError(_("目标已存在,拒绝合并或覆盖"))
153
154
  manifest = inventory(source)
154
155
  if move.get("fingerprint") and move["fingerprint"] != manifest["fingerprint"]:
155
- raise WorkspaceError("预演后源文件发生变化")
156
+ raise WorkspaceError(_("预演后源文件发生变化"))
156
157
  source_paths.append(source)
157
158
  target_paths.append(target)
158
159
  planned.append({"source": move["source"], "target": move["target"], "manifest": manifest})
@@ -160,25 +161,25 @@ def plan_migration(root: Path, mapping: dict) -> dict:
160
161
  for index, path in enumerate(collection):
161
162
  for other in collection[index + 1:]:
162
163
  if path == other or path in other.parents or other in path.parents:
163
- raise WorkspaceError("映射存在重复或嵌套目录")
164
+ raise WorkspaceError(_("映射存在重复或嵌套目录"))
164
165
  for source in source_paths:
165
166
  for target in target_paths:
166
167
  if source == target or source in target.parents or target in source.parents:
167
- raise WorkspaceError("源与目标重叠")
168
+ raise WorkspaceError(_("源与目标重叠"))
168
169
  record_paths = []
169
170
  for record in records:
170
171
  path = safe_path(root, record.get("path", ""))
171
172
  parts = path.relative_to(root).parts
172
173
  content = record.get("content")
173
174
  if len(parts) != len(case_parts) + 2 or parts[:len(case_parts)] != case_parts or parts[-1] != "CASE.md" or not isinstance(content, str):
174
- raise WorkspaceError("新增记录只能是完整 CASE.md")
175
+ raise WorkspaceError(_("新增记录只能是完整 CASE.md"))
175
176
  if path.exists() or path in record_paths or len(content.encode()) > 2 * 1024 * 1024:
176
- raise WorkspaceError("新增记录冲突或超出大小限制")
177
+ raise WorkspaceError(_("新增记录冲突或超出大小限制"))
177
178
  if path.parent not in target_paths:
178
- raise WorkspaceError("新增 CASE.md 必须属于本次迁移目录")
179
+ raise WorkspaceError(_("新增 CASE.md 必须属于本次迁移目录"))
179
180
  source = source_paths[target_paths.index(path.parent)]
180
181
  if not source.is_dir() or (source / "CASE.md").exists():
181
- raise WorkspaceError("源已有 CASE.md,请使用项目工具维护现有记录")
182
+ raise WorkspaceError(_("源已有 CASE.md,请使用项目工具维护现有记录"))
182
183
  record_paths.append(path)
183
184
  return {"ok": True, "applied": False, "moves": planned, "records": [r["path"] for r in records],
184
185
  "file_count": sum(item["manifest"]["file_count"] for item in planned),
@@ -197,7 +198,7 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
197
198
  target = safe_path(root, move["target"])
198
199
  # 再核对源指纹,防止预演后并发变动被静默接纳。
199
200
  if inventory(source) != move["manifest"]:
200
- raise WorkspaceError("源目录在应用前发生变化")
201
+ raise WorkspaceError(_("源目录在应用前发生变化"))
201
202
  missing = []
202
203
  parent = target.parent
203
204
  while not parent.exists():
@@ -207,13 +208,13 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
207
208
  parent.mkdir()
208
209
  directories.append(parent)
209
210
  if target.exists():
210
- raise WorkspaceError("目标在应用前出现")
211
+ raise WorkspaceError(_("目标在应用前出现"))
211
212
  if source.stat().st_dev != target.parent.stat().st_dev:
212
- raise WorkspaceError("跨文件系统移动不受支持")
213
+ raise WorkspaceError(_("跨文件系统移动不受支持"))
213
214
  rename_exclusive(source, target)
214
215
  moved.append((source, target))
215
216
  if inventory(target) != move["manifest"]:
216
- raise WorkspaceError("移动后内容核验失败")
217
+ raise WorkspaceError(_("移动后内容核验失败"))
217
218
  for record in mapping.get("records", []):
218
219
  path = safe_path(root, record["path"])
219
220
  with path.open("x", encoding="utf-8") as stream:
@@ -228,7 +229,7 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
228
229
  path.unlink()
229
230
  for source, target in reversed(moved):
230
231
  if source.exists():
231
- raise WorkspaceError("回滚源路径冲突,请按映射人工恢复")
232
+ raise WorkspaceError(_("回滚源路径冲突,请按映射人工恢复"))
232
233
  rename_exclusive(target, source)
233
234
  for directory in reversed(directories):
234
235
  try:
@@ -240,16 +241,16 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
240
241
 
241
242
  def metadata(path: Path) -> dict:
242
243
  if path.is_symlink() or not path.is_file() or path.stat().st_size > 2 * 1024 * 1024:
243
- raise WorkspaceError("非法或过大的 Case 记录")
244
+ raise WorkspaceError(_("非法或过大的 Case 记录"))
244
245
  fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0))
245
246
  with os.fdopen(fd, 'r', encoding='utf-8') as stream:
246
247
  if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
247
- raise WorkspaceError('Case 记录必须是普通文件')
248
+ raise WorkspaceError(_('Case 记录必须是普通文件'))
248
249
  source = stream.read(2 * 1024 * 1024 + 1)
249
250
  if len(source.encode()) > 2 * 1024 * 1024:
250
- raise WorkspaceError('Case 记录过大')
251
+ raise WorkspaceError(_('Case 记录过大'))
251
252
  if not source.startswith("---\n") or "\n---" not in source[4:]:
252
- raise WorkspaceError("Case 缺少 Frontmatter")
253
+ raise WorkspaceError(_("Case 缺少 Frontmatter"))
253
254
  header = source.split("\n---", 1)[0][4:]
254
255
  data, key = {}, None
255
256
  for line in header.splitlines():
@@ -260,19 +261,19 @@ def metadata(path: Path) -> dict:
260
261
  except json.JSONDecodeError:
261
262
  value = raw.strip("\"'")
262
263
  if not isinstance(data[key], list):
263
- raise WorkspaceError("非法数组字段")
264
+ raise WorkspaceError(_("非法数组字段"))
264
265
  data[key].append(value)
265
266
  elif re.match(r"^[a-z_]+:", line):
266
267
  key, raw = line.split(":", 1)
267
268
  if key in data:
268
- raise WorkspaceError("Case 存在重复字段")
269
+ raise WorkspaceError(_("Case 存在重复字段"))
269
270
  raw = raw.strip()
270
271
  try:
271
272
  data[key] = json.loads(raw) if raw else []
272
273
  except json.JSONDecodeError:
273
274
  data[key] = raw.strip("\"'")
274
275
  if not all(isinstance(data.get(k), str) and data[k] for k in ("id", "title", "status")):
275
- raise WorkspaceError("Case 缺少身份与状态")
276
+ raise WorkspaceError(_("Case 缺少身份与状态"))
276
277
  return data
277
278
 
278
279
 
@@ -284,18 +285,18 @@ def inspect_cases(root: Path, terms: list[str] | None = None) -> dict:
284
285
  words = [term.casefold() for term in terms or []]
285
286
  for directory in sorted(case_root.iterdir()):
286
287
  if directory.is_symlink():
287
- invalid.append({"path": directory.relative_to(root).as_posix(), "error": "符号链接"})
288
+ invalid.append({"path": directory.relative_to(root).as_posix(), "error": _("符号链接")})
288
289
  continue
289
290
  if not directory.is_dir():
290
291
  continue
291
292
  if opaque(directory) or directory.name in BLOCKED:
292
- invalid.append({'path': directory.relative_to(root).as_posix(), 'error': '隐私路径'})
293
+ invalid.append({'path': directory.relative_to(root).as_posix(), 'error': _('隐私路径')})
293
294
  continue
294
295
  try:
295
296
  record = directory / "CASE.md"
296
297
  data = metadata(record)
297
298
  if data['id'] != directory.name or data.get('sensitivity', 'shared') != 'shared':
298
- raise WorkspaceError('Case 身份或隐私位置不一致')
299
+ raise WorkspaceError(_('Case 身份或隐私位置不一致'))
299
300
  files = []
300
301
  size = 0
301
302
  for parent, dirs, names in os.walk(directory, followlinks=False):
@@ -320,7 +321,7 @@ def inspect_cases(root: Path, terms: list[str] | None = None) -> dict:
320
321
 
321
322
 
322
323
  def main(argv=None) -> int:
323
- parser = argparse.ArgumentParser(description=__doc__)
324
+ parser = argparse.ArgumentParser(description=_("Case 目录盘点、检索与已审阅映射的无损迁移;不代替项目状态工具。"))
324
325
  parser.add_argument("command", choices=("inspect", "search", "migrate"))
325
326
  parser.add_argument("--project", type=Path, required=True)
326
327
  parser.add_argument("--map", type=Path)
@@ -329,15 +330,15 @@ def main(argv=None) -> int:
329
330
  args = parser.parse_args(argv)
330
331
  try:
331
332
  if args.project.is_symlink() or not args.project.is_dir():
332
- raise WorkspaceError("项目根目录必须是实际目录")
333
+ raise WorkspaceError(_("项目根目录必须是实际目录"))
333
334
  root = args.project.resolve()
334
335
  if args.command == "migrate":
335
336
  if not args.map or args.map.is_symlink():
336
- raise WorkspaceError("迁移需要普通映射文件")
337
+ raise WorkspaceError(_("迁移需要普通映射文件"))
337
338
  result = migrate(root, json.loads(args.map.read_text(encoding="utf-8")), apply=args.apply)
338
339
  else:
339
340
  if args.apply or args.map:
340
- raise WorkspaceError("只读命令不支持 apply 或 map")
341
+ raise WorkspaceError(_("只读命令不支持 apply 或 map"))
341
342
  result = inspect_cases(root, args.term if args.command == "search" else None)
342
343
  print(json.dumps(result, ensure_ascii=False, indent=2))
343
344
  return 0 if result["ok"] else 1
@@ -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