@masculinecache/chezmoi-axi 0.1.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +309 -0
  3. package/chezmoi-axi +713 -0
  4. package/package.json +37 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 masculinecache
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,309 @@
1
+ # chezmoi-axi
2
+
3
+ Agent-ergonomic TOON wrapper around [chezmoi](https://www.chezmoi.io/) for token-efficient dotfiles management.
4
+
5
+ `chezmoi-axi` is a thin, [AXI](https://toonformat.dev/)-compliant bash CLI that wraps the `chezmoi` binary and emits
6
+ [TOON](https://toonformat.dev/) on stdout — compact, token-efficient output that agents can consume without a
7
+ JSON-parsing round trip. It stays close to chezmoi's own command surface: anything you would do with `chezmoi`
8
+ you can do with `chezmoi-axi`, plus predictable list schemas, definitive empty states, aggregate counts, content
9
+ truncation with a `--full` escape hatch, and structured errors with actionable suggestions.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install -g @masculinecache/chezmoi-axi
15
+ ```
16
+
17
+ Or copy the single self-contained script to a directory on `PATH`:
18
+
19
+ ```sh
20
+ install -m 0755 chezmoi-axi ~/.local/bin/chezmoi-axi
21
+ ```
22
+
23
+ Requires the `chezmoi` binary on `PATH` (install it with `brew install chezmoi`, your distro package manager, or
24
+ [the official installer](https://www.chezmoi.io/install/)). The wrapper resolves the chezmoi source directory from
25
+ `$HOME` at runtime (default `~/.local/share/chezmoi`), so it works on any machine without editing the script.
26
+
27
+ ## Usage
28
+
29
+ ```
30
+ chezmoi-axi <command> [args]
31
+ ```
32
+
33
+ Run with no arguments to see the home view (content first — live state, not a manual).
34
+
35
+ ```
36
+ $ chezmoi-axi
37
+ chezmoi:
38
+ bin: ~/.local/bin/chezmoi-axi
39
+ description: Agent-ergonomic chezmoi wrapper for dotfiles management
40
+ managed: 1039
41
+ changed: 2
42
+ encrypted: 38
43
+ last_sync: 2h ago
44
+ help[3]:
45
+ Run `chezmoi-axi list --changed` to see pending changes
46
+ Run `chezmoi-axi diff` to review diffs
47
+ Run `chezmoi-axi list` to see all managed files
48
+ ```
49
+
50
+ Every subcommand supports `--help` (and `-h`) with a concise usage summary. Unknown flags fail loudly, list the
51
+ valid flags, and exit `2` so the agent self-corrects in one step.
52
+
53
+ ## Commands
54
+
55
+ ### `status`
56
+
57
+ Home view — shows current state at a glance. This is the default when run with no arguments.
58
+
59
+ ```
60
+ $ chezmoi-axi status
61
+ ```
62
+
63
+ Outputs managed count, pending-diff count, encrypted count, and last sync time (derived from the most recent
64
+ commit in the chezmoi source). The `bin:` line collapses paths under `$HOME` to `~`.
65
+
66
+ ### `list`
67
+
68
+ List managed files with a minimal schema: `path`, `type`, `encrypted`.
69
+
70
+ ```
71
+ $ chezmoi-axi list
72
+ files[5]{path,type,encrypted}:
73
+ ~/.bashrc,shell,no
74
+ ~/.config/opencode/opencode.json,json,no
75
+ ~/.config/opencode/.groq-key,file,maybe
76
+ ~/.ssh/config,ssh,no
77
+ ~/.tmux.conf,config,no
78
+ count: 5 of 1039 total
79
+ help[2]:
80
+ Run `chezmoi-axi add <file>` to track a new file
81
+ Run `chezmoi-axi diff` to review changes
82
+ ```
83
+
84
+ | Sub | Meaning |
85
+ | --- | --- |
86
+ | `list` | All managed files |
87
+ | `list --changed` | Only files with pending diffs |
88
+ | `list --encrypted` | Only encrypted (age) files |
89
+ | `list --full` | Show every file (bypass the 50-row cap) |
90
+
91
+ When the result exceeds 50 rows, the list is truncated to the first 50 and a help hint points at `--full` to see
92
+ all of them. An empty result is stated explicitly:
93
+
94
+ ```
95
+ files: 0 changed files found
96
+ help[1]:
97
+ Run `chezmoi-axi list` to see all managed files
98
+ ```
99
+
100
+ ### `diff`
101
+
102
+ Show differences between source state and installed files.
103
+
104
+ ```
105
+ $ chezmoi-axi diff
106
+ chezmoi-axi diff # all diffs
107
+ chezmoi-axi diff <file> # diff for a specific file
108
+ ```
109
+
110
+ Output is a TOON summary with one row per changed file plus aggregate counts:
111
+
112
+ ```
113
+ diffs:
114
+ .bashrc
115
+ .tmux.conf
116
+ summary: 2 files, +12 -3
117
+ help[2]:
118
+ Run `chezmoi-axi apply` to apply these changes
119
+ Run `chezmoi-axi apply --preview` to dry-run first
120
+ ```
121
+
122
+ `diff <file>` for a path not managed by chezmoi returns a structured error (exit `1`) with a suggestion to `add`
123
+ it. An empty diff reports `diffs: 0 files with changes` (exit `0`).
124
+
125
+ ### `add`
126
+
127
+ Add a file to chezmoi source state. Idempotent — no error if already tracked (exit `0`).
128
+
129
+ ```
130
+ $ chezmoi-axi add ~/.config/app/config.json
131
+ added: ~/.config/app/config.json
132
+ summary: 1 added, 0 skipped
133
+ help[2]:
134
+ Run `chezmoi-axi diff` to verify no drift
135
+ Run `chezmoi-axi commit` to commit and push
136
+ ```
137
+
138
+ | Flag | Meaning |
139
+ | --- | --- |
140
+ | `--encrypt`, `-e` | Encrypt the file with age before adding |
141
+
142
+ Already-managed files report `already managed:` and count as skipped, not failed. A missing file reports
143
+ `error: file not found:`.
144
+
145
+ ### `re-add`
146
+
147
+ Capture on-disk changes back to source state. Runs `chezmoi re-add --dry-run --verbose` first and branches on the
148
+ tool's feedback.
149
+
150
+ ```
151
+ $ chezmoi-axi re-add ~/.bashrc
152
+ $ chezmoi-axi re-add --all # re-add all changed files
153
+ ```
154
+
155
+ | Flag | Meaning |
156
+ | --- | --- |
157
+ | `--all`, `-a` | Re-add all changed files |
158
+
159
+ Verdicts per file:
160
+
161
+ - **`re-added: <file>`** — change captured to source state.
162
+ - **`skipped: template-managed`** — the source is a `.tmpl`; re-add cannot merge live-file edits into a
163
+ template, so it skips (exit `0`). Edit the template directly and verify with
164
+ `chezmoi execute-template < src | diff - <target>`.
165
+ - **`in sync: <file>`** — nothing to capture.
166
+
167
+ Summary: `summary: N re-added, N skipped (template-managed), N in sync`.
168
+
169
+ ### `apply`
170
+
171
+ Apply source state to installed files.
172
+
173
+ ```
174
+ $ chezmoi-axi apply # apply
175
+ $ chezmoi-axi apply --preview # dry run — shows what would change
176
+ ```
177
+
178
+ | Flag | Meaning |
179
+ | --- | --- |
180
+ | `--preview`, `-n` | Show what would change without applying (runs `diff`) |
181
+
182
+ On success: `applied: changes applied successfully`. On failure: structured `error: chezmoi apply failed` with a
183
+ `help` hint to `diff`.
184
+
185
+ ### `verify`
186
+
187
+ Check that installed files match source state. Exit `0` if clean, `1` if drifted.
188
+
189
+ ```
190
+ $ chezmoi-axi verify
191
+ verify: all tracked files match source state
192
+ help[2]:
193
+ Run `chezmoi-axi list` to see managed files
194
+ Run `chezmoi-axi diff` to check for pending changes
195
+ ```
196
+
197
+ When drift is detected, it reports the count and returns exit `1` (which agents can branch on):
198
+
199
+ ```
200
+ verify: 2 files have drifted from source state
201
+ help[2]:
202
+ Run `chezmoi-axi diff` to see what changed
203
+ Run `chezmoi-axi re-add --all` to capture changes
204
+ ```
205
+
206
+ ### `sync`
207
+
208
+ Pull remote changes and apply. Combines `git fetch` + `chezmoi update`.
209
+
210
+ ```
211
+ $ chezmoi-axi sync # fetch + apply
212
+ $ chezmoi-axi sync --preview # fetch + diff, no apply
213
+ $ chezmoi-axi sync --force # apply without prompting (resolves local drift, for cron)
214
+ $ chezmoi-axi sync --branch dev # sync a non-default branch explicitly
215
+ ```
216
+
217
+ | Flag | Meaning |
218
+ | --- | --- |
219
+ | `--preview`, `-n` | Fetch remote and show the diff without applying |
220
+ | `--force`, `-f` | Apply without prompting (resolves local drift; for cron) |
221
+ | `--branch <name>` | Sync `<name>` instead of the default branch (escape hatch; checks the branch out if needed) |
222
+
223
+ **Branch guard.** Sync only auto-checks out the default branch (`master`, detected from
224
+ `origin/HEAD`) when the current branch is not the default. Before switching it checks for
225
+ (a) uncommitted changes and (b) local work not yet in `origin/master` — HEAD is fully merged
226
+ when it is an ancestor of `origin/master` or its only local commits are merge commits. If
227
+ either check fails, sync **fails without switching or pulling** and reports a structured error
228
+ naming the branch, the stranded commit count, and the recovery command:
229
+
230
+ ```
231
+ error: sync blocked: branch 'feature' has live work (uncommitted: true, unpushed: 2)
232
+ help[2]:
233
+ recover: commit or push on 'feature', then rerun `chezmoi-axi sync`
234
+ deliberate: run `chezmoi-axi sync --branch feature` to sync this branch anyway
235
+ ```
236
+
237
+ Only a clean, fully-merged branch (all work already in `origin/master`, merge commits
238
+ included) is auto-checked out to the default branch. To sync a
239
+ non-default branch deliberately, pass `--branch <name>`.
240
+
241
+ Every sync stamps ISO-8601 `start` and `end` lines — the `end` line carries the exit code — so the periodic
242
+ crontab log is auditable:
243
+
244
+ ```
245
+ [2026-08-28T09:15:00+0200] chezmoi-axi sync start
246
+ [2026-08-28T09:15:03+0200] chezmoi-axi sync end rc=0
247
+ ```
248
+
249
+ Before running, sync rotates the canonical cron log `~/.local/share/chezmoi/.chezmoi-sync.log` to `.old` once it
250
+ exceeds 512 KiB (override with `CHEZMOI_SYNC_LOG_MAX` bytes); other redirections are untouched. It also exports a
251
+ passphrase-free `GIT_SSH_COMMAND` deploy key so sync never prompts — safe for cron, agents, and headless contexts.
252
+
253
+ ### `commit` [message]
254
+
255
+ Stage all chezmoi changes, commit, push, and open a PR. Uses conventional commits.
256
+
257
+ ```
258
+ $ chezmoi-axi commit # auto-generated message
259
+ $ chezmoi-axi commit "feat(app): add new config"
260
+ ```
261
+
262
+ If the working tree is clean it reports `commit: nothing to commit` (exit `0`). Otherwise it auto-generates a
263
+ `chore(dotfiles): update N managed files` message when none is given, pushes a timestamped branch, and opens a PR
264
+ against `master` via `gh`.
265
+
266
+ ## Output
267
+
268
+ Structured data is emitted as TOON on **stdout**. Errors also go to stdout, in a structured form with an
269
+ actionable suggestion, and map to a stable exit code:
270
+
271
+ | Exit | Meaning |
272
+ | --- | --- |
273
+ | `0` | success (including no-ops and empty results) |
274
+ | `1` | runtime error (e.g. file not found, drift detected, apply/sync failed) |
275
+ | `2` | usage error (unknown flag, missing required arg, unknown command) |
276
+
277
+ ```
278
+ $ chezmoi-axi list --bogus
279
+ error: unknown flag: --bogus
280
+ help: chezmoi-axi list [--changed] [--encrypted] [--full]
281
+ ```
282
+ (exit 2)
283
+
284
+ Unknown flags fail loudly and list the valid flags so the agent self-corrects in one step. `--help` is always
285
+ allowed. No command prompts interactively — every operation is completable with flags alone. chezmoi's own
286
+ errors are translated into the structured format above and raw dependency stack traces are never leaked to
287
+ stdout.
288
+
289
+ ## Integrations
290
+
291
+ `chezmoi-axi` follows the AXI ambient-context pattern: run it with no arguments (or `status`) to get a compact
292
+ home-view dashboard that can be surfaced to an agent at session start. The same output powers the
293
+ [installable skill](https://github.com/masculinecache/chezmoi-axi/blob/master/skills/chezmoi-axi/SKILL.md), which lets
294
+ agents work with chezmoi through a stable TOON interface.
295
+
296
+ ## Development
297
+
298
+ ```sh
299
+ bash -n chezmoi-axi # syntax check
300
+ shellcheck -S error chezmoi-axi # static analysis (error level)
301
+ ./tests/run.sh # test suite (bash only, no dependencies)
302
+ ```
303
+
304
+ The test suite runs the executable through its public CLI surface (list, diff, add, re-add, sync, verify,
305
+ structured errors, exit codes) against a mock `chezmoi` binary — no real dotfiles or network required.
306
+
307
+ ## License
308
+
309
+ MIT
package/chezmoi-axi ADDED
@@ -0,0 +1,713 @@
1
+ #!/usr/bin/env bash
2
+ # chezmoi-axi — Agent-ergonomic chezmoi wrapper with TOON output
3
+ # Follows AXI 10 principles: token-efficient, minimal schemas, structured errors
4
+ set -euo pipefail
5
+
6
+ BIN_PATH="${BASH_SOURCE[0]}"
7
+ DESCRIPTION="Agent-ergonomic chezmoi wrapper for dotfiles management"
8
+ CHEZMOI_SRC="${HOME}/.local/share/chezmoi"
9
+
10
+ # --- Helpers ---
11
+
12
+ die() {
13
+ echo "error: $1"
14
+ [[ -n "${2:-}" ]] && echo "help: $2"
15
+ exit 1
16
+ }
17
+
18
+ # Usage errors (unknown flag, missing required arg) exit 2 per AXI; runtime
19
+ # errors (file not found, drift, etc.) exit 1 via die().
20
+ usage_error() {
21
+ echo "error: $1"
22
+ [[ -n "${2:-}" ]] && echo "help: $2"
23
+ exit 2
24
+ }
25
+
26
+ help_hint() {
27
+ local hints=("$@")
28
+ if [[ ${#hints[@]} -gt 0 ]]; then
29
+ echo "help[${#hints[@]}]:"
30
+ for h in "${hints[@]}"; do
31
+ echo " $h"
32
+ done
33
+ fi
34
+ }
35
+
36
+ toon_list() {
37
+ # $1 = header label, $2 = fields spec, then data lines
38
+ local label="$1" fields="$2"
39
+ shift 2
40
+ echo "${label}[$#]{${fields}}:"
41
+ for item in "$@"; do
42
+ echo " $item"
43
+ done
44
+ }
45
+
46
+ # Map a target path (~/.ssh/config) to its source template entry if the target
47
+ # is managed by a template (dot_ssh/config.tmpl). Prints the source path and
48
+ # returns 0 if template-managed, 1 otherwise.
49
+ template_source_for() {
50
+ local target="$1"
51
+ local rel="${target#$HOME/}"
52
+ local rel_dir rel_base mapped_dir=""
53
+ rel_dir=$(dirname "$rel")
54
+ rel_base=$(basename "$rel")
55
+
56
+ # Map dir components: .ssh -> dot_ssh
57
+ local p
58
+ IFS='/' read -ra parts <<< "$rel_dir"
59
+ for p in "${parts[@]}"; do
60
+ [[ -z "$p" || "$p" == "." ]] && continue
61
+ if [[ "$p" == .* ]]; then
62
+ mapped_dir="${mapped_dir}/dot_${p#.}"
63
+ else
64
+ mapped_dir="${mapped_dir}/${p}"
65
+ fi
66
+ done
67
+
68
+ # Map file component: .bashrc -> dot_bashrc
69
+ local src_base="$rel_base"
70
+ [[ "$src_base" == .* ]] && src_base="dot_${src_base#.}"
71
+
72
+ local candidates=("${CHEZMOI_SRC}${mapped_dir}/${src_base}.tmpl")
73
+ if [[ -n "$mapped_dir" ]]; then
74
+ candidates+=(
75
+ "${CHEZMOI_SRC}/private_${mapped_dir#/}/${src_base}.tmpl"
76
+ "${CHEZMOI_SRC}/encrypted_${mapped_dir#/}/${src_base}.tmpl"
77
+ )
78
+ else
79
+ candidates+=(
80
+ "${CHEZMOI_SRC}/private_${src_base}.tmpl"
81
+ "${CHEZMOI_SRC}/encrypted_${src_base}.tmpl"
82
+ )
83
+ fi
84
+
85
+ local c
86
+ for c in "${candidates[@]}"; do
87
+ if [[ -f "$c" ]]; then
88
+ echo "$c"
89
+ return 0
90
+ fi
91
+ done
92
+ return 1
93
+ }
94
+
95
+ # Re-add a single target with dry-run branch verdicts (Principle 5, 8).
96
+ # Returns: 0 = re-added, 2 = skipped (template-managed), 3 = in sync
97
+ readd_one() {
98
+ local f="$1"
99
+ f="${f/#\~/$HOME}"
100
+ [[ "$f" != /* ]] && f="${HOME}/${f}" # normalize relative paths (--all mode)
101
+
102
+ if [[ ! -e "$f" ]]; then
103
+ echo "error: file not found: ~${f#"$HOME"}"
104
+ return 1
105
+ fi
106
+
107
+ local dry_out src
108
+ # -n -v: dry-run with verbose diff on stderr — the only re-add feedback that
109
+ # distinguishes "has changes" from "nothing to do". Without -v, --dry-run is
110
+ # silent even for files with pending changes.
111
+ dry_out=$(chezmoi re-add --dry-run --verbose "$f" 2>&1 || true)
112
+
113
+ if [[ -n "$dry_out" ]]; then
114
+ if chezmoi re-add "$f" >/dev/null 2>&1; then
115
+ echo "re-added: ~${f#"$HOME"}"
116
+ return 0
117
+ fi
118
+ echo "error: failed to re-add ~${f#"$HOME"}"
119
+ return 1
120
+ fi
121
+
122
+ # Empty dry-run: nothing would change. re-add silently skips template-managed
123
+ # files (documented: "chezmoi will not overwrite templates"; exits 0, no
124
+ # source change) — flag them instead of reporting success.
125
+ if src=$(template_source_for "$f"); then
126
+ echo "skipped: ~${f#"$HOME"} is template-managed (source ${src#$CHEZMOI_SRC/})"
127
+ echo " re-add cannot merge live-file edits into a template - edit the template directly"
128
+ echo " verify: chezmoi execute-template < ${src#$CHEZMOI_SRC/} | diff - ~${f#$HOME}"
129
+ return 2
130
+ fi
131
+
132
+ echo "in sync: ~${f#"$HOME"} (no changes to capture)"
133
+ return 3
134
+ }
135
+
136
+ # --- Commands ---
137
+
138
+ cmd_status() {
139
+ local managed_count changed_count encrypted_count last_sync="never"
140
+
141
+ managed_count=$(chezmoi managed 2>/dev/null | wc -l || true)
142
+ changed_count=$(chezmoi diff 2>/dev/null | grep '^diff --git' | wc -l || true)
143
+ encrypted_count=$(find "$CHEZMOI_SRC" -name 'encrypted_*' -type f 2>/dev/null | wc -l || true)
144
+
145
+ # Last sync: most recent commit in chezmoi source
146
+ if [[ -d "$CHEZMOI_SRC/.git" ]]; then
147
+ local last_epoch
148
+ last_epoch=$(chezmoi git -- log -1 --format=%ct 2>/dev/null || echo 0)
149
+ if [[ "$last_epoch" -gt 0 ]]; then
150
+ local now diff_s
151
+ now=$(date +%s)
152
+ diff_s=$(( now - last_epoch ))
153
+ if (( diff_s < 3600 )); then
154
+ last_sync="$(( diff_s / 60 ))m ago"
155
+ elif (( diff_s < 86400 )); then
156
+ last_sync="$(( diff_s / 3600 ))h ago"
157
+ else
158
+ last_sync="$(( diff_s / 86400 ))d ago"
159
+ fi
160
+ fi
161
+ fi
162
+
163
+ echo "chezmoi:"
164
+ local bin_display="$BIN_PATH"
165
+ [[ "$bin_display" == "$HOME"* ]] && bin_display="~${bin_display#"$HOME"}"
166
+ echo " bin: ${bin_display}"
167
+ echo " description: ${DESCRIPTION}"
168
+ echo " managed: ${managed_count}"
169
+ echo " changed: ${changed_count}"
170
+ echo " encrypted: ${encrypted_count}"
171
+ echo " last_sync: ${last_sync}"
172
+
173
+ local hints=()
174
+ if (( changed_count > 0 )); then
175
+ hints+=("Run \`chezmoi-axi list --changed\` to see pending changes")
176
+ hints+=("Run \`chezmoi-axi diff\` to review diffs")
177
+ fi
178
+ hints+=("Run \`chezmoi-axi list\` to see all managed files")
179
+ help_hint "${hints[@]}"
180
+ }
181
+
182
+ cmd_list() {
183
+ local filter="all" full=false
184
+ while [[ $# -gt 0 ]]; do
185
+ case "$1" in
186
+ --changed) filter="changed"; shift ;;
187
+ --encrypted) filter="encrypted"; shift ;;
188
+ --full) full=true; shift ;;
189
+ -h|--help)
190
+ echo "Usage: chezmoi-axi list [--changed] [--encrypted] [--full]"
191
+ echo " --changed Show only files with pending diffs"
192
+ echo " --encrypted Show only encrypted files"
193
+ echo " --full Show every file (bypass the 50-row cap)"
194
+ exit 0 ;;
195
+ *) usage_error "unknown flag: $1" "chezmoi-axi list [--changed] [--encrypted] [--full]" ;;
196
+ esac
197
+ done
198
+
199
+ local files=()
200
+ case "$filter" in
201
+ changed)
202
+ while IFS= read -r line; do
203
+ local f
204
+ f=$(echo "$line" | sed 's|^diff --git a/.* b/||')
205
+ files+=("$f")
206
+ done < <(chezmoi diff 2>/dev/null | grep '^diff --git')
207
+ ;;
208
+ encrypted)
209
+ while IFS= read -r f; do
210
+ local target
211
+ target=$(basename "$f" | sed 's/^encrypted_//' | sed 's/\.age$//' | sed 's/^private_//')
212
+ # Map source name back to target path
213
+ local relpath="${f#$CHEZMOI_SRC/}"
214
+ relpath=$(echo "$relpath" | sed 's|^dot_|.|' | sed 's|^encrypted_||' | sed 's|\.age$||' | sed 's|^private_|.|')
215
+ files+=("~/${relpath}")
216
+ done < <(find "$CHEZMOI_SRC" -name 'encrypted_*' -type f 2>/dev/null)
217
+ ;;
218
+ all)
219
+ while IFS= read -r f; do
220
+ local relpath="${f#$CHEZMOI_SRC/}"
221
+ relpath=$(echo "$relpath" | sed 's|^dot_|.|' )
222
+ files+=("~/${relpath}")
223
+ done < <(chezmoi managed 2>/dev/null)
224
+ ;;
225
+ esac
226
+
227
+ local count=${#files[@]}
228
+ local total
229
+ total=$(chezmoi managed 2>/dev/null | wc -l || true)
230
+
231
+ if (( count == 0 )); then
232
+ echo "files: 0 ${filter} files found"
233
+ help_hint "Run \`chezmoi-axi list\` to see all managed files"
234
+ return 0
235
+ fi
236
+
237
+ # Truncate if >50 files (Principle 3); --full bypasses the cap (escape hatch)
238
+ local display_files=("${files[@]}")
239
+ local truncated=false
240
+ if [[ "$full" != "true" && "$count" -gt 50 ]]; then
241
+ display_files=("${files[@]:0:50}")
242
+ truncated=true
243
+ fi
244
+
245
+ echo "files[${count}]{path,type,encrypted}:"
246
+ for f in "${display_files[@]}"; do
247
+ local enc="no"
248
+ [[ "$f" == *"secret"* || "$f" == *"key"* || "$f" == *".env"* ]] && enc="maybe"
249
+ # Determine type from extension
250
+ local ext="${f##*.}"
251
+ local ftype="file"
252
+ [[ "$ext" == "sh" || "$ext" == "bash" || "$ext" == "zsh" ]] && ftype="shell"
253
+ [[ "$ext" == "json" ]] && ftype="json"
254
+ [[ "$ext" == "yaml" || "$ext" == "yml" ]] && ftype="yaml"
255
+ [[ "$ext" == "toml" ]] && ftype="toml"
256
+ [[ "$ext" == "conf" || "$ext" == "cfg" ]] && ftype="config"
257
+ [[ "$ext" == "age" ]] && ftype="encrypted"
258
+ echo " ${f},${ftype},${enc}"
259
+ done
260
+
261
+ if (( count != total )); then
262
+ echo "count: ${count} of ${total} total"
263
+ fi
264
+ if [[ "$truncated" == "true" ]]; then
265
+ echo " ... (${count} total, showing first 50)"
266
+ echo "help[1]: Run \`chezmoi-axi list --full\` for all ${count} files"
267
+ fi
268
+
269
+ local hints=()
270
+ hints+=("Run \`chezmoi-axi add <file>\` to track a new file")
271
+ [[ "$filter" == "all" ]] && hints+=("Run \`chezmoi-axi diff\` to review changes")
272
+ help_hint "${hints[@]}"
273
+ }
274
+
275
+ cmd_diff() {
276
+ local target="${1:-}"
277
+ local raw_diff
278
+
279
+ if [[ -n "$target" ]]; then
280
+ # Diff specific file
281
+ local src_path
282
+ src_path=$(chezmoi managed --include files 2>/dev/null | grep -i "$(basename "$target")" | head -1 || true)
283
+ if [[ -z "$src_path" ]]; then
284
+ die "file not managed by chezmoi: $target" "Run \`chezmoi-axi add $target\` to start tracking it"
285
+ fi
286
+ raw_diff=$(chezmoi diff -- "$target" 2>/dev/null || true)
287
+ else
288
+ raw_diff=$(chezmoi diff 2>/dev/null || true)
289
+ fi
290
+
291
+ if [[ -z "$raw_diff" ]]; then
292
+ echo "diffs: 0 files with changes"
293
+ help_hint "Run \`chezmoi-axi list\` to see all managed files"
294
+ return 0
295
+ fi
296
+
297
+ # Parse diff into TOON summary
298
+ local files_changed=0
299
+ local total_add=0
300
+ local total_del=0
301
+ echo "diffs:"
302
+ while IFS= read -r line; do
303
+ if [[ "$line" == diff\ --git* ]]; then
304
+ (( files_changed++ )) || true
305
+ local path
306
+ path=$(echo "$line" | sed 's|^diff --git a/.* b/||')
307
+ echo " ${path}"
308
+ elif [[ "$line" == @@* ]]; then
309
+ local stats
310
+ stats=$(echo "$line" | grep -oP '[+-]\d+' | paste -sd' ' || true)
311
+ local adds dels
312
+ adds=$(echo "$stats" | grep -oP '\+\d+' | tr -d '+' | paste -sd'+' | bc 2>/dev/null || echo 0)
313
+ dels=$(echo "$stats" | grep -oP '-\d+' | tr -d '-' | paste -sd'+' | bc 2>/dev/null || echo 0)
314
+ (( total_add += adds )) || true
315
+ (( total_del += dels )) || true
316
+ fi
317
+ done <<< "$raw_diff"
318
+
319
+ echo "summary: ${files_changed} files, +${total_add} -${total_del}"
320
+
321
+ local hints=()
322
+ (( files_changed > 0 )) && hints+=("Run \`chezmoi-axi apply\` to apply these changes")
323
+ hints+=("Run \`chezmoi-axi apply --preview\` to dry-run first")
324
+ help_hint "${hints[@]}"
325
+ }
326
+
327
+ cmd_add() {
328
+ local encrypt=false
329
+ local files=()
330
+
331
+ while [[ $# -gt 0 ]]; do
332
+ case "$1" in
333
+ --encrypt|-e) encrypt=true; shift ;;
334
+ -h|--help)
335
+ echo "Usage: chezmoi-axi add [--encrypt] <file>..."
336
+ echo " --encrypt Encrypt the file with age before adding"
337
+ exit 0 ;;
338
+ -*) usage_error "unknown flag: $1" "chezmoi-axi add [--encrypt] <file>..." ;;
339
+ *) files+=("$1"); shift ;;
340
+ esac
341
+ done
342
+
343
+ [[ ${#files[@]} -eq 0 ]] && usage_error "no files specified" "chezmoi-axi add [--encrypt] <file>..."
344
+
345
+ local added=0 skipped=0
346
+ for f in "${files[@]}"; do
347
+ # Expand ~ if present
348
+ f="${f/#\~/$HOME}"
349
+ if [[ ! -f "$f" ]]; then
350
+ echo "error: file not found: $f"
351
+ (( skipped++ )) || true
352
+ continue
353
+ fi
354
+
355
+ local args=()
356
+ [[ "$encrypt" == "true" ]] && args+=(--encrypt)
357
+
358
+ if chezmoi add "${args[@]}" "$f" 2>/dev/null; then
359
+ echo "added: ~${f#"$HOME"}"
360
+ (( added++ )) || true
361
+ else
362
+ # Check if already managed
363
+ if chezmoi managed 2>/dev/null | grep -q "$(basename "$f")"; then
364
+ echo "already managed: ~${f#"$HOME"}"
365
+ (( skipped++ )) || true
366
+ else
367
+ echo "error: failed to add ~${f#"$HOME"}"
368
+ (( skipped++ )) || true
369
+ fi
370
+ fi
371
+ done
372
+
373
+ echo "summary: ${added} added, ${skipped} skipped"
374
+ local hints=()
375
+ (( added > 0 )) && hints+=("Run \`chezmoi-axi diff\` to verify no drift")
376
+ (( added > 0 )) && hints+=("Run \`chezmoi-axi commit\` to commit and push")
377
+ (( added == 0 )) && hints+=("Run \`chezmoi-axi list\` to see what's managed")
378
+ help_hint "${hints[@]}"
379
+ }
380
+
381
+ cmd_readd() {
382
+ local add_all=false
383
+ local files=()
384
+
385
+ while [[ $# -gt 0 ]]; do
386
+ case "$1" in
387
+ --all|-a) add_all=true; shift ;;
388
+ -h|--help)
389
+ echo "Usage: chezmoi-axi re-add [--all] [file]..."
390
+ echo " --all Re-add all changed files"
391
+ exit 0 ;;
392
+ -*) usage_error "unknown flag: $1" "chezmoi-axi re-add [--all] [file]..." ;;
393
+ *) files+=("$1"); shift ;;
394
+ esac
395
+ done
396
+
397
+ if [[ "$add_all" == "true" ]]; then
398
+ while IFS= read -r line; do
399
+ local f
400
+ f=$(echo "$line" | sed 's|^diff --git a/.* b/||')
401
+ files+=("$f")
402
+ done < <(chezmoi diff 2>/dev/null | grep '^diff --git')
403
+ fi
404
+
405
+ if [[ ${#files[@]} -eq 0 ]]; then
406
+ echo "re-added: 0 files (no changes detected)"
407
+ help_hint "Run \`chezmoi-axi list\` to see all managed files"
408
+ return 0
409
+ fi
410
+
411
+ local readded=0 skipped=0 insync=0 rc=0
412
+ for f in "${files[@]}"; do
413
+ readd_one "$f" || rc=$?
414
+ if [[ $rc -eq 0 ]]; then (( readded++ )) || true
415
+ elif [[ $rc -eq 2 ]]; then (( skipped++ )) || true
416
+ elif [[ $rc -eq 3 ]]; then (( insync++ )) || true
417
+ fi
418
+ rc=0
419
+ done
420
+
421
+ echo "summary: ${readded} re-added, ${skipped} skipped (template-managed), ${insync} in sync"
422
+
423
+ local hints=()
424
+ (( skipped > 0 )) && hints+=("Run \`chezmoi-axi diff\` to review template drift")
425
+ (( readded > 0 )) && hints+=("Run \`chezmoi-axi diff\` to verify clean state")
426
+ (( readded > 0 )) && hints+=("Run \`chezmoi-axi commit\` to commit and push")
427
+ help_hint "${hints[@]}"
428
+ }
429
+
430
+ cmd_apply() {
431
+ local preview=false
432
+ while [[ $# -gt 0 ]]; do
433
+ case "$1" in
434
+ --preview|-n) preview=true; shift ;;
435
+ -h|--help)
436
+ echo "Usage: chezmoi-axi apply [--preview]"
437
+ echo " --preview Show what would change without applying"
438
+ exit 0 ;;
439
+ *) usage_error "unknown flag: $1" "chezmoi-axi apply [--preview]" ;;
440
+ esac
441
+ done
442
+
443
+ if [[ "$preview" == "true" ]]; then
444
+ cmd_diff
445
+ return
446
+ fi
447
+
448
+ local output
449
+ if output=$(chezmoi apply 2>&1); then
450
+ echo "applied: changes applied successfully"
451
+ help_hint "Run \`chezmoi-axi verify\` to confirm state"
452
+ else
453
+ echo "error: chezmoi apply failed"
454
+ echo "$output" | head -5
455
+ help_hint "Run \`chezmoi-axi diff\` to review what failed"
456
+ return 1
457
+ fi
458
+ }
459
+
460
+ cmd_verify() {
461
+ local drift_count=0
462
+ local output
463
+
464
+ # chezmoi verify exits 1 for untracked files too; use diff for actual drift detection
465
+ output=$(chezmoi verify 2>&1 || true)
466
+ drift_count=$(echo "$output" | grep -c 'has changed' || true)
467
+
468
+ # Also check if verify failed but produced no "has changed" lines (untracked files)
469
+ if (( drift_count > 0 )); then
470
+ echo "verify: ${drift_count} files have drifted from source state"
471
+ echo "$output" | head -10
472
+ help_hint "Run \`chezmoi-axi diff\` to see what changed" \
473
+ "Run \`chezmoi-axi re-add --all\` to capture changes"
474
+ return 1
475
+ fi
476
+
477
+ # Check for untracked files (files on disk not in source state)
478
+ local untracked
479
+ untracked=$(chezmoi managed --include files 2>/dev/null | wc -l || true)
480
+ local target_files
481
+ target_files=$(find "${HOME}" -maxdepth 1 -name '.*' -type f 2>/dev/null | wc -l || true)
482
+
483
+ echo "verify: all tracked files match source state"
484
+ help_hint "Run \`chezmoi-axi list\` to see managed files" \
485
+ "Run \`chezmoi-axi diff\` to check for pending changes"
486
+ return 0
487
+ }
488
+
489
+ cmd_sync() {
490
+ # Keep the periodic sync log bounded: rotate once past the cap.
491
+ # Applies to the canonical cron log only; other redirections stay untouched.
492
+ local sync_log="${CHEZMOI_SRC}/.chezmoi-sync.log"
493
+ local max_bytes="${CHEZMOI_SYNC_LOG_MAX:-524288}"
494
+ if [[ -f "$sync_log" && "$(wc -c <"$sync_log")" -ge "$max_bytes" ]]; then
495
+ cp "$sync_log" "$sync_log.old" && : > "$sync_log"
496
+ fi
497
+
498
+ echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] chezmoi-axi sync start"
499
+ local rc=0
500
+ _sync_run "$@" || rc=$?
501
+ echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] chezmoi-axi sync end rc=$rc"
502
+ return "$rc"
503
+ }
504
+
505
+ _sync_run() {
506
+ local preview=false force=false branch_arg=""
507
+ while [[ $# -gt 0 ]]; do
508
+ case "$1" in
509
+ --preview|-n) preview=true; shift ;;
510
+ --force|-f) force=true; shift ;;
511
+ --branch)
512
+ if [[ $# -lt 2 || -z "${2:-}" || "${2:-}" == -* ]]; then
513
+ usage_error "--branch requires a branch name" "chezmoi-axi sync [--preview] [--force] [--branch <name>]"
514
+ fi
515
+ branch_arg="$2"; shift 2 ;;
516
+ -h|--help)
517
+ echo "Usage: chezmoi-axi sync [--preview] [--force] [--branch <name>]"
518
+ echo " --preview Fetch remote and show diff without applying"
519
+ echo " --force Apply without prompting (resolves local drift, for cron)"
520
+ echo " --branch <name> Sync a non-default branch explicitly (escape hatch)"
521
+ echo "Stamps start/end (with exit code) around every sync; rotates"
522
+ echo " ~/.local/share/chezmoi/.chezmoi-sync.log to .old at 512 KiB"
523
+ echo " (CHEZMOI_SYNC_LOG_MAX bytes, for the crontab redirect)"
524
+ return 0 ;;
525
+ *) usage_error "unknown flag: $1" "chezmoi-axi sync [--preview] [--force] [--branch <name>]" ;;
526
+ esac
527
+ done
528
+
529
+ # Force the passphrase-free deploy key for all git SSH operations so
530
+ # sync never prompts — safe for cron, agents, and headless contexts.
531
+ export GIT_SSH_COMMAND="ssh -i ~/.ssh/chezmoi-deploy-key -o IdentitiesOnly=yes -o BatchMode=yes"
532
+
533
+ echo "syncing: fetching remote changes..."
534
+ chezmoi git -- fetch origin 2>/dev/null || true
535
+
536
+ # Branch guard: never auto-switch away from a branch carrying live work.
537
+ local current_branch default_branch
538
+ current_branch=$(chezmoi git -- branch --show-current 2>/dev/null || true)
539
+ default_branch=$(chezmoi git -- rev-parse --abbrev-ref origin/HEAD 2>/dev/null | sed 's|^origin/||' || true)
540
+ [[ -z "$default_branch" ]] && default_branch="master"
541
+
542
+ if [[ -n "$branch_arg" ]]; then
543
+ # Escape hatch: caller explicitly wants this branch synced.
544
+ if [[ "$current_branch" != "$branch_arg" ]]; then
545
+ if ! chezmoi git -- checkout "$branch_arg" >/dev/null 2>&1; then
546
+ die "cannot check out branch: ${branch_arg}" "Verify the branch exists: \`chezmoi git -- branch --list\`"
547
+ fi
548
+ echo "checked out: ${branch_arg}"
549
+ fi
550
+ elif [[ -n "$current_branch" && "$current_branch" != "$default_branch" ]]; then
551
+ # On a non-default branch: fail if there is live work, never auto-switch over it.
552
+ local dirty=false merged=false unpushed=0
553
+ [[ -n "$(chezmoi git -- status --porcelain 2>/dev/null || true)" ]] && dirty=true
554
+ # Fully merged means every commit's work is already on origin/<default>:
555
+ # either HEAD itself is an ancestor, or the only local commits are merge
556
+ # commits (e.g. a local merge of origin/<default> after a PR merge).
557
+ if chezmoi git -- merge-base --is-ancestor "HEAD" "origin/${default_branch}" 2>/dev/null; then
558
+ merged=true
559
+ else
560
+ unpushed=$({ chezmoi git -- log --oneline --no-merges "origin/${default_branch}..HEAD" 2>/dev/null || true; } | wc -l)
561
+ fi
562
+ if [[ "$dirty" == "true" || ("$merged" != "true" && "$unpushed" -gt 0) ]]; then
563
+ echo "error: sync blocked: branch '${current_branch}' has live work (uncommitted: ${dirty}, unpushed: ${unpushed})"
564
+ echo "help[2]:"
565
+ echo " recover: commit or push on '${current_branch}', then rerun \`chezmoi-axi sync\`"
566
+ echo " deliberate: run \`chezmoi-axi sync --branch ${current_branch}\` to sync this branch anyway"
567
+ return 1
568
+ fi
569
+ # Clean and fully merged: safe to auto-checkout the default branch.
570
+ if ! chezmoi git -- checkout "$default_branch" >/dev/null 2>&1; then
571
+ die "cannot check out default branch: ${default_branch}" "Check \`chezmoi git -- status\` in ${CHEZMOI_SRC}"
572
+ fi
573
+ echo "checked out: ${default_branch} (branch '${current_branch}' was clean and merged)"
574
+ fi
575
+
576
+ local remote_branch="$default_branch"
577
+ [[ -n "$branch_arg" ]] && remote_branch="$branch_arg"
578
+
579
+ local remote_commits
580
+ remote_commits=$({ chezmoi git -- log --oneline "HEAD..origin/${remote_branch}" 2>/dev/null || true; } | wc -l)
581
+
582
+ if [[ "$remote_commits" -eq 0 && "$force" != "true" ]]; then
583
+ echo "sync: already up to date"
584
+ help_hint "Run \`chezmoi-axi status\` for current state"
585
+ return 0
586
+ fi
587
+
588
+ if [[ "$remote_commits" -gt 0 ]]; then
589
+ echo "found: ${remote_commits} new commits on remote"
590
+ else
591
+ echo "sync: no new commits, applying to resolve local drift"
592
+ fi
593
+
594
+ if [[ "$preview" == "true" ]]; then
595
+ echo "diffs:"
596
+ chezmoi git -- diff --stat "HEAD" "origin/${remote_branch}" 2>/dev/null || true
597
+ help_hint "Run \`chezmoi-axi sync\` to apply these changes"
598
+ return 0
599
+ fi
600
+
601
+ local update_args=()
602
+ [[ "$force" == "true" ]] && update_args+=(--force)
603
+
604
+ local output
605
+ if output=$(chezmoi update "${update_args[@]}" 2>&1); then
606
+ echo "sync: pulled and applied${force:+ (force)}"
607
+ else
608
+ echo "error: chezmoi update failed"
609
+ echo "$output" | head -10
610
+ help_hint "Run \`chezmoi-axi diff\` to review what failed"
611
+ return 1
612
+ fi
613
+ help_hint "Run \`chezmoi-axi verify\` to confirm state"
614
+ }
615
+
616
+ cmd_commit() {
617
+ local message="${1:-}"
618
+
619
+ cd "$CHEZMOI_SRC" || die "chezmoi source dir not found: $CHEZMOI_SRC"
620
+
621
+ local changes
622
+ changes=$(git diff --name-only 2>/dev/null || true)
623
+ local untracked
624
+ untracked=$(git ls-files --others --exclude-standard 2>/dev/null || true)
625
+
626
+ if [[ -z "$changes" && -z "$untracked" ]]; then
627
+ echo "commit: nothing to commit"
628
+ help_hint "Run \`chezmoi-axi status\` for current state"
629
+ return 0
630
+ fi
631
+
632
+ # Auto-generate message if not provided
633
+ if [[ -z "$message" ]]; then
634
+ local file_count
635
+ file_count=$(echo -e "${changes}\n${untracked}" | grep -c '[^[:space:]]' || true)
636
+ message="chore(dotfiles): update ${file_count} managed files"
637
+ fi
638
+
639
+ # Stage all changes
640
+ git add -A 2>/dev/null
641
+ git commit -m "$message" 2>/dev/null
642
+
643
+ # Create branch and push
644
+ local branch="update-$(date +%Y%m%d-%H%M%S)"
645
+ git checkout -b "$branch" 2>/dev/null
646
+ git push -u origin "$branch" 2>/dev/null
647
+
648
+ # Open PR
649
+ local pr_url
650
+ pr_url=$(gh pr create \
651
+ --title "$message" \
652
+ --body "Auto-generated by chezmoi-axi" \
653
+ --base master 2>/dev/null || echo "")
654
+
655
+ if [[ -n "$pr_url" ]]; then
656
+ echo "commit: pushed and PR created"
657
+ echo "pr: ${pr_url}"
658
+ else
659
+ echo "commit: pushed to branch ${branch}"
660
+ echo " Run \`gh pr create --base master\` to open a PR"
661
+ fi
662
+
663
+ # Return to master
664
+ git checkout master 2>/dev/null
665
+ git branch -d "$branch" 2>/dev/null || true
666
+
667
+ help_hint "Run \`chezmoi-axi status\` to verify state"
668
+ }
669
+
670
+ cmd_help() {
671
+ echo "chezmoi-axi — ${DESCRIPTION}"
672
+ echo ""
673
+ echo "Commands:"
674
+ echo " status Show current state at a glance"
675
+ echo " list List managed files [--changed] [--encrypted]"
676
+ echo " diff Show diffs between source and installed"
677
+ echo " add Add file to source state [--encrypt]"
678
+ echo " re-add Capture on-disk changes [--all]"
679
+ echo " apply Apply source state to installed files"
680
+ echo " verify Check files match source state"
681
+ echo " sync Pull remote and apply [--preview] [--force] [--branch <name>]"
682
+ echo " commit Commit, push, and open PR"
683
+ echo " help Show this help"
684
+ echo ""
685
+ echo "Run \`chezmoi-axi <command> --help\` for command-specific help"
686
+ }
687
+
688
+ # --- Main ---
689
+
690
+ main() {
691
+ local cmd="${1:-status}"
692
+ shift || true
693
+
694
+ case "$cmd" in
695
+ status) cmd_status "$@" ;;
696
+ list|ls) cmd_list "$@" ;;
697
+ diff) cmd_diff "$@" ;;
698
+ add) cmd_add "$@" ;;
699
+ re-add) cmd_readd "$@" ;;
700
+ apply) cmd_apply "$@" ;;
701
+ verify) cmd_verify "$@" ;;
702
+ sync) cmd_sync "$@" ;;
703
+ commit) cmd_commit "$@" ;;
704
+ help|-h|--help) cmd_help "$@" ;;
705
+ *)
706
+ echo "error: unknown command: $cmd"
707
+ echo "help: valid commands: status, list, diff, add, re-add, apply, verify, sync, commit, help"
708
+ exit 2
709
+ ;;
710
+ esac
711
+ }
712
+
713
+ main "$@"
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@masculinecache/chezmoi-axi",
3
+ "version": "0.1.0",
4
+ "description": "Agent-ergonomic TOON wrapper around chezmoi for token-efficient dotfiles management",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/masculinecache/chezmoi-axi.git"
8
+ },
9
+ "homepage": "https://github.com/masculinecache/chezmoi-axi#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/masculinecache/chezmoi-axi/issues"
12
+ },
13
+ "keywords": [
14
+ "chezmoi",
15
+ "dotfiles",
16
+ "cli",
17
+ "bash",
18
+ "agent",
19
+ "axi",
20
+ "toon"
21
+ ],
22
+ "bin": {
23
+ "chezmoi-axi": "chezmoi-axi"
24
+ },
25
+ "files": [
26
+ "chezmoi-axi",
27
+ "LICENSE",
28
+ "README.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "test": "bash tests/run.sh"
35
+ },
36
+ "license": "MIT"
37
+ }