From 57e49ddde1dffb17043cd4b065c2907fbe5a863d Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Fri, 25 Sep 2026 11:30:38 +0300 Subject: pi: providers can come from ~/.config/pi/models.d, like settings already do models.json was a plain symlink to the tracked file, which left a machine whose models are served from somewhere other than a vendor's own API with nowhere to say so. pi reads providers from exactly one path -- join(getAgentDir(), "models.json") -- and a `providers` block in a settings fragment is inert, because the settings schema has no such key. So on such a machine pi cannot be used at all, and the endpoint is not something this repo may name. link/pi.sh now generates models.json the way it already generates settings.json: the repo's file, then every ~/.config/pi/models.d/*.json. No three-way merge here -- pi never writes this file, keeping runtime state in the sibling models-store.json -- so a hand-edit of ~/.pi/agent/models.json no longer survives a relink. Put it in a fragment. It reports which provider came from which fragment, added or replaced, so a local file shadowing a provider the repo ships cannot do it quietly. Proposed as a change request from the machine that needs it; verified here against pi 0.84.2 before applying. --- CLAUDE.md | 2 ++ link.sh | 5 ++-- link/pi.sh | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d0c4a7..4cb78b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ what it needs through these, each of them optional and absent by default: | `~/.config/claude/mcp-servers.d/*.json` | MCP servers, registered by `link/claude.sh` | | `~/.claude/rules/*.md` | extra Claude Code user memory, loaded next to `config/claude/CLAUDE.md` | | `~/.config/pi/settings.d/*.json` | pi settings merged into the generated `~/.pi/agent/settings.json` by `link/pi.sh` | +| `~/.config/pi/models.d/*.json` | pi providers merged into the generated `~/.pi/agent/models.json` by `link/pi.sh` | | `~/.config/agent-skills.d/*` | agent skills, linked into every installed agent by `link/skills.sh` | | `~/.local/share/szecret/{store,id.pub}` | the szecret store; the tool is here, its secrets are not | | `~/.config/shell/dir-hashes.zsh` | more zsh named directories, sourced by `config/zshrc` | @@ -135,6 +136,7 @@ tracked config says so, and in no repo: | `~/.local/share/alacritty/local.toml` | Alacritty | imported by `config/alacritty/alacritty.toml` | | `~/.config/dot/skip` | which `link/` parts this machine does not want, one name per line | `link/lib.sh`, as each part starts | | `~/.config/dot/inbox-key` | the key this machine signs change requests with | `bin/dot-request` | +| `~/.config/pi/models.d/*.json` | pi providers only this machine has -- a model served somewhere other than a vendor's own API | `link/pi.sh`, merged over `config/pi/models.json` | Not covered today: nvim, tmux, bat, lsd, and `~/.claude/settings.json` -- which a machine can only leave alone entirely, by naming `claude` in the skip file. diff --git a/link.sh b/link.sh index 53bbf85..4f5bbff 100755 --- a/link.sh +++ b/link.sh @@ -36,9 +36,8 @@ link_all "$config_dir/bat" "$xdg_config_dir/bat" link "$config_dir/aider.conf.yml" "$HOME/.aider.conf.yml" link "$config_dir/pi/extensions" "$HOME/.pi/agent/extensions" -link "$config_dir/pi/models.json" "$HOME/.pi/agent/models.json" -# settings.json is generated by link/pi.sh; AGENTS.md and mcp.json, if any, -# come from another layer. +# settings.json and models.json are both generated by link/pi.sh; AGENTS.md and +# mcp.json, if any, come from another layer. # Utils bin="$HOME/.bin" diff --git a/link/pi.sh b/link/pi.sh index 2f0a05c..22ecd96 100755 --- a/link/pi.sh +++ b/link/pi.sh @@ -229,3 +229,91 @@ case "${pi_status%%:*}" in locked) yellow "pi settings: pi holds its settings lock; left as is, the next link catches up" ;; error) red "pi settings: ${pi_status#*:}" ;; esac + +# pi's models.json, generated too. It used to be a plain symlink to the tracked +# file, which left a machine whose models come from somewhere other than a +# vendor's own API with nowhere to say so: pi reads providers from exactly one +# path (join(getAgentDir(), "models.json")), and a `providers` block in a +# settings fragment is inert, since the settings schema has no such key. +# +# So the same shape as the settings above: the repo's file, then every +# ~/.config/pi/models.d/*.json another layer or this machine puts there. Unlike +# the settings this is a plain generated file with no three-way merge -- pi +# never writes it, keeping its runtime state in the sibling models-store.json -- +# so what lands is whatever the repo and the fragments say. A hand-edit of +# ~/.pi/agent/models.json does not survive a relink; put it in a fragment. +pi_models_out="$(DRY_RUN="$is_dry_run" python3 - \ + "$config_dir/pi/models.json" \ + "$xdg_config_dir/pi/models.d" \ + "$pi_dir/models.json" <<'PY' +import glob, json, os, sys + +base_path, frag_dir, target = sys.argv[1:4] + + +def load(path): + with open(path) as f: + return json.load(f) + + +# The repo's providers, then each fragment's. A fragment may add a provider or +# replace one of the same name; any other top-level key takes the last file's +# value. Which provider came from where is reported: a fragment quietly +# shadowing a provider the repo ships would otherwise be invisible. +desired = {} +notes = [] +for path in [base_path] + sorted(glob.glob(os.path.join(frag_dir, "*.json"))): + try: + layer = load(path) + except (OSError, ValueError) as e: + print("error:cannot read %s: %s" % (path, e)) + sys.exit(0) + for key, value in layer.items(): + if key == "providers": + providers = desired.setdefault("providers", {}) + if path != base_path: + for name in value: + notes.append("%s %s by %s" % ( + name, + "replaced" if name in providers else "added", + os.path.basename(path), + )) + providers.update(value) + else: + desired[key] = value + +# A symlink is the pre-generation state: replace it, even with no fragments, so +# the live file is never the tracked one. +was_link = os.path.islink(target) +try: + current = None if was_link else load(target) +except (OSError, ValueError): + current = None + +for note in notes: + print("note:" + note) + +if current == desired: + print("unchanged:") +elif os.environ.get("DRY_RUN"): + print("would-write:" + ("replacing the link" if was_link else "merged changes")) +else: + tmp = target + ".dotfiles-tmp" + with open(tmp, "w") as f: + json.dump(desired, f, indent=2) + f.write("\n") + os.replace(tmp, target) + print("wrote:" + ("replaced the link" if was_link else "merged changes")) +PY +)" + +while IFS= read -r line +do + case "${line%%:*}" in + note) yellow "pi models: ${line#*:}" ;; + wrote) green "pi models: ${line#*:}" ;; + unchanged) green "pi models: unchanged" ;; + would-write) yellow "pi models will be generated: ${line#*:}" ;; + error) red "pi models: ${line#*:}" ;; + esac +done <<< "$pi_models_out" -- cgit v1.2.3