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:
Bastien Chanot
2026-07-10 12:38:32 +02:00
parent 61a98d3ae1
commit 8bf7459566
7 changed files with 258 additions and 27 deletions
+2 -3
View File
@@ -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
View File
@@ -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
+45
View File
@@ -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
View File
@@ -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
+73 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)