@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
@@ -2,7 +2,7 @@
2
2
  """agenthelm:在 Claude Code、Codex、Cursor 间安装、检查与更新 agenthelm 插件。
3
3
 
4
4
  ``project`` 打开不需要 AI Agent 的项目自助入口(由 project-orchestrator 实现);``runtime``
5
- 管理用户级运行时。``check`` 只读:比较各客户端已安装的插件版本、项目里由模板渲染的文件,
5
+ 管理用户级运行时;``nudge`` 是各客户端 hook 调用的观点候选采集与未决事项提示(见 references/nudge.md)。``check`` 只读:比较各客户端已安装的插件版本、项目里由模板渲染的文件,
6
6
  以及 Opinion 来源与云端 ``latest.json``。``install``、``update``、``migrate``
7
7
  默认只预览,``--apply`` 才执行。通过 ``npx @sakanano-moe/agenthelm <命令>`` 或
8
8
  ``python3 <agent-pack>/runtime/agenthelm.py <命令>`` 运行。``--hook`` 供会话开始时静默调用:
@@ -28,6 +28,8 @@ from dataclasses import dataclass, field
28
28
  from datetime import UTC, datetime
29
29
  from pathlib import Path
30
30
  from typing import Any
31
+ from i18n_agent_pack import _ # noqa: E402
32
+ from i18n_agent_pack import _ # noqa: E402
31
33
 
32
34
  MARKETPLACE = "agenthelm"
33
35
  CLI = "npx @sakanano-moe/agenthelm"
@@ -174,14 +176,14 @@ def load_latest(env: Env, source: str | None = None, *, offline: bool = False,
174
176
  if cached and (offline or time.time() - cached.get("fetched_at", 0) < max_age):
175
177
  return cached["latest"], {"source": cached.get("url"), "cached": True, "fetched_at": cached.get("fetched_at")}
176
178
  if offline:
177
- raise CheckError("离线且没有缓存的 latest.json")
179
+ raise CheckError(_("离线且没有缓存的 latest.json"))
178
180
  failure_file = env.cache_dir / "last_failure.json"
179
181
  if backoff:
180
182
  try:
181
183
  if time.time() - read_json(failure_file).get("at", 0) < FAILURE_BACKOFF_SECONDS:
182
184
  if cached:
183
185
  return cached["latest"], {"source": cached.get("url"), "cached": True, "stale": True}
184
- raise CheckError("最近联网失败,稍后再试")
186
+ raise CheckError(_("最近联网失败,稍后再试"))
185
187
  except (OSError, ValueError, AttributeError):
186
188
  pass
187
189
  errors = []
@@ -203,7 +205,7 @@ def load_latest(env: Env, source: str | None = None, *, offline: bool = False,
203
205
  if cached:
204
206
  return cached["latest"], {"source": cached.get("url"), "cached": True, "stale": True,
205
207
  "fetched_at": cached.get("fetched_at")}
206
- raise CheckError("无法获取 latest.json:" + "; ".join(errors))
208
+ raise CheckError(_("无法获取 latest.json:") + "; ".join(errors))
207
209
 
208
210
 
209
211
  # ---------------------------------------------------------------- installed plugins
@@ -222,7 +224,7 @@ def installed_plugins(env: Env, names: set[str]) -> list[dict]:
222
224
  except (OSError, ValueError, AttributeError):
223
225
  record = {}
224
226
  for key, entries in sorted(record.items()):
225
- plugin, _, market = key.partition("@")
227
+ plugin, _sep, market = key.partition("@")
226
228
  if market != MARKETPLACE or plugin not in names:
227
229
  continue
228
230
  for entry in entries:
@@ -250,14 +252,18 @@ def installed_plugins(env: Env, names: set[str]) -> list[dict]:
250
252
 
251
253
 
252
254
  def migrations_between(latest: dict, plugin: str, current: str, target: str) -> list[dict]:
253
- steps = []
255
+ """The steps from ``current`` to ``target``; when two entries end at the same version (a span kept for the
256
+ release gate and the single steps inside it) the narrower one, with the newer ``from``, is shown."""
257
+ steps: dict[str, dict] = {}
254
258
  for entry in latest.get("migrations", []):
255
259
  if entry.get("plugin") != plugin:
256
260
  continue
257
261
  to = str(entry.get("to"))
258
262
  if is_newer(to, current) and not is_newer(to, target):
259
- steps.append(entry)
260
- return sorted(steps, key=lambda entry: version_key(str(entry.get("to"))) or (0,))
263
+ kept = steps.get(to)
264
+ if kept is None or is_newer(str(entry.get("from")), str(kept.get("from"))):
265
+ steps[to] = entry
266
+ return sorted(steps.values(), key=lambda entry: version_key(str(entry.get("to"))) or (0,))
261
267
 
262
268
 
263
269
  def plugin_findings(latest: dict, installed: list[dict]) -> list[dict]:
@@ -301,7 +307,7 @@ def content_findings(project: Path, latest: dict) -> list[dict]:
301
307
  try:
302
308
  lock = read_json(lock_path)
303
309
  except (OSError, ValueError) as exc:
304
- return [{"pack": None, "status": "error", "error": f"{CONTENT_LOCK} 无法解析:{exc}"}]
310
+ return [{"pack": None, "status": "error", "error": _("{0} 无法解析:{1}").format(CONTENT_LOCK, exc)}]
305
311
  findings = []
306
312
  for pack_id, entry in sorted(lock.get("packs", {}).items()):
307
313
  plugin = entry.get("plugin") or pack_id.split("/", 1)[0]
@@ -342,11 +348,11 @@ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
342
348
  return []
343
349
  manager = locate_companion("opinion-manager", installed)
344
350
  if manager is None:
345
- return [{"status": "unknown", "error": "未找到已安装的 opinion-manager"}]
351
+ return [{"status": "unknown", "error": _("未找到已安装的 opinion-manager")}]
346
352
  try:
347
353
  profile = read_json(lock_path)["profile"]
348
354
  except (OSError, ValueError, KeyError, TypeError) as exc:
349
- return [{"status": "error", "error": f"{OPINION_LOCK} 无法解析:{exc}"}]
355
+ return [{"status": "error", "error": _("{0} 无法解析:{1}").format(OPINION_LOCK, exc)}]
350
356
  with tempfile.TemporaryDirectory() as temporary:
351
357
  # macOS temporary paths live under /var, a symlink opinion-manager refuses.
352
358
  profile_file = Path(temporary).resolve() / "profile.json"
@@ -361,7 +367,7 @@ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
361
367
  except ValueError:
362
368
  return [{"status": "error", "error": (completed.stderr or completed.stdout).strip()[:300]}]
363
369
  if not result.get("ok", False):
364
- return [{"status": "error", "error": result.get("error", "opinion-manager updates 失败")}]
370
+ return [{"status": "error", "error": result.get("error", _("opinion-manager updates 失败"))}]
365
371
  return [{"status": "update-available" if result["updates"] else "current", "updates": result["updates"]}]
366
372
 
367
373
 
@@ -408,7 +414,7 @@ def scaffold_report(env: Env, project: Path | None, *, reprompt: bool = False) -
408
414
  base = {"available": False, "root": str(root), "findings": [], "skipped": [], "error": ""}
409
415
  module = scaffold_module(env)
410
416
  if module is None:
411
- return {**base, "error": "已安装的 agents-init 没有脚手架检查(更新 agents-init 后再检查)"}
417
+ return {**base, "error": _("已安装的 agents-init 没有脚手架检查(更新 agents-init 后再检查)")}
412
418
  try:
413
419
  return {**module.check(root, state_dir=env.state_dir, reprompt=reprompt), "available": True,
414
420
  "root": str(root), "error": ""}
@@ -416,12 +422,16 @@ def scaffold_report(env: Env, project: Path | None, *, reprompt: bool = False) -
416
422
  return {**base, "error": str(exc)}
417
423
 
418
424
 
419
- SYNC_SERVICES = (
420
- ("iCloud Drive", re.compile(r"^(com~apple~CloudDocs|Mobile Documents|iCloud Drive|iCloudDrive)$")),
421
- ("Dropbox", re.compile(r"^Dropbox( \(.*\))?$")),
422
- ("OneDrive", re.compile(r"^OneDrive( - .*|-.*)?$")),
423
- ("Google Drive", re.compile(r"^(GoogleDrive(-.*)?|Google Drive|My Drive)$")),
424
- )
425
+ def _local_only() -> Any:
426
+ """local_only 模块(与本文件同目录,只用标准库)。"""
427
+ if str(RUNTIME_DIR) not in sys.path:
428
+ sys.path.insert(0, str(RUNTIME_DIR))
429
+ import local_only # noqa: PLC0415 - shipped next to this file
430
+
431
+ return local_only
432
+
433
+
434
+ SYNC_SERVICES = tuple((name, pattern) for _identifier, name, pattern in _local_only().SYNC_SERVICES)
425
435
  REBUILDABLE_DIRS = {"node_modules", ".venv", "venv", "__pycache__", ".pytest_cache", ".cache", "dist", "build", "target",
426
436
  ".tox", ".next", ".gradle"}
427
437
 
@@ -445,7 +455,7 @@ def sync_folder(root: Path) -> dict | None:
445
455
  return {"service": service, "path": str(resolved), "rebuildable": sorted(found)}
446
456
 
447
457
 
448
- def machine_findings(env: Env, project: Path | None, *, reprompt: bool = False) -> dict:
458
+ def machine_findings(env: Env, project: Path | None, *, reprompt: bool = False, quick: bool = False) -> dict:
449
459
  """Findings about this machine only (no user-level runtime ...); their decisions live in the user state directory."""
450
460
  root = profile_root(project)
451
461
  result: dict = {"findings": [], "skipped": []}
@@ -458,19 +468,29 @@ def machine_findings(env: Env, project: Path | None, *, reprompt: bool = False)
458
468
  if data["current"] is None:
459
469
  text = agenthelm_runtime.missing_text(data, agenthelm_runtime.command_status())
460
470
  findings.append({"id": "local:runtime-missing", "kind": "runtime-missing", "level": "suggest", "scope": "machine",
461
- "title": "这台机器还没有安装用户级运行时", "reason": text["reason"], "impact": text["impact"],
471
+ "title": _("这台机器还没有安装用户级运行时"), "reason": text["reason"], "impact": text["impact"],
462
472
  "fingerprint": "runtime-missing", "fix": "install-runtime", "next": text["label"]})
463
473
  where = sync_folder(root)
464
474
  if where is not None:
465
- extra = f";其中有可重建目录:{'、'.join(where['rebuildable'][:6])}" if where["rebuildable"] else ""
475
+ extra = _(";其中有可重建目录:{0}").format(_('、').join(where['rebuildable'][:6])) if where["rebuildable"] else ""
466
476
  findings.append({"id": "local:sync-folder", "kind": "sync-folder", "level": "suggest", "scope": "machine",
467
- "title": f"项目位于同步盘({where['service']})",
477
+ "title": _("项目位于同步盘({0})").format(where['service']),
468
478
  "reason": f"{where['path']}{extra}",
469
- "impact": "多台机器共用同一份项目时:项目文件随同步盘走,但运行时、缓存与 wf 的台账是每台机器各一份;"
470
- "可重建目录(依赖、构建产物)同步很慢,也容易产生冲突副本",
479
+ "impact": _("多台机器共用同一份项目时:项目文件随同步盘走,但运行时、缓存与 wf 的台账是每台机器各一份;"
480
+ "可重建目录(依赖、构建产物)同步很慢,也容易产生冲突副本"),
471
481
  "fingerprint": sha256_text(where["service"]), "fix": "sync-folder",
472
- "next": "运行 agenthelm fix sync-folder 查看指引(只给方案,不改文件、不删内容);不需要就 fix --skip local:sync-folder",
482
+ "next": _("运行 agenthelm fix sync-folder 查看指引(只给方案,不改文件、不删内容);不需要就 fix --skip local:sync-folder"),
473
483
  "sync": where})
484
+ # local-only:同步盘里的本机专属目录、外置链接、HOME、体积……没有同步服务也没有配置时不扫描。
485
+ try:
486
+ engine = _local_only().check(root, home=env.home, environ=env.variables, mode="quick" if quick else "standard",
487
+ user_config=env.config_file)
488
+ findings.extend(engine["findings"])
489
+ except Exception as exc: # noqa: BLE001 - 本机目录检查出错不能拖垮插件与脚手架检查
490
+ findings.append({"id": "local:local-only-error", "kind": "error", "level": "suggest", "scope": "machine",
491
+ "title": _("无法检查本机专属目录"), "reason": str(exc), "impact": _("只影响同步盘相关提示"),
492
+ "fingerprint": sha256_text(str(exc)), "fix": None, "attention": False, "options": [],
493
+ "next": _("运行 agenthelm local-only check 查看详情")})
474
494
  module = scaffold_module(env)
475
495
  if module is None or not findings:
476
496
  result["findings"] = findings
@@ -480,6 +500,30 @@ def machine_findings(env: Env, project: Path | None, *, reprompt: bool = False)
480
500
  return result
481
501
 
482
502
 
503
+ def local_only_report(env: Env, project: Path | None, *, mode: str = "full", reprompt: bool = False) -> dict:
504
+ """local-only 的完整检查结果(供 ``agenthelm local-only check`` 与 MCP 工具),已套用这台机器上记录的决定。"""
505
+ module = _local_only()
506
+ base = (project or Path.cwd()).resolve()
507
+ root = profile_root(base) or base
508
+ report = module.check(root, home=env.home, environ=env.variables, mode=mode, user_config=env.config_file)
509
+ report["skipped"] = []
510
+ scaffold = scaffold_module(env)
511
+ if scaffold is not None and report["findings"]:
512
+ decisions = scaffold.load_decisions(root, env.state_dir)
513
+ report["findings"], skipped = scaffold.partition(report["findings"], decisions, reprompt=reprompt)
514
+ report["skipped"] = [{"id": item["id"], "decision": item["decision"]} for item in skipped]
515
+ report["summary"] = {"findings": len(report["findings"]),
516
+ "attention": sum(1 for item in report["findings"] if item.get("attention"))}
517
+ return report
518
+
519
+
520
+ def local_only_plan(env: Env, project: Path | None, **options: Any) -> dict:
521
+ """``agenthelm local-only fix`` 的预览(不写任何东西);原因不能处理时抛出 LocalOnlyError。"""
522
+ base = (project or Path.cwd()).resolve()
523
+ return _local_only().plan_fix(profile_root(base) or base, home=env.home, environ=env.variables,
524
+ user_config=env.config_file, **options)
525
+
526
+
483
527
  # ---------------------------------------------------------------- report
484
528
 
485
529
  def scaffold_fingerprint(env: Env, scaffold: dict | None) -> dict | None:
@@ -501,7 +545,8 @@ def run_check(env: Env, project: Path | None, *, latest_source: str | None = Non
501
545
  content = content_findings(project, latest) if project else []
502
546
  opinion = opinion_findings(project, installed) if project else []
503
547
  scaffold = scaffold_report(env, project, reprompt=reprompt)
504
- machine = machine_findings(env, project, reprompt=reprompt)
548
+ machine = machine_findings(env, project, reprompt=reprompt, quick=hook_mode)
549
+ local_attention = sum(1 for item in machine["findings"] if item.get("attention"))
505
550
  scaffold_active = len(scaffold["findings"]) if scaffold else 0
506
551
  behind = [item for item in plugins if item["status"] == "behind"]
507
552
  legacy = [item for item in plugins if item["client"] == "legacy"]
@@ -519,13 +564,15 @@ def run_check(env: Env, project: Path | None, *, latest_source: str | None = Non
519
564
  "summary": {
520
565
  "scaffold_findings": scaffold_active,
521
566
  "scaffold_skipped": len(scaffold["skipped"]) if scaffold else 0,
567
+ "local_only_attention": local_attention,
522
568
  "plugins_behind": len(behind),
523
569
  "breaking": any(item["breaking"] for item in behind),
524
570
  "content_updates": len(content_updates),
525
571
  "content_conflicts": sum(1 for item in content if item["status"] == "conflict"),
526
572
  "opinion_updates": len(opinion_updates),
527
573
  "legacy_installs": len(legacy),
528
- "up_to_date": not (behind or content_updates or opinion_updates or legacy or scaffold_active),
574
+ "up_to_date": not (behind or content_updates or opinion_updates or legacy or scaffold_active
575
+ or local_attention),
529
576
  },
530
577
  }
531
578
 
@@ -544,17 +591,19 @@ def one_line(report: dict) -> str | None:
544
591
  return None
545
592
  parts = []
546
593
  if summary["plugins_behind"]:
547
- parts.append(f"{summary['plugins_behind']} 个插件可更新" + ("(含不兼容变更)" if summary["breaking"] else ""))
594
+ parts.append(_("{0} 个插件可更新").format(summary['plugins_behind']) + (_("(含不兼容变更)") if summary["breaking"] else ""))
548
595
  if summary["content_updates"]:
549
- parts.append(f"{summary['content_updates']} 个模板/样例包可更新"
550
- + (f"({summary['content_conflicts']} 个本地有改动,更新时逐文件确认)" if summary["content_conflicts"] else ""))
596
+ parts.append(_("{0} 个模板/样例包可更新").format(summary['content_updates'])
597
+ + (_("({0} 个本地有改动,更新时逐文件确认)").format(summary['content_conflicts']) if summary["content_conflicts"] else ""))
551
598
  if summary["opinion_updates"]:
552
- parts.append("Opinion 有新版本模板或规则")
599
+ parts.append(_("Opinion 有新版本模板或规则"))
553
600
  if summary["legacy_installs"]:
554
- parts.append(f"{summary['legacy_installs']} 个旧方式安装待迁移")
601
+ parts.append(_("{0} 个旧方式安装待迁移").format(summary['legacy_installs']))
555
602
  if summary["scaffold_findings"]:
556
- parts.append(f"项目脚手架有 {summary['scaffold_findings']} 项差异")
557
- return "agenthelm:" + ",".join(parts) + f"。运行 `{CLI} check` 查看详情;更新前会先预览,由你决定。"
603
+ parts.append(_("项目脚手架有 {0} 项差异").format(summary['scaffold_findings']))
604
+ if summary.get("local_only_attention"):
605
+ parts.append(_("同步目录里有 {0} 项本机专属目录需要处理").format(summary['local_only_attention']))
606
+ return _("agenthelm:") + _(",").join(parts) + _("。运行 `{0} check` 查看详情;更新前会先预览,由你决定。").format(CLI)
558
607
 
559
608
 
560
609
  def render_scaffold(report: dict, show_skipped: bool) -> list[str]:
@@ -563,91 +612,89 @@ def render_scaffold(report: dict, show_skipped: bool) -> list[str]:
563
612
  if scaffold is None:
564
613
  return lines
565
614
  if scaffold.get("error"):
566
- lines.append(f" [脚手架] 无法检查:{scaffold['error']}")
615
+ lines.append(_(" [脚手架] 无法检查:{0}").format(scaffold['error']))
567
616
  return lines
568
617
  for item in scaffold["findings"]:
569
- mark = "(稍后)" if item.get("deferred") else "(期望内容已变化,重新提示)" if item.get("changed_since_decision") else ""
570
- fix = f" 处理:fix {item['fix']}" if item.get("fix") else " 需要你自己处理"
571
- lines.append(f" [脚手架] {item['title']}{mark}:{item['reason']}\n ID {item['id']}{fix}")
618
+ mark = _("(稍后)") if item.get("deferred") else _("(期望内容已变化,重新提示)") if item.get("changed_since_decision") else ""
619
+ fix = _(" 处理:fix {0}").format(item['fix']) if item.get("fix") else _(" 需要你自己处理")
620
+ lines.append(_(" [脚手架] {0}{1}:{2}\n ID {3}{4}").format(item['title'], mark, item['reason'], item['id'], fix))
572
621
  for message in scaffold["decisions"]["errors"]:
573
- lines.append(f" [脚手架] 决定文件有问题:{message}")
622
+ lines.append(_(" [脚手架] 决定文件有问题:{0}").format(message))
574
623
  if scaffold["decisions"]["conflict_copies"]:
575
- lines.append(" [脚手架] 同步服务留下了决定文件的冲突副本:"
576
- + "、".join(scaffold["decisions"]["conflict_copies"]) + "(合并到 check-decisions.json 后删除)")
624
+ lines.append(_(" [脚手架] 同步服务留下了决定文件的冲突副本:")
625
+ + _("、").join(scaffold["decisions"]["conflict_copies"]) + _("(合并到 check-decisions.json 后删除)"))
577
626
  if not scaffold["findings"] and not scaffold["decisions"]["errors"]:
578
- lines.append(" [脚手架] 与期望一致" + (f"({len(scaffold['skipped'])} 项已跳过)" if scaffold["skipped"] else ""))
627
+ lines.append(_(" [脚手架] 与期望一致") + (_("({0} 项已跳过)").format(len(scaffold['skipped'])) if scaffold["skipped"] else ""))
579
628
  skipped = [*scaffold["skipped"], *report.get("machine", {}).get("skipped", [])]
580
629
  if show_skipped:
581
630
  for item in skipped:
582
631
  decision = item["decision"]
583
- lines.append(f" [已跳过·{decision['decision']}] {item['id']}:{decision.get('reason') or '(未写理由)'}"
584
- f" {decision.get('decided_at', '')}")
632
+ lines.append(_(" [已跳过·{0}] {1}:{2} {3}").format(decision['decision'], item['id'], decision.get('reason') or _('(未写理由)'), decision.get('decided_at', '')))
585
633
  if not skipped:
586
- lines.append(" 没有被跳过的项。")
634
+ lines.append(_(" 没有被跳过的项。"))
587
635
  elif skipped:
588
- lines.append(f" (另有 {len(skipped)} 项已被你跳过:check --show-skipped 查看)")
636
+ lines.append(_(" (另有 {0} 项已被你跳过:check --show-skipped 查看)").format(len(skipped)))
589
637
  return lines
590
638
 
591
639
 
592
640
  def render_machine(report: dict) -> list[str]:
593
641
  lines = []
594
642
  for item in report.get("machine", {}).get("findings", []):
595
- lines.append(f" [本机] {item['title']}:{item['reason']}\n 影响:{item['impact']}\n 处理:{item['next']}"
596
- f"\n ID {item['id']}(只记在这台机器上;fix --skip {item['id']} --reason …)")
643
+ lines.append(_(" [本机] {0}:{1}\n 影响:{2}\n 处理:{3}\n ID {4}(只记在这台机器上;fix --skip {5} --reason …)").format(item['title'], item['reason'], item['impact'], item['next'], item['id'], item['id']))
597
644
  return lines
598
645
 
599
646
 
600
647
  def render_text(report: dict, *, show_skipped: bool = False) -> str:
601
648
  lines = []
602
649
  origin = report["latest"]["source"]
603
- lines.append(f"云端版本源:{origin.get('source')}" + ("(缓存)" if origin.get("cached") else ""))
650
+ lines.append(_("云端版本源:{0}").format(origin.get('source')) + (_("(缓存)") if origin.get("cached") else ""))
604
651
  if not report["plugins"]:
605
- lines.append("未发现已安装的 agenthelm 插件。")
652
+ lines.append(_("未发现已安装的 agenthelm 插件。"))
606
653
  for item in report["plugins"]:
607
- label = {"behind": "可更新", "current": "最新", "ahead": "高于云端", "unknown": "版本未知",
608
- "legacy": "旧方式安装,待迁移"}[item["status"]]
654
+ label = {"behind": _("可更新"), "current": _("最新"), "ahead": _("高于云端"), "unknown": _("版本未知"),
655
+ "legacy": _("旧方式安装,待迁移")}[item["status"]]
609
656
  if item["status"] == "legacy":
610
- lines.append(f" [旧方式] {item['plugin']} {item.get('version')} {label}")
657
+ lines.append(_(" [旧方式] {0} {1} {2}").format(item['plugin'], item.get('version'), label))
611
658
  continue
612
659
  line = f" [{item['client']}] {item['plugin']} {item.get('version')} → {item.get('latest')} {label}"
613
660
  if item["status"] == "behind":
614
- kinds = {"code": "代码", "content": "仅模板/样例", "both": "代码与模板"}
661
+ kinds = {"code": _("代码"), "content": _("仅模板/样例"), "both": _("代码与模板")}
615
662
  if item["change_kinds"]:
616
- line += "(" + "、".join(kinds[kind] for kind in item["change_kinds"]) + ")"
663
+ line += _("(") + _("、").join(kinds[kind] for kind in item["change_kinds"]) + _(")")
617
664
  if item["breaking"]:
618
- line += "(含不兼容变更)"
665
+ line += _("(含不兼容变更)")
619
666
  for step in item["steps"]:
620
667
  if step.get("breaking"):
621
- line += f"\n {step['to']} 不兼容:{step.get('summary')}"
668
+ line += _("\n {0} 不兼容:{1}").format(step['to'], step.get('summary'))
622
669
  line += "".join(f"\n - {action}" for action in step.get("actions") or [])
623
670
  hint = UPDATE_HINTS.get(item["client"])
624
671
  if hint:
625
- line += f"\n 更新:{hint.format(plugin=item['plugin'])}"
672
+ line += _("\n 更新:{0}").format(hint.format(plugin=item['plugin']))
626
673
  lines.append(line)
627
674
  for item in report["content"]:
628
675
  if item.get("pack") is None:
629
- lines.append(f" 内容锁:{item.get('error')}")
676
+ lines.append(_(" 内容锁:{0}").format(item.get('error')))
630
677
  continue
631
- label = {"current": "最新", "update-available": "可更新(本地未改动)",
632
- "conflict": "可更新(本地有改动,更新时逐文件确认)", "unknown": "云端未列出"}[item["status"]]
633
- line = f" [内容] {item['pack']} {item['version']} → {item.get('latest')} {label}"
678
+ label = {"current": _("最新"), "update-available": _("可更新(本地未改动)"),
679
+ "conflict": _("可更新(本地有改动,更新时逐文件确认)"), "unknown": _("云端未列出")}[item["status"]]
680
+ line = _(" [内容] {0} {1} → {2} {3}").format(item['pack'], item['version'], item.get('latest'), label)
634
681
  if item["locally_changed"]:
635
- line += "\n 本地已改动:" + "、".join(item["locally_changed"])
682
+ line += _("\n 本地已改动:") + _("、").join(item["locally_changed"])
636
683
  lines.append(line)
637
684
  for item in report["opinion"]:
638
685
  if item["status"] == "update-available":
639
- refs = ";".join(f"{update['current']} → {update['available'][-1]}" for update in item["updates"])
640
- lines.append(f" [Opinion] 有新版本:{refs}(用 opinion-manager 预览后确认)")
686
+ refs = _(";").join(f"{update['current']} → {update['available'][-1]}" for update in item["updates"])
687
+ lines.append(_(" [Opinion] 有新版本:{0}(用 opinion-manager 预览后确认)").format(refs))
641
688
  elif item["status"] in {"error", "unknown"}:
642
689
  lines.append(f" [Opinion] {item.get('error')}")
643
690
  lines += render_scaffold(report, show_skipped)
644
691
  lines += render_machine(report)
645
692
  if not report["plugins"]:
646
- lines.append(f"运行 `{CLI} install` 预览安装。")
693
+ lines.append(_("运行 `{0} install` 预览安装。").format(CLI))
647
694
  else:
648
695
  if any(item["status"] == "legacy" for item in report["plugins"]):
649
- lines.append(f"旧方式安装可用 `{UPDATE_HINTS['legacy']}` 预览迁移。")
650
- lines.append(one_line(report) or "全部为最新。")
696
+ lines.append(_("旧方式安装可用 `{0}` 预览迁移。").format(UPDATE_HINTS['legacy']))
697
+ lines.append(one_line(report) or _("全部为最新。"))
651
698
  return "\n".join(lines) + "\n"
652
699
 
653
700
 
@@ -695,7 +742,7 @@ CLIENTS = ("claude", "codex", "cursor")
695
742
  def github_slug(latest: dict) -> str:
696
743
  match = re.match(r"https://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", str(latest.get("repository", "")))
697
744
  if not match:
698
- raise CheckError(f"latest.json 的 repository 不是 GitHub 仓库:{latest.get('repository')}")
745
+ raise CheckError(_("latest.json 的 repository 不是 GitHub 仓库:{0}").format(latest.get('repository')))
699
746
  return match.group(1)
700
747
 
701
748
 
@@ -715,9 +762,9 @@ def _safe_extract(archive: Path, destination: Path) -> None:
715
762
  for member in bundle.getmembers():
716
763
  target = (destination / member.name).resolve()
717
764
  if destination.resolve() not in target.parents and target != destination.resolve():
718
- raise CheckError(f"发布包含越界路径:{member.name}")
765
+ raise CheckError(_("发布包含越界路径:{0}").format(member.name))
719
766
  if not (member.isfile() or member.isdir()):
720
- raise CheckError(f"发布包含不支持的条目:{member.name}")
767
+ raise CheckError(_("发布包含不支持的条目:{0}").format(member.name))
721
768
  try:
722
769
  bundle.extractall(destination, filter="data") # noqa: S202 - members validated above
723
770
  except TypeError: # Python without extraction filters
@@ -748,25 +795,25 @@ def release_source(latest: dict, workdir: Path, source: Path | None = None) -> P
748
795
  if source is not None:
749
796
  root = source.expanduser().resolve()
750
797
  if not (root / "plugins").is_dir():
751
- raise CheckError(f"{root} 不含 plugins/ 目录")
798
+ raise CheckError(_("{0} 不含 plugins/ 目录").format(root))
752
799
  return root
753
800
  release = latest.get("release") or github_release(latest)
754
801
  tag, digest = release.get("tag"), release.get("tarball_sha256")
755
802
  if not tag or not digest:
756
- raise CheckError("云端 latest.json 没有发布 tag 与摘要;请用 --source 指定本地仓库")
803
+ raise CheckError(_("云端 latest.json 没有发布 tag 与摘要;请用 --source 指定本地仓库"))
757
804
  url = release.get("tarball_url") or f"https://github.com/{github_slug(latest)}/releases/download/{tag}/agenthelm-{tag}.tar.gz"
758
805
  archive = workdir / "release.tar.gz"
759
806
  request = urllib.request.Request(url, headers={"User-Agent": "agenthelm"})
760
807
  with urllib.request.urlopen(request, timeout=60) as response, archive.open("wb") as handle: # noqa: S310
761
808
  shutil.copyfileobj(response, handle)
762
809
  if sha256_file(archive) != digest:
763
- raise CheckError(f"发布包摘要不符:{url}")
810
+ raise CheckError(_("发布包摘要不符:{0}").format(url))
764
811
  extracted = workdir / "release"
765
812
  extracted.mkdir()
766
813
  _safe_extract(archive, extracted)
767
814
  roots = [path for path in extracted.iterdir() if path.is_dir()]
768
815
  if len(roots) != 1 or not (roots[0] / "plugins").is_dir():
769
- raise CheckError("发布包结构不符合预期")
816
+ raise CheckError(_("发布包结构不符合预期"))
770
817
  return roots[0]
771
818
 
772
819
 
@@ -824,7 +871,7 @@ def find_cli(env: Env, name: str) -> str | None:
824
871
  def _cli(env: Env, name: str, *args: str) -> tuple[list[str], str | None]:
825
872
  """The command (using the resolved executable) and why it cannot run, if it cannot."""
826
873
  found = find_cli(env, name)
827
- return [found or name, *args], (None if found else f"未找到 {name} 命令(已查 PATH 与常见安装位置;可用 AGENTHELM_{name.upper()} 指定)")
874
+ return [found or name, *args], (None if found else _("未找到 {0} 命令(已查 PATH 与常见安装位置;可用 AGENTHELM_{1} 指定)").format(name, name.upper()))
828
875
 
829
876
 
830
877
  def install_steps(env: Env, latest: dict, clients: list[str], plugins: list[str], source: Path | None,
@@ -834,18 +881,18 @@ def install_steps(env: Env, latest: dict, clients: list[str], plugins: list[str]
834
881
  for client in clients:
835
882
  if client == "claude":
836
883
  command, blocked = _cli(env, "claude", "plugin", "marketplace", "add", slug)
837
- steps.append(Step("install", "claude", f"Claude Code 添加 agenthelm 市场({slug})", command, blocked=blocked))
884
+ steps.append(Step("install", "claude", _("Claude Code 添加 agenthelm 市场({0})").format(slug), command, blocked=blocked))
838
885
  for plugin in plugins:
839
886
  command, blocked = _cli(env, "claude", "plugin", "install", f"{plugin}@{MARKETPLACE}")
840
- steps.append(Step("install", "claude", f"Claude Code 安装 {plugin}", command, blocked=blocked))
887
+ steps.append(Step("install", "claude", _("Claude Code 安装 {0}").format(plugin), command, blocked=blocked))
841
888
  elif client == "codex":
842
889
  command, blocked = _cli(env, "codex", "plugin", "marketplace", "add", slug)
843
- steps.append(Step("install", "codex", f"Codex 添加 agenthelm 市场({slug})", command, blocked=blocked))
890
+ steps.append(Step("install", "codex", _("Codex 添加 agenthelm 市场({0})").format(slug), command, blocked=blocked))
844
891
  for plugin in plugins:
845
892
  command, blocked = _cli(env, "codex", "plugin", "add", f"{plugin}@{MARKETPLACE}")
846
- steps.append(Step("install", "codex", f"Codex 安装 {plugin}", command, blocked=blocked))
893
+ steps.append(Step("install", "codex", _("Codex 安装 {0}").format(plugin), command, blocked=blocked))
847
894
  elif client == "cursor":
848
- steps.append(cursor_step(latest, plugins, source, home, "安装"))
895
+ steps.append(cursor_step(latest, plugins, source, home, _("安装")))
849
896
  return steps
850
897
 
851
898
 
@@ -857,7 +904,7 @@ def cursor_step(latest: dict, plugins: list[str], source: Path | None, home: Pat
857
904
  return [config.install_plugin(root / "plugins" / plugin, "cursor", home=home, apply=apply,
858
905
  replace=True, recovery_root=recovery_root)
859
906
  for plugin in plugins]
860
- return Step("install", "cursor", f"Cursor {verb} {'、'.join(plugins)} 到 ~/.cursor/plugins/local(重启 Cursor 生效)",
907
+ return Step("install", "cursor", _("Cursor {0} {1} 到 ~/.cursor/plugins/local(重启 Cursor 生效)").format(verb, _('、').join(plugins)),
861
908
  action=run)
862
909
 
863
910
 
@@ -871,18 +918,18 @@ def update_steps(env: Env, report: dict, project: Path | None, source: Path | No
871
918
  claude = sorted({item["plugin"] for item in behind if item["client"] == "claude"})
872
919
  if claude:
873
920
  command, blocked = _cli(env, "claude", "plugin", "marketplace", "update", MARKETPLACE)
874
- steps.append(Step("update", "claude", "刷新 Claude Code 的 agenthelm 市场", command, blocked=blocked))
921
+ steps.append(Step("update", "claude", _("刷新 Claude Code 的 agenthelm 市场"), command, blocked=blocked))
875
922
  for item in [entry for entry in behind if entry["client"] == "claude"]:
876
923
  extra = ["--scope", item["scope"]] if item.get("scope") and item["scope"] != "user" else []
877
924
  command, blocked = _cli(env, "claude", "plugin", "update", f"{item['plugin']}@{MARKETPLACE}", *extra)
878
- steps.append(Step("update", "claude", f"Claude Code 更新 {item['plugin']} → {item['latest']}",
925
+ steps.append(Step("update", "claude", _("Claude Code 更新 {0} → {1}").format(item['plugin'], item['latest']),
879
926
  command, blocked=blocked))
880
927
  if any(item["client"] == "codex" for item in behind):
881
928
  command, blocked = _cli(env, "codex", "plugin", "marketplace", "upgrade", MARKETPLACE)
882
- steps.append(Step("update", "codex", "Codex 升级 agenthelm 市场与已装插件", command, blocked=blocked))
929
+ steps.append(Step("update", "codex", _("Codex 升级 agenthelm 市场与已装插件"), command, blocked=blocked))
883
930
  cursor = sorted({item["plugin"] for item in behind if item["client"] == "cursor"})
884
931
  if cursor:
885
- steps.append(cursor_step(latest, cursor, source, home, "更新"))
932
+ steps.append(cursor_step(latest, cursor, source, home, _("更新")))
886
933
  if only in (None, "content") and project is not None:
887
934
  for item in report["content"]:
888
935
  if item.get("status") not in {"update-available", "conflict"}:
@@ -894,15 +941,15 @@ def update_steps(env: Env, report: dict, project: Path | None, source: Path | No
894
941
  def content_step(env: Env, item: dict, report: dict, project: Path, accept: list[str], keep: list[str],
895
942
  recovery_dir: Path | None) -> Step:
896
943
  if item["plugin"] != "agents-init":
897
- return Step("content", None, f"{item['pack']}:没有可用的刷新工具", blocked="unsupported")
944
+ return Step("content", None, _("{0}:没有可用的刷新工具").format(item['pack']), blocked="unsupported")
898
945
  stale = any(entry["plugin"] == "agents-init" and entry["status"] == "behind" for entry in report["plugins"])
899
- description = f"重新渲染 {item['pack']} {item['version']} → {item['latest']}"
946
+ description = _("重新渲染 {0} {1} → {2}").format(item['pack'], item['version'], item['latest'])
900
947
 
901
948
  def run(apply: bool, _recovery_root: Path | None) -> dict:
902
949
  installed = installed_plugins(env, {"agents-init"})
903
950
  manager = locate_companion("agents-init", installed)
904
951
  if manager is None:
905
- raise CheckError("未找到已安装的 agents-init")
952
+ raise CheckError(_("未找到已安装的 agents-init"))
906
953
  command = [sys.executable, str(manager / "skills/agents-init/scripts/init_project.py"),
907
954
  "--project", str(project), "--mode", "refresh", "--output", "json"]
908
955
  for path in accept:
@@ -920,7 +967,7 @@ def content_step(env: Env, item: dict, report: dict, project: Path, accept: list
920
967
  except ValueError as exc:
921
968
  raise CheckError((completed.stderr or completed.stdout).strip()[:300]) from exc
922
969
 
923
- blocked = "先更新 agents-init 插件,再用新模板预览" if stale else None
970
+ blocked = _("先更新 agents-init 插件,再用新模板预览") if stale else None
924
971
  return Step("content", None, description, action=run, blocked=blocked)
925
972
 
926
973
 
@@ -1122,95 +1169,97 @@ def doctor(env: Env, report: dict) -> dict:
1122
1169
  clients = [item["client"] for item in items]
1123
1170
  if "legacy" in clients and any(client in {"claude", "codex"} for client in clients):
1124
1171
  issues.append({"plugin": plugin, "issue": "duplicate",
1125
- "detail": f"同时存在市场安装与旧方式安装,运行 {CLI} migrate"})
1172
+ "detail": _("同时存在市场安装与旧方式安装,运行 {0} migrate").format(CLI)})
1126
1173
  versions = {item.get("version") for item in items if item["client"] != "legacy"}
1127
1174
  if len(versions) > 1:
1128
1175
  issues.append({"plugin": plugin, "issue": "version-skew",
1129
- "detail": "各客户端版本不一致:" + "、".join(f"{i['client']}={i.get('version')}" for i in items)})
1176
+ "detail": _("各客户端版本不一致:") + _("、").join(f"{i['client']}={i.get('version')}" for i in items)})
1130
1177
  names = set(report["_latest"].get("plugins", {}))
1131
1178
  installed_names = {item["plugin"] for item in report["plugins"] if item["client"] != "legacy"}
1132
1179
  if installed_names and installed_names != names:
1133
1180
  issues.append({"plugin": None, "issue": "incomplete",
1134
- "detail": "缺少:" + "、".join(sorted(names - installed_names))})
1181
+ "detail": _("缺少:") + _("、").join(sorted(names - installed_names))})
1135
1182
  if not update_check_enabled(env):
1136
- issues.append({"plugin": None, "issue": "update-check-disabled", "detail": "会话内更新检查已关闭"})
1183
+ issues.append({"plugin": None, "issue": "update-check-disabled", "detail": _("会话内更新检查已关闭")})
1137
1184
  return {"ok": not [issue for issue in issues if issue["issue"] != "update-check-disabled"],
1138
1185
  "issues": issues, "legacy": legacy_artifacts(env, names), "summary": report["summary"]}
1139
1186
 
1140
1187
 
1141
1188
  def parser() -> argparse.ArgumentParser:
1142
- result = argparse.ArgumentParser(prog="agenthelm", description=__doc__.split("\n\n")[0])
1189
+ result = argparse.ArgumentParser(prog="agenthelm", description=_("agenthelm:在 Claude Code、Codex、Cursor 间安装、检查与更新 agenthelm 插件。"))
1143
1190
  sub = result.add_subparsers(dest="command", required=True)
1144
1191
 
1145
1192
  def common(command: argparse.ArgumentParser, *, project: bool = True) -> None:
1146
- command.add_argument("--latest", help="latest.json 的 URL 或本地路径(测试、离线镜像)")
1193
+ command.add_argument("--latest", help=_("latest.json 的 URL 或本地路径(测试、离线镜像)"))
1147
1194
  command.add_argument("--output", choices=("text", "json"), default="text")
1148
1195
  command.add_argument("--home", type=Path, help=argparse.SUPPRESS)
1149
1196
  if project:
1150
- command.add_argument("--project", type=Path, default=None, help="项目目录;默认当前目录")
1151
- command.add_argument("--no-project", action="store_true", help="不检查项目内容")
1197
+ command.add_argument("--project", type=Path, default=None, help=_("项目目录;默认当前目录"))
1198
+ command.add_argument("--no-project", action="store_true", help=_("不检查项目内容"))
1152
1199
 
1153
- check = sub.add_parser("check", help="只读检查插件、模板/样例与 Opinion 是否落后于云端")
1200
+ check = sub.add_parser("check", help=_("只读检查插件、模板/样例与 Opinion 是否落后于云端"))
1154
1201
  common(check)
1155
- check.add_argument("--offline", action="store_true", help="只用缓存,不联网")
1156
- check.add_argument("--refresh", action="store_true", help="忽略一天的缓存,立即联网")
1157
- check.add_argument("--hook", action="store_true", help="会话开始时静默调用:有更新才输出一行")
1202
+ check.add_argument("--offline", action="store_true", help=_("只用缓存,不联网"))
1203
+ check.add_argument("--refresh", action="store_true", help=_("忽略一天的缓存,立即联网"))
1204
+ check.add_argument("--hook", action="store_true", help=_("会话开始时静默调用:有更新才输出一行"))
1158
1205
  check.add_argument("--hook-format", choices=("text", "claude-hook"), default="text")
1159
- check.add_argument("--strict", action="store_true", help="有可更新项或脚手架差异时退出码为 1(CI 用)")
1160
- check.add_argument("--show-skipped", action="store_true", help="列出被你跳过的项及理由")
1161
- check.add_argument("--reprompt", action="store_true", help="忽略已记录的决定,重新评估全部脚手架差异")
1206
+ check.add_argument("--strict", action="store_true", help=_("有可更新项或脚手架差异时退出码为 1(CI 用)"))
1207
+ check.add_argument("--show-skipped", action="store_true", help=_("列出被你跳过的项及理由"))
1208
+ check.add_argument("--reprompt", action="store_true", help=_("忽略已记录的决定,重新评估全部脚手架差异"))
1162
1209
  check.add_argument("--fingerprint", action="store_true",
1163
- help="输出脚手架指纹(骨架状态 + 受检 Profile 字段 + 内容锁 + 命名规则;不含 Opinion、自有字段、已跳过项)")
1164
- check.add_argument("--explain", nargs="?", const="-", metavar="对方的 JSON",
1165
- help="与 --fingerprint 合用:不带参数时逐项列出这台的明细;带另一台机器 check --fingerprint "
1166
- "--output json 的输出文件时,指出两台差在哪里(有差异退出码 1)")
1210
+ help=_("输出脚手架指纹(骨架状态 + 受检 Profile 字段 + 内容锁 + 命名规则;不含 Opinion、自有字段、已跳过项)"))
1211
+ check.add_argument("--explain", nargs="?", const="-", metavar=_("对方的 JSON"),
1212
+ help=_("与 --fingerprint 合用:不带参数时逐项列出这台的明细;带另一台机器 check --fingerprint "
1213
+ "--output json 的输出文件时,指出两台差在哪里(有差异退出码 1)"))
1167
1214
 
1168
- install = sub.add_parser("install", help="经各客户端的原生市场安装 agenthelm 插件(默认预览)")
1215
+ install = sub.add_parser("install", help=_("经各客户端的原生市场安装 agenthelm 插件(默认预览)"))
1169
1216
  common(install, project=False)
1170
- install.add_argument("--client", action="append", choices=CLIENTS, help="默认安装到检测到的全部客户端")
1171
- install.add_argument("--plugin", action="append", help="默认安装全部插件")
1172
- install.add_argument("--source", type=Path, help="Cursor 安装用的本地仓库(默认下载发布包)")
1173
- install.add_argument("--marketplace-source", help="市场来源(默认 latest.json 的 GitHub 仓库;可用镜像或本地路径)")
1217
+ install.add_argument("--client", action="append", choices=CLIENTS, help=_("默认安装到检测到的全部客户端"))
1218
+ install.add_argument("--plugin", action="append", help=_("默认安装全部插件"))
1219
+ install.add_argument("--source", type=Path, help=_("Cursor 安装用的本地仓库(默认下载发布包)"))
1220
+ install.add_argument("--marketplace-source", help=_("市场来源(默认 latest.json 的 GitHub 仓库;可用镜像或本地路径)"))
1174
1221
  install.add_argument("--apply", action="store_true")
1175
1222
 
1176
- update = sub.add_parser("update", help="更新落后的插件与模板/样例(默认预览,确认后 --apply)")
1223
+ update = sub.add_parser("update", help=_("更新落后的插件与模板/样例(默认预览,确认后 --apply)"))
1177
1224
  common(update)
1178
1225
  update.add_argument("--only", choices=("plugins", "content"))
1179
- update.add_argument("--accept", action="append", default=[], help="内容更新:用新模板替换这个本地已改动的文件")
1180
- update.add_argument("--keep", action="append", default=[], help="内容更新:保留这个文件的本地内容")
1181
- update.add_argument("--recovery-dir", type=Path, help="被替换文件的备份目录(项目外)")
1182
- update.add_argument("--source", type=Path, help="Cursor 更新用的本地仓库(默认下载发布包)")
1226
+ update.add_argument("--accept", action="append", default=[], help=_("内容更新:用新模板替换这个本地已改动的文件"))
1227
+ update.add_argument("--keep", action="append", default=[], help=_("内容更新:保留这个文件的本地内容"))
1228
+ update.add_argument("--recovery-dir", type=Path, help=_("被替换文件的备份目录(项目外)"))
1229
+ update.add_argument("--source", type=Path, help=_("Cursor 更新用的本地仓库(默认下载发布包)"))
1183
1230
  update.add_argument("--apply", action="store_true")
1184
1231
 
1185
- migrate = sub.add_parser("migrate", help="把旧方式安装(agent-pack 复制 + 软链)迁到市场安装(默认预览)")
1232
+ migrate = sub.add_parser("migrate", help=_("把旧方式安装(agent-pack 复制 + 软链)迁到市场安装(默认预览)"))
1186
1233
  common(migrate, project=False)
1187
- migrate.add_argument("--install", action="store_true", help="先经市场安装到检测到的客户端")
1234
+ migrate.add_argument("--install", action="store_true", help=_("先经市场安装到检测到的客户端"))
1188
1235
  migrate.add_argument("--client", action="append", choices=CLIENTS)
1189
1236
  migrate.add_argument("--source", type=Path)
1190
1237
  migrate.add_argument("--marketplace-source")
1191
1238
  migrate.add_argument("--apply", action="store_true")
1192
1239
 
1193
- doctor_parser = sub.add_parser("doctor", help="报告重复安装、版本不一致、旧方式残留与检查开关")
1240
+ doctor_parser = sub.add_parser("doctor", help=_("报告重复安装、版本不一致、旧方式残留与检查开关"))
1194
1241
  common(doctor_parser)
1195
1242
 
1196
1243
  sub.add_parser("fix", add_help=False,
1197
- help="修复项目脚手架差异与更新(等同 project fix;默认预览,--apply 写入;--skip/--unskip 记录决定)")
1244
+ help=_("修复项目脚手架差异与更新(等同 project fix;默认预览,--apply 写入;--skip/--unskip 记录决定)"))
1245
+ sub.add_parser("local-only", add_help=False,
1246
+ help=_("只属于这台机器的目录(.venv、node_modules、缓存)不进云同步盘:check / fix / config(详见 local-only --help)"))
1198
1247
  sub.add_parser("runtime", add_help=False,
1199
- help="用户级运行时:install / update / use / status(日常只需要 Python,不经过 npx)")
1248
+ help=_("用户级运行时:install / update / use / status(日常只需要 Python,不经过 npx)"))
1200
1249
  sub.add_parser("project", add_help=False,
1201
- help="项目自助入口:找资料、看进展、项目指南、检查(不需要 AI Agent;详见 project --help)")
1202
- locate_parser = sub.add_parser("locate", help="输出插件的已安装位置(最新优先),供下游脚本查找")
1250
+ help=_("项目自助入口:找资料、看进展、项目指南、检查(不需要 AI Agent;详见 project --help)"))
1251
+ locate_parser = sub.add_parser("locate", help=_("输出插件的已安装位置(最新优先),供下游脚本查找"))
1203
1252
  locate_parser.add_argument("plugin")
1204
- locate_parser.add_argument("--version", help="只要这个版本")
1253
+ locate_parser.add_argument("--version", help=_("只要这个版本"))
1205
1254
  locate_parser.add_argument("--include-legacy", action="store_true",
1206
- help="也列出旧方式安装(默认不列,避免清理后才失效)")
1255
+ help=_("也列出旧方式安装(默认不列,避免清理后才失效)"))
1207
1256
  locate_parser.add_argument("--output", choices=("text", "json"), default="json")
1208
1257
  locate_parser.add_argument("--home", type=Path, help=argparse.SUPPRESS)
1209
1258
  return result
1210
1259
 
1211
1260
 
1212
1261
  def _print_steps(results: list[dict], apply: bool) -> None:
1213
- labels = {"preview": "预览", "applied": "完成", "blocked": "阻塞", "failed": "失败"}
1262
+ labels = {"preview": _("预览"), "applied": _("完成"), "blocked": _("阻塞"), "failed": _("失败")}
1214
1263
  for record in results:
1215
1264
  line = f" [{labels.get(record['status'], record['status'])}] {record['description']}"
1216
1265
  if record.get("command"):
@@ -1227,7 +1276,7 @@ def _print_steps(results: list[dict], apply: bool) -> None:
1227
1276
  line += f"\n {result['hint']}"
1228
1277
  print(line)
1229
1278
  if not apply and results:
1230
- print("以上为预览;确认后追加 --apply 执行。")
1279
+ print(_("以上为预览;确认后追加 --apply 执行。"))
1231
1280
 
1232
1281
 
1233
1282
  def run_project(argv: list[str]) -> int:
@@ -1238,9 +1287,8 @@ def run_project(argv: list[str]) -> int:
1238
1287
  usable = [path for path in copies if (path / "runtime" / "project_cli.py").is_file()]
1239
1288
  if not usable:
1240
1289
  stale = any((path / "plugin.json").is_file() for path in copies)
1241
- found = "(已安装的 project-orchestrator 版本过旧)" if stale else ""
1242
- print(f"agenthelm project 需要 project-orchestrator 的项目入口,但没有找到{found}。\n"
1243
- f" · 更新插件:{CLI} update,或从发布包安装运行时(见 INSTALL.md)", file=sys.stderr)
1290
+ found = _("(已安装的 project-orchestrator 版本过旧)") if stale else ""
1291
+ print(_("agenthelm project 需要 project-orchestrator 的项目入口,但没有找到{0}。\n · 更新插件:{1} update,或从发布包安装运行时(见 INSTALL.md)").format(found, CLI), file=sys.stderr)
1244
1292
  return 3
1245
1293
  # Prefer the copy shipped next to this agent-pack (same release), then the newest installed one.
1246
1294
  sibling = PLUGIN_ROOT.parent / "project-orchestrator"
@@ -1253,6 +1301,19 @@ def run_project(argv: list[str]) -> int:
1253
1301
  return project_cli.main(argv)
1254
1302
 
1255
1303
 
1304
+ def run_nudge(argv: list[str]) -> int:
1305
+ """``agenthelm nudge``: hooks that collect opinion candidates and surface unconfirmed items."""
1306
+ sys.path.insert(0, str(RUNTIME_DIR))
1307
+ import agent_pack_nudge # noqa: PLC0415 - shipped next to this file
1308
+
1309
+ return agent_pack_nudge.main(argv, sys.modules[__name__])
1310
+
1311
+
1312
+ def run_local_only(argv: list[str]) -> int:
1313
+ """``agenthelm local-only``: keep rebuildable directories out of cloud-synced folders."""
1314
+ return _local_only().main(argv, sys.modules[__name__])
1315
+
1316
+
1256
1317
  def run_runtime(argv: list[str]) -> int:
1257
1318
  """``agenthelm runtime``: the user-level runtime that project launchers use without any AI client."""
1258
1319
  sys.path.insert(0, str(RUNTIME_DIR))
@@ -1261,35 +1322,51 @@ def run_runtime(argv: list[str]) -> int:
1261
1322
  return agenthelm_runtime.main(argv, sys.modules[__name__])
1262
1323
 
1263
1324
 
1264
- def print_fingerprint(env: Env, report: dict, args: argparse.Namespace) -> int:
1325
+ def print_fingerprint(env: Env, report: dict, args: argparse.Namespace, project: Path | None = None) -> int:
1265
1326
  """``check --fingerprint [--explain [file]]``: the scaffold fingerprint, and where two machines differ."""
1266
1327
  result = scaffold_fingerprint(env, report.get("scaffold"))
1267
1328
  if result is None:
1268
- reason = (report.get("scaffold") or {}).get("error") or "当前目录不在 agenthelm 项目里"
1329
+ reason = (report.get("scaffold") or {}).get("error") or _("当前目录不在 agenthelm 项目里")
1269
1330
  print(json.dumps({"ok": False, "error": reason}, ensure_ascii=False) if args.output == "json"
1270
- else f"无法计算脚手架指纹:{reason}", file=sys.stderr if args.output != "json" else sys.stdout)
1331
+ else _("无法计算脚手架指纹:{0}").format(reason), file=sys.stderr if args.output != "json" else sys.stdout)
1271
1332
  return 4
1272
1333
  payload: dict = {"ok": True, "fingerprint": result["fingerprint"], "detail": result["detail"]}
1273
- lines = [f"脚手架指纹 {result['fingerprint']}"]
1334
+ lines = [_("脚手架指纹 {0}").format(result['fingerprint'])]
1335
+ mine_local: dict = {}
1336
+ if project is not None:
1337
+ # 本机环境(外置链接、HOME)单独成段:没有 local-only 配置的项目,输出与以前完全一样。
1338
+ mine_local = local_only_report(env, project, mode="quick")["fingerprint"]
1339
+ if mine_local["detail"]:
1340
+ payload["local_only"] = mine_local
1341
+ lines.append(_("本机环境指纹 {0}").format(mine_local['fingerprint']))
1274
1342
  differences: list[str] = []
1275
1343
  if args.explain and args.explain != "-":
1276
1344
  try:
1277
1345
  other = read_json(Path(args.explain).expanduser())
1278
1346
  except (OSError, ValueError) as exc:
1279
- print(f"读不了对方的输出:{exc}", file=sys.stderr)
1347
+ print(_("读不了对方的输出:{0}").format(exc), file=sys.stderr)
1280
1348
  return 2
1281
1349
  theirs = other.get("detail") or (other.get("scaffold_fingerprint") or {}).get("detail") or other.get("fingerprint", {}).get("detail")
1282
1350
  if not isinstance(theirs, dict):
1283
- print("对方的文件里没有指纹明细:请在对方机器运行 agenthelm check --fingerprint --output json 并保存输出", file=sys.stderr)
1351
+ print(_("对方的文件里没有指纹明细:请在对方机器运行 agenthelm check --fingerprint --output json 并保存输出"), file=sys.stderr)
1284
1352
  return 2
1285
1353
  module = scaffold_module(env)
1286
1354
  differences = module.explain(result["detail"], theirs)
1355
+ their_local = (other.get("local_only") or {}).get("detail") or {}
1356
+ if mine_local.get("detail") or their_local:
1357
+ if not their_local:
1358
+ differences.append(_("本机环境:对方没有 local-only 明细(对方的 agent-pack 较旧或项目还没配置)"))
1359
+ elif not mine_local.get("detail"):
1360
+ differences.append(_("本机环境:这台没有 local-only 配置,对方有"))
1361
+ else:
1362
+ differences += _local_only().explain(mine_local["detail"], their_local)
1287
1363
  payload["same"] = not differences
1288
1364
  payload["differences"] = differences
1289
- lines.append("两台机器的脚手架一致。" if not differences else f"有 {len(differences)} 处不同:")
1365
+ lines.append(_("两台机器的脚手架一致。") if not differences else _("有 {0} 处不同:").format(len(differences)))
1290
1366
  lines += [f" {item}" for item in differences]
1291
1367
  elif args.explain:
1292
1368
  lines += [f" {key} = {value}" for key, value in result["detail"].items()]
1369
+ lines += [f" local-only {key} = {value}" for key, value in mine_local.get("detail", {}).items()]
1293
1370
  if args.output == "json":
1294
1371
  print(json.dumps(payload, ensure_ascii=False, indent=2))
1295
1372
  else:
@@ -1305,6 +1382,10 @@ def main(argv: list[str] | None = None) -> int:
1305
1382
  return run_project(["fix", *argv[1:]])
1306
1383
  if argv[:1] == ["runtime"]:
1307
1384
  return run_runtime(argv[1:])
1385
+ if argv[:1] == ["nudge"]:
1386
+ return run_nudge(argv[1:])
1387
+ if argv[:1] == ["local-only"]:
1388
+ return run_local_only(argv[1:])
1308
1389
  args = parser().parse_args(argv)
1309
1390
  env = current_env(args.home)
1310
1391
  home = env.home
@@ -1326,7 +1407,7 @@ def main(argv: list[str] | None = None) -> int:
1326
1407
  max_age=0 if args.refresh else CACHE_TTL_SECONDS, reprompt=args.reprompt)
1327
1408
  report.pop("_latest")
1328
1409
  if args.fingerprint or args.explain:
1329
- return print_fingerprint(env, report, args)
1410
+ return print_fingerprint(env, report, args, project)
1330
1411
  if args.output == "json":
1331
1412
  print(json.dumps(report, ensure_ascii=False, indent=2))
1332
1413
  else:
@@ -1341,9 +1422,9 @@ def main(argv: list[str] | None = None) -> int:
1341
1422
  print(f" [{issue['issue']}] {issue.get('plugin') or ''} {issue['detail']}".rstrip())
1342
1423
  for item in result["legacy"]:
1343
1424
  print(f" [legacy] {item['kind']} {item['name']}: {item['path']}")
1344
- print("没有发现问题。" if result["ok"] and not result["legacy"] else "")
1425
+ print(_("没有发现问题。") if result["ok"] and not result["legacy"] else "")
1345
1426
  return 0 if result["ok"] else 1
1346
- latest, _ = load_latest(env, args.latest, max_age=0)
1427
+ latest, _origin = load_latest(env, args.latest, max_age=0)
1347
1428
  if args.command == "install":
1348
1429
  clients = args.client or detect_clients(env)
1349
1430
  plugins = args.plugin or sorted(latest.get("plugins", {}))
@@ -1364,7 +1445,7 @@ def main(argv: list[str] | None = None) -> int:
1364
1445
  payload = {"ok": all(r["status"] != "failed" for r in results), "applied": args.apply,
1365
1446
  "steps": results, "summary": report["summary"]}
1366
1447
  if not steps:
1367
- payload["message"] = "没有需要更新的内容。"
1448
+ payload["message"] = _("没有需要更新的内容。")
1368
1449
  else: # migrate
1369
1450
  names = set(latest.get("plugins", {}))
1370
1451
  artifacts = legacy_artifacts(env, names)
@@ -1391,9 +1472,9 @@ def main(argv: list[str] | None = None) -> int:
1391
1472
  print(payload["message"])
1392
1473
  _print_steps(payload["steps"], args.apply)
1393
1474
  for item in payload.get("legacy", []):
1394
- print(f" [旧安装] {item['kind']} {item['name']}: {item['path']}" + ("(需手动处理)" if item.get("manual") else ""))
1475
+ print(_(" [旧安装] {0} {1}: {2}").format(item['kind'], item['name'], item['path']) + (_("(需手动处理)") if item.get("manual") else ""))
1395
1476
  if payload.get("recovery"):
1396
- print(f"旧安装已移入:{payload['recovery']}(含 migration.json 清单,可按清单恢复)")
1477
+ print(_("旧安装已移入:{0}(含 migration.json 清单,可按清单恢复)").format(payload['recovery']))
1397
1478
  return 0 if payload["ok"] else 1
1398
1479
 
1399
1480