youmd 0.6.27 → 0.7.1

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.
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env bash
2
+ # sync.sh — cross-machine agent skill/stack syncer
3
+ # Compatible: bash 3.2+, macOS BSD coreutils (NO mapfile, NO GNU mktemp flags)
4
+ #
5
+ # Usage:
6
+ # ./sync.sh # sync all repos + loose skills
7
+ # ./sync.sh --dry-run # log intended actions only, write nothing
8
+ #
9
+ # Env overrides:
10
+ # SKILLSTACK_REPOS space-separated list of absolute repo paths (default: ~/.agent-shared ~/.claude/scistack)
11
+ # GIT_USER_NAME commit author name (default: Houston Golden)
12
+ # GIT_USER_EMAIL commit author email (default: houston@bamf.ai)
13
+
14
+ set -euo pipefail
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Config
18
+ # ---------------------------------------------------------------------------
19
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
20
+ LOG_DIR="${HOME}/.youmd/logs"
21
+ LOG_FILE="${LOG_DIR}/skillstack-sync.log"
22
+
23
+ GIT_USER_NAME="${GIT_USER_NAME:-Houston Golden}"
24
+ GIT_USER_EMAIL="${GIT_USER_EMAIL:-houston@bamf.ai}"
25
+
26
+ AGENT_SHARED="${HOME}/.agent-shared"
27
+ SCISTACK="${HOME}/.claude/scistack"
28
+
29
+ # 4 loose skills that live in ~/.claude/skills/ but aren't in any repo
30
+ LOOSE_SKILLS="agent-runtime-guard agent-stack-sync continue skill-governor"
31
+
32
+ # Repos to sync (space-separated absolute paths; override via env).
33
+ # v1 default is agent-shared ONLY — it's purpose-built for sync and safe to
34
+ # auto-commit. scistack is intentionally NOT auto-synced by default: it holds
35
+ # protected learning-loop IP where auto-committing WIP on a timer is undesirable.
36
+ # To opt scistack (or other repos) in, set:
37
+ # SKILLSTACK_REPOS="$HOME/.agent-shared $HOME/.claude/scistack"
38
+ DEFAULT_REPOS="${AGENT_SHARED}"
39
+ SKILLSTACK_REPOS="${SKILLSTACK_REPOS:-${DEFAULT_REPOS}}"
40
+
41
+ DRY_RUN=0
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Parse flags
45
+ # ---------------------------------------------------------------------------
46
+ for arg in "$@"; do
47
+ case "$arg" in
48
+ --dry-run) DRY_RUN=1 ;;
49
+ --once) : ;; # default — accepted for explicit invocation
50
+ *) echo "Unknown flag: $arg" >&2; exit 1 ;;
51
+ esac
52
+ done
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Logging
56
+ # ---------------------------------------------------------------------------
57
+ mkdir -p "${LOG_DIR}"
58
+
59
+ log() {
60
+ local ts
61
+ ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
62
+ local msg="[${ts}] $*"
63
+ echo "${msg}"
64
+ echo "${msg}" >> "${LOG_FILE}"
65
+ }
66
+
67
+ log_warn() {
68
+ log "WARN $*"
69
+ }
70
+
71
+ log_error() {
72
+ log "ERROR $*"
73
+ }
74
+
75
+ log_dry() {
76
+ log "DRY $*"
77
+ }
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Helpers
81
+ # ---------------------------------------------------------------------------
82
+
83
+ hostname_short() {
84
+ # BSD hostname (no --short flag)
85
+ hostname | cut -d. -f1
86
+ }
87
+
88
+ is_dry() {
89
+ [ "${DRY_RUN}" -eq 1 ]
90
+ }
91
+
92
+ # Check if repo is in a mid-merge/rebase state
93
+ repo_is_in_progress() {
94
+ local repo="$1"
95
+ if [ -f "${repo}/.git/MERGE_HEAD" ] \
96
+ || [ -d "${repo}/.git/rebase-merge" ] \
97
+ || [ -d "${repo}/.git/rebase-apply" ]; then
98
+ return 0
99
+ fi
100
+ return 1
101
+ }
102
+
103
+ # Diff two directories; returns 0 if they differ, 1 if identical
104
+ dirs_differ() {
105
+ local src="$1" dst="$2"
106
+ if diff -rq "${src}" "${dst}" >/dev/null 2>&1; then
107
+ return 1 # identical
108
+ fi
109
+ return 0 # different
110
+ }
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # LOOSE SKILL MIRROR (local → agent-shared repo)
114
+ # ---------------------------------------------------------------------------
115
+ mirror_loose_skills_to_repo() {
116
+ local skill name src dst
117
+ log "--- Loose-skill mirror: ~/.claude/skills/ → ${AGENT_SHARED}/claude-skills/ ---"
118
+
119
+ for skill in ${LOOSE_SKILLS}; do
120
+ src="${HOME}/.claude/skills/${skill}"
121
+ dst="${AGENT_SHARED}/claude-skills/${skill}"
122
+
123
+ if [ ! -d "${src}" ]; then
124
+ log_warn "Loose skill '${skill}' not found at ${src}, skipping."
125
+ continue
126
+ fi
127
+
128
+ if is_dry; then
129
+ log_dry "Would rsync ${src}/ → ${dst}/"
130
+ else
131
+ mkdir -p "${dst}"
132
+ rsync -a "${src}/" "${dst}/"
133
+ log "Mirrored ${skill} → ${AGENT_SHARED}/claude-skills/${skill}"
134
+ fi
135
+ done
136
+ }
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # REVERSE LOOSE SKILL MIRROR (agent-shared repo → local ~/.claude/skills/)
140
+ # Called AFTER agent-shared is synced, so we pick up remote edits.
141
+ # ---------------------------------------------------------------------------
142
+ mirror_loose_skills_from_repo() {
143
+ local skill src dst bak ts
144
+ log "--- Reverse loose-skill mirror: ${AGENT_SHARED}/claude-skills/ → ~/.claude/skills/ ---"
145
+
146
+ for skill in ${LOOSE_SKILLS}; do
147
+ src="${AGENT_SHARED}/claude-skills/${skill}"
148
+ dst="${HOME}/.claude/skills/${skill}"
149
+
150
+ if [ ! -d "${src}" ]; then
151
+ log_warn "Repo copy of loose skill '${skill}' not found at ${src}, skipping reverse mirror."
152
+ continue
153
+ fi
154
+
155
+ # Detect if there are actually changes to apply
156
+ if [ -d "${dst}" ] && ! dirs_differ "${src}" "${dst}"; then
157
+ log "Loose skill '${skill}': no changes, skipping reverse mirror."
158
+ continue
159
+ fi
160
+
161
+ if is_dry; then
162
+ log_dry "Would backup ${dst} → ${dst}.bak.<timestamp> and rsync ${src}/ → ${dst}/"
163
+ continue
164
+ fi
165
+
166
+ # Backup existing local copy before overwriting
167
+ if [ -d "${dst}" ]; then
168
+ ts="$(date -u +"%Y-%m-%dT%H-%M-%SZ")"
169
+ bak="${dst}.bak.${ts}"
170
+ log "Backing up ${dst} → ${bak}"
171
+ rsync -a "${dst}/" "${bak}/"
172
+ fi
173
+
174
+ mkdir -p "${dst}"
175
+ rsync -a "${src}/" "${dst}/"
176
+ log "Reverse-mirrored ${skill} → ${dst}"
177
+ done
178
+ }
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # SYNC ONE REPO
182
+ # Returns 0 on success, 1 on non-fatal failure.
183
+ # ---------------------------------------------------------------------------
184
+ sync_repo() {
185
+ local repo="$1"
186
+ local repo_name
187
+ repo_name="$(basename "${repo}")"
188
+
189
+ log "=== Syncing repo: ${repo} ==="
190
+
191
+ # Guard: must be a git repo
192
+ if [ ! -d "${repo}/.git" ]; then
193
+ log_warn "Skipping ${repo}: not a git repository."
194
+ return 1
195
+ fi
196
+
197
+ # Guard: must not be mid-merge/rebase
198
+ if repo_is_in_progress "${repo}"; then
199
+ log_warn "Skipping ${repo}: repository is mid-merge or mid-rebase. Resolve manually first."
200
+ return 1
201
+ fi
202
+
203
+ # Stage and commit local changes if any
204
+ local status_out
205
+ status_out="$(git -C "${repo}" status --porcelain 2>&1)"
206
+ if [ -n "${status_out}" ]; then
207
+ local ts host commit_msg
208
+ ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
209
+ host="$(hostname_short)"
210
+ commit_msg="auto-sync: ${host} ${ts}"
211
+
212
+ if is_dry; then
213
+ log_dry "Would commit in ${repo}: '${commit_msg}'"
214
+ log_dry "Changed files:\n${status_out}"
215
+ else
216
+ git -C "${repo}" add -A
217
+ git -C "${repo}" \
218
+ -c "user.name=${GIT_USER_NAME}" \
219
+ -c "user.email=${GIT_USER_EMAIL}" \
220
+ commit -m "${commit_msg}"
221
+ log "Committed local changes in ${repo_name}: '${commit_msg}'"
222
+ fi
223
+ else
224
+ log "No local changes in ${repo_name}."
225
+ fi
226
+
227
+ # Pull (merge, no rebase)
228
+ if is_dry; then
229
+ log_dry "Would git pull --no-rebase --no-edit in ${repo}"
230
+ else
231
+ local pull_log
232
+ pull_log="$(mktemp -t skillstack_pull)"
233
+ local pull_ok=0
234
+ git -C "${repo}" pull --no-rebase --no-edit >"${pull_log}" 2>&1 || pull_ok=$?
235
+ # Echo pull output line by line into the log
236
+ while IFS= read -r line; do
237
+ log "pull|${repo_name}: ${line}"
238
+ done < "${pull_log}"
239
+ rm -f "${pull_log}"
240
+
241
+ if [ "${pull_ok}" -ne 0 ]; then
242
+ log_error "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
243
+ log_error "CONFLICT in ${repo_name} — pull failed."
244
+ log_error "Aborting merge/rebase and skipping push."
245
+ log_error "Manual resolution needed: cd ${repo} && git status"
246
+ log_error "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
247
+ # Abort any in-progress merge or rebase, leave working tree clean
248
+ git -C "${repo}" merge --abort 2>/dev/null || true
249
+ git -C "${repo}" rebase --abort 2>/dev/null || true
250
+ return 1
251
+ fi
252
+ log "Pull succeeded for ${repo_name}."
253
+ fi
254
+
255
+ # Push
256
+ if is_dry; then
257
+ log_dry "Would git push in ${repo}"
258
+ else
259
+ local push_log
260
+ push_log="$(mktemp -t skillstack_push)"
261
+ local push_ok=0
262
+ git -C "${repo}" push >"${push_log}" 2>&1 || push_ok=$?
263
+ while IFS= read -r line; do
264
+ log "push|${repo_name}: ${line}"
265
+ done < "${push_log}"
266
+ rm -f "${push_log}"
267
+
268
+ if [ "${push_ok}" -ne 0 ]; then
269
+ log_error "Push FAILED for ${repo_name}. Check connectivity/auth."
270
+ return 1
271
+ fi
272
+ log "Push succeeded for ${repo_name}."
273
+ fi
274
+
275
+ log "=== Done: ${repo_name} ==="
276
+ return 0
277
+ }
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # MAIN
281
+ # ---------------------------------------------------------------------------
282
+ main() {
283
+ local mode
284
+ if is_dry; then
285
+ mode="DRY-RUN"
286
+ else
287
+ mode="LIVE"
288
+ fi
289
+
290
+ log "============================================================"
291
+ log "skillstack-sync START [${mode}] on $(hostname_short)"
292
+ log "============================================================"
293
+
294
+ # Step 1: Mirror loose skills into agent-shared BEFORE syncing it
295
+ mirror_loose_skills_to_repo
296
+
297
+ # Step 2: Sync each repo
298
+ local agent_shared_synced=0
299
+ for repo in ${SKILLSTACK_REPOS}; do
300
+ if sync_repo "${repo}"; then
301
+ if [ "${repo}" = "${AGENT_SHARED}" ]; then
302
+ agent_shared_synced=1
303
+ fi
304
+ fi
305
+ done
306
+
307
+ # Step 3: Reverse-mirror loose skills from agent-shared back to ~/.claude/skills/
308
+ # Only do this if agent-shared was successfully synced (or in dry-run mode)
309
+ if is_dry || [ "${agent_shared_synced}" -eq 1 ]; then
310
+ mirror_loose_skills_from_repo
311
+ else
312
+ log_warn "Skipping reverse loose-skill mirror because agent-shared sync did not complete cleanly."
313
+ fi
314
+
315
+ log "============================================================"
316
+ log "skillstack-sync DONE [${mode}]"
317
+ log "============================================================"
318
+ }
319
+
320
+ main "$@"