Initial scaffold for ShellMate - SSH chess TUI

This commit is contained in:
Greg Hendrickson
2026-01-27 15:11:08 +00:00
commit 95471924a4
18 changed files with 902 additions and 0 deletions

36
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,3 @@
"""ShellMate - SSH into chess mastery."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,5 @@
"""AI engine integration."""
from .engine import ChessAI
__all__ = ["ChessAI"]

158
src/shellmate/ai/engine.py Normal file
View 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

View 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
View 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

View 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

View File

View File

View File

@@ -0,0 +1,5 @@
"""SSH server for ShellMate."""
from .server import ShellMateSSHServer
__all__ = ["ShellMateSSHServer"]

118
src/shellmate/ssh/server.py Normal file
View 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()

View File

@@ -0,0 +1,5 @@
"""TUI components for ShellMate."""
from .app import ShellMateApp
__all__ = ["ShellMateApp"]

182
src/shellmate/tui/app.py Normal file
View 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"

View File