@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.
- package/package.json +1 -1
- package/runtime/index.json +6 -6
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/references/local-only.md +126 -0
- package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
- package/runtime/plugins/agent-pack/references/nudge.md +58 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
- package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
- package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
- package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
- package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
- package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agents-init/content.json +17 -2
- package/runtime/plugins/agents-init/plugin.json +1 -1
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
- package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
- package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
- package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
- package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
- package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
- package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
- package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
- package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/content.json +30 -3
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
- package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
- package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
- package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
- package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
- package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
- package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
- package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
- package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
- package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
- package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
import re
|
|
13
13
|
import stat
|
|
14
14
|
import sys
|
|
15
|
+
from i18n_orchestrator import _ # noqa: E402
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
BLOCKED = {"local-ai", "confidential", ".git", ".agents", "credential"}
|
|
@@ -35,27 +36,27 @@ def rename_exclusive(source: Path, target: Path) -> None:
|
|
|
35
36
|
args = (-100, os.fsencode(source), -100, os.fsencode(target), 1)
|
|
36
37
|
function.argtypes = (ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint)
|
|
37
38
|
else:
|
|
38
|
-
raise WorkspaceError('当前平台缺少原子禁止覆盖的 rename')
|
|
39
|
+
raise WorkspaceError(_('当前平台缺少原子禁止覆盖的 rename'))
|
|
39
40
|
if function(*args) != 0:
|
|
40
41
|
code = ctypes.get_errno()
|
|
41
42
|
if code == errno.EEXIST:
|
|
42
|
-
raise WorkspaceError('目标在移动时出现,拒绝覆盖')
|
|
43
|
+
raise WorkspaceError(_('目标在移动时出现,拒绝覆盖'))
|
|
43
44
|
raise OSError(code, os.strerror(code))
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
def safe_path(root: Path, value: str) -> Path:
|
|
47
48
|
if not isinstance(value, str) or not value or "\\" in value or "\x00" in value:
|
|
48
|
-
raise WorkspaceError("非法相对路径")
|
|
49
|
+
raise WorkspaceError(_("非法相对路径"))
|
|
49
50
|
relative = Path(value)
|
|
50
51
|
if relative.is_absolute() or ".." in relative.parts or "." == value or re.match(r"^[A-Za-z]:", value):
|
|
51
|
-
raise WorkspaceError("路径必须处于项目内")
|
|
52
|
+
raise WorkspaceError(_("路径必须处于项目内"))
|
|
52
53
|
if set(relative.parts) & BLOCKED:
|
|
53
|
-
raise WorkspaceError("迁移范围包含受保护路径")
|
|
54
|
+
raise WorkspaceError(_("迁移范围包含受保护路径"))
|
|
54
55
|
path = root
|
|
55
56
|
for part in relative.parts:
|
|
56
57
|
path /= part
|
|
57
58
|
if path.is_symlink():
|
|
58
|
-
raise WorkspaceError("拒绝符号链接")
|
|
59
|
+
raise WorkspaceError(_("拒绝符号链接"))
|
|
59
60
|
return path
|
|
60
61
|
|
|
61
62
|
|
|
@@ -63,19 +64,19 @@ def case_root_path(root: Path) -> Path:
|
|
|
63
64
|
profile = root / '.agents/moe.sakanano.agent-pack/project.json'
|
|
64
65
|
for parent in (profile, profile.parent, profile.parent.parent):
|
|
65
66
|
if parent.is_symlink():
|
|
66
|
-
raise WorkspaceError('Project Profile 不允许符号链接')
|
|
67
|
+
raise WorkspaceError(_('Project Profile 不允许符号链接'))
|
|
67
68
|
name = 'cases'
|
|
68
69
|
if profile.exists():
|
|
69
70
|
if not profile.is_file() or profile.stat().st_size > 512 * 1024:
|
|
70
|
-
raise WorkspaceError('非法 Project Profile')
|
|
71
|
+
raise WorkspaceError(_('非法 Project Profile'))
|
|
71
72
|
fd = os.open(profile, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
|
|
72
73
|
with os.fdopen(fd, 'r', encoding='utf-8') as stream:
|
|
73
74
|
data = json.load(stream)
|
|
74
75
|
if not isinstance(data, dict):
|
|
75
|
-
raise WorkspaceError('Project Profile 必须是对象')
|
|
76
|
+
raise WorkspaceError(_('Project Profile 必须是对象'))
|
|
76
77
|
work = data.get('work') or {}
|
|
77
78
|
if not isinstance(work, dict) or not isinstance(work.get('case') or {}, dict):
|
|
78
|
-
raise WorkspaceError('非法 Case 配置')
|
|
79
|
+
raise WorkspaceError(_('非法 Case 配置'))
|
|
79
80
|
name = (work.get('case') or {}).get('root', name)
|
|
80
81
|
return safe_path(root, name)
|
|
81
82
|
|
|
@@ -91,7 +92,7 @@ def digest_file(path: Path) -> str:
|
|
|
91
92
|
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, 'O_NONBLOCK', 0))
|
|
92
93
|
with os.fdopen(fd, "rb") as stream:
|
|
93
94
|
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
94
|
-
raise WorkspaceError("拒绝非普通文件")
|
|
95
|
+
raise WorkspaceError(_("拒绝非普通文件"))
|
|
95
96
|
h = hashlib.sha256()
|
|
96
97
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
97
98
|
h.update(chunk)
|
|
@@ -100,9 +101,9 @@ def digest_file(path: Path) -> str:
|
|
|
100
101
|
|
|
101
102
|
def inventory(path: Path) -> dict:
|
|
102
103
|
if path.is_symlink():
|
|
103
|
-
raise WorkspaceError("拒绝符号链接")
|
|
104
|
+
raise WorkspaceError(_("拒绝符号链接"))
|
|
104
105
|
if not path.exists() or not (path.is_dir() or path.is_file()):
|
|
105
|
-
raise WorkspaceError("源目录或文件不存在")
|
|
106
|
+
raise WorkspaceError(_("源目录或文件不存在"))
|
|
106
107
|
files = []
|
|
107
108
|
directories = []
|
|
108
109
|
traversal = os.walk(path, followlinks=False) if path.is_dir() else [(path.parent, [], [path.name])]
|
|
@@ -110,13 +111,13 @@ def inventory(path: Path) -> dict:
|
|
|
110
111
|
for name in sorted(dirs):
|
|
111
112
|
child = Path(parent) / name
|
|
112
113
|
if child.is_symlink():
|
|
113
|
-
raise WorkspaceError("目录中存在符号链接")
|
|
114
|
+
raise WorkspaceError(_("目录中存在符号链接"))
|
|
114
115
|
directories.append(child.relative_to(path).as_posix())
|
|
115
116
|
for name in sorted(names):
|
|
116
117
|
child = Path(parent) / name
|
|
117
118
|
meta = child.lstat()
|
|
118
119
|
if not stat.S_ISREG(meta.st_mode):
|
|
119
|
-
raise WorkspaceError("目录中存在非普通文件")
|
|
120
|
+
raise WorkspaceError(_("目录中存在非普通文件"))
|
|
120
121
|
item = {"path": child.relative_to(path).as_posix() if path.is_dir() else '.', "bytes": meta.st_size}
|
|
121
122
|
private_ancestor = any(opaque(part) or part.name in BLOCKED for part in (child, *child.parents) if part != path.parent and path.parent in part.parents)
|
|
122
123
|
if private_ancestor or opaque(path):
|
|
@@ -133,26 +134,26 @@ def inventory(path: Path) -> dict:
|
|
|
133
134
|
|
|
134
135
|
def plan_migration(root: Path, mapping: dict) -> dict:
|
|
135
136
|
if not isinstance(mapping, dict) or mapping.get("schema_version") != "1.0":
|
|
136
|
-
raise WorkspaceError("映射必须声明 schema_version=1.0")
|
|
137
|
+
raise WorkspaceError(_("映射必须声明 schema_version=1.0"))
|
|
137
138
|
moves = mapping.get("moves")
|
|
138
139
|
records = mapping.get("records", [])
|
|
139
140
|
if not isinstance(moves, list) or not moves or not isinstance(records, list):
|
|
140
|
-
raise WorkspaceError("映射需要非空 moves 数组")
|
|
141
|
+
raise WorkspaceError(_("映射需要非空 moves 数组"))
|
|
141
142
|
source_paths, target_paths, planned = [], [], []
|
|
142
143
|
case_parts = case_root_path(root).relative_to(root).parts
|
|
143
144
|
for move in moves:
|
|
144
145
|
if not isinstance(move, dict):
|
|
145
|
-
raise WorkspaceError("非法映射项")
|
|
146
|
+
raise WorkspaceError(_("非法映射项"))
|
|
146
147
|
source = safe_path(root, move.get("source", ""))
|
|
147
148
|
target = safe_path(root, move.get("target", ""))
|
|
148
149
|
relative = target.relative_to(root)
|
|
149
150
|
if len(source.relative_to(root).parts) < 2 or len(relative.parts) < len(case_parts) + 1 or relative.parts[:len(case_parts)] != case_parts:
|
|
150
|
-
raise WorkspaceError("源需要明确子目录,目标需要位于 cases 的工作目录")
|
|
151
|
+
raise WorkspaceError(_("源需要明确子目录,目标需要位于 cases 的工作目录"))
|
|
151
152
|
if target.exists():
|
|
152
|
-
raise WorkspaceError("目标已存在,拒绝合并或覆盖")
|
|
153
|
+
raise WorkspaceError(_("目标已存在,拒绝合并或覆盖"))
|
|
153
154
|
manifest = inventory(source)
|
|
154
155
|
if move.get("fingerprint") and move["fingerprint"] != manifest["fingerprint"]:
|
|
155
|
-
raise WorkspaceError("预演后源文件发生变化")
|
|
156
|
+
raise WorkspaceError(_("预演后源文件发生变化"))
|
|
156
157
|
source_paths.append(source)
|
|
157
158
|
target_paths.append(target)
|
|
158
159
|
planned.append({"source": move["source"], "target": move["target"], "manifest": manifest})
|
|
@@ -160,25 +161,25 @@ def plan_migration(root: Path, mapping: dict) -> dict:
|
|
|
160
161
|
for index, path in enumerate(collection):
|
|
161
162
|
for other in collection[index + 1:]:
|
|
162
163
|
if path == other or path in other.parents or other in path.parents:
|
|
163
|
-
raise WorkspaceError("映射存在重复或嵌套目录")
|
|
164
|
+
raise WorkspaceError(_("映射存在重复或嵌套目录"))
|
|
164
165
|
for source in source_paths:
|
|
165
166
|
for target in target_paths:
|
|
166
167
|
if source == target or source in target.parents or target in source.parents:
|
|
167
|
-
raise WorkspaceError("源与目标重叠")
|
|
168
|
+
raise WorkspaceError(_("源与目标重叠"))
|
|
168
169
|
record_paths = []
|
|
169
170
|
for record in records:
|
|
170
171
|
path = safe_path(root, record.get("path", ""))
|
|
171
172
|
parts = path.relative_to(root).parts
|
|
172
173
|
content = record.get("content")
|
|
173
174
|
if len(parts) != len(case_parts) + 2 or parts[:len(case_parts)] != case_parts or parts[-1] != "CASE.md" or not isinstance(content, str):
|
|
174
|
-
raise WorkspaceError("新增记录只能是完整 CASE.md")
|
|
175
|
+
raise WorkspaceError(_("新增记录只能是完整 CASE.md"))
|
|
175
176
|
if path.exists() or path in record_paths or len(content.encode()) > 2 * 1024 * 1024:
|
|
176
|
-
raise WorkspaceError("新增记录冲突或超出大小限制")
|
|
177
|
+
raise WorkspaceError(_("新增记录冲突或超出大小限制"))
|
|
177
178
|
if path.parent not in target_paths:
|
|
178
|
-
raise WorkspaceError("新增 CASE.md 必须属于本次迁移目录")
|
|
179
|
+
raise WorkspaceError(_("新增 CASE.md 必须属于本次迁移目录"))
|
|
179
180
|
source = source_paths[target_paths.index(path.parent)]
|
|
180
181
|
if not source.is_dir() or (source / "CASE.md").exists():
|
|
181
|
-
raise WorkspaceError("源已有 CASE.md,请使用项目工具维护现有记录")
|
|
182
|
+
raise WorkspaceError(_("源已有 CASE.md,请使用项目工具维护现有记录"))
|
|
182
183
|
record_paths.append(path)
|
|
183
184
|
return {"ok": True, "applied": False, "moves": planned, "records": [r["path"] for r in records],
|
|
184
185
|
"file_count": sum(item["manifest"]["file_count"] for item in planned),
|
|
@@ -197,7 +198,7 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
|
|
|
197
198
|
target = safe_path(root, move["target"])
|
|
198
199
|
# 再核对源指纹,防止预演后并发变动被静默接纳。
|
|
199
200
|
if inventory(source) != move["manifest"]:
|
|
200
|
-
raise WorkspaceError("源目录在应用前发生变化")
|
|
201
|
+
raise WorkspaceError(_("源目录在应用前发生变化"))
|
|
201
202
|
missing = []
|
|
202
203
|
parent = target.parent
|
|
203
204
|
while not parent.exists():
|
|
@@ -207,13 +208,13 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
|
|
|
207
208
|
parent.mkdir()
|
|
208
209
|
directories.append(parent)
|
|
209
210
|
if target.exists():
|
|
210
|
-
raise WorkspaceError("目标在应用前出现")
|
|
211
|
+
raise WorkspaceError(_("目标在应用前出现"))
|
|
211
212
|
if source.stat().st_dev != target.parent.stat().st_dev:
|
|
212
|
-
raise WorkspaceError("跨文件系统移动不受支持")
|
|
213
|
+
raise WorkspaceError(_("跨文件系统移动不受支持"))
|
|
213
214
|
rename_exclusive(source, target)
|
|
214
215
|
moved.append((source, target))
|
|
215
216
|
if inventory(target) != move["manifest"]:
|
|
216
|
-
raise WorkspaceError("移动后内容核验失败")
|
|
217
|
+
raise WorkspaceError(_("移动后内容核验失败"))
|
|
217
218
|
for record in mapping.get("records", []):
|
|
218
219
|
path = safe_path(root, record["path"])
|
|
219
220
|
with path.open("x", encoding="utf-8") as stream:
|
|
@@ -228,7 +229,7 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
|
|
|
228
229
|
path.unlink()
|
|
229
230
|
for source, target in reversed(moved):
|
|
230
231
|
if source.exists():
|
|
231
|
-
raise WorkspaceError("回滚源路径冲突,请按映射人工恢复")
|
|
232
|
+
raise WorkspaceError(_("回滚源路径冲突,请按映射人工恢复"))
|
|
232
233
|
rename_exclusive(target, source)
|
|
233
234
|
for directory in reversed(directories):
|
|
234
235
|
try:
|
|
@@ -240,16 +241,16 @@ def migrate(root: Path, mapping: dict, *, apply: bool = False) -> dict:
|
|
|
240
241
|
|
|
241
242
|
def metadata(path: Path) -> dict:
|
|
242
243
|
if path.is_symlink() or not path.is_file() or path.stat().st_size > 2 * 1024 * 1024:
|
|
243
|
-
raise WorkspaceError("非法或过大的 Case 记录")
|
|
244
|
+
raise WorkspaceError(_("非法或过大的 Case 记录"))
|
|
244
245
|
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_NONBLOCK', 0))
|
|
245
246
|
with os.fdopen(fd, 'r', encoding='utf-8') as stream:
|
|
246
247
|
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
|
247
|
-
raise WorkspaceError('Case 记录必须是普通文件')
|
|
248
|
+
raise WorkspaceError(_('Case 记录必须是普通文件'))
|
|
248
249
|
source = stream.read(2 * 1024 * 1024 + 1)
|
|
249
250
|
if len(source.encode()) > 2 * 1024 * 1024:
|
|
250
|
-
raise WorkspaceError('Case 记录过大')
|
|
251
|
+
raise WorkspaceError(_('Case 记录过大'))
|
|
251
252
|
if not source.startswith("---\n") or "\n---" not in source[4:]:
|
|
252
|
-
raise WorkspaceError("Case 缺少 Frontmatter")
|
|
253
|
+
raise WorkspaceError(_("Case 缺少 Frontmatter"))
|
|
253
254
|
header = source.split("\n---", 1)[0][4:]
|
|
254
255
|
data, key = {}, None
|
|
255
256
|
for line in header.splitlines():
|
|
@@ -260,19 +261,19 @@ def metadata(path: Path) -> dict:
|
|
|
260
261
|
except json.JSONDecodeError:
|
|
261
262
|
value = raw.strip("\"'")
|
|
262
263
|
if not isinstance(data[key], list):
|
|
263
|
-
raise WorkspaceError("非法数组字段")
|
|
264
|
+
raise WorkspaceError(_("非法数组字段"))
|
|
264
265
|
data[key].append(value)
|
|
265
266
|
elif re.match(r"^[a-z_]+:", line):
|
|
266
267
|
key, raw = line.split(":", 1)
|
|
267
268
|
if key in data:
|
|
268
|
-
raise WorkspaceError("Case 存在重复字段")
|
|
269
|
+
raise WorkspaceError(_("Case 存在重复字段"))
|
|
269
270
|
raw = raw.strip()
|
|
270
271
|
try:
|
|
271
272
|
data[key] = json.loads(raw) if raw else []
|
|
272
273
|
except json.JSONDecodeError:
|
|
273
274
|
data[key] = raw.strip("\"'")
|
|
274
275
|
if not all(isinstance(data.get(k), str) and data[k] for k in ("id", "title", "status")):
|
|
275
|
-
raise WorkspaceError("Case 缺少身份与状态")
|
|
276
|
+
raise WorkspaceError(_("Case 缺少身份与状态"))
|
|
276
277
|
return data
|
|
277
278
|
|
|
278
279
|
|
|
@@ -284,18 +285,18 @@ def inspect_cases(root: Path, terms: list[str] | None = None) -> dict:
|
|
|
284
285
|
words = [term.casefold() for term in terms or []]
|
|
285
286
|
for directory in sorted(case_root.iterdir()):
|
|
286
287
|
if directory.is_symlink():
|
|
287
|
-
invalid.append({"path": directory.relative_to(root).as_posix(), "error": "符号链接"})
|
|
288
|
+
invalid.append({"path": directory.relative_to(root).as_posix(), "error": _("符号链接")})
|
|
288
289
|
continue
|
|
289
290
|
if not directory.is_dir():
|
|
290
291
|
continue
|
|
291
292
|
if opaque(directory) or directory.name in BLOCKED:
|
|
292
|
-
invalid.append({'path': directory.relative_to(root).as_posix(), 'error': '隐私路径'})
|
|
293
|
+
invalid.append({'path': directory.relative_to(root).as_posix(), 'error': _('隐私路径')})
|
|
293
294
|
continue
|
|
294
295
|
try:
|
|
295
296
|
record = directory / "CASE.md"
|
|
296
297
|
data = metadata(record)
|
|
297
298
|
if data['id'] != directory.name or data.get('sensitivity', 'shared') != 'shared':
|
|
298
|
-
raise WorkspaceError('Case 身份或隐私位置不一致')
|
|
299
|
+
raise WorkspaceError(_('Case 身份或隐私位置不一致'))
|
|
299
300
|
files = []
|
|
300
301
|
size = 0
|
|
301
302
|
for parent, dirs, names in os.walk(directory, followlinks=False):
|
|
@@ -320,7 +321,7 @@ def inspect_cases(root: Path, terms: list[str] | None = None) -> dict:
|
|
|
320
321
|
|
|
321
322
|
|
|
322
323
|
def main(argv=None) -> int:
|
|
323
|
-
parser = argparse.ArgumentParser(description=
|
|
324
|
+
parser = argparse.ArgumentParser(description=_("Case 目录盘点、检索与已审阅映射的无损迁移;不代替项目状态工具。"))
|
|
324
325
|
parser.add_argument("command", choices=("inspect", "search", "migrate"))
|
|
325
326
|
parser.add_argument("--project", type=Path, required=True)
|
|
326
327
|
parser.add_argument("--map", type=Path)
|
|
@@ -329,15 +330,15 @@ def main(argv=None) -> int:
|
|
|
329
330
|
args = parser.parse_args(argv)
|
|
330
331
|
try:
|
|
331
332
|
if args.project.is_symlink() or not args.project.is_dir():
|
|
332
|
-
raise WorkspaceError("项目根目录必须是实际目录")
|
|
333
|
+
raise WorkspaceError(_("项目根目录必须是实际目录"))
|
|
333
334
|
root = args.project.resolve()
|
|
334
335
|
if args.command == "migrate":
|
|
335
336
|
if not args.map or args.map.is_symlink():
|
|
336
|
-
raise WorkspaceError("迁移需要普通映射文件")
|
|
337
|
+
raise WorkspaceError(_("迁移需要普通映射文件"))
|
|
337
338
|
result = migrate(root, json.loads(args.map.read_text(encoding="utf-8")), apply=args.apply)
|
|
338
339
|
else:
|
|
339
340
|
if args.apply or args.map:
|
|
340
|
-
raise WorkspaceError("只读命令不支持 apply 或 map")
|
|
341
|
+
raise WorkspaceError(_("只读命令不支持 apply 或 map"))
|
|
341
342
|
result = inspect_cases(root, args.term if args.command == "search" else None)
|
|
342
343
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
343
344
|
return 0 if result["ok"] else 1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""运行时文案的多语言:源语言是中文,``_("中文原文")`` 在中文下原样返回,在英文下查 ``locale/en.json``。
|
|
2
|
+
|
|
3
|
+
语言按这个顺序决定:调用方指定(如 ``nudge.language``)→ 环境变量 ``AGENTHELM_LANG`` → 用户配置
|
|
4
|
+
``~/.config/agenthelm/config.json`` 的 ``language`` → 系统 locale(``LC_ALL``、``LC_MESSAGES``、``LANG``)→ 英文。
|
|
5
|
+
``auto``、空值和 C/POSIX 视为未指定;中文 locale 用中文,其他语言的 locale 暂用英文。
|
|
6
|
+
没有英文译文的文案原样输出(中文),测试守住译文完整。四个插件各带一份相同的本模块(模块名不同,以免
|
|
7
|
+
同一进程里互相覆盖),``tests/test_i18n_parity.py`` 守住它们行为一致。模块级常量里的文案用 ``N_`` 标记
|
|
8
|
+
(原样返回,仅供提取),使用处再调用 ``_``,这样语言在运行时而不是导入时决定。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Mapping
|
|
17
|
+
|
|
18
|
+
LANGUAGES = ("zh", "en")
|
|
19
|
+
DEFAULT_LANGUAGE = "en"
|
|
20
|
+
LOCALE_DIR = Path(__file__).resolve().parent / "locale"
|
|
21
|
+
_catalogs: dict[str, dict[str, str]] = {}
|
|
22
|
+
_configured: dict[str, tuple[tuple, str | None]] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize(value: object) -> str | None:
|
|
26
|
+
text = str(value or "").strip().lower().replace("_", "-")
|
|
27
|
+
if not text or text in ("auto", "c", "posix") or text.startswith(("c.", "posix.")):
|
|
28
|
+
return None
|
|
29
|
+
return "zh" if text.startswith("zh") else "en"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def configured_language(environ: Mapping[str, str]) -> str | None:
|
|
33
|
+
"""用户配置里的 language;按文件修改时间缓存,读不了就当没有。"""
|
|
34
|
+
base = environ.get("XDG_CONFIG_HOME") or (str(Path(environ["HOME"]) / ".config") if environ.get("HOME") else "")
|
|
35
|
+
if not base:
|
|
36
|
+
return None
|
|
37
|
+
path = Path(base) / "agenthelm" / "config.json"
|
|
38
|
+
try:
|
|
39
|
+
info = path.stat()
|
|
40
|
+
signature = (info.st_mtime_ns, info.st_size)
|
|
41
|
+
cached = _configured.get(str(path))
|
|
42
|
+
if cached and cached[0] == signature:
|
|
43
|
+
return cached[1]
|
|
44
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
value = normalize(data.get("language")) if isinstance(data, dict) else None
|
|
46
|
+
except (OSError, ValueError):
|
|
47
|
+
return None
|
|
48
|
+
_configured[str(path)] = (signature, value)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_language(requested: str | None = None, environ: Mapping[str, str] | None = None) -> str:
|
|
53
|
+
environ = os.environ if environ is None else environ
|
|
54
|
+
for candidate in (requested, environ.get("AGENTHELM_LANG"), configured_language(environ),
|
|
55
|
+
environ.get("LC_ALL"), environ.get("LC_MESSAGES"), environ.get("LANG")):
|
|
56
|
+
language = normalize(candidate)
|
|
57
|
+
if language:
|
|
58
|
+
return language
|
|
59
|
+
return DEFAULT_LANGUAGE
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def catalog(language: str) -> dict[str, str]:
|
|
63
|
+
if language not in _catalogs:
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads((LOCALE_DIR / f"{language}.json").read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, ValueError):
|
|
67
|
+
data = {}
|
|
68
|
+
_catalogs[language] = data if isinstance(data, dict) else {}
|
|
69
|
+
return _catalogs[language]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _(message: str) -> str:
|
|
73
|
+
language = resolve_language()
|
|
74
|
+
return message if language == "zh" else catalog(language).get(message, message)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def N_(message: str) -> str:
|
|
78
|
+
return message
|