mirror of
https://github.com/ghndrx/shellmate.git
synced 2026-02-10 06:45:02 +00:00
Initial scaffold for ShellMate - SSH chess TUI
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
*.egg
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Secrets
|
||||
.env
|
||||
*.pem
|
||||
*_key
|
||||
ssh_host_*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Install Stockfish
|
||||
RUN apt-get update && apt-get install -y \
|
||||
stockfish \
|
||||
openssh-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python dependencies
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# Copy application
|
||||
COPY src/ src/
|
||||
|
||||
# Generate SSH host key
|
||||
RUN mkdir -p /etc/shellmate && \
|
||||
ssh-keygen -t ed25519 -f /etc/shellmate/ssh_host_key -N ""
|
||||
|
||||
# Run as non-root
|
||||
RUN useradd -m shellmate
|
||||
USER shellmate
|
||||
|
||||
EXPOSE 2222
|
||||
|
||||
CMD ["shellmate-server"]
|
||||
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ♟️ ShellMate
|
||||
|
||||
**SSH into chess mastery.**
|
||||
|
||||
```
|
||||
ssh play@shellmate.sh
|
||||
```
|
||||
|
||||
A terminal-based chess experience over SSH. Play against AI, challenge friends, or learn the game with interactive tutorials.
|
||||
|
||||
## Features
|
||||
|
||||
### 🎮 Game Modes
|
||||
- **vs AI** — Challenge Stockfish at adjustable difficulty levels
|
||||
- **vs Player** — Real-time PvP matchmaking
|
||||
- **vs Friend** — Private rooms with shareable codes
|
||||
|
||||
### 📚 Learn
|
||||
- **Interactive Tutorials** — From basics to advanced tactics
|
||||
- **Move Analysis** — AI explains why each move matters
|
||||
- **Puzzle Rush** — Tactical training exercises
|
||||
- **Opening Explorer** — Learn popular openings with explanations
|
||||
|
||||
### 🏆 Features
|
||||
- ELO rating system
|
||||
- Game history & replay
|
||||
- Multiple board themes
|
||||
- Move hints & analysis
|
||||
- Chat in PvP games
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Connect and play
|
||||
ssh play@shellmate.sh
|
||||
|
||||
# Spectate a game
|
||||
ssh watch@shellmate.sh
|
||||
|
||||
# Tutorial mode
|
||||
ssh learn@shellmate.sh
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Python 3.11+**
|
||||
- **Textual** — Modern TUI framework
|
||||
- **python-chess** — Chess logic & notation
|
||||
- **Stockfish** — AI engine
|
||||
- **asyncssh** — SSH server
|
||||
- **Redis** — Matchmaking & sessions
|
||||
- **PostgreSQL** — User data & game history
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [docs/self-hosting.md](docs/self-hosting.md) for configuration.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
*Built with ♟️ by Greg Hendrickson*
|
||||
49
docker-compose.yml
Normal file
49
docker-compose.yml
Normal file
@@ -0,0 +1,49 @@
|
||||
services:
|
||||
shellmate:
|
||||
build: .
|
||||
ports:
|
||||
- "2222:2222"
|
||||
environment:
|
||||
- SHELLMATE_SSH_PORT=2222
|
||||
- SHELLMATE_REDIS_URL=redis://redis:6379
|
||||
- SHELLMATE_DATABASE_URL=postgresql://shellmate:shellmate@postgres:5432/shellmate
|
||||
- STOCKFISH_PATH=/usr/games/stockfish
|
||||
depends_on:
|
||||
- redis
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "2222"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=shellmate
|
||||
- POSTGRES_PASSWORD=shellmate
|
||||
- POSTGRES_DB=shellmate
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U shellmate"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
postgres_data:
|
||||
46
pyproject.toml
Normal file
46
pyproject.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[project]
|
||||
name = "shellmate"
|
||||
version = "0.1.0"
|
||||
description = "SSH into chess mastery - Terminal chess over SSH"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Greg Hendrickson", email = "greg@gregh.dev" }]
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"textual>=0.50.0",
|
||||
"python-chess>=1.10.0",
|
||||
"asyncssh>=2.14.0",
|
||||
"redis>=5.0.0",
|
||||
"asyncpg>=0.29.0",
|
||||
"stockfish>=3.28.0",
|
||||
"rich>=13.7.0",
|
||||
"pydantic>=2.5.0",
|
||||
"pydantic-settings>=2.1.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.23.0",
|
||||
"ruff>=0.2.0",
|
||||
"mypy>=1.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
shellmate = "shellmate.cli:main"
|
||||
shellmate-server = "shellmate.ssh.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
strict = true
|
||||
3
src/shellmate/__init__.py
Normal file
3
src/shellmate/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""ShellMate - SSH into chess mastery."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
src/shellmate/ai/__init__.py
Normal file
5
src/shellmate/ai/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""AI engine integration."""
|
||||
|
||||
from .engine import ChessAI
|
||||
|
||||
__all__ = ["ChessAI"]
|
||||
158
src/shellmate/ai/engine.py
Normal file
158
src/shellmate/ai/engine.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Stockfish AI engine wrapper with move explanations."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
import chess
|
||||
from stockfish import Stockfish
|
||||
|
||||
|
||||
@dataclass
|
||||
class MoveAnalysis:
|
||||
"""Analysis of a chess position/move."""
|
||||
best_move: str
|
||||
evaluation: float # centipawns, positive = white advantage
|
||||
depth: int
|
||||
pv: list[str] # principal variation
|
||||
explanation: str
|
||||
|
||||
|
||||
class ChessAI:
|
||||
"""Chess AI powered by Stockfish with explanations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stockfish_path: str = "/usr/bin/stockfish",
|
||||
skill_level: int = 10,
|
||||
think_time_ms: int = 1000,
|
||||
):
|
||||
self.engine = Stockfish(path=stockfish_path)
|
||||
self.engine.set_skill_level(skill_level)
|
||||
self.think_time_ms = think_time_ms
|
||||
self._skill_level = skill_level
|
||||
|
||||
def set_difficulty(self, level: int) -> None:
|
||||
"""Set AI difficulty (0-20)."""
|
||||
self._skill_level = max(0, min(20, level))
|
||||
self.engine.set_skill_level(self._skill_level)
|
||||
|
||||
def get_best_move(self, fen: str) -> str:
|
||||
"""Get the best move for the current position."""
|
||||
self.engine.set_fen_position(fen)
|
||||
return self.engine.get_best_move_time(self.think_time_ms)
|
||||
|
||||
def analyze_position(self, fen: str, depth: int = 15) -> MoveAnalysis:
|
||||
"""Analyze a position and return detailed analysis."""
|
||||
self.engine.set_fen_position(fen)
|
||||
self.engine.set_depth(depth)
|
||||
|
||||
evaluation = self.engine.get_evaluation()
|
||||
best_move = self.engine.get_best_move()
|
||||
top_moves = self.engine.get_top_moves(3)
|
||||
|
||||
# Convert evaluation to centipawns
|
||||
if evaluation["type"] == "cp":
|
||||
eval_cp = evaluation["value"]
|
||||
else: # mate
|
||||
eval_cp = 10000 if evaluation["value"] > 0 else -10000
|
||||
|
||||
# Generate explanation
|
||||
explanation = self._generate_explanation(fen, best_move, eval_cp, top_moves)
|
||||
|
||||
return MoveAnalysis(
|
||||
best_move=best_move,
|
||||
evaluation=eval_cp / 100, # convert to pawns
|
||||
depth=depth,
|
||||
pv=[m["Move"] for m in top_moves] if top_moves else [best_move],
|
||||
explanation=explanation,
|
||||
)
|
||||
|
||||
def _generate_explanation(
|
||||
self,
|
||||
fen: str,
|
||||
best_move: str,
|
||||
eval_cp: int,
|
||||
top_moves: list,
|
||||
) -> str:
|
||||
"""Generate human-readable explanation for a move."""
|
||||
board = chess.Board(fen)
|
||||
move = chess.Move.from_uci(best_move)
|
||||
san = board.san(move)
|
||||
piece = board.piece_at(move.from_square)
|
||||
|
||||
explanations = []
|
||||
|
||||
# Describe the move
|
||||
piece_name = chess.piece_name(piece.piece_type).capitalize() if piece else "Piece"
|
||||
explanations.append(f"{piece_name} to {chess.square_name(move.to_square)} ({san})")
|
||||
|
||||
# Check for captures
|
||||
if board.is_capture(move):
|
||||
captured = board.piece_at(move.to_square)
|
||||
if captured:
|
||||
explanations.append(f"Captures {chess.piece_name(captured.piece_type)}")
|
||||
|
||||
# Check for checks
|
||||
board.push(move)
|
||||
if board.is_check():
|
||||
if board.is_checkmate():
|
||||
explanations.append("Checkmate!")
|
||||
else:
|
||||
explanations.append("Puts the king in check")
|
||||
board.pop()
|
||||
|
||||
# Evaluation context
|
||||
if abs(eval_cp) < 50:
|
||||
explanations.append("Position is roughly equal")
|
||||
elif eval_cp > 300:
|
||||
explanations.append("White has a significant advantage")
|
||||
elif eval_cp < -300:
|
||||
explanations.append("Black has a significant advantage")
|
||||
elif eval_cp > 0:
|
||||
explanations.append("White is slightly better")
|
||||
else:
|
||||
explanations.append("Black is slightly better")
|
||||
|
||||
return ". ".join(explanations) + "."
|
||||
|
||||
def explain_move(self, fen_before: str, move_uci: str) -> str:
|
||||
"""Explain why a specific move is good or bad."""
|
||||
analysis_before = self.analyze_position(fen_before)
|
||||
|
||||
board = chess.Board(fen_before)
|
||||
move = chess.Move.from_uci(move_uci)
|
||||
san = board.san(move)
|
||||
board.push(move)
|
||||
|
||||
analysis_after = self.analyze_position(board.fen())
|
||||
|
||||
eval_change = analysis_before.evaluation - analysis_after.evaluation
|
||||
|
||||
if move_uci == analysis_before.best_move:
|
||||
quality = "This is the best move in this position!"
|
||||
elif abs(eval_change) < 0.3:
|
||||
quality = "A solid move that maintains the position."
|
||||
elif eval_change > 1.0:
|
||||
quality = f"This move loses about {eval_change:.1f} pawns of advantage."
|
||||
elif eval_change > 0.3:
|
||||
quality = "A slight inaccuracy - there was a better option."
|
||||
else:
|
||||
quality = "A good move!"
|
||||
|
||||
return f"{san}: {quality} {analysis_before.explanation}"
|
||||
|
||||
def get_hint(self, fen: str) -> str:
|
||||
"""Get a hint for the current position."""
|
||||
analysis = self.analyze_position(fen)
|
||||
board = chess.Board(fen)
|
||||
move = chess.Move.from_uci(analysis.best_move)
|
||||
piece = board.piece_at(move.from_square)
|
||||
|
||||
if piece:
|
||||
piece_name = chess.piece_name(piece.piece_type).capitalize()
|
||||
return f"Consider moving your {piece_name}..."
|
||||
return "Look for tactical opportunities..."
|
||||
|
||||
def close(self) -> None:
|
||||
"""Clean up engine resources."""
|
||||
del self.engine
|
||||
6
src/shellmate/core/__init__.py
Normal file
6
src/shellmate/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Core chess game logic."""
|
||||
|
||||
from .game import ChessGame
|
||||
from .player import Player, PlayerType
|
||||
|
||||
__all__ = ["ChessGame", "Player", "PlayerType"]
|
||||
125
src/shellmate/core/game.py
Normal file
125
src/shellmate/core/game.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Chess game engine wrapper."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
import chess
|
||||
import chess.pgn
|
||||
|
||||
|
||||
class GameResult(Enum):
|
||||
IN_PROGRESS = "in_progress"
|
||||
WHITE_WINS = "white_wins"
|
||||
BLACK_WINS = "black_wins"
|
||||
DRAW = "draw"
|
||||
ABORTED = "aborted"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Move:
|
||||
"""Represents a chess move with metadata."""
|
||||
uci: str
|
||||
san: str
|
||||
fen_before: str
|
||||
fen_after: str
|
||||
timestamp: datetime = field(default_factory=datetime.utcnow)
|
||||
think_time_ms: int = 0
|
||||
evaluation: Optional[float] = None
|
||||
explanation: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChessGame:
|
||||
"""Chess game instance."""
|
||||
|
||||
id: str
|
||||
white_player_id: str
|
||||
black_player_id: str
|
||||
board: chess.Board = field(default_factory=chess.Board)
|
||||
moves: list[Move] = field(default_factory=list)
|
||||
result: GameResult = GameResult.IN_PROGRESS
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
|
||||
@property
|
||||
def current_turn(self) -> str:
|
||||
"""Return whose turn it is."""
|
||||
return "white" if self.board.turn == chess.WHITE else "black"
|
||||
|
||||
@property
|
||||
def current_player_id(self) -> str:
|
||||
"""Return the ID of the player whose turn it is."""
|
||||
return self.white_player_id if self.board.turn == chess.WHITE else self.black_player_id
|
||||
|
||||
def make_move(self, uci: str) -> Optional[Move]:
|
||||
"""Make a move and return Move object if valid."""
|
||||
try:
|
||||
chess_move = chess.Move.from_uci(uci)
|
||||
if chess_move not in self.board.legal_moves:
|
||||
return None
|
||||
|
||||
fen_before = self.board.fen()
|
||||
san = self.board.san(chess_move)
|
||||
self.board.push(chess_move)
|
||||
fen_after = self.board.fen()
|
||||
|
||||
move = Move(
|
||||
uci=uci,
|
||||
san=san,
|
||||
fen_before=fen_before,
|
||||
fen_after=fen_after,
|
||||
)
|
||||
self.moves.append(move)
|
||||
self._check_game_end()
|
||||
return move
|
||||
except (ValueError, chess.InvalidMoveError):
|
||||
return None
|
||||
|
||||
def get_legal_moves(self) -> list[str]:
|
||||
"""Return list of legal moves in UCI format."""
|
||||
return [move.uci() for move in self.board.legal_moves]
|
||||
|
||||
def get_legal_moves_san(self) -> list[str]:
|
||||
"""Return list of legal moves in SAN format."""
|
||||
return [self.board.san(move) for move in self.board.legal_moves]
|
||||
|
||||
def _check_game_end(self) -> None:
|
||||
"""Check if the game has ended and set result."""
|
||||
if self.board.is_checkmate():
|
||||
self.result = GameResult.BLACK_WINS if self.board.turn == chess.WHITE else GameResult.WHITE_WINS
|
||||
elif self.board.is_stalemate() or self.board.is_insufficient_material():
|
||||
self.result = GameResult.DRAW
|
||||
elif self.board.can_claim_draw():
|
||||
self.result = GameResult.DRAW
|
||||
|
||||
def is_check(self) -> bool:
|
||||
"""Return True if current player is in check."""
|
||||
return self.board.is_check()
|
||||
|
||||
def is_game_over(self) -> bool:
|
||||
"""Return True if the game is over."""
|
||||
return self.result != GameResult.IN_PROGRESS
|
||||
|
||||
def to_pgn(self) -> str:
|
||||
"""Export game as PGN string."""
|
||||
game = chess.pgn.Game()
|
||||
game.headers["White"] = self.white_player_id
|
||||
game.headers["Black"] = self.black_player_id
|
||||
game.headers["Date"] = self.created_at.strftime("%Y.%m.%d")
|
||||
|
||||
node = game
|
||||
temp_board = chess.Board()
|
||||
for move in self.moves:
|
||||
chess_move = chess.Move.from_uci(move.uci)
|
||||
node = node.add_variation(chess_move)
|
||||
temp_board.push(chess_move)
|
||||
|
||||
return str(game)
|
||||
|
||||
def get_board_display(self, perspective: str = "white") -> str:
|
||||
"""Return ASCII board from given perspective."""
|
||||
board_str = str(self.board)
|
||||
if perspective == "black":
|
||||
lines = board_str.split('\n')
|
||||
board_str = '\n'.join(reversed(lines))
|
||||
return board_str
|
||||
68
src/shellmate/core/player.py
Normal file
68
src/shellmate/core/player.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Player models."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class PlayerType(Enum):
|
||||
HUMAN = "human"
|
||||
AI = "ai"
|
||||
GUEST = "guest"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Player:
|
||||
"""Represents a player."""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
player_type: PlayerType = PlayerType.HUMAN
|
||||
elo: int = 1200
|
||||
games_played: int = 0
|
||||
wins: int = 0
|
||||
losses: int = 0
|
||||
draws: int = 0
|
||||
created_at: datetime = field(default_factory=datetime.utcnow)
|
||||
last_seen: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
def winrate(self) -> float:
|
||||
"""Calculate win rate percentage."""
|
||||
if self.games_played == 0:
|
||||
return 0.0
|
||||
return (self.wins / self.games_played) * 100
|
||||
|
||||
def update_elo(self, opponent_elo: int, result: float, k: int = 32) -> int:
|
||||
"""
|
||||
Update ELO rating based on game result.
|
||||
result: 1.0 for win, 0.5 for draw, 0.0 for loss
|
||||
Returns the ELO change.
|
||||
"""
|
||||
expected = 1 / (1 + 10 ** ((opponent_elo - self.elo) / 400))
|
||||
change = int(k * (result - expected))
|
||||
self.elo += change
|
||||
return change
|
||||
|
||||
def record_game(self, won: bool, draw: bool = False) -> None:
|
||||
"""Record a completed game."""
|
||||
self.games_played += 1
|
||||
if draw:
|
||||
self.draws += 1
|
||||
elif won:
|
||||
self.wins += 1
|
||||
else:
|
||||
self.losses += 1
|
||||
self.last_seen = datetime.utcnow()
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIPlayer(Player):
|
||||
"""AI player with configurable difficulty."""
|
||||
|
||||
difficulty: int = 10 # Stockfish skill level 0-20
|
||||
think_time_ms: int = 1000 # Time to think per move
|
||||
|
||||
def __post_init__(self):
|
||||
self.player_type = PlayerType.AI
|
||||
0
src/shellmate/learn/__init__.py
Normal file
0
src/shellmate/learn/__init__.py
Normal file
0
src/shellmate/multiplayer/__init__.py
Normal file
0
src/shellmate/multiplayer/__init__.py
Normal file
5
src/shellmate/ssh/__init__.py
Normal file
5
src/shellmate/ssh/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""SSH server for ShellMate."""
|
||||
|
||||
from .server import ShellMateSSHServer
|
||||
|
||||
__all__ = ["ShellMateSSHServer"]
|
||||
118
src/shellmate/ssh/server.py
Normal file
118
src/shellmate/ssh/server.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""SSH server implementation."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
import asyncssh
|
||||
|
||||
from shellmate.tui.app import ShellMateApp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShellMateSSHServer(asyncssh.SSHServer):
|
||||
"""SSH server that launches ShellMate TUI for each connection."""
|
||||
|
||||
def __init__(self):
|
||||
self._username: Optional[str] = None
|
||||
|
||||
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
|
||||
logger.info(f"SSH connection from {conn.get_extra_info('peername')}")
|
||||
|
||||
def connection_lost(self, exc: Optional[Exception]) -> None:
|
||||
if exc:
|
||||
logger.error(f"SSH connection error: {exc}")
|
||||
else:
|
||||
logger.info("SSH connection closed")
|
||||
|
||||
def begin_auth(self, username: str) -> bool:
|
||||
self._username = username
|
||||
# Allow all usernames, we'll handle auth in the app
|
||||
return True
|
||||
|
||||
def password_auth_supported(self) -> bool:
|
||||
return True
|
||||
|
||||
def validate_password(self, username: str, password: str) -> bool:
|
||||
# Guest access - accept any password or empty
|
||||
# For registered users, validate against database
|
||||
return True # TODO: Implement proper auth
|
||||
|
||||
def public_key_auth_supported(self) -> bool:
|
||||
return True
|
||||
|
||||
def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool:
|
||||
# Accept any public key for now
|
||||
return True # TODO: Implement key-based auth
|
||||
|
||||
|
||||
async def handle_client(process: asyncssh.SSHServerProcess) -> None:
|
||||
"""Handle an SSH client session."""
|
||||
username = process.get_extra_info("username") or "guest"
|
||||
|
||||
# Determine mode based on username
|
||||
if username == "learn":
|
||||
mode = "tutorial"
|
||||
elif username == "watch":
|
||||
mode = "spectate"
|
||||
else:
|
||||
mode = "play"
|
||||
|
||||
logger.info(f"Starting ShellMate for {username} in {mode} mode")
|
||||
|
||||
try:
|
||||
# Create and run the TUI app
|
||||
app = ShellMateApp(
|
||||
username=username,
|
||||
mode=mode,
|
||||
stdin=process.stdin,
|
||||
stdout=process.stdout,
|
||||
)
|
||||
await app.run_async()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ShellMate session: {e}")
|
||||
process.stdout.write(f"\r\nError: {e}\r\n")
|
||||
finally:
|
||||
process.exit(0)
|
||||
|
||||
|
||||
async def start_server(
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 22,
|
||||
host_keys: list[str] = None,
|
||||
) -> None:
|
||||
"""Start the SSH server."""
|
||||
host_keys = host_keys or ["/etc/shellmate/ssh_host_key"]
|
||||
|
||||
logger.info(f"Starting ShellMate SSH server on {host}:{port}")
|
||||
|
||||
await asyncssh.create_server(
|
||||
ShellMateSSHServer,
|
||||
host,
|
||||
port,
|
||||
server_host_keys=host_keys,
|
||||
process_factory=handle_client,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the SSH server."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(start_server())
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
5
src/shellmate/tui/__init__.py
Normal file
5
src/shellmate/tui/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""TUI components for ShellMate."""
|
||||
|
||||
from .app import ShellMateApp
|
||||
|
||||
__all__ = ["ShellMateApp"]
|
||||
182
src/shellmate/tui/app.py
Normal file
182
src/shellmate/tui/app.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Main TUI application."""
|
||||
|
||||
from typing import Any, Optional
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.widgets import Button, Footer, Header, Static, Label
|
||||
from textual.binding import Binding
|
||||
|
||||
from shellmate.tui.widgets.board import ChessBoard
|
||||
from shellmate.tui.widgets.move_list import MoveList
|
||||
from shellmate.tui.widgets.status import GameStatus
|
||||
|
||||
|
||||
class ShellMateApp(App):
|
||||
"""ShellMate TUI Chess Application."""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
layout: grid;
|
||||
grid-size: 2;
|
||||
grid-columns: 3fr 1fr;
|
||||
}
|
||||
|
||||
#board-container {
|
||||
height: 100%;
|
||||
content-align: center middle;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
height: 100%;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
ChessBoard {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#move-list {
|
||||
height: 1fr;
|
||||
border: solid green;
|
||||
}
|
||||
|
||||
#game-status {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
border: solid blue;
|
||||
}
|
||||
|
||||
#controls {
|
||||
height: auto;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
width: 100%;
|
||||
margin: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("q", "quit", "Quit"),
|
||||
Binding("n", "new_game", "New Game"),
|
||||
Binding("h", "hint", "Hint"),
|
||||
Binding("u", "undo", "Undo"),
|
||||
Binding("r", "resign", "Resign"),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
username: str = "guest",
|
||||
mode: str = "play",
|
||||
stdin: Any = None,
|
||||
stdout: Any = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.username = username
|
||||
self.mode = mode
|
||||
self._stdin = stdin
|
||||
self._stdout = stdout
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
|
||||
with Horizontal():
|
||||
with Container(id="board-container"):
|
||||
yield ChessBoard(id="chess-board")
|
||||
|
||||
with Vertical(id="sidebar"):
|
||||
yield GameStatus(id="game-status")
|
||||
yield MoveList(id="move-list")
|
||||
|
||||
with Container(id="controls"):
|
||||
yield Button("New Game", id="btn-new", classes="menu-button")
|
||||
yield Button("vs AI", id="btn-ai", classes="menu-button")
|
||||
yield Button("vs Player", id="btn-pvp", classes="menu-button")
|
||||
yield Button("Tutorial", id="btn-learn", classes="menu-button")
|
||||
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Called when app is mounted."""
|
||||
self.title = "♟️ ShellMate"
|
||||
self.sub_title = f"Welcome, {self.username}!"
|
||||
|
||||
def action_new_game(self) -> None:
|
||||
"""Start a new game."""
|
||||
board = self.query_one("#chess-board", ChessBoard)
|
||||
board.new_game()
|
||||
self.notify("New game started!")
|
||||
|
||||
def action_hint(self) -> None:
|
||||
"""Get a hint from the AI."""
|
||||
self.notify("Hint: Consider developing your pieces...")
|
||||
|
||||
def action_undo(self) -> None:
|
||||
"""Undo the last move."""
|
||||
board = self.query_one("#chess-board", ChessBoard)
|
||||
if board.undo_move():
|
||||
self.notify("Move undone")
|
||||
else:
|
||||
self.notify("No moves to undo")
|
||||
|
||||
def action_resign(self) -> None:
|
||||
"""Resign the current game."""
|
||||
self.notify("Game resigned")
|
||||
|
||||
|
||||
# Placeholder widgets - to be implemented
|
||||
class ChessBoard(Static):
|
||||
"""Chess board widget."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._board_str = self._get_initial_board()
|
||||
|
||||
def _get_initial_board(self) -> str:
|
||||
return """
|
||||
a b c d e f g h
|
||||
┌───┬───┬───┬───┬───┬───┬───┬───┐
|
||||
8 │ ♜ │ ♞ │ ♝ │ ♛ │ ♚ │ ♝ │ ♞ │ ♜ │ 8
|
||||
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||
7 │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ ♟ │ 7
|
||||
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||
6 │ │ │ │ │ │ │ │ │ 6
|
||||
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||
5 │ │ │ │ │ │ │ │ │ 5
|
||||
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||
4 │ │ │ │ │ │ │ │ │ 4
|
||||
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||
3 │ │ │ │ │ │ │ │ │ 3
|
||||
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||
2 │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ ♙ │ 2
|
||||
├───┼───┼───┼───┼───┼───┼───┼───┤
|
||||
1 │ ♖ │ ♘ │ ♗ │ ♕ │ ♔ │ ♗ │ ♘ │ ♖ │ 1
|
||||
└───┴───┴───┴───┴───┴───┴───┴───┘
|
||||
a b c d e f g h
|
||||
"""
|
||||
|
||||
def render(self) -> str:
|
||||
return self._board_str
|
||||
|
||||
def new_game(self) -> None:
|
||||
self._board_str = self._get_initial_board()
|
||||
self.refresh()
|
||||
|
||||
def undo_move(self) -> bool:
|
||||
return False # TODO: Implement
|
||||
|
||||
|
||||
class MoveList(Static):
|
||||
"""Move history widget."""
|
||||
|
||||
def render(self) -> str:
|
||||
return "Move History\n─────────────\n1. e4 e5\n2. Nf3 Nc6\n3. ..."
|
||||
|
||||
|
||||
class GameStatus(Static):
|
||||
"""Game status widget."""
|
||||
|
||||
def render(self) -> str:
|
||||
return "⚪ White to move\n\n🕐 10:00 | 10:00"
|
||||
0
src/shellmate/tui/widgets/__init__.py
Normal file
0
src/shellmate/tui/widgets/__init__.py
Normal file
Reference in New Issue
Block a user