install-hermes-bridge.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. """Install the bundled Hermes plugin; preserve disabled settings and keep a backup.
  2. Run with the Hermes virtualenv Python. This command does not restart Hermes.
  3. """
  4. import argparse
  5. import json
  6. import hashlib
  7. import os
  8. import re
  9. import shutil
  10. import tempfile
  11. import uuid
  12. from datetime import datetime, timezone
  13. from pathlib import Path
  14. import yaml
  15. def fingerprint(directory):
  16. files = sorted(p for p in directory.rglob('*') if p.is_file() and '__pycache__' not in p.parts
  17. and 'tests' not in p.relative_to(directory).parts and (p.suffix == '.py' or p.name in ('plugin.yaml', 'manifest.json')))
  18. digest = hashlib.sha256()
  19. for path in files:
  20. digest.update(path.relative_to(directory).as_posix().encode() + b'\0' + path.read_bytes() + b'\0')
  21. return digest.hexdigest()
  22. def check(root, profile, source):
  23. home = root if profile == 'default' else root / 'profiles' / profile
  24. result = {'profile': profile, 'exists': (home / 'config.yaml').is_file(), 'valid': False}
  25. if not result['exists'] or home.is_symlink() or (home / 'config.yaml').is_symlink():
  26. return result
  27. try:
  28. config = yaml.safe_load((home / 'config.yaml').read_bytes()) or {}
  29. plugins = config.get('plugins') or {}
  30. result.update(valid=True, enabled='yaoyao-bot-bridge' in plugins.get('enabled', []),
  31. disabled='yaoyao-bot-bridge' in plugins.get('disabled', []))
  32. target = home / 'plugins' / 'yaoyao-bot-bridge'
  33. try:
  34. manifest = yaml.safe_load((target / 'plugin.yaml').read_bytes()) if (target / 'plugin.yaml').is_file() else {}
  35. result.update(installedVersion=str(manifest.get('version', '')) or None,
  36. fingerprint=fingerprint(target) if manifest else None,
  37. filesCurrent=bool(manifest) and fingerprint(target) == fingerprint(source))
  38. except Exception:
  39. result.update(installedVersion='未知', filesCurrent=False, message='插件文件损坏,可以重新安装修复')
  40. except Exception:
  41. result['message'] = '无法读取此 Profile 的插件配置'
  42. return result
  43. def install(root, profile, source, enable=False):
  44. if not re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,63}', profile):
  45. raise ValueError('Profile 名称无效')
  46. home = root if profile == 'default' else root / 'profiles' / profile
  47. config_path = home / 'config.yaml'
  48. if not config_path.is_file() or config_path.is_symlink() or home.is_symlink():
  49. raise ValueError('Profile 配置不存在或是符号链接')
  50. original = config_path.read_bytes()
  51. config = yaml.safe_load(original) or {}
  52. if not isinstance(config, dict):
  53. raise ValueError('Profile 配置格式无效')
  54. plugins = config.setdefault('plugins', {})
  55. if not isinstance(plugins, dict):
  56. raise ValueError('Profile 插件配置格式无效')
  57. for key in ('enabled', 'disabled'):
  58. if key in plugins and not isinstance(plugins[key], list):
  59. raise ValueError('Profile 插件列表格式无效')
  60. name = 'yaoyao-bot-bridge'
  61. if enable:
  62. plugins['disabled'] = [item for item in plugins.get('disabled', []) if item != name]
  63. # A dedicated Enable action changes this plugin's toolset only.
  64. agent = config.get('agent') or {}
  65. if isinstance(agent.get('disabled_toolsets'), list):
  66. agent['disabled_toolsets'] = [item for item in agent['disabled_toolsets'] if item != 'yaoyao_bot_bridge']
  67. platforms = config.get('platform_toolsets') or {}
  68. for platform in ('cli', 'tui'):
  69. if isinstance(platforms.get(platform), list) and 'all' not in platforms[platform] and 'yaoyao_bot_bridge' not in platforms[platform]:
  70. platforms[platform].append('yaoyao_bot_bridge')
  71. disabled = name in plugins.get('disabled', [])
  72. if not disabled and name not in plugins.setdefault('enabled', []):
  73. plugins['enabled'].append(name)
  74. directory = home / 'plugins'
  75. target = directory / name
  76. if directory.is_symlink() or target.is_symlink():
  77. raise ValueError('插件目录不能是符号链接')
  78. directory.mkdir(parents=True, exist_ok=True)
  79. stamp = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + uuid.uuid4().hex[:8]
  80. backup = home / 'backups' / ('yaoyao-bridge-' + stamp)
  81. backup.mkdir(parents=True, mode=0o700)
  82. (backup / 'config.yaml').write_bytes(original)
  83. (backup / 'config.yaml').chmod(0o600)
  84. staged = directory / ('.yaoyao-bridge-' + uuid.uuid4().hex)
  85. previous = backup / name
  86. moved = False
  87. activated = False
  88. try:
  89. shutil.copytree(source, staged, ignore=shutil.ignore_patterns('__pycache__', '*.pyc', 'tests'))
  90. if target.exists():
  91. target.rename(previous)
  92. moved = True
  93. staged.rename(target)
  94. activated = True
  95. fd, temporary = tempfile.mkstemp(prefix='.config-yaoyao-', dir=home)
  96. try:
  97. with os.fdopen(fd, 'w') as stream:
  98. yaml.safe_dump(config, stream, allow_unicode=True, sort_keys=False)
  99. os.replace(temporary, config_path)
  100. finally:
  101. Path(temporary).unlink(missing_ok=True)
  102. except BaseException:
  103. if activated:
  104. shutil.rmtree(target)
  105. if moved:
  106. previous.rename(target)
  107. config_path.write_bytes(original)
  108. raise
  109. finally:
  110. if staged.exists():
  111. shutil.rmtree(staged)
  112. return {'profile': profile, 'installed': str(target), 'backup': str(backup), 'disabled': disabled,
  113. 'message': '已更新,保留显式禁用设置' if disabled else '已更新并启用;空闲时重启 Hermes Dashboard 服务后生效'}
  114. def main():
  115. parser = argparse.ArgumentParser(description=__doc__)
  116. parser.add_argument('--hermes-home', required=True, type=Path)
  117. parser.add_argument('--profile', action='append', default=[])
  118. parser.add_argument('--check', action='store_true')
  119. parser.add_argument('--enable', action='store_true', help='明确启用此插件及其工具集,保留其他禁用项')
  120. args = parser.parse_args()
  121. source = Path(__file__).resolve().parent / 'hermes-bots-bridge'
  122. if not source.is_dir():
  123. source = Path(__file__).resolve().parents[1] / 'integrations' / 'hermes-bots-bridge'
  124. if not (source / 'plugin.yaml').is_file():
  125. raise ValueError('缺少随附的工具桥插件')
  126. root = args.hermes_home.expanduser().resolve()
  127. profiles = args.profile or (['default'] + sorted(p.name for p in (root / 'profiles').iterdir()
  128. if p.is_dir() and not p.is_symlink() and re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,63}', p.name))
  129. if args.check and (root / 'profiles').is_dir() else ['default'])
  130. for profile in dict.fromkeys(profiles):
  131. if not re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,63}', profile):
  132. raise ValueError('Profile 名称无效')
  133. result = check(root, profile, source) if args.check else install(root, profile, source, enable=args.enable)
  134. print(json.dumps(result, ensure_ascii=False))
  135. if __name__ == '__main__':
  136. main()