summaryrefslogtreecommitdiff
path: root/link/pi.sh
blob: 727ed2fb50b01a2deb57f033bb15a78e17b2a1f4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#!/bin/bash

# pi's extensions directory, and its settings.json, models.json and mcp.json,
# generated.
# Dry run by default; pass `yes` to apply.

set -euo pipefail

# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib.sh
source "$(dirname "$0")/lib.sh" "$@"

# ~/.pi/agent/settings.json is a real file built from config/pi/settings.json
# plus every ~/.config/pi/settings.d/*.json that another layer links in. It
# cannot be a symlink into a repo: pi has no second global settings file to
# layer with, and it writes to this one at runtime (lastChangelogVersion,
# /model, `pi install`), which used to dirty the tracked file.
#
# Merging the sources: list keys (packages, extensions, skills, prompts,
# themes) are concatenated in file order, a package deduplicated by its source
# (an object entry beats a bare string); any other key takes the last file's
# value. Relative paths in a fragment's `extensions`, `skills`, `prompts` and
# `themes` resolve against the fragment's real location, so a layer can point
# at files in its own checkout.
#
# Merging with what is already there is three-way, against what this script
# generated last time (kept in ~/.local/state/dotfiles/pi-settings.json): a key
# -- or a list entry, a package by its source -- that pi or the operator has not
# changed since then takes the new generated value, removal included; one they
# changed keeps their value. With no record of a last run, what is there is kept
# and what is missing is added. So a /model choice or a `pi install` survives
# relinks, and a package dropped from a repo really goes. Keys nothing generates
# (lastChangelogVersion) are simply kept.
#
# Written under pi's own settings lock, via a temporary file and a rename; a run
# that finds the lock held skips and says so, and the next link catches up.
#
# Run after other layers' link.sh, so their fragments are in place.
pi_dir="$HOME/.pi/agent"
if [[ ! -d "$pi_dir" ]]
then
  exit 0
fi

# pi's extensions: a real directory, with this repo's tracked extensions linked
# in one by one, so an extension only one machine has -- herdr's pi integration
# writes herdr-agent-state.ts here -- sits beside them instead of landing in this
# checkout. pi loads every *.ts and *.js there and follows links, and it skips a
# dangling one silently (resource-loader.js, pi 1.0.0), so the link left behind
# by an extension deleted from the repo does no harm.
#
# Machines used to link the whole directory to the repo. That link goes first:
# linking into it would write through to the repo and turn its files into links
# to themselves. Tracked files only, so nothing a machine left in the repo's
# directory is linked back in.
pi_extensions="$pi_dir/extensions"
if [[ -L "$pi_extensions" ]]
then
  if [[ -n "${is_dry_run}" ]]
  then
    yellow "$pi_extensions is a link to the repo; will become a directory of links to its tracked files"
  else
    rm "$pi_extensions"
  fi
fi
if [[ ! -L "$pi_extensions" ]]
then
  [[ -n "${is_dry_run}" ]] || mkdir -p "$pi_extensions"
  while IFS= read -r -d '' f
  do
    link "$dotfiles_dir/$f" "$pi_extensions/$(basename "$f")"
  done < <(git -C "$dotfiles_dir" ls-files -z -- config/pi/extensions)
fi

pi_status="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-settings.py" \
  "$config_dir/pi/settings.json" \
  "$xdg_config_dir/pi/settings.d" \
  "$pi_dir/settings.json" \
  "${XDG_STATE_HOME:-$HOME/.local/state}/dotfiles/pi-settings.json" \
  "$config_dir")"

case "${pi_status%%:*}" in
  wrote)       green "pi settings: ${pi_status#*:}" ;;
  unchanged)   green "pi settings: unchanged" ;;
  would-write) yellow "pi settings will be generated: ${pi_status#*:}" ;;
  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 "$dotfiles_dir/link/pi-merge.py" \
  providers \
  "$config_dir/pi/models.json" \
  "$xdg_config_dir/pi/models.d" \
  "$pi_dir/models.json")"

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"

# pi's mcp.json, the same way: pi reads user-level MCP servers from this one
# file only, and the servers are another layer's or one machine's business --
# a browser on one host, a server behind a credential -- so this repo ships
# none. It is built from every ~/.config/pi/mcp.d/*.json alone, and left
# absent where there are none. It used to be a private layer's link to one
# file for every host, which gave each of them a server only one could run.
# `pi mcp add` writes here and does not survive a relink; add a fragment.
pi_mcp_out="$(DRY_RUN="$is_dry_run" python3 "$dotfiles_dir/link/pi-merge.py" \
  mcpServers \
  - \
  "$xdg_config_dir/pi/mcp.d" \
  "$pi_dir/mcp.json")"

while IFS= read -r line
do
  case "${line%%:*}" in
    note)        yellow "pi mcp: ${line#*:}" ;;
    wrote)       green "pi mcp: ${line#*:}" ;;
    unchanged)   green "pi mcp: unchanged" ;;
    would-write) yellow "pi mcp will be generated: ${line#*:}" ;;
    error)       red "pi mcp: ${line#*:}" ;;
  esac
done <<< "$pi_mcp_out"