合并 OpenClaw 配置:$include 指令 VS 自定义脚本

点击上方蓝字关注我们







{"skills": {"install": {"nodeManager": "npm"},"entries": {"coding-agent": {"enabled": true},"apple-notes": {"enabled": false},"apple-reminders": {"enabled": false},"bear-notes": {"enabled": false},"discord": {"enabled": false},"goplaces": {"enabled": false},"imsg": {"enabled": false},"model-usage": {"enabled": false},"openai-whisper-api": {"enabled": false},"peekaboo": {"enabled": false},"sag": {"enabled": false},"sherpa-onnx-tts": {"enabled": false},"slack": {"enabled": false},"things-mac": {"enabled": false},"trello": {"enabled": false},"voice-call": {"enabled": false}}},"wizard": {"lastRunAt": "2026-05-28T16:54:08.170Z","lastRunVersion": "2026.5.22","lastRunCommand": "doctor","lastRunMode": "local"},"meta": {"lastTouchedVersion": "2026.5.22","lastTouchedAt": "2026-05-28T16:54:08.182Z"},"agents": {"defaults": {"workspace": "/home/openclaw/.openclaw/workspace"}},"gateway": {"mode": "local","port": 18789,"bind": "loopback","auth": {"mode": "token","token": "..."},"tailscale": {"mode": "off","resetOnExit": false}},"tools": {"web": {"search": {"enabled": false},"fetch": {"enabled": false}}}}
{"agents": {"defaults": {"model": {"primary": "xxx/gpt-5.5-Pro"},"models": {"xxx/gpt-5.3-codex-spark": {"alias": "GPT 5.3 Codex Spark [xxx]"},"xxx/gpt-5.5": {"alias": "GPT 5.5 [xxx]"},"xxx/gpt-5.5-Pro": {"alias": "GPT 5.5 Pro [xxx]"},"xxx/claude-haiku-4-5": {"alias": "Claude Haiku 4.5 [xxx]"},"xxx/claude-sonnet-4-5": {"alias": "Claude Sonnet 4.5 [xxx]"},"xxx/glm-5-turbo": {"alias": "GLM 5 Turbo [xxx]"},"xxx/glm-5.1": {"alias": "GLM 5.1 [xxx]"},"xxx/MiniMax-M2.7": {"alias": "MiniMax M2.7 [xxx]"},"xxx/qwen3.6-plus": {"alias": "Qwen3.6 Plus [xxx]"}}}},"models": {"mode": "merge","providers": {"xxx": {"baseUrl": "https://api.xxx.com/v1","apiKey": "...","api": "openai-completions","models": [{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},{"id": "gpt-5.5", "name": "GPT 5.5"},{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"id": "claude-haiku-4-5", "name": "Claude Haiku 4.5"},{"id": "claude-sonnet-4-5", "name": "Claude Sonnet 4.5"},{"id": "glm-5-turbo", "name": "GLM 5 Turbo"},{"id": "glm-5.1", "name": "GLM 5.1"},{"id": "MiniMax-M2.7", "name": "MiniMax M2.7"},{"id": "qwen3.6-plus", "name": "Qwen3.6 Plus"}]}}}}
{"skills": {"install": {"nodeManager": "npm"},"entries": {"coding-agent": {"enabled": true},"apple-notes": {"enabled": false},"apple-reminders": {"enabled": false},"bear-notes": {"enabled": false},"discord": {"enabled": false},"goplaces": {"enabled": false},"imsg": {"enabled": false},"model-usage": {"enabled": false},"openai-whisper-api": {"enabled": false},"peekaboo": {"enabled": false},"sag": {"enabled": false},"sherpa-onnx-tts": {"enabled": false},"slack": {"enabled": false},"things-mac": {"enabled": false},"trello": {"enabled": false},"voice-call": {"enabled": false}}},"wizard": {"lastRunAt": "2026-05-28T16:54:08.170Z","lastRunVersion": "2026.5.22","lastRunCommand": "doctor","lastRunMode": "local"},"meta": {"lastTouchedVersion": "2026.5.22","lastTouchedAt": "2026-05-28T16:54:08.182Z"},"agents": {"defaults": {"workspace": "/home/openclaw/.openclaw/workspace"}},"gateway": {"mode": "local","port": 18789,"bind": "loopback","auth": {"mode": "token","token": "..."},"tailscale": {"mode": "off","resetOnExit": false}},"tools": {"web": {"search": {"enabled": false},"fetch": {"enabled": false}}}}{"agents": {"defaults": {"model": {"primary": "xxx/gpt-5.5-Pro"},"models": {"xxx/gpt-5.3-codex-spark": {"alias": "GPT 5.3 Codex Spark [xxx]"},"xxx/gpt-5.5": {"alias": "GPT 5.5 [xxx]"},"xxx/gpt-5.5-Pro": {"alias": "GPT 5.5 Pro [xxx]"},"xxx/claude-haiku-4-5": {"alias": "Claude Haiku 4.5 [xxx]"},"xxx/claude-sonnet-4-5": {"alias": "Claude Sonnet 4.5 [xxx]"},"xxx/glm-5-turbo": {"alias": "GLM 5 Turbo [xxx]"},"xxx/glm-5.1": {"alias": "GLM 5.1 [xxx]"},"xxx/MiniMax-M2.7": {"alias": "MiniMax M2.7 [xxx]"},"xxx/qwen3.6-plus": {"alias": "Qwen3.6 Plus [xxx]"}}}},"models": {"mode": "merge","providers": {"xxx": {"baseUrl": "https://api.xxx.com/v1","apiKey": "...","api": "openai-completions","models": [{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},{"id": "gpt-5.5", "name": "GPT 5.5"},{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"id": "claude-haiku-4-5", "name": "Claude Haiku 4.5"},{"id": "claude-sonnet-4-5", "name": "Claude Sonnet 4.5"},{"id": "glm-5-turbo", "name": "GLM 5 Turbo"},{"id": "glm-5.1", "name": "GLM 5.1"},{"id": "MiniMax-M2.7", "name": "MiniMax M2.7"},{"id": "qwen3.6-plus", "name": "Qwen3.6 Plus"}]}}}}




{..."agents": {"$include": "./agents.json5"}}
{"defaults": ...}
{..."agents": {"defaults": ...}}
{"defaults": ...}
{"list": ...}
{..."agents": {"$include": ["./defaults.json5","./list.json5"]}}
{"defaults": ..."$include": "./list.json5"}
{..."agents": {"$include": "./defaults.json5"}}
{..."agents": {"defaults": ..."list": ...}}
{..."agents": {"$include": ["./agents/defaults.json5","./agents/list.json5"]}}
{"defaults": {"model": {"primary": "xxx/gpt-5.5-Pro"},"sandbox": {"mode": "all"}}}
{"defaults": {"model": {"primary": "xxx/glm-5.1"},"sandbox": {"backend": "docker"}}}
{..."agents": {"$include": ["./agents1.json5", "./agents2.json5"]}}
{..."agents": {"defaults": {"model": {"primary": "xxx/glm-5.1"},"sandbox": {"mode": "all","backend": "docker"}}}}
{"defaults": {"model": {"primary": "xxx/gpt-5.5-Pro"},"sandbox": {"mode": "all"}}}
{..."agents": {"$include": "./agents.json5","defaults": {"model": {"primary": "xxx/glm-5.1"},"sandbox": {"backend": "docker"}}}}
{..."agents": {"defaults": {"model": {"primary": "xxx/glm-5.1"},"sandbox": {"mode": "all","backend": "docker"}}}}
{..."agents": ...}
{"$include": "./config.json5"}
[{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}]
{..."models": {"mode": "merge","providers": {"xxx": {..."models": {"$include": "./models.json5"}}}}}


[{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}]
[{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"id": "gpt-5.5", "name": "GPT 5.5"}]
{..."models": {"mode": "merge","providers": {"xxx": {..."models": {"$include": ["./models1.json5", "models2.json5"]}}}}}


{"models": [{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"}]}
{"models": [{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"id": "gpt-5.5", "name": "GPT 5.5"}]}
{..."models": {"mode": "merge","providers": {"xxx": {"baseUrl": "https://api.xxx.com/v1","apiKey": "...","api": "openai-completions","$include": ["./models1.json5", "./models2.json5"]}}}}
{"id": "gpt-5.3-codex-spark","name": "GPT 5.3 Codex Spark"}
{..."models": {"mode": "merge","providers": {"xxx": {..."models": [{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"$include": "./model.json5"}]}}}}


{"id": "gpt-5.3-codex-spark","name": "GPT 5.3 Codex Spark"}
{"id": "gpt-5.5","name": "GPT 5.5"}
{..."models": {"mode": "merge","providers": {"xxx": {..."models": [{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"$include": ["./model1.json5", "./model2.json5"]}]}}}}


{..."models": {"mode": "merge","providers": {"xxx":{..."models": [{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"$include": "./model1.json5"},{"$include": "./model2.json5"}]}}}}


[{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},{"id": "gpt-5.5", "name": "GPT 5.5"}]
{..."models": {"mode": "merge","providers": {"xxx": {..."models": [{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},{"$include": "./models.json5"}]}}}}

models.providers.xxx.models.1: Invalid input: expected object, received array
{..."models": {"mode": "merge","providers": {"xxx": {..."models": [{"id": "gpt-5.5-Pro", "name": "GPT 5.5 Pro"},[{"id": "gpt-5.3-codex-spark", "name": "GPT 5.3 Codex Spark"},{"id": "gpt-5.5", "name": "GPT 5.5"}]]}}}}
"xxx/gpt-5.5-Pro"
{..."agents": {..."defaults": {..."model": {"primary": {"$include": "./model_primary.json5"}}}}}


"xxx/gpt-5.5-Pro"
"xxx/gpt-5.3-codex-spark"
{..."agents": {..."defaults": {..."model": {"primary": {"$include": ["./mode1_primary1.json5","./model_primary2.json5"]}}}}}


-
$include 单个 JSON 对象文件且没有冲突,执行递归合并 -
同一个位置同时 $include 多个 JSON 对象文件且没有冲突,按顺序执行递归合并 -
允许使用嵌套 $include,但是如果参考 OpenClaw 的官方文档的话,就会发现最多只能 10 层嵌套 -
$include 指令对应文件位置的相对路径是相对于调用 $include 指令的那个文件的路径而言的,不是相对于 OpenClaw 配置文件 openclaw.json 的路径而言的 -
$include 指令对应文件必须在 OpenClaw 配置文件 openclaw.json 对应目录或者其子目录内,除非设置环境变量 OPENCLAW_INCLUDE_ROOTS 来指定对应目录,如果需要设置很多个这样的目录,参考环境变量 PATH 的格式即可 -
$include 指令对应文件经过系统解析后得到的绝对路径对应字符串长度必须小于 4096,系统解析包含两个步骤:第一,相对路径转绝对路径;第二,跟随符号链接找真实目标路径 -
可以在根位置使用 $include 指令,但是如果参考 OpenClaw 官方文档的话,就会发现根位置使用 $include 指令会导致 OpenClaw 配置文件关闭自动写入,即除了修改文件本身外,无法通过其他方式修改 OpenClaw 的配置 -
除根位置 $include 外,$include 多个 JSON 文件,有冲突,这两种情况也会让 OpenClaw 配置文件关闭自动写入,即除了修改文件本身外,无法通过其他方式修改 OpenClaw 的配置 -
可以 $include 单个或者多个 JSON 数组文件 -
$include 单个 JSON 数组文件,执行替换操作 -
同一个位置同时 $include 多个 JSON 数组文件,先按顺序把多个文件对应的 JSON 数组拼接成一个大的 JSON 数组,再执行替换操作 -
$include 单个或者多个 JSON 数组文件,不会进行去除重复的操作 -
可以 $include 单个或者多个 JSON 标量文件 -
$include 单个 JSON 标量文件,执行替换操作 -
同一个位置同时 $include 多个 JSON 标量文件,用最后一个 JSON 标量文件的标量值执行替换操作 -
如果尝试 $include 单个或者多个 JSON 数组或者 JSON 标量文件,不能给 $include 字段设置同级字段;注意:不管是直接 $include 单个或者多个这样的 JSON 文件,还是通过嵌套 $include 方式来间接 $include 单个或者多个这样的 JSON 文件,还是通过符号链接的方式来变相 $include 单个或者多个这样的 JSON 文件,都认定为是 $include 单个或者多个这样的 JSON 文件 -
如果在同一个位置 $include 多个 JSON 文件,这些 JSON 文件要么必须全是 JSON 对象文件,要么必须全是 JSON 数组文件,要么必须全是 JSON 标量文件,不能出现二者或者三者的混合 -
JSON 数组中也能使用 $include 指令 -
JSON 数组中 $include 单个 JSON 对象文件,在对应位置执行替换操作且不覆盖 JSON 数组中的其他元素 -
JSON 数组中一次性 $include 多个 JSON 对象文件,参考第 2 点和之后提到的冲突处理机制得到一个 JSON 对象,在对应位置执行替换操作且不覆盖 JSON 数组中的其他元素 -
JSON 数组中分多次 $include 多个 JSON 对象文件,每次只 $include 一个 JSON 对象文件,依次在对应位置执行替换操作且不覆盖 JSON 数组中的其他元素 -
JSON 数组中 $include 单个或者多个 JSON 数组文件,会产生嵌套 JSON 数组的结构
-
如果该冲突字段对应值类型既不是 JSON 对象也不是 JSON 数组,采用后者覆盖前者的操作;最后别忘了如果和 OpenClaw 配置文件 openclaw.json 本身的对应字段存在冲突,最终的覆盖结果就是 OpenClaw 配置文件的对应字段的值 -
如果该冲突字段对应值类型是 JSON 数组,采用顺序拼接操作;最后别忘了如果和 OpenClaw 配置文件 openclaw.json 本身的对应字段存在冲突,需要把 OpenClaw 配置文件 openclaw.json 本身的对应字段的值拼接到最后面去 -
如果该冲突字段对应值类型是 JSON 对象,依次采用递归合并操作,直到无法递归为止;如果无法递归且存在冲突,处理方式参考上述两点



-
不管是 OpenClaw 配置文件 openclaw.json 还是需要合并的 JSON 配置文件,其中都不能出现 $include 指令 -
通过一个元配置文件来说明当前需要合并的 JSON 文件合并到 OpenClaw 配置文件的哪一处地方 -
去除 JSON 数组中的重复元素 -
多个 JSON 数组的合并可以选择拼接合并也可以选择逐元素合并,通过另一个元配置文件指定
openclaw config schema > a.txt


openclaw config schema 2>/dev/null >a.json

def export_old_config():schema = subprocess.run(('openclaw', 'config', 'schema'), capture_output=True, check=True,encoding='UTF-8-SIG', text=True)schema = json.loads(schema.stdout)top_fields = {_ for _ in schema['properties'] if not _.startswith("$")}config = {}for top_field in top_fields:cur_config = subprocess.run(('openclaw', 'config', 'get', top_field), capture_output=True,encoding='UTF-8-SIG', text=True)if not cur_config.returncode:config[top_field] = json.loads(cur_config.stdout)return config
def merge(origin_config, extra_config):for _ in extra_config:if _ in origin_config:if isinstance(origin_config[_], dict) and isinstance(extra_config[_], dict):merge(origin_config[_], extra_config[_])elif isinstance(origin_config[_], list) and isinstance(extra_config[_], list):origin_config[_].extend(extra_config[_])else:origin_config[_] = extra_config[_]else:origin_config[_] = extra_config[_]
-
考虑到用户在输入单个元素的列表的时候可能会忘记中括号,所以可以允许列表和非列表之间进行合并 -
对于有冲突且对应冲突值是标量的情况,没有检查标量的数据类型是否一致 -
如果有冲突且冲突值是标量,外加上标量数据类型不一致,需要提供人性化的报错,即给出对应位置、期望的数据类型和实际的数据类型 -
JSON 数组没有去除重复的元素
def merge_(self, origin_config, extra_config, check_type):for _ in extra_config:self.loc.append(_)if _ in origin_config:if isinstance(origin_config[_], dict) and isinstance(extra_config[_], dict):self.merge_(origin_config[_], extra_config[_], check_type)elif isinstance(origin_config[_], list) and isinstance(extra_config[_], list):origin_config[_].extend(extra_config[_])elif isinstance(origin_config[_], list):origin_config[_].append(extra_config[_])elif isinstance(extra_config[_], list):origin_config[_] = [origin_config[_]]+extra_config[_]elif type(origin_config[_]) is not type(extra_config[_]) and check_type:raise TypeError(f'{".".join(self.loc)} 字段类型不一致!期望类型:'f'{self.type_table[type(origin_config[_])]},实际类型:'f'{self.type_table[type(extra_config[_])]}')else:origin_config[_] = extra_config[_]else:origin_config[_] = extra_config[_]del self.loc[-1]
def merge(self, filename):if not os.path.exists(filename):raise FileNotFoundError(f'元配置文件 {filename} 不存在!')if os.path.isdir(filename):raise IsADirectoryError(f'{filename} 是目录而非文件!')# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行configs, idx = [], -1try:for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):if not len(row):continueelif len(row) > 2:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的列数大于 2!有以下解法:\n'f'1. 检查元配置文件 {filename} 的第 {idx+1} 行的内容!'f'若需合并的配置文件路径对应字符串或者合并位置对应字符串有逗号,需用双引号包裹,'f'否则可能会导致逗号被当成分隔符把当前行分割成多列(超过 2 列),从而导致这个问题!\n'f'2. 检查元配置文件 {filename} 中是否有多个连续的且被双引号包裹的逗号,若有,保留一个即可!')elif len(row) == 1 or not row[1]:raise ValueError('第 %d 行,%s 没有待合并配置文件!' % (idx+1, f'当前位置 {row[0]}' if row[0] else '根位置'))if '\n' in row[0] or '\r' in row[0]:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串出现跨行!')if '\n' in row[1] or '\r' in row[1]:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的待合并配置文件名对应字符串出现跨行!')configs.append((row[0], row[1]))except UnicodeDecodeError:raise ValueError(f'元配置文件 {filename} 的编码不是 UTF-8!')except csv.Error:# 此处 idx+1 对应的是上一行,所以要用 idx+2raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'f'2. 若某字符串被双引号包裹,左边的双引号的左边或者右边的双引号的右边要么什么都没有,要么只有逗号,'f'不可出现其他东西!')# TODO: 依次把其他配置和旧配置做合并for config in configs:if not os.path.exists(config[1]):raise FileNotFoundError(f'待合并配置文件 {config[1]} 不存在!')if os.path.isdir(config[1]):raise IsADirectoryError(f'{config[1]} 是目录而非文件!')try:c = json5.load(open(config[1], encoding='UTF-8-SIG'), parse_constant=parse_constant)# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeErrorexcept UnicodeDecodeError:raise ValueError(f"待合并配置文件 {config[1]} 的编码不是 UTF-8!")except ValueError:raise ValueError(f'1. 待合并配置文件 {config[1]} 有语法错误!\n'f'2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!')if not config[0]:if not isinstance(c, dict):raise TypeError(f'待合并配置文件 {config[1]} 的合并位置是根位置,其内容必须是一个 JSON 对象!')cur_config = celse:# TODO: 转嵌套字典loc = config[0].split('.')loc.reverse()for _ in loc:c = {_: c}cur_config = cself.merge_(self.old_config, cur_config, True)
-
当合并位置有冲突时,可以执行类型检查,防止配置错误 -
不同于 $include 指令需要去 JSON 配置文件中找到对应位置,一旦配置文件复杂起来对应位置会非常难找,在此处我选择参考上文访问 OpenClaw 配置中某个字段对应的值,用点号从顶级字段依次遍历下去
[{"id": "id1"},{"id": "id2"}]
[{"name": "name1"},{"name": "name2"}]
[{"id": "id1", "name": "name1"},{"id": "id2", "name": "name2"}]
-
不要求必须是两个这样的 JSON 数组做逐元素合并,允许超过两个,只要等长且元素是 JSON 对象就能通过 -
需要对每次的合并结果提供一个合并位置
def element_wise_merge(self, filename):if not os.path.exists(filename):raise FileNotFoundError(f'元配置文件 {filename} 不存在!')if os.path.isdir(filename):raise IsADirectoryError(f'{filename} 是目录而非文件!')# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件列表# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行configs, idx = [], -1try:for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):row = [_ for _ in row if _]for _ in row:if '\n' in _ or '\r' in _:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串或者待合并文件名对应字符串出现跨行!')if not row:continueif len(row) < 2:raise ValueError(f'第 {idx+1} 行的合并位置 {row[0]} 没有待合并配置文件!')configs.append((row[0], row[1:]))except UnicodeDecodeError:raise ValueError(f'元配置文件 {filename} 编码不是 UTF-8!')except csv.Error:# 此处 idx+1 对应的是上一行,所以要用 idx+2raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'f'2. 若某字符串被双引号包裹,其左边或者右边要么什么都没有,要么只有逗号,不可出现其他东西!')# TODO: 合并配置for config in configs:# TODO: 读取首个配置first_config = check_and_get_array4element_wise_merge(config[1][0])expected_length = len(first_config)# TODO: 读取其他配置for filename in config[1][1:]:cur_config = check_and_get_array4element_wise_merge(filename)cur_length = len(cur_config)if cur_length != expected_length:raise ValueError(f'当前合并位置:{config[0]}\n'f'当前文件:{filename}\n'f'当前 JSON 数组的长度和期望长度不一致,无法进行逐元素合并!\n'f'当前长度:{cur_length},期望长度:{expected_length}')for _ in range(expected_length):# 因为逐元素合并的过程中出现报错信息会有歧义,所以这里直接不做类型检查!具体会有什么歧义,举例如下:# 假设合并位置为 agents.list,如果用 agents.list.0.<...> 表示出错位置,其对应两种解释:# 1. agents.list 数组下的第 1 个元素;2. agents.list 对象下键为 0 的值# 如果用 agents.list[0] 表示出错位置,一样会有两种解释:# 1. agents.list 数组下的第 1 个元素;2. agents 对象下键为 list[0] 的值self.merge_(first_config[_], cur_config[_], False)config[1][:] = first_config# TODO: 转嵌套字典for i in range(len(configs)):loc = configs[i][0].split('.')loc.reverse()d = {loc[0]: configs[i][1]}for _ in loc[1:]:d = {_: d}configs[i] = dreturn configs
def check_and_get_array4element_wise_merge(filename):if not os.path.exists(filename):raise FileNotFoundError(f'待合并配置文件 {filename} 不存在!')if os.path.isdir(filename):raise IsADirectoryError(f'{filename} 是目录而非文件!')try:config = json5.load(open(filename, encoding='UTF-8-SIG'), parse_constant=parse_constant)# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeErrorexcept UnicodeDecodeError:raise ValueError(f"待合并配置文件 {filename} 的编码不是 UTF-8!")except ValueError:raise ValueError(f"1. 待合并配置文件 {filename} 有语法错误!\n"f"2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!")if not isinstance(config, list) or not all(isinstance(_, dict) for _ in config):raise TypeError(f'待合并配置文件 {filename} 对应内容必须是其元素为 JSON 对象的 JSON 数组!')return config
def parse_constant(_):# 防止 JSON5 解析在 JSON 中不合法的内容,比如 Infinity、NaNraise ValueError
def deduplicate(config):if isinstance(config, dict):for _ in config:deduplicate(config[_])elif isinstance(config, list):for _ in config:deduplicate(_)li = []for ec in config:if not any(strict_eq(ec, el) for el in li):li.append(ec)config[:] = li
def strict_eq(a, b):if type(a) is not type(b):return Falseif isinstance(a, dict):if a.keys() != b.keys():return Falsereturn all(strict_eq(a[_], b[_]) for _ in a)elif isinstance(a, list):if len(a) != len(b):return Falsereturn all(strict_eq(ea, eb) for ea, eb in zip(a, b))return a == b
import argparseimport csvimport jsonimport json5import osimport subprocessclass Merger:def __init__(self, old_config):self.loc, self.old_config = [], old_configself.type_table = {type(None): '空值类型', bool: '布尔类型', dict: 'JSON 对象', float: '浮点类型', int: '整型',list: 'JSON 数组', str: '字符串类型'}def element_wise_merge(self, filename):if not os.path.exists(filename):raise FileNotFoundError(f'元配置文件 {filename} 不存在!')if os.path.isdir(filename):raise IsADirectoryError(f'{filename} 是目录而非文件!')# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件列表# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行configs, idx = [], -1try:for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):row = [_ for _ in row if _]for _ in row:if '\n' in _ or '\r' in _:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串或者待合并文件名对应字符串出现跨行!')if not row:continueif len(row) < 2:raise ValueError(f'第 {idx+1} 行的合并位置 {row[0]} 没有待合并配置文件!')configs.append((row[0], row[1:]))except UnicodeDecodeError:raise ValueError(f'元配置文件 {filename} 编码不是 UTF-8!')except csv.Error:# 此处 idx+1 对应的是上一行,所以要用 idx+2raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'f'2. 若某字符串被双引号包裹,其左边或者右边要么什么都没有,要么只有逗号,不可出现其他东西!')# TODO: 合并配置for config in configs:# TODO: 读取首个配置first_config = check_and_get_array4element_wise_merge(config[1][0])expected_length = len(first_config)# TODO: 读取其他配置for filename in config[1][1:]:cur_config = check_and_get_array4element_wise_merge(filename)cur_length = len(cur_config)if cur_length != expected_length:raise ValueError(f'当前合并位置:{config[0]}\n'f'当前文件:{filename}\n'f'当前 JSON 数组的长度和期望长度不一致,无法进行逐元素合并!\n'f'当前长度:{cur_length},期望长度:{expected_length}')for _ in range(expected_length):# 因为逐元素合并的过程中出现报错信息会有歧义,所以这里直接不做类型检查!具体会有什么歧义,举例如下:# 假设合并位置为 agents.list,如果用 agents.list.0.<...> 表示出错位置,其对应两种解释:# 1. agents.list 数组下的第 1 个元素;2. agents.list 对象下键为 0 的值# 如果用 agents.list[0] 表示出错位置,一样会有两种解释:# 1. agents.list 数组下的第 1 个元素;2. agents 对象下键为 list[0] 的值self.merge_(first_config[_], cur_config[_], False)config[1][:] = first_config# TODO: 转嵌套字典for i in range(len(configs)):loc = configs[i][0].split('.')loc.reverse()d = {loc[0]: configs[i][1]}for _ in loc[1:]:d = {_: d}configs[i] = dreturn configsdef merge(self, filename):if not os.path.exists(filename):raise FileNotFoundError(f'元配置文件 {filename} 不存在!')if os.path.isdir(filename):raise IsADirectoryError(f'{filename} 是目录而非文件!')# TODO: 转二元组列表,二元组的第一个元素是合并位置,第二个元素是待合并文件# 之所以 idx 初值设为 -1,是因为这方便后续 raise csv.Error 可以统一计算行号,从而不需要单独考虑是不是首行configs, idx = [], -1try:for idx, row in enumerate(csv.reader(open(filename, encoding='UTF-8-SIG'), strict=True)):if not len(row):continueelif len(row) > 2:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的列数大于 2!有以下解法:\n'f'1. 检查元配置文件 {filename} 的第 {idx+1} 行的内容!'f'若需合并的配置文件路径对应字符串或者合并位置对应字符串有逗号,需用双引号包裹,'f'否则可能会导致逗号被当成分隔符把当前行分割成多列(超过 2 列),从而导致这个问题!\n'f'2. 检查元配置文件 {filename} 中是否有多个连续的且被双引号包裹的逗号,若有,保留一个即可!')elif len(row) == 1 or not row[1]:raise ValueError('第 %d 行,%s 没有待合并配置文件!' % (idx+1, f'当前位置 {row[0]}' if row[0] else '根位置'))if '\n' in row[0] or '\r' in row[0]:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的合并位置对应字符串出现跨行!')if '\n' in row[1] or '\r' in row[1]:raise ValueError(f'元配置文件 {filename} 的第 {idx+1} 行的待合并配置文件名对应字符串出现跨行!')configs.append((row[0], row[1]))except UnicodeDecodeError:raise ValueError(f'元配置文件 {filename} 的编码不是 UTF-8!')except csv.Error:# 此处 idx+1 对应的是上一行,所以要用 idx+2raise csv.Error(f'元配置文件 {filename} 第 {idx+2} 行存在语法错误!检查以下内容:\n'f'1. 若某字符串有双引号,则需在该字符串两边各放一个双引号,'f'且其中的双引号必须在前面或者后面再来一个双引号实现转义(即两个连续的双引号)!\n'f'2. 若某字符串被双引号包裹,左边的双引号的左边或者右边的双引号的右边要么什么都没有,要么只有逗号,'f'不可出现其他东西!')# TODO: 依次把其他配置和旧配置做合并for config in configs:if not os.path.exists(config[1]):raise FileNotFoundError(f'待合并配置文件 {config[1]} 不存在!')if os.path.isdir(config[1]):raise IsADirectoryError(f'{config[1]} 是目录而非文件!')try:c = json5.load(open(config[1], encoding='UTF-8-SIG'), parse_constant=parse_constant)# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeErrorexcept UnicodeDecodeError:raise ValueError(f"待合并配置文件 {config[1]} 的编码不是 UTF-8!")except ValueError:raise ValueError(f'1. 待合并配置文件 {config[1]} 有语法错误!\n'f'2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!')if not config[0]:if not isinstance(c, dict):raise TypeError(f'待合并配置文件 {config[1]} 的合并位置是根位置,其内容必须是一个 JSON 对象!')cur_config = celse:# TODO: 转嵌套字典loc = config[0].split('.')loc.reverse()for _ in loc:c = {_: c}cur_config = cself.merge_(self.old_config, cur_config, True)def merge_(self, origin_config, extra_config, check_type):for _ in extra_config:self.loc.append(_)if _ in origin_config:if isinstance(origin_config[_], dict) and isinstance(extra_config[_], dict):self.merge_(origin_config[_], extra_config[_], check_type)elif isinstance(origin_config[_], list) and isinstance(extra_config[_], list):origin_config[_].extend(extra_config[_])elif isinstance(origin_config[_], list):origin_config[_].append(extra_config[_])elif isinstance(extra_config[_], list):origin_config[_] = [origin_config[_]]+extra_config[_]elif type(origin_config[_]) is not type(extra_config[_]) and check_type:raise TypeError(f'{".".join(self.loc)} 字段类型不一致!期望类型:'f'{self.type_table[type(origin_config[_])]},实际类型:'f'{self.type_table[type(extra_config[_])]}')else:origin_config[_] = extra_config[_]else:origin_config[_] = extra_config[_]del self.loc[-1]def check_and_get_array4element_wise_merge(filename):if not os.path.exists(filename):raise FileNotFoundError(f'待合并配置文件 {filename} 不存在!')if os.path.isdir(filename):raise IsADirectoryError(f'{filename} 是目录而非文件!')try:config = json5.load(open(filename, encoding='UTF-8-SIG'), parse_constant=parse_constant)# 因为 UnicodeDecodeError 是 ValueError 的子类,所以必须先 except UnicodeDecodeErrorexcept UnicodeDecodeError:raise ValueError(f"待合并配置文件 {filename} 的编码不是 UTF-8!")except ValueError:raise ValueError(f"1. 待合并配置文件 {filename} 有语法错误!\n"f"2. 待合并配置文件存在特殊内容,比如 NaN、Infinity!")if not isinstance(config, list) or not all(isinstance(_, dict) for _ in config):raise TypeError(f'待合并配置文件 {filename} 对应内容必须是其元素为 JSON 对象的 JSON 数组!')return configdef deduplicate(config):if isinstance(config, dict):for _ in config:deduplicate(config[_])elif isinstance(config, list):for _ in config:deduplicate(_)li = []for ec in config:if not any(strict_eq(ec, el) for el in li):li.append(ec)config[:] = lidef export_old_config():schema = subprocess.run(('openclaw', 'config', 'schema'), capture_output=True, check=True,encoding='UTF-8-SIG', text=True)schema = json.loads(schema.stdout)top_fields = {_ for _ in schema['properties'] if not _.startswith("$")}config = {}for top_field in top_fields:cur_config = subprocess.run(('openclaw', 'config', 'get', top_field), capture_output=True,encoding='UTF-8-SIG', text=True)if not cur_config.returncode:config[top_field] = json.loads(cur_config.stdout)return configdef main():parser = argparse.ArgumentParser(description='合并 OpenClaw 多个配置片段的脚本程序,合并顺序如下所示:\n''1. 旧配置\n''2. 逐元素合并的 JSON 数组及其位置(内部按照元配置文件的顺序来)\n''3. 额外的配置(内部按照元配置文件的顺序来)\n\n''注意事项:\n''1. 合并期间会对 JSON 数组去除重复元素(以首次出现的顺序为准)\n''2. 冲突处理方式参考 $include 指令的对应处理方式\n''3. 报错信息若出现 " <文件名> " 的内容,则表示 <文件名> 对应文件有问题,比如不存在!\n''4. 所有文件,包括元配置文件、待合并配置文件以及导出最终配置的文件,编码必须全是 UTF-8!\n\n''<合并位置>注意事项,当合并位置出现类似索引的信息,比如 agents.list.0,''其会转化为 {"agents": {"list": {"0": ...}}} 而不是 {"agents": {"list": [...]}}\n''这样做的目的是为了不让合并位置出现在 JSON 数组内部,换句话说就是直接从根本上防止合并后的配置出现直接嵌套 JSON 数组,''当然,JSON 数组的元素是 JSON 对象,JSON 对象内又有 JSON 数组,这样的间接嵌套是允许的,由此可得:\n''1. 在进行逐元素合并的过程中,会禁止类型一致性检查,因为报错位置根本无法被完美定义,举个例子,假设报错位置是'' agents.list 下的第一个元素,比如:\n''\t(1)用 agents.list.0 表示报错位置会有两种解读方式,第一种是 {"agents": {"list": {"0": ...}}},''第二种是 {"agents": {"list": [...]}}\n''\t(2)用 agents.list[0] 表示报错位置也会有两种解读方式,第一种是 {"agents": {"list[0]": ...}},''第二种是 {"agents": {"list": [...]}}\n''2. 无法检查数组中的元素类型是否一致,原因同上,即报错位置歧义',formatter_class=argparse.RawTextHelpFormatter)parser.add_argument('--export-old-config', action='store_true',help='是否导出旧配置?命令行不提供该参数表示不导出旧配置;否则表示导出旧配置')parser.add_argument('--element-wise-merge', default='', type=str,help='需要进行逐元素合并的元配置文件的带路径的文件名,空字符串表示没有需要进行逐元素合并的元配置文件,默认值为空字符串。\n''元配置文件是一个至少两列且无表头的 CSV 文件,每一行格式如下所示:\n''<合并位置>,<文件名 1>,<文件名 2>,...,<文件名 N>\n''其中<合并位置>不能为空,<文件名 1>一直到<文件名 N>对应文件的内容必须全是等长且其元素为 JSON 对象的 JSON 数组,\n''<合并位置>示例 1:"agents.list",对应解释:agents 字段下的 list 子字段\n''<合并位置>示例 2:"agents..list",对应解释:agents 字段下的 空字符串 子字段,空字符串 子字段下的 list 子字段\n''<合并位置>示例 3:".agents",对应解释:空字符串 字段下的 agents 子字段\n''<合并位置>示例 4:"agents.",对应解释:agents 字段下的 空字符串 子字段\n\n''注意事项:\n''1. 为了提升容错能力,这里直接令当前行首个非缺失值作为<合并位置>,即若<合并位置>为空,则可能会把某个文件名当成合并位置,''会导致不符合预期的结果!\n''2. <合并位置>、<文件名 1>一直到<文件名 N>对应字符串必须是单行字符串!')parser.add_argument('--merge', default='', type=str,help='需要进行合并的元配置文件的带路径的文件名,空字符串表示没有需要进行合并的元配置文件,默认值为空字符串。\n''元配置文件是一个只有两列且无表头的 CSV 文件,每一行格式如下所示:\n''<合并位置>,<文件名>\n''其中,<合并位置>可为空,为空表示合并位置是根位置;<文件名>不能为空。\n''如果合并位置不是根位置,那么<文件名>对应文件内容可以是一个任意的 JSON 内容;''如果合并位置是根位置,那么<文件名>对应文件内容必须是一个 JSON 对象\n''<合并位置>的写法参考--element-wise-merge 的帮助信息\n\n''注意事项:\n''1. <合并位置>和<文件名>对应字符串必须是单行字符串!\n''2. 若要在一个<合并位置>放多个待合并文件,必须使用以下写法:\n''```\n''<合并位置 1>,<文件名 11>\n''<合并位置 1>,<文件名 12>\n''...\n''<合并位置 1>,<文件名 1N>\n''```')parser.add_argument('--dump', type=str, required=True,help='最终的配置文件名,必填参数。注意事项:\n''1. 别带路径,直接提供文件名即可,该文件对应目录就是当前工作目录!\n''2. 检查是否带路径这里是通过检查 / 或者 \\ 是否位于 --dump 参数值中,因此,即使 \\ 不是路径分隔符,此处依旧禁用!\n''3. 该参数对应字符串不可含有以下字符::、*、?、"、<、> 和 |\n''4. 该参数不可以是以下字符串:空字符串 和 空白字符串\n''5. 该参数对应字符串不能以点号结尾,点号右边加上空白字符也不行!\n''6. 若该脚本所在目录无修改权限,可考虑把该脚本文件复制一份到有修改权限的目录,比如用户家目录!\n''7. 最终的配置文件不可提前存在!\n''8. 若是 Windows 系统,则该参数对应字符串不能是保留设备名!\n''9. 本代码未检查该参数对应字符串是不是保留设备名,若使用保留设备名作为该参数对应字符串可能会出现各种奇怪的问题!')args = parser.parse_args()if not args.dump:raise ValueError('最终的配置文件名是空字符串!')if os.path.exists(args.dump):if os.path.isdir(args.dump):raise IsADirectoryError(f'{args.dump} 已存在且 {args.dump} 是目录!其绝对路径:{os.path.abspath(args.dump)}')else:raise FileExistsError(f'{args.dump} 已存在且 {args.dump} 是文件!其绝对路径:{os.path.abspath(args.dump)}')if '/' in args.dump or '\\' in args.dump:raise ValueError('--dump 参数只需提供文件名,别带路径!')for _ in ':*?"<>|':if _ in args.dump:raise ValueError(f'文件名 {args.dump} 中有非法字符(非法字符::、*、?、"、<、> 和 |)!')if args.dump.isspace():raise ValueError(f'文件名 {args.dump} 是空白字符串!')if args.dump.rstrip().endswith('.'):raise ValueError(f'文件名 {args.dump} 不能以点号结尾,点号右边加上空白字符也不行!')config = {}if args.export_old_config:config.update(export_old_config())merger = Merger(config)if args.element_wise_merge:element_wise_configs = merger.element_wise_merge(args.element_wise_merge)for element_wise_config in element_wise_configs:merger.merge_(config, element_wise_config, True)if args.merge:merger.merge(args.merge)if config:deduplicate(config)json.dump(config, open(args.dump, 'w', encoding='UTF-8'), ensure_ascii=False, indent=2)print(f'最终配置不为空!已经导出到 {args.dump} 文件中!其绝对路径:{os.path.abspath(args.dump)}')else:print('最终配置是空的!')def parse_constant(_):# 防止 JSON5 解析在 JSON 中不合法的内容,比如 Infinity、NaNraise ValueErrordef strict_eq(a, b):if type(a) is not type(b):return Falseif isinstance(a, dict):if a.keys() != b.keys():return Falsereturn all(strict_eq(a[_], b[_]) for _ in a)elif isinstance(a, list):if len(a) != len(b):return Falsereturn all(strict_eq(ea, eb) for ea, eb in zip(a, b))return a == bmain()








夜雨聆风