ai-rules-sync 1.0.0__tar.gz
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.
- ai_rules_sync-1.0.0/PKG-INFO +61 -0
- ai_rules_sync-1.0.0/README.md +45 -0
- ai_rules_sync-1.0.0/pyproject.toml +38 -0
- ai_rules_sync-1.0.0/setup.cfg +4 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync/__init__.py +3 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync/__main__.py +8 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync/core.py +307 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync/data/AGENTS.md +301 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync.egg-info/PKG-INFO +61 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync.egg-info/SOURCES.txt +12 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync.egg-info/dependency_links.txt +1 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync.egg-info/entry_points.txt +2 -0
- ai_rules_sync-1.0.0/src/ai_rules_sync.egg-info/top_level.txt +1 -0
- ai_rules_sync-1.0.0/tests/test_core.py +202 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-rules-sync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 跨平台同步 AI 编程 Agent 的中文代码注释规范
|
|
5
|
+
Author: AI Rules Sync contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ai,agent,rules,codex,claude,qoder
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# AI Rules Sync
|
|
18
|
+
|
|
19
|
+
AI Rules Sync 是一个只依赖 Python 标准库的跨平台命令行工具,用一份中文代码注释规范同步多个 AI 编程 Agent 的全局规则。
|
|
20
|
+
|
|
21
|
+
## 使用方式
|
|
22
|
+
|
|
23
|
+
无需克隆仓库或安装 Python 包,直接使用 `uvx`:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# 查看当前电脑检测到的 Agent
|
|
27
|
+
uvx ai-rules-sync --list
|
|
28
|
+
|
|
29
|
+
# 同步规则
|
|
30
|
+
uvx ai-rules-sync
|
|
31
|
+
|
|
32
|
+
# 预览变更,不写入文件
|
|
33
|
+
uvx ai-rules-sync --dry-run
|
|
34
|
+
|
|
35
|
+
# 检查是否需要同步;需要同步时返回退出码 1
|
|
36
|
+
uvx ai-rules-sync --check
|
|
37
|
+
|
|
38
|
+
# 只同步指定 Agent
|
|
39
|
+
uvx ai-rules-sync --agents codex,claude
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
也可以指定配置目录:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uvx ai-rules-sync --config-dir /path/to/config
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 支持的 Agent
|
|
49
|
+
|
|
50
|
+
内置支持 Codex、Claude Code、Hermes 和 Qoder。工具会自动检测已安装的 Agent;未检测到的 Agent 会跳过,不会创建配置目录。使用 `--agents` 可以显式初始化指定 Agent。
|
|
51
|
+
|
|
52
|
+
同步过程只替换受管标记内的规则内容,会保留 Agent 原有配置,并在写入前创建备份。
|
|
53
|
+
|
|
54
|
+
## 本机配置
|
|
55
|
+
|
|
56
|
+
共享规则随 PyPI wheel 一起发布。安装后的本机覆盖配置和备份默认保存在:
|
|
57
|
+
|
|
58
|
+
- macOS/Linux:`~/.config/ai-rules-sync/`
|
|
59
|
+
- Windows:`%APPDATA%\\ai-rules-sync\\`
|
|
60
|
+
|
|
61
|
+
也可以通过 `AI_RULES_HOME` 或 `--config-dir` 指定配置目录。完整的配置格式、安全策略和开发说明请参阅项目中的 [使用说明书](docs/USAGE.md)。
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# AI Rules Sync
|
|
2
|
+
|
|
3
|
+
AI Rules Sync 是一个只依赖 Python 标准库的跨平台命令行工具,用一份中文代码注释规范同步多个 AI 编程 Agent 的全局规则。
|
|
4
|
+
|
|
5
|
+
## 使用方式
|
|
6
|
+
|
|
7
|
+
无需克隆仓库或安装 Python 包,直接使用 `uvx`:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
# 查看当前电脑检测到的 Agent
|
|
11
|
+
uvx ai-rules-sync --list
|
|
12
|
+
|
|
13
|
+
# 同步规则
|
|
14
|
+
uvx ai-rules-sync
|
|
15
|
+
|
|
16
|
+
# 预览变更,不写入文件
|
|
17
|
+
uvx ai-rules-sync --dry-run
|
|
18
|
+
|
|
19
|
+
# 检查是否需要同步;需要同步时返回退出码 1
|
|
20
|
+
uvx ai-rules-sync --check
|
|
21
|
+
|
|
22
|
+
# 只同步指定 Agent
|
|
23
|
+
uvx ai-rules-sync --agents codex,claude
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
也可以指定配置目录:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
uvx ai-rules-sync --config-dir /path/to/config
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## 支持的 Agent
|
|
33
|
+
|
|
34
|
+
内置支持 Codex、Claude Code、Hermes 和 Qoder。工具会自动检测已安装的 Agent;未检测到的 Agent 会跳过,不会创建配置目录。使用 `--agents` 可以显式初始化指定 Agent。
|
|
35
|
+
|
|
36
|
+
同步过程只替换受管标记内的规则内容,会保留 Agent 原有配置,并在写入前创建备份。
|
|
37
|
+
|
|
38
|
+
## 本机配置
|
|
39
|
+
|
|
40
|
+
共享规则随 PyPI wheel 一起发布。安装后的本机覆盖配置和备份默认保存在:
|
|
41
|
+
|
|
42
|
+
- macOS/Linux:`~/.config/ai-rules-sync/`
|
|
43
|
+
- Windows:`%APPDATA%\\ai-rules-sync\\`
|
|
44
|
+
|
|
45
|
+
也可以通过 `AI_RULES_HOME` 或 `--config-dir` 指定配置目录。完整的配置格式、安全策略和开发说明请参阅项目中的 [使用说明书](docs/USAGE.md)。
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ai-rules-sync"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "跨平台同步 AI 编程 Agent 的中文代码注释规范"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "AI Rules Sync contributors" }]
|
|
13
|
+
keywords = ["ai", "agent", "rules", "codex", "claude", "qoder"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
ai-rules-sync = "ai_rules_sync.core:main"
|
|
26
|
+
|
|
27
|
+
[tool.uv]
|
|
28
|
+
# 明确这是可安装的包项目,便于使用 uv run、uv sync 和 uv build。
|
|
29
|
+
package = true
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
ai_rules_sync = ["data/*.md"]
|
|
36
|
+
|
|
37
|
+
[tool.unittest]
|
|
38
|
+
start-directory = "tests"
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""跨平台同步中文注释规范;自动跳过未检测到的 Agent,支持自定义全局入口。"""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import datetime
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
|
|
17
|
+
BEGIN = '<!-- ai-rules:chinese-comments:begin -->'
|
|
18
|
+
END = '<!-- ai-rules:chinese-comments:end -->'
|
|
19
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
20
|
+
PACKAGED_RULES = PACKAGE_DIR / 'data' / 'AGENTS.md'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_root():
|
|
24
|
+
"""返回配置与备份目录;兼容源码运行和已安装 wheel 的两种布局。"""
|
|
25
|
+
configured = os.environ.get('AI_RULES_HOME')
|
|
26
|
+
if configured:
|
|
27
|
+
return Path(configured).expanduser()
|
|
28
|
+
source_root = PACKAGE_DIR.parents[1] / 'config'
|
|
29
|
+
if (source_root / 'AGENTS.md').is_file():
|
|
30
|
+
return source_root
|
|
31
|
+
if os.name == 'nt':
|
|
32
|
+
base = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming'))
|
|
33
|
+
else:
|
|
34
|
+
base = Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config'))
|
|
35
|
+
return base / 'ai-rules-sync'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# 源码仓库优先使用项目内 config/;安装后使用用户配置目录保存本机覆盖项和备份,
|
|
39
|
+
# 共享规则本身从随 wheel 发布的 data/AGENTS.md 读取。
|
|
40
|
+
ROOT = default_root()
|
|
41
|
+
BUILTINS = {
|
|
42
|
+
'codex': {'path': '~/.codex/AGENTS.md', 'home_env': 'CODEX_HOME',
|
|
43
|
+
'relative': 'AGENTS.md', 'commands': ['codex']},
|
|
44
|
+
'claude': {'path': '~/.claude/CLAUDE.md', 'home_env': 'CLAUDE_CONFIG_DIR',
|
|
45
|
+
'relative': 'CLAUDE.md', 'commands': ['claude']},
|
|
46
|
+
'hermes': {'path': '~/.hermes/SOUL.md', 'home_env': 'HERMES_HOME',
|
|
47
|
+
'relative': 'SOUL.md', 'commands': ['hermes']},
|
|
48
|
+
'qoder': {'path': '~/.qoder/rules/ai-chinese-comments.md',
|
|
49
|
+
'commands': ['qoder', 'qodercli'], 'format': 'qoder'},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def system_name():
|
|
54
|
+
"""返回配置使用的平台名称;WSL 按独立的 Linux 环境处理。"""
|
|
55
|
+
return 'windows' if os.name == 'nt' else ('macos' if sys.platform == 'darwin' else 'linux')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def expand_path(value, home, env, base):
|
|
59
|
+
"""展开当前用户目录及两种环境变量写法;相对路径基于配置目录。"""
|
|
60
|
+
if not isinstance(value, str) or not value.strip():
|
|
61
|
+
raise ValueError('入口路径必须是非空字符串')
|
|
62
|
+
def substitute(match):
|
|
63
|
+
"""变量不存在时报告错误,避免创建名称中带变量占位符的目录。"""
|
|
64
|
+
key = next(part for part in match.groups() if part is not None)
|
|
65
|
+
if not env.get(key):
|
|
66
|
+
raise ValueError('环境变量未设置:' + key)
|
|
67
|
+
return env[key]
|
|
68
|
+
value = re.sub(r'\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)|%([^%]+)%', substitute, value)
|
|
69
|
+
if value == '~':
|
|
70
|
+
return home
|
|
71
|
+
if value.startswith(('~/', '~\\')):
|
|
72
|
+
return home / value[2:]
|
|
73
|
+
path = Path(value)
|
|
74
|
+
return path if path.is_absolute() else base / path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_agents(root):
|
|
78
|
+
"""读取可共享的配置,再合并本机覆盖项;拒绝旧版绝对路径配置。"""
|
|
79
|
+
agents = {name: dict(spec, enabled='auto', format=spec.get('format', 'markdown'))
|
|
80
|
+
for name, spec in BUILTINS.items()}
|
|
81
|
+
for filename in ['targets.json', 'targets.local.json']:
|
|
82
|
+
path = root / filename
|
|
83
|
+
if not path.exists():
|
|
84
|
+
continue
|
|
85
|
+
config = json.loads(path.read_text(encoding='utf-8-sig'))
|
|
86
|
+
if config.get('version') != 2 or not isinstance(config.get('agents'), dict):
|
|
87
|
+
raise ValueError(filename + ' 必须使用 version: 2;请勿沿用旧版机器绝对路径配置')
|
|
88
|
+
for name, entry in config['agents'].items():
|
|
89
|
+
if not re.fullmatch(r'[a-z][a-z0-9_-]*', name) or not isinstance(entry, dict):
|
|
90
|
+
raise ValueError('Agent 名称或配置无效:' + str(name))
|
|
91
|
+
extra = set(entry) - {'enabled', 'path', 'commands', 'format'}
|
|
92
|
+
if extra:
|
|
93
|
+
raise ValueError('未知配置字段:' + name + ': ' + ', '.join(extra))
|
|
94
|
+
spec = agents.setdefault(name, {'enabled': 'auto', 'format': 'markdown', 'commands': []})
|
|
95
|
+
spec.update(entry)
|
|
96
|
+
if 'path' in entry:
|
|
97
|
+
spec.pop('home_env', None)
|
|
98
|
+
spec.pop('relative', None)
|
|
99
|
+
for name, spec in agents.items():
|
|
100
|
+
enabled = spec['enabled']
|
|
101
|
+
if not (enabled == 'auto' or type(enabled) is bool) or spec['format'] not in ('markdown', 'qoder'):
|
|
102
|
+
raise ValueError('启用方式或格式无效:' + name)
|
|
103
|
+
if 'path' not in spec or not isinstance(spec['commands'], list) or not all(isinstance(x, str) for x in spec['commands']):
|
|
104
|
+
raise ValueError('缺少 path 或 commands 格式无效:' + name)
|
|
105
|
+
return agents
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def resolve_target(name, spec, root, home, env, platform, which):
|
|
109
|
+
"""计算本机入口并检测配置目录、命令或已知 GUI 安装位置。"""
|
|
110
|
+
value = spec['path']
|
|
111
|
+
if isinstance(value, dict):
|
|
112
|
+
value = value.get(platform, value.get('default'))
|
|
113
|
+
if value is None:
|
|
114
|
+
return None, '此平台未配置入口'
|
|
115
|
+
env_name = spec.get('home_env')
|
|
116
|
+
if env_name and env.get(env_name):
|
|
117
|
+
config_home = expand_path(env[env_name], home, env, root)
|
|
118
|
+
path = config_home / spec['relative']
|
|
119
|
+
else:
|
|
120
|
+
path = expand_path(value, home, env, root)
|
|
121
|
+
config_home = path.parent.parent if spec['format'] == 'qoder' else path.parent
|
|
122
|
+
if path.exists():
|
|
123
|
+
return path, '已有规则文件'
|
|
124
|
+
if config_home.is_dir():
|
|
125
|
+
return path, '已有配置目录'
|
|
126
|
+
if any(which(command) for command in spec['commands']):
|
|
127
|
+
return path, '检测到命令'
|
|
128
|
+
if platform == 'macos' and name in ('codex', 'qoder'):
|
|
129
|
+
app = 'Codex.app' if name == 'codex' else 'Qoder.app'
|
|
130
|
+
if any((folder / app).exists() for folder in [Path('/Applications'), home / 'Applications']):
|
|
131
|
+
return path, '检测到桌面应用'
|
|
132
|
+
if platform == 'windows' and name == 'qoder' and env.get('LOCALAPPDATA'):
|
|
133
|
+
if (Path(env['LOCALAPPDATA']) / 'Programs/Qoder/Qoder.exe').exists():
|
|
134
|
+
return path, '检测到桌面应用'
|
|
135
|
+
return path, ''
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def replace_section(original, content):
|
|
139
|
+
"""替换本工具的规则段,保留其他文字、BOM 和已有换行形式。"""
|
|
140
|
+
newline = '\r\n' if '\r\n' in original else '\n'
|
|
141
|
+
block = BEGIN + newline + content.replace('\r\n', '\n').strip().replace('\n', newline) + newline + END
|
|
142
|
+
if BEGIN not in original and END not in original:
|
|
143
|
+
separator = '' if not original else (newline if original.endswith('\n') else newline * 2)
|
|
144
|
+
return original + separator + block + newline
|
|
145
|
+
if original.count(BEGIN) != 1 or original.count(END) != 1:
|
|
146
|
+
raise ValueError('受管段标记重复或缺失,请先修复')
|
|
147
|
+
start, end = original.index(BEGIN), original.index(END)
|
|
148
|
+
if end < start:
|
|
149
|
+
raise ValueError('受管段标记顺序错误')
|
|
150
|
+
return original[:start] + block + original[end + len(END):]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def snapshot(path):
|
|
154
|
+
"""读取原始字节;用 None 区分不存在和空文件,并拒绝覆盖符号链接。"""
|
|
155
|
+
if path.is_symlink():
|
|
156
|
+
raise ValueError('入口是符号链接,请在本机配置中明确其目标:' + str(path))
|
|
157
|
+
return path.read_bytes() if path.exists() else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def atomic_write(path, content, mode):
|
|
161
|
+
"""同目录写入并原子替换;Windows 沿用目录权限,POSIX 保留文件模式。"""
|
|
162
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
fd, name = tempfile.mkstemp(prefix='.ai-rules-', dir=path.parent)
|
|
164
|
+
try:
|
|
165
|
+
with os.fdopen(fd, 'wb') as stream:
|
|
166
|
+
stream.write(content)
|
|
167
|
+
if os.name != 'nt':
|
|
168
|
+
os.chmod(name, mode)
|
|
169
|
+
os.replace(name, path)
|
|
170
|
+
finally:
|
|
171
|
+
if os.path.exists(name):
|
|
172
|
+
os.unlink(name)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def apply_changes(changes, root):
|
|
176
|
+
"""先完整备份再写入;失败时回滚尚未被其他程序修改的已写文件。"""
|
|
177
|
+
stamp = datetime.datetime.now().strftime('%Y%m%d-%H%M%S-%f')
|
|
178
|
+
backup = root / 'backups' / stamp
|
|
179
|
+
backup.mkdir(parents=True, mode=0o700)
|
|
180
|
+
manifest = []
|
|
181
|
+
for name, path, old, new in changes:
|
|
182
|
+
if snapshot(path) != old:
|
|
183
|
+
raise RuntimeError('文件已被其他程序修改:' + str(path))
|
|
184
|
+
filename = hashlib.sha256(str(path).encode()).hexdigest()[:12] + '-' + path.name
|
|
185
|
+
mode = path.stat().st_mode & 0o777 if old is not None else 0o600
|
|
186
|
+
if old is not None:
|
|
187
|
+
shutil.copy2(path, backup / filename)
|
|
188
|
+
manifest.append({'path': str(path), 'existed': old is not None,
|
|
189
|
+
'backup': filename if old is not None else None, 'mode': mode})
|
|
190
|
+
(backup / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
191
|
+
written = []
|
|
192
|
+
try:
|
|
193
|
+
for item, record in zip(changes, manifest):
|
|
194
|
+
name, path, old, new = item
|
|
195
|
+
if snapshot(path) != old:
|
|
196
|
+
raise RuntimeError('文件已被其他程序修改:' + str(path))
|
|
197
|
+
atomic_write(path, new, record['mode'])
|
|
198
|
+
written.append((item, record))
|
|
199
|
+
print('已同步 ' + name + ': ' + str(path))
|
|
200
|
+
except Exception:
|
|
201
|
+
for (name, path, old, new), record in reversed(written):
|
|
202
|
+
try:
|
|
203
|
+
if snapshot(path) != new:
|
|
204
|
+
print('回滚保留并发修改:' + str(path), file=sys.stderr)
|
|
205
|
+
elif old is None:
|
|
206
|
+
path.unlink()
|
|
207
|
+
else:
|
|
208
|
+
atomic_write(path, old, record['mode'])
|
|
209
|
+
except (OSError, ValueError) as error:
|
|
210
|
+
print('回滚失败,请使用备份恢复:' + str(error), file=sys.stderr)
|
|
211
|
+
print('备份:' + str(backup), file=sys.stderr)
|
|
212
|
+
raise
|
|
213
|
+
print('备份:' + str(backup))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def main(argv=None, root=ROOT):
|
|
217
|
+
"""检测、预览、检查或同步入口;显式选择允许为指定 Agent 初始化配置。"""
|
|
218
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
219
|
+
parser.add_argument('--version', action='version', version='%(prog)s ' + __version__)
|
|
220
|
+
parser.add_argument('--config-dir', type=Path,
|
|
221
|
+
help='规则与 targets.json 所在目录;默认使用项目 config/、AI_RULES_HOME 或用户配置目录')
|
|
222
|
+
parser.add_argument('--agents', help='只同步指定 Agent,逗号分隔;可显式初始化未检测到的工具')
|
|
223
|
+
modes = parser.add_mutually_exclusive_group()
|
|
224
|
+
modes.add_argument('--list', action='store_true', help='仅列出检测结果')
|
|
225
|
+
modes.add_argument('--check', action='store_true', help='检查一致性,需同步时退出码为 1')
|
|
226
|
+
modes.add_argument('--dry-run', action='store_true', help='预览变更,不写入')
|
|
227
|
+
args = parser.parse_args(argv)
|
|
228
|
+
if args.config_dir is not None:
|
|
229
|
+
root = args.config_dir.expanduser().resolve()
|
|
230
|
+
agents = load_agents(root)
|
|
231
|
+
selected = {x.strip() for x in args.agents.split(',') if x.strip()} if args.agents is not None else None
|
|
232
|
+
if selected is not None and (not selected or selected - agents.keys()):
|
|
233
|
+
raise ValueError('指定了空列表或未登记的 Agent;请先在 targets.json 中配置')
|
|
234
|
+
targets = []
|
|
235
|
+
home, env, platform = Path.home(), dict(os.environ), system_name()
|
|
236
|
+
print('平台:' + platform + ';当前用户目录:' + str(home))
|
|
237
|
+
for name, spec in agents.items():
|
|
238
|
+
if selected is not None and name not in selected:
|
|
239
|
+
continue
|
|
240
|
+
if selected is None and spec['enabled'] is False:
|
|
241
|
+
print('跳过 ' + name + ':已禁用')
|
|
242
|
+
continue
|
|
243
|
+
path, reason = resolve_target(name, spec, root, home, env, platform, shutil.which)
|
|
244
|
+
if path is None:
|
|
245
|
+
if selected or spec['enabled'] is True:
|
|
246
|
+
raise ValueError(name + ':' + reason)
|
|
247
|
+
print('跳过 ' + name + ':' + reason)
|
|
248
|
+
continue
|
|
249
|
+
enabled = selected is not None or spec['enabled'] is True or bool(reason)
|
|
250
|
+
print(('启用 ' if enabled else '跳过 ') + name + ': ' + str(path) + '(' + (reason or ('显式启用' if enabled else '未检测到')) + ')')
|
|
251
|
+
if enabled:
|
|
252
|
+
targets.append((name, path, spec))
|
|
253
|
+
if args.list:
|
|
254
|
+
return 0
|
|
255
|
+
if not targets:
|
|
256
|
+
print('没有可同步的 Agent,未创建任何配置。')
|
|
257
|
+
return 1 if args.check else 0
|
|
258
|
+
rules_path = root / 'AGENTS.md'
|
|
259
|
+
if not rules_path.is_file() and PACKAGED_RULES.is_file():
|
|
260
|
+
rules_path = PACKAGED_RULES
|
|
261
|
+
rules = rules_path.read_text(encoding='utf-8-sig').strip()
|
|
262
|
+
if not rules or BEGIN in rules or END in rules:
|
|
263
|
+
raise ValueError('共享规范为空或包含保留的受管段标记')
|
|
264
|
+
protected = {str(path.resolve()) for path in [rules_path, root / 'sync.py', root / 'targets.json', root / 'targets.local.json']}
|
|
265
|
+
seen, changes = set(), []
|
|
266
|
+
for name, path, spec in targets:
|
|
267
|
+
identity = str(path.resolve())
|
|
268
|
+
if os.name == 'nt':
|
|
269
|
+
identity = os.path.normcase(identity)
|
|
270
|
+
protected = {os.path.normcase(x) for x in protected}
|
|
271
|
+
if identity in seen or identity in protected:
|
|
272
|
+
raise ValueError('入口重复或与工具自身文件冲突:' + str(path))
|
|
273
|
+
seen.add(identity)
|
|
274
|
+
# 非空覆盖文件优先级更高,提前提示,避免把未加载误判为同步成功。
|
|
275
|
+
if name == 'codex' and path.name == 'AGENTS.md':
|
|
276
|
+
override = path.with_name('AGENTS.override.md')
|
|
277
|
+
if override.exists() and override.read_bytes().strip():
|
|
278
|
+
raise ValueError('Codex 存在非空 AGENTS.override.md,请将本机 path 指向实际生效的入口')
|
|
279
|
+
old = snapshot(path)
|
|
280
|
+
text = old.decode('utf-8') if old is not None else ''
|
|
281
|
+
if spec['format'] == 'qoder' and text and BEGIN not in text:
|
|
282
|
+
raise ValueError('同名 Qoder 规则不是本工具创建,已停止:' + str(path))
|
|
283
|
+
new = replace_section(text, rules)
|
|
284
|
+
if spec['format'] == 'qoder' and not text:
|
|
285
|
+
new = '---\ntrigger: always_on\nalwaysApply: true\n---\n\n' + new
|
|
286
|
+
data = new.encode('utf-8')
|
|
287
|
+
print(('OK ' if old == data else '需同步 ') + name)
|
|
288
|
+
if old != data:
|
|
289
|
+
changes.append((name, path, old, data))
|
|
290
|
+
if args.check:
|
|
291
|
+
return 1 if changes else 0
|
|
292
|
+
if args.dry_run:
|
|
293
|
+
print('预览完成,未写入;待更新入口数:' + str(len(changes)))
|
|
294
|
+
return 0
|
|
295
|
+
if changes:
|
|
296
|
+
apply_changes(changes, root)
|
|
297
|
+
else:
|
|
298
|
+
print('全部选中入口已同步,无需修改。')
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
if __name__ == '__main__':
|
|
303
|
+
try:
|
|
304
|
+
raise SystemExit(main())
|
|
305
|
+
except (ValueError, OSError, RuntimeError) as error:
|
|
306
|
+
print('错误:' + str(error), file=sys.stderr)
|
|
307
|
+
raise SystemExit(2)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# 语言与代码注释规范
|
|
2
|
+
|
|
3
|
+
## 默认语言
|
|
4
|
+
- 用户的母语是中文。默认使用简体中文交流。
|
|
5
|
+
- 新增或修改的代码注释、文档注释均使用简体中文,无需用户再次提醒。
|
|
6
|
+
- 代码标识符、API 名称、协议字段、文档标签和工具指令保持原有形式,例如 @param、@return、TODO、eslint-disable;其中的解释文字使用中文。
|
|
7
|
+
|
|
8
|
+
## 注释必须与代码同时完成
|
|
9
|
+
- 编写、修改或重构代码时,同步完成必要的中文注释。
|
|
10
|
+
- 不得将聊天中的解释当作源码注释;必要说明必须写入代码文件。
|
|
11
|
+
- 修改代码行为时,同步更新相关注释,避免注释与实现不一致。
|
|
12
|
+
|
|
13
|
+
## 注释覆盖范围
|
|
14
|
+
- 新增或实质修改的模块、类、接口、函数和方法,应说明其用途;下述显而易见的简单代码除外。
|
|
15
|
+
- 对外接口和复杂函数,按需说明参数含义、返回值、异常、副作用、单位、取值范围和边界条件。
|
|
16
|
+
- 复杂分支、业务规则、算法、状态转换、并发控制、重试、缓存和兼容性处理,应说明设计原因及关键约束。
|
|
17
|
+
- 具有业务含义的常量、位运算、协议字段和特殊数值,应解释其含义。
|
|
18
|
+
- 简单赋值、直观的 getter/setter 和显而易见的语句无需逐行注释。
|
|
19
|
+
|
|
20
|
+
## 遵循对应语言的规范
|
|
21
|
+
- 自动识别代码语言、框架和项目的文档工具,使用其惯用注释形式。
|
|
22
|
+
- 下方列出的 20 种语言是常见工程覆盖集合,不是严格的语言排行榜;选择参考了近期开发者调查中的主流语言范围。项目已有规范优先于本表。
|
|
23
|
+
- 已有项目保持注释结构、缩进和文档标签风格一致,自然语言说明默认使用中文。
|
|
24
|
+
- 未列出的语言采用其通行规范,不生搬硬套其他语言的格式。
|
|
25
|
+
- 严格 JSON 等不支持注释的格式,不插入非法注释;必要说明放在对应文档中。
|
|
26
|
+
|
|
27
|
+
## 20 种常见语言的注释规范与示例
|
|
28
|
+
|
|
29
|
+
以下示例只展示注释写法;新增代码时按项目实际的 formatter、lint、文档生成器和版本约定调整。示例中的自然语言必须保持简体中文,语言要求的标签、关键字和 API 名称保持原样。
|
|
30
|
+
|
|
31
|
+
### 1. C
|
|
32
|
+
- 行注释使用 `//`,块注释使用 `/* ... */`;公共头文件可使用 Doxygen 的 `/** ... */`。
|
|
33
|
+
|
|
34
|
+
```c
|
|
35
|
+
/**
|
|
36
|
+
* 根据用户编号读取用户信息。
|
|
37
|
+
*
|
|
38
|
+
* @param user_id 用户编号,必须大于 0。
|
|
39
|
+
* @return 成功返回用户指针,失败返回 NULL。
|
|
40
|
+
*/
|
|
41
|
+
struct User *user_find(int user_id);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 2. C++
|
|
45
|
+
- 普通逻辑使用 `//`;公共类、函数和模板优先使用 Doxygen 的 `///` 或 `/** ... */`。
|
|
46
|
+
|
|
47
|
+
```cpp
|
|
48
|
+
/// 将输入值限制在闭区间 [min, max] 内。
|
|
49
|
+
template <typename T>
|
|
50
|
+
T clamp_value(T value, T min, T max) {
|
|
51
|
+
return value < min ? min : (value > max ? max : value);
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 3. C#
|
|
56
|
+
- 普通说明使用 `//`;公共 API 使用 XML 文档注释 `///`,并保留 `<summary>`、`<param>`、`<returns>` 等标签。
|
|
57
|
+
|
|
58
|
+
```csharp
|
|
59
|
+
/// <summary>按编号加载订单。</summary>
|
|
60
|
+
/// <param name="orderId">订单编号。</param>
|
|
61
|
+
/// <returns>订单不存在时返回 <c>null</c>。</returns>
|
|
62
|
+
public Order? LoadOrder(long orderId)
|
|
63
|
+
{
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 4. Java
|
|
69
|
+
- 普通说明使用 `//` 或 `/* ... */`;公共 API 使用 Javadoc `/** ... */`,标签使用 `@param`、`@return`、`@throws`。
|
|
70
|
+
|
|
71
|
+
```java
|
|
72
|
+
/**
|
|
73
|
+
* 根据订单编号查询订单。
|
|
74
|
+
*
|
|
75
|
+
* @param orderId 订单编号。
|
|
76
|
+
* @return 找不到订单时返回 {@code null}。
|
|
77
|
+
*/
|
|
78
|
+
Order findOrder(long orderId) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 5. Kotlin
|
|
84
|
+
- 普通说明使用 `//`;公共 API 使用 KDoc `/** ... */`,支持 `@param`、`@return`、`@throws` 和 Markdown。
|
|
85
|
+
|
|
86
|
+
```kotlin
|
|
87
|
+
/**
|
|
88
|
+
* 根据编号查询订单;找不到时返回 null。
|
|
89
|
+
*
|
|
90
|
+
* @param orderId 订单编号。
|
|
91
|
+
*/
|
|
92
|
+
fun findOrder(orderId: Long): Order? {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 6. Swift
|
|
98
|
+
- 普通说明使用 `//`;公共 API 使用 Swift 文档注释 `///` 或 `/** ... */`,参数说明使用 `- Parameter`,返回值使用 `- Returns`。
|
|
99
|
+
|
|
100
|
+
```swift
|
|
101
|
+
/// 根据编号加载订单。
|
|
102
|
+
/// - Parameter orderID: 订单编号。
|
|
103
|
+
/// - Returns: 找不到订单时返回 `nil`。
|
|
104
|
+
func loadOrder(orderID: Int) -> Order? {
|
|
105
|
+
return nil
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### 7. JavaScript
|
|
110
|
+
- 普通逻辑使用 `//`;跨模块函数、类和复杂对象使用 JSDoc `/** ... */`,标签使用 `@param`、`@returns`、`@throws`。
|
|
111
|
+
|
|
112
|
+
```javascript
|
|
113
|
+
/**
|
|
114
|
+
* 将字符串解析为非负整数。
|
|
115
|
+
*
|
|
116
|
+
* @param {string} text 待解析文本。
|
|
117
|
+
* @returns {number|null} 无法解析时返回 null。
|
|
118
|
+
*/
|
|
119
|
+
function parseCount(text) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### 8. TypeScript
|
|
125
|
+
- 普通说明使用 `//`;导出的类型、接口、函数和类使用 TSDoc/JSDoc,类型标签应与实际类型一致。
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
/** 根据用户编号获取用户;不存在时返回 undefined。 */
|
|
129
|
+
export function getUser(userId: string): User | undefined {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 9. Python
|
|
135
|
+
- 行内说明使用 `#`;模块、类和公共函数使用 PEP 257 风格 docstring,复杂 API 可使用 Google、NumPy 或项目既有风格。
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
def retry_delay(failure_count: int) -> float:
|
|
139
|
+
"""根据连续失败次数计算重试间隔。
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
failure_count: 已连续失败的次数,从 1 开始。
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
以秒为单位的等待时间,最大为 32 秒。
|
|
146
|
+
"""
|
|
147
|
+
return min(2 ** max(failure_count - 1, 0), 32)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 10. Go
|
|
151
|
+
- 普通说明使用 `//`;导出的包、类型、函数和变量的文档注释应以被说明的名称开头,块注释只用于确有需要的多行说明。
|
|
152
|
+
|
|
153
|
+
```go
|
|
154
|
+
// ParseCount 将文本解析为非负整数;格式错误时返回错误。
|
|
155
|
+
func ParseCount(text string) (int, error) {
|
|
156
|
+
return 0, nil
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 11. Rust
|
|
161
|
+
- 普通说明使用 `//`;公开项使用 rustdoc `///`,模块或 crate 级说明使用 `//!`,可用 Markdown 和 `# Panics`、`# Errors` 等章节。
|
|
162
|
+
|
|
163
|
+
```rust
|
|
164
|
+
/// 根据连续失败次数计算重试间隔。
|
|
165
|
+
///
|
|
166
|
+
/// 返回值单位为秒,最大不超过 32 秒。
|
|
167
|
+
pub fn retry_delay(failure_count: u32) -> u64 {
|
|
168
|
+
0
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 12. Ruby
|
|
173
|
+
- 普通说明使用 `#`;公共类和方法沿用 RDoc/YARD 风格,可用 `@param`、`@return` 和 `@raise`。
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# 根据连续失败次数计算重试间隔。
|
|
177
|
+
# @param failure_count [Integer] 连续失败次数
|
|
178
|
+
# @return [Integer] 等待秒数,最大为 32
|
|
179
|
+
def retry_delay(failure_count)
|
|
180
|
+
0
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 13. PHP
|
|
185
|
+
- 普通说明使用 `//` 或 `/* ... */`;公共函数、类和属性使用 PHPDoc `/** ... */`,标签使用 `@param`、`@return`、`@throws`。
|
|
186
|
+
|
|
187
|
+
```php
|
|
188
|
+
/**
|
|
189
|
+
* 根据用户编号加载用户。
|
|
190
|
+
*
|
|
191
|
+
* @param int $userId 用户编号
|
|
192
|
+
* @return User|null 找不到时返回 null
|
|
193
|
+
*/
|
|
194
|
+
function findUser(int $userId): ?User
|
|
195
|
+
{
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 14. Dart
|
|
201
|
+
- 普通说明使用 `//`;公共 API 优先使用 `///` 文档注释,Flutter/Dart API 可用 Markdown 和 `{@template ...}` 等项目约定标签。
|
|
202
|
+
|
|
203
|
+
```dart
|
|
204
|
+
/// 根据编号加载订单;找不到时返回 `null`。
|
|
205
|
+
Order? loadOrder(int orderId) => null;
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 15. Scala
|
|
209
|
+
- 普通说明使用 `//`;公共 API 使用 Scaladoc `/** ... */`,参数和返回值使用 `@param`、`@return`。
|
|
210
|
+
|
|
211
|
+
```scala
|
|
212
|
+
/**
|
|
213
|
+
* 根据编号加载订单。
|
|
214
|
+
*
|
|
215
|
+
* @param orderId 订单编号
|
|
216
|
+
* @return 找不到时返回 None
|
|
217
|
+
*/
|
|
218
|
+
def loadOrder(orderId: Long): Option[Order] = {
|
|
219
|
+
None
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### 16. R
|
|
224
|
+
- 普通说明使用 `#`;包和公共函数优先使用 roxygen2 `#'`,通过 `@param`、`@return`、`@export` 生成文档。
|
|
225
|
+
|
|
226
|
+
```r
|
|
227
|
+
#' 根据编号加载订单
|
|
228
|
+
#' @param order_id 订单编号
|
|
229
|
+
#' @return 订单对象或 NULL
|
|
230
|
+
#' @export
|
|
231
|
+
load_order <- function(order_id) {
|
|
232
|
+
NULL
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### 17. SQL
|
|
237
|
+
- 单行说明使用 `--`,多行说明使用 `/* ... */`;迁移和复杂查询应解释业务规则、关联条件及特殊过滤。
|
|
238
|
+
|
|
239
|
+
```sql
|
|
240
|
+
-- 只统计已支付订单,避免把取消订单计入收入。
|
|
241
|
+
SELECT customer_id, SUM(amount) AS total_amount
|
|
242
|
+
FROM orders
|
|
243
|
+
WHERE status = 'paid'
|
|
244
|
+
GROUP BY customer_id;
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### 18. Bash / Shell
|
|
248
|
+
- 使用 `#`;脚本入口、函数、危险命令、环境变量约束和退出码应说明,避免把每条简单命令逐行翻译。
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
# 清理超过保留天数的临时文件;失败时返回非零状态。
|
|
252
|
+
cleanup_temp_files() {
|
|
253
|
+
find "$TMP_DIR" -type f -mtime +"$RETENTION_DAYS" -delete
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### 19. PowerShell
|
|
258
|
+
- 单行说明使用 `#`,多行说明使用 `<# ... #>`;高级函数使用 comment-based help 的 `.SYNOPSIS`、`.PARAMETER`、`.OUTPUTS`。
|
|
259
|
+
|
|
260
|
+
```powershell
|
|
261
|
+
function Get-Order {
|
|
262
|
+
<#
|
|
263
|
+
.SYNOPSIS
|
|
264
|
+
根据编号加载订单。
|
|
265
|
+
.PARAMETER OrderId
|
|
266
|
+
订单编号。
|
|
267
|
+
#>
|
|
268
|
+
param([long]$OrderId)
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### 20. Lua
|
|
273
|
+
- 单行说明使用 `--`,多行说明使用 `--[[ ... ]]`;公共模块函数可按项目约定使用 LDoc 注释标签。
|
|
274
|
+
|
|
275
|
+
```lua
|
|
276
|
+
--- 根据编号加载订单;找不到时返回 nil。
|
|
277
|
+
-- @param order_id number 订单编号
|
|
278
|
+
-- @return table|nil 订单对象或 nil
|
|
279
|
+
function M.load_order(order_id)
|
|
280
|
+
return nil
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### 多语言文件和模板
|
|
285
|
+
- HTML/XML 使用 `<!-- ... -->`;CSS 使用 `/* ... */`;JSON、YAML、TOML 和多数纯数据格式不支持注释,不要插入非法注释。
|
|
286
|
+
- Vue、Svelte、JSX/TSX 等混合文件必须根据注释所在区域使用对应语法;不要把 HTML 注释放进 JavaScript 表达式,也不要把 JavaScript 注释放进 JSON 数据。
|
|
287
|
+
- SQL、Shell、配置模板和迁移脚本中的注释必须说明环境、执行顺序、不可逆操作和兼容性约束。
|
|
288
|
+
|
|
289
|
+
## 注释表达方式
|
|
290
|
+
- 使用简洁、准确的中文,优先解释用途、原因、约束和边界。
|
|
291
|
+
- 不机械翻译代码,不写“声明变量”“进入循环”等无信息量注释。
|
|
292
|
+
- 不编造业务原因;无法从上下文确定时明确说明不确定之处。
|
|
293
|
+
- 不添加无必要的作者、日期、修改流水账或装饰性分隔线。
|
|
294
|
+
- 不为补注释修改无关代码,不手动修改生成文件或第三方源码,保留许可证声明。
|
|
295
|
+
|
|
296
|
+
## 完成前自检
|
|
297
|
+
- 检查本次新增或修改的代码是否已补充必要注释。
|
|
298
|
+
- 检查说明文字是否为简体中文。
|
|
299
|
+
- 检查注释语法是否符合当前语言及文档工具要求。
|
|
300
|
+
- 检查注释是否与实际实现一致。
|
|
301
|
+
- 发现遗漏时,在交付前主动补齐,无需等待用户提醒。
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ai-rules-sync
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 跨平台同步 AI 编程 Agent 的中文代码注释规范
|
|
5
|
+
Author: AI Rules Sync contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ai,agent,rules,codex,claude,qoder
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
14
|
+
Requires-Python: >=3.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# AI Rules Sync
|
|
18
|
+
|
|
19
|
+
AI Rules Sync 是一个只依赖 Python 标准库的跨平台命令行工具,用一份中文代码注释规范同步多个 AI 编程 Agent 的全局规则。
|
|
20
|
+
|
|
21
|
+
## 使用方式
|
|
22
|
+
|
|
23
|
+
无需克隆仓库或安装 Python 包,直接使用 `uvx`:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
# 查看当前电脑检测到的 Agent
|
|
27
|
+
uvx ai-rules-sync --list
|
|
28
|
+
|
|
29
|
+
# 同步规则
|
|
30
|
+
uvx ai-rules-sync
|
|
31
|
+
|
|
32
|
+
# 预览变更,不写入文件
|
|
33
|
+
uvx ai-rules-sync --dry-run
|
|
34
|
+
|
|
35
|
+
# 检查是否需要同步;需要同步时返回退出码 1
|
|
36
|
+
uvx ai-rules-sync --check
|
|
37
|
+
|
|
38
|
+
# 只同步指定 Agent
|
|
39
|
+
uvx ai-rules-sync --agents codex,claude
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
也可以指定配置目录:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uvx ai-rules-sync --config-dir /path/to/config
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 支持的 Agent
|
|
49
|
+
|
|
50
|
+
内置支持 Codex、Claude Code、Hermes 和 Qoder。工具会自动检测已安装的 Agent;未检测到的 Agent 会跳过,不会创建配置目录。使用 `--agents` 可以显式初始化指定 Agent。
|
|
51
|
+
|
|
52
|
+
同步过程只替换受管标记内的规则内容,会保留 Agent 原有配置,并在写入前创建备份。
|
|
53
|
+
|
|
54
|
+
## 本机配置
|
|
55
|
+
|
|
56
|
+
共享规则随 PyPI wheel 一起发布。安装后的本机覆盖配置和备份默认保存在:
|
|
57
|
+
|
|
58
|
+
- macOS/Linux:`~/.config/ai-rules-sync/`
|
|
59
|
+
- Windows:`%APPDATA%\\ai-rules-sync\\`
|
|
60
|
+
|
|
61
|
+
也可以通过 `AI_RULES_HOME` 或 `--config-dir` 指定配置目录。完整的配置格式、安全策略和开发说明请参阅项目中的 [使用说明书](docs/USAGE.md)。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ai_rules_sync/__init__.py
|
|
4
|
+
src/ai_rules_sync/__main__.py
|
|
5
|
+
src/ai_rules_sync/core.py
|
|
6
|
+
src/ai_rules_sync.egg-info/PKG-INFO
|
|
7
|
+
src/ai_rules_sync.egg-info/SOURCES.txt
|
|
8
|
+
src/ai_rules_sync.egg-info/dependency_links.txt
|
|
9
|
+
src/ai_rules_sync.egg-info/entry_points.txt
|
|
10
|
+
src/ai_rules_sync.egg-info/top_level.txt
|
|
11
|
+
src/ai_rules_sync/data/AGENTS.md
|
|
12
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ai_rules_sync
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""验证跨平台路径、不同 Agent 组合、配置保留与失败恢复;不访问真实 Agent 配置。"""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path, PureWindowsPath
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
|
|
14
|
+
from ai_rules_sync import core as sync
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PortableSyncTests(unittest.TestCase):
|
|
18
|
+
"""在临时用户目录中验证同步行为,隔离机器实际安装的 Agent。"""
|
|
19
|
+
|
|
20
|
+
def setUp(self):
|
|
21
|
+
"""创建含中文和空格的独立工具目录与用户目录。"""
|
|
22
|
+
self.temp = tempfile.TemporaryDirectory()
|
|
23
|
+
self.addCleanup(self.temp.cleanup)
|
|
24
|
+
self.base = Path(self.temp.name)
|
|
25
|
+
self.root = self.base / '共享规则 tool'
|
|
26
|
+
self.home = self.base / '用户 home'
|
|
27
|
+
self.root.mkdir()
|
|
28
|
+
self.home.mkdir()
|
|
29
|
+
(self.root / 'AGENTS.md').write_text('# 中文规则\n注释使用简体中文。\n', encoding='utf-8')
|
|
30
|
+
self.config({})
|
|
31
|
+
|
|
32
|
+
def config(self, agents, filename='targets.json'):
|
|
33
|
+
"""写入当前测试的 Agent 配置。"""
|
|
34
|
+
(self.root / filename).write_text(json.dumps({'version': 2, 'agents': agents}), encoding='utf-8')
|
|
35
|
+
|
|
36
|
+
def run_sync(self, args=None, platform='linux', env=None, which=None):
|
|
37
|
+
"""用模拟的系统与用户环境运行工具,捕获输出。"""
|
|
38
|
+
with patch.object(sync.Path, 'home', return_value=self.home), \
|
|
39
|
+
patch.object(sync, 'system_name', return_value=platform), \
|
|
40
|
+
patch.dict(os.environ, env or {}, clear=True), \
|
|
41
|
+
patch.object(sync.shutil, 'which', side_effect=which or (lambda name: None)), \
|
|
42
|
+
contextlib.redirect_stdout(io.StringIO()):
|
|
43
|
+
return sync.main(args or [], root=self.root)
|
|
44
|
+
|
|
45
|
+
def test_missing_agents_create_nothing(self):
|
|
46
|
+
"""没有安装痕迹时,跳过所有 Agent,不产生规则目录。"""
|
|
47
|
+
self.assertEqual(self.run_sync(), 0)
|
|
48
|
+
self.assertEqual(list(self.home.iterdir()), [])
|
|
49
|
+
self.assertFalse((self.root / 'backups').exists())
|
|
50
|
+
self.assertEqual(self.run_sync(['--check']), 1)
|
|
51
|
+
|
|
52
|
+
def test_subset_and_idempotency(self):
|
|
53
|
+
"""只安装部分 Agent 时,仅同步对应入口且重复执行无变更。"""
|
|
54
|
+
for name in ['.codex', '.hermes']:
|
|
55
|
+
(self.home / name).mkdir()
|
|
56
|
+
self.assertEqual(self.run_sync(), 0)
|
|
57
|
+
self.assertTrue((self.home / '.codex/AGENTS.md').exists())
|
|
58
|
+
self.assertTrue((self.home / '.hermes/SOUL.md').exists())
|
|
59
|
+
self.assertFalse((self.home / '.claude').exists())
|
|
60
|
+
self.assertFalse((self.home / '.qoder').exists())
|
|
61
|
+
self.assertEqual(self.run_sync(), 0)
|
|
62
|
+
self.assertEqual(self.run_sync(['--check']), 0)
|
|
63
|
+
self.assertEqual(len(list((self.root / 'backups').iterdir())), 1)
|
|
64
|
+
|
|
65
|
+
def test_explicit_and_disabled(self):
|
|
66
|
+
"""显式选择可以初始化入口,也可以覆盖自动同步时的禁用设置。"""
|
|
67
|
+
self.config({'claude': {'enabled': False}})
|
|
68
|
+
(self.home / '.claude').mkdir()
|
|
69
|
+
self.assertEqual(self.run_sync(), 0)
|
|
70
|
+
self.assertFalse((self.home / '.claude/CLAUDE.md').exists())
|
|
71
|
+
self.assertEqual(self.run_sync(['--agents', 'claude']), 0)
|
|
72
|
+
content = (self.home / '.claude/CLAUDE.md').read_text(encoding='utf-8')
|
|
73
|
+
self.assertIn('注释使用简体中文', content)
|
|
74
|
+
self.assertNotIn(str(self.root), content)
|
|
75
|
+
|
|
76
|
+
def test_env_overrides(self):
|
|
77
|
+
"""三种官方主目录环境变量分别决定入口,未污染默认目录。"""
|
|
78
|
+
env = {name: str(self.base / name) for name in ['CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'HERMES_HOME']}
|
|
79
|
+
for value in env.values():
|
|
80
|
+
Path(value).mkdir()
|
|
81
|
+
self.run_sync(env=env)
|
|
82
|
+
for key, filename in [('CODEX_HOME', 'AGENTS.md'), ('CLAUDE_CONFIG_DIR', 'CLAUDE.md'), ('HERMES_HOME', 'SOUL.md')]:
|
|
83
|
+
self.assertTrue((Path(env[key]) / filename).exists())
|
|
84
|
+
self.assertEqual(list(self.home.iterdir()), [])
|
|
85
|
+
|
|
86
|
+
def test_custom_agent_and_platform_paths(self):
|
|
87
|
+
"""macOS、Windows、Linux 配置分支均选择本平台路径。"""
|
|
88
|
+
paths = {name: str(self.base / name / '中文规则.md') for name in ['macos', 'windows', 'linux']}
|
|
89
|
+
self.config({'extra': {'enabled': True, 'path': paths}})
|
|
90
|
+
for platform in paths:
|
|
91
|
+
self.run_sync(['--agents', 'extra'], platform=platform)
|
|
92
|
+
self.assertTrue(Path(paths[platform]).exists())
|
|
93
|
+
|
|
94
|
+
def test_native_windows_path_expansion(self):
|
|
95
|
+
"""用 Windows 路径语义验证用户目录、反斜杠和环境变量。"""
|
|
96
|
+
home = PureWindowsPath('C:/Users/张 三')
|
|
97
|
+
base = PureWindowsPath('D:/规则工具')
|
|
98
|
+
with patch.object(sync, 'Path', PureWindowsPath):
|
|
99
|
+
self.assertEqual(sync.expand_path('~/.codex/AGENTS.md', home, {}, base), home / '.codex/AGENTS.md')
|
|
100
|
+
self.assertEqual(sync.expand_path('~\\.claude\\CLAUDE.md', home, {}, base), home / '.claude/CLAUDE.md')
|
|
101
|
+
self.assertEqual(sync.expand_path('%APPDATA%/extra/RULES.md', home, {'APPDATA': 'C:/Users/张 三/AppData/Roaming'}, base), home / 'AppData/Roaming/extra/RULES.md')
|
|
102
|
+
self.assertEqual(sync.expand_path('D:\\Agent\\RULES.md', home, {}, base), PureWindowsPath('D:/Agent/RULES.md'))
|
|
103
|
+
|
|
104
|
+
def test_local_override_and_command_detection(self):
|
|
105
|
+
"""本机覆盖配置优先;命令存在时可初始化自定义规则文件。"""
|
|
106
|
+
self.config({'extra': {'path': '~/unused/RULES.md', 'commands': ['extra-cli']}})
|
|
107
|
+
self.config({'extra': {'path': '~/actual/RULES.md'}}, 'targets.local.json')
|
|
108
|
+
self.run_sync(which=lambda name: '/bin/extra-cli' if name == 'extra-cli' else None)
|
|
109
|
+
self.assertTrue((self.home / 'actual/RULES.md').exists())
|
|
110
|
+
self.assertFalse((self.home / 'unused').exists())
|
|
111
|
+
|
|
112
|
+
def test_preview_is_read_only(self):
|
|
113
|
+
"""列表和预览不写文件;检查能发现不同步。"""
|
|
114
|
+
for flag in ['--list', '--dry-run', '--check']:
|
|
115
|
+
self.run_sync(['--agents', 'codex', flag])
|
|
116
|
+
self.assertEqual(list(self.home.iterdir()), [])
|
|
117
|
+
self.assertFalse((self.root / 'backups').exists())
|
|
118
|
+
|
|
119
|
+
def test_packaged_rules_are_used_without_source_config(self):
|
|
120
|
+
"""安装后的 wheel 没有项目 config/ 时仍能使用包内共享规则。"""
|
|
121
|
+
packaged = self.base / 'package-data' / 'AGENTS.md'
|
|
122
|
+
packaged.parent.mkdir()
|
|
123
|
+
packaged.write_text('# 安装后的规则\n使用中文注释。\n', encoding='utf-8')
|
|
124
|
+
(self.root / 'AGENTS.md').unlink()
|
|
125
|
+
with patch.object(sync, 'PACKAGED_RULES', packaged):
|
|
126
|
+
self.assertEqual(self.run_sync(['--agents', 'codex']), 0)
|
|
127
|
+
content = (self.home / '.codex/AGENTS.md').read_text(encoding='utf-8')
|
|
128
|
+
self.assertIn('使用中文注释', content)
|
|
129
|
+
|
|
130
|
+
def test_preserve_crlf_bom_and_backup(self):
|
|
131
|
+
"""保留原内容、Windows 换行和 BOM,并保存完全一致的备份。"""
|
|
132
|
+
target = self.home / '.hermes/SOUL.md'
|
|
133
|
+
target.parent.mkdir()
|
|
134
|
+
before = '\ufeff已有的人格设置。\r\n'.encode('utf-8')
|
|
135
|
+
target.write_bytes(before)
|
|
136
|
+
self.run_sync()
|
|
137
|
+
after = target.read_bytes()
|
|
138
|
+
self.assertTrue(after.startswith(before))
|
|
139
|
+
self.assertNotIn(b'\n', after.replace(b'\r\n', b''))
|
|
140
|
+
backup = next((self.root / 'backups').iterdir())
|
|
141
|
+
record = json.loads((backup / 'manifest.json').read_text(encoding='utf-8'))[0]
|
|
142
|
+
self.assertEqual((backup / record['backup']).read_bytes(), before)
|
|
143
|
+
|
|
144
|
+
def test_preflight_rejects_bad_markers_before_writes(self):
|
|
145
|
+
"""任一文件的标记损坏时,不先修改其他入口。"""
|
|
146
|
+
target = self.home / '.hermes/SOUL.md'
|
|
147
|
+
target.parent.mkdir()
|
|
148
|
+
target.write_text(sync.BEGIN, encoding='utf-8')
|
|
149
|
+
with self.assertRaises(ValueError):
|
|
150
|
+
self.run_sync(['--agents', 'codex,hermes'])
|
|
151
|
+
self.assertFalse((self.home / '.codex').exists())
|
|
152
|
+
|
|
153
|
+
def test_duplicate_and_source_targets_rejected(self):
|
|
154
|
+
"""配置不能让两个 Agent 写同一文件,也不能覆盖主规则。"""
|
|
155
|
+
shared = str(self.home / 'shared.md')
|
|
156
|
+
self.config({'one': {'enabled': True, 'path': shared}, 'two': {'enabled': True, 'path': shared}})
|
|
157
|
+
with self.assertRaises(ValueError):
|
|
158
|
+
self.run_sync()
|
|
159
|
+
self.assertFalse(Path(shared).exists())
|
|
160
|
+
self.config({'extra': {'enabled': True, 'path': str(self.root / 'AGENTS.md')}})
|
|
161
|
+
with self.assertRaises(ValueError):
|
|
162
|
+
self.run_sync()
|
|
163
|
+
|
|
164
|
+
def test_qoder_and_codex_override(self):
|
|
165
|
+
"""Qoder 仅写全局始终应用规则;Codex 覆盖入口必须明确配置。"""
|
|
166
|
+
self.run_sync(['--agents', 'qoder'])
|
|
167
|
+
target = self.home / '.qoder/rules/ai-chinese-comments.md'
|
|
168
|
+
self.assertIn('trigger: always_on', target.read_text(encoding='utf-8'))
|
|
169
|
+
codex = self.home / '.codex'
|
|
170
|
+
codex.mkdir()
|
|
171
|
+
(codex / 'AGENTS.override.md').write_text('已有覆盖规范', encoding='utf-8')
|
|
172
|
+
with self.assertRaises(ValueError):
|
|
173
|
+
self.run_sync(['--agents', 'codex'])
|
|
174
|
+
self.assertFalse((codex / 'AGENTS.md').exists())
|
|
175
|
+
|
|
176
|
+
def test_rollback_on_second_write_failure(self):
|
|
177
|
+
"""第二个写入失败时,第一个文件恢复为原内容。"""
|
|
178
|
+
first, second = self.home / 'first.md', self.home / 'second.md'
|
|
179
|
+
first.write_bytes(b'original')
|
|
180
|
+
real_write = sync.atomic_write
|
|
181
|
+
def fail_second(path, data, mode):
|
|
182
|
+
"""模拟目标文件被占用或没有写权限。"""
|
|
183
|
+
if path == second:
|
|
184
|
+
raise PermissionError('模拟第二个入口写入失败')
|
|
185
|
+
real_write(path, data, mode)
|
|
186
|
+
with patch.object(sync, 'atomic_write', side_effect=fail_second), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
187
|
+
with self.assertRaises(PermissionError):
|
|
188
|
+
sync.apply_changes([('first', first, b'original', b'new'), ('second', second, None, b'new')], self.root)
|
|
189
|
+
self.assertEqual(first.read_bytes(), b'original')
|
|
190
|
+
self.assertFalse(second.exists())
|
|
191
|
+
|
|
192
|
+
def test_legacy_and_unknown_agent_rejected(self):
|
|
193
|
+
"""旧机器路径配置和拼错的 Agent 名称不会触发写入。"""
|
|
194
|
+
with self.assertRaises(ValueError):
|
|
195
|
+
self.run_sync(['--agents', 'unknown'])
|
|
196
|
+
(self.root / 'targets.json').write_text('{"codex":"/old/user/AGENTS.md"}', encoding='utf-8')
|
|
197
|
+
with self.assertRaises(ValueError):
|
|
198
|
+
self.run_sync()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == '__main__':
|
|
202
|
+
unittest.main()
|