@delorenj/pjangler 1.2.21 → 1.2.25
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/dist/index.js +1852 -368
- package/dist/mcp-server.js +1782 -296
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +3 -0
- package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +9 -2
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
|
@@ -10,7 +10,7 @@ set -euo pipefail
|
|
|
10
10
|
# contract runtime/profile.yaml opts into config.inherit_from: default
|
|
11
11
|
# registry ~/.hermes/profiles/<profile_name> -> <role_dir>/runtime
|
|
12
12
|
# role <role_dir>/role.yaml profile: <profile_name>
|
|
13
|
-
# services
|
|
13
|
+
# services stale per-profile Bloodbank consumers retired; gateways restarted
|
|
14
14
|
#
|
|
15
15
|
# Default is a DRY-RUN drift report (exit 1 when drift exists, 0 when clean).
|
|
16
16
|
# --apply writes the fixes and restarts the changed agents' services.
|
|
@@ -74,7 +74,7 @@ done
|
|
|
74
74
|
[[ -f "$REGISTRY_FILE" ]] || { echo "fleet-sync: registry not found: $REGISTRY_FILE" >&2; exit 2; }
|
|
75
75
|
[[ -f "$WRAPPER_TEMPLATE" ]] || { echo "fleet-sync: wrapper template not found: $WRAPPER_TEMPLATE" >&2; exit 2; }
|
|
76
76
|
|
|
77
|
-
# Registry -> TSV: agent_id, role_dir, profile_name, gateway_unit,
|
|
77
|
+
# Registry -> TSV: agent_id, role_dir, profile_name, gateway_unit, legacy_consumer_unit
|
|
78
78
|
read_registry() {
|
|
79
79
|
python3 - "$REGISTRY_FILE" <<'PYEOF'
|
|
80
80
|
import sys
|
|
@@ -91,7 +91,7 @@ for agent_id, a in sorted((data.get("agents") or {}).items()):
|
|
|
91
91
|
if not isinstance(a, dict):
|
|
92
92
|
continue
|
|
93
93
|
systemd = a.get("systemd") or {}
|
|
94
|
-
print("\
|
|
94
|
+
print("\x1f".join([
|
|
95
95
|
agent_id,
|
|
96
96
|
str(a.get("role_dir") or ""),
|
|
97
97
|
str(a.get("profile_name") or agent_id),
|
|
@@ -111,12 +111,113 @@ declare -a RESTART_UNITS=()
|
|
|
111
111
|
|
|
112
112
|
# Registry agents sharing a role_dir would overwrite each other's wrapper
|
|
113
113
|
# and role.yaml on --apply; refuse to write into contested dirs.
|
|
114
|
-
CONTESTED_DIRS="$(read_registry | cut -f2 | sort | uniq -d)"
|
|
114
|
+
CONTESTED_DIRS="$(read_registry | cut -d $'\x1f' -f2 | sort | uniq -d)"
|
|
115
115
|
|
|
116
116
|
note() { # note <agent> <status> <message>
|
|
117
117
|
printf '%-34s %-7s %s\n' "$1" "$2" "$3"
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
systemctl_user_unit_state() { # systemctl_user_unit_state <is-active|is-enabled> <unit>
|
|
121
|
+
local query="$1" unit="$2" output rc first_line
|
|
122
|
+
command -v systemctl >/dev/null 2>&1 \
|
|
123
|
+
|| { printf 'error|systemctl unavailable'; return 0; }
|
|
124
|
+
set +e
|
|
125
|
+
output="$(LC_ALL=C systemctl --user "$query" "$unit" 2>&1)"
|
|
126
|
+
rc=$?
|
|
127
|
+
set -e
|
|
128
|
+
first_line="${output%%$'\n'*}"
|
|
129
|
+
first_line="${first_line//$'\t'/ }"
|
|
130
|
+
first_line="${first_line//|/}"
|
|
131
|
+
case "$query:$rc:$first_line" in
|
|
132
|
+
is-active:0:active|is-active:0:reloading|is-active:0:activating|is-active:0:deactivating)
|
|
133
|
+
printf 'ok|%s' "$first_line" ;;
|
|
134
|
+
is-active:3:inactive|is-active:3:failed)
|
|
135
|
+
printf 'ok|%s' "$first_line" ;;
|
|
136
|
+
is-active:4:inactive)
|
|
137
|
+
printf 'ok|not-found' ;;
|
|
138
|
+
is-enabled:0:enabled|is-enabled:0:enabled-runtime|is-enabled:0:linked|is-enabled:0:linked-runtime|is-enabled:0:alias|is-enabled:0:static|is-enabled:0:indirect|is-enabled:0:generated|is-enabled:0:transient)
|
|
139
|
+
printf 'ok|%s' "$first_line" ;;
|
|
140
|
+
is-enabled:1:disabled|is-enabled:1:masked|is-enabled:1:masked-runtime)
|
|
141
|
+
printf 'ok|%s' "$first_line" ;;
|
|
142
|
+
is-enabled:4:not-found)
|
|
143
|
+
printf 'ok|not-found' ;;
|
|
144
|
+
*)
|
|
145
|
+
[[ -n "$first_line" ]] || first_line="exit $rc with no state"
|
|
146
|
+
printf 'error|%s' "$first_line" ;;
|
|
147
|
+
esac
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
retire_registry_consumer_metadata() { # retire_registry_consumer_metadata <agent_id>
|
|
151
|
+
local target_agent="$1" lock_file="${REGISTRY_FILE}.lock" registry_lock_fd=""
|
|
152
|
+
command -v flock >/dev/null 2>&1 \
|
|
153
|
+
|| { echo "fleet-sync: flock is required for registry updates" >&2; return 1; }
|
|
154
|
+
[[ ! -L "$lock_file" ]] \
|
|
155
|
+
|| { echo "fleet-sync: refusing registry lock symlink: $lock_file" >&2; return 1; }
|
|
156
|
+
exec {registry_lock_fd}>"$lock_file"
|
|
157
|
+
chmod 600 "$lock_file"
|
|
158
|
+
flock -w 30 "$registry_lock_fd" \
|
|
159
|
+
|| { echo "fleet-sync: timed out waiting for registry lock" >&2; return 1; }
|
|
160
|
+
python3 - "$REGISTRY_FILE" "$target_agent" <<'PYEOF'
|
|
161
|
+
import errno
|
|
162
|
+
import os
|
|
163
|
+
import pathlib
|
|
164
|
+
import sys
|
|
165
|
+
import tempfile
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
import yaml
|
|
169
|
+
except ImportError:
|
|
170
|
+
raise SystemExit("fleet-sync: python3-yaml is required")
|
|
171
|
+
|
|
172
|
+
path = pathlib.Path(sys.argv[1])
|
|
173
|
+
agent_id = sys.argv[2]
|
|
174
|
+
if path.is_symlink():
|
|
175
|
+
raise SystemExit(f"fleet-sync: refusing registry symlink: {path}")
|
|
176
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
177
|
+
entry = (data.get("agents") or {}).get(agent_id)
|
|
178
|
+
if isinstance(entry, dict) and isinstance(entry.get("systemd"), dict):
|
|
179
|
+
entry["systemd"].pop("consumer_unit", None)
|
|
180
|
+
rendered = yaml.safe_dump(data, sort_keys=False)
|
|
181
|
+
|
|
182
|
+
def fsync_parent(target):
|
|
183
|
+
unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
|
|
184
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
185
|
+
try:
|
|
186
|
+
directory_fd = os.open(target.parent, flags)
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
if exc.errno in unsupported:
|
|
189
|
+
return
|
|
190
|
+
raise
|
|
191
|
+
try:
|
|
192
|
+
try:
|
|
193
|
+
os.fsync(directory_fd)
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
if exc.errno not in unsupported:
|
|
196
|
+
raise
|
|
197
|
+
finally:
|
|
198
|
+
os.close(directory_fd)
|
|
199
|
+
|
|
200
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.fleet-sync-", dir=path.parent)
|
|
201
|
+
try:
|
|
202
|
+
os.fchmod(fd, 0o600)
|
|
203
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
204
|
+
handle.write(rendered)
|
|
205
|
+
handle.flush()
|
|
206
|
+
os.fsync(handle.fileno())
|
|
207
|
+
os.replace(temporary, path)
|
|
208
|
+
os.chmod(path, 0o600)
|
|
209
|
+
fsync_parent(path)
|
|
210
|
+
except BaseException:
|
|
211
|
+
try:
|
|
212
|
+
os.unlink(temporary)
|
|
213
|
+
except FileNotFoundError:
|
|
214
|
+
pass
|
|
215
|
+
raise
|
|
216
|
+
PYEOF
|
|
217
|
+
flock -u "$registry_lock_fd"
|
|
218
|
+
eval "exec ${registry_lock_fd}>&-"
|
|
219
|
+
}
|
|
220
|
+
|
|
120
221
|
ensure_env_key() { # ensure_env_key <path> <key> <value>
|
|
121
222
|
python3 - "$1" "$2" "$3" <<'PYEOF'
|
|
122
223
|
import pathlib, sys
|
|
@@ -143,7 +244,7 @@ wanted_agent() {
|
|
|
143
244
|
return 1
|
|
144
245
|
}
|
|
145
246
|
|
|
146
|
-
while IFS=$'\
|
|
247
|
+
while IFS=$'\x1f' read -r agent_id role_dir profile_name gateway_unit consumer_unit; do
|
|
147
248
|
wanted_agent "$agent_id" || continue
|
|
148
249
|
if [[ -z "$role_dir" || ! -d "$role_dir" ]]; then
|
|
149
250
|
note "$agent_id" DRIFT "role_dir missing: ${role_dir:-<unset>} (MANUAL: deprovision or fix registry)"
|
|
@@ -158,6 +259,63 @@ while IFS=$'\t' read -r agent_id role_dir profile_name gateway_unit consumer_uni
|
|
|
158
259
|
runtime="$role_dir/runtime"
|
|
159
260
|
changed=0
|
|
160
261
|
|
|
262
|
+
# Legacy per-profile Bloodbank consumers are unhealthy until fully retired.
|
|
263
|
+
legacy_consumer_unit="${consumer_unit:-hermes-${agent_id}-consumer.service}"
|
|
264
|
+
if [[ ! "$legacy_consumer_unit" =~ ^hermes-[A-Za-z0-9._-]+-consumer\.service$ ]]; then
|
|
265
|
+
note "$agent_id" DRIFT "unsafe legacy consumer unit name in registry: $legacy_consumer_unit (MANUAL: fix registry)"
|
|
266
|
+
DRIFT=$((DRIFT + 1))
|
|
267
|
+
else
|
|
268
|
+
legacy_consumer_path="$HOME/.config/systemd/user/$legacy_consumer_unit"
|
|
269
|
+
legacy_consumer_present=0
|
|
270
|
+
[[ -n "$consumer_unit" || -e "$legacy_consumer_path" || -L "$legacy_consumer_path" ]] \
|
|
271
|
+
&& legacy_consumer_present=1
|
|
272
|
+
if command -v systemctl >/dev/null 2>&1; then
|
|
273
|
+
legacy_active_result="$(systemctl_user_unit_state is-active "$legacy_consumer_unit")"
|
|
274
|
+
legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$legacy_consumer_unit")"
|
|
275
|
+
if [[ "$legacy_active_result" == error\|* || "$legacy_enabled_result" == error\|* ]]; then
|
|
276
|
+
note "$agent_id" DRIFT "legacy consumer state query failed; unit and metadata preserved"
|
|
277
|
+
DRIFT=$((DRIFT + 1))
|
|
278
|
+
legacy_consumer_query_failed=1
|
|
279
|
+
else
|
|
280
|
+
legacy_consumer_query_failed=0
|
|
281
|
+
legacy_active_state="${legacy_active_result#*|}"
|
|
282
|
+
legacy_enabled_state="${legacy_enabled_result#*|}"
|
|
283
|
+
[[ "$legacy_active_state" == "not-found" && "$legacy_enabled_state" == "not-found" ]] \
|
|
284
|
+
|| legacy_consumer_present=1
|
|
285
|
+
fi
|
|
286
|
+
elif [[ $legacy_consumer_present -eq 1 ]]; then
|
|
287
|
+
note "$agent_id" DRIFT "systemctl unavailable; legacy unit and metadata preserved"
|
|
288
|
+
DRIFT=$((DRIFT + 1))
|
|
289
|
+
legacy_consumer_query_failed=1
|
|
290
|
+
else
|
|
291
|
+
legacy_consumer_query_failed=0
|
|
292
|
+
fi
|
|
293
|
+
if [[ $legacy_consumer_query_failed -eq 0 && $legacy_consumer_present -eq 1 ]]; then
|
|
294
|
+
if [[ $APPLY -eq 0 ]]; then
|
|
295
|
+
note "$agent_id" DRIFT "legacy per-profile Bloodbank consumer remains: $legacy_consumer_unit"
|
|
296
|
+
DRIFT=$((DRIFT + 1))
|
|
297
|
+
else
|
|
298
|
+
if ! systemctl --user disable --now "$legacy_consumer_unit" >/dev/null 2>&1; then
|
|
299
|
+
note "$agent_id" DRIFT "legacy consumer disable failed; unit and metadata preserved"
|
|
300
|
+
DRIFT=$((DRIFT + 1))
|
|
301
|
+
else
|
|
302
|
+
legacy_active_result="$(systemctl_user_unit_state is-active "$legacy_consumer_unit")"
|
|
303
|
+
legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$legacy_consumer_unit")"
|
|
304
|
+
if [[ "$legacy_active_result" != "ok|inactive" || "$legacy_enabled_result" != "ok|disabled" ]]; then
|
|
305
|
+
note "$agent_id" DRIFT "legacy consumer is not proven inactive and disabled; unit and metadata preserved"
|
|
306
|
+
DRIFT=$((DRIFT + 1))
|
|
307
|
+
else
|
|
308
|
+
rm -f -- "$legacy_consumer_path"
|
|
309
|
+
systemctl --user daemon-reload >/dev/null 2>&1 || true
|
|
310
|
+
retire_registry_consumer_metadata "$agent_id"
|
|
311
|
+
note "$agent_id" FIXED "legacy per-profile Bloodbank consumer retired"
|
|
312
|
+
FIXED=$((FIXED + 1))
|
|
313
|
+
fi
|
|
314
|
+
fi
|
|
315
|
+
fi
|
|
316
|
+
fi
|
|
317
|
+
fi
|
|
318
|
+
|
|
161
319
|
# 1. Launcher wrapper regenerated from the current template.
|
|
162
320
|
expected_wrapper="$(render_wrapper "$agent_id")"
|
|
163
321
|
if [[ ! -f "$role_dir/hermes" ]] || ! diff -q <(printf '%s\n' "$expected_wrapper") "$role_dir/hermes" >/dev/null 2>&1; then
|
|
@@ -334,13 +492,12 @@ PYEOF
|
|
|
334
492
|
|
|
335
493
|
if [[ $changed -eq 1 && $RESTART -eq 1 ]]; then
|
|
336
494
|
[[ -n "$gateway_unit" ]] && RESTART_UNITS+=("$gateway_unit")
|
|
337
|
-
[[ -n "$consumer_unit" ]] && RESTART_UNITS+=("$consumer_unit")
|
|
338
495
|
fi
|
|
339
496
|
done < <(read_registry)
|
|
340
497
|
|
|
341
498
|
# 5. Stale registry symlinks: entries pointing into fleet runtimes under a
|
|
342
499
|
# name the registry does not know. Reported only — removal is a human call.
|
|
343
|
-
known_names="$(read_registry | cut -f3 | sort -u)"
|
|
500
|
+
known_names="$(read_registry | cut -d $'\x1f' -f3 | sort -u)"
|
|
344
501
|
if [[ -d "$PROFILES_DIR" ]]; then
|
|
345
502
|
for link in "$PROFILES_DIR"/*; do
|
|
346
503
|
[[ -L "$link" ]] || continue
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""hermes-runtime-templatize — dedup a runtime's skills onto the shared
|
|
3
|
+
hermes-base pack, at PER-SKILL granularity, with scoped-name overrides.
|
|
4
|
+
|
|
5
|
+
Why per-skill: 14 of the 18 base dirs are CATEGORY dirs (apple, creative,
|
|
6
|
+
github, …) holding 73 sub-skills total. hermes keys skill identity on each
|
|
7
|
+
SKILL.md's frontmatter `name:` (and the containing dir name), so dedup and
|
|
8
|
+
override must happen at the sub-skill level, not the category-dir level.
|
|
9
|
+
|
|
10
|
+
Policy (fleet decision, 2026-07-23):
|
|
11
|
+
* a base sub-skill IDENTICAL to the pack -> delete the local copy (it then
|
|
12
|
+
resolves read-only from the pack via config.yaml skills.external_dirs).
|
|
13
|
+
* a base sub-skill that was OVERWRITTEN -> SCOPED-NAME override: rename its
|
|
14
|
+
dir AND its frontmatter `name:` to `<name>-<slug>`, promoting it from a
|
|
15
|
+
shared-base skill to an unambiguous agent-scoped copy. The pristine base
|
|
16
|
+
stays in the pack for every other agent; no collision, no data loss.
|
|
17
|
+
* an agent-ADDED skill (not in the pack) -> left untouched in the overlay.
|
|
18
|
+
Then the pack is wired into config.yaml (if not already). Empty category dirs
|
|
19
|
+
left after deletions are pruned.
|
|
20
|
+
|
|
21
|
+
A runtime whose config has no skills.external_dirs (bare-layout agents) is
|
|
22
|
+
BLOCKED (can't resolve deleted skills from the pack) and left untouched.
|
|
23
|
+
|
|
24
|
+
Dry-run by default; --apply mutates. Idempotent (a second run finds nothing to
|
|
25
|
+
do). `verify` checks a runtime is name-disjoint from the pack.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
hermes-runtime-templatize.py dedup [--apply] [--root DIR] [--slug S] [--pack DIR]
|
|
29
|
+
hermes-runtime-templatize.py verify [--root DIR] [--pack DIR]
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
import argparse, hashlib, os, re, shutil, sys
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
DEFAULT_PACK = Path("/home/delorenj/code/skillex/packs/hermes-base/0.18.2")
|
|
36
|
+
|
|
37
|
+
# The canonical fleet skills.external_dirs (in precedence order). Every agent
|
|
38
|
+
# should carry exactly these; `provision` normalizes to them (preserving any
|
|
39
|
+
# agent-specific extras).
|
|
40
|
+
MAINLINE_EXTERNAL_DIRS = [
|
|
41
|
+
"/home/delorenj/code/skillex/skill-sets/global/.system",
|
|
42
|
+
"/home/delorenj/code/skillex/packs/bmad/6.10.1-next.31",
|
|
43
|
+
str(DEFAULT_PACK),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# Mirror hermes agent/skill_utils.py: only these dirs are pruned from skill
|
|
47
|
+
# scanning, and a SKILL.md inside a skill's support dir is progressive-
|
|
48
|
+
# disclosure data, not an active skill root. Keeping this identical means we
|
|
49
|
+
# classify EXACTLY the skills hermes treats as active (never .archive, .git, …).
|
|
50
|
+
EXCLUDED_SKILL_DIRS = frozenset((
|
|
51
|
+
".archive", ".git", ".github", ".hub", ".mypy_cache", ".nox", ".pytest_cache",
|
|
52
|
+
".ruff_cache", ".tox", ".venv", "__pycache__", "node_modules", "site-packages", "venv",
|
|
53
|
+
))
|
|
54
|
+
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def iter_skill_roots(skills_dir: Path):
|
|
58
|
+
"""Yield each ACTIVE SKILL.md path (exact copy of hermes iter_skill_index_files)."""
|
|
59
|
+
matches = []
|
|
60
|
+
for root, dirs, files in os.walk(str(skills_dir)):
|
|
61
|
+
has = "SKILL.md" in files
|
|
62
|
+
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS and not (has and d in SKILL_SUPPORT_DIRS)]
|
|
63
|
+
if "SKILL.md" in files:
|
|
64
|
+
matches.append(os.path.join(root, "SKILL.md"))
|
|
65
|
+
for p in sorted(matches):
|
|
66
|
+
yield Path(p)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def treehash(d: Path) -> str:
|
|
70
|
+
lines = []
|
|
71
|
+
for p in sorted(d.rglob("*")):
|
|
72
|
+
if p.is_file():
|
|
73
|
+
lines.append(hashlib.sha256(p.read_bytes()).hexdigest() + " " + str(p.relative_to(d)))
|
|
74
|
+
lines.sort()
|
|
75
|
+
return hashlib.sha256("\n".join(lines).encode()).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def frontmatter_name(smd: Path) -> str:
|
|
79
|
+
try:
|
|
80
|
+
t = smd.read_text(encoding="utf-8")
|
|
81
|
+
except Exception:
|
|
82
|
+
return smd.parent.name
|
|
83
|
+
m = re.match(r"^---\s*\n(.*?)\n---", t, re.S)
|
|
84
|
+
if m:
|
|
85
|
+
mm = re.search(r"^name:\s*(.+?)\s*$", m.group(1), re.M)
|
|
86
|
+
if mm:
|
|
87
|
+
return mm.group(1).strip().strip("'\"")
|
|
88
|
+
return smd.parent.name
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_frontmatter_name(smd: Path, newname: str) -> None:
|
|
92
|
+
t = smd.read_text(encoding="utf-8")
|
|
93
|
+
m = re.match(r"^(---\s*\n)(.*?)(\n---)", t, re.S)
|
|
94
|
+
if m:
|
|
95
|
+
head, fm, fence = m.group(1), m.group(2), m.group(3)
|
|
96
|
+
if re.search(r"^name:\s*.+$", fm, re.M):
|
|
97
|
+
fm = re.sub(r"^name:\s*.+$", f"name: {newname}", fm, count=1, flags=re.M)
|
|
98
|
+
else:
|
|
99
|
+
fm = f"name: {newname}\n" + fm
|
|
100
|
+
smd.write_text(head + fm + fence + t[m.end():], encoding="utf-8")
|
|
101
|
+
else:
|
|
102
|
+
smd.write_text(f"---\nname: {newname}\n---\n" + t, encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def pack_index(pack: Path) -> dict[str, str]:
|
|
106
|
+
"""frontmatter-name -> treehash of the skill's own dir, across the pack."""
|
|
107
|
+
idx: dict[str, str] = {}
|
|
108
|
+
for smd in iter_skill_roots(pack):
|
|
109
|
+
idx[frontmatter_name(smd)] = treehash(smd.parent)
|
|
110
|
+
return idx
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def runtimes(root: Path):
|
|
114
|
+
base = root / "agents" / "hermes"
|
|
115
|
+
if not base.is_dir():
|
|
116
|
+
return []
|
|
117
|
+
return sorted(p for p in base.glob("*/runtime") if p.is_dir())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def config_wireable(cfg: Path, pack: Path) -> tuple[bool, bool]:
|
|
121
|
+
"""(wireable, already). wireable=False for a config with no external_dirs."""
|
|
122
|
+
if not cfg.is_file():
|
|
123
|
+
return (False, False)
|
|
124
|
+
txt = cfg.read_text(encoding="utf-8")
|
|
125
|
+
if str(pack) in txt:
|
|
126
|
+
return (True, True)
|
|
127
|
+
return (bool(re.search(r"^\s*external_dirs:", txt, re.M)), False)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def wire(cfg: Path, pack: Path, apply: bool) -> bool:
|
|
131
|
+
ok, already = config_wireable(cfg, pack)
|
|
132
|
+
if already:
|
|
133
|
+
return True
|
|
134
|
+
if not ok:
|
|
135
|
+
return False
|
|
136
|
+
if not apply:
|
|
137
|
+
return True
|
|
138
|
+
lines = cfg.read_text(encoding="utf-8").splitlines()
|
|
139
|
+
out, i, done = [], 0, False
|
|
140
|
+
while i < len(lines):
|
|
141
|
+
m = re.match(r"^(\s*)external_dirs:\s*(.*)$", lines[i])
|
|
142
|
+
if not done and m:
|
|
143
|
+
indent, inline = m.group(1), m.group(2).strip()
|
|
144
|
+
if inline == "": # block form: keep existing items, append ours
|
|
145
|
+
out.append(lines[i])
|
|
146
|
+
j, item_indent = i + 1, None
|
|
147
|
+
while j < len(lines) and lines[j].strip().startswith("- "):
|
|
148
|
+
if item_indent is None:
|
|
149
|
+
item_indent = len(lines[j]) - len(lines[j].lstrip())
|
|
150
|
+
out.append(lines[j]); j += 1
|
|
151
|
+
out.append(" " * (item_indent if item_indent is not None else indent) + "- " + str(pack))
|
|
152
|
+
done, i = True, j
|
|
153
|
+
continue
|
|
154
|
+
else: # inline form ([] or [a, b]) -> convert to block
|
|
155
|
+
inner = inline.strip("[]").strip()
|
|
156
|
+
items = [x.strip() for x in inner.split(",") if x.strip()] if inner else []
|
|
157
|
+
out.append(f"{indent}external_dirs:")
|
|
158
|
+
for it in items:
|
|
159
|
+
out.append(f"{indent}- {it}")
|
|
160
|
+
out.append(f"{indent}- {pack}")
|
|
161
|
+
done, i = True, i + 1
|
|
162
|
+
continue
|
|
163
|
+
out.append(lines[i]); i += 1
|
|
164
|
+
if not done:
|
|
165
|
+
return False
|
|
166
|
+
cfg.write_text("\n".join(out) + "\n", encoding="utf-8")
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def set_external_dirs(cfg: Path, dirs: list[str], apply: bool) -> str:
|
|
171
|
+
"""Normalize skills.external_dirs to `dirs` (canonical order) + preserve any
|
|
172
|
+
agent-specific extras. Creates skills:/external_dirs: if absent. Idempotent."""
|
|
173
|
+
if not cfg.is_file():
|
|
174
|
+
return "no config.yaml"
|
|
175
|
+
lines = cfg.read_text(encoding="utf-8").splitlines()
|
|
176
|
+
skills_i = next((i for i, l in enumerate(lines) if re.match(r"^skills:\s*$", l)), None)
|
|
177
|
+
items = [f" - {d}" for d in dirs]
|
|
178
|
+
if skills_i is None:
|
|
179
|
+
new = lines[:]
|
|
180
|
+
if new and new[-1].strip() != "":
|
|
181
|
+
new.append("")
|
|
182
|
+
new += ["skills:", " external_dirs:"] + items
|
|
183
|
+
action = "created skills.external_dirs"
|
|
184
|
+
else:
|
|
185
|
+
end = next((j for j in range(skills_i + 1, len(lines)) if lines[j] and not lines[j][0].isspace()), len(lines))
|
|
186
|
+
ext_i = next((j for j in range(skills_i + 1, end) if re.match(r"^\s*external_dirs:", lines[j])), None)
|
|
187
|
+
if ext_i is None:
|
|
188
|
+
new = lines[:skills_i + 1] + [" external_dirs:"] + items + lines[skills_i + 1:]
|
|
189
|
+
action = "added external_dirs under skills:"
|
|
190
|
+
else:
|
|
191
|
+
m = re.match(r"^(\s*)external_dirs:\s*(.*)$", lines[ext_i])
|
|
192
|
+
indent, inline = m.group(1), m.group(2).strip()
|
|
193
|
+
existing, item_end = [], ext_i + 1
|
|
194
|
+
if inline == "":
|
|
195
|
+
while item_end < end and lines[item_end].strip().startswith("- "):
|
|
196
|
+
existing.append(re.sub(r"^-\s*", "", lines[item_end].strip()))
|
|
197
|
+
item_end += 1
|
|
198
|
+
elif inline != "[]":
|
|
199
|
+
existing = [x.strip() for x in inline.strip("[]").split(",") if x.strip()]
|
|
200
|
+
merged = list(dirs) + [x for x in existing if x not in dirs]
|
|
201
|
+
new = lines[:ext_i] + [f"{indent}external_dirs:"] + [f"{indent}- {d}" for d in merged] + lines[item_end:]
|
|
202
|
+
action = "normalized external_dirs"
|
|
203
|
+
if new == lines:
|
|
204
|
+
return "ok (already mainline)"
|
|
205
|
+
if apply:
|
|
206
|
+
cfg.write_text("\n".join(new) + "\n", encoding="utf-8")
|
|
207
|
+
return action
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def provision(root: Path, dirs: list[str], apply: bool) -> None:
|
|
211
|
+
for rt in runtimes(root):
|
|
212
|
+
print(f"-- {rt}")
|
|
213
|
+
print(" " + set_external_dirs(rt / "config.yaml", dirs, apply))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def prune_empty_categories(sk: Path, apply: bool) -> int:
|
|
217
|
+
"""Remove immediate child dirs of skills/ that hold no SKILL.md anymore."""
|
|
218
|
+
n = 0
|
|
219
|
+
for child in sorted(p for p in sk.iterdir() if p.is_dir()):
|
|
220
|
+
if not any(child.rglob("SKILL.md")):
|
|
221
|
+
n += 1
|
|
222
|
+
if apply:
|
|
223
|
+
shutil.rmtree(child)
|
|
224
|
+
return n
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def dedup(root: Path, slug: str, pack: Path, apply: bool) -> None:
|
|
228
|
+
idx = pack_index(pack)
|
|
229
|
+
for rt in runtimes(root):
|
|
230
|
+
sk = rt / "skills"
|
|
231
|
+
print(f"-- {rt}")
|
|
232
|
+
if not sk.is_dir():
|
|
233
|
+
print(" no skills/ — nothing to do"); continue
|
|
234
|
+
ok, already = config_wireable(rt / "config.yaml", pack)
|
|
235
|
+
if not ok:
|
|
236
|
+
print(" BLOCKED: config.yaml has no skills.external_dirs (bare-layout) — provision a skills block first; untouched")
|
|
237
|
+
continue
|
|
238
|
+
ident, scoped, adds = [], [], []
|
|
239
|
+
for smd in iter_skill_roots(sk):
|
|
240
|
+
d = smd.parent
|
|
241
|
+
name = frontmatter_name(smd)
|
|
242
|
+
if name in idx:
|
|
243
|
+
(ident if treehash(d) == idx[name] else scoped).append((d, name))
|
|
244
|
+
else:
|
|
245
|
+
adds.append(name)
|
|
246
|
+
print(f" classify (per-skill): base-identical={len(ident)} overwritten={len(scoped)} agent-adds={len(adds)}")
|
|
247
|
+
if not apply:
|
|
248
|
+
for d, name in scoped:
|
|
249
|
+
print(f" [would] scoped-name override: {d.relative_to(sk)} -> name '{name}-{slug}'")
|
|
250
|
+
for d, name in ident:
|
|
251
|
+
print(f" [would] rm {d.relative_to(sk)} (identical → pack)")
|
|
252
|
+
print(f" [would] wire pack into config.yaml{' (already)' if already else ''}")
|
|
253
|
+
continue
|
|
254
|
+
if not wire(rt / "config.yaml", pack, True):
|
|
255
|
+
print(" ABORT: wire failed — leaving skills untouched"); continue
|
|
256
|
+
for d, name in scoped:
|
|
257
|
+
newdir = d.parent / f"{d.name}-{slug}"
|
|
258
|
+
if newdir.exists():
|
|
259
|
+
print(f" SKIP scoped rename (target exists): {newdir.relative_to(sk)}"); continue
|
|
260
|
+
set_frontmatter_name(d / "SKILL.md", f"{name}-{slug}")
|
|
261
|
+
d.rename(newdir)
|
|
262
|
+
for d, name in ident:
|
|
263
|
+
shutil.rmtree(d)
|
|
264
|
+
pruned = prune_empty_categories(sk, True)
|
|
265
|
+
print(f" reconciled: {len(ident)} identical removed, {len(scoped)} scoped→*-{slug}, {pruned} empty categories pruned; base resolves from pack")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def verify(root: Path, pack: Path) -> int:
|
|
269
|
+
idx = pack_index(pack)
|
|
270
|
+
bad = 0
|
|
271
|
+
for rt in runtimes(root):
|
|
272
|
+
sk = rt / "skills"
|
|
273
|
+
if not sk.is_dir():
|
|
274
|
+
continue
|
|
275
|
+
for smd in iter_skill_roots(sk):
|
|
276
|
+
name = frontmatter_name(smd)
|
|
277
|
+
if name in idx and treehash(smd.parent) != idx[name]:
|
|
278
|
+
print(f" NOT DISJOINT: {rt.name}: local '{name}' shares a pack name but differs (scope-rename it)")
|
|
279
|
+
bad += 1
|
|
280
|
+
if name in idx and treehash(smd.parent) == idx[name]:
|
|
281
|
+
print(f" REDUNDANT: {rt.name}: local '{name}' is an identical copy of the pack (delete it)")
|
|
282
|
+
bad += 1
|
|
283
|
+
print("verify:", "CLEAN ✓ (runtime is name-disjoint from the pack)" if bad == 0 else f"{bad} issue(s)")
|
|
284
|
+
return 1 if bad else 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main() -> int:
|
|
288
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
289
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
290
|
+
for name in ("dedup", "verify", "provision"):
|
|
291
|
+
s = sub.add_parser(name)
|
|
292
|
+
s.add_argument("--root", default=".")
|
|
293
|
+
s.add_argument("--pack", default=str(DEFAULT_PACK))
|
|
294
|
+
if name in ("dedup", "provision"):
|
|
295
|
+
s.add_argument("--apply", action="store_true")
|
|
296
|
+
if name == "dedup":
|
|
297
|
+
s.add_argument("--slug", default=None, help="agent scope token (default: repo dir basename, lowercased)")
|
|
298
|
+
a = ap.parse_args()
|
|
299
|
+
root = Path(a.root).resolve()
|
|
300
|
+
pack = Path(a.pack)
|
|
301
|
+
if not (pack / "SKILL.md").exists() and not any(pack.rglob("SKILL.md")):
|
|
302
|
+
print(f"ERROR: no skills under pack {pack}", file=sys.stderr); return 2
|
|
303
|
+
if a.cmd == "verify":
|
|
304
|
+
return verify(root, pack)
|
|
305
|
+
if a.cmd == "provision":
|
|
306
|
+
mode = "APPLY" if a.apply else "dry-run"
|
|
307
|
+
print(f"== provision external_dirs ({mode}) :: {root} ==")
|
|
308
|
+
provision(root, MAINLINE_EXTERNAL_DIRS, a.apply)
|
|
309
|
+
print("\nRESULT:", "applied." if a.apply else "dry-run. --apply to write the mainline external_dirs.")
|
|
310
|
+
return 0
|
|
311
|
+
slug = (a.slug or root.name).lower()
|
|
312
|
+
mode = "APPLY" if a.apply else "dry-run"
|
|
313
|
+
print(f"== templatize dedup ({mode}) :: {root} :: slug={slug} :: pack={pack} ==")
|
|
314
|
+
dedup(root, slug, pack, a.apply)
|
|
315
|
+
print("\nRESULT:", "applied where safe (BLOCKED runtimes untouched)." if a.apply
|
|
316
|
+
else "dry-run. --apply to reconcile. Bare-config runtimes are reported BLOCKED.")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Move chat-platform credentials out of a fleet-wide dotenv file safely.
|
|
3
|
+
|
|
4
|
+
The Hermes fleet shares provider credentials, but polling/socket credentials
|
|
5
|
+
belong to one gateway profile. This helper performs a dry-run by default and
|
|
6
|
+
moves only named assignments without ever printing their values.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_KEYS = ("SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_ALLOWED_USERS")
|
|
22
|
+
ASSIGNMENT = re.compile(r"^(?P<prefix>\s*)(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>.*)$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read(path: Path) -> list[str]:
|
|
26
|
+
return path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _assignments(lines: list[str], keys: set[str]) -> dict[str, tuple[int, str]]:
|
|
30
|
+
found: dict[str, tuple[int, str]] = {}
|
|
31
|
+
for index, line in enumerate(lines):
|
|
32
|
+
match = ASSIGNMENT.match(line.rstrip("\n"))
|
|
33
|
+
if match and match.group("key") in keys:
|
|
34
|
+
found[match.group("key")] = (index, line)
|
|
35
|
+
return found
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _same_file(left: Path, right: Path) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
return left.resolve(strict=True) == right.resolve(strict=True)
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
return left.resolve() == right.resolve()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _atomic_write(path: Path, lines: list[str]) -> None:
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
48
|
+
try:
|
|
49
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
50
|
+
handle.writelines(lines)
|
|
51
|
+
handle.flush()
|
|
52
|
+
os.fsync(handle.fileno())
|
|
53
|
+
os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
|
|
54
|
+
os.replace(temporary, path)
|
|
55
|
+
finally:
|
|
56
|
+
if os.path.exists(temporary):
|
|
57
|
+
os.unlink(temporary)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def migrate(central: Path, target: Path, keys: tuple[str, ...], *, apply: bool) -> list[str]:
|
|
61
|
+
if not central.is_file():
|
|
62
|
+
raise ValueError(f"central env file does not exist: {central}")
|
|
63
|
+
if target.is_symlink():
|
|
64
|
+
raise ValueError(f"target env must be a profile-local file, not a symlink: {target}")
|
|
65
|
+
if _same_file(central, target):
|
|
66
|
+
raise ValueError("central and target env resolve to the same file")
|
|
67
|
+
|
|
68
|
+
selected = set(keys)
|
|
69
|
+
central_lines = _read(central)
|
|
70
|
+
target_lines = _read(target) if target.exists() else []
|
|
71
|
+
source_values = _assignments(central_lines, selected)
|
|
72
|
+
target_values = _assignments(target_lines, selected)
|
|
73
|
+
|
|
74
|
+
missing = [key for key in keys if key not in source_values]
|
|
75
|
+
if missing:
|
|
76
|
+
raise ValueError(f"central env is missing required keys: {', '.join(missing)}")
|
|
77
|
+
|
|
78
|
+
conflicts = [
|
|
79
|
+
key
|
|
80
|
+
for key in keys
|
|
81
|
+
if key in target_values and target_values[key][1].rstrip("\n") != source_values[key][1].rstrip("\n")
|
|
82
|
+
]
|
|
83
|
+
if conflicts:
|
|
84
|
+
raise ValueError(f"target env already has different values for: {', '.join(conflicts)}")
|
|
85
|
+
|
|
86
|
+
if not apply:
|
|
87
|
+
return list(keys)
|
|
88
|
+
|
|
89
|
+
timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
90
|
+
central_backup = central.with_name(f"{central.name}.bak-{timestamp}-before-chat-scope")
|
|
91
|
+
shutil.copy2(central, central_backup)
|
|
92
|
+
os.chmod(central_backup, stat.S_IRUSR | stat.S_IWUSR)
|
|
93
|
+
if target.exists():
|
|
94
|
+
target_backup = target.with_name(f"{target.name}.bak-{timestamp}-before-chat-scope")
|
|
95
|
+
shutil.copy2(target, target_backup)
|
|
96
|
+
os.chmod(target_backup, stat.S_IRUSR | stat.S_IWUSR)
|
|
97
|
+
|
|
98
|
+
remove_indexes = {source_values[key][0] for key in keys}
|
|
99
|
+
next_central = [line for index, line in enumerate(central_lines) if index not in remove_indexes]
|
|
100
|
+
next_target = list(target_lines)
|
|
101
|
+
if next_target and not next_target[-1].endswith("\n"):
|
|
102
|
+
next_target[-1] += "\n"
|
|
103
|
+
if next_target and next_target[-1].strip():
|
|
104
|
+
next_target.append("\n")
|
|
105
|
+
next_target.append("# Profile-scoped chat gateway credentials.\n")
|
|
106
|
+
for key in keys:
|
|
107
|
+
if key not in target_values:
|
|
108
|
+
line = source_values[key][1]
|
|
109
|
+
next_target.append(line if line.endswith("\n") else f"{line}\n")
|
|
110
|
+
|
|
111
|
+
_atomic_write(target, next_target)
|
|
112
|
+
_atomic_write(central, next_central)
|
|
113
|
+
return list(keys)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parser() -> argparse.ArgumentParser:
|
|
117
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
118
|
+
parser.add_argument("--central-env", type=Path, default=Path.home() / ".hermes" / ".env")
|
|
119
|
+
parser.add_argument("--target-env", type=Path, required=True)
|
|
120
|
+
parser.add_argument("--keys", nargs="+", default=list(DEFAULT_KEYS))
|
|
121
|
+
parser.add_argument("--apply", action="store_true", help="write the migration; default is dry-run")
|
|
122
|
+
return parser
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main() -> int:
|
|
126
|
+
args = _parser().parse_args()
|
|
127
|
+
moved = migrate(args.central_env.expanduser(), args.target_env.expanduser(), tuple(args.keys), apply=args.apply)
|
|
128
|
+
action = "moved" if args.apply else "would move"
|
|
129
|
+
print(f"{action} {', '.join(moved)} from {args.central_env} to {args.target_env}")
|
|
130
|
+
if not args.apply:
|
|
131
|
+
print("dry-run only; rerun with --apply after reviewing the target paths")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
if __name__ == "__main__":
|
|
136
|
+
raise SystemExit(main())
|