linux_configuration: track ~/.local/bin wrapper scripts in repo, symlink instead of generating
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014yhTkYV6ZeuAbQYvykFGFc

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-11 18:49:56 +02:00
parent f8a7aded7b
commit 8be1fac8f9
13 changed files with 746 additions and 360 deletions

View File

@ -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/<name> resolves to linux_configuration/scripts/<file> 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"
}

View File

@ -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=<tmpdir>; 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 <shellcheck|no-inline-python|no-polling-antipatterns|shell-coverage-ratchet|file-length-cap> --files <staged>",
"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 <hash>:linux_configuration/scripts/<file>) over the symlinks",
"Check systemctl --user status nvidia-pmon.service usage-report-catchup.timer afterwards"
]
}

View File

@ -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 <<EOF
Usage: $SCRIPT_NAME [options]
Options:
@ -55,101 +55,101 @@ EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--install-dir)
shift
[[ $# -gt 0 ]] || fail "--install-dir requires a value"
INSTALL_ROOT="$1"
;;
--project)
shift
[[ $# -gt 0 ]] || fail "--project requires a path to .uproject"
PROJECT_UPROJECT="$1"
;;
--no-continue)
CONFIGURE_CONTINUE=false
;;
--no-vscode)
CONFIGURE_VSCODE_USER=false
;;
--force-update)
FORCE_UPDATE=true
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
shift
case "$1" in
--install-dir)
shift
[[ $# -gt 0 ]] || fail "--install-dir requires a value"
INSTALL_ROOT="$1"
;;
--project)
shift
[[ $# -gt 0 ]] || fail "--project requires a path to .uproject"
PROJECT_UPROJECT="$1"
;;
--no-continue)
CONFIGURE_CONTINUE=false
;;
--no-vscode)
CONFIGURE_VSCODE_USER=false
;;
--force-update)
FORCE_UPDATE=true
;;
-h | --help)
usage
exit 0
;;
*)
fail "Unknown option: $1"
;;
esac
shift
done
REPO_DIR="$INSTALL_ROOT/unreal-mcp"
# ---------- Dependencies ----------
require_cmd() { command -v "$1" > /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 "$@"

View File

@ -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" <<JSON
{
"mcpServers": {
"unrealMCP": {
@ -76,147 +77,147 @@ configure_continue() {
}
}
JSON
mv "$tmp_file" "$cont_cfg"
else
# Merge using jq: ensure .mcpServers exists, then set/overwrite unrealMCP
if ! require_cmd jq; then
fail "jq is required to merge ~/.continue/config.json"
fi
jq --arg dir "$python_dir" '
mv "$tmp_file" "$cont_cfg"
else
# Merge using jq: ensure .mcpServers exists, then set/overwrite unrealMCP
if ! require_cmd jq; then
fail "jq is required to merge ~/.continue/config.json"
fi
jq --arg dir "$python_dir" '
.mcpServers = (.mcpServers // {}) |
.mcpServers.unrealMCP = {
command: "uv",
args: ["--directory", $dir, "run", "unreal_mcp_server.py"]
}
' "$cont_cfg" > "$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 <<EOF
============================================
Unreal MCP setup complete
============================================

View File

@ -41,143 +41,108 @@ set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly BIN_DIR="${HOME}/.local/bin"
readonly LIB_DIR="${HOME}/.local/share/flutter-git-env"
readonly WRAPPER="${LIB_DIR}/wrapper.sh"
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
readonly SCRIPT_DIR
WRAPPER="$(readlink -f "${SCRIPT_DIR}/../scripts/flutter-git-env-wrapper.sh")"
readonly WRAPPER
readonly LEGACY_DIR="${HOME}/.local/share/flutter-git-env"
# Every SDK entry point that shells out to git to identify itself.
readonly TOOLS=(flutter dart)
log() { echo "$SCRIPT_NAME: $*"; }
fail() {
echo "$SCRIPT_NAME: $*" >&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 "$@"

View File

@ -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 "$@"

View File

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

View File

@ -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 <command> [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 "$@"

View File

@ -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" "$@"

View File

@ -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 "$@"

View File

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

View File

@ -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:-}" </dev/null

View File

@ -0,0 +1,34 @@
#!/bin/bash
set -euo pipefail
REPO="/home/kuhy/src/testsAndMisc"
RUN_SCRIPT="$REPO/run.sh"
OUT_DIR="$HOME/.local/share/usage-reports"
ATOP_DIR="/var/log/atop"
mkdir -p "$OUT_DIR"
if [[ ! -x "$RUN_SCRIPT" ]]; then
echo "usage-report-catchup: missing executable $RUN_SCRIPT" >&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