Compare commits

..
Author SHA1 Message Date
noconnorandClaude Opus 5 154dc18eb9 claude-obsidian: don't count skipped empty sessions as still queued
The first full drain reported "105 written, 0 failed, 9 still queued" when
the 9 were empty sessions already recorded as skipped -- nothing was left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-14 01:19:56 -04:00
noconnorandClaude Opus 5 9fb0e8bcae claude-obsidian: unattended overnight runs -- lock, URL fallback, config fix
Needed to leave a full backlog drain running unattended:

- Run lock (flock): enabling the timer fired an immediate catch-up run
  (Persistent=true) while a manual run was about to start; without a
  lock the two would race on state.json and usage.jsonl.
- OBSIDIAN_API_URL accepts a comma-separated list, first reachable wins,
  so the permanent address and an interim port-forward share one config.
- Fix: inline "# comments" in config.env were parsed into values, so
  BUDGET_USD_PER_RUN=0.50  # ... crashed float(). The shipped
  config.env.example had exactly that shape on several lines -- anyone
  copying it would have hit the crash. A '#' inside a value (URL fragment)
  still survives. Tested against both the example and a real config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-14 01:03:22 -04:00
noconnorandClaude Opus 5 dd228c1ff9 claude-obsidian: meter and cap usage; cut per-session cost ~5.6x
The user asked for usage to be monitored closely, and measuring it turned
up that most of the spend was avoidable.

Metering: every model call is appended to usage.jsonl with BILLED tokens
(modelUsage -- the visible `usage` turn undercounted by half) and the
CLI's list-price cost. `usage` reports 5h/24h/7d. Each call's cost is
estimated first and refused if it would cross the per-run or rolling 24h
budget, so budgets cannot be overshot. Removed --max-budget-usd: it
aborts after spending and discards the result ($0.26 lost in one call).

Cost, measured on one session ($0.057 -> $0.010, same summary content):
- alwaysThinkingEnabled=false: extended thinking was ~85% of output.
- DISABLE_PROMPT_CACHING=1: one-shot prompts were being written to the
  1h cache at 2x input price.
- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1: a hidden side request
  re-read the whole prompt, doubling billed input (9,638 vs 4,663).

Summaries are cached until their note is written, so a vault failure
never re-pays; --prefetch summarizes while the vault is unreachable.
Offline tests (fake claude binary): budget refusal pre-flight, cache
reuse with zero calls, map-reduce, env/argv levers, billed-token ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-14 00:56:38 -04:00
noconnorandClaude Opus 5 6df2acfe24 claude-obsidian: summarize Claude conversations into Obsidian notes
One note per Claude Code session (incremental, hourly user timer) and a
claude.ai data-export importer, written through the Obsidian Local REST API
plugin -- whose built-in /mcp/ server is also how Claude reads them back.

Transcripts are condensed (tool output dropped, one line per tool call,
~60x smaller), redacted, then summarized by headless `claude -p` on the
user's own plan. Three things learned the hard way are encoded here:

- --no-session-persistence, or every summary becomes a transcript that
  the next run summarizes.
- A LiteLLM gateway passing through the caller's Claude subscription has
  no key of its own, so a background job cannot use its claude-* models.
- Summaries share the plan's usage limits. A parallel ~100-session
  backfill exhausted a session limit in minutes. So one job, a per-run
  cap, and the run stops at the first usage-limit error (exit 75) instead
  of failing the whole queue; a stop flag, because Future.cancel() cannot
  stop work a thread already picked up. Covered by an offline test of
  stop / resume / idempotent rerun.

Nothing environment-specific is committed: hosts, keys and paths live in
~/.config/claude-obsidian/ (this repo is public).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-13 08:51:28 -04:00
22 changed files with 191 additions and 1080 deletions
+4 -4
View File
@@ -44,9 +44,9 @@ Every module's `install.sh` satisfies all of these. Copy the shape from
## Wiring it up
**`bootstrap.sh`** — add the call. Order is load-bearing and each existing
out-of-order entry carries a comment explaining why (`secrets/` after `bin/`
because it needs `op`; `docker-rootless-cgroup/` last because it needs the
rootless daemon already up). If your module has an ordering
out-of-order entry carries a comment explaining why (`docker/` before `bin/`
so kind's gate sees the daemon; `docker-rootless-cgroup/` last because it
needs the rootless daemon already up). If your module has an ordering
constraint, add it *with* a comment; if it doesn't, append it near related
modules.
@@ -61,7 +61,7 @@ Never commit the value itself.
## Non-systemd hosts
Kasm has no systemd. Anything that would `systemctl enable` must degrade
gracefully — see `docker-rootless-cgroup/install.sh`, which detects the situation and no-ops
gracefully — see `docker/install.sh`, which detects the situation and no-ops
rather than failing the whole bootstrap run.
## Before you're done
+1 -1
View File
@@ -14,7 +14,7 @@ Everything in this repo is version-pinned on purpose. Nothing floats.
| CLI tools | `bin/install.sh` | `ver=v1.2.3` inside each `install_<name>()` |
| Node.js | `node/install.sh` | `NODE_VERSION="v24.18.0"` |
| MCP servers | `mcp-config/opencode.gateway.json` | `["npx", "-y", "pkg@0.0.10"]` |
| oh-my-zsh | `zsh/` | installed from upstream script — not pinned |
| oh-my-zsh, Docker | `zsh/`, `docker/` | installed from upstream scripts — not pinned |
Grep before assuming: `grep -rnE 'ver=|_VERSION=|@[0-9]+\.[0-9]+' --include='*.sh' --include='*.json'`
+2 -2
View File
@@ -63,8 +63,8 @@ faithful rehearsal.
Kasm has no systemd and no rootful Docker. Anything that would `systemctl` must
no-op rather than abort — you can't fully rehearse this in a container, so read
the code path and confirm the guard exists (`docker-rootless-cgroup/install.sh`
is the reference).
the code path and confirm the guard exists (`docker/install.sh` and
`docker-rootless-cgroup/install.sh` are the references).
## Full run
-2
View File
@@ -1,2 +0,0 @@
__pycache__/
*.pyc
+13 -61
View File
@@ -29,51 +29,29 @@ Already have a clone? Just run it directly:
(robbyrussell theme, git plugin), and sets zsh as the login shell. The
managed rc sources `~/.zshrc.local` last for per-machine customizations
(see below).
- `bash/` — for hosts that keep bash as the login shell (Omarchy), copies a
bash port of the `zsh/zshrc` helpers (`opr`/`opc`/`opd`/`opl`/`ops`,
`grmb`, and `secrets.env` loading) to `~/.config/dotfiles/dotfiles.bash` and
adds one marked source line to `~/.bashrc`. Never owns `~/.bashrc`; no-ops
when there is none. No sudo.
- `git/` — sets git `user.name`/`user.email` and configures
`credential.helper store` (plaintext `~/.git-credentials`, 0600) for
HTTPS auth.
- `docker/` — installs full (rootful) Docker Engine from Docker's upstream
package repo (get.docker.com on Debian-family; the docker-ce repo wired up
directly on RHEL-family, since the convenience script rejects EL rebuilds
like Oracle Linux by name), enables the service (systemd hosts), and adds
the invoking user to the
`docker` group (takes effect at next login). No-ops where a rootless
Docker daemon is already running (e.g. Kasm) so it never fights that
setup. Uses sudo.
- `bin/` — installs userland devops CLIs as self-contained, statically
linked release binaries into `~/.local/bin`: jq, kubectl, kubelogin (the
`kubectl oidc-login` plugin, for OIDC auth to Omni clusters), helm, k9s,
linked release binaries into `~/.local/bin`: jq, kubectl, helm, k9s,
kubectx/kubens, stern, yq, OpenTofu, terragrunt, task (go-task, the
Taskfile runner), the 1Password CLI `op` for secrets storage, the
opencode coding agent, zarf (DevSecOps for Offline and Airgapped
Kubernetes), packer (HashiCorp's infrastructure build tool), and the
coder CLI for Coder workspaces.
Kubernetes), and packer (HashiCorp's infrastructure build tool).
kind is installed only when full (non-rootless) Docker is available —
it doesn't work against rootless Docker, so it is skipped on Kasm.
Dotfiles does not install Docker itself; install it on the host (or bake
it into the image) where you want it.
No sudo, nothing outside `$HOME`, so tools persist across Kasm
redeploys with no reinstall. Idempotent: each tool records its version
under `~/.local/bin/.versions` and is skipped when the pinned version
is already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`.
A tool is also skipped when another copy comes *earlier* on `PATH` than
`~/.local/bin` (e.g. mise or pacman on Omarchy, where `~/.local/bin` is last),
since a copy there would never run. `node/` and `ansible/` follow the same
rule. On boxes where zshrc puts `~/.local/bin` first, nothing changes.
- `secrets/` — reads your 1Password vault once and writes every field to
`~/.config/dotfiles/secrets.env` (0600), which the managed `~/.zshrc`
sources, so opening a shell never calls `op`. One vault per user and one
item in it, both named after your login by default; each field's label is
the variable name (`GITEA_KEY`, `LITELLM_KEY`, `PROXMOX_TOKEN_SECRET`, …).
The first run prompts for the service-account token and checks it with
1Password straight away, re-prompting on a bad paste. It then picks the vault
and item: the only one the token can see, or a prompt that lists them. It keeps
all three in `~/.config/dotfiles/op.env` (0600, not loaded into shells). If
that fails, bootstrap skips mcp-config/ and hermes/ with an error rather
than asking for the LiteLLM key by hand.
Runs after `bin/` (needs `op` and `jq`); bootstrap loads the result so later
modules get the keys without prompting. Day to day: `opr NAME` reads a
field, `opc NAME [VALUE]` creates or updates one (prompts, hidden, without
VALUE), `opd NAME` deletes one; both of those refresh the file and this
shell. `opl [VAULT [ITEM]]` shows every field with its current value, and
`ops` refreshes after a change made elsewhere. No sudo.
opencode is configured by `mcp-config/`: it points at the covert-gpt
LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do
@@ -87,18 +65,6 @@ Already have a clone? Just run it directly:
`~/.local/bin`. Persists across redeploys; only rebuilt when the venv is
missing or broken. Building it may install `python3`/`python3-venv` via
the system package manager (the one place this module uses sudo).
- `vscode/` — installs Visual Studio Code from upstream's portable tarball into
`~/.local/opt/vscode`, symlinks `code` into `~/.local/bin`, and writes a
user-level `.desktop` entry so it shows up in the application menu. **No
sudo, nothing outside `$HOME`** — the same contract as `bin/`, so it survives
a Kasm redeploy. Pinned via `VSCODE_VERSION` in the module; bump it to upgrade.
Also sets `password-store: basic` in `~/.vscode/argv.json` so KDE's kwallet
does not prompt for an unlock on every launch (see below). **No-ops on
headless hosts** — it's a ~400MB GUI app — override with `DOTFILES_VSCODE`.
**Defers to a system VS Code** (any `code` on `PATH` outside `~/.local/bin`,
e.g. Arch's `visual-studio-code-bin`): it skips the install and removes a
tarball copy it put there earlier, since two builds would show two menu
entries and share one `~/.config/Code` profile.
- `docker-rootless-cgroup/` — on systemd-less hosts (e.g. Kasm), hand-delegates
cgroup v2 controllers to the rootless Docker daemon so per-container cgroups
work (rootless Docker otherwise comes up with
@@ -120,8 +86,7 @@ rc sources last (so it can override anything) and bootstrap never touches:
cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
```
Variables modules look for. With `secrets/` set up, put these in your vault
item instead of `~/.zshrc.local`:
Variables modules look for:
- `GITEA_KEY` — personal access token for `git.nic-oconnor.com`. Used for
API/CLI work, and by `bootstrap.sh`/`mcp-config/` to read this repo when it
@@ -132,21 +97,8 @@ item instead of `~/.zshrc.local`:
don't set this by hand: run `mcp-config/install.sh` once and its shell
hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local`
only on a box without mcp-sync.
- `DOTFILES_VSCODE` — controls the `vscode/` module. Unset (default) installs
VS Code only when a graphical session is detected; `1` forces the install on
a headless host (e.g. for `code tunnel`); `0` skips it everywhere. A plain
shell/env var, not a vault secret.
On the keyring: VS Code's secret storage reaches for the OS keyring through
libsecret, which on KDE makes kwalletd prompt for an unlock before the editor
is usable — every session, since `kwallet-pam` is not installed. The module
sets `"password-store": "basic"` in `~/.vscode/argv.json` to stop that, which
means VS Code's stored tokens are obfuscated on disk rather than sealed
behind a wallet password. To use the wallet instead, install `kwallet-pam` so
it unlocks at login, then change that value to `"kwallet5"`.
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token. Not a shell
variable any more: `secrets/install.sh` asks for it once and stores it in
`~/.config/dotfiles/op.env`. Pass it in the env only for an unattended run.
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for
non-interactive `op read` / `op run`.
- `GITEA_KEY`, `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` — **your own**
credentials for the client-side MCP servers in `mcp-config/`. opencode reads
them from your environment (`{env:VAR}`) and passes them to the MCP server it
-9
View File
@@ -15,8 +15,6 @@ REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
# shellcheck source=../lib/os-detect.sh
. "$REPO_ROOT/lib/os-detect.sh"
# shellcheck source=../lib/fetch.sh
. "$REPO_ROOT/lib/fetch.sh"
say() { printf '%s\n' "$*" >&2; }
@@ -24,13 +22,6 @@ VENV="$HOME/.local/opt/ansible"
BIN_DIR="$HOME/.local/bin"
ANSIBLE_CLIS="ansible ansible-playbook ansible-galaxy ansible-vault ansible-config ansible-doc ansible-inventory ansible-console ansible-pull"
# Another ansible (mise, pipx, a distro package) already wins on PATH, so this
# venv would never run -- see `shadowed` in lib/fetch.sh.
if _winner="$(shadowed ansible)"; then
say "ansible: $_winner comes first on PATH; skipping (a copy in $BIN_DIR would never run)."
exit 0
fi
# Verify it actually runs, not just that the file exists: a Kasm redeploy can
# wipe the system python3 the venv links to, leaving a present-but-broken
# venv that must be rebuilt.
-110
View File
@@ -1,110 +0,0 @@
# Managed by dotfiles/bash/install.sh — copied to ~/.config/dotfiles/dotfiles.bash
# and overwritten on the next bootstrap run. Edit bash/dotfiles.bash in the
# dotfiles repo instead.
#
# The bash port of the helpers in zsh/zshrc, for hosts that keep bash as the
# login shell (Omarchy ships its own bash config and the zsh module cannot
# install zsh there). Only the helpers: PATH, prompt and history stay with the
# host's own rc. Keep the two in sync — see zsh/zshrc for the full rationale
# behind each function.
# grmb <branch>... — delete remote branches; takes `origin/foo` or `foo`.
grmb() {
local remote=origin b rc=0
if [ "$#" -eq 0 ]; then
echo "grmb: usage: grmb <branch>... (origin/ prefix optional)" >&2
return 2
fi
git fetch --prune --quiet "$remote" || return 1
for b in "$@"; do
b=${b#"${remote}"/}
if git push "$remote" --delete "$b"; then
echo "deleted ${remote}/${b}"
else
echo "grmb: could not delete ${b} (already gone? check: git ls-remote --heads ${remote})" >&2
rc=1
fi
done
return $rc
}
# Secrets from this user's 1Password vault, written by dotfiles/secrets/install.sh.
# Reading a local 0600 file keeps shell startup off the network.
_DOTFILES_CONF="$HOME/.config/dotfiles"
[ -f "$_DOTFILES_CONF/secrets.env" ] && source "$_DOTFILES_CONF/secrets.env"
# opr NAME | opc NAME[=VALUE] | opc NAME VALUE | opd NAME | opl [VAULT [ITEM]] | ops
# Same behaviour as zsh/zshrc. The service-account token is loaded only inside
# the subshells, never into this shell's environment.
_dotfiles_op() {
[ -f "$_DOTFILES_CONF/op.env" ] || { echo "no $_DOTFILES_CONF/op.env; run: ops" >&2; return 1; }
( set -a; source "$_DOTFILES_CONF/op.env"; set +a; "$@" )
}
_dotfiles_env_name() {
[[ $1 =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] && return 0
echo "$2: '$1' is not a valid variable name" >&2; return 2
}
opr() {
_dotfiles_env_name "$1" opr || return
_dotfiles_op sh -c 'op read "op://$OP_VAULT/$OP_ITEM/$1"' opr "$1"
}
opc() {
local name=$1 value=
if [[ $1 == *=* ]]; then
# NAME=VALUE: split on the first '=' so values may contain '='
name=${1%%=*} value=${1#*=}
elif (( $# >= 2 )); then
value=$2
fi
_dotfiles_env_name "$name" opc || return
if [[ -z $value ]]; then
read -rsp "$name: " value && echo
[[ -n $value ]] || { echo "opc: empty value for $name; nothing changed" >&2; return 1; }
fi
# op's exit code is unreliable after an edit it did save; read the field back.
_dotfiles_op sh -c '
clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; }
ref="op://$OP_VAULT/$OP_ITEM/$1"
err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[concealed]=$2" 2>&1 >/dev/null)
if [ "$(op read "$ref" 2>/dev/null)" = "$2" ]; then
printf "opc: saved %s\n" "$ref" >&2
else
printf "opc: could not save %s\n op said: %s\n check vault, item and service-account write access with: opl\n" \
"$ref" "$(clean "$err")" >&2
exit 1
fi' opc "$name" "$value" && ops
}
opd() {
_dotfiles_env_name "$1" opd || return
_dotfiles_op sh -c '
clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; }
ref="op://$OP_VAULT/$OP_ITEM/$1"
err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[delete]" 2>&1 >/dev/null)
if ! item=$(op item get "$OP_ITEM" --vault "$OP_VAULT" --format json 2>&1); then
printf "opd: could not re-read %s to confirm the delete\n op said: %s\n" "$ref" "$(clean "$item")" >&2
exit 1
elif printf "%s" "$item" | jq -r ".fields[].label" | grep -qxF "$1"; then
printf "opd: could not delete %s\n op said: %s\n" "$ref" "$(clean "$err")" >&2
exit 1
fi
printf "opd: deleted %s\n" "$ref" >&2' opd "$1" \
&& unset "$1" && ops
}
opl() {
_dotfiles_op sh -c '
vault=${1:-$OP_VAULT} item=${2:-$OP_ITEM}
printf "op://%s/%s\n" "$vault" "$item" >&2
op item get "$item" --vault "$vault" --reveal --format json' opl "$@" \
| jq -r '
[.fields[] | select(.purpose != "NOTES")] as $f
| ([$f[] | .label // "" | length] | max // 0) as $w
| $f[]
| ((.label // "") + " " * ($w - ((.label // "") | length))) as $name
| ((.value // "") | split("\n")) as $lines
| "\($name) \($lines[0] // "")",
($lines[1:][] | (" " * ($w + 2)) + .)'
}
ops() {
"${DOTFILES_DIR:-$HOME/.dotfiles}/secrets/install.sh" \
&& source "$_DOTFILES_CONF/secrets.env"
}
-47
View File
@@ -1,47 +0,0 @@
#!/usr/bin/env sh
# bash/install.sh — give bash the dotfiles shell helpers (opr/opc/opd/opl/ops,
# grmb, and secrets.env loading).
#
# WHY: those helpers lived only in the managed ~/.zshrc, so on a host that
# keeps bash as its login shell they silently did not exist. Omarchy is that
# host: it ships its own ~/.bashrc and default rc, and zsh/ cannot install zsh
# there (no dnf/apt). secrets/ still wrote secrets.env, but nothing loaded it,
# so every vault-backed variable was simply unset in new shells.
#
# Unlike zsh/, this never owns ~/.bashrc -- the host's rc stays in charge. It
# copies bash/dotfiles.bash to ~/.config/dotfiles/ and adds one marked source
# line, the same pattern mcp-config/ uses. Skipped when there is no ~/.bashrc
# (a zsh-only box has no use for it). No sudo. Idempotent.
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
say() { printf '%s\n' "$*" >&2; }
BASHRC="$HOME/.bashrc"
CONF_DIR="$HOME/.config/dotfiles"
DEST="$CONF_DIR/dotfiles.bash"
MARK_BEGIN="# >>> dotfiles helpers >>>"
MARK_END="# <<< dotfiles helpers <<<"
if [ ! -f "$BASHRC" ]; then
say "bash: no $BASHRC; skipping."
exit 0
fi
mkdir -p "$CONF_DIR"
chmod 700 "$CONF_DIR"
install -m 0644 "$SCRIPT_DIR/dotfiles.bash" "$DEST"
say "bash: wrote $DEST"
if grep -qxF "$MARK_BEGIN" "$BASHRC"; then
say "bash: $BASHRC already sources it."
else
cat >>"$BASHRC" <<EOF
$MARK_BEGIN
[ -f "\$HOME/.config/dotfiles/dotfiles.bash" ] && . "\$HOME/.config/dotfiles/dotfiles.bash"
$MARK_END
EOF
say "bash: added a source line to $BASHRC (open a new shell to pick it up)."
fi
+16 -48
View File
@@ -49,18 +49,6 @@ install_kubectl() {
mark_version kubectl "$ver"
}
# kubelogin — kubectl OIDC plugin, so omnictl-generated kubeconfigs can
# authenticate (they exec `kubectl oidc-login get-token`). Installed as
# kubectl-oidc_login, the name kubectl's plugin lookup expects.
install_oidc_login() {
ver=v1.36.4
have_version kubectl-oidc_login "$ver" && { fsay "kubectl-oidc_login $ver already installed."; return 0; }
install_zip_bin \
"https://github.com/int128/kubelogin/releases/download/$ver/kubelogin_linux_$DEB.zip" \
kubelogin kubectl-oidc_login
mark_version kubectl-oidc_login "$ver"
}
# helm — Kubernetes package manager (from get.helm.sh).
install_helm() {
ver=v4.2.2
@@ -216,17 +204,6 @@ install_terragrunt() {
mark_version terragrunt "$ver"
}
# coder — CLI for Coder workspaces (coder login, coder ssh, coder config-ssh).
# Release tag is vX but asset drops the v; binary is at the archive root.
install_coder() {
ver=2.36.5
have_version coder "$ver" && { fsay "coder $ver already installed."; return 0; }
install_tarball_bin \
"https://github.com/coder/coder/releases/download/v$ver/coder_${ver}_linux_$DEB.tar.gz" \
coder coder
mark_version coder "$ver"
}
# try <install_fn> — run one tool's installer; on failure, warn and keep going.
#
# WHY: these installs used to be bare calls, so under `set -eu` the FIRST
@@ -238,42 +215,33 @@ install_coder() {
# The subshell re-enables `set -e` inside itself (it is disabled for a command
# being tested by `if`), so a failed download still aborts that tool's function
# before mark_version stamps a version that was never installed.
#
# try <install_fn> <binary> also skips the tool when another <binary> already
# wins on PATH (see `shadowed` in lib/fetch.sh): a copy here would never run.
FAILED=""
try() {
if _winner="$(shadowed "$2")"; then
fsay "$2: $_winner comes first on PATH; skipping (a copy in $BIN_DIR would never run)."
return 0
fi
if ( set -e; "$1" ); then return 0; fi
FAILED="$FAILED ${1#install_}"
fsay "WARNING: ${1#install_} failed to install; continuing."
}
try install_jq jq
try install_kubectl kubectl
try install_oidc_login kubectl-oidc_login
try install_helm helm
try install_k9s k9s
try install_jq
try install_kubectl
try install_helm
try install_k9s
if docker_full_available; then
try install_kind kind
try install_kind
else
fsay "Skipping kind: full (non-rootless) Docker not available."
fi
try install_kubectx kubectx
try install_kubens kubens
try install_stern stern
try install_yq yq
try install_tofu tofu
try install_task task
try install_terragrunt terragrunt
try install_1p op
try install_opencode opencode
try install_zarf zarf
try install_packer packer
try install_coder coder
try install_kubectx
try install_kubens
try install_stern
try install_yq
try install_tofu
try install_task
try install_terragrunt
try install_1p
try install_opencode
try install_zarf
try install_packer
if [ -n "$FAILED" ]; then
fsay ""
+7 -38
View File
@@ -66,7 +66,7 @@ fi
#
# WHY: these used to be bare calls, so under `set -eu` the FIRST failing module
# aborted bootstrap and every later module silently never ran — one unsupported
# distro in one module was enough to leave a box with no CLIs, no npx and no
# distro in docker/ was enough to leave a box with no CLIs, no npx and no
# ansible. The modules are independent, so a failure in one is not a reason to
# skip the rest. (bin/install.sh does the same for individual tools; this is
# that guard one level up.)
@@ -78,19 +78,10 @@ run_module() {
}
run_module zsh
# bash/ is zsh/'s helpers for hosts that keep bash (Omarchy); no-ops without ~/.bashrc.
run_module bash
run_module git
# Runs before bin/ so the kind install gate can see the rootful daemon.
run_module docker
run_module bin
# Needs op and jq from bin/. Runs before everything that consumes a secret, and
# its output is loaded into THIS process so later modules see the vault's
# values: mcp-config/ takes LITELLM_KEY from the env instead of prompting for
# it, and GITEA_KEY is there for anything that reads the repo over the API.
run_module secrets
if [ -f "$HOME/.config/dotfiles/secrets.env" ]; then
# shellcheck source=/dev/null
. "$HOME/.config/dotfiles/secrets.env"
fi
# Provides npx, which opencode needs to spawn the npm-based MCP servers in
# mcp-config/. Without it every MCP server silently fails to start.
run_module node
@@ -103,33 +94,11 @@ run_module node
# be repaired by re-running bootstrap. Losing ~/.zshrc.local (or the hook line
# in it) left opencode with no covert-gpt provider and no way to get it back
# short of curl-ing this module by hand.
#
# Both it and hermes/ are SKIPPED when the vault did not deliver LITELLM_KEY
# (and no box-local copy exists): mcp-config/ would otherwise fall back to a
# "paste your LiteLLM key" prompt, which reads like a LiteLLM problem and hides
# the real one — a bad 1Password token, vault or item, which also means every
# other vault key is missing.
if [ -z "${LITELLM_KEY:-}" ] && [ ! -s "$HOME/.config/mcp-sync/key" ]; then
echo "" >&2
echo "bootstrap: ERROR: no LITELLM_KEY from 1Password — skipping mcp-config and hermes." >&2
case " $FAILED " in
*" secrets "*) echo "bootstrap: the secrets module failed (see its messages above)." >&2 ;;
*) echo "bootstrap: the vault item has no LITELLM_KEY field; add it with: opc LITELLM_KEY" >&2 ;;
esac
echo "bootstrap: fix it with $SCRIPT_DIR/secrets/install.sh, then re-run bootstrap." >&2
echo "" >&2
FAILED="$FAILED mcp-config hermes"
else
run_module mcp-config
# Hermes Agent (Nous Research) is distributed as an npm package with a Python
# dependency. Runs after node/ ensures npx is available.
run_module hermes
fi
run_module mcp-config
# Hermes Agent (Nous Research) is distributed as an npm package with a Python
# dependency. Runs after node/ ensures npx is available.
run_module hermes
run_module ansible
# GUI editor, installed from upstream's tarball into ~/.local/opt (no sudo, same
# contract as bin/). No ordering constraints — it needs nothing from bin/,
# secrets/ or node/ — and no-ops on headless hosts.
run_module vscode
# Runs last: needs rootless Docker already up (started at boot) so it can
# delegate cgroups to it. No-ops where that does not apply.
run_module docker-rootless-cgroup
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env sh
# docker/install.sh — install full (rootful) Docker Engine and let the
# invoking user talk to it without sudo.
#
# What it does:
# 1. Installs Docker Engine from Docker's upstream package repo. Skipped when
# `docker` is already installed. Debian-family hosts go through Docker's
# convenience script (get.docker.com); RHEL-family hosts wire up the
# docker-ce repo directly — see install_rhel() for why.
# 2. Enables and starts the service (systemd hosts only).
# 3. Adds the invoking user to the `docker` group so the daemon socket is
# usable without sudo. Takes effect at next login.
#
# Deliberately no-ops when a rootless Docker daemon is detected (e.g. Kasm
# ships its own rootless setup) — installing rootful Docker there would fight
# the existing runtime. See docker-rootless-cgroup/ for the Kasm-side config.
#
# Idempotent and safe to re-run. Uses sudo.
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
# shellcheck source=../lib/os-detect.sh
. "$REPO_ROOT/lib/os-detect.sh"
say() { printf '%s\n' "$*" >&2; }
DOCKER_PKGS="docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"
# install_rhel — install docker-ce from Docker's EL repo.
#
# WHY NOT get.docker.com: the convenience script allowlists distros by
# /etc/os-release ID and aborts on anything it does not recognize —
# "ERROR: Unsupported distribution 'ol'" on Oracle Linux, and the same for
# other EL rebuilds it has not enumerated. On a match it does nothing more
# than what this function does: add the repo, then install the packages.
# Wiring the repo up directly drops the allowlist, so every EL rebuild
# (Oracle, Rocky, Alma, CentOS, RHEL) installs from one code path.
#
# The repo URL is keyed on $releasever/$basearch, which dnf expands per host,
# so EL9 and EL10 both resolve without a version table here.
install_rhel() {
say "docker: adding Docker CE repo (EL \$releasever/\$basearch)..."
sudo tee /etc/yum.repos.d/docker-ce.repo >/dev/null <<'REPO'
[docker-ce-stable]
name=Docker CE Stable - $basearch
baseurl=https://download.docker.com/linux/centos/$releasever/$basearch/stable
enabled=1
gpgcheck=1
gpgkey=https://download.docker.com/linux/centos/gpg
REPO
# --allowerasing: containerd.io conflicts with the distro's own runc/containerd
# on EL rebuilds that ship them (Oracle Linux does). Let dnf swap them out
# rather than dead-end on the conflict.
if command -v dnf >/dev/null 2>&1; then
# shellcheck disable=SC2086
sudo dnf install -y --allowerasing $DOCKER_PKGS
else
# shellcheck disable=SC2086
sudo yum install -y --allowerasing $DOCKER_PKGS
fi
}
# install_debian — Docker's convenience script; it supports every Debian-family
# distro this repo targets and sets up the apt repo and keyring.
install_debian() {
say "docker: installing Docker Engine via get.docker.com..."
tmp="$(mktemp)"
if command -v curl >/dev/null 2>&1; then
curl -fsSL https://get.docker.com -o "$tmp"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$tmp" https://get.docker.com
else
say "docker: need curl or wget"; rm -f "$tmp"; return 1
fi
sudo sh "$tmp"
rm -f "$tmp"
}
# --- skip on rootless-Docker hosts -------------------------------------------
if command -v docker >/dev/null 2>&1 &&
docker info -f '{{println .SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless'; then
say "docker: rootless Docker daemon detected; leaving it alone."
exit 0
fi
# --- 1. install engine -------------------------------------------------------
if command -v docker >/dev/null 2>&1; then
say "docker: engine already installed ($(docker --version 2>/dev/null || echo 'version unknown'))."
else
case "$(detect_pkg_family)" in
rhel) install_rhel ;;
debian) install_debian ;;
*) say "docker: unsupported/undetected OS family; install Docker manually."; exit 1 ;;
esac
fi
# --- 2. enable + start the service (systemd only) ----------------------------
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
sudo systemctl enable --now docker
else
say "docker: no systemd; not managing the service (start dockerd yourself)."
fi
# --- 3. group membership for the invoking user -------------------------------
user="$(id -un)"
if id -nG "$user" | tr ' ' '\n' | grep -qx docker; then
say "docker: $user already in the docker group."
else
sudo usermod -aG docker "$user"
say "docker: added $user to the docker group."
say " Log out and back in (or run 'newgrp docker') for it to take effect."
fi
+1 -22
View File
@@ -30,25 +30,4 @@ say "fetch.prune = true (stale remote-tracking refs cleaned on every fetch)"
git config --global credential.helper store
say "credential.helper = store (~/.git-credentials, created on first auth)"
# Our own Gitea authenticates from $GITEA_KEY (exported by secrets/ from the
# vault), not from ~/.git-credentials.
#
# WHY: git never reads GITEA_KEY by itself. With only `store`, the token was
# whatever got typed at the first prompt, copied into ~/.git-credentials, and
# it silently went stale when the vault's key was rotated — or, on a box that
# never pushed, there was no credential at all and the push just failed. Now
# `opc GITEA_KEY` is the only step when the token changes.
#
# The empty helper first clears the inherited `store` for this host, so a
# rejected token is never cached and a stale cached one can never win. Gitea
# accepts the token as the password; the username is informational (GITEA_USER
# overrides it). With GITEA_KEY unset the helper prints nothing and git falls
# back to prompting, as before.
GITEA_URL="https://git.nic-oconnor.com"
git config --global --unset-all "credential.$GITEA_URL.helper" 2>/dev/null || true
git config --global --add "credential.$GITEA_URL.helper" ''
git config --global --add "credential.$GITEA_URL.helper" \
'!f() { test "$1" = get && test -n "${GITEA_KEY:-}" && printf "username=%s\npassword=%s\n" "${GITEA_USER:-$(id -un)}" "$GITEA_KEY"; }; f'
say "credential helper for $GITEA_URL = \$GITEA_KEY (from secrets.env)"
say "Done. $GITEA_URL uses \$GITEA_KEY; other HTTPS hosts prompt once and cache in ~/.git-credentials."
say "Done. On the next HTTPS push/pull to a new host, git prompts once and caches the credential in ~/.git-credentials."
+1 -9
View File
@@ -26,12 +26,6 @@ REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
say() { printf '%s\n' "$*" >&2; }
# The installer links the CLI into ~/.local/bin, which is only on PATH in
# shells that sourced the managed ~/.zshrc. bootstrap run by a non-shell
# parent (the Coder agent's dotfiles script) has no such PATH, so the
# post-install `command -v hermes` failed on a successful install.
PATH="$HOME/.local/bin:$PATH"
# Hermes Agent version (synced with PyPI/npm).
HERMES_VERSION="v0.18.2"
@@ -80,9 +74,7 @@ if [ "$KEY_CHANGED" -eq 1 ] && [ -f "$HOME/.config/mcp-sync/key" ]; then
fi
say "hermes: Updated LITELLM_KEY in ~/.hermes/.env"
else
# Create .env if it doesn't exist. On a fresh home this runs BEFORE the
# installer below has created ~/.hermes, so make the directory first.
mkdir -p "$HOME/.hermes"
# Create .env if it doesn't exist
printf 'LITELLM_KEY=%s\n' "$NEW_KEY" >"$HOME/.hermes/.env"
say "hermes: Created ~/.hermes/.env with LITELLM_KEY"
fi
+2 -62
View File
@@ -8,8 +8,8 @@
# reset.
#
# Source this (callers already run `set -eu`), then use fetch_arch /
# fetch_arch_deb, have_version + mark_version, install_tarball_bin,
# install_tarball_tree, and install_raw_bin.
# fetch_arch_deb, have_version + mark_version, install_tarball_bin, and
# install_raw_bin.
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
VERSION_DIR="$BIN_DIR/.versions"
@@ -55,38 +55,6 @@ have_version() {
[ "$(cat "$VERSION_DIR/$1")" = "$2" ]
}
# shadowed <name> — true (printing the winner's path) when another <name> sits
# EARLIER on PATH than $BIN_DIR, so a copy installed there would never run.
#
# WHY: on a desktop that already manages these tools (Omarchy: pacman for VS
# Code and jq, mise for kubectl/helm/node/...), ~/.local/bin is at the END of
# PATH. Installing there anyway left a second, older copy of every tool that
# nothing ran but `type -a` and the version stamps still reported. On the VMs
# this repo targets, zshrc puts ~/.local/bin FIRST, so nothing shadows it and
# this is always false -- installs there behave exactly as before. If $BIN_DIR
# is not on PATH at all (a fresh box, before zshrc), it is false too.
#
# A mise shim with no version configured is not a real provider (it errors when
# run), so shims only count when `mise which` resolves them.
shadowed() {
_sh_name="$1"
_sh_ifs="$IFS"; IFS=:
# shellcheck disable=SC2086 # split PATH on ':' deliberately
set -- $PATH
IFS="$_sh_ifs"
case ":$PATH:" in *":$BIN_DIR:"*) ;; *) return 1 ;; esac
for _sh_dir; do
[ "$_sh_dir" = "$BIN_DIR" ] && return 1
[ -n "$_sh_dir" ] && [ -x "$_sh_dir/$_sh_name" ] && [ ! -d "$_sh_dir/$_sh_name" ] || continue
case "$_sh_dir" in
*/mise/shims) mise which "$_sh_name" >/dev/null 2>&1 || continue ;;
esac
printf '%s\n' "$_sh_dir/$_sh_name"
return 0
done
return 1
}
# mark_version <name> <version> — record the installed version.
mark_version() {
mkdir -p "$VERSION_DIR"
@@ -125,34 +93,6 @@ install_zip_bin() {
fsay "installed $BIN_DIR/$3"
}
# install_tarball_tree <url> <root-within-archive> <dest-dir> — download a
# .tar.gz that holds a whole application directory rather than one binary, and
# swap it into <dest-dir>. install_tarball_bin cannot be reused here: VS Code
# is an Electron bundle of thousands of files that only runs from an intact
# tree, not a single binary that can be picked out of the archive.
#
# The new tree is staged beside the target and moved into place only once the
# download and extraction have both succeeded, so an interrupted run leaves the
# previously installed version intact instead of a half-extracted directory.
install_tarball_tree() {
_dest="$3"
_tmp="$(mktemp -d)"
_fetch_dl "$1" "$_tmp/archive.tar.gz"
tar -xzf "$_tmp/archive.tar.gz" -C "$_tmp"
if [ ! -d "$_tmp/$2" ]; then
fsay "fetch: '$2' not found in archive"; rm -rf "$_tmp"; return 1
fi
mkdir -p "$(dirname "$_dest")"
rm -rf "$_dest.new" "$_dest.old"
mv "$_tmp/$2" "$_dest.new"
if [ -e "$_dest" ]; then
mv "$_dest" "$_dest.old"
fi
mv "$_dest.new" "$_dest"
rm -rf "$_dest.old" "$_tmp"
fsay "installed $_dest"
}
# install_raw_bin <url> <dest-name> — download a single raw binary.
install_raw_bin() {
mkdir -p "$BIN_DIR"
Executable → Regular
+5 -17
View File
@@ -120,23 +120,11 @@ say "Key stored at $KEYFILE (0600)."
# bootstrap run — and then models and skills silently vanish, because sync.sh
# never runs and OPENCODE_CONFIG is never exported. ~/.zshrc.local is the
# unmanaged file the managed zshrc sources last, so the hook persists.
#
# Do NOT pick the file from $SHELL alone. On a fresh box bootstrap runs this
# from the user's ORIGINAL shell: zsh/ has just switched the login shell to zsh,
# but $SHELL in this process still says bash. The hook then went into ~/.bashrc,
# below zsh/'s auto-exec hook, so every new terminal exec'd into zsh before
# reaching it, zsh never sourced sync.sh, and opencode came up unconfigured.
# The dotfiles-managed ~/.zshrc (or zsh as the account's login shell) wins.
_login_shell="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)"
if grep -qs 'Managed by dotfiles/zsh/install.sh' "$HOME/.zshrc"; then
RC="$HOME/.zshrc.local"
else
case "${_login_shell:-${SHELL:-}}" in
*zsh) RC="$HOME/.zshrc.local" ;;
*bash) RC="$HOME/.bashrc" ;;
*) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc.local" || RC="$HOME/.bashrc" ;;
esac
fi
case "${SHELL:-}" in
*zsh) RC="$HOME/.zshrc.local" ;;
*bash) RC="$HOME/.bashrc" ;;
*) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc.local" || RC="$HOME/.bashrc" ;;
esac
touch "$RC"
# It sits alongside the user's secrets; keep it non-readable by others.
[ "$RC" = "$HOME/.zshrc.local" ] && chmod 0600 "$RC" 2>/dev/null || true
+6 -72
View File
@@ -315,50 +315,21 @@ OUTPUT_RESERVE_CAP = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 32768)
CONTEXT_SAFETY_MARGIN = 1024
# NEVER trust /model/info's number TYPES. The litellm-helm chart renders the
# proxy config with Go's toYaml, which writes 0.000005 as `5e-06`; LiteLLM
# parses it with PyYAML (YAML 1.1), which only reads an exponent float if it has
# a dot. So `5e-06` arrives as the STRING "5e-06" while `2.5e-05` is a float.
# `"5e-06" * 1_000_000` is not an error in Python — it is the string repeated a
# million times. That wrote a 20 MB opencode.gateway.json with a string where
# opencode requires a number, opencode refused to start, and every new shell
# redeployed it. Coerce every number through here; anything that is not a
# finite number is dropped, never passed through.
def num(v):
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
f = float(v)
elif isinstance(v, str) and len(v) <= 64:
try:
f = float(v.strip())
except ValueError:
return None
else:
return None
if f != f or f in (float("inf"), float("-inf")):
return None
return int(f) if isinstance(v, int) or f.is_integer() else f
def spec_for(mi):
spec = {}
limit = {}
max_in, max_out = num(mi.get("max_input_tokens")), num(mi.get("max_output_tokens"))
if max_in and max_in > 0:
limit["context"] = int(max(1, max_in - CONTEXT_SAFETY_MARGIN))
if max_out and max_out > 0:
limit["output"] = int(min(max_out, OUTPUT_RESERVE_CAP))
if mi.get("max_input_tokens"):
limit["context"] = max(1, mi["max_input_tokens"] - CONTEXT_SAFETY_MARGIN)
if mi.get("max_output_tokens"):
limit["output"] = min(mi["max_output_tokens"], OUTPUT_RESERVE_CAP)
if limit:
spec["limit"] = limit
# LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens.
cost = {}
for src, dst in (("input_cost_per_token", "input"),
("output_cost_per_token", "output")):
per_token = num(mi.get(src))
if per_token is not None and per_token >= 0:
# round() so 5e-06 * 1e6 is 5.0, not 4.999999999999999.
cost[dst] = round(per_token * 1_000_000, 6)
if mi.get(src) is not None:
cost[dst] = mi[src] * 1_000_000
if cost:
spec["cost"] = cost
for src, dst in (("supports_function_calling", "tool_call"),
@@ -407,47 +378,10 @@ if not models:
except Exception:
pass
# Validate EVERY spec before it is written, whatever its source. The carry-over
# path above re-reads the deployed file, so a bad value that was ever written
# (e.g. the 20 MB repeated-string cost) would otherwise be preserved forever;
# this makes the next sync heal it instead. opencode rejects the WHOLE config
# over one wrongly typed field, so dropping a bad field is always the better
# failure.
def clean_spec(spec):
if not isinstance(spec, dict):
return {}
out = {}
for group, keys, cast in (("limit", ("context", "output"), int),
("cost", ("input", "output", "cache_read", "cache_write"), float)):
vals = {}
for k in keys:
v = num((spec.get(group) or {}).get(k)) if isinstance(spec.get(group), dict) else None
if v is not None and v >= 0:
vals[k] = cast(v)
if vals:
out[group] = vals
for k, v in spec.items():
if k in ("limit", "cost"):
continue
if isinstance(v, (bool, int, float)) or (isinstance(v, str) and len(v) <= 256):
out[k] = v
elif isinstance(v, (dict, list)) and len(json.dumps(v)) <= 4096:
out[k] = v
return out
models = {k: clean_spec(v) for k, v in (models or {}).items()
if isinstance(k, str) and len(k) <= 256}
prov = cfg.get("provider", {}).get(provider)
if prov is not None and models:
prov["models"] = models
# Last line of defence: the shared config is a few KB. Anything wildly larger is
# a bug upstream of here, and deploying it is what breaks opencode — keep the
# last-good file (or none) instead.
if len(json.dumps(cfg)) > 1_000_000:
raise SystemExit(0)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest))
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2)
-7
View File
@@ -29,13 +29,6 @@ PREFIX="$HOME/.local/opt/node"
BIN_DIR="$HOME/.local/bin"
NODE_CLIS="node npm npx"
# Another node (mise, nvm, a distro package) already wins on PATH, so ours
# would never run -- see `shadowed` in lib/fetch.sh. npx comes with it.
if _winner="$(shadowed node)"; then
say "node: $_winner comes first on PATH; skipping (a copy in $BIN_DIR would never run)."
exit 0
fi
case "$(fetch_arch)" in
x86_64) NODE_ARCH=x64 ;;
aarch64) NODE_ARCH=arm64 ;;
-224
View File
@@ -1,224 +0,0 @@
#!/usr/bin/env sh
# secrets/install.sh — write this user's 1Password vault item to a local,
# 0600 env file that the managed ~/.zshrc sources.
#
# WHY: secrets used to be pulled live in ~/.zshrc.local, one `op read` per
# field on EVERY new shell. That made each terminal wait on 1Password, broke
# every shell when the network or the service account was down, and meant each
# box carried its own hand-written copy of the loop. Now the vault is read once
# here (at bootstrap, or on demand with `ops`), and shells read a local file.
#
# Layout: one vault per user (default: the login name), holding one item
# (default: also the login name) whose field LABELS are the variable names —
# GITEA_KEY, LITELLM_KEY, PROXMOX_TOKEN_SECRET, ... Every such field is
# exported. Add a secret by adding a field (`opc NAME`), not by editing
# this repo.
#
# Files, all under ~/.config/dotfiles (0700), none in the repo:
# op.env OP_SERVICE_ACCOUNT_TOKEN, OP_VAULT, OP_ITEM (0600). Kept so a
# refresh needs no prompt. Deliberately NOT sourced by shells:
# the token stays out of every process's environment, and only
# the op* helpers in zsh/zshrc load it, in a subshell.
# secrets.env the exported fields (0600), replaced atomically, so a failed
# fetch leaves the previous copy in place.
#
# Prompts for the token on the controlling TTY the first time (bootstrap is
# often piped into sh, so stdin is not the terminal). Without a TTY, pass
# OP_SERVICE_ACCOUNT_TOKEN (and optionally OP_VAULT / OP_ITEM) in the env.
# Needs op and jq, which bin/ installs. No sudo.
set -eu
say() { printf '%s\n' "$*" >&2; }
CONF_DIR="${DOTFILES_CONFIG_DIR:-$HOME/.config/dotfiles}"
OP_ENV="$CONF_DIR/op.env"
SECRETS_ENV="$CONF_DIR/secrets.env"
MCP_SYNC_KEYFILE="$HOME/.config/mcp-sync/key"
umask 077
mkdir -p "$CONF_DIR"
chmod 0700 "$CONF_DIR"
PATH="$HOME/.local/bin:$PATH"
for _tool in op jq; do
if ! "$_tool" --version >/dev/null 2>&1; then
say "secrets: $_tool is not installed or does not run; run bin/install.sh first."
exit 1
fi
done
# --- 1. resolve the service account, vault and item -------------------------
# Precedence: the environment, then op.env, then a prompt / auto-detection.
#
# WHY each piece is checked on its own: the vault holds every key later
# modules need (LITELLM_KEY, GITEA_KEY, ...). A bad token paste used to fall
# through as a generic "could not read item", bootstrap carried on, and the
# first visible symptom was mcp-config/ asking for a LiteLLM key by hand —
# which hid the real problem. Now a rejected token, a wrong vault and a wrong
# item each get their own message, and on a TTY a re-prompt.
_env_token="${OP_SERVICE_ACCOUNT_TOKEN:-}"
_token_src="from OP_SERVICE_ACCOUNT_TOKEN"
[ -n "$_env_token" ] || _token_src="stored in $OP_ENV"
_env_vault="${OP_VAULT:-}"
_env_item="${OP_ITEM:-}"
if [ -f "$OP_ENV" ]; then
# shellcheck source=/dev/null
. "$OP_ENV"
fi
OP_SERVICE_ACCOUNT_TOKEN="${_env_token:-${OP_SERVICE_ACCOUNT_TOKEN:-}}"
OP_VAULT="${_env_vault:-${OP_VAULT:-}}"
OP_ITEM="${_env_item:-${OP_ITEM:-}}"
_user="$(id -un)"
# Probe in a subshell: in POSIX sh a failed redirect on a special builtin
# like `:` exits the whole script.
have_tty() { ( : < /dev/tty ) 2>/dev/null; }
# ask <prompt> <default> — read one line from the TTY; echoes the default on empty.
ask() {
printf '%s [%s]: ' "$1" "$2" >&2
IFS= read -r _ans < /dev/tty || _ans=""
printf '%s' "${_ans:-$2}"
}
# mask <secret> — first 10 and last 10 characters plus the length, so a
# truncated or doubled paste is visible without printing the whole token.
mask() {
printf '%s' "$1" | awk '{ n = length($0)
if (n <= 20) printf "%s (%d chars)", "(too short)", n
else printf "%s**********%s (first 10 + last 10 of %d chars)", substr($0, 1, 10), substr($0, n - 9), n }'
}
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ] && [ -f "$SECRETS_ENV" ]; then
say "secrets: no service-account token; keeping existing $SECRETS_ENV."
exit 0
fi
# The token: `op whoami` is the cheapest call that fails on a bad token and
# nothing else. Up to three pastes before giving up.
_tries=0
while :; do
if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
export OP_SERVICE_ACCOUNT_TOKEN
op whoami >/dev/null 2>&1 && break
if [ "$_tries" -eq 0 ]; then
say "secrets: 1Password REJECTED the service-account token $_token_src (bad paste, revoked or expired?)."
say " token: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
else
say "secrets: 1Password REJECTED that token — most likely a truncated or bad paste."
say " token: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
fi
fi
if ! have_tty; then
say "secrets: no valid OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt."
say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0"
exit 1
fi
if [ "$_tries" -ge 3 ]; then
say "secrets: giving up after $_tries attempts. Every key in the vault (LITELLM_KEY,"
say " GITEA_KEY, ...) is missing until this works: re-run $0"
exit 1
fi
_tries=$((_tries + 1))
printf '1Password service-account token (ops_..., input hidden): ' >&2
stty -echo < /dev/tty 2>/dev/null || true
IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < /dev/tty || true
stty echo < /dev/tty 2>/dev/null || true
printf '\n' >&2
OP_SERVICE_ACCOUNT_TOKEN="$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | tr -d ' \t\r\n')"
[ -n "$OP_SERVICE_ACCOUNT_TOKEN" ] || say "secrets: no token entered."
done
say "secrets: service-account token OK: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
# pick <kind> <current> <default> <check-fn> <list-cmd...>
# Resolve a vault or item name: keep <current> if it checks out; otherwise use
# the only one the token can see, or ask (TTY) showing what it can see.
pick() {
_kind="$1" _cur="$2" _def="$3"; shift 3
_check="$1"; shift
_names="$("$@" --format json 2>/dev/null | jq -r '.[] | .name // .title')"
if [ -n "$_cur" ]; then
$_check "$_cur" && { printf '%s' "$_cur"; return 0; }
say "secrets: $_kind '$_cur' not found. This token can see:"
printf '%s\n' "$_names" | sed 's/^/ - /' >&2
fi
if [ "$(printf '%s\n' "$_names" | grep -c .)" -eq 1 ]; then
say "secrets: using the only $_kind this token can see: $_names"
printf '%s' "$_names"; return 0
fi
have_tty || { say "secrets: set OP_$(printf '%s' "$_kind" | tr a-z A-Z) and re-run."; return 1; }
printf '%s\n' "$_names" | grep -Fxq -- "$_def" || _def="$(printf '%s\n' "$_names" | head -n 1)"
while :; do
_cur="$(ask "$_kind" "$_def")"
$_check "$_cur" && { printf '%s' "$_cur"; return 0; }
say "secrets: $_kind '$_cur' not found. This token can see:"
printf '%s\n' "$_names" | sed 's/^/ - /' >&2
done
}
vault_ok() { op vault get "$1" >/dev/null 2>&1; }
item_ok() { op item get "$1" --vault "$OP_VAULT" >/dev/null 2>&1; }
OP_VAULT="$(pick vault "$OP_VAULT" "$_user" vault_ok op vault list)" || exit 1
OP_ITEM="$(pick item "$OP_ITEM" "$_user" item_ok op item list --vault "$OP_VAULT")" || exit 1
# --- 2. fetch the item once and render it -----------------------------------
# One `op item get` instead of one `op read` per field. Labels that are not
# valid shell identifiers (and the item's built-in notes field) are skipped
# rather than eval'd; values go through jq's @sh, so quotes, $ and newlines in
# a secret cannot break or inject into the file.
if ! _json="$(op item get "$OP_ITEM" --vault "$OP_VAULT" --reveal --format json)"; then
say "secrets: could not read item '$OP_ITEM' in vault '$OP_VAULT'."
[ -f "$SECRETS_ENV" ] && say "secrets: keeping the previous $SECRETS_ENV."
exit 1
fi
_skipped="$(printf '%s' "$_json" | jq -r '
[.fields[] | select(.purpose != "NOTES")
| select((.label // "") | test("^[A-Za-z_][A-Za-z0-9_]*$") | not) | .label]
| join(", ")')"
[ -z "$_skipped" ] || say "secrets: skipped fields whose labels are not variable names: $_skipped"
_tmp="$(mktemp "$CONF_DIR/secrets.env.XXXXXX")"
trap 'rm -f "$_tmp"' EXIT
{
printf '# Generated by dotfiles secrets/install.sh from op://%s/%s — do not edit.\n' "$OP_VAULT" "$OP_ITEM"
printf '# Refresh with: ops (change the vault with: opc NAME / opd NAME)\n'
printf '%s' "$_json" | jq -r '
.fields[]
| select(.purpose != "NOTES")
| select((.label // "") | test("^[A-Za-z_][A-Za-z0-9_]*$"))
| select((.value // "") != "")
| "export \(.label)=\(.value | @sh)"'
} > "$_tmp"
chmod 0600 "$_tmp"
_count="$(grep -c '^export ' "$_tmp" || true)"
if [ "$_count" -eq 0 ]; then
say "secrets: item '$OP_ITEM' has no exportable fields; keeping the previous file."
exit 1
fi
mv -f "$_tmp" "$SECRETS_ENV"
trap - EXIT
say "secrets: wrote $_count variables to $SECRETS_ENV (0600)."
grep -q '^export LITELLM_KEY=' "$SECRETS_ENV" \
|| say "secrets: WARNING: op://$OP_VAULT/$OP_ITEM has no LITELLM_KEY field; mcp-config/ and hermes/ need it."
# Remember the account only after it has actually worked.
{
printf 'OP_SERVICE_ACCOUNT_TOKEN=%s\n' "$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | jq -Rr @sh)"
printf 'OP_VAULT=%s\n' "$(printf '%s' "$OP_VAULT" | jq -Rr @sh)"
printf 'OP_ITEM=%s\n' "$(printf '%s' "$OP_ITEM" | jq -Rr @sh)"
} > "$OP_ENV"
chmod 0600 "$OP_ENV"
# --- 3. keep mcp-sync's copy of the gateway key in step ----------------------
# mcp-config/sync.sh exports LITELLM_KEY from its own key file on every shell,
# AFTER secrets.env is loaded, so a key rotated in the vault would otherwise be
# silently overridden by the stale copy. Only update a file that already exists:
# creating it is mcp-config/install.sh's job.
_litellm="$(printf '%s' "$_json" | jq -r '.fields[] | select(.label == "LITELLM_KEY") | .value // empty' | head -n 1)"
if [ -n "$_litellm" ] && [ -f "$MCP_SYNC_KEYFILE" ] \
&& [ "$(tr -d ' \t\r\n' < "$MCP_SYNC_KEYFILE")" != "$_litellm" ]; then
printf '%s' "$_litellm" > "$MCP_SYNC_KEYFILE"
chmod 0600 "$MCP_SYNC_KEYFILE"
say "secrets: LITELLM_KEY changed; updated $MCP_SYNC_KEYFILE (re-run hermes/install.sh for Hermes)."
fi
-236
View File
@@ -1,236 +0,0 @@
#!/usr/bin/env sh
# vscode/install.sh — install Visual Studio Code into ~/.local/opt/vscode.
#
# WHY: VS Code is the one GUI app this repo installs, and the obvious way to get
# it — Microsoft's rpm/deb repo — needs root, writes outside $HOME, and is wiped
# by a Kasm redeploy, so the editor quietly disappears on every rebuild. The box
# this was written for had exactly that half-state: /etc/yum.repos.d/vscode.repo
# present, `code` never installed, and nothing in this repo recording that VS
# Code was meant to be there at all. Upstream's portable tarball is the same
# build with none of that: everything lands under $HOME, no sudo, and it
# survives a redeploy. The only thing given up is dnf/apt-driven updates, which
# is what the pinned VSCODE_VERSION below replaces.
#
# It is not in bin/install.sh because that script installs single self-contained
# binaries; VS Code is an Electron tree of thousands of files plus a menu entry
# and an icon. It follows node/ and ansible/ instead: unpack under ~/.local/opt
# and symlink the CLI into ~/.local/bin.
#
# The Chromium sandbox still works from a userland install: the bundled
# chrome-sandbox helper is not setuid here, so Electron falls back to the
# unprivileged user-namespace sandbox. That needs user namespaces enabled
# (user.max_user_namespaces > 0, the default on every distro this repo targets).
# Verified by launching without --no-sandbox. Nothing is given up for dropping
# root; if a host ever has userns disabled, VS Code says so on startup.
#
# Idempotent and safe to re-run. To upgrade, bump VSCODE_VERSION.
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
# shellcheck source=../lib/fetch.sh
. "$REPO_ROOT/lib/fetch.sh"
say() { printf '%s\n' "$*" >&2; }
# Pinned deliberately. VS Code auto-updates itself on other platforms; a tarball
# install does not, which is the point — the editor runs your code and holds
# your Git and extension credentials, so the version is a supply-chain decision
# rather than whatever upstream served today. `code --version` prints this
# string verbatim, so it must match with no leading "v".
VSCODE_VERSION="1.139.0"
PREFIX="$HOME/.local/opt/vscode"
DESKTOP_DIR="$HOME/.local/share/applications"
ARGV_JSON="$HOME/.vscode/argv.json"
# has_desktop — true when this host has a graphical session to run a GUI editor
# in. VS Code is ~400MB unpacked and $HOME is the thing that persists across
# redeploys, so it is not worth carrying on a box that will never display it.
# Desktop images ship session files under /usr/share/{x,wayland-}sessions.
has_desktop() {
[ -n "${XDG_CURRENT_DESKTOP:-}" ] && return 0
for d in /usr/share/xsessions /usr/share/wayland-sessions; do
[ -d "$d" ] && [ -n "$(ls -A "$d" 2>/dev/null)" ] && return 0
done
return 1
}
# --- skip on headless hosts --------------------------------------------------
# DOTFILES_VSCODE=1 forces the install anyway (e.g. for `code tunnel` on a
# server); DOTFILES_VSCODE=0 skips it even on a desktop.
case "${DOTFILES_VSCODE:-auto}" in
0) say "vscode: DOTFILES_VSCODE=0; skipping."; exit 0 ;;
1) ;;
*) if ! has_desktop; then
say "vscode: no graphical session detected; skipping (DOTFILES_VSCODE=1 to force)."
exit 0
fi ;;
esac
# --- defer to a system-wide VS Code -------------------------------------------
# WHY: a distro package (Arch's visual-studio-code-bin on Omarchy, Microsoft's
# rpm/deb elsewhere) and this tarball both add a "Visual Studio Code" menu entry
# and both open the same ~/.config/Code profile. Two builds on one profile is
# the real hazard: the older one opens state the newer one already migrated,
# and extensions break in ways that look like extension bugs. The system copy
# also gets updates this pinned one does not, so it wins.
#
# PATH order is irrelevant here (unlike `shadowed` in bin/): the menu entries
# collide wherever the binaries sit. Anything we installed earlier is removed --
# only ours, recognised by the .desktop Exec pointing into $PREFIX.
_sys_code=""
_sh_ifs="$IFS"; IFS=:
for _d in $PATH; do
[ "$_d" != "$BIN_DIR" ] && [ -x "$_d/code" ] && { _sys_code="$_d/code"; break; }
done
IFS="$_sh_ifs"
if [ -n "$_sys_code" ]; then
say "vscode: system VS Code at $_sys_code; skipping the tarball install."
if grep -qs "^Exec=$PREFIX/" "$DESKTOP_DIR/code.desktop"; then
rm -f "$DESKTOP_DIR/code.desktop"
[ "$(readlink "$BIN_DIR/code" 2>/dev/null)" = "$PREFIX/bin/code" ] && rm -f "$BIN_DIR/code"
rm -f "$VERSION_DIR/code"
rm -rf "$PREFIX"
command -v update-desktop-database >/dev/null 2>&1 &&
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
say "vscode: removed the earlier tarball copy ($PREFIX, its menu entry and $BIN_DIR/code)."
fi
exit 0
fi
# Upstream keys its download URLs on x64/arm64 rather than amd64/arm64.
case "$(fetch_arch)" in
x86_64) VSCODE_ARCH=x64 ;;
aarch64) VSCODE_ARCH=arm64 ;;
*) say "vscode: unsupported arch $(fetch_arch)"; exit 1 ;;
esac
# --- install the app tree ----------------------------------------------------
# Verify it RUNS, not just that the tree exists: a Kasm redeploy can leave a
# present-but-broken directory behind (the same failure node/ and ansible/
# guard against).
#
# The root fallback is not optional. Run as root, `code --version` refuses --
# it prints "You are trying to start Visual Studio Code as a super user"
# instead of the version, and still exits 0 -- so a bare run-check never
# matches and would re-download 325MB on every single bootstrap, silently.
# That is the forced-install path (DOTFILES_VSCODE=1 in a container or on a
# server), so for root only, fall back to the recorded version stamp.
installed_version="$("$PREFIX/bin/code" --version 2>/dev/null | head -1 || true)"
if [ "$installed_version" = "$VSCODE_VERSION" ]; then
say "vscode: $VSCODE_VERSION already present at $PREFIX."
elif [ "$(id -u)" = "0" ] && have_version code "$VSCODE_VERSION"; then
say "vscode: $VSCODE_VERSION already present at $PREFIX (stamped; root cannot query --version)."
else
say "vscode: installing $VSCODE_VERSION into $PREFIX (this is a ~325MB download)..."
install_tarball_tree \
"https://update.code.visualstudio.com/$VSCODE_VERSION/linux-$VSCODE_ARCH/stable" \
"VSCode-linux-$VSCODE_ARCH" "$PREFIX"
# bin/code is upstream's CLI wrapper (`code .`, `code --install-extension`);
# the Electron binary at the tree root is what the menu entry launches.
mkdir -p "$BIN_DIR"
ln -sf "$PREFIX/bin/code" "$BIN_DIR/code"
mark_version code "$VSCODE_VERSION"
say "vscode: installed $VSCODE_VERSION and linked $BIN_DIR/code."
fi
# --- menu entry --------------------------------------------------------------
# Icon= is an absolute path rather than a theme name: the bundled icon is a
# single 1024x1024 PNG and hicolor tops out at 512x512, so there is no correct
# themed directory to drop it in. The spec allows an absolute path, and it needs
# no icon-cache rebuild to take effect.
mkdir -p "$DESKTOP_DIR"
cat >"$DESKTOP_DIR/code.desktop" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Visual Studio Code
GenericName=Text Editor
Comment=Code Editing. Redefined.
Exec=$PREFIX/code %F
Icon=$PREFIX/resources/app/resources/linux/code.png
Terminal=false
StartupNotify=true
StartupWMClass=Code
Categories=TextEditor;Development;IDE;
MimeType=text/plain;inode/directory;
Keywords=vscode;editor;
Actions=new-window;
[Desktop Action new-window]
Name=New Empty Window
Exec=$PREFIX/code --new-window %F
Icon=$PREFIX/resources/app/resources/linux/code.png
DESKTOP
say "vscode: wrote $DESKTOP_DIR/code.desktop"
# Refresh the per-user menu caches so the entry appears without a re-login.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
fi
if command -v kbuildsycoca5 >/dev/null 2>&1; then
kbuildsycoca5 --noincremental >/dev/null 2>&1 || true
fi
# --- keyring prompt suppression ----------------------------------------------
# WHY: on first launch Electron's safeStorage reaches for the OS keyring through
# libsecret. On a KDE session that wakes kwalletd, which pops an unlock/create
# dialog before the editor is usable -- and since kwallet-pam is not installed
# on these hosts, nothing unlocks the wallet at login, so it prompts every
# session. "basic" keeps VS Code's secrets in its own store and never reaches
# for the wallet at all. Verified with dbus-monitor: no org.kde.KWallet traffic
# on launch.
#
# TRADE-OFF: basic is weaker than a real keyring. Secrets VS Code stores (Git
# and GitHub auth tokens, extension credentials) end up obfuscated on disk
# rather than sealed behind a wallet password. To move to the wallet later,
# install kwallet-pam so it unlocks at login, then change the value to
# "kwallet5" and restart VS Code.
#
# The path comes from product.json's dataFolderName (".vscode"), NOT the
# user-data-dir: argv.json lives at ~/.vscode/argv.json and a copy written
# anywhere else is read by nothing and silently ignored.
if [ ! -e "$ARGV_JSON" ]; then
mkdir -p "$(dirname "$ARGV_JSON")"
cat >"$ARGV_JSON" <<'ARGV'
// Runtime arguments for VS Code. See dotfiles vscode/install.sh.
{
// Keep secrets in VS Code's own store rather than the OS keyring, so KDE's
// kwallet does not prompt for an unlock on every launch.
"password-store": "basic"
}
ARGV
say "vscode: wrote $ARGV_JSON (password-store=basic)"
elif grep -q '"password-store"' "$ARGV_JSON"; then
say "vscode: $ARGV_JSON already sets password-store; leaving it alone."
else
# Merge rather than overwrite: this file is JSON-with-comments that VS Code
# and the user both own, and it carries a crash-reporter-id that should
# survive. Insert the key after the opening brace and keep the rest byte for
# byte.
cp -p "$ARGV_JSON" "$ARGV_JSON.bak"
if awk '
BEGIN { done = 0 }
{
print
if (!done && $0 ~ /^[[:space:]]*\{[[:space:]]*$/) {
print "\t// Added by dotfiles vscode/install.sh: keep secrets in VS Code'"'"'s"
print "\t// own store so kwallet does not prompt for an unlock on every launch."
print "\t\"password-store\": \"basic\","
done = 1
}
}
END { exit(done ? 0 : 1) }
' "$ARGV_JSON.bak" >"$ARGV_JSON.new"; then
mv "$ARGV_JSON.new" "$ARGV_JSON"
say "vscode: set password-store=basic in $ARGV_JSON (backup: $ARGV_JSON.bak)"
else
rm -f "$ARGV_JSON.new"
say "vscode: WARNING: could not find the object opening in $ARGV_JSON."
say " Add '\"password-store\": \"basic\"' by hand to stop the keyring prompt."
fi
fi
say "vscode: ready."
+13 -99
View File
@@ -2,6 +2,19 @@
# overwritten on the next bootstrap run. Edit zsh/zshrc in the dotfiles repo
# instead.
# If this account was added to the docker group (dotfiles/docker/install.sh)
# but this shell's session predates that, re-exec via newgrp so docker works
# without a full logout. Guarded: fires only when the group is on the account
# yet missing from the running shell's groups, so the newgrp'd shell (which
# has the group) cannot loop.
# Only for real terminals ([ -t 0 ]): newgrp replaces the shell and would
# swallow the command in scripted `zsh -i -c ...` invocations.
if [ -t 0 ] && [ -S /var/run/docker.sock ] \
&& id -nG "${USER:-$(id -un)}" 2>/dev/null | grep -qw docker \
&& ! id -Gn 2>/dev/null | grep -qw docker; then
exec newgrp docker
fi
# Userland tools installed by dotfiles/bin/install.sh live here.
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
@@ -21,9 +34,6 @@ SAVEHIST=10000
setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS
# grmb and the op* helpers below have a bash port in bash/dotfiles.bash for
# hosts that keep bash; change both together.
# Delete remote branches without fighting tab-completion.
#
# `git push origin --delete <TAB>` completes to `origin/foo`, but `origin/foo`
@@ -64,102 +74,6 @@ grmb() {
return $rc
}
# Secrets from this user's 1Password vault, written by dotfiles/secrets/install.sh.
# Reading a local 0600 file keeps shell startup off the network: nothing here
# calls op. opc/opd refresh the file themselves; after editing the vault
# anywhere else (the 1Password app, another box), run `ops`.
_DOTFILES_CONF="$HOME/.config/dotfiles"
[ -f "$_DOTFILES_CONF/secrets.env" ] && source "$_DOTFILES_CONF/secrets.env"
# opr NAME read one field straight from the vault
# opc NAME=VALUE create or update a field, then refresh and reload this
# opc NAME shell; with no VALUE (NAME or NAME=) it prompts, hidden,
# which keeps the secret out of shell history.
# `opc NAME VALUE` also still works.
# opd NAME delete a field, refresh, and unset it in this shell
# opl [VAULT [ITEM]] show every field and its current value (default: your
# vault and item from op.env)
# ops re-fetch the vault into secrets.env and reload this shell
#
# The service-account token lives in op.env and is loaded only inside these
# subshells, so it never lands in the environment of everything you run.
_dotfiles_op() {
[ -f "$_DOTFILES_CONF/op.env" ] || { print -u2 "no $_DOTFILES_CONF/op.env; run: ops"; return 1; }
( set -a; source "$_DOTFILES_CONF/op.env"; set +a; "$@" )
}
_dotfiles_env_name() {
[[ "$1" =~ '^[A-Za-z_][A-Za-z0-9_]*$' ]] && return 0
print -u2 "$2: '$1' is not a valid variable name"; return 2
}
opr() {
_dotfiles_env_name "$1" opr || return
_dotfiles_op sh -c 'op read "op://$OP_VAULT/$OP_ITEM/$1"' opr "$1"
}
opc() {
emulate -L zsh
local name=$1 value
if [[ $1 == *=* ]]; then
# NAME=VALUE: split on the first '=' so values may contain '='
name=${1%%=*} value=${1#*=}
elif (( $# >= 2 )); then
value=$2
fi
_dotfiles_env_name "$name" opc || return
if [[ -z $value ]]; then
read -rs "value?$name: " && print
[[ -n $value ]] || { print -u2 "opc: empty value for $name; nothing changed"; return 1; }
fi
# [concealed] makes it a password field; an existing field of that name is updated.
# op can exit non-zero with "(404) Not Found" after an edit it did save (seen with
# op 2.34.1 and 2.39.0), so its exit code is ignored: the field is read back instead.
_dotfiles_op sh -c '
clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; }
ref="op://$OP_VAULT/$OP_ITEM/$1"
err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[concealed]=$2" 2>&1 >/dev/null)
if [ "$(op read "$ref" 2>/dev/null)" = "$2" ]; then
printf "opc: saved %s\n" "$ref" >&2
else
printf "opc: could not save %s\n op said: %s\n check vault, item and service-account write access with: opl\n" \
"$ref" "$(clean "$err")" >&2
exit 1
fi' opc "$name" "$value" && ops
}
opd() {
_dotfiles_env_name "$1" opd || return
# As in opc, op's exit code is unreliable here: check the item's field labels instead.
_dotfiles_op sh -c '
clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; }
ref="op://$OP_VAULT/$OP_ITEM/$1"
err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[delete]" 2>&1 >/dev/null)
if ! item=$(op item get "$OP_ITEM" --vault "$OP_VAULT" --format json 2>&1); then
printf "opd: could not re-read %s to confirm the delete\n op said: %s\n" "$ref" "$(clean "$item")" >&2
exit 1
elif printf "%s" "$item" | jq -r ".fields[].label" | grep -qxF "$1"; then
printf "opd: could not delete %s\n op said: %s\n" "$ref" "$(clean "$err")" >&2
exit 1
fi
printf "opd: deleted %s\n" "$ref" >&2' opd "$1" \
&& unset "$1" && ops
}
opl() {
_dotfiles_op sh -c '
vault=${1:-$OP_VAULT} item=${2:-$OP_ITEM}
printf "op://%s/%s\n" "$vault" "$item" >&2
op item get "$item" --vault "$vault" --reveal --format json' opl "$@" \
| jq -r '
[.fields[] | select(.purpose != "NOTES")] as $f
| ([$f[] | .label // "" | length] | max // 0) as $w
| $f[]
| ((.label // "") + " " * ($w - ((.label // "") | length))) as $name
| ((.value // "") | split("\n")) as $lines
| "\($name) \($lines[0] // "")",
($lines[1:][] | (" " * ($w + 2)) + .)'
}
ops() {
"${DOTFILES_DIR:-$HOME/.dotfiles}/secrets/install.sh" \
&& source "$_DOTFILES_CONF/secrets.env"
}
# Per-machine customizations (secrets, keys, local overrides) live in
# ~/.zshrc.local, which bootstrap never touches. See zsh/zshrc.local.example
# for the variables modules look for. Loaded last so it can override anything.
+6 -10
View File
@@ -1,9 +1,7 @@
# ~/.zshrc.local — per-machine customizations. Copy this file to
# ~/.zshrc.local and fill in what this box needs. It is sourced at the end
# of the managed ~/.zshrc, is never overwritten by bootstrap, and never
# lives in the repo. Secrets normally come from your 1Password vault instead
# (secrets/install.sh -> ~/.config/dotfiles/secrets.env); set one here only on
# a box without 1Password, or to override the vault's value on this machine.
# lives in the repo — this is the ONLY place to put secrets.
#
# cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
@@ -32,11 +30,9 @@
# Only the bootstrap-managed checkout (~/.dotfiles) is ever `git pull`-ed by the
# hook, and only when clean — a tree you work in is read, never touched.
# --- 1Password -----------------------------------------------------------
# Nothing to set here. Do NOT loop over `op read` in this file: that ran on
# every shell. secrets/install.sh stores the service-account token in
# ~/.config/dotfiles/op.env and writes the vault's fields to secrets.env, which
# the managed ~/.zshrc loads. Use opr / opc / opd (see zsh/zshrc).
# --- 1Password CLI -------------------------------------------------------
# Service-account token for non-interactive `op read` / `op run`.
# export OP_SERVICE_ACCOUNT_TOKEN=ops_...
# --- Proxmox -------------------------------------------------------------
# One pair of credentials, several consumers: the proxmox MCP server
@@ -44,8 +40,8 @@
# rke2-devops-airgap, whose Taskfile composes PROXMOX_VE_API_TOKEN from them.
# Unprefixed on purpose — these are Proxmox credentials, not MCP ones.
#
# secrets.env already exports them from your vault item, so these lines are
# only needed without 1Password. Never committed.
# The op-vault loop above already exports them from the `noconnor` item, so
# these lines are only needed without 1Password. Never committed.
# export PROXMOX_TOKEN_ID="root@pam!terragrunt-proxmox"
# export PROXMOX_TOKEN_SECRET="00000000-0000-0000-0000-000000000000"
#