Compare commits

..
Author SHA1 Message Date
noconnorandClaude Opus 5.5 9cb18a795d Coexist with a desktop that already manages its tools (Omarchy)
On an Omarchy laptop bootstrap left two of almost everything: VS Code from
both pacman and the tarball (two menu entries, one shared ~/.config/Code
profile), and kubectl/helm/k9s/kubectx/yq/jq/node/ansible in ~/.local/bin
behind mise and pacman copies that always won on PATH. The zsh helpers
(op*, grmb, secrets.env) also never reached the bash login shell.

- lib/fetch.sh: add `shadowed`, true when another copy of a tool comes
  earlier on PATH than ~/.local/bin. Always false where zshrc puts
  ~/.local/bin first, so VM installs are unchanged.
- bin/, node/, ansible/: skip tools that are shadowed.
- vscode/: defer to any system `code`, and remove a tarball copy this
  module installed earlier.
- bash/: new module; bash port of the zshrc helpers, sourced from
  ~/.bashrc via a marked block. No-ops without ~/.bashrc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:44:18 -04:00
noconnorandClaude Opus 5.5 41a90a10fb zsh: verify opc/opd against the vault and explain failures
op item edit can exit non-zero with "(404) Not Found" after an edit it
did save (seen with op 2.34.1 and 2.39.0 on an existing item), which
made opc report failure and skip the ops refresh. opc now reads the
field back and opd re-checks the item's labels, then prints either the
op:// reference it changed or what failed, op's message without its
timestamp prefix, and where to look next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:49:10 +00:00
noconnorandClaude Opus 5.5 5f0dec5270 zsh: let opc take NAME=VALUE and prompt when the value is missing
`opc NAME=VALUE` sets a field directly; `opc NAME` or `opc NAME=` prompts
with hidden input, and an empty answer changes nothing. The old
`opc NAME VALUE` form still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:43:27 +00:00
noconnorandClaude Opus 5.5 fe8b9a3ec0 claude-obsidian: drop committed bytecode; ignore __pycache__
The claude-obsidian merge carried a CPython 3.12 .pyc from running the
sync script in place. Bytecode is a build artifact; ignore it repo-wide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:10:49 +00:00
noconnorandClaude Opus 5 72d85eb9ec vscode: install VS Code into ~/.local/opt from upstream's tarball
The box this was written on had a half-finished install: vscode.repo
present under /etc/yum.repos.d, `code` never installed, and nothing in
this repo recording that VS Code was meant to be there. Microsoft's
rpm/deb would also need root, write outside $HOME, and be wiped by a
Kasm redeploy. Upstream's portable tarball is the same build with none
of that, so the module keeps bin/'s contract: no sudo, nothing outside
$HOME, pinned rather than floating.

The Chromium sandbox survives a userland install — the bundled
chrome-sandbox is not setuid, so Electron falls back to the
unprivileged user-namespace sandbox. Verified by launching without
--no-sandbox, so dropping root costs nothing.

Adds install_tarball_tree to lib/fetch.sh: install_tarball_bin picks a
single binary out of an archive, and VS Code only runs from an intact
Electron tree. It stages beside the target and swaps in after
extraction succeeds, so an interrupted run leaves the old version
intact.

Two things the verification caught rather than the design:

  - argv.json lives at ~/.vscode/argv.json (product.json's
    dataFolderName), not under the user-data-dir. A copy written
    anywhere else is read by nothing, so the first attempt at
    suppressing the keyring prompt silently did nothing.

  - Run as root, `code --version` prints a "super user" warning
    instead of the version and still exits 0, so a bare run-check
    never matches and would re-download 325MB on every bootstrap.
    Root falls back to the .versions stamp.

Sets password-store=basic in argv.json so Electron's safeStorage stops
waking kwalletd, which prompts for an unlock on every launch because
kwallet-pam is not installed. Confirmed with dbus-monitor: no
org.kde.KWallet traffic on launch. The trade-off — tokens obfuscated
on disk rather than sealed behind a wallet password — and the route
back to the wallet are documented in the module and the README.

No-ops on headless hosts; DOTFILES_VSCODE overrides either way.

Verified per verify-module: shellcheck -s sh clean, idempotent re-run
on both paths, and fresh install plus re-run as root on oraclelinux:9
and debian:13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 13:08:49 +00:00
noconnor 7bbd911365 Merge remote-tracking branch 'origin/claude-obsidian-sync' 2026-09-24 17:56:16 -04:00
noconnorandClaude Opus 5 b6d36ba113 hermes: put ~/.local/bin on PATH so the post-install check finds the CLI
Under the Coder agent (no managed zshrc in the parent) a successful
install still failed with "hermes CLI not found after install".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 13:46:09 -04:00
noconnorandClaude Opus 5 c0f1fc21b7 hermes: create ~/.hermes before writing .env on a fresh home
On a first run the key-changed branch writes ~/.hermes/.env before the
installer has created ~/.hermes, so it died with "Directory nonexistent"
and failed bootstrap (seen on a fresh Coder workspace).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 13:39:59 -04:00
noconnorandClaude Opus 5 86b8e51646 Remove the docker module: dotfiles no longer installs Docker
Installing Docker Engine is a host/image decision, not a per-user
environment one. In Coder workspaces (unprivileged pods, no systemd) the
module ran get.docker.com on every start for a daemon that can never
run there. Where Docker is wanted, install it on the host or bake it
into the image.

Also drops the zshrc newgrp-docker hook that existed only for this
module's group change, and the docs/skills references to docker/.
docker-rootless-cgroup/ (configures Kasm's existing rootless daemon)
and bin/'s kind gate (detects a daemon) stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 13:39:59 -04:00
noconnorandClaude Opus 5 ffedc84cfb secrets: widen the token mask to 10 stars and label first/last 10
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:59:02 -04:00
noconnorandClaude Opus 5 cb7ab17769 secrets: show the token masked (first/last 10 chars + length)
Makes a truncated or doubled paste visible at a glance without printing
the whole token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:57:16 -04:00
noconnorandClaude Opus 5 5bc8a27f60 secrets: fail loudly on a bad 1Password token instead of a LiteLLM prompt
A bad token paste (or a vault/item that did not match the login-name
default) used to surface only as mcp-config asking for a LiteLLM key,
hiding that every vault key was missing. Now:

- the token is verified with `op whoami` and re-prompted on a bad paste
- vault and item are validated, auto-picked when the token sees only one,
  and otherwise prompted with the list it can see
- bootstrap skips mcp-config and hermes, with an error naming the cause,
  when no LITELLM_KEY came from the vault

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:55:27 -04:00
noconnorandClaude Opus 5 8ec3269f4a bin: install kubelogin as the kubectl oidc-login plugin
omnictl-generated kubeconfigs authenticate via `kubectl oidc-login
get-token`, so install int128/kubelogin as kubectl-oidc_login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:35:15 -04:00
noconnor 46052cf573 Revert "claude-obsidian: Claude conversations -> Obsidian notes (+ MCP) (#1)"
This reverts commit d3393edfdd.
2026-09-17 16:23:54 -04:00
noconnor d3393edfdd claude-obsidian: Claude conversations -> Obsidian notes (+ MCP) (#1) 2026-09-17 20:18:09 +00:00
noconnorandClaude Opus 5 db3a3e6e82 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-17 16:14:45 -04:00
noconnorandClaude Opus 5 376ec03428 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-17 16:14:45 -04:00
noconnorandClaude Opus 5 2bfa7e9a2d 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-17 16:14:45 -04:00
noconnorandClaude Opus 5 11138d90a9 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-17 16:14:45 -04:00
noconnorandClaude Opus 5 7a7e987268 mcp-config: put the shell hook in ~/.zshrc.local on a fresh box
install.sh chose the rc file from $SHELL. On a fresh box bootstrap runs
from the user's original bash: zsh/ has already switched the login shell
to zsh, but $SHELL in that process still says bash. The hook went into
~/.bashrc, below zsh/'s auto-exec hook, so every new terminal exec'd into
zsh before reaching it, sync.sh never ran, OPENCODE_CONFIG was never
exported, and opencode came up unconfigured. Boxes already on zsh were
fine, which is why it only showed up on a new machine.

Now the dotfiles-managed ~/.zshrc, or zsh as the account's login shell
(getent), selects ~/.zshrc.local regardless of $SHELL. Standalone
`curl | sh` installs without these dotfiles behave as before.

Reproduced and verified in a clean ubuntu:24.04 container (zsh/ then
mcp-config/, dummy key): before, hook in .bashrc and OPENCODE_CONFIG
unset in a new zsh; after, hook in .zshrc.local and OPENCODE_CONFIG
points at a config with the covert-gpt provider and gitea/proxmox MCP
servers. A box already broken this way is repaired by re-running the
module, and re-runs leave a single hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:53:12 +00:00
noconnorandClaude Opus 5 0426ebc973 git: authenticate to our Gitea from $GITEA_KEY
git never reads GITEA_KEY on its own. With only `credential.helper
store`, auth depended on whatever was typed at a first prompt and cached
in ~/.git-credentials: it went stale when the token was rotated in the
vault, and a box that never pushed had no credential at all, so pushes
failed even with a key exported.

Add a credential helper scoped to https://git.nic-oconnor.com that hands
git $GITEA_KEY as the password (username from GITEA_USER, else the login
name). An empty helper entry first clears the inherited `store` for that
host, so a rejected token is never cached and a stale one can't win.
Other hosts keep `store`. With GITEA_KEY unset it prints nothing and git
prompts as before. Idempotent: re-runs leave one copy.

Verified: `git credential fill` returns exactly $GITEA_KEY for the host,
other hosts are unaffected, ~/.git-credentials stays absent. A dry-run
push still fails, because the key currently in the vault is rejected by
Gitea (401 on the API too); that is the token, not this wiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:36:20 +00:00
noconnorandClaude Opus 5 1159197e4b secrets: rename the vault helpers from env* to op*
They act on 1Password, not on the environment, so the prefix now says so:
opr (read), opc (create/update), opd (delete), opl (list), ops (refresh).
No behavior change. Checked the new names don't collide with any command
or oh-my-zsh alias, and verified opr/opl/ops against the real vault
(values compared, not printed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:36:20 +00:00
noconnorandClaude Opus 5 c6e5f22acb secrets: read the 1Password vault once into a 0600 env file
Secrets were pulled in each box's hand-written ~/.zshrc.local with one
`op read` per field on EVERY new shell: every terminal waited on
1Password, every shell broke when it was unreachable, and the loop lived
outside the repo.

New secrets/ module (after bin/, which provides op and jq): one vault per
user and one item, both defaulting to the login name, whose field labels
are the variable names. It asks for the service-account token once
(hidden), fetches the item with a single `op item get`, and writes every
valid-named field to ~/.config/dotfiles/secrets.env (0600), replaced
atomically so a failed fetch keeps the previous copy. Values go through
jq @sh, so quotes, $() and newlines in a secret can't break or inject.
The token is kept in op.env (0600), which shells never source.

bootstrap loads secrets.env right after the module, so mcp-config takes
LITELLM_KEY from the env instead of prompting. A LITELLM_KEY rotated in
the vault also updates ~/.config/mcp-sync/key, since sync.sh re-exports
from that file after secrets.env loads and would otherwise win.

The managed zshrc sources secrets.env (no op call at startup) and adds
envr / envc / envd / envl / envs to read, upsert, delete, list and
refresh; they load the token only in a subshell.

Existing boxes still need the old op loop removed from ~/.zshrc.local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:30:24 +00:00
noconnorandClaude Opus 5 1acbd28c6e bin: add the coder CLI
Pinned to v2.36.5 (latest release). Same shape as the other tarball
installs: the asset drops the tag's leading v and the binary sits at the
archive root. Verified on this box: installs, `coder version` reports
v2.36.5, and a re-run skips it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:30:24 +00:00
noconnorandClaude Opus 5 5fac9cb63b mcp-config: make install.sh executable, so bootstrap can run it
It was created 100644 for the curl | sh path, where the mode doesn't
matter. Since bootstrap.sh started calling it via run_module, which execs
the script directly, the module failed with "Permission denied" (126) and
the sync hook / LITELLM_KEY never got deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:09:36 +00:00
noconnorandClaude Opus 5 6ac2a02f1b mcp-config: coerce /model/info numbers, so a string price can't break opencode
The litellm-helm chart renders the proxy config with Go's toYaml, which writes
0.000005 as `5e-06`. LiteLLM loads it with PyYAML (YAML 1.1), which only reads
an exponent float that contains a dot, so /model/info returns input_cost_per_token
as the STRING "5e-06" for opus/sonnet/haiku while 2.5e-05 stays a float.
`"5e-06" * 1_000_000` is string repetition in Python: sync.sh wrote a 20 MB
opencode.gateway.json with a string cost, opencode rejected the whole config
("Expected number"), and every new shell redeployed it.

- num(): accept int/float/numeric strings, drop anything non-finite or non-numeric
- clean_spec(): validate every model spec before writing, whatever its source,
  so the discovery-failed carry-over path heals an already-corrupted file
  instead of preserving it
- refuse to deploy a config over 1 MB; keep the last-good file

Tested against the live gateway: costs render 5/25, 3/15, 1/5 $/1M, and
`opencode models covert-gpt` lists all 7 models; with no key, the 20 MB
corrupted file heals to 2.8 KB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Duzwo1tmEqD2kYYyNKm7xs
2026-09-14 13:04:28 -04:00
22 changed files with 1080 additions and 191 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 (`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
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
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/install.sh`, which detects the situation and no-ops
gracefully — see `docker-rootless-cgroup/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, Docker | `zsh/`, `docker/` | installed from upstream scripts — not pinned |
| oh-my-zsh | `zsh/` | installed from upstream script — 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/install.sh` and
`docker-rootless-cgroup/install.sh` are the references).
the code path and confirm the guard exists (`docker-rootless-cgroup/install.sh`
is the reference).
## Full run
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+61 -13
View File
@@ -29,29 +29,51 @@ 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, helm, k9s,
linked release binaries into `~/.local/bin`: jq, kubectl, kubelogin (the
`kubectl oidc-login` plugin, for OIDC auth to Omni clusters), 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), and packer (HashiCorp's infrastructure build tool).
Kubernetes), packer (HashiCorp's infrastructure build tool), and the
coder CLI for Coder workspaces.
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
@@ -65,6 +87,18 @@ 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
@@ -86,7 +120,8 @@ 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:
Variables modules look for. With `secrets/` set up, put these in your vault
item instead of `~/.zshrc.local`:
- `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
@@ -97,8 +132,21 @@ Variables modules look for:
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.
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for
non-interactive `op read` / `op run`.
- `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.
- `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,6 +15,8 @@ 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; }
@@ -22,6 +24,13 @@ 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
@@ -0,0 +1,110 @@
# 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
@@ -0,0 +1,47 @@
#!/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
+48 -16
View File
@@ -49,6 +49,18 @@ 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
@@ -204,6 +216,17 @@ 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
@@ -215,33 +238,42 @@ install_terragrunt() {
# 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
try install_kubectl
try install_helm
try install_k9s
try install_jq jq
try install_kubectl kubectl
try install_oidc_login kubectl-oidc_login
try install_helm helm
try install_k9s k9s
if docker_full_available; then
try install_kind
try install_kind kind
else
fsay "Skipping kind: full (non-rootless) Docker not available."
fi
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
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
if [ -n "$FAILED" ]; then
fsay ""
+38 -7
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 docker/ was enough to leave a box with no CLIs, no npx and no
# distro in one module 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,10 +78,19 @@ 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
@@ -94,11 +103,33 @@ 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.
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
#
# 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 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
@@ -1,114 +0,0 @@
#!/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
+22 -1
View File
@@ -30,4 +30,25 @@ 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)"
say "Done. On the next HTTPS push/pull to a new host, git prompts once and caches the credential in ~/.git-credentials."
# 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."
+9 -1
View File
@@ -26,6 +26,12 @@ 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"
@@ -74,7 +80,9 @@ 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
# 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"
printf 'LITELLM_KEY=%s\n' "$NEW_KEY" >"$HOME/.hermes/.env"
say "hermes: Created ~/.hermes/.env with LITELLM_KEY"
fi
+62 -2
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, and
# install_raw_bin.
# fetch_arch_deb, have_version + mark_version, install_tarball_bin,
# install_tarball_tree, and install_raw_bin.
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
VERSION_DIR="$BIN_DIR/.versions"
@@ -55,6 +55,38 @@ 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"
@@ -93,6 +125,34 @@ 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"
Regular → Executable
+17 -5
View File
@@ -120,11 +120,23 @@ 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.
case "${SHELL:-}" in
*zsh) RC="$HOME/.zshrc.local" ;;
*bash) RC="$HOME/.bashrc" ;;
*) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc.local" || RC="$HOME/.bashrc" ;;
esac
#
# 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
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
+72 -6
View File
@@ -315,21 +315,50 @@ 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 = {}
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)
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 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")):
if mi.get(src) is not None:
cost[dst] = mi[src] * 1_000_000
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 cost:
spec["cost"] = cost
for src, dst in (("supports_function_calling", "tool_call"),
@@ -378,10 +407,47 @@ 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,6 +29,13 @@ 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
@@ -0,0 +1,224 @@
#!/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
@@ -0,0 +1,236 @@
#!/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."
+99 -13
View File
@@ -2,19 +2,6 @@
# 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:"*) ;;
@@ -34,6 +21,9 @@ 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`
@@ -74,6 +64,102 @@ 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.
+10 -6
View File
@@ -1,7 +1,9 @@
# ~/.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 — this is the ONLY place to put secrets.
# 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.
#
# cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
@@ -30,9 +32,11 @@
# 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 CLI -------------------------------------------------------
# Service-account token for non-interactive `op read` / `op run`.
# export OP_SERVICE_ACCOUNT_TOKEN=ops_...
# --- 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).
# --- Proxmox -------------------------------------------------------------
# One pair of credentials, several consumers: the proxmox MCP server
@@ -40,8 +44,8 @@
# rke2-devops-airgap, whose Taskfile composes PROXMOX_VE_API_TOKEN from them.
# Unprefixed on purpose — these are Proxmox credentials, not MCP ones.
#
# The op-vault loop above already exports them from the `noconnor` item, so
# these lines are only needed without 1Password. Never committed.
# secrets.env already exports them from your vault 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"
#