forked from bchanot/claude
feat(seo): account-management verbs (connect/accounts/forget) + connect.sh wrapper
tokenstore remove/clear, fetch.sh forget dispatch, and a connect.sh wrapper that sources ~/.claude/.env internally and runs from any project. /seo now routes connect|accounts|forget before the audit flow; Makefile seo-connect delegates to the wrapper. Labels are guarded to shell-safe ASCII (POSIX case, whole-string, C-locale) as defense-in-depth; forget output states local removal is not a Google-side revocation.
This commit is contained in:
@@ -25,9 +25,8 @@ onboard: link ## Onboard an existing project (run from the project directory)
|
|||||||
seo-connect: ## Connect a Google account for /seo FULL (creates venv, OAuth consent)
|
seo-connect: ## Connect a Google account for /seo FULL (creates venv, OAuth consent)
|
||||||
@python3 -m venv "$$HOME/.claude/.venv-seo-data"
|
@python3 -m venv "$$HOME/.claude/.venv-seo-data"
|
||||||
@"$$HOME/.claude/.venv-seo-data/bin/pip" install -q -r lib/seo-data/requirements.txt
|
@"$$HOME/.claude/.venv-seo-data/bin/pip" install -q -r lib/seo-data/requirements.txt
|
||||||
@bash -c 'set -a; [ -f "$$HOME/.claude/.env" ] && . "$$HOME/.claude/.env"; set +a; \
|
@bash -c 'read -r -p "Label for this account (e.g. client-a): " label; \
|
||||||
read -r -p "Label for this account (e.g. client-a): " label; \
|
bash lib/seo-data/connect.sh --label "$$label"'
|
||||||
"$$HOME/.claude/.venv-seo-data/bin/python3" lib/seo-data/connect.py --label "$$label"'
|
|
||||||
|
|
||||||
test: ## Run deterministic tests (lib/tests/*.test.sh + lib/gitflow-test.sh + lib/tests/run-*.sh)
|
test: ## Run deterministic tests (lib/tests/*.test.sh + lib/gitflow-test.sh + lib/tests/run-*.sh)
|
||||||
@fail=0; for t in lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh; do \
|
@fail=0; for t in lib/tests/*.test.sh lib/seo-data/*.test.sh lib/gitflow-test.sh lib/tests/run-*.sh; do \
|
||||||
|
|||||||
+23
-6
@@ -18,14 +18,19 @@ is written to disk during an audit, only at `make seo-connect`.
|
|||||||
One-time per Google account:
|
One-time per Google account:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make seo-connect
|
make seo-connect # from the claude-config repo
|
||||||
|
bash ~/.claude/lib/seo-data/connect.sh --label <label> # from ANY directory (venv must exist)
|
||||||
```
|
```
|
||||||
|
|
||||||
This creates `~/.claude/.venv-seo-data/` (isolated venv, deps pinned in
|
`make seo-connect` creates `~/.claude/.venv-seo-data/` (isolated venv, deps
|
||||||
`requirements.txt`), installs `google-auth`, `google-auth-oauthlib`,
|
pinned in `requirements.txt`), installs `google-auth`,
|
||||||
`requests`, then runs `connect.py`: it opens a browser for OAuth consent and
|
`google-auth-oauthlib`, `requests`, then delegates to `connect.sh`. The
|
||||||
asks for a **label** (e.g. `client-a`) to key the account — pick a name, not
|
wrapper sources `~/.claude/.env` internally, prefers the venv python, and
|
||||||
an email, since the store never stores or requests the account's email.
|
runs `connect.py`: it opens a browser for OAuth consent and takes a
|
||||||
|
**label** (e.g. `client-a`) to key the account — pick a name, not an email,
|
||||||
|
since the store never stores or requests the account's email. Once the venv
|
||||||
|
exists, `connect.sh` alone connects further accounts from anywhere (the
|
||||||
|
`/seo connect [label]` skill verb uses exactly this path).
|
||||||
|
|
||||||
Before running it, set these 3 keys in `~/.claude/.env` (the canonical
|
Before running it, set these 3 keys in `~/.claude/.env` (the canonical
|
||||||
vault; `link.sh` only symlinks the repo's `.env` to it and warns with a
|
vault; `link.sh` only symlinks the repo's `.env` to it and warns with a
|
||||||
@@ -77,6 +82,12 @@ fetch.sh queries --account client-a --property sc-domain:ex.com [--days 90] [--d
|
|||||||
fetch.sh inspect --account client-a --property … --url https://ex.com/page
|
fetch.sh inspect --account client-a --property … --url https://ex.com/page
|
||||||
→ {"status":"ok","source":"gsc","indexed":true,"coverage":"…","last_crawl":"…"}
|
→ {"status":"ok","source":"gsc","indexed":true,"coverage":"…","last_crawl":"…"}
|
||||||
→ {"status":"degraded","reason":"…"}
|
→ {"status":"degraded","reason":"…"}
|
||||||
|
|
||||||
|
fetch.sh forget --label client-a
|
||||||
|
→ {"status":"ok","removed":true|false} # false = label wasn't in the store
|
||||||
|
|
||||||
|
fetch.sh forget --all
|
||||||
|
→ {"status":"ok","cleared":<n>} # n = accounts removed
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules that hold for every subcommand:
|
Rules that hold for every subcommand:
|
||||||
@@ -151,6 +162,12 @@ Security posture:
|
|||||||
- **Also gitignored** (`.venv-seo-data/` and `seo-data/tokens.json` in
|
- **Also gitignored** (`.venv-seo-data/` and `seo-data/tokens.json` in
|
||||||
`.gitignore`) as a second, belt-and-suspenders guard in case a relative
|
`.gitignore`) as a second, belt-and-suspenders guard in case a relative
|
||||||
path ever put either under the repo tree.
|
path ever put either under the repo tree.
|
||||||
|
- **Removal is local-only.** `fetch.sh forget --label <x>` / `--all` (the
|
||||||
|
`/seo forget` skill verb) deletes the stored refresh token — it does NOT
|
||||||
|
revoke the OAuth grant at Google's end. For a real revocation, visit
|
||||||
|
https://myaccount.google.com/permissions with the account concerned and
|
||||||
|
remove the app's access; the deleted local token then becomes useless
|
||||||
|
everywhere, including to anyone who copied it beforehand.
|
||||||
|
|
||||||
## Graceful degradation
|
## Graceful degradation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time OAuth consent wrapper — runnable from ANY directory:
|
||||||
|
# bash ~/.claude/lib/seo-data/connect.sh --label <label>
|
||||||
|
# Sources the env vault internally (never echoed), prefers the engine venv,
|
||||||
|
# then execs connect.py. Interactive by design: stdout carries the auth URL,
|
||||||
|
# stderr stays visible (unlike fetch.sh, there is no secret-leak surface to
|
||||||
|
# suppress — connect.py never prints tokens).
|
||||||
|
set -uo pipefail
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
ENV_FILE="${SEO_DATA_ENV_FILE:-${HOME}/.claude/.env}" # canonical; tests override to /dev/null
|
||||||
|
VENV_PY="${HOME}/.claude/.venv-seo-data/bin/python3"
|
||||||
|
|
||||||
|
# Whole-string label guard (shell-safe ASCII: leading alnum then alnum/._-).
|
||||||
|
# POSIX `case` in a C-locale subshell: no per-line grep pitfall (a newline is
|
||||||
|
# a non-allowed byte caught by *[!...]*), no locale range surprise, no second
|
||||||
|
# grammar to differ from. Empty and non-alnum-leading are rejected too.
|
||||||
|
_label_safe() ( LC_ALL=C; case "$1" in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) exit 1;; esac )
|
||||||
|
|
||||||
|
# Strict argv grammar (parser-differential defense): accept ONLY the exact
|
||||||
|
# forms `--label <value>` / `--store <path>` — never `=`-joined or abbreviated
|
||||||
|
# forms — so the downstream argparse can never resolve a token this guard
|
||||||
|
# didn't see. Runs BEFORE any secret is loaded.
|
||||||
|
argv=("$@"); n=${#argv[@]}; i=0
|
||||||
|
while [ "$i" -lt "$n" ]; do
|
||||||
|
case "${argv[$i]}" in
|
||||||
|
--label)
|
||||||
|
if ! _label_safe "${argv[$((i+1))]:-}"; then
|
||||||
|
echo "connect.sh: unsafe label — must match ^[A-Za-z0-9][A-Za-z0-9._-]*\$" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
i=$((i+2)) ;;
|
||||||
|
--store) i=$((i+2)) ;;
|
||||||
|
*)
|
||||||
|
echo "connect.sh: unsupported argument '${argv[$i]}' — usage: connect.sh --label <label> [--store <path>]" >&2
|
||||||
|
exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Load secrets quietly (sourced, never echoed).
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a; # shellcheck source=/dev/null
|
||||||
|
. "$ENV_FILE"; set +a
|
||||||
|
fi
|
||||||
|
PY="python3"; [ -x "$VENV_PY" ] && PY="$VENV_PY"
|
||||||
|
exec "$PY" "$HERE/connect.py" "$@"
|
||||||
+17
-1
@@ -20,11 +20,27 @@ fi
|
|||||||
# stdlib-only paths (accounts / mock / degrade).
|
# stdlib-only paths (accounts / mock / degrade).
|
||||||
PY="python3"; [ -x "$VENV_PY" ] && PY="$VENV_PY"
|
PY="python3"; [ -x "$VENV_PY" ] && PY="$VENV_PY"
|
||||||
|
|
||||||
|
# Whole-string label guard (shell-safe ASCII). POSIX `case` in a C-locale
|
||||||
|
# subshell — newline-proof and locale-independent, unlike a per-line grep.
|
||||||
|
_label_safe() ( LC_ALL=C; case "$1" in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._-]*) exit 1;; esac )
|
||||||
|
|
||||||
cmd="${1:-}"; shift || true
|
cmd="${1:-}"; shift || true
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
accounts) exec "$PY" "$HERE/tokenstore.py" list --file "$STORE" ;;
|
accounts) exec "$PY" "$HERE/tokenstore.py" list --file "$STORE" ;;
|
||||||
crux|queries|inspect)
|
crux|queries|inspect)
|
||||||
exec "$PY" "$HERE/google_seo.py" "$cmd" --store "$STORE" "$@" ;;
|
exec "$PY" "$HERE/google_seo.py" "$cmd" --store "$STORE" "$@" ;;
|
||||||
*) echo '{"status":"error","reason":"usage: fetch.sh {accounts|crux|queries|inspect} [flags]"}'
|
forget)
|
||||||
|
# forget --label <label> → drop one account; forget --all → empty the store.
|
||||||
|
# Local removal only — does NOT revoke the grant at Google's end.
|
||||||
|
# Label charset guard: store keys stay shell-safe wherever an agent
|
||||||
|
# interpolates them into a command line (defense-in-depth vs injection).
|
||||||
|
if [ "${1:-}" = "--all" ]; then
|
||||||
|
exec "$PY" "$HERE/tokenstore.py" clear --file "$STORE"
|
||||||
|
elif [ "${1:-}" = "--label" ] && _label_safe "${2:-}"; then
|
||||||
|
exec "$PY" "$HERE/tokenstore.py" remove --file "$STORE" --label "$2"
|
||||||
|
fi
|
||||||
|
echo '{"status":"error","reason":"usage: fetch.sh forget {--label <label>|--all} (label charset: A-Za-z0-9._-)"}'
|
||||||
|
exit 2 ;;
|
||||||
|
*) echo '{"status":"error","reason":"usage: fetch.sh {accounts|crux|queries|inspect|forget} [flags]"}'
|
||||||
exit 2 ;;
|
exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -106,12 +106,76 @@ has "connect.persist wrote prop" "$L3" 'sc-domain:x.com'
|
|||||||
hasnt "connect.persist redacts" "$L3" 'RT_X'
|
hasnt "connect.persist redacts" "$L3" 'RT_X'
|
||||||
rm -rf "$TMP3"
|
rm -rf "$TMP3"
|
||||||
|
|
||||||
|
echo "── forget (remove/clear) ──"
|
||||||
|
TMP6="$(mktemp -d)"; S6="$TMP6/tokens.json"
|
||||||
|
python3 "$SD/tokenstore.py" set --file "$S6" --label keep --refresh-token RT_KEEP \
|
||||||
|
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:k.com >/dev/null
|
||||||
|
python3 "$SD/tokenstore.py" set --file "$S6" --label drop --refresh-token RT_DROP \
|
||||||
|
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:d.com >/dev/null
|
||||||
|
RM="$(python3 "$SD/tokenstore.py" remove --file "$S6" --label drop)"
|
||||||
|
has "remove reports ok" "$RM" '"status": "ok"'
|
||||||
|
has "remove reports removed" "$RM" '"removed": true'
|
||||||
|
hasnt "remove prints no token" "$RM" 'RT_DROP'
|
||||||
|
L6="$(python3 "$SD/tokenstore.py" list --file "$S6")"
|
||||||
|
has "remove keeps others" "$L6" '"keep"'
|
||||||
|
hasnt "removed label gone" "$L6" '"drop"'
|
||||||
|
RM2="$(python3 "$SD/tokenstore.py" remove --file "$S6" --label ghost)"
|
||||||
|
has "remove missing = false" "$RM2" '"removed": false'
|
||||||
|
CL="$(python3 "$SD/tokenstore.py" clear --file "$S6")"
|
||||||
|
has "clear reports ok" "$CL" '"status": "ok"'
|
||||||
|
has "clear reports count" "$CL" '"cleared": 1'
|
||||||
|
L7="$(python3 "$SD/tokenstore.py" list --file "$S6")"
|
||||||
|
has "clear empties store" "$L7" '"accounts": []'
|
||||||
|
PERM6="$(stat -c '%a' "$S6")"
|
||||||
|
[ "$PERM6" = "600" ] && ok "store stays 0600 after clear" || no "store 0600 after clear" "got $PERM6"
|
||||||
|
# via the real fetch.sh dispatch layer
|
||||||
|
python3 "$SD/tokenstore.py" set --file "$S6" --label back --refresh-token RT_BACK \
|
||||||
|
--scopes https://www.googleapis.com/auth/webmasters.readonly --properties sc-domain:b.com >/dev/null
|
||||||
|
FG="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label back)"; FRC=$?
|
||||||
|
has "fetch forget removes" "$FG" '"removed": true'
|
||||||
|
[ "$FRC" = "0" ] && ok "fetch forget exit 0" || no "fetch forget exit 0" "got $FRC"
|
||||||
|
FB="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget)"; FRC2=$?
|
||||||
|
has "forget bad usage json" "$FB" '"status"'
|
||||||
|
[ "$FRC2" = "2" ] && ok "forget bad usage exit 2" || no "forget bad usage exit 2" "got $FRC2"
|
||||||
|
FA="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --all)"
|
||||||
|
has "fetch forget --all ok" "$FA" '"status": "ok"'
|
||||||
|
FI="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label 'x;touch /tmp/pwn')"; FIRC=$?
|
||||||
|
has "forget unsafe label json" "$FI" '"status":"error"'
|
||||||
|
[ "$FIRC" = "2" ] && ok "forget unsafe label exit 2" || no "forget unsafe label exit 2" "got $FIRC"
|
||||||
|
# embedded-newline label must NOT pass the per-line-grep pitfall
|
||||||
|
FN="$(SEO_DATA_ENV_FILE=$NOENV SEO_DATA_STORE="$S6" bash "$FETCH" forget --label "$(printf 'ok\nrm -rf x')")"; FNRC=$?
|
||||||
|
has "forget newline label json" "$FN" '"status":"error"'
|
||||||
|
[ "$FNRC" = "2" ] && ok "forget newline label exit 2" || no "forget newline label exit 2" "got $FNRC"
|
||||||
|
rm -rf "$TMP6"
|
||||||
|
|
||||||
|
echo "── connect.sh (offline negative) ──"
|
||||||
|
CN="$(SEO_DATA_ENV_FILE=$NOENV env -u GOOGLE_OAUTH_CLIENT_ID -u GOOGLE_OAUTH_CLIENT_SECRET \
|
||||||
|
bash "$SD/connect.sh" --label t 2>&1)"; CNRC=$?
|
||||||
|
[ "$CNRC" != "0" ] && ok "connect.sh no-creds nonzero" || no "connect.sh no-creds nonzero" "got 0"
|
||||||
|
has "connect.sh creds gate msg" "$CN" 'GOOGLE_OAUTH_CLIENT_ID'
|
||||||
|
CU="$(SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label 'x;y' 2>&1)"; CURC=$?
|
||||||
|
[ "$CURC" = "2" ] && ok "connect.sh unsafe label exit 2" || no "connect.sh unsafe label exit 2" "got $CURC"
|
||||||
|
has "connect.sh label guard msg" "$CU" 'unsafe label'
|
||||||
|
# parser-differential bypasses must be rejected too (=-joined, abbreviated)
|
||||||
|
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label='x;y' >/dev/null 2>&1; CJRC=$?
|
||||||
|
[ "$CJRC" = "2" ] && ok "connect.sh =-joined rejected" || no "connect.sh =-joined rejected" "got $CJRC"
|
||||||
|
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --labe 'x;y' >/dev/null 2>&1; CBRC=$?
|
||||||
|
[ "$CBRC" = "2" ] && ok "connect.sh abbrev rejected" || no "connect.sh abbrev rejected" "got $CBRC"
|
||||||
|
SEO_DATA_ENV_FILE=$NOENV bash "$SD/connect.sh" --label "$(printf 'ok\nrm -rf x')" >/dev/null 2>&1; CWRC=$?
|
||||||
|
[ "$CWRC" = "2" ] && ok "connect.sh newline rejected" || no "connect.sh newline rejected" "got $CWRC"
|
||||||
|
# a VALID label must still reach the creds gate (guard is not over-tight)
|
||||||
|
CV="$(SEO_DATA_ENV_FILE=$NOENV env -u GOOGLE_OAUTH_CLIENT_ID -u GOOGLE_OAUTH_CLIENT_SECRET \
|
||||||
|
bash "$SD/connect.sh" --label ok-1.2_3 2>&1)"; CVRC=$?
|
||||||
|
[ "$CVRC" = "1" ] && ok "connect.sh valid label reaches gate" || no "connect.sh valid label reaches gate" "got $CVRC"
|
||||||
|
has "connect.sh valid gate msg" "$CV" 'GOOGLE_OAUTH_CLIENT_ID'
|
||||||
|
|
||||||
echo "── wiring locks ──"
|
echo "── wiring locks ──"
|
||||||
tf() { if grep -qF -- "$3" "$2" 2>/dev/null; then ok "$1"; else no "$1" "missing: $3"; fi; }
|
tf() { if grep -qF -- "$3" "$2" 2>/dev/null; then ok "$1"; else no "$1" "missing: $3"; fi; }
|
||||||
tf "env.example client id" "$REPO/.env.example" "GOOGLE_OAUTH_CLIENT_ID="
|
tf "env.example client id" "$REPO/.env.example" "GOOGLE_OAUTH_CLIENT_ID="
|
||||||
tf "env.example crux key" "$REPO/.env.example" "CRUX_API_KEY="
|
tf "env.example crux key" "$REPO/.env.example" "CRUX_API_KEY="
|
||||||
tf "makefile seo-connect" "$REPO/Makefile" "seo-connect:"
|
tf "makefile seo-connect" "$REPO/Makefile" "seo-connect:"
|
||||||
tf "seo-connect sources env" "$REPO/Makefile" ".claude/.env"
|
tf "makefile delegates wrapper" "$REPO/Makefile" "lib/seo-data/connect.sh"
|
||||||
|
tf "connect.sh sources vault" "$SD/connect.sh" ".claude/.env"
|
||||||
tf "makefile discovers test" "$REPO/Makefile" "lib/seo-data/*.test.sh"
|
tf "makefile discovers test" "$REPO/Makefile" "lib/seo-data/*.test.sh"
|
||||||
tf "install prompts connect" "$REPO/install.sh" "make seo-connect"
|
tf "install prompts connect" "$REPO/install.sh" "make seo-connect"
|
||||||
tf "doctor checks seo-data" "$REPO/doctor.sh" "seo-data"
|
tf "doctor checks seo-data" "$REPO/doctor.sh" "seo-data"
|
||||||
@@ -125,9 +189,17 @@ tf "analyzer calls fetch queries" "$REPO/agents/seo-analyzer.md" "fetch.sh queri
|
|||||||
tf "analyzer gsc subsection" "$REPO/agents/seo-analyzer.md" "Performance GSC"
|
tf "analyzer gsc subsection" "$REPO/agents/seo-analyzer.md" "Performance GSC"
|
||||||
tf "catalog gsc oauth entry" "$REPO/agents/resources/automation-catalog.md" "make seo-connect"
|
tf "catalog gsc oauth entry" "$REPO/agents/resources/automation-catalog.md" "make seo-connect"
|
||||||
|
|
||||||
|
echo "── account-mgmt locks ──"
|
||||||
|
tf "skill routes account verbs" "$REPO/skills/seo/SKILL.md" "forget --all"
|
||||||
|
tf "skill connect wrapper path" "$REPO/skills/seo/SKILL.md" "lib/seo-data/connect.sh"
|
||||||
|
tf "skill revocation notice" "$REPO/skills/seo/SKILL.md" "myaccount.google.com/permissions"
|
||||||
|
tf "skill label charset rule" "$REPO/skills/seo/SKILL.md" "A-Za-z0-9._-"
|
||||||
|
|
||||||
echo "── readme lock ──"
|
echo "── readme lock ──"
|
||||||
tf "readme documents fetch.sh" "$REPO/lib/seo-data/README.md" "fetch.sh"
|
tf "readme documents fetch.sh" "$REPO/lib/seo-data/README.md" "fetch.sh"
|
||||||
tf "readme documents seo-connect" "$REPO/lib/seo-data/README.md" "make seo-connect"
|
tf "readme documents seo-connect" "$REPO/lib/seo-data/README.md" "make seo-connect"
|
||||||
|
tf "readme documents forget" "$REPO/lib/seo-data/README.md" "forget --all"
|
||||||
|
tf "readme revocation note" "$REPO/lib/seo-data/README.md" "myaccount.google.com/permissions"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "seo-data engine: $PASS pass, $FAIL fail"
|
echo "seo-data engine: $PASS pass, $FAIL fail"
|
||||||
|
|||||||
+57
-14
@@ -2,6 +2,7 @@
|
|||||||
"""Label-keyed OAuth refresh-token store. Atomic writes under an fcntl lock.
|
"""Label-keyed OAuth refresh-token store. Atomic writes under an fcntl lock.
|
||||||
No third-party deps — must run without the venv (used by the offline test path)."""
|
No third-party deps — must run without the venv (used by the offline test path)."""
|
||||||
import argparse, fcntl, json, os, tempfile
|
import argparse, fcntl, json, os, tempfile
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
def load(path):
|
def load(path):
|
||||||
@@ -21,14 +22,34 @@ def list_accounts(path):
|
|||||||
def get_refresh_token(path, label):
|
def get_refresh_token(path, label):
|
||||||
return load(path).get("accounts", {}).get(label, {}).get("refresh_token")
|
return load(path).get("accounts", {}).get(label, {}).get("refresh_token")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _locked(path):
|
||||||
|
"""Exclusive fcntl lock around a store mutation (serializes writers)."""
|
||||||
|
lock_path = path + ".lock"
|
||||||
|
with open(lock_path, "w") as lock:
|
||||||
|
os.chmod(lock_path, 0o600) # defense-in-depth (empty flock handle, never holds token)
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
|
||||||
|
def _atomic_write(path, data):
|
||||||
|
"""tmp → fsync → chmod 0600 → atomic rename, in the store's directory."""
|
||||||
|
dirpath = os.path.dirname(path) or "."
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
f.flush(); os.fsync(f.fileno())
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, path) # atomic
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
|
||||||
def save_account(path, label, refresh_token, scopes, properties):
|
def save_account(path, label, refresh_token, scopes, properties):
|
||||||
dirpath = os.path.dirname(path) or "."
|
dirpath = os.path.dirname(path) or "."
|
||||||
os.makedirs(dirpath, mode=0o700, exist_ok=True)
|
os.makedirs(dirpath, mode=0o700, exist_ok=True)
|
||||||
os.chmod(dirpath, 0o700) # re-assert invariant (makedirs no-ops if dir exists)
|
os.chmod(dirpath, 0o700) # re-assert invariant (makedirs no-ops if dir exists)
|
||||||
lock_path = path + ".lock"
|
with _locked(path):
|
||||||
with open(lock_path, "w") as lock:
|
|
||||||
os.chmod(lock_path, 0o600) # defense-in-depth (empty flock handle, never holds token)
|
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX) # serialize concurrent connects
|
|
||||||
data = load(path)
|
data = load(path)
|
||||||
data.setdefault("version", 1)
|
data.setdefault("version", 1)
|
||||||
data.setdefault("accounts", {})
|
data.setdefault("accounts", {})
|
||||||
@@ -38,16 +59,28 @@ def save_account(path, label, refresh_token, scopes, properties):
|
|||||||
"granted_at": datetime.now(timezone.utc).isoformat(),
|
"granted_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"properties": properties,
|
"properties": properties,
|
||||||
}
|
}
|
||||||
fd, tmp = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
|
_atomic_write(path, data)
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
def remove_account(path, label):
|
||||||
json.dump(data, f, indent=2)
|
"""Drop one label from the store. Returns True if it existed."""
|
||||||
f.flush(); os.fsync(f.fileno())
|
if not os.path.exists(path):
|
||||||
os.chmod(tmp, 0o600)
|
return False
|
||||||
os.replace(tmp, path) # atomic
|
with _locked(path):
|
||||||
finally:
|
data = load(path)
|
||||||
if os.path.exists(tmp):
|
existed = data.get("accounts", {}).pop(label, None) is not None
|
||||||
os.unlink(tmp)
|
if existed:
|
||||||
|
_atomic_write(path, data)
|
||||||
|
return existed
|
||||||
|
|
||||||
|
def clear_accounts(path):
|
||||||
|
"""Empty the store (file and perms kept). Returns removed count."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return 0
|
||||||
|
with _locked(path):
|
||||||
|
data = load(path)
|
||||||
|
count = len(data.get("accounts", {}))
|
||||||
|
_atomic_write(path, {"version": 1, "accounts": {}})
|
||||||
|
return count
|
||||||
|
|
||||||
def _cli():
|
def _cli():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
@@ -58,10 +91,20 @@ def _cli():
|
|||||||
ps.add_argument(flag, required=True)
|
ps.add_argument(flag, required=True)
|
||||||
ps.add_argument("--scopes", default="")
|
ps.add_argument("--scopes", default="")
|
||||||
ps.add_argument("--properties", default="")
|
ps.add_argument("--properties", default="")
|
||||||
|
pr = sub.add_parser("remove")
|
||||||
|
for flag in ("--file", "--label"):
|
||||||
|
pr.add_argument(flag, required=True)
|
||||||
|
pc = sub.add_parser("clear"); pc.add_argument("--file", required=True)
|
||||||
try:
|
try:
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
if args.cmd == "list":
|
if args.cmd == "list":
|
||||||
print(json.dumps({"status": "ok", "accounts": list_accounts(args.file)}))
|
print(json.dumps({"status": "ok", "accounts": list_accounts(args.file)}))
|
||||||
|
elif args.cmd == "remove":
|
||||||
|
print(json.dumps({"status": "ok",
|
||||||
|
"removed": remove_account(args.file, args.label)}))
|
||||||
|
elif args.cmd == "clear":
|
||||||
|
print(json.dumps({"status": "ok",
|
||||||
|
"cleared": clear_accounts(args.file)}))
|
||||||
else:
|
else:
|
||||||
save_account(args.file, args.label, getattr(args, "refresh_token"),
|
save_account(args.file, args.label, getattr(args, "refresh_token"),
|
||||||
[s for s in args.scopes.split(",") if s],
|
[s for s in args.scopes.split(",") if s],
|
||||||
|
|||||||
+41
-2
@@ -36,6 +36,43 @@ entry point for any SEO/GEO work on a web project.
|
|||||||
Read `resources/depth-matrix.md` at the start of STEP 0 — it pre-answers
|
Read `resources/depth-matrix.md` at the start of STEP 0 — it pre-answers
|
||||||
several questions and keeps token cost down by removing repeated explanations.
|
several questions and keeps token cost down by removing repeated explanations.
|
||||||
|
|
||||||
|
## STEP -1 — Account management verbs (intercept BEFORE any audit)
|
||||||
|
|
||||||
|
If `$ARGUMENTS` starts with `connect`, `accounts`, or `forget`, run the
|
||||||
|
matching action below and STOP — no audit, no analyzer dispatch, no report.
|
||||||
|
Tilde paths mandatory (this skill runs from the audited project's directory,
|
||||||
|
not the claude-config repo). Anything else falls through to STEP 0 unchanged.
|
||||||
|
|
||||||
|
**Label safety rule (both verbs):** a label MUST match
|
||||||
|
`^[A-Za-z0-9][A-Za-z0-9._-]*$` — anything else (spaces, quotes, `;`, `$`,
|
||||||
|
backticks…), refuse it and ask for another name; the engine also rejects it
|
||||||
|
(exit 2). ALWAYS single-quote the label when composing the Bash call
|
||||||
|
(`--label 'client-a'`) — never paste it unquoted into a command line.
|
||||||
|
|
||||||
|
- **`connect [label]`** — connect a Google account (one-time OAuth consent):
|
||||||
|
1. No label given → ask for one (a client/site name, not an email).
|
||||||
|
2. Run in background: `bash ~/.claude/lib/seo-data/connect.sh --label <label>`
|
||||||
|
— the wrapper sources `~/.claude/.env` itself and works from any
|
||||||
|
directory (from the claude-config repo, `make seo-connect` also works
|
||||||
|
and builds the venv first; use it if the venv doesn't exist yet).
|
||||||
|
3. Read the background output for the authorization URL it prints and hand
|
||||||
|
that URL to the user — they consent in their browser; the localhost
|
||||||
|
callback completes the flow on its own.
|
||||||
|
4. On success, report the label + discovered Search Console properties.
|
||||||
|
On failure, surface the error verbatim (e.g. missing
|
||||||
|
`GOOGLE_OAUTH_CLIENT_ID/SECRET` in `~/.claude/.env`, 403 API disabled).
|
||||||
|
- **`accounts`** — list connected accounts:
|
||||||
|
`bash ~/.claude/lib/seo-data/fetch.sh accounts` → render one line per
|
||||||
|
label with its properties; `"accounts": []` → say none connected and
|
||||||
|
point at `/seo connect`.
|
||||||
|
- **`forget <label>`** / **`forget --all`** — remove one account / empty the
|
||||||
|
store: `bash ~/.claude/lib/seo-data/fetch.sh forget --label <label>` (or
|
||||||
|
`forget --all`). Confirm with the user BEFORE `--all`. ALWAYS append this
|
||||||
|
notice to the result: local removal deletes the stored refresh token but
|
||||||
|
does NOT revoke the grant at Google — for a real revocation, visit
|
||||||
|
https://myaccount.google.com/permissions (account concerned) and remove
|
||||||
|
the app's access there.
|
||||||
|
|
||||||
## STEP 0 — Collect shared context (ONCE)
|
## STEP 0 — Collect shared context (ONCE)
|
||||||
|
|
||||||
Before spawning any agent, collect the context both agents need.
|
Before spawning any agent, collect the context both agents need.
|
||||||
@@ -82,8 +119,10 @@ COMPTE GOOGLE pour cet audit FULL :
|
|||||||
1. <label> — <property 1>, <property 2>, ...
|
1. <label> — <property 1>, <property 2>, ...
|
||||||
2. <label> — <property>
|
2. <label> — <property>
|
||||||
...
|
...
|
||||||
[connecter un nouveau compte] — lancer `make seo-connect` (depuis le
|
[connecter un nouveau compte] — `/seo connect <label>` (ou
|
||||||
repo claude-config, une fois par compte), puis relancer /seo
|
`bash ~/.claude/lib/seo-data/connect.sh --label <label>` depuis
|
||||||
|
n'importe quel projet ; `make seo-connect` depuis le repo claude-config
|
||||||
|
construit aussi la venv), puis relancer /seo
|
||||||
[Ignorer] — continuer sans GSC/CrUX (PageSpeed anonyme uniquement,
|
[Ignorer] — continuer sans GSC/CrUX (PageSpeed anonyme uniquement,
|
||||||
dégradation normale — cf. SEO.md §11)
|
dégradation normale — cf. SEO.md §11)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user