@sebastienrousseau/dotfiles 0.2.519 → 0.2.520

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +1163 -163
  3. package/docs/ARCHITECTURE.md +117 -0
  4. package/docs/COPYRIGHT +1 -1
  5. package/docs/ECOSYSTEM.md +220 -0
  6. package/docs/GOLD-STANDARD-AUDIT.md +352 -0
  7. package/docs/GOVERNANCE.md +1 -1
  8. package/docs/MAINTAINERS.md +1 -1
  9. package/docs/MINIMUM-TOOLCHAIN.md +100 -0
  10. package/docs/README.md +1 -1
  11. package/docs/STRUCTURE.md +1 -1
  12. package/docs/architecture/ARCHITECTURE.md +11 -108
  13. package/docs/architecture/REPO_LAYOUT.md +3 -3
  14. package/docs/guides/MACOS_ICLOUD_SYMLINKS.md +121 -0
  15. package/docs/index.md +3 -3
  16. package/docs/manual/00-introduction.md +1 -1
  17. package/docs/manual/03-reference/01-dot-cli.md +28 -3
  18. package/docs/manual/command-index.md +24 -4
  19. package/docs/manual/concept-index.md +2 -0
  20. package/docs/migration/README.md +81 -0
  21. package/docs/migration/from-bare-git-repo.md +156 -0
  22. package/docs/migration/from-gnu-stow.md +165 -0
  23. package/docs/migration/from-plain-chezmoi.md +148 -0
  24. package/docs/migration/from-yadm.md +187 -0
  25. package/docs/operations/PERFORMANCE_BUDGETS.md +196 -0
  26. package/docs/operations/REGISTRY.md +1 -1
  27. package/docs/operations/RELEASE_PIPELINE.md +4 -4
  28. package/docs/operations/TESTING.md +3 -3
  29. package/docs/operations/TRACEABILITY.md +1 -0
  30. package/docs/packaging.md +222 -0
  31. package/docs/reference/FEATURE-MATRIX.md +646 -0
  32. package/docs/reference/TOOLS.md +1 -1
  33. package/docs/reference/UTILS.md +1 -0
  34. package/docs/security/COMPLIANCE.md +1 -1
  35. package/docs/security/DISCLOSURE.md +4 -4
  36. package/docs/security/FUZZING.md +113 -18
  37. package/docs/security/KEY_ROTATION.md +1 -1
  38. package/docs/security/SCORECARD.md +3 -3
  39. package/docs/security/VERIFY_RELEASE.md +2 -2
  40. package/install.sh +6 -6
  41. package/package.json +2 -2
  42. package/scripts/ci/check-copyright-headers.sh +1 -1
  43. package/scripts/ci/check-shell-preamble.sh +1 -1
  44. package/scripts/ci/guard-gitleaks-checkout.sh +1 -1
  45. package/scripts/demo/record.sh +1 -1
  46. package/scripts/diagnostics/a2a-conformance.sh +1 -1
  47. package/scripts/diagnostics/alias-governance.sh +30 -3
  48. package/scripts/diagnostics/aliases-cheatsheet.sh +1 -1
  49. package/scripts/diagnostics/aliases-manifest.sh +1 -1
  50. package/scripts/diagnostics/attest-verify.sh +147 -0
  51. package/scripts/diagnostics/benchmark.sh +1 -1
  52. package/scripts/diagnostics/conflicts.sh +1 -1
  53. package/scripts/diagnostics/doctor-unified.sh +6 -2
  54. package/scripts/diagnostics/doctor.sh +56 -10
  55. package/scripts/diagnostics/drift-dashboard.sh +3 -2
  56. package/scripts/diagnostics/health.sh +43 -10
  57. package/scripts/diagnostics/history-analysis.sh +1 -1
  58. package/scripts/diagnostics/mcp-doctor.sh +2 -2
  59. package/scripts/diagnostics/perf.sh +1 -1
  60. package/scripts/diagnostics/scorecard.sh +3 -2
  61. package/scripts/diagnostics/secret-governance.sh +1 -1
  62. package/scripts/diagnostics/security-score.sh +1 -1
  63. package/scripts/diagnostics/smoke-test.sh +1 -1
  64. package/scripts/diagnostics/snapshot.sh +1 -1
  65. package/scripts/diagnostics/verify.sh +1 -1
  66. package/scripts/diagnostics/verify_state.sh +1 -1
  67. package/scripts/diagnostics/version-locks.sh +1 -1
  68. package/scripts/diagnostics/workstation-attestation.sh +26 -1
  69. package/scripts/dot/commands/agent.sh +64 -14
  70. package/scripts/dot/commands/agents.sh +24 -8
  71. package/scripts/dot/commands/ai.sh +20 -7
  72. package/scripts/dot/commands/aliases.sh +1 -1
  73. package/scripts/dot/commands/appearance.sh +1 -1
  74. package/scripts/dot/commands/completion.sh +42 -5
  75. package/scripts/dot/commands/core.sh +1 -1
  76. package/scripts/dot/commands/diagnostics.sh +1 -1
  77. package/scripts/dot/commands/env-emit.sh +1 -1
  78. package/scripts/dot/commands/fleet.sh +41 -18
  79. package/scripts/dot/commands/init.sh +1 -1
  80. package/scripts/dot/commands/lint.sh +1 -1
  81. package/scripts/dot/commands/manual.sh +1 -1
  82. package/scripts/dot/commands/meta.sh +115 -10
  83. package/scripts/dot/commands/patterns.sh +1 -1
  84. package/scripts/dot/commands/registry.sh +43 -7
  85. package/scripts/dot/commands/restore.sh +1 -1
  86. package/scripts/dot/commands/secrets.sh +1 -1
  87. package/scripts/dot/commands/security.sh +1 -1
  88. package/scripts/dot/commands/tools.sh +21 -7
  89. package/scripts/fonts/install-nerd-fonts.sh +1 -1
  90. package/scripts/fonts/patch-fonts.sh +1 -1
  91. package/scripts/git-hooks/install.sh +1 -1
  92. package/scripts/git-hooks/pre-commit-audit.sh +2 -2
  93. package/scripts/lib/secrets_provider.sh +19 -4
  94. package/scripts/nvim/headless-upgrade.lua +81 -0
  95. package/scripts/ops/ai-setup.sh +1 -1
  96. package/scripts/ops/bundle.sh +1 -1
  97. package/scripts/ops/chaos.sh +1 -1
  98. package/scripts/ops/chezmoi-apply.sh +1 -1
  99. package/scripts/ops/chezmoi-diff.sh +1 -1
  100. package/scripts/ops/chezmoi-remove.sh +3 -3
  101. package/scripts/ops/chezmoi-update.sh +6 -2
  102. package/scripts/ops/heal-chezmoi.sh +1 -1
  103. package/scripts/ops/heal-system.sh +1 -1
  104. package/scripts/ops/heal-tools.sh +1 -1
  105. package/scripts/ops/heal.sh +1 -1
  106. package/scripts/ops/post-apply-repair.sh +1 -1
  107. package/scripts/ops/prewarm.sh +1 -1
  108. package/scripts/ops/release.sh +1 -1
  109. package/scripts/ops/rollback.sh +11 -2
  110. package/scripts/ops/setup.sh +1 -1
  111. package/scripts/ops/teleport.sh +1 -1
  112. package/scripts/qa/check-feature-matrix.sh +296 -0
  113. package/scripts/qa/check-version-consistency.sh +7 -119
  114. package/scripts/qa/coverage-baseline.sh +1 -1
  115. package/scripts/qa/docs-coverage.sh +9 -3
  116. package/scripts/qa/examples-coverage.sh +1 -1
  117. package/scripts/qa/reliability-audit.sh +1 -1
  118. package/scripts/qa/scorecard-snapshot.sh +1 -1
  119. package/scripts/qa/traceability-coverage.sh +9 -2
  120. package/scripts/qa/validate-examples.sh +66 -3
  121. package/scripts/qa/wsl-contract.sh +1 -1
  122. package/scripts/secrets/age-init.sh +1 -1
  123. package/scripts/secrets/create-secrets-file.sh +1 -1
  124. package/scripts/secrets/encrypt-ssh-key.sh +1 -1
  125. package/scripts/security/backup.sh +1 -1
  126. package/scripts/security/check-disclosure-key-expiry.sh +1 -1
  127. package/scripts/security/dns-doh.sh +1 -1
  128. package/scripts/security/encryption-check.sh +1 -1
  129. package/scripts/security/enforce-policies.sh +315 -98
  130. package/scripts/security/firewall.sh +1 -1
  131. package/scripts/security/lock-configs.sh +1 -1
  132. package/scripts/security/lock-screen.sh +1 -1
  133. package/scripts/security/manage-secrets.sh +1 -1
  134. package/scripts/security/ssh-cert.sh +1 -1
  135. package/scripts/security/telemetry-kill.sh +1 -1
  136. package/scripts/security/usb-safety.sh +1 -1
  137. package/scripts/theme/apply-gnome-theme.sh +1 -1
  138. package/scripts/theme/extract-heic-frames.sh +1 -1
  139. package/scripts/theme/extract-theme.py +284 -6
  140. package/scripts/theme/install-boot-logo.sh +1 -1
  141. package/scripts/theme/install-catppuccin-themes.sh +1 -1
  142. package/scripts/theme/install-cursors.sh +1 -1
  143. package/scripts/theme/install-file-icons.sh +1 -1
  144. package/scripts/theme/install-grub-theme.sh +1 -1
  145. package/scripts/theme/install-lock-icon.sh +1 -1
  146. package/scripts/theme/merge-wallpaper.sh +1 -1
  147. package/scripts/theme/rebuild-themes.sh +64 -5
  148. package/scripts/theme/switch.sh +46 -19
  149. package/scripts/theme/wallpaper-rotate.sh +1 -1
  150. package/scripts/theme/wallpaper-sync.sh +1 -1
  151. package/scripts/tools/cmatrix.sh +1 -1
  152. package/scripts/tools/detect-collisions.py +1 -1
  153. package/scripts/tools/emoji-picker.sh +1 -1
  154. package/scripts/tools/figlet-banner.sh +1 -1
  155. package/scripts/tools/log-rotate.sh +1 -1
  156. package/scripts/tools/lolcat-wrap.sh +1 -1
  157. package/scripts/tools/pipes.sh +1 -1
  158. package/scripts/tuning/linux.sh +1 -1
  159. package/scripts/tuning/macos.sh +1 -1
  160. package/scripts/uninstall.sh +1 -1
  161. package/scripts/verify-release-versions +156 -0
  162. package/scripts/version-sync.sh +63 -3
  163. package/LICENSE +0 -21
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  ## Dotfiles AI Commands.
5
5
  ##
@@ -22,12 +22,21 @@ AI_CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/dotfiles/ai"
22
22
  AI_STATUS_TTL="${DOTFILES_AI_STATUS_TTL:-300}"
23
23
  AI_STATUS_CACHE_FILE="${AI_CACHE_DIR}/status.tsv"
24
24
 
25
- # Fallback to source tree if patterns don't exist in config (common in CI)
25
+ # Fallback to source tree if patterns don't exist in config (common in CI).
26
+ # `.chezmoiroot` moves the source tree one level down (it holds
27
+ # `defaults` here), so probing <repo>/dot_config/… alone made this a
28
+ # no-op and `--style` died with "Pattern not found" off-deployment.
26
29
  if [[ ! -d "$PATTERN_DIR" ]]; then
27
30
  _AI_SRC="$(cd "$SCRIPT_DIR/../../.." && pwd)"
28
- if [[ -d "$_AI_SRC/dot_config/ai/patterns" ]]; then
29
- PATTERN_DIR="$_AI_SRC/dot_config/ai/patterns"
30
- fi
31
+ _AI_SUB=""
32
+ [[ -f "$_AI_SRC/.chezmoiroot" ]] &&
33
+ _AI_SUB="/$(head -1 "$_AI_SRC/.chezmoiroot" | tr -d '[:space:]')"
34
+ for _AI_CAND in "$_AI_SRC/dot_config/ai/patterns" "$_AI_SRC$_AI_SUB/dot_config/ai/patterns"; do
35
+ if [[ -d "$_AI_CAND" ]]; then
36
+ PATTERN_DIR="$_AI_CAND"
37
+ break
38
+ fi
39
+ done
31
40
  fi
32
41
 
33
42
  _show_ai_bridge_usage() {
@@ -188,7 +197,7 @@ cmd_ai_status() {
188
197
  done
189
198
 
190
199
  # Offer to install missing providers via mise
191
- if [[ ${#missing[@]} -gt 0 ]] && has_command mise; then
200
+ if [[ ${#missing[@]} -gt 0 ]] && has_command mise && [[ -t 0 && -t 1 ]] && [[ "${DOTFILES_NONINTERACTIVE:-0}" != "1" ]]; then
192
201
  echo ""
193
202
  local _ai_install_action=""
194
203
  if has_command gum; then
@@ -288,6 +297,8 @@ cmd_ai_status() {
288
297
  echo ""
289
298
  if [ ${#installed[@]} -eq 0 ]; then
290
299
  ui_warn "No AI CLIs installed"
300
+ elif [[ ! -t 0 || ! -t 1 || "${DOTFILES_NONINTERACTIVE:-0}" == "1" ]]; then
301
+ return 0
291
302
  elif has_command gum; then
292
303
  ui_info "Launch" "Select an AI CLI to start"
293
304
  local -a choices=()
@@ -404,7 +415,9 @@ ${prompt}"
404
415
  do_install=$(gum confirm "Install $tool via mise ($mise_pkg)?" && echo "yes" || echo "no")
405
416
  else
406
417
  printf "Install %s via mise (%s)? [y/N] " "$tool" "$mise_pkg"
407
- read -r do_install
418
+ # `|| true`: at EOF (piped, cron, CI) `read` returns 1 and
419
+ # `set -e` killed the script mid-prompt. EOF means "no".
420
+ read -r do_install || true
408
421
  case "$do_install" in y | Y | yes) do_install="yes" ;; *) do_install="no" ;; esac
409
422
  fi
410
423
  if [[ "$do_install" == "yes" ]]; then
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Aliases Commands (extracted from tools.sh)
5
5
  # aliases list|search|why|stats|cheatsheet|tiers, alias-check
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Appearance Commands
5
5
  # theme, wallpaper, fonts, tune
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  ## `dot completion <bash|zsh|fish|nu>` — emit shell completion for `dot`,
5
5
  ## generated from the canonical _dot_help_specs registry in bin/dot. Using one
@@ -35,7 +35,11 @@ _completion_commands() {
35
35
  in_func && /cat <<'\''EOF'\''/ { in_block = 1; next }
36
36
  in_block && /^EOF$/ { exit }
37
37
  in_block && NF >= 3 && $2 !~ / / {
38
- name = $2; desc = $3
38
+ name = $2
39
+ # Description = fields 3..NF-1 rejoined: registry descriptions can
40
+ # contain "|" (e.g. "--ai|-A") and the last field is the hint.
41
+ desc = $3
42
+ for (i = 4; i < NF; i++) desc = desc "|" $i
39
43
  gsub(/^[ \t]+|[ \t]+$/, "", name)
40
44
  gsub(/^[ \t]+|[ \t]+$/, "", desc)
41
45
  gsub(/[":\047]/, "", desc)
@@ -54,7 +58,9 @@ _completion_subcommands() {
54
58
  in_func && /cat <<'\''EOF'\''/ { in_block = 1; next }
55
59
  in_block && /^EOF$/ { exit }
56
60
  in_block && NF >= 3 && $2 ~ / / {
57
- full = $2; desc = $3
61
+ full = $2
62
+ desc = $3
63
+ for (i = 4; i < NF; i++) desc = desc "|" $i
58
64
  gsub(/^[ \t]+|[ \t]+$/, "", full)
59
65
  gsub(/^[ \t]+|[ \t]+$/, "", desc)
60
66
  gsub(/[":\047]/, "", desc)
@@ -65,11 +71,42 @@ _completion_subcommands() {
65
71
  ' "$cli"
66
72
  }
67
73
 
74
+ # The printf templates below are the *text of the generated script*;
75
+ # `$cur`, `$prev`, `$commands` must reach the output unexpanded.
76
+ # shellcheck disable=SC2016
68
77
  gen_bash() {
69
- local names
78
+ local names parent child desc
70
79
  names="$(_completion_commands | cut -f1 | tr '\n' ' ')"
71
80
  printf '# dot bash completion — generated by: dot completion bash\n'
72
- printf "complete -W '%s' dot\n" "${names% }"
81
+ printf '# Source this file, or install it to a bash-completion completions dir.\n'
82
+ printf '_dot_completions() {\n'
83
+ printf ' local cur prev commands\n'
84
+ printf ' COMPREPLY=()\n'
85
+ printf ' cur="${COMP_WORDS[COMP_CWORD]}"\n'
86
+ printf ' prev="${COMP_WORDS[COMP_CWORD - 1]}"\n'
87
+ printf " commands='%s'\n" "${names% }"
88
+ printf ' case "$prev" in\n'
89
+ printf ' dot)\n'
90
+ printf ' COMPREPLY=($(compgen -W "$commands" -- "$cur"))\n'
91
+ printf ' return 0\n'
92
+ printf ' ;;\n'
93
+ # One case arm per parent command that has subcommands.
94
+ while IFS=$'\t' read -r parent child desc; do
95
+ printf '%s\t%s\n' "$parent" "$child"
96
+ done < <(_completion_subcommands) |
97
+ awk -F'\t' '{ subs[$1] = subs[$1] (subs[$1] == "" ? "" : " ") $2 }
98
+ END { for (p in subs) printf "%s\t%s\n", p, subs[p] }' |
99
+ LC_ALL=C sort |
100
+ while IFS=$'\t' read -r parent child; do
101
+ printf ' %s)\n' "$parent"
102
+ printf ' COMPREPLY=($(compgen -W "%s" -- "$cur"))\n' "$child"
103
+ printf ' return 0\n'
104
+ printf ' ;;\n'
105
+ done
106
+ printf ' esac\n'
107
+ printf ' COMPREPLY=($(compgen -f -- "$cur"))\n'
108
+ printf '}\n'
109
+ printf 'complete -F _dot_completions dot\n'
73
110
  }
74
111
 
75
112
  gen_zsh() {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  ## Dotfiles Core Commands — Core chezmoi operations for the dot CLI.
5
5
  ##
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Diagnostics Commands
5
5
  # doctor, heal, health, rollback, drift, history, benchmark, verify, perf,
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  #
5
5
  # `dot env emit` — render a portable workstation-environment
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Fleet Commands
5
5
  # fleet status|nodes|drift|events|namespace
@@ -93,7 +93,8 @@ cmd_fleet_status() {
93
93
  local drift_status="clean"
94
94
  if has_command chezmoi; then
95
95
  local drift_output
96
- drift_output="$(chezmoi status 2>/dev/null || true)"
96
+ # --exclude=always: always-run scripts are pending by design, not drift.
97
+ drift_output="$(chezmoi status --exclude=always 2>/dev/null || true)"
97
98
  if [[ -n "$drift_output" ]]; then
98
99
  drift_status="drifted"
99
100
  fi
@@ -168,7 +169,7 @@ cmd_fleet_drift() {
168
169
  fi
169
170
 
170
171
  local drift_output
171
- drift_output="$(chezmoi status 2>/dev/null || true)"
172
+ drift_output="$(chezmoi status --exclude=always 2>/dev/null || true)"
172
173
 
173
174
  _fleet_drift_append_history "$drift_output"
174
175
 
@@ -206,9 +207,12 @@ cmd_fleet_drift() {
206
207
  tail -n "$count" "$_DRIFT_HISTORY_FILE" | while IFS= read -r line; do
207
208
  local time status file_count
208
209
  # One jq per line (was three: .time, .status, .files|length).
210
+ # `|| true`: on an unparsable line jq prints nothing, `read`
211
+ # hits EOF and returns 1, and `set -e` would otherwise abort the
212
+ # whole listing instead of rendering the `?` placeholders below.
209
213
  IFS=$'\t' read -r time status file_count < <(
210
214
  printf '%s' "$line" | jq -r '[.time, .status, (.files | length)] | @tsv' 2>/dev/null
211
- )
215
+ ) || true
212
216
  [[ -n "$time" ]] || time="?"
213
217
  [[ -n "$status" ]] || status="?"
214
218
  [[ -n "$file_count" ]] || file_count=0
@@ -298,23 +302,42 @@ cmd_fleet_namespace() {
298
302
  validate_name "$new_ns" "namespace"
299
303
  local data_file
300
304
  data_file="$(resolve_chezmoi_source_dir)/.chezmoidata.toml"
301
- if grep -q "^namespace = " "$data_file" 2>/dev/null; then
302
- # Atomic write: render into a tempfile + mv so concurrent
303
- # `dot fleet namespace set` callers can't corrupt the TOML.
304
- # Avoids `sed -i` portability dance (GNU `-i` vs BSD `-i ''`).
305
- local _tmp
306
- _tmp="$(mktemp "${data_file}.XXXXXX")" || die "Cannot create tempfile"
307
- # Explicit if/else instead of A && B || C — the latter (SC2015)
308
- # silently runs C when B itself fails, masking real mv errors.
305
+ [[ -f "$data_file" ]] || die ".chezmoidata.toml not found: $data_file"
306
+ # Atomic write: render into a tempfile + mv so concurrent
307
+ # `dot fleet namespace set` callers can't corrupt the TOML.
308
+ # Avoids `sed -i` portability dance (GNU `-i` vs BSD `-i ''`).
309
+ local _tmp _rendered=0
310
+ _tmp="$(mktemp "${data_file}.XXXXXX")" || die "Cannot create tempfile"
311
+ # Explicit if/else instead of A && B || C — the latter (SC2015)
312
+ # silently runs C when B itself fails, masking real mv errors.
313
+ if grep -q "^namespace = " "$data_file"; then
309
314
  if sed "s/^namespace = \".*\"/namespace = \"$new_ns\"/" "$data_file" >"$_tmp"; then
310
- if ! mv "$_tmp" "$data_file"; then
311
- rm -f "$_tmp"
312
- die "Failed to commit namespace update"
313
- fi
314
- else
315
+ _rendered=1
316
+ fi
317
+ else
318
+ # No key yet — the shipped .chezmoidata.toml has none, and rewriting
319
+ # only when one already existed made `set` a silent no-op on a fresh
320
+ # checkout while still reporting success. Insert it after the first
321
+ # line: appending at the end would land the key inside whatever
322
+ # [table] the file happens to end with, which TOML reads as a
323
+ # different key entirely. `dot profile set` does the same for
324
+ # `profile`.
325
+ if awk -v ns="$new_ns" '
326
+ NR == 1 { print; printf "namespace = \"%s\"\n", ns; inserted = 1; next }
327
+ { print }
328
+ END { if (!inserted) printf "namespace = \"%s\"\n", ns }
329
+ ' "$data_file" >"$_tmp"; then
330
+ _rendered=1
331
+ fi
332
+ fi
333
+ if [[ "$_rendered" -eq 1 ]]; then
334
+ if ! mv "$_tmp" "$data_file"; then
315
335
  rm -f "$_tmp"
316
- die "Failed to render namespace update"
336
+ die "Failed to commit namespace update"
317
337
  fi
338
+ else
339
+ rm -f "$_tmp"
340
+ die "Failed to render namespace update"
318
341
  fi
319
342
  ui_ok "Namespace" "Set to '$new_ns'. Run 'dot sync' to apply."
320
343
  _fleet_emit_event "namespace_set" "ok" "namespace=$new_ns"
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # shellcheck shell=bash
5
5
  #
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Lint Command
5
5
  # Wraps shellcheck and shfmt with project-specific flags
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # dot manual — open or download the dotfiles manual in multiple formats.
5
5
  #
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Meta Commands
5
5
  # upgrade, prewarm, docs, learn, keys, sandbox, mcp, mode, agent
@@ -28,7 +28,12 @@ meta_banner_section() {
28
28
  esac
29
29
  }
30
30
 
31
- dot_ui_command_banner "$(meta_banner_section "${1:-}")" "${1:-}" "$@"
31
+ # `mcp serve` owns stdout from its first byte — it is a JSON-RPC frame stream,
32
+ # not a terminal — so the banner is suppressed for it outright. (It already
33
+ # self-suppresses off a TTY; this also covers a client that allocates a pty.)
34
+ if [[ "${1:-}" != "mcp" || "${2:-}" != "serve" ]]; then
35
+ dot_ui_command_banner "$(meta_banner_section "${1:-}")" "${1:-}" "$@"
36
+ fi
32
37
 
33
38
  # Last meaningful line of a captured log, used as a step's `ok` detail.
34
39
  # Collapses carriage-return progress (git/nvim spam \r), strips ANSI,
@@ -65,7 +70,13 @@ cmd_upgrade() {
65
70
  [[ "${1:-}" == "--" ]] && shift
66
71
  local log="$log_dir/$id.log" rc=0
67
72
  ui_step "$id" "$label" run "$running"
68
- "$@" >"$log" 2>&1 || rc=$?
73
+ # stdin is closed on purpose. Output is captured to a log, so a phase
74
+ # that prompts (chezmoi's "X has changed since chezmoi last wrote it?",
75
+ # a git credential helper, nvim waiting on a keypress) would otherwise
76
+ # block on the terminal with its question invisible — the 2026-09-12
77
+ # "chezmoi update seems very slow" report. With no stdin the prompt
78
+ # fails fast, the step is marked failed, and the tail below says why.
79
+ "$@" >"$log" 2>&1 </dev/null || rc=$?
69
80
  if [[ "$rc" -eq 0 ]]; then
70
81
  ui_step "$id" "" ok "$(_upgrade_last_line "$log")"
71
82
  else
@@ -84,11 +95,27 @@ cmd_upgrade() {
84
95
  _upgrade_step nix-gc "Nix GC" "collecting…" -- nix-collect-garbage -d
85
96
  fi
86
97
 
87
- _upgrade_step dotfiles "Dotfiles" "chezmoi update…" -- chezmoi update
98
+ # --no-tty: chezmoi otherwise opens /dev/tty directly for its
99
+ # overwrite prompt, bypassing the closed stdin above and hanging.
100
+ _upgrade_step dotfiles "Dotfiles" "chezmoi update…" -- chezmoi update --no-tty
88
101
 
89
102
  if has_command nvim; then
90
- _upgrade_step nvim "Neovim plugins" "Lazy sync…" -- \
91
- nvim --headless "+Lazy! sync" +qa
103
+ # scripts/nvim/headless-upgrade.lua runs Lazy sync AND waits for
104
+ # Mason's async install queue to drain, so ensure_installed installs
105
+ # (codelldb, debugpy, delve, etc.) aren't aborted by an early quitall.
106
+ #
107
+ # `nvim -l` does NOT load the user's init.lua, so without an explicit
108
+ # -u the script found no lazy.nvim and logged "skipping plugin update"
109
+ # on every run — the upgrade step was a silent no-op. Point -u at the
110
+ # config nvim would load itself (honouring XDG_CONFIG_HOME and
111
+ # NVIM_APPNAME) and skip visibly when there is none to load.
112
+ local nvim_init="${XDG_CONFIG_HOME:-$HOME/.config}/${NVIM_APPNAME:-nvim}/init.lua"
113
+ if [ -f "$nvim_init" ]; then
114
+ _upgrade_step nvim "Neovim plugins" "Lazy sync + Mason drain…" -- \
115
+ nvim --headless -u "$nvim_init" -l "$src_dir/scripts/nvim/headless-upgrade.lua"
116
+ else
117
+ ui_step nvim "Neovim plugins" skip "no init.lua at $nvim_init"
118
+ fi
92
119
  fi
93
120
 
94
121
  if [ "${DOTFILES_FONTS:-}" = "1" ] &&
@@ -109,6 +136,9 @@ cmd_upgrade() {
109
136
  while ((i < n)); do
110
137
  ui_err "${fail_labels[$i]}" "log tail:"
111
138
  tail -n 15 "${fail_logs[$i]}" | sed 's/^/ /'
139
+ if grep -q 'has changed since chezmoi last wrote it' "${fail_logs[$i]}"; then
140
+ ui_info "Hint" "a managed file was edited locally — run 'chezmoi apply' to choose per file, or 'chezmoi update --force' to overwrite"
141
+ fi
112
142
  ((i++)) || true
113
143
  done
114
144
  ui_info "Logs" "$log_dir"
@@ -196,11 +226,33 @@ cmd_keys() {
196
226
  return 0
197
227
  fi
198
228
 
199
- if [ -n "$src_dir" ] && [ -f "$src_dir/docs/KEYS.md" ]; then
229
+ # The keybindings document lives at docs/security/KEYS.md. Probing only
230
+ # docs/KEYS.md meant every `dot keys` fell through to
231
+ # scripts/diagnostics/keys.sh, which is not in the tree either, so the
232
+ # command could only ever report "Keys script not found". The legacy path
233
+ # stays in the list for trees that predate the move.
234
+ local keys_doc=""
235
+ if [ -n "$src_dir" ]; then
236
+ local candidate
237
+ for candidate in docs/security/KEYS.md docs/KEYS.md; do
238
+ if [ -f "$src_dir/$candidate" ]; then
239
+ keys_doc="$src_dir/$candidate"
240
+ break
241
+ fi
242
+ done
243
+ fi
244
+
245
+ if [ -n "$keys_doc" ]; then
200
246
  if [ -n "${1:-}" ]; then
201
- rg -i --fixed-strings --context 1 "${1:-}" "$src_dir/docs/KEYS.md" || true
247
+ # ripgrep is not installed everywhere (the CI runners have none), and
248
+ # a bare `rg` there printed nothing at all while exiting 0.
249
+ if has_command rg; then
250
+ rg -i --fixed-strings --context 1 "${1:-}" "$keys_doc" || true
251
+ else
252
+ grep -i -F -C 1 -- "${1:-}" "$keys_doc" || true
253
+ fi
202
254
  else
203
- exec cat "$src_dir/docs/KEYS.md"
255
+ exec cat "$keys_doc"
204
256
  fi
205
257
  else
206
258
  run_script "scripts/diagnostics/keys.sh" "Keys script" "$@"
@@ -268,13 +320,66 @@ cmd_mcp() {
268
320
  exec cat "$registry_file"
269
321
  fi
270
322
  ;;
323
+ serve)
324
+ cmd_mcp_serve "$@"
325
+ ;;
271
326
  *)
272
- echo "Usage: dot mcp [doctor|registry]" >&2
327
+ echo "Usage: dot mcp [doctor|registry|serve]" >&2
273
328
  exit 1
274
329
  ;;
275
330
  esac
276
331
  }
277
332
 
333
+ # cmd_mcp_serve — exec the stdio MCP server (dot-mcp).
334
+ #
335
+ # This is the transport `.well-known/mcp/server-card.json` advertises:
336
+ # JSON-RPC 2.0 over newline-delimited frames on stdin/stdout. Nothing this
337
+ # function prints may reach stdout, because stdout is the protocol stream from
338
+ # the moment the client launches us — every message below goes to stderr, and
339
+ # the server is exec'd so it owns the descriptors outright.
340
+ #
341
+ # The binary is deployed by run_onchange_26-build-dot-mcp.sh. When it is not
342
+ # built yet and a Go toolchain is available, it is built on demand into the
343
+ # user cache rather than failing: a client that trusted the card must get a
344
+ # server, not an error.
345
+ cmd_mcp_serve() {
346
+ local repo_root="" source_dir bin src cache
347
+ # resolve_chezmoi_source_dir points at the chezmoi source (the `defaults/`
348
+ # directory); the server wants the repository above it. Fall back to the
349
+ # checkout root when chezmoi is not configured.
350
+ source_dir="$(resolve_chezmoi_source_dir 2>/dev/null || true)"
351
+ if [[ -n "$source_dir" && -d "$source_dir" ]]; then
352
+ repo_root="$(cd "$source_dir/.." && pwd)"
353
+ fi
354
+ [[ -n "$repo_root" ]] || repo_root="$(require_source_dir)"
355
+
356
+ # Tell the server which tree it is serving, and which CLI to shell out to,
357
+ # so it never has to guess either.
358
+ export DOT_MCP_REPO_ROOT="$repo_root"
359
+ export DOT_MCP_DOT_BIN="${DOT_MCP_DOT_BIN:-$(command -v dot || echo dot)}"
360
+
361
+ if bin="$(command -v dot-mcp 2>/dev/null)"; then
362
+ exec "$bin" serve "$@"
363
+ fi
364
+ if [[ -x "$HOME/.local/bin/dot-mcp" ]]; then
365
+ exec "$HOME/.local/bin/dot-mcp" serve "$@"
366
+ fi
367
+
368
+ src="$repo_root/defaults/dot_local/share/dot-mcp"
369
+ [[ -d "$src" ]] || src="$HOME/.local/share/dot-mcp"
370
+ if [[ -f "$src/main.go" ]] && has_command go; then
371
+ cache="${XDG_CACHE_HOME:-$HOME/.cache}/dotfiles"
372
+ mkdir -p "$cache"
373
+ echo "dot mcp serve: building dot-mcp (first run)…" >&2
374
+ if (cd "$src" && go build -o "$cache/dot-mcp" .) >&2; then
375
+ exec "$cache/dot-mcp" serve "$@"
376
+ fi
377
+ fi
378
+
379
+ echo "dot mcp serve: dot-mcp is not built. Run 'chezmoi apply' (or 'go build' in $src) to install it." >&2
380
+ exit 1
381
+ }
382
+
278
383
  # Dispatch — cmd_mode is defined in agent.sh (sourced above)
279
384
  case "${1:-}" in
280
385
  upgrade)
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # AI Steering Patterns Management.
5
5
  # Usage: dot patterns [list|view|edit] [name]
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # shellcheck shell=bash
5
5
  #
@@ -76,6 +76,38 @@ _registry_cache_dir() {
76
76
  printf '%s/dotfiles/registry\n' "${XDG_CACHE_HOME:-$HOME/.cache}"
77
77
  }
78
78
 
79
+ ## The index cache is keyed by URL, not by one fixed path.
80
+ ##
81
+ ## It used to live at <cache>/index.json for every registry, and be treated as
82
+ ## fresh for six hours: a freshness window says nothing about WHICH registry
83
+ ## produced the file, so changing DOTFILES_REGISTRY_URL — or running
84
+ ## `dot registry set-url` — kept serving the previous registry's index for up
85
+ ## to six hours. Keying by URL also means switching back and forth does not
86
+ ## re-download.
87
+ ##
88
+ ## SHA-256 where a hasher exists, POSIX `cksum` otherwise: this is a cache
89
+ ## key, not a security boundary, and every index is schema-validated on read.
90
+ _registry_cache_key() {
91
+ local url="$1" digest=""
92
+ if command -v shasum >/dev/null 2>&1; then
93
+ digest="$(printf '%s' "$url" | shasum -a 256 2>/dev/null | awk '{print $1}')"
94
+ elif command -v sha256sum >/dev/null 2>&1; then
95
+ digest="$(printf '%s' "$url" | sha256sum 2>/dev/null | awk '{print $1}')"
96
+ fi
97
+ if [[ -z "$digest" ]]; then
98
+ digest="$(printf '%s' "$url" | cksum | awk '{print $1 "-" $2}')"
99
+ fi
100
+ printf '%s\n' "${digest:0:32}"
101
+ }
102
+
103
+ ## _registry_cache_file [url] — absolute path of the cached index for a URL
104
+ ## (the active one by default). Tests seed the cache through this.
105
+ _registry_cache_file() {
106
+ local url="${1:-}"
107
+ [[ -n "$url" ]] || url="$(_registry_url)"
108
+ printf '%s/index-%s.json\n' "$(_registry_cache_dir)" "$(_registry_cache_key "$url")"
109
+ }
110
+
79
111
  _registry_data_dir() {
80
112
  printf '%s/dotfiles/modules\n' "${XDG_DATA_HOME:-$HOME/.local/share}"
81
113
  }
@@ -95,15 +127,19 @@ _registry_validate_index() {
95
127
  ' "$index" >/dev/null 2>&1
96
128
  }
97
129
 
130
+ # Prints the path of a validated index file on stdout. Every diagnostic goes
131
+ # to stderr: stdout is this function's return channel, and callers read it
132
+ # with `index="$(_registry_fetch)"` — a warning printed here would be
133
+ # captured into that variable and then handed to jq as a filename.
98
134
  _registry_fetch() {
99
135
  local url cache_dir cache_file
100
136
  url="$(_registry_url)"
101
137
  [[ "$url" =~ ^(https://|file://) ]] || {
102
- ui_err "registry" "registry URL must use https:// (or file:// for local testing)"
138
+ ui_err "registry" "registry URL must use https:// (or file:// for local testing)" >&2
103
139
  return 1
104
140
  }
105
141
  cache_dir="$(_registry_cache_dir)"
106
- cache_file="$cache_dir/index.json"
142
+ cache_file="$(_registry_cache_file "$url")"
107
143
  mkdir -p "$cache_dir"
108
144
  # Refresh if older than 6h or missing.
109
145
  local now mtime
@@ -119,7 +155,7 @@ _registry_fetch() {
119
155
  fi
120
156
  fi
121
157
  if ! command -v curl >/dev/null 2>&1; then
122
- ui_err "registry" "curl not installed"
158
+ ui_err "registry" "curl not installed" >&2
123
159
  return 127
124
160
  fi
125
161
  local tmp
@@ -137,17 +173,17 @@ _registry_fetch() {
137
173
  if ! curl "${curl_args[@]}" -o "$tmp" "$url" >/dev/null; then
138
174
  rm -f "$tmp"
139
175
  if [[ -s "$cache_file" ]]; then
140
- ui_warn "registry" "fetch failed; using stale cache at $cache_file"
176
+ ui_warn "registry" "fetch failed; using stale cache at $cache_file" >&2
141
177
  printf '%s\n' "$cache_file"
142
178
  return 0
143
179
  fi
144
- ui_err "registry" "could not fetch $url"
180
+ ui_err "registry" "could not fetch $url" >&2
145
181
  return 1
146
182
  fi
147
183
  mv "$tmp" "$cache_file"
148
184
  if ! _registry_validate_index "$cache_file"; then
149
185
  rm -f "$cache_file"
150
- ui_err "registry" "index failed schema and integrity validation"
186
+ ui_err "registry" "index failed schema and integrity validation" >&2
151
187
  return 1
152
188
  fi
153
189
  printf '%s\n' "$cache_file"
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # dot restore - Restore dotfiles from backup or previous state
5
5
  # Usage: dot restore [--list|-l|--latest|-L|<backup-id>]
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Secrets/Env Commands
5
5
  # secrets-init, secrets, secrets-create, ssh-key, env load
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Security Commands
5
5
  # backup, encrypt-check, firewall, telemetry, dns-doh, lock-screen, usb-safety
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # SPDX-License-Identifier: MIT
2
+ # SPDX-License-Identifier: Apache-2.0 OR MIT
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # Dotfiles CLI - Tools Commands
5
5
  # tools, new, packages, log-rotate, aliases, alias-check, env, profile
@@ -196,7 +196,15 @@ show_language_package_managers() {
196
196
  # mid-output. Capture the pipeline separately so we can fall back
197
197
  # cleanly without stray "N/A" lines from pipe-with-|| tricks.
198
198
  if pipx_list_out="$(pipx list --short 2>/dev/null)"; then
199
- pipx_installed="$(printf '%s' "$pipx_list_out" | wc -l | tr -d ' ')"
199
+ # Command substitution strips the trailing newline, so counting
200
+ # with `printf '%s' … | wc -l` reported one package fewer than
201
+ # installed (and 0 when exactly one was installed). Re-add the
202
+ # terminator, and treat "no output" as zero rather than one.
203
+ if [[ -n "$pipx_list_out" ]]; then
204
+ pipx_installed="$(printf '%s\n' "$pipx_list_out" | wc -l | tr -d ' ')"
205
+ else
206
+ pipx_installed=0
207
+ fi
200
208
  else
201
209
  pipx_installed="N/A"
202
210
  fi
@@ -245,11 +253,17 @@ cmd_tools() {
245
253
  exit 1
246
254
  fi
247
255
  elif [ "$subcommand" = "docs" ]; then
248
- if [ -n "$src_dir" ] && [ -f "$src_dir/docs/TOOLS.md" ]; then
249
- exec cat "$src_dir/docs/TOOLS.md"
250
- elif [ -n "$src_dir" ] && [ -f "$src_dir/docs/UTILS.md" ]; then
251
- exec cat "$src_dir/docs/UTILS.md"
252
- fi
256
+ # docs/reference/ first: both documents were moved there, and looking
257
+ # only in docs/ meant `dot tools docs` answered "TOOLS.md not found" on a
258
+ # complete checkout. The legacy paths stay in the list so a pre-reorg
259
+ # tree still resolves.
260
+ local doc
261
+ for doc in docs/reference/TOOLS.md docs/reference/UTILS.md \
262
+ docs/TOOLS.md docs/UTILS.md; do
263
+ if [ -n "$src_dir" ] && [ -f "$src_dir/$doc" ]; then
264
+ exec cat "$src_dir/$doc"
265
+ fi
266
+ done
253
267
  ui_err "Docs" "TOOLS.md not found"
254
268
  exit 1
255
269
  else