Files
server-manager/core/claude_setup.py

651 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Local AI agent integration setup.
Installs the shared ssh.py/encryption.py backend, Claude /ssh command,
Codex/Gemini skill packages, platform-specific wrappers, and SSH key material.
"""
import os
import re
import shutil
import subprocess
import sys
from core.logger import log
# 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")
CLAUDE_SKILL_SRC = os.path.join(_BASE_DIR, "tools", "skill-ssh.md")
GEMINI_CONTRACT_SRC = os.path.join(_BASE_DIR, "GEMINI.md")
CODEX_SKILL_SRC_DIR = os.path.join(_BASE_DIR, ".codex", "skills", "server-manager")
CODEX_WRAPPER_SRC_SH = os.path.join(CODEX_SKILL_SRC_DIR, "scripts", "codex-ssh-wrapper.sh")
CODEX_WRAPPER_SRC_CMD = os.path.join(CODEX_SKILL_SRC_DIR, "scripts", "codex-ssh-wrapper.cmd")
GEMINI_SKILL_SRC_DIR = os.path.join(_BASE_DIR, ".gemini", "skills", "server-manager")
GEMINI_WRAPPER_SRC_SH = os.path.join(GEMINI_SKILL_SRC_DIR, "scripts", "gemini-ssh-wrapper.sh")
GEMINI_WRAPPER_SRC_CMD = os.path.join(GEMINI_SKILL_SRC_DIR, "scripts", "gemini-ssh-wrapper.cmd")
_BLOCK_START = "<!-- server-manager:start -->"
_BLOCK_END = "<!-- server-manager:end -->"
_GEMINI_BLOCK_START = "<!-- server-manager-gemini:start -->"
_GEMINI_BLOCK_END = "<!-- server-manager-gemini:end -->"
GLOBAL_CLAUDE_MD_BLOCK = f"""{_BLOCK_START}
## Серверы — ТОЛЬКО через /ssh
**НИКОГДА не используй raw `ssh` команды.** НИКОГДА не читай `~/.ssh/config` для поиска серверов.
Все операции с серверами — **ТОЛЬКО через скилл `/ssh`** или напрямую через `ssh.py`:
```bash
python ~/.server-connections/ssh.py --list # список серверов (alias, тип, заметки)
python ~/.server-connections/ssh.py --info ALIAS # инфо (без creds)
python ~/.server-connections/ssh.py --status # online/offline
```
При вопросе о сервере — **СНАЧАЛА `--list`**, найди нужный алиас по заметкам и **ПРОВЕРЬ ТИП**.
Скрипт `ssh.py` сам читает credentials из зашифрованного хранилища. Claude НЕ видит IP, логины, пароли.
### КРИТИЧНО — команды зависят от типа сервера
**`ALIAS "command"` (shell) — ТОЛЬКО для типов `ssh` и `telnet`!**
| Тип | Команды |
|-----|---------|
| `ssh`/`telnet` | `ALIAS "cmd"`, `--upload ALIAS local remote`, `--download ALIAS remote local` |
| `s3` (MinIO и др.) | `--s3-buckets ALIAS`, `--s3-ls ALIAS bucket/prefix`, `--s3-upload ALIAS local bucket/key`, `--s3-download ALIAS bucket/key local`, `--s3-delete ALIAS bucket/key`, `--s3-url ALIAS bucket/key [SEC]`, `--s3-create-bucket ALIAS name` |
| `mariadb`/`mssql`/`postgresql` | `--sql ALIAS "SELECT ..."`, `--sql-databases ALIAS`, `--sql-tables ALIAS [db]` |
| `redis` | `--redis ALIAS "GET key"`, `--redis-info ALIAS`, `--redis-keys ALIAS "pattern"` |
| `grafana` | `--grafana-dashboards ALIAS`, `--grafana-alerts ALIAS` |
| `prometheus` | `--prom-query ALIAS "up"`, `--prom-targets ALIAS`, `--prom-alerts ALIAS` |
| `winrm` | `--ps ALIAS "Get-Process"`, `--cmd ALIAS "dir"` |
**Формат: `python ~/.server-connections/ssh.py КОМАНДА АЛИАС АРГУМЕНТЫ`** — алиас ВСЕГДА второй после команды.
**S3 правило:** перед `--s3-upload/download/delete` — СНАЧАЛА `--s3-buckets ALIAS` и `--s3-ls ALIAS bucket/` чтобы узнать реальные бакеты и пути. НЕ УГАДЫВАЙ имена бакетов!
**Запрещено:** использовать `ssh`/`sshpass`, читать `~/.server-connections/` напрямую, раскрывать IP/пароли/порты.
{_BLOCK_END}
"""
GLOBAL_GEMINI_MD_BLOCK = f"""{_GEMINI_BLOCK_START}
## ServerManager — use the installed skill
When a user asks about a server managed by ServerManager, use the installed `server-manager` skill first.
Preferred discovery commands:
```bash
$HOME/.server-connections/gemini-ssh --list
$HOME/.server-connections/gemini-ssh --info ALIAS
$HOME/.server-connections/gemini-ssh --status
```
Rules:
- Never read `~/.server-connections/servers.json`, `settings.json`, or `encryption.py` directly.
- Never use `--list-full`.
- Never use raw `ssh`, `scp`, `redis-cli`, `psql`, `mysql`, `mc`, or cloud CLIs unless the user explicitly asks to bypass ServerManager.
- Choose commands strictly by the endpoint type reported by `--list`.
- Use exactly one connection attempt per action and stop on timeout/failure.
{_GEMINI_BLOCK_END}
"""
def _target_home() -> str:
override = os.environ.get("SERVER_MANAGER_TARGET_HOME", "").strip()
if override:
return os.path.abspath(os.path.expanduser(override))
return os.path.expanduser("~")
def _shared_dir() -> str:
return os.path.join(_target_home(), ".server-connections")
def _gemini_dir() -> str:
return os.path.join(_target_home(), ".gemini")
def _claude_skill_dst_dir() -> str:
return os.path.join(_target_home(), ".claude", "commands")
def _claude_skill_dst() -> str:
return os.path.join(_claude_skill_dst_dir(), "ssh.md")
def _ssh_key_path() -> str:
return os.path.join(_target_home(), ".ssh", "id_ed25519")
def _global_claude_md() -> str:
return os.path.join(_target_home(), ".claude", "CLAUDE.md")
def _global_gemini_md() -> str:
return os.path.join(_gemini_dir(), "GEMINI.md")
def _codex_skill_dst_root() -> str:
return os.path.join(_target_home(), ".codex", "skills")
def _codex_skill_dst_dir() -> str:
return os.path.join(_codex_skill_dst_root(), "server-manager")
def _codex_skill_entry() -> str:
return os.path.join(_codex_skill_dst_dir(), "SKILL.md")
def _codex_wrapper_dst() -> str:
return os.path.join(
_shared_dir(),
"codex-ssh.cmd" if sys.platform == "win32" else "codex-ssh",
)
def _gemini_skill_dst_root() -> str:
return os.path.join(_gemini_dir(), "skills")
def _gemini_skill_dst_dir() -> str:
return os.path.join(_gemini_skill_dst_root(), "server-manager")
def _gemini_skill_entry() -> str:
return os.path.join(_gemini_skill_dst_dir(), "SKILL.md")
def _agents_skill_dst_root() -> str:
return os.path.join(_target_home(), ".agents", "skills")
def _agents_skill_dst_dir() -> str:
return os.path.join(_agents_skill_dst_root(), "server-manager")
def _gemini_wrapper_dst() -> str:
return os.path.join(
_shared_dir(),
"gemini-ssh.cmd" if sys.platform == "win32" else "gemini-ssh",
)
def _ensure_executable(path: str):
if sys.platform == "win32" or not os.path.exists(path):
return
mode = os.stat(path).st_mode
os.chmod(path, mode | 0o755)
def _copy_file(src: str, dst: str, executable: bool = False) -> str:
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
if executable:
_ensure_executable(dst)
return dst
def _copy_tree(src: str, dst: str) -> str:
os.makedirs(dst, exist_ok=True)
shutil.copytree(src, dst, dirs_exist_ok=True)
return dst
def _install_wrapper(src: str, dst: str) -> str:
return _copy_file(src, dst, executable=(sys.platform != "win32"))
def _skill_script_names() -> list[str]:
if sys.platform == "win32":
return [
os.path.join("scripts", "server-manager-doctor.cmd"),
os.path.join("scripts", "codex-ssh-wrapper.cmd"),
os.path.join("scripts", "server-manager-gemini-doctor.cmd"),
os.path.join("scripts", "gemini-ssh-wrapper.cmd"),
]
return [
os.path.join("scripts", "server-manager-doctor.sh"),
os.path.join("scripts", "codex-ssh-wrapper.sh"),
os.path.join("scripts", "server-manager-gemini-doctor.sh"),
os.path.join("scripts", "gemini-ssh-wrapper.sh"),
]
def _ensure_skill_scripts(skill_dir: str):
for rel_path in _skill_script_names():
_ensure_executable(os.path.join(skill_dir, rel_path))
def _iter_all_user_homes() -> list[str]:
homes: list[str] = []
def add(path: str):
expanded = os.path.abspath(os.path.expanduser(path))
if os.path.isdir(expanded) and expanded not in homes:
homes.append(expanded)
add(_target_home())
if sys.platform == "win32":
users_root = os.path.join(os.environ.get("SystemDrive", "C:"), "Users")
skip = {"public", "default", "default user", "all users"}
if os.path.isdir(users_root):
for name in sorted(os.listdir(users_root)):
if name.lower() in skip:
continue
add(os.path.join(users_root, name))
elif sys.platform == "darwin":
add("/var/root")
users_root = "/Users"
if os.path.isdir(users_root):
for name in sorted(os.listdir(users_root)):
if name.startswith("."):
continue
add(os.path.join(users_root, name))
else:
add("/root")
users_root = "/home"
if os.path.isdir(users_root):
for name in sorted(os.listdir(users_root)):
if name.startswith("."):
continue
add(os.path.join(users_root, name))
return homes
def check_status() -> dict:
"""Check what's installed and what's missing."""
shared_dir = _shared_dir()
ssh_key_path = _ssh_key_path()
return {
"target_home": _target_home(),
"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")),
"claude_skill_installed": os.path.exists(_claude_skill_dst()),
"codex_skill_installed": os.path.exists(_codex_skill_entry()),
"codex_wrapper_installed": os.path.exists(_codex_wrapper_dst()),
"gemini_skill_installed": os.path.exists(_gemini_skill_entry()),
"gemini_wrapper_installed": os.path.exists(_gemini_wrapper_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."""
shared_dir = _shared_dir()
os.makedirs(shared_dir, exist_ok=True)
results = []
dst = os.path.join(shared_dir, "ssh.py")
if os.path.exists(SSH_SCRIPT_SRC):
_copy_file(SSH_SCRIPT_SRC, dst, executable=True)
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")
enc_dst = os.path.join(shared_dir, "encryption.py")
if os.path.exists(ENCRYPTION_SRC):
_copy_file(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_claude_skill() -> str:
"""Install /ssh skill for Claude Code."""
claude_skill_dst_dir = _claude_skill_dst_dir()
claude_skill_dst = _claude_skill_dst()
os.makedirs(claude_skill_dst_dir, exist_ok=True)
if os.path.exists(CLAUDE_SKILL_SRC):
_copy_file(CLAUDE_SKILL_SRC, claude_skill_dst)
log.info(f"Claude skill installed: {claude_skill_dst}")
return f"Claude skill installed: {claude_skill_dst}"
if os.path.exists(claude_skill_dst):
return f"Claude skill already exists: {claude_skill_dst}"
skill_content = _generate_skill_content()
with open(claude_skill_dst, "w", encoding="utf-8") as f:
f.write(skill_content)
log.info(f"Claude skill generated: {claude_skill_dst}")
return f"Claude skill generated: {claude_skill_dst}"
def install_codex_skill() -> str:
"""Install ServerManager skill package for Codex and the local wrapper."""
results = []
codex_skill_dst_dir = _codex_skill_dst_dir()
codex_skill_entry = _codex_skill_entry()
codex_wrapper_dst = _codex_wrapper_dst()
if os.path.isdir(CODEX_SKILL_SRC_DIR):
_copy_tree(CODEX_SKILL_SRC_DIR, codex_skill_dst_dir)
_ensure_skill_scripts(codex_skill_dst_dir)
log.info(f"Codex skill installed: {codex_skill_dst_dir}")
results.append(f"Codex skill installed: {codex_skill_dst_dir}")
elif os.path.exists(codex_skill_entry):
results.append(f"Codex skill already exists: {codex_skill_dst_dir}")
else:
results.append("ERROR: Codex skill source not found")
wrapper_src = CODEX_WRAPPER_SRC_CMD if sys.platform == "win32" else CODEX_WRAPPER_SRC_SH
if os.path.exists(wrapper_src):
_copy_file(wrapper_src, codex_wrapper_dst, executable=(sys.platform != "win32"))
log.info(f"Codex wrapper installed: {codex_wrapper_dst}")
results.append(f"Codex wrapper installed: {codex_wrapper_dst}")
elif os.path.exists(codex_wrapper_dst):
results.append(f"Codex wrapper already exists: {codex_wrapper_dst}")
else:
results.append("ERROR: Codex wrapper source not found")
return "\n".join(results)
def install_gemini_skill() -> str:
"""Install ServerManager skill package for Gemini."""
results = []
gemini_skill_dst_dir = _gemini_skill_dst_dir()
gemini_skill_entry = _gemini_skill_entry()
agents_skill_dst_dir = _agents_skill_dst_dir()
gemini_wrapper_dst = _gemini_wrapper_dst()
install_generic_mirror = os.environ.get(
"SERVER_MANAGER_INSTALL_GENERIC_SKILL_MIRROR", ""
).strip() == "1"
if os.path.isdir(GEMINI_SKILL_SRC_DIR):
_copy_tree(GEMINI_SKILL_SRC_DIR, gemini_skill_dst_dir)
_ensure_skill_scripts(gemini_skill_dst_dir)
log.info(f"Gemini skill installed: {gemini_skill_dst_dir}")
results.append(f"Gemini skill installed: {gemini_skill_dst_dir}")
if install_generic_mirror:
_copy_tree(GEMINI_SKILL_SRC_DIR, agents_skill_dst_dir)
_ensure_skill_scripts(agents_skill_dst_dir)
log.info(f"Generic agents skill mirror installed: {agents_skill_dst_dir}")
results.append(f"Generic agents skill mirror installed: {agents_skill_dst_dir}")
elif os.path.exists(agents_skill_dst_dir):
shutil.rmtree(agents_skill_dst_dir, ignore_errors=True)
log.info(f"Removed generic agents skill mirror to avoid Gemini conflicts: {agents_skill_dst_dir}")
results.append(
f"Removed generic agents skill mirror to avoid Gemini conflicts: {agents_skill_dst_dir}"
)
elif os.path.exists(gemini_skill_entry):
results.append(f"Gemini skill already exists: {gemini_skill_dst_dir}")
else:
results.append("ERROR: Gemini skill source not found")
wrapper_src = GEMINI_WRAPPER_SRC_CMD if sys.platform == "win32" else GEMINI_WRAPPER_SRC_SH
if not os.path.exists(wrapper_src):
wrapper_src = CODEX_WRAPPER_SRC_CMD if sys.platform == "win32" else CODEX_WRAPPER_SRC_SH
if os.path.exists(wrapper_src):
_install_wrapper(wrapper_src, gemini_wrapper_dst)
log.info(f"Gemini wrapper installed: {gemini_wrapper_dst}")
results.append(f"Gemini wrapper installed: {gemini_wrapper_dst}")
elif os.path.exists(gemini_wrapper_dst):
results.append(f"Gemini wrapper already exists: {gemini_wrapper_dst}")
else:
results.append("ERROR: Gemini wrapper source not found")
return "\n".join(results)
def install_skill() -> str:
"""Backward-compatible alias for the Claude /ssh skill installer."""
return install_claude_skill()
def generate_ssh_key() -> str:
"""Generate ed25519 SSH key if not exists."""
ssh_key_path = _ssh_key_path()
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)
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."""
global_claude_md = _global_claude_md()
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}"
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_global_gemini_md() -> str:
"""Add/update server manager section in global ~/.gemini/GEMINI.md."""
global_gemini_md = _global_gemini_md()
os.makedirs(os.path.dirname(global_gemini_md), exist_ok=True)
existing = ""
if os.path.exists(global_gemini_md):
with open(global_gemini_md, encoding="utf-8") as f:
existing = f.read()
pattern = re.compile(
re.escape(_GEMINI_BLOCK_START) + r".*?" + re.escape(_GEMINI_BLOCK_END),
re.DOTALL
)
if pattern.search(existing):
updated = pattern.sub(GLOBAL_GEMINI_MD_BLOCK.strip(), existing)
with open(global_gemini_md, "w", encoding="utf-8") as f:
f.write(updated)
log.info(f"Global GEMINI.md block updated: {global_gemini_md}")
return f"Global GEMINI.md block updated: {global_gemini_md}"
with open(global_gemini_md, "a", encoding="utf-8") as f:
if existing and not existing.endswith("\n"):
f.write("\n")
f.write("\n" + GLOBAL_GEMINI_MD_BLOCK)
log.info(f"Global GEMINI.md block added: {global_gemini_md}")
return f"Global GEMINI.md block added: {global_gemini_md}"
def install_all() -> list[str]:
"""Full setup — install everything for Claude Code, Codex, and Gemini."""
all_users = os.environ.get("SERVER_MANAGER_INSTALL_ALL_USERS", "").strip() == "1"
base_steps = [
("ssh_script", install_ssh_script),
("claude_skill", install_claude_skill),
("codex_skill", install_codex_skill),
("gemini_skill", install_gemini_skill),
("global_claude_md", install_global_claude_md),
("global_gemini_md", install_global_gemini_md),
]
if not all_users:
steps = base_steps[:3] + [("ssh_key", generate_ssh_key)] + base_steps[3:]
results = []
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
results = []
original_target = os.environ.get("SERVER_MANAGER_TARGET_HOME")
for home in _iter_all_user_homes():
os.environ["SERVER_MANAGER_TARGET_HOME"] = home
results.append(f"[target_home] {home}")
for name, func in base_steps:
try:
log.info(f"install_all(all_users): running {name} for {home}")
result = func()
results.append(result)
except Exception as e:
msg = f"ERROR ({name}, {home}): {e}"
log.error(msg)
results.append(msg)
if original_target is None:
os.environ.pop("SERVER_MANAGER_TARGET_HOME", None)
else:
os.environ["SERVER_MANAGER_TARGET_HOME"] = original_target
results.append("INFO: SSH key generation skipped in SERVER_MANAGER_INSTALL_ALL_USERS=1 mode")
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` если нужны детали
"""