Split five more linux_configuration single_use scripts

install_unreal_mcp.sh 395 -> 155, diagnose_arch_performance.sh 377 ->
123 (two cuts), setup_local_llm.sh 370 -> 163, repo_to_study.sh 365 ->
160, setup_dwm.sh 359 -> 147.

The SCRIPT_DIR fix from the last batch held on all five. Fixed a second
unguarded cd, this time before the study-material generator ran - the
same latent bug class as build_website, surfaced because a moved
function gets linted in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
This commit is contained in:
Krzysztof Rudnicki 2026-08-16 18:51:28 +02:00
parent 1a75ed5bf5
commit 58fdc6c9de
13 changed files with 1247 additions and 1128 deletions

View File

@ -0,0 +1,18 @@
{
"title": "Split five more linux_configuration single_use scripts",
"objective": "Bring install_unreal_mcp.sh (395), diagnose_arch_performance.sh (377), setup_local_llm.sh (370), repo_to_study.sh (365) and setup_dwm.sh (359) under the 250-line cap.",
"acceptance_criteria": [
"All five entry scripts and all six new libs are at most 250 lines.",
"bash -n and shellcheck pass on every touched file, with no new suppression and no shellcheck disable directive.",
"The stubbed run reports every moved function as defined, for all five scripts.",
"Each entry script still declares SCRIPT_DIR exactly once.",
"No script is executed. These install packages, write systemd and Ollama config, and build dwm from source.",
"The unchecked cd exposed in the repo_to_study steps is fixed rather than suppressed."
],
"out_of_scope": [
"Executing any install or diagnostic step.",
"fix_arch_performance.sh, a different file that is over the cap but was not touched here.",
"Changing what any script does when run, beyond the cd guard."
],
"verifier": "scratchpad/verify_split.sh per script (bash -n + shellcheck -x + stubbed run), plus check_file_length.sh showing none of the eleven files, plus pre-commit run --files and the real git commit for jscpd."
}

View File

@ -0,0 +1,46 @@
{
"intent": "Split five more linux_configuration single_use scripts under the cap, fixing a second latent unchecked cd.",
"scope": [
"install_unreal_mcp.sh (395 -> 155) + lib/unreal_mcp_repo.sh",
"diagnose_arch_performance.sh (377 -> 123) + lib/arch_perf_probes.sh, lib/arch_perf_report.sh",
"setup_local_llm.sh (370 -> 163) + lib/local_llm_model.sh",
"repo_to_study.sh (365 -> 160) + lib/repo_study_steps.sh",
"setup_dwm.sh (359 -> 147) + lib/dwm_config.sh",
"Non-goal: no script executed"
],
"changes": [
"One cut each, except diagnose_arch_performance.sh: its first lib came out at 263 lines, so the GPU and journal probes, the safe-fix pass and the report summary moved on to a second lib.",
"All five already declared SCRIPT_DIR exactly once and the splitter correctly reused it, confirming the fix made in the previous batch.",
"Fixed SC2164 in the repo_to_study steps: a bare cd \"$analysis_dir\" immediately before invoking the study-material generator. A failed cd would have run the generator against the caller's directory. Now guarded with an error message and return 1 - the second instance of this same latent bug found by splitting."
],
"verification": [
{
"command": "scratchpad/verify_split.sh per script",
"result": "pass",
"evidence": "All five report 'libs sourced, functions defined' after bash -n and shellcheck -x. No SCRIPT_DIR collision this time."
},
{
"command": "shellcheck on each new lib",
"result": "fail then pass",
"evidence": "SC2164 on the unguarded cd in repo_study_steps.sh, fixed at the source. Clean afterwards."
},
{
"command": "bash ~/utils/scripts/check_file_length.sh --all | grep <these files>",
"result": "pass",
"evidence": "None of the eleven files listed. The only remaining hit in that grep is fix_arch_performance.sh, a different script that was already over the cap and is untouched here."
},
{
"command": "pre-commit run --files <changed>, then the real git commit",
"result": "pass",
"evidence": "Clean, jscpd included, now with fifteen new libs across the linux_configuration commits."
}
],
"risks": [
"None of the five was run: they install AUR packages, write Ollama modelfiles and systemd config, and compile dwm. The stubbed run proves the libs are reachable and the functions defined, not that the scripts still work end to end.",
"Two unchecked cd calls have now turned up in five splits. Others likely remain in unsplit files; splitting surfaces them because a moved function is linted in isolation."
],
"rollback": [
"git revert the commit; the five scripts return to their previous sizes and the six libs disappear.",
"After rollback, re-run bash -n and shellcheck on the restored scripts."
]
}

View File

@ -137,248 +137,8 @@ check_python_version() {
fi
}
# ---------- 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
# 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"
}
# ---------- VS Code: Continue MCP config ----------
configure_continue() {
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"
# Base JSON when no config exists
local tmp_file
tmp_file="$(mktemp)"
if [[ ! -f $cont_cfg ]]; then
cat > "$tmp_file" << JSON
{
"mcpServers": {
"unrealMCP": {
"command": "uv",
"args": ["--directory", "$python_dir", "run", "unreal_mcp_server.py"]
}
}
}
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" '
.mcpServers = (.mcpServers // {}) |
.mcpServers.unrealMCP = {
command: "uv",
args: ["--directory", $dir, "run", "unreal_mcp_server.py"]
}
' "$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"
}
# ---------- 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 ! 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"]}')
# 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
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."
}
# ---------- 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
============================================
Unreal MCP setup complete
============================================
Repo: $REPO_DIR
Python dir: $python_dir
Launcher: $USER_HOME/.local/bin/unreal-mcp-server
VS Code (Continue) MCP configured: ${CONFIGURE_CONTINUE}
- File: $USER_HOME/.continue/config.json
- Server ID: unrealMCP
VS Code (User profile) MCP configured: ${CONFIGURE_VSCODE_USER}
- Command used: code --add-mcp '{"name":"unrealMCP", "command":"uv", "args":["--directory","$python_dir","run","unreal_mcp_server.py"]}'
Optional usage:
- Run server manually: unreal-mcp-server
- In VS Code with Continue installed, the unrealMCP server will auto-start when needed.
Unreal plugin:
- Source: MCPGameProject/Plugins/UnrealMCP
- If you provided --project, the plugin was copied to: $plugin_dest
- In the Unreal Editor: Edit > Plugins > search "UnrealMCP" and enable. Restart when prompted.
Notes:
- Ensure you have Unreal Engine 5.5+ installed.
- The Python server listens to the Unreal plugin on TCP port 55557 by default.
- For other MCP clients (Claude Desktop, Cursor, Windsurf), copy the JSON snippet from the repo README to their config locations.
EOF
}
# shellcheck source=lib/unreal_mcp_repo.sh
source "$SCRIPT_DIR/lib/unreal_mcp_repo.sh"
main() {
log "Installing prerequisites (Arch Linux)"

View File

@ -0,0 +1,221 @@
#!/bin/bash
# dwm config install, healing and the patch applications.
#
# Sourced by setup_dwm.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# ---------------------------------------------------------------------------
# 3. Install our versioned config.h onto the fresh upstream clone. dwm.c stays
# pristine here — movestack()/togglefullscr() are defined inside config.h, so
# only the two intentional behaviour changes below patch dwm.c.
# ---------------------------------------------------------------------------
install_config() {
log "Installing config.h from the repo (${REPO_DWM_DIR}/config.h)…"
cp -- "${REPO_DWM_DIR}/config.h" "$SRC_DIR/config.h"
}
# ---------------------------------------------------------------------------
# 3b. Auto-merge bleeding-edge config churn. When upstream adds a new config
# knob (e.g. `refreshrate`), dwm.c starts referencing a symbol our
# hand-written config.h doesn't define, breaking the build. To keep
# "always latest master" sustainable, copy across any scalar knob that the
# current dwm.c needs but our config.h lacks, using upstream's default.
# Only single-line scalars referenced by dwm.c are merged (arrays we own
# and unused symbols are left alone), so our customisations always win.
# ---------------------------------------------------------------------------
heal_config() {
local defh="$SRC_DIR/config.def.h" cfgh="$SRC_DIR/config.h" dwmc="$SRC_DIR/dwm.c"
[[ -f "$defh" && -f "$dwmc" ]] || return 0
local line name added=0
while IFS= read -r line; do
name="$(sed -nE 's/^.*[^A-Za-z0-9_]([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=.*/\1/p' <<<"$line")"
[[ -n "$name" ]] || continue
grep -qw "$name" "$cfgh" && continue # we already define it — keep ours
grep -qw "$name" "$dwmc" || continue # dwm.c doesn't need it — skip
printf '%s\n' "$line" >>"$cfgh"
warn "config.h: auto-merged new upstream knob '$name' (bleeding-edge churn)"
added=1
done < <(grep -E '^[[:space:]]*static[[:space:]]+const[[:space:]]+[^={]*=[^;{]*;' "$defh")
((added)) && log "Merged upstream config symbol(s) into config.h for this build."
return 0
}
# ---------------------------------------------------------------------------
# 3c. Focus-on-click: stop the pointer from changing focus / switching monitors.
# dwm defaults to focus-follows-mouse — and worse, crossing the screen
# boundary over EMPTY space (motionnotify) switches the active monitor, which
# yanks focus away from a fullscreen game on the other screen (no window edge
# to stop the pointer). We rewrite enternotify + motionnotify to no-ops so
# focus only changes on a CLICK or via the Mod+,/. (Mod+Ctrl+arrows) keys.
# Applied as an idempotent source rewrite after each reset so it survives the
# bleeding-edge `git pull`. perl -0777 slurps the file so the multi-line
# function bodies match at once (.*? stops at the first column-0 `}`, i.e. the
# function's own closing brace — nested if-block braces are tab-indented).
# The dwm/patches/focus-on-click.patch file is the human-readable equivalent.
# If upstream refactors these handlers the rewrite no-ops and we warn loudly
# rather than silently dropping the behaviour.
# ---------------------------------------------------------------------------
apply_focusonclick() {
local src="$SRC_DIR/dwm.c"
[[ -f "$src" ]] || return 0
perl -0777 -i -pe '
s!\nenternotify\(XEvent \*e\)\n\{.*?\n\}\n!\nenternotify(XEvent *e)\n{\n\t/* focusonclick: pointer never changes focus; use a click or Mod+keys. */\n\t(void)e;\n}\n!s;
s!\nmotionnotify\(XEvent \*e\)\n\{.*?\n\}\n!\nmotionnotify(XEvent *e)\n{\n\t/* focusonclick: keep the active monitor fixed when crossing screens. */\n\t(void)e;\n}\n!s;
' "$src"
# Verify both rewrites landed; warn (never abort) if upstream changed shape.
local ok=1
grep -q 'focusonclick: pointer never changes focus' "$src" || ok=0
grep -q 'focusonclick: keep the active monitor fixed' "$src" || ok=0
if ((ok)); then
log "Applied focus-on-click (pointer no longer changes focus or switches monitors)."
else
warn "focus-on-click rewrite did NOT match upstream dwm.c — pointer focus unchanged."
warn "enternotify/motionnotify were likely refactored upstream; update dwm/patches."
fi
return 0
}
# ---------------------------------------------------------------------------
# 3d. Auto pointer-confinement on fullscreen. dwm has no pointer barriers, so on
# a dual-monitor setup the cursor slides off a fullscreen game onto the other
# screen (there is no window edge to stop it). We hook setfullscreen() to
# start the `pointer-confine` helper (XFixes barriers) when a window goes
# fullscreen and stop it when fullscreen ends; unmanage() also stops it so a
# game that closes WHILE fullscreen can never leave the cursor trapped. The
# hook is a quick `if (system(...)) {}` — the `if` consumes system()'s result
# so -Wall stays warning-free; the trailing `&` returns to dwm immediately.
# Reapplied after each reset (idempotent via git) and self-verifying; the
# dwm/patches/fullscreen-pointer-confine.patch file mirrors it for reading.
# ---------------------------------------------------------------------------
apply_fullscreen_confine_hook() {
local src="$SRC_DIR/dwm.c"
[[ -f "$src" ]] || return 0
perl -0777 -i -pe '
s!(\n\t\tc->isfullscreen = 1;\n)!$1\t\tif (system("pconfine-auto on &")) {}\n!;
s!(\n\t\tc->isfullscreen = 0;\n)!$1\t\tif (system("pconfine-auto off &")) {}\n!;
s!(\nunmanage\(Client \*c, int destroyed\)\n\{\n\tMonitor \*m = c->mon;\n\tXWindowChanges wc;\n)!$1\tif (c->isfullscreen) { if (system("pconfine-auto off &")) {} }\n!;
' "$src"
# Expect: 1 "on", 2 "off" (setfullscreen-leave + unmanage). Warn if not.
local on off
on=$(grep -c 'pconfine-auto on' "$src")
off=$(grep -c 'pconfine-auto off' "$src")
if [[ "$on" == 1 && "$off" == 2 ]]; then
log "Applied auto pointer-confinement hook (locks the cursor to a fullscreen window's screen)."
else
warn "pointer-confine hook only partially applied (on=$on off=$off, expected 1/2)."
warn "setfullscreen/unmanage were likely refactored upstream; update dwm/patches."
fi
return 0
}
# ---------------------------------------------------------------------------
# 4. Build & install (PREFIX defaults to /usr/local).
# ---------------------------------------------------------------------------
build_install() {
log "Compiling dwm…"
make -C "$SRC_DIR" clean >/dev/null
make -C "$SRC_DIR" 2>&1 | tail -15
log "Installing dwm (sudo make install)…"
sudo make -C "$SRC_DIR" install 2>&1 | tail -8
}
# ---------------------------------------------------------------------------
# 4b. Build & install the pointer-confine helper (XFixes barriers) from the
# versioned dwm/pointer-confine.c. Standalone C so it stays out of dwm.c;
# dwm only spawns it via the setfullscreen() hook. If the X dev headers are
# missing it fails soft: warn and skip, leaving the rest of dwm fully working
# (fullscreen just won't auto-lock the cursor).
# ---------------------------------------------------------------------------
build_pointer_confine() {
log "Compiling pointer-confine from the repo (${REPO_DWM_DIR}/pointer-confine.c)…"
local bin
bin="$(mktemp)"
if cc -std=c99 -pedantic -Wall -O2 "${REPO_DWM_DIR}/pointer-confine.c" -o "$bin" \
-lX11 -lXfixes -lXinerama 2>/tmp/pointer-confine-build.log; then
sudo install -m 755 "$bin" "$BIN_CONFINE"
log "Installed $BIN_CONFINE."
else
warn "pointer-confine failed to compile — fullscreen cursor-lock disabled (dwm itself is fine):"
sed 's/^/ /' /tmp/pointer-confine-build.log >&2 || true
fi
rm -f "$bin"
}
# ---------------------------------------------------------------------------
# 5. Install the helper scripts (from the repo) and register the lightdm
# xsession. The scripts are the versioned files in dwm/bin/; we just place
# them on PATH with the right mode.
# ---------------------------------------------------------------------------
write_session_files() {
log "Installing helper scripts from the repo and the lightdm xsession entry…"
sudo install -m 755 "${REPO_DWM_DIR}/bin/dwm-session" "$BIN_SESSION"
sudo install -m 755 "${REPO_DWM_DIR}/bin/dwmstatus" "$BIN_STATUS"
sudo install -m 755 "${REPO_DWM_DIR}/bin/dwm-rebuild" "$BIN_REBUILD"
sudo install -m 755 "${REPO_DWM_DIR}/bin/switch-wm" "$BIN_SWITCH"
sudo install -m 755 "${REPO_DWM_DIR}/bin/pconfine-auto" "$BIN_CONFINE_AUTO"
# --- xsession entry for lightdm (absolute Exec path) --------------------
sudo tee "$XSESSION" >/dev/null <<'DESKTOP_EOF'
[Desktop Entry]
Name=dwm (i3-like)
Comment=dynamic window manager, compiled from source
Exec=/usr/local/bin/dwm-session
TryExec=/usr/local/bin/dwm
Type=Application
DesktopNames=dwm
DESKTOP_EOF
}
# ---------------------------------------------------------------------------
# 6. Verify the build links and the session is registered.
# ---------------------------------------------------------------------------
verify() {
log "Verifying install…"
local ver
ver="$(dwm -v 2>&1)" || true # dwm -v prints version then exit(1)
log "dwm version: ${ver:-<none>}"
command -v dwm >/dev/null && log "dwm binary: $(command -v dwm)"
[[ -f "$XSESSION" ]] && log "xsession registered: $XSESSION"
}
print_summary() {
cat <<SUMMARY
dwm is installed alongside i3 (i3 untouched).
This machine autologins (no session picker), so choose the WM you boot into:
switch-wm dwm -> boot dwm switch-wm i3 -> boot i3 switch-wm -> show
then reboot. Recovery if dwm misbehaves: TTY (Ctrl+Alt+F3) -> 'switch-wm i3' -> reboot.
Key bindings (Mod = Super):
Mod+Return terminator Mod+d dmenu
Mod+j / Mod+k focus next / prev Mod+Shift+j/k move in stack
Mod+h / Mod+l shrink / grow master Mod+i/Shift+i +/- master count
Mod+1..0 view tag 1..10 Mod+Shift+1..0 send to tag
Mod+f fullscreen Mod+Shift+space toggle floating
Mod+t / Mod+w tiling / monocle Mod+Shift+Return promote to master
Mod+Shift+q kill window Mod+Shift+e exit dwm
Mod+m mic mute Mod+Shift+r recompile (dwm-rebuild)
Two monitors (no i3-style per-output workspaces — see config.h note):
Mod+, / Mod+. focus the other screen (or Mod+Ctrl+Left/Right)
Mod+Shift+, / Mod+Shift+. throw window there (or Mod+Ctrl+Shift+L/R)
Focus-on-click is ON: the pointer no longer steals focus or switches monitors
when it crosses screens. Focus changes on click/keys.
Fullscreen cursor-lock is ON: when a window goes fullscreen (games), the cursor
is trapped on that screen (XFixes barriers) and released when fullscreen ends.
Stuck barrier? Mod+Shift+p force-releases it.
Status bar (clock, temps, load, RAM, volume) needs xsetroot:
sudo pacman -S xorg-xsetroot # then log out / back in
Preview it now without the bar: dwmstatus once
Customise (permanent): edit files in linux_configuration/dwm/ then re-run this
script. Quick experiment: edit ~/.local/src/dwm/config.h + run 'dwm-rebuild'
(re-running setup_dwm.sh overwrites it from the repo).
SUMMARY
}

View File

@ -0,0 +1,217 @@
#!/bin/bash
# Modelfile authoring and model pull for the local LLM.
#
# Sourced by setup_local_llm.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# ── 4. Create Modelfile with developer system prompt ─────────────────────────
create_modelfile() {
section "Creating custom model with developer system prompt"
# Condense Claude preferences into a system prompt (~1 K tokens).
# Note: hooks, skills, and Claude Code MCP tools do NOT port to local models.
# This only captures code-style, persona, and workflow preferences.
local modelfile
modelfile="$(mktemp --suffix=.modelfile)"
cat > "$modelfile" << MODELFILE_EOF
FROM ${MODEL}
PARAMETER num_ctx ${CTX_LEN}
PARAMETER temperature 0.6
PARAMETER top_p 0.95
PARAMETER top_k 20
SYSTEM """
You are a coding assistant for Krzysztof (kuhy) Rudnicki, a software developer
who maintains personal automation and app monorepos.
## About me
- Stack: Python, Bash, C/C++, TypeScript, Dart/Flutter, Go
- OS: Arch Linux, i3 window manager
- Key repos: testsAndMisc (automation), linux-configuration, praca_magisterska
## Code style
Python:
- Use from __future__ import annotations for forward refs
- Google docstring convention (triple-quoted, brief summary + Args/Returns)
- Absolute imports only, no relative imports
- Type hints required on every function signature
- Prefer typed dataclasses over plain dicts
Shell (bash):
- Always set -euo pipefail at the top
- Double-quote all variable expansions
- Avoid fork-heavy patterns: prefer /proc, /sys, bash builtins over \$(...)
in tight loops or status-bar scripts (fork-storm anti-pattern)
- Use jq/yq for JSON/YAML, not grep/awk
- NEVER embed multi-line Python logic inside shell heredocs — put it in a
separate .py file and invoke with python3 path/to/file.py
TypeScript:
- Target ES2022, pure ESM (no CommonJS require)
- No any — use unknown + narrowing
- Discriminated unions for state machines
- async/await + structured error handling
## Behaviour I expect
- Keep responses brief and direct — no filler preamble
- Ask before making high-impact changes (schema changes, deleting data)
- If you spot a bad approach, say so before complying
- Development order: implement → run to verify → confirm → tests
(tests are always LAST, not first)
- Include only one short comment per non-obvious block; no inline summaries
of what the code obviously does
## Testing rules
- 100% branch coverage required for Python packages
- Use pytest + coverage; never mock the filesystem in integration tests
- Shell scripts: shellcheck must pass with no suppressions
## Git workflow
- Commit directly to main (no branches for personal repo)
- Conventional commit format: type: brief description
- Always run pre-commit hooks before committing
"""
MODELFILE_EOF
local custom_model="${MODEL/:/-}-dev"
ollama create "$custom_model" -f "$modelfile"
rm -f "$modelfile"
ok "Custom model created: $custom_model (includes developer system prompt)"
ok "Load '$custom_model' in Open WebUI for kuhy-personalised responses"
}
# ── 5. Pull base model ────────────────────────────────────────────────────────
pull_model() {
section "Pulling ${MODEL}"
# Check if already present
if curl -sf "http://localhost:${OLLAMA_PORT}/api/tags" \
| grep -q "\"${MODEL}\""; then
ok "${MODEL} already downloaded"
return
fi
warn "Downloading ${MODEL} — approx 20 GB. This will take a while."
warn "Progress is shown live; do not interrupt."
ollama pull "${MODEL}"
ok "${MODEL} downloaded"
}
# ── 6. Set up Open WebUI via Docker ───────────────────────────────────────────
setup_webui() {
section "Setting up Open WebUI"
# Remove existing container so we can apply any config changes cleanly
if docker ps -a --format '{{.Names}}' | grep -qx "$WEBUI_CONTAINER"; then
warn "Removing existing $WEBUI_CONTAINER container..."
docker rm -f "$WEBUI_CONTAINER"
fi
docker pull ghcr.io/open-webui/open-webui:main
docker run -d \
--name "$WEBUI_CONTAINER" \
--restart always \
-p "${WEBUI_PORT}:8080" \
-v "${WEBUI_VOLUME}:/app/backend/data" \
-e OLLAMA_BASE_URL="http://host.docker.internal:${OLLAMA_PORT}" \
--add-host=host.docker.internal:host-gateway \
ghcr.io/open-webui/open-webui:main
ok "Open WebUI container started on port ${WEBUI_PORT}"
}
# ── 7. Enable Docker on boot ──────────────────────────────────────────────────
enable_autostart() {
section "Enabling auto-start on boot"
# Docker daemon must start for the container (restart=always) to come up
sudo systemctl enable docker
ok "docker.service enabled on boot"
ok "Ollama is already enabled; Open WebUI container uses restart=always"
}
# ── 8. Verify: GPU utilisation + generation speed ─────────────────────────────
verify_gpu() {
section "Verifying GPU utilisation and generation speed"
# Warm up with a short generation and measure tok/s
warn "Running a warm-up generation (checking GPU offload + speed)..."
local start_ts end_ts elapsed toks_per_sec
start_ts="$(date +%s%N)"
local response
response="$(curl -sf "http://localhost:${OLLAMA_PORT}/api/generate" \
-d "{\"model\":\"${MODEL}\",\"prompt\":\"Reply with exactly: ready\",\"stream\":false}" \
--max-time 120)"
end_ts="$(date +%s%N)"
elapsed=$(( (end_ts - start_ts) / 1000000 )) # milliseconds
if [[ -z "$response" ]]; then
err "No response from Ollama — generation failed"
return 1
fi
# Parse eval_count (output tokens) and eval_duration (nanoseconds) from response
local eval_count eval_duration_ns
eval_count="$(printf '%s' "$response" | grep -o '"eval_count":[0-9]*' | grep -o '[0-9]*' || echo 0)"
eval_duration_ns="$(printf '%s' "$response" | grep -o '"eval_duration":[0-9]*' | grep -o '[0-9]*' || echo 1)"
if (( eval_count > 0 && eval_duration_ns > 0 )); then
toks_per_sec=$(( eval_count * 1000000000 / eval_duration_ns ))
ok "Generation speed: ${toks_per_sec} tok/s (${eval_count} tokens in ${elapsed}ms)"
if (( toks_per_sec < 10 )); then
warn "Speed is below 10 tok/s — model may be partially CPU-offloaded"
warn "Run: ollama ps to check GPU layer count"
warn "If GPU% < 100, try reducing CTX_LEN in this script or use a smaller quant"
fi
else
warn "Could not parse tok/s from response; check manually with: ollama ps"
fi
# Check GPU offload via ollama ps
warn "Checking GPU layer utilisation..."
if command -v ollama &>/dev/null; then
ollama ps 2>/dev/null | head -10 || true
fi
ok "Verify done — see ollama ps output above for GPU% column"
}
# ── 9. WebUI readiness check ──────────────────────────────────────────────────
verify_webui() {
section "Waiting for Open WebUI to become ready"
wait_for_url "http://localhost:${WEBUI_PORT}" "Open WebUI" 60
}
# ── Summary ───────────────────────────────────────────────────────────────────
print_summary() {
local custom_model="${MODEL/:/-}-dev"
printf '\n\033[1;32m'
printf '═══════════════════════════════════════════════════════\n'
printf ' Local LLM setup complete!\n'
printf '═══════════════════════════════════════════════════════\n'
printf ' Open WebUI: http://localhost:%s\n' "$WEBUI_PORT"
printf ' Ollama API: http://localhost:%s\n' "$OLLAMA_PORT"
printf ' Primary model: %s (%d K context)\n' "$MODEL" $(( CTX_LEN / 1024 ))
printf ' Dev model: %s (with code-style system prompt)\n' "$custom_model"
printf '\n'
printf ' GPU fit notes:\n'
printf ' Current context: %d K (safe for 24 GB VRAM)\n' $(( CTX_LEN / 1024 ))
printf ' To try 24 K: edit CTX_LEN=24576 in this script\n'
printf ' To try 32 K: edit CTX_LEN=32768 (risks partial CPU offload)\n'
printf ' After changes: re-run this script; check tok/s in verify step\n'
printf '\n'
printf ' To use with Claude Code:\n'
printf ' Claude Code uses the Anthropic API — it cannot point at Ollama\n'
printf ' directly (different wire format). Use Open WebUI for local-model\n'
printf ' coding sessions, or ask about setting up a LiteLLM proxy.\n'
printf '═══════════════════════════════════════════════════════\n'
printf '\033[0m\n'
}
# ── Main ──────────────────────────────────────────────────────────────────────

View File

@ -0,0 +1,249 @@
#!/bin/bash
# Repo checkout, launcher install and MCP client registration.
#
# Sourced by install_unreal_mcp.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
# ---------- 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
# 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"
}
# ---------- VS Code: Continue MCP config ----------
configure_continue() {
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"
# Base JSON when no config exists
local tmp_file
tmp_file="$(mktemp)"
if [[ ! -f $cont_cfg ]]; then
cat > "$tmp_file" << JSON
{
"mcpServers": {
"unrealMCP": {
"command": "uv",
"args": ["--directory", "$python_dir", "run", "unreal_mcp_server.py"]
}
}
}
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" '
.mcpServers = (.mcpServers // {}) |
.mcpServers.unrealMCP = {
command: "uv",
args: ["--directory", $dir, "run", "unreal_mcp_server.py"]
}
' "$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"
}
# ---------- 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 ! 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"]}')
# 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
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."
}
# ---------- 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
============================================
Unreal MCP setup complete
============================================
Repo: $REPO_DIR
Python dir: $python_dir
Launcher: $USER_HOME/.local/bin/unreal-mcp-server
VS Code (Continue) MCP configured: ${CONFIGURE_CONTINUE}
- File: $USER_HOME/.continue/config.json
- Server ID: unrealMCP
VS Code (User profile) MCP configured: ${CONFIGURE_VSCODE_USER}
- Command used: code --add-mcp '{"name":"unrealMCP", "command":"uv", "args":["--directory","$python_dir","run","unreal_mcp_server.py"]}'
Optional usage:
- Run server manually: unreal-mcp-server
- In VS Code with Continue installed, the unrealMCP server will auto-start when needed.
Unreal plugin:
- Source: MCPGameProject/Plugins/UnrealMCP
- If you provided --project, the plugin was copied to: $plugin_dest
- In the Unreal Editor: Edit > Plugins > search "UnrealMCP" and enable. Restart when prompted.
Notes:
- Ensure you have Unreal Engine 5.5+ installed.
- The Python server listens to the Unreal plugin on TCP port 55557 by default.
- For other MCP clients (Claude Desktop, Cursor, Windsurf), copy the JSON snippet from the repo README to their config locations.
EOF
}

View File

@ -126,220 +126,8 @@ fetch_dwm() {
log "dwm source now at commit $(git -C "$SRC_DIR" rev-parse --short HEAD) ($(git -C "$SRC_DIR" log -1 --format=%cd --date=short))"
}
# ---------------------------------------------------------------------------
# 3. Install our versioned config.h onto the fresh upstream clone. dwm.c stays
# pristine here — movestack()/togglefullscr() are defined inside config.h, so
# only the two intentional behaviour changes below patch dwm.c.
# ---------------------------------------------------------------------------
install_config() {
log "Installing config.h from the repo (${REPO_DWM_DIR}/config.h)…"
cp -- "${REPO_DWM_DIR}/config.h" "$SRC_DIR/config.h"
}
# ---------------------------------------------------------------------------
# 3b. Auto-merge bleeding-edge config churn. When upstream adds a new config
# knob (e.g. `refreshrate`), dwm.c starts referencing a symbol our
# hand-written config.h doesn't define, breaking the build. To keep
# "always latest master" sustainable, copy across any scalar knob that the
# current dwm.c needs but our config.h lacks, using upstream's default.
# Only single-line scalars referenced by dwm.c are merged (arrays we own
# and unused symbols are left alone), so our customisations always win.
# ---------------------------------------------------------------------------
heal_config() {
local defh="$SRC_DIR/config.def.h" cfgh="$SRC_DIR/config.h" dwmc="$SRC_DIR/dwm.c"
[[ -f "$defh" && -f "$dwmc" ]] || return 0
local line name added=0
while IFS= read -r line; do
name="$(sed -nE 's/^.*[^A-Za-z0-9_]([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=.*/\1/p' <<<"$line")"
[[ -n "$name" ]] || continue
grep -qw "$name" "$cfgh" && continue # we already define it — keep ours
grep -qw "$name" "$dwmc" || continue # dwm.c doesn't need it — skip
printf '%s\n' "$line" >>"$cfgh"
warn "config.h: auto-merged new upstream knob '$name' (bleeding-edge churn)"
added=1
done < <(grep -E '^[[:space:]]*static[[:space:]]+const[[:space:]]+[^={]*=[^;{]*;' "$defh")
((added)) && log "Merged upstream config symbol(s) into config.h for this build."
return 0
}
# ---------------------------------------------------------------------------
# 3c. Focus-on-click: stop the pointer from changing focus / switching monitors.
# dwm defaults to focus-follows-mouse — and worse, crossing the screen
# boundary over EMPTY space (motionnotify) switches the active monitor, which
# yanks focus away from a fullscreen game on the other screen (no window edge
# to stop the pointer). We rewrite enternotify + motionnotify to no-ops so
# focus only changes on a CLICK or via the Mod+,/. (Mod+Ctrl+arrows) keys.
# Applied as an idempotent source rewrite after each reset so it survives the
# bleeding-edge `git pull`. perl -0777 slurps the file so the multi-line
# function bodies match at once (.*? stops at the first column-0 `}`, i.e. the
# function's own closing brace — nested if-block braces are tab-indented).
# The dwm/patches/focus-on-click.patch file is the human-readable equivalent.
# If upstream refactors these handlers the rewrite no-ops and we warn loudly
# rather than silently dropping the behaviour.
# ---------------------------------------------------------------------------
apply_focusonclick() {
local src="$SRC_DIR/dwm.c"
[[ -f "$src" ]] || return 0
perl -0777 -i -pe '
s!\nenternotify\(XEvent \*e\)\n\{.*?\n\}\n!\nenternotify(XEvent *e)\n{\n\t/* focusonclick: pointer never changes focus; use a click or Mod+keys. */\n\t(void)e;\n}\n!s;
s!\nmotionnotify\(XEvent \*e\)\n\{.*?\n\}\n!\nmotionnotify(XEvent *e)\n{\n\t/* focusonclick: keep the active monitor fixed when crossing screens. */\n\t(void)e;\n}\n!s;
' "$src"
# Verify both rewrites landed; warn (never abort) if upstream changed shape.
local ok=1
grep -q 'focusonclick: pointer never changes focus' "$src" || ok=0
grep -q 'focusonclick: keep the active monitor fixed' "$src" || ok=0
if ((ok)); then
log "Applied focus-on-click (pointer no longer changes focus or switches monitors)."
else
warn "focus-on-click rewrite did NOT match upstream dwm.c — pointer focus unchanged."
warn "enternotify/motionnotify were likely refactored upstream; update dwm/patches."
fi
return 0
}
# ---------------------------------------------------------------------------
# 3d. Auto pointer-confinement on fullscreen. dwm has no pointer barriers, so on
# a dual-monitor setup the cursor slides off a fullscreen game onto the other
# screen (there is no window edge to stop it). We hook setfullscreen() to
# start the `pointer-confine` helper (XFixes barriers) when a window goes
# fullscreen and stop it when fullscreen ends; unmanage() also stops it so a
# game that closes WHILE fullscreen can never leave the cursor trapped. The
# hook is a quick `if (system(...)) {}` — the `if` consumes system()'s result
# so -Wall stays warning-free; the trailing `&` returns to dwm immediately.
# Reapplied after each reset (idempotent via git) and self-verifying; the
# dwm/patches/fullscreen-pointer-confine.patch file mirrors it for reading.
# ---------------------------------------------------------------------------
apply_fullscreen_confine_hook() {
local src="$SRC_DIR/dwm.c"
[[ -f "$src" ]] || return 0
perl -0777 -i -pe '
s!(\n\t\tc->isfullscreen = 1;\n)!$1\t\tif (system("pconfine-auto on &")) {}\n!;
s!(\n\t\tc->isfullscreen = 0;\n)!$1\t\tif (system("pconfine-auto off &")) {}\n!;
s!(\nunmanage\(Client \*c, int destroyed\)\n\{\n\tMonitor \*m = c->mon;\n\tXWindowChanges wc;\n)!$1\tif (c->isfullscreen) { if (system("pconfine-auto off &")) {} }\n!;
' "$src"
# Expect: 1 "on", 2 "off" (setfullscreen-leave + unmanage). Warn if not.
local on off
on=$(grep -c 'pconfine-auto on' "$src")
off=$(grep -c 'pconfine-auto off' "$src")
if [[ "$on" == 1 && "$off" == 2 ]]; then
log "Applied auto pointer-confinement hook (locks the cursor to a fullscreen window's screen)."
else
warn "pointer-confine hook only partially applied (on=$on off=$off, expected 1/2)."
warn "setfullscreen/unmanage were likely refactored upstream; update dwm/patches."
fi
return 0
}
# ---------------------------------------------------------------------------
# 4. Build & install (PREFIX defaults to /usr/local).
# ---------------------------------------------------------------------------
build_install() {
log "Compiling dwm…"
make -C "$SRC_DIR" clean >/dev/null
make -C "$SRC_DIR" 2>&1 | tail -15
log "Installing dwm (sudo make install)…"
sudo make -C "$SRC_DIR" install 2>&1 | tail -8
}
# ---------------------------------------------------------------------------
# 4b. Build & install the pointer-confine helper (XFixes barriers) from the
# versioned dwm/pointer-confine.c. Standalone C so it stays out of dwm.c;
# dwm only spawns it via the setfullscreen() hook. If the X dev headers are
# missing it fails soft: warn and skip, leaving the rest of dwm fully working
# (fullscreen just won't auto-lock the cursor).
# ---------------------------------------------------------------------------
build_pointer_confine() {
log "Compiling pointer-confine from the repo (${REPO_DWM_DIR}/pointer-confine.c)…"
local bin
bin="$(mktemp)"
if cc -std=c99 -pedantic -Wall -O2 "${REPO_DWM_DIR}/pointer-confine.c" -o "$bin" \
-lX11 -lXfixes -lXinerama 2>/tmp/pointer-confine-build.log; then
sudo install -m 755 "$bin" "$BIN_CONFINE"
log "Installed $BIN_CONFINE."
else
warn "pointer-confine failed to compile — fullscreen cursor-lock disabled (dwm itself is fine):"
sed 's/^/ /' /tmp/pointer-confine-build.log >&2 || true
fi
rm -f "$bin"
}
# ---------------------------------------------------------------------------
# 5. Install the helper scripts (from the repo) and register the lightdm
# xsession. The scripts are the versioned files in dwm/bin/; we just place
# them on PATH with the right mode.
# ---------------------------------------------------------------------------
write_session_files() {
log "Installing helper scripts from the repo and the lightdm xsession entry…"
sudo install -m 755 "${REPO_DWM_DIR}/bin/dwm-session" "$BIN_SESSION"
sudo install -m 755 "${REPO_DWM_DIR}/bin/dwmstatus" "$BIN_STATUS"
sudo install -m 755 "${REPO_DWM_DIR}/bin/dwm-rebuild" "$BIN_REBUILD"
sudo install -m 755 "${REPO_DWM_DIR}/bin/switch-wm" "$BIN_SWITCH"
sudo install -m 755 "${REPO_DWM_DIR}/bin/pconfine-auto" "$BIN_CONFINE_AUTO"
# --- xsession entry for lightdm (absolute Exec path) --------------------
sudo tee "$XSESSION" >/dev/null <<'DESKTOP_EOF'
[Desktop Entry]
Name=dwm (i3-like)
Comment=dynamic window manager, compiled from source
Exec=/usr/local/bin/dwm-session
TryExec=/usr/local/bin/dwm
Type=Application
DesktopNames=dwm
DESKTOP_EOF
}
# ---------------------------------------------------------------------------
# 6. Verify the build links and the session is registered.
# ---------------------------------------------------------------------------
verify() {
log "Verifying install…"
local ver
ver="$(dwm -v 2>&1)" || true # dwm -v prints version then exit(1)
log "dwm version: ${ver:-<none>}"
command -v dwm >/dev/null && log "dwm binary: $(command -v dwm)"
[[ -f "$XSESSION" ]] && log "xsession registered: $XSESSION"
}
print_summary() {
cat <<SUMMARY
dwm is installed alongside i3 (i3 untouched).
This machine autologins (no session picker), so choose the WM you boot into:
switch-wm dwm -> boot dwm switch-wm i3 -> boot i3 switch-wm -> show
then reboot. Recovery if dwm misbehaves: TTY (Ctrl+Alt+F3) -> 'switch-wm i3' -> reboot.
Key bindings (Mod = Super):
Mod+Return terminator Mod+d dmenu
Mod+j / Mod+k focus next / prev Mod+Shift+j/k move in stack
Mod+h / Mod+l shrink / grow master Mod+i/Shift+i +/- master count
Mod+1..0 view tag 1..10 Mod+Shift+1..0 send to tag
Mod+f fullscreen Mod+Shift+space toggle floating
Mod+t / Mod+w tiling / monocle Mod+Shift+Return promote to master
Mod+Shift+q kill window Mod+Shift+e exit dwm
Mod+m mic mute Mod+Shift+r recompile (dwm-rebuild)
Two monitors (no i3-style per-output workspaces — see config.h note):
Mod+, / Mod+. focus the other screen (or Mod+Ctrl+Left/Right)
Mod+Shift+, / Mod+Shift+. throw window there (or Mod+Ctrl+Shift+L/R)
Focus-on-click is ON: the pointer no longer steals focus or switches monitors
when it crosses screens. Focus changes on click/keys.
Fullscreen cursor-lock is ON: when a window goes fullscreen (games), the cursor
is trapped on that screen (XFixes barriers) and released when fullscreen ends.
Stuck barrier? Mod+Shift+p force-releases it.
Status bar (clock, temps, load, RAM, volume) needs xsetroot:
sudo pacman -S xorg-xsetroot # then log out / back in
Preview it now without the bar: dwmstatus once
Customise (permanent): edit files in linux_configuration/dwm/ then re-run this
script. Quick experiment: edit ~/.local/src/dwm/config.h + run 'dwm-rebuild'
(re-running setup_dwm.sh overwrites it from the repo).
SUMMARY
}
# shellcheck source=lib/dwm_config.sh
source "$SCRIPT_DIR/lib/dwm_config.sh"
main() {
validate_repo_files

View File

@ -141,216 +141,9 @@ start_ollama() {
wait_for_url "http://localhost:${OLLAMA_PORT}/api/tags" "Ollama API" 30
}
# ── 4. Create Modelfile with developer system prompt ─────────────────────────
create_modelfile() {
section "Creating custom model with developer system prompt"
# shellcheck source=lib/local_llm_model.sh
source "$SCRIPT_DIR/lib/local_llm_model.sh"
# Condense Claude preferences into a system prompt (~1 K tokens).
# Note: hooks, skills, and Claude Code MCP tools do NOT port to local models.
# This only captures code-style, persona, and workflow preferences.
local modelfile
modelfile="$(mktemp --suffix=.modelfile)"
cat > "$modelfile" << MODELFILE_EOF
FROM ${MODEL}
PARAMETER num_ctx ${CTX_LEN}
PARAMETER temperature 0.6
PARAMETER top_p 0.95
PARAMETER top_k 20
SYSTEM """
You are a coding assistant for Krzysztof (kuhy) Rudnicki, a software developer
who maintains personal automation and app monorepos.
## About me
- Stack: Python, Bash, C/C++, TypeScript, Dart/Flutter, Go
- OS: Arch Linux, i3 window manager
- Key repos: testsAndMisc (automation), linux-configuration, praca_magisterska
## Code style
Python:
- Use from __future__ import annotations for forward refs
- Google docstring convention (triple-quoted, brief summary + Args/Returns)
- Absolute imports only, no relative imports
- Type hints required on every function signature
- Prefer typed dataclasses over plain dicts
Shell (bash):
- Always set -euo pipefail at the top
- Double-quote all variable expansions
- Avoid fork-heavy patterns: prefer /proc, /sys, bash builtins over \$(...)
in tight loops or status-bar scripts (fork-storm anti-pattern)
- Use jq/yq for JSON/YAML, not grep/awk
- NEVER embed multi-line Python logic inside shell heredocs — put it in a
separate .py file and invoke with python3 path/to/file.py
TypeScript:
- Target ES2022, pure ESM (no CommonJS require)
- No any — use unknown + narrowing
- Discriminated unions for state machines
- async/await + structured error handling
## Behaviour I expect
- Keep responses brief and direct — no filler preamble
- Ask before making high-impact changes (schema changes, deleting data)
- If you spot a bad approach, say so before complying
- Development order: implement → run to verify → confirm → tests
(tests are always LAST, not first)
- Include only one short comment per non-obvious block; no inline summaries
of what the code obviously does
## Testing rules
- 100% branch coverage required for Python packages
- Use pytest + coverage; never mock the filesystem in integration tests
- Shell scripts: shellcheck must pass with no suppressions
## Git workflow
- Commit directly to main (no branches for personal repo)
- Conventional commit format: type: brief description
- Always run pre-commit hooks before committing
"""
MODELFILE_EOF
local custom_model="${MODEL/:/-}-dev"
ollama create "$custom_model" -f "$modelfile"
rm -f "$modelfile"
ok "Custom model created: $custom_model (includes developer system prompt)"
ok "Load '$custom_model' in Open WebUI for kuhy-personalised responses"
}
# ── 5. Pull base model ────────────────────────────────────────────────────────
pull_model() {
section "Pulling ${MODEL}"
# Check if already present
if curl -sf "http://localhost:${OLLAMA_PORT}/api/tags" \
| grep -q "\"${MODEL}\""; then
ok "${MODEL} already downloaded"
return
fi
warn "Downloading ${MODEL} — approx 20 GB. This will take a while."
warn "Progress is shown live; do not interrupt."
ollama pull "${MODEL}"
ok "${MODEL} downloaded"
}
# ── 6. Set up Open WebUI via Docker ───────────────────────────────────────────
setup_webui() {
section "Setting up Open WebUI"
# Remove existing container so we can apply any config changes cleanly
if docker ps -a --format '{{.Names}}' | grep -qx "$WEBUI_CONTAINER"; then
warn "Removing existing $WEBUI_CONTAINER container..."
docker rm -f "$WEBUI_CONTAINER"
fi
docker pull ghcr.io/open-webui/open-webui:main
docker run -d \
--name "$WEBUI_CONTAINER" \
--restart always \
-p "${WEBUI_PORT}:8080" \
-v "${WEBUI_VOLUME}:/app/backend/data" \
-e OLLAMA_BASE_URL="http://host.docker.internal:${OLLAMA_PORT}" \
--add-host=host.docker.internal:host-gateway \
ghcr.io/open-webui/open-webui:main
ok "Open WebUI container started on port ${WEBUI_PORT}"
}
# ── 7. Enable Docker on boot ──────────────────────────────────────────────────
enable_autostart() {
section "Enabling auto-start on boot"
# Docker daemon must start for the container (restart=always) to come up
sudo systemctl enable docker
ok "docker.service enabled on boot"
ok "Ollama is already enabled; Open WebUI container uses restart=always"
}
# ── 8. Verify: GPU utilisation + generation speed ─────────────────────────────
verify_gpu() {
section "Verifying GPU utilisation and generation speed"
# Warm up with a short generation and measure tok/s
warn "Running a warm-up generation (checking GPU offload + speed)..."
local start_ts end_ts elapsed toks_per_sec
start_ts="$(date +%s%N)"
local response
response="$(curl -sf "http://localhost:${OLLAMA_PORT}/api/generate" \
-d "{\"model\":\"${MODEL}\",\"prompt\":\"Reply with exactly: ready\",\"stream\":false}" \
--max-time 120)"
end_ts="$(date +%s%N)"
elapsed=$(( (end_ts - start_ts) / 1000000 )) # milliseconds
if [[ -z "$response" ]]; then
err "No response from Ollama — generation failed"
return 1
fi
# Parse eval_count (output tokens) and eval_duration (nanoseconds) from response
local eval_count eval_duration_ns
eval_count="$(printf '%s' "$response" | grep -o '"eval_count":[0-9]*' | grep -o '[0-9]*' || echo 0)"
eval_duration_ns="$(printf '%s' "$response" | grep -o '"eval_duration":[0-9]*' | grep -o '[0-9]*' || echo 1)"
if (( eval_count > 0 && eval_duration_ns > 0 )); then
toks_per_sec=$(( eval_count * 1000000000 / eval_duration_ns ))
ok "Generation speed: ${toks_per_sec} tok/s (${eval_count} tokens in ${elapsed}ms)"
if (( toks_per_sec < 10 )); then
warn "Speed is below 10 tok/s — model may be partially CPU-offloaded"
warn "Run: ollama ps to check GPU layer count"
warn "If GPU% < 100, try reducing CTX_LEN in this script or use a smaller quant"
fi
else
warn "Could not parse tok/s from response; check manually with: ollama ps"
fi
# Check GPU offload via ollama ps
warn "Checking GPU layer utilisation..."
if command -v ollama &>/dev/null; then
ollama ps 2>/dev/null | head -10 || true
fi
ok "Verify done — see ollama ps output above for GPU% column"
}
# ── 9. WebUI readiness check ──────────────────────────────────────────────────
verify_webui() {
section "Waiting for Open WebUI to become ready"
wait_for_url "http://localhost:${WEBUI_PORT}" "Open WebUI" 60
}
# ── Summary ───────────────────────────────────────────────────────────────────
print_summary() {
local custom_model="${MODEL/:/-}-dev"
printf '\n\033[1;32m'
printf '═══════════════════════════════════════════════════════\n'
printf ' Local LLM setup complete!\n'
printf '═══════════════════════════════════════════════════════\n'
printf ' Open WebUI: http://localhost:%s\n' "$WEBUI_PORT"
printf ' Ollama API: http://localhost:%s\n' "$OLLAMA_PORT"
printf ' Primary model: %s (%d K context)\n' "$MODEL" $(( CTX_LEN / 1024 ))
printf ' Dev model: %s (with code-style system prompt)\n' "$custom_model"
printf '\n'
printf ' GPU fit notes:\n'
printf ' Current context: %d K (safe for 24 GB VRAM)\n' $(( CTX_LEN / 1024 ))
printf ' To try 24 K: edit CTX_LEN=24576 in this script\n'
printf ' To try 32 K: edit CTX_LEN=32768 (risks partial CPU offload)\n'
printf ' After changes: re-run this script; check tok/s in verify step\n'
printf '\n'
printf ' To use with Claude Code:\n'
printf ' Claude Code uses the Anthropic API — it cannot point at Ollama\n'
printf ' directly (different wire format). Use Open WebUI for local-model\n'
printf ' coding sessions, or ask about setting up a LiteLLM proxy.\n'
printf '═══════════════════════════════════════════════════════\n'
printf '\033[0m\n'
}
# ── Main ──────────────────────────────────────────────────────────────────────
main() {
log "Local LLM setup — model: ${MODEL}, GPU: RTX 3090, VRAM: 24 GB"

View File

@ -97,262 +97,10 @@ install_optional_tools() {
pacman -S --needed --noconfirm "${packages[@]}"
}
collect_basics() {
run_and_log "Kernel" uname -a
run_and_log "Uptime" uptime
run_and_log "Memory" free -h
run_and_log "Swap" swapon --show
run_and_log "CPU (lscpu)" lscpu
run_and_log "Disk Usage" df -h /
run_and_log "Boot Time" systemd-analyze
run_and_log "Failed Units" systemctl --failed --no-pager
run_and_log "Recent Errors (this boot)" journalctl -b -p err --no-pager -n 200
local cpu_count
cpu_count=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
local load1
load1=$(awk '{print int($1)}' /proc/loadavg 2>/dev/null || echo 0)
if [[ ${load1:-0} -ge ${cpu_count:-1} ]]; then
add_finding "1-minute load average is at/above CPU thread count (${load1}/${cpu_count}); background tasks may be saturating the system."
fi
local failed_count
failed_count=$(systemctl --failed --no-legend 2>/dev/null | wc -l || true)
failed_count=${failed_count//[[:space:]]/}
if [[ ${failed_count:-0} -gt 0 ]]; then
add_finding "One or more systemd units are failed (${failed_count}); failed services can cause repeated retries and instability."
fi
local acpi_error_count
acpi_error_count=$(journalctl -b -p err --no-pager 2>/dev/null | grep -ic 'acpi' || true)
if [[ ${acpi_error_count:-0} -ge 5 ]]; then
add_finding "Frequent ACPI errors detected in current boot (${acpi_error_count}); BIOS/firmware update may improve stability."
fi
local top_snapshot
top_snapshot=$(ps -eo pid,comm,%cpu,%mem --sort=-%cpu | head -n 12 || true)
{
echo
echo "=== Top CPU Processes ==="
echo "$top_snapshot"
} >>"$REPORT_FILE"
local xorg_cpu
xorg_cpu=$(ps -C Xorg -o %cpu= | awk '{sum+=$1} END {printf "%.0f", sum+0}' || echo 0)
if [[ ${xorg_cpu:-0} -ge 20 ]]; then
add_finding "Xorg is using high CPU (${xorg_cpu}%); desktop/compositor/GPU driver path may be a primary slowdown source."
fi
}
check_cpu_governor() {
# An ARRAY, not a newline-joined string: the awk/grep calls below need one
# argument per file. With the old unquoted "$gov_files" an empty result
# also left awk with NO file operands, so it fell through to reading stdin
# and the diagnostic hung instead of reporting 'not found'.
local -a gov_files=()
mapfile -t gov_files < <(find /sys/devices/system/cpu -maxdepth 3 -name scaling_governor 2>/dev/null || true)
if ((${#gov_files[@]} == 0)); then
add_action "CPU governor files not found (may be unsupported on this platform)."
return
fi
local summary
summary=$(awk '{count[$1]++} END {for (g in count) printf "%s:%d ", g, count[g]}' "${gov_files[@]}" 2>/dev/null || true)
echo "CPU governor summary: ${summary:-unknown}" >>"$REPORT_FILE"
if grep -q '^powersave$' "${gov_files[@]}" 2>/dev/null; then
add_finding "CPU governor includes 'powersave' on one or more cores; this can make high-end hardware feel slow."
fi
}
check_thermal_state() {
if has_cmd sensors; then
run_and_log "Temperatures (sensors)" sensors
else
add_action "Install lm_sensors and run 'sensors' to verify thermal throttling."
fi
if has_cmd dmesg; then
local therm_hits
therm_hits=$(dmesg | grep -Ei 'throttl|thermal|overheat|cpu clock throttled' | tail -n 30 || true)
if [[ -n $therm_hits ]]; then
add_finding "Kernel logs show thermal/throttling related messages."
{
echo
echo "=== Thermal/Throttling dmesg excerpts ==="
echo "$therm_hits"
} >>"$REPORT_FILE"
fi
fi
}
check_power_services() {
local tlp_enabled="false"
local ppd_enabled="false"
if systemctl is-enabled tlp.service >/dev/null 2>&1; then
tlp_enabled="true"
fi
if systemctl is-enabled power-profiles-daemon.service >/dev/null 2>&1; then
ppd_enabled="true"
fi
echo "Power services: tlp=${tlp_enabled}, power-profiles-daemon=${ppd_enabled}" >>"$REPORT_FILE"
if [[ $tlp_enabled == "true" && $ppd_enabled == "true" ]]; then
add_finding "Both TLP and power-profiles-daemon are enabled; they often conflict and cause inconsistent performance."
fi
if [[ $tlp_enabled == "false" && $ppd_enabled == "false" ]]; then
add_action "No power management daemon is enabled; consider installing/enabling power-profiles-daemon for predictable AC/battery behavior."
fi
}
check_storage_health() {
run_and_log "Block Devices" lsblk -o NAME,MODEL,ROTA,SIZE,TYPE,MOUNTPOINT,FSTYPE
if has_cmd fstrim; then
run_and_log "fstrim dry-run" fstrim -av --dry-run
fi
if systemctl is-enabled fstrim.timer >/dev/null 2>&1; then
add_action "fstrim.timer is enabled (good for SSD performance longevity)."
else
add_finding "fstrim.timer is not enabled; SSD maintenance trimming may be missing."
fi
if has_cmd smartctl; then
local root_disk
root_disk=$(findmnt -n -o SOURCE / | sed 's/[0-9]*$//' | sed 's/p$//' || true)
if [[ -n ${root_disk:-} && -b $root_disk ]]; then
run_and_log "SMART Summary ($root_disk)" smartctl -H "$root_disk"
fi
else
add_action "Install smartmontools and run SMART health checks for your SSD/NVMe."
fi
}
check_memory_pressure() {
local mem_total mem_available swap_total swap_free
mem_total=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
mem_available=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
swap_total=$(awk '/SwapTotal/ {print $2}' /proc/meminfo)
swap_free=$(awk '/SwapFree/ {print $2}' /proc/meminfo)
if [[ ${swap_total:-0} -gt 0 ]]; then
local swap_used
swap_used=$((swap_total - swap_free))
local swap_pct
swap_pct=$((swap_used * 100 / swap_total))
echo "Swap usage: ${swap_pct}%" >>"$REPORT_FILE"
if [[ $swap_pct -ge 35 && ${mem_available:-0} -gt $((mem_total / 3)) ]]; then
add_finding "High swap usage while RAM is still available; this can cause stutter."
add_action "Consider lowering swappiness (temporary: sudo sysctl vm.swappiness=10)."
fi
fi
if [[ -f /proc/pressure/memory ]]; then
run_and_log "Memory PSI" cat /proc/pressure/memory
fi
}
check_gpu_state() {
if has_cmd nvidia-smi; then
run_and_log "NVIDIA State" nvidia-smi
local pstate util power
pstate=$(nvidia-smi --query-gpu=pstate --format=csv,noheader 2>/dev/null | head -n 1 | xargs || true)
util=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true)
power=$(nvidia-smi --query-gpu=power.draw --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true)
{
echo "NVIDIA pstate: ${pstate:-unknown}"
echo "NVIDIA util: ${util:-unknown}%"
echo "NVIDIA power: ${power:-unknown}W"
} >>"$REPORT_FILE"
if [[ ${pstate:-} == "P0" && ${util:-100} -le 5 ]]; then
add_finding "NVIDIA GPU is in P0 high-performance state while mostly idle; this can increase heat and trigger thermal limits."
add_action "If laptop has hybrid graphics, prefer iGPU mode for desktop workloads and use dGPU on demand."
fi
else
run_and_log "PCI VGA Devices" lspci -nnk | grep -A3 -Ei 'vga|3d|display'
fi
}
check_journal_size() {
local journal_line
journal_line=$(journalctl --disk-usage 2>/dev/null || true)
echo "Journal usage: ${journal_line:-unknown}" >>"$REPORT_FILE"
if [[ $journal_line =~ ([0-9]+\.?[0-9]*)\ (G|M) ]]; then
local value unit
value="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
if [[ $unit == "G" ]]; then
add_finding "Systemd journal is large (${value}G); excessive logs can waste I/O and disk space."
fi
fi
}
apply_safe_fixes() {
if [[ $APPLY_SAFE_FIXES != "true" ]]; then
return
fi
log_info "Applying safe fixes..."
if ! systemctl is-enabled fstrim.timer >/dev/null 2>&1; then
systemctl enable --now fstrim.timer
add_action "Enabled and started fstrim.timer."
fi
if systemctl is-enabled tlp.service >/dev/null 2>&1 && systemctl is-enabled power-profiles-daemon.service >/dev/null 2>&1; then
systemctl disable --now tlp.service
add_action "Disabled tlp.service to avoid conflict with power-profiles-daemon."
fi
local journal_line
journal_line=$(journalctl --disk-usage 2>/dev/null || true)
if [[ $journal_line =~ ([0-9]+\.?[0-9]*)\ G ]]; then
journalctl --vacuum-size=300M
add_action "Vacuumed systemd journal to 300M."
fi
}
print_summary() {
echo
echo "=============================="
echo " Arch Performance Diagnostics"
echo "=============================="
echo "Report: $REPORT_FILE"
echo
if [[ ${#FINDINGS[@]} -eq 0 ]]; then
log_ok "No high-confidence bottlenecks detected by automated checks."
else
log_warn "Likely issues found (${#FINDINGS[@]}):"
local item
for item in "${FINDINGS[@]}"; do
echo " - $item"
done
fi
if [[ ${#ACTIONS[@]} -gt 0 ]]; then
echo
log_info "Actions/recommendations:"
local action
for action in "${ACTIONS[@]}"; do
echo " - $action"
done
fi
echo
echo "Recommended next command for deep per-process analysis:"
echo " sudo iotop -oPa"
echo " top"
echo " systemd-analyze blame"
}
# shellcheck source=lib/arch_perf_probes.sh
source "$SCRIPT_DIR/lib/arch_perf_probes.sh"
# shellcheck source=lib/arch_perf_report.sh
source "$SCRIPT_DIR/lib/arch_perf_report.sh"
main() {
parse_args "$@"

View File

@ -0,0 +1,166 @@
#!/bin/bash
# Individual diagnostic probes for the Arch performance report.
#
# Sourced by diagnose_arch_performance.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
collect_basics() {
run_and_log "Kernel" uname -a
run_and_log "Uptime" uptime
run_and_log "Memory" free -h
run_and_log "Swap" swapon --show
run_and_log "CPU (lscpu)" lscpu
run_and_log "Disk Usage" df -h /
run_and_log "Boot Time" systemd-analyze
run_and_log "Failed Units" systemctl --failed --no-pager
run_and_log "Recent Errors (this boot)" journalctl -b -p err --no-pager -n 200
local cpu_count
cpu_count=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1)
local load1
load1=$(awk '{print int($1)}' /proc/loadavg 2>/dev/null || echo 0)
if [[ ${load1:-0} -ge ${cpu_count:-1} ]]; then
add_finding "1-minute load average is at/above CPU thread count (${load1}/${cpu_count}); background tasks may be saturating the system."
fi
local failed_count
failed_count=$(systemctl --failed --no-legend 2>/dev/null | wc -l || true)
failed_count=${failed_count//[[:space:]]/}
if [[ ${failed_count:-0} -gt 0 ]]; then
add_finding "One or more systemd units are failed (${failed_count}); failed services can cause repeated retries and instability."
fi
local acpi_error_count
acpi_error_count=$(journalctl -b -p err --no-pager 2>/dev/null | grep -ic 'acpi' || true)
if [[ ${acpi_error_count:-0} -ge 5 ]]; then
add_finding "Frequent ACPI errors detected in current boot (${acpi_error_count}); BIOS/firmware update may improve stability."
fi
local top_snapshot
top_snapshot=$(ps -eo pid,comm,%cpu,%mem --sort=-%cpu | head -n 12 || true)
{
echo
echo "=== Top CPU Processes ==="
echo "$top_snapshot"
} >>"$REPORT_FILE"
local xorg_cpu
xorg_cpu=$(ps -C Xorg -o %cpu= | awk '{sum+=$1} END {printf "%.0f", sum+0}' || echo 0)
if [[ ${xorg_cpu:-0} -ge 20 ]]; then
add_finding "Xorg is using high CPU (${xorg_cpu}%); desktop/compositor/GPU driver path may be a primary slowdown source."
fi
}
check_cpu_governor() {
# An ARRAY, not a newline-joined string: the awk/grep calls below need one
# argument per file. With the old unquoted "$gov_files" an empty result
# also left awk with NO file operands, so it fell through to reading stdin
# and the diagnostic hung instead of reporting 'not found'.
local -a gov_files=()
mapfile -t gov_files < <(find /sys/devices/system/cpu -maxdepth 3 -name scaling_governor 2>/dev/null || true)
if ((${#gov_files[@]} == 0)); then
add_action "CPU governor files not found (may be unsupported on this platform)."
return
fi
local summary
summary=$(awk '{count[$1]++} END {for (g in count) printf "%s:%d ", g, count[g]}' "${gov_files[@]}" 2>/dev/null || true)
echo "CPU governor summary: ${summary:-unknown}" >>"$REPORT_FILE"
if grep -q '^powersave$' "${gov_files[@]}" 2>/dev/null; then
add_finding "CPU governor includes 'powersave' on one or more cores; this can make high-end hardware feel slow."
fi
}
check_thermal_state() {
if has_cmd sensors; then
run_and_log "Temperatures (sensors)" sensors
else
add_action "Install lm_sensors and run 'sensors' to verify thermal throttling."
fi
if has_cmd dmesg; then
local therm_hits
therm_hits=$(dmesg | grep -Ei 'throttl|thermal|overheat|cpu clock throttled' | tail -n 30 || true)
if [[ -n $therm_hits ]]; then
add_finding "Kernel logs show thermal/throttling related messages."
{
echo
echo "=== Thermal/Throttling dmesg excerpts ==="
echo "$therm_hits"
} >>"$REPORT_FILE"
fi
fi
}
check_power_services() {
local tlp_enabled="false"
local ppd_enabled="false"
if systemctl is-enabled tlp.service >/dev/null 2>&1; then
tlp_enabled="true"
fi
if systemctl is-enabled power-profiles-daemon.service >/dev/null 2>&1; then
ppd_enabled="true"
fi
echo "Power services: tlp=${tlp_enabled}, power-profiles-daemon=${ppd_enabled}" >>"$REPORT_FILE"
if [[ $tlp_enabled == "true" && $ppd_enabled == "true" ]]; then
add_finding "Both TLP and power-profiles-daemon are enabled; they often conflict and cause inconsistent performance."
fi
if [[ $tlp_enabled == "false" && $ppd_enabled == "false" ]]; then
add_action "No power management daemon is enabled; consider installing/enabling power-profiles-daemon for predictable AC/battery behavior."
fi
}
check_storage_health() {
run_and_log "Block Devices" lsblk -o NAME,MODEL,ROTA,SIZE,TYPE,MOUNTPOINT,FSTYPE
if has_cmd fstrim; then
run_and_log "fstrim dry-run" fstrim -av --dry-run
fi
if systemctl is-enabled fstrim.timer >/dev/null 2>&1; then
add_action "fstrim.timer is enabled (good for SSD performance longevity)."
else
add_finding "fstrim.timer is not enabled; SSD maintenance trimming may be missing."
fi
if has_cmd smartctl; then
local root_disk
root_disk=$(findmnt -n -o SOURCE / | sed 's/[0-9]*$//' | sed 's/p$//' || true)
if [[ -n ${root_disk:-} && -b $root_disk ]]; then
run_and_log "SMART Summary ($root_disk)" smartctl -H "$root_disk"
fi
else
add_action "Install smartmontools and run SMART health checks for your SSD/NVMe."
fi
}
check_memory_pressure() {
local mem_total mem_available swap_total swap_free
mem_total=$(awk '/MemTotal/ {print $2}' /proc/meminfo)
mem_available=$(awk '/MemAvailable/ {print $2}' /proc/meminfo)
swap_total=$(awk '/SwapTotal/ {print $2}' /proc/meminfo)
swap_free=$(awk '/SwapFree/ {print $2}' /proc/meminfo)
if [[ ${swap_total:-0} -gt 0 ]]; then
local swap_used
swap_used=$((swap_total - swap_free))
local swap_pct
swap_pct=$((swap_used * 100 / swap_total))
echo "Swap usage: ${swap_pct}%" >>"$REPORT_FILE"
if [[ $swap_pct -ge 35 && ${mem_available:-0} -gt $((mem_total / 3)) ]]; then
add_finding "High swap usage while RAM is still available; this can cause stutter."
add_action "Consider lowering swappiness (temporary: sudo sysctl vm.swappiness=10)."
fi
fi
if [[ -f /proc/pressure/memory ]]; then
run_and_log "Memory PSI" cat /proc/pressure/memory
fi
}

View File

@ -0,0 +1,103 @@
#!/bin/bash
# GPU and journal probes, the safe-fix pass and the report summary.
#
# Sourced by diagnose_arch_performance.sh; split out to keep
# arch_perf_probes.sh under the 250-line cap. Sourced rather than run, so
# it inherits the caller's strict mode and the variables above the source.
check_gpu_state() {
if has_cmd nvidia-smi; then
run_and_log "NVIDIA State" nvidia-smi
local pstate util power
pstate=$(nvidia-smi --query-gpu=pstate --format=csv,noheader 2>/dev/null | head -n 1 | xargs || true)
util=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true)
power=$(nvidia-smi --query-gpu=power.draw --format=csv,noheader,nounits 2>/dev/null | head -n 1 | xargs || true)
{
echo "NVIDIA pstate: ${pstate:-unknown}"
echo "NVIDIA util: ${util:-unknown}%"
echo "NVIDIA power: ${power:-unknown}W"
} >>"$REPORT_FILE"
if [[ ${pstate:-} == "P0" && ${util:-100} -le 5 ]]; then
add_finding "NVIDIA GPU is in P0 high-performance state while mostly idle; this can increase heat and trigger thermal limits."
add_action "If laptop has hybrid graphics, prefer iGPU mode for desktop workloads and use dGPU on demand."
fi
else
run_and_log "PCI VGA Devices" lspci -nnk | grep -A3 -Ei 'vga|3d|display'
fi
}
check_journal_size() {
local journal_line
journal_line=$(journalctl --disk-usage 2>/dev/null || true)
echo "Journal usage: ${journal_line:-unknown}" >>"$REPORT_FILE"
if [[ $journal_line =~ ([0-9]+\.?[0-9]*)\ (G|M) ]]; then
local value unit
value="${BASH_REMATCH[1]}"
unit="${BASH_REMATCH[2]}"
if [[ $unit == "G" ]]; then
add_finding "Systemd journal is large (${value}G); excessive logs can waste I/O and disk space."
fi
fi
}
apply_safe_fixes() {
if [[ $APPLY_SAFE_FIXES != "true" ]]; then
return
fi
log_info "Applying safe fixes..."
if ! systemctl is-enabled fstrim.timer >/dev/null 2>&1; then
systemctl enable --now fstrim.timer
add_action "Enabled and started fstrim.timer."
fi
if systemctl is-enabled tlp.service >/dev/null 2>&1 && systemctl is-enabled power-profiles-daemon.service >/dev/null 2>&1; then
systemctl disable --now tlp.service
add_action "Disabled tlp.service to avoid conflict with power-profiles-daemon."
fi
local journal_line
journal_line=$(journalctl --disk-usage 2>/dev/null || true)
if [[ $journal_line =~ ([0-9]+\.?[0-9]*)\ G ]]; then
journalctl --vacuum-size=300M
add_action "Vacuumed systemd journal to 300M."
fi
}
print_summary() {
echo
echo "=============================="
echo " Arch Performance Diagnostics"
echo "=============================="
echo "Report: $REPORT_FILE"
echo
if [[ ${#FINDINGS[@]} -eq 0 ]]; then
log_ok "No high-confidence bottlenecks detected by automated checks."
else
log_warn "Likely issues found (${#FINDINGS[@]}):"
local item
for item in "${FINDINGS[@]}"; do
echo " - $item"
done
fi
if [[ ${#ACTIONS[@]} -gt 0 ]]; then
echo
log_info "Actions/recommendations:"
local action
for action in "${ACTIONS[@]}"; do
echo " - $action"
done
fi
echo
echo "Recommended next command for deep per-process analysis:"
echo " sudo iotop -oPa"
echo " top"
echo " systemd-analyze blame"
}

View File

@ -0,0 +1,215 @@
#!/bin/bash
# Dependency checks and the per-repo study-material steps.
#
# Sourced by repo_to_study.sh; split out to keep it under the 250-line cap.
# Sourced rather than run, so it inherits the caller's strict mode and
# the helper functions and variables defined above the source line.
#==============================================================================
# Check Dependencies
#==============================================================================
check_dependencies() {
local missing=()
# Check for required scripts
if [ ! -x "$ANALYZE_SCRIPT" ]; then
missing+=("analyze_repo.sh not found at $ANALYZE_SCRIPT")
fi
if [ ! -x "$STUDY_SCRIPT" ]; then
missing+=("generate_study_materials.sh not found at $STUDY_SCRIPT")
fi
# Check for basic tools
for cmd in git curl grep sed awk; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
print_error "Missing dependencies:"
for dep in "${missing[@]}"; do
echo " - $dep"
done
exit 1
fi
}
#==============================================================================
# Ensure Offline Docs are Available
#==============================================================================
ensure_offline_docs() {
local docs_dir="$HOME/.local/share/offline-docs"
if [ ! -d "$docs_dir/python" ]; then
print_info "Offline docs not found. Setting up Python documentation..."
if [ -x "$SETUP_DOCS_SCRIPT" ]; then
"$SETUP_DOCS_SCRIPT" --python
else
print_info "Run setup_offline_docs.sh --all to enable offline documentation"
fi
fi
}
# Global to store repo name for cloned repos
REPO_NAME=""
#==============================================================================
# Get Repository
#==============================================================================
get_repo() {
local input="$1"
local repo_dir=""
# Check if it's a URL (git clone needed)
if [[ $input =~ ^https?:// ]] || [[ $input =~ ^git@ ]]; then
print_step "Cloning repository..."
# Extract repo name from URL
REPO_NAME=$(basename "$input" .git)
repo_dir="$WORK_DIR/$REPO_NAME"
mkdir -p "$WORK_DIR"
if git clone --depth 1 "$input" "$repo_dir" >&2 2>&1; then
print_success "Cloned: $input"
else
print_error "Failed to clone repository"
exit 1
fi
echo "$repo_dir"
# Local path
elif [ -d "$input" ]; then
# Convert to absolute path
repo_dir="$(cd "$input" && pwd)"
REPO_NAME=$(basename "$repo_dir")
print_success "Using local repository: $repo_dir"
echo "$repo_dir"
else
print_error "Invalid input: '$input' is not a valid URL or directory"
exit 1
fi
}
#==============================================================================
# Analyze Repository
#==============================================================================
analyze_repo() {
local repo_path="$1"
local repo_name="$REPO_NAME"
[ -z "$repo_name" ] && repo_name=$(basename "$repo_path")
print_step "Analyzing repository..."
# Run the analyzer (it outputs to stderr/stdout, results go to /tmp/repo_analysis/)
"$ANALYZE_SCRIPT" "$repo_path" >&2 || true
# Find the results directory
local results_dir="/tmp/repo_analysis/results_${repo_name}"
if [ ! -d "$results_dir" ]; then
# Try without prefix
results_dir="/tmp/repo_analysis/results"
fi
if [ ! -d "$results_dir" ] || [ ! -d "$results_dir/per_language" ]; then
print_error "Could not find analysis results at $results_dir"
exit 1
fi
print_success "Analysis complete: $results_dir"
echo "$results_dir"
}
#==============================================================================
# Generate Study Materials
#==============================================================================
generate_materials() {
local analysis_dir="$1"
local output_dir="$2"
print_step "Generating study materials with offline documentation..."
# Run study materials generator
cd "$analysis_dir" || { print_error "Cannot enter $analysis_dir"; return 1; }
if "$STUDY_SCRIPT" . 2>/dev/null | grep -E "^(Created|✓|Files created)" | head -5; then
print_success "Study materials generated"
else
# Try anyway, might have succeeded
true
fi
# Create output directory and copy results
mkdir -p "$output_dir"
# Copy generated files
[ -f "documentation_links.md" ] && cp "documentation_links.md" "$output_dir/"
[ -f "anki_cards.txt" ] && cp "anki_cards.txt" "$output_dir/"
[ -f "llm_anki_prompt.md" ] && cp "llm_anki_prompt.md" "$output_dir/"
# Copy analysis data
mkdir -p "$output_dir/analysis"
[ -d "per_language" ] && cp -r "per_language" "$output_dir/analysis/"
[ -f "grep_imports.txt" ] && cp "grep_imports.txt" "$output_dir/analysis/"
[ -f "grep_keywords.txt" ] && cp "grep_keywords.txt" "$output_dir/analysis/"
[ -f "grep_function_calls.txt" ] && cp "grep_function_calls.txt" "$output_dir/analysis/"
print_success "Files saved to: $output_dir"
}
#==============================================================================
# Show Summary
#==============================================================================
show_summary() {
local output_dir="$1"
print_header "Study Materials Ready!"
echo -e "${BOLD}Output directory:${NC} $output_dir"
echo ""
echo -e "${BOLD}Generated files:${NC}"
if [ -f "$output_dir/documentation_links.md" ]; then
local doc_lines
doc_lines=$(wc -l <"$output_dir/documentation_links.md")
echo -e " 📚 ${GREEN}documentation_links.md${NC} ($doc_lines lines)"
echo " Contains links to OFFLINE documentation"
fi
if [ -f "$output_dir/anki_cards.txt" ]; then
local card_count
card_count=$(grep -c $'^\w' "$output_dir/anki_cards.txt" 2>/dev/null || echo "0")
echo -e " 🎴 ${GREEN}anki_cards.txt${NC} (~$card_count cards)"
echo " Import to Anki: File → Import → Tab separated"
fi
if [ -f "$output_dir/llm_anki_prompt.md" ]; then
echo -e " 🤖 ${GREEN}llm_anki_prompt.md${NC}"
echo " Use with ChatGPT/Claude to generate more cards"
fi
if [ -d "$output_dir/analysis" ]; then
echo -e " 📊 ${GREEN}analysis/${NC}"
echo " Raw analysis data (imports, keywords, functions per language)"
fi
echo ""
echo -e "${BOLD}Quick preview of imports with offline docs:${NC}"
if [ -f "$output_dir/documentation_links.md" ]; then
grep -A20 "import/from" "$output_dir/documentation_links.md" 2>/dev/null |
grep "^\| \`" | head -5 |
sed 's/|/│/g'
fi
echo ""
echo -e "${BOLD}Next steps:${NC}"
echo " 1. Open documentation_links.md to browse offline docs"
echo " 2. Import anki_cards.txt into Anki for spaced repetition"
echo " 3. Use llm_anki_prompt.md to generate more targeted cards"
echo ""
echo -e "${CYAN}To view a doc:${NC} xdg-open 'file:///path/from/documentation_links.md'"
}
#==============================================================================
# Main
#==============================================================================

View File

@ -103,214 +103,9 @@ EOF
exit 0
}
#==============================================================================
# Check Dependencies
#==============================================================================
check_dependencies() {
local missing=()
# shellcheck source=lib/repo_study_steps.sh
source "$SCRIPT_DIR/lib/repo_study_steps.sh"
# Check for required scripts
if [ ! -x "$ANALYZE_SCRIPT" ]; then
missing+=("analyze_repo.sh not found at $ANALYZE_SCRIPT")
fi
if [ ! -x "$STUDY_SCRIPT" ]; then
missing+=("generate_study_materials.sh not found at $STUDY_SCRIPT")
fi
# Check for basic tools
for cmd in git curl grep sed awk; do
if ! command -v "$cmd" &>/dev/null; then
missing+=("$cmd")
fi
done
if [ ${#missing[@]} -gt 0 ]; then
print_error "Missing dependencies:"
for dep in "${missing[@]}"; do
echo " - $dep"
done
exit 1
fi
}
#==============================================================================
# Ensure Offline Docs are Available
#==============================================================================
ensure_offline_docs() {
local docs_dir="$HOME/.local/share/offline-docs"
if [ ! -d "$docs_dir/python" ]; then
print_info "Offline docs not found. Setting up Python documentation..."
if [ -x "$SETUP_DOCS_SCRIPT" ]; then
"$SETUP_DOCS_SCRIPT" --python
else
print_info "Run setup_offline_docs.sh --all to enable offline documentation"
fi
fi
}
# Global to store repo name for cloned repos
REPO_NAME=""
#==============================================================================
# Get Repository
#==============================================================================
get_repo() {
local input="$1"
local repo_dir=""
# Check if it's a URL (git clone needed)
if [[ $input =~ ^https?:// ]] || [[ $input =~ ^git@ ]]; then
print_step "Cloning repository..."
# Extract repo name from URL
REPO_NAME=$(basename "$input" .git)
repo_dir="$WORK_DIR/$REPO_NAME"
mkdir -p "$WORK_DIR"
if git clone --depth 1 "$input" "$repo_dir" >&2 2>&1; then
print_success "Cloned: $input"
else
print_error "Failed to clone repository"
exit 1
fi
echo "$repo_dir"
# Local path
elif [ -d "$input" ]; then
# Convert to absolute path
repo_dir="$(cd "$input" && pwd)"
REPO_NAME=$(basename "$repo_dir")
print_success "Using local repository: $repo_dir"
echo "$repo_dir"
else
print_error "Invalid input: '$input' is not a valid URL or directory"
exit 1
fi
}
#==============================================================================
# Analyze Repository
#==============================================================================
analyze_repo() {
local repo_path="$1"
local repo_name="$REPO_NAME"
[ -z "$repo_name" ] && repo_name=$(basename "$repo_path")
print_step "Analyzing repository..."
# Run the analyzer (it outputs to stderr/stdout, results go to /tmp/repo_analysis/)
"$ANALYZE_SCRIPT" "$repo_path" >&2 || true
# Find the results directory
local results_dir="/tmp/repo_analysis/results_${repo_name}"
if [ ! -d "$results_dir" ]; then
# Try without prefix
results_dir="/tmp/repo_analysis/results"
fi
if [ ! -d "$results_dir" ] || [ ! -d "$results_dir/per_language" ]; then
print_error "Could not find analysis results at $results_dir"
exit 1
fi
print_success "Analysis complete: $results_dir"
echo "$results_dir"
}
#==============================================================================
# Generate Study Materials
#==============================================================================
generate_materials() {
local analysis_dir="$1"
local output_dir="$2"
print_step "Generating study materials with offline documentation..."
# Run study materials generator
cd "$analysis_dir"
if "$STUDY_SCRIPT" . 2>/dev/null | grep -E "^(Created|✓|Files created)" | head -5; then
print_success "Study materials generated"
else
# Try anyway, might have succeeded
true
fi
# Create output directory and copy results
mkdir -p "$output_dir"
# Copy generated files
[ -f "documentation_links.md" ] && cp "documentation_links.md" "$output_dir/"
[ -f "anki_cards.txt" ] && cp "anki_cards.txt" "$output_dir/"
[ -f "llm_anki_prompt.md" ] && cp "llm_anki_prompt.md" "$output_dir/"
# Copy analysis data
mkdir -p "$output_dir/analysis"
[ -d "per_language" ] && cp -r "per_language" "$output_dir/analysis/"
[ -f "grep_imports.txt" ] && cp "grep_imports.txt" "$output_dir/analysis/"
[ -f "grep_keywords.txt" ] && cp "grep_keywords.txt" "$output_dir/analysis/"
[ -f "grep_function_calls.txt" ] && cp "grep_function_calls.txt" "$output_dir/analysis/"
print_success "Files saved to: $output_dir"
}
#==============================================================================
# Show Summary
#==============================================================================
show_summary() {
local output_dir="$1"
print_header "Study Materials Ready!"
echo -e "${BOLD}Output directory:${NC} $output_dir"
echo ""
echo -e "${BOLD}Generated files:${NC}"
if [ -f "$output_dir/documentation_links.md" ]; then
local doc_lines
doc_lines=$(wc -l <"$output_dir/documentation_links.md")
echo -e " 📚 ${GREEN}documentation_links.md${NC} ($doc_lines lines)"
echo " Contains links to OFFLINE documentation"
fi
if [ -f "$output_dir/anki_cards.txt" ]; then
local card_count
card_count=$(grep -c $'^\w' "$output_dir/anki_cards.txt" 2>/dev/null || echo "0")
echo -e " 🎴 ${GREEN}anki_cards.txt${NC} (~$card_count cards)"
echo " Import to Anki: File → Import → Tab separated"
fi
if [ -f "$output_dir/llm_anki_prompt.md" ]; then
echo -e " 🤖 ${GREEN}llm_anki_prompt.md${NC}"
echo " Use with ChatGPT/Claude to generate more cards"
fi
if [ -d "$output_dir/analysis" ]; then
echo -e " 📊 ${GREEN}analysis/${NC}"
echo " Raw analysis data (imports, keywords, functions per language)"
fi
echo ""
echo -e "${BOLD}Quick preview of imports with offline docs:${NC}"
if [ -f "$output_dir/documentation_links.md" ]; then
grep -A20 "import/from" "$output_dir/documentation_links.md" 2>/dev/null |
grep "^\| \`" | head -5 |
sed 's/|/│/g'
fi
echo ""
echo -e "${BOLD}Next steps:${NC}"
echo " 1. Open documentation_links.md to browse offline docs"
echo " 2. Import anki_cards.txt into Anki for spaced repetition"
echo " 3. Use llm_anki_prompt.md to generate more targeted cards"
echo ""
echo -e "${CYAN}To view a doc:${NC} xdg-open 'file:///path/from/documentation_links.md'"
}
#==============================================================================
# Main
#==============================================================================
main() {
# Handle help
if [ $# -lt 1 ] || [ "$1" = "-h" ] || [ "$1" = "--help" ]; then