diff --git a/bin/gstack-brain-consumer b/bin/gstack-brain-consumer new file mode 100755 index 00000000..cf92ea3e --- /dev/null +++ b/bin/gstack-brain-consumer @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# gstack-brain-consumer — manage the consumer (reader) registry. +# +# Consumer = a reader that ingests the gstack-brain git repo as a source of +# session memory. v1 primary consumer is GBrain; later versions can register +# Codex, OpenClaw, or third-party readers. +# +# NOTE ON NAMING: internally this helper uses "consumer" (correct data-model +# term). User-facing copy and the alias `gstack-brain-reader` use "reader" +# (matches user mental model: "what's reading my brain?"). +# +# Usage: +# gstack-brain-consumer add --ingest-url --token +# gstack-brain-consumer list +# gstack-brain-consumer remove +# gstack-brain-consumer test +# +# Env: +# GSTACK_HOME — override ~/.gstack + +set -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +CONSUMERS_FILE="$GSTACK_HOME/consumers.json" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" + +ensure_file() { + mkdir -p "$GSTACK_HOME" + if [ ! -f "$CONSUMERS_FILE" ]; then + echo '{"consumers": []}' > "$CONSUMERS_FILE" + fi +} + +get_remote_url() { + git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "" +} + +sub_add() { + local name="" url="" token="" + local positional="" + while [ $# -gt 0 ]; do + case "$1" in + --ingest-url) url="$2"; shift 2 ;; + --token) token="$2"; shift 2 ;; + --) shift; break ;; + -*) echo "Unknown flag: $1" >&2; exit 1 ;; + *) positional="$1"; shift ;; + esac + done + name="$positional" + if [ -z "$name" ] || [ -z "$url" ]; then + echo "Usage: gstack-brain-consumer add --ingest-url [--token ]" >&2 + exit 1 + fi + ensure_file + # Upsert in consumers.json, store token in gstack-config under `_token`. + python3 - "$CONSUMERS_FILE" "$name" "$url" <<'PYEOF' +import sys, json +path, name, url = sys.argv[1:4] +try: + with open(path) as f: + data = json.load(f) +except Exception: + data = {"consumers": []} +entry = {"name": name, "ingest_url": url, "status": "unknown", "token_ref": f"{name}_token"} +cs = data.setdefault("consumers", []) +for i, c in enumerate(cs): + if c.get("name") == name: + cs[i] = entry + break +else: + cs.append(entry) +with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") +print(f"registered consumer: {name}") +PYEOF + if [ -n "$token" ]; then + "$CONFIG_BIN" set "${name}_token" "$token" + echo "token stored: gstack-config get ${name}_token to retrieve" + fi + # Attempt registration with remote (HTTP POST). + sub_test "$name" +} + +sub_list() { + if [ ! -f "$CONSUMERS_FILE" ]; then + echo '{"consumers": []}' + return 0 + fi + cat "$CONSUMERS_FILE" +} + +sub_remove() { + local name="${1:-}" + if [ -z "$name" ]; then + echo "Usage: gstack-brain-consumer remove " >&2 + exit 1 + fi + ensure_file + python3 - "$CONSUMERS_FILE" "$name" <<'PYEOF' +import sys, json +path, name = sys.argv[1:3] +try: + with open(path) as f: + data = json.load(f) +except Exception: + data = {"consumers": []} +before = len(data.get("consumers", [])) +data["consumers"] = [c for c in data.get("consumers", []) if c.get("name") != name] +after = len(data["consumers"]) +with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") +print(f"removed: {before - after} entry(ies)") +PYEOF +} + +sub_test() { + local name="${1:-}" + if [ -z "$name" ]; then + echo "Usage: gstack-brain-consumer test " >&2 + exit 1 + fi + ensure_file + # Look up the consumer by name. + local info + info=$(python3 - "$CONSUMERS_FILE" "$name" <<'PYEOF' +import sys, json +path, name = sys.argv[1:3] +try: + with open(path) as f: + data = json.load(f) +except Exception: + data = {"consumers": []} +for c in data.get("consumers", []): + if c.get("name") == name: + print(c.get("ingest_url", "")) + sys.exit(0) +sys.exit(1) +PYEOF + ) || { echo "No such consumer: $name" >&2; exit 1; } + + local url="$info" + local token + token=$("$CONFIG_BIN" get "${name}_token" 2>/dev/null || echo "") + if [ -z "$url" ] || [ -z "$token" ]; then + echo "consumer '$name': url or token missing; cannot test" + return 0 + fi + local repo_url + repo_url=$(get_remote_url) + echo "Testing $name at ${url%/}/ingest-repo ..." + local resp + resp=$(curl -sS -X POST "${url%/}/ingest-repo" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + --data "{\"repo_url\":\"$repo_url\"}" \ + -w "\n%{http_code}" 2>&1 || echo -e "\ncurl-error") + local code + code=$(echo "$resp" | tail -1) + if [ "$code" = "200" ] || [ "$code" = "201" ] || [ "$code" = "204" ]; then + echo "ok (HTTP $code)" + # Update status in consumers.json. + python3 - "$CONSUMERS_FILE" "$name" "ok" <<'PYEOF' +import sys, json +path, name, status = sys.argv[1:4] +with open(path) as f: data = json.load(f) +for c in data.get("consumers", []): + if c.get("name") == name: + c["status"] = status +with open(path, "w") as f: json.dump(data, f, indent=2); f.write("\n") +PYEOF + else + echo "failed (HTTP $code)" + python3 - "$CONSUMERS_FILE" "$name" "error" <<'PYEOF' +import sys, json +path, name, status = sys.argv[1:4] +with open(path) as f: data = json.load(f) +for c in data.get("consumers", []): + if c.get("name") == name: + c["status"] = status +with open(path, "w") as f: json.dump(data, f, indent=2); f.write("\n") +PYEOF + fi +} + +case "${1:-}" in + add) shift; sub_add "$@" ;; + list) sub_list ;; + remove) shift; sub_remove "$@" ;; + test) shift; sub_test "$@" ;; + --help|-h|"") sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' ;; + *) echo "Unknown subcommand: $1" >&2; exit 1 ;; +esac diff --git a/bin/gstack-brain-init b/bin/gstack-brain-init new file mode 100755 index 00000000..6399c12c --- /dev/null +++ b/bin/gstack-brain-init @@ -0,0 +1,360 @@ +#!/usr/bin/env bash +# gstack-brain-init — set up ~/.gstack/ as a git repo that syncs to GBrain. +# +# Usage: +# gstack-brain-init [--remote ] +# +# Interactive by default. Pass --remote to skip the remote prompt. +# +# Idempotent: safe to re-run. If ~/.gstack/.git already exists AND points at +# the same remote, reconfigures drivers/hooks/attributes without clobbering +# history. If it points at a DIFFERENT remote, refuses and suggests +# `gstack-brain-uninstall` first. +# +# What it does: +# 1. git init ~/.gstack/ (or verify existing repo points at the right remote) +# 2. Write .gitignore = "*" (ignore everything; allowlist is explicit) +# 3. Write .brain-allowlist (canonical paths to sync) +# 4. Write .brain-privacy-map.json (paths → privacy class) +# 5. Write .gitattributes (register JSONL + union merge drivers) +# 6. git config merge.jsonl-append.driver + merge.union.driver +# 7. Install .git/hooks/pre-commit (defense-in-depth secret scan) +# 8. Prompt for remote (default: gh repo create --private gstack-brain-$USER) +# 9. Initial commit + push +# 10. Write ~/.gstack-brain-remote.txt (URL-only, safe to share) +# 11. Register GBrain consumer (HTTP POST if GBRAIN_URL set; else defer) +# +# Env: +# GSTACK_HOME — override ~/.gstack +# GBRAIN_URL — GBrain ingest endpoint base URL (for consumer registration) + +set -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" +REMOTE_FILE="$HOME/.gstack-brain-remote.txt" +CONSUMERS_FILE="$GSTACK_HOME/consumers.json" + +REMOTE_URL="" +while [ $# -gt 0 ]; do + case "$1" in + --remote) REMOTE_URL="$2"; shift 2 ;; + --help|-h) sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 1 ;; + esac +done + +# ---- preconditions ---- +mkdir -p "$GSTACK_HOME" + +EXISTING_REMOTE="" +if [ -d "$GSTACK_HOME/.git" ]; then + EXISTING_REMOTE=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + if [ -n "$EXISTING_REMOTE" ] && [ -n "$REMOTE_URL" ] && [ "$EXISTING_REMOTE" != "$REMOTE_URL" ]; then + cat >&2 <) +EOF + exit 1 + fi +fi + +# ---- choose the remote ---- +if [ -z "$REMOTE_URL" ] && [ -n "$EXISTING_REMOTE" ]; then + REMOTE_URL="$EXISTING_REMOTE" + echo "Using existing remote: $REMOTE_URL" +fi + +if [ -z "$REMOTE_URL" ]; then + # Interactive prompt. Default: gh repo create (if available). + echo "gstack-brain-init will create a private git repo that holds your" + echo "gstack session memory across machines and lets GBrain index it." + echo + if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + DEFAULT_NAME="gstack-brain-${USER:-$(whoami)}" + echo "Default: gh will create a private repo named '$DEFAULT_NAME' under your account." + printf "Press Enter to accept, or paste a custom git URL: " + read -r REPLY || REPLY="" + if [ -z "$REPLY" ]; then + echo "Creating GitHub repo: $DEFAULT_NAME ..." + if ! gh repo create "$DEFAULT_NAME" --private --description "gstack session memory" --source "$GSTACK_HOME" 2>/dev/null; then + # Maybe the repo already exists; try to fetch its URL. + REMOTE_URL=$(gh repo view "$DEFAULT_NAME" --json sshUrl -q .sshUrl 2>/dev/null || echo "") + if [ -z "$REMOTE_URL" ]; then + echo "Failed to create or find '$DEFAULT_NAME'. Try --remote ." >&2 + exit 1 + fi + echo "Repo already exists; using $REMOTE_URL" + else + REMOTE_URL=$(gh repo view "$DEFAULT_NAME" --json sshUrl -q .sshUrl 2>/dev/null || echo "") + fi + else + REMOTE_URL="$REPLY" + fi + else + echo "(gh CLI not found or not authenticated; provide a git URL directly)" + printf "Paste a private git URL (e.g. git@github.com:you/gstack-brain.git): " + read -r REMOTE_URL || REMOTE_URL="" + if [ -z "$REMOTE_URL" ]; then + echo "No URL provided. Aborting." >&2 + exit 1 + fi + fi +fi + +# ---- verify remote reachable ---- +echo "Verifying remote connectivity: $REMOTE_URL" +if ! git ls-remote "$REMOTE_URL" >/dev/null 2>&1; then + cat >&2 </dev/null || git -C "$GSTACK_HOME" init -q + # If -b main wasn't supported, rename. + git -C "$GSTACK_HOME" branch -M main 2>/dev/null || true +fi + +if [ -z "$(git -C "$GSTACK_HOME" remote 2>/dev/null)" ]; then + git -C "$GSTACK_HOME" remote add origin "$REMOTE_URL" +else + git -C "$GSTACK_HOME" remote set-url origin "$REMOTE_URL" +fi + +# ---- write canonical files (idempotent) ---- +cat > "$GSTACK_HOME/.gitignore" <<'EOF' +# gstack-brain sync: ignore-everything base. Paths are included explicitly via +# .brain-allowlist and `git add -f` from gstack-brain-sync. Do not edit. +* +EOF + +cat > "$GSTACK_HOME/.brain-allowlist" <<'EOF' +# Canonical allowlist of paths that gstack-brain-sync will publish. +# One glob per line. Anything not matching stays local. +# Do not edit directly; managed by gstack-brain-init. User additions go below +# the marker and survive re-init. +projects/*/learnings.jsonl +projects/*/*-reviews.jsonl +projects/*/ceo-plans/*.md +projects/*/ceo-plans/*/*.md +projects/*/designs/*.md +projects/*/designs/*/*.md +projects/*/timeline.jsonl +retros/*.md +developer-profile.json +builder-journey.md +builder-profile.jsonl +# NOT synced (per Codex v2 review — machine-local UX state): +# projects/*/question-preferences.json (per-machine UX preferences) +# projects/*/question-log.jsonl (audit/derivation log stays with preferences) +# projects/*/question-events.jsonl (same) +# ---- USER ADDITIONS BELOW ---- (survives re-init; above is managed) +EOF + +cat > "$GSTACK_HOME/.brain-privacy-map.json" <<'EOF' +[ + {"pattern": "projects/*/learnings.jsonl", "class": "artifact"}, + {"pattern": "projects/*/*-reviews.jsonl", "class": "artifact"}, + {"pattern": "projects/*/ceo-plans/*.md", "class": "artifact"}, + {"pattern": "projects/*/ceo-plans/*/*.md", "class": "artifact"}, + {"pattern": "projects/*/designs/*.md", "class": "artifact"}, + {"pattern": "projects/*/designs/*/*.md", "class": "artifact"}, + {"pattern": "retros/*.md", "class": "artifact"}, + {"pattern": "builder-journey.md", "class": "artifact"}, + {"pattern": "projects/*/timeline.jsonl", "class": "behavioral"}, + {"pattern": "developer-profile.json", "class": "behavioral"}, + {"pattern": "builder-profile.jsonl", "class": "behavioral"} +] +EOF + +cat > "$GSTACK_HOME/.gitattributes" <<'EOF' +# gstack-brain: merge drivers for cross-machine sync conflicts. +# Matching driver must be registered in local git config; gstack-brain-init +# and gstack-brain-restore run `git config merge..driver ...` after init. +*.jsonl merge=jsonl-append +retros/*.md merge=union +projects/*/designs/**/*.md merge=union +projects/*/ceo-plans/**/*.md merge=union +EOF + +# ---- register merge drivers in local git config ---- +git -C "$GSTACK_HOME" config merge.jsonl-append.driver "$SCRIPT_DIR/gstack-jsonl-merge %O %A %B" +git -C "$GSTACK_HOME" config merge.jsonl-append.name "gstack JSONL append-only merger" +git -C "$GSTACK_HOME" config merge.union.driver "cat %A %B > %A.merged && mv %A.merged %A" +git -C "$GSTACK_HOME" config merge.union.name "union concat" + +# ---- install pre-commit hook (defense-in-depth) ---- +HOOK="$GSTACK_HOME/.git/hooks/pre-commit" +mkdir -p "$(dirname "$HOOK")" +cat > "$HOOK" <<'HOOK_EOF' +#!/usr/bin/env bash +# gstack-brain pre-commit hook — secret-scan defense-in-depth. +# The primary scanner runs inside gstack-brain-sync BEFORE staging. This hook +# catches any manual `git commit` a user might accidentally run against the +# brain repo. +set -uo pipefail + +python3 -c " +import sys, re, subprocess +try: + out = subprocess.check_output(['git', 'diff', '--cached'], stderr=subprocess.DEVNULL).decode('utf-8', 'replace') +except Exception: + sys.exit(0) + +patterns = [ + ('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')), + ('github-token', re.compile(r'\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')), + ('openai-key', re.compile(r'\bsk-[A-Za-z0-9_-]{20,}')), + ('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')), + ('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b')), + ('bearer-token-json', + re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\s*:\s*\"[A-Za-z0-9_./+=-]{16,}\"', + re.IGNORECASE)), +] +for name, rx in patterns: + if rx.search(out): + sys.stderr.write(f'gstack-brain pre-commit: refusing commit — {name} detected in staged diff.\n') + sys.stderr.write('Either edit the offending file, or if intentional, run:\n') + sys.stderr.write(' gstack-brain-sync --skip-file (to permanently exclude)\n') + sys.exit(1) +sys.exit(0) +" +HOOK_EOF +chmod +x "$HOOK" + +# ---- initial commit (idempotent; skips if already committed) ---- +cd "$GSTACK_HOME" +git add -f .gitignore .brain-allowlist .brain-privacy-map.json .gitattributes +# Only commit if the index has changes from HEAD (if there is a HEAD). +if git rev-parse HEAD >/dev/null 2>&1; then + if ! git diff --cached --quiet 2>/dev/null; then + git -c user.email="gstack@localhost" -c user.name="gstack-brain-init" \ + commit -q -m "chore: gstack-brain-init (refresh sync config)" + fi +else + # First commit ever. + git -c user.email="gstack@localhost" -c user.name="gstack-brain-init" \ + commit -q -m "chore: gstack-brain-init" +fi + +# ---- initial push ---- +if ! git push -q -u origin main 2>/dev/null; then + # Maybe the default branch is master, or the remote has existing content. + # Try to resolve: fetch + fast-forward merge + push. + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + if git fetch origin 2>/dev/null && git pull --ff-only origin "$CURRENT_BRANCH" 2>/dev/null; then + git push -q -u origin "$CURRENT_BRANCH" || { + echo "Push to $REMOTE_URL failed. The remote may have divergent content." >&2 + echo "Try: cd ~/.gstack && git pull --rebase origin $CURRENT_BRANCH && git push origin $CURRENT_BRANCH" >&2 + exit 1 + } + else + # Couldn't fetch/merge; print what to do. + echo "Push to $REMOTE_URL failed and fetch/merge didn't help." >&2 + echo "Manual recovery: cd ~/.gstack && git status, then push once conflicts are resolved." >&2 + exit 1 + fi +fi + +# ---- write the remote-url helper file (outside ~/.gstack/, survives restore) ---- +echo "$REMOTE_URL" > "$REMOTE_FILE" +chmod 600 "$REMOTE_FILE" + +# ---- register GBrain consumer ---- +mkdir -p "$GSTACK_HOME" +CONSUMER_STATUS="pending" +GBRAIN_URL_VAL="${GBRAIN_URL:-$("$CONFIG_BIN" get gbrain_url 2>/dev/null || echo "")}" +GBRAIN_TOKEN_VAL="${GBRAIN_TOKEN:-$("$CONFIG_BIN" get gbrain_token 2>/dev/null || echo "")}" + +if [ -n "$GBRAIN_URL_VAL" ] && [ -n "$GBRAIN_TOKEN_VAL" ]; then + # Try the HTTP handoff. + HTTP_RESP=$(curl -sS -X POST "${GBRAIN_URL_VAL%/}/ingest-repo" \ + -H "Authorization: Bearer $GBRAIN_TOKEN_VAL" \ + -H "Content-Type: application/json" \ + --data "{\"repo_url\":\"$REMOTE_URL\"}" \ + -w "\n%{http_code}" 2>&1 || echo -e "\ncurl-error") + HTTP_CODE=$(echo "$HTTP_RESP" | tail -1) + if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "204" ]; then + CONSUMER_STATUS="ok" + echo "GBrain consumer registered: $GBRAIN_URL_VAL" + else + echo "GBrain ingest endpoint returned HTTP $HTTP_CODE; will retry on next skill run." + fi +elif [ -z "$GBRAIN_URL_VAL" ]; then + echo "(GBRAIN_URL not configured; skipping consumer registration. Set it with:" + echo " gstack-config set gbrain_url " + echo " gstack-config set gbrain_token " + echo " then run: gstack-brain-consumer add gbrain --ingest-url --token )" +fi + +# Write consumers.json — the canonical registry. Tokens are NOT stored here; +# they stay in gstack-config (machine-local). This file IS synced so a new +# machine knows which consumers exist and can prompt for tokens. +python3 - "$CONSUMERS_FILE" "$GBRAIN_URL_VAL" "$CONSUMER_STATUS" <<'PYEOF' +import sys, json, os +path, url, status = sys.argv[1:4] +try: + with open(path) as f: + data = json.load(f) +except (FileNotFoundError, json.JSONDecodeError): + data = {"consumers": []} +# Upsert GBrain entry. +entry = {"name": "gbrain", "ingest_url": url, "status": status, "token_ref": "gbrain_token"} +updated = False +for i, c in enumerate(data.get("consumers", [])): + if c.get("name") == "gbrain": + data["consumers"][i] = entry + updated = True + break +if not updated: + data.setdefault("consumers", []).append(entry) +with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") +PYEOF + +# Stage and commit consumers.json in the same session. +cd "$GSTACK_HOME" +git add -f consumers.json 2>/dev/null || true +if ! git diff --cached --quiet 2>/dev/null; then + git -c user.email="gstack@localhost" -c user.name="gstack-brain-init" \ + commit -q -m "chore: register GBrain consumer" + git push -q origin HEAD 2>/dev/null || true +fi + +# ---- done ---- +cat <] +# +# If no URL is given, reads from ~/.gstack-brain-remote.txt (written by +# gstack-brain-init on the original machine). Copy that file to the new +# machine before running this command. +# +# Safety gates (refuses with clear message): +# - ~/.gstack/.git already exists with a DIFFERENT remote +# - ~/.gstack/ contains non-allowlisted, non-gitignored user files +# that would be clobbered by restore +# +# What it does: +# 1. Clone the remote to a staging directory +# 2. Validate the repo is gstack-brain-shaped (.brain-allowlist, .gitattributes) +# 3. rsync-copy tracked files into ~/.gstack/ with skip-if-same-hash +# 4. Move staging's .git into ~/.gstack/.git +# 5. Register local git config merge drivers (they don't clone from remote) +# 6. Rehydrate consumers.json endpoints; prompt for tokens +# +# Env: +# GSTACK_HOME — override ~/.gstack + +set -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" +REMOTE_FILE="$HOME/.gstack-brain-remote.txt" + +REMOTE_URL="${1:-}" +if [ -z "$REMOTE_URL" ]; then + if [ -f "$REMOTE_FILE" ]; then + REMOTE_URL=$(head -1 "$REMOTE_FILE" | tr -d '[:space:]') + fi +fi + +if [ -z "$REMOTE_URL" ]; then + cat >&2 < + or put the URL in $REMOTE_FILE (copy from the original machine) +EOF + exit 1 +fi + +# ---- safety gates ---- +if [ -d "$GSTACK_HOME/.git" ]; then + EXISTING_REMOTE=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + if [ -n "$EXISTING_REMOTE" ] && [ "$EXISTING_REMOTE" != "$REMOTE_URL" ]; then + cat >&2 </dev/null' EXIT + +echo "Cloning $REMOTE_URL to staging..." +if ! git clone --quiet "$REMOTE_URL" "$STAGING/repo" 2>/dev/null; then + echo "Clone failed. Check:" >&2 + echo " - URL is correct: $REMOTE_URL" >&2 + echo " - Auth: gh auth status (github) / glab auth status (gitlab)" >&2 + exit 1 +fi + +# ---- validate shape ---- +if [ ! -f "$STAGING/repo/.brain-allowlist" ] || [ ! -f "$STAGING/repo/.gitattributes" ]; then + cat >&2 < 5: + print(f"...and {len(risks) - 5} more") +sys.exit(0 if not risks else 2) +PYEOF + ) || true + if [ -n "$CLOBBER_RISK" ]; then + cat >&2 </dev/null 2>&1 || true +else + mv "$STAGING/repo/.git" "$GSTACK_HOME/.git" +fi + +# ---- register merge drivers (local git config; don't survive clones) ---- +git -C "$GSTACK_HOME" config merge.jsonl-append.driver "$SCRIPT_DIR/gstack-jsonl-merge %O %A %B" +git -C "$GSTACK_HOME" config merge.jsonl-append.name "gstack JSONL append-only merger" +git -C "$GSTACK_HOME" config merge.union.driver "cat %A %B > %A.merged && mv %A.merged %A" +git -C "$GSTACK_HOME" config merge.union.name "union concat" + +# ---- install pre-commit hook (same as init) ---- +HOOK="$GSTACK_HOME/.git/hooks/pre-commit" +mkdir -p "$(dirname "$HOOK")" +cat > "$HOOK" <<'HOOK_EOF' +#!/usr/bin/env bash +set -uo pipefail +python3 -c " +import sys, re, subprocess +try: + out = subprocess.check_output(['git', 'diff', '--cached'], stderr=subprocess.DEVNULL).decode('utf-8', 'replace') +except Exception: + sys.exit(0) +patterns = [ + ('aws-access-key', re.compile(r'AKIA[0-9A-Z]{16}')), + ('github-token', re.compile(r'\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})')), + ('openai-key', re.compile(r'\bsk-[A-Za-z0-9_-]{20,}')), + ('pem-block', re.compile(r'-----BEGIN [A-Z ]{3,}-----')), + ('jwt', re.compile(r'\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b')), + ('bearer-token-json', + re.compile(r'\"(authorization|api[_-]?key|apikey|token|secret|password)\"\s*:\s*\"[A-Za-z0-9_./+=-]{16,}\"', + re.IGNORECASE)), +] +for name, rx in patterns: + if rx.search(out): + sys.stderr.write(f'gstack-brain pre-commit: refusing commit — {name} detected.\n') + sys.exit(1) +sys.exit(0) +" +HOOK_EOF +chmod +x "$HOOK" + +# ---- rehydrate consumers, prompt for tokens ---- +if [ -f "$GSTACK_HOME/consumers.json" ]; then + echo "" + echo "Consumer registry restored. Tokens are machine-local and NOT synced." + echo "Run these for each consumer to re-enter tokens:" + python3 - "$GSTACK_HOME/consumers.json" <<'PYEOF' +import sys, json +try: + with open(sys.argv[1]) as f: + data = json.load(f) +except Exception: + sys.exit(0) +for c in data.get("consumers", []): + name = c.get("name", "") + token_ref = c.get("token_ref", f"{name}_token") + print(f" gstack-config set {token_ref} ") +PYEOF +fi + +# ---- write remote helper file if missing ---- +if [ ! -f "$REMOTE_FILE" ]; then + echo "$REMOTE_URL" > "$REMOTE_FILE" + chmod 600 "$REMOTE_FILE" + echo "" + echo "Wrote $REMOTE_FILE for future skill-run auto-detection." +fi + +cat <_token keys) +# ~/.gstack-brain-remote.txt in your home directory +# The actual remote git repo (unless --delete-remote) + +set -euo pipefail + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_BIN="$SCRIPT_DIR/gstack-config" +REMOTE_FILE="$HOME/.gstack-brain-remote.txt" + +ASSUME_YES=0 +DELETE_REMOTE=0 +while [ $# -gt 0 ]; do + case "$1" in + --yes|-y) ASSUME_YES=1; shift ;; + --delete-remote) DELETE_REMOTE=1; shift ;; + --help|-h) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 1 ;; + esac +done + +if [ ! -d "$GSTACK_HOME/.git" ]; then + echo "gstack-brain-uninstall: nothing to do (~/.gstack/.git doesn't exist)." + exit 0 +fi + +REMOTE_URL=$(git -C "$GSTACK_HOME" remote get-url origin 2>/dev/null || echo "") + +# ---- confirmation ---- +if [ "$ASSUME_YES" != "1" ]; then + cat </dev/null 2>&1; then + # Extract owner/repo from URL. + REPO_SLUG=$(echo "$REMOTE_URL" | sed -E 's#.*[:/]([^/:]+/[^/]+)(\.git)?$#\1#' | sed 's/\.git$//') + if [ -n "$REPO_SLUG" ]; then + echo "Deleting GitHub repo: $REPO_SLUG" + if [ "$ASSUME_YES" = "1" ]; then + gh repo delete "$REPO_SLUG" --yes 2>/dev/null || echo "gh repo delete failed; continuing local uninstall" + else + gh repo delete "$REPO_SLUG" 2>/dev/null || echo "gh repo delete failed; continuing local uninstall" + fi + fi + else + echo "--delete-remote requires the gh CLI. Skipping remote deletion." + fi + ;; + *) + echo "--delete-remote only supports github.com remotes. Delete manually if needed: $REMOTE_URL" + ;; + esac +fi + +# ---- remove sync files ---- +echo "Removing git layer and sync config files..." +rm -rf "$GSTACK_HOME/.git" 2>/dev/null || true +rm -f "$GSTACK_HOME/.gitignore" 2>/dev/null || true +rm -f "$GSTACK_HOME/.gitattributes" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-allowlist" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-privacy-map.json" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-queue.jsonl" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-discover-cursor" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-last-push" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-last-pull" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-skip.txt" 2>/dev/null || true +rm -f "$GSTACK_HOME/.brain-sync-status.json" 2>/dev/null || true +rm -rf "$GSTACK_HOME/.brain-sync.lock.d" 2>/dev/null || true +rm -f "$GSTACK_HOME/consumers.json" 2>/dev/null || true + +# ---- clear config keys ---- +"$CONFIG_BIN" set gbrain_sync_mode off >/dev/null 2>&1 || true +"$CONFIG_BIN" set gbrain_sync_mode_prompted false >/dev/null 2>&1 || true + +# ---- leave remote-helper file alone unless user asked to delete remote ---- +if [ "$DELETE_REMOTE" = "1" ]; then + rm -f "$REMOTE_FILE" 2>/dev/null || true +else + if [ -f "$REMOTE_FILE" ]; then + echo "(keeping $REMOTE_FILE — remove manually if you want to forget the URL)" + fi +fi + +cat <