@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.
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/chezmoi-axi +713 -0
- 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
|
+
}
|