@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
@@ -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"
@@ -73,6 +75,10 @@ def write_json_atomic(path: Path, data: Any) -> None:
73
75
  os.replace(temporary, path)
74
76
 
75
77
 
78
+ def sha256_text(value: str) -> str:
79
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
80
+
81
+
76
82
  def sha256_file(path: Path) -> str:
77
83
  return hashlib.sha256(path.read_bytes()).hexdigest()
78
84
 
@@ -99,6 +105,11 @@ class Env:
99
105
  base = self.get("XDG_CACHE_HOME")
100
106
  return (Path(base) if base else self.home / ".cache") / "agenthelm"
101
107
 
108
+ @property
109
+ def state_dir(self) -> Path:
110
+ base = self.get("XDG_STATE_HOME")
111
+ return Path(base) if base else self.home / ".local" / "state"
112
+
102
113
  @property
103
114
  def config_file(self) -> Path:
104
115
  base = self.get("XDG_CONFIG_HOME")
@@ -165,14 +176,14 @@ def load_latest(env: Env, source: str | None = None, *, offline: bool = False,
165
176
  if cached and (offline or time.time() - cached.get("fetched_at", 0) < max_age):
166
177
  return cached["latest"], {"source": cached.get("url"), "cached": True, "fetched_at": cached.get("fetched_at")}
167
178
  if offline:
168
- raise CheckError("离线且没有缓存的 latest.json")
179
+ raise CheckError(_("离线且没有缓存的 latest.json"))
169
180
  failure_file = env.cache_dir / "last_failure.json"
170
181
  if backoff:
171
182
  try:
172
183
  if time.time() - read_json(failure_file).get("at", 0) < FAILURE_BACKOFF_SECONDS:
173
184
  if cached:
174
185
  return cached["latest"], {"source": cached.get("url"), "cached": True, "stale": True}
175
- raise CheckError("最近联网失败,稍后再试")
186
+ raise CheckError(_("最近联网失败,稍后再试"))
176
187
  except (OSError, ValueError, AttributeError):
177
188
  pass
178
189
  errors = []
@@ -194,7 +205,7 @@ def load_latest(env: Env, source: str | None = None, *, offline: bool = False,
194
205
  if cached:
195
206
  return cached["latest"], {"source": cached.get("url"), "cached": True, "stale": True,
196
207
  "fetched_at": cached.get("fetched_at")}
197
- raise CheckError("无法获取 latest.json:" + "; ".join(errors))
208
+ raise CheckError(_("无法获取 latest.json:") + "; ".join(errors))
198
209
 
199
210
 
200
211
  # ---------------------------------------------------------------- installed plugins
@@ -213,7 +224,7 @@ def installed_plugins(env: Env, names: set[str]) -> list[dict]:
213
224
  except (OSError, ValueError, AttributeError):
214
225
  record = {}
215
226
  for key, entries in sorted(record.items()):
216
- plugin, _, market = key.partition("@")
227
+ plugin, _sep, market = key.partition("@")
217
228
  if market != MARKETPLACE or plugin not in names:
218
229
  continue
219
230
  for entry in entries:
@@ -241,14 +252,18 @@ def installed_plugins(env: Env, names: set[str]) -> list[dict]:
241
252
 
242
253
 
243
254
  def migrations_between(latest: dict, plugin: str, current: str, target: str) -> list[dict]:
244
- 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] = {}
245
258
  for entry in latest.get("migrations", []):
246
259
  if entry.get("plugin") != plugin:
247
260
  continue
248
261
  to = str(entry.get("to"))
249
262
  if is_newer(to, current) and not is_newer(to, target):
250
- steps.append(entry)
251
- 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,))
252
267
 
253
268
 
254
269
  def plugin_findings(latest: dict, installed: list[dict]) -> list[dict]:
@@ -292,7 +307,7 @@ def content_findings(project: Path, latest: dict) -> list[dict]:
292
307
  try:
293
308
  lock = read_json(lock_path)
294
309
  except (OSError, ValueError) as exc:
295
- return [{"pack": None, "status": "error", "error": f"{CONTENT_LOCK} 无法解析:{exc}"}]
310
+ return [{"pack": None, "status": "error", "error": _("{0} 无法解析:{1}").format(CONTENT_LOCK, exc)}]
296
311
  findings = []
297
312
  for pack_id, entry in sorted(lock.get("packs", {}).items()):
298
313
  plugin = entry.get("plugin") or pack_id.split("/", 1)[0]
@@ -333,11 +348,11 @@ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
333
348
  return []
334
349
  manager = locate_companion("opinion-manager", installed)
335
350
  if manager is None:
336
- return [{"status": "unknown", "error": "未找到已安装的 opinion-manager"}]
351
+ return [{"status": "unknown", "error": _("未找到已安装的 opinion-manager")}]
337
352
  try:
338
353
  profile = read_json(lock_path)["profile"]
339
354
  except (OSError, ValueError, KeyError, TypeError) as exc:
340
- return [{"status": "error", "error": f"{OPINION_LOCK} 无法解析:{exc}"}]
355
+ return [{"status": "error", "error": _("{0} 无法解析:{1}").format(OPINION_LOCK, exc)}]
341
356
  with tempfile.TemporaryDirectory() as temporary:
342
357
  # macOS temporary paths live under /var, a symlink opinion-manager refuses.
343
358
  profile_file = Path(temporary).resolve() / "profile.json"
@@ -352,14 +367,174 @@ def opinion_findings(project: Path, installed: list[dict]) -> list[dict]:
352
367
  except ValueError:
353
368
  return [{"status": "error", "error": (completed.stderr or completed.stdout).strip()[:300]}]
354
369
  if not result.get("ok", False):
355
- return [{"status": "error", "error": result.get("error", "opinion-manager updates 失败")}]
370
+ return [{"status": "error", "error": result.get("error", _("opinion-manager updates 失败"))}]
356
371
  return [{"status": "update-available" if result["updates"] else "current", "updates": result["updates"]}]
357
372
 
358
373
 
374
+ # ---------------------------------------------------------------- project scaffold
375
+
376
+ PROFILE_FILE = Path(".agents/moe.sakanano.agent-pack/project.json")
377
+ _SCAFFOLD_CACHE: dict[str, Any] = {}
378
+
379
+
380
+ def profile_root(project: Path | None) -> Path | None:
381
+ """The project root that holds the Project Profile, searching upwards from ``project``."""
382
+ if project is None:
383
+ return None
384
+ base = project.expanduser().resolve()
385
+ return next((path for path in [base, *base.parents] if (path / PROFILE_FILE).is_file()), None)
386
+
387
+
388
+ def scaffold_module(env: Env) -> Any | None:
389
+ """agents-init's scaffold module (the sibling copy first, then the newest installed one); None when too old."""
390
+ installed = installed_plugins(env, {"agents-init"})
391
+ copies = [PLUGIN_ROOT.parent / "agents-init", *(Path(item["path"]) for item in installed)]
392
+ usable = [path for path in copies if (path / "skills/agents-init/scripts/scaffold.py").is_file()]
393
+ if not usable:
394
+ return None
395
+ sibling = PLUGIN_ROOT.parent / "agents-init"
396
+ root = sibling if sibling in usable else max(usable, key=lambda p: version_key(_manifest_version(p) or "") or (0,))
397
+ scripts = root / "skills/agents-init/scripts"
398
+ key = str(scripts)
399
+ if key not in _SCAFFOLD_CACHE:
400
+ import importlib.util
401
+ spec = importlib.util.spec_from_file_location(f"agenthelm_scaffold_{len(_SCAFFOLD_CACHE)}", scripts / "scaffold.py")
402
+ module = importlib.util.module_from_spec(spec)
403
+ sys.modules[spec.name] = module
404
+ spec.loader.exec_module(module) # type: ignore[union-attr]
405
+ _SCAFFOLD_CACHE[key] = module
406
+ return _SCAFFOLD_CACHE[key]
407
+
408
+
409
+ def scaffold_report(env: Env, project: Path | None, *, reprompt: bool = False) -> dict | None:
410
+ """Read-only comparison of the project with the expected scaffold; None outside a project."""
411
+ root = profile_root(project)
412
+ if root is None:
413
+ return None
414
+ base = {"available": False, "root": str(root), "findings": [], "skipped": [], "error": ""}
415
+ module = scaffold_module(env)
416
+ if module is None:
417
+ return {**base, "error": _("已安装的 agents-init 没有脚手架检查(更新 agents-init 后再检查)")}
418
+ try:
419
+ return {**module.check(root, state_dir=env.state_dir, reprompt=reprompt), "available": True,
420
+ "root": str(root), "error": ""}
421
+ except Exception as exc: # noqa: BLE001 - a broken Profile must not break plugin and content checks
422
+ return {**base, "error": str(exc)}
423
+
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)
435
+ REBUILDABLE_DIRS = {"node_modules", ".venv", "venv", "__pycache__", ".pytest_cache", ".cache", "dist", "build", "target",
436
+ ".tox", ".next", ".gradle"}
437
+
438
+
439
+ def sync_folder(root: Path) -> dict | None:
440
+ """The sync service whose folder holds the project (by path), and the rebuildable directories inside it."""
441
+ resolved = root.resolve()
442
+ service = next((name for name, pattern in SYNC_SERVICES if any(pattern.match(part) for part in resolved.parts)), None)
443
+ if service is None:
444
+ return None
445
+ found: list[str] = []
446
+ for current, directories, _files in os.walk(resolved):
447
+ depth = len(Path(current).relative_to(resolved).parts)
448
+ descend = []
449
+ for name in directories:
450
+ if name in REBUILDABLE_DIRS:
451
+ found.append((Path(current) / name).relative_to(resolved).as_posix())
452
+ elif name != ".git" and depth < 1: # 只看根目录和它的下一层
453
+ descend.append(name)
454
+ directories[:] = descend
455
+ return {"service": service, "path": str(resolved), "rebuildable": sorted(found)}
456
+
457
+
458
+ def machine_findings(env: Env, project: Path | None, *, reprompt: bool = False, quick: bool = False) -> dict:
459
+ """Findings about this machine only (no user-level runtime ...); their decisions live in the user state directory."""
460
+ root = profile_root(project)
461
+ result: dict = {"findings": [], "skipped": []}
462
+ if root is None:
463
+ return result
464
+ import agenthelm_runtime # noqa: PLC0415 - shipped next to this file
465
+
466
+ findings = []
467
+ data = agenthelm_runtime.report(env, None)
468
+ if data["current"] is None:
469
+ text = agenthelm_runtime.missing_text(data, agenthelm_runtime.command_status())
470
+ findings.append({"id": "local:runtime-missing", "kind": "runtime-missing", "level": "suggest", "scope": "machine",
471
+ "title": _("这台机器还没有安装用户级运行时"), "reason": text["reason"], "impact": text["impact"],
472
+ "fingerprint": "runtime-missing", "fix": "install-runtime", "next": text["label"]})
473
+ where = sync_folder(root)
474
+ if where is not None:
475
+ extra = _(";其中有可重建目录:{0}").format(_('、').join(where['rebuildable'][:6])) if where["rebuildable"] else ""
476
+ findings.append({"id": "local:sync-folder", "kind": "sync-folder", "level": "suggest", "scope": "machine",
477
+ "title": _("项目位于同步盘({0})").format(where['service']),
478
+ "reason": f"{where['path']}{extra}",
479
+ "impact": _("多台机器共用同一份项目时:项目文件随同步盘走,但运行时、缓存与 wf 的台账是每台机器各一份;"
480
+ "可重建目录(依赖、构建产物)同步很慢,也容易产生冲突副本"),
481
+ "fingerprint": sha256_text(where["service"]), "fix": "sync-folder",
482
+ "next": _("运行 agenthelm fix sync-folder 查看指引(只给方案,不改文件、不删内容);不需要就 fix --skip local:sync-folder"),
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 查看详情")})
494
+ module = scaffold_module(env)
495
+ if module is None or not findings:
496
+ result["findings"] = findings
497
+ return result
498
+ decisions = module.load_decisions(root, env.state_dir)
499
+ result["findings"], result["skipped"] = module.partition(findings, decisions, reprompt=reprompt)
500
+ return result
501
+
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
+
359
527
  # ---------------------------------------------------------------- report
360
528
 
529
+ def scaffold_fingerprint(env: Env, scaffold: dict | None) -> dict | None:
530
+ module = scaffold_module(env)
531
+ if scaffold is None or module is None or not scaffold.get("available"):
532
+ return None
533
+ return module.fingerprint(scaffold)
534
+
535
+
361
536
  def run_check(env: Env, project: Path | None, *, latest_source: str | None = None, offline: bool = False,
362
- max_age: int = CACHE_TTL_SECONDS, hook_mode: bool = False) -> dict:
537
+ max_age: int = CACHE_TTL_SECONDS, hook_mode: bool = False, reprompt: bool = False) -> dict:
363
538
  latest, origin = load_latest(
364
539
  env, latest_source, offline=offline, max_age=max_age,
365
540
  timeout=HOOK_FETCH_TIMEOUT_SECONDS if hook_mode else FETCH_TIMEOUT_SECONDS, backoff=hook_mode,
@@ -369,6 +544,10 @@ def run_check(env: Env, project: Path | None, *, latest_source: str | None = Non
369
544
  plugins = plugin_findings(latest, installed)
370
545
  content = content_findings(project, latest) if project else []
371
546
  opinion = opinion_findings(project, installed) if project else []
547
+ scaffold = scaffold_report(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"))
550
+ scaffold_active = len(scaffold["findings"]) if scaffold else 0
372
551
  behind = [item for item in plugins if item["status"] == "behind"]
373
552
  legacy = [item for item in plugins if item["client"] == "legacy"]
374
553
  content_updates = [item for item in content if item["status"] in {"update-available", "conflict"}]
@@ -380,14 +559,20 @@ def run_check(env: Env, project: Path | None, *, latest_source: str | None = Non
380
559
  "plugins": plugins,
381
560
  "content": content,
382
561
  "opinion": opinion,
562
+ "scaffold": scaffold,
563
+ "machine": machine,
383
564
  "summary": {
565
+ "scaffold_findings": scaffold_active,
566
+ "scaffold_skipped": len(scaffold["skipped"]) if scaffold else 0,
567
+ "local_only_attention": local_attention,
384
568
  "plugins_behind": len(behind),
385
569
  "breaking": any(item["breaking"] for item in behind),
386
570
  "content_updates": len(content_updates),
387
571
  "content_conflicts": sum(1 for item in content if item["status"] == "conflict"),
388
572
  "opinion_updates": len(opinion_updates),
389
573
  "legacy_installs": len(legacy),
390
- "up_to_date": not (behind or content_updates or opinion_updates or legacy),
574
+ "up_to_date": not (behind or content_updates or opinion_updates or legacy or scaffold_active
575
+ or local_attention),
391
576
  },
392
577
  }
393
578
 
@@ -406,66 +591,110 @@ def one_line(report: dict) -> str | None:
406
591
  return None
407
592
  parts = []
408
593
  if summary["plugins_behind"]:
409
- parts.append(f"{summary['plugins_behind']} 个插件可更新" + ("(含不兼容变更)" if summary["breaking"] else ""))
594
+ parts.append(_("{0} 个插件可更新").format(summary['plugins_behind']) + (_("(含不兼容变更)") if summary["breaking"] else ""))
410
595
  if summary["content_updates"]:
411
- parts.append(f"{summary['content_updates']} 个模板/样例包可更新"
412
- + (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 ""))
413
598
  if summary["opinion_updates"]:
414
- parts.append("Opinion 有新版本模板或规则")
599
+ parts.append(_("Opinion 有新版本模板或规则"))
415
600
  if summary["legacy_installs"]:
416
- parts.append(f"{summary['legacy_installs']} 个旧方式安装待迁移")
417
- return "agenthelm:" + ",".join(parts) + f"。运行 `{CLI} check` 查看详情;更新前会先预览,由你决定。"
601
+ parts.append(_("{0} 个旧方式安装待迁移").format(summary['legacy_installs']))
602
+ if summary["scaffold_findings"]:
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)
418
607
 
419
608
 
420
- def render_text(report: dict) -> str:
609
+ def render_scaffold(report: dict, show_skipped: bool) -> list[str]:
610
+ scaffold = report.get("scaffold")
611
+ lines = []
612
+ if scaffold is None:
613
+ return lines
614
+ if scaffold.get("error"):
615
+ lines.append(_(" [脚手架] 无法检查:{0}").format(scaffold['error']))
616
+ return lines
617
+ for item in scaffold["findings"]:
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))
621
+ for message in scaffold["decisions"]["errors"]:
622
+ lines.append(_(" [脚手架] 决定文件有问题:{0}").format(message))
623
+ if scaffold["decisions"]["conflict_copies"]:
624
+ lines.append(_(" [脚手架] 同步服务留下了决定文件的冲突副本:")
625
+ + _("、").join(scaffold["decisions"]["conflict_copies"]) + _("(合并到 check-decisions.json 后删除)"))
626
+ if not scaffold["findings"] and not scaffold["decisions"]["errors"]:
627
+ lines.append(_(" [脚手架] 与期望一致") + (_("({0} 项已跳过)").format(len(scaffold['skipped'])) if scaffold["skipped"] else ""))
628
+ skipped = [*scaffold["skipped"], *report.get("machine", {}).get("skipped", [])]
629
+ if show_skipped:
630
+ for item in skipped:
631
+ decision = item["decision"]
632
+ lines.append(_(" [已跳过·{0}] {1}:{2} {3}").format(decision['decision'], item['id'], decision.get('reason') or _('(未写理由)'), decision.get('decided_at', '')))
633
+ if not skipped:
634
+ lines.append(_(" 没有被跳过的项。"))
635
+ elif skipped:
636
+ lines.append(_(" (另有 {0} 项已被你跳过:check --show-skipped 查看)").format(len(skipped)))
637
+ return lines
638
+
639
+
640
+ def render_machine(report: dict) -> list[str]:
641
+ lines = []
642
+ for item in report.get("machine", {}).get("findings", []):
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']))
644
+ return lines
645
+
646
+
647
+ def render_text(report: dict, *, show_skipped: bool = False) -> str:
421
648
  lines = []
422
649
  origin = report["latest"]["source"]
423
- lines.append(f"云端版本源:{origin.get('source')}" + ("(缓存)" if origin.get("cached") else ""))
650
+ lines.append(_("云端版本源:{0}").format(origin.get('source')) + (_("(缓存)") if origin.get("cached") else ""))
424
651
  if not report["plugins"]:
425
- lines.append("未发现已安装的 agenthelm 插件。")
652
+ lines.append(_("未发现已安装的 agenthelm 插件。"))
426
653
  for item in report["plugins"]:
427
- label = {"behind": "可更新", "current": "最新", "ahead": "高于云端", "unknown": "版本未知",
428
- "legacy": "旧方式安装,待迁移"}[item["status"]]
654
+ label = {"behind": _("可更新"), "current": _("最新"), "ahead": _("高于云端"), "unknown": _("版本未知"),
655
+ "legacy": _("旧方式安装,待迁移")}[item["status"]]
429
656
  if item["status"] == "legacy":
430
- lines.append(f" [旧方式] {item['plugin']} {item.get('version')} {label}")
657
+ lines.append(_(" [旧方式] {0} {1} {2}").format(item['plugin'], item.get('version'), label))
431
658
  continue
432
659
  line = f" [{item['client']}] {item['plugin']} {item.get('version')} → {item.get('latest')} {label}"
433
660
  if item["status"] == "behind":
434
- kinds = {"code": "代码", "content": "仅模板/样例", "both": "代码与模板"}
661
+ kinds = {"code": _("代码"), "content": _("仅模板/样例"), "both": _("代码与模板")}
435
662
  if item["change_kinds"]:
436
- line += "(" + "、".join(kinds[kind] for kind in item["change_kinds"]) + ")"
663
+ line += _("(") + _("、").join(kinds[kind] for kind in item["change_kinds"]) + _(")")
437
664
  if item["breaking"]:
438
- line += "(含不兼容变更)"
665
+ line += _("(含不兼容变更)")
439
666
  for step in item["steps"]:
440
667
  if step.get("breaking"):
441
- line += f"\n {step['to']} 不兼容:{step.get('summary')}"
668
+ line += _("\n {0} 不兼容:{1}").format(step['to'], step.get('summary'))
442
669
  line += "".join(f"\n - {action}" for action in step.get("actions") or [])
443
670
  hint = UPDATE_HINTS.get(item["client"])
444
671
  if hint:
445
- line += f"\n 更新:{hint.format(plugin=item['plugin'])}"
672
+ line += _("\n 更新:{0}").format(hint.format(plugin=item['plugin']))
446
673
  lines.append(line)
447
674
  for item in report["content"]:
448
675
  if item.get("pack") is None:
449
- lines.append(f" 内容锁:{item.get('error')}")
676
+ lines.append(_(" 内容锁:{0}").format(item.get('error')))
450
677
  continue
451
- label = {"current": "最新", "update-available": "可更新(本地未改动)",
452
- "conflict": "可更新(本地有改动,更新时逐文件确认)", "unknown": "云端未列出"}[item["status"]]
453
- 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)
454
681
  if item["locally_changed"]:
455
- line += "\n 本地已改动:" + "、".join(item["locally_changed"])
682
+ line += _("\n 本地已改动:") + _("、").join(item["locally_changed"])
456
683
  lines.append(line)
457
684
  for item in report["opinion"]:
458
685
  if item["status"] == "update-available":
459
- refs = ";".join(f"{update['current']} → {update['available'][-1]}" for update in item["updates"])
460
- 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))
461
688
  elif item["status"] in {"error", "unknown"}:
462
689
  lines.append(f" [Opinion] {item.get('error')}")
690
+ lines += render_scaffold(report, show_skipped)
691
+ lines += render_machine(report)
463
692
  if not report["plugins"]:
464
- lines.append(f"运行 `{CLI} install` 预览安装。")
693
+ lines.append(_("运行 `{0} install` 预览安装。").format(CLI))
465
694
  else:
466
695
  if any(item["status"] == "legacy" for item in report["plugins"]):
467
- lines.append(f"旧方式安装可用 `{UPDATE_HINTS['legacy']}` 预览迁移。")
468
- lines.append(one_line(report) or "全部为最新。")
696
+ lines.append(_("旧方式安装可用 `{0}` 预览迁移。").format(UPDATE_HINTS['legacy']))
697
+ lines.append(one_line(report) or _("全部为最新。"))
469
698
  return "\n".join(lines) + "\n"
470
699
 
471
700
 
@@ -513,7 +742,7 @@ CLIENTS = ("claude", "codex", "cursor")
513
742
  def github_slug(latest: dict) -> str:
514
743
  match = re.match(r"https://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", str(latest.get("repository", "")))
515
744
  if not match:
516
- raise CheckError(f"latest.json 的 repository 不是 GitHub 仓库:{latest.get('repository')}")
745
+ raise CheckError(_("latest.json 的 repository 不是 GitHub 仓库:{0}").format(latest.get('repository')))
517
746
  return match.group(1)
518
747
 
519
748
 
@@ -533,9 +762,9 @@ def _safe_extract(archive: Path, destination: Path) -> None:
533
762
  for member in bundle.getmembers():
534
763
  target = (destination / member.name).resolve()
535
764
  if destination.resolve() not in target.parents and target != destination.resolve():
536
- raise CheckError(f"发布包含越界路径:{member.name}")
765
+ raise CheckError(_("发布包含越界路径:{0}").format(member.name))
537
766
  if not (member.isfile() or member.isdir()):
538
- raise CheckError(f"发布包含不支持的条目:{member.name}")
767
+ raise CheckError(_("发布包含不支持的条目:{0}").format(member.name))
539
768
  try:
540
769
  bundle.extractall(destination, filter="data") # noqa: S202 - members validated above
541
770
  except TypeError: # Python without extraction filters
@@ -566,25 +795,25 @@ def release_source(latest: dict, workdir: Path, source: Path | None = None) -> P
566
795
  if source is not None:
567
796
  root = source.expanduser().resolve()
568
797
  if not (root / "plugins").is_dir():
569
- raise CheckError(f"{root} 不含 plugins/ 目录")
798
+ raise CheckError(_("{0} 不含 plugins/ 目录").format(root))
570
799
  return root
571
800
  release = latest.get("release") or github_release(latest)
572
801
  tag, digest = release.get("tag"), release.get("tarball_sha256")
573
802
  if not tag or not digest:
574
- raise CheckError("云端 latest.json 没有发布 tag 与摘要;请用 --source 指定本地仓库")
803
+ raise CheckError(_("云端 latest.json 没有发布 tag 与摘要;请用 --source 指定本地仓库"))
575
804
  url = release.get("tarball_url") or f"https://github.com/{github_slug(latest)}/releases/download/{tag}/agenthelm-{tag}.tar.gz"
576
805
  archive = workdir / "release.tar.gz"
577
806
  request = urllib.request.Request(url, headers={"User-Agent": "agenthelm"})
578
807
  with urllib.request.urlopen(request, timeout=60) as response, archive.open("wb") as handle: # noqa: S310
579
808
  shutil.copyfileobj(response, handle)
580
809
  if sha256_file(archive) != digest:
581
- raise CheckError(f"发布包摘要不符:{url}")
810
+ raise CheckError(_("发布包摘要不符:{0}").format(url))
582
811
  extracted = workdir / "release"
583
812
  extracted.mkdir()
584
813
  _safe_extract(archive, extracted)
585
814
  roots = [path for path in extracted.iterdir() if path.is_dir()]
586
815
  if len(roots) != 1 or not (roots[0] / "plugins").is_dir():
587
- raise CheckError("发布包结构不符合预期")
816
+ raise CheckError(_("发布包结构不符合预期"))
588
817
  return roots[0]
589
818
 
590
819
 
@@ -642,7 +871,7 @@ def find_cli(env: Env, name: str) -> str | None:
642
871
  def _cli(env: Env, name: str, *args: str) -> tuple[list[str], str | None]:
643
872
  """The command (using the resolved executable) and why it cannot run, if it cannot."""
644
873
  found = find_cli(env, name)
645
- 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()))
646
875
 
647
876
 
648
877
  def install_steps(env: Env, latest: dict, clients: list[str], plugins: list[str], source: Path | None,
@@ -652,18 +881,18 @@ def install_steps(env: Env, latest: dict, clients: list[str], plugins: list[str]
652
881
  for client in clients:
653
882
  if client == "claude":
654
883
  command, blocked = _cli(env, "claude", "plugin", "marketplace", "add", slug)
655
- 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))
656
885
  for plugin in plugins:
657
886
  command, blocked = _cli(env, "claude", "plugin", "install", f"{plugin}@{MARKETPLACE}")
658
- 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))
659
888
  elif client == "codex":
660
889
  command, blocked = _cli(env, "codex", "plugin", "marketplace", "add", slug)
661
- 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))
662
891
  for plugin in plugins:
663
892
  command, blocked = _cli(env, "codex", "plugin", "add", f"{plugin}@{MARKETPLACE}")
664
- steps.append(Step("install", "codex", f"Codex 安装 {plugin}", command, blocked=blocked))
893
+ steps.append(Step("install", "codex", _("Codex 安装 {0}").format(plugin), command, blocked=blocked))
665
894
  elif client == "cursor":
666
- steps.append(cursor_step(latest, plugins, source, home, "安装"))
895
+ steps.append(cursor_step(latest, plugins, source, home, _("安装")))
667
896
  return steps
668
897
 
669
898
 
@@ -675,7 +904,7 @@ def cursor_step(latest: dict, plugins: list[str], source: Path | None, home: Pat
675
904
  return [config.install_plugin(root / "plugins" / plugin, "cursor", home=home, apply=apply,
676
905
  replace=True, recovery_root=recovery_root)
677
906
  for plugin in plugins]
678
- 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)),
679
908
  action=run)
680
909
 
681
910
 
@@ -689,18 +918,18 @@ def update_steps(env: Env, report: dict, project: Path | None, source: Path | No
689
918
  claude = sorted({item["plugin"] for item in behind if item["client"] == "claude"})
690
919
  if claude:
691
920
  command, blocked = _cli(env, "claude", "plugin", "marketplace", "update", MARKETPLACE)
692
- steps.append(Step("update", "claude", "刷新 Claude Code 的 agenthelm 市场", command, blocked=blocked))
921
+ steps.append(Step("update", "claude", _("刷新 Claude Code 的 agenthelm 市场"), command, blocked=blocked))
693
922
  for item in [entry for entry in behind if entry["client"] == "claude"]:
694
923
  extra = ["--scope", item["scope"]] if item.get("scope") and item["scope"] != "user" else []
695
924
  command, blocked = _cli(env, "claude", "plugin", "update", f"{item['plugin']}@{MARKETPLACE}", *extra)
696
- 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']),
697
926
  command, blocked=blocked))
698
927
  if any(item["client"] == "codex" for item in behind):
699
928
  command, blocked = _cli(env, "codex", "plugin", "marketplace", "upgrade", MARKETPLACE)
700
- steps.append(Step("update", "codex", "Codex 升级 agenthelm 市场与已装插件", command, blocked=blocked))
929
+ steps.append(Step("update", "codex", _("Codex 升级 agenthelm 市场与已装插件"), command, blocked=blocked))
701
930
  cursor = sorted({item["plugin"] for item in behind if item["client"] == "cursor"})
702
931
  if cursor:
703
- steps.append(cursor_step(latest, cursor, source, home, "更新"))
932
+ steps.append(cursor_step(latest, cursor, source, home, _("更新")))
704
933
  if only in (None, "content") and project is not None:
705
934
  for item in report["content"]:
706
935
  if item.get("status") not in {"update-available", "conflict"}:
@@ -712,15 +941,15 @@ def update_steps(env: Env, report: dict, project: Path | None, source: Path | No
712
941
  def content_step(env: Env, item: dict, report: dict, project: Path, accept: list[str], keep: list[str],
713
942
  recovery_dir: Path | None) -> Step:
714
943
  if item["plugin"] != "agents-init":
715
- return Step("content", None, f"{item['pack']}:没有可用的刷新工具", blocked="unsupported")
944
+ return Step("content", None, _("{0}:没有可用的刷新工具").format(item['pack']), blocked="unsupported")
716
945
  stale = any(entry["plugin"] == "agents-init" and entry["status"] == "behind" for entry in report["plugins"])
717
- description = f"重新渲染 {item['pack']} {item['version']} → {item['latest']}"
946
+ description = _("重新渲染 {0} {1} → {2}").format(item['pack'], item['version'], item['latest'])
718
947
 
719
948
  def run(apply: bool, _recovery_root: Path | None) -> dict:
720
949
  installed = installed_plugins(env, {"agents-init"})
721
950
  manager = locate_companion("agents-init", installed)
722
951
  if manager is None:
723
- raise CheckError("未找到已安装的 agents-init")
952
+ raise CheckError(_("未找到已安装的 agents-init"))
724
953
  command = [sys.executable, str(manager / "skills/agents-init/scripts/init_project.py"),
725
954
  "--project", str(project), "--mode", "refresh", "--output", "json"]
726
955
  for path in accept:
@@ -738,7 +967,7 @@ def content_step(env: Env, item: dict, report: dict, project: Path, accept: list
738
967
  except ValueError as exc:
739
968
  raise CheckError((completed.stderr or completed.stdout).strip()[:300]) from exc
740
969
 
741
- blocked = "先更新 agents-init 插件,再用新模板预览" if stale else None
970
+ blocked = _("先更新 agents-init 插件,再用新模板预览") if stale else None
742
971
  return Step("content", None, description, action=run, blocked=blocked)
743
972
 
744
973
 
@@ -940,86 +1169,97 @@ def doctor(env: Env, report: dict) -> dict:
940
1169
  clients = [item["client"] for item in items]
941
1170
  if "legacy" in clients and any(client in {"claude", "codex"} for client in clients):
942
1171
  issues.append({"plugin": plugin, "issue": "duplicate",
943
- "detail": f"同时存在市场安装与旧方式安装,运行 {CLI} migrate"})
1172
+ "detail": _("同时存在市场安装与旧方式安装,运行 {0} migrate").format(CLI)})
944
1173
  versions = {item.get("version") for item in items if item["client"] != "legacy"}
945
1174
  if len(versions) > 1:
946
1175
  issues.append({"plugin": plugin, "issue": "version-skew",
947
- "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)})
948
1177
  names = set(report["_latest"].get("plugins", {}))
949
1178
  installed_names = {item["plugin"] for item in report["plugins"] if item["client"] != "legacy"}
950
1179
  if installed_names and installed_names != names:
951
1180
  issues.append({"plugin": None, "issue": "incomplete",
952
- "detail": "缺少:" + "、".join(sorted(names - installed_names))})
1181
+ "detail": _("缺少:") + _("、").join(sorted(names - installed_names))})
953
1182
  if not update_check_enabled(env):
954
- issues.append({"plugin": None, "issue": "update-check-disabled", "detail": "会话内更新检查已关闭"})
1183
+ issues.append({"plugin": None, "issue": "update-check-disabled", "detail": _("会话内更新检查已关闭")})
955
1184
  return {"ok": not [issue for issue in issues if issue["issue"] != "update-check-disabled"],
956
1185
  "issues": issues, "legacy": legacy_artifacts(env, names), "summary": report["summary"]}
957
1186
 
958
1187
 
959
1188
  def parser() -> argparse.ArgumentParser:
960
- result = argparse.ArgumentParser(prog="agenthelm", description=__doc__.split("\n\n")[0])
1189
+ result = argparse.ArgumentParser(prog="agenthelm", description=_("agenthelm:在 Claude Code、Codex、Cursor 间安装、检查与更新 agenthelm 插件。"))
961
1190
  sub = result.add_subparsers(dest="command", required=True)
962
1191
 
963
1192
  def common(command: argparse.ArgumentParser, *, project: bool = True) -> None:
964
- command.add_argument("--latest", help="latest.json 的 URL 或本地路径(测试、离线镜像)")
1193
+ command.add_argument("--latest", help=_("latest.json 的 URL 或本地路径(测试、离线镜像)"))
965
1194
  command.add_argument("--output", choices=("text", "json"), default="text")
966
1195
  command.add_argument("--home", type=Path, help=argparse.SUPPRESS)
967
1196
  if project:
968
- command.add_argument("--project", type=Path, default=None, help="项目目录;默认当前目录")
969
- 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=_("不检查项目内容"))
970
1199
 
971
- check = sub.add_parser("check", help="只读检查插件、模板/样例与 Opinion 是否落后于云端")
1200
+ check = sub.add_parser("check", help=_("只读检查插件、模板/样例与 Opinion 是否落后于云端"))
972
1201
  common(check)
973
- check.add_argument("--offline", action="store_true", help="只用缓存,不联网")
974
- check.add_argument("--refresh", action="store_true", help="忽略一天的缓存,立即联网")
975
- 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=_("会话开始时静默调用:有更新才输出一行"))
976
1205
  check.add_argument("--hook-format", choices=("text", "claude-hook"), default="text")
977
- check.add_argument("--strict", action="store_true", help="有可更新项时退出码为 1(CI 用)")
978
-
979
- install = sub.add_parser("install", help="经各客户端的原生市场安装 agenthelm 插件(默认预览)")
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=_("忽略已记录的决定,重新评估全部脚手架差异"))
1209
+ check.add_argument("--fingerprint", action="store_true",
1210
+ help=_("输出脚手架指纹(骨架状态 + 受检 Profile 字段 + 内容锁 + 命名规则;不含 Opinion、自有字段、已跳过项)"))
1211
+ check.add_argument("--explain", nargs="?", const="-", metavar=_("对方的 JSON"),
1212
+ help=_("与 --fingerprint 合用:不带参数时逐项列出这台的明细;带另一台机器 check --fingerprint "
1213
+ "--output json 的输出文件时,指出两台差在哪里(有差异退出码 1)"))
1214
+
1215
+ install = sub.add_parser("install", help=_("经各客户端的原生市场安装 agenthelm 插件(默认预览)"))
980
1216
  common(install, project=False)
981
- install.add_argument("--client", action="append", choices=CLIENTS, help="默认安装到检测到的全部客户端")
982
- install.add_argument("--plugin", action="append", help="默认安装全部插件")
983
- install.add_argument("--source", type=Path, help="Cursor 安装用的本地仓库(默认下载发布包)")
984
- 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 仓库;可用镜像或本地路径)"))
985
1221
  install.add_argument("--apply", action="store_true")
986
1222
 
987
- update = sub.add_parser("update", help="更新落后的插件与模板/样例(默认预览,确认后 --apply)")
1223
+ update = sub.add_parser("update", help=_("更新落后的插件与模板/样例(默认预览,确认后 --apply)"))
988
1224
  common(update)
989
1225
  update.add_argument("--only", choices=("plugins", "content"))
990
- update.add_argument("--accept", action="append", default=[], help="内容更新:用新模板替换这个本地已改动的文件")
991
- update.add_argument("--keep", action="append", default=[], help="内容更新:保留这个文件的本地内容")
992
- update.add_argument("--recovery-dir", type=Path, help="被替换文件的备份目录(项目外)")
993
- 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 更新用的本地仓库(默认下载发布包)"))
994
1230
  update.add_argument("--apply", action="store_true")
995
1231
 
996
- migrate = sub.add_parser("migrate", help="把旧方式安装(agent-pack 复制 + 软链)迁到市场安装(默认预览)")
1232
+ migrate = sub.add_parser("migrate", help=_("把旧方式安装(agent-pack 复制 + 软链)迁到市场安装(默认预览)"))
997
1233
  common(migrate, project=False)
998
- migrate.add_argument("--install", action="store_true", help="先经市场安装到检测到的客户端")
1234
+ migrate.add_argument("--install", action="store_true", help=_("先经市场安装到检测到的客户端"))
999
1235
  migrate.add_argument("--client", action="append", choices=CLIENTS)
1000
1236
  migrate.add_argument("--source", type=Path)
1001
1237
  migrate.add_argument("--marketplace-source")
1002
1238
  migrate.add_argument("--apply", action="store_true")
1003
1239
 
1004
- doctor_parser = sub.add_parser("doctor", help="报告重复安装、版本不一致、旧方式残留与检查开关")
1240
+ doctor_parser = sub.add_parser("doctor", help=_("报告重复安装、版本不一致、旧方式残留与检查开关"))
1005
1241
  common(doctor_parser)
1006
1242
 
1243
+ sub.add_parser("fix", add_help=False,
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)"))
1007
1247
  sub.add_parser("runtime", add_help=False,
1008
- help="用户级运行时:install / update / use / status(日常只需要 Python,不经过 npx)")
1248
+ help=_("用户级运行时:install / update / use / status(日常只需要 Python,不经过 npx)"))
1009
1249
  sub.add_parser("project", add_help=False,
1010
- help="项目自助入口:找资料、看进展、项目指南、检查(不需要 AI Agent;详见 project --help)")
1011
- locate_parser = sub.add_parser("locate", help="输出插件的已安装位置(最新优先),供下游脚本查找")
1250
+ help=_("项目自助入口:找资料、看进展、项目指南、检查(不需要 AI Agent;详见 project --help)"))
1251
+ locate_parser = sub.add_parser("locate", help=_("输出插件的已安装位置(最新优先),供下游脚本查找"))
1012
1252
  locate_parser.add_argument("plugin")
1013
- locate_parser.add_argument("--version", help="只要这个版本")
1253
+ locate_parser.add_argument("--version", help=_("只要这个版本"))
1014
1254
  locate_parser.add_argument("--include-legacy", action="store_true",
1015
- help="也列出旧方式安装(默认不列,避免清理后才失效)")
1255
+ help=_("也列出旧方式安装(默认不列,避免清理后才失效)"))
1016
1256
  locate_parser.add_argument("--output", choices=("text", "json"), default="json")
1017
1257
  locate_parser.add_argument("--home", type=Path, help=argparse.SUPPRESS)
1018
1258
  return result
1019
1259
 
1020
1260
 
1021
1261
  def _print_steps(results: list[dict], apply: bool) -> None:
1022
- labels = {"preview": "预览", "applied": "完成", "blocked": "阻塞", "failed": "失败"}
1262
+ labels = {"preview": _("预览"), "applied": _("完成"), "blocked": _("阻塞"), "failed": _("失败")}
1023
1263
  for record in results:
1024
1264
  line = f" [{labels.get(record['status'], record['status'])}] {record['description']}"
1025
1265
  if record.get("command"):
@@ -1036,7 +1276,7 @@ def _print_steps(results: list[dict], apply: bool) -> None:
1036
1276
  line += f"\n {result['hint']}"
1037
1277
  print(line)
1038
1278
  if not apply and results:
1039
- print("以上为预览;确认后追加 --apply 执行。")
1279
+ print(_("以上为预览;确认后追加 --apply 执行。"))
1040
1280
 
1041
1281
 
1042
1282
  def run_project(argv: list[str]) -> int:
@@ -1047,9 +1287,8 @@ def run_project(argv: list[str]) -> int:
1047
1287
  usable = [path for path in copies if (path / "runtime" / "project_cli.py").is_file()]
1048
1288
  if not usable:
1049
1289
  stale = any((path / "plugin.json").is_file() for path in copies)
1050
- found = "(已安装的 project-orchestrator 版本过旧)" if stale else ""
1051
- print(f"agenthelm project 需要 project-orchestrator 的项目入口,但没有找到{found}。\n"
1052
- 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)
1053
1292
  return 3
1054
1293
  # Prefer the copy shipped next to this agent-pack (same release), then the newest installed one.
1055
1294
  sibling = PLUGIN_ROOT.parent / "project-orchestrator"
@@ -1062,6 +1301,19 @@ def run_project(argv: list[str]) -> int:
1062
1301
  return project_cli.main(argv)
1063
1302
 
1064
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
+
1065
1317
  def run_runtime(argv: list[str]) -> int:
1066
1318
  """``agenthelm runtime``: the user-level runtime that project launchers use without any AI client."""
1067
1319
  sys.path.insert(0, str(RUNTIME_DIR))
@@ -1070,12 +1322,70 @@ def run_runtime(argv: list[str]) -> int:
1070
1322
  return agenthelm_runtime.main(argv, sys.modules[__name__])
1071
1323
 
1072
1324
 
1325
+ def print_fingerprint(env: Env, report: dict, args: argparse.Namespace, project: Path | None = None) -> int:
1326
+ """``check --fingerprint [--explain [file]]``: the scaffold fingerprint, and where two machines differ."""
1327
+ result = scaffold_fingerprint(env, report.get("scaffold"))
1328
+ if result is None:
1329
+ reason = (report.get("scaffold") or {}).get("error") or _("当前目录不在 agenthelm 项目里")
1330
+ print(json.dumps({"ok": False, "error": reason}, ensure_ascii=False) if args.output == "json"
1331
+ else _("无法计算脚手架指纹:{0}").format(reason), file=sys.stderr if args.output != "json" else sys.stdout)
1332
+ return 4
1333
+ payload: dict = {"ok": True, "fingerprint": result["fingerprint"], "detail": result["detail"]}
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']))
1342
+ differences: list[str] = []
1343
+ if args.explain and args.explain != "-":
1344
+ try:
1345
+ other = read_json(Path(args.explain).expanduser())
1346
+ except (OSError, ValueError) as exc:
1347
+ print(_("读不了对方的输出:{0}").format(exc), file=sys.stderr)
1348
+ return 2
1349
+ theirs = other.get("detail") or (other.get("scaffold_fingerprint") or {}).get("detail") or other.get("fingerprint", {}).get("detail")
1350
+ if not isinstance(theirs, dict):
1351
+ print(_("对方的文件里没有指纹明细:请在对方机器运行 agenthelm check --fingerprint --output json 并保存输出"), file=sys.stderr)
1352
+ return 2
1353
+ module = scaffold_module(env)
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)
1363
+ payload["same"] = not differences
1364
+ payload["differences"] = differences
1365
+ lines.append(_("两台机器的脚手架一致。") if not differences else _("有 {0} 处不同:").format(len(differences)))
1366
+ lines += [f" {item}" for item in differences]
1367
+ elif args.explain:
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()]
1370
+ if args.output == "json":
1371
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
1372
+ else:
1373
+ print("\n".join(lines))
1374
+ return 1 if differences else 0
1375
+
1376
+
1073
1377
  def main(argv: list[str] | None = None) -> int:
1074
1378
  argv = list(sys.argv[1:] if argv is None else argv)
1075
1379
  if argv[:1] == ["project"]:
1076
1380
  return run_project(argv[1:])
1381
+ if argv[:1] == ["fix"]:
1382
+ return run_project(["fix", *argv[1:]])
1077
1383
  if argv[:1] == ["runtime"]:
1078
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:])
1079
1389
  args = parser().parse_args(argv)
1080
1390
  env = current_env(args.home)
1081
1391
  home = env.home
@@ -1094,12 +1404,14 @@ def main(argv: list[str] | None = None) -> int:
1094
1404
  try:
1095
1405
  if args.command == "check":
1096
1406
  report = run_check(env, project, latest_source=args.latest, offline=args.offline,
1097
- max_age=0 if args.refresh else CACHE_TTL_SECONDS)
1407
+ max_age=0 if args.refresh else CACHE_TTL_SECONDS, reprompt=args.reprompt)
1098
1408
  report.pop("_latest")
1409
+ if args.fingerprint or args.explain:
1410
+ return print_fingerprint(env, report, args, project)
1099
1411
  if args.output == "json":
1100
1412
  print(json.dumps(report, ensure_ascii=False, indent=2))
1101
1413
  else:
1102
- sys.stdout.write(render_text(report))
1414
+ sys.stdout.write(render_text(report, show_skipped=args.show_skipped))
1103
1415
  return 1 if args.strict and not report["summary"]["up_to_date"] else 0
1104
1416
  if args.command == "doctor":
1105
1417
  result = doctor(env, run_check(env, project, latest_source=args.latest))
@@ -1110,9 +1422,9 @@ def main(argv: list[str] | None = None) -> int:
1110
1422
  print(f" [{issue['issue']}] {issue.get('plugin') or ''} {issue['detail']}".rstrip())
1111
1423
  for item in result["legacy"]:
1112
1424
  print(f" [legacy] {item['kind']} {item['name']}: {item['path']}")
1113
- print("没有发现问题。" if result["ok"] and not result["legacy"] else "")
1425
+ print(_("没有发现问题。") if result["ok"] and not result["legacy"] else "")
1114
1426
  return 0 if result["ok"] else 1
1115
- latest, _ = load_latest(env, args.latest, max_age=0)
1427
+ latest, _origin = load_latest(env, args.latest, max_age=0)
1116
1428
  if args.command == "install":
1117
1429
  clients = args.client or detect_clients(env)
1118
1430
  plugins = args.plugin or sorted(latest.get("plugins", {}))
@@ -1133,7 +1445,7 @@ def main(argv: list[str] | None = None) -> int:
1133
1445
  payload = {"ok": all(r["status"] != "failed" for r in results), "applied": args.apply,
1134
1446
  "steps": results, "summary": report["summary"]}
1135
1447
  if not steps:
1136
- payload["message"] = "没有需要更新的内容。"
1448
+ payload["message"] = _("没有需要更新的内容。")
1137
1449
  else: # migrate
1138
1450
  names = set(latest.get("plugins", {}))
1139
1451
  artifacts = legacy_artifacts(env, names)
@@ -1160,9 +1472,9 @@ def main(argv: list[str] | None = None) -> int:
1160
1472
  print(payload["message"])
1161
1473
  _print_steps(payload["steps"], args.apply)
1162
1474
  for item in payload.get("legacy", []):
1163
- 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 ""))
1164
1476
  if payload.get("recovery"):
1165
- print(f"旧安装已移入:{payload['recovery']}(含 migration.json 清单,可按清单恢复)")
1477
+ print(_("旧安装已移入:{0}(含 migration.json 清单,可按清单恢复)").format(payload['recovery']))
1166
1478
  return 0 if payload["ok"] else 1
1167
1479
 
1168
1480