SLOPSHOPPER

supabase-guard

Supabase guard: stops Supabase calls aimed at a project other than the current folder's, and asks in a one-key popup before destructive SQL, branch resets or…

newguardcommandtoast
v0.1.0no licenseupdated 2026-10-07Szotasz/claude-mods/plugins/supabase-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · supabase-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /supaor ⎿ supabase-guard: Supabase guard off: Supabase calls run unchecked. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Saját Claude Code mod-ok (function hook pluginek) egy pluginpiacon.

Telepítés

claude plugin marketplace add Szotasz/claude-mods
claude plugin install usage-band@claude-mods
claude plugin install usage-header@claude-mods
claude plugin install project-pane@claude-mods
claude plugin install focus-mode@claude-mods
claude plugin install tool-hub@claude-mods
claude plugin install snake-pane@claude-mods
claude plugin install supabase-guard@claude-mods
claude plugin install public-repo-guard@claude-mods
claude plugin install decision-popup@claude-mods

Frissítés: claude plugin update usage-band@claude-mods, majd /reload-plugins (vagy új session).

Pluginek

usage-band

A státuszsorba (a prompt alá) írja:

🤖 Opus 5.5  │  Ctx ███░░░░░ 42% 420k/1M  │  5h ███░░░░░ 34% → 16:20  │  Hét ███████░ 93% ⚠ → Cs 10:00
  • modell neve (/model váltáskor és turn végén frissül)
  • kontextusablak telítettsége
  • 5 órás és heti rate-limit, reset időponttal (csak Pro/Max előfizetéssel)
  • ⚠ 90% felett, és egy toast, amikor egy limit átlépi (ablakonként, resetenként egyszer)

Nyelv / Language: /config → usage-band.language, vagy a settings.json-ban:

"pluginConfigs": { "usage-band": { "language": "en" } }
  • auto (alapértelmezett): magyar, ha a rendszer nyelve (LANG) magyar, különben angol
  • hu: magyar (Hét, Cs 10:00)
  • en: English (Week, Thu 10:00)

Követelmény: Claude Code 2.1.289 körüli verzió. A mod-API early access, kiadásról kiadásra változhat.

usage-header

Sáv a prompt fölött: a kontextusablak kategóriánként színezve, alatta az 5 órás és a heti limit.

Kontextus  ██▓▓▓▓▓▓████████████▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ 42% 84k/200k
           ■ Rendszerprompt 3k  ■ Eszközök 12k  ■ Memória 400  ■ Üzenetek 69k  ▒ Tömörítési tartalék 33k  ░ Szabad 83k
Limitek    5 óra ███████░░░░░░░░░░░░░ 34% → 16:20   Hét ███████████████████░ 93% ⚠ → Cs 10:00
  • A kontextussáv minden kategóriája a /context saját színével látszik (rendszerprompt, eszközök, MCP, memóriafájlok, skillek, ágensek, üzenetek), utána a tömörítési tartalék és a szabad hely halványan; alatta jelmagyarázat tokenszámmal. Az igény szerint betöltött (halasztott) eszközsémák kimaradnak, mert nincsenek az ablakban.
  • A bontás helyi becslés ($.session.usage({ breakdown: 'summary' })), API-hívás nélkül; minden turn végén és limitmozduláskor frissül.
  • Limitek: zöld 70% alatt, sárga 70–90%, piros 90% fölött (⚠), reset időponttal. Csak Pro/Max előfizetéssel van adat.
  • /hasznalat kapcsolja, /hasznalat be|ki beállítja; az állapotot sessionök között megőrzi. Az eszköztárban (/eszkozok) is kapcsolható.
  • A sávot megosztja a fókusz mód listájával és az eszköztárral (azok alatt jelenik meg).

Beállítás (/config): usage-header.language (auto/hu/en).

project-pane

Oldalpanel az aktuális git repóhoz. Magától megnyílik, ha a session git repóban indul és a terminál legalább 144 oszlop széles (miután egyszer /project-tel megnyitottad, 110 is elég); keskenyebb ablaknál egy toast szól, hogy vár. Bármikor: /project.

acme/shop                          ↻ most [ Frissítés ]
GIT
🌿 develop  ↑0 ↓0  · 7 módosított fájl
utolsó commit: 57704fe · 7 hónapja — Fix checkout flow
⚠ a main 13 committal előrébb jár, te a develop branch-en vagy
Előbb commitold a módosításokat, vagy tedd félre őket:
[ Stash + szinkron ]
DEPLOY (Netlify)
✔ ready  main  3 perce
PULL REQUESTEK
✔ #142 Stripe webhook retry  · approved
CI (GitHub Actions)
✘ netlify-deploy-verify  main · 5 napja      [ Kérdezd Claude-ot ]
SUPABASE
abcdefghijklmnopqrst · Acme Shop · eu-west-3
18 migráció · utolsó: 018_add_orders_rls.sql
GitHub  Netlify  Supabase
  • Szinkron gomb (s): csak fast-forward, soha nem merge-öl, rebase-el vagy töröl.
  • a branch lemaradt az upstreamtől → git pull --ff-only
  • másik branchen vagy, és az alapértelmezett (main) előrébb jár → git switch main + git merge --ff-only origin/main
  • commitolatlan módosítás esetén csak „Stash + szinkron” van: git stash push -u, vissza: git stash pop
  • ha elakad, a hibát Claude-nak szóló kérdésként a promptba teszi
  • Frissítés (r): 60 mp-enként és minden turn végén; git fetch 5 percenként.
  • Kérdezd Claude-ot (a): a legutóbbi elbukott CI-futásról kérdést tesz a promptba.
  • Toast, ha a session alatt elbukik egy Netlify deploy vagy CI-futás.

Adatforrások (a CLI-k saját bejelentkezésével, a plugin tokent nem olvas):

SzekcióForrásHa hiányzik
Gitgit—
PR, CIgh (gh auth login)tipp a panelen
Deploynetlify CLI + .netlify/state.jsonnpm i -g netlify-cli && netlify login
Supabasesupabase/.temp/project-ref, supabase/migrations, supabase projects listsupabase login az állapothoz

Beállítás (/config): project-pane.language (auto/hu/en), project-pane.autoOpen (alapból be).

focus-mode

Fókusz mód: a transcriptből eltűnnek az eszközhívások és eredményeik, a köztes szövegek, a saját promptjaid és a parancsok kimenete. Csak két dolog marad: a prompt fölötti sávban a részfeladatok listája állapotsávval és pipával, alatta a turn végső válasza.

✔ Meglévő mod-repo áttekintése
◐ Játék-mod megírása  ██████░░░░ 60%
○ Felvétel az eszközök közé
  • /fokusz kapcsolja, /fokusz be / /fokusz ki beállítja; az állapotot sessionök között megőrzi.
  • A listát a modell tölti a mod által regisztrált mcp__focus-mode__plan eszközzel; a rendszerprompt bekapcsolt állapotban erre utasítja (minden több lépéses kérésnél előbb a részfeladatok, aztán frissítés indításkor, haladáskor és befejezéskor).
  • Kikapcsolva minden a megszokott módon látszik.

Beállítás (/config): focus-mode.language (auto/hu/en).

tool-hub

Eszköztár: egy helyről kapcsolhatók a fenti modok. A prompt alatti lábléc jobb oldalán lévő ⚙ Eszközök gomb (vagy /eszkozok) a prompt fölötti sávban nyitja meg:

⚙ Szabolcs eszközei  kattintás, vagy ctrl+x tab után szám  [ Bezár ]
1. Fókusz mód     ● BE  [ Kikapcsol ] · Csak a részfeladatok listája és a végső válasz látszik
2. Limitsáv       ○ KI  [ Bekapcsol ] · Alsó sáv: 5 órás és heti limit, kontextusablak
3. Használati sáv ● BE  [ Kikapcsol ] · Prompt fölött: kontextus kategóriánként színezve, 5 órás és heti limit
4. Projektpanel   ● BE  [ Kikapcsol ] · GitHub, CI, Netlify és Supabase állapot oldalt
5. Kígyó játék    ● BE  [ Kikapcsol ] · Oldalt nyílik promptküldéskor, amíg Claude dolgozik
  • Modonként BE/KI állapot és kapcsoló gomb, ctrl+x tab után az 1–5 számbillentyűvel is.
  • Ami nincs telepítve, „nincs betöltve” felirattal jelenik meg.
  • Bekapcsolt fókusz módnál a lábléc is kiírja: „Fókusz mód”.
  • Parancsból is ugyanez: /fokusz, /limitsav, /hasznalat, /project, /jatek (mind be|ki argumentummal).
  • A sávot megosztja a fókusz mód listájával (a kapcsolók alatta jelennek meg).

Új mod felvétele az eszköztárba: a mod írja a saját isOn állapotát, adjon /<parancs> be|ki-t, és hookolja a state.set-et { plugin: 'tool-hub', key: 'request' }-re; a hubban egy sor a TOOLS listába, egy ág a stateOf-ba és a kulcs a types/index.d.ts-be.

Beállítás (/config): tool-hub.language (auto/hu/en).

snake-pane

Kígyó játék az oldalpanelben, amíg Claude dolgozik.

⏳ Claude dolgozik…
Pont: 4  Rekord: 17
╭──────────────────────────────────────╮
│                                      │
│          ████████                    │
│                ██      ●             │
╰──────────────────────────────────────╯
p: szünet · r: újra
w: ↑ a: ← s: ↓ d: → p: szünet r: új
  • Promptküldéskor magától megnyílik (ha ebben a sessionben bezártad, már nem; /jatek mindig nyitja). /jatek ki kikapcsolja, az eszköztárban (/eszkozok) is kapcsolható.
  • Irányítás: kattints a táblára, utána nyilak / wasd / hjkl, szóköz vagy p szünet, r új játék. Vagy ctrl+x tab a panelre, és a w a s d p r gombok.
  • A turn végén a futó játék szünetel („Claude végzett”), hogy visszatérj a munkához.
  • A pontszámmal gyorsul; a rekordot sessionök között megőrzi.
  • A játék a rajzoló szálon fut (Client surface modul), nem terheli a sessiont.

supabase-guard

Supabase-őr: megállítja a Supabase-hívást, ha nem az aktuális mappa projektjére menne, és egygombos felugróban rákérdez.

  • A mappa projektjét a supabase/.temp/project-ref, a .env* fájlok https://<ref>.supabase.co címei és a CLAUDE.md (ha egyetlen projekt-ID-t említ) adják, plusz amit a felugróban „Ez a mappa projektje” gombbal megerősítettél.
  • Más projektre menő írás (MCP execute_sql, apply_migration, deploy_edge_function, branch-műveletek, illetve supabase db push --project-ref …): kérdez — Megtiltom / Engedélyezem / Ez a mappa projektje.
  • Romboló művelet a saját projekten is kérdez: DROP, TRUNCATE, DELETE FROM, WHERE nélküli UPDATE, REVOKE, RLS kikapcsolása, reset_branch, delete_branch, pause_project, supabase db reset --linked.
  • Ha a mappához nem talál projektet, írás előtt kérdez (van „Engedem a session végéig” gomb is).
  • Más projekt olvasása fut, de a modell kap egy figyelmeztetést, hogy ne a rossz projektről vonjon le következtetést.
  • Hiba esetén zárva marad: ha az ellenőrzés elhasal, a Supabase-hívás nem fut le. /supaor be|ki, vagy az eszköztárban.

public-repo-guard

Push-őr: nyilvános GitHub-repóba push előtt (és gh repo edit --visibility public / gh repo create --public előtt) átnézi, mi menne ki.

  • A távoli ágon még nem lévő commitokat nézi (üzenet + hozzáadott sorok, a history is); ha nincs mihez mérni, az egész fát. Kezeli a git push origin forrás:cél alakot is.
  • Keres: Supabase projekt-ID (URL, project_id, táblázatsor), JWT / Supabase-kulcs, Stripe/Anthropic/OpenAI/GitHub/AWS/Google/Slack/Resend/Telegram/ElevenLabs kulcsok, privát kulcs, e-mail-cím (a saját git e-mail és a noreply/példa címek kivételével), helyi /Users/<te>/ útvonal, privát repóid nevei (gh repo list --visibility private), .env fájlok, és amit /pushor figyel <szöveg>-gel felvettél.
  • Találatnál felugró, maszkolt listával — Megtiltom / Pushold így is / Pushold, jegyezd meg (az utóbbi kivételként megjegyzi a találatokat). Tiltáskor a modell megkapja a teljes listát a javításhoz.
  • Privát repóba menő pushnál nem csinál semmit. Kell hozzá a gh CLI (bejelentkezve). /pushor be|ki.

decision-popup

Döntési felugró: a döntések egygombos kérdésként jönnek.

  • A rendszerpromptban arra kéri Claude-ot, hogy minden döntést (engedélyezés, A vagy B, visszafordíthatatlan vagy kifelé ható lépés) az AskUserQuestion dialógussal kérdezzen meg, 2–4 rövid opcióval, a javasoltat vagy a biztonságosabbat elöl — egy számgombbal válaszolsz.
  • A dialógus keretet és címet kap („Döntés kell”); az őr-modok (supabase-guard, public-repo-guard) kérdése piros keretben, „Az őr megállított egy műveletet” címmel.
  • /dontes be|ki, vagy az eszköztárban.

Ötletek

project-pane bővítése:

  • Vercel deploy-ok (vercel ls)
  • remote Supabase migrációk összevetése a lokálissal
  • több repó egy panelen

Státuszsor bővítése (usage-band):

  • figyelmeztetés kontextusablakra (pl. 85%), opcionálisan automatikus /compact
  • session költsége ($.session.usage().cost)
  • fast mód jelzése, git branch, aktuális projekt
  • beállítható küszöb és megjelenő elemek (userConfig)

Új mod-ok:

  • env-guard: .env, kulcsfájlok, supabase/.temp szerkesztésének tiltása (tool.call → deny)
  • turn-timer: hosszú turn végén hang/toast, hogy vissza lehet nézni (turn.complete, $.audio.play)
  • quote: /quote — a kijelölt szöveget idézetként a promptba teszi ($.ui.selection)
  • hu-prompt: a rendszerpromptba magyar válasz- és stílusszabály (prompt.compose)

Fejlesztés

Egy plugin mappája közvetlenül is betölthető:

claude --plugin-dir ./plugins/usage-band

Ellenőrzés:

claude plugin validate plugins/usage-band
claude plugin test plugins/usage-band
Source 3 files
hooks/register.tsx 243 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { LOOK_IN, actionRisk, isRef, refsIn, short, supabaseAction, supabaseCli } from './classify'
5import type { Risk } from './classify'
6
7const isOn = atom({ plugin: 'supabase-guard', key: 'isOn' } as const, true)
8
9type Lang = 'hu' | 'en'
10
11const STRINGS = {
12  hu: {
13    header: 'Supabase-őr',
14    command: 'Supabase-őr be/ki: rossz projektre menő és romboló Supabase-hívások megállítása',
15    on: 'Supabase-őr bekapcsolva.',
16    off: 'Supabase-őr kikapcsolva: a Supabase-hívások ellenőrzés nélkül futnak.',
17    usage: 'Használat: /supaor [be|ki]',
18    deny: 'Megtiltom',
19    once: 'Engedélyezem',
20    session: 'Engedem a session végéig',
21    bind: 'Ez a mappa projektje',
22    mismatch: (what: string, target: string, folder: string, own: string) =>
23      `${what} a(z) ${target} projekten — de ez a mappa (${folder}) a(z) ${own} projekthez tartozik. Engeded?`,
24    unknown: (what: string, target: string, folder: string) =>
25      `${what} a(z) ${target} projekten. Ehhez a mappához (${folder}) nem találtam Supabase-projektet. Engeded?`,
26    destructive: (what: string, target: string) => `Romboló művelet a(z) ${target} projekten: ${what}. Engeded?`,
27    readNote: (target: string, own: string) =>
28      `Figyelem (supabase-guard): ez a hívás a(z) ${target} projektet olvasta, de az aktuális mappa projektje ${own}. Ellenőrizd, hogy a jó projektről vonsz-e le következtetést.`,
29    denied: (what: string) =>
30      `A supabase-guard megállította (${what}): a felhasználó nem engedélyezte. Ne próbáld meg más úton; kérdezd meg, mit szeretne, vagy használd a mappához tartozó projektet.`,
31    dismissed: (what: string) => `A supabase-guard megállította (${what}): a felhasználó bezárta a kérdést. Ne futtasd újra kérdés nélkül.`,
32    other: (answer: string) => `A supabase-guard megállította; a felhasználó válasza: „${answer}”`,
33    bound: (folder: string, ref: string) => `Megjegyeztem: a(z) ${folder} mappa projektje ${short(ref)}.`,
34  },
35  en: {
36    header: 'Supabase',
37    command: 'Supabase guard on/off: stop Supabase calls to the wrong project and destructive ones',
38    on: 'Supabase guard on.',
39    off: 'Supabase guard off: Supabase calls run unchecked.',
40    usage: 'Usage: /supaor [on|off]',
41    deny: 'Deny',
42    once: 'Allow once',
43    session: 'Allow for this session',
44    bind: "It is this folder's project",
45    mismatch: (what: string, target: string, folder: string, own: string) =>
46      `${what} on project ${target}, but this folder (${folder}) belongs to ${own}. Allow it?`,
47    unknown: (what: string, target: string, folder: string) =>
48      `${what} on project ${target}. I found no Supabase project for this folder (${folder}). Allow it?`,
49    destructive: (what: string, target: string) => `Destructive action on project ${target}: ${what}. Allow it?`,
50    readNote: (target: string, own: string) =>
51      `Note (supabase-guard): this call read project ${target}, but the current folder's project is ${own}. Make sure you draw conclusions about the right project.`,
52    denied: (what: string) =>
53      `Stopped by supabase-guard (${what}): the user did not allow it. Do not try another route; ask them what they want, or use this folder's project.`,
54    dismissed: (what: string) => `Stopped by supabase-guard (${what}): the user closed the question. Do not run it again without asking.`,
55    other: (answer: string) => `Stopped by supabase-guard; the user answered: "${answer}"`,
56    bound: (folder: string, ref: string) => `Noted: the project of ${folder} is ${short(ref)}.`,
57  },
58}
59
60let lang: Lang = 'en'
61const t = () => STRINGS[lang]
62
63async function pickLanguage($: EngineInterface, setting: unknown): Promise<Lang> {
64  if (setting === 'hu' || setting === 'en') return setting
65  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || ''
66  return locale.toLowerCase().startsWith('hu') ? 'hu' : 'en'
67}
68
69const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() || path
70
71// Session szintű engedélyek (`ref|kockázat`): újratöltéskor elfelejtődnek, ez szándékos.
72const allowed = new Set<string>()
73// Mappánkénti gyorsítótár, hogy ne olvassuk minden hívásnál a fájlokat.
74const cache = new Map<string, { at: number; refs: string[] }>()
75const CACHE_MS = 60_000
76
77type Bindings = Record<string, string[]>
78type Names = Record<string, string>
79
80async function storeObject<T extends object>($: EngineInterface, key: string): Promise<T> {
81  const value = await $.store.get(key)
82  return (typeof value === 'object' && value !== null ? value : {}) as T
83}
84
85// A mappa projektje: a supabase CLI link, a .env fájlok URL-jei, a CLAUDE.md (ha egyetlen ref-et említ), és amit a felhasználó megerősített.
86async function expected($: EngineInterface): Promise<{ root: string; refs: string[] }> {
87  const cwd = await $.session.cwd()
88  const root = (await $.session.repo())?.root ?? cwd
89  const hit = cache.get(root)
90  if (hit !== undefined && Date.now() - hit.at < CACHE_MS) return { root, refs: hit.refs }
91
92  const refs = new Set<string>((await storeObject<Bindings>($, 'bindings'))[root] ?? [])
93  for (const dir of new Set([root, cwd])) {
94    for (const file of LOOK_IN) {
95      const path = `${dir}/${file}`
96      if (!(await $.fs.exists(path))) continue
97      const found = refsIn(await $.fs.read(path))
98      if (file === 'CLAUDE.md' && found.length !== 1) continue
99      for (const ref of found) refs.add(ref)
100    }
101  }
102  const list = [...refs]
103  cache.set(root, { at: Date.now(), refs: list })
104
105  // Megjegyezzük, melyik ref melyik mappáé, hogy máshol is néven nevezhessük.
106  if (list.length > 0) {
107    const names = await storeObject<Names>($, 'names')
108    const folder = basename(root)
109    if (list.some(ref => names[ref] !== folder)) {
110      await $.store.set('names', { ...names, ...Object.fromEntries(list.map(ref => [ref, folder])) })
111    }
112  }
113  return { root, refs: list }
114}
115
116async function label($: EngineInterface, ref: string): Promise<string> {
117  const name = (await storeObject<Names>($, 'names'))[ref]
118  return name === undefined ? ref : `${name} (${short(ref)})`
119}
120
121type Verdict = { allow: true; note?: string } | { allow: false; reason: string }
122
123async function judge($: EngineInterface, ref: string | null, risk: Risk, what: string): Promise<Verdict> {
124  if (!(await read($, isOn))) return { allow: true }
125  const { root, refs } = await expected($)
126  const folder = basename(root)
127  const known = refs.length > 0
128  const mismatch = known && ref !== null && !refs.includes(ref)
129
130  if (risk === 'read') {
131    if (!mismatch || ref === null) return { allow: true }
132    const own = (await Promise.all(refs.map(r => label($, r)))).join(', ')
133    return { allow: true, note: t().readNote(await label($, ref), own) }
134  }
135
136  const key = `${ref ?? '?'}|${risk}`
137  if (allowed.has(key)) return { allow: true }
138  const target = ref === null ? '?' : await label($, ref)
139
140  let question: string
141  let options: string[]
142  if (mismatch) {
143    const own = (await Promise.all(refs.map(r => label($, r)))).join(', ')
144    question = t().mismatch(what, target, folder, own)
145    options = risk === 'destructive' ? [t().deny, t().once] : [t().deny, t().once, t().bind]
146  } else if (risk === 'destructive') {
147    question = t().destructive(what, target)
148    options = [t().deny, t().once]
149  } else if (!known && ref !== null) {
150    question = t().unknown(what, target, folder)
151    options = [t().deny, t().once, t().session, t().bind]
152  } else {
153    return { allow: true }
154  }
155
156  let answer: string
157  try {
158    answer = await $.ui.ask(question, { header: t().header, options })
159  } catch {
160    return { allow: false, reason: t().dismissed(what) }
161  }
162  if (answer === t().once) return { allow: true }
163  if (answer === t().session) {
164    allowed.add(key)
165    return { allow: true }
166  }
167  if (answer === t().bind && ref !== null) {
168    const bindings = await storeObject<Bindings>($, 'bindings')
169    await $.store.set('bindings', { ...bindings, [root]: [...new Set([...(bindings[root] ?? []), ref])] })
170    cache.delete(root)
171    $.ui.toast(t().bound(folder, ref))
172    return { allow: true }
173  }
174  if (answer === t().deny) return { allow: false, reason: t().denied(what) }
175  return { allow: false, reason: t().other(answer) }
176}
177
178function describe(action: string, input: Record<string, unknown>): string {
179  const sql = typeof input.query === 'string' ? input.query.replace(/\s+/g, ' ').trim() : ''
180  const name = typeof input.name === 'string' ? ` ${input.name}` : ''
181  if (sql === '') return `${action}${name}`
182  return `${action}${name}: ${sql.length > 120 ? `${sql.slice(0, 119)}…` : sql}`
183}
184
185async function setOn($: EngineInterface, value: boolean): Promise<void> {
186  await update($, isOn, () => value)
187  await $.store.set('isOn', value)
188}
189
190export const register: Register = (on, options) => {
191  if (options.language === 'hu' || options.language === 'en') lang = options.language
192
193  on('session.start', async ($, e, next) => {
194    lang = await pickLanguage($, options.language)
195    const saved = await $.store.get('isOn')
196    await update($, isOn, () => saved !== false)
197    await $.command.register({
198      name: 'supaor',
199      description: t().command,
200      argumentHint: lang === 'hu' ? '[be|ki]' : '[on|off]',
201      immediate: true,
202    })
203    return next(e)
204  })
205
206  on('command.run', { command: 'supaor' }, async ($, e) => {
207    const arg = e.args.trim().toLowerCase()
208    const wanted = ['be', 'on'].includes(arg) ? true : ['ki', 'off'].includes(arg) ? false : arg === '' ? !(await read($, isOn)) : null
209    if (wanted === null) return { text: t().usage }
210    await setOn($, wanted)
211    return { text: wanted ? t().on : t().off }
212  })
213
214  on('state.set', { plugin: 'tool-hub', key: 'request' }, async ($, e, next) => {
215    const done = await next(e)
216    if (e.value?.tool === 'supabase') await setOn($, e.value.on)
217    return done
218  })
219
220  // Supabase MCP eszközök (bármelyik szerveren, aminek a nevében szerepel a „supabase”).
221  on('tool.call', async ($, e, next) => {
222    const action = supabaseAction(e.tool)
223    if (action === null) return next(e)
224    const input = e as unknown as Record<string, unknown>
225    const ref = isRef(input.project_id) ? input.project_id : null
226    const what = describe(action, input)
227    const verdict = await judge($, ref, actionRisk(action, input), what)
228    if (!verdict.allow) return { deny: verdict.reason }
229    const result = await next(e)
230    if (verdict.note === undefined || result.deny !== undefined) return result
231    return { ...result, context: [...(result.context ?? []), verdict.note] }
232  }).catch(($, e, next) => (next.called || supabaseAction(e.tool) === null ? next(e) : { deny: 'supabase-guard: the check failed, so the call was stopped.' }))
233
234  // A supabase CLI a Bash-ban.
235  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
236    const cli = supabaseCli(e.command)
237    if (cli === null || cli.risk === 'read') return next(e)
238    const verdict = await judge($, cli.ref, cli.risk, `supabase ${cli.what}`)
239    if (!verdict.allow) return { deny: verdict.reason }
240    return next(e)
241  }).catch(($, e, next) => (next.called || supabaseCli(e.command) === null ? next(e) : { deny: 'supabase-guard: the check failed, so the command was stopped.' }))
242}
243
hooks/classify.ts 99 lines
1// Tiszta függvények: melyik Supabase-hívás milyen veszélyes, és melyik projekthez tartozik a mappa.
2
3export type Risk = 'read' | 'write' | 'destructive'
4
5// Projekt-ref: a Supabase 20 kisbetűs azonosítója.
6const REF = /^[a-z]{20}$/
7
8export const isRef = (value: unknown): value is string => typeof value === 'string' && REF.test(value)
9
10// mcp__supabase__execute_sql, mcp__claude_ai_Supabase__execute_sql, … → execute_sql
11export function supabaseAction(tool: string): string | null {
12  const m = /^mcp__([^_].*?)__(.+)$/.exec(tool)
13  if (m === null || !/supabase/i.test(m[1] ?? '')) return null
14  return m[2] ?? null
15}
16
17const READ_ACTIONS = /^(list_|get_|search_docs$|generate_typescript_types$|query_logs$|confirm_cost$)/
18// Ezek a megegyező projekten is mindig kérdeznek.
19const DESTRUCTIVE_ACTIONS = new Set(['delete_branch', 'reset_branch', 'pause_project', 'restore_project'])
20
21// DROP, TRUNCATE, DELETE, ALTER … DROP, jogosultság-visszavonás, WHERE nélküli UPDATE.
22const DESTRUCTIVE_SQL = [
23  /\bdrop\s+(table|schema|database|function|view|materialized|type|trigger|policy|index|extension|role|publication)\b/i,
24  /\btruncate\b/i,
25  /\bdelete\s+from\b/i,
26  /\balter\s+table\b[^;]*\bdrop\b/i,
27  /\brevoke\b/i,
28  /\bdisable\s+row\s+level\s+security\b/i,
29]
30const READ_SQL = /^\s*(select|with|explain|show|values|table)\b/i
31
32function stripSqlComments(sql: string): string {
33  return sql.replace(/--[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '')
34}
35
36function updateWithoutWhere(sql: string): boolean {
37  return stripSqlComments(sql)
38    .split(';')
39    .some(stmt => /^\s*update\b/i.test(stmt) && !/\bwhere\b/i.test(stmt))
40}
41
42export function sqlRisk(sql: string): Risk {
43  const clean = stripSqlComments(sql)
44  if (DESTRUCTIVE_SQL.some(re => re.test(clean)) || updateWithoutWhere(clean)) return 'destructive'
45  const statements = clean.split(';').filter(s => s.trim() !== '')
46  if (statements.length > 0 && statements.every(s => READ_SQL.test(s) && !/\b(insert|update|delete)\b/i.test(s))) return 'read'
47  return 'write'
48}
49
50export function actionRisk(action: string, input: Record<string, unknown>): Risk {
51  if (action === 'execute_sql') return sqlRisk(typeof input.query === 'string' ? input.query : '')
52  if (DESTRUCTIVE_ACTIONS.has(action)) return 'destructive'
53  if (action === 'apply_migration' && typeof input.query === 'string' && sqlRisk(input.query) === 'destructive') return 'destructive'
54  if (READ_ACTIONS.test(action)) return 'read'
55  return 'write'
56}
57
58// A Bash-ban futó supabase CLI: --project-ref és a veszélyes alparancsok.
59export type CliCall = { ref: string | null; risk: Risk; what: string }
60
61export function supabaseCli(command: string): CliCall | null {
62  if (!/(^|[\s;&|(])(npx\s+|bunx\s+)?supabase\s/.test(command)) return null
63  const ref = /--project-ref[=\s]+["']?([a-z]{20})\b/.exec(command)?.[1] ?? null
64  const what = /supabase\s+((?:[a-z][a-z-]*\s*){1,2})/.exec(command)?.[1]?.trim() ?? 'supabase'
65  let risk: Risk = 'read'
66  if (/\bdb\s+reset\b[^;&|]*--linked|\bprojects\s+delete\b|\bbranches\s+delete\b|\bmigration\s+repair\b/.test(command)) risk = 'destructive'
67  else if (/\bdb\s+push\b|\bfunctions\s+deploy\b|\bsecrets\s+(set|unset)\b|\bfunctions\s+delete\b|\bconfig\s+push\b/.test(command)) risk = 'write'
68  return { ref, risk, what }
69}
70
71// A mappa fájljaiból kiolvasható projekt-ref-ek.
72export const LOOK_IN = [
73  'supabase/.temp/project-ref',
74  '.env',
75  '.env.local',
76  '.env.development',
77  '.env.development.local',
78  '.env.production',
79  'frontend/.env',
80  'frontend/.env.local',
81  'web/.env.local',
82  'app/.env.local',
83  'CLAUDE.md',
84]
85
86export function refsIn(text: string): string[] {
87  const found = new Set<string>()
88  const trimmed = text.trim()
89  if (isRef(trimmed)) found.add(trimmed)
90  for (const m of text.matchAll(/https?:\/\/([a-z]{20})\.supabase\.(co|in)\b/g)) found.add(m[1] ?? '')
91  for (const m of text.matchAll(/project[ _-]?(?:id|ref)\W{1,6}([a-z]{20})\b/gi)) found.add(m[1] ?? '')
92  for (const m of text.matchAll(/SUPABASE_(?:PROJECT_)?(?:ID|REF)\s*=\s*["']?([a-z]{20})\b/g)) found.add(m[1] ?? '')
93  return [...found].filter(isRef)
94}
95
96export function short(ref: string): string {
97  return `${ref.slice(0, 6)}…`
98}
99
types/index.d.ts 10 lines
1// Az eszköztár (tool-hub) kérése: a kapcsoló gomb ezt írja, a mod a state.set hookjában veszi át.
2export type HubRequest = { tool: string; on: boolean; n: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'tool-hub': { request: HubRequest | null }
7    'supabase-guard': { isOn: boolean }
8  }
9}
10