- main.py: wrap _ensure_display_access() in sys.platform != "win32" check - main.py: add FileNotFoundError to except in _find_active_xauthority() - claude_setup.py: platform-aware error message for ssh-keygen not found - CLAUDE.md: add mandatory cross-platform rules for all contributors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
11 KiB
Python
293 lines
11 KiB
Python
"""
|
||
Claude Code integration setup.
|
||
Installs ssh.py, encryption.py, /ssh skill, SSH key — everything needed
|
||
for Claude Code to manage servers via the shared servers.json.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import shutil
|
||
from core.logger import log
|
||
|
||
SHARED_DIR = os.path.expanduser("~/.server-connections")
|
||
|
||
# PyInstaller: bundled data is in sys._MEIPASS; otherwise use project dir
|
||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||
_BASE_DIR = sys._MEIPASS
|
||
else:
|
||
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
SSH_SCRIPT_SRC = os.path.join(_BASE_DIR, "tools", "ssh.py")
|
||
ENCRYPTION_SRC = os.path.join(_BASE_DIR, "core", "encryption.py")
|
||
SKILL_SRC = os.path.join(_BASE_DIR, "tools", "skill-ssh.md")
|
||
|
||
SKILL_DST_DIR = os.path.expanduser("~/.claude/commands")
|
||
SKILL_DST = os.path.join(SKILL_DST_DIR, "ssh.md")
|
||
SSH_KEY_PATH = os.path.expanduser("~/.ssh/id_ed25519")
|
||
GLOBAL_CLAUDE_MD = os.path.expanduser("~/.claude/CLAUDE.md")
|
||
|
||
_BLOCK_START = "<!-- server-manager:start -->"
|
||
_BLOCK_END = "<!-- server-manager:end -->"
|
||
|
||
GLOBAL_CLAUDE_MD_BLOCK = f"""{_BLOCK_START}
|
||
## Server Manager — управление серверами
|
||
|
||
**ВСЕГДА** используй server manager для подключения к серверам. Никогда не используй `ssh`, `sshpass` или прямые подключения.
|
||
|
||
- Скилл: `/ssh ALIAS "command"` — выполнить команду на сервере
|
||
- Список серверов: `python3 ~/.server-connections/ssh.py --list`
|
||
- Документация: `~/.claude/commands/ssh.md`
|
||
- Memory bank: проект `global-infrastructure` → `techContext.md`
|
||
- Инфраструктура: https://git.sensey24.ru/aibot777/infrastructure-docs
|
||
|
||
**Запрещено:** использовать `ssh`, `sshpass`, читать `~/.server-connections/` напрямую, раскрывать IP/пароли/порты.
|
||
{_BLOCK_END}
|
||
"""
|
||
|
||
|
||
def check_status() -> dict:
|
||
"""Check what's installed and what's missing."""
|
||
return {
|
||
"shared_dir": os.path.exists(SHARED_DIR),
|
||
"servers_json": os.path.exists(os.path.join(SHARED_DIR, "servers.json")),
|
||
"ssh_script": os.path.exists(os.path.join(SHARED_DIR, "ssh.py")),
|
||
"encryption": os.path.exists(os.path.join(SHARED_DIR, "encryption.py")),
|
||
"skill_installed": os.path.exists(SKILL_DST),
|
||
"ssh_key_exists": os.path.exists(SSH_KEY_PATH),
|
||
"ssh_key_pub": os.path.exists(SSH_KEY_PATH + ".pub"),
|
||
}
|
||
|
||
|
||
def install_ssh_script() -> str:
|
||
"""Copy ssh.py and encryption.py to shared dir."""
|
||
os.makedirs(SHARED_DIR, exist_ok=True)
|
||
results = []
|
||
|
||
# Copy ssh.py
|
||
dst = os.path.join(SHARED_DIR, "ssh.py")
|
||
if os.path.exists(SSH_SCRIPT_SRC):
|
||
shutil.copy2(SSH_SCRIPT_SRC, dst)
|
||
log.info(f"ssh.py installed: {dst}")
|
||
results.append(f"ssh.py installed: {dst}")
|
||
elif os.path.exists(dst):
|
||
results.append(f"ssh.py already exists: {dst}")
|
||
else:
|
||
results.append("ERROR: ssh.py source not found")
|
||
|
||
# Copy encryption.py
|
||
enc_dst = os.path.join(SHARED_DIR, "encryption.py")
|
||
if os.path.exists(ENCRYPTION_SRC):
|
||
shutil.copy2(ENCRYPTION_SRC, enc_dst)
|
||
log.info(f"encryption.py installed: {enc_dst}")
|
||
results.append(f"encryption.py installed: {enc_dst}")
|
||
elif os.path.exists(enc_dst):
|
||
results.append(f"encryption.py already exists: {enc_dst}")
|
||
else:
|
||
results.append("ERROR: encryption.py source not found")
|
||
|
||
return "\n".join(results)
|
||
|
||
|
||
def install_skill() -> str:
|
||
"""Install /ssh skill for Claude Code."""
|
||
os.makedirs(SKILL_DST_DIR, exist_ok=True)
|
||
if os.path.exists(SKILL_SRC):
|
||
shutil.copy2(SKILL_SRC, SKILL_DST)
|
||
log.info(f"Skill installed: {SKILL_DST}")
|
||
return f"Skill installed: {SKILL_DST}"
|
||
# Fallback: check existing
|
||
if os.path.exists(SKILL_DST):
|
||
return f"Skill already exists: {SKILL_DST}"
|
||
# Generate minimal skill
|
||
skill_content = _generate_skill_content()
|
||
with open(SKILL_DST, "w", encoding="utf-8") as f:
|
||
f.write(skill_content)
|
||
log.info(f"Skill generated: {SKILL_DST}")
|
||
return f"Skill generated: {SKILL_DST}"
|
||
|
||
|
||
def generate_ssh_key() -> str:
|
||
"""Generate ed25519 SSH key if not exists."""
|
||
if os.path.exists(SSH_KEY_PATH):
|
||
return f"Key already exists: {SSH_KEY_PATH}"
|
||
|
||
os.makedirs(os.path.dirname(SSH_KEY_PATH), exist_ok=True)
|
||
|
||
import subprocess
|
||
try:
|
||
subprocess.run(
|
||
["ssh-keygen", "-t", "ed25519", "-f", SSH_KEY_PATH,
|
||
"-N", "", "-C", "server-manager"],
|
||
check=True, capture_output=True, timeout=15
|
||
)
|
||
log.info(f"SSH key generated: {SSH_KEY_PATH}")
|
||
return f"Key generated: {SSH_KEY_PATH}"
|
||
except FileNotFoundError:
|
||
hint = "enable OpenSSH optional feature" if sys.platform == "win32" else "install openssh-client"
|
||
msg = f"ERROR: ssh-keygen not found — {hint}"
|
||
log.error(msg)
|
||
return msg
|
||
except subprocess.CalledProcessError as e:
|
||
msg = f"ERROR: ssh-keygen failed: {e.stderr.decode().strip()}"
|
||
log.error(msg)
|
||
return msg
|
||
|
||
|
||
def install_global_claude_md() -> str:
|
||
"""Add/update server manager section in global ~/.claude/CLAUDE.md.
|
||
|
||
Uses start/end markers to safely replace existing block without duplication.
|
||
"""
|
||
import re
|
||
os.makedirs(os.path.dirname(GLOBAL_CLAUDE_MD), exist_ok=True)
|
||
|
||
existing = ""
|
||
if os.path.exists(GLOBAL_CLAUDE_MD):
|
||
with open(GLOBAL_CLAUDE_MD, encoding="utf-8") as f:
|
||
existing = f.read()
|
||
|
||
pattern = re.compile(
|
||
re.escape(_BLOCK_START) + r".*?" + re.escape(_BLOCK_END),
|
||
re.DOTALL
|
||
)
|
||
|
||
if pattern.search(existing):
|
||
# Блок уже есть — заменяем на актуальную версию
|
||
updated = pattern.sub(GLOBAL_CLAUDE_MD_BLOCK.strip(), existing)
|
||
with open(GLOBAL_CLAUDE_MD, "w", encoding="utf-8") as f:
|
||
f.write(updated)
|
||
log.info(f"Global CLAUDE.md block updated: {GLOBAL_CLAUDE_MD}")
|
||
return f"Global CLAUDE.md block updated: {GLOBAL_CLAUDE_MD}"
|
||
else:
|
||
# Блока нет — добавляем в конец
|
||
with open(GLOBAL_CLAUDE_MD, "a", encoding="utf-8") as f:
|
||
if existing and not existing.endswith("\n"):
|
||
f.write("\n")
|
||
f.write("\n" + GLOBAL_CLAUDE_MD_BLOCK)
|
||
log.info(f"Global CLAUDE.md block added: {GLOBAL_CLAUDE_MD}")
|
||
return f"Global CLAUDE.md block added: {GLOBAL_CLAUDE_MD}"
|
||
|
||
|
||
def install_all() -> list[str]:
|
||
"""Full setup — install everything."""
|
||
results = []
|
||
|
||
steps = [
|
||
("ssh_script", install_ssh_script),
|
||
("skill", install_skill),
|
||
("ssh_key", generate_ssh_key),
|
||
("global_claude_md", install_global_claude_md),
|
||
]
|
||
|
||
for name, func in steps:
|
||
try:
|
||
log.info(f"install_all: running {name}")
|
||
result = func()
|
||
results.append(result)
|
||
except Exception as e:
|
||
msg = f"ERROR ({name}): {e}"
|
||
log.error(msg)
|
||
results.append(msg)
|
||
|
||
return results
|
||
|
||
|
||
def _generate_skill_content() -> str:
|
||
"""Generate /ssh skill markdown (fallback if skill-ssh.md not found)."""
|
||
return """# Скилл /ssh — управление удалёнными серверами
|
||
|
||
Ты управляешь удалёнными серверами через SSH-утилиту.
|
||
|
||
## ВАЖНО — Безопасность
|
||
|
||
- **НИКОГДА не читай файлы** в директории `~/.server-connections/` напрямую
|
||
- **НИКОГДА не читай** файлы `encryption.py`, `servers.json`, `settings.json`
|
||
- **НИКОГДА не выводи пароли, IP-адреса, логины, порты** пользователю и в контекст нейронки
|
||
- **НИКОГДА не используй** `--list-full` — он выводит IP/порты/логины в контекст AI
|
||
- **Все операции только через** `python ~/.server-connections/ssh.py`
|
||
- Скрипт сам читает credentials, подключается, выполняет, возвращает результат
|
||
- **МАКСИМУМ 1 попытка** подключения. Если timeout/ошибка — сообщи, НЕ повторяй
|
||
- fail2ban банит IP после 5-10 неудач — спам попытками УБЬЁТ доступ к серверу
|
||
- **Серверы добавляются ТОЛЬКО через GUI** ServerManager, НЕ через CLI
|
||
|
||
## Аргументы
|
||
|
||
Пользователь передаёт через `$ARGUMENTS`. Разбери и выполни.
|
||
|
||
## Команды
|
||
|
||
### Список серверов (безопасный — alias, тип, ключ, заметки)
|
||
```bash
|
||
python ~/.server-connections/ssh.py --list
|
||
```
|
||
|
||
### Информация о сервере (безопасная — без IP/логина/пароля/порта)
|
||
```bash
|
||
python ~/.server-connections/ssh.py --info ALIAS
|
||
```
|
||
|
||
### Статус всех серверов (alias + online/offline)
|
||
```bash
|
||
python ~/.server-connections/ssh.py --status
|
||
```
|
||
|
||
### Выполнить команду на сервере
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS "command"
|
||
```
|
||
|
||
### Выполнить команду БЕЗ sudo
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --no-sudo "command"
|
||
```
|
||
|
||
### Загрузить файл на сервер
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --upload "local/file" //remote/path/file
|
||
```
|
||
**ВАЖНО (Windows/Git Bash):** remote path ОБЯЗАТЕЛЬНО с двойным слешем `//home/...`, `//tmp/...`.
|
||
|
||
### Скачать файл с сервера
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --download //remote/path/file "local/file"
|
||
```
|
||
|
||
### Установить SSH-ключ на сервер
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --install-key
|
||
```
|
||
|
||
### Проверить доступность сервера
|
||
```bash
|
||
python ~/.server-connections/ssh.py ALIAS --ping
|
||
```
|
||
|
||
### Обновить заметки сервера
|
||
```bash
|
||
python ~/.server-connections/ssh.py --set-note ALIAS "описание сервера"
|
||
```
|
||
|
||
### Удалить сервер
|
||
```bash
|
||
python ~/.server-connections/ssh.py --remove ALIAS
|
||
```
|
||
**Спроси подтверждение у пользователя перед удалением!**
|
||
|
||
## Поведение
|
||
|
||
- **Auto-sudo**: если user не root — команды автоматически оборачиваются в `sudo -S`
|
||
- **--no-sudo**: для команд без root (например `ls`, `cat`)
|
||
- **Timeout**: 120 секунд на команду, 15 секунд на подключение
|
||
- **SSH-ключ**: пробуется первым, fallback на пароль
|
||
- **Прогресс**: файлы >=1MB показывают 25/50/75%, итог с размером/временем/скоростью
|
||
|
||
## Правила
|
||
|
||
- Отвечай на русском языке
|
||
- Показывай результат каждой операции
|
||
- При ошибках — объясняй причину и предлагай решение
|
||
- Если timeout — предложи проверить VPN/firewall/панель хостера
|
||
- Файлы создаваемые на сервере должны иметь права 664 (owner+group rw)
|
||
- При вопросе о серверах — СНАЧАЛА `--list`, потом `--info ALIAS` если нужны детали
|
||
"""
|