Compare commits

..
4 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
9 changed files with 22 additions and 384 deletions
+4 -20
View File
@@ -45,27 +45,13 @@ Already have a clone? Just run it directly:
kubectx/kubens, stern, yq, OpenTofu, terragrunt, task (go-task, the kubectx/kubens, stern, yq, OpenTofu, terragrunt, task (go-task, the
Taskfile runner), the 1Password CLI `op` for secrets storage, the Taskfile runner), the 1Password CLI `op` for secrets storage, the
opencode coding agent, zarf (DevSecOps for Offline and Airgapped opencode coding agent, zarf (DevSecOps for Offline and Airgapped
Kubernetes), packer (HashiCorp's infrastructure build tool), and the Kubernetes), and packer (HashiCorp's infrastructure build tool).
coder CLI for Coder workspaces.
kind is installed only when full (non-rootless) Docker is available — kind is installed only when full (non-rootless) Docker is available —
it doesn't work against rootless Docker, so it is skipped on Kasm. it doesn't work against rootless Docker, so it is skipped on Kasm.
No sudo, nothing outside `$HOME`, so tools persist across Kasm No sudo, nothing outside `$HOME`, so tools persist across Kasm
redeploys with no reinstall. Idempotent: each tool records its version redeploys with no reinstall. Idempotent: each tool records its version
under `~/.local/bin/.versions` and is skipped when the pinned version under `~/.local/bin/.versions` and is skipped when the pinned version
is already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`. is already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`.
- `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 vault/item), and
keeps them in `~/.config/dotfiles/op.env` (0600, not loaded into shells).
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 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 LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do
@@ -100,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 cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
``` ```
Variables modules look for. With `secrets/` set up, put these in your vault Variables modules look for:
item instead of `~/.zshrc.local`:
- `GITEA_KEY` — personal access token for `git.nic-oconnor.com`. Used 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 API/CLI work, and by `bootstrap.sh`/`mcp-config/` to read this repo when it
@@ -112,9 +97,8 @@ item instead of `~/.zshrc.local`:
don't set this by hand: run `mcp-config/install.sh` once and its shell 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` hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local`
only on a box without mcp-sync. only on a box without mcp-sync.
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token. Not a shell - `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for
variable any more: `secrets/install.sh` asks for it once and stores it in non-interactive `op read` / `op run`.
`~/.config/dotfiles/op.env`. Pass it in the env only for an unattended run.
- `GITEA_KEY`, `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` — **your own** - `GITEA_KEY`, `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` — **your own**
credentials for the client-side MCP servers in `mcp-config/`. opencode reads 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 them from your environment (`{env:VAR}`) and passes them to the MCP server it
-12
View File
@@ -204,17 +204,6 @@ install_terragrunt() {
mark_version terragrunt "$ver" 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. # 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 # WHY: these installs used to be bare calls, so under `set -eu` the FIRST
@@ -253,7 +242,6 @@ try install_1p
try install_opencode try install_opencode
try install_zarf try install_zarf
try install_packer try install_packer
try install_coder
if [ -n "$FAILED" ]; then if [ -n "$FAILED" ]; then
fsay "" fsay ""
-9
View File
@@ -82,15 +82,6 @@ run_module git
# Runs before bin/ so the kind install gate can see the rootful daemon. # Runs before bin/ so the kind install gate can see the rootful daemon.
run_module docker run_module docker
run_module bin 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 # Provides npx, which opencode needs to spawn the npm-based MCP servers in
# mcp-config/. Without it every MCP server silently fails to start. # mcp-config/. Without it every MCP server silently fails to start.
run_module node run_module node
+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 git config --global credential.helper store
say "credential.helper = store (~/.git-credentials, created on first auth)" say "credential.helper = store (~/.git-credentials, created on first auth)"
# Our own Gitea authenticates from $GITEA_KEY (exported by secrets/ from the say "Done. On the next HTTPS push/pull to a new host, git prompts once and caches the credential in ~/.git-credentials."
# 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."
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 # bootstrap run — and then models and skills silently vanish, because sync.sh
# never runs and OPENCODE_CONFIG is never exported. ~/.zshrc.local is the # never runs and OPENCODE_CONFIG is never exported. ~/.zshrc.local is the
# unmanaged file the managed zshrc sources last, so the hook persists. # unmanaged file the managed zshrc sources last, so the hook persists.
# case "${SHELL:-}" in
# Do NOT pick the file from $SHELL alone. On a fresh box bootstrap runs this *zsh) RC="$HOME/.zshrc.local" ;;
# from the user's ORIGINAL shell: zsh/ has just switched the login shell to zsh, *bash) RC="$HOME/.bashrc" ;;
# but $SHELL in this process still says bash. The hook then went into ~/.bashrc, *) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc.local" || RC="$HOME/.bashrc" ;;
# below zsh/'s auto-exec hook, so every new terminal exec'd into zsh before esac
# 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" touch "$RC"
# It sits alongside the user's secrets; keep it non-readable by others. # It sits alongside the user's secrets; keep it non-readable by others.
[ "$RC" = "$HOME/.zshrc.local" ] && chmod 0600 "$RC" 2>/dev/null || true [ "$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 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): def spec_for(mi):
spec = {} spec = {}
limit = {} limit = {}
max_in, max_out = num(mi.get("max_input_tokens")), num(mi.get("max_output_tokens")) if mi.get("max_input_tokens"):
if max_in and max_in > 0: limit["context"] = max(1, mi["max_input_tokens"] - CONTEXT_SAFETY_MARGIN)
limit["context"] = int(max(1, max_in - CONTEXT_SAFETY_MARGIN)) if mi.get("max_output_tokens"):
if max_out and max_out > 0: limit["output"] = min(mi["max_output_tokens"], OUTPUT_RESERVE_CAP)
limit["output"] = int(min(max_out, OUTPUT_RESERVE_CAP))
if limit: if limit:
spec["limit"] = limit spec["limit"] = limit
# LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens. # LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens.
cost = {} cost = {}
for src, dst in (("input_cost_per_token", "input"), for src, dst in (("input_cost_per_token", "input"),
("output_cost_per_token", "output")): ("output_cost_per_token", "output")):
per_token = num(mi.get(src)) if mi.get(src) is not None:
if per_token is not None and per_token >= 0: cost[dst] = mi[src] * 1_000_000
# round() so 5e-06 * 1e6 is 5.0, not 4.999999999999999.
cost[dst] = round(per_token * 1_000_000, 6)
if cost: if cost:
spec["cost"] = cost spec["cost"] = cost
for src, dst in (("supports_function_calling", "tool_call"), for src, dst in (("supports_function_calling", "tool_call"),
@@ -407,47 +378,10 @@ if not models:
except Exception: except Exception:
pass 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) prov = cfg.get("provider", {}).get(provider)
if prov is not None and models: if prov is not None and models:
prov["models"] = 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)) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest))
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2) json.dump(cfg, f, indent=2)
-155
View File
@@ -1,155 +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 (token) / defaults.
_env_token="${OP_SERVICE_ACCOUNT_TOKEN:-}"
_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)"
# 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}"
}
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
if [ -f "$SECRETS_ENV" ]; then
say "secrets: no service-account token; keeping existing $SECRETS_ENV."
exit 0
fi
# Probe in a subshell: in POSIX sh a failed redirect on a special builtin
# like `:` exits the whole script, which would skip the message below.
if ! ( : < /dev/tty ) 2>/dev/null; then
say "secrets: no OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt."
say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0"
exit 1
fi
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."; exit 1; }
[ -n "$OP_VAULT" ] || OP_VAULT="$(ask 'Vault' "$_user")"
[ -n "$OP_ITEM" ] || OP_ITEM="$(ask 'Item' "$_user")"
fi
OP_VAULT="${OP_VAULT:-$_user}"
OP_ITEM="${OP_ITEM:-$_user}"
export OP_SERVICE_ACCOUNT_TOKEN
# --- 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)."
# 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
-67
View File
@@ -74,73 +74,6 @@ grmb() {
return $rc 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 (prompts, hidden, when VALUE is
# omitted, which keeps the secret out of shell history),
# then refresh and reload this shell
# 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
_dotfiles_env_name "$name" opc || return
if (( $# >= 2 )); then
value=$2
else
read -rs "value?$name: " && print
fi
# [concealed] makes it a password field; an existing field of that name is updated.
_dotfiles_op sh -c 'op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[concealed]=$2" >/dev/null' \
opc "$name" "$value" && ops
}
opd() {
_dotfiles_env_name "$1" opd || return
_dotfiles_op sh -c 'op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[delete]" >/dev/null' 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 # Per-machine customizations (secrets, keys, local overrides) live in
# ~/.zshrc.local, which bootstrap never touches. See zsh/zshrc.local.example # ~/.zshrc.local, which bootstrap never touches. See zsh/zshrc.local.example
# for the variables modules look for. Loaded last so it can override anything. # 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 — per-machine customizations. Copy this file to
# ~/.zshrc.local and fill in what this box needs. It is sourced at the end # ~/.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 # of the managed ~/.zshrc, is never overwritten by bootstrap, and never
# lives in the repo. Secrets normally come from your 1Password vault instead # lives in the repo — this is the ONLY place to put secrets.
# (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 # 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 # 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. # hook, and only when clean — a tree you work in is read, never touched.
# --- 1Password ----------------------------------------------------------- # --- 1Password CLI -------------------------------------------------------
# Nothing to set here. Do NOT loop over `op read` in this file: that ran on # Service-account token for non-interactive `op read` / `op run`.
# every shell. secrets/install.sh stores the service-account token in # export OP_SERVICE_ACCOUNT_TOKEN=ops_...
# ~/.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 ------------------------------------------------------------- # --- Proxmox -------------------------------------------------------------
# One pair of credentials, several consumers: the proxmox MCP server # 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. # rke2-devops-airgap, whose Taskfile composes PROXMOX_VE_API_TOKEN from them.
# Unprefixed on purpose — these are Proxmox credentials, not MCP ones. # Unprefixed on purpose — these are Proxmox credentials, not MCP ones.
# #
# secrets.env already exports them from your vault item, so these lines are # The op-vault loop above already exports them from the `noconnor` item, so
# only needed without 1Password. Never committed. # these lines are only needed without 1Password. Never committed.
# export PROXMOX_TOKEN_ID="root@pam!terragrunt-proxmox" # export PROXMOX_TOKEN_ID="root@pam!terragrunt-proxmox"
# export PROXMOX_TOKEN_SECRET="00000000-0000-0000-0000-000000000000" # export PROXMOX_TOKEN_SECRET="00000000-0000-0000-0000-000000000000"
# #