#!/usr/bin/env bash # learner-add.sh - Add a new learner account for maubot plugin development # Usage: learner-add.sh # # Creates: # - Unix user account with SSH key # - ~/plugins directory with hello-world starter plugin # - Symlink in maubot plugins directory # - Outputs onboarding instructions set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MAUBOT_PLUGINS_DIR="/var/lib/maubot/plugins" TEMPLATE_DIR="${SCRIPT_DIR}/../templates/plugin-skeleton" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; } usage() { echo "Usage: $0 " echo "" echo "Arguments:" echo " username - Learner's username (alphanumeric, 3-16 chars)" echo " ssh-pubkey - SSH public key (starts with ssh-ed25519, ssh-rsa, etc.)" echo "" echo "Example:" echo " $0 alice 'ssh-ed25519 AAAA... alice@laptop'" exit 1 } validate_username() { local username="$1" if [[ ! "$username" =~ ^[a-z][a-z0-9_-]{2,15}$ ]]; then log_error "Invalid username: must be 3-16 chars, start with letter, alphanumeric/underscore/dash only" exit 1 fi # Check if user already exists if id "$username" &>/dev/null; then log_error "User '$username' already exists" exit 1 fi } validate_ssh_key() { local key="$1" if [[ ! "$key" =~ ^ssh-(ed25519|rsa|ecdsa) ]]; then log_error "Invalid SSH key: must start with ssh-ed25519, ssh-rsa, or ssh-ecdsa" exit 1 fi } create_user() { local username="$1" local ssh_key="$2" log_info "Creating user '$username'..." # Create user with home directory # NixOS: don't specify shell (uses default), group is 'users' useradd -m -g users "$username" # Add to learners group for Slack token access usermod -aG learners "$username" # Set up SSH key local ssh_dir="/home/$username/.ssh" mkdir -p "$ssh_dir" echo "$ssh_key" > "$ssh_dir/authorized_keys" chmod 700 "$ssh_dir" chmod 600 "$ssh_dir/authorized_keys" chown -R "$username:users" "$ssh_dir" # Add Slack env vars to bashrc echo '' >> "/home/$username/.bashrc" echo '# Slack bot development tokens' >> "/home/$username/.bashrc" echo 'source /etc/slack-learner.env' >> "/home/$username/.bashrc" # Set up ~/.local/bin with claude symlink local local_bin="/home/$username/.local/bin" mkdir -p "$local_bin" ln -sf /usr/local/bin/claude "$local_bin/claude" chown -R "$username:users" "/home/$username/.local" log_info "User created with SSH access" } setup_plugin_directory() { local username="$1" local plugin_name="hello-${username}" local user_plugins_dir="/home/$username/plugins" local plugin_dir="$user_plugins_dir/$plugin_name" log_info "Setting up plugin directory..." # Create plugins directory mkdir -p "$plugin_dir" mkdir -p "$plugin_dir/dist" # Python module names must use underscores, not hyphens local module_name="${plugin_name//-/_}" # Copy template if exists, otherwise create from scratch if [[ -d "$TEMPLATE_DIR" ]]; then cp -r "$TEMPLATE_DIR/"* "$plugin_dir/" # Rename MODULE_NAME directory to actual module name if [[ -d "$plugin_dir/MODULE_NAME" ]]; then mv "$plugin_dir/MODULE_NAME" "$plugin_dir/$module_name" fi # Replace placeholders in all files find "$plugin_dir" -type f -exec sed -i "s/MODULE_NAME/$module_name/g" {} \; find "$plugin_dir" -type f -exec sed -i "s/USERNAME/$username/g" {} \; else # Create minimal starter plugin create_starter_plugin "$plugin_dir" "$module_name" "$username" fi # Set ownership chown -R "$username:users" "$user_plugins_dir" # Make plugin dir readable by maubot group chmod 755 "/home/$username" chmod 755 "$user_plugins_dir" chmod -R 755 "$plugin_dir" log_info "Plugin directory created at $plugin_dir" } create_starter_plugin() { local plugin_dir="$1" local module_name="$2" local username="$3" # Create module directory mkdir -p "$plugin_dir/$module_name" # maubot.yaml cat > "$plugin_dir/maubot.yaml" << EOF maubot: 0.4.0 id: xyz.clarun.${module_name} version: 0.1.0 license: MIT modules: - ${module_name} main_class: ${module_name}.Bot EOF # Python module __init__.py cat > "$plugin_dir/$module_name/__init__.py" << EOF from .bot import Bot __all__ = ["Bot"] EOF # Main bot.py cat > "$plugin_dir/$module_name/bot.py" << EOF from maubot import Plugin, MessageEvent from maubot.handlers import command class Bot(Plugin): """Hello world bot for $username""" @command.new("hello") async def hello_command(self, evt: MessageEvent) -> None: """Respond to !hello command""" await evt.reply(f"Hello from ${username}'s bot!") @command.new("ping") async def ping_command(self, evt: MessageEvent) -> None: """Respond to !ping command""" await evt.reply("Pong!") EOF # Makefile cat > "$plugin_dir/Makefile" << 'EOF' PLUGIN_NAME := $(shell grep '^id:' maubot.yaml | cut -d: -f2 | tr -d ' ') PLUGIN_VERSION := $(shell grep '^version:' maubot.yaml | cut -d: -f2 | tr -d ' ') MBP_FILE := dist/$(PLUGIN_NAME)-$(PLUGIN_VERSION).mbp .PHONY: build reload clean help help: @echo "Available targets:" @echo " build - Build the .mbp plugin file" @echo " reload - Reload plugin in maubot (requires instance configured)" @echo " clean - Remove built files" @echo " dev - Build and reload" build: @echo "Building $(PLUGIN_NAME)..." @mkdir -p dist @rm -f $(MBP_FILE) @zip -r $(MBP_FILE) maubot.yaml $(shell grep -E '^ - ' maubot.yaml | sed 's/ - //') @echo "Built: $(MBP_FILE)" reload: @echo "Reloading plugin..." @echo "TODO: Add maubot API reload command" clean: rm -rf dist/*.mbp dev: build reload EOF # README cat > "$plugin_dir/README.md" << EOF # $module_name Hello world maubot plugin for $username. ## Quick Start 1. Edit \`$module_name/bot.py\` to add your bot logic 2. Run \`make build\` to create the .mbp file 3. Upload to maubot via admin UI (http://localhost:29316) ## Commands - \`!hello\` - Says hello - \`!ping\` - Responds with pong ## Files - \`maubot.yaml\` - Plugin manifest - \`$module_name/bot.py\` - Main bot code - \`Makefile\` - Build commands EOF } create_maubot_symlink() { local username="$1" local plugin_name="hello-${username}" local plugin_dir="/home/$username/plugins/$plugin_name" local symlink_path="$MAUBOT_PLUGINS_DIR/${username}-${plugin_name}.mbp" log_info "Creating maubot plugin symlink..." # Note: Symlink will point to the built .mbp file # For now, we'll link to the dist directory's expected output # The learner will need to run 'make build' first # We could also configure maubot to load from source directories # but .mbp symlinks are simpler for now log_warn "Learner must run 'make build' before plugin appears in maubot" } print_onboarding() { local username="$1" local server_ip # NixOS: use ip command instead of hostname -I server_ip=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1) echo "" echo "==========================================" echo " Dev Environment Ready: $username" echo "==========================================" echo "" echo "## SSH Config (~/.ssh/config on your laptop)" echo "" echo " Host dev-server" echo " HostName ${server_ip:-}" echo " User $username" echo " LocalForward 8080 127.0.0.1:8080" echo "" echo "## Quick Start" echo "" echo "1. SSH in:" echo " ssh dev-server" echo "" echo "2. Authenticate Claude (first time only):" echo " claude" echo " # Opens localhost URL - paste in your local browser" echo " # Complete OAuth, token flows back automatically" echo "" echo "3. Start coding:" echo " mkdir mybot && cd mybot" echo " claude 'create a slack bot that responds to hello'" echo "" echo "## Tools Available" echo " python3, uv, go (nix profile install nixpkgs#go)" echo " Slack tokens: \$SLACK_BOT_TOKEN, \$SLACK_APP_TOKEN" echo "" echo "==========================================" } main() { if [[ $# -lt 2 ]]; then usage fi local username="$1" local ssh_key="$2" # Must run as root if [[ $EUID -ne 0 ]]; then log_error "This script must be run as root" exit 1 fi validate_username "$username" validate_ssh_key "$ssh_key" create_user "$username" "$ssh_key" setup_plugin_directory "$username" create_maubot_symlink "$username" print_onboarding "$username" log_info "Learner '$username' setup complete!" } main "$@"