From 8be1fac8f9299b2ce7f8bf4237abee30ef0d9f91 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 11 Oct 2026 18:49:56 +0200 Subject: [PATCH] linux_configuration: track ~/.local/bin wrapper scripts in repo, symlink instead of generating Moves 7 loose ~/.local/bin scripts into linux_configuration/scripts/ and tracks the flutter/dart git-env wrapper; fix_flutter_git_env.sh and unreal_mcp_repo.sh now symlink to the tracked files instead of writing heredocs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014yhTkYV6ZeuAbQYvykFGFc Co-Authored-By: Claude Opus 5.5 (1M context) --- .../track-local-bin-scripts-2026-10-11.json | 14 + .../track-local-bin-scripts-2026-10-11.json | 37 ++ .../features/install_unreal_mcp.sh | 166 ++++----- .../features/lib/unreal_mcp_repo.sh | 335 +++++++++--------- .../fixes/fix_flutter_git_env.sh | 185 ++++------ linux_configuration/scripts/blender-mcp-host | 174 +++++++++ linux_configuration/scripts/blender-mcp-serve | 24 ++ .../scripts/fix-execstack-launch.sh | 44 +++ .../scripts/flutter-git-env-wrapper.sh | 39 ++ linux_configuration/scripts/krita-scaled.sh | 8 + .../scripts/nvidia-pmon-logger.sh | 38 ++ linux_configuration/scripts/unreal-mcp-server | 8 + .../scripts/usage-report-catchup.sh | 34 ++ 13 files changed, 746 insertions(+), 360 deletions(-) create mode 100644 docs/superpowers/contracts/track-local-bin-scripts-2026-10-11.json create mode 100644 docs/superpowers/evidence/track-local-bin-scripts-2026-10-11.json create mode 100755 linux_configuration/scripts/blender-mcp-host create mode 100755 linux_configuration/scripts/blender-mcp-serve create mode 100755 linux_configuration/scripts/fix-execstack-launch.sh create mode 100755 linux_configuration/scripts/flutter-git-env-wrapper.sh create mode 100755 linux_configuration/scripts/krita-scaled.sh create mode 100755 linux_configuration/scripts/nvidia-pmon-logger.sh create mode 100755 linux_configuration/scripts/unreal-mcp-server create mode 100755 linux_configuration/scripts/usage-report-catchup.sh diff --git a/docs/superpowers/contracts/track-local-bin-scripts-2026-10-11.json b/docs/superpowers/contracts/track-local-bin-scripts-2026-10-11.json new file mode 100644 index 00000000..9e76650e --- /dev/null +++ b/docs/superpowers/contracts/track-local-bin-scripts-2026-10-11.json @@ -0,0 +1,14 @@ +{ + "title": "Track hand-placed ~/.local/bin scripts in linux_configuration/scripts", + "objective": "Every hand-placed script in ~/.local/bin (fix-execstack-launch.sh, nvidia-pmon-logger.sh, usage-report-catchup.sh, krita, blender-mcp-host, blender-mcp-serve, unreal-mcp-server) becomes a tracked file under linux_configuration/scripts/, and ~/.local/bin keeps a symlink with the original name so every caller (systemd user units, ~/.claude.json MCP config) still works. install_unreal_mcp.sh symlinks the launcher instead of generating it with a heredoc.", + "acceptance_criteria": [ + "readlink -f ~/.local/bin/ resolves to linux_configuration/scripts/ for all seven names (krita -> krita-scaled.sh)", + "bash -n passes for every tracked script; git ls-files -s shows mode 100755", + "install_launcher creates ~/.local/bin/unreal-mcp-server as a symlink to the tracked wrapper, idempotently, and chowns the link (not the target) under sudo" + ], + "out_of_scope": [ + "Editing systemd units, crontab or MCP configs (symlinks keep their paths valid)", + "Changing the moved scripts' behaviour" + ], + "verifier": "readlink -f + bash -n loop over ~/.local/bin entries; install_launcher run against a temp USER_HOME; pre-commit hooks on the staged files" +} diff --git a/docs/superpowers/evidence/track-local-bin-scripts-2026-10-11.json b/docs/superpowers/evidence/track-local-bin-scripts-2026-10-11.json new file mode 100644 index 00000000..ce75dcf4 --- /dev/null +++ b/docs/superpowers/evidence/track-local-bin-scripts-2026-10-11.json @@ -0,0 +1,37 @@ +{ + "intent": "Hand-placed ~/.local/bin scripts are version-controlled in linux_configuration/scripts/ while every existing ~/.local/bin path keeps working through a symlink.", + "scope": [ + "linux_configuration/scripts/{fix-execstack-launch.sh,nvidia-pmon-logger.sh,usage-report-catchup.sh,krita-scaled.sh,blender-mcp-host,blender-mcp-serve,unreal-mcp-server}, linux_configuration/features/lib/unreal_mcp_repo.sh, linux_configuration/features/install_unreal_mcp.sh", + "Non-goal: no behaviour change to the moved scripts; callers are not edited" + ], + "changes": [ + "Six scripts copied byte-for-byte (cmp-verified, mode preserved); ~/.local/bin/krita is now named krita-scaled.sh in the repo", + "unreal-mcp-server is tracked; it reads UNREAL_MCP_PYTHON_DIR (default ~/.local/share/unreal-mcp/unreal-mcp/Python) because a static file cannot bake in --install-dir", + "install_launcher replaces the heredoc with ln -sfn to the tracked wrapper, chown -h under sudo, and logs a warning to export UNREAL_MCP_PYTHON_DIR when --install-dir is non-default" + ], + "verification": [ + { + "command": "for n in ...; do readlink -f ~/.local/bin/$n; bash -n ~/.local/bin/$n; done", + "result": "pass", + "evidence": "all seven names resolve into linux_configuration/scripts and parse" + }, + { + "command": "source lib/unreal_mcp_repo.sh with USER_HOME=; install_launcher twice over a stale regular file", + "result": "pass", + "evidence": "regular file replaced by symlink to scripts/unreal-mcp-server; second run idempotent; non-default install dir warning logged" + }, + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "all passed" + } + ], + "risks": [ + "A non-default --install-dir now needs UNREAL_MCP_PYTHON_DIR exported where the MCP client launches the server", + "Moving or renaming the repo checkout breaks the ~/.local/bin symlinks" + ], + "rollback": [ + "git revert the commit, then cp the scripts from the previous ~/.local/bin state (git show :linux_configuration/scripts/) over the symlinks", + "Check systemctl --user status nvidia-pmon.service usage-report-catchup.timer afterwards" + ] +} diff --git a/linux_configuration/features/install_unreal_mcp.sh b/linux_configuration/features/install_unreal_mcp.sh index 7f48ceee..b077a10e 100755 --- a/linux_configuration/features/install_unreal_mcp.sh +++ b/linux_configuration/features/install_unreal_mcp.sh @@ -2,7 +2,7 @@ # Install Unreal MCP and connect it to VS Code (via Continue MCP) on Arch Linux # - Installs deps: git, jq, uv, python # - Clones https://github.com/chongdashu/unreal-mcp -# - Creates a launcher: ~/.local/bin/unreal-mcp-server +# - Symlinks ~/.local/bin/unreal-mcp-server to the tracked scripts/unreal-mcp-server # - Configures VS Code Continue MCP: ~/.continue/config.json # - Optional: copies UnrealMCP plugin into a specified .uproject's Plugins/ @@ -31,12 +31,12 @@ FORCE_UPDATE=false log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } fail() { - echo "[ERROR] $*" >&2 - exit 1 + echo "[ERROR] $*" >&2 + exit 1 } usage() { - cat << EOF + cat < /dev/null 2>&1; } +require_cmd() { command -v "$1" >/dev/null 2>&1; } ensure_packages_arch() { - # Install with pacman using sudo when needed; keep idempotent with --needed - local pkgs=(git jq uv python rsync) - local to_install=() - for p in "${pkgs[@]}"; do - if ! pacman -Qi "$p" > /dev/null 2>&1; then - to_install+=("$p") - fi - done - if [[ ${#to_install[@]} -gt 0 ]]; then - log "Installing packages: ${to_install[*]}" - if [[ $EUID -eq 0 ]]; then - pacman -S --noconfirm --needed "${to_install[@]}" - else - sudo pacman -S --noconfirm --needed "${to_install[@]}" - fi - else - log "All required packages already installed" - fi + # Install with pacman using sudo when needed; keep idempotent with --needed + local pkgs=(git jq uv python rsync) + local to_install=() + for p in "${pkgs[@]}"; do + if ! pacman -Qi "$p" >/dev/null 2>&1; then + to_install+=("$p") + fi + done + if [[ ${#to_install[@]} -gt 0 ]]; then + log "Installing packages: ${to_install[*]}" + if [[ $EUID -eq 0 ]]; then + pacman -S --noconfirm --needed "${to_install[@]}" + else + sudo pacman -S --noconfirm --needed "${to_install[@]}" + fi + else + log "All required packages already installed" + fi } check_python_version() { - if require_cmd python; then - local v - v=$(python -V 2>&1 | awk '{print $2}') - elif require_cmd python3; then - local v - v=$(python3 -V 2>&1 | awk '{print $2}') - else - log "python not found; pacman install will provide it" - return 0 - fi - # Require >= 3.12 (Unreal MCP docs) - local major minor - major=$(echo "$v" | cut -d. -f1) - minor=$(echo "$v" | cut -d. -f2) - if ((major < 3 || (major == 3 && minor < 12))); then - log "Python $v detected; installing newer python via pacman" - if [[ $EUID -eq 0 ]]; then - pacman -S --noconfirm --needed python - else - sudo pacman -S --noconfirm --needed python - fi - fi + if require_cmd python; then + local v + v=$(python -V 2>&1 | awk '{print $2}') + elif require_cmd python3; then + local v + v=$(python3 -V 2>&1 | awk '{print $2}') + else + log "python not found; pacman install will provide it" + return 0 + fi + # Require >= 3.12 (Unreal MCP docs) + local major minor + major=$(echo "$v" | cut -d. -f1) + minor=$(echo "$v" | cut -d. -f2) + if ((major < 3 || (major == 3 && minor < 12))); then + log "Python $v detected; installing newer python via pacman" + if [[ $EUID -eq 0 ]]; then + pacman -S --noconfirm --needed python + else + sudo pacman -S --noconfirm --needed python + fi + fi } # shellcheck source=lib/unreal_mcp_repo.sh source "$SCRIPT_DIR/lib/unreal_mcp_repo.sh" main() { - log "Installing prerequisites (Arch Linux)" - ensure_packages_arch - check_python_version - setup_repo - install_launcher - configure_continue - install_plugin_into_project - configure_vscode_user_mcp - print_summary + log "Installing prerequisites (Arch Linux)" + ensure_packages_arch + check_python_version + setup_repo + install_launcher + configure_continue + install_plugin_into_project + configure_vscode_user_mcp + print_summary } main "$@" diff --git a/linux_configuration/features/lib/unreal_mcp_repo.sh b/linux_configuration/features/lib/unreal_mcp_repo.sh index 3fe8024c..ebe2e2f5 100755 --- a/linux_configuration/features/lib/unreal_mcp_repo.sh +++ b/linux_configuration/features/lib/unreal_mcp_repo.sh @@ -7,66 +7,67 @@ # ---------- Git clone/update ---------- setup_repo() { - mkdir -p "$INSTALL_ROOT" - if [[ ! -d "$REPO_DIR/.git" ]]; then - log "Cloning unreal-mcp into $REPO_DIR" - if require_cmd git; then - git clone "$REPO_URL" "$REPO_DIR" - else - fail "git is required but not found after install" - fi - else - log "Repo exists at $REPO_DIR" - if [[ $FORCE_UPDATE == true ]]; then - log "Updating repo with --force-update" - git -C "$REPO_DIR" fetch origin - git -C "$REPO_DIR" reset --hard origin/main - git -C "$REPO_DIR" pull --rebase --autostash - else - log "Pulling latest changes" - git -C "$REPO_DIR" pull --rebase --autostash - fi - fi + mkdir -p "$INSTALL_ROOT" + if [[ ! -d "$REPO_DIR/.git" ]]; then + log "Cloning unreal-mcp into $REPO_DIR" + if require_cmd git; then + git clone "$REPO_URL" "$REPO_DIR" + else + fail "git is required but not found after install" + fi + else + log "Repo exists at $REPO_DIR" + if [[ $FORCE_UPDATE == true ]]; then + log "Updating repo with --force-update" + git -C "$REPO_DIR" fetch origin + git -C "$REPO_DIR" reset --hard origin/main + git -C "$REPO_DIR" pull --rebase --autostash + else + log "Pulling latest changes" + git -C "$REPO_DIR" pull --rebase --autostash + fi + fi - # Ensure ownership for the real user when script ran via sudo - if [[ $EUID -eq 0 ]]; then - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$INSTALL_ROOT" - fi + # Ensure ownership for the real user when script ran via sudo + if [[ $EUID -eq 0 ]]; then + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$INSTALL_ROOT" + fi } # ---------- Launcher ---------- install_launcher() { - local bin_dir="$USER_HOME/.local/bin" - local python_dir="$REPO_DIR/Python" - local launcher="$bin_dir/unreal-mcp-server" - mkdir -p "$bin_dir" - cat > "$launcher" << EOF -#!/bin/bash -set -euo pipefail -exec uv --directory "$python_dir" run unreal_mcp_server.py "\${1:-}" < /dev/null -EOF - chmod +x "$launcher" - if [[ $EUID -eq 0 ]]; then chown "$ACTUAL_USER:$ACTUAL_USER" "$launcher"; fi - log "Installed launcher: $launcher" + local bin_dir="$USER_HOME/.local/bin" + local launcher="$bin_dir/unreal-mcp-server" + # The wrapper is tracked in the repo; ~/.local/bin only holds a symlink. + local target + target="$(readlink -f "$SCRIPT_DIR/../scripts/unreal-mcp-server")" + [[ -x $target ]] || fail "Tracked launcher missing: $target" + mkdir -p "$bin_dir" + ln -sfn "$target" "$launcher" + if [[ $EUID -eq 0 ]]; then chown -h "$ACTUAL_USER:$ACTUAL_USER" "$launcher"; fi + if [[ $INSTALL_ROOT != "$INSTALL_ROOT_DEFAULT" ]]; then + log "WARNING: non-default --install-dir; export UNREAL_MCP_PYTHON_DIR=$REPO_DIR/Python for the launcher" + fi + log "Installed launcher: $launcher -> $target" } # ---------- VS Code: Continue MCP config ---------- configure_continue() { - if [[ $CONFIGURE_CONTINUE != true ]]; then - log "Skipping Continue config (--no-continue)" - return 0 - fi + if [[ $CONFIGURE_CONTINUE != true ]]; then + log "Skipping Continue config (--no-continue)" + return 0 + fi - local cont_dir="$USER_HOME/.continue" - local cont_cfg="$cont_dir/config.json" - local python_dir="$REPO_DIR/Python" - mkdir -p "$cont_dir" + local cont_dir="$USER_HOME/.continue" + local cont_cfg="$cont_dir/config.json" + local python_dir="$REPO_DIR/Python" + mkdir -p "$cont_dir" - # Base JSON when no config exists - local tmp_file - tmp_file="$(mktemp)" - if [[ ! -f $cont_cfg ]]; then - cat > "$tmp_file" << JSON + # Base JSON when no config exists + local tmp_file + tmp_file="$(mktemp)" + if [[ ! -f $cont_cfg ]]; then + cat >"$tmp_file" < "$tmp_file" && mv "$tmp_file" "$cont_cfg" - fi + ' "$cont_cfg" >"$tmp_file" && mv "$tmp_file" "$cont_cfg" + fi - if [[ $EUID -eq 0 ]]; then chown "$ACTUAL_USER:$ACTUAL_USER" "$cont_cfg"; fi - log "Configured Continue MCP at: $cont_cfg" + if [[ $EUID -eq 0 ]]; then chown "$ACTUAL_USER:$ACTUAL_USER" "$cont_cfg"; fi + log "Configured Continue MCP at: $cont_cfg" } # ---------- VS Code user MCP (native) ---------- configure_vscode_user_mcp() { - if [[ $CONFIGURE_VSCODE_USER != true ]]; then - log "Skipping VS Code user MCP config (--no-vscode)" - return 0 - fi + if [[ $CONFIGURE_VSCODE_USER != true ]]; then + log "Skipping VS Code user MCP config (--no-vscode)" + return 0 + fi - if ! require_cmd jq; then - fail "jq is required to compose VS Code --add-mcp JSON and to parse profiles" - fi + if ! require_cmd jq; then + fail "jq is required to compose VS Code --add-mcp JSON and to parse profiles" + fi - local python_dir="$REPO_DIR/Python" - local json - json=$(jq -n --arg dir "$python_dir" '{name:"unrealMCP", command:"uv", args:["--directory", $dir, "run", "unreal_mcp_server.py"]}') + local python_dir="$REPO_DIR/Python" + local json + json=$(jq -n --arg dir "$python_dir" '{name:"unrealMCP", command:"uv", args:["--directory", $dir, "run", "unreal_mcp_server.py"]}') - # Handle multiple VS Code variants if present - local candidates=(code code-insiders codium) - local found_any=false - for cli in "${candidates[@]}"; do - if ! command -v "$cli" > /dev/null 2>&1; then - continue - fi - found_any=true - log "Registering MCP server in VS Code user profile via: $cli --add-mcp" - if "$cli" --add-mcp "$json" > "/tmp/${cli}-add-mcp.log" 2>&1; then - log "[$cli] user profile: unrealMCP added/updated" - else - sed -n '1,200p' "/tmp/${cli}-add-mcp.log" || true - fail "[$cli] --add-mcp failed for user profile. Ensure your VS Code supports MCP or rerun with --no-vscode." - fi + # Handle multiple VS Code variants if present + local candidates=(code code-insiders codium) + local found_any=false + for cli in "${candidates[@]}"; do + if ! command -v "$cli" >/dev/null 2>&1; then + continue + fi + found_any=true + log "Registering MCP server in VS Code user profile via: $cli --add-mcp" + if "$cli" --add-mcp "$json" >"/tmp/${cli}-add-mcp.log" 2>&1; then + log "[$cli] user profile: unrealMCP added/updated" + else + sed -n '1,200p' "/tmp/${cli}-add-mcp.log" || true + fail "[$cli] --add-mcp failed for user profile. Ensure your VS Code supports MCP or rerun with --no-vscode." + fi - # Detect profiles with 'unreal' (case-insensitive) and add there too - local data_dir="" - case "$cli" in - code) - data_dir="$USER_HOME/.config/Code" - ;; - code-insiders) - data_dir="$USER_HOME/.config/Code - Insiders" - ;; - codium) - data_dir="$USER_HOME/.config/VSCodium" - ;; - esac - local profiles_json="$data_dir/User/profiles/profiles.json" - if [[ -f $profiles_json ]]; then - # Extract profile names matching /unreal/i - mapfile -t unreal_profiles < <(jq -r '.profiles // [] | .[] | .name // empty | select(test("unreal"; "i"))' "$profiles_json") - if [[ ${#unreal_profiles[@]} -gt 0 ]]; then - log "[$cli] Found profiles with 'unreal': ${unreal_profiles[*]}" - local name - for name in "${unreal_profiles[@]}"; do - log "[$cli] Adding unrealMCP to profile: $name" - if "$cli" --profile "$name" --add-mcp "$json" > "/tmp/${cli}-add-mcp-${name// /_}.log" 2>&1; then - log "[$cli] profile '$name': unrealMCP added/updated" - else - sed -n '1,200p' "/tmp/${cli}-add-mcp-${name// /_}.log" || true - fail "[$cli] --add-mcp failed for profile '$name'." - fi - done - else - log "[$cli] No VS Code profiles with 'unreal' in name" - fi - else - log "[$cli] Profiles file not found: $profiles_json (skipping profile-specific adds)" - fi - done + # Detect profiles with 'unreal' (case-insensitive) and add there too + local data_dir="" + case "$cli" in + code) + data_dir="$USER_HOME/.config/Code" + ;; + code-insiders) + data_dir="$USER_HOME/.config/Code - Insiders" + ;; + codium) + data_dir="$USER_HOME/.config/VSCodium" + ;; + esac + local profiles_json="$data_dir/User/profiles/profiles.json" + if [[ -f $profiles_json ]]; then + # Extract profile names matching /unreal/i + mapfile -t unreal_profiles < <(jq -r '.profiles // [] | .[] | .name // empty | select(test("unreal"; "i"))' "$profiles_json") + if [[ ${#unreal_profiles[@]} -gt 0 ]]; then + log "[$cli] Found profiles with 'unreal': ${unreal_profiles[*]}" + local name + for name in "${unreal_profiles[@]}"; do + log "[$cli] Adding unrealMCP to profile: $name" + if "$cli" --profile "$name" --add-mcp "$json" >"/tmp/${cli}-add-mcp-${name// /_}.log" 2>&1; then + log "[$cli] profile '$name': unrealMCP added/updated" + else + sed -n '1,200p' "/tmp/${cli}-add-mcp-${name// /_}.log" || true + fail "[$cli] --add-mcp failed for profile '$name'." + fi + done + else + log "[$cli] No VS Code profiles with 'unreal' in name" + fi + else + log "[$cli] Profiles file not found: $profiles_json (skipping profile-specific adds)" + fi + done - if [[ $found_any == false ]]; then - fail "VS Code CLI not found (code/code-insiders/codium). Install VS Code and ensure 'code' CLI is available, or run with --no-vscode to skip." - fi + if [[ $found_any == false ]]; then + fail "VS Code CLI not found (code/code-insiders/codium). Install VS Code and ensure 'code' CLI is available, or run with --no-vscode to skip." + fi } # ---------- Unreal Plugin copy (optional) ---------- install_plugin_into_project() { - [[ -n $PROJECT_UPROJECT ]] || return 0 - local upath="$PROJECT_UPROJECT" - if [[ -d $upath ]]; then - # Resolve .uproject in the provided directory - mapfile -t _uprojects < <(find "$upath" -maxdepth 1 -type f -name "*.uproject" 2> /dev/null || true) - if [[ ${#_uprojects[@]} -eq 0 ]]; then - fail "--project directory '$upath' contains no .uproject files" - elif [[ ${#_uprojects[@]} -gt 1 ]]; then - printf '[ERROR] Multiple .uproject files found in %s:\n' "$upath" >&2 - printf ' - %s\n' "${_uprojects[@]}" >&2 - fail "Please pass the specific .uproject path to --project" - else - upath="${_uprojects[0]}" - log "Resolved .uproject: $upath" - fi - elif [[ -f $upath ]]; then - true - else - fail "--project path does not exist: $upath" - fi - if [[ ${upath##*.} != "uproject" ]]; then - fail "--project must point to a .uproject file (got: $upath)" - fi - local proj_dir - proj_dir="$(cd "$(dirname "$upath")" && pwd)" - RESOLVED_PROJECT_DIR="$proj_dir" - local src_plugin="$REPO_DIR/MCPGameProject/Plugins/UnrealMCP" - local dst_plugin="$proj_dir/Plugins/UnrealMCP" - if [[ ! -d $src_plugin ]]; then - fail "Source plugin not found at $src_plugin (did repo layout change?)" - fi - mkdir -p "$proj_dir/Plugins" - log "Copying UnrealMCP plugin to project: $dst_plugin" - rsync -a --delete "$src_plugin/" "$dst_plugin/" - # Set ownership back to actual user if run as root - if [[ $EUID -eq 0 ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$proj_dir/Plugins"; fi - log "Plugin installed. Enable it from Unreal Editor (Edit > Plugins) if needed." + [[ -n $PROJECT_UPROJECT ]] || return 0 + local upath="$PROJECT_UPROJECT" + if [[ -d $upath ]]; then + # Resolve .uproject in the provided directory + mapfile -t _uprojects < <(find "$upath" -maxdepth 1 -type f -name "*.uproject" 2>/dev/null || true) + if [[ ${#_uprojects[@]} -eq 0 ]]; then + fail "--project directory '$upath' contains no .uproject files" + elif [[ ${#_uprojects[@]} -gt 1 ]]; then + printf '[ERROR] Multiple .uproject files found in %s:\n' "$upath" >&2 + printf ' - %s\n' "${_uprojects[@]}" >&2 + fail "Please pass the specific .uproject path to --project" + else + upath="${_uprojects[0]}" + log "Resolved .uproject: $upath" + fi + elif [[ -f $upath ]]; then + true + else + fail "--project path does not exist: $upath" + fi + if [[ ${upath##*.} != "uproject" ]]; then + fail "--project must point to a .uproject file (got: $upath)" + fi + local proj_dir + proj_dir="$(cd "$(dirname "$upath")" && pwd)" + RESOLVED_PROJECT_DIR="$proj_dir" + local src_plugin="$REPO_DIR/MCPGameProject/Plugins/UnrealMCP" + local dst_plugin="$proj_dir/Plugins/UnrealMCP" + if [[ ! -d $src_plugin ]]; then + fail "Source plugin not found at $src_plugin (did repo layout change?)" + fi + mkdir -p "$proj_dir/Plugins" + log "Copying UnrealMCP plugin to project: $dst_plugin" + rsync -a --delete "$src_plugin/" "$dst_plugin/" + # Set ownership back to actual user if run as root + if [[ $EUID -eq 0 ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$proj_dir/Plugins"; fi + log "Plugin installed. Enable it from Unreal Editor (Edit > Plugins) if needed." } # ---------- Summary ---------- print_summary() { - local python_dir="$REPO_DIR/Python" - local plugin_dest="N/A" - if [[ -n $RESOLVED_PROJECT_DIR ]]; then - plugin_dest="$RESOLVED_PROJECT_DIR/Plugins/UnrealMCP" - fi - cat << EOF + local python_dir="$REPO_DIR/Python" + local plugin_dest="N/A" + if [[ -n $RESOLVED_PROJECT_DIR ]]; then + plugin_dest="$RESOLVED_PROJECT_DIR/Plugins/UnrealMCP" + fi + cat <&2 - exit 1 + echo "$SCRIPT_NAME: $*" >&2 + exit 1 } -write_wrapper() { - mkdir -p "$LIB_DIR" "$BIN_DIR" - cat > "$WRAPPER" <<'WRAPPER_EOF' -#!/bin/bash - -# Strip the git-hook environment, then hand off to the real SDK binary. -# Installed by linux_configuration/fixes/fix_flutter_git_env.sh -# -- see that script for why this exists. Invoked through a symlink named -# after the tool it fronts (flutter, dart). - -set -euo pipefail - -tool="$(basename "$0")" -real="/usr/bin/${tool}" - -[[ -x $real ]] || { - echo "flutter-git-env: $real is missing or not executable" >&2 - exit 127 -} - -# Drop our own directory so a PATH lookup inside the real tool cannot find -# this wrapper again. The Flutter entry point does exactly such a lookup. -clean_path="" -IFS=':' read -r -a path_parts <<< "$PATH" -for part in "${path_parts[@]}"; do - [[ $part == "$HOME/.local/bin" ]] && continue - clean_path="${clean_path:+$clean_path:}$part" -done - -# GIT_DIR is the one that actually bites; the rest are exported by git in the -# same breath and would mislead the SDK the same way. -exec env \ - -u GIT_DIR \ - -u GIT_WORK_TREE \ - -u GIT_INDEX_FILE \ - -u GIT_COMMON_DIR \ - -u GIT_OBJECT_DIRECTORY \ - -u GIT_ALTERNATE_OBJECT_DIRECTORIES \ - -u GIT_NAMESPACE \ - -u GIT_PREFIX \ - PATH="$clean_path" \ - "$real" "$@" -WRAPPER_EOF - chmod +x "$WRAPPER" +# The wrapper is tracked in the repo; ~/.local/bin only holds symlinks to it. +# A copy left by an older install (LEGACY_DIR) is generated, so it is removed. +check_wrapper() { + [[ -x $WRAPPER ]] || fail "tracked wrapper missing: $WRAPPER" + mkdir -p "$BIN_DIR" + rm -rf "$LEGACY_DIR" } link_tools() { - local tool link - for tool in "${TOOLS[@]}"; do - link="${BIN_DIR}/${tool}" - if [[ -e $link && ! -L $link ]]; then - fail "$link exists and is not a symlink — refusing to overwrite it" - fi - ln -sfn "$WRAPPER" "$link" - log "linked $link -> $WRAPPER" - done + local tool link + for tool in "${TOOLS[@]}"; do + link="${BIN_DIR}/${tool}" + if [[ -e $link && ! -L $link ]]; then + fail "$link exists and is not a symlink — refusing to overwrite it" + fi + ln -sfn "$WRAPPER" "$link" + log "linked $link -> $WRAPPER" + done } check_path() { - case ":$PATH:" in - *":$BIN_DIR:"*) ;; - *) fail "$BIN_DIR is not on PATH — the wrapper would never be used" ;; - esac - local resolved - for tool in "${TOOLS[@]}"; do - resolved="$(command -v "$tool" || true)" - [[ $resolved == "${BIN_DIR}/${tool}" ]] || - fail "$tool resolves to '$resolved', not the wrapper — check PATH order" - done + case ":$PATH:" in + *":$BIN_DIR:"*) ;; + *) fail "$BIN_DIR is not on PATH — the wrapper would never be used" ;; + esac + local resolved + for tool in "${TOOLS[@]}"; do + resolved="$(command -v "$tool" || true)" + [[ $resolved == "${BIN_DIR}/${tool}" ]] || + fail "$tool resolves to '$resolved', not the wrapper — check PATH order" + done } # The real test: does the SDK still know what it is with GIT_DIR poisoned? verify() { - local probe_repo probe_version - probe_repo="$(mktemp -d)" - git -C "$probe_repo" init -q - git -C "$probe_repo" -c user.email=x@y -c user.name=x commit -q --allow-empty -m probe + local probe_repo probe_version + probe_repo="$(mktemp -d)" + git -C "$probe_repo" init -q + git -C "$probe_repo" -c user.email=x@y -c user.name=x commit -q --allow-empty -m probe - probe_version="$(GIT_DIR="$probe_repo/.git" flutter --version 2>&1 | head -1 || true)" - rm -rf "$probe_repo" + probe_version="$(GIT_DIR="$probe_repo/.git" flutter --version 2>&1 | head -1 || true)" + rm -rf "$probe_repo" - if grep -q '0\.0\.0-unknown' <<< "$probe_version"; then - fail "still broken: flutter reports an unknown SDK under GIT_DIR" - fi - if ! grep -q 'flutter/flutter' <<< "$probe_version"; then - fail "still broken: flutter reports '$probe_version' under GIT_DIR" - fi - log "verified: $probe_version" + if grep -q '0\.0\.0-unknown' <<<"$probe_version"; then + fail "still broken: flutter reports an unknown SDK under GIT_DIR" + fi + if ! grep -q 'flutter/flutter' <<<"$probe_version"; then + fail "still broken: flutter reports '$probe_version' under GIT_DIR" + fi + log "verified: $probe_version" } uninstall() { - local tool link - for tool in "${TOOLS[@]}"; do - link="${BIN_DIR}/${tool}" - if [[ -L $link && "$(readlink -f "$link")" == "$WRAPPER" ]]; then - rm -f "$link" - log "removed $link" - fi - done - rm -rf "$LIB_DIR" - log "uninstalled" + local tool link + for tool in "${TOOLS[@]}"; do + link="${BIN_DIR}/${tool}" + if [[ -L $link && "$(readlink -f "$link")" == "$WRAPPER" ]]; then + rm -f "$link" + log "removed $link" + fi + done + rm -rf "$LEGACY_DIR" + log "uninstalled" } main() { - case "${1:-}" in - --uninstall) - uninstall - return - ;; - --check) - [[ -x $WRAPPER ]] || fail "wrapper is not installed" - check_path - verify - return - ;; - "") ;; - *) fail "unknown option: $1 (expected --check or --uninstall)" ;; - esac + case "${1:-}" in + --uninstall) + uninstall + return + ;; + --check) + [[ -x $WRAPPER ]] || fail "wrapper is not installed" + check_path + verify + return + ;; + "") ;; + *) fail "unknown option: $1 (expected --check or --uninstall)" ;; + esac - command -v flutter >/dev/null 2>&1 || fail "flutter is not on PATH" - write_wrapper - link_tools - check_path - verify - log "done — flutter/dart now ignore an inherited GIT_DIR" + command -v flutter >/dev/null 2>&1 || fail "flutter is not on PATH" + check_wrapper + link_tools + check_path + verify + log "done — flutter/dart now ignore an inherited GIT_DIR" } main "$@" diff --git a/linux_configuration/scripts/blender-mcp-host b/linux_configuration/scripts/blender-mcp-host new file mode 100755 index 00000000..e8e5f59a --- /dev/null +++ b/linux_configuration/scripts/blender-mcp-host @@ -0,0 +1,174 @@ +#!/bin/bash + +# ============================================================================ +# Start Blender as a headless MCP host for blender-mcp. +# +# The blender-mcp addon refuses to run under `blender --background`: its +# command queue is pumped by bpy.app.timers, which never tick in background +# mode, so requests would be accepted and then never execute. The addon's own +# error message names the fix -- give Blender a display. Xvfb provides one +# without putting a window on a real output (i3 focus stealing is an explicit +# hazard here), and a real EEVEE render works fine against it. +# +# Known limitation: get_viewport_screenshot returns success but writes an +# all-black PNG under Xvfb -- there is no GL viewport to capture. Use +# bpy.ops.render.render(write_still=True) via execute_code instead; that is +# verified working and is what the artgate 3D route needs anyway. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +readonly ADDON_PATH="/home/kuhy/src/mcp-servers/servers/blender-mcp/addon.py" +readonly START_SCRIPT="/home/kuhy/.local/share/blender-mcp-host/start_addon.py" +readonly LOG_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/blender-mcp-host.log" + +PORT="${BLENDER_PORT:-9876}" + +usage() { + echo "Usage: $SCRIPT_NAME [start|stop|status|restart]" + echo " start Launch headless Blender hosting the MCP socket (default)" + echo " stop Terminate the hosted Blender instance" + echo " status Report whether the socket is being served" + echo " restart stop, then start" + echo + echo "Env: BLENDER_PORT (default 9876)" + exit 0 +} + +validate_requirements() { + local missing=0 + for tool in blender xvfb-run; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "Error: required tool '$tool' not found in PATH" >&2 + missing=1 + fi + done + if [[ ! -f "$ADDON_PATH" ]]; then + echo "Error: addon not found at $ADDON_PATH" >&2 + missing=1 + fi + [[ $missing -eq 0 ]] || exit 1 +} + +# True when something is listening on the MCP port. +port_is_served() { + ss -ltn 2>/dev/null | grep -q ":${PORT}\b" +} + +# PID of the Blender process holding the port, empty if none. +host_pid() { + host_pids | head -1 +} + +# Every process belonging to a host instance: the Blender that serves the +# socket plus the xvfb-run wrapper that owns its display. Stopping only the +# Blender pid leaves the wrapper behind, which then keeps a stale instance +# alive and makes the next `start` a silent no-op. +host_pids() { + pgrep -f "blender .*--python ${START_SCRIPT}" || true + pgrep -f "xvfb-run .*--python ${START_SCRIPT}" || true +} + +do_start() { + if port_is_served; then + echo "Already serving on port ${PORT} (pid $(host_pid))" + return 0 + fi + + mkdir -p "$(dirname "$START_SCRIPT")" "$(dirname "$LOG_FILE")" + + # The addon's register() auto-starts the server, so this script only needs + # to load it and then block -- Blender must stay alive to pump the timers. + cat > "$START_SCRIPT" <<'PY' +"""Load the blender-mcp addon; Blender's event loop then serves the socket.""" +import importlib.util +import sys + +_SPEC = importlib.util.spec_from_file_location( + "blendermcp_addon", "/home/kuhy/src/mcp-servers/servers/blender-mcp/addon.py") +_MOD = importlib.util.module_from_spec(_SPEC) +sys.modules["blendermcp_addon"] = _MOD +_SPEC.loader.exec_module(_MOD) +_MOD.register() +print("blender-mcp-host: addon registered, serving", flush=True) +PY + + echo "Starting headless Blender on port ${PORT}..." + BLENDER_PORT="$PORT" nohup xvfb-run -a \ + blender --factory-startup --python "$START_SCRIPT" \ + >> "$LOG_FILE" 2>&1 & + disown + + # Wait for the socket rather than sleeping a fixed guess. + local waited=0 + while [[ $waited -lt 30 ]]; do + if port_is_served; then + echo "Serving on port ${PORT} (pid $(host_pid))" + echo "Log: $LOG_FILE" + return 0 + fi + command sleep 1 + waited=$((waited + 1)) + done + + echo "Error: port ${PORT} not served after ${waited}s; see $LOG_FILE" >&2 + exit 1 +} + +do_stop() { + local pids=() + mapfile -t pids < <(host_pids) + if [[ ${#pids[@]} -eq 0 ]]; then + echo "Not running" + return 0 + fi + + kill "${pids[@]}" 2>/dev/null || true + + # Confirm the port is actually released rather than trusting the signal; + # a half-dead instance would make the next start a no-op. + local waited=0 remaining=() + while [[ $waited -lt 10 ]]; do + mapfile -t remaining < <(host_pids) + if ! port_is_served && [[ ${#remaining[@]} -eq 0 ]]; then + echo "Stopped Blender host (${pids[*]})" + return 0 + fi + command sleep 1 + waited=$((waited + 1)) + done + + mapfile -t remaining < <(host_pids) + [[ ${#remaining[@]} -gt 0 ]] && kill -9 "${remaining[@]}" 2>/dev/null || true + command sleep 1 + if port_is_served; then + echo "Error: port ${PORT} still served after SIGKILL" >&2 + exit 1 + fi + echo "Stopped Blender host (forced)" +} + +do_status() { + if port_is_served; then + echo "serving port=${PORT} pid=$(host_pid)" + else + echo "stopped port=${PORT}" + exit 1 + fi +} + +main() { + validate_requirements + case "${1:-start}" in + start) do_start ;; + stop) do_stop ;; + status) do_status ;; + restart) do_stop; command sleep 1; do_start ;; + -h|--help|help) usage ;; + *) echo "Unknown command: $1" >&2; exit 1 ;; + esac +} + +main "$@" diff --git a/linux_configuration/scripts/blender-mcp-serve b/linux_configuration/scripts/blender-mcp-serve new file mode 100755 index 00000000..b40337d5 --- /dev/null +++ b/linux_configuration/scripts/blender-mcp-serve @@ -0,0 +1,24 @@ +#!/bin/bash + +# ============================================================================ +# MCP entrypoint: guarantee a Blender host exists, then serve the MCP protocol. +# +# Registered as the `blender` MCP server's command. The blender-mcp server +# itself only *connects* to Blender's socket -- it cannot start Blender. If +# nothing hosts port 9876 (e.g. after a reboot) the server comes up and every +# tool call fails with a connection error, which reads like a broken MCP +# rather than a missing host. Starting the host here makes that failure +# impossible instead of relying on someone remembering to run a command. +# ============================================================================ + +set -euo pipefail + +readonly HOST_SCRIPT="/home/kuhy/.local/bin/blender-mcp-host" +readonly SERVER_DIR="/home/kuhy/src/mcp-servers/servers/blender-mcp" +readonly UV="/home/kuhy/.local/bin/uv" + +# stdout is the MCP JSON-RPC channel -- any stray byte written there corrupts +# the protocol, so all host-startup chatter goes to stderr. +"$HOST_SCRIPT" start >&2 + +exec "$UV" run --directory "$SERVER_DIR" blender-mcp diff --git a/linux_configuration/scripts/fix-execstack-launch.sh b/linux_configuration/scripts/fix-execstack-launch.sh new file mode 100755 index 00000000..7e99d92e --- /dev/null +++ b/linux_configuration/scripts/fix-execstack-launch.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# Fix executable stack flags on Source Engine .so files before launching. +# Usage: Set as Steam Launch Options: +# ~/.local/bin/fix-execstack-launch.sh %command% +# +# Works for any Source Engine game that fails on modern kernels (6.x+) +# due to shared libraries requiring executable stack (RWE flag). + +if [[ $# -eq 0 ]]; then + echo "Usage: $0 [args...]" + echo "Set as Steam launch option: ~/.local/bin/fix-execstack-launch.sh %command%" + exit 1 +fi + +# Steam wraps %command% as: reaper SteamLaunch ... -- /path/to/game/hl2.sh -game ... +# Find the game directory by looking for a path inside steamapps/common/ in the args. +GAME_DIR="" +for arg in "$@"; do + if [[ "$arg" == */steamapps/common/* ]]; then + resolved="$(readlink -f "$arg" 2>/dev/null)" + if [[ -n "$resolved" ]]; then + # Walk up to find the game root (directory directly under steamapps/common/) + candidate="$resolved" + while [[ "$candidate" == */steamapps/common/*/* ]]; do + candidate="$(dirname "$candidate")" + done + GAME_DIR="$candidate" + break + fi + fi +done + +if [[ -z "$GAME_DIR" || ! -d "$GAME_DIR" ]]; then + echo "[fix-execstack] Warning: Could not detect game directory, skipping patch." +else + # Fast path: directly patch known Source Engine offenders without scanning. + # patchelf --clear-execstack is a no-op if the flag is already cleared. + for sofile in "$GAME_DIR"/bin/engine.so "$GAME_DIR"/bin/video_bink.so; do + [[ -f "$sofile" ]] && patchelf --clear-execstack "$sofile" 2>/dev/null + done +fi + +# Launch the game +exec "$@" diff --git a/linux_configuration/scripts/flutter-git-env-wrapper.sh b/linux_configuration/scripts/flutter-git-env-wrapper.sh new file mode 100755 index 00000000..a09449c3 --- /dev/null +++ b/linux_configuration/scripts/flutter-git-env-wrapper.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# Strip the git-hook environment, then hand off to the real SDK binary. +# Linked into ~/.local/bin by linux_configuration/fixes/fix_flutter_git_env.sh +# -- see that script for why this exists. Invoked through a symlink named +# after the tool it fronts (flutter, dart). + +set -euo pipefail + +tool="$(basename "$0")" +real="/usr/bin/${tool}" + +[[ -x $real ]] || { + echo "flutter-git-env: $real is missing or not executable" >&2 + exit 127 +} + +# Drop our own directory so a PATH lookup inside the real tool cannot find +# this wrapper again. The Flutter entry point does exactly such a lookup. +clean_path="" +IFS=':' read -r -a path_parts <<<"$PATH" +for part in "${path_parts[@]}"; do + [[ $part == "$HOME/.local/bin" ]] && continue + clean_path="${clean_path:+$clean_path:}$part" +done + +# GIT_DIR is the one that actually bites; the rest are exported by git in the +# same breath and would mislead the SDK the same way. +exec env \ + -u GIT_DIR \ + -u GIT_WORK_TREE \ + -u GIT_INDEX_FILE \ + -u GIT_COMMON_DIR \ + -u GIT_OBJECT_DIRECTORY \ + -u GIT_ALTERNATE_OBJECT_DIRECTORIES \ + -u GIT_NAMESPACE \ + -u GIT_PREFIX \ + PATH="$clean_path" \ + "$real" "$@" diff --git a/linux_configuration/scripts/krita-scaled.sh b/linux_configuration/scripts/krita-scaled.sh new file mode 100755 index 00000000..618ff9fc --- /dev/null +++ b/linux_configuration/scripts/krita-scaled.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# +# Scaled Krita launcher (shadows /usr/bin/krita on PATH). +# QT_SCALE_FACTOR=2 -> whole GUI at 2x (crisp, image res & tablet unaffected) +# XCURSOR_SIZE=32 -> ~2.5x on-screen cursor (visual only, no stroke change) +# Tune the two numbers to taste. exec keeps the same PID/args. +# +exec env QT_SCALE_FACTOR=2 XCURSOR_SIZE=32 /usr/bin/krita "$@" diff --git a/linux_configuration/scripts/nvidia-pmon-logger.sh b/linux_configuration/scripts/nvidia-pmon-logger.sh new file mode 100755 index 00000000..8e2e5d67 --- /dev/null +++ b/linux_configuration/scripts/nvidia-pmon-logger.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +LOG_DIR="$HOME/.local/share/gpu-log" +ERR_LOG="$LOG_DIR/pmon-errors.log" +mkdir -p "$LOG_DIR" + +if ! command -v nvidia-smi >/dev/null 2>&1; then + echo "nvidia-pmon-logger: nvidia-smi not found" >&2 + exit 1 +fi + +# Wake every 60s to (a) check that the pmon child is still alive and (b) rotate +# the log on day-rollover. This used to spin in a `read -r -t 20` loop, but +# under systemd stdin is /dev/null, so `read` returned instantly on EOF. That +# turned the loop into a hot fork-storm calling `date +%Y%m%d` thousands of +# times per second (~22% of one core, ~122 CPU-hours/day). +# +# Use the bash printf %(...)T builtin (zero forks) for the date check, and a +# real sleep (one fork/min, ~1440/day — negligible) for the wait. + +while true; do + printf -v day '%(%Y%m%d)T' -1 + out_file="$LOG_DIR/pmon-${day}.log" + + nvidia-smi pmon -d 10 -o DT >>"$out_file" 2>>"$ERR_LOG" & + pmon_pid=$! + + while kill -0 "$pmon_pid" >/dev/null 2>&1; do + printf -v today '%(%Y%m%d)T' -1 + if [[ $today != "$day" ]]; then + kill "$pmon_pid" >/dev/null 2>&1 || true + wait "$pmon_pid" 2>/dev/null || true + break + fi + sleep 60 + done +done diff --git a/linux_configuration/scripts/unreal-mcp-server b/linux_configuration/scripts/unreal-mcp-server new file mode 100755 index 00000000..1a2be29e --- /dev/null +++ b/linux_configuration/scripts/unreal-mcp-server @@ -0,0 +1,8 @@ +#!/bin/bash +# Launch the unreal-mcp Python server over stdio. +# ~/.local/bin/unreal-mcp-server is a symlink here, installed by +# features/install_unreal_mcp.sh. Set UNREAL_MCP_PYTHON_DIR when the repo was +# installed with --install-dir somewhere other than the default. +set -euo pipefail +python_dir="${UNREAL_MCP_PYTHON_DIR:-${HOME}/.local/share/unreal-mcp/unreal-mcp/Python}" +exec uv --directory "${python_dir}" run unreal_mcp_server.py "${1:-}" &2 + exit 1 +fi + +shopt -s nullglob +TODAY="$(date +%Y%m%d)" +for atop_file in "$ATOP_DIR"/atop_*; do + date_part="${atop_file##*_}" + if [[ ! "$date_part" =~ ^[0-9]{8}$ ]]; then + continue + fi + + out_file="$OUT_DIR/usage-report-${date_part}.md" + tmp_file="$out_file.tmp" + + if [[ "$date_part" == "$TODAY" || ! -s "$out_file" ]]; then + if "$RUN_SCRIPT" --date "$date_part" >"$tmp_file"; then + mv -f "$tmp_file" "$out_file" + else + rm -f "$tmp_file" + fi + fi +done