Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Modern dotfiles repository managed with [Chezmoi](https://chezmoi.io/), featurin
- **Automated Validation**: Pre-commit hooks and validation scripts ensure configuration quality
- **Windows Enterprise Detection**: Automatic detection of Entra ID (Azure AD) and Intune enrollment status
- **Task Automation**: Integrated [Task](https://taskfile.dev/) runner for common operations (validation, testing, installation)
- **Global Tasks**: Shell-independent `task -g llm:chat` with automatic MLX setup on Apple Silicon; see [global tasks](docs/development-tools.md#global-tasks).
- **Tool Version Management**: [mise](https://mise.jdx.dev/) for managing development tool versions

## 🔧 Chezmoi Variables
Expand Down
119 changes: 119 additions & 0 deletions docs/development-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,125 @@ task dev:setup # Complete dev environment setup
task ci:validate # Run CI validation pipeline
```

## Global Tasks

Chezmoi deploys `home/Taskfile.yml` to `~/Taskfile.yml`, separate from the
repository's development `Taskfile.yml`. With Task on your PATH, `task -g`
selects the home Taskfile from **any directory and shell**, including Fish,
Bash, Zsh, and PowerShell. Ordinary repository `task` commands stay unchanged.

```sh
task -g --list
task -g llm:chat
```

### Local MLX chat (Apple Silicon)

`llm:chat` uses Apple's `mlx-lm` Python API directly, not an inference frontend.
It requires native Apple Silicon macOS; other platforms get an explicit error
only if they invoke this task. A Task dependency checks prerequisites before
launch, installing only what is missing.

The default setup uses:

- `~/mlx-qwen/.venv/bin/python` with `mlx-lm` 0.32.2 or newer (developed against 0.32.2).
- The `mlx-community/Qwen3.5-9B-4bit` model in
`/Volumes/External Disk Data/AI/huggingface/hub`.
- `/Volumes/External Disk Data/AI/huggingface/xet` on the same external SSD.

**Setup order:** native Apple Silicon and the mounted SSD are checked first.
Cache directories are created on that SSD and checked for writability and
symlink redirection. A `status` check then verifies the venv and imports the
required MLX APIs without loading weights. A working installation skips
provisioning entirely: no Python replacement, pip upgrade, or package upgrade.

If the venv is missing, Task creates it with native Python 3.10+ and `venv`.
If suitable Python is missing, it uses the same Homebrew `python@3` formula
as the repository's macOS package setup. Missing pip is bootstrapped with
`ensurepip`; missing requirements are installed with isolated
`python -m pip install 'mlx-lm>=0.32.2'`, without `--upgrade`. Pip and Homebrew
download caches, plus setup temporary files, stay on the SSD; the venv itself
remains at the configured `MLX_VENV` path. No uv or inference frontend is involved.
An existing broken/non-native venv is **not deleted**: repair it or move it
aside, then rerun. Installation failures and a failed post-install import
check stop the task before model launch; corrupt installed packages may
require manual repair rather than a forced reinstall.

The launcher at `~/.config/mlx/chat.py` sets `HF_HOME`, `HF_HUB_CACHE`, and
`HF_XET_CACHE` to the external cache, overriding inherited Hugging Face
settings. It first resolves the existing cached model without network access.
If absent, it downloads the model there (approximately 6 GB); existing cached
weights are reused, not copied or updated. There is no internal-disk fallback.
Connect the SSD first and run from a terminal permitted to access it (for
example Terminal.app); this does not bypass macOS volume permissions.

**Bootstrap boundary:** Task cannot install itself before the `task` command
exists. Full macOS dotfiles setup already includes `go-task` and Python. If
Task alone is missing, use the existing Homebrew setup and `brew install go-task`,
or your configured mise installation with `mise use --global task@latest`.
If neither suitable Python nor Homebrew is available, the task reports that
Homebrew must be installed through the existing dotfiles setup; it does not run
a new privileged bootstrap script.

Thinking is disabled via `enable_thinking=False` in the model's chat template,
which supplies an empty closed `<think>` block. The launcher uses temperature
0.7, top-p 0.8, top-k 20, at most 512 reply tokens, and 512-token prefill chunks.
It does not use the unsupported `mlx_lm.chat` flags or a `/nothink` soft switch.

Type `q` to quit, `r` to reset, or `h` for help. Ctrl+C (including during
generation) and EOF exit cleanly. Each reply reports token speed and peak MLX
memory; conversation state stays in memory, with no saved transcripts.
The prompt cache is reused across turns, including the correct end-of-turn
boundary after a truncated reply.

**Memory bound:** Qwen3.5's hybrid cache ignores `max_kv_size`. Instead, every
turn must fit within **8,192 total tokens**, counting cached history, the new
prompt and its delimiters, and 512 reserved reply tokens. Oversized turns are
rejected before generation: reset or shorten the input. History is never
silently discarded. This bounds context growth, not total process RAM;
the approximately 6 GB of weights and other applications still need memory.

### Selective deployment

Once these changes are present in your primary dotfiles checkout, run these
commands **from its repository root**. Only the global Taskfile and launcher
are selected; Chezmoi scripts are explicitly excluded, and neither the venv
nor model caches are managed by Git or Chezmoi.

Create the launcher's parent directory first. When selecting individual files,
Chezmoi 2.73.0 requires that parent to exist; a fresh home otherwise fails with
`stat .../.config/mlx: no such file or directory`. Use `--verbose` to display
the proposed changes during the dry-run:

```sh
mkdir -p "$HOME/.config/mlx" &&
chezmoi --source=. apply --exclude=scripts --dry-run --verbose "$HOME/Taskfile.yml" "$HOME/.config/mlx/chat.py"
```

Review that preview first, especially if you already maintain a home Taskfile.
Then apply and launch only if deployment succeeds:

```sh
chezmoi --source=. apply --exclude=scripts "$HOME/Taskfile.yml" "$HOME/.config/mlx/chat.py" &&
task -g llm:chat
```

There is no need to activate the venv, set shell-specific environment variables,
or remove an earlier standalone launcher. For an intentionally relocated
existing setup, Task variables can be overridden explicitly:

```sh
task -g llm:chat MLX_VENV="/path/to/.venv" MLX_VOLUME="/Volumes/Another SSD"
```

Focused validation uses fake MLX/Hugging Face modules, a mock venv, and an
isolated Chezmoi destination. It covers installed/missing/broken environments
and setup ordering without real provisioning, downloads, or inference:

```sh
timeout 60 ./tests/bash/run-tests.sh --test global-mlx-chat.bats
```

## Mise (Tool Version Manager)

Mise manages tool versions defined in
Expand Down
1 change: 0 additions & 1 deletion home/.chezmoiignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ LICENSE
tests/
install.sh
install.ps1
Taskfile.yml
.mise.toml
CONTRIBUTING.md
STRUCTURE.md
Expand Down
100 changes: 100 additions & 0 deletions home/Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
version: '3'

vars:
MLX_VENV: '{{.ROOT_DIR}}/mlx-qwen/.venv'
MLX_VOLUME: /Volumes/External Disk Data
MLX_CACHE_DIR: '{{.MLX_VOLUME}}/AI/huggingface'
MLX_PYTHON_CHECK: 'import platform, sys, venv; assert sys.version_info >= (3, 10) and platform.machine() == "arm64"'
MLX_VENV_CHECK: '{{.MLX_PYTHON_CHECK}}; assert sys.prefix != sys.base_prefix'
MLX_CHECK_ENV: >-
"{{.MLX_VENV}}/bin/python" "{{.ROOT_DIR}}/.config/mlx/chat.py"
--cache-dir "{{.MLX_CACHE_DIR}}" --volume "{{.MLX_VOLUME}}" --check-env

tasks:
llm:chat:
desc: Set up and chat with local Qwen3.5 (Apple Silicon, thinking disabled)
interactive: true
silent: true
deps: [llm:setup]
cmds:
- >-
"{{.MLX_VENV}}/bin/python" "{{.ROOT_DIR}}/.config/mlx/chat.py"
--cache-dir "{{.MLX_CACHE_DIR}}" --volume "{{.MLX_VOLUME}}"

llm:storage:
internal: true
silent: true
preconditions:
- sh: test "$(uname -s)" = Darwin && test "$(uname -m)" = arm64
msg: 'llm:chat requires native Apple Silicon macOS (not Rosetta).'
- sh: mount | grep -F -- " on {{.MLX_VOLUME}} (" >/dev/null
msg: 'Connect the SSD at {{.MLX_VOLUME}} before setup. No internal cache fallback is used.'
- sh: |
case "{{.MLX_CACHE_DIR}}" in
*"/../"*|*"/.."|*"/./"*|*"/.") exit 1 ;;
"{{.MLX_VOLUME}}"/*) exit 0 ;;
*) exit 1 ;;
esac
msg: 'MLX_CACHE_DIR must be a directory beneath MLX_VOLUME, without dot segments.'
- sh: test -f "{{.ROOT_DIR}}/.config/mlx/chat.py"
msg: 'Missing ~/.config/mlx/chat.py. Apply the global Taskfile and launcher together.'
cmds:
- mkdir -p "{{.MLX_CACHE_DIR}}/hub" "{{.MLX_CACHE_DIR}}/xet" "{{.MLX_CACHE_DIR}}/pip" "{{.MLX_CACHE_DIR}}/tmp"

llm:setup:
internal: true
deps: [llm:storage]
preconditions:
- sh: |
for path in "{{.MLX_CACHE_DIR}}" "{{.MLX_CACHE_DIR}}/hub" "{{.MLX_CACHE_DIR}}/xet" "{{.MLX_CACHE_DIR}}/pip" "{{.MLX_CACHE_DIR}}/tmp"; do
test "$(cd "$path" && pwd -P)" = "$path" && test -w "$path" || exit 1
done
msg: 'External cache directories must be writable and not redirected through symlinks. Check SSD permissions.'
status:
- '{{.MLX_CHECK_ENV}}'
cmds:
- task: llm:venv
- |
export TMPDIR="{{.MLX_CACHE_DIR}}/tmp"
"{{.MLX_VENV}}/bin/python" -m pip --version ||
"{{.MLX_VENV}}/bin/python" -m ensurepip
- >-
env TMPDIR="{{.MLX_CACHE_DIR}}/tmp"
"{{.MLX_VENV}}/bin/python" -m pip --isolated --require-virtualenv
--disable-pip-version-check --no-input --cache-dir "{{.MLX_CACHE_DIR}}/pip"
install 'mlx-lm>=0.32.2'
- '{{.MLX_CHECK_ENV}}'

llm:venv:
internal: true
preconditions:
- sh: >-
(test ! -e "{{.MLX_VENV}}" && test ! -L "{{.MLX_VENV}}") ||
"{{.MLX_VENV}}/bin/python" -c '{{.MLX_VENV_CHECK}}'
msg: 'Existing {{.MLX_VENV}} is not a usable Python 3.10+ arm64 venv. Repair it or move it aside; it will not be deleted automatically.'
status:
- '"{{.MLX_VENV}}/bin/python" -c ''{{.MLX_VENV_CHECK}}'''
cmds:
- task: llm:python
- |
export TMPDIR="{{.MLX_CACHE_DIR}}/tmp"
if python3 -c '{{.MLX_PYTHON_CHECK}}' 2>/dev/null; then
python3 -m venv "{{.MLX_VENV}}"
else
"$(brew --prefix python@3)/libexec/bin/python3" -m venv "{{.MLX_VENV}}"
fi

llm:python:
internal: true
preconditions:
- sh: python3 -c '{{.MLX_PYTHON_CHECK}}' 2>/dev/null || command -v brew
msg: 'A native Python 3.10+ or Homebrew is required. Install Homebrew using the existing dotfiles setup; this task cannot safely bootstrap Homebrew.'
status:
- |
python3 -c '{{.MLX_PYTHON_CHECK}}' 2>/dev/null ||
(command -v brew >/dev/null && "$(brew --prefix python@3)/libexec/bin/python3" -c '{{.MLX_PYTHON_CHECK}}')
cmds:
- >-
brew list --versions python@3 ||
env HOMEBREW_CACHE="{{.MLX_CACHE_DIR}}/homebrew" TMPDIR="{{.MLX_CACHE_DIR}}/tmp"
brew install python@3
Loading
Loading