1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
|
#!/usr/bin/env bats
# Characterization tests for the ai launcher's internal functions.
#
# These pin the launcher's CURRENT behavior (record-not-spec, per testing.md)
# before the hardening refactor, so the extraction of pure cores and the
# footgun fixes can proceed against a green net. The pure/near-pure functions
# are exercised by sourcing bin/ai (the run-vs-sourced guard keeps dispatch
# off) and calling them directly; the tmux-coupled pipeline is exercised
# against a throwaway tmux server on a PRIVATE socket (TMUX_TMPDIR under the
# test tmpdir, TMUX unset) so nothing can reach Craig's live 'ai' session.
# The launcher stores candidates as literal "~/..." display strings and expands
# them downstream; the assertions below compare against those literal tildes on
# purpose, so SC2088 (tilde-in-quotes) does not apply. SC2030/SC2031 fire on
# the shared `candidates` global because bats runs each @test in its own
# subshell — the array is set and read within that same subshell, so the
# warning is a false positive here.
# shellcheck disable=SC2088,SC2030,SC2031
setup() {
REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)"
AI="$REPO_ROOT/claude-templates/bin/ai"
WORK="$(mktemp -d)"
# Isolate every tmux call in this file onto a private server.
export TMUX_TMPDIR="$WORK"
unset TMUX
# Source for direct function access. The guard skips main() when sourced.
# shellcheck disable=SC1090
source "$AI"
}
teardown() {
tmux kill-server 2>/dev/null || true
rm -rf "$WORK"
}
# --- git repo helpers ---------------------------------------------------------
# A committed repo with no remote/upstream. Auto-maintenance off so background
# git can't race the teardown rm -rf (the rename-ai flake, fixed 2026-07-19).
_mk_repo() {
local d="$1"
git init -q "$d"
git -C "$d" config user.email t@example.com
git -C "$d" config user.name tester
git -C "$d" config commit.gpgsign false
git -C "$d" config gc.auto 0
git -C "$d" config maintenance.auto false
git -C "$d" commit -q --allow-empty -m init
}
# A repo with a bare origin and a tracked branch, in sync at one commit.
_mk_repo_upstream() {
local d="$1" remote="$1.remote"
git init -q --bare "$remote"
_mk_repo "$d"
git -C "$d" remote add origin "$remote"
git -C "$d" push -q -u origin HEAD
}
# --- usage() ------------------------------------------------------------------
@test "usage: -h prints the help banner and exits 0" {
run bash "$AI" -h
[ "$status" -eq 0 ]
[[ "$output" == *"Usage:"* ]]
[[ "$output" == *"--attach"* ]]
[[ "$output" == *"Single-project mode"* ]]
}
# --- maybe_add_candidate() ----------------------------------------------------
@test "maybe_add_candidate: a HOME-rooted project dir is added as ~/rel" {
HOME="$WORK/home"
mkdir -p "$HOME/code/proj/.ai"
touch "$HOME/code/proj/.ai/protocols.org"
candidates=()
maybe_add_candidate "$HOME/code/proj"
[ "${#candidates[@]}" -eq 1 ]
[ "${candidates[0]}" = "~/code/proj" ]
}
@test "maybe_add_candidate: a dir without .ai/protocols.org is filtered out" {
HOME="$WORK/home"
mkdir -p "$HOME/code/plain"
candidates=()
maybe_add_candidate "$HOME/code/plain" || true
[ "${#candidates[@]}" -eq 0 ]
}
@test "maybe_add_candidate: a non-HOME dir keeps its full path after ~/ (current quirk)" {
HOME="$WORK/home"
mkdir -p "$WORK/outside/.ai"
touch "$WORK/outside/.ai/protocols.org"
candidates=()
maybe_add_candidate "$WORK/outside"
# $HOME/ prefix doesn't match, so the path is left whole behind the literal ~/.
[ "${candidates[0]}" = "~/$WORK/outside" ]
}
# --- git_status_indicator() ---------------------------------------------------
@test "git_status_indicator: a non-git dir yields no annotation" {
mkdir -p "$WORK/plain"
run git_status_indicator "$WORK/plain"
[ "$status" -eq 0 ]
[ -z "$output" ]
}
@test "git_status_indicator: clean repo with no upstream reads (no upstream)" {
_mk_repo "$WORK/r"
run git_status_indicator "$WORK/r"
[ "$output" = " (no upstream)" ]
}
@test "git_status_indicator: an untracked file with no upstream reads (no upstream dirty)" {
_mk_repo "$WORK/r"
touch "$WORK/r/scratch"
run git_status_indicator "$WORK/r"
[ "$output" = " (no upstream dirty)" ]
}
@test "git_status_indicator: in-sync tracked repo reads (✓)" {
_mk_repo_upstream "$WORK/r"
run git_status_indicator "$WORK/r"
[ "$output" = " (✓)" ]
}
@test "git_status_indicator: one unpushed commit reads (↑1)" {
_mk_repo_upstream "$WORK/r"
git -C "$WORK/r" commit -q --allow-empty -m ahead
run git_status_indicator "$WORK/r"
[ "$output" = " (↑1)" ]
}
@test "git_status_indicator: one commit behind upstream reads (↓1)" {
_mk_repo_upstream "$WORK/r"
git -C "$WORK/r" commit -q --allow-empty -m b
git -C "$WORK/r" push -q origin HEAD
git -C "$WORK/r" reset -q --hard HEAD~1
run git_status_indicator "$WORK/r"
[ "$output" = " (↓1)" ]
}
# --- annotate_candidates() ----------------------------------------------------
@test "annotate_candidates: appends each candidate's status suffix" {
_mk_repo_upstream "$WORK/home/code/clean"
mkdir -p "$WORK/home/code/plain"
HOME="$WORK/home"
candidates=("~/code/clean" "~/code/plain")
annotate_candidates
[ "${candidates[0]}" = "~/code/clean (✓)" ]
# A non-git candidate gets an empty suffix (git_status_indicator returns "").
[ "${candidates[1]}" = "~/code/plain" ]
}
# --- read_selections() --------------------------------------------------------
@test "read_selections: strips the ' (annotation)' suffix from each line" {
read_selections "$(printf '~/code/a (✓)\n~/projects/b (↑1 dirty)')"
[ "${#selected[@]}" -eq 2 ]
[ "${selected[0]}" = "~/code/a" ]
[ "${selected[1]}" = "~/projects/b" ]
}
@test "read_selections: a line with no annotation is kept verbatim" {
read_selections "~/code/plain"
[ "${selected[0]}" = "~/code/plain" ]
}
@test "read_selections: empty input yields a single empty element (current quirk)" {
read_selections ""
[ "${#selected[@]}" -eq 1 ]
[ -z "${selected[0]}" ]
}
# --- build_candidates() -------------------------------------------------------
@test "build_candidates: discovers .ai projects under HOME, filters, sorts" {
HOME="$WORK/home"
mkdir -p "$HOME/.emacs.d/.ai" "$HOME/code/beta/.ai" "$HOME/code/alpha/.ai" \
"$HOME/code/plain" "$HOME/projects/gamma/.ai"
touch "$HOME/.emacs.d/.ai/protocols.org" \
"$HOME/code/beta/.ai/protocols.org" \
"$HOME/code/alpha/.ai/protocols.org" \
"$HOME/projects/gamma/.ai/protocols.org"
# '|| true' suspends bats's errexit so the function runs to completion as it
# does in production (bin/ai runs without set -e). Its non-zero exit is
# incidental — it inherits the last maybe_add_candidate's filter result,
# which callers ignore; see the file's top-of-script NOTE.
build_candidates || true
# .emacs.d first (probed first), then ~/code sorted, then ~/projects.
[ "${candidates[0]}" = "~/.emacs.d" ]
[ "${candidates[1]}" = "~/code/alpha" ]
[ "${candidates[2]}" = "~/code/beta" ]
[ "${candidates[3]}" = "~/projects/gamma" ]
[ "${#candidates[@]}" -eq 4 ]
}
@test "build_candidates: a HOME with no .ai projects yields an empty list" {
HOME="$WORK/empty-home"
mkdir -p "$HOME/code/plain"
build_candidates || true
[ "${#candidates[@]}" -eq 0 ]
}
# --- functional: tmux-coupled pipeline (private socket) -----------------------
@test "functional create_window: adds a named window and returns its id" {
export AGENT_CMD="true"
tmux new-session -d -s ai -n base -c "$WORK"
run create_window "$WORK" "proj-x"
[ "$status" -eq 0 ]
[ -n "$output" ]
tmux list-windows -t ai -F '#{window_name}' | grep -qx proj-x
}
@test "functional find_window_id: returns the id for a name, empty for a miss" {
tmux new-session -d -s ai -n only -c "$WORK"
run find_window_id only
[ -n "$output" ]
run find_window_id nope
[ -z "$output" ]
}
@test "functional sort_windows: others alpha-first, then projects alpha" {
HOME="$WORK/home"
mkdir -p "$HOME/code/alpha/.ai" "$HOME/code/beta/.ai"
touch "$HOME/code/alpha/.ai/protocols.org" "$HOME/code/beta/.ai/protocols.org"
tmux new-session -d -s ai -n zzz-other -c "$WORK"
tmux new-window -t ai -n beta
tmux new-window -t ai -n alpha
tmux new-window -t ai -n aaa-other
run sort_windows
[ "$status" -eq 0 ]
order="$(tmux list-windows -t ai -F '#{window_name}' | paste -sd, -)"
[ "$order" = "aaa-other,zzz-other,alpha,beta" ]
}
@test "functional attach_mode: no session prints an error and exits 1" {
run bash "$AI" --attach
[ "$status" -eq 1 ]
[[ "$output" == *"no 'ai' session"* ]]
}
# --- extracted pure cores -----------------------------------------------------
@test "_git_prep_action: no upstream is always 'none'" {
run _git_prep_action 0 0 0 5
[ "$output" = none ]
}
@test "_git_prep_action: clean and purely behind is 'pull'" {
run _git_prep_action 1 0 0 3
[ "$output" = pull ]
}
@test "_git_prep_action: in sync with upstream is 'none'" {
run _git_prep_action 1 0 0 0
[ "$output" = none ]
}
@test "_git_prep_action: ahead is 'report', never 'pull'" {
run _git_prep_action 1 0 2 0
[ "$output" = report ]
}
@test "_git_prep_action: dirty is 'report', never 'pull'" {
run _git_prep_action 1 1 0 0
[ "$output" = report ]
}
@test "_git_prep_action: behind AND dirty is 'report' — a dirty repo is never auto-pulled" {
run _git_prep_action 1 1 0 3
[ "$output" = report ]
}
@test "_git_prep_action: diverged (ahead and behind) is 'report'" {
run _git_prep_action 1 0 2 3
[ "$output" = report ]
}
@test "_order_windows: others alpha, then projects alpha" {
local listing names out
listing="$(printf 'beta\t@1\nzzz-other\t@2\nalpha\t@3\naaa-other\t@4')"
names="$(printf 'alpha\nbeta')"
out="$(printf '%s\n' "$listing" | _order_windows "$names" | cut -f1 | paste -sd, -)"
[ "$out" = "aaa-other,zzz-other,alpha,beta" ]
}
@test "_order_windows: all-projects input keeps only the projects, sorted" {
local out
out="$(printf 'beta\t@1\nalpha\t@2\n' | _order_windows "$(printf 'alpha\nbeta')" | cut -f1 | paste -sd, -)"
[ "$out" = "alpha,beta" ]
}
@test "_order_windows: empty input yields empty output" {
run _order_windows "$(printf 'alpha\nbeta')" <<<""
[ -z "$output" ]
}
@test "_order_windows: a name matches only as a whole line, not as a substring" {
# 'alph' must not match project 'alpha' (grep -qxF is a full-line match).
local out
out="$(printf 'alph\t@1\n' | _order_windows "$(printf 'alpha')" | cut -f1)"
# 'alph' is not a project, so it lands in others, still present.
[ "$out" = "alph" ]
}
@test "_match_window_id: returns the id for a name, empty for a miss" {
local listing out
listing="$(printf 'one\t@1\ntwo\t@2\n')"
out="$(printf '%s\n' "$listing" | _match_window_id two)"
[ "$out" = "@2" ]
out="$(printf '%s\n' "$listing" | _match_window_id nope)"
[ -z "$out" ]
}
@test "_match_window_id: first match wins and stops" {
local out
out="$(printf 'dup\t@1\ndup\t@2\n' | _match_window_id dup)"
[ "$out" = "@1" ]
}
|