SLOPSHOPPER

project-pane

Side pane for the current repo: git state with a one-press GitHub sync, Netlify deploys, PRs and CI, Supabase project. Hungarian or English.

newpanecommandtoastprocesstimer
v0.3.0no licenseupdated 2026-10-07Szotasz/claude-mods/plugins/project-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · project-pane
│ ┃ Project: app ✕ › fix the failing auth test and add an audit log call │ ┃ acme/app ↻ just now [ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ GIT ⎿ Read 6 lines │ ┃ 🌿 ? · 2 changed files ⏺ Update(src/auth.ts) │ ┃ last commit: a1b2c3d fix: refresh expired t… ⎿ Added 2 lines, removed 1 line │ ┃ fetched just now ⏺ Bash(bun test) │ ┃ ✔ up to date with GitHub ⎿ 3 pass, 1 fail │ ┃ │ ┃ PULL REQUESTS ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Needs gh: brew install gh && gh auth login │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ GitHub https://github.com/acme/app │ › /project │ ⎿ project-pane: Project pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Project: app
acme/app ↻ just now [ Refresh ] GIT 🌿 ? · 2 changed files last commit: a1b2c3d fix: refresh expired tokens (2 minutes… fetched just now ✔ up to date with GitHub PULL REQUESTS Needs gh: brew install gh && gh auth login GitHub https://github.com/acme/app
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 4 files
hooks/register.tsx 419 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ProjectGit, ProjectNetlify, ProjectSnapshot, ProjectSupabase } from '../types'
5import { STRINGS, languageFor } from './i18n'
6import type { Strings } from './i18n'
7import { githubSlug, json, mapDeploys, mapPrs, mapRuns, parseStatus, planSync } from './parse'
8import type { SyncPlan } from './parse'
9
10const PANE = 'project'
11const REFRESH_MS = 60_000
12const FETCH_MS = 5 * 60_000
13
14const snap = atom({ plugin: 'project-pane', key: 'snap' } as const, null)
15const busy = atom({ plugin: 'project-pane', key: 'busy' } as const, null)
16// Kikapcsolva nincs panel és nincs háttérfrissítés; az eszköztár (tool-hub) is ezt olvassa.
17const isOn = atom({ plugin: 'project-pane', key: 'isOn' } as const, true)
18
19// A modul minden beállításváltáskor és szerkesztéskor újratöltődik: ezek onnan indulnak újra.
20let t: Strings = STRINGS.en
21let root: string | undefined
22let lastFetch: number | undefined
23let startedAt = 0
24let isRefreshing = false
25const notified = new Set<string>()
26
27// Háttérben indított munka: egy hibája ne legyen kezeletlen (pl. a modul újratöltése közben).
28const background = (work: Promise<unknown>) => void work.catch(() => undefined)
29
30const STATE_COLOR: Record<string, string> = {
31  ready: 'green', success: 'green', error: 'red', failure: 'red',
32  building: 'yellow', enqueued: 'yellow', processing: 'yellow', in_progress: 'yellow', queued: 'yellow',
33}
34const color = (state?: string) => STATE_COLOR[state ?? ''] ?? 'gray'
35const mark = (state?: string) =>
36  ({ green: '✔', red: '✘', yellow: '…', gray: '·' } as Record<string, string>)[color(state)] ?? '·'
37
38type Run = { ok: boolean; out: string; err: string }
39
40// Egy parancs, ami nem indul el (nincs telepítve), ugyanúgy "nem sikerült", mint a hibakód.
41async function run($: EngineInterface, argv: string[], cwd: string, timeoutMs = 30_000): Promise<Run> {
42  try {
43    const r = await $.process.run(argv, { cwd, timeoutMs })
44    return { ok: r.exitCode === 0, out: r.stdout.trim(), err: r.stderr.trim() }
45  } catch (error) {
46    return { ok: false, out: '', err: String(error) }
47  }
48}
49
50async function repoRoot($: EngineInterface): Promise<string | undefined> {
51  const r = await run($, ['git', 'rev-parse', '--show-toplevel'], await $.session.cwd())
52  return r.ok && r.out !== '' ? r.out : undefined
53}
54
55async function collectGit($: EngineInterface, dir: string, fetchedAt?: number): Promise<ProjectGit> {
56  const status = parseStatus((await run($, ['git', 'status', '--porcelain=2', '--branch'], dir)).out)
57
58  const head = await run($, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], dir)
59  let defaultBranch = head.ok ? head.out.replace(/^origin\//, '') : 'main'
60  if (!head.ok && (await run($, ['git', 'rev-parse', '--verify', '--quiet', 'origin/master'], dir)).ok) {
61    defaultBranch = 'master'
62  }
63
64  const vs = await run($, ['git', 'rev-list', '--left-right', '--count', `HEAD...origin/${defaultBranch}`], dir)
65  const [aheadOfDefault = 0, behindDefault = 0] = vs.ok ? vs.out.split(/\s+/).map(Number) : []
66
67  const log = await run($, ['git', 'log', '-1', '--format=%h%x09%ct%x09%s'], dir)
68  const [hash, at, ...subject] = log.out.split('\t')
69  const lastCommit = log.ok && hash ? { hash, at: Number(at) * 1000, subject: subject.join('\t') } : undefined
70
71  return { ...status, defaultBranch, behindDefault, aheadOfDefault, lastCommit, fetchedAt }
72}
73
74async function collectNetlify($: EngineInterface, dir: string): Promise<ProjectNetlify | undefined> {
75  const siteId = json<{ siteId?: string }>(await $.fs.read(`${dir}/.netlify/state.json`).catch(() => ''))?.siteId
76  if (siteId === undefined) {
77    // netlify.toml van, de nincs összekötve: így is jelezzük, hogy Netlify-os a projekt.
78    return (await $.fs.exists(`${dir}/netlify.toml`)) ? { siteId: '', deploys: [], note: 'no-cli' } : undefined
79  }
80
81  const deploys = await run($, ['netlify', 'api', 'listSiteDeploys', '--data', JSON.stringify({ site_id: siteId, per_page: 3 })], dir)
82  if (!deploys.ok) {
83    const missing = /ENOENT|not found|No such file|cannot start|spawn/i.test(deploys.err)
84    return { siteId, deploys: [], note: missing ? 'no-cli' : 'error' }
85  }
86  const site = json<{ name?: string; admin_url?: string }>(
87    (await run($, ['netlify', 'api', 'getSite', '--data', JSON.stringify({ site_id: siteId })], dir)).out,
88  )
89  return { siteId, name: site?.name, adminUrl: site?.admin_url, deploys: mapDeploys(deploys.out) }
90}
91
92async function collectSupabase($: EngineInterface, dir: string): Promise<ProjectSupabase | undefined> {
93  const ref = (await $.fs.read(`${dir}/supabase/.temp/project-ref`).catch(() => '')).trim()
94  if (ref === '') return undefined
95
96  const files = (await $.fs.list(`${dir}/supabase/migrations`).catch(() => []))
97    .filter(f => f.kind === 'file' && f.name.endsWith('.sql'))
98    .map(f => f.name)
99    .sort()
100  const base: ProjectSupabase = { ref, migrations: files.length, latest: files.at(-1) }
101
102  const r = await run($, ['supabase', 'projects', 'list', '-o', 'json'], dir)
103  if (!r.ok) return { ...base, note: 'not-logged-in' }
104  const project = json<{ id: string; name: string; status: string; region: string }[]>(r.out)?.find(p => p.id === ref)
105  return { ...base, name: project?.name, status: project?.status, region: project?.region }
106}
107
108async function collect($: EngineInterface, dir: string, fetchedAt?: number): Promise<ProjectSnapshot> {
109  const remote = await run($, ['git', 'remote', 'get-url', 'origin'], dir)
110  const slug = remote.ok ? githubSlug(remote.out) : undefined
111  const gh = async (args: string[]) => {
112    const r = slug ? await run($, ['gh', ...args, '-R', slug], dir) : undefined
113    return r?.ok ? r.out : undefined
114  }
115
116  const [git, prs, runs, netlify, supabase] = await Promise.all([
117    collectGit($, dir, fetchedAt),
118    gh(['pr', 'list', '--limit', '8', '--json', 'number,title,headRefName,url,reviewDecision,statusCheckRollup']),
119    gh(['run', 'list', '-L', '5', '--json', 'databaseId,name,headBranch,status,conclusion,createdAt,url']),
120    collectNetlify($, dir),
121    collectSupabase($, dir),
122  ])
123
124  return {
125    root: dir, name: dir.split('/').at(-1) ?? dir, slug, git,
126    prs: prs === undefined ? undefined : mapPrs(prs),
127    runs: runs === undefined ? undefined : mapRuns(runs),
128    netlify, supabase, updatedAt: await $.clock.now(),
129  }
130}
131
132type SyncOutcome = { ok: boolean; step: string; detail?: string; stashed?: boolean }
133
134async function runSync($: EngineInterface, dir: string, plan: SyncPlan, stash: boolean): Promise<SyncOutcome> {
135  let stashed = false
136  if (stash) {
137    const at = new Date(await $.clock.now()).toISOString().slice(0, 16)
138    const s = await run($, ['git', 'stash', 'push', '--include-untracked', '-m', `project-pane sync ${at}`], dir)
139    if (!s.ok) return { ok: false, step: 'stash', detail: s.err }
140    stashed = true
141  }
142
143  if (plan.kind === 'pull') {
144    const p = await run($, ['git', 'pull', '--ff-only'], dir, 120_000)
145    return { ok: p.ok, step: 'pull', detail: p.err, stashed }
146  }
147  if (plan.kind === 'switch') {
148    const sw = await run($, ['git', 'switch', plan.to], dir)
149    if (!sw.ok) return { ok: false, step: 'switch', detail: sw.err, stashed }
150    const ff = await run($, ['git', 'merge', '--ff-only', `origin/${plan.to}`], dir, 120_000)
151    return { ok: ff.ok, step: ff.ok ? 'switch' : 'merge', detail: ff.err, stashed }
152  }
153  return { ok: false, step: 'none', stashed }
154}
155
156async function refresh($: EngineInterface, withFetch = false): Promise<void> {
157  if (root === undefined || isRefreshing || !(await read($, isOn))) return
158  isRefreshing = true
159  try {
160    if (withFetch && (await run($, ['git', 'fetch', '--quiet', 'origin'], root, 60_000)).ok) lastFetch = await $.clock.now()
161    const next = await collect($, root, lastFetch)
162    await update($, snap, () => next)
163    notifyFailures($, next)
164  } finally {
165    isRefreshing = false
166  }
167}
168
169// Csak a session indulása utáni hibákra szól, mindegyikre egyszer.
170function notifyFailures($: EngineInterface, s: ProjectSnapshot): void {
171  const deploy = s.netlify?.deploys[0]
172  if (deploy && deploy.state === 'error' && deploy.at > startedAt && !notified.has(`d${deploy.at}`)) {
173    notified.add(`d${deploy.at}`)
174    $.ui.toast(t.deployFailed(deploy.branch), { timeoutMs: 10_000 })
175  }
176  for (const r of s.runs ?? []) {
177    if (r.conclusion === 'failure' && r.at > startedAt && !notified.has(`r${r.id}`)) {
178      notified.add(`r${r.id}`)
179      $.ui.toast(t.runFailed(r.name), { timeoutMs: 10_000 })
180    }
181  }
182}
183
184async function sync($: EngineInterface, stash: boolean): Promise<void> {
185  const s = await read($, snap)
186  if (root === undefined || s === null || (await read($, busy)) !== null) return
187  const plan = planSync(s.git)
188  if (plan.kind === 'none' || plan.kind === 'diverged') return
189
190  await update($, busy, () => 'sync')
191  try {
192    const out = await runSync($, root, plan, stash)
193    if (out.ok) $.ui.toast(t.syncDone(out.stashed === true))
194    else {
195      $.ui.toast(t.syncFailed(out.step), { timeoutMs: 10_000 })
196      await $.prompt.fill({ text: t.syncAsk(out.step, out.detail ?? ''), mode: 'replace' })
197    }
198  } finally {
199    await update($, busy, () => null)
200    await refresh($, true)
201  }
202}
203
204// Be/ki kapcsolás: a /project parancs és az eszköztár (tool-hub) gombja is ezt hívja.
205async function setOn($: EngineInterface, wanted: boolean): Promise<string> {
206  await update($, isOn, () => wanted)
207  await $.store.set('isOn', wanted)
208  if (!wanted) {
209    await $.ui.close({ id: PANE })
210    return t.closed
211  }
212  root ??= await repoRoot($)
213  if (root === undefined) return t.notRepo
214  await $.ui.open({ id: PANE, title: t.title(root.split('/').at(-1) ?? '') })
215  background(refresh($, true))
216  return t.opened
217}
218
219export const register: Register = (on, options) => {
220  if (options.language === 'hu' || options.language === 'en') t = STRINGS[options.language]
221
222  on('session.start', async ($, e, next) => {
223    const started = await next(e)
224    t = languageFor(options.language, (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || '')
225    startedAt = await $.clock.now()
226    root = await repoRoot($)
227
228    const saved = (await $.store.get('isOn')) !== false
229    await update($, isOn, () => saved)
230    await $.command.register({
231      name: 'project',
232      description: t.commandDescription,
233      argumentHint: t === STRINGS.hu ? '[be|ki]' : '[on|off]',
234      immediate: true,
235    })
236    if (root === undefined) return started
237
238    if (saved && options.autoOpen !== false) {
239      const title = t.title(root.split('/').at(-1) ?? '')
240      // Keskeny terminálon a magától nyíló panel rejtve vár: szólunk, hogy ne tűnjön el nyomtalanul.
241      background($.ui.open({ id: PANE, title }).then(opened => {
242        if (!opened.isPlaced) $.ui.toast(t.waiting(opened.reason), { timeoutMs: 10_000 })
243      }))
244    }
245    background(refresh($, true))
246    $.clock.every(REFRESH_MS, () => background(refresh($)))
247    $.clock.every(FETCH_MS, () => background(refresh($, true)))
248
249    return started
250  })
251
252  on('command.run', { command: 'project' }, async ($, e) => {
253    const arg = e.args.trim().toLowerCase()
254    if (arg !== '' && !['be', 'on', 'ki', 'off'].includes(arg)) return { text: t.usage }
255    return { text: await setOn($, !['ki', 'off'].includes(arg)) }
256  })
257
258  // Az eszköztár gombja parancs nélkül kapcsol (így nem marad parancssor a transcriptben).
259  on('state.set', { plugin: 'tool-hub', key: 'request' }, async ($, e, next) => {
260    const done = await next(e)
261    if (e.value?.tool === 'project') await setOn($, e.value.on)
262    return done
263  })
264
265  // Claude futtathatott git/gh parancsot: a turn végén frissítünk (fetch nélkül, az olcsó).
266  on('turn.complete', async ($, e, next) => {
267    const done = await next(e)
268    background(refresh($))
269    return done
270  })
271
272  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
273    const { Box, Text, Button, Link } = $.ui.resolve(e)
274    const s = await read($, snap)
275    const working = await read($, busy)
276    const now = await $.clock.now()
277
278    if (s === null) return <Text dimColor>{root === undefined ? t.notRepo : t.loading}</Text>
279
280    const g = s.git
281    const plan = planSync(g)
282    const failedRun = s.runs?.find(r => r.conclusion === 'failure')
283
284    const heading = (text: string) => <Text bold color="cyan">{text}</Text>
285
286    return (
287      <Box flexDirection="column" gap={1}>
288        <Box flexDirection="row" justifyContent="space-between">
289          <Text bold>{s.slug ?? s.name}</Text>
290          <Box flexDirection="row" gap={1}>
291            <Text dimColor>{t.updated(t.ago(now - s.updatedAt))}</Text>
292            <Button key="refresh" label={t.refresh} hotkey="r" dimColor onPress={() => refresh($, true)} />
293          </Box>
294        </Box>
295
296        <Box flexDirection="column">
297          {heading(t.git)}
298          <Text>
299            🌿 <Text bold>{g.branch}</Text>
300            {g.upstream ? <Text dimColor>  ↑{g.ahead} ↓{g.behind}</Text> : null}
301            <Text color={g.dirty > 0 ? 'yellow' : 'green'}>  · {g.dirty > 0 ? t.dirty(g.dirty) : t.clean}</Text>
302          </Text>
303          {g.lastCommit && (
304            <Text dimColor wrap="truncate-end">
305              {t.lastCommit(g.lastCommit.hash, t.ago(now - g.lastCommit.at))} — {g.lastCommit.subject}
306            </Text>
307          )}
308          <Text dimColor>{g.fetchedAt ? t.fetched(t.ago(now - g.fetchedAt)) : t.noFetch}</Text>
309
310          {plan.kind === 'none' && <Text color="green">{t.upToDate}</Text>}
311          {plan.kind === 'diverged' && <Text color="red">{t.diverged(plan.ahead, plan.behind)}</Text>}
312          {(plan.kind === 'pull' || plan.kind === 'switch') && (
313            <Box flexDirection="column">
314              <Text color="yellow">
315                {plan.kind === 'pull' ? t.behindPull(plan.commits, g.branch) : t.behindSwitch(plan.to, plan.commits, g.branch)}
316              </Text>
317              {working !== null ? (
318                <Text color="yellow">{t.syncing}</Text>
319              ) : g.dirty === 0 ? (
320                <Button
321                  key="sync"
322                  hotkey="s"
323                  variant="primary"
324                  label={plan.kind === 'pull' ? t.pull(plan.commits) : t.switchTo(plan.to)}
325                  onPress={() => sync($, false)}
326                />
327              ) : (
328                <Box flexDirection="column">
329                  <Text dimColor>{t.dirtyBlocks}</Text>
330                  <Button key="stash-sync" hotkey="s" label={t.stashSync} onPress={() => sync($, true)} />
331                </Box>
332              )}
333            </Box>
334          )}
335        </Box>
336
337        {s.netlify && (
338          <Box flexDirection="column">
339            {heading(t.deploy)}
340            {s.netlify.note === 'no-cli' && <Text dimColor>{t.netlifyNoCli}</Text>}
341            {s.netlify.note === 'error' && <Text dimColor>{t.netlifyError}</Text>}
342            {s.netlify.note === undefined && s.netlify.deploys.length === 0 && <Text dimColor>{t.noDeploys}</Text>}
343            {s.netlify.deploys.map(d => (
344              <Text>
345                <Text color={color(d.state)}>{mark(d.state)} {d.state}</Text>
346                <Text>  {d.branch}</Text>
347                <Text dimColor>  {t.ago(now - d.at)}</Text>
348              </Text>
349            ))}
350          </Box>
351        )}
352
353        <Box flexDirection="column">
354          {heading(t.prs)}
355          {s.prs === undefined && <Text dimColor>{s.slug ? t.ghMissing : '—'}</Text>}
356          {s.prs?.length === 0 && <Text dimColor>{t.noPrs}</Text>}
357          {s.prs?.map(p => (
358            <Text wrap="truncate-end">
359              <Text color={color(p.checks === 'pass' ? 'success' : p.checks === 'fail' ? 'failure' : p.checks === 'pending' ? 'queued' : '')}>
360                {mark(p.checks === 'pass' ? 'success' : p.checks === 'fail' ? 'failure' : p.checks === 'pending' ? 'queued' : '')}
361              </Text>
362              <Text bold> #{p.number}</Text> {p.title}
363              {p.review ? <Text dimColor>  · {p.review.toLowerCase().replace('_', ' ')}</Text> : null}
364            </Text>
365          ))}
366        </Box>
367
368        {s.runs !== undefined && (
369          <Box flexDirection="column">
370            {heading(t.ci)}
371            {s.runs.length === 0 && <Text dimColor>{t.noRuns}</Text>}
372            {s.runs.map(r => (
373              <Text wrap="truncate-end">
374                <Text color={color(r.conclusion ?? r.status)}>{mark(r.conclusion ?? r.status)}</Text> {r.name}
375                <Text dimColor>  {r.branch} · {t.ago(now - r.at)}</Text>
376              </Text>
377            ))}
378            {failedRun && (
379              <Button
380                key="ask-run"
381                hotkey="a"
382                label={t.ask}
383                onPress={() =>
384                  $.prompt.fill({
385                    text: t.askRun(failedRun.name, failedRun.branch, failedRun.id, failedRun.url),
386                    mode: 'replace',
387                  })
388                }
389              />
390            )}
391          </Box>
392        )}
393
394        {s.supabase && (
395          <Box flexDirection="column">
396            {heading(t.supabase)}
397            <Text>
398              <Text bold>{s.supabase.ref}</Text>
399              {s.supabase.name ? <Text> · {s.supabase.name}</Text> : null}
400              {s.supabase.region ? <Text dimColor> · {s.supabase.region}</Text> : null}
401            </Text>
402            {s.supabase.status && (
403              <Text color={s.supabase.status === 'ACTIVE_HEALTHY' ? 'green' : 'yellow'}>{s.supabase.status}</Text>
404            )}
405            <Text dimColor wrap="truncate-end">{t.migrations(s.supabase.migrations, s.supabase.latest)}</Text>
406            {s.supabase.note === 'not-logged-in' && <Text dimColor>{t.supabaseLogin}</Text>}
407          </Box>
408        )}
409
410        <Box flexDirection="row" gap={2} flexWrap="wrap">
411          {s.slug && <Link href={`https://github.com/${s.slug}`} label="GitHub" />}
412          {s.netlify?.adminUrl && <Link href={s.netlify.adminUrl} label="Netlify" />}
413          {s.supabase && <Link href={`https://supabase.com/dashboard/project/${s.supabase.ref}`} label="Supabase" />}
414        </Box>
415      </Box>
416    )
417  })
418}
419
hooks/i18n.ts 130 lines
1const hu = {
2  title: (name: string) => `Projekt: ${name}`,
3  commandDescription: 'Projekt-panel be/ki (alapból megnyitja és frissíti)',
4  opened: 'Projekt-panel megnyitva.',
5  closed: 'Projekt-panel kikapcsolva (/project be: újra be).',
6  usage: 'Használat: /project [be|ki]',
7  waiting: (reason: string) => `A projekt-panel szélesebb terminálra vár (${reason}) — /project most megnyitja`,
8  notRepo: 'Ez a mappa nem git repó, nincs mit mutatni.',
9  loading: 'Betöltés…',
10  refresh: 'Frissítés',
11  updated: (ago: string) => `↻ ${ago}`,
12  git: 'GIT',
13  dirty: (n: number) => `${n} módosított fájl`,
14  clean: 'tiszta',
15  lastCommit: (hash: string, ago: string) => `utolsó commit: ${hash} · ${ago}`,
16  noFetch: 'még nem volt fetch',
17  fetched: (ago: string) => `fetch: ${ago}`,
18  upToDate: '✔ naprakész a GitHubbal',
19  behindPull: (n: number, branch: string) => `⚠ az origin/${branch} ${n} committal előrébb jár`,
20  behindSwitch: (to: string, n: number, branch: string) =>
21    `⚠ a ${to} ${n} committal előrébb jár, te a ${branch} branch-en vagy`,
22  diverged: (a: number, b: number) => `⚠ szétvált: ${a} helyi és ${b} távoli commit — ezt kézzel kell rendezni`,
23  pull: (n: number) => `Pull (${n})`,
24  switchTo: (to: string) => `Váltás: ${to} + frissítés`,
25  dirtyBlocks: 'Előbb commitold a módosításokat, vagy tedd félre őket:',
26  stashSync: 'Stash + szinkron',
27  syncing: '⏳ szinkronizálás…',
28  syncDone: (stashed: boolean) => `Szinkron kész${stashed ? ' (a módosítások: git stash list)' : ''}`,
29  syncFailed: (step: string) => `Szinkron sikertelen (${step}) — a részletek a promptban`,
30  syncAsk: (step: string, detail: string) =>
31    `A projekt-panel szinkronja elakadt a(z) "${step}" lépésnél:\n\n${detail}\n\nNézd meg, mi a gond, és javasolj megoldást.`,
32  deploy: 'DEPLOY (Netlify)',
33  netlifyNoCli: 'Netlify CLI kell: npm i -g netlify-cli && netlify login',
34  netlifyError: 'Nem sikerült lekérni (netlify login?)',
35  noDeploys: 'Még nincs deploy',
36  deployFailed: (branch: string) => `Netlify deploy hiba: ${branch}`,
37  prs: 'PULL REQUESTEK',
38  noPrs: 'Nincs nyitott PR',
39  ghMissing: 'gh CLI kell: brew install gh && gh auth login',
40  ci: 'CI (GitHub Actions)',
41  noRuns: 'Nincs futás',
42  ask: 'Kérdezd Claude-ot',
43  askRun: (name: string, branch: string, id: number, url: string) =>
44    `A "${name}" GitHub Actions futás elbukott a ${branch} branch-en (${url}).\n` +
45    `Nézd meg a hibát (gh run view ${id} --log-failed), és mondd meg, mi okozza és hogyan javítsuk.`,
46  runFailed: (name: string) => `CI hiba: ${name}`,
47  supabase: 'SUPABASE',
48  migrations: (n: number, latest?: string) => `${n} migráció${latest ? ` · utolsó: ${latest}` : ''}`,
49  supabaseLogin: 'supabase login → projekt állapota',
50  links: 'LINKEK',
51  ago: (ms: number) => {
52    const m = Math.round(ms / 60_000)
53    if (m < 1) return 'most'
54    if (m < 60) return `${m} perce`
55    const h = Math.round(m / 60)
56    if (h < 24) return `${h} órája`
57    const d = Math.round(h / 24)
58    return d < 45 ? `${d} napja` : `${Math.round(d / 30)} hónapja`
59  },
60}
61
62export type Strings = typeof hu
63
64const en: Strings = {
65  title: name => `Project: ${name}`,
66  commandDescription: 'Project pane on/off (by default opens and refreshes it)',
67  opened: 'Project pane opened.',
68  closed: 'Project pane off (/project on: back on).',
69  usage: 'Usage: /project [on|off]',
70  waiting: reason => `The project pane waits for a wider terminal (${reason}) — /project opens it now`,
71  notRepo: 'This folder is not a git repository; nothing to show.',
72  loading: 'Loading…',
73  refresh: 'Refresh',
74  updated: ago => `↻ ${ago}`,
75  git: 'GIT',
76  dirty: n => `${n} changed file${n === 1 ? '' : 's'}`,
77  clean: 'clean',
78  lastCommit: (hash, ago) => `last commit: ${hash} · ${ago}`,
79  noFetch: 'not fetched yet',
80  fetched: ago => `fetched ${ago}`,
81  upToDate: '✔ up to date with GitHub',
82  behindPull: (n, branch) => `⚠ origin/${branch} is ${n} commit${n === 1 ? '' : 's'} ahead`,
83  behindSwitch: (to, n, branch) => `⚠ ${to} is ${n} commit${n === 1 ? '' : 's'} ahead; you are on ${branch}`,
84  diverged: (a, b) => `⚠ diverged: ${a} local and ${b} remote commits — sort this out by hand`,
85  pull: n => `Pull (${n})`,
86  switchTo: to => `Switch to ${to} + update`,
87  dirtyBlocks: 'Commit your changes first, or set them aside:',
88  stashSync: 'Stash + sync',
89  syncing: '⏳ syncing…',
90  syncDone: stashed => `Sync done${stashed ? ' (your changes: git stash list)' : ''}`,
91  syncFailed: step => `Sync failed (${step}) — details in the prompt`,
92  syncAsk: (step, detail) =>
93    `The project pane's sync stopped at "${step}":\n\n${detail}\n\nLook into it and suggest a fix.`,
94  deploy: 'DEPLOY (Netlify)',
95  netlifyNoCli: 'Needs the Netlify CLI: npm i -g netlify-cli && netlify login',
96  netlifyError: 'Could not fetch (netlify login?)',
97  noDeploys: 'No deploys yet',
98  deployFailed: branch => `Netlify deploy failed: ${branch}`,
99  prs: 'PULL REQUESTS',
100  noPrs: 'No open PRs',
101  ghMissing: 'Needs gh: brew install gh && gh auth login',
102  ci: 'CI (GitHub Actions)',
103  noRuns: 'No runs',
104  ask: 'Ask Claude',
105  askRun: (name, branch, id, url) =>
106    `The "${name}" GitHub Actions run failed on ${branch} (${url}).\n` +
107    `Look at the failure (gh run view ${id} --log-failed) and tell me what causes it and how to fix it.`,
108  runFailed: name => `CI failed: ${name}`,
109  supabase: 'SUPABASE',
110  migrations: (n, latest) => `${n} migration${n === 1 ? '' : 's'}${latest ? ` · latest: ${latest}` : ''}`,
111  supabaseLogin: 'supabase login → project status',
112  links: 'LINKS',
113  ago: ms => {
114    const m = Math.round(ms / 60_000)
115    if (m < 1) return 'just now'
116    if (m < 60) return `${m} min ago`
117    const h = Math.round(m / 60)
118    if (h < 24) return `${h} h ago`
119    const d = Math.round(h / 24)
120    return d < 45 ? `${d} days ago` : `${Math.round(d / 30)} months ago`
121  },
122}
123
124export const STRINGS = { hu, en }
125
126export function languageFor(setting: unknown, locale: string): Strings {
127  if (setting === 'hu' || setting === 'en') return STRINGS[setting]
128  return locale.toLowerCase().startsWith('hu') ? hu : en
129}
130
hooks/parse.ts 89 lines
1import type { ProjectGit, ProjectPr, ProjectRun, ProjectDeploy } from '../types'
2
3export function json<T>(text: string): T | undefined {
4  try {
5    return JSON.parse(text) as T
6  } catch {
7    return undefined
8  }
9}
10
11export function githubSlug(remoteUrl: string): string | undefined {
12  const m = /github\.com[:/]([^/]+)\/([^/]+?)(\.git)?\/?$/.exec(remoteUrl)
13  return m ? `${m[1]}/${m[2]}` : undefined
14}
15
16export type StatusPart = Pick<ProjectGit, 'branch' | 'upstream' | 'ahead' | 'behind' | 'dirty'>
17
18/** `git status --porcelain=2 --branch` */
19export function parseStatus(out: string): StatusPart {
20  const part: StatusPart = { branch: '?', ahead: 0, behind: 0, dirty: 0 }
21  for (const line of out.split('\n')) {
22    if (line.startsWith('# branch.head ')) part.branch = line.slice(14)
23    else if (line.startsWith('# branch.upstream ')) part.upstream = line.slice(18)
24    else if (line.startsWith('# branch.ab ')) {
25      const [a, b] = line.slice(12).split(' ')
26      part.ahead = Number(a?.slice(1) ?? 0)
27      part.behind = Number(b?.slice(1) ?? 0)
28    } else if (line !== '' && !line.startsWith('#')) part.dirty += 1
29  }
30  return part
31}
32
33type GhCheck = { status?: string; conclusion?: string; state?: string }
34type GhPr = {
35  number: number; title: string; headRefName: string; url: string
36  reviewDecision?: string; statusCheckRollup?: GhCheck[]
37}
38
39export function checksOf(rollup: GhCheck[] = []): ProjectPr['checks'] {
40  if (rollup.length === 0) return 'none'
41  const results = rollup.map(c => (c.conclusion || c.state || c.status || '').toUpperCase())
42  if (results.some(r => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED'].includes(r))) return 'fail'
43  if (results.some(r => ['', 'PENDING', 'IN_PROGRESS', 'QUEUED', 'EXPECTED'].includes(r))) return 'pending'
44  return 'pass'
45}
46
47export function mapPrs(out: string): ProjectPr[] | undefined {
48  return json<GhPr[]>(out)?.map(p => ({
49    number: p.number, title: p.title, branch: p.headRefName, url: p.url,
50    review: p.reviewDecision || undefined, checks: checksOf(p.statusCheckRollup),
51  }))
52}
53
54type GhRun = {
55  databaseId: number; name: string; headBranch: string; status: string
56  conclusion?: string; createdAt: string; url: string
57}
58
59export function mapRuns(out: string): ProjectRun[] | undefined {
60  return json<GhRun[]>(out)?.map(x => ({
61    id: x.databaseId, name: x.name, branch: x.headBranch, status: x.status,
62    conclusion: x.conclusion || undefined, at: Date.parse(x.createdAt), url: x.url,
63  }))
64}
65
66type NlDeploy = { state: string; branch: string; created_at: string; deploy_ssl_url?: string }
67
68export function mapDeploys(out: string): ProjectDeploy[] {
69  return (json<NlDeploy[]>(out) ?? []).slice(0, 3).map(d => ({
70    state: d.state, branch: d.branch, at: Date.parse(d.created_at), url: d.deploy_ssl_url,
71  }))
72}
73
74export type SyncPlan =
75  | { kind: 'none' }
76  | { kind: 'pull'; commits: number }
77  | { kind: 'switch'; to: string; commits: number }
78  | { kind: 'diverged'; ahead: number; behind: number }
79
80// Csak fast-forward: a gomb soha nem merge-öl, nem rebase-el és nem dob el commitot.
81export function planSync(git: ProjectGit): SyncPlan {
82  if (git.behind > 0 && git.ahead > 0) return { kind: 'diverged', ahead: git.ahead, behind: git.behind }
83  if (git.behind > 0) return { kind: 'pull', commits: git.behind }
84  if (git.branch !== git.defaultBranch && git.behindDefault > 0) {
85    return { kind: 'switch', to: git.defaultBranch, commits: git.behindDefault }
86  }
87  return { kind: 'none' }
88}
89
types/index.d.ts 76 lines
1export type ProjectGit = {
2  branch: string
3  upstream?: string
4  ahead: number
5  behind: number
6  dirty: number
7  defaultBranch: string
8  /** Commits origin/<default> has that HEAD lacks, and the reverse. */
9  behindDefault: number
10  aheadOfDefault: number
11  lastCommit?: { hash: string; at: number; subject: string }
12  fetchedAt?: number
13}
14
15export type ProjectPr = {
16  number: number
17  title: string
18  branch: string
19  checks: 'pass' | 'fail' | 'pending' | 'none'
20  review?: string
21  url: string
22}
23
24export type ProjectRun = {
25  id: number
26  name: string
27  branch: string
28  status: string
29  conclusion?: string
30  at: number
31  url: string
32}
33
34export type ProjectDeploy = { state: string; branch: string; at: number; url?: string }
35
36export type ProjectNetlify = {
37  siteId: string
38  name?: string
39  adminUrl?: string
40  deploys: ProjectDeploy[]
41  note?: 'no-cli' | 'error'
42}
43
44export type ProjectSupabase = {
45  ref: string
46  migrations: number
47  latest?: string
48  name?: string
49  status?: string
50  region?: string
51  note?: 'not-logged-in'
52}
53
54export type ProjectSnapshot = {
55  root: string
56  name: string
57  slug?: string
58  git: ProjectGit
59  /** undefined: gh is missing or not logged in. */
60  prs?: ProjectPr[]
61  runs?: ProjectRun[]
62  netlify?: ProjectNetlify
63  supabase?: ProjectSupabase
64  updatedAt: number
65}
66
67// Az eszköztár (tool-hub) kérése: a kapcsoló gomb ezt írja, a mod a state.set hookjában veszi át.
68export type HubRequest = { tool: string; on: boolean; n: number }
69
70declare module 'claude-code' {
71  interface PluginState {
72    'tool-hub': { request: HubRequest | null }
73    'project-pane': { snap: ProjectSnapshot | null; busy: string | null; isOn: boolean }
74  }
75}
76