@sakanano-moe/agenthelm 6.5.3 → 6.6.0

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 (109) hide show
  1. package/bin/agenthelm.js +1 -1
  2. package/package.json +2 -2
  3. package/runtime/index.json +80 -0
  4. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +19 -0
  5. package/runtime/plugins/agent-pack/.mcp.json +12 -0
  6. package/runtime/plugins/agent-pack/hooks/hooks.json +16 -0
  7. package/runtime/plugins/agent-pack/mcp.json +14 -0
  8. package/runtime/plugins/agent-pack/plugin.json +20 -0
  9. package/runtime/plugins/agent-pack/references/capability-config.md +124 -0
  10. package/runtime/{agenthelm.py → plugins/agent-pack/runtime/agenthelm.py} +42 -1
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +388 -0
  12. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +46 -0
  13. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +22 -0
  14. package/runtime/plugins/agents-init/content.json +31 -0
  15. package/runtime/plugins/agents-init/plugin.json +19 -0
  16. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +376 -0
  17. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +119 -0
  18. package/runtime/plugins/agents-init/skills/agents-init/index.html +357 -0
  19. package/runtime/plugins/agents-init/skills/agents-init/launcher.sh +93 -0
  20. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +171 -0
  21. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +1190 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +678 -0
  23. package/runtime/plugins/agents-init/skills/agents-init/skill.json +122 -0
  24. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +26 -0
  25. package/runtime/plugins/opinion-manager/catalog/README.md +15 -0
  26. package/runtime/plugins/opinion-manager/catalog/rules/.gitkeep +0 -0
  27. package/runtime/plugins/opinion-manager/catalog/rules/om.align-requirements/versions/1.0.0.json +11 -0
  28. package/runtime/plugins/opinion-manager/catalog/rules/om.assumptions-authority/versions/1.0.0.json +11 -0
  29. package/runtime/plugins/opinion-manager/catalog/rules/om.batch-questions/versions/1.0.0.json +11 -0
  30. package/runtime/plugins/opinion-manager/catalog/rules/om.bilingual-terms/versions/1.0.0.json +11 -0
  31. package/runtime/plugins/opinion-manager/catalog/rules/om.calculations/versions/1.0.0.json +11 -0
  32. package/runtime/plugins/opinion-manager/catalog/rules/om.chinese-response/versions/1.0.0.json +11 -0
  33. package/runtime/plugins/opinion-manager/catalog/rules/om.clean-deliverable/versions/1.0.0.json +11 -0
  34. package/runtime/plugins/opinion-manager/catalog/rules/om.complete-options/versions/1.0.0.json +11 -0
  35. package/runtime/plugins/opinion-manager/catalog/rules/om.conclusion-first/versions/1.0.0.json +11 -0
  36. package/runtime/plugins/opinion-manager/catalog/rules/om.concrete-titles/versions/1.0.0.json +11 -0
  37. package/runtime/plugins/opinion-manager/catalog/rules/om.consistent-items/versions/1.0.0.json +11 -0
  38. package/runtime/plugins/opinion-manager/catalog/rules/om.device-readable/versions/1.0.0.json +11 -0
  39. package/runtime/plugins/opinion-manager/catalog/rules/om.editable-diagrams/versions/1.0.0.json +11 -0
  40. package/runtime/plugins/opinion-manager/catalog/rules/om.evidence-inference/versions/1.0.0.json +11 -0
  41. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-chinese/versions/1.0.0.json +11 -0
  42. package/runtime/plugins/opinion-manager/catalog/rules/om.maintenance-project-language/versions/1.0.0.json +11 -0
  43. package/runtime/plugins/opinion-manager/catalog/rules/om.markdown-structure/versions/1.0.0.json +11 -0
  44. package/runtime/plugins/opinion-manager/catalog/rules/om.portable-files/versions/1.0.0.json +11 -0
  45. package/runtime/plugins/opinion-manager/catalog/rules/om.report-context/versions/1.0.0.json +11 -0
  46. package/runtime/plugins/opinion-manager/catalog/rules/om.report-formats/versions/1.0.0.json +11 -0
  47. package/runtime/plugins/opinion-manager/catalog/rules/om.report-review/versions/1.0.0.json +11 -0
  48. package/runtime/plugins/opinion-manager/catalog/rules/om.response-brief/versions/1.0.0.json +11 -0
  49. package/runtime/plugins/opinion-manager/catalog/rules/om.response-dense/versions/1.0.0.json +11 -0
  50. package/runtime/plugins/opinion-manager/catalog/rules/om.rules-tools/versions/1.0.0.json +11 -0
  51. package/runtime/plugins/opinion-manager/catalog/rules/om.semantic-wrap/versions/1.0.0.json +11 -0
  52. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-proportional/versions/1.0.0.json +11 -0
  53. package/runtime/plugins/opinion-manager/catalog/rules/om.solutions-three-level/versions/1.0.0.json +11 -0
  54. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-compact/versions/1.0.0.json +11 -0
  55. package/runtime/plugins/opinion-manager/catalog/rules/om.tables-list-first/versions/1.0.0.json +11 -0
  56. package/runtime/plugins/opinion-manager/catalog/rules/om.test-evidence/versions/1.0.0.json +11 -0
  57. package/runtime/plugins/opinion-manager/catalog/rules/om.versioned-files/versions/1.0.0.json +11 -0
  58. package/runtime/plugins/opinion-manager/catalog/templates/.gitkeep +0 -0
  59. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/compact-tables/versions/1.0.0.json +21 -0
  60. package/runtime/plugins/opinion-manager/catalog/templates/delivery-standard/list-first/versions/1.0.0.json +21 -0
  61. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/chinese-maintenance/versions/1.0.0.json +15 -0
  62. package/runtime/plugins/opinion-manager/catalog/templates/development-standard/project-language/versions/1.0.0.json +15 -0
  63. package/runtime/plugins/opinion-manager/catalog/templates/response-style/brief-chinese/versions/1.0.0.json +19 -0
  64. package/runtime/plugins/opinion-manager/catalog/templates/response-style/dense-chinese/versions/1.0.0.json +19 -0
  65. package/runtime/plugins/opinion-manager/catalog/templates/scenario/concise-project/versions/1.0.0.json +39 -0
  66. package/runtime/plugins/opinion-manager/catalog/templates/scenario/reviewed-project/versions/1.0.0.json +39 -0
  67. package/runtime/plugins/opinion-manager/catalog/templates/work-method/proportional/versions/1.0.0.json +20 -0
  68. package/runtime/plugins/opinion-manager/catalog/templates/work-method/three-level/versions/1.0.0.json +20 -0
  69. package/runtime/plugins/opinion-manager/content.json +63 -0
  70. package/runtime/plugins/opinion-manager/plugin.json +18 -0
  71. package/runtime/plugins/opinion-manager/references/lock.schema.json +14 -0
  72. package/runtime/plugins/opinion-manager/references/onboarding.md +57 -0
  73. package/runtime/plugins/opinion-manager/references/profile.schema.json +56 -0
  74. package/runtime/plugins/opinion-manager/references/rule.schema.json +16 -0
  75. package/runtime/plugins/opinion-manager/references/template.schema.json +71 -0
  76. package/runtime/plugins/opinion-manager/references/versioned-template.schema.json +19 -0
  77. package/runtime/plugins/opinion-manager/references/versioning.md +263 -0
  78. package/runtime/plugins/opinion-manager/runtime/constants.py +7 -0
  79. package/runtime/plugins/opinion-manager/runtime/context.py +180 -0
  80. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +395 -0
  81. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +419 -0
  82. package/runtime/plugins/opinion-manager/runtime/versioned.py +705 -0
  83. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +62 -0
  84. package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +4 -0
  85. package/runtime/plugins/opinion-manager/templates/.gitkeep +1 -0
  86. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +18 -0
  87. package/runtime/plugins/project-orchestrator/content.json +37 -0
  88. package/runtime/plugins/project-orchestrator/plugin.json +19 -0
  89. package/runtime/plugins/project-orchestrator/references/asset-synonyms.example.json +8 -0
  90. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +159 -0
  91. package/runtime/plugins/project-orchestrator/references/case.schema.json +31 -0
  92. package/runtime/plugins/project-orchestrator/runtime/assets.py +1288 -0
  93. package/runtime/plugins/project-orchestrator/runtime/case.py +557 -0
  94. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +350 -0
  95. package/runtime/plugins/project-orchestrator/runtime/memory.py +363 -0
  96. package/runtime/plugins/project-orchestrator/runtime/project_base.py +107 -0
  97. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +599 -0
  98. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +504 -0
  99. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +470 -0
  100. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +394 -0
  101. package/runtime/plugins/project-orchestrator/runtime/project_real.py +522 -0
  102. package/runtime/plugins/project-orchestrator/runtime/project_write.py +329 -0
  103. package/runtime/plugins/project-orchestrator/runtime/wf.py +439 -0
  104. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +230 -0
  105. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +4 -0
  106. package/runtime/plugins/project-orchestrator/templates/CASE.md +39 -0
  107. /package/runtime/{agent_pack_config.py → plugins/agent-pack/runtime/agent_pack_config.py} +0 -0
  108. /package/runtime/{mcp_env_launcher.py → plugins/agent-pack/runtime/mcp_env_launcher.py} +0 -0
  109. /package/runtime/{mcp_server.py → plugins/agent-pack/runtime/mcp_server.py} +0 -0
@@ -0,0 +1,678 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import unittest
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+
14
+ SCRIPT = Path(__file__).with_name("init_project.py")
15
+ sys.path.insert(0, str(SCRIPT.parent))
16
+ import init_project # noqa: E402
17
+
18
+
19
+ class InitProjectTests(unittest.TestCase):
20
+ def run_script(self, root: Path, *extra: str) -> subprocess.CompletedProcess[str]:
21
+ return subprocess.run(
22
+ [
23
+ sys.executable,
24
+ str(SCRIPT),
25
+ "--project", str(root),
26
+ "--name", "Example Project",
27
+ "--project-type", "code,docs",
28
+ "--vcs", "github",
29
+ "--stack", "python,markdown",
30
+ "--runtime", "local",
31
+ "--agent-cli", "codex,cursor",
32
+ "--output", "json",
33
+ *extra,
34
+ ],
35
+ check=False,
36
+ capture_output=True,
37
+ text=True,
38
+ )
39
+
40
+ def test_dry_run_does_not_write(self) -> None:
41
+ with tempfile.TemporaryDirectory() as temporary:
42
+ root = Path(temporary)
43
+ result = self.run_script(root)
44
+ self.assertEqual(result.returncode, 0)
45
+ payload = json.loads(result.stdout)
46
+ self.assertFalse(payload["applied"])
47
+ self.assertFalse((root / "AGENTS.md").exists())
48
+
49
+ def test_work_mode_default_and_legacy(self):
50
+ for mode in ('case-workspace', 'legacy'):
51
+ with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary:
52
+ root = Path(temporary)
53
+ args = () if mode == 'case-workspace' else ('--work-mode', mode)
54
+ result = self.run_script(root, '--apply', *args)
55
+ self.assertEqual(result.returncode, 0, result.stdout)
56
+ profile = json.loads((root / init_project.PROFILE_PATH).read_text())
57
+ self.assertEqual(profile['work']['mode'], mode)
58
+ self.assertEqual((root / 'cases/.gitkeep').exists(), mode == 'case-workspace')
59
+
60
+ def test_migrate_preserves_custom_profile_and_explicit_mode(self):
61
+ for mode in (None, 'case-workspace'):
62
+ with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as recovery:
63
+ root = Path(temporary)
64
+ profile = {'schema_version': '1.0', 'name': 'Existing',
65
+ 'runtime': {'commit_policy': 'explicit', 'custom': True},
66
+ 'opinion': {'custom': 'keep'}, 'privacy': {'keep': True},
67
+ 'work': {'task': {'root': 'tasks'}, 'case': {'root': 'cases', 'command': 'own-tool'}}}
68
+ path = root / init_project.PROFILE_PATH
69
+ path.parent.mkdir(parents=True)
70
+ path.write_text(json.dumps(profile))
71
+ args = () if mode is None else ('--work-mode', mode)
72
+ result = self.run_script(root, '--mode', 'migrate', '--replace', str(init_project.PROFILE_PATH),
73
+ '--recovery-dir', recovery, '--apply', *args)
74
+ self.assertEqual(result.returncode, 0, result.stdout)
75
+ updated = json.loads(path.read_text())
76
+ self.assertEqual(updated['opinion'], profile['opinion'])
77
+ self.assertEqual(updated['privacy'], profile['privacy'])
78
+ self.assertEqual(updated['runtime']['custom'], True)
79
+ self.assertEqual(updated['runtime']['commit_policy'], 'explicit')
80
+ if mode is None:
81
+ self.assertEqual(updated['work'], profile['work'])
82
+ self.assertFalse((root / 'cases').exists())
83
+ else:
84
+ self.assertEqual(updated['work']['legacy_task']['root'], 'tasks')
85
+ self.assertIsNone(updated['work']['task'])
86
+ self.assertEqual(updated['work']['case']['command'], 'own-tool')
87
+
88
+ def test_apply_creates_v6_skeleton(self) -> None:
89
+ with tempfile.TemporaryDirectory() as temporary:
90
+ root = Path(temporary)
91
+ result = self.run_script(root, "--apply")
92
+ self.assertEqual(result.returncode, 0, result.stderr)
93
+ profile = json.loads((root / ".agents/moe.sakanano.agent-pack/project.json").read_text())
94
+ self.assertFalse((root / ".agents/moe.sakanano.project-runtime").exists())
95
+ self.assertEqual(profile["schema_version"], "1.0")
96
+ self.assertEqual(profile["initializer_version"], "6.6.0")
97
+ self.assertEqual(profile["$schema"], "https://skill.sakanano.moe/skills/agents-init/project.schema.json")
98
+ self.assertEqual(profile["runtime"]["skill"], "project-orchestrator")
99
+ self.assertEqual(profile["runtime"]["plugin"], "project-orchestrator")
100
+ self.assertEqual(profile["runtime"]["capability_manager"], "agent-pack")
101
+ self.assertEqual(profile["runtime"]["distribution"], "marketplace")
102
+ self.assertEqual(profile["opinion"]["provider"], "opinion-manager")
103
+ self.assertEqual(profile["opinion"]["loading_mode"], "tiered")
104
+ self.assertEqual(profile["memory"], {"provider": "project-orchestrator", "root": "memory",
105
+ "index_command": None, "check_command": None, "policy": None})
106
+ self.assertTrue((root / "docs/drafts/.gitkeep").exists())
107
+ self.assertTrue((root / "memory/.gitkeep").exists())
108
+ payload = json.loads(result.stdout)
109
+ self.assertEqual(payload["runtime"]["source"], "bundled")
110
+ self.assertTrue(payload["runtime"]["lifecycle"].startswith("project-orchestrator@"))
111
+ self.assertTrue(payload["runtime"]["capabilities"].startswith("agent-pack@"))
112
+ self.assertTrue(payload["runtime"]["opinion"].startswith("opinion-manager@"))
113
+ agents = (root / "AGENTS.md").read_text()
114
+ self.assertIn("# Example Project — Agent 执行入口", agents)
115
+ self.assertIn("project-orchestrator", agents)
116
+ self.assertNotIn("project-runtime", agents)
117
+ rules = (root / "AGENT.RULES.md").read_text()
118
+ self.assertIn("# 项目专属规则", rules)
119
+ for phrase in ("## 记忆", "写入去向", "不写入记忆", "召回", "客户端私有记忆", "收尾"):
120
+ self.assertIn(phrase, rules)
121
+ self.assertIn("`memory` 段声明的记忆索引", agents)
122
+ self.assertIn("`fallback` 时完整读取 `OPINION.md`", agents)
123
+ summary = (root / ".agent-doc/chat-summary.md").read_text()
124
+ for heading in ("## 待确认假设", "## 未解决冲突", "## Opinion 演化候选"):
125
+ self.assertIn(heading, summary)
126
+ self.assertIn("只保存尚未处理的事项与指向权威位置的指针", summary)
127
+
128
+ def test_apply_records_content_baseline_for_rendered_templates(self) -> None:
129
+ with tempfile.TemporaryDirectory() as temporary:
130
+ root = Path(temporary)
131
+ result = self.run_script(root, "--apply")
132
+ self.assertEqual(result.returncode, 0, result.stderr)
133
+ lock = json.loads((root / init_project.CONTENT_LOCK_PATH).read_text())
134
+ entry = lock["packs"]["agents-init/project-contract"]
135
+ pack = init_project.contract_pack()
136
+ self.assertEqual(entry["version"], pack["version"])
137
+ # The pack's fixed files plus the project's root launcher (default name menu).
138
+ self.assertEqual(sorted(entry["files"]), sorted([*pack["materializes"], init_project.DEFAULT_ENTRY]))
139
+ for relative, digest in entry["files"].items():
140
+ self.assertEqual(hashlib.sha256((root / relative).read_bytes()).hexdigest(), digest)
141
+ # Working documents the user fills in are not template content.
142
+ self.assertNotIn(".agent-doc/progress.md", entry["files"])
143
+ self.assertEqual(json.loads(result.stdout)["content_lock"]["path"], str(init_project.CONTENT_LOCK_PATH))
144
+
145
+ def test_dry_run_writes_no_content_lock(self) -> None:
146
+ with tempfile.TemporaryDirectory() as temporary:
147
+ root = Path(temporary)
148
+ payload = json.loads(self.run_script(root).stdout)
149
+ self.assertEqual(payload["content_lock"]["pack"].split("@")[0], "agents-init/project-contract")
150
+ self.assertFalse((root / init_project.CONTENT_LOCK_PATH).exists())
151
+
152
+ def test_migrate_merges_baseline_only_for_rewritten_files(self) -> None:
153
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup:
154
+ root = Path(temporary)
155
+ self.assertEqual(self.run_script(root, "--apply").returncode, 0)
156
+ lock_path = root / init_project.CONTENT_LOCK_PATH
157
+ before = json.loads(lock_path.read_text())["packs"]["agents-init/project-contract"]["files"]
158
+ (root / "AGENTS.md").write_text("edited\n", encoding="utf-8")
159
+ result = self.run_script(root, "--mode", "migrate", "--replace", "AGENT.md",
160
+ "--recovery-dir", str(Path(backup) / "r"), "--apply")
161
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
162
+ after = json.loads(lock_path.read_text())["packs"]["agents-init/project-contract"]["files"]
163
+ # AGENTS.md was preserved, so its install-time baseline must survive the user's edit.
164
+ self.assertEqual(after["AGENTS.md"], before["AGENTS.md"])
165
+ self.assertEqual(after["AGENT.md"], hashlib.sha256((root / "AGENT.md").read_bytes()).hexdigest())
166
+
167
+ def refresh(self, root: Path, *extra: str, template_suffix: str = "\n<!-- v1.1 -->\n") -> tuple[int, dict]:
168
+ """Run --mode refresh in-process against a newer AGENTS.md template and pack version."""
169
+ templates = init_project.load_templates()
170
+ templates["AGENTS.md"] += template_suffix
171
+ pack = {**init_project.contract_pack(), "version": "1.1.0"}
172
+ output = io.StringIO()
173
+ with mock.patch.object(init_project, "load_templates", return_value=templates), \
174
+ mock.patch.object(init_project, "contract_pack", return_value=pack), \
175
+ mock.patch("sys.stdout", output):
176
+ code = init_project.main(["--project", str(root), "--mode", "refresh", "--output", "json", *extra])
177
+ return code, json.loads(output.getvalue())
178
+
179
+ def lock_entry(self, root: Path) -> dict:
180
+ return json.loads((root / init_project.CONTENT_LOCK_PATH).read_text())["packs"]["agents-init/project-contract"]
181
+
182
+ def test_refresh_replaces_clean_files_after_backup_and_bumps_the_lock(self) -> None:
183
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup:
184
+ root = Path(temporary)
185
+ self.assertEqual(self.run_script(root, "--apply").returncode, 0)
186
+ original = (root / "AGENTS.md").read_text()
187
+ code, preview = self.refresh(root)
188
+ self.assertEqual(code, 0)
189
+ actions = {item["path"]: (item["state"], item["action"]) for item in preview["files"]}
190
+ self.assertEqual(actions["AGENTS.md"], ("clean", "replace"))
191
+ self.assertEqual(actions["AGENT.md"], ("unchanged", "keep"))
192
+ self.assertIn("+<!-- v1.1 -->", next(i for i in preview["files"] if i["path"] == "AGENTS.md")["diff"])
193
+ self.assertEqual((root / "AGENTS.md").read_text(), original) # dry-run writes nothing
194
+ code, applied = self.refresh(root, "--apply", "--recovery-dir", str(Path(backup) / "r"))
195
+ self.assertTrue(applied["applied"], applied)
196
+ self.assertTrue((root / "AGENTS.md").read_text().endswith("<!-- v1.1 -->\n"))
197
+ self.assertEqual((Path(backup) / "r/AGENTS.md").read_text(), original)
198
+ entry = self.lock_entry(root)
199
+ self.assertEqual(entry["version"], "1.1.0")
200
+ self.assertEqual(entry["files"]["AGENTS.md"], hashlib.sha256((root / "AGENTS.md").read_bytes()).hexdigest())
201
+
202
+ def test_refresh_blocks_on_local_edits_until_the_user_chooses(self) -> None:
203
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup:
204
+ root = Path(temporary)
205
+ self.assertEqual(self.run_script(root, "--apply").returncode, 0)
206
+ (root / "AGENTS.md").write_text("my own rules\n", encoding="utf-8")
207
+ old_baseline = self.lock_entry(root)["files"]["AGENTS.md"]
208
+ code, blocked = self.refresh(root, "--apply", "--recovery-dir", str(Path(backup) / "a"))
209
+ self.assertEqual(code, 1)
210
+ self.assertEqual(blocked["blocked"], ["AGENTS.md"])
211
+ self.assertFalse(blocked["applied"])
212
+ self.assertEqual((root / "AGENTS.md").read_text(), "my own rules\n")
213
+ code, kept = self.refresh(root, "--apply", "--keep", "AGENTS.md")
214
+ self.assertTrue(kept["applied"])
215
+ self.assertEqual((root / "AGENTS.md").read_text(), "my own rules\n")
216
+ entry = self.lock_entry(root)
217
+ self.assertEqual(entry["version"], "1.1.0")
218
+ self.assertEqual(entry["files"]["AGENTS.md"], old_baseline)
219
+
220
+ def test_refresh_leaves_edits_alone_when_the_template_output_did_not_change(self) -> None:
221
+ with tempfile.TemporaryDirectory() as temporary:
222
+ root = Path(temporary)
223
+ self.assertEqual(self.run_script(root, "--apply").returncode, 0)
224
+ (root / "AGENT.RULES.md").write_text("project rules of my own\n", encoding="utf-8")
225
+ code, preview = self.refresh(root)
226
+ self.assertEqual(code, 0, preview)
227
+ states = {item["path"]: (item["state"], item["action"]) for item in preview["files"]}
228
+ self.assertEqual(states["AGENT.RULES.md"], ("local-only", "keep"))
229
+ self.assertEqual(preview["blocked"], [])
230
+
231
+ def test_refresh_accept_replaces_an_edited_file_with_backup(self) -> None:
232
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup:
233
+ root = Path(temporary)
234
+ self.assertEqual(self.run_script(root, "--apply").returncode, 0)
235
+ (root / "AGENTS.md").write_text("my own rules\n", encoding="utf-8")
236
+ code, applied = self.refresh(root, "--apply", "--accept", "AGENTS.md",
237
+ "--recovery-dir", str(Path(backup) / "r"))
238
+ self.assertEqual(code, 0, applied)
239
+ self.assertEqual((Path(backup) / "r/AGENTS.md").read_text(), "my own rules\n")
240
+ self.assertIn("<!-- v1.1 -->", (root / "AGENTS.md").read_text())
241
+
242
+ def test_refresh_without_a_lock_treats_differences_as_local_edits(self) -> None:
243
+ with tempfile.TemporaryDirectory() as temporary:
244
+ root = Path(temporary)
245
+ self.assertEqual(self.run_script(root, "--apply").returncode, 0)
246
+ (root / init_project.CONTENT_LOCK_PATH).unlink()
247
+ code, preview = self.refresh(root)
248
+ self.assertEqual(code, 1)
249
+ self.assertEqual(preview["blocked"], ["AGENTS.md"])
250
+ self.assertIsNone(preview["from_version"])
251
+
252
+ def test_refresh_rejects_untracked_paths(self) -> None:
253
+ with tempfile.TemporaryDirectory() as temporary:
254
+ root = Path(temporary)
255
+ self.assertEqual(self.run_script(root, "--apply").returncode, 0)
256
+ output = io.StringIO()
257
+ with mock.patch("sys.stdout", output):
258
+ code = init_project.main(["--project", str(root), "--mode", "refresh", "--output", "json",
259
+ "--accept", ".agent-doc/progress.md"])
260
+ self.assertEqual(code, 2)
261
+ self.assertIn("不是内容包跟踪的文件", json.loads(output.getvalue())["error"])
262
+
263
+ def test_profile_schema_declares_every_generated_top_level_field(self) -> None:
264
+ schema = json.loads((SCRIPT.parents[1] / "project.schema.json").read_text(encoding="utf-8"))
265
+ inputs = init_project.Inputs(Path("."), "Example", "example", ("code",), "github", ("python",), "local", ("codex",))
266
+ profile = json.loads(init_project.files_for(inputs)[init_project.PROFILE_PATH])
267
+ self.assertLessEqual(set(profile), set(schema["properties"]))
268
+ self.assertLessEqual(set(schema["required"]), set(profile))
269
+ self.assertNotIn("memory", schema["required"])
270
+ self.assertIn(profile["memory"]["provider"], schema["properties"]["memory"]["properties"]["provider"]["enum"])
271
+ self.assertIn(profile["opinion"]["loading_mode"], schema["properties"]["opinion"]["properties"]["loading_mode"]["enum"])
272
+
273
+ def test_generated_markdown_uses_named_chinese_template_blocks(self) -> None:
274
+ templates = init_project.load_templates()
275
+ inputs = init_project.Inputs(
276
+ Path("."), "示例项目", "example", ("代码",), "github", ("python",), "本地", ("codex",)
277
+ )
278
+ files = init_project.files_for(inputs, templates)
279
+ self.assertEqual(files[Path("AGENTS.md")], init_project.render_template(
280
+ templates["AGENTS.md"],
281
+ {
282
+ "PROJECT_NAME": "示例项目",
283
+ "PROJECT_SLUG": "example",
284
+ "PROJECT_TYPE": "代码",
285
+ "VCS": "github",
286
+ "STACK": "python",
287
+ "RUNTIME": "本地",
288
+ "AGENT_CLI": "codex",
289
+ "ENTRY_COMMAND": "agenthelm project",
290
+ },
291
+ ))
292
+ self.assertEqual(files[Path("docs/refs/README.md")], templates["docs/refs/README.md"])
293
+
294
+ def make_companions(self, parent: Path, layout: str) -> Path:
295
+ """Lay out agents-init plus its three companions the way a client stores them."""
296
+ def plugin(root: Path, name: str, version: str) -> None:
297
+ (root).mkdir(parents=True, exist_ok=True)
298
+ (root / "plugin.json").write_text(json.dumps({"name": name, "version": version}))
299
+ for relative in init_project.BUNDLED_PLUGINS.get(name, ()):
300
+ (root / relative).parent.mkdir(parents=True, exist_ok=True)
301
+ (root / relative).write_text("x")
302
+ if layout == "flat":
303
+ plugin(parent / "agents-init", "agents-init", "7.0.0")
304
+ for name in init_project.BUNDLED_PLUGINS:
305
+ plugin(parent / name, name, "9.0.0")
306
+ return parent / "agents-init"
307
+ market = parent / "plugins/cache/agenthelm"
308
+ plugin(market / "agents-init/7.0.0", "agents-init", "7.0.0")
309
+ for name in init_project.BUNDLED_PLUGINS:
310
+ plugin(market / name / "1.0.0", name, "1.0.0")
311
+ plugin(market / name / "2.0.0", name, "2.0.0")
312
+ if layout == "claude-cache":
313
+ # Claude Code leaves the superseded 2.0.0 directory behind; its record points at 1.0.0.
314
+ record = {"version": 2, "plugins": {
315
+ f"{name}@agenthelm": [{"installPath": str(market / name / "1.0.0")}]
316
+ for name in init_project.BUNDLED_PLUGINS}}
317
+ (parent / "plugins/installed_plugins.json").write_text(json.dumps(record))
318
+ return market / "agents-init/7.0.0"
319
+
320
+ def test_companions_are_found_in_every_client_layout(self) -> None:
321
+ for layout, expected in (("flat", "9.0.0"), ("codex-cache", "2.0.0"), ("claude-cache", "1.0.0")):
322
+ with self.subTest(layout=layout), tempfile.TemporaryDirectory() as temporary:
323
+ agents_init = self.make_companions(Path(temporary), layout)
324
+ status = init_project.validate_runtime(agents_init)
325
+ self.assertEqual(status["source"], "external")
326
+ self.assertEqual(status["capabilities"], f"agent-pack@{expected}")
327
+ self.assertEqual(status["opinion"], f"opinion-manager@{expected}")
328
+
329
+ def test_repository_root_is_accepted_as_runtime_root(self) -> None:
330
+ repository = SCRIPT.parents[5]
331
+ self.assertTrue(init_project.validate_runtime(repository)["available"])
332
+
333
+ def test_missing_runtime_fails_before_any_project_write(self) -> None:
334
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as incomplete:
335
+ root = Path(temporary)
336
+ result = self.run_script(
337
+ root, "--runtime-plugin-root", incomplete, "--apply"
338
+ )
339
+ self.assertEqual(result.returncode, 2)
340
+ payload = json.loads(result.stdout)
341
+ self.assertEqual(payload["code"], "runtime-required")
342
+ self.assertEqual(list(root.iterdir()), [])
343
+
344
+ def test_apply_collision_writes_nothing(self) -> None:
345
+ with tempfile.TemporaryDirectory() as temporary:
346
+ root = Path(temporary)
347
+ (root / "AGENTS.md").write_text("existing\n")
348
+ result = self.run_script(root, "--apply")
349
+ self.assertEqual(result.returncode, 1)
350
+ self.assertEqual((root / "AGENTS.md").read_text(), "existing\n")
351
+ payload = json.loads(result.stdout)
352
+ self.assertIn("AGENTS.md", payload["preserved_collisions"])
353
+ self.assertFalse(payload["applied"])
354
+ self.assertFalse((root / ".agents/plugin.json").exists())
355
+
356
+ def test_rejects_symlinked_parent(self) -> None:
357
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as outside:
358
+ root = Path(temporary)
359
+ (root / ".agents").symlink_to(Path(outside), target_is_directory=True)
360
+ result = self.run_script(root, "--apply")
361
+ self.assertEqual(result.returncode, 2)
362
+ self.assertIn("symlinked parent", json.loads(result.stdout)["error"])
363
+ self.assertEqual(list(Path(outside).iterdir()), [])
364
+
365
+ def test_midway_failure_rolls_back_known_files(self) -> None:
366
+ with tempfile.TemporaryDirectory() as temporary:
367
+ root = Path(temporary)
368
+ files = {Path("one/file.txt"): "one\n", Path("two/file.txt"): "two\n"}
369
+ original = init_project._write_exclusive
370
+ calls = 0
371
+
372
+ def fail_second(parent_fd: int, name: str, payload: bytes) -> None:
373
+ nonlocal calls
374
+ calls += 1
375
+ if calls == 2:
376
+ raise OSError("injected failure")
377
+ original(parent_fd, name, payload)
378
+
379
+ with mock.patch.object(init_project, "_write_exclusive", side_effect=fail_second):
380
+ with self.assertRaises(init_project.InitError):
381
+ init_project.apply_writes(root, files, files)
382
+ self.assertFalse((root / "one/file.txt").exists())
383
+ self.assertFalse((root / "two/file.txt").exists())
384
+ self.assertEqual(list(root.iterdir()), [])
385
+
386
+ def test_rejects_invalid_slug(self) -> None:
387
+ with tempfile.TemporaryDirectory() as temporary:
388
+ result = self.run_script(Path(temporary), "--slug", "Bad Slug")
389
+ self.assertEqual(result.returncode, 2)
390
+ self.assertFalse(json.loads(result.stdout)["ok"])
391
+
392
+ def test_migrate_creates_missing_and_preserves_unselected_collision(self) -> None:
393
+ with tempfile.TemporaryDirectory() as temporary:
394
+ root = Path(temporary)
395
+ (root / "AGENTS.md").write_text("legacy\n", encoding="utf-8")
396
+ result = self.run_script(root, "--mode", "migrate", "--apply")
397
+ self.assertEqual(result.returncode, 0, result.stderr)
398
+ payload = json.loads(result.stdout)
399
+ self.assertTrue(payload["applied"])
400
+ self.assertEqual((root / "AGENTS.md").read_text(), "legacy\n")
401
+ self.assertTrue((root / ".agents/plugin.json").is_file())
402
+
403
+ def test_migrate_replaces_only_selected_path_with_external_recovery(self) -> None:
404
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup:
405
+ root = Path(temporary)
406
+ (root / "AGENTS.md").write_text("legacy\n", encoding="utf-8")
407
+ recovery = Path(backup) / "migration"
408
+ result = self.run_script(
409
+ root,
410
+ "--mode", "migrate",
411
+ "--replace", "AGENTS.md",
412
+ "--recovery-dir", str(recovery),
413
+ "--apply",
414
+ )
415
+ self.assertEqual(result.returncode, 0, result.stderr)
416
+ self.assertNotEqual((root / "AGENTS.md").read_text(), "legacy\n")
417
+ self.assertEqual((recovery / "AGENTS.md").read_text(), "legacy\n")
418
+
419
+ def write_v5_profile(self, root: Path) -> Path:
420
+ legacy = root / ".agents/moe.sakanano.project-runtime/project.json"
421
+ legacy.parent.mkdir(parents=True)
422
+ legacy.write_text(json.dumps({
423
+ "schema_version": "1.0",
424
+ "initializer_version": "5.0.0",
425
+ "name": "Legacy",
426
+ "profile": {"project_type": ["docs"], "vcs": "github", "stack": ["python"], "runtime": "local", "agent_cli": ["codex"]},
427
+ "runtime": {
428
+ "skill": "project-runtime",
429
+ "required": True,
430
+ "session_bootstrap": "make brief",
431
+ "commit_policy": "explicit",
432
+ "push_policy": "explicit",
433
+ },
434
+ "opinion": {"provider": "opinion-workflow", "project_overlay": "OPINION.md", "strict_mode": "smart"},
435
+ "capabilities": {
436
+ "plugin_roots": [".agents", "vendor/plugins"],
437
+ "plugin_dirs": [],
438
+ "skill_roots": [".agents/skills"],
439
+ "mcp_sources": [".agents/mcp.json"],
440
+ "native_mcp_sources": ["config/mcp.jsonc"],
441
+ "credential_env_file": ".env",
442
+ "mcp_client_policy": {"tapd": {"exclude": ["claude"]}},
443
+ "destination_skill_root": ".agents/skills",
444
+ "destination_mcp": ".agents/mcp.json",
445
+ },
446
+ "work": {"task": {"root": "tasks"}},
447
+ "privacy": {"forbidden_default_reads": [".env"], "generated_outputs": []},
448
+ }), encoding="utf-8")
449
+ return legacy
450
+
451
+ def test_migrate_upgrades_v5_profile_and_retires_it_to_recovery(self) -> None:
452
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup:
453
+ root = Path(temporary)
454
+ legacy = self.write_v5_profile(root)
455
+ original = legacy.read_text(encoding="utf-8")
456
+ recovery = Path(backup) / "migration"
457
+ result = self.run_script(root, "--mode", "migrate", "--recovery-dir", str(recovery), "--apply")
458
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
459
+ payload = json.loads(result.stdout)
460
+ self.assertEqual(payload["retire"], [".agents/moe.sakanano.project-runtime/project.json"])
461
+ self.assertFalse(legacy.exists())
462
+ self.assertFalse(legacy.parent.exists())
463
+ self.assertEqual((recovery / ".agents/moe.sakanano.project-runtime/project.json").read_text(), original)
464
+ profile = json.loads((root / ".agents/moe.sakanano.agent-pack/project.json").read_text())
465
+ self.assertEqual(profile["initializer_version"], "6.6.0")
466
+ self.assertEqual(profile["runtime"]["skill"], "project-orchestrator")
467
+ self.assertEqual(profile["runtime"]["capability_manager"], "agent-pack")
468
+ self.assertEqual(profile["runtime"]["session_bootstrap"], "make brief")
469
+ self.assertEqual(profile["name"], "Legacy")
470
+ self.assertEqual(profile["capabilities"]["plugin_roots"], [".agents", "vendor/plugins"])
471
+ self.assertEqual(profile["capabilities"]["mcp_client_policy"], {"tapd": {"exclude": ["claude"]}})
472
+ self.assertEqual(profile["work"], {"task": {"root": "tasks"}})
473
+ self.assertEqual(profile["opinion"]["provider"], "opinion-workflow")
474
+
475
+ def test_migrate_v5_profile_requires_recovery_dir(self) -> None:
476
+ with tempfile.TemporaryDirectory() as temporary:
477
+ root = Path(temporary)
478
+ legacy = self.write_v5_profile(root)
479
+ result = self.run_script(root, "--mode", "migrate", "--apply")
480
+ self.assertEqual(result.returncode, 2)
481
+ self.assertTrue(legacy.exists())
482
+ self.assertFalse((root / ".agents/moe.sakanano.agent-pack").exists())
483
+
484
+ def test_migrate_skips_symlinked_skill_dir_but_still_upgrades_profile(self) -> None:
485
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup, \
486
+ tempfile.TemporaryDirectory() as outside:
487
+ root = Path(temporary)
488
+ self.write_v5_profile(root)
489
+ (root / ".agents/skills").symlink_to(Path(outside), target_is_directory=True)
490
+ result = self.run_script(
491
+ root, "--mode", "migrate", "--recovery-dir", str(Path(backup) / "r"), "--apply"
492
+ )
493
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
494
+ payload = json.loads(result.stdout)
495
+ self.assertEqual(payload["skipped_symlinked_parents"], [".agents/skills/.gitkeep"])
496
+ self.assertEqual(list(Path(outside).iterdir()), [])
497
+ self.assertTrue((root / ".agents/skills").is_symlink())
498
+ self.assertTrue((root / ".agents/moe.sakanano.agent-pack/project.json").is_file())
499
+
500
+ def test_migrate_refuses_replace_through_symlinked_parent(self) -> None:
501
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as backup, \
502
+ tempfile.TemporaryDirectory() as outside:
503
+ root = Path(temporary)
504
+ (root / ".agents").mkdir()
505
+ (root / ".agents/skills").symlink_to(Path(outside), target_is_directory=True)
506
+ result = self.run_script(
507
+ root, "--mode", "migrate", "--replace", ".agents/skills/.gitkeep",
508
+ "--recovery-dir", str(Path(backup) / "r"), "--apply",
509
+ )
510
+ self.assertEqual(result.returncode, 2)
511
+ self.assertIn("symlinked parent", json.loads(result.stdout)["error"])
512
+ self.assertEqual(list(Path(outside).iterdir()), [])
513
+
514
+ def test_migrate_skip_omits_unwanted_skeleton_files(self) -> None:
515
+ with tempfile.TemporaryDirectory() as temporary:
516
+ root = Path(temporary)
517
+ result = self.run_script(
518
+ root, "--mode", "migrate",
519
+ "--skip", "docs/refs/README.md", "--skip", "docs/drafts/.gitkeep", "--apply",
520
+ )
521
+ self.assertEqual(result.returncode, 0, result.stderr or result.stdout)
522
+ self.assertFalse((root / "docs").exists())
523
+ self.assertTrue((root / "AGENTS.md").is_file())
524
+ self.assertEqual(
525
+ json.loads(result.stdout)["skipped_by_request"],
526
+ ["docs/drafts/.gitkeep", "docs/refs/README.md"],
527
+ )
528
+
529
+ def test_skip_rejected_in_init_mode_and_for_profile(self) -> None:
530
+ with tempfile.TemporaryDirectory() as temporary:
531
+ root = Path(temporary)
532
+ init = self.run_script(root, "--skip", "docs/refs/README.md")
533
+ self.assertEqual(init.returncode, 2)
534
+ profile = self.run_script(
535
+ root, "--mode", "migrate", "--skip", ".agents/moe.sakanano.agent-pack/project.json"
536
+ )
537
+ self.assertEqual(profile.returncode, 2)
538
+
539
+ def test_init_mode_refuses_v5_project(self) -> None:
540
+ with tempfile.TemporaryDirectory() as temporary:
541
+ root = Path(temporary)
542
+ self.write_v5_profile(root)
543
+ result = self.run_script(root, "--apply")
544
+ self.assertEqual(result.returncode, 2)
545
+ self.assertEqual(json.loads(result.stdout)["code"], "migrate-required")
546
+ self.assertFalse((root / "AGENTS.md").exists())
547
+
548
+ def test_migrate_replacement_requires_recovery(self) -> None:
549
+ with tempfile.TemporaryDirectory() as temporary:
550
+ root = Path(temporary)
551
+ (root / "AGENTS.md").write_text("legacy\n", encoding="utf-8")
552
+ result = self.run_script(
553
+ root, "--mode", "migrate", "--replace", "AGENTS.md", "--apply"
554
+ )
555
+ self.assertEqual(result.returncode, 2)
556
+ self.assertEqual((root / "AGENTS.md").read_text(), "legacy\n")
557
+
558
+
559
+ class EntryTests(unittest.TestCase):
560
+ """根目录启动器、Profile 的 entry 段与 README「怎样使用」。"""
561
+
562
+ def init(self, root: Path, *extra: str) -> subprocess.CompletedProcess[str]:
563
+ return subprocess.run([sys.executable, str(SCRIPT), "--project", str(root), "--name", "Notes",
564
+ "--project-type", "study", "--vcs", "none", "--stack", "none", "--runtime", "local",
565
+ "--agent-cli", "none", "--output", "json", *extra],
566
+ capture_output=True, text=True, check=False)
567
+
568
+ def entry(self, root: Path, *extra: str) -> tuple[int, dict]:
569
+ result = subprocess.run([sys.executable, str(SCRIPT), "--project", str(root), "--mode", "entry",
570
+ "--output", "json", *extra], capture_output=True, text=True, check=False)
571
+ return result.returncode, json.loads(result.stdout)
572
+
573
+ def profile(self, root: Path) -> dict:
574
+ return json.loads((root / init_project.PROFILE_PATH).read_text(encoding="utf-8"))
575
+
576
+ def test_init_creates_an_executable_launcher_readme_and_entry(self) -> None:
577
+ with tempfile.TemporaryDirectory() as temporary:
578
+ root = Path(temporary)
579
+ self.assertEqual(self.init(root, "--apply").returncode, 0)
580
+ launcher = root / "menu"
581
+ self.assertTrue(launcher.stat().st_mode & 0o111)
582
+ self.assertEqual(launcher.read_text(encoding="utf-8"), init_project.launcher_text())
583
+ self.assertEqual(self.profile(root)["entry"], {"api": 1, "launcher": "menu"})
584
+ self.assertIn("`./menu`", (root / "README.md").read_text(encoding="utf-8"))
585
+ self.assertIn("## 人用入口", (root / "AGENTS.md").read_text(encoding="utf-8"))
586
+
587
+ def test_init_keeps_an_existing_readme_and_can_skip_the_entry(self) -> None:
588
+ with tempfile.TemporaryDirectory() as temporary:
589
+ root = Path(temporary)
590
+ (root / "README.md").write_text("# Mine\n", encoding="utf-8")
591
+ self.assertEqual(self.init(root, "--no-entry", "--apply").returncode, 0)
592
+ self.assertEqual((root / "README.md").read_text(encoding="utf-8"), "# Mine\n")
593
+ self.assertNotIn("entry", self.profile(root))
594
+ self.assertFalse((root / "menu").exists())
595
+ self.assertIn("agenthelm project", (root / "AGENTS.md").read_text(encoding="utf-8"))
596
+
597
+ def test_custom_name_and_invalid_names(self) -> None:
598
+ with tempfile.TemporaryDirectory() as temporary:
599
+ root = Path(temporary)
600
+ self.assertEqual(self.init(root, "--entry-name", "go", "--apply").returncode, 0)
601
+ self.assertTrue((root / "go").is_file())
602
+ for bad in ("../x", "README.md", "a b", ".hidden"):
603
+ with self.subTest(bad=bad), tempfile.TemporaryDirectory() as temporary:
604
+ self.assertEqual(self.init(Path(temporary), "--entry-name", bad).returncode, 2)
605
+
606
+ def test_entry_mode_adds_launcher_and_profile_only(self) -> None:
607
+ with tempfile.TemporaryDirectory() as temporary:
608
+ root = Path(temporary)
609
+ self.init(root, "--no-entry", "--apply")
610
+ agents_before = (root / "AGENTS.md").read_text(encoding="utf-8")
611
+ code, preview = self.entry(root, "--entry-name", "work")
612
+ self.assertEqual((code, preview["applied"]), (0, False))
613
+ self.assertFalse((root / "work").exists())
614
+ code, result = self.entry(root, "--entry-name", "work", "--apply")
615
+ self.assertTrue(result["applied"])
616
+ self.assertTrue((root / "work").stat().st_mode & 0o111)
617
+ self.assertEqual(self.profile(root)["entry"]["launcher"], "work")
618
+ self.assertEqual((root / "AGENTS.md").read_text(encoding="utf-8"), agents_before,
619
+ "其他模板文件走正常的 refresh,不在这里改")
620
+ lock = json.loads((root / init_project.CONTENT_LOCK_PATH).read_text(encoding="utf-8"))
621
+ self.assertIn("work", lock["packs"]["agents-init/project-contract"]["files"])
622
+ code, again = self.entry(root, "--entry-name", "work", "--apply")
623
+ self.assertEqual(again["entry"]["action"], "unchanged")
624
+ code, renamed = self.entry(root, "--entry-name", "other")
625
+ self.assertEqual(code, 2, "改名需要手动修改 Profile")
626
+
627
+ def test_entry_mode_respects_an_existing_file(self) -> None:
628
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as recovery:
629
+ root = Path(temporary)
630
+ self.init(root, "--no-entry", "--apply")
631
+ (root / "work").write_text("mine\n", encoding="utf-8")
632
+ code, blocked = self.entry(root, "--entry-name", "work", "--apply")
633
+ self.assertEqual((code, blocked["applied"], blocked["blocked"]), (1, False, ["work"]))
634
+ self.assertNotIn("entry", self.profile(root))
635
+ code, kept = self.entry(root, "--entry-name", "work", "--keep", "work", "--apply")
636
+ self.assertEqual((root / "work").read_text(encoding="utf-8"), "mine\n")
637
+ self.assertEqual(self.profile(root)["entry"]["launcher"], "work")
638
+ lock = json.loads((root / init_project.CONTENT_LOCK_PATH).read_text(encoding="utf-8"))
639
+ self.assertNotIn("work", lock["packs"]["agents-init/project-contract"]["files"], "保留的文件不跟踪")
640
+ code, replaced = self.entry(root, "--entry-name", "work", "--accept", "work",
641
+ "--recovery-dir", str(Path(recovery) / "r"), "--apply")
642
+ self.assertTrue(replaced["applied"])
643
+ self.assertEqual((Path(recovery) / "r/work").read_text(encoding="utf-8"), "mine\n")
644
+ self.assertEqual((root / "work").read_text(encoding="utf-8"), init_project.launcher_text())
645
+
646
+ def test_readme_section_is_appended_once(self) -> None:
647
+ with tempfile.TemporaryDirectory() as temporary:
648
+ root = Path(temporary)
649
+ self.init(root, "--apply")
650
+ (root / "README.md").write_text("# Notes\n\nMy intro.\n", encoding="utf-8")
651
+ self.entry(root, "--entry-name", "menu", "--readme-entry", "--apply")
652
+ self.entry(root, "--entry-name", "menu", "--readme-entry", "--apply")
653
+ text = (root / "README.md").read_text(encoding="utf-8")
654
+ self.assertTrue(text.startswith("# Notes\n\nMy intro.\n"))
655
+ self.assertEqual(text.count(init_project.README_START), 1)
656
+
657
+ def test_refresh_tracks_the_launcher_and_keeps_it_executable(self) -> None:
658
+ with tempfile.TemporaryDirectory() as temporary, tempfile.TemporaryDirectory() as recovery:
659
+ root = Path(temporary)
660
+ self.init(root, "--apply")
661
+ launcher = root / "menu"
662
+ launcher.write_text("#!/bin/sh\n# an older launcher\n", encoding="utf-8")
663
+ lock_path = root / init_project.CONTENT_LOCK_PATH
664
+ lock = json.loads(lock_path.read_text(encoding="utf-8"))
665
+ lock["packs"]["agents-init/project-contract"]["files"]["menu"] = hashlib.sha256(
666
+ launcher.read_bytes()).hexdigest()
667
+ lock_path.write_text(json.dumps(lock), encoding="utf-8")
668
+ result = subprocess.run([sys.executable, str(SCRIPT), "--project", str(root), "--mode", "refresh",
669
+ "--recovery-dir", str(Path(recovery) / "r"), "--apply", "--output", "json"],
670
+ capture_output=True, text=True, check=False)
671
+ plan = {item["path"]: item for item in json.loads(result.stdout)["files"]}
672
+ self.assertEqual(plan["menu"]["action"], "replace", result.stdout)
673
+ self.assertEqual(launcher.read_text(encoding="utf-8"), init_project.launcher_text())
674
+ self.assertTrue(launcher.stat().st_mode & 0o111)
675
+
676
+
677
+ if __name__ == "__main__":
678
+ unittest.main()