diff --git a/.claude/commands/syncro.md b/.claude/commands/syncro.md
index dd62f58f..93672074 100644
--- a/.claude/commands/syncro.md
+++ b/.claude/commands/syncro.md
@@ -629,7 +629,7 @@ COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}
}
JSON
)
-COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
+COMMENT_ID=$(echo "$COMMENT_RESP" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
```
- `hidden: true` = internal only (customer can't see)
@@ -1275,7 +1275,7 @@ COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}
{"subject": "Resolution", "body": "
", "hidden": false, "do_not_email": false}
JSON
)
-COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id')
+COMMENT_ID=$(echo "$COMMENT_RESP" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
# STOP if null: GET ticket, check .ticket.comments[] by subject
# 2. Add line item — response is FLAT: {"id": N, ...}
diff --git a/.claude/skills/coord/scripts/coord.py b/.claude/skills/coord/scripts/coord.py
index d60a29ff..2736fc0d 100644
--- a/.claude/skills/coord/scripts/coord.py
+++ b/.claude/skills/coord/scripts/coord.py
@@ -236,7 +236,7 @@ def c_todo_list(a):
extra += f" @{t['assigned_to_user']}"
if t.get("project_key"):
extra += f" ({t['project_key']})"
- print(f" [{t.get('status'):<7}] {str(t.get('id',''))[:8]} {str(t.get('text',''))[:88]}{extra}")
+ print(f" [{t.get('status'):<7}] {t.get('id','')} {str(t.get('text',''))[:88]}{extra}")
def c_todo_done(a):
diff --git a/.claude/skills/remediation-tool/scripts/get-token.sh b/.claude/skills/remediation-tool/scripts/get-token.sh
index dde676a0..a7a74b0f 100755
--- a/.claude/skills/remediation-tool/scripts/get-token.sh
+++ b/.claude/skills/remediation-tool/scripts/get-token.sh
@@ -134,13 +134,22 @@ if [[ -f "$CACHE_FILE" ]] && [[ $(find "$CACHE_FILE" -mmin -55 2>/dev/null) ]];
fi
# Locate vault repo via .claude/identity.json (per-machine, gitignored).
-# Falls back to VAULT_PATH env var if set.
+# Resolution order for identity.json:
+# 1. Repo-local: /.claude/identity.json
+# 2. Home dir: $HOME/.claude/identity.json
+# Falls back to VAULT_ROOT_ENV / VAULT_DIR env vars or D:/vault default if vault_path missing.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLAUDETOOLS_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
-IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
+IDENTITY_FILE=""
+for _candidate in "$CLAUDETOOLS_ROOT/.claude/identity.json" "$HOME/.claude/identity.json"; do
+ if [[ -f "$_candidate" ]]; then
+ IDENTITY_FILE="$_candidate"
+ break
+ fi
+done
VAULT_ROOT="${VAULT_ROOT_ENV:-}"
-if [[ -z "$VAULT_ROOT" && -f "$IDENTITY_FILE" ]]; then
+if [[ -z "$VAULT_ROOT" && -n "$IDENTITY_FILE" ]]; then
if command -v jq >/dev/null 2>&1; then
VAULT_ROOT=$(jq -r '.vault_path // empty' "$IDENTITY_FILE" 2>/dev/null)
fi
@@ -153,7 +162,15 @@ if [[ -z "$VAULT_ROOT" && -f "$IDENTITY_FILE" ]]; then
done
fi
fi
-[[ -z "$VAULT_ROOT" ]] && { echo "ERROR: vault_path not set in $IDENTITY_FILE and VAULT_ROOT_ENV env var not set" >&2; exit 3; }
+# Fallback: VAULT_DIR env var, then sensible default (D:/vault).
+# Note: VAULT_PATH is already used in this script for the sops file path within the vault.
+if [[ -z "$VAULT_ROOT" ]]; then
+ VAULT_ROOT="${VAULT_DIR:-}"
+fi
+if [[ -z "$VAULT_ROOT" ]]; then
+ VAULT_ROOT="D:/vault"
+ echo "[WARNING] vault_path not in identity.json and no VAULT_ROOT_ENV/VAULT_DIR env var; defaulting to $VAULT_ROOT" >&2
+fi
[[ ! -d "$VAULT_ROOT" ]] && { echo "ERROR: vault not found at $VAULT_ROOT (check vault_path in $IDENTITY_FILE)" >&2; exit 3; }
SOPS_FILE="$VAULT_ROOT/$VAULT_PATH"
diff --git a/.claude/skills/remediation-tool/scripts/reset-password.sh b/.claude/skills/remediation-tool/scripts/reset-password.sh
index ea5a2d2e..e0a7c835 100644
--- a/.claude/skills/remediation-tool/scripts/reset-password.sh
+++ b/.claude/skills/remediation-tool/scripts/reset-password.sh
@@ -92,7 +92,34 @@ fi
cleanup() {
if [[ -n "$CREATED_ASSIGNMENT" ]]; then
DC=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "${GH[@]}" "$G/roleManagement/directory/roleAssignments/$CREATED_ASSIGNMENT")
- if [[ "$DC" == "204" ]]; then echo "[info] removed JIT role assignment (de-elevated)"; else echo "[WARNING] failed to remove JIT role assignment $CREATED_ASSIGNMENT (HTTP $DC) - REMOVE MANUALLY" >&2; bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: failed to remove JIT Privileged Auth Admin role - standing privilege left behind, REMOVE MANUALLY" --context "tenant=$TENANT_ID assignment=$CREATED_ASSIGNMENT http=$DC" >/dev/null 2>&1 || true; fi
+ if [[ "$DC" == "204" ]]; then
+ echo "[info] removed JIT role assignment (de-elevated)"
+ else
+ # Graph blocks an SP from removing its own active role assignment (self-removal).
+ # The Tenant Admin SP is the only principal with RoleManagement.ReadWrite.Directory,
+ # so no other SP in our app suite can remove it either. A Global Admin must do it.
+ echo "" >&2
+ echo "[WARNING] ================================================================" >&2
+ echo "[WARNING] STANDING PRIVILEGE LEFT BEHIND — MANUAL REMOVAL REQUIRED" >&2
+ echo "[WARNING] ================================================================" >&2
+ echo "[WARNING] The Tenant Admin service principal now holds Privileged" >&2
+ echo "[WARNING] Authentication Administrator in tenant $TENANT_ID." >&2
+ echo "[WARNING] Graph returned HTTP $DC on self-removal (an SP cannot remove" >&2
+ echo "[WARNING] its own active directory-role assignment)." >&2
+ echo "[WARNING]" >&2
+ echo "[WARNING] To remove via Graph (requires a Global Admin token or a" >&2
+ echo "[WARNING] different principal with RoleManagement.ReadWrite.Directory):" >&2
+ echo "[WARNING]" >&2
+ echo "[WARNING] DELETE $G/roleManagement/directory/roleAssignments/$CREATED_ASSIGNMENT" >&2
+ echo "[WARNING]" >&2
+ echo "[WARNING] To remove via Azure Portal:" >&2
+ echo "[WARNING] 1. Sign in as Global Admin at https://entra.microsoft.com" >&2
+ echo "[WARNING] 2. Roles and administrators > Privileged Authentication Administrator" >&2
+ echo "[WARNING] 3. Find 'ComputerGuru - Tenant Admin' (SP id: $SPID)" >&2
+ echo "[WARNING] 4. Remove the assignment" >&2
+ echo "[WARNING] ================================================================" >&2
+ bash "$__ROOT/.claude/scripts/log-skill-error.sh" "remediation-tool" "reset-password: self-removal blocked — standing PAA role left on Tenant Admin SP (HTTP $DC). A Global Admin must DELETE roleAssignments/$CREATED_ASSIGNMENT" --context "tenant=$TENANT_ID assignment=$CREATED_ASSIGNMENT sp=$SPID http=$DC" >/dev/null 2>&1 || true
+ fi
fi
}
trap cleanup EXIT
diff --git a/.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh b/.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh
index 93554fe4..63c4d6e7 100644
--- a/.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh
+++ b/.claude/skills/unifi-wifi/scripts/gw-sitemanager.sh
@@ -260,7 +260,7 @@ for x in sorted(d,key=lambda a:(a.get('type',''),a.get('name') or '')):
" || exit 1 ;;
raw)
[ -z "$RAWPATH" ] && { echo "usage: $SELF net raw " >&2; exit 2; }
- api "$CB$RAWPATH" | "$PY" -c "import json,sys; print(json.dumps(json.load(sys.stdin),indent=1)[:5000])" || exit 1 ;;
+ api "$CB$RAWPATH" | "$PY" -c "import json,sys; print(json.dumps(json.load(sys.stdin),indent=1))" || exit 1 ;;
*) echo "[$SELF] net subcommand must be radios|clients|devices|raw" >&2; exit 2 ;;
esac
;;
diff --git a/projects/discord-bot b/projects/discord-bot
index 56938bbf..bd6009c4 160000
--- a/projects/discord-bot
+++ b/projects/discord-bot
@@ -1 +1 @@
-Subproject commit 56938bbf5ad18a8afe9712891d5eabbc57c5fb10
+Subproject commit bd6009c478371cca77df24751a0cb4bd7ce3bf51
diff --git a/projects/msp-tools/guru-rmm b/projects/msp-tools/guru-rmm
index c924081c..38971315 160000
--- a/projects/msp-tools/guru-rmm
+++ b/projects/msp-tools/guru-rmm
@@ -1 +1 @@
-Subproject commit c924081c833f06e7f501c92adc79451ab1542010
+Subproject commit 38971315f1c56ccc31eaead4ffb7304288d4dde2