From e9ec2cfd980dcb877a414f4a31cb2ac5d66efbe7 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 11:09:54 +0800 Subject: [PATCH 1/4] fix(wire-agents): keep .claude/settings.json across a re-wire (#419) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + bin/wire-agents.sh | 55 +++++++++++++++++++++++ tests/wave2/test-wire-agents-settings.sh | 56 ++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100755 tests/wave2/test-wire-agents-settings.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 49ee9dc1..c3f4c214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Sections dated before 2026-09-19 predate the cycle and stay as they are. Named m ## Unreleased +- fix(wire-agents): **a sync re-wire no longer wipes `.claude/settings.json`.** `mxcli init` overwrites that file; wire-agents now snapshots it and merges after init: the project's entries win and keep their order (permission profile, user entries, the `mxcli exec` deny rule, hooks), and entries that only mxcli's new file has are appended. Merged rather than added to PRESERVE because PRESERVE restores old bytes and would also drop anything a newer mxcli adds; an unparsable snapshot is restored byte for byte. Test: `tests/wave2/test-wire-agents-settings.sh` (4 of 6 checks fail on master). — review of #414 (#419) - fix(lint-gate): **a ratchet rise now lists the findings that are NEW, not three arbitrary members of the rule.** The baseline keeps a multiset of findings (rule, module, document, message) next to the counts; on a rise the gate prints the surplus per rule, up to three per rule (the rest in `.claude/loop/lint-last.json`), labelled as new or changed findings next to the net rise. An old counts-only baseline falls back to the first three and says they are not necessarily new. Not done here: pre-exec snapshot attribution, a per-slice growth cap, a slice-close lint check (the rest of [#239](https://github.com/MendixMau/mxcli-project-toolkit/issues/239)). Test: `tests/wave2/test-lint-gate-new-findings.sh` — MendixMau (#239) - fix(conformance-check): read ledger rows whose pointer cell is bare (no backticks) and treat status `planned` as not-built; a ledger whose acceptance text is also bare prose is still not measured (acceptance must hold a backticked SHOW/DESCRIBE), but it now exits 2 with a FAULT that says rows were found and the text is prose, instead of "shape changed or glob matched nothing" (TK-006, partial: that ledger shape is diagnosed, not fixed) — field report TK-006 - fix(app-restart): **a secret with spaces in quotes is masked whole, and `Pwd` and `Credential` join the secret words.** Follow-up to #403: `--constant "Mod.ApiKey=two words"` and `--db-password 'two words'` printed everything after the first word (the quoted-flag case leaked before #403 too). `mask()` now masks each argument before quoting it for display, so the whole quoted value goes; the executed command is untouched, and a real-launch test proves the started app receives the raw argv; an env assignment at the start of a `--cmd` string keeps the whole command visible; the per-argument seds only run for arguments holding a secret word. The substring match still over-masks names like `Monkey` (Key) and `Turkey`; that is display only, kept, and pinned by a test. `tests/wave2/test-app-restart.sh` gains 22 cases, 8 of them failing on master. — review of #403 diff --git a/bin/wire-agents.sh b/bin/wire-agents.sh index 359efdae..e99623aa 100755 --- a/bin/wire-agents.sh +++ b/bin/wire-agents.sh @@ -50,6 +50,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOLKIT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" . "$SCRIPT_DIR/lib/wiring-item3.sh" +. "$SCRIPT_DIR/lib/portable.sh" DEFAULT_TOOLS="claude copilot cursor continue windsurf aider" OPTIN_TOOLS="opencode vibe" @@ -329,6 +330,16 @@ for rel in $PRESERVE; do fi done +# .claude/settings.json is NOT in PRESERVE on purpose. PRESERVE restores the old bytes, which +# would also throw away anything a newer mxcli adds to that file. It is merged instead: snapshot +# now, union after init (see the re-merge below). Issue #419. +SETTINGS_REL=".claude/settings.json" +SETTINGS_SNAP="" +if [ -z "$DRY" ] && [ -f "$PROJECT_DIR/$SETTINGS_REL" ]; then + SETTINGS_SNAP="$BACKUP_DIR/.claude_settings.json.$STAMP_TS" + cp "$PROJECT_DIR/$SETTINGS_REL" "$SETTINGS_SNAP" +fi + TOOL_ARGS="" for t in $TOOLS; do TOOL_ARGS="$TOOL_ARGS --tool $t"; done @@ -385,6 +396,50 @@ if [ -z "$DRY" ]; then done fi +# Re-merge .claude/settings.json after `mxcli init` overwrote it (issue #419). The project's +# snapshot wins on every scalar and keeps every list entry in its own order (permission profile, +# user entries, the `mxcli exec` deny rule, hooks); entries and keys that only mxcli's new file +# has are appended or added. Nothing the project had is ever dropped. A snapshot that is not +# valid JSON is restored byte for byte instead of guessed at. +if [ -z "$DRY" ] && [ -n "$SETTINGS_SNAP" ]; then + require_py + "$PY" - "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" <<'PYMERGE' +import json, sys, shutil +snap, cur = sys.argv[1], sys.argv[2] +def load(p): + with open(p, encoding="utf-8") as f: + return json.load(f) +try: + old = load(snap) +except Exception: + shutil.copyfile(snap, cur) + print("Restored .claude/settings.json byte for byte (previous copy was not valid JSON).") + sys.exit(0) +try: + new = load(cur) +except Exception: + new = None +def merge(o, n): + if isinstance(o, dict) and isinstance(n, dict): + out = dict(o) + for k, v in n.items(): + out[k] = merge(o[k], v) if k in o else v + return out + if isinstance(o, list) and isinstance(n, list): + return o + [x for x in n if x not in o] + return o +res = merge(old, new) if new is not None else old +if res != old or new is None: + with open(cur, "w", encoding="utf-8") as f: + json.dump(res, f, indent=2, ensure_ascii=False) + f.write("\n") + print("Re-merged .claude/settings.json after mxcli init (your entries kept, new mxcli entries added).") +else: + shutil.copyfile(snap, cur) + print("Kept .claude/settings.json (mxcli init added nothing new).") +PYMERGE +fi + # --- stamp ------------------------------------------------------------------------------------ for rel in $STAMP_MD; do stamp_file "$PROJECT_DIR/$rel" md; done for rel in $STAMP_HASH; do stamp_file "$PROJECT_DIR/$rel" hash; done diff --git a/tests/wave2/test-wire-agents-settings.sh b/tests/wave2/test-wire-agents-settings.sh new file mode 100755 index 00000000..f116ec63 --- /dev/null +++ b/tests/wave2/test-wire-agents-settings.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# test-wire-agents-settings.sh - a re-wire (wire-agents.sh, run by sync-project.sh when --check +# fails) must not lose .claude/settings.json content to `mxcli init` (issue #419). +# +# Fixture: a stub mxcli whose `init` overwrites settings.json with a stock file (as the real one +# does) and adds one new entry. The project file holds a profile entry, a user entry, the +# `mxcli exec` deny rule and a hook. All must survive; the new mxcli entry must also arrive. +# +# usage: bash test-wire-agents-settings.sh [path-to-wire-agents.sh] +set -uo pipefail +TOOLKIT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SUBJECT="${1:-$TOOLKIT/bin/wire-agents.sh}" +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo " ok - $1"; } +bad() { FAIL=$((FAIL+1)); echo " FAIL - $1"; } +WORK="$(mktemp -d "${TMPDIR:-/tmp}/wasettings.XXXXXX")" || exit 2 +trap 'rm -rf "$WORK"' EXIT +echo "== subject: $SUBJECT" + +P="$WORK/proj"; mkdir -p "$P/.claude"; : > "$P/Fixture.mpr" +cat > "$P/mxcli" <<'MXCLI' +#!/bin/sh +case "$1" in + init) + mkdir -p .claude + printf '{"permissions":{"allow":["Bash(./mxcli *)","Bash(mxcli-new-thing)"]}}\n' > .claude/settings.json + for f in AGENTS.md CLAUDE.md .github/copilot-instructions.md .cursorrules .windsurfrules .aider.conf.yml; do + mkdir -p "$(dirname "$f")"; [ -f "$f" ] || printf '# stub\n' > "$f" + done ;; +esac +exit 0 +MXCLI +chmod +x "$P/mxcli" +cat > "$P/.claude/settings.json" <<'JSON' +{ + "permissions": { + "allow": ["Bash(./mxcli *)", "Bash(git status)", "Edit(/docs/**)"], + "deny": ["Bash(./mxcli exec*)", "Bash(rm -rf *)"] + }, + "hooks": {"PostToolUse": [{"matcher": "Read", "hooks": [{"type": "command", "command": "echo für"}]}]} +} +JSON + +bash "$SUBJECT" "$P" >/dev/null 2>&1 +S="$P/.claude/settings.json" +has() { grep -qF -- "$1" "$S"; } +has 'Edit(/docs/**)' && ok "profile entry survives re-wire" || bad "profile entry lost" +has 'Bash(git status)' && ok "user entry survives re-wire" || bad "user entry lost" +has 'Bash(./mxcli exec*)' && ok "mxcli exec deny rule survives" || bad "mxcli exec deny rule lost" +has '"matcher": "Read"' && ok "user hook survives" || bad "user hook lost" +has 'mxcli-new-thing' && ok "entry new in mxcli init is also added" || bad "new mxcli entry not added" +n1="$(cksum < "$S")"; bash "$SUBJECT" "$P" >/dev/null 2>&1 +[ "$n1" = "$(cksum < "$S")" ] && ok "second re-wire changes nothing" || bad "second re-wire changed the file" + +echo "passed $PASS, failed $FAIL" +[ "$FAIL" -eq 0 ] From c426e51649c11ffae42be5904c3b3307d193dcd1 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 15:23:05 +0800 Subject: [PATCH 2/4] wire-agents: restore settings.json snapshot on init failure and missing Python Co-Authored-By: Claude Opus 5.5 --- bin/wire-agents.sh | 27 +++++++++++++++++++++--- tests/wave2/test-wire-agents-settings.sh | 23 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/bin/wire-agents.sh b/bin/wire-agents.sh index e99623aa..c537df15 100755 --- a/bin/wire-agents.sh +++ b/bin/wire-agents.sh @@ -349,6 +349,11 @@ else echo "Running: ./mxcli init$TOOL_ARGS" ( cd "$PROJECT_DIR" && "$MXCLI" init $TOOL_ARGS >/dev/null 2>&1 ) || { echo "wire-agents.sh: mxcli init failed. Nothing was stamped; your backups are in .mxtk-backup/." >&2 + # init may have written its stock file before failing: put the project's own copy back. + if [ -n "$SETTINGS_SNAP" ]; then + cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" \ + && echo "wire-agents.sh: restored .claude/settings.json from the snapshot." >&2 + fi exit 1 } fi @@ -402,8 +407,15 @@ fi # has are appended or added. Nothing the project had is ever dropped. A snapshot that is not # valid JSON is restored byte for byte instead of guessed at. if [ -z "$DRY" ] && [ -n "$SETTINGS_SNAP" ]; then - require_py - "$PY" - "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" <<'PYMERGE' + # Never worse than before the re-wire: no Python, or a failed merge, puts the snapshot back + # byte for byte (and warns) instead of leaving mxcli's stock file. Stock entries a user had + # deleted come back on each re-wire (a union cannot see deletions). + SETTINGS_PY="$(resolve_py)" || SETTINGS_PY="" + if [ -z "$SETTINGS_PY" ]; then + cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" + echo "WARN: wire-agents.sh: no Python 3 found; restored .claude/settings.json byte for byte, new mxcli entries NOT merged. Install python3 and re-run." >&2 + else + "$SETTINGS_PY" - "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" <<'PYMERGE' import json, sys, shutil snap, cur = sys.argv[1], sys.argv[2] def load(p): @@ -426,7 +438,11 @@ def merge(o, n): out[k] = merge(o[k], v) if k in o else v return out if isinstance(o, list) and isinstance(n, list): - return o + [x for x in n if x not in o] + out = list(o) + for x in n: + if x not in out: + out.append(x) + return out return o res = merge(old, new) if new is not None else old if res != old or new is None: @@ -438,6 +454,11 @@ else: shutil.copyfile(snap, cur) print("Kept .claude/settings.json (mxcli init added nothing new).") PYMERGE + if [ $? -ne 0 ]; then + cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" + echo "WARN: wire-agents.sh: settings merge failed; restored .claude/settings.json byte for byte." >&2 + fi + fi fi # --- stamp ------------------------------------------------------------------------------------ diff --git a/tests/wave2/test-wire-agents-settings.sh b/tests/wave2/test-wire-agents-settings.sh index f116ec63..a5c5e449 100755 --- a/tests/wave2/test-wire-agents-settings.sh +++ b/tests/wave2/test-wire-agents-settings.sh @@ -52,5 +52,28 @@ has 'mxcli-new-thing' && ok "entry new in mxcli init is also added" || b n1="$(cksum < "$S")"; bash "$SUBJECT" "$P" >/dev/null 2>&1 [ "$n1" = "$(cksum < "$S")" ] && ok "second re-wire changes nothing" || bad "second re-wire changed the file" +# --- failure paths: the result must never be worse than before the re-wire -------------------- +ORIG='{ "permissions": { "allow": ["Bash(git status)"] }, "keep": "für" }' +mkproj() { # $1 dir, $2 init exit code + mkdir -p "$1/.claude"; : > "$1/Fixture.mpr" + sed "s/^exit 0\$/exit $2/" "$P/mxcli" > "$1/mxcli"; chmod +x "$1/mxcli" + printf '%s\n' "$ORIG" > "$1/.claude/settings.json"; cp "$1/.claude/settings.json" "$1/orig" +} +# 1. mxcli init writes its stock file then exits 3 +F="$WORK/fail"; mkproj "$F" 3 +bash "$SUBJECT" "$F" >/dev/null 2>&1; rc=$? +[ "$rc" -ne 0 ] && cmp -s "$F/orig" "$F/.claude/settings.json" \ + && ok "failing mxcli init: snapshot restored byte for byte" || bad "failing mxcli init left stock file (rc=$rc)" +# 2. no python on PATH +N="$WORK/nopy"; mkproj "$N" 0 +BIN="$WORK/bin"; mkdir -p "$BIN" +for c in bash sh cp cat mkdir rm mv date dirname basename grep sed awk tr sort uniq head tail wc cksum shasum md5 md5sum cmp env mktemp ls find xargs chmod touch printf tee readlink pwd id uname git diff; do + w="$(command -v "$c" 2>/dev/null)" && [ -x "$w" ] && ln -sf "$w" "$BIN/$c" +done +PATH="$BIN" bash "$SUBJECT" "$N" >"$WORK/nopy.out" 2>&1 +cmp -s "$N/orig" "$N/.claude/settings.json" \ + && ok "no python: snapshot restored byte for byte" || bad "no python left a changed settings.json" +grep -q "WARN" "$WORK/nopy.out" && ok "no python: WARN printed" || bad "no python: no WARN" + echo "passed $PASS, failed $FAIL" [ "$FAIL" -eq 0 ] From fa0be72141c7b275700987ace84634a7bb59a5c1 Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 15:38:10 +0800 Subject: [PATCH 3/4] wire-agents: make failed-merge snapshot restore reachable under set -e The merge ran as a bare command, so a non-zero exit ended the script before the restore. Wrap it in if ! ... then cp; WARN; fi. Tests: fake python3 that fails the merge, and one that truncates the target first. Co-Authored-By: Claude Opus 5.5 --- bin/wire-agents.sh | 5 +++-- tests/wave2/test-wire-agents-settings.sh | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/bin/wire-agents.sh b/bin/wire-agents.sh index c537df15..3873d5ce 100755 --- a/bin/wire-agents.sh +++ b/bin/wire-agents.sh @@ -415,7 +415,8 @@ if [ -z "$DRY" ] && [ -n "$SETTINGS_SNAP" ]; then cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" echo "WARN: wire-agents.sh: no Python 3 found; restored .claude/settings.json byte for byte, new mxcli entries NOT merged. Install python3 and re-run." >&2 else - "$SETTINGS_PY" - "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" <<'PYMERGE' + # `if !` keeps a non-zero merge exit reachable under set -e. + if ! "$SETTINGS_PY" - "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" <<'PYMERGE' import json, sys, shutil snap, cur = sys.argv[1], sys.argv[2] def load(p): @@ -454,7 +455,7 @@ else: shutil.copyfile(snap, cur) print("Kept .claude/settings.json (mxcli init added nothing new).") PYMERGE - if [ $? -ne 0 ]; then + then cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" echo "WARN: wire-agents.sh: settings merge failed; restored .claude/settings.json byte for byte." >&2 fi diff --git a/tests/wave2/test-wire-agents-settings.sh b/tests/wave2/test-wire-agents-settings.sh index a5c5e449..04dd1bef 100755 --- a/tests/wave2/test-wire-agents-settings.sh +++ b/tests/wave2/test-wire-agents-settings.sh @@ -75,5 +75,26 @@ cmp -s "$N/orig" "$N/.claude/settings.json" \ && ok "no python: snapshot restored byte for byte" || bad "no python left a changed settings.json" grep -q "WARN" "$WORK/nopy.out" && ok "no python: WARN printed" || bad "no python: no WARN" +# 3/4. python3 passes the version probe (-c) but the merge fails; the second one truncates the +# target first. Both must restore byte for byte, warn, stamp, and exit 0. +REALPY="$(command -v python3)" +for mode in fail truncate; do + M="$WORK/merge-$mode"; mkproj "$M" 0 + FB="$WORK/fakepy-$mode"; mkdir -p "$FB" + cat > "$FB/python3" < "\$3" +exit 1 +FAKE + chmod +x "$FB/python3" + PATH="$FB:$PATH" bash "$SUBJECT" "$M" >"$WORK/merge-$mode.out" 2>&1; rc=$? + cmp -s "$M/orig" "$M/.claude/settings.json" \ + && ok "failed merge ($mode): snapshot restored byte for byte" || bad "failed merge ($mode): settings.json differs" + grep -q "WARN" "$WORK/merge-$mode.out" && ok "failed merge ($mode): WARN printed" || bad "failed merge ($mode): no WARN" + grep -q "mxtk:wiring:start" "$M/AGENTS.md" && ok "failed merge ($mode): stamped" || bad "failed merge ($mode): not stamped" + [ "$rc" -eq 0 ] && ok "failed merge ($mode): rc 0" || bad "failed merge ($mode): rc $rc" +done + echo "passed $PASS, failed $FAIL" [ "$FAIL" -eq 0 ] From 583487a559d0774c37e5b35dea0acaa078dea7af Mon Sep 17 00:00:00 2001 From: MendixMau Date: Fri, 16 Oct 2026 18:15:23 +0800 Subject: [PATCH 4/4] fix(portability): mark deliberate python3 names in wire-agents and its settings test The warning text and the fake-interpreter test wrappers name python3 on purpose. Mark them with portability-ok instead of weakening the test. Co-Authored-By: Claude Opus 5.5 --- bin/wire-agents.sh | 2 +- tests/wave2/test-wire-agents-settings.sh | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/wire-agents.sh b/bin/wire-agents.sh index 3873d5ce..5bae695e 100755 --- a/bin/wire-agents.sh +++ b/bin/wire-agents.sh @@ -413,7 +413,7 @@ if [ -z "$DRY" ] && [ -n "$SETTINGS_SNAP" ]; then SETTINGS_PY="$(resolve_py)" || SETTINGS_PY="" if [ -z "$SETTINGS_PY" ]; then cp "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" - echo "WARN: wire-agents.sh: no Python 3 found; restored .claude/settings.json byte for byte, new mxcli entries NOT merged. Install python3 and re-run." >&2 + echo "WARN: wire-agents.sh: no Python 3 found; restored .claude/settings.json byte for byte, new mxcli entries NOT merged. Install python3 and re-run." >&2 # portability-ok: diagnostic else # `if !` keeps a non-zero merge exit reachable under set -e. if ! "$SETTINGS_PY" - "$SETTINGS_SNAP" "$PROJECT_DIR/$SETTINGS_REL" <<'PYMERGE' diff --git a/tests/wave2/test-wire-agents-settings.sh b/tests/wave2/test-wire-agents-settings.sh index 04dd1bef..ea24346c 100755 --- a/tests/wave2/test-wire-agents-settings.sh +++ b/tests/wave2/test-wire-agents-settings.sh @@ -72,22 +72,22 @@ for c in bash sh cp cat mkdir rm mv date dirname basename grep sed awk tr sort u done PATH="$BIN" bash "$SUBJECT" "$N" >"$WORK/nopy.out" 2>&1 cmp -s "$N/orig" "$N/.claude/settings.json" \ - && ok "no python: snapshot restored byte for byte" || bad "no python left a changed settings.json" + && ok "no python: snapshot restored byte for byte" || bad "no python left a changed settings.json" # portability-ok: test name, not an interpreter call grep -q "WARN" "$WORK/nopy.out" && ok "no python: WARN printed" || bad "no python: no WARN" # 3/4. python3 passes the version probe (-c) but the merge fails; the second one truncates the # target first. Both must restore byte for byte, warn, stamp, and exit 0. -REALPY="$(command -v python3)" +REALPY="$(command -v python3)" # portability-ok: finds the real interpreter to wrap for mode in fail truncate; do M="$WORK/merge-$mode"; mkproj "$M" 0 FB="$WORK/fakepy-$mode"; mkdir -p "$FB" - cat > "$FB/python3" < "$FB/python3" < "\$3" exit 1 FAKE - chmod +x "$FB/python3" + chmod +x "$FB/python3" # portability-ok: deliberate fake interpreter PATH="$FB:$PATH" bash "$SUBJECT" "$M" >"$WORK/merge-$mode.out" 2>&1; rc=$? cmp -s "$M/orig" "$M/.claude/settings.json" \ && ok "failed merge ($mode): snapshot restored byte for byte" || bad "failed merge ($mode): settings.json differs"